// ONE JSON READER AND ONE ATOMIC WRITER — for the config files and sidecars, // and (since release 4 slice W) for every tmp + rename write in common/ and // editor/: JSON through `writeJsonAtomic`, text and binary through // `writeFileAtomic`, copies through `copyFileAtomic`. What is left on its own // temp name is listed in plans/one-core-phase-3.md, "Slice W, as shipped". // // one-core phase 3 slice 4b. Before this module the repo had seven private // copies of `writeJsonAtomic` and some twenty inline `tmp + rename` writes, all // of them spelling the temp file `${file}.tmp-${process.pid}`. That name is the // same for every write this process makes to one file, so two writers in the // same process — a sync stamping `lastSyncedAt` while the channel form saves // `config.json` — shared one temp file: the second `writeFile` truncated the // first's bytes and one `rename` then found no file. This module fixes both // halves of that: // // - every temp name is unique (`${file}.tmp-${pid}-${seq}-${random}`), and // - writes to one ABSOLUTE PATH are CHAINED: a write starts only once the // previous write to that path has settled, so two same-process writers // serialise and the last one to be ISSUED is the one on disk. // // ONE CHAIN PER PROCESS, NOT PER MODULE COPY. Next can load this module more // than once in one server (instrumentation.ts arms the runners; server actions // are another bundle layer), so the counter, the write chains and the locks // live on `globalThis` — the house pattern (jobs/registry.ts, // controller/autoRunner.ts) — and the temp name also carries random bytes, so // its uniqueness never rests on a shared counter alone. // // The chain is per process. A CLI run beside a live editor (e.g. // `migrate-channel-priority.ts`) is a different process and is NOT covered — // the rename is still atomic, so a reader never sees a torn file, but the // last rename wins. // // The chain serialises WRITES, not read-modify-write cycles. A caller that reads, // changes and writes back takes `withJsonFileLock` around the whole cycle, so // its read is current — see `patchChannelConfig` in controller/channels.ts. // // BYTES ARE PRESERVED, per caller. `indent` and `newline` are options, not a // house style, because the bytes on disk are what the phase-3 numbers diff: the // attribution and diarization sidecars are compact with a trailing newline, // the export build's pages are compact without one, the chart/alias/tag stores // are indented without one, and everything else is indented with one. // // SERVER-ONLY (node:fs). Named `-server` so a `"use client"` graph never // reaches it. import { randomBytes } from "node:crypto"; import fs from "node:fs"; import { copyFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import path from "node:path"; export type ReadJsonResult = | { ok: true; value: unknown } | { ok: false; reason: "absent" | "unreadable" | "unparseable" }; function readFailure(err: unknown): ReadJsonResult { const code = (err as NodeJS.ErrnoException | null)?.code; return { ok: false, reason: code === "ENOENT" || code === "ENOTDIR" ? "absent" : "unreadable", }; } function parseText(text: string): ReadJsonResult { try { return { ok: true, value: JSON.parse(text) as unknown }; } catch { return { ok: false, reason: "unparseable" }; } } // The file as JSON. Never throws: `absent` is a missing file (or a missing // parent), `unreadable` any other read error (EACCES, EISDIR, EIO, …), // `unparseable` a file that is not JSON — which includes a truncated one. export async function readJsonFile(file: string): Promise { let text: string; try { text = await readFile(file, "utf8"); } catch (err) { return readFailure(err); } return parseText(text); } // The synchronous twin, for the two readers that are synchronous by contract: // `getSettings` (lib/settings.ts) and `getSite` (lib/site.ts). export function readJsonFileSync(file: string): ReadJsonResult { let text: string; try { text = fs.readFileSync(file, "utf8"); } catch (err) { return readFailure(err); } return parseText(text); } export type WriteJsonOptions = { // 2 (the default) = `JSON.stringify(value, null, 2)`; 0 = compact. indent?: 0 | 2; // Append "\n". Defaults to true — the common case; pass false to keep a // file's historical bytes. newline?: boolean; // Create the parent directory first (`mkdir -p`). mkdir?: boolean; }; // Exactly the bytes a write puts on disk. export function jsonText(value: unknown, opts: WriteJsonOptions = {}): string { const indent = opts.indent ?? 2; const body = indent === 0 ? JSON.stringify(value) : JSON.stringify(value, null, indent); return opts.newline === false ? body : body + "\n"; } type JsonFileState = { tmpSeq: number; chains: Map>; locks: Map>; }; declare global { // eslint-disable-next-line no-var var __yttJsonFile__: JsonFileState | undefined; } // Exported for the test that proves two module copies share it. export function jsonFileState(): JsonFileState { if (!globalThis.__yttJsonFile__) { globalThis.__yttJsonFile__ = { tmpSeq: 0, chains: new Map(), locks: new Map() }; } return globalThis.__yttJsonFile__; } // Unique per write, not per process: `${file}.tmp-${pid}-${seq}-${random}`. export function tmpPathFor(file: string): string { const seq = ++jsonFileState().tmpSeq; return `${file}.tmp-${process.pid}-${seq}-${randomBytes(4).toString("hex")}`; } export type WriteFileOptions = { // Create the parent directory first (`mkdir -p`). mkdir?: boolean; // Permission bits for the NEW file, applied as the temp file is CREATED // (`writeFile(tmp, data, { mode })`, under the umask), so the bytes are never // readable more widely than `mode` — not even for the instant before the // rename. The X cookie jar writes 0o600 through this. mode?: number; }; // Run `op` for `file` after every earlier chained operation on the same // absolute path this process issued has settled. A failed earlier op does not // block a later one; each caller sees only its own op's error. function chained(key: string, op: () => Promise): Promise { const { chains } = jsonFileState(); const prev = chains.get(key) ?? Promise.resolve(); const next = prev.then(op, op); chains.set(key, next); const release = () => { if (chains.get(key) === next) chains.delete(key); }; next.then(release, release); return next; } // Put the temp file in place with `fill`, then rename it over `file`; on any // failure remove the temp and rethrow. async function replaceNow( file: string, makeDir: boolean, fill: (tmp: string) => Promise, ): Promise { if (makeDir) await mkdir(path.dirname(file), { recursive: true }); const tmp = tmpPathFor(file); try { await fill(tmp); await rename(tmp, file); } catch (err) { await rm(tmp, { force: true }).catch(() => {}); throw err; } } // THE ONE ATOMIC WRITE (tmp + rename) for text and binary files: the playlist, // failed-transcriptions, the cookie jar, a CHANGELOG, a VTT — and every JSON // file, through `writeJsonAtomic` below. Same chain, same unique temp name. // `data` is not copied: a Buffer must not change before the promise settles. export function writeFileAtomic( file: string, data: string | Buffer, opts: WriteFileOptions = {}, ): Promise { const key = path.resolve(file); const { mode } = opts; return chained(key, () => replaceNow(key, opts.mkdir === true, (tmp) => mode === undefined ? writeFile(tmp, data) : writeFile(tmp, data, { mode }), ), ); } // Copy `src` to `dest` atomically: copy into a temp beside `dest`, then rename, // so a crash mid-copy never leaves a partial file under the final name. On the // same chain as the writers above (keyed on `dest`). export function copyFileAtomic( src: string, dest: string, opts: { mkdir?: boolean } = {}, ): Promise { const key = path.resolve(dest); return chained(key, () => replaceNow(key, opts.mkdir === true, (tmp) => copyFile(src, tmp)), ); } // Write `value` as JSON to `file` atomically: `jsonText` → `writeFileAtomic`. // The value is serialised NOW, at the call, so a caller that goes on mutating // its object cannot change what lands. export function writeJsonAtomic( file: string, value: unknown, opts: WriteJsonOptions = {}, ): Promise { return writeFileAtomic(file, jsonText(value, opts), { mkdir: opts.mkdir }); } // The synchronous twin, for the three stores whose API is synchronous // (chartsStore, aliasesStore, curatedTagsStore). A synchronous write cannot // wait on the async chain, and needs no chain of its own: nothing else in this // process runs while it does. It shares the unique temp name. export function writeJsonAtomicSync( file: string, value: unknown, opts: WriteJsonOptions = {}, ): void { if (opts.mkdir === true) fs.mkdirSync(path.dirname(file), { recursive: true }); const tmp = tmpPathFor(file); try { fs.writeFileSync(tmp, jsonText(value, opts)); fs.renameSync(tmp, file); } catch (err) { fs.rmSync(tmp, { force: true }); throw err; } } // Run `fn` with exclusive use of `file` among callers of this function in this // process: a READ-MODIFY-WRITE cycle that must not interleave with another one // on the same path (two `patchChannelConfig`s, say, each of which would // otherwise read the file before the other wrote it and so drop its patch). // Callers that only write need not take it — writes are chained anyway. Per // process, like the write chain. export function withJsonFileLock(file: string, fn: () => Promise): Promise { const key = path.resolve(file); const { locks } = jsonFileState(); const prev = locks.get(key) ?? Promise.resolve(); const next = prev.then(fn, fn); locks.set(key, next); const release = () => { if (locks.get(key) === next) locks.delete(key); }; next.then(release, release); return next; } // For tests: how many paths currently have a write (of any of the three // kinds) in flight. export function pendingJsonWrites(): number { return jsonFileState().chains.size; }