Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
501d623985 |
@@ -33,6 +33,15 @@ export {
|
|||||||
requestEmailOTP,
|
requestEmailOTP,
|
||||||
submitEmailOTP,
|
submitEmailOTP,
|
||||||
} from "./auth/login.js";
|
} 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 { decryptCollection, decryptFile } from "./model/index.js";
|
||||||
export { downloadFile, downloadThumbnail } from "./download/index.js";
|
export { downloadFile, downloadThumbnail } from "./download/index.js";
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
// The library surface over the local cache.
|
||||||
|
//
|
||||||
|
// `Library.open()` loads the on-disk metadata store (issue #41), does one
|
||||||
|
// refresh against the server, then keeps a background timer that 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 initial refresh and the background timer.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
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<CollectionsPage>;
|
||||||
|
filesSince(args: {
|
||||||
|
collectionID: number;
|
||||||
|
collectionKey: Uint8Array;
|
||||||
|
sinceTime: number;
|
||||||
|
}): Promise<FilesPage>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<typeof setTimeout>;
|
||||||
|
private refreshing = false;
|
||||||
|
private closed = false;
|
||||||
|
private lastRefreshAt?: number;
|
||||||
|
private lastError?: string;
|
||||||
|
|
||||||
|
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, do one refresh, then start the background timer. Resolves
|
||||||
|
// even when the initial refresh fails: the library then opens from whatever
|
||||||
|
// was cached (possibly nothing), with the failure recorded in `status()`.
|
||||||
|
static async open(opts: LibraryOptions): Promise<Library> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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) await this.store.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(event: RefreshEvent): void {
|
||||||
|
if (!this.onProgress) return;
|
||||||
|
// A misbehaving callback must not break the refresh loop.
|
||||||
|
try {
|
||||||
|
this.onProgress(event);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* `open()` awaits the initial refresh (including its cache write), so state
|
||||||
|
* right after `open()` is deterministic. 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<number, FilesPage[]>();
|
||||||
|
|
||||||
|
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<CollectionsPage> {
|
||||||
|
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<FilesPage> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
const lib = await Library.open({ client, cacheDirectory });
|
||||||
|
try {
|
||||||
|
// The initial refresh resumed collections from the stored cursor.
|
||||||
|
expect(client.collectionsSinceTimes[0]).toBe(500);
|
||||||
|
// Files were re-enumerated from the stored collection updationTime.
|
||||||
|
expect(client.filesCalls).toEqual([
|
||||||
|
{ collectionID: 1, sinceTime: 400 },
|
||||||
|
]);
|
||||||
|
expect(lib.listFiles(1).map((f) => f.id)).toEqual([1001, 1002]);
|
||||||
|
} 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,
|
||||||
|
});
|
||||||
|
|
||||||
|
const lib = await Library.open({ client, cacheDirectory });
|
||||||
|
try {
|
||||||
|
expect(client.filesCalls).toEqual([]);
|
||||||
|
expect(lib.getCollection(1)?.name).toBe("renamed");
|
||||||
|
} 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("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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user