quak files COLLECTION fetches that collection's files alone (closes #217)
check / check (push) Waiting to run
check / check (push) Waiting to run
`quak files COLLECTION` used to wait for a full refresh, which on an empty cache fetched the files of every collection first. It now reads the collection list, then calls the new `Library.refreshCollection()`. That brings one collection up to date in the store as a refresh would. Once the stored cursor is past 0, it only updates a collection the store holds. For a new one, `quak files COLLECTION` waits for a refresh. A refresh from a cursor of 0 replaces all the store holds, so collections then sit in the server's order. `open()` awaits the first refresh while the cursor is 0. The store lists what did not decrypt in a fixed order. Model: opus-5-5
This commit is contained in:
+122
-29
@@ -124,20 +124,35 @@ const FILES: Record<number, EnteFile[]> = {
|
||||
// thumbnail as empty. `withNewFile` adds new.jpg (102) to Vacation, advancing
|
||||
// the collection's updationTime as the server does. `beachInWork` adds
|
||||
// beach.jpg (100) to Work as well, so that one file is in both albums.
|
||||
// `refreshError` makes listing collections fail with that message.
|
||||
// `newAlbums` adds the albums A (3) and B (4), in that order, which both hold
|
||||
// shared.jpg (300). `refreshError` makes listing collections fail with that
|
||||
// message.
|
||||
const fakeClient = (
|
||||
opts: {
|
||||
failID?: number;
|
||||
emptyThumbID?: number;
|
||||
withNewFile?: boolean;
|
||||
beachInWork?: boolean;
|
||||
newAlbums?: boolean;
|
||||
refreshError?: string;
|
||||
} = {},
|
||||
) => {
|
||||
const collections = opts.withNewFile
|
||||
let collections = opts.withNewFile
|
||||
? [{ ...COLLECTIONS[0], updationTime: 2 }, COLLECTIONS[1]]
|
||||
: COLLECTIONS;
|
||||
let files = FILES;
|
||||
if (opts.newAlbums) {
|
||||
collections = [
|
||||
...collections,
|
||||
{ ...collection(3, "A"), updationTime: 2 },
|
||||
{ ...collection(4, "B"), updationTime: 2 },
|
||||
];
|
||||
files = {
|
||||
...files,
|
||||
3: [file(300, 3, "shared.jpg")],
|
||||
4: [file(300, 4, "shared.jpg")],
|
||||
};
|
||||
}
|
||||
if (opts.withNewFile) {
|
||||
files = {
|
||||
...files,
|
||||
@@ -535,9 +550,7 @@ describe("files", () => {
|
||||
|
||||
it("exits 1 for an unknown collection", async () => {
|
||||
expect(await filesCommand(context(), "9", {})).toBe(1);
|
||||
expect(stderr.text).toBe(
|
||||
FETCHED_BOTH_ALBUMS + "Collection 9 not found\n",
|
||||
);
|
||||
expect(stderr.text).toBe("Collection 9 not found\n");
|
||||
});
|
||||
|
||||
// Without a collection ID it lists every file in the account once (issue
|
||||
@@ -571,6 +584,57 @@ describe("files", () => {
|
||||
]);
|
||||
expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS);
|
||||
});
|
||||
|
||||
// `files 2` on an empty cache stores Work alone. The refresh that `files`
|
||||
// then waits for must still store Vacation first, as the server lists it.
|
||||
it("without a collection ID lists in the server's order after files 2 on an empty cache", async () => {
|
||||
const ctx = context(fakeClient({ beachInWork: true }));
|
||||
expect(await filesCommand(ctx, "2", {})).toBe(0);
|
||||
stdout.text = "";
|
||||
stderr.text = "";
|
||||
|
||||
expect(await filesCommand(ctx, undefined, { json: true })).toBe(0);
|
||||
expect(
|
||||
JSON.parse(stdout.text).map(
|
||||
(row: { id: number; collectionID: number }) => [
|
||||
row.id,
|
||||
row.collectionID,
|
||||
],
|
||||
),
|
||||
).toEqual([
|
||||
[100, 1],
|
||||
[101, 1],
|
||||
[200, 2],
|
||||
]);
|
||||
expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS);
|
||||
});
|
||||
|
||||
// After a refresh, the server adds A and then B. `files 4` must not store B
|
||||
// ahead of A, so it waits for a refresh, which stores both in that order.
|
||||
it("without a collection ID lists new albums in the server's order after files with the second one's ID", async () => {
|
||||
expect(await filesCommand(context(), undefined, {})).toBe(0);
|
||||
const ctx = context(fakeClient({ newAlbums: true }));
|
||||
stdout.text = "";
|
||||
|
||||
expect(await filesCommand(ctx, "4", {})).toBe(0);
|
||||
expect(stdout.text).toBe("300\timage\tshared.jpg\n");
|
||||
stdout.text = "";
|
||||
|
||||
expect(await filesCommand(ctx, undefined, { json: true })).toBe(0);
|
||||
expect(
|
||||
JSON.parse(stdout.text).map(
|
||||
(row: { id: number; collectionID: number }) => [
|
||||
row.id,
|
||||
row.collectionID,
|
||||
],
|
||||
),
|
||||
).toEqual([
|
||||
[100, 1],
|
||||
[101, 1],
|
||||
[200, 2],
|
||||
[300, 3],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get and get-thumb", () => {
|
||||
@@ -1394,7 +1458,7 @@ describe("backup-metadata --exif", () => {
|
||||
// collection list alone and stores nothing.)
|
||||
describe("backup-metadata and the thumbnail helpers refresh first", () => {
|
||||
beforeEach(async () => {
|
||||
expect(await filesCommand(context(), "1", {})).toBe(0);
|
||||
expect(await filesCommand(context(), undefined, {})).toBe(0);
|
||||
stdout.text = "";
|
||||
stderr.text = "";
|
||||
});
|
||||
@@ -1579,11 +1643,13 @@ describe("an ID given on the command line", () => {
|
||||
|
||||
// An account with many albums (issue #210). On an empty cache `collections`
|
||||
// used to wait for a refresh that fetched and decrypted the files of every
|
||||
// album, one album after another, before it printed anything, so on such an
|
||||
// account it seemed to hang. The account here has 40 albums of one photo each,
|
||||
// encrypted as Ente's clients encrypt them, on the fake Ente server of
|
||||
// `test/ente-server.ts`, which also records the path of each request quak
|
||||
// sends. The session is the one logging in to that server saves.
|
||||
// album, one album after another. It printed nothing until then, so on such an
|
||||
// account it seemed to hang. `files` with an album's ID did the same before it
|
||||
// listed that album (issue #217). The account here has 40 albums of one photo
|
||||
// each, encrypted as Ente's clients encrypt them. The fake Ente server of
|
||||
// `test/ente-server.ts` serves them. A wrapper around it records the path and
|
||||
// query of each request quak sends. The session is the one logging in to that
|
||||
// server saves.
|
||||
describe("an account with many albums", () => {
|
||||
const ALBUMS = 40;
|
||||
const PASSWORD = "many-albums";
|
||||
@@ -1664,7 +1730,8 @@ describe("an account with many albums", () => {
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url;
|
||||
paths.push(new URL(url).pathname);
|
||||
const { pathname, search } = new URL(url);
|
||||
paths.push(pathname + search);
|
||||
return ente(input, init);
|
||||
}) as typeof globalThis.fetch;
|
||||
const client = await Client.login({
|
||||
@@ -1698,42 +1765,68 @@ describe("an account with many albums", () => {
|
||||
albums += `${id}\talbum\tAlbum ${id}\n`;
|
||||
expect(stdout.text).toBe(albums);
|
||||
expect(stderr.text).toBe("");
|
||||
expect(paths).toEqual(["/collections/v2"]);
|
||||
expect(paths).toEqual(["/collections/v2?sinceTime=0"]);
|
||||
|
||||
// It stored nothing that would make a refresh skip an album's files:
|
||||
// `files` after it still finds the album's photo.
|
||||
// It stored nothing that would make `refreshCollection()` skip an
|
||||
// album's files: `files` after it still finds the album's photo.
|
||||
stdout.text = "";
|
||||
expect(await filesCommand(ctx, "7", {})).toBe(0);
|
||||
expect(stdout.text).toBe("700\timage\tphoto-7.jpg\n");
|
||||
});
|
||||
|
||||
it("files counts on stderr each album whose files its refresh fetched", async () => {
|
||||
it("files fetches the files of the album it lists and of no other", async () => {
|
||||
expect(await filesCommand(accountContext(), "7", {})).toBe(0);
|
||||
|
||||
expect(stdout.text).toBe("700\timage\tphoto-7.jpg\n");
|
||||
expect(stderr.text).toBe("");
|
||||
expect(paths).toEqual([
|
||||
"/collections/v2?sinceTime=0",
|
||||
"/collections/v2/diff?collectionID=7&sinceTime=0",
|
||||
]);
|
||||
});
|
||||
|
||||
// The first listing stored the album, so the second finds its stored copy
|
||||
// current and asks for no album's files.
|
||||
it("files run again for the same album sends no request for its files", async () => {
|
||||
const ctx = accountContext();
|
||||
expect(await filesCommand(ctx, "7", {})).toBe(0);
|
||||
stdout.text = "";
|
||||
paths.length = 0;
|
||||
|
||||
expect(await filesCommand(ctx, "7", {})).toBe(0);
|
||||
expect(stdout.text).toBe("700\timage\tphoto-7.jpg\n");
|
||||
expect(paths).toEqual(["/collections/v2?sinceTime=0"]);
|
||||
});
|
||||
|
||||
it("files with no album's ID counts on stderr each album whose files its refresh fetched", async () => {
|
||||
expect(await filesCommand(accountContext(), undefined, {})).toBe(0);
|
||||
|
||||
let photos = "";
|
||||
let progress = "";
|
||||
for (let n = 1; n <= ALBUMS; n++) {
|
||||
photos += `${n * 100}\timage\tphoto-${n}.jpg\n`;
|
||||
progress += `Fetched the files of ${n} of ${ALBUMS} collections\n`;
|
||||
}
|
||||
expect(stdout.text).toBe(photos);
|
||||
expect(stderr.text).toBe(progress);
|
||||
});
|
||||
|
||||
// `--json` shapes stdout only, so the refresh's progress is still counted
|
||||
// on stderr.
|
||||
it("files --json counts on stderr each album whose files its refresh fetched", async () => {
|
||||
expect(await filesCommand(accountContext(), "7", { json: true })).toBe(
|
||||
0,
|
||||
);
|
||||
it("files --json with no album's ID counts on stderr each album whose files its refresh fetched", async () => {
|
||||
expect(
|
||||
await filesCommand(accountContext(), undefined, { json: true }),
|
||||
).toBe(0);
|
||||
|
||||
expect(JSON.parse(stdout.text)).toEqual([
|
||||
{
|
||||
id: 700,
|
||||
title: "photo-7.jpg",
|
||||
fileType: "image",
|
||||
creationTime: 1000,
|
||||
collectionID: 7,
|
||||
},
|
||||
]);
|
||||
const rows = JSON.parse(stdout.text);
|
||||
expect(rows).toHaveLength(ALBUMS);
|
||||
expect(rows[6]).toEqual({
|
||||
id: 700,
|
||||
title: "photo-7.jpg",
|
||||
fileType: "image",
|
||||
creationTime: 1000,
|
||||
collectionID: 7,
|
||||
});
|
||||
let progress = "";
|
||||
for (let n = 1; n <= ALBUMS; n++) {
|
||||
progress += `Fetched the files of ${n} of ${ALBUMS} collections\n`;
|
||||
|
||||
@@ -234,8 +234,8 @@ describe("-q", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// The refresh's progress is left out; the line saying why the command
|
||||
// failed is the fatal one, and it is printed.
|
||||
// The line saying why the command failed is the fatal one, and it is
|
||||
// printed.
|
||||
it("prints only the line a failing command exits with", async () => {
|
||||
expect(await quak("-q", "files", "9")).toEqual({
|
||||
code: 1,
|
||||
|
||||
+58
-16
@@ -3,13 +3,18 @@
|
||||
* issue #36, issue #52).
|
||||
*
|
||||
* The `collections`, `files`, `get`, and `get-thumb` commands must answer for
|
||||
* current server state, not the local cache. `files`, `get` and `get-thumb`
|
||||
* force a `Library.fresh()` round-trip before they read; the stand-in library
|
||||
* below serves nothing until `fresh()` has been awaited, so a helper that read
|
||||
* without refreshing would come back empty and fail here. `collections` asks
|
||||
* the library for the collection list alone (`freshCollections()`, issue
|
||||
* #210), and must not wait for a refresh, which would fetch the files of every
|
||||
* collection.
|
||||
* current server state, not the local cache. `files` without a collection ID,
|
||||
* `get` and `get-thumb` force a `Library.fresh()` round-trip before they read.
|
||||
* The stand-in library below serves nothing until `fresh()` has been awaited.
|
||||
* A helper that read without refreshing would come back empty and fail here.
|
||||
* `collections` asks the library for the collection list alone
|
||||
* (`freshCollections()`, issue #210). It must not wait for a refresh, which
|
||||
* would fetch the files of every collection. `files COLLECTION` reads that list
|
||||
* too, then brings that one collection up to date (`refreshCollection()`,
|
||||
* issue #217). The stand-in serves a collection's files once `fresh()` has run,
|
||||
* or once `refreshCollection()` has resolved to `true` for that collection.
|
||||
* When `refreshCollection()` resolves to `false`, `files COLLECTION` forces a
|
||||
* refresh instead.
|
||||
*
|
||||
* `collections` and `files` also list in the library's enumeration order
|
||||
* (`listCollections`/`listFiles`) — the order the pre-library CLI printed — not
|
||||
@@ -64,12 +69,18 @@ const file = (
|
||||
|
||||
// A library that reveals its records only after `fresh()` has been awaited, and
|
||||
// serves them in the enumeration order it was given. `freshCollections()`
|
||||
// answers the collection list in that order without a refresh. `photos.byID`
|
||||
// returns a stand-in `Photo` carrying just the fileID the helper passes
|
||||
// through.
|
||||
// answers the collection list in that order without a refresh. When
|
||||
// `refreshCollection()` resolves to true, it reveals the files of that one
|
||||
// collection.
|
||||
// `photos.byID` returns a stand-in `Photo` carrying just the fileID the helper
|
||||
// passes through.
|
||||
class FakeLibrary implements FreshReadLibrary {
|
||||
freshCalls = 0;
|
||||
freshCollectionsCalls = 0;
|
||||
refreshCollectionIDs: number[] = [];
|
||||
// What `refreshCollection()` resolves to. With false it reveals nothing,
|
||||
// as a library that has had a refresh does for a collection it lacks.
|
||||
refreshCollectionHolds = true;
|
||||
private refreshed = false;
|
||||
|
||||
constructor(
|
||||
@@ -88,6 +99,11 @@ class FakeLibrary implements FreshReadLibrary {
|
||||
return this.collections;
|
||||
}
|
||||
|
||||
async refreshCollection(collection: Collection): Promise<boolean> {
|
||||
this.refreshCollectionIDs.push(collection.id);
|
||||
return this.refreshCollectionHolds;
|
||||
}
|
||||
|
||||
getCollection(id: number): Collection | undefined {
|
||||
if (!this.refreshed) return undefined;
|
||||
return this.collections.find((c) => c.id === id);
|
||||
@@ -98,7 +114,11 @@ class FakeLibrary implements FreshReadLibrary {
|
||||
}
|
||||
|
||||
listFiles(collectionID: number): EnteFile[] {
|
||||
return this.refreshed
|
||||
const current =
|
||||
this.refreshed ||
|
||||
(this.refreshCollectionHolds &&
|
||||
this.refreshCollectionIDs.includes(collectionID));
|
||||
return current
|
||||
? this.files.filter((f) => f.collectionID === collectionID)
|
||||
: [];
|
||||
}
|
||||
@@ -138,18 +158,25 @@ describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
|
||||
expect(rows.map((c) => c.id)).not.toEqual(newestFirst);
|
||||
});
|
||||
|
||||
it("freshFiles refreshes first, lists in enumeration order, keeps raw fields", async () => {
|
||||
it("freshFiles brings the collection alone up to date, lists in enumeration order, keeps raw fields", async () => {
|
||||
// Enumeration order by id 10, 11, 12; creationTimes ascending, so a
|
||||
// newest-first sort would reverse them.
|
||||
// newest-first sort would reverse them. Collection 2's file is not
|
||||
// listed.
|
||||
const files = [
|
||||
file(10, 1, 1_700_000_000_000_000),
|
||||
file(11, 1, 1_700_000_000_000_001),
|
||||
file(12, 1, 1_700_000_000_000_002),
|
||||
file(20, 2, 1_700_000_000_000_003),
|
||||
];
|
||||
const lib = new FakeLibrary([collection(1, 100)], files);
|
||||
const lib = new FakeLibrary(
|
||||
[collection(1, 100), collection(2, 100)],
|
||||
files,
|
||||
);
|
||||
|
||||
const rows = await freshFiles(lib, 1);
|
||||
expect(lib.freshCalls).toBe(1);
|
||||
expect(lib.freshCollectionsCalls).toBe(1);
|
||||
expect(lib.refreshCollectionIDs).toEqual([1]);
|
||||
expect(lib.freshCalls).toBe(0);
|
||||
expect(rows?.map((f) => f.id)).toEqual([10, 11, 12]);
|
||||
|
||||
// Field values come from raw metadata: microsecond creationTime and the
|
||||
@@ -203,10 +230,25 @@ describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("freshFiles forces a refresh when refreshCollection resolves to false", async () => {
|
||||
const lib = new FakeLibrary(
|
||||
[collection(1, 100)],
|
||||
[file(10, 1, 1_700_000_000_000_000)],
|
||||
);
|
||||
lib.refreshCollectionHolds = false;
|
||||
|
||||
const rows = await freshFiles(lib, 1);
|
||||
expect(lib.refreshCollectionIDs).toEqual([1]);
|
||||
expect(lib.freshCalls).toBe(1);
|
||||
expect(rows?.map((f) => f.id)).toEqual([10]);
|
||||
});
|
||||
|
||||
it("freshFiles returns undefined for an unknown collection", async () => {
|
||||
const lib = new FakeLibrary([collection(1, 100)], []);
|
||||
const rows = await freshFiles(lib, 999);
|
||||
expect(lib.freshCalls).toBe(1);
|
||||
expect(lib.freshCollectionsCalls).toBe(1);
|
||||
expect(lib.refreshCollectionIDs).toEqual([]);
|
||||
expect(lib.freshCalls).toBe(0);
|
||||
expect(rows).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
+273
-4
@@ -17,6 +17,14 @@
|
||||
* 4. `freshCollections()` reads the collection list alone, with one request
|
||||
* and no collection's files, and stores nothing, so the next refresh still
|
||||
* fetches the files of every collection (issue #210).
|
||||
* 5. `refreshCollection()` brings one collection up to date in the store
|
||||
* (issue #217). It leaves the stored cursor where it was, so the next
|
||||
* refresh still fetches the files of every other collection that changed.
|
||||
* It never runs at the same time as a refresh. After a refresh, it stores
|
||||
* no collection the store does not hold: not a new one, and not one a
|
||||
* refresh deleted. `close()` waits for it. A library opened on a cache that
|
||||
* only it wrote awaits its first refresh. That refresh replaces what it
|
||||
* stored, in the server's order.
|
||||
*
|
||||
* The client is the same metadata-only mock the background-refresh tests use:
|
||||
* no crypto, no network, scripted pages, and a record of each call. A long
|
||||
@@ -30,6 +38,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { Library } 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";
|
||||
|
||||
@@ -144,6 +153,26 @@ const openSeeded = async (
|
||||
return { client, lib };
|
||||
};
|
||||
|
||||
// Hold the client's next files request until `release()` is called. `started`
|
||||
// resolves once that request has been sent.
|
||||
const holdNextFilesRequest = (
|
||||
client: MockClient,
|
||||
): { started: Promise<void>; release: () => void } => {
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>((r) => (release = r));
|
||||
let sent!: () => void;
|
||||
const started = new Promise<void>((r) => (sent = r));
|
||||
const realFilesSince = client.filesSince.bind(client);
|
||||
client.filesSince = async (args) => {
|
||||
client.filesSince = realFilesSince;
|
||||
const page = await realFilesSince(args);
|
||||
sent();
|
||||
await held;
|
||||
return page;
|
||||
};
|
||||
return { started, release };
|
||||
};
|
||||
|
||||
describe("Library.fresh", () => {
|
||||
let dir: string;
|
||||
let cacheDirectory: string;
|
||||
@@ -287,10 +316,10 @@ describe("Library.freshCollections", () => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// A refresh fetches a collection's files only when the collection is new
|
||||
// or its updationTime has advanced past the stored copy's. Had the
|
||||
// collection list been stored, the next refresh would take every
|
||||
// collection for one it already holds the files of, and fetch none.
|
||||
// A refresh from a stored cursor past 0 fetches a collection's files only
|
||||
// when the collection is new or its updationTime has advanced past the
|
||||
// stored copy's. So does `refreshCollection()`. Neither would fetch the
|
||||
// files of a collection stored here without them.
|
||||
it("asks for the collection list alone and stores nothing", async () => {
|
||||
const client = new MockClient();
|
||||
const page = {
|
||||
@@ -340,3 +369,243 @@ describe("Library.freshCollections", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Library.refreshCollection", () => {
|
||||
let dir: string;
|
||||
let cacheDirectory: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "quak-refresh-collection-"));
|
||||
cacheDirectory = join(dir, "cache");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Collections 1 and 2 are stored at updationTime 100, with a stored cursor
|
||||
// of 100, and then both change on the server.
|
||||
it("fetches the files of one changed collection and leaves the others to the next refresh", 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: SLOW_INTERVAL,
|
||||
});
|
||||
try {
|
||||
client.filesCalls.length = 0;
|
||||
client.filesFor(1, {
|
||||
files: [file(1002, 1, 110)],
|
||||
deleted: [],
|
||||
cursor: 110,
|
||||
});
|
||||
client.filesFor(2, {
|
||||
files: [file(2002, 2, 120)],
|
||||
deleted: [],
|
||||
cursor: 120,
|
||||
});
|
||||
|
||||
// Collection 1's files alone, since the stored copy.
|
||||
expect(await lib.refreshCollection(collection(1, 110))).toBe(true);
|
||||
expect(client.filesCalls).toEqual([
|
||||
{ collectionID: 1, sinceTime: 100 },
|
||||
]);
|
||||
expect(lib.listFiles(1).map((f) => f.id)).toEqual([1001, 1002]);
|
||||
expect(lib.listFiles(2).map((f) => f.id)).toEqual([2001]);
|
||||
|
||||
// The stored copy is now current, so no request.
|
||||
await lib.refreshCollection(collection(1, 110));
|
||||
expect(client.filesCalls).toHaveLength(1);
|
||||
|
||||
// The next refresh asks from the stored cursor, 100, so it still
|
||||
// sees collection 2 changed and fetches its files.
|
||||
client.collectionsQueue.push({
|
||||
collections: [collection(1, 110), collection(2, 120)],
|
||||
deleted: [],
|
||||
cursor: 120,
|
||||
});
|
||||
await lib.fresh();
|
||||
expect(client.collectionsSinceTimes).toEqual([0, 100]);
|
||||
expect(client.filesCalls).toEqual([
|
||||
{ collectionID: 1, sinceTime: 100 },
|
||||
{ collectionID: 2, sinceTime: 100 },
|
||||
]);
|
||||
expect(lib.listFiles(2).map((f) => f.id)).toEqual([2001, 2002]);
|
||||
} finally {
|
||||
await lib.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Collection 1 is stored at 100. `refreshCollection()` asks for it at 110,
|
||||
// which adds file 1002. While that files request waits, a fresh read
|
||||
// starts a refresh. The server tells that refresh that collection 1 was
|
||||
// deleted at 130.
|
||||
it("holds off a refresh that starts during the files request, which then deletes the collection", async () => {
|
||||
const { client, lib } = await openSeeded(cacheDirectory);
|
||||
const files = holdNextFilesRequest(client);
|
||||
try {
|
||||
client.filesFor(1, {
|
||||
files: [file(1002, 1, 110)],
|
||||
deleted: [],
|
||||
cursor: 110,
|
||||
});
|
||||
client.collectionsQueue.push({
|
||||
collections: [],
|
||||
deleted: [1],
|
||||
cursor: 130,
|
||||
});
|
||||
|
||||
const refreshing = lib.refreshCollection(collection(1, 110));
|
||||
await files.started;
|
||||
const reading = lib.fresh();
|
||||
// The refresh has not sent its collections request yet.
|
||||
await new Promise((r) => setImmediate(r));
|
||||
expect(client.collectionsSinceTimes).toEqual([0]);
|
||||
|
||||
files.release();
|
||||
await refreshing;
|
||||
await reading;
|
||||
expect(client.collectionsSinceTimes).toEqual([0, 100]);
|
||||
expect(lib.getCollection(1)).toBeUndefined();
|
||||
expect(lib.listFiles(1)).toEqual([]);
|
||||
} finally {
|
||||
files.release();
|
||||
await lib.close();
|
||||
}
|
||||
});
|
||||
|
||||
// `freshCollections()` lists collection 1 at 110. Before
|
||||
// `refreshCollection()` is called, a refresh learns that it was deleted at
|
||||
// 130.
|
||||
it("stores nothing for a collection that a refresh deleted before the call", async () => {
|
||||
const { client, lib } = await openSeeded(cacheDirectory);
|
||||
try {
|
||||
client.collectionsQueue.push(
|
||||
{ collections: [collection(1, 110)], deleted: [], cursor: 110 },
|
||||
{ collections: [], deleted: [1], cursor: 130 },
|
||||
);
|
||||
const [listed] = await lib.freshCollections();
|
||||
await lib.fresh();
|
||||
client.filesCalls.length = 0;
|
||||
|
||||
expect(await lib.refreshCollection(listed)).toBe(false);
|
||||
expect(client.filesCalls).toEqual([]);
|
||||
expect(lib.getCollection(1)).toBeUndefined();
|
||||
} finally {
|
||||
await lib.close();
|
||||
}
|
||||
});
|
||||
|
||||
// A refresh has stored collection 1. Collection 2 is new on the server
|
||||
// since then. Only the next refresh may add it, after collection 1.
|
||||
it("stores nothing for a collection that is new since the last refresh", async () => {
|
||||
const { client, lib } = await openSeeded(cacheDirectory);
|
||||
try {
|
||||
client.filesCalls.length = 0;
|
||||
|
||||
expect(await lib.refreshCollection(collection(2, 110))).toBe(false);
|
||||
expect(client.filesCalls).toEqual([]);
|
||||
expect(lib.getCollection(2)).toBeUndefined();
|
||||
} finally {
|
||||
await lib.close();
|
||||
}
|
||||
});
|
||||
|
||||
// The caller may remove the cache directory once `close()` resolves, so
|
||||
// the cache write must have landed by then.
|
||||
it("close() waits for a refreshCollection() in flight", async () => {
|
||||
const { client, lib } = await openSeeded(cacheDirectory);
|
||||
const files = holdNextFilesRequest(client);
|
||||
client.filesFor(1, {
|
||||
files: [file(1002, 1, 110)],
|
||||
deleted: [],
|
||||
cursor: 110,
|
||||
});
|
||||
|
||||
const refreshing = lib.refreshCollection(collection(1, 110));
|
||||
await files.started;
|
||||
let closed = false;
|
||||
const closing = lib.close().then(() => {
|
||||
closed = true;
|
||||
});
|
||||
await new Promise((r) => setImmediate(r));
|
||||
expect(closed).toBe(false);
|
||||
|
||||
files.release();
|
||||
await closing;
|
||||
const saved = await MetadataStore.load(
|
||||
join(cacheDirectory, "metadata.json"),
|
||||
);
|
||||
expect(saved.listFiles(1).map((f) => f.id)).toEqual([1001, 1002]);
|
||||
await refreshing;
|
||||
});
|
||||
|
||||
// `quak files 2` on an empty cache stores collection 2 alone. A library
|
||||
// opened later on that cache must not serve it as the whole account. Its
|
||||
// first refresh replaces what the cache holds, in the server's order.
|
||||
it("open() awaits the first refresh on a cache that only refreshCollection() wrote", async () => {
|
||||
const client = new MockClient();
|
||||
client.filesFor(2, {
|
||||
files: [file(2001, 2, 90)],
|
||||
deleted: [],
|
||||
cursor: 90,
|
||||
});
|
||||
const first = await Library.open({
|
||||
client,
|
||||
cacheDirectory,
|
||||
refreshOnOpen: false,
|
||||
refreshIntervalSeconds: SLOW_INTERVAL,
|
||||
});
|
||||
expect(await first.refreshCollection(collection(2, 100))).toBe(true);
|
||||
await first.close();
|
||||
|
||||
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,
|
||||
});
|
||||
client.filesCalls.length = 0;
|
||||
const lib = await Library.open({
|
||||
client,
|
||||
cacheDirectory,
|
||||
refreshIntervalSeconds: SLOW_INTERVAL,
|
||||
});
|
||||
try {
|
||||
expect(lib.status().lastRefreshAt).toBeDefined();
|
||||
expect(lib.listCollections().map((c) => c.id)).toEqual([1, 2]);
|
||||
// It fetches collection 2's files again, from the beginning.
|
||||
expect(client.filesCalls).toEqual([
|
||||
{ collectionID: 1, sinceTime: 0 },
|
||||
{ collectionID: 2, sinceTime: 0 },
|
||||
]);
|
||||
expect(lib.listFiles(2).map((f) => f.id)).toEqual([2001]);
|
||||
} finally {
|
||||
await lib.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
* 5. `close()` stops the timer and is idempotent, and its promise resolves
|
||||
* only once an in-flight refresh has written the cache file.
|
||||
* 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
|
||||
* 7. `open()` branches on the stored cursor. At 0, as on an empty cache, it
|
||||
* awaits the first refresh. Past 0 it serves the cache's 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.
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* These are the surface the GUI consumes across Electron IPC. `snapshot()` is
|
||||
* synchronous — it reads the in-RAM store and projects it into plain records
|
||||
* (no keys) — and `subscribe({ onChange })` delivers a `LibraryChange` whenever
|
||||
* a background refresh actually changes the derived records. The contracts:
|
||||
* a refresh or `refreshCollection()` changes the derived records. The
|
||||
* contracts:
|
||||
*
|
||||
* 1. `snapshot()` deduplicates a file across memberships into one record with
|
||||
* every album id, orders photos newest first, and carries no key material.
|
||||
|
||||
@@ -172,7 +172,7 @@ describe("MetadataStore", () => {
|
||||
expect(store.listCollections()).toEqual([]);
|
||||
});
|
||||
|
||||
it("loads an empty store when the list of what the refresh could not decrypt is damaged", async () => {
|
||||
it("loads an empty store when the list of what did not decrypt is damaged", async () => {
|
||||
// A backup reads that list on every run, so a damaged one that loaded
|
||||
// would fail every backup until the cache was deleted. A string is
|
||||
// damaged too, though JavaScript can loop over its characters.
|
||||
@@ -266,4 +266,23 @@ describe("MetadataStore", () => {
|
||||
expect(store.getFile(12345, 67890)).toBeUndefined();
|
||||
expect(store.getFile(55555, 67890)?.key).toEqual(inB.key);
|
||||
});
|
||||
|
||||
// A backup reports these in this order. It must not depend on whether a
|
||||
// refresh or `Library.refreshCollection()` fetched each one first.
|
||||
it("lists what did not decrypt by collection ID, then file ID", async () => {
|
||||
const store = await MetadataStore.load(path);
|
||||
store.putUndecryptable({ collectionID: 2, fileID: 7, error: "a" });
|
||||
store.putUndecryptable({ collectionID: 3, error: "b" });
|
||||
store.putUndecryptable({ collectionID: 1, fileID: 9, error: "c" });
|
||||
store.putUndecryptable({ collectionID: 2, fileID: 5, error: "d" });
|
||||
|
||||
expect(
|
||||
store.listUndecryptable().map((r) => [r.collectionID, r.fileID]),
|
||||
).toEqual([
|
||||
[1, 9],
|
||||
[2, 5],
|
||||
[2, 7],
|
||||
[3, undefined],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user