Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit fecbbe0a9ed9fb9e55a0190276e377bc79ba27db
parent a7d8432ee5331dd706a714612dfe28dcfebd16b4
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 25 Sep 2026 18:35:12 -0400

editor: a queued job's revalidation can no longer fail it (safeRevalidate)

A job body or hook started by the platform queue's drain loop runs outside
any request, where revalidatePath throws "static generation store missing".
The throw landed after the work was done, so the job read `failed` and
`pnpm ops … --wait` exited 1 (the teamrcn sync, 2026-09-25 15:21).

app/lib/safeRevalidate.ts swallows exactly that invariant (warned once per
process, naming the paths) and rethrows anything else. Swapped in at every
job body (`fn`) and job hook (`onDone` / `afterRun` / `afterDone`); plain
server actions keep calling revalidatePath inside their request. Unit test
for the helper; ops-api e2e: a sync queued behind another on
platform:youtube ends `done`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Meditor/app/channels/[slug]/availabilityActions.ts | 8++++----
Meditor/app/channels/[slug]/backfillActions.ts | 4++--
Meditor/app/channels/[slug]/digestActions.ts | 5+++--
Meditor/app/channels/[slug]/incompleteTranscriptActions.ts | 5+++--
Meditor/app/channels/[slug]/normalizeActions.ts | 4++--
Meditor/app/channels/[slug]/persistActions.ts | 5++---
Meditor/app/channels/[slug]/pipelineActions.ts | 27+++++++++++++++------------
Meditor/app/channels/[slug]/socialActions.ts | 5+++--
Meditor/app/channels/[slug]/videos/[id]/operationActions.ts | 8+++++---
Meditor/app/channels/[slug]/videos/[id]/videoActions.ts | 31++++++++++++++++++++-----------
Meditor/app/channels/[slug]/whisperActions.ts | 22+++++++++++-----------
Meditor/app/channels/groupActions.ts | 4++--
Meditor/app/channels/lib/relocationJob.ts | 6++----
Aeditor/app/lib/safeRevalidate.test.ts | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/lib/safeRevalidate.ts | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/saved-videos/backupActions.ts | 3++-
Meditor/app/sites/lib/buildAction.ts | 4++--
Meditor/app/storage/lib/evictClipsJob.ts | 6+++---
Meditor/app/storage/lib/repointJob.ts | 6++----
Meditor/app/storage/lib/savedVideosJob.ts | 5++---
Meditor/e2e/ops-api.spec.ts | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
21 files changed, 285 insertions(+), 73 deletions(-)

diff --git a/editor/app/channels/[slug]/availabilityActions.ts b/editor/app/channels/[slug]/availabilityActions.ts @@ -1,6 +1,6 @@ "use server"; -import { revalidatePath } from "next/cache"; +import { safeRevalidate } from "../../lib/safeRevalidate"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { platformQueueKey, @@ -63,7 +63,7 @@ export async function checkAvailabilityAction( onLog, signal, }); - revalidatePath(`/channels/${slug}`); + safeRevalidate([`/channels/${slug}`]); }, }); } @@ -91,7 +91,7 @@ export async function quickAvailabilityCheckAction( onLog, signal, }); - revalidatePath(`/channels/${slug}`); + safeRevalidate([`/channels/${slug}`]); }, }); } @@ -135,7 +135,7 @@ export async function checkMaybeMissingAction( onLog, signal, }); - revalidatePath(`/channels/${slug}`); + safeRevalidate([`/channels/${slug}`]); }, }); } diff --git a/editor/app/channels/[slug]/backfillActions.ts b/editor/app/channels/[slug]/backfillActions.ts @@ -1,6 +1,6 @@ "use server"; -import { revalidatePath } from "next/cache"; +import { safeRevalidate } from "../../lib/safeRevalidate"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand"; import { runBackfillChannelJob } from "yt-dlp-transcript-common/controller/operationJobs"; @@ -35,6 +35,6 @@ export async function backfillChannelAction( kindIds, ids, queueKey, - onDone: () => revalidatePath(`/channels/${slug}`), + onDone: () => safeRevalidate([`/channels/${slug}`]), }); } diff --git a/editor/app/channels/[slug]/digestActions.ts b/editor/app/channels/[slug]/digestActions.ts @@ -1,6 +1,7 @@ "use server"; import { revalidatePath } from "next/cache"; +import { safeRevalidate } from "../../lib/safeRevalidate"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { @@ -74,7 +75,7 @@ export async function digestChannelAction( order, limitCount, force, - onDone: () => revalidatePath(`/channels/${slug}`), + onDone: () => safeRevalidate([`/channels/${slug}`]), }); } @@ -133,7 +134,7 @@ export async function digestBucketAction( `Digest bucket: ${result.succeeded} generated, ${result.fresh} already current, ${result.failed} failed.`, ); requestChannelSnapshot(paths, slug); - revalidatePath(`/channels/${slug}`); + safeRevalidate([`/channels/${slug}`]); }, }); } diff --git a/editor/app/channels/[slug]/incompleteTranscriptActions.ts b/editor/app/channels/[slug]/incompleteTranscriptActions.ts @@ -1,6 +1,7 @@ "use server"; import { revalidatePath } from "next/cache"; +import { safeRevalidate } from "../../lib/safeRevalidate"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { TRANSCRIPTION_QUEUE, @@ -111,7 +112,7 @@ export async function redownloadIncompleteBucketAction( onLog( `Re-download incomplete: ${succeeded} fixed, ${failed} failed of ${cleaned.length}.`, ); - revalidatePath(`/channels/${slug}`); + safeRevalidate([`/channels/${slug}`]); }, }); } @@ -175,7 +176,7 @@ export async function redownloadShortAudioBucketAction( onLog( `Re-download short-audio: ${succeeded} fixed, ${failed} failed of ${cleaned.length}.`, ); - revalidatePath(`/channels/${slug}`); + safeRevalidate([`/channels/${slug}`]); }, }); } diff --git a/editor/app/channels/[slug]/normalizeActions.ts b/editor/app/channels/[slug]/normalizeActions.ts @@ -15,7 +15,7 @@ // putting it anywhere else would make the fix for a stalled digest lane queue // up BEHIND the digest lane it is meant to unblock. -import { revalidatePath } from "next/cache"; +import { safeRevalidate } from "../../lib/safeRevalidate"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { channelQueueKey, @@ -56,7 +56,7 @@ export async function normalizeChannelAction( // The digest lane's classification is derived from disk, so the count this // run just moved is only visible once the snapshot is rebuilt. requestChannelSnapshot(paths, slug); - revalidatePath(`/channels/${slug}`); + safeRevalidate([`/channels/${slug}`]); }, }); } diff --git a/editor/app/channels/[slug]/persistActions.ts b/editor/app/channels/[slug]/persistActions.ts @@ -1,6 +1,6 @@ "use server"; -import { revalidatePath } from "next/cache"; +import { safeRevalidate } from "../../lib/safeRevalidate"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { diskGate } from "yt-dlp-transcript-common/lib/diskSpace"; @@ -53,8 +53,7 @@ export async function persistKeptAction( onLog, signal, }); - revalidatePath(`/channels/${slug}`); - revalidatePath("/saved-videos"); + safeRevalidate([`/channels/${slug}`, "/saved-videos"]); }, }); } diff --git a/editor/app/channels/[slug]/pipelineActions.ts b/editor/app/channels/[slug]/pipelineActions.ts @@ -1,7 +1,7 @@ "use server"; import path from "node:path"; -import { revalidatePath } from "next/cache"; +import { safeRevalidate } from "../../lib/safeRevalidate"; import { HANDLING_VALUES, type AudioFormat, @@ -217,11 +217,13 @@ async function runPipelineAction( // The channel report (snapshot) is regenerated automatically after this // job finishes, via the global debounced scheduler hooked into // runManagedFunction's completion. See common/jobs/snapshotScheduler.ts. - revalidatePath(`/channels/${slug}`); - revalidatePath("/channels"); - revalidatePath("/operations/[id]", "page"); - revalidatePath("/cleanup"); - revalidatePath("/"); + safeRevalidate([ + `/channels/${slug}`, + "/channels", + ["/operations/[id]", "page"], + "/cleanup", + "/", + ]); }, }); } @@ -362,9 +364,11 @@ export async function runMetadataScanAction( channelConfig, queueKey, afterRun: () => { - revalidatePath(`/channels/${slug}`); - revalidatePath("/channels"); - revalidatePath("/operations/[id]", "page"); + safeRevalidate([ + `/channels/${slug}`, + "/channels", + ["/operations/[id]", "page"], + ]); }, }); } @@ -519,10 +523,9 @@ export async function importVideoAction( ).catch(() => { /* the download succeeded; a roster write failure must not fail it */ }); - revalidatePath(`/channels/${slug}/videos/${videoId}`); + safeRevalidate([`/channels/${slug}/videos/${videoId}`]); } - revalidatePath(`/channels/${slug}`); - revalidatePath("/channels"); + safeRevalidate([`/channels/${slug}`, "/channels"]); } finally { task.end(); } diff --git a/editor/app/channels/[slug]/socialActions.ts b/editor/app/channels/[slug]/socialActions.ts @@ -7,6 +7,7 @@ // (Fetch → Index), and this file owns the first. import { revalidatePath } from "next/cache"; +import { safeRevalidate } from "../../lib/safeRevalidate"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { @@ -114,7 +115,7 @@ export async function checkPostAvailabilityAction( onLog, signal, }); - revalidatePath(`/channels/${slug}`); + safeRevalidate([`/channels/${slug}`]); if (!result.ok) throw new Error(result.error ?? "Availability check failed"); }, }); @@ -154,7 +155,7 @@ export async function fetchPostsAction( onLog, signal, }); - revalidatePath(`/channels/${slug}`); + safeRevalidate([`/channels/${slug}`]); // Surface a failed fetch as a failed JOB (the managed wrapper turns a // throw into status "failed"), so it shows up in the jobs list the same // way a failed download does rather than silently logging. diff --git a/editor/app/channels/[slug]/videos/[id]/operationActions.ts b/editor/app/channels/[slug]/videos/[id]/operationActions.ts @@ -13,7 +13,7 @@ // the export 500s at runtime while tsc stays quiet. digestActions.ts has the // long version of this note. -import { revalidatePath } from "next/cache"; +import { safeRevalidate } from "../../../../lib/safeRevalidate"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { @@ -48,8 +48,10 @@ export async function runOperationForVideoAction( // No queueKey — the lane's own key. No `background` either: a hand-clicked // run goes in front of the sweep's, exactly as backfillChannelAction's does. onDone: () => { - revalidatePath(`/channels/${slug}/videos/${videoId}`); - revalidatePath(`/channels/${slug}`); + safeRevalidate([ + `/channels/${slug}/videos/${videoId}`, + `/channels/${slug}`, + ]); }, }); } diff --git a/editor/app/channels/[slug]/videos/[id]/videoActions.ts b/editor/app/channels/[slug]/videos/[id]/videoActions.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { readdir, readFile, rm, stat } from "node:fs/promises"; import { revalidatePath } from "next/cache"; +import { safeRevalidate } from "../../../../lib/safeRevalidate"; import { redirect } from "next/navigation"; import type { AudioFormat, @@ -128,7 +129,7 @@ export async function transcodeAudioAction( onLog, signal, }); - revalidatePath(`/channels/${slug}/videos/${videoId}`); + safeRevalidate([`/channels/${slug}/videos/${videoId}`]); }, }); } @@ -159,7 +160,7 @@ export async function transcribeOneAction( onLog, signal, }); - revalidatePath(`/channels/${slug}/videos/${videoId}`); + safeRevalidate([`/channels/${slug}/videos/${videoId}`]); }, }); } @@ -220,8 +221,10 @@ export async function downloadVideoPipelineAction( }), appendArchive: true, }); - revalidatePath(`/channels/${slug}/videos/${videoId}`); - revalidatePath(`/channels/${slug}`); + safeRevalidate([ + `/channels/${slug}/videos/${videoId}`, + `/channels/${slug}`, + ]); } finally { task.end(); } @@ -299,8 +302,10 @@ async function archiveSourceVideo( keepSourceVideoOverride: true, persistOrigin, }); - revalidatePath(`/channels/${slug}/videos/${videoId}`); - revalidatePath(`/channels/${slug}`); + safeRevalidate([ + `/channels/${slug}/videos/${videoId}`, + `/channels/${slug}`, + ]); } finally { task.end(); } @@ -394,8 +399,10 @@ export async function whisperVideoAction( onLog, signal, }); - revalidatePath(`/channels/${slug}/videos/${videoId}`); - revalidatePath(`/channels/${slug}`); + safeRevalidate([ + `/channels/${slug}/videos/${videoId}`, + `/channels/${slug}`, + ]); }, }); } @@ -429,8 +436,10 @@ export async function redownloadIncompleteTranscriptAction( signal, tracker: makeTaskTracker(ctx, onLog), }); - revalidatePath(`/channels/${slug}/videos/${videoId}`); - revalidatePath(`/channels/${slug}`); + safeRevalidate([ + `/channels/${slug}/videos/${videoId}`, + `/channels/${slug}`, + ]); }, }); } @@ -970,7 +979,7 @@ export async function fetchWindowAction(req: { signal, onPlatformBackoff: () => recordDownloadBackoff(platform, paths), }); - revalidatePath(`/channels/${slug}/videos/${videoId}`); + safeRevalidate([`/channels/${slug}/videos/${videoId}`]); }, }); if (!res.ok) { diff --git a/editor/app/channels/[slug]/whisperActions.ts b/editor/app/channels/[slug]/whisperActions.ts @@ -1,6 +1,6 @@ "use server"; -import { revalidatePath } from "next/cache"; +import { safeRevalidate } from "../../lib/safeRevalidate"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { AUDIO_FORMAT_VALUES, @@ -91,7 +91,7 @@ export async function transcribeMissingAction( onLog( `Whisper batch: ${result.succeeded} succeeded, ${result.failed} failed, ${result.skipped} skipped, ${result.attempted} attempted.`, ); - revalidatePath(`/channels/${slug}`); + safeRevalidate([`/channels/${slug}`]); }, }); } @@ -150,7 +150,7 @@ export async function transcribeBucketAction( onLog( `Whisper bucket: ${result.succeeded} succeeded, ${result.failed} failed, ${result.skipped} skipped, ${result.attempted} attempted.`, ); - revalidatePath(`/channels/${slug}`); + safeRevalidate([`/channels/${slug}`]); }, }); } @@ -203,7 +203,7 @@ export async function transcribeAutoSubsBucketAction( onLog( `Replace auto-captions: ${result.succeeded} succeeded, ${result.failed} failed, ${result.skipped} skipped, ${result.attempted} attempted.`, ); - revalidatePath(`/channels/${slug}`); + safeRevalidate([`/channels/${slug}`]); }, }); } @@ -224,7 +224,7 @@ export async function clearFailedTranscriptionsAction( onLog( `Cleared ${cleared} entr${cleared === 1 ? "y" : "ies"} from failed-transcriptions; they'll be retried next "Transcribe missing".`, ); - revalidatePath(`/channels/${slug}`); + safeRevalidate([`/channels/${slug}`]); }, }); } @@ -250,7 +250,7 @@ export async function cleanExtraAudioFormatsAction( onLog( `Clean extra audio formats: ${result.removedFiles} file(s) removed across ${result.cleanedDirs} dir(s); inspected ${result.inspected}.`, ); - revalidatePath(`/channels/${slug}`); + safeRevalidate([`/channels/${slug}`]); }, }); } @@ -279,7 +279,7 @@ export async function removeWrongFormatAudioAction( onLog( `Remove wrong-format audio: ${result.removedFiles} file(s) removed across ${result.cleanedDirs} dir(s); inspected ${result.inspected}.`, ); - revalidatePath(`/channels/${slug}`); + safeRevalidate([`/channels/${slug}`]); }, }); } @@ -302,7 +302,7 @@ export async function cleanAudioAction( onLog, signal, }); - revalidatePath(`/channels/${slug}`); + safeRevalidate([`/channels/${slug}`]); }, }); } @@ -334,7 +334,7 @@ export async function diarizeChannelAction( `Diarize ${slug}: ${result.diarized} diarized, ${result.alreadyExists} already done, ` + `${result.noAudio} without audio, ${result.failed} failed.`, ); - revalidatePath(`/channels/${slug}`); + safeRevalidate([`/channels/${slug}`]); }, }); } @@ -367,7 +367,7 @@ export async function purgeSupersededAutoSubsAction( onLog, signal, }); - revalidatePath(`/channels/${slug}`); + safeRevalidate([`/channels/${slug}`]); }, }); } @@ -397,7 +397,7 @@ export async function checkKeptDeletedAction( onLog( `Kept-deletion check: inspected ${result.kept}, ${result.deleted} gone from source, pinned ${result.pinned}.`, ); - revalidatePath(`/channels/${slug}`); + safeRevalidate([`/channels/${slug}`]); }, }); } diff --git a/editor/app/channels/groupActions.ts b/editor/app/channels/groupActions.ts @@ -1,6 +1,6 @@ "use server"; -import { revalidatePath } from "next/cache"; +import { safeRevalidate } from "../lib/safeRevalidate"; import { listChannelBriefs, type ChannelBrief, @@ -147,7 +147,7 @@ const RUN_FOR: Record< channelSlug: slug, operation: DIGEST_OPERATION_ID, digest: { lane: "local" }, - onDone: () => revalidatePath(`/channels/${slug}`), + onDone: () => safeRevalidate([`/channels/${slug}`]), }), // EVERY ENABLED LANE KIND, and so NOT runOperationChannelJob: this station is // the LANE, not one operation on it — KIND_FOR above says the same thing by diff --git a/editor/app/channels/lib/relocationJob.ts b/editor/app/channels/lib/relocationJob.ts @@ -1,4 +1,4 @@ -import { revalidatePath } from "next/cache"; +import { safeRevalidate } from "../../lib/safeRevalidate"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { relocationQueueKey } from "yt-dlp-transcript-common/lib/queueKeys"; import { @@ -109,9 +109,7 @@ export async function enqueueRelocation(opts: { // // What DOES have to change is what the pages read per render — the badge, // the location line, the free-space figure — so those are revalidated. - revalidatePath(`/channels/${slug}`); - revalidatePath("/channels"); - revalidatePath("/"); + safeRevalidate([`/channels/${slug}`, "/channels", "/"]); }, }); } diff --git a/editor/app/lib/safeRevalidate.test.ts b/editor/app/lib/safeRevalidate.test.ts @@ -0,0 +1,61 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + resetSafeRevalidateWarning, + runGuarded, + safeRevalidate, +} from "./safeRevalidate"; + +// Run with: pnpm -C editor exec tsx --test "app/**/*.test.ts" +// +// Outside a Next request (which is where a QUEUED job's body runs, and where +// this test runs) revalidatePath throws the missing-store invariant. The helper +// must swallow exactly that, warn once per process, and rethrow anything else. + +function captureWarn<T>(fn: () => T): { result: T; warnings: string[] } { + const warnings: string[] = []; + const orig = console.warn; + console.warn = (...args: unknown[]) => { + warnings.push(args.map(String).join(" ")); + }; + try { + return { result: fn(), warnings }; + } finally { + console.warn = orig; + } +} + +test("outside a request, safeRevalidate does not throw and warns once", () => { + resetSafeRevalidateWarning(); + const { warnings } = captureWarn(() => { + safeRevalidate(["/channels/x", "/channels", ["/operations/[id]", "page"]]); + safeRevalidate(["/"]); + }); + const ours = warnings.filter((w) => w.includes("[safeRevalidate]")); + assert.equal(ours.length, 1); + assert.match(ours[0], /\/channels\/x/); + assert.match(ours[0], /\/operations\/\[id\] \(page\)/); +}); + +test("the real revalidatePath does throw here (the premise of the helper)", async () => { + const { revalidatePath } = await import("next/cache"); + assert.throws( + () => revalidatePath("/channels"), + /static generation store missing/, + ); +}); + +test("any other error is rethrown", () => { + resetSafeRevalidateWarning(); + assert.throws( + () => + runGuarded( + () => { + throw new Error("disk on fire"); + }, + "/x", + ["/x"], + ), + /disk on fire/, + ); +}); diff --git a/editor/app/lib/safeRevalidate.ts b/editor/app/lib/safeRevalidate.ts @@ -0,0 +1,82 @@ +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 (logged once per process, naming the +// paths), 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/; + +let warned = false; + +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]})`; +} + +// The guard itself, exported for the unit test so the rethrow path can be +// exercised without a Next runtime. `call` is one revalidation. +export function runGuarded( + call: () => void, + label: string, + allLabels: readonly string[], +): void { + try { + call(); + } catch (err) { + if (!isMissingStore(err)) throw err; + if (!warned) { + warned = true; + console.warn( + `[safeRevalidate] no request store (a queued job ran outside a request); ` + + `skipped revalidating ${allLabels.join(", ")} — first skip was ${label}. ` + + `Logged once per process; the pages re-read disk on their next request.`, + ); + } + } +} + +export function safeRevalidate( + paths: RevalidateTarget[], + tags: string[] = [], +): void { + const labels = [...paths.map(describe), ...tags.map((t) => `tag:${t}`)]; + for (const target of paths) { + runGuarded( + () => + typeof target === "string" + ? revalidatePath(target) + : revalidatePath(target[0], target[1]), + describe(target), + labels, + ); + } + for (const tag of tags) { + runGuarded(() => revalidateTag(tag, "max"), `tag:${tag}`, labels); + } +} + +// Test seam: the once-per-process latch. +export function resetSafeRevalidateWarning(): void { + warned = false; +} diff --git a/editor/app/saved-videos/backupActions.ts b/editor/app/saved-videos/backupActions.ts @@ -1,6 +1,7 @@ "use server"; import { revalidatePath } from "next/cache"; +import { safeRevalidate } from "../lib/safeRevalidate"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { saveSettings } from "../settings/saveSettings"; @@ -45,7 +46,7 @@ export async function backupSavedVideosAction( onLog( `Saved-video backup: ${result.backedUp}/${result.entries} container(s), ${result.bytes} bytes.`, ); - revalidatePath("/saved-videos"); + safeRevalidate(["/saved-videos"]); }, }); } diff --git a/editor/app/sites/lib/buildAction.ts b/editor/app/sites/lib/buildAction.ts @@ -1,6 +1,6 @@ "use server"; -import { revalidatePath } from "next/cache"; +import { safeRevalidate } from "../../lib/safeRevalidate"; import { buildIndex } from "yt-dlp-transcript-common/controller/buildIndex"; import { buildStats } from "yt-dlp-transcript-common/controller/buildStats"; import { normalizeAllTranscripts } from "yt-dlp-transcript-common/controller/normalizeAll"; @@ -66,7 +66,7 @@ export async function buildStatsAction( fn: async (onLog, signal) => { await buildStats({ paths, onLog, signal }); // every site's Charts tab previews the dataset this just rebuilt. - revalidatePath("/sites/[siteId]/charts", "page"); + safeRevalidate([["/sites/[siteId]/charts", "page"]]); }, }); } diff --git a/editor/app/storage/lib/evictClipsJob.ts b/editor/app/storage/lib/evictClipsJob.ts @@ -1,4 +1,4 @@ -import { revalidatePath } from "next/cache"; +import { safeRevalidate } from "../../lib/safeRevalidate"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { runManagedFunction, @@ -63,8 +63,8 @@ export async function enqueueEvictClipWindows(opts: { signal, }); onLog(evictClipWindowsSummary(result)); - revalidatePath("/storage"); - if (opts.slug) revalidatePath(`/channels/${opts.slug}`); + safeRevalidate(["/storage"]); + if (opts.slug) safeRevalidate([`/channels/${opts.slug}`]); }, }); } diff --git a/editor/app/storage/lib/repointJob.ts b/editor/app/storage/lib/repointJob.ts @@ -1,4 +1,4 @@ -import { revalidatePath } from "next/cache"; +import { safeRevalidate } from "../../lib/safeRevalidate"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand"; import { enqueueRepoint } from "yt-dlp-transcript-common/controller/storageLocations"; @@ -35,9 +35,7 @@ export async function enqueueRepointJob(opts: { // Every channel on the location now reads from a different path, so the // pages that draw a media location are stale. No snapshot regen — the // kind is in NO_REGEN_KINDS, and a re-point changes no count. - revalidatePath("/storage"); - revalidatePath("/channels"); - revalidatePath("/"); + safeRevalidate(["/storage", "/channels", "/"]); }, }); } diff --git a/editor/app/storage/lib/savedVideosJob.ts b/editor/app/storage/lib/savedVideosJob.ts @@ -1,4 +1,4 @@ -import { revalidatePath } from "next/cache"; +import { safeRevalidate } from "../../lib/safeRevalidate"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { relocationQueueKey } from "yt-dlp-transcript-common/lib/queueKeys"; import { @@ -73,8 +73,7 @@ export async function enqueueSavedVideosRelocation(opts: { // where the store's bytes are, not what any channel has: every count in // every report is identical afterwards. What DOES change is what these // pages read per render. - revalidatePath("/storage"); - revalidatePath("/saved-videos"); + safeRevalidate(["/storage", "/saved-videos"]); }, }); } diff --git a/editor/e2e/ops-api.spec.ts b/editor/e2e/ops-api.spec.ts @@ -430,6 +430,63 @@ test("metadata-scan starts a job, and the job says it is a metadata-scan", async expect(unknownChannel.body.error).toContain('Channel "nope" not found'); }); +// A QUEUED JOB MUST NOT END `failed` FOR REVALIDATING (release 9, B1). +// +// A job that starts at once runs its body inside the request that submitted +// it. One that waits behind another on the same platform queue is started +// later by the queue's drain loop, OUTSIDE any request, and there Next's +// revalidatePath throws "static generation store missing". The sync body calls +// it last, so the throw landed after the work was done: the job read `failed` +// and `pnpm ops … --wait` exited 1 (the teamrcn sync, 2026-09-25). slow-a's +// `--test-slow` holds platform:youtube for ~30 s; slow-b is submitted behind +// it with no slow flag, so it is the job the drain loop starts. +test("a sync queued behind another platform job ends done, not failed", async ({ + request, +}) => { + test.setTimeout(150_000); + await resetData("two-slow-channels"); + await settings(); + await writeChannelConfig("slow-b", { + name: "Slow B", + url: "https://www.youtube.com/@slow-b/videos", + }); + + type Meta = { status: string; queueKey?: string }; + const meta = (id: string) => + readJson<Meta>(`test-transcripts/.jobs/${id}.meta.json`).catch(() => null); + + const first = await ops(request, "sync", { slug: "slow-a" }); + expect(first.body.ok).toBe(true); + await expect + .poll(async () => (await meta(first.body.jobId!))?.status ?? null) + .toBe("running"); + + const second = await ops(request, "sync", { slug: "slow-b" }); + expect(second.body.ok).toBe(true); + // Same platform queue, so it waits: this is the job the drain loop starts. + await expect + .poll(async () => (await meta(second.body.jobId!))?.status ?? null) + .toBe("queued"); + expect((await meta(second.body.jobId!))?.queueKey).toBe( + (await meta(first.body.jobId!))?.queueKey, + ); + + await expect + .poll(async () => (await meta(second.body.jobId!))?.status ?? null, { + timeout: 120_000, + intervals: [1_000], + }) + .toMatch(/^(done|failed|cancelled)$/); + const log = await request.get( + `${baseUrl}/api/jobs/${second.body.jobId}/log?from=0`, + ); + const payload = (await log.json()) as { status: string; text?: string }; + expect(JSON.stringify(payload)).not.toContain( + "static generation store missing", + ); + expect((await meta(second.body.jobId!))?.status).toBe("done"); +}); + test("refresh-report regenerates snapshot.json", async ({ request }) => { test.setTimeout(120_000); await resetData("title-filter-channel");