quak files COLLECTION fetches that collection's files alone (closes #217)
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 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
This commit is contained in:
2026-10-26 03:28:18 +00:00
parent 657266a60e
commit 98f674c3a3
9 changed files with 719 additions and 298 deletions
+96 -65
View File
@@ -685,17 +685,21 @@ library. The read commands — `collections`, `files`, `get`, `get-thumb`,
`helper fix-missing-thumbnails` — force a fresh server round-trip before they
answer, so they report current account state rather than whatever the cache last
held. `collections` asks the server for the collection list alone, in one
request, and neither fetches any collection's files nor changes the cache. The
others wait for a refresh. With an empty cache, as on a first run, that refresh
fetches the files of every collection, one collection after another, and stores
nothing until it has them all, which on a large account takes a while. Meanwhile
the command logs a line on stderr for each collection whose files it has
fetched, such as `Fetched the files of 3 of 120 collections`, with `--json` too;
`backup` does the same while the refresh it starts with runs. stdout holds only
the command's output. If that round-trip fails, the command prints the error on
one line and exits 1, or 3 when the server no longer accepts the saved session
(see "Session handling"). An album or file quak cannot decrypt does not fail it;
the command answers from what did decrypt (see "Default reads vs. fresh reads").
request, and neither fetches any collection's files nor changes the cache.
`files` with a collection ID asks for the collection list the same way, then for
the files of that collection alone, and stores them in the cache. It asks for
none of them when the cache's copy of that collection is current. `files`
without a collection ID and the other read commands wait for a refresh. With an
empty cache, as on a first run, that refresh fetches the files of every
collection, one collection after another, and stores nothing until it has them
all, which on a large account takes a while. Meanwhile the command logs a line
on stderr for each collection whose files it has fetched, such as
`Fetched the files of 3 of 120 collections`, with `--json` too; `backup` does
the same while the refresh it starts with runs. stdout holds only the command's
output. If that round-trip fails, the command prints the error on one line and
exits 1, or 3 when the server no longer accepts the saved session (see "Session
handling"). An album or file quak cannot decrypt does not fail it; the command
answers from what did decrypt (see "Default reads vs. fresh reads").
`--cache-dir` overrides where the cache lives; without it each account gets its
own directory under the per-user cache path.
@@ -1093,12 +1097,13 @@ against a fake of the Ente HTTP API, and `yarn test` verifies them.
`Library.open(options)` loads the on-disk cache, starts the background refresh
loop, and resolves to a `Library`. On an empty cache it awaits the first
refresh, so it opens onto the account's data whenever the server is reachable;
if that refresh fails, it opens with no data and records the error in
`lib.status()`. On an existing cache it returns immediately and 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 the
cache holds, empty or not; a refresh then starts only when `fresh()` or
refresh, so it opens onto the account's data whenever the server is reachable.
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
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.
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
`lib.backup()` asks, or the background timer fires. The CLI's read commands open
it so (see "CLI surface").
@@ -1127,8 +1132,8 @@ when the library opens; `lib.downloadDirectory` holds the result.
`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.
In between, a refresh sends `"progress"` after each collection whose files it
fetched, with `fetched`, how many collections it has fetched the files of so
far, and `total`, how many it will. A refresh fetches the files of each
fetched. That event's `fetched` is how many collections it has fetched the files
of so far, and its `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
it fetches those of every collection.
@@ -1138,11 +1143,16 @@ and pass it. The three pools default to 10 / 5 / 25 (see Request pools below).
`lib.status()` returns a `LibraryStatus` (collection/file counts, last
refresh/ML times and errors, originals usage and effective limit, precache
progress, and `closed`). `lib.close()` stops the background timer; it is
idempotent, and an in-flight refresh is left to finish. The promise it returns
resolves once that refresh (including its cache write), the ML data fetch and
the precache fetches already running have all finished, so the cache directory
can then be removed.
progress, and `closed`).
`lib.close()` stops the background timer, and is idempotent. A refresh or
`lib.refreshCollection()` in flight is left to finish. The promise `close()`
returns resolves once all of these have finished, so the cache directory can
then be removed:
- that refresh or `lib.refreshCollection()`, with its cache write
- the ML data fetch
- the precache fetches already running
`lib.status().mlDataErrors` lists, as `{ fileID, error }`, each file whose ML
data the last ML data fetch that succeeded got but could not decrypt, gunzip or
@@ -1156,39 +1166,58 @@ cache.
Default reads — `lib.albums`, `lib.photos`, `lib.timeline` — answer
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, and the
background timer starts the next refresh `refreshIntervalSeconds` after the
previous one ends. So a default read is immediate, but only as current as the
last refresh whose requests all succeeded; right after opening an existing
cache, it is the copy on disk.
changes that copy only once all its server requests have succeeded.
`lib.refreshCollection()` changes it the same way for one collection. So a
default read is immediate, but only as current as the last refresh or
`lib.refreshCollection()` whose requests all succeeded. Right after opening an
existing 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
same `{ albums, photos, timeline }` namespaces, which then reflect a completed
server round-trip. When a refresh is already running, background or not,
`fresh()` waits for that one, so its answer can come from requests made before
the call; only when none is running does it start one. A refresh that fails
rejects the caller (default reads stay silent and keep serving the last good
copy). The CLI's read commands use fresh reads (issue
https://git.eeqj.de/sneak/quak/issues/75).
server round-trip. It first waits for a running `lib.refreshCollection()` to
end. When a refresh is already running, background or not, `fresh()` waits for
that one. Its answer can then come from requests made before the call. Only when
none is running does it start one. A refresh that fails rejects the caller
(default reads stay silent and keep serving the last good copy). The CLI's read
commands use fresh reads (issue https://git.eeqj.de/sneak/quak/issues/75).
`await lib.freshCollections()` is the fresh read of the collection list alone:
one collections request, answered with every live collection that decrypts, as
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
since the stored copy, so a collection stored without its files would never get
since the stored copy. A collection stored without its files would never get
them. `quak collections` uses it (issue
https://git.eeqj.de/sneak/quak/issues/210).
`await lib.refreshCollection(collection)` brings one collection up to date in
the cache, given its `Collection` from `freshCollections()`. It does for that
collection what a refresh does for a changed one, and fetches no other
collection's files. It sends no request when the cache's copy has the same
`updationTime` or a later one. It also sends none when a refresh has already
dropped the collection from the cache, because the server deleted it or it did
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
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
refresh. It first waits for a running refresh to end. A `fresh()` or
`lib.backup()` called after that waits for it. `quak files` with a collection ID
uses it.
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
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
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
a later refresh gets a copy that decrypts or learns that the server deleted it.
`lib.backup()` reports each such album, and each such file in its scope that has
no copy that decrypted (see "Backup layout").
a later refresh or `lib.refreshCollection()` stores a copy that decrypts, or
learns that the server deleted it. `lib.backup()` reports each such album, and
each such file in its scope that has no copy that decrypted (see "Backup
layout").
### Read surface
@@ -1282,10 +1311,11 @@ The GUI-facing records hold no key material and no binary, so they survive
- `LibrarySnapshot`: `{ albums, photos, takenAt }`.
`lib.snapshot()` returns a `LibrarySnapshot` (albums newest-updated first,
photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange`
(`albumsChanged`, `photosChanged`, `fileIDsRemoved`, `albumIDsRemoved`,
`refreshedAt`) whenever a refresh alters the projection, and returns
`{ unsubscribe }`; a refresh that changes nothing delivers nothing.
photos newest first). `lib.subscribe({ onChange })` returns `{ unsubscribe }`.
It delivers a `LibraryChange` (`albumsChanged`, `photosChanged`,
`fileIDsRemoved`, `albumIDsRemoved`, `refreshedAt`) whenever a refresh or
`lib.refreshCollection()` alters the projection. One that changes nothing
delivers nothing.
### Thumbnails, ML search, and backup
@@ -1304,26 +1334,27 @@ photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange`
- `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
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)`
takes it, failing the same way, and returns a `BackupLock`, whose `release()`
removes the lock unless it was lost and whose `lost` holds why it was lost.
The caller passes it to the backup as `lock`, and releases it once the backup
ends. It waits for a refresh as `fresh()` does, puts every in-scope original
not already at its save path there as `photo.download()` does (and, with
`includeThumbnails`, fetches thumbnails) through the content cache, waits for
an ML data fetch, and rebuilds the on-disk backup tree with a durable failure
ledger. Each file's JSON holds its ML data and keywords. An image's JSON also
holds the EXIF and XMP of its original, and its dimensions if it is a JPEG. A
live photo's JSON holds these for its image. A video's JSON holds no EXIF, XMP
or dimensions. A fetched original is written straight to its save path and not
into the cache, which then counts it as present; one the cache already held is
copied from there. `BackupOptions`: `downloadDirectory` (falls back to the
library's), `includeOriginals` (default `true`), `includeThumbnails` (default
`false`), `onlyAlbumNames`, `verify` (default `false`), `onProgress`, and
`lock`. See Backup layout above for the tree it writes, and for the albums
`BackupResult` lists in `undecryptableAlbums` and `failedAlbums`. Whether the
backup took the lock or its caller did, once the lock is lost the backup
rejects with `lost the lock on <dir>: <reason>` before it starts another file.
it opens its library, as `quak backup` does. `await lockBackupDirectory(dir)`
takes it, failing the same way, and returns a `BackupLock`. Its `release()`
removes the lock unless it was lost, and its `lost` holds why it was lost. 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
in-scope original not already at its save path there, as `photo.download()`
does, through the content cache. With `includeThumbnails` it also fetches
thumbnails. It waits for an ML data fetch, and rebuilds the on-disk backup
tree with a durable failure ledger. Each file's JSON holds its ML data and
keywords. An image's JSON also holds the EXIF and XMP of its original, and its
dimensions if it is a JPEG. A live photo's JSON holds these for its image. A
video's JSON holds no EXIF, XMP or dimensions. A fetched original is written
straight to its save path and not into the cache, which then counts it as
present; one the cache already held is copied from there. `BackupOptions`:
`downloadDirectory` (falls back to the library's), `includeOriginals` (default
`true`), `includeThumbnails` (default `false`), `onlyAlbumNames`, `verify`
(default `false`), `onProgress`, and `lock`. See Backup layout above for the
tree it writes, and for the albums `BackupResult` lists in
`undecryptableAlbums` and `failedAlbums`. Whether the backup took the lock or
its caller did, once the lock is lost the backup rejects with
`lost the lock on <dir>: <reason>` before it starts another file.
### Request pools