src/backup.ts
409 lines and 21 functions in DrDustinEdwards/capsid, at commit daa875b on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| isOlderThan | 35 to 39 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| cutoffDay | 41 to 43 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| backupHttpStatus | 178 to 181 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| deleteInChunks | 190 to 195 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| listAllKeys | 197 to 206 | 4 | 100% | OkCRAP 4 | No data: mutation testing has not run on this repo yet |
| runBackup | 208 to 226 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| kvPinKeys | 233 to 235 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| readKvPins | 241 to 252 | 4 | 100% | OkCRAP 4 | No data: mutation testing has not run on this repo yet |
| exportDump | 268 to 297 | 7 | 100% | WatchCRAP 7 | No data: mutation testing has not run on this repo yet |
| <anonymous:282> | 282 to 282 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:290> | 290 to 290 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:292> | 292 to 292 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:293> | 293 to 293 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| preflight | 307 to 311 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| mirrorMarkdown | 315 to 331 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| <anonymous:319> | 319 to 323 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:328> | 328 to 328 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| pruneR2 | 339 to 348 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:343> | 343 to 344 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| pruneD1 | 353 to 370 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| exportAndPrune | 372 to 444 | 5 | 100% | OkCRAP 5 | No data: mutation testing has not run on this repo yet |
Imports
It imports src/env.ts, src/health.ts, src/log.ts, src/security-headers.ts, src/store-probe.ts, src/time.ts. Imported by src/index.ts, src/routes.ts.
Source
import { dumpDatabase, pruneDumps, writeCompleteMarker, type DumpResult } from "@dustinedwards/d1-dump";
import type { Env } from "./env";
import { BACKUP_LAST_OK_KEY } from "./health";
import { REPORT_PREFIX } from "./security-headers";
import { probeFts } from "./store-probe";
import { logEvent } from "./log";
import { DAY_MS } from "./time";
const JSON_PREFIX = "backups/json/";
const MARKDOWN_PREFIX = "backups/markdown/";
const PUT_CONCURRENCY = 20;
// KV lease is best-effort (no CAS). Export before prune. Dump retention is d1-dump's graduated policy.
// Two runs can both read the key empty and both take it, so the run re-reads the lease
// after its export and refuses every destructive step unless the lease is still its own.
//
// The lease value carries a per-run token and a run releases only its own, so a run
// that outlived its TTL cannot delete the lease of the run that started after it. The
// TTL is an hour: above the 15-minute wall limit on a cron invocation, with room for
// an /ops/backup run, which has no wall limit. A lease left by an isolate that died
// mid-run then blocks backups for at most an hour of a daily schedule.
const LEASE_KEY = "backup:lease";
const LEASE_TTL_SECONDS = 3600;
// Retention for the history tables. Both are covered by the dump shelf life above.
const VERSION_RETENTION_DAYS = 90;
const AUDIT_RETENTION_DAYS = 180;
// CSP and COOP violation reports. Nothing else prunes this prefix, and a public
// unauthenticated path writes it.
const REPORT_RETENTION_DAYS = 30;
// Both prefixes carry an ISO date at a fixed offset, so the age test is a string
// comparison. A key with no parseable day is never pruned.
function isOlderThan(key: string, prefix: string, cutoffDay: string): boolean {
const day = key.slice(prefix.length, prefix.length + 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return false;
return day < cutoffDay;
}
function cutoffDay(now: Date, days: number): string {
return new Date(now.getTime() - days * DAY_MS).toISOString().slice(0, 10);
}
// The dump itself is @dustinedwards/d1-dump, the package every site installs, so
// Capsid and the sites run one implementation.
//
// Tables paged rather than read whole. Reading every table in one batch holds the
// whole database in the isolate, and once version bodies grew past what the isolate
// holds the cron died before its first put. A table qualifies only if it is
// append-only with an AUTOINCREMENT id, so the MAX(id) bound read inside the snapshot
// keeps the one-instant dump: rows at or below it cannot have changed, and only this
// run's own prune (after the export, under the lease) deletes them. documents and jobs
// are updated in place, so they stay in the batch. A version row can be a few hundred
// KB; an audit row carries no document body. A claim row can list 500 touched paths,
// so job_claims pages too; its two sibling tables carry no lists and stay in the batch.
const PAGED = {
document_versions: { idColumn: "id", pageRows: 100 },
audit_log: { idColumn: "id", pageRows: 1000 },
job_claims: { idColumn: "id", pageRows: 200 },
};
// wrangler's migration ledger. A restore builds the schema from migrations/, which
// writes its own. The weekly drill builds its scratch database from _schema.json, and a
// run whose dumped tables differ from TABLES is refused as tables-mismatch.
const EXCLUDED = ["d1_migrations"];
// Every real table in the schema. test/backup.test.ts derives this list from
// migrations/ and fails in both directions. Excluded: documents_fts and its
// documents_fts_* shadow tables, which the sync triggers rebuild from documents,
// and sqlite_sequence, which SQLite maintains for AUTOINCREMENT.
export const TABLES = [
"documents",
"namespaces",
"document_versions",
"audit_log",
"document_links",
// Nothing prunes the tables below except improve_jti, so a dump is the only copy
// outside D1. job_outcomes and job_outcome_prs hold counts verified against pull
// requests GitHub can delete. agents carries the sha256 verifier, never a key, and
// a revoked agent keeps its row so the audit trail it wrote still resolves.
"jobs",
"job_outcomes",
"agents",
"job_outcome_prs",
// The Portal's site configuration: edited by hand in the Portal, so the dump is
// its only copy outside D1 besides the audit rows of each edit.
"ops_sites",
// The Portal's package configuration and its weekly GitHub numbers (migrations/0028):
// the configuration is edited by hand, and the weekly rows are kept nowhere else.
"ops_packages",
"ops_package_weeks",
// The claims dataset (migrations/0023_job_claims.sql): what each agent said it did,
// each check of that against GitHub, and every human touch. Append-only by trigger,
// and nothing prunes them, so the dump is the only copy outside D1.
"job_claims",
"job_evaluations",
"job_touches",
// The watcher's memory of each finding across jobs (migrations/0024_watcher_findings.sql),
// and what Claude Code sessions reported by hook and telemetry (0025). session_events
// holds a row per tool call since the tool events (docs/hooks.md), the largest table
// here, and is pruned at 30 days in code.
"watcher_findings",
"agent_sessions",
"session_events",
"session_usage",
// The run ledger (migrations/0029_task_runs.sql): each scheduled task's runs,
// pruned at 14 days in code (src/task-runs.ts).
"task_runs",
// Each job's earlier versions, kept by capsid_work_jobs action "edit" (migrations/0033_job_versions.sql).
// Nothing prunes it, and the job's row holds only the newest version.
"job_versions",
// Drivers' proposed changes to canon documents, pending or decided
// (migrations/0034_canon_proposals.sql). Nothing prunes it: a decided row is the
// record of who proposed and who approved a canon change.
"canon_proposals",
// The shared packages the Shared code view follows (migrations/0036_shared_packages.sql):
// configuration, seeded by its migration, so a restore empties it before importing.
"shared_packages",
// The merge pipeline's approvals (migrations/0037_pr_approvals.sql): who approved which
// pull request at which head, and how each approval ended. Nothing prunes it.
"pr_approvals",
// Revoked agents moved out of agents by the watcher pass (migrations/0038_agents_archive.sql).
// Nothing prunes it: an archived row keeps a name reserved and a revoked key refused.
"agents_archive",
// What became of each deploy whose live gate ran (migrations/0039_deploy_outcomes.sql),
// the evidence the promotion and demotion proposals read. Nothing prunes it.
"deploy_outcomes",
// The Access group pinned for each app's members page (migrations/0040_access_sync_groups.sql):
// configuration the seat writes by hand. Nothing prunes it.
"access_sync_groups",
// The devices that get a phone alert (migrations/0041_push_subscriptions.sql).
"push_subscriptions",
// The methods paper's paired comparison (migrations/0047_bench.sql): the registered
// tasks and every run's outcome, the data its primary outcome is computed from.
// Insert-only and close-once by trigger; nothing prunes them.
"bench_tasks",
"bench_runs",
// The registration and the drawn task order (migrations/0049_bench_draw.sql),
// insert-only by trigger; nothing prunes them.
"bench_registration",
"bench_draw",
// The replay cache, pruned below: a jti matters only inside the signature window.
"improve_jti",
] as const;
interface BackupSummary {
ran: true;
json_prefix: string;
json_keys: string[];
documents: number;
markdown_written: number;
markdown_pruned: number;
json_backups_kept: number;
json_backups_pruned: number;
// Stale runs an R2 bucket lock refused to delete, left for a later prune (docs/backups.md).
json_backups_locked: number;
reports_pruned: number;
versions_pruned: number;
audit_pruned: number;
// Null on a healthy run. Otherwise the named reason NOTHING was deleted. Read it
// before believing a zero in any of the *_pruned counters.
prune_refused: string | null;
preflight: { documents: number; fts: string };
}
// A run that did not run. Named rather than thrown so the caller can tell a held
// lease from a failure.
interface BackupSkipped {
ran: false;
skipped: string;
}
export type BackupResult = BackupSummary | BackupSkipped;
// The HTTP status /ops/backup answers with, so "did nothing" is visible without the
// body: 409 when another run holds the lease, 503 when the preflight refused the prune
// (the dumps were written but nothing was marked, mirrored or pruned), 200 otherwise.
export function backupHttpStatus(result: BackupResult): number {
if (!result.ran) return 409;
return result.prune_refused === null ? 200 : 503;
}
// R2's bulk delete takes at most 1000 keys per call and refuses the rest rather than
// truncating, so an unbounded array throws after the dumps are written and before
// backup:last-ok is stamped.
const R2_DELETE_MAX = 1000;
// One chunker for every prune, so a new delete site cannot be written unchunked
// beside the others.
async function deleteInChunks(bucket: R2Bucket, keys: string[]): Promise<number> {
for (let i = 0; i < keys.length; i += R2_DELETE_MAX) {
await bucket.delete(keys.slice(i, i + R2_DELETE_MAX));
}
return keys.length;
}
async function listAllKeys(bucket: R2Bucket, prefix: string): Promise<string[]> {
const keys: string[] = [];
let cursor: string | undefined;
do {
const page = await bucket.list({ prefix, cursor });
for (const obj of page.objects) keys.push(obj.key);
cursor = page.truncated ? page.cursor : undefined;
} while (cursor);
return keys;
}
export async function runBackup(env: Env): Promise<BackupResult> {
const held = await env.APP_KV.get(LEASE_KEY);
if (held !== null) {
logEvent("error", "BACKUP_LEASE_HELD", { message: `BACKUP_LEASE_HELD another run holds ${LEASE_KEY} (started ${held}); this run pruned nothing` });
return { ran: false, skipped: "lease-held" };
}
const now = new Date().toISOString();
const lease = `${now} ${crypto.randomUUID()}`;
await env.APP_KV.put(LEASE_KEY, lease, { expirationTtl: LEASE_TTL_SECONDS });
try {
return await exportAndPrune(env, now, lease);
} finally {
// Release on success or throw, and only this run's own lease: if the TTL lapsed
// and another run took the key, deleting it would let a third run start.
const current = await env.APP_KV.get(LEASE_KEY);
if (current === lease) await env.APP_KV.delete(LEASE_KEY);
else logEvent("error", "BACKUP_LEASE_LOST", { message: `BACKUP_LEASE_LOST ${LEASE_KEY} is no longer this run's (now ${current}); left in place` });
}
}
// The KV pins, by allowlist and never by prefix sweep. APP_KV also holds the GitHub
// installation-token cache, and a dump leaves the account, so a prefix sweep would
// mirror a live credential the first time someone added a key under a prefix nobody
// re-read. Every key below is a control value, none a secret. backup:lease is absent:
// it is this run's own bookkeeping.
function kvPinKeys(): string[] {
return [BACKUP_LAST_OK_KEY];
}
// null means the key was unset. An unreadable key is { unreadable: reason }, because
// restoring a null for it would clear a value that existed.
type KvPin = string | null | { unreadable: string };
async function readKvPins(env: Env): Promise<Record<string, KvPin>> {
const pins: Record<string, KvPin> = {};
for (const key of kvPinKeys()) {
try {
pins[key] = await env.APP_KV.get(key);
} catch (err) {
// Does not fail the run: the D1 dump must not be lost to a KV hiccup.
pins[key] = { unreadable: err instanceof Error ? err.message : String(err) };
}
}
return pins;
}
type DocRow = { namespace: string; path: string; body: string | null };
// Writes one JSON object per table, _schema.json and the KV sidecar under one run
// prefix, WITHOUT the completion marker, which exportAndPrune writes only once the
// preflight passes. Returns the documents rows as dumped, which the preflight and the
// markdown mirror both read, and any difference between the tables dumped and TABLES.
//
// The package reads every unpaged table in one D1 batch, which is one transaction, so
// every table describes the same instant and exported_at describes it. The restore
// invariants script (scripts/dump-invariants.ts) checks for the signature a torn read
// leaves.
//
// The sidecar, underscore-prefixed so it cannot collide with a table name, holds the
// KV pins, which are not in D1.
async function exportDump(env: Env, now: string): Promise<{ dump: DumpResult; docs: DocRow[]; tablesMismatch: string | null }> {
const dump = await dumpDatabase(env.DB, env.MEDIA, {
prefix: JSON_PREFIX,
now: new Date(now),
paged: PAGED,
exclude: EXCLUDED,
sidecars: {
kv: { keys: await readKvPins(env) },
},
markComplete: false,
});
// Read back rather than kept from the snapshot: the package drops each table once it
// is written. A documents object that cannot be read fails the run.
const docsKey = dump.tables.find((t) => t.name === "documents")?.key;
const docsObject = docsKey ? await env.MEDIA.get(docsKey) : null;
if (!docsObject) throw new Error(`BACKUP_DOCUMENTS_UNREADABLE the dump at ${dump.prefix} has no readable documents object`);
const docs = (JSON.parse(await docsObject.text()) as { rows: DocRow[] }).rows;
// The package dumps what sqlite_master holds; TABLES is what the migrations create
// (test/backup.test.ts). A difference means the live schema is not the one the
// restore procedure knows, so the run is refused rather than marked complete.
const dumped = new Set(dump.tables.map((t) => t.name));
const listed = new Set<string>(TABLES);
const missing = TABLES.filter((t) => !dumped.has(t));
const extra = [...dumped].filter((t) => !listed.has(t));
const tablesMismatch =
missing.length || extra.length ? `tables-mismatch: missing ${missing.join(",") || "none"}; unlisted ${extra.join(",") || "none"}` : null;
return { dump, docs, tablesMismatch };
}
// The preflight, run before anything destructive. The dangerous case is a SELECT that
// succeeds and returns nothing: an empty documents table, or a binding resolved to
// an empty or different database, makes currentKeys empty, which marks every
// markdown object stale and deletes the mirror in one call.
//
// Two probes: a count above zero, and the same pinned FTS probe /health uses, which
// catches a binding pointed at a different database that has rows. Returns the named
// reason to refuse, or null.
async function preflight(env: Env, docs: DocRow[]): Promise<{ fts: string; pruneRefused: string | null }> {
const fts = await probeFts(env.DB);
const pruneRefused = docs.length === 0 ? "documents-empty" : fts === "ok" ? null : `fts-probe-failed: ${fts}`;
return { fts, pruneRefused };
}
// Writes every document to the markdown mirror and deletes mirror objects no document
// backs. Returns how many were deleted.
async function mirrorMarkdown(env: Env, docs: DocRow[]): Promise<number> {
const currentKeys = new Set<string>();
for (let i = 0; i < docs.length; i += PUT_CONCURRENCY) {
await Promise.all(
docs.slice(i, i + PUT_CONCURRENCY).map((doc) => {
const key = `${MARKDOWN_PREFIX}${doc.namespace}/${doc.path}`;
currentKeys.add(key);
return env.MEDIA.put(key, doc.body ?? "", { httpMetadata: { contentType: "text/markdown" } });
})
);
}
const existingMarkdown = await listAllKeys(env.MEDIA, MARKDOWN_PREFIX);
const staleMarkdown = existingMarkdown.filter((key) => !currentKeys.has(key));
if (staleMarkdown.length > 0) await deleteInChunks(env.MEDIA, staleMarkdown);
return staleMarkdown.length;
}
// Deletes JSON dump runs outside the graduated retention (14 daily, 8 weekly and 6
// monthly copies, capsid/rulings/shared-homes-2026-10-06.md), which is d1-dump's default
// policy and is pruneDumps's job, and CSP reports past retention (the sink stopped
// storing them on 2026-10-20; this reaps the last of them). pruneDumps counts runs
// and not objects, deletes a run whole, never prunes the newest complete run, and
// deletes nothing while no complete run exists.
async function pruneR2(env: Env, now: string): Promise<{ kept: number; pruned: number; locked: number; reports: number }> {
const dumps = await pruneDumps(env.MEDIA, { prefix: JSON_PREFIX, now: new Date(now) });
const reportCutoff = cutoffDay(new Date(now), REPORT_RETENTION_DAYS);
const staleReports = (await listAllKeys(env.MEDIA, REPORT_PREFIX)).filter((key) =>
isOlderThan(key, REPORT_PREFIX, reportCutoff)
);
if (staleReports.length > 0) await deleteInChunks(env.MEDIA, staleReports);
return { kept: dumps.kept, pruned: dumps.pruned, locked: dumps.locked, reports: staleReports.length };
}
// Prunes history after the export, so the rows leaving D1 are in today's dump.
// meta.changes is inflated by the FTS5 triggers, so each DELETE is preceded by a
// COUNT over the same predicate in the same transaction.
async function pruneD1(env: Env): Promise<{ versions: number; audit: number }> {
const pruned = await env.DB.batch<{ n: number }>([
env.DB.prepare("SELECT COUNT(*) AS n FROM document_versions WHERE snapshot_at < datetime('now', ?1)").bind(
`-${VERSION_RETENTION_DAYS} days`
),
env.DB.prepare("DELETE FROM document_versions WHERE snapshot_at < datetime('now', ?1)").bind(
`-${VERSION_RETENTION_DAYS} days`
),
env.DB.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE at < datetime('now', ?1)").bind(
`-${AUDIT_RETENTION_DAYS} days`
),
env.DB.prepare("DELETE FROM audit_log WHERE at < datetime('now', ?1)").bind(`-${AUDIT_RETENTION_DAYS} days`),
// The replay cache, appended last so the count/delete pairs above keep the
// positions their counters read. A jti matters only inside the signature window.
env.DB.prepare("DELETE FROM improve_jti WHERE seen_at < datetime('now', '-1 day')"),
]);
return { versions: pruned[0]?.results?.[0]?.n ?? 0, audit: pruned[2]?.results?.[0]?.n ?? 0 };
}
async function exportAndPrune(env: Env, now: string, lease: string): Promise<BackupSummary> {
const { dump, docs, tablesMismatch } = await exportDump(env, now);
const jsonPrefix = dump.prefix;
const jsonKeys = dump.keys;
// A failed preflight refuses everything past this point as a unit: the marker, the
// markdown write, the R2 prunes and the D1 deletes. A run that cannot trust its read
// of documents cannot trust content derived from it. The JSON dumps above are kept:
// an export deletes nothing, and an empty dump is the evidence of the day the store
// looked empty.
const checked = await preflight(env, docs);
const fts = checked.fts;
let pruneRefused = tablesMismatch ?? checked.pruneRefused;
// The lease is get-then-put, so a run that started alongside this one may hold it
// now. Re-read before anything destructive: a run that lost it prunes nothing.
if (pruneRefused === null) {
const holder = await env.APP_KV.get(LEASE_KEY);
if (holder !== lease) pruneRefused = `lease-lost: ${LEASE_KEY} is now ${holder}`;
}
if (pruneRefused !== null) {
logEvent("error", "BACKUP_PREFLIGHT_REFUSED", {
message:
`BACKUP_PREFLIGHT_REFUSED reason=${pruneRefused} documents=${docs.length} fts=${fts} ` +
`dumps_written=${jsonKeys.length} prefix=${jsonPrefix}; nothing was written to or deleted from the mirror`,
});
return {
ran: true,
json_prefix: jsonPrefix,
json_keys: jsonKeys,
documents: docs.length,
markdown_written: 0,
markdown_pruned: 0,
json_backups_kept: 0,
json_backups_pruned: 0,
json_backups_locked: 0,
reports_pruned: 0,
versions_pruned: 0,
audit_pruned: 0,
prune_refused: pruneRefused,
preflight: { documents: docs.length, fts },
};
}
// The completion marker, written after every object and only once the preflight
// passed, so a run that threw or was refused carries none. Only a marked run counts
// toward the retention floor. It lists the objects it vouches for, and the call adds
// its own key to jsonKeys.
await writeCompleteMarker(env.MEDIA, dump);
const markdownPruned = await mirrorMarkdown(env, docs);
const dumps = await pruneR2(env, now);
const history = await pruneD1(env);
// Stamp the last clean success, read by /health. A refused run returned above.
await env.APP_KV.put(BACKUP_LAST_OK_KEY, now);
return {
ran: true,
json_prefix: jsonPrefix,
json_keys: jsonKeys,
documents: docs.length,
markdown_written: docs.length,
markdown_pruned: markdownPruned,
json_backups_kept: dumps.kept,
json_backups_pruned: dumps.pruned,
json_backups_locked: dumps.locked,
reports_pruned: dumps.reports,
versions_pruned: history.versions,
audit_pruned: history.audit,
prune_refused: null,
preflight: { documents: docs.length, fts },
};
}