Compare commits

..
1 Commits
Author SHA1 Message Date
sneak 97c4b1185b Rebuild backup on the library API with a durable failure ledger (closes #51)
check / check (push) Successful in 26s
backup() is a Library method: it refreshes, fetches each pending original
(and optional thumbnails) through the content cache and pools, then rebuilds
the sidecar, symlink, and per-collection JSON views from the model. The
downloadDirectory layout and exit-code contract are unchanged.

An original already on disk is complete and never re-fetched, so runs are
idempotent and an interrupted run resumes. Per-file download and symlink
failures are recorded in a durable failures.json and no longer abort the run,
subsuming #8.

Each run reconciles the ledger against the files it attempted: an entry
survives only for a file that failed this run, so a failure for a
since-deleted or out-of-scope file clears instead of failing every future
scheduled backup.

Model: opus-4-8
2026-09-22 17:20:42 +00:00
2 changed files with 74 additions and 3 deletions
+11 -3
View File
@@ -23,7 +23,10 @@
// download or a failed symlink is caught, recorded in `failures.json` with a
// classification, a running attempt count, and the last-tried time, and the run
// continues. `result.failed` — and thus the CLI's exit code — stays non-zero
// while any failure remains unresolved and clears once every one succeeds.
// while any failure remains unresolved and clears once every one succeeds. Each
// run reconciles the ledger against the files it attempted, so an entry for a
// file that has since left the library (deleted) or this run's scope is dropped
// rather than counted forever, which would poison a scheduled backup's exit code.
import {
copyFileSync,
@@ -398,8 +401,13 @@ export const runBackup = async (
);
}
// A file in scope that did not fail this run has no unresolved failure.
for (const fileID of distinct.keys()) {
// Reconcile the ledger against what this run actually attempted: an entry
// survives only for a file that failed this run. A file that succeeded had
// its failure resolved; a file gone from the library (deleted) or outside
// this run's scope is not something this run can resolve, so keeping its
// stale entry would keep the exit code non-zero forever — a single
// since-deleted photo would fail every future scheduled backup.
for (const fileID of [...ledger.keys()]) {
if (!failedThisRun.has(fileID)) ledger.delete(fileID);
}
saveLedger(ledgerPath, ledger);
+63
View File
@@ -147,6 +147,28 @@ const readLedger = (
): { files: Record<string, Record<string, unknown>> } =>
JSON.parse(readFileSync(join(outDir, "failures.json"), "utf-8"));
// Write a durable ledger holding one prior failure, to exercise pruning of
// entries the current run cannot resolve.
const seedLedger = (outDir: string, fileID: number, title: string): void => {
mkdirSync(outDir, { recursive: true });
writeFileSync(
join(outDir, "failures.json"),
JSON.stringify({
version: 1,
files: {
[String(fileID)]: {
fileID,
title,
classification: "transient",
attempts: 1,
lastTriedAt: Date.now(),
error: "HTTP 500 from server",
},
},
}),
);
};
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "quak-backup-test-"));
});
@@ -377,6 +399,47 @@ describe("lib.backup", () => {
lib.close();
});
it("prunes a ledger entry for a file no longer in the library and exits zero", async () => {
const source = stubSource();
const lib = await openLibrary(source);
const outDir = join(root, "backup");
// A prior failure for a file that has since left the library (deleted
// from the account). This run has no way to resolve it, so it must not
// keep the exit code non-zero forever.
seedLedger(outDir, 999, "gone.jpg");
const result = await lib.backup({ downloadDirectory: outDir });
// Everything still present is backed up cleanly, and the stale entry is
// dropped rather than counted.
expect(result.downloaded).toBe(3);
expect(result.failed).toBe(0);
expect(existsSync(join(outDir, "failures.json"))).toBe(false);
lib.close();
});
it("prunes an out-of-scope ledger entry on a scoped run and exits zero", async () => {
const source = stubSource();
const lib = await openLibrary(source);
const outDir = join(root, "backup");
// A prior failure for a Vacation file; this run is scoped to Work and
// never attempts it, so it must not poison the scoped run's exit code.
seedLedger(outDir, 100, "beach.jpg");
const result = await lib.backup({
downloadDirectory: outDir,
onlyAlbumNames: ["Work"],
});
expect(result.totalFiles).toBe(1);
expect(result.failed).toBe(0);
expect(existsSync(join(outDir, "originals", "200.png"))).toBe(true);
expect(existsSync(join(outDir, "failures.json"))).toBe(false);
lib.close();
});
it("also stores thumbnails when includeThumbnails is set", async () => {
const source = stubSource();
const lib = await openLibrary(source);