snapshot-file.ts
Language TypeScript5.9 KB141 lines
/*** Caching the last sync to `data/last-snapshot.json`. Node only.** `pnpm morning` renders the page, writes the briefings from that render's* numbers, then renders again so the prose appears on the board. A second* render that re-synced would move `snap.syncedAt`, so the briefings would no* longer match the numbers beside them and every clean run would end by* declaring its own output stale. `--render-only` reads this file and* re-renders from the same sync, so the check passes because the* prose and the numbers genuinely do describe one moment, not because the check* was loosened. Loosening it would remove the only thing telling a reader whether* the two halves of the page agree.** Not re-rendered from briefing-payload.json because That file is deliberately* distilled (roughly 4k tokens where the snapshot is 300k), and `render()` needs* the whole snapshot. Widening the payload to serve the renderer would throw away* the entire cost strategy.** Node-only IO lives in a `*-file.ts` module because a static `node:fs` import* resolves even behind `import type` and breaks the Worker build (rule 7). See* also history-file.ts and briefings-file.ts.*/import { readFile, writeFile, mkdir } from 'node:fs/promises'import { dirname } from 'node:path'import { fileURLToPath } from 'node:url'import type { Snapshot } from './collect.js'import { errorMessage } from '@meridian/shared'export const SNAPSHOT_FILE = new URL('../../../data/last-snapshot.json', import.meta.url)/*** Plain JSON throughout: no Date, Map or Set anywhere in Snapshot, and* `ninetySpread` is four numbers. If a future field breaks that,* `--render-only` is where it will show up.*/export async function writeSnapshot(snap: Snapshot, file: URL = SNAPSHOT_FILE): Promise<void> {try {await mkdir(dirname(fileURLToPath(file)), { recursive: true })await writeFile(file, `${JSON.stringify(snap)}\n`, 'utf8')} catch {// Read-only checkout, CI, a full disk. `--render-only` stops working and// says so; a live build must not fail because a cache could not be written.}}/*** Missing and unusable are different answers, so this does not collapse them* into null. "No cache yet" means run a sync; "there is a cache and it is not a* snapshot" means something wrote a bad file, which is a problem of its own and* the caller should be able to say so instead of guessing.*/export type SnapshotRead =| { ok: true; snap: Snapshot }| { ok: false; missing: boolean; reason: string }/*** The fields checked are the ones decisions are made from. `syncedAt` dates every* number on the page and is what the staleness check compares; the four sources* carry both the data and whether it can be trusted. Everything else is left* alone deliberately: being strict about fields nothing reads discards good* records (rule 4).** Rendering half a snapshot would put up a page that looks complete and is not,* which is the one outcome this whole board exists to prevent. So a file failing* these checks is refused rather than rendered around.*/export async function readSnapshot(file: URL = SNAPSHOT_FILE): Promise<SnapshotRead> {let raw: stringtry {raw = await readFile(file, 'utf8')} catch {return { ok: false, missing: true, reason: 'no file there yet' }}let parsed: unknowntry {parsed = JSON.parse(raw)} catch (err) {return {ok: false,missing: false,reason: `it is not valid JSON (${errorMessage(err)})`,}}if (!parsed || typeof parsed !== 'object') {return { ok: false, missing: false, reason: `it holds ${typeof parsed}, not an object` }}const s = parsed as Snapshotif (typeof s.syncedAt !== 'string' || !s.syncedAt) {return { ok: false, missing: false, reason: 'it has no syncedAt, so nothing on it can be dated' }}if (s.mode !== 'live' && s.mode !== 'fixtures') {return { ok: false, missing: false, reason: `mode is ${JSON.stringify(s.mode)}, not 'live' or 'fixtures'` }}for (const key of ['leads', 'orderLines', 'tasks', 'ninety'] as const) {const src = s[key]if (!src || typeof src !== 'object' || typeof src.ok !== 'boolean' || !Array.isArray(src.data)) {return { ok: false, missing: false, reason: `the '${key}' source is missing or malformed` }}}// A cache without orderWindow is refused, not defaulted. Defaulting// `truncated` to false would have this file assert that a sweep it knows// nothing about was complete. The cache is disposable; re-syncing// costs one run.if (!s.orderWindow || typeof s.orderWindow.truncated !== 'boolean') {return {ok: false,missing: false,reason: 'it predates the orderWindow field, so whether its order counts are ' +'complete or truncated cannot be known',}}// The same reasoning one field deeper. A cache without assignment coverage// cannot say// whether its per-person counts were complete or blind, and --render-only would// put them on the page as though it could.if (!s.orderWindow.assignmentFields || typeof s.orderWindow.assignmentFields.total !== 'number') {return {ok: false,missing: false,reason: 'it predates assignment field coverage, so whether its per-person counts are ' +'complete or blind cannot be known',}}// Same reasoning again for the multi-key Fathom source. A cache without this// field cannot say how many// keys were asked or whether any of them was dark, and neither default is// safe: `[]` renders as "live, from no keys", and leaving it undefined throws// somewhere inside the view-model instead of here, where the reason is known.if (s.fathomConfigured && !Array.isArray(s.fathomSources)) {return {ok: false,missing: false,reason: 'it predates per-key Fathom status, so whether every meeting key was ' +'read or one of them was dark cannot be known',}}return { ok: true, snap: s }}