import { readFile, writeFile, rename, mkdir } from "node:fs/promises"; import { existsSync } from "node:fs"; import path from "node:path"; import { stateFile, dataFile } from "./paths"; // The JSON state has TWO writers: this app and the CLI scripts (decode-um.mjs // applies paste-backs from the static page, which stays usable). A lost update // here is lost human judgement, so: // // * nothing is held in memory across requests -- every write re-reads first // * writes go to a temp file and rename() over the target, which is atomic on // the same filesystem, so a reader never sees a half-written file // * writes within this process are serialised through one queue, because // read-modify-write from two concurrent requests would otherwise interleave export async function readJson(file: string, fallback: T): Promise { try { return JSON.parse(await readFile(file, "utf8")) as T; } catch { return fallback; } } export async function writeJsonAtomic(file: string, value: unknown): Promise { const dir = path.dirname(file); if (!existsSync(dir)) await mkdir(dir, { recursive: true }); const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`; await writeFile(tmp, JSON.stringify(value, null, 1)); await rename(tmp, file); } let queue: Promise = Promise.resolve(); /** Serialise a read-modify-write against the shared state files. */ export function withStateLock(fn: () => Promise): Promise { const run = queue.then(fn, fn); // Keep the chain alive even when a caller rejects. queue = run.then( () => undefined, () => undefined, ); return run; } export type Reason = { verdict: number; code: number; why: string; text?: string }; export type SongState = { accepted: string[]; rejected: string[]; unclean: string[]; provisional: string[]; impure: string[]; keepside: Record; decided: Record; reasons: Record; corepitch: Record; words: Record; }; /** Read every state file fresh. Cheap (a few MB) and correctness depends on it. */ export async function readState(): Promise { const acc = await readJson<{ accepted: string[]; rejected: string[] }>( stateFile("accepted.json"), { accepted: [], rejected: [] }, ); return { accepted: acc.accepted ?? [], rejected: acc.rejected ?? [], unclean: await readJson(stateFile("unclean.json"), []), provisional: await readJson(stateFile("provisional.json"), []), impure: await readJson(stateFile("impure.json"), []), keepside: await readJson>(stateFile("keepside.json"), {}), decided: await readJson>(stateFile("decided.json"), {}), reasons: await readJson>(stateFile("reasons.json"), {}), corepitch: await readJson>( stateFile("corepitch.json"), {}, ), words: await readJson>( stateFile("words.json"), {}, ), }; } export type SalvagedFile = { video: string; candidates: Record[] }; export const SALVAGED = () => dataFile("cand2", "_salvaged.json"); export async function readSalvaged(): Promise { return readJson(SALVAGED(), { video: "_salvaged", candidates: [] }); }