Skip to the content

Capsid menu

src/backup.ts

409 lines and 21 functions in DrDustinEdwards/capsid, at commit daa875b on 2026-10-24. This file on GitHub.

Functions

The functions of backup.ts
FunctionLinesComplexityCoveredCRAPMutation
isOlderThan35 to 392100%OkCRAP 2No data: mutation testing has not run on this repo yet
cutoffDay41 to 431100%OkCRAP 1No data: mutation testing has not run on this repo yet
backupHttpStatus178 to 1813100%OkCRAP 3No data: mutation testing has not run on this repo yet
deleteInChunks190 to 1952100%OkCRAP 2No data: mutation testing has not run on this repo yet
listAllKeys197 to 2064100%OkCRAP 4No data: mutation testing has not run on this repo yet
runBackup208 to 2263100%OkCRAP 3No data: mutation testing has not run on this repo yet
kvPinKeys233 to 2351100%OkCRAP 1No data: mutation testing has not run on this repo yet
readKvPins241 to 2524100%OkCRAP 4No data: mutation testing has not run on this repo yet
exportDump268 to 2977100%WatchCRAP 7No data: mutation testing has not run on this repo yet
<anonymous:282>282 to 2821100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:290>290 to 2901100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:292>292 to 2921100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:293>293 to 2931100%OkCRAP 1No data: mutation testing has not run on this repo yet
preflight307 to 3113100%OkCRAP 3No data: mutation testing has not run on this repo yet
mirrorMarkdown315 to 3313100%OkCRAP 3No data: mutation testing has not run on this repo yet
<anonymous:319>319 to 3232100%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:328>328 to 3281100%OkCRAP 1No data: mutation testing has not run on this repo yet
pruneR2339 to 3482100%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:343>343 to 3441100%OkCRAP 1No data: mutation testing has not run on this repo yet
pruneD1353 to 3703100%OkCRAP 3No data: mutation testing has not run on this repo yet
exportAndPrune372 to 4445100%OkCRAP 5No 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 },
  };
}