import { revalidatePath, revalidateTag } from "next/cache"; // REVALIDATION FROM INSIDE A JOB (release 9, B1). A job body (`fn` of // runManagedFunction) or a job hook (`onDone` / `afterRun` / `afterDone`) runs // in the request that submitted it only when the job STARTS at once. When the // job was QUEUED behind another on the same platform queue, the queue's drain // loop starts it later, outside any request, and Next has no work store: // `revalidatePath` throws `Invariant: static generation store missing in // revalidatePath …`. That throw escaped the body AFTER the work was done, so // the job was finalized `failed` and `pnpm ops … --wait` exited 1 (the teamrcn // sync, 2026-09-25 15:21). // // Nothing is lost by skipping the revalidation there: every page these paths // name is dynamic and re-reads disk on the next request, and the snapshot // scheduler revalidates the channel pages itself when it regenerates. So the // missing-store invariant is swallowed (and reported — see SkipReporter), and // ANY OTHER error is rethrown — a real failure stays a failure. // // Use it ONLY inside job bodies and job hooks. A plain server action (a form // handler that revalidates and returns) runs inside its request and keeps // calling `revalidatePath` directly. export type RevalidateTarget = string | [string, "page" | "layout"]; const MISSING_STORE = /static generation store missing/; function isMissingStore(err: unknown): boolean { return err instanceof Error && MISSING_STORE.test(err.message); } function describe(target: RevalidateTarget): string { return typeof target === "string" ? target : `${target[0]} (${target[1]})`; } // HOW OFTEN IT HAPPENS, WITHOUT A LINE EACH TIME (release 10, L2). // // Release 9 warned once per module instance and then went silent, so the log // could not say whether this was one job a day or every job in the queue — and // Next can load a module more than once, so "once" was not even once. Now each // skip (one `safeRevalidate` call, however many targets it names) is COUNTED, // on a process-wide holder: // - the first skip in a quiet period is logged at once, with its paths, and // opens a SKIP_REPORT_WINDOW_MS window; // - further skips inside the window are counted, not logged; // - when the window ends, one line reports how many there were and the latest // paths (nothing, if there were none). // So the log carries at most two lines per window, and every skip is in a // number. The running total since the process started is on every line. export const SKIP_REPORT_WINDOW_MS = 10 * 60 * 1000; type Timer = { cancel: () => void }; export type SkipReporterDeps = { windowMs?: number; now?: () => number; // Arms the end-of-window report. The default is an unref'd setTimeout: a log // line must never hold the process open. schedule?: (fn: () => void, ms: number) => Timer; warn?: (line: string) => void; }; function defaultSchedule(fn: () => void, ms: number): Timer { const t = setTimeout(fn, ms); t.unref?.(); return { cancel: () => clearTimeout(t) }; } function minutes(ms: number): string { const m = Math.round(ms / 60_000); return m >= 1 ? `${m} min` : `${Math.round(ms / 1000)} s`; } export class SkipReporter { private readonly windowMs: number; private readonly now: () => number; private readonly schedule: (fn: () => void, ms: number) => Timer; private readonly warn: (line: string) => void; private total = 0; // End of the open window; at or before `now()` means no window is open. private windowEndsAt = 0; private suppressed = 0; private latest = ""; private timer: Timer | null = null; constructor(deps: SkipReporterDeps = {}) { this.windowMs = deps.windowMs ?? SKIP_REPORT_WINDOW_MS; this.now = deps.now ?? Date.now; this.schedule = deps.schedule ?? defaultSchedule; this.warn = deps.warn ?? ((line) => console.warn(line)); } // One skip: one safeRevalidate call that had no request store. record(labels: readonly string[]): void { const now = this.now(); const paths = labels.join(", "); if (now >= this.windowEndsAt) { // A quiet period ended (or this is the first skip): report what the last // window held, if its timer has not already, then this one, at once. this.flush(); this.total++; this.windowEndsAt = now + this.windowMs; this.warn( `[safeRevalidate] no request store (a queued job ran outside a request); ` + `skipped revalidating ${paths}. The pages re-read disk on their next request. ` + `${this.total} skip(s) since this process started; more in the next ` + `${minutes(this.windowMs)} are counted and reported together.`, ); return; } this.total++; this.suppressed++; this.latest = paths; if (!this.timer) { this.timer = this.schedule(() => { this.timer = null; this.flush(); }, this.windowEndsAt - now); } } // Report the counted skips, if any. Called by the window's timer, and by the // first skip of the next window in case the timer has not fired yet. flush(): void { if (this.timer) { this.timer.cancel(); this.timer = null; } if (this.suppressed === 0) return; this.warn( `[safeRevalidate] ${this.suppressed} more skip(s) with no request store in the last ` + `${minutes(this.windowMs)} (latest: ${this.latest}); ` + `${this.total} since this process started.`, ); this.suppressed = 0; this.latest = ""; } // Test seam: drop the pending report without writing it. dispose(): void { this.timer?.cancel(); this.timer = null; } } // ONE PER PROCESS, not per module instance: on globalThis, like the job // registry. Not reset by /api/test/invalidate-cache — it decides only how many // log lines a skip costs, and nothing reads that log. declare global { // eslint-disable-next-line no-var var __yttSafeRevalidateSkips__: SkipReporter | undefined; } function skipReporter(): SkipReporter { globalThis.__yttSafeRevalidateSkips__ ??= new SkipReporter(); return globalThis.__yttSafeRevalidateSkips__; } // The guard itself, exported for the unit test so the rethrow path can be // exercised without a Next runtime. `call` is one revalidation. True when it // was skipped for want of a request store. export function runGuarded(call: () => void): boolean { try { call(); return false; } catch (err) { if (!isMissingStore(err)) throw err; return true; } } export function safeRevalidate( paths: RevalidateTarget[], tags: string[] = [], ): void { let skipped = false; for (const target of paths) { if ( runGuarded(() => typeof target === "string" ? revalidatePath(target) : revalidatePath(target[0], target[1]), ) ) { skipped = true; } } for (const tag of tags) { if (runGuarded(() => revalidateTag(tag, "max"))) skipped = true; } if (skipped) { skipReporter().record([ ...paths.map(describe), ...tags.map((t) => `tag:${t}`), ]); } } // Test seam: a fresh process-wide reporter (any pending report dropped). export function resetSafeRevalidateWarning(): void { globalThis.__yttSafeRevalidateSkips__?.dispose(); globalThis.__yttSafeRevalidateSkips__ = undefined; }