From 2e885d54e5de483aa951d98acf0028aaeb28a7a4 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 23 Sep 2026 05:49:41 +0000 Subject: [PATCH] Refresh before backup-metadata and the thumbnail helpers answer (closes #100) backup-metadata, helper list-missing-thumbnails and helper fix-missing-thumbnails now await lib.fresh() before reading the library, as collections, files, get and get-thumb already do. A file added since the cache was written is included, and a failed refresh is thrown, so the CLI prints one line and exits 1 instead of answering from a stale or empty cache. The README lists them among the commands that refresh first. Model: opus-5-5 --- README.md | 11 +++-- TODO.md | 7 +++ src/cli-commands.ts | 9 ++++ src/metadata-backup.ts | 4 +- test/cli/commands.test.ts | 100 +++++++++++++++++++++++++++++++++++--- 5 files changed, 117 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index fbd9336..73db1a7 100644 --- a/README.md +++ b/README.md @@ -449,10 +449,13 @@ quak helper fix-missing-thumbnails [--file ids] generate + upload missing thumbn ``` Every command runs on the same cache-backed library. The read commands — -`collections`, `files`, `get`, and `get-thumb` — force a fresh server round-trip -before they answer, so they report current account state rather than whatever -the cache last held. `--cache-dir` overrides where the cache lives; without it -each account gets its own directory under the per-user cache path. +`collections`, `files`, `get`, `get-thumb`, `backup-metadata`, +`helper list-missing-thumbnails` and `helper fix-missing-thumbnails` — force a +fresh server round-trip before they answer, so they report current account state +rather than whatever the cache last held. If that round-trip fails, the command +prints the error on one line and exits 1. `--cache-dir` overrides where the +cache lives; without it each account gets its own directory under the per-user +cache path. `get` and `get-thumb` resolve the file by ID directly, so `--collection` is accepted for backward compatibility but ignored. `backup-metadata --exif` (alias diff --git a/TODO.md b/TODO.md index f1b3a4c..111fcf0 100644 --- a/TODO.md +++ b/TODO.md @@ -18,6 +18,13 @@ Tag v1.0.0. # Completed Steps +- 2026-09-23: `backup-metadata`, `helper list-missing-thumbnails` and + `helper fix-missing-thumbnails` refresh before they answer (issue 100). Each + awaits `lib.fresh()` before reading, so a file added since the cache was + written is included, and a failed refresh prints one line and exits 1 instead + of answering from a stale or empty cache. The README lists them among the + commands that refresh first. + - 2026-09-23: `quak backup` waits for the server refresh and fails when it fails (issue 99). `lib.backup()` joins a refresh already running or starts one, as `fresh()` does, and rejects before touching any file when it fails, leaving diff --git a/src/cli-commands.ts b/src/cli-commands.ts index 67a40d0..a78526f 100644 --- a/src/cli-commands.ts +++ b/src/cli-commands.ts @@ -357,6 +357,9 @@ export const backupMetadataCommand = async ( if (!client) return 1; const lib = await openReadLibrary(ctx, client); try { + // Refresh first so the dump holds current account state, not what the + // cache last held; a failed refresh throws. + await lib.fresh(); const { failedMLBatches } = await runMetadataBackup(lib, client, dir, { exif: opts.exif || opts.all, onProgress: (msg) => ctx.stderr.write(msg + "\n"), @@ -423,6 +426,9 @@ export const listMissingThumbnailsCommand = async ( if (!client) return 1; const lib = await openReadLibrary(ctx, client); try { + // Refresh first so files added since the cache was written are + // checked; a failed refresh throws. + await lib.fresh(); const missing = await listMissingThumbnails(lib, client, (msg) => { if (!opts.json) ctx.stderr.write(msg + "\n"); }); @@ -458,6 +464,9 @@ export const fixMissingThumbnailsCommand = async ( if (!client) return 1; const lib = await openReadLibrary(ctx, client); try { + // Refresh first so files added since the cache was written are found; + // a failed refresh throws. + await lib.fresh(); let fileIDs: number[]; if (opts.file && opts.file.length > 0) { fileIDs = opts.file.map(Number).filter(Number.isFinite); diff --git a/src/metadata-backup.ts b/src/metadata-backup.ts index 12435cb..6021f87 100644 --- a/src/metadata-backup.ts +++ b/src/metadata-backup.ts @@ -140,8 +140,8 @@ const extractExif = async ( // Dump every decrypted metadata layer the account holds into a directory tree // of plain JSON: account, per-collection, and per-file records including the // private and public magic metadata and (by default) the ML data. Collections -// and files are enumerated from the library's cache rather than a fresh server -// scan. Returns how many ML data requests failed; their files are still +// and files are enumerated from the library's cache, which the caller refreshes +// first. Returns how many ML data requests failed; their files are still // written, with `mlDataError` in place of `mlData`. export const runMetadataBackup = async ( lib: Library, diff --git a/test/cli/commands.test.ts b/test/cli/commands.test.ts index 0f012c4..3e4a1ba 100644 --- a/test/cli/commands.test.ts +++ b/test/cli/commands.test.ts @@ -33,7 +33,9 @@ import { getCommand, getThumbCommand, backupCommand, + backupMetadataCommand, listMissingThumbnailsCommand, + fixMissingThumbnailsCommand, } from "../../src/cli-commands.js"; import { run } from "../../src/cli-run.js"; import { loadSession } from "../../src/cli-session.js"; @@ -84,8 +86,26 @@ const FILES: Record = { // An original is 7 bytes and a thumbnail 3. `failID` makes that file's // original fail; `emptyThumbID` makes the server report that file's -// thumbnail as empty. -const fakeClient = (opts: { failID?: number; emptyThumbID?: number } = {}) => { +// thumbnail as empty. `withNewFile` adds new.jpg (102) to Vacation, advancing +// the collection's updationTime as the server does, and `refreshError` makes +// listing collections fail with that message. +const fakeClient = ( + opts: { + failID?: number; + emptyThumbID?: number; + withNewFile?: boolean; + refreshError?: string; + } = {}, +) => { + const collections = opts.withNewFile + ? [{ ...COLLECTIONS[0], updationTime: 2 }, COLLECTIONS[1]] + : COLLECTIONS; + const files = opts.withNewFile + ? { + ...FILES, + 1: [...FILES[1], { ...file(102, 1, "new.jpg"), updationTime: 2 }], + } + : FILES; const source: ContentSource = { original: async ({ file: f, destination }) => { if (f.id === opts.failID) throw new Error("HTTP 500 from server"); @@ -99,18 +119,19 @@ const fakeClient = (opts: { failID?: number; emptyThumbID?: number } = {}) => { }; const fake = { whoami: () => ({ email: "cli@example.com", userID: USER_ID }), - collectionsSince: async () => ({ - collections: COLLECTIONS, - deleted: [], - cursor: 1, - }), + collectionsSince: async () => { + if (opts.refreshError) throw new Error(opts.refreshError); + return { collections, deleted: [], cursor: 1 }; + }, filesSince: async (args: { collectionID: number }) => ({ - files: FILES[args.collectionID] ?? [], + files: files[args.collectionID] ?? [], deleted: [], cursor: 1, }), contentSource: () => source, getApiClient: () => ({ + // The ML data request of `backup-metadata`: no file has any. + postJSON: async () => ({ data: [] }), getThumbnailStream: async (fileID: number) => new ReadableStream({ start(controller) { @@ -492,3 +513,66 @@ describe("helper list-missing-thumbnails", () => { expect(stderr.text).toBe(""); }); }); + +// Each test first runs `collections` so the cache holds the account as it was, +// then changes the server under it. +describe("backup-metadata and the thumbnail helpers refresh first", () => { + beforeEach(async () => { + expect(await collectionsCommand(context(), {})).toBe(0); + stdout.text = ""; + stderr.text = ""; + }); + + it("backup-metadata writes a file added since the cache was written", async () => { + const ctx = context(fakeClient({ withNewFile: true })); + const dir = join(root, "dump"); + expect(await backupMetadataCommand(ctx, dir, {})).toBe(0); + expect( + existsSync(join(dir, "collections", "1-Vacation", "102.json")), + ).toBe(true); + }); + + it("list-missing-thumbnails checks a file added since the cache was written", async () => { + const ctx = context( + fakeClient({ withNewFile: true, emptyThumbID: 102 }), + ); + expect(await listMissingThumbnailsCommand(ctx, {})).toBe(0); + expect(stdout.text).toBe( + "102\tnew.jpg\tVacation\tempty thumbnail (0 bytes)\n", + ); + }); + + it("fix-missing-thumbnails finds a file added since the cache was written", async () => { + const ctx = context(fakeClient({ withNewFile: true })); + expect( + await fixMissingThumbnailsCommand(ctx, { + file: ["102"], + json: true, + }), + ).toBe(0); + // Found, then skipped because the server records no thumbnail size + // for it; a file missing from the cache would fail as not found. + expect(JSON.parse(stdout.text)).toMatchObject([ + { fileID: 102, title: "new.jpg", status: "skipped" }, + ]); + }); + + // `run` in `cli-run.ts` prints a thrown error as one line and exits 1. + it("all three throw when the refresh fails", async () => { + const ctx = context( + fakeClient({ refreshError: "HTTP 503 from server" }), + ); + const dir = join(root, "dump"); + await expect(backupMetadataCommand(ctx, dir, {})).rejects.toThrow( + "HTTP 503 from server", + ); + expect(existsSync(dir)).toBe(false); + await expect(listMissingThumbnailsCommand(ctx, {})).rejects.toThrow( + "HTTP 503 from server", + ); + await expect( + fixMissingThumbnailsCommand(ctx, { file: ["100"] }), + ).rejects.toThrow("HTTP 503 from server"); + expect(stdout.text).toBe(""); + }); +}); -- 2.54.0