Files
HealthLog/src/lib/insights/status-cache.ts
T
Marc-André Bombeck fea1d1e0b5 chore: strip planning tags from comments and enable the rule
Output of `node scripts/strip-planning-tags.mjs --write`: 3,157 comment
lines in 1,308 files lose the work-item identifier at their head. Release
versions and issue references stay; nothing outside comment text
changes, which the script checks per file against a comment-free
printout. healthlog/no-planning-tags is enabled at error level with this
commit, so the tree it lands on is clean.
2026-10-20 09:08:40 +02:00

813 lines
28 KiB
TypeScript

import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import type { AiUnavailableReason } from "@/lib/ai/capabilities/types";
import { probeProviderPresence } from "@/lib/ai/provider";
import {
enqueueStatusGeneration,
type InsightStatusScope,
} from "@/lib/jobs/insight-status-generate-shared";
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
import {
discoveryMeasurementTypes,
DISCOVERY_BEHAVIOURS,
DISCOVERY_OUTCOMES,
} from "@/lib/insights/correlation-discovery";
import { MAX_CUSTOM_CORRELATION_CHANNELS } from "@/lib/insights/correlation-channel-series";
import { annotate } from "@/lib/logging/context";
import { delegatedGenerationSuppressed } from "@/lib/sharing/delegated-generation";
import type { SupportedLocale } from "@/lib/insights/status-shared";
/**
* v1.18.11 — the measurement types the FDR correlation-discovery
* matrix scans (`correlation-discovery.ts`). A status card whose prompt folds
* the surviving cross-metric correlations (`getRelevantCorrelationsForMetric`)
* can have a NEW correlation surface purely because one of these channels
* gained paired data — with no change to the card's own metric rows. The input
* gate must therefore fingerprint these channels too, or a freshly discovered
* relation would be silently skipped for the day. `MOOD` is mood-entry backed
* (folded separately via `includeMood`), so it is excluded from the
* measurement-type set here.
*
* v1.21.0 (FDREXTEND) — `MEDICATION_COMPLIANCE` (dose-history ledger) and
* `SYMPTOM_SEVERITY` (illness day-log) are likewise NON-measurement channels;
* `discoveryMeasurementTypes` excludes them too, or the `type IN (...)` groupBy
* below would try to cast a non-enum string to `MeasurementType` and error.
* Their own data changes still flip a card's gate through their source models —
* they simply do not belong in the measurement fingerprint set.
*/
const CORRELATION_CHANNEL_TYPES: readonly MeasurementType[] =
discoveryMeasurementTypes([
...DISCOVERY_BEHAVIOURS,
...DISCOVERY_OUTCOMES,
]) as MeasurementType[];
/**
* The status-note store: what a model wrote about one metric, for one person,
* in one language.
*
* Every per-metric generator (the seven specialised cards, the generic
* `metric:<ID>` cards, the derived-score and biomarker assessments) keeps its
* note in `InsightStatusCache`, one row per `(user, metric, locale)`, the note
* itself encrypted at rest. Until v1.39.0 the notes were appended to
* `audit_logs` as plaintext JSON; the callers still name a note by the old
* cache-action string (`insights.<scope>-status.<locale>`, built only by
* `statusCacheAction`), and this module maps it onto the row.
*
* A row carries two things that are updated independently:
*
* - the note (`textEncrypted`, its `dateKey`, `generatedAt` and the two
* fingerprints), written when a generation succeeds or an unchanged-data
* run re-dates it;
* - a short negative-cache window (`retryAt`, `negativeReason`), written
* when a generation stalls, errs or is screened, so a stalled provider
* does not turn every page visit into another enqueue. Writing it never
* touches the note, so one stall cannot hide yesterday's text, and writing
* a note clears it.
*
* Nothing here serves a note while the `statusText` capability is unavailable
* for the record, whatever the reason: an operator switch, the person's AI
* opt-out, a missing provider, a withdrawn consent. The note stays stored (a
* switch flipped back costs nothing) unless consent withdrawal purges it.
*/
/**
* The single builder of a status note's name. The shape is part of the
* contract between the generators, the queue and this store, so it is built
* in one place only.
*/
export function statusCacheAction(scope: string, locale: string): string {
return `insights.${scope}-status.${locale}`;
}
const CACHE_ACTION_SHAPE = /^insights\.(.+)-status\.([^.]+)$/;
/** The row key behind a note's name, or `null` for a name of another shape. */
function statusCacheKey(
cacheAction: string,
): { metric: string; locale: string } | null {
const match = CACHE_ACTION_SHAPE.exec(cacheAction);
if (!match) return null;
return { metric: match[1], locale: match[2] };
}
/** One stored row, decrypted. */
interface StoredStatusNote {
/** The note, `null` when the row carries only a negative-cache window. */
text: string | null;
/** Per-item notes (JSON), for a card that carries them. */
items: unknown;
inputHash: string | null;
snapshotHash: string | null;
dateKey: string;
generatedAt: Date | null;
retryAt: Date | null;
negativeReason: string | null;
}
function decryptOrNull(payload: Uint8Array | null): string | null {
if (!payload || payload.byteLength === 0) return null;
try {
return decryptFromBytes(payload);
} catch {
// A note encrypted under a key the host no longer holds is a cache miss;
// the next run writes it again.
return null;
}
}
async function readStatusNote(
userId: string,
cacheAction: string,
): Promise<StoredStatusNote | null> {
const key = statusCacheKey(cacheAction);
if (!key) return null;
const row = await prisma.insightStatusCache.findUnique({
where: { userId_metric_locale: { userId, ...key } },
select: {
textEncrypted: true,
itemsEncrypted: true,
inputHash: true,
snapshotHash: true,
dateKey: true,
generatedAt: true,
retryAt: true,
negativeReason: true,
},
});
if (!row) return null;
const text = decryptOrNull(row.textEncrypted);
const itemsJson = decryptOrNull(row.itemsEncrypted);
let items: unknown = null;
if (itemsJson !== null) {
try {
items = JSON.parse(itemsJson);
} catch {
items = null;
}
}
return {
text: text !== null && text.trim().length > 0 ? text : null,
items,
inputHash: row.inputHash,
snapshotHash: row.snapshotHash,
dateKey: row.dateKey,
generatedAt: row.generatedAt,
retryAt: row.retryAt,
negativeReason: row.negativeReason,
};
}
/**
* Whether stored status notes may be served, or re-dated, for this record.
* Every reader and both unchanged-data gates ask this first. Answered from
* the record's own state, so a delegate reads the owner's notes exactly when
* the owner would; starting work is a separate question (see below).
*/
async function statusTextServable(userId: string): Promise<boolean> {
return (await aiCapabilityToServe(userId, "statusText")).available;
}
/**
* Write a note: a successful generation. Upserts the one row, clears any
* negative-cache window, and returns when the note was written.
*/
export async function writeStatusNote(args: {
userId: string;
cacheAction: string;
todayKey: string;
text: string;
/** Per-item notes for a card that carries them; serialised as JSON. */
items?: unknown;
snapshotHash?: string;
inputHash?: string;
}): Promise<Date> {
const key = statusCacheKey(args.cacheAction);
if (!key) {
throw new Error(`Not a status note name: ${args.cacheAction}`);
}
const generatedAt = new Date();
const note = {
textEncrypted: encryptToBytes(args.text),
itemsEncrypted:
args.items === undefined
? null
: encryptToBytes(JSON.stringify(args.items)),
inputHash: args.inputHash ?? null,
snapshotHash: args.snapshotHash ?? null,
dateKey: args.todayKey,
generatedAt,
retryAt: null,
negativeReason: null,
};
await prisma.insightStatusCache.upsert({
where: { userId_metric_locale: { userId: args.userId, ...key } },
create: { userId: args.userId, ...key, ...note },
update: note,
});
return generatedAt;
}
/**
* Re-date the stored note under today's day key without touching the note or
* its fingerprints: an unchanged-data run. Returns the new timestamp.
*/
async function redateStatusNote(
userId: string,
cacheAction: string,
todayKey: string,
): Promise<Date> {
const key = statusCacheKey(cacheAction);
const generatedAt = new Date();
if (key) {
// `updateMany`, not `update`: a note purged between the read and here (a
// consent withdrawal) is a no-op, not a thrown "record not found".
await prisma.insightStatusCache.updateMany({
where: { userId, ...key },
data: {
dateKey: todayKey,
generatedAt,
retryAt: null,
negativeReason: null,
},
});
}
return generatedAt;
}
/**
* Open a negative-cache window for a note: the generation stalled, erred, or
* was screened. Leaves any stored note exactly as it was.
*/
export async function writeStatusNegativeWindow(args: {
userId: string;
cacheAction: string;
todayKey: string;
reason: "timeout" | "error" | "screened";
retryAt: Date;
}): Promise<void> {
const key = statusCacheKey(args.cacheAction);
if (!key) return;
await prisma.insightStatusCache.upsert({
where: { userId_metric_locale: { userId: args.userId, ...key } },
create: {
userId: args.userId,
...key,
dateKey: args.todayKey,
retryAt: args.retryAt,
negativeReason: args.reason,
},
update: { retryAt: args.retryAt, negativeReason: args.reason },
});
}
export interface FreshStatusCacheHit {
kind: "generated";
text: string;
updatedAt: string;
retryable: false;
expiresAfterDateKey: string;
}
/**
* Today's note for `(userId, cacheAction)`, or `null` on a miss, a note from
* an earlier day, no note at all, or a capability that is unavailable. Every
* one of those means the caller should not serve stored text as current.
*
* `force` short-circuits to `null` without a read, so a forced regeneration
* never reads the cache.
*/
export async function readFreshStatusText(args: {
userId: string;
cacheAction: string;
todayKey: string;
force: boolean;
}): Promise<FreshStatusCacheHit | null> {
const { userId, cacheAction, todayKey, force } = args;
if (force) return null;
if (!(await statusTextServable(userId))) return null;
const note = await readStatusNote(userId, cacheAction);
if (!note?.text || note.dateKey !== todayKey || !note.generatedAt) {
return null;
}
return {
kind: "generated",
text: note.text,
updatedAt: note.generatedAt.toISOString(),
retryable: false,
expiresAfterDateKey: note.dateKey,
};
}
/**
* Today's per-item note for a card that carries one (medication compliance):
* the summary plus the parsed items, under the same rules as
* `readFreshStatusText`.
*/
export async function readFreshStatusItems(args: {
userId: string;
cacheAction: string;
todayKey: string;
}): Promise<{ text: string; items: unknown; updatedAt: string } | null> {
if (!(await statusTextServable(args.userId))) return null;
const note = await readStatusNote(args.userId, args.cacheAction);
if (!note?.text || note.dateKey !== args.todayKey || !note.generatedAt) {
return null;
}
return {
text: note.text,
items: note.items,
updatedAt: note.generatedAt.toISOString(),
};
}
/**
* The content-hash regeneration gate for the per-status and generic metric
* cards.
*
* A generator that has already gathered its data snapshot calls this BEFORE
* the provider round-trip. When the stored note's `snapshotHash` equals the
* fresh snapshot's, nothing the prompt sees has changed: the note is re-dated
* under today's key and returned, and the caller skips the model entirely.
* `null` on any miss (no note, a missing or differing hash).
*
* The capability comes first. Re-dating a note presents it as current, so a
* record whose `statusText` capability is unavailable (consent withdrawn,
* AI analysis switched off, …) never has old text re-dated; the gate misses,
* and the generator's own chokepoint then refuses the generation.
*/
export async function refreshUnchangedStatusInsight(args: {
userId: string;
cacheAction: string;
todayKey: string;
snapshotHash: string;
}): Promise<FreshStatusCacheHit | null> {
if (!(await statusTextServable(args.userId))) {
annotate({
action: { name: "insights.status.unavailable" },
meta: { cache_action: args.cacheAction, gate: "unchanged-refresh" },
});
return null;
}
const note = await readStatusNote(args.userId, args.cacheAction);
if (!note?.text || note.snapshotHash !== args.snapshotHash) return null;
const generatedAt = await redateStatusNote(
args.userId,
args.cacheAction,
args.todayKey,
);
annotate({
action: { name: "insights.status.skipped_unchanged" },
meta: { cache_action: args.cacheAction },
});
return {
kind: "generated",
text: note.text,
updatedAt: generatedAt.toISOString(),
retryable: false,
expiresAfterDateKey: args.todayKey,
};
}
/**
* The same gate for a card that carries per-item notes: re-dates the stored
* note when its `snapshotHash` matches and hands back the items too.
*/
export async function refreshUnchangedStatusItems(args: {
userId: string;
cacheAction: string;
todayKey: string;
snapshotHash: string;
}): Promise<{ text: string; items: unknown; updatedAt: string } | null> {
if (!(await statusTextServable(args.userId))) return null;
const note = await readStatusNote(args.userId, args.cacheAction);
if (!note?.text || note.snapshotHash !== args.snapshotHash) return null;
const generatedAt = await redateStatusNote(
args.userId,
args.cacheAction,
args.todayKey,
);
annotate({
action: { name: "insights.status.skipped_unchanged" },
meta: { cache_action: args.cacheAction },
});
return {
text: note.text,
items: note.items,
updatedAt: generatedAt.toISOString(),
};
}
/**
* v1.18.11 — cheap input fingerprint for the slow-moving status
* metrics (weight / BMI).
*
* The post-build content-hash gate (`refreshUnchangedStatusInsight`)
* already skips the LLM on unchanged data, but it only runs AFTER the heavy
* snapshot build (the bounded `findMany` + per-series rollup reads +
* correlation math). For metrics that move on a weekly cadence that rebuild
* is paid six days out of seven for nothing.
*
* This probe answers "did any salient input change since the cached
* assessment?" with ONE grouped query — per salient type, the live row
* `count` plus the newest `measuredAt`. A new or removed reading flips one
* of those, which flips the hash; an idle day leaves it byte-identical. The
* caller hashes the result and, on a match, skips the entire build. The
* finer post-build snapshot gate stays in place for the cases this coarse
* probe can't see (e.g. an in-place edit that keeps count + newest stamp).
*/
export async function computeStatusInputFingerprint(args: {
userId: string;
types: readonly MeasurementType[];
/**
* v1.18.11 — include the mood-entry table in the fingerprint. The
* weight snapshot folds a mood-context block, so a mood change must flip
* the input hash or the gate would skip a build whose prose could have
* moved. Omit for metrics that don't read mood.
*/
includeMood?: boolean;
/**
* v1.18.11 — include the FDR correlation-discovery channels
* (`CORRELATION_CHANNEL_TYPES`) in the fingerprint. A card that folds the
* surviving cross-metric correlations (via `getRelevantCorrelationsForMetric`)
* can surface a NEW relation purely because a discovery channel — steps,
* sleep, HRV, glucose, daylight, … — gained paired data, with NO change to
* the card's own metric rows. Without this the input gate would re-stamp the
* stale assessment and the freshly discovered correlation would never reach
* the prose. Cheap: it widens the SAME grouped query by the channel type set
* (no extra round-trip). `includeMood` is honoured for the mood arm of the
* discovery matrix as before. Set on any card whose prompt carries a
* relations block (i.e. whose metric is a discovery channel).
*/
includeCorrelationChannels?: boolean;
/**
* v1.18.11 — extra non-measurement inputs the snapshot derives from
* (e.g. BMI reads the profile `heightCm`). Folded into the hash so a
* change to one of them flips the gate. Values must be JSON-stable.
*/
extra?: Record<string, string | number | null>;
}): Promise<string> {
// Widen the grouped query by the correlation-discovery channels when the
// card folds a relations block, so a discovery-channel change flips the gate.
// De-duplicate the union (a card's own type can also be a discovery channel)
// so the `type IN (...)` list carries each type once.
const groupTypes = args.includeCorrelationChannels
? Array.from(new Set<string>([...args.types, ...CORRELATION_CHANNEL_TYPES]))
: [...args.types];
const [grouped, mood, customMetrics] = await Promise.all([
prisma.measurement.groupBy({
by: ["type"],
where: {
userId: args.userId,
type: { in: groupTypes as MeasurementType[] },
deletedAt: null,
},
_count: { _all: true },
_max: { measuredAt: true },
}),
args.includeMood
? prisma.moodEntry.aggregate({
// Tombstoned rows are excluded here for the same reason they are in
// the measurement arm above: this aggregate IS the freshness
// fingerprint, so a deletion that leaves the count and the newest
// timestamp untouched cannot invalidate the cached assessment, and
// yesterday's text gets re-dated as today's over data that is gone.
where: { userId: args.userId, deletedAt: null },
_count: { _all: true },
_max: { moodLoggedAt: true },
})
: Promise.resolve(null),
args.includeCorrelationChannels
? prisma.customMetric.findMany({
where: {
userId: args.userId,
deletedAt: null,
correlationEnabled: true,
},
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
take: MAX_CUSTOM_CORRELATION_CHANNELS,
select: {
id: true,
unit: true,
updatedAt: true,
_count: { select: { entries: { where: { deletedAt: null } } } },
entries: {
where: { deletedAt: null },
orderBy: [{ measuredAt: "desc" }, { id: "desc" }],
take: 1,
select: { measuredAt: true, unit: true },
},
},
})
: Promise.resolve([]),
]);
// Deterministic shape regardless of group order: sort by type, project a
// stable `{ type, count, newest }` triple. `hashInsightSnapshot` sorts keys
// and collapses Date → ISO, so the hash is order- and clock-stable.
const fingerprint = grouped
.map((row) => ({
type: row.type,
count: row._count._all,
newest: row._max.measuredAt ? row._max.measuredAt.toISOString() : null,
}))
.sort((a, b) => (a.type < b.type ? -1 : a.type > b.type ? 1 : 0));
return hashInsightSnapshot({
statusInput: fingerprint,
...(mood
? {
mood: {
count: mood._count._all,
newest: mood._max.moodLoggedAt
? mood._max.moodLoggedAt.toISOString()
: null,
},
}
: {}),
customMetrics: customMetrics.map((metric) => ({
id: metric.id,
unit: metric.unit,
updatedAt: metric.updatedAt.toISOString(),
count: metric._count.entries,
newest: metric.entries[0]?.measuredAt.toISOString() ?? null,
newestUnit: metric.entries[0]?.unit ?? null,
})),
...(args.extra ? { extra: args.extra } : {}),
});
}
/**
* The INPUT gate for slow-moving status metrics.
*
* Runs BEFORE the snapshot build. When the stored note's `inputHash` equals
* the freshly probed one, nothing the prompt could see has changed, so the
* note is re-dated under today's key and returned; the caller then skips the
* whole gather AND the provider call. `null` on any miss (no note, a missing
* or differing `inputHash`, a forced regeneration), in which case the caller
* proceeds to the normal build and the finer post-build content-hash gate.
*
* The capability is checked first, for the reason `refreshUnchangedStatusInsight`
* gives.
*/
export async function gateUnchangedStatusInput(args: {
userId: string;
cacheAction: string;
todayKey: string;
inputHash: string;
force: boolean;
}): Promise<FreshStatusCacheHit | null> {
if (args.force) return null;
if (!(await statusTextServable(args.userId))) {
annotate({
action: { name: "insights.status.unavailable" },
meta: { cache_action: args.cacheAction, gate: "unchanged-input" },
});
return null;
}
const note = await readStatusNote(args.userId, args.cacheAction);
if (!note?.text || !note.inputHash || note.inputHash !== args.inputHash) {
return null;
}
// Same inputs, a stored note: re-date it WITHOUT rebuilding the snapshot or
// calling the provider. Both fingerprints stay, so tomorrow's gates match.
const generatedAt = await redateStatusNote(
args.userId,
args.cacheAction,
args.todayKey,
);
annotate({
action: { name: "insights.status.skipped_unchanged" },
meta: { cache_action: args.cacheAction, gate: "input" },
});
return {
kind: "generated",
text: note.text,
updatedAt: generatedAt.toISOString(),
retryable: false,
expiresAfterDateKey: args.todayKey,
};
}
export interface LastGoodStatusHit {
text: string;
updatedAt: string;
}
/**
* The stored note for `(userId, cacheAction)` whatever day it was written:
* the stale-while-revalidate source. When today's note is a miss the read path
* can still show the last one instantly while a fresh generation is warmed,
* so opening a category never drops to the "preparing" skeleton if a note
* was ever written. `null` when there is none, or when the capability is
* unavailable.
*/
export async function readLastGoodStatusText(args: {
userId: string;
cacheAction: string;
}): Promise<LastGoodStatusHit | null> {
if (!(await statusTextServable(args.userId))) return null;
const note = await readStatusNote(args.userId, args.cacheAction);
if (!note?.text || !note.generatedAt) return null;
return { text: note.text, updatedAt: note.generatedAt.toISOString() };
}
/**
* The stored note written at least `minAgeHours` ago, for the next
* generation's "what did I say last time" prompt block. Not a serving read:
* it feeds a generation that the chokepoint has already admitted.
*/
export async function readPreviousStatusNote(args: {
userId: string;
cacheAction: string;
olderThan: Date;
}): Promise<{ text: string; generatedAt: Date } | null> {
const note = await readStatusNote(args.userId, args.cacheAction);
if (!note?.text || !note.generatedAt) return null;
if (note.generatedAt.getTime() >= args.olderThan.getTime()) return null;
return { text: note.text, generatedAt: note.generatedAt };
}
/**
* Which of `cacheActions` carry a note written at or after `since`. The
* ingest invalidator skips those, so a fresh note survives a sync drip.
*/
export async function statusNotesWrittenSince(args: {
userId: string;
cacheActions: readonly string[];
since: Date;
}): Promise<Set<string>> {
const byKey = new Map<string, string>();
for (const action of args.cacheActions) {
const key = statusCacheKey(action);
if (key) byKey.set(`${key.metric}\u0000${key.locale}`, action);
}
if (byKey.size === 0) return new Set();
const keys = [...byKey.keys()].map((k) => {
const [metric, locale] = k.split("\u0000");
return { metric, locale };
});
const rows = await prisma.insightStatusCache.findMany({
where: {
userId: args.userId,
OR: keys,
generatedAt: { gte: args.since },
textEncrypted: { not: null },
},
select: { metric: true, locale: true },
});
const fresh = new Set<string>();
for (const row of rows) {
const action = byKey.get(`${row.metric}\u0000${row.locale}`);
if (action) fresh.add(action);
}
return fresh;
}
/**
* Outcome of the read-only cache-miss resolution. The generators map this
* onto their public return shape:
*
* - `unavailable` → the deterministic line, no enqueue. `hasProvider` is
* provider presence and nothing else, so a withdrawn consent or a switch
* turned off no longer reads as "no provider configured"; `reason` says
* which layer said no.
* - `preparing` → a generation is (or recently was) in flight; the last
* note, if any, is served meanwhile. `revalidating` is true only when a
* last note is served AND a fresh generation was actually enqueued, so
* an open card keeps polling until the new note lands.
*/
export type ReadOnlyMissOutcome =
| {
kind: "unavailable";
reason: AiUnavailableReason;
hasProvider: boolean;
}
| {
kind: "preparing";
lastGood: LastGoodStatusHit | null;
revalidating: boolean;
};
/**
* Resolve what a read-only status read returns on a cache miss WITHOUT the
* heavy SQL gather or a blocking model round-trip: a navigation request never
* awaits a provider.
*
* The `statusText` capability decides first. Unavailable, for any reason,
* means no enqueue and no stored text. Available means: serve the last note
* (if any) and enqueue a generation out of band, unless a recent stall opened
* a negative-cache window.
*/
export async function resolveReadOnlyStatusMiss(args: {
userId: string;
metric: InsightStatusScope;
locale: SupportedLocale;
}): Promise<ReadOnlyMissOutcome> {
// Serving view: whether the record's notes may be shown at all. Starting
// work for a delegate is refused by the suppression branch below.
const capability = await aiCapabilityToServe(args.userId, "statusText");
if (!capability.available) {
const reason = capability.reason ?? "check_failed";
annotate({
action: { name: "insights.status.unavailable" },
meta: { metric: args.metric, reason, read_only_miss: true },
});
return {
kind: "unavailable",
reason,
hasProvider: await probeProviderPresence(args.userId, "text"),
};
}
// v1.37.0 — a manager holding a MANAGE grant may READ the record's notes;
// the miss behind that read must not ship the owner's data to the owner's
// provider on the owner's budget. The last note if there is one,
// `preparing` if not, and nothing enqueued either way. One gate here rather
// than in every generator, so a generator added later inherits it.
if (delegatedGenerationSuppressed()) {
const lastGood = await readLastGoodStatusText({
userId: args.userId,
cacheAction: statusCacheAction(args.metric, args.locale),
});
annotate({
action: { name: "insights.status.preparing" },
meta: {
metric: args.metric,
suppressed_enqueue: true,
delegated: true,
},
});
return { kind: "preparing", lastGood, revalidating: false };
}
const cacheAction = statusCacheAction(args.metric, args.locale);
const lastGood = await readLastGoodStatusText({
userId: args.userId,
cacheAction,
});
// Honour the short negative-cache window: re-enqueuing on every navigation
// while the provider is still degraded would be a storm.
const negativeCache = await readStatusNegativeCache({
userId: args.userId,
cacheAction,
});
if (negativeCache && !negativeCache.retryable) {
annotate({
action: { name: "insights.status.preparing" },
meta: { metric: args.metric, suppressed_enqueue: true },
});
return { kind: "preparing", lastGood, revalidating: false };
}
// Enqueue out of band, never await a model here. Best-effort and de-duped
// per (user, metric).
await enqueueStatusGeneration({
userId: args.userId,
metric: args.metric,
locale: args.locale,
});
annotate({
action: { name: "insights.status.preparing" },
meta: { metric: args.metric, stale_served: lastGood !== null },
});
return { kind: "preparing", lastGood, revalidating: lastGood !== null };
}
/** An open negative-cache window on a note. */
export interface StatusNegativeCache {
kind: "negative";
reason: "timeout" | "error" | "screened";
retryAt: string;
retryable: boolean;
}
/**
* The note's negative-cache window, without exposing provider errors or note
* content. `retryable` flips once `retryAt` has passed.
*/
export async function readStatusNegativeCache(args: {
userId: string;
cacheAction: string;
}): Promise<StatusNegativeCache | null> {
const note = await readStatusNote(args.userId, args.cacheAction);
if (!note?.retryAt) return null;
const reason =
note.negativeReason === "error" || note.negativeReason === "screened"
? note.negativeReason
: "timeout";
return {
kind: "negative",
reason,
retryAt: note.retryAt.toISOString(),
retryable: note.retryAt.getTime() <= Date.now(),
};
}