Files
fofos-admin/api/ledger.ts
T
pastilhasandClaude Opus 5 f30d40446a a ledger of runs, and what is still outstanding
Two things the panel could not answer. A run's state lives in a Map, so a
restart erased it — and with it any record that a file FAILED. That is the gap
the manifests structurally cannot cover: a derived folder records the size,
mtime and variants it was built from, so provenance is already precise, but a
file that did not build has no folder and leaves no trace. Once the watcher is
running, a failure at three in the morning would never be discovered.

So one JSON line per run, appended to media/.sync-log.jsonl. A file rather than
a table because db/schema.ts is emphatic that the connection is the only thing
this plugin stores, and a ledger is losable — re-run and it rebuilds. Beside
derived/ rather than inside it, so wiping derived does not erase the history of
what happened before, which is the comparison it exists for. Written in a
finally, so a run that died still records that it did.

And the line above the button now says what is TRUE rather than what a run once
did: reconcile() walks originals, reads each manifest, and reports current,
missing and changed. It uses upToDate — the same predicate the sync uses to skip
a file — because two definitions of "current" is how a panel comes to say
everything is in step while the next run rebuilds half of it. Missing and
changed are separated because only the second means somebody edited something.

The ledger is not polled. It reads the whole tree, so it loads on arrival and
again when a run stops — the only two moments its answer can change.

Sync also takes an optional folder now, so a subtree can be re-synced on its own
rather than the whole library. The scope is recorded on each run, because "3
built" means different things for one folder and for everything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 11:47:32 +01:00

99 lines
3.9 KiB
TypeScript

import { appendFile, readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { mediaPath } from './media';
import type { SyncError } from './sync';
/**
* What has been synced, and when.
*
* ── The hole this fills ──
*
* A run's state lives in memory, so a restart erases it — and with it any record that a file FAILED.
* That is the part the manifests cannot cover: a derived folder records the size, mtime and variants it
* was built from, so provenance is already answerable precisely, but a file that did not build has no
* folder and leaves no trace at all. With the watcher running, a failure at three in the morning would
* simply never be discovered.
*
* So the ledger's job is RUNS AND FAILURES, not provenance. The manifests keep the latter and are
* better at it.
*
* ── A file, not a table ──
*
* `db/schema.ts` says the connection is the only thing this plugin stores, and gives the reason: the
* installer drops a plugin's tables when "delete its data" is ticked, so the strongest thing that can
* be said about this plugin is that no operation on it can destroy the client's work. A ledger is
* losable — re-run and it rebuilds — so it does not need that protection, and a table would be a
* schema, a migration and a third thing to keep in step for something a line of JSON answers.
*
* It also matches the design already in play next door: the filesystem is the record.
*
* ── Beside `derived/`, not inside it ──
*
* Wiping `derived/` is a normal thing to do and must not erase the history of what happened before it —
* that history is exactly the comparison this exists for.
*
* ── Append-only, and why that is safe ──
*
* One line per run, written with a single `appendFile` on an O_APPEND handle. Writes under a page are
* atomic on Linux, and these are a couple of hundred bytes, so two writers cannot interleave a line.
* Today there is only ever one run at a time; that will stop being true when the watcher lands, which
* is why it is worth being true by construction rather than by luck.
*/
const FILE = '.sync-log.jsonl';
export type RunTrigger = 'manual' | 'watcher';
export type Run = {
startedAt: string;
finishedAt: string;
trigger: RunTrigger;
/** The subtree covered, relative to `originals/`. Empty means all of it. */
scope: string;
/** Convertible files the walk found. */
total: number;
built: number;
skipped: number;
errors: SyncError[];
/** Set when the run stopped for a reason that was not about any single file. */
fatal: string | null;
};
const ledgerPath = async (email: string): Promise<string> => join(await mediaPath(email), FILE);
export async function appendRun(email: string, run: Run): Promise<void> {
try {
await appendFile(await ledgerPath(email), `${JSON.stringify(run)}\n`, 'utf8');
} catch {
/*
Swallowed on purpose. Failing to RECORD a sync must never fail the sync — the files are the
product and this is the note about them. The most likely cause is `media/` not existing, which
the panel already reports far more usefully than an exception thrown from here would.
*/
}
}
/**
* The most recent runs, newest first.
*
* Read whole and sliced. The file is a couple of hundred bytes a line and would take years of a
* watcher firing on every upload to become worth streaming; when it does, that is the moment to
* change this, and not before.
*/
export async function readRuns(email: string, limit = 20): Promise<Run[]> {
const raw = await readFile(await ledgerPath(email), 'utf8').catch(() => '');
const runs: Run[] = [];
for (const line of raw.split('\n')) {
if (!line.trim()) continue;
try {
runs.push(JSON.parse(line) as Run);
} catch {
// A truncated last line — a crash mid-append. Skipped rather than fatal: one unreadable entry
// must not make the whole history unreadable.
}
}
return runs.reverse().slice(0, limit);
}