src/runs.ts
199 lines and 19 functions in DrDustinEdwards/d1-dump, at commit e15f966 on 2026-10-23. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| runsOf | 12 to 22 | 5 | 86% | WatchCRAP 5.1 | Watch68.8% |
| <anonymous:20> | 20 to 20 | 3 | 100% | OkCRAP 3 | Critical0% |
| <anonymous:21> | 21 to 21 | 1 | 100% | OkCRAP 1 | Ok100% |
| latestDump | 31 to 40 | 4 | 100% | OkCRAP 4 | Ok100% |
| backupHealth | 51 to 70 | 8 | 93% | WatchCRAP 8 | Watch76.7% |
| isLockRefusal | 85 to 90 | 5 | 83% | WatchCRAP 5.1 | Critical50% |
| dayOf | 105 to 105 | 1 | 100% | OkCRAP 1 | Ok100% |
| mondayOf | 108 to 111 | 1 | 100% | OkCRAP 1 | Critical50% |
| wantedKeys | 113 to 126 | 4 | 100% | OkCRAP 4 | Ok100% |
| selectStaleRuns | 152 to 187 | 15 | 100% | WatchCRAP 15 | Ok83.8% |
| <anonymous:154> | 154 to 154 | 3 | 100% | OkCRAP 3 | Watch66.7% |
| <anonymous:155> | 155 to 155 | 1 | 100% | OkCRAP 1 | Ok100% |
| <anonymous:162> | 162 to 162 | 1 | 100% | OkCRAP 1 | Ok100% |
| <anonymous:164> | 164 to 164 | 2 | 100% | OkCRAP 2 | Ok80% |
| <anonymous:184> | 184 to 184 | 3 | 100% | OkCRAP 3 | Ok100% |
| <anonymous:186> | 186 to 186 | 1 | 100% | OkCRAP 1 | Ok100% |
| pruneDumps | 195 to 215 | 6 | 100% | WatchCRAP 6 | Ok93.3% |
| <anonymous:202> | 202 to 202 | 1 | 100% | OkCRAP 1 | Ok100% |
| <anonymous:208> | 208 to 208 | 1 | 100% | OkCRAP 1 | Watch60% |
Imports
It imports src/dump.ts, src/r2.ts, src/types.ts. Imported by src/backup.ts, src/drill.ts, src/index.ts.
Source
import { COMPLETE_MARKER, DEFAULT_PREFIX, type CompleteMarker } from "./dump.js";
import { deleteInChunks, listAllKeys } from "./r2.js";
import type { R2Like } from "./types.js";
// A daily dump plus a 2 hour grace, as Capsid's /health uses.
export const DEFAULT_STALE_HOURS = 26;
export const DEFAULT_RETENTION_DAYS = 90;
export const DEFAULT_MIN_KEPT = 14;
// Run id -> its keys, newest run first. A run id is the ISO time with ':' and '.'
// replaced, so it sorts as the time does.
async function runsOf(bucket: R2Like, prefix: string): Promise<Array<{ id: string; keys: string[]; complete: boolean }>> {
const byRun = new Map<string, string[]>();
for (const key of await listAllKeys(bucket, prefix)) {
const id = key.slice(prefix.length).split("/")[0];
if (!id || !key.slice(prefix.length).includes("/")) continue;
byRun.set(id, [...(byRun.get(id) ?? []), key]);
}
return [...byRun.entries()]
.sort(([a], [b]) => (a < b ? 1 : a > b ? -1 : 0))
.map(([id, keys]) => ({ id, keys, complete: keys.includes(`${prefix}${id}/${COMPLETE_MARKER}`) }));
}
export interface LatestDump {
run_id: string;
prefix: string;
exported_at: string;
}
// The newest complete dump, or null when there is none.
export async function latestDump(bucket: R2Like, options: { prefix?: string } = {}): Promise<LatestDump | null> {
const prefix = options.prefix ?? DEFAULT_PREFIX;
for (const run of await runsOf(bucket, prefix)) {
const obj = await bucket.get(`${prefix}${run.id}/${COMPLETE_MARKER}`);
if (!obj) continue;
const marker = JSON.parse(await obj.text()) as CompleteMarker;
return { run_id: run.id, prefix: `${prefix}${run.id}/`, exported_at: marker.exported_at };
}
return null;
}
// The shape Capsid's /health reports under `backup`, which the console's operations
// page reads as backup.age_hours. A failure to read is a warning, never an exception,
// so a health route that calls this still answers.
export interface BackupHealth {
last_ok: string | null;
age_hours: number | null;
warning?: string;
}
export async function backupHealth(
bucket: R2Like,
options: { prefix?: string; now?: Date; staleHours?: number } = {}
): Promise<BackupHealth> {
const staleHours = options.staleHours ?? DEFAULT_STALE_HOURS;
let latest: LatestDump | null;
try {
latest = await latestDump(bucket, { prefix: options.prefix });
} catch (err) {
return { last_ok: null, age_hours: null, warning: `backup freshness unreadable: ${(err instanceof Error ? err.message : String(err)).slice(0, 80)}` };
}
if (!latest) return { last_ok: null, age_hours: null, warning: "no complete dump found" };
const stamped = Date.parse(latest.exported_at);
if (Number.isNaN(stamped)) return { last_ok: latest.exported_at, age_hours: null, warning: `the newest dump's time is not parseable: ${String(latest.exported_at).slice(0, 40)}` };
const ageMs = (options.now ?? new Date()).getTime() - stamped;
const ageHours = Math.round((ageMs / 3_600_000) * 10) / 10;
const result: BackupHealth = { last_ok: latest.exported_at, age_hours: ageHours };
if (ageMs > staleHours * 3_600_000) result.warning = `last complete dump was ${ageHours}h ago, over the ${staleHours}h threshold`;
return result;
}
// kept: runs retention keeps. pruned: stale runs deleted. locked: stale runs a bucket lock
// rule refused to delete, left in place for a later prune; kept + pruned + locked is
// every run under the prefix.
export interface PruneResult {
kept: number;
pruned: number;
locked: number;
}
// R2 refuses a delete of an object a bucket lock rule still holds with error 10069,
// ObjectLockedByBucketPolicy (developers.cloudflare.com/r2/api/error-codes/). The Workers
// binding throws an R2Error carrying it as `code` and at the end of its message.
const R2_OBJECT_LOCKED = 10069;
function isLockRefusal(err: unknown): boolean {
if (typeof err !== "object" || err === null) return false;
if ((err as { code?: unknown }).code === R2_OBJECT_LOCKED) return true;
const message = (err as { message?: unknown }).message;
return typeof message === "string" && /\(10069\)|ObjectLockedByBucketPolicy/.test(message);
}
// Graduated retention, the default (capsid/rulings/shared-homes-2026-10-06.md): the
// newest complete run of each of the last `daily` UTC days, of each of the last
// `weekly` ISO weeks (Monday to Sunday) and of each of the last `monthly` calendar
// months, counting the current one in each case. The three sets overlap, so the default
// 14 + 8 + 6 keeps about 25 runs from a daily history and reaches back six months.
export interface RetentionPolicy {
daily: number;
weekly: number;
monthly: number;
}
export const DEFAULT_RETENTION: RetentionPolicy = { daily: 14, weekly: 8, monthly: 6 };
const DAY_MS = 86_400_000;
const dayOf = (ms: number): string => new Date(ms).toISOString().slice(0, 10);
// The Monday of the ISO week a UTC day falls in, as a day string. Keyed this way the
// year boundary needs no week-number arithmetic.
function mondayOf(day: string): string {
const ms = Date.parse(`${day}T00:00:00.000Z`);
return dayOf(ms - ((new Date(ms).getUTCDay() + 6) % 7) * DAY_MS);
}
function wantedKeys(now: Date, policy: RetentionPolicy): { days: Set<string>; weeks: Set<string>; months: Set<string> } {
const today = Date.parse(`${dayOf(now.getTime())}T00:00:00.000Z`);
const days = new Set<string>();
for (let k = 0; k < policy.daily; k++) days.add(dayOf(today - k * DAY_MS));
const thisMonday = Date.parse(`${mondayOf(dayOf(today))}T00:00:00.000Z`);
const weeks = new Set<string>();
for (let k = 0; k < policy.weekly; k++) weeks.add(dayOf(thisMonday - k * 7 * DAY_MS));
const months = new Set<string>();
const here = new Date(today);
for (let k = 0; k < policy.monthly; k++) {
months.add(new Date(Date.UTC(here.getUTCFullYear(), here.getUTCMonth() - k, 1)).toISOString().slice(0, 7));
}
return { days, weeks, months };
}
export interface RunRef {
id: string;
complete: boolean;
}
export interface SelectOptions {
now?: Date;
policy?: RetentionPolicy;
retentionDays?: number;
minKept?: number;
}
// Which runs retention deletes, from a list of run ids and the time. Pure: no bucket, no
// disk, so a caller that holds its runs some other way (a directory of run folders) uses
// the same rule pruneDumps does. With no retentionDays or minKept it applies the
// graduated policy (options.policy, DEFAULT_RETENTION). Passing retentionDays or minKept
// asks for the older flat rule instead: a run older than retentionDays is deleted whole,
// except that the minKept newest COMPLETE runs are kept whatever their age.
//
// Either way: the newest complete run is never pruned; an incomplete run holds no slot
// and is kept only while it is inside the daily window (flat: inside retentionDays), so
// it stays as evidence for a while and then ages out; and nothing is selected while no
// complete run exists, since that is what an emptied or misbound bucket looks like.
// Returns the ids to delete, newest first; the input order does not matter.
export function selectStaleRuns(runs: RunRef[], options: SelectOptions = {}): string[] {
const now = options.now ?? new Date();
const sorted = [...runs].sort((x, y) => (x.id < y.id ? 1 : x.id > y.id ? -1 : 0));
const complete = sorted.filter((r) => r.complete);
if (complete.length === 0) return [];
let stale: RunRef[];
if (options.retentionDays !== undefined || options.minKept !== undefined) {
const retentionDays = options.retentionDays ?? DEFAULT_RETENTION_DAYS;
const minKept = options.minKept ?? DEFAULT_MIN_KEPT;
const floor = new Set(complete.slice(0, minKept).map((r) => r.id));
const cutoffDay = new Date(now.getTime() - retentionDays * DAY_MS).toISOString().slice(0, 10);
stale = sorted.filter((r) => !floor.has(r.id) && r.id.slice(0, 10) < cutoffDay);
} else {
const policy = options.policy ?? DEFAULT_RETENTION;
const { days, weeks, months } = wantedKeys(now, policy);
const keep = new Set<string>([complete[0].id]);
// `complete` is newest first, so the first run seen for a key is the newest of it.
const seenDay = new Set<string>();
const seenWeek = new Set<string>();
const seenMonth = new Set<string>();
for (const r of complete) {
const day = r.id.slice(0, 10);
const week = mondayOf(day);
const month = day.slice(0, 7);
if (days.has(day) && !seenDay.has(day)) keep.add(r.id);
if (weeks.has(week) && !seenWeek.has(week)) keep.add(r.id);
if (months.has(month) && !seenMonth.has(month)) keep.add(r.id);
seenDay.add(day);
seenWeek.add(week);
seenMonth.add(month);
}
stale = sorted.filter((r) => !keep.has(r.id) && !(!r.complete && days.has(r.id.slice(0, 10))));
}
return stale.map((r) => r.id);
}
// Prunes dumps in a bucket: selectStaleRuns over the runs under the prefix, each stale run
// deleted whole. A run is deleted marker first, in its own call, then the rest, so a run
// that is only partly deleted is never left looking complete. A run whose delete a bucket
// lock refuses is skipped and counted in `locked`, and the prune goes on to the next run;
// it is selected again by a later prune and goes once the lock has aged off it. Any other
// delete failure throws.
export async function pruneDumps(
bucket: R2Like,
options: { prefix?: string; now?: Date; policy?: RetentionPolicy; retentionDays?: number; minKept?: number } = {}
): Promise<PruneResult> {
const prefix = options.prefix ?? DEFAULT_PREFIX;
const runs = await runsOf(bucket, prefix);
const gone = new Set(selectStaleRuns(runs, options));
const stale = runs.filter((r) => gone.has(r.id));
let locked = 0;
for (const r of stale) {
const marker = `${prefix}${r.id}/${COMPLETE_MARKER}`;
try {
if (r.complete) await bucket.delete(marker);
await deleteInChunks(bucket, r.keys.filter((k) => k !== marker));
} catch (err) {
if (!isLockRefusal(err)) throw err;
locked++;
}
}
return { kept: runs.length - stale.length, pruned: stale.length - locked, locked };
}