diff --git a/src/index.ts b/src/index.ts index f9e34c2..06c4bca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,6 +33,15 @@ export { requestEmailOTP, submitEmailOTP, } from "./auth/login.js"; +export { + Library, + DEFAULT_REFRESH_INTERVAL_SECONDS, + type LibraryClient, + type LibraryOptions, + type LibraryStatus, + type RefreshEvent, + type RefreshProgressCallback, +} from "./library/index.js"; export { decryptCollection, decryptFile } from "./model/index.js"; export { downloadFile, downloadThumbnail } from "./download/index.js"; export type { diff --git a/src/library/index.ts b/src/library/index.ts new file mode 100644 index 0000000..ff349e9 --- /dev/null +++ b/src/library/index.ts @@ -0,0 +1,316 @@ +// The library surface over the local cache. +// +// `Library.open()` loads the on-disk metadata store (issue #41), then starts +// the refresh loop. When the cache loaded empty it awaits the first refresh, +// so the library never opens onto an empty store it could have filled; when an +// existing copy loaded, that first refresh runs in the background and `open()` +// returns as soon as the cached data is ready to serve — a slow or unreachable +// server no longer stalls opening. A background timer then refreshes every +// `refreshIntervalSeconds`. Every read is answered from RAM — no read touches +// the network. There is deliberately no `sync()`, no `refresh()`, no +// `serverReachable` flag, and no "before each read" mode (design #36): the +// only ways state changes are the refreshes above. +// +// A refresh stages all of its network work first and only mutates the store +// once every fetch has succeeded. A refresh that fails partway therefore never +// becomes visible to reads: the last good snapshot stays in place, and the +// failure surfaces through `onProgress` and `status()` instead. A commit that +// mutates RAM but then fails to persist keeps `status().lastError` set and the +// store marked unsaved until a later save actually lands, so a stuck disk is +// never masked by a subsequent empty refresh. + +import { join } from "node:path"; +import envPaths from "env-paths"; + +import { MetadataStore } from "./store.js"; +import type { CollectionsPage, FilesPage } from "../client.js"; +import type { Collection, EnteFile } from "../model/types.js"; + +export const DEFAULT_REFRESH_INTERVAL_SECONDS = 3; + +// The slice of `Client` the library depends on. Narrowing to an interface lets +// tests drive a mock with no crypto or network; the real `Client` satisfies it +// structurally. +export interface LibraryClient { + whoami(): { email: string; userID: number }; + collectionsSince(args: { sinceTime: number }): Promise; + filesSince(args: { + collectionID: number; + collectionKey: Uint8Array; + sinceTime: number; + }): Promise; +} + +// A single refresh cycle's progress. "started" fires before the network work, +// then exactly one of "done" or "failed"; "failed" carries the error message. +export interface RefreshEvent { + operation: "refresh"; + status: "started" | "done" | "failed"; + error?: string; +} + +export type RefreshProgressCallback = (event: RefreshEvent) => void; + +export interface LibraryOptions { + client: LibraryClient; + // Where `metadata.json` lives. Defaults to the env-paths cache directory + // plus the user id, so each account has its own cache. + cacheDirectory?: string; + // Persistent backup destination for later phases (backup, thumbnails); the + // refresh loop does not use it. + downloadDirectory?: string; + refreshIntervalSeconds?: number; + onProgress?: RefreshProgressCallback; +} + +export interface LibraryStatus { + userID: number; + collections: number; + files: number; + // Wall-clock ms of the last refresh that succeeded, or undefined if none + // has yet. + lastRefreshAt?: number; + // The message from the most recent refresh, set only while that refresh + // failed; cleared by the next success. + lastError?: string; + closed: boolean; +} + +export class Library { + readonly cacheDirectory: string; + readonly downloadDirectory?: string; + + private readonly client: LibraryClient; + private readonly store: MetadataStore; + private readonly userID: number; + private readonly intervalMs: number; + private readonly onProgress?: RefreshProgressCallback; + + private timer?: ReturnType; + private refreshing = false; + private closed = false; + private lastRefreshAt?: number; + private lastError?: string; + // RAM holds changes disk has not yet accepted (an earlier save failed). + // Cleared only when a save actually succeeds; keeps the store trying to + // persist and the failure visible in `status()` until then. + private unsaved = false; + + private constructor(args: { + client: LibraryClient; + store: MetadataStore; + userID: number; + cacheDirectory: string; + downloadDirectory?: string; + intervalMs: number; + onProgress?: RefreshProgressCallback; + }) { + this.client = args.client; + this.store = args.store; + this.userID = args.userID; + this.cacheDirectory = args.cacheDirectory; + this.downloadDirectory = args.downloadDirectory; + this.intervalMs = args.intervalMs; + this.onProgress = args.onProgress; + } + + // Load the cache and start the refresh loop. With an empty cache the first + // refresh is awaited, so `open()` resolves onto populated data whenever the + // server is reachable; that awaited refresh may still fail, and the library + // then opens empty with the failure recorded in `status()`. With an + // existing cache the first refresh runs in the background and `open()` + // returns as soon as the cached data is ready — an unreachable server does + // not block opening. + static async open(opts: LibraryOptions): Promise { + const { userID } = opts.client.whoami(); + const cacheDirectory = + opts.cacheDirectory ?? + join(envPaths("quak", { suffix: "" }).cache, String(userID)); + const store = await MetadataStore.load( + join(cacheDirectory, "metadata.json"), + ); + const intervalMs = + (opts.refreshIntervalSeconds ?? DEFAULT_REFRESH_INTERVAL_SECONDS) * + 1000; + + const lib = new Library({ + client: opts.client, + store, + userID, + cacheDirectory, + downloadDirectory: opts.downloadDirectory, + intervalMs, + onProgress: opts.onProgress, + }); + + if (store.loadedFromDisk) { + // An existing copy already answers reads; refresh in the background + // and start the interval once that first cycle settles. + void lib.runRefresh().then(() => lib.scheduleNext()); + } else { + // Nothing was cached: wait for the first refresh to fill the store + // (or fail) rather than resolve onto an empty library. + await lib.runRefresh(); + lib.scheduleNext(); + } + return lib; + } + + listCollections(): Collection[] { + return this.store.listCollections(); + } + + getCollection(id: number): Collection | undefined { + return this.store.getCollection(id); + } + + listFiles(collectionID: number): EnteFile[] { + return this.store.listFiles(collectionID); + } + + getFile(collectionID: number, fileID: number): EnteFile | undefined { + return this.store.getFile(collectionID, fileID); + } + + status(): LibraryStatus { + let files = 0; + const collections = this.store.listCollections(); + for (const c of collections) { + files += this.store.listFiles(c.id).length; + } + return { + userID: this.store.userID, + collections: collections.length, + files, + lastRefreshAt: this.lastRefreshAt, + lastError: this.lastError, + closed: this.closed, + }; + } + + // Stop the background timer. Idempotent. An in-flight refresh is left to + // finish; it will not schedule another cycle once closed. + close(): void { + this.closed = true; + if (this.timer !== undefined) { + clearTimeout(this.timer); + this.timer = undefined; + } + } + + private scheduleNext(): void { + if (this.closed) return; + this.timer = setTimeout(() => { + void this.runRefresh().then(() => this.scheduleNext()); + }, this.intervalMs); + // Do not keep the process alive for the sake of the timer. + this.timer.unref?.(); + } + + // One refresh cycle, guarded so a failure never escapes and overlapping + // cycles never run. Errors are reported, not thrown. + private async runRefresh(): Promise { + if (this.closed || this.refreshing) return; + this.refreshing = true; + this.emit({ operation: "refresh", status: "started" }); + try { + await this.refreshOnce(); + this.lastRefreshAt = Date.now(); + this.lastError = undefined; + this.emit({ operation: "refresh", status: "done" }); + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + this.lastError = error; + this.emit({ operation: "refresh", status: "failed", error }); + } finally { + this.refreshing = false; + } + } + + // Fetch every change since the stored cursor, then commit. All network + // reads happen before any store mutation, so a fetch that throws leaves the + // store untouched and the previous snapshot intact. + private async refreshOnce(): Promise { + const page = await this.client.collectionsSince({ + sinceTime: this.store.collectionsSinceTime, + }); + + // Stage per-collection file diffs. A collection's files are + // re-enumerated only when its updationTime has advanced past the cached + // copy; an unchanged album's file list cannot have changed. New + // collections enumerate from the beginning of time. + const filePages: { collectionID: number; page: FilesPage }[] = []; + for (const collection of page.collections) { + const known = this.store.getCollection(collection.id); + if (known && collection.updationTime <= known.updationTime) + continue; + const filePage = await this.client.filesSince({ + collectionID: collection.id, + collectionKey: collection.key, + sinceTime: known ? known.updationTime : 0, + }); + filePages.push({ collectionID: collection.id, page: filePage }); + } + + // Network work done; commit to the store and persist only if something + // actually changed. + let changed = false; + + if (this.store.userID !== this.userID) { + this.store.userID = this.userID; + changed = true; + } + + for (const id of page.deleted) { + if (this.store.getCollection(id)) { + this.store.deleteCollection(id); + changed = true; + } + } + for (const collection of page.collections) { + this.store.putCollection(collection); + changed = true; + } + + for (const { collectionID, page: filePage } of filePages) { + for (const id of filePage.deleted) { + if (this.store.getFile(collectionID, id)) { + this.store.deleteFile(collectionID, id); + changed = true; + } + } + for (const f of filePage.files) { + this.store.putFile(f); + changed = true; + } + } + + if (page.cursor !== this.store.collectionsSinceTime) { + this.store.collectionsSinceTime = page.cursor; + changed = true; + } + + if (changed) this.unsaved = true; + + // Persist whenever RAM holds changes disk has not accepted — including + // changes an earlier cycle staged whose save failed. `unsaved` clears + // only once a save lands, so a save failure both stays visible through + // `status().lastError` (the throw below records it) and keeps being + // retried, instead of a later empty refresh silently clearing it while + // the on-disk cache is still behind RAM. + if (this.unsaved) { + await this.store.save(); + this.unsaved = false; + } + } + + private emit(event: RefreshEvent): void { + if (!this.onProgress) return; + // A misbehaving callback must not break the refresh loop. + try { + this.onProgress(event); + } catch { + // ignore + } + } +} diff --git a/src/library/store.ts b/src/library/store.ts index 3174c1f..cfacb43 100644 --- a/src/library/store.ts +++ b/src/library/store.ts @@ -58,6 +58,12 @@ export class MetadataStore { userID = 0; collectionsSinceTime: Microseconds = 0; + // True when `load` populated this store from a valid existing file; false + // on a first run or a missing/corrupt/wrong-version file that loaded empty. + // `Library.open` reads it to decide whether the first refresh may run in + // the background (an existing copy already serves reads) or must be awaited. + loadedFromDisk = false; + private readonly collections = new Map(); private readonly files = new Map(); @@ -83,6 +89,7 @@ export class MetadataStore { if (parsed.schemaVersion !== METADATA_SCHEMA_VERSION) { return store; } + store.loadedFromDisk = true; store.userID = parsed.userID ?? 0; store.collectionsSinceTime = parsed.collectionsSinceTime ?? 0; for (const stored of parsed.collections ?? []) { diff --git a/test/library/library.test.ts b/test/library/library.test.ts new file mode 100644 index 0000000..fe951c3 --- /dev/null +++ b/test/library/library.test.ts @@ -0,0 +1,669 @@ +/** + * Tests for `Library.open()` and its transparent background refresh loop. + * + * The library keeps the account's server state in a `MetadataStore` (issue + * #41) and pulls changes with the resumable, tombstone-aware enumerators on + * `Client` (issue #38: `collectionsSince` / `filesSince`). `open()` loads the + * cache, does one refresh, then refreshes again every `refreshIntervalSeconds` + * on a background timer. The design (#36) forbids an exposed `sync()`, a + * `serverReachable` flag, a `lib.refresh()` method, and a "before each read" + * mode. The contracts exercised here: + * + * 1. Reads are answered from RAM. A read never calls the client. + * 2. `open()` does an initial refresh, then the interval keeps refreshing; + * each refresh resumes from the stored cursor and applies diffs + tombstones. + * 3. The cache is rewritten only when a refresh actually changes something. + * 4. A failed refresh is invisible to reads: the last good data stays, the + * failure surfaces via `onProgress` ("failed") and `status()`, and a later + * success clears the error. `open()` itself resolves even when the first + * refresh fails (offline start from cache). + * 5. `close()` stops the timer and is idempotent. + * 6. `cacheDirectory` defaults to the env-paths cache dir plus the user id. + * 7. `open()` branches on the cache: an empty cache awaits the first refresh + * (it has nothing to serve yet); an existing cache serves its copy at once + * and refreshes in the background, so a slow or dead server never stalls + * opening. + * 8. A save failure that leaves RAM ahead of disk keeps `status().lastError` + * set and keeps retrying the write; a later empty refresh does not clear it. + * + * The client is a mock: no crypto, no network. It serves scripted pages and + * records the `sinceTime` each call carried so cursor threading is provable. + * + * On an empty cache `open()` awaits the initial refresh (including its cache + * write), so state right after `open()` is deterministic; the tests that + * inspect post-`open()` state seed no cache and rely on that. Tests for an + * existing-cache open seed a store first and prove `open()` returns without + * waiting for the network. The interval tests then use real timers with a + * short interval and `vi.waitFor`: a fake clock cannot settle the real + * fsync-and-rename cache write, and empty diffs never write, so the eventual + * state is stable to poll for. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import envPaths from "env-paths"; + +import { Library, type RefreshEvent } from "../../src/library/index.js"; +import { MetadataStore } from "../../src/library/store.js"; +import type { CollectionsPage, FilesPage } from "../../src/client.js"; +import type { Collection, EnteFile } from "../../src/model/types.js"; + +const USER_ID = 42; + +// Short enough that a couple of ticks pass within a test, long enough not to +// spin; interval tests poll for the eventual state rather than counting ticks. +const FAST_INTERVAL = 0.02; + +const collection = ( + id: number, + updationTime: number, + name = `album-${id}`, +): Collection => ({ + id, + ownerID: USER_ID, + key: new Uint8Array([id & 0xff]), + name, + type: "album", + updationTime, + isShared: false, +}); + +const file = ( + id: number, + collectionID: number, + updationTime: number, +): EnteFile => ({ + id, + collectionID, + ownerID: USER_ID, + key: new Uint8Array([id & 0xff]), + metadata: { + title: `file-${id}.jpg`, + fileType: "image", + creationTime: updationTime, + modificationTime: updationTime, + }, + file: { decryptionHeader: "aGVhZGVy" }, + thumbnail: { decryptionHeader: "dGh1bWI=" }, + updationTime, +}); + +/** + * A mock `Client`. `collectionsSince` shifts one page off `collectionsQueue` + * per call (an empty diff that advances nothing when the queue runs dry); + * `filesSince` shifts from a per-collection queue. `failCollections` makes the + * next and all further collection fetches throw, to simulate an offline server. + */ +class MockClient { + userID = USER_ID; + failCollections = false; + collectionsQueue: CollectionsPage[] = []; + filesByCollection = new Map(); + + collectionsSinceTimes: number[] = []; + filesCalls: { collectionID: number; sinceTime: number }[] = []; + + whoami(): { email: string; userID: number } { + return { email: "user@example.com", userID: this.userID }; + } + + async collectionsSince(args: { + sinceTime: number; + }): Promise { + this.collectionsSinceTimes.push(args.sinceTime); + if (this.failCollections) throw new Error("network down"); + return ( + this.collectionsQueue.shift() ?? { + collections: [], + deleted: [], + cursor: args.sinceTime, + } + ); + } + + async filesSince(args: { + collectionID: number; + collectionKey: Uint8Array; + sinceTime: number; + }): Promise { + this.filesCalls.push({ + collectionID: args.collectionID, + sinceTime: args.sinceTime, + }); + const queue = this.filesByCollection.get(args.collectionID); + return ( + queue?.shift() ?? { + files: [], + deleted: [], + cursor: args.sinceTime, + } + ); + } + + filesFor(collectionID: number, ...pages: FilesPage[]): void { + this.filesByCollection.set(collectionID, pages); + } +} + +describe("Library.open and background refresh", () => { + let dir: string; + let cacheDirectory: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "quak-library-")); + cacheDirectory = join(dir, "cache"); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("does an initial refresh and answers reads from the cache", async () => { + const client = new MockClient(); + client.collectionsQueue.push({ + collections: [collection(1, 100)], + deleted: [], + cursor: 100, + }); + client.filesFor(1, { + files: [file(1001, 1, 90), file(1002, 1, 95)], + deleted: [], + cursor: 95, + }); + + const lib = await Library.open({ client, cacheDirectory }); + try { + expect(lib.listCollections().map((c) => c.id)).toEqual([1]); + expect(lib.listFiles(1).map((f) => f.id)).toEqual([1001, 1002]); + expect(lib.getFile(1, 1001)?.metadata.title).toBe("file-1001.jpg"); + + const status = lib.status(); + expect(status.userID).toBe(USER_ID); + expect(status.collections).toBe(1); + expect(status.files).toBe(2); + expect(status.lastRefreshAt).toBeGreaterThan(0); + expect(status.lastError).toBeUndefined(); + + // The initial refresh persisted the cache to disk. + const reloaded = await MetadataStore.load( + join(cacheDirectory, "metadata.json"), + ); + expect(reloaded.getFile(1, 1001)?.id).toBe(1001); + expect(reloaded.collectionsSinceTime).toBe(100); + } finally { + lib.close(); + } + }); + + it("reads never call the client", async () => { + const client = new MockClient(); + client.collectionsQueue.push({ + collections: [collection(1, 100)], + deleted: [], + cursor: 100, + }); + client.filesFor(1, { + files: [file(1001, 1, 90)], + deleted: [], + cursor: 90, + }); + + const lib = await Library.open({ client, cacheDirectory }); + try { + const collectionCalls = client.collectionsSinceTimes.length; + const fileCalls = client.filesCalls.length; + + lib.listCollections(); + lib.getCollection(1); + lib.listFiles(1); + lib.getFile(1, 1001); + lib.status(); + + expect(client.collectionsSinceTimes.length).toBe(collectionCalls); + expect(client.filesCalls.length).toBe(fileCalls); + } finally { + lib.close(); + } + }); + + it("resumes each refresh from the stored cursor", async () => { + // Seed a cache with a cursor and a collection, as a prior run left it. + const path = join(cacheDirectory, "metadata.json"); + const seed = await MetadataStore.load(path); + seed.userID = USER_ID; + seed.collectionsSinceTime = 500; + seed.putCollection(collection(1, 400)); + seed.putFile(file(1001, 1, 400)); + await seed.save(); + + const client = new MockClient(); + // The collection's updationTime advances (400 -> 600), so its files are + // re-enumerated from the collection's stored updationTime (400). + client.collectionsQueue.push({ + collections: [collection(1, 600)], + deleted: [], + cursor: 600, + }); + client.filesFor(1, { + files: [file(1002, 1, 550)], + deleted: [], + cursor: 550, + }); + + // Opening from an existing cache serves the seeded copy at once and + // refreshes in the background, so the refresh's effects are polled for. + const lib = await Library.open({ client, cacheDirectory }); + try { + await vi.waitFor( + () => { + // Collections resumed from the stored cursor, and files were + // re-enumerated from the stored collection updationTime. + expect(client.collectionsSinceTimes[0]).toBe(500); + expect(client.filesCalls).toEqual([ + { collectionID: 1, sinceTime: 400 }, + ]); + expect(lib.listFiles(1).map((f) => f.id)).toEqual([ + 1001, 1002, + ]); + }, + { timeout: 2000, interval: 5 }, + ); + } finally { + lib.close(); + } + }); + + it("does not re-enumerate a collection whose updationTime did not advance", async () => { + const path = join(cacheDirectory, "metadata.json"); + const seed = await MetadataStore.load(path); + seed.userID = USER_ID; + seed.collectionsSinceTime = 100; + seed.putCollection(collection(1, 400)); + await seed.save(); + + const client = new MockClient(); + // The collection comes back in the diff (its metadata changed) but at + // the same updationTime, so its files must not be re-fetched. + client.collectionsQueue.push({ + collections: [collection(1, 400, "renamed")], + deleted: [], + cursor: 400, + }); + + // Existing cache: the rename lands via the background refresh. + const lib = await Library.open({ client, cacheDirectory }); + try { + await vi.waitFor( + () => expect(lib.getCollection(1)?.name).toBe("renamed"), + { timeout: 2000, interval: 5 }, + ); + // The collection's updationTime did not advance, so its files were + // never re-fetched. + expect(client.filesCalls).toEqual([]); + } finally { + lib.close(); + } + }); + + it("applies diffs and tombstones on the interval", async () => { + const client = new MockClient(); + client.collectionsQueue.push({ + collections: [collection(1, 100), collection(2, 100)], + deleted: [], + cursor: 100, + }); + client.filesFor(1, { + files: [file(1001, 1, 90)], + deleted: [], + cursor: 90, + }); + client.filesFor(2, { + files: [file(2001, 2, 90)], + deleted: [], + cursor: 90, + }); + + const lib = await Library.open({ + client, + cacheDirectory, + refreshIntervalSeconds: FAST_INTERVAL, + }); + try { + expect(lib.listCollections().map((c) => c.id)).toEqual([1, 2]); + expect(lib.listFiles(2).map((f) => f.id)).toEqual([2001]); + + // Next refresh: collection 2 is tombstoned; collection 1 gains a + // file and loses its old one. + client.filesFor(1, { + files: [file(1002, 1, 190)], + deleted: [1001], + cursor: 190, + }); + client.collectionsQueue.push({ + collections: [collection(1, 200)], + deleted: [2], + cursor: 200, + }); + + await vi.waitFor( + () => { + expect(lib.listCollections().map((c) => c.id)).toEqual([1]); + expect(lib.listFiles(1).map((f) => f.id)).toEqual([1002]); + // Collection 2's files went with it. + expect(lib.listFiles(2)).toEqual([]); + }, + { timeout: 2000, interval: 5 }, + ); + } finally { + lib.close(); + } + }); + + it("rewrites the cache only when a refresh changes something", async () => { + const saveSpy = vi.spyOn(MetadataStore.prototype, "save"); + const client = new MockClient(); + client.collectionsQueue.push({ + collections: [collection(1, 100)], + deleted: [], + cursor: 100, + }); + client.filesFor(1, { + files: [file(1001, 1, 90)], + deleted: [], + cursor: 90, + }); + + const lib = await Library.open({ + client, + cacheDirectory, + refreshIntervalSeconds: FAST_INTERVAL, + }); + try { + // The initial refresh changed everything, so it saved once. + expect(saveSpy).toHaveBeenCalledTimes(1); + + // Several empty-diff ticks pass; none of them may rewrite the file. + await new Promise((r) => setTimeout(r, FAST_INTERVAL * 1000 * 4)); + expect(saveSpy).toHaveBeenCalledTimes(1); + + // A real change triggers exactly one more rewrite; later empty ticks + // still do not, so the count settles at two. + client.collectionsQueue.push({ + collections: [collection(2, 200)], + deleted: [], + cursor: 200, + }); + await vi.waitFor(() => expect(saveSpy).toHaveBeenCalledTimes(2), { + timeout: 2000, + interval: 5, + }); + await new Promise((r) => setTimeout(r, FAST_INTERVAL * 1000 * 4)); + expect(saveSpy).toHaveBeenCalledTimes(2); + } finally { + lib.close(); + saveSpy.mockRestore(); + } + }); + + it("keeps a failed refresh invisible to reads and recovers later", async () => { + const events: RefreshEvent[] = []; + const client = new MockClient(); + client.collectionsQueue.push({ + collections: [collection(1, 100)], + deleted: [], + cursor: 100, + }); + client.filesFor(1, { + files: [file(1001, 1, 90)], + deleted: [], + cursor: 90, + }); + + const lib = await Library.open({ + client, + cacheDirectory, + refreshIntervalSeconds: FAST_INTERVAL, + onProgress: (e) => events.push(e), + }); + try { + expect(lib.listFiles(1).map((f) => f.id)).toEqual([1001]); + + // The server goes away; refreshes now fail. + client.failCollections = true; + await vi.waitFor( + () => expect(lib.status().lastError).toMatch(/network down/), + { timeout: 2000, interval: 5 }, + ); + + // Reads still see the last good data; the failure was reported. + expect(lib.listFiles(1).map((f) => f.id)).toEqual([1001]); + expect( + events.some( + (e) => e.operation === "refresh" && e.status === "failed", + ), + ).toBe(true); + + // Recovery: a later refresh succeeds and clears the error. + client.failCollections = false; + client.collectionsQueue.push({ + collections: [collection(2, 300)], + deleted: [], + cursor: 300, + }); + await vi.waitFor( + () => { + expect(lib.status().lastError).toBeUndefined(); + expect(lib.listCollections().map((c) => c.id)).toEqual([ + 1, 2, + ]); + }, + { timeout: 2000, interval: 5 }, + ); + } finally { + lib.close(); + } + }); + + it("resolves open() even when the first refresh fails", async () => { + const client = new MockClient(); + client.failCollections = true; + const events: RefreshEvent[] = []; + + const lib = await Library.open({ + client, + cacheDirectory, + onProgress: (e) => events.push(e), + }); + try { + // Nothing was cached and the server is unreachable: reads are empty, + // but the library opened and the failure is on record. + expect(lib.listCollections()).toEqual([]); + expect(lib.status().lastError).toMatch(/network down/); + expect(lib.status().lastRefreshAt).toBeUndefined(); + expect( + events.some( + (e) => e.operation === "refresh" && e.status === "failed", + ), + ).toBe(true); + } finally { + lib.close(); + } + }); + + it("opens from an existing cache without waiting for the first refresh", async () => { + // Seed a cache as a prior run left it. + const path = join(cacheDirectory, "metadata.json"); + const seed = await MetadataStore.load(path); + seed.userID = USER_ID; + seed.collectionsSinceTime = 500; + seed.putCollection(collection(1, 400)); + seed.putFile(file(1001, 1, 400)); + await seed.save(); + + // The server never answers this run's first refresh. + const client = new MockClient(); + client.collectionsSince = () => new Promise(() => {}); + + // open() must resolve from the cache without blocking on the network, + // and reads must serve the seeded copy. + const lib = await Library.open({ client, cacheDirectory }); + try { + expect(lib.listCollections().map((c) => c.id)).toEqual([1]); + expect(lib.listFiles(1).map((f) => f.id)).toEqual([1001]); + // The first refresh is still outstanding: nothing has completed or + // failed yet. + expect(lib.status().lastRefreshAt).toBeUndefined(); + expect(lib.status().lastError).toBeUndefined(); + } finally { + lib.close(); + } + }); + + it("awaits the first refresh on a first run with an empty cache", async () => { + // No cache on disk: open() must not resolve until the first fetch does, + // so it never hands back an empty library it could have filled. + let releaseFirstFetch: (page: CollectionsPage) => void = () => {}; + const gate = new Promise((resolve) => { + releaseFirstFetch = resolve; + }); + const client = new MockClient(); + client.filesFor(1, { + files: [file(1001, 1, 90)], + deleted: [], + cursor: 90, + }); + client.collectionsSince = async (args: { sinceTime: number }) => { + client.collectionsSinceTimes.push(args.sinceTime); + return gate; + }; + + let opened = false; + const openPromise = Library.open({ client, cacheDirectory }).then( + (l) => { + opened = true; + return l; + }, + ); + + // While the first fetch is outstanding, open() has not resolved. + await new Promise((r) => setTimeout(r, 20)); + expect(opened).toBe(false); + + // Completing the fetch lets open() resolve with the data in place. + releaseFirstFetch({ + collections: [collection(1, 100)], + deleted: [], + cursor: 100, + }); + const lib = await openPromise; + try { + expect(opened).toBe(true); + expect(lib.listCollections().map((c) => c.id)).toEqual([1]); + expect(lib.listFiles(1).map((f) => f.id)).toEqual([1001]); + expect(lib.status().lastRefreshAt).toBeGreaterThan(0); + } finally { + lib.close(); + } + }); + + it("keeps a save failure visible until a save actually succeeds", async () => { + const saveSpy = vi + .spyOn(MetadataStore.prototype, "save") + .mockRejectedValue(new Error("disk full")); + const client = new MockClient(); + client.collectionsQueue.push({ + collections: [collection(1, 100)], + deleted: [], + cursor: 100, + }); + client.filesFor(1, { + files: [file(1001, 1, 90)], + deleted: [], + cursor: 90, + }); + + const lib = await Library.open({ + client, + cacheDirectory, + refreshIntervalSeconds: FAST_INTERVAL, + }); + try { + // The initial refresh mutated RAM but its save failed, so the error + // is on record and no refresh has counted as successful. + expect(lib.status().lastError).toMatch(/disk full/); + expect(lib.status().lastRefreshAt).toBeUndefined(); + + // Empty-diff ticks pass. Each still retries the unsaved write and + // still fails, so the error never silently clears and the refresh + // clock never advances — RAM must not run ahead of disk unnoticed. + const savesBefore = saveSpy.mock.calls.length; + await new Promise((r) => setTimeout(r, FAST_INTERVAL * 1000 * 4)); + expect(saveSpy.mock.calls.length).toBeGreaterThan(savesBefore); + expect(lib.status().lastError).toMatch(/disk full/); + expect(lib.status().lastRefreshAt).toBeUndefined(); + + // Once the disk recovers, the next tick persists the pending change + // and only then clears the error and advances the clock. + saveSpy.mockRestore(); + await vi.waitFor( + () => { + expect(lib.status().lastError).toBeUndefined(); + expect(lib.status().lastRefreshAt).toBeGreaterThan(0); + }, + { timeout: 2000, interval: 5 }, + ); + const reloaded = await MetadataStore.load( + join(cacheDirectory, "metadata.json"), + ); + expect(reloaded.getFile(1, 1001)?.id).toBe(1001); + } finally { + lib.close(); + saveSpy.mockRestore(); + } + }); + + it("close() stops the timer and is idempotent", async () => { + const client = new MockClient(); + + const lib = await Library.open({ + client, + cacheDirectory, + refreshIntervalSeconds: FAST_INTERVAL, + }); + const callsAfterOpen = client.collectionsSinceTimes.length; + + lib.close(); + lib.close(); // second close must not throw + expect(lib.status().closed).toBe(true); + + // No further refreshes fire once closed. + await new Promise((r) => setTimeout(r, FAST_INTERVAL * 1000 * 5)); + expect(client.collectionsSinceTimes.length).toBe(callsAfterOpen); + }); + + it("defaults cacheDirectory to the env-paths cache dir plus user id", async () => { + const xdg = join(dir, "xdg-cache"); + const prev = process.env.XDG_CACHE_HOME; + process.env.XDG_CACHE_HOME = xdg; + try { + const client = new MockClient(); + const lib = await Library.open({ client }); + try { + const expected = join( + envPaths("quak", { suffix: "" }).cache, + String(USER_ID), + ); + expect(lib.cacheDirectory).toBe(expected); + expect(lib.cacheDirectory.startsWith(xdg)).toBe(true); + expect(lib.cacheDirectory.endsWith(String(USER_ID))).toBe(true); + } finally { + lib.close(); + } + } finally { + if (prev === undefined) delete process.env.XDG_CACHE_HOME; + else process.env.XDG_CACHE_HOME = prev; + } + }); +});