1 Commits
Author SHA1 Message Date
sneak 659f9bb830 quak files COLLECTION fetches that collection's files alone (closes #217)
check / check (push) Canceled after 0s
`quak files COLLECTION` used to wait for a full refresh, which on an
empty cache fetched the files of every collection before it listed one.
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, and saves it. It runs in the same slot as
a refresh, so the two never run at the same time, and `close()` waits
for it. It never stores a collection that a refresh has already dropped.
It leaves the refresh cursor alone. `open()` therefore awaits the first
refresh on a cache whose cursor is still 0. `quak files` with no
argument still waits for a refresh.

Model: opus-5-5
2026-10-26 01:50:28 +00:00
2 changed files with 167 additions and 191 deletions

No files matched your search

+47 -54
View File
@@ -1102,8 +1102,8 @@ It also awaits it on a cache that holds only what `lib.refreshCollection()`
stored. If that refresh fails, it opens onto what the cache holds and records stored. If that refresh fails, it opens onto what the cache holds and records
the error in `lib.status()`. On any other cache it returns immediately and the error in `lib.status()`. On any other cache it returns immediately and
refreshes in the background, so an unreachable server does not block opening. refreshes in the background, so an unreachable server does not block opening.
With `refreshOnOpen: false` it starts no refresh, and returns at once onto what With `refreshOnOpen: false` it starts no refresh and returns at once onto what
the cache holds, empty or not. A refresh then starts only when `fresh()` or the cache holds, empty or not; a refresh then starts only when `fresh()` or
`lib.backup()` asks, or the background timer fires. The CLI's read commands open `lib.backup()` asks, or the background timer fires. The CLI's read commands open
it so (see "CLI surface"). it so (see "CLI surface").
@@ -1132,8 +1132,8 @@ when the library opens; `lib.downloadDirectory` holds the result.
`onProgress` receives a `RefreshEvent` with status `"started"` as a refresh, an `onProgress` receives a `RefreshEvent` with status `"started"` as a refresh, an
ML data fetch or a precache sweep starts, and `"done"` or `"failed"` as it ends. ML data fetch or a precache sweep starts, and `"done"` or `"failed"` as it ends.
In between, a refresh sends `"progress"` after each collection whose files it In between, a refresh sends `"progress"` after each collection whose files it
fetched. That event's `fetched` is how many collections it has fetched the files fetched, with `fetched`, how many collections it has fetched the files of so
of so far, and its `total` how many it will. A refresh fetches the files of each far, and `total`, how many it will. A refresh fetches the files of each
collection that is new or changed since the cache's copy, so on an empty cache collection that is new or changed since the cache's copy, so on an empty cache
it fetches those of every collection. it fetches those of every collection.
@@ -1166,31 +1166,27 @@ cache.
Default reads — `lib.albums`, `lib.photos`, `lib.timeline` — answer Default reads — `lib.albums`, `lib.photos`, `lib.timeline` — answer
synchronously from the copy held in RAM and never touch the network. A refresh synchronously from the copy held in RAM and never touch the network. A refresh
changes that copy only once all its server requests have succeeded. changes that copy only once all its server requests have succeeded, and the
`lib.refreshCollection()` changes it the same way for one collection. So a background timer starts the next refresh `refreshIntervalSeconds` after the
default read is immediate, but only as current as the last refresh or previous one ends. So a default read is immediate, but only as current as the
`lib.refreshCollection()` whose requests all succeeded. Right after opening an last refresh whose requests all succeeded; right after opening an existing
existing cache, it is the copy on disk. cache, it is the copy on disk.
The background timer takes a turn `refreshIntervalSeconds` after its last turn
ends. Each turn starts a refresh, unless a refresh or `lib.refreshCollection()`
is already running.
`await lib.fresh()` waits for a refresh to complete and persist, and returns the `await lib.fresh()` waits for a refresh to complete and persist, and returns the
same `{ albums, photos, timeline }` namespaces, which then reflect a completed same `{ albums, photos, timeline }` namespaces, which then reflect a completed
server round-trip. It first waits for a running `lib.refreshCollection()` to server round-trip. When a refresh is already running, background or not,
end. When a refresh is already running, background or not, `fresh()` waits for `fresh()` waits for that one, so its answer can come from requests made before
that one. Its answer can then come from requests made before the call. Only when the call; only when none is running does it start one. A refresh that fails
none is running does it start one. A refresh that fails rejects the caller rejects the caller (default reads stay silent and keep serving the last good
(default reads stay silent and keep serving the last good copy). The CLI's read copy). The CLI's read commands use fresh reads (issue
commands use fresh reads (issue https://git.eeqj.de/sneak/quak/issues/75). https://git.eeqj.de/sneak/quak/issues/75).
`await lib.freshCollections()` is the fresh read of the collection list alone: `await lib.freshCollections()` is the fresh read of the collection list alone:
one collections request, answered with every live collection that decrypts, as one collections request, answered with every live collection that decrypts, as
the `Collection` objects `listCollections()` returns, in the server's order. It the `Collection` objects `listCollections()` returns, in the server's order. It
fetches no collection's files and stores nothing, not even the list. A refresh fetches no collection's files and stores nothing, not even the list: a refresh
fetches a collection's files only when the collection is new or has changed fetches a collection's files only when the collection is new or has changed
since the stored copy. A collection stored without its files would never get since the stored copy, so a collection stored without its files would never get
them. `quak collections` uses it (issue them. `quak collections` uses it (issue
https://git.eeqj.de/sneak/quak/issues/210). https://git.eeqj.de/sneak/quak/issues/210).
@@ -1204,20 +1200,19 @@ not decrypt. Otherwise it fetches the collection's files changed since the
cache's copy, or all of them when there is none, then stores and saves them. The cache's copy, or all of them when there is none, then stores and saves them. The
refresh cursor stays where it was, so the next refresh still fetches the files refresh cursor stays 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 of every other collection that changed. It never runs at the same time as a
refresh. It first waits for a running refresh to end. A `fresh()` or refresh. It waits for a running refresh to end, and a `fresh()` or
`lib.backup()` called after that waits for it. `quak files` with a collection ID `lib.backup()` called meanwhile waits for it. `quak files` with a collection ID
uses it. uses it.
An album or file in the server's answer that quak cannot decrypt, or whose An album or file in the server's answer that quak cannot decrypt, or whose
metadata is not JSON, does not fail the refresh. The refresh leaves it out, an metadata is not JSON, does not fail the refresh. The refresh leaves it out, an
album with all its files, and drops any older copy the cache held, so reads album with all its files, and drops any older copy the cache held, so reads
answer from what did decrypt. The library keeps a list of what it left out, with answer from what did decrypt. The library keeps a list of what it left out, with
the reason, in `metadata.json`. The next refresh asks the server only for what the reason, in `metadata.json`: the next refresh asks the server only for what
changed, so it would not see the record again. A record stays on that list until changed, so it would not see the record again. A record stays on that list until
a later refresh or `lib.refreshCollection()` stores a copy that decrypts, or a later refresh gets a copy that decrypts or learns that the server deleted it.
learns that the server deleted it. `lib.backup()` reports each such album, and `lib.backup()` reports each such album, and each such file in its scope that has
each such file in its scope that has no copy that decrypted (see "Backup no copy that decrypted (see "Backup layout").
layout").
### Read surface ### Read surface
@@ -1311,11 +1306,10 @@ The GUI-facing records hold no key material and no binary, so they survive
- `LibrarySnapshot`: `{ albums, photos, takenAt }`. - `LibrarySnapshot`: `{ albums, photos, takenAt }`.
`lib.snapshot()` returns a `LibrarySnapshot` (albums newest-updated first, `lib.snapshot()` returns a `LibrarySnapshot` (albums newest-updated first,
photos newest first). `lib.subscribe({ onChange })` returns `{ unsubscribe }`. photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange`
It delivers a `LibraryChange` (`albumsChanged`, `photosChanged`, (`albumsChanged`, `photosChanged`, `fileIDsRemoved`, `albumIDsRemoved`,
`fileIDsRemoved`, `albumIDsRemoved`, `refreshedAt`) whenever a refresh or `refreshedAt`) whenever a refresh alters the projection, and returns
`lib.refreshCollection()` alters the projection. One that changes nothing `{ unsubscribe }`; a refresh that changes nothing delivers nothing.
delivers nothing.
### Thumbnails, ML search, and backup ### Thumbnails, ML search, and backup
@@ -1334,27 +1328,26 @@ delivers nothing.
- `await lib.backup(opts?)` → `BackupResult`. It takes the lock in the download - `await lib.backup(opts?)` → `BackupResult`. It takes the lock in the download
directory, and fails at once with an error whose `code` is `ELOCKED` while directory, and fails at once with an error whose `code` is `ELOCKED` while
another backup of it runs. A caller can instead take the lock itself, before another backup of it runs. A caller can instead take the lock itself, before
it opens its library, as `quak backup` does. `await lockBackupDirectory(dir)` it opens its library, as `quak backup` does: `await lockBackupDirectory(dir)`
takes it, failing the same way, and returns a `BackupLock`. Its `release()` takes it, failing the same way, and returns a `BackupLock`, whose `release()`
removes the lock unless it was lost, and its `lost` holds why it was lost. The removes the lock unless it was lost and whose `lost` holds why it was lost.
caller passes it to the backup as `lock`, and releases it once the backup The caller passes it to the backup as `lock`, and releases it once the backup
ends. The backup waits for a refresh as `fresh()` does. It then puts every ends. It waits for a refresh as `fresh()` does, puts every in-scope original
in-scope original not already at its save path there, as `photo.download()` not already at its save path there as `photo.download()` does (and, with
does, through the content cache. With `includeThumbnails` it also fetches `includeThumbnails`, fetches thumbnails) through the content cache, waits for
thumbnails. It waits for an ML data fetch, and rebuilds the on-disk backup an ML data fetch, and rebuilds the on-disk backup tree with a durable failure
tree with a durable failure ledger. Each file's JSON holds its ML data and ledger. Each file's JSON holds its ML data and keywords. An image's JSON also
keywords. An image's JSON also holds the EXIF and XMP of its original, and its holds the EXIF and XMP of its original, and its dimensions if it is a JPEG. A
dimensions if it is a JPEG. A live photo's JSON holds these for its image. A live photo's JSON holds these for its image. A video's JSON holds no EXIF, XMP
video's JSON holds no EXIF, XMP or dimensions. A fetched original is written or dimensions. A fetched original is written straight to its save path and not
straight to its save path and not into the cache, which then counts it as into the cache, which then counts it as present; one the cache already held is
present; one the cache already held is copied from there. `BackupOptions`: copied from there. `BackupOptions`: `downloadDirectory` (falls back to the
`downloadDirectory` (falls back to the library's), `includeOriginals` (default library's), `includeOriginals` (default `true`), `includeThumbnails` (default
`true`), `includeThumbnails` (default `false`), `onlyAlbumNames`, `verify` `false`), `onlyAlbumNames`, `verify` (default `false`), `onProgress`, and
(default `false`), `onProgress`, and `lock`. See Backup layout above for the `lock`. See Backup layout above for the tree it writes, and for the albums
tree it writes, and for the albums `BackupResult` lists in `BackupResult` lists in `undecryptableAlbums` and `failedAlbums`. Whether the
`undecryptableAlbums` and `failedAlbums`. Whether the backup took the lock or backup took the lock or its caller did, once the lock is lost the backup
its caller did, once the lock is lost the backup rejects with rejects with `lost the lock on <dir>: <reason>` before it starts another file.
`lost the lock on <dir>: <reason>` before it starts another file.
### Request pools ### Request pools
+120 -137
View File
@@ -1,33 +1,32 @@
// The library surface over the local cache. // The library surface over the local cache.
// //
// `Library.open()` loads the on-disk metadata store (issue #41), then starts // `Library.open()` loads the on-disk metadata store (issue #41), then starts
// the refresh loop. When the stored cursor is 0, `open()` awaits the first // the refresh loop. When no refresh has stored a collection yet (the stored
// refresh. No refresh has then stored any collection: the cache is empty, or // cursor is 0), `open()` awaits the first refresh. The cache is then empty, or
// holds only what `refreshCollection()` stored. The library must not serve // holds only what `refreshCollection()` stored, and the library must not serve
// that as the whole account. Otherwise that first refresh runs in the // it as the whole account. Otherwise that first refresh runs in the
// background. `open()` returns as soon as the cached data is ready to serve, // background. `open()` returns as soon as the cached data is ready to serve,
// so a slow or unreachable server does not stall opening. A background timer // so a slow or unreachable server does not stall opening. A background timer
// then takes a turn `refreshIntervalSeconds` after its last turn ends. Each // then refreshes every `refreshIntervalSeconds`. Every default read is
// turn starts a refresh, unless a refresh or `refreshCollection()` is already // answered from RAM — no default read touches the network. There is
// running. Every default read is answered from RAM — no default read touches // deliberately no `sync()`, no `refresh()`, no `serverReachable` flag, and no
// the network. There is deliberately no `sync()`, no `refresh()`, no // "before each read" mode (design #36).
// `serverReachable` flag, and no "before each read" mode (design #36).
// //
// `fresh()` is an exception (issue #75, an owner amendment to #36). It awaits // `fresh()` is the one exception (issue #75, an owner amendment to #36): it
// a refresh, and only then hands back the read namespaces. A caller that needs // forces a refresh, awaits it, and only then hands back the read namespaces, so
// server-current data uses it. Concurrent `fresh()` calls coalesce onto one // a caller that needs server-current data can ask for it. Concurrent `fresh()`
// in-flight refresh. A refresh that fails rejects the caller. The default // calls coalesce onto one in-flight refresh, and a refresh that fails rejects
// reads stay silent and serve the last good copy. The default methods and the // the caller (the default reads stay silent and serve the last good copy). The
// background loop are unchanged. // default methods and the background loop are unchanged.
// //
// `freshCollections()` is the fresh read of the collection list alone (issue // `freshCollections()` is the fresh read of the collection list alone (issue
// #210). It sends one collections request and stores nothing. // #210): one collections request, no collection's files, and nothing stored,
// `quak collections` uses it. `refreshCollection()` brings one collection // for a caller such as `quak collections` that needs no more.
// from that list up to date in the store (issue #217). It fetches that // `refreshCollection()` brings one collection from that list up to date in the
// collection's files alone, as a refresh would. `quak files COLLECTION` uses // store (issue #217). It fetches that collection's files alone, as a refresh
// it. It never runs at the same time as a refresh. A caller that asks for each // would. `quak files COLLECTION` uses it. It never runs at the same time as a
// refresh it needs can also open the library with `refreshOnOpen: false`, so // refresh. A caller that asks for each refresh it needs can also open the
// that `open()` starts none. // library with `refreshOnOpen: false`, so that `open()` starts none.
// //
// A refresh reports through `onProgress` each collection whose files it has // A refresh reports through `onProgress` each collection whose files it has
// fetched, so a large account, whose first refresh fetches the files of every // fetched, so a large account, whose first refresh fetches the files of every
@@ -36,12 +35,10 @@
// A refresh stages all of its network work first and only mutates the store // 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 // 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 // becomes visible to reads: the last good snapshot stays in place, and the
// failure surfaces through `onProgress` and `status()` instead. A save can // failure surfaces through `onProgress` and `status()` instead. A refresh that
// still fail after the store has changed in RAM. The store then stays marked // mutates RAM but then fails to persist keeps `status().lastError` set and the
// unsaved until a later save lands. Until then, every `commit()` retries the // store marked unsaved until a later save actually lands, so a stuck disk is
// save. A refresh whose save fails sets `status().lastError`, which stays set // never masked by a subsequent empty refresh.
// until a later refresh succeeds. `refreshCollection()` never sets or clears
// it. So a stuck disk is never hidden by a later refresh that changes nothing.
import { rm } from "node:fs/promises"; import { rm } from "node:fs/promises";
import { join, resolve } from "node:path"; import { join, resolve } from "node:path";
@@ -166,23 +163,21 @@ export interface LibraryClient {
payloads: Map<number, MLData>; payloads: Map<number, MLData>;
errors: Map<number, unknown>; errors: Map<number, unknown>;
}>; }>;
// The byte source for the on-disk content cache. Optional, so a mock // The byte source for the on-disk content cache. Optional so a mock client
// client that only serves metadata still satisfies the interface. Without // that only serves metadata still satisfies the interface; when absent (and
// it, and with no `contentSource` passed to `open`, the content cache is // no explicit `contentSource` is passed to `open`) the content cache is
// disabled. `Photo.original`/`thumbnail` and `thumbnails.ensure` then // disabled and `Photo.original`/`thumbnail` and `thumbnails.ensure` throw.
// throw.
contentSource?(): ContentSource; contentSource?(): ContentSource;
} }
// A progress event for one unit of background work. A metadata "refresh" and // A progress event for one unit of background work. A metadata "refresh" or an
// an ML "fetchMLData" pass each fire "started" before their network work. Each // ML "fetchMLData" pass each fire "started" before their network work and then
// then fires exactly one of "done" or "failed". A "failed" event carries the // exactly one of "done" or "failed"; "failed" carries the error message and
// error message. An ML "done" reports in `fetched` how many payloads it // an ML "done" reports how many payloads it stored and, in `mlDataErrors`,
// stored. Its `mlDataErrors` lists each file whose ML data did not decrypt, // each file whose ML data did not decrypt, gunzip or parse, with the reason.
// gunzip or parse, with the reason. In between, a refresh fires "progress" // In between, a refresh fires "progress" after each collection whose files it
// after each collection whose files it fetched. Its `fetched` is how many // fetched: `fetched` is how many collections it has fetched the files of so
// collections it has fetched the files of so far, and `total` how many it // far, `total` how many it will. The precache fills
// will. The precache fills
// ("precacheThumbnails"/"precacheOriginals", #48) fire "started"/"done" around // ("precacheThumbnails"/"precacheOriginals", #48) fire "started"/"done" around
// each sweep that has work, "done" reporting the count newly cached. // each sweep that has work, "done" reporting the count newly cached.
export interface RefreshEvent { export interface RefreshEvent {
@@ -210,10 +205,9 @@ export interface LibraryOptions {
// directory at open. The content cache treats an original already stored // directory at open. The content cache treats an original already stored
// at its save path as present. // at its save path as present.
downloadDirectory?: string; downloadDirectory?: string;
// How long after its last turn ends the background timer takes the next. // How long after one refresh ends the background timer starts the next.
// A turn starts a refresh unless a refresh or `refreshCollection()` is // `Infinity` starts none: the library then refreshes only as it opens and
// running. With `Infinity` the timer takes no turn: the library then // when `fresh()` or `backup()` asks.
// refreshes only as it opens and when `fresh()` or `backup()` asks.
refreshIntervalSeconds?: number; refreshIntervalSeconds?: number;
// Whether `open()` starts a refresh. Defaults to true. With false, `open()` // Whether `open()` starts a refresh. Defaults to true. With false, `open()`
// returns at once onto what the cache holds, empty or not, and a refresh // returns at once onto what the cache holds, empty or not, and a refresh
@@ -252,9 +246,8 @@ export interface LibraryStatus {
// Wall-clock ms of the last refresh that succeeded, or undefined if none // Wall-clock ms of the last refresh that succeeded, or undefined if none
// has yet. // has yet.
lastRefreshAt?: number; lastRefreshAt?: number;
// The error of the most recent refresh, set only when it failed. The next // The message from the most recent refresh, set only while that refresh
// refresh that succeeds clears it. `refreshCollection()` never sets or // failed; cleared by the next success.
// clears it.
lastError?: string; lastError?: string;
// Wall-clock ms of the last ML fetch pass that succeeded, or undefined if // Wall-clock ms of the last ML fetch pass that succeeded, or undefined if
// none has yet (or ML fetching is disabled). // none has yet (or ML fetching is disabled).
@@ -322,9 +315,8 @@ export class Library {
// The background loop skips while it is set. A fresh read (issue #75) // The background loop skips while it is set. A fresh read (issue #75)
// coalesces onto a running refresh. It waits for a running // coalesces onto a running refresh. It waits for a running
// `refreshCollection()` to end, and then joins or starts a refresh. The // `refreshCollection()` to end, and then joins or starts a refresh. The
// promise carries the real outcome: it rejects on failure. The background // promise carries the real outcome (it rejects on failure); the background
// loop ignores a failure. A fresh read passes on the failure of the // loop ignores that, a fresh read propagates it.
// refresh it joins or starts, and `refreshCollection()` passes on its own.
private cycle?: Promise<void>; private cycle?: Promise<void>;
// Whether `cycle` is a refresh cycle rather than a `refreshCollection()`. // Whether `cycle` is a refresh cycle rather than a `refreshCollection()`.
private cycleIsRefresh = false; private cycleIsRefresh = false;
@@ -340,20 +332,19 @@ export class Library {
private lastMLError?: string; private lastMLError?: string;
// For each file whose ML data the last ML fetch pass that succeeded got // For each file whose ML data the last ML fetch pass that succeeded got
// but could not decrypt, gunzip or parse, why not, by fileID. A pass // but could not decrypt, gunzip or parse, why not, by fileID. A pass
// gathers its own and puts them here only when it ends without failing. // gathers its own and puts them here when it ends without failing, so
// So `status()` and `backup()` never see the list of a pass still running // `status()` and `backup()` never see the list of a pass that is still
// or one that failed. Kept in RAM only, never in the cache. // running or that failed. Kept in RAM only, never in the cache.
private mlDataErrors = new Map<number, unknown>(); private mlDataErrors = new Map<number, unknown>();
// The plain-record projection as of the last store change, and the GUI // The plain-record projection as of the last refresh, and the GUI change
// change subscribers. A refresh or `refreshCollection()` that alters the // subscribers. A refresh that alters the projection notifies each with the
// projection notifies each subscriber with the delta. `lastRecords` // delta; `lastRecords` is kept current every refresh so a subscriber that
// advances on each `commit()` that changes the store. A subscriber that // joins later diffs against the state its own `snapshot()` already returned.
// joins later then diffs against the state its own `snapshot()` returned.
private readonly subscribers = new Set<(change: LibraryChange) => void>(); private readonly subscribers = new Set<(change: LibraryChange) => void>();
private lastRecords: DerivedRecords; private lastRecords: DerivedRecords;
// RAM holds changes disk has not yet accepted (an earlier save failed). // RAM holds changes disk has not yet accepted (an earlier save failed).
// Cleared only when a save succeeds. Until then, every `commit()` retries // Cleared only when a save actually succeeds; keeps the store trying to
// the save. // persist and the failure visible in `status()` until then.
private unsaved = false; private unsaved = false;
private constructor(args: { private constructor(args: {
@@ -383,7 +374,7 @@ export class Library {
this.lastRecords = this.deriveNow(); this.lastRecords = this.deriveNow();
// The read namespaces derive fresh from the store on each call, so they // The read namespaces derive fresh from the store on each call, so they
// always reflect what it holds now. // always reflect the latest refresh.
const derive = (): DerivedRecords => this.deriveNow(); const derive = (): DerivedRecords => this.deriveNow();
const root = this.downloadDirectory; const root = this.downloadDirectory;
const saves: SavePathLookup = { const saves: SavePathLookup = {
@@ -435,8 +426,8 @@ export class Library {
let store = await MetadataStore.load(metadataPath); let store = await MetadataStore.load(metadataPath);
// A cache directory given explicitly can hold another account's cache. // A cache directory given explicitly can hold another account's cache.
// Its records and cursor are not this account's, so delete it and the // Its records and cursor are not this account's, so delete it and the
// ML data beside it and start empty. A user ID of 0 means nothing was // ML data beside it and start empty. A user ID of 0 means the cache
// ever stored there, so there is nothing to discard. // was never refreshed and so holds nothing to discard.
if (store.userID !== 0 && store.userID !== userID) { if (store.userID !== 0 && store.userID !== userID) {
await rm(metadataPath, { force: true }); await rm(metadataPath, { force: true });
await rm(join(cacheDirectory, "mldata"), { await rm(join(cacheDirectory, "mldata"), {
@@ -459,11 +450,10 @@ export class Library {
? await MLDataStore.open(join(cacheDirectory, "mldata")) ? await MLDataStore.open(join(cacheDirectory, "mldata"))
: undefined; : undefined;
// Build the content cache from an explicit source or the client's own. // Build the content cache from an explicit source or the client's own,
// Take its record of what is already cached, and reap orphan temp // and take its record of what is already cached (and reap orphan temp
// files, before the first projection. Cached paths are then present // files) before the first projection, so cached paths are present from
// from the start, and the first `commit()` raises no spurious // the start and the first refresh raises no spurious path-change diff.
// path-change diff.
const source = opts.contentSource ?? opts.client.contentSource?.(); const source = opts.contentSource ?? opts.client.contentSource?.();
let cache: ContentCache | undefined; let cache: ContentCache | undefined;
let precache: Precache | undefined; let precache: Precache | undefined;
@@ -517,10 +507,10 @@ export class Library {
precache, precache,
}); });
// Start filling from whatever the loaded store already holds. Each // Start filling from whatever the loaded store already holds; each
// later `commit()` re-kicks the fills with its new files, and retries // refresh below re-kicks with the new files (and retries any that
// any that failed. An empty store starts empty here, and fills once a // failed). An empty store starts empty here and fills after its first
// `commit()` stores files. // refresh.
precache?.start(); precache?.start();
if (opts.refreshOnOpen === false) { if (opts.refreshOnOpen === false) {
@@ -574,9 +564,9 @@ export class Library {
return snapshotFrom(this.deriveNow(), Date.now()); return snapshotFrom(this.deriveNow(), Date.now());
} }
// Deliver a `LibraryChange` whenever a refresh or `refreshCollection()` // Deliver a `LibraryChange` whenever a refresh alters the projection. A
// alters the projection. One that changes nothing delivers nothing. The // refresh that changes nothing delivers nothing. The returned handle's
// returned handle's `unsubscribe` stops delivery. // `unsubscribe` stops delivery.
subscribe(args: { onChange: (change: LibraryChange) => void }): { subscribe(args: { onChange: (change: LibraryChange) => void }): {
unsubscribe: () => void; unsubscribe: () => void;
} { } {
@@ -619,13 +609,12 @@ export class Library {
}; };
} }
// Fresh reads (issue #75, owner amendment to design #36). Wait for a // Fresh reads (issue #75, owner amendment to design #36). Force a refresh,
// refresh to complete and persist, then hand back the same // wait for it to complete and persist, then hand back the same
// `albums`/`photos`/`timeline` namespaces. They then reflect a completed // `albums`/`photos`/`timeline` namespaces — now guaranteed to reflect a
// server round-trip. A running `refreshCollection()` is waited for first. // completed server round-trip. Concurrent calls coalesce onto one refresh;
// Concurrent calls coalesce onto one refresh. A refresh that fails rejects // a refresh that fails rejects here, where the default namespaces would
// here, where the default namespaces would stay silent and serve the last // instead stay silent and serve the last good copy.
// good copy.
async fresh(): Promise<FreshReads> { async fresh(): Promise<FreshReads> {
await this.refreshNow(); await this.refreshNow();
return { return {
@@ -638,10 +627,10 @@ export class Library {
// The fresh read of the collection list alone (issue #210): every live // The fresh read of the collection list alone (issue #210): every live
// collection that decrypts, in the server's order, from one collections // collection that decrypts, in the server's order, from one collections
// request. Unlike a refresh, it fetches no collection's files, which on a // request. Unlike a refresh, it fetches no collection's files, which on a
// large account take long. It stores nothing. A refresh fetches a // large account take long. Nothing is stored: a refresh fetches a
// collection's files only when the collection is new or has changed since // collection's files only when the collection is new or has changed since
// the stored copy. A collection stored here without its files would never // the stored copy, so a collection stored here without its files would
// have them fetched. // never have them fetched.
async freshCollections(): Promise<Collection[]> { async freshCollections(): Promise<Collection[]> {
if (this.closed) throw new Error("the library is closed"); if (this.closed) throw new Error("the library is closed");
const { collections } = await this.client.collectionsSince({ const { collections } = await this.client.collectionsSince({
@@ -655,8 +644,8 @@ export class Library {
// refresh does for a changed one. It fetches no other collection's files. // refresh does for a changed one. It fetches no other collection's files.
// It runs in the same slot as a refresh, so the two never run at the same // It runs in the same slot as a refresh, so the two never run at the same
// time. It first waits for a running refresh or `refreshCollection()` to // time. It first waits for a running refresh or `refreshCollection()` to
// end. While its own work runs, a fresh read or `backup()` waits for it, // end. A fresh read or `backup()` that starts meanwhile waits for it, and
// and the background loop skips its turn. // the background loop skips its turn.
async refreshCollection(collection: Collection): Promise<void> { async refreshCollection(collection: Collection): Promise<void> {
while (this.cycle) await this.cycle.catch(() => {}); while (this.cycle) await this.cycle.catch(() => {});
if (this.closed) throw new Error("the library is closed"); if (this.closed) throw new Error("the library is closed");
@@ -667,18 +656,17 @@ export class Library {
// Back up every in-scope file to `opts.downloadDirectory`, or else the // Back up every in-scope file to `opts.downloadDirectory`, or else the
// library's, each original at its save path, with a durable failure // library's, each original at its save path, with a durable failure
// ledger (issue #51). It first takes the lock in that directory, unless // ledger (issue #51). Takes the lock in that directory first, unless the
// the caller passes the lock it took as `opts.lock`. Taking it fails at // caller passes the lock it took as `opts.lock`, failing at once while
// once while another backup of that directory runs. Once the lock is lost, // another backup of it runs, and before it starts another file once the
// the backup fails before it starts another file (see // lock is lost (see `lockBackupDirectory`). Waits for a
// `lockBackupDirectory`). It waits for a completed refresh as `fresh()` // completed refresh, as `fresh()` does, joining one already running, and
// does, joining one already running. When that refresh fails, it rejects // rejects before touching any file but the lock when it fails. Then puts
// before touching any file but the lock. It then puts pending originals // pending originals at their save paths as `Photo.download()` does (and
// at their save paths as `Photo.download()` does, through the content // optional thumbnails) through the content cache and pools, waits for an
// cache and pools. With `includeThumbnails` it also fetches thumbnails. It // ML data fetch, and rebuilds the derived symlink/JSON views from the
// waits for an ML data fetch. It rebuilds the derived symlink/JSON views // model, each file's JSON with its ML data, beside an `account.json` with
// from the model, each file's JSON with its ML data. An `account.json` // the account's email and user ID.
// beside them holds the account's email and user ID.
// Throws before any network work when no content cache backs the // Throws before any network work when no content cache backs the
// originals it must fetch. // originals it must fetch.
backup(opts?: BackupOptions): Promise<BackupResult> { backup(opts?: BackupOptions): Promise<BackupResult> {
@@ -722,8 +710,7 @@ export class Library {
// - the ML fetch pass; // - the ML fetch pass;
// - the precache fetches already running. // - the precache fetches already running.
// A caller can then remove the cache directory. A refresh failure is // A caller can then remove the cache directory. A refresh failure is
// reported through `status()`, and a `refreshCollection()` failure to its // reported through `status()`, not thrown here.
// own caller. Neither is thrown here.
async close(): Promise<void> { async close(): Promise<void> {
this.closed = true; this.closed = true;
const precacheClosed = this.precache?.close(); const precacheClosed = this.precache?.close();
@@ -749,18 +736,17 @@ export class Library {
// The background loop's refresh: run a cycle unless a refresh or // The background loop's refresh: run a cycle unless a refresh or
// `refreshCollection()` is in flight (or the library is closed), and never // `refreshCollection()` is in flight (or the library is closed), and never
// let a failure escape. The background path reports errors through // let a failure escape. The background path reports errors through
// `status()`/`onProgress`; it does not throw. It resolves once the cycle // `status()`/`onProgress`; it does not throw. Resolves once the cycle it
// it started settles, or at once when it starts none. // started (or skipped past) settles.
private runRefresh(): Promise<void> { private runRefresh(): Promise<void> {
if (this.closed || this.cycle) return Promise.resolve(); if (this.closed || this.cycle) return Promise.resolve();
return this.startCycle(true, () => this.refreshCycle()).catch(() => {}); return this.startCycle(true, () => this.refreshCycle()).catch(() => {});
} }
// A fresh read's refresh (issue #75): join the running refresh or start // A fresh read's refresh (issue #75): force a cycle and await it, rejecting
// one, and await it, rejecting if it fails. Concurrent fresh reads // if it fails. Concurrent fresh reads coalesce onto the one in-flight cycle
// coalesce onto the one in-flight cycle — the background loop's included — // — the background loop's included — so they never fan out into redundant
// so they never fan out into redundant server round-trips. A running // server round-trips. A running `refreshCollection()` is waited for first.
// `refreshCollection()` is waited for first.
private async refreshNow(): Promise<void> { private async refreshNow(): Promise<void> {
while (this.cycle && !this.cycleIsRefresh) { while (this.cycle && !this.cycleIsRefresh) {
await this.cycle.catch(() => {}); await this.cycle.catch(() => {});
@@ -803,9 +789,9 @@ export class Library {
this.lastError = undefined; this.lastError = undefined;
this.emit({ operation: "refresh", status: "done" }); this.emit({ operation: "refresh", status: "done" });
// Backfill ML data for the files this refresh knows about. It runs // Backfill ML data for the files this refresh knows about. It runs
// outside the refresh's success or failure, so a fetch or disk // outside the refresh's success/failure so a fetch or disk problem
// problem there never marks the metadata refresh failed. It is not // there never marks the metadata refresh failed, and it is not
// awaited, so it never stalls the refresh interval. Its failure is // awaited so it never stalls the refresh interval. Its failure is
// reported through `status()` and `onProgress`. // reported through `status()` and `onProgress`.
void this.fetchMLDataNow().catch(() => {}); void this.fetchMLDataNow().catch(() => {});
} catch (err) { } catch (err) {
@@ -863,8 +849,8 @@ export class Library {
// No copy is stored, yet the stored cursor has reached this // No copy is stored, yet the stored cursor has reached this
// `updationTime`. A refresh has then seen this version or a later one, // `updationTime`. A refresh has then seen this version or a later one,
// and dropped the collection: it was deleted, or did not decrypt. // and dropped the collection: it was deleted, or did not decrypt.
// Storing it would bring it back, and no later refresh would see it // Storing it would bring it back for good, since no later refresh sees
// again until the server's copy changes. // it again.
if ( if (
!known && !known &&
collection.updationTime <= this.store.collectionsSinceTime collection.updationTime <= this.store.collectionsSinceTime
@@ -889,16 +875,15 @@ export class Library {
} }
// Commit a collections page and the file pages fetched for its collections // Commit a collections page and the file pages fetched for its collections
// to the store. A collection or file that does not decrypt is left out, as // to the store, then save. A collection or file that does not decrypt is
// if deleted. The reason is kept in the store. No later refresh sees it // left out, as if deleted. The reason is kept in the store. No later
// again until the server's copy changes. Last, save the store when it // refresh sees it again until the server's copy changes.
// holds changes not yet saved.
private async commit( private async commit(
page: CollectionsPage, page: CollectionsPage,
filePages: { collectionID: number; page: FilesPage }[], filePages: { collectionID: number; page: FilesPage }[],
): Promise<void> { ): Promise<void> {
// The network work is done, so change the store now. `changed` says // Network work done; commit to the store and persist only if something
// whether this commit changed anything. // actually changed.
let changed = false; let changed = false;
if (this.store.userID !== this.userID) { if (this.store.userID !== this.userID) {
@@ -961,8 +946,8 @@ export class Library {
// Reproject and notify subscribers of the delta. This tracks RAM (what // Reproject and notify subscribers of the delta. This tracks RAM (what
// reads see), so it fires whether or not the save below succeeds. A // reads see), so it fires whether or not the save below succeeds. A
// failed save rejects the caller, and a refresh also records it in // failed save rejects the caller, and a refresh also records it in
// `status().lastError`. `lastRecords` advances on every commit that // `status().lastError`. `lastRecords` advances every changed refresh so
// changes something, so the next diff is against current state. // the next diff is against current state.
if (changed) { if (changed) {
const next = this.deriveNow(); const next = this.deriveNow();
if (this.subscribers.size > 0) { if (this.subscribers.size > 0) {
@@ -973,9 +958,8 @@ export class Library {
// Recompute the fill orders and pinned set against the new library. // Recompute the fill orders and pinned set against the new library.
this.precache?.update(next); this.precache?.update(next);
} }
// Re-kick the fills on every commit. A finished sweep starts afresh to // Re-kick the fills every cycle: a finished sweep starts afresh to pick
// pick up new files and retry any that failed. A running one is left // up new files and retry any that failed, and a running one is left be.
// be.
this.precache?.start(); this.precache?.start();
// Persist whenever RAM holds changes disk has not accepted — including // Persist whenever RAM holds changes disk has not accepted — including
@@ -998,14 +982,13 @@ export class Library {
return this.mlFetch; return this.mlFetch;
} }
// One ML fetch pass. It fetches, decrypts and stores the ML data of every // One ML fetch pass: fetch, decrypt and store the ML data for every file
// file the store knows about that is not cached, or whose `updationTime` // the store knows about that is not cached (or whose `updationTime` has
// has advanced. It fetches through the metadata pool, and updates the CLIP // advanced), through the metadata pool, and update the CLIP index. For a
// index. For a file whose ML data does not decrypt, gunzip or parse, it // file whose ML data does not decrypt, gunzip or parse, it gathers the
// gathers the reason instead. It puts the reasons it gathered in // reason instead, and puts the reasons it gathered in `mlDataErrors` when
// `mlDataErrors` when it ends without failing. A failure leaves // it ends without failing. A failure leaves `mlDataErrors` as it was and
// `mlDataErrors` as it was and is reported through `status()` and // is reported through `status()` and `onProgress`, then thrown.
// `onProgress`, then thrown.
private async runMLFetch(): Promise<void> { private async runMLFetch(): Promise<void> {
const mldata = this.mlStore; const mldata = this.mlStore;
// Bind so the call keeps the client as its receiver when invoked // Bind so the call keeps the client as its receiver when invoked
@@ -1073,9 +1056,9 @@ export class Library {
})); }));
} }
// The distinct files the store holds, one entry per fileID, since a file // The distinct files the store holds, one entry per fileID (a file in
// in several collections shares its ML data. Each entry carries the key // several collections shares its ML data), each carrying the key and the
// and the newest `updationTime` seen across the file's memberships. // newest `updationTime` seen across its memberships.
private uniqueFiles(): { private uniqueFiles(): {
id: number; id: number;
key: Uint8Array; key: Uint8Array;