Author SHA1 Message Date
clawbot b8c5a26552 An expired session exits 3 with one line saying to run quak login (closes #164)
check / check (push) Waiting to run
A 401 from the server that ends a command reaches run in src/cli-run.ts as
an ApiError; run prints one line saying to run "quak login" and exits 3.
quak backup meets it on its first refresh, before it touches any file. For
the commands that load the saved session, a missing or corrupt session file
keeps its message and also exits 3.

Judgement call: quak logout is unchanged; it handles its own errors and
deletes the session file whatever the server answers.
Judgement call: quak backup still prints its two progress lines before the
error line.

Model: opus-5-5
2026-10-06 10:48:13 +00:00
clawbot ad0e11407f quak backup retries failed requests for longer (closes #165)
check / check (push) Waiting to run
src/retry.ts exports UNATTENDED_RETRY_OPTIONS beside the unchanged
default: 10 attempts, a 1 s base delay and a 60 s cap, so a request that
keeps failing waits at most 243 s before it gives up. bin/quak.ts loads
the backup's session with them, so its refresh, ML data and downloads
all use them; every other command keeps the default. What is retried
and the backoff formula are unchanged.

Model: opus-5-5
2026-10-06 12:47:40 +02:00
clawbot fb4fff59fe Re-vendor the canonical files from sneak/prompts at dd4027b (closes #171)
check / check (push) Waiting to run
.dockerignore, .gitignore, .prettierignore and REPO_POLICIES.md are
copied from sneak/prompts at dd4027b; .editorconfig, check.yml and
.prettierrc already matched. This repository's build artifacts follow
the copied content in .gitignore and, anchored, in .dockerignore. The
scripts follow the new policy: check runs fmt-check, cibuild bootstraps
and runs check before the image build, fmt and fmt-check find the
pinned yarn under nvm, and the image's last stage marks /app safe for
git. The build-context test expects the new pattern forms.

Judgement call: script/precommit runs lint and fmt-check without the tests, so the README's red-phase test commit can still land; the policy allows that form only where local testing is impossible.
Not applicable: the issue's .golangci.yml entries; this TypeScript repository has none.

Model: opus-5-5
2026-10-06 10:30:29 +02:00
clawbot 2483c85321 quak backup writes the account and album records backup-metadata writes (closes #166)
check / check (push) Successful in 1m23s
quak backup now writes account.json with the account's email and user ID,
adds each album's ownerID, isShared, updationTime and, when present, its
magicMetadata, pubMagicMetadata and sharedMagicMetadata to the album's
JSON, and adds updationTime to each file's JSON. The fields and their order
match backup-metadata's account.json, _collection.json and per-file JSON.
The interface runBackup drives gains whoami, which the library passes
through from its client.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-06 07:13:24 +02:00
clawbot ab4d5d2cc2 quak backup writes each file's ML data into its JSON (closes #163)
check / check (push) Successful in 1m37s
lib.backup() now waits for an ML data fetch, joining the one its refresh
started or starting one, before it writes the per-file JSON, and each
file's JSON carries the cached payload as mlData. When the fetch fails,
each file with no cached ML data gets mlDataError and an entry in
failures.json, so the result counts it as failed and quak backup exits 1;
the next run fetches again.

Judgement call: the wait comes after the originals are downloaded, so the fetch runs alongside the downloads.
Judgement call: a failed ML fetch is recorded per file in failures.json, which is how the exit code goes non-zero without changing src/cli-commands.ts.

Model: opus-5-5
2026-10-06 05:13:27 +02:00
clawbot 14ac7d05ef Download-albums example test no longer depends on the disk's free space (closes #160)
check / check (push) Successful in 1m32s
The test left freeBelowBytes at its 50 GiB default, so on a disk with under
50 GiB free the content cache's limit fell to zero. Downloading another photo
then evicted photo 1's cached original, and the test fetched it a fourth time
instead of copying it. Both of its libraries now open with freeBelowBytes: 0.
Every other test that opens a library or content cache with the real free
space was read; none has a result that depends on it. The 50 GiB default is
unchanged.

Model: opus-5-5
2026-10-03 15:30:53 +02:00
clawbot 9e94542a57 docker build . stamps the git tag or short commit, not dev (closes #154)
check / check (push) Failing after 46s
A plain `docker build .` now stamps the version from git rather than `dev`/`0.0.0`. After `tsc`, `script/build` writes into `dist/package.json` the version `script/version` decides: `VERSION` when given, otherwise `git describe --tags --always` (the tag; or tag, commits since and short commit; or the short commit), otherwise `package.json`'s. A checkout with `.git` that yields an empty, `dev` or `unknown` version fails the build. `.dockerignore` sends `.git` but not `.git/config`, so no remote URL or credential reaches the image. `ARG VERSION` has no default, and the host scripts' version still wins.

Not changed: `REPO_POLICIES.md` still says `ARG VERSION=dev` until the shared policy changes.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-02 06:12:56 +02:00
clawbot e50d2a78c8 exif() returns every EXIF tag in the file (closes #156)
check / check (push) Failing after 59s
`photo.exif()` now returns every EXIF tag in the file, as the owner ruled, typed `ExifTags`: each tag keyed by name with `exifreader`'s `id`, `value`, `description` and `computed`. A tag with no name is keyed `undefined-` plus its number, and the embedded thumbnail's tags sit under `Thumbnail`. The thirteen typed methods stay, with the same names and types; each now picks its field from `exif()`'s tags. GPS latitude and longitude are worked out from the GPS tags and their reference tags, and a position with no reference tags gives neither. `backup-metadata --exif` output is unchanged.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-02 05:30:05 +02:00
clawbot 0c995a8c4f Remove the content cache's handling of an earlier version's live-photo ZIP (closes #151)
check / check (push) Failing after 44s
quak is pre-1.0 and keeps no handling of old data. The content cache no longer recognises or removes a live photo that an earlier quak version cached as one ZIP. A live-photo download no longer removes what was at its destination before renaming the image and video into place; the rename already replaces it. The README sentences and the tests about that old ZIP are gone. The two removed tests that also covered current behaviour are replaced by tests with no ZIP: the cache re-fetching a live photo recorded with no video, and the library telling the cache which files are live photos when it opens.

Model: opus-5-5
2026-10-02 04:14:30 +02:00
38 changed files with 1417 additions and 365 deletions
+75 -39
View File
@@ -1,50 +1,86 @@
# Mirrors .gitignore, with one deliberate exception: .gitignore itself stays
# in the build context, because prettier 3 reads it as a default ignore file
# and dropping it would change what the lint phase's prettier check sees.
# .dockerignore does NOT use .gitignore semantics. Docker matches with
# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross
# `/` and an unprefixed pattern is anchored at the context root. Every
# depth-independent pattern therefore needs `**/`, or `config/.env` and
# `certs/server.key` still ship while this file reads as solved. Only
# genuinely root-anchored entries go unprefixed. Never transplant these
# into .gitignore, where `**/` is wrong.
#
# Matching is case-sensitive, so secrets use character ranges rather
# than an ALL-CAPS twin, which would still miss `Server.Key`.
#
# Extend with this repo's own host-built artifacts, written anchored:
# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and
# deletes the package directory from the context.
# VCS
.git
# .git is sent without its config. Without a VERSION build argument the
# stage that compiles runs `git describe --tags --always` on .git, which
# does not need .git/config; that file can hold a credential, such as a
# password in a remote URL or the token the CI checkout step stores there.
# Each submodule keeps a config with the same exposure in its git directory
# under .git/modules/, nested again for a submodule's own submodules, or in
# its own .git directory when it keeps one.
# KNOWN GAP: a submodule whose name has a `config` segment (`config`,
# `deploy/config`, `config/lib`) loses its whole git directory, because
# `**/.git/modules/**/config` also matches that segment's directory
# under .git/modules/. Go's version stamping then fails the build;
# nothing leaks. Name such a submodule without that segment:
# `git submodule add --name`.
**/.git/config
**/.git/modules/**/config
# OS
.DS_Store
Thumbs.db
# Agent scratch: one full checkout of the repo per in-flight agent.
# Anchored because it occurs once where agents run at the repo root.
# KNOWN GAP: a repo running agents in subdirectories still ships
# `services/api/.claude/` and must add its own anchored entry.
.claude
# Editors
*.swp
*.swo
*~
*.bak
.idea/
.vscode/
*.sublime-*
# Environment files. `*.env` covers bare `.env` and the `prod.env`
# convention. Re-include a committed template with a negation if the
# build needs one: `!docs/example.env`.
**/*.[eE][nN][vV]
**/.[eE][nN][vV].*
**/.[eE][nN][vV][rR][cC]
# Node
node_modules
# Private keys and the bundles carrying them. Public certificates
# (*.crt, *.cer) are deliberately absent: they are legitimate inputs.
**/*.[pP][eE][mM]
**/*.[kK][eE][yY]
**/*.[pP]12
**/*.[pP][fF][xX]
**/[iI][dD]_[rR][sS][aA]
**/[iI][dD]_[dD][sS][aA]
**/[iI][dD]_[eE][cC][dD][sS][aA]
**/[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
**/[iI][dD]_[eE][dD]25519
**/[iI][dD]_[eE][dD]25519_[sS][kK]
# TypeScript / build artifacts
dist
build
*.tsbuildinfo
coverage
.nyc_output/
# Dependencies: restored inside the image, never copied in.
**/node_modules
# Vitest
.vitest-cache/
# OS metadata.
**/.DS_Store
**/Thumbs.db
# Environment / secrets
.env
.env.*
*.pem
*.key
# Editor state: never a build input, and it churns COPY.
**/*.swp
**/*.swo
**/*~
**/*.bak
**/.idea
**/.vscode
**/*.sublime-*
# TypeScript / build artifacts: the image compiles its own.
/dist
/build
/*.tsbuildinfo
/coverage
/.nyc_output
/.vitest-cache
# Compiled binary (built by make build-bin); around 100 MB
bin/quak
/bin/quak
# quak runtime data (in case anyone runs the CLI from inside the repo)
.quak/
# Local per-developer tool state, including agent worktrees. Correctness,
# not context size: a worktree copied in here has its own test/ tree, which
# vitest globs alongside the real one, so the containerised suite runs N+1
# times over and still reports success.
.claude/
/.quak
+32 -10
View File
@@ -11,9 +11,41 @@ Thumbs.db
.vscode/
*.sublime-*
# Agent scratch (worktrees of this repo, created and destroyed by
# in-flight tooling). Unanchored: .gitignore patterns already match at
# every depth, so no prefix is wanted here. This is not a .dockerignore
# entry and must not be given a `**/` prefix on the way into one.
.claude/
# Node
node_modules/
# Secrets. Unanchored like every entry above, so each matches at every
# depth. Matching is case-sensitive on Linux, so names use character
# ranges rather than a lowercase form that misses `Server.Key`.
# Environment files. `*.env` covers bare `.env` and the `prod.env`
# convention. Only the templates `example.env` and `sample.env` are
# re-included below. A repository that commits any other template adds
# its own negation after these lines, for example `!.env.example`.
*.[eE][nN][vV]
.[eE][nN][vV].*
.[eE][nN][vV][rR][cC]
!example.env
!sample.env
# Private keys and the bundles carrying them.
*.[pP][eE][mM]
*.[kK][eE][yY]
*.[pP]12
*.[pP][fF][xX]
[iI][dD]_[rR][sS][aA]
[iI][dD]_[dD][sS][aA]
[iI][dD]_[eE][cC][dD][sS][aA]
[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
[iI][dD]_[eE][dD]25519
[iI][dD]_[eE][dD]25519_[sS][kK]
# TypeScript / build artifacts
dist/
build/
@@ -24,18 +56,8 @@ coverage/
# Vitest
.vitest-cache/
# Environment / secrets
.env
.env.*
*.pem
*.key
# Compiled binary (built by make build-bin)
bin/quak
# quak runtime data (in case anyone runs the CLI from inside the repo)
.quak/
# Local per-developer tool settings and scratch state, including the
# worktrees agents check out under this directory
.claude/
-3
View File
@@ -1,5 +1,2 @@
node_modules/
yarn.lock
dist/
build/
coverage/
+8 -3
View File
@@ -58,12 +58,17 @@ COPY --from=test /app/package.json /dev/null
COPY script/ script/
COPY package.json yarn.lock ./
RUN script/bootstrap
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /app
COPY . .
# The version is computed on the host and passed in, because
# .dockerignore excludes .git.
ARG VERSION=dev
# Version stamped into the build: the VERSION build arg when one is given,
# otherwise what script/version derives from the .git the build context
# carries (script/bootstrap installed git), so any `docker build .` of a
# clone stamps its commit. The label can only carry the build arg, and is
# empty without one.
ARG VERSION
LABEL org.opencontainers.image.version="${VERSION}"
RUN make build
+4 -2
View File
@@ -26,8 +26,10 @@ check:
build:
@script/build
build-bin:
nix-shell -p bun --run "bun build bin/quak.ts --compile --outfile bin/quak"
# Bundles the built dist/, so the binary reports the version script/build
# stamped.
build-bin: build
nix-shell -p bun --run "bun build dist/bin/quak.js --compile --outfile bin/quak"
install: build-bin
mkdir -p ~/bin
+117 -62
View File
@@ -126,24 +126,26 @@ alpine. We provide:
`script/bootstrap`, then `script/install-precommit`
- `script/projectname` — output the project name (our own extension); used by
`script/docker` for the image tag
- `script/build` — compile the TypeScript sources into `dist/`, then verify that
the entrypoints `package.json` declares (`main`, `types`, `bin`) are among the
files the compiler wrote, and make the CLI executable (our own extension)
- `script/build` — compile the TypeScript sources into `dist/`, stamp the
version into `dist/package.json`, then verify that the entrypoints
`package.json` declares (`main`, `types`, `bin`) are among the files the
compiler wrote, and make the CLI executable (our own extension)
- `script/version` — print the version `script/build` stamps (our own
extension); see Version below
- `script/test` — run the test suite, by building the `test` phase of the
`Dockerfile` (vitest, 90s timeout, verbose rerun on failure); requires docker
- `script/lint` — run eslint and a prettier check, by building the `lint` phase
of the `Dockerfile`; requires docker (see Linting and testing below)
- `script/fmt` — format all files with prettier (writes)
- `script/fmt-check` — check formatting on the host (read-only); standalone, and
not called by `script/check` or `script/precommit`, because `script/lint`
already checks formatting in the container
- `script/check` — run all checks: `test`, `lint` (our own extension)
- `script/fmt-check` — check formatting on the host (read-only)
- `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own
extension)
- `script/docker` — build the image, tagged via `script/projectname`
- `script/cibuild` — build the image (what CI runs); its last stage depends on
the `lint` and `test` phases, so this one build lints, tests and compiles
- `script/cibuild` — what CI runs: `script/bootstrap`, then `script/check`, then
the image build
- `script/precommit` — run by the git pre-commit hook (our own extension); runs
`script/lint`, which checks both lint and formatting, but deliberately not the
tests, so the TDD red-phase commit can land
`script/lint` and `script/fmt-check` but deliberately not the tests, so the
TDD red-phase commit can land
- `script/install-precommit` — installs the git pre-commit hook (our own
extension); `make hooks` shims to it
@@ -159,22 +161,47 @@ script runs the tools on the host: docker is required, and that also works where
the docker daemon is remote and bind mounts are impossible.
The last stage of the `Dockerfile` compiles the package, and it copies a file
from each phase, so it cannot be built unless lint and the tests pass. That is
why `script/cibuild` is a single `docker build`: it runs lint and the tests once
each and then compiles.
from each phase, so it cannot be built unless lint and the tests pass. The image
build in `script/cibuild` therefore runs lint and the tests a second time, after
`script/check` has run them.
Every `docker build` in `script/` passes `--no-cache`. On an unchanged tree
Docker would otherwise serve the lint and test steps from cache, nothing would
run, and the build would still exit 0.
The formatting check is part of the `lint` phase, not a step beside it, so
`script/check` and `script/precommit` do not call `script/fmt-check` as well;
that would run prettier a second time over the same tree for the same verdict.
`script/fmt-check` remains as a standalone entrypoint for asking the formatting
question on the host. Its verdict matches the container's: prettier is pinned to
an exact version, installed from `yarn.lock` under `--frozen-lockfile` in both
places, and reads `.gitignore` as its default ignore file — which is why
`.dockerignore` keeps `.gitignore` in the build context.
`script/fmt-check` runs prettier on the host. Its verdict matches the `lint`
phase's: prettier is pinned to an exact version, installed from `yarn.lock`
under `--frozen-lockfile` in both places, and reads `.gitignore` as its default
ignore file — which is why `.dockerignore` keeps `.gitignore` in the build
context.
### Version
`quak --version` reports the `version` of `dist/package.json`, which
`script/build` writes after compiling; the repo's own `package.json` keeps
`0.0.0`, and that is what the tests, which run from source, report.
`script/version` decides what is written:
- the `VERSION` environment variable, or the `Dockerfile`'s `VERSION` build arg
(`--build-arg VERSION=...`), when one is given and not empty;
- otherwise, in a checkout with `.git`, `git describe --tags --always`: the tag
on a tagged commit; the tag, the commits since it and the short commit on a
later commit (`v1.2.3-4-gabc1234`); the short commit when no tag is reachable;
- otherwise, as in a source tarball, the version `package.json` declares.
The build fails if the checkout has `.git` and the version still comes out
empty, `dev` or `unknown`: such a build could not be traced back to its commit.
`.dockerignore` therefore does not leave out `.git`, so any `docker build .` of
a clone stamps the commit it was built from; a shallow clone stamps a tag only
when the cloned commit itself carries one, and otherwise the short commit. It
leaves out `.git/config`, which holds the clone's remote URL and any credential
in it, so the image carries `.git` without its config; `git describe` does not
need that file. `script/docker` (and so `make docker`) and `script/cibuild` pass
the version they resolve on the host, with `--dirty`, as the build arg, which
takes precedence. The image's `org.opencontainers.image.version` label carries
that build arg only, so a build given none leaves it empty. `make build-bin`
bundles the built `dist/`, so the single binary reports the stamped version too.
## Rationale
@@ -228,11 +255,10 @@ All work on quak is test-driven. No exceptions.
history must still show tests landing before (or with) the matching
implementation.
8. The pre-commit hook installed by `make hooks` runs `script/precommit`, which
runs `script/lint` — eslint and the prettier check, in the container — but
not the tests, and so not the full `make check`. This is deliberate so the
TDD red-phase commit (failing tests, no implementation yet) can land. The
`test` phase is part of the image build, which is what CI executes via
`script/cibuild`, so a red branch still cannot reach `next`.
runs `script/lint` and `script/fmt-check` but not the tests, and so not the
full `make check`. This is deliberate so the TDD red-phase commit (failing
tests, no implementation yet) can land. CI executes `script/cibuild`, which
runs the tests, so a red branch still cannot reach `next`.
## Design
@@ -389,18 +415,24 @@ Backoff is exponential with full jitter: the delay before retry _n_ is
`random() * min(maxDelayMs, baseDelayMs * 2 ** (n - 1))`. The exponential term
is the ceiling and the wait is drawn below it, so a client that lost many
parallel downloads to one CDN blip does not send them all again at the same
instant. Defaults, configurable through `ApiClientOptions.retry`:
instant. The numbers, configurable through `ApiClientOptions.retry`:
| Option | Default | Meaning |
| ------------- | ------- | ----------------------------------- |
| `attempts` | `4` | total calls, not retries |
| `baseDelayMs` | `500` | ceiling for the first retry's delay |
| `maxDelayMs` | `10000` | upper bound on that ceiling |
| Option | Default | `quak backup` | Meaning |
| ------------- | ------- | ------------- | ----------------------------------- |
| `attempts` | `4` | `10` | total calls, not retries |
| `baseDelayMs` | `500` | `1000` | ceiling for the first retry's delay |
| `maxDelayMs` | `10000` | `60000` | upper bound on that ceiling |
With those defaults a file that is going to fail gives up after at most three
and a half seconds of waiting. `sleep` and `random` are injectable through the
same option, which is how the test suite exercises the whole policy without
waiting.
With the defaults a file that is going to fail gives up after at most three and
a half seconds of waiting. `quak backup` usually runs from cron with nobody
watching, so every request it makes uses the `quak backup` column instead,
exported as `UNATTENDED_RETRY_OPTIONS`: a request that keeps failing gives up
after at most 243 seconds of waiting, and usually after about half that, since
each wait is drawn at random below its ceiling. Every other command uses the
defaults. A library user gets the same budget by passing
`UNATTENDED_RETRY_OPTIONS` as `ApiClientOptions.retry`. `sleep` and `random` are
injectable through the same option, which is how the test suite exercises the
whole policy without waiting.
Two deadlines, renewed for each attempt:
@@ -485,8 +517,17 @@ The CLI stores the snapshot at the platform-appropriate data directory via
`0600`. The key material is stored in cleartext in the JSON; treat this file as
you would treat the password itself. A missing file is reported as "not logged
in"; a file that exists but is corrupt is reported as such, naming the bad
field. Both exit with status 1, except that `quak logout` with no file says
there is no session and exits 0.
field. When the refresh a command starts with gets HTTP 401 from the server,
because it no longer accepts the saved session's token, the command prints one
line, `quak: the saved session is no longer valid; run "quak login"`, with no
stack trace. All three exit with status 3, which means the user must run
`quak login` again. `quak logout` is the exception: with no file it says there
is no session and exits 0, and it handles a corrupt file or a failed server call
as described below. `quak backup` meets an expired session on the refresh that
starts every run, before it touches any file. A session that stops working
partway through a backup instead fails each remaining file into `failures.json`,
so that run exits 1 and the next one stops at its refresh with status 3. No
command but `quak login` ever prompts.
`quak logout` ends the session on the server, so the token in `session.json`
stops working even in a copy of the file, and then deletes the file. If the
@@ -520,8 +561,9 @@ 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. If that round-trip fails, the command prints the error on one line and
exits 1. `--cache-dir` overrides where the cache lives; without it each account
gets its own directory under the per-user cache path.
exits 1, or 3 when the server no longer accepts the saved session (see "Session
handling"). `--cache-dir` overrides where the cache lives; without it each
account gets its own directory under the per-user cache path.
`get` and `get-thumb` resolve the file by ID directly, so `--collection` is
accepted for backward compatibility but ignored. For a live photo, `get` writes
@@ -568,8 +610,8 @@ the smallest does not.
per unique file, two for a live photo: see
below)
YYYY-MM-DD.<fileID>.json the file's basic metadata fields quak
keeps, and its private and public magic
metadata
keeps, its update time, its private and
public magic metadata, and its ML data
YYYY-MM-DD.<fileID>.livephoto.json
which of a live photo's two files is which
collections/
@@ -577,6 +619,7 @@ the smallest does not.
<title> -> ../../YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>
(symlink)
<name>.json collection metadata + file list
account.json the account's email and user ID
failures.json files that failed and have not yet succeeded
```
@@ -588,6 +631,23 @@ name as uploaded, case kept, or `.bin` when it has none or it holds anything but
letters and digits. When the date or the time zone changes, the next run saves
the original at its new path and leaves the old copy where it is.
`account.json` holds the account's `email` and `userID`, as `backup-metadata`
writes it. A collection's JSON holds its `id`, `name`, `type`, `ownerID`,
`isShared` and `updationTime`, its `magicMetadata`, `pubMagicMetadata` and
`sharedMagicMetadata` when it has them, as `backup-metadata`'s
`_collection.json` does, and `files`, each file's `id` and `metadata`. A file's
JSON holds its `id`, `collectionID`, `ownerID`, `metadata` and `updationTime`,
and its `magicMetadata` and `pubMagicMetadata` when it has them. Update times
are in microseconds, as Ente records them.
A file's JSON holds Ente's ML data for it (its faces and its CLIP embedding) as
`mlData`, the same payload `backup-metadata` writes; a file Ente has no ML data
for has no `mlData`. The backup waits for the library's ML data fetch to finish
before it writes the JSON files. If that fetch fails, each file whose ML data is
not in the cache gets the reason in `mlDataError` instead and counts as failed,
and the next run fetches it again. The JSON files are rewritten on every run, so
ML data that arrived since the last run appears.
`failures.json` records each failed file with the kind of failure, how many
times it has been tried and when it was last tried. A file leaves it once it
succeeds, or once it is no longer in the library or in the backup's scope. The
@@ -624,8 +684,7 @@ subsequent runs, existing originals are skipped. If a download fails, the error
is logged and the backup continues with the next file. The exit code is non-zero
if any files failed. `quak backup` opens its library with the thumbnail and
originals precache off, so the only file content it fetches is the originals the
backup stores. The library's ML data fetch still runs and fills the cache's
`mldata/`.
backup stores.
Each original is written to a temporary file in the same directory, synced to
disk, and renamed into place, so an original is either complete or absent, even
@@ -845,11 +904,12 @@ photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange`
- `await lib.backup(opts?)` → `BackupResult`. 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, and rebuilds the on-disk backup tree
with a durable failure ledger. 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`),
thumbnails) through the content cache, waits for an ML data fetch, and
rebuilds the on-disk backup tree, each file's JSON with its ML data, with a
durable failure ledger. 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`, and `onProgress`. See
Backup layout above for the tree it writes.
@@ -884,10 +944,7 @@ current account's records name.
A live photo's original is cached as at its save path: its image and its video,
each `originals/<fileID>.<ext>` with its own extension, and
`originals/<fileID>.livephoto.json` naming them; the two are evicted together. A
live photo that an earlier version cached as its ZIP is not served: the library
removes the ZIP when it opens the cache, and fetches the two files when the
photo is next read or precached.
`originals/<fileID>.livephoto.json` naming them; the two are evicted together.
A stored file appears only via an atomic temp-then-rename, so its presence means
it is complete. Every downloaded original (by `quak get`, the cache, or
@@ -949,14 +1006,12 @@ documents:
before the implementation. Tests are the canonical API documentation and must
be commented thoroughly. `main` and `next` are always green.
- **Required checks before every commit:** `make lint` must pass — that is
eslint plus the prettier check, and it builds the `lint` phase of the
`Dockerfile`, so it needs docker. The pre-commit hook enforces exactly that.
`make check` (which also runs the tests) must pass before merging into `next`.
`make fmt-check` is available for a host-side formatting check on its own, but
it is not a separate requirement: `make lint` already covers it, and running
both would check formatting twice. Never invoke eslint or prettier directly;
linting runs in the container only.
- **Required checks before every commit:** `make lint` and `make fmt-check` must
pass. `make lint` is eslint plus the prettier check, and it builds the `lint`
phase of the `Dockerfile`, so it needs docker. The pre-commit hook enforces
exactly that. `make check` (which also runs the tests) must pass before
merging into `next`. Never invoke eslint or prettier directly; linting runs in
the container only.
- **Formatting:** prettier with 4-space indents and `proseWrap: always` for
markdown. Use `make fmt` to format. Use `yarn` not `npm`.
+120 -44
View File
@@ -1,6 +1,6 @@
---
title: Repository Policies
last_modified: 2026-09-08
last_modified: 2026-10-04
---
This document covers repository structure, tooling, and workflow standards. Code
@@ -104,10 +104,14 @@ style conventions are in separate documents:
`lint` phase and a `test` phase, with the final stage depending on both so the
image cannot be built unless they pass. For non-server repos the final stage
brings up a development environment; for server repos it is the runtime image.
Dockerfiles install development prerequisites by running `script/bootstrap`
rather than duplicating installs inline; COPY `script/` and the dependency
manifests (`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before
running it.
The gate phases and the build stage start from their pinned base images and
install what those images lack either inline, as the canonical Go `Dockerfile`
below does for `git`, or by running `script/bootstrap`, as the `prompts`
repo's own `Dockerfile` does for its yarn packages. The development
environment stage installs development prerequisites by running
`script/bootstrap` rather than duplicating its installs inline. A stage that
runs `script/bootstrap` COPYs `script/` and the dependency manifests
(`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it.
- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is
no separate lint file. `script/lint` and `script/test` each build one phase
@@ -156,11 +160,14 @@ style conventions are in separate documents:
not evidence that anything ran: a sub-second build reporting success is a
cache hit, not a result. Never invalidate by pruning — `docker builder prune`
and friends destroy a build cache shared with every other build on the host.
When a check is added or changed, prove it works by planting a defect it must
catch and watching the run fail on it, then revert the defect. A green run
alone shows neither that the check ran nor that it covers what it should.
- **The gate phases are separate stages, and the build stage depends on both.**
The lint phase is based on the `golangci/golangci-lint` image (pinned by
hash), so lint failures surface in seconds rather than after a full compile,
and the test phase is based on the Go image. The canonical Go repo
and the test phase is based on the Debian Go image. The canonical Go repo
`Dockerfile`:
```dockerfile
@@ -173,8 +180,9 @@ style conventions are in separate documents:
COPY . .
RUN golangci-lint run --config .golangci.yml ./...
# Test phase
# golang:1.x-alpine, YYYY-MM-DD
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
# image ships and the alpine one does not.
# golang:1.x, YYYY-MM-DD
FROM golang@sha256:... AS test
WORKDIR /src
COPY go.mod go.sum ./
@@ -191,15 +199,29 @@ style conventions are in separate documents:
FROM golang@sha256:... AS builder
COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
RUN apk add --no-cache git
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/
# The VERSION build arg when one is given, otherwise
# `git describe --tags --always` on the .git in the build context. With
# .git present, a version that is still empty, dev or unknown fails the
# build: git is missing or could not read the checkout.
ARG VERSION
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
if [ -e .git ]; then \
case "$VERSION" in ""|dev|unknown) \
echo "version is '$VERSION' although .git is present" >&2; \
exit 1 ;; \
esac; \
fi; \
CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/
# Runtime stage, and the last one
FROM alpine@sha256:...
@@ -221,10 +243,41 @@ style conventions are in separate documents:
(e.g. a web frontend compiled in a separate stage), the lint phase must
create placeholder files so the embed directives resolve. Example:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
- If the project requires CGO or system libraries for linting (e.g.
`vips-dev`), install them in the lint phase with `apk add`.
- `ARG VERSION=dev` is declared in the stage that compiles and supplied by
`script/docker` and `script/cibuild`; no stage may call `git describe`.
- If the project requires CGO or system libraries for linting, install them
in the lint phase. The `golangci/golangci-lint` image is Debian-based and
has no `apk`, so install with `apt-get` under the Debian package name
(`libvips-dev`, where alpine says `vips-dev`), and delete the package
lists in the same `RUN`, so the layer does not keep them:
```dockerfile
RUN apt-get update \
&& apt-get install -y --no-install-recommends libvips-dev \
&& rm -rf /var/lib/apt/lists/*
```
- `.dockerignore` lets `.git` into the build context. It keeps out every git
`config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the
repository's own, each submodule's under `.git/modules/`, and that of a
submodule keeping its own `.git` directory. `git describe` does not need
them, and each can hold a credential: a password in a remote URL, or the
token the CI checkout step stores there. A submodule whose name has a
`config` segment (`config`, `deploy/config`, `config/lib`) loses its whole
git directory to `**/.git/modules/**/config`, and Go's version stamping
then fails the build: give it a name without that segment
(`git submodule add --name`). The stage that compiles has `git` (the
Debian Go image has it; an alpine one needs `apk add --no-cache git`) and
takes the version from the `VERSION` build argument when one is given,
otherwise from `git describe --tags --always`. That gives the tag on a
tagged commit; on a later commit, the tag, the number of commits since it
and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no
tag is reachable. The stage that compiles also marks its working directory
safe for git (`git config --system --add safe.directory /src`): a context
sent as a tar stream keeps the sender's file owners, and git refuses a
checkout owned by another user, so the version would come out empty.
`ARG VERSION` has no default, and the build fails if the context carries
`.git` and the version still comes out empty, `dev` or `unknown`. A plain
`docker build .` with no build arguments must succeed; a Dockerfile that
refuses an empty build argument drops that refusal and keeps the argument.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` on push, and checks out the repo as its only other step.
@@ -233,7 +286,12 @@ style conventions are in separate documents:
carry the same guarantee, because its gate phases may come from the cache. The
image build is uncached and so runs the gate phases a second time. That is the
price of the rule above, and it is worth paying: the image that ships is built
from a run of its own gates rather than from a cache entry.
from a run of its own gates rather than from a cache entry. A separate
workflow limited to `main` by a `branches` list under `on: push` cannot be
checked by review: to try a change to it, add the feature branch to that list
and push, then remove the branch from the list again before merging. Keep any
job in it that publishes behind `if: github.ref_name == 'main'`, so the run
from the feature branch publishes nothing.
- Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
@@ -286,17 +344,19 @@ style conventions are in separate documents:
```
`-count=1` is required on both invocations: it defeats Go's test _result_
cache, so the target cannot report a pass it did not earn, and the rerun
reproduces a failure instead of replaying it. It leaves the build cache
alone, so it costs the runtime of the suite and no recompilation.
cache, so neither run can report a stored pass in place of running the
tests. It leaves the build cache alone, so it costs the runtime of the suite
and no recompilation.
Note that this is a second, independent cache, stacked below the Docker
layer cache that [issue #26](https://git.eeqj.de/sneak/prompts/issues/26)
addresses. `CHECK_EPOCH` guarantees the `RUN make test` _step_ re-executes;
it does not guarantee `go test` inside that step does any work, because the
`GOCACHE` baked into earlier image layers survives into the re-executed
step. They are two separate defects requiring two separate fixes, and a fix
for one must not be recorded as covering the other.
That cache is Go's own, separate from Docker's layer cache. Go stores a
passing result in its cache directory (`GOCACHE`), and when the same tests
run again on unchanged code it prints that result, marked `(cached)`,
without running them. That matters on a developer's machine, where this
target runs and the directory lasts from one run to the next. The `test`
phase of the `Dockerfile` needs no `-count=1`: its base image holds no
result for this repo's tests and nothing before its `go test` step runs a
test, so there is nothing to replay. `--no-cache` (above) is what makes that
step run on an unchanged tree.
Python example:
@@ -340,7 +400,7 @@ style conventions are in separate documents:
— which is more dangerous than a short file with no secret patterns at all,
because it reads as solved and stops anyone looking. Give every
depth-independent pattern the `**/` prefix and leave only genuinely
root-anchored entries unprefixed: `.git`, and the repo's own host-built
root-anchored entries unprefixed: `.claude`, and the repo's own host-built
binary, written `/myapp` and never `**/myapp`, which would also match
`cmd/myapp/` and delete the package directory from the context. Matching is
case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so
@@ -365,12 +425,13 @@ style conventions are in separate documents:
directory, so a repo running agents in subdirectories still ships
`services/api/.claude/` and must add its own anchored entry there.
- **Excluding `.git` means `git describe` cannot run inside any build stage, and
it fails quietly there.** In a build stage there is no repository, so
`git describe` writes nothing to stdout, `-X main.Version=` comes out empty,
the binary reports no version at all, and the build still exits 0. Compute the
version on the host and thread it in as a build arg. `script/docker` and
`script/cibuild` do this, byte-identically across repos:
- **A plain `docker build .` of a clone stamps the version that
`git describe --tags --always` gives**, derived from the `.git` in the build
context as the canonical `Dockerfile` above shows. Without its failure check,
a missing `git` or an unreadable checkout would leave `-X main.Version=` empty
and the build would still exit 0. `script/docker` and `script/cibuild` pass
the version they compute on the host; it takes precedence. They do this
byte-identically across repos:
```sh
# Own line: a failing command substitution inside an argument does not
@@ -387,7 +448,7 @@ style conventions are in separate documents:
fallback is applied — a live check that fires on a build from an export with
no `.git` and on a repository with no commits yet. Do not fold it into the
substitution as `|| echo unknown`, which makes the guard unreachable. The
Dockerfile's side is `ARG VERSION=dev` in the stage that compiles, declared
Dockerfile's side is `ARG VERSION` in the stage that compiles, declared
there because `ARG` is stage-scoped; passing `VERSION` to a repo whose
Dockerfile declares no such `ARG` is ignored and costs nothing, which is why
the scripts stay byte-identical. One consequence for CI: the standard
@@ -426,12 +487,18 @@ style conventions are in separate documents:
`test-support` depguard rule, where a repo names its own test-support packages
by full import path. A repo adds entries there and changes nothing else, and a
re-vendor carries its entries forward. The canonical golangci-lint version is
v2.12.2 (released 2026-05-06), pinned as the digest of the lint phase's base
v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base
image
(`golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`,
which reports `2.12.2 built with go1.26.2 from c0d3ddc9`). That digest is the
only pin, since no repo installs golangci-lint on the host: bumping the
version means changing it and nothing else.
(`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`,
which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go`
directive must not name a newer Go minor version than the one golangci-lint
was built with, or golangci-lint refuses to lint it: this release lints
`go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo
installs golangci-lint on the host. A repo sets the lint phase digest to the
one named here and re-vendors `.golangci.yml` in the same commit, whichever of
the two prompted the change: the canonical copy can name linters that an older
golangci-lint rejects, and a newer golangci-lint can add linters that
`default: all` switches on until the canonical copy disables them.
- **`script/bootstrap` installs a pinned tool by comparing versions, never by
testing presence.** An `if ! command -v <tool>; then install; fi` guard tests
@@ -455,6 +522,11 @@ style conventions are in separate documents:
Keep it POSIX sh: no arrays, no `[[`, no `grep -P`.
A Go tool a repo needs on the host is installed with `go install` pinned to
a commit hash (`go install <package>@<commit hash>`). It is never tracked as
a `go.mod` tool dependency or through a `tools.go` file, either of which
pulls the tool's own dependencies into the repo's `go.mod` and `go.sum`.
- When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD).
@@ -567,10 +639,10 @@ style conventions are in separate documents:
settings.
- Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
language-specific config). Everything else goes in a subdirectory. Canonical
subdirectory names:
only project-level config files (`README.md`, `AGENTS.md`, `Makefile`,
`Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`,
and language-specific config). Everything else goes in a subdirectory.
Canonical subdirectory names:
- `bin/` — executable scripts and tools
- `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose
body is a single call into `internal/` or `pkg/`, no project logic in
@@ -601,3 +673,7 @@ style conventions are in separate documents:
- Go: `go.mod`, `go.sum`, `.golangci.yml`
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
- Python: `pyproject.toml`
- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It
is never committed under a file or directory named after one agent tool, such
as `CLAUDE.md` or `.claude/`, and never split into separate memory files.
+58
View File
@@ -25,6 +25,60 @@ declares one.
# Completed Steps
- 2026-10-06: When the server answers HTTP 401 and that ends a command that
loads the saved session, because the server no longer accepts its token, the
command prints one line,
`quak: the saved session is no longer valid; run "quak login"`, and exits 3
(issue 164). `quak backup` meets that 401 on the refresh it starts with,
before it touches any file. For the same commands, a missing or corrupt
session file keeps its message and also exits 3, so a cron job can tell that
the user must log in again. `quak logout` is unchanged. `run` in
`src/cli-run.ts` recognises the 401, which reaches it unchanged.
- 2026-10-06: `quak backup` retries a failed request for longer than the other
commands do (issue 165). `src/retry.ts` exports `UNATTENDED_RETRY_OPTIONS`
beside the unchanged default: 10 attempts, a 1 s base delay and a 60 s cap, so
a request that keeps failing waits at most 243 s before it gives up.
`bin/quak.ts` loads the backup's session with them, so its refresh, ML data
and downloads all use them. What is retried and the backoff formula are
unchanged.
- 2026-10-06: The files this repository copies from `sneak/prompts` are copied
again from its commit `dd4027b` (issue 171). `.gitignore` and `.dockerignore`
keep out more secret files, and this repository's build artifacts follow the
copied content. `script/check` runs `script/fmt-check` again, `script/cibuild`
runs `script/bootstrap` and `script/check` before the image build,
`script/fmt` and `script/fmt-check` find the pinned yarn under nvm, and the
image's last stage marks `/app` safe for git.
- 2026-10-06: `quak backup` writes the account and album records
`backup-metadata` writes (issue 166): `account.json` with the account's
`email` and `userID`, and in each album's JSON its `ownerID`, `isShared`,
`updationTime` and, when present, its three layers of magic metadata. Each
file's JSON gains its `updationTime`.
- 2026-10-05: `quak backup` writes each file's ML data (its faces and CLIP
embedding) into the file's JSON as `mlData`, the payload
`lib.mldata.forFile()` returns (issue 163). `lib.backup()` waits for an ML
data fetch before it writes the JSON files. When that fetch fails, each file
whose ML data is not cached gets the reason in `mlDataError` and counts as
failed, and the next run fetches it again.
- 2026-10-03: The download-albums example test no longer fails on a disk with
under 50 GiB free (issue 160). It opens its libraries with
`freeBelowBytes: 0`, so the free space of the disk it runs on cannot shrink
the cache, evict the original it cached and add a fetch to the count. No other
test's result depends on that free space. The 50 GiB default is unchanged.
- 2026-10-02: `docker build .` stamps the commit's tag or short commit, not
`dev` (issue 154). `script/build` writes the version `script/version` prints
into `dist/package.json`: the `VERSION` environment variable or build arg when
one is given, otherwise `git describe --tags --always`, otherwise the version
`package.json` declares. `.dockerignore` sends `.git`, and a checkout with
`.git` whose version comes out empty, `dev` or `unknown` fails the build.
`make build-bin` bundles the built `dist/`, so the single binary reports the
same version.
- 2026-10-02: `photo.exif()` returns every EXIF tag in the file as `ExifTags`,
keyed by tag name, each as exifreader decodes it, not only the thirteen common
fields (issue 156). The embedded thumbnail's tags are under `Thumbnail`,
@@ -32,6 +86,10 @@ declares one.
field from the tags `exif()` returns, typed as in `PhotoExif`. The example
script's JSON files now carry every tag.
- 2026-10-01: The content cache no longer looks for a live photo that an earlier
version cached as one ZIP (issue 151). When the cache opens, a live photo's
file that no JSON file names is now always left alone.
- 2026-10-01: `examples/download-albums.ts` logs in, opens the library, and for
every album downloads each photo to its save path, writes the photo's record
and EXIF fields to a JSON file beside it, and writes the album's photos to
+14 -1
View File
@@ -23,6 +23,7 @@ import { run as runCommand } from "../src/cli-run.js";
import { loadSession } from "../src/cli-session.js";
import { Client } from "../src/client.js";
import { VERSION } from "../src/index.js";
import { UNATTENDED_RETRY_OPTIONS } from "../src/retry.js";
const paths = envPaths("quak", { suffix: "" });
@@ -129,8 +130,20 @@ program
)
.argument("<dir>", "Output directory")
.option("--json", "Print result as JSON instead of human-readable summary")
// A backup usually runs from cron with nobody watching, so every request
// it makes retries for longer than the other commands' requests do.
.action((dir: string, opts: { json?: boolean }) =>
run(backupCommand(context(), dir, opts)),
run(
backupCommand(
{
...context(),
loadSession: (path) =>
loadSession(path, { retry: UNATTENDED_RETRY_OPTIONS }),
},
dir,
opts,
),
),
);
const helper = program
+23 -9
View File
@@ -1,7 +1,7 @@
#!/bin/sh
# script/build: compile the TypeScript sources into dist/, then verify that
# the artifacts package.json advertises are among the files the compiler
# actually wrote. tsc reports success by exit status alone and knows nothing
# script/build: compile the TypeScript sources into dist/, stamp the version
# script/version prints into it, then verify that the artifacts package.json
# advertises are among the files the compiler actually wrote. tsc reports success by exit status alone and knows nothing
# about the manifest, so without this step a green build can still ship a
# package whose main, types or bin resolve to nothing. Our own extension to
# scripts-to-rule-them-all.
@@ -46,13 +46,24 @@ for (const bin of bins) {
}
# src/index.ts imports ../package.json for the version, which tsc copies to
# dist/package.json. Running the built CLI proves that import resolves from
# dist/ and reports the version package.json declares.
# dist/package.json. The version script/version prints is written into that
# copy only; the repo's own package.json is left as it is.
stamp_version() {
node -e '
const { readFileSync, writeFileSync } = require("node:fs");
const pkg = JSON.parse(readFileSync("dist/package.json", "utf-8"));
pkg.version = process.argv[1];
writeFileSync("dist/package.json", JSON.stringify(pkg, null, 4) + "\n");
' "$1"
}
# Running the built CLI proves the import resolves from dist/ and reports
# the stamped version.
verify_version() {
built="$(node dist/bin/quak.js --version)"
declared="$(node -p 'require("./package.json").version')"
if [ "$built" != "$declared" ]; then
echo "build: dist/bin/quak.js reports $built, package.json declares $declared" >&2
if [ "$built" != "$1" ]; then
echo "build: dist/bin/quak.js reports $built, the build stamped $1" >&2
exit 1
fi
echo "build: dist/bin/quak.js reports version $built"
@@ -60,9 +71,12 @@ verify_version() {
main() {
cd "$ROOT"
# Own line, so that a failing script/version stops the build.
version="$("$ROOT/script/version")"
yarn run tsc
stamp_version "$version"
verify_entrypoints
verify_version
verify_version "$version"
}
main "$@"
+5 -7
View File
@@ -1,11 +1,8 @@
#!/bin/sh
# script/check: run all checks (test, lint). Our own extension to
# scripts-to-rule-them-all. Both are Docker phases. Must not modify any
# files.
#
# script/fmt-check is not called here, unlike the template: the lint
# phase already runs `prettier --check .`, so calling it would run
# prettier a second time over the same tree for the same verdict.
# script/check: run all checks (test, lint, fmt-check). Our own
# extension to scripts-to-rule-them-all. test and lint are Docker
# phases; fmt-check is native, because a formatter writes the working
# tree. Must not modify any files.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -13,6 +10,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/test"
"$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check"
}
main "$@"
+7 -7
View File
@@ -1,8 +1,7 @@
#!/bin/sh
# script/cibuild: run the CI build. The image's last stage depends on the
# lint and test phases, so this one build runs eslint, prettier and the
# suite once each and then compiles. Unlike the template it does not run
# script/check first, which would run lint and the tests a second time.
# script/cibuild: run the CI build. It bootstraps first: a CI runner
# checks out and runs this and nothing else, and script/fmt-check runs
# the formatter on the host, which a pristine checkout cannot do.
# --no-cache for the same reason as script/docker: the gate phases the
# final stage depends on are RUN steps, and a cached one is a check that
# did not run.
@@ -13,11 +12,12 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
"$SCRIPT_DIR/bootstrap"
"$SCRIPT_DIR/check"
# Own line: a failing command substitution inside an argument does
# not trip `set -e`, so the inline form degrades silently to an
# empty constant. VERSION is computed here because .dockerignore
# excludes .git, so `git describe` in a build stage yields an empty
# version without failing.
# empty constant. The VERSION build argument takes precedence over
# the version a build stage derives from the .git in the context.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build --no-cache \
+2 -3
View File
@@ -12,9 +12,8 @@ main() {
cd "$ROOT"
# Own line: a failing command substitution inside an argument does
# not trip `set -e`, so the inline form degrades silently to an
# empty constant. VERSION is computed here because .dockerignore
# excludes .git, so `git describe` in a build stage yields an empty
# version without failing.
# empty constant. The VERSION build argument takes precedence over
# the version a build stage derives from the .git in the context.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build --no-cache \
+20 -1
View File
@@ -4,9 +4,28 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap.
NODE_VERSION="22.17.0"
# script/bootstrap installs node and yarn under nvm and leaves neither
# on the PATH of the shell that called it, so resolve the pinned
# toolchain here the way bootstrap's own install step does. nvm is a
# bash script, hence the subshell.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
exec yarn "$@"
fi
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "fmt: no yarn; run script/bootstrap first" >&2
exit 1
fi
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
}
main() {
cd "$ROOT"
yarn run prettier --write .
run_yarn run prettier --write .
}
main "$@"
+20 -1
View File
@@ -4,9 +4,28 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap.
NODE_VERSION="22.17.0"
# script/bootstrap installs node and yarn under nvm and leaves neither
# on the PATH of the shell that called it, so resolve the pinned
# toolchain here the way bootstrap's own install step does. nvm is a
# bash script, hence the subshell.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
exec yarn "$@"
fi
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "fmt-check: no yarn; run script/bootstrap first" >&2
exit 1
fi
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
}
main() {
cd "$ROOT"
yarn run prettier --check .
run_yarn run prettier --check .
}
main "$@"
+5 -5
View File
@@ -2,17 +2,17 @@
# script/precommit: run by the git pre-commit hook; fails the commit if
# checks fail. Our own extension to scripts-to-rule-them-all.
#
# Runs lint but deliberately NOT the tests, so the TDD red-phase commit
# (failing tests, no implementation yet) can land. CI runs
# script/cibuild, whose image build includes the test phase, and so
# catches any branch that ships red. The lint phase includes the
# prettier check, so a badly formatted tree still fails the commit.
# Runs lint and fmt-check but deliberately NOT the tests, so the TDD
# red-phase commit (failing tests, no implementation yet) can land. CI
# runs script/cibuild, which runs the tests, and so catches any branch
# that ships red.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check"
}
main "$@"
Executable
+41
View File
@@ -0,0 +1,41 @@
#!/bin/sh
# script/version: print the version script/build stamps into the built
# package. Our own extension to scripts-to-rule-them-all.
#
# Order of precedence:
#
# 1. $VERSION, if set and not empty: an explicit value, such as the
# Dockerfile's VERSION build arg.
# 2. If this checkout has .git, `git describe --tags --always`: the tag
# on a tagged commit; the tag, the commits since it and the short
# commit on a later commit (v1.2.3-4-gabc1234); the short commit when
# no tag is reachable.
# 3. Otherwise, as in a source tarball, the version package.json declares.
#
# A checkout with .git whose version still comes out empty, dev or unknown
# fails: git is missing or could not read the checkout, and the build could
# not be traced back to its commit.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
version="${VERSION:-}"
if [ -e .git ]; then
if [ -z "$version" ]; then
version="$(git describe --tags --always || true)"
fi
case "$version" in
"" | dev | unknown)
echo "version: $ROOT has .git, but the version came out '$version'" >&2
exit 1
;;
esac
elif [ -z "$version" ]; then
version="$(node -p 'require("./package.json").version')"
fi
echo "$version"
}
main "$@"
+96 -23
View File
@@ -3,16 +3,18 @@
// `lib.backup()` waits for a completed refresh of the library (a failed one
// fails the backup before any file is touched), then, for every file in scope,
// puts its original at its save path under `downloadDirectory`, as
// `Photo.download()` does, and rebuilds the derived views (per-file sidecars,
// per-collection symlink trees, per-collection JSON) from the model. The
// on-disk layout:
// `Photo.download()` does, waits for an ML data fetch, and rebuilds the derived
// views (per-file sidecars, per-collection symlink trees, per-collection JSON)
// from the model. The on-disk layout:
//
// <downloadDirectory>/
// YYYY/YYYY-MM/YYYY-MM-DD/
// YYYY-MM-DD.<fileID>.<ext> the decrypted bytes (the save path)
// YYYY-MM-DD.<fileID>.json per-file metadata sidecar
// YYYY-MM-DD.<fileID>.json per-file metadata sidecar, with
// the file's ML data
// collections/<name>/<title> symlink to the original
// collections/<name>.json per-collection metadata
// account.json the account's email and user ID
// failures.json durable ledger of unresolved failures
//
// A live photo's original is its image and its video, each with its own
@@ -29,13 +31,15 @@
// directories of albums that no longer exist.
//
// Resilience (issue #8): no per-file condition aborts the run. A failed
// download or a failed symlink is caught, recorded in `failures.json` with a
// classification, a running attempt count, and the last-tried time, and the run
// continues. `result.failed` — and thus the CLI's exit code — stays non-zero
// while any failure remains unresolved and clears once every one succeeds. Each
// run reconciles the ledger against the files it attempted, so an entry for a
// file that has since left the library (deleted) or this run's scope is dropped
// rather than counted forever, which would poison a scheduled backup's exit code.
// download, a failed symlink, or ML data missing because the ML data fetch
// failed is caught, recorded in `failures.json` with a classification, a
// running attempt count, and the last-tried time, and the run continues.
// `result.failed` — and thus the CLI's exit code — stays non-zero while any
// failure remains unresolved and clears once every one succeeds. Each run
// reconciles the ledger against the files it attempted, so an entry for a file
// that has since left the library (deleted) or this run's scope is dropped
// rather than counted forever, which would poison a scheduled backup's exit
// code.
import {
lstatSync,
@@ -60,7 +64,8 @@ import {
storedAtSavePath,
} from "./library/content.js";
import { representative } from "./library/records.js";
import type { Collection, EnteFile } from "./model/types.js";
import type { MLData } from "./mldata-fetch.js";
import type { Collection, EnteFile, FileMetadata } from "./model/types.js";
export type ProgressCallback = (message: string) => void;
@@ -104,6 +109,8 @@ export interface BackupResult {
// The slice of the library that backup drives. `Library` implements it; a test
// can drive backup with a stand-in.
export interface BackupLibrary {
// The account the library belongs to.
whoami(): { email: string; userID: number };
refresh(): Promise<void>;
listCollections(): Collection[];
listFiles(collectionID: number): EnteFile[];
@@ -117,6 +124,13 @@ export interface BackupLibrary {
destination: string,
): Promise<{ path: string; videoPath?: string }>;
thumbnail(fileID: number): Promise<{ path: string }>;
// Wait for an ML data fetch to complete, joining one already running or
// starting one. Rejects with the reason when it fails; resolves at once
// when the library cannot fetch ML data.
fetchMLData(): Promise<void>;
// A file's cached ML data, as `lib.mldata.forFile()` returns it, or
// undefined when none is cached.
mlData(fileID: number): Promise<MLData | undefined>;
}
type FailureClass = "transient" | "permanent" | "unknown";
@@ -340,18 +354,50 @@ const saveLedger = (path: string, ledger: Map<number, FailureEntry>): void => {
);
};
const writeSidecar = (path: string, file: EnteFile): void => {
// The file's JSON: its basic fields, its magic metadata, and its ML data, or
// the reason the ML data is missing.
const writeSidecar = (
path: string,
file: EnteFile,
ml: { mlData?: MLData; mlDataError?: string },
): void => {
const meta: Record<string, unknown> = {
id: file.id,
collectionID: file.collectionID,
ownerID: file.ownerID,
metadata: file.metadata,
updationTime: file.updationTime,
};
if (file.magicMetadata) meta.magicMetadata = file.magicMetadata;
if (file.pubMagicMetadata) meta.pubMagicMetadata = file.pubMagicMetadata;
if (ml.mlData) meta.mlData = ml.mlData;
if (ml.mlDataError) meta.mlDataError = ml.mlDataError;
writeFileSync(path, JSON.stringify(meta, null, 2));
};
// The album's JSON: its basic fields, its magic metadata, and its files.
const writeAlbumJSON = (
path: string,
c: Collection,
files: { id: number; metadata: FileMetadata }[],
): void => {
const album: Record<string, unknown> = {
id: c.id,
name: c.name,
type: c.type,
ownerID: c.ownerID,
isShared: c.isShared,
updationTime: c.updationTime,
};
if (c.magicMetadata) album.magicMetadata = c.magicMetadata;
if (c.pubMagicMetadata) album.pubMagicMetadata = c.pubMagicMetadata;
if (c.sharedMagicMetadata) {
album.sharedMagicMetadata = c.sharedMagicMetadata;
}
album.files = files;
writeFileSync(path, JSON.stringify(album, null, 2));
};
export const runBackup = async (
lib: BackupLibrary,
opts: BackupOptions,
@@ -374,6 +420,11 @@ export const runBackup = async (
const collectionsDir = join(downloadDirectory, "collections");
const thumbnailsDir = join(downloadDirectory, "thumbnails");
mkdirSync(collectionsDir, { recursive: true });
const { email, userID } = lib.whoami();
writeFileSync(
join(downloadDirectory, "account.json"),
JSON.stringify({ email, userID }, null, 2),
);
if (includeThumbnails) mkdirSync(thumbnailsDir, { recursive: true });
removeLeftoverTempFiles(thumbnailsDir);
for (const dir of dateFolders(downloadDirectory)) {
@@ -497,12 +548,37 @@ export const runBackup = async (
}
// Phase 2: rebuild the derived views from the model. Sidecars first, for
// every present original (this repairs stale ones).
// every present original (this repairs stale ones), each with the file's
// ML data once an ML data fetch has completed. When the fetch fails, a
// file with no cached ML data gets the reason instead and is recorded as
// failed. The next run fetches its ML data again because none is cached.
if (includeOriginals) {
let mlDataError: string | undefined;
try {
log("Fetching ML data...");
await lib.fetchMLData();
} catch (err) {
mlDataError = errorMessage(err);
log(`FAILED ML data: ${mlDataError}`);
}
for (const file of distinct.values()) {
if (storedAtSavePath(downloadDirectory, file) !== undefined) {
const path = savePath(downloadDirectory, file);
writeSidecar(withExtension(path, ".json"), file);
if (storedAtSavePath(downloadDirectory, file) === undefined) {
continue;
}
const path = withExtension(
savePath(downloadDirectory, file),
".json",
);
const mlData = await lib.mlData(file.id);
if (mlData === undefined && mlDataError !== undefined) {
writeSidecar(path, file, { mlDataError });
recordFailure(
file,
collectionName.get(file.collectionID) ?? "",
new Error(`ML data: ${mlDataError}`),
);
} else {
writeSidecar(path, file, { mlData });
}
}
}
@@ -574,13 +650,10 @@ export const runBackup = async (
}
}
writeFileSync(
writeAlbumJSON(
join(collectionsDir, `${colDirName}.json`),
JSON.stringify(
{ id: c.id, name: c.name, type: c.type, files: metaFiles },
null,
2,
),
c,
metaFiles,
);
}
+12 -10
View File
@@ -73,7 +73,9 @@ export const saveSession = (
);
};
// The saved client, or undefined after telling the user why there is none.
// The saved client, or undefined after telling the user why there is none. The
// command then exits 3, the code for "log in again", as `run` in `cli-run.ts`
// does when the server no longer accepts the saved session.
const requireSession = (ctx: CliContext): Client | undefined => {
let client: Client | null;
try {
@@ -150,7 +152,7 @@ export const loginCommand = async (ctx: CliContext): Promise<number> => {
export const whoamiCommand = async (ctx: CliContext): Promise<number> => {
await init();
const client = requireSession(ctx);
if (!client) return 1;
if (!client) return 3;
const info = client.whoami();
ctx.stdout.write(JSON.stringify(info) + "\n");
return 0;
@@ -201,7 +203,7 @@ export const collectionsCommand = async (
): Promise<number> => {
await init();
const client = requireSession(ctx);
if (!client) return 1;
if (!client) return 3;
const lib = await openReadLibrary(ctx, client);
try {
// Force a server round-trip and list in enumeration order (issue #36
@@ -243,7 +245,7 @@ export const filesCommand = async (
): Promise<number> => {
await init();
const client = requireSession(ctx);
if (!client) return 1;
if (!client) return 3;
const collectionID = Number(opts.collection);
if (!Number.isFinite(collectionID)) {
ctx.stderr.write("Invalid collection ID\n");
@@ -285,7 +287,7 @@ export const getCommand = async (
): Promise<number> => {
await init();
const client = requireSession(ctx);
if (!client) return 1;
if (!client) return 3;
const fileID = Number(fileIDStr);
if (!Number.isFinite(fileID)) {
ctx.stderr.write("Invalid file ID\n");
@@ -343,7 +345,7 @@ export const getThumbCommand = async (
): Promise<number> => {
await init();
const client = requireSession(ctx);
if (!client) return 1;
if (!client) return 3;
const fileID = Number(fileIDStr);
if (!Number.isFinite(fileID)) {
ctx.stderr.write("Invalid file ID\n");
@@ -380,7 +382,7 @@ export const backupMetadataCommand = async (
): Promise<number> => {
await init();
const client = requireSession(ctx);
if (!client) return 1;
if (!client) return 3;
const lib = await openReadLibrary(ctx, client);
try {
// Refresh first so the dump holds current account state, not what the
@@ -403,7 +405,7 @@ export const backupCommand = async (
): Promise<number> => {
await init();
const client = requireSession(ctx);
if (!client) return 1;
if (!client) return 3;
ctx.stderr.write("Starting backup...\n");
// The precache is off: the backup fetches what it needs, and must not
@@ -453,7 +455,7 @@ export const listMissingThumbnailsCommand = async (
): Promise<number> => {
await init();
const client = requireSession(ctx);
if (!client) return 1;
if (!client) return 3;
const lib = await openReadLibrary(ctx, client);
try {
// Refresh first so files added since the cache was written are
@@ -491,7 +493,7 @@ export const fixMissingThumbnailsCommand = async (
): Promise<number> => {
await init();
const client = requireSession(ctx);
if (!client) return 1;
if (!client) return 3;
const lib = await openReadLibrary(ctx, client);
try {
// Refresh first so files added since the cache was written are found;
+15 -5
View File
@@ -1,12 +1,15 @@
// Runs one CLI command for `bin/quak.ts` and exits with its code.
import type { Writable } from "node:stream";
import { ApiError } from "./api/client.js";
// Run a command and exit with its code once stdout/stderr have drained.
// Exiting before the drain can truncate piped output, and the library can keep
// the event loop alive after a command returns, so a plain return could hang.
// An error the command throws is printed as one `quak: MESSAGE` line, without
// the stack trace, and exits 1.
// the stack trace, and exits 1. A 401 from the server means it no longer
// accepts the saved session: that prints one line saying to log in again and
// exits 3, as a missing or corrupt session file does.
export const run = async (
command: Promise<number>,
stdout: Writable,
@@ -17,10 +20,17 @@ export const run = async (
try {
code = await command;
} catch (err) {
stderr.write(
`quak: ${err instanceof Error ? err.message : String(err)}\n`,
);
code = 1;
if (err instanceof ApiError && err.status === 401) {
stderr.write(
`quak: the saved session is no longer valid; run "quak login"\n`,
);
code = 3;
} else {
stderr.write(
`quak: ${err instanceof Error ? err.message : String(err)}\n`,
);
code = 1;
}
}
const pending = [stdout, stderr].filter((s) => s.writableLength > 0);
if (pending.length === 0) {
+3 -5
View File
@@ -361,8 +361,8 @@ const openPart = async (
// written unpacked: each part is named `destination` with the extension
// replaced by its own entry's, and the two must differ ignoring case. When the
// file records a hash, `<imageHash>:<videoHash>` must match it, each over that
// part's own bytes. Only then is whatever was at `destination` removed and the
// image, then the video, renamed into place; on any failure neither is stored.
// part's own bytes. Only then are the image, then the video, renamed into
// place; on any failure neither is stored.
//
// The ZIP is chosen by its uploader and may expand enormously, so each part is
// written as it decompresses and never held, and the ZIP is refused once the
@@ -486,7 +486,6 @@ const decryptLivePhoto = async (
await part.handle.sync();
await part.handle.close();
}
await rm(destination, { force: true });
await rename(image.tmpPath, path);
try {
await rename(video.tmpPath, videoPath);
@@ -570,8 +569,7 @@ const fetchAndDecrypt = async (
}, api.getRetryOptions());
// Write `file`'s original to `outPath`. A live photo is written as its image
// and its video beside `outPath` instead, and whatever was at `outPath` is
// removed (see `decryptLivePhoto`).
// and its video beside `outPath` instead (see `decryptLivePhoto`).
export const downloadFile = async (
api: ApiClient,
file: EnteFile,
+5 -2
View File
@@ -1,5 +1,7 @@
// package.json is the one place the version is written. tsc copies it to
// dist/package.json, so this path resolves from source and from dist/src/.
// A build reports the version script/build stamps into dist/package.json;
// package.json's own version is reported only when running from source. tsc
// copies package.json to dist/package.json, so this path resolves from source
// and from dist/src/.
import pkg from "../package.json" with { type: "json" };
export const VERSION: string = pkg.version;
@@ -25,6 +27,7 @@ export {
isRetryable,
isSafeToReplay,
resolveRetryOptions,
UNATTENDED_RETRY_OPTIONS,
withRetry,
type ResolvedRetryOptions,
type RetryOptions,
+8 -38
View File
@@ -29,14 +29,7 @@
// the cache does not count as saved there, but is copied there rather than
// fetched again.
import {
closeSync,
existsSync,
openSync,
readFileSync,
readSync,
statSync,
} from "node:fs";
import { existsSync, readFileSync, statSync } from "node:fs";
import {
chmod,
copyFile,
@@ -281,25 +274,6 @@ const fileSize = (path: string): number | undefined => {
const hasContent = (path: string | undefined): boolean =>
path !== undefined && (fileSize(path) ?? 0) > 0;
// Whether the file at `path` begins as a ZIP does, with `PK\x03\x04`. False
// when it cannot be read.
const isZip = (path: string): boolean => {
try {
const fd = openSync(path, "r");
try {
const head = Buffer.alloc(4);
return (
readSync(fd, head, 0, 4, 0) === 4 &&
head.toString("latin1") === "PK\x03\x04"
);
} finally {
closeSync(fd);
}
} catch {
return false;
}
};
// A live photo's image and video are named with the extensions from inside its
// ZIP, so their names alone do not say which is which. Wherever the cache or a
// save path stores one, a JSON file of this name beside them names both.
@@ -713,8 +687,10 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
await this.touch(cached.path);
return { ...cached, bytes: size, cached: true };
}
// A recorded file that has since gone, or a live photo an earlier
// version stored as one ZIP, re-fetches below.
// A recorded file that has since gone re-fetches below. So does a
// live photo recorded with no video: the cache opened before the
// library's records said it is a live photo, while its image and
// video had no JSON file beside them yet.
known.delete(fileID);
}
@@ -983,20 +959,14 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
if (id === undefined || !existsSync(path)) continue;
// A live photo's image and video are one entry, as the JSON file
// beside them names them. A live photo's file with no such JSON
// file is not its original. If it is a ZIP, it is the one an
// earlier version stored under the image's name, and is removed.
// Any other is left alone: another process may have just stored
// it and not yet written the JSON file.
// file is not its original and is left alone: another process may
// have just stored it and not yet written the JSON file.
const livePhoto = names.has(livePhotoJSONName(String(id)))
? readLivePhotoJSON(dir, String(id))
: undefined;
if (livePhoto !== undefined) {
into.set(id, livePhoto);
} else if (isLivePhoto(id)) {
if (isZip(path)) {
await rm(path, { force: true }).catch(() => undefined);
}
} else {
} else if (!isLivePhoto(id)) {
into.set(id, { path });
}
}
+25 -10
View File
@@ -279,7 +279,8 @@ export class Library {
private cycle?: Promise<void>;
// Guards the ML fetch pass so a slow backfill never runs twice at once; a
// refresh whose pass is still running kicks nothing new. Holds the running
// pass, so `close()` can wait for it.
// pass, so `close()` and `backup()` can wait for it. It rejects when the
// pass fails.
private mlFetch?: Promise<void>;
private closed = false;
private lastRefreshAt?: number;
@@ -569,8 +570,10 @@ export class Library {
// does, joining one already running, and rejects before touching any file
// when it fails. Then puts pending originals at their save paths as
// `Photo.download()` does (and optional thumbnails) through the content
// cache and pools, and rebuilds the derived symlink/JSON views from the
// model. Throws before any network work when no content cache backs the
// cache and pools, waits for an ML data fetch, and rebuilds the derived
// symlink/JSON views from the model, each file's JSON with its ML data,
// beside an `account.json` with the account's email and user ID.
// Throws before any network work when no content cache backs the
// originals it must fetch.
backup(opts?: BackupOptions): Promise<BackupResult> {
const downloadDirectory =
@@ -587,12 +590,15 @@ export class Library {
const cache = this.cache;
return runBackup(
{
whoami: () => this.client.whoami(),
refresh: () => this.refreshNow(),
listCollections: () => this.store.listCollections(),
listFiles: (id) => this.store.listFiles(id),
original: (fileID, destination) =>
cache!.backupOriginal(fileID, destination),
thumbnail: (fileID) => cache!.thumbnail(fileID),
fetchMLData: () => this.fetchMLDataNow(),
mlData: (fileID) => this.mldata.forFile({ fileID }),
},
{ ...opts, downloadDirectory },
);
@@ -612,7 +618,7 @@ export class Library {
this.timer = undefined;
}
await this.cycle?.catch(() => {});
await this.mlFetch;
await this.mlFetch?.catch(() => {});
await precacheClosed;
}
@@ -676,10 +682,9 @@ export class Library {
// Backfill ML data for the files this refresh knows about. It runs
// outside the refresh's success/failure so a fetch or disk problem
// there never marks the metadata refresh failed, and it is not
// awaited so it never stalls the refresh interval.
this.mlFetch ??= this.runMLFetch().finally(() => {
this.mlFetch = undefined;
});
// awaited so it never stalls the refresh interval. Its failure is
// reported through `status()` and `onProgress`.
void this.fetchMLDataNow().catch(() => {});
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
this.lastError = error;
@@ -784,10 +789,19 @@ export class Library {
}
}
// Join the running ML fetch pass, or start one when none runs. Resolves at
// once when the client cannot fetch ML data; rejects when the pass fails.
private fetchMLDataNow(): Promise<void> {
this.mlFetch ??= this.runMLFetch().finally(() => {
this.mlFetch = undefined;
});
return this.mlFetch;
}
// One ML fetch pass: fetch, decrypt and store the ML data for every file
// the store knows about that is not cached (or whose `updationTime` has
// advanced), through the metadata pool, and update the CLIP index. Guarded
// so passes never overlap; a failure is reported, not thrown.
// advanced), through the metadata pool, and update the CLIP index. A
// failure is reported through `status()` and `onProgress`, then thrown.
private async runMLFetch(): Promise<void> {
const mldata = this.mlStore;
// Bind so the call keeps the client as its receiver when invoked
@@ -831,6 +845,7 @@ export class Library {
const error = err instanceof Error ? err.message : String(err);
this.lastMLError = error;
this.emit({ operation: "fetchMLData", status: "failed", error });
throw err;
}
}
+10
View File
@@ -37,6 +37,16 @@ export const DEFAULT_RETRY_OPTIONS: ResolvedRetryOptions = {
random: Math.random,
};
// For a run nobody is watching, such as `quak backup` from cron. A request that
// keeps failing waits at most 243 s in all before it gives up, usually about
// half that, since each wait is drawn at random below its ceiling.
export const UNATTENDED_RETRY_OPTIONS: ResolvedRetryOptions = {
...DEFAULT_RETRY_OPTIONS,
attempts: 10,
baseDelayMs: 1_000,
maxDelayMs: 60_000,
};
export const resolveRetryOptions = (
opts?: RetryOptions,
): ResolvedRetryOptions => ({
+193
View File
@@ -12,6 +12,7 @@
* collections/
* <name>/<title> symlink to the original (rebuilt each run)
* <name>.json per-collection metadata (rebuilt each run)
* account.json the account's email and user ID
* failures.json durable ledger of unresolved failures
*
* The properties that distinguish backup from a naive download loop, and that
@@ -52,6 +53,7 @@ import { runBackup, type BackupLibrary } from "../../src/backup.js";
import { Library } from "../../src/library/index.js";
import type { ContentSource } from "../../src/library/content.js";
import type { CollectionsPage, FilesPage } from "../../src/client.js";
import type { MLData } from "../../src/mldata-fetch.js";
import type { Collection, EnteFile } from "../../src/model/types.js";
import {
asLivePhoto,
@@ -828,6 +830,194 @@ describe("the refresh before a backup", () => {
});
});
// Also serves ML data: file 100 has `ML_PAYLOAD`, the other two have none.
// While `mlError` is set, every ML data request fails with it.
const ML_PAYLOAD = { face: { faces: [] }, clip: { embedding: [0.5, 0.25] } };
class MLClient extends MockClient {
mlError?: string;
async fetchMLData(args: {
fileIDs: number[];
}): Promise<Map<number, MLData>> {
if (this.mlError) throw new Error(this.mlError);
const result = new Map<number, MLData>();
if (args.fileIDs.includes(100)) result.set(100, ML_PAYLOAD);
return result;
}
}
// The JSON the backup in `outDir` wrote beside the original of `fileID`.
const fileJSON = (outDir: string, fileID: number): Record<string, unknown> =>
JSON.parse(readFileSync(saved(outDir, `${fileID}.json`), "utf-8"));
describe("ML data in each file's JSON", () => {
it("writes a file's ML data, and no ML field for a file that has none", async () => {
const lib = await openLibrary(stubSource(), new MLClient());
const outDir = join(root, "backup");
const result = await lib.backup({ downloadDirectory: outDir });
expect(result.failed).toBe(0);
expect(fileJSON(outDir, 100).mlData).toEqual(ML_PAYLOAD);
expect(fileJSON(outDir, 101)).not.toHaveProperty("mlData");
expect(fileJSON(outDir, 101)).not.toHaveProperty("mlDataError");
await lib.close();
});
it("gives each file with no cached ML data the reason when the fetch fails, and fails the run", async () => {
const client = new MLClient();
const outDir = join(root, "backup");
const first = await openLibrary(stubSource(), client);
await first.backup({ downloadDirectory: outDir });
await first.close();
client.mlError = "HTTP 503 from server";
const lib = await openLibrary(stubSource(), client);
const result = await lib.backup({ downloadDirectory: outDir });
// File 100's ML data was cached by the first run and is kept.
expect(fileJSON(outDir, 100).mlData).toEqual(ML_PAYLOAD);
expect(fileJSON(outDir, 100)).not.toHaveProperty("mlDataError");
for (const fileID of [101, 200]) {
expect(fileJSON(outDir, fileID).mlDataError).toBe(
"HTTP 503 from server",
);
}
expect(result.failed).toBe(2);
expect(result.errors.map((e) => [e.fileID, e.error])).toEqual([
[101, "ML data: HTTP 503 from server"],
[200, "ML data: HTTP 503 from server"],
]);
expect(Object.keys(readLedger(outDir).files)).toEqual(["101", "200"]);
await lib.close();
});
it("writes the ML data on the run after a failed fetch", async () => {
const client = new MLClient();
client.mlError = "HTTP 503 from server";
const outDir = join(root, "backup");
const first = await openLibrary(stubSource(), client);
expect((await first.backup({ downloadDirectory: outDir })).failed).toBe(
3,
);
expect(fileJSON(outDir, 100)).not.toHaveProperty("mlData");
await first.close();
client.mlError = undefined;
const lib = await openLibrary(stubSource(), client);
const result = await lib.backup({ downloadDirectory: outDir });
expect(result.failed).toBe(0);
expect(result.skipped).toBe(3);
expect(fileJSON(outDir, 100).mlData).toEqual(ML_PAYLOAD);
for (const fileID of [100, 101, 200]) {
expect(fileJSON(outDir, fileID)).not.toHaveProperty("mlDataError");
}
expect(existsSync(join(outDir, "failures.json"))).toBe(false);
await lib.close();
});
});
// The fields `RecordsClient` gives the Vacation album: shared by another
// account, with all three layers of magic metadata.
const SHARED_ALBUM = {
ownerID: 7,
isShared: true,
updationTime: 1700,
magicMetadata: { visibility: 0 },
pubMagicMetadata: { coverID: 100 },
sharedMagicMetadata: { visibility: 2 },
};
// Serves the Vacation album with `SHARED_ALBUM`'s fields, and each file with
// the update time 5000 + its ID.
class RecordsClient extends MockClient {
override async collectionsSince(): Promise<CollectionsPage> {
const page = await super.collectionsSince();
return {
...page,
collections: page.collections.map((c) =>
c.id === 1 ? { ...c, ...SHARED_ALBUM } : c,
),
};
}
override async filesSince(args: {
collectionID: number;
}): Promise<FilesPage> {
const page = await super.filesSince(args);
return {
...page,
files: page.files.map((f) => ({
...f,
updationTime: 5000 + f.id,
})),
};
}
}
// The JSON the backup in `outDir` wrote for the album named `name`.
const albumJSON = (outDir: string, name: string): Record<string, unknown> =>
JSON.parse(
readFileSync(join(outDir, "collections", `${name}.json`), "utf-8"),
);
describe("account and album records", () => {
it("writes account.json with the account's email and user ID", async () => {
const lib = await openLibrary(stubSource());
const outDir = join(root, "backup");
await lib.backup({ downloadDirectory: outDir });
expect(
JSON.parse(readFileSync(join(outDir, "account.json"), "utf-8")),
).toEqual({ email: "backup@example.com", userID: USER_ID });
await lib.close();
});
it("writes each album's owner, sharing, update time and magic metadata into its JSON", async () => {
const lib = await openLibrary(stubSource(), new RecordsClient());
const outDir = join(root, "backup");
await lib.backup({ downloadDirectory: outDir });
expect(albumJSON(outDir, "Vacation")).toEqual({
id: 1,
name: "Vacation",
type: "album",
...SHARED_ALBUM,
files: [
{ id: 100, metadata: file(100, 1, "beach.jpg").metadata },
{ id: 101, metadata: file(101, 1, "sunset.jpg").metadata },
],
});
// An album with no magic metadata gets no magic metadata fields.
expect(albumJSON(outDir, "Work")).toEqual({
id: 2,
name: "Work",
type: "album",
ownerID: USER_ID,
isShared: false,
updationTime: 1,
files: [
{ id: 200, metadata: file(200, 2, "diagram.png").metadata },
],
});
await lib.close();
});
it("writes each file's update time into its JSON", async () => {
const lib = await openLibrary(stubSource(), new RecordsClient());
const outDir = join(root, "backup");
await lib.backup({ downloadDirectory: outDir });
for (const fileID of [100, 101, 200]) {
expect(fileJSON(outDir, fileID).updationTime).toBe(5000 + fileID);
}
await lib.close();
});
});
// Every entry under collections/, one level of directories deep, with each
// symlink's target.
const tree = (outDir: string): string[] => {
@@ -871,6 +1061,9 @@ describe("backup album folders", () => {
thumbnail: async () => {
throw new Error("no thumbnails in this stand-in");
},
fetchMLData: async () => {},
mlData: async () => undefined,
whoami: () => ({ email: "backup@example.com", userID: USER_ID }),
});
const albumID = (outDir: string, jsonName: string): number =>
+71
View File
@@ -0,0 +1,71 @@
/**
* Tests for the retry options `bin/quak.ts` loads the saved session with:
* `quak backup` gets the unattended ones, every other command the default.
*
* Each test runs `bin/quak.ts`, as the smoke test does, with its session loader
* replaced by one that records the options it is given and reports no session,
* so the command stops before it makes any request.
*/
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import type { ApiClientOptions } from "../../src/api/client.js";
import { UNATTENDED_RETRY_OPTIONS } from "../../src/retry.js";
const loaded = vi.hoisted(() => [] as (ApiClientOptions | undefined)[]);
vi.mock("../../src/cli-session.js", () => ({
loadSession: (_path: string, apiOptions?: ApiClientOptions) => {
loaded.push(apiOptions);
return null;
},
}));
const argv = process.argv;
const dir = mkdtempSync(join(tmpdir(), "quak-bin-test-"));
// Run `quak <args>` to completion. The "Not logged in" message and the exit
// are swallowed.
const quak = async (...args: string[]): Promise<void> => {
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
process.argv = ["node", "quak", ...args];
vi.resetModules();
await import("../../bin/quak.js");
};
afterEach(() => {
process.argv = argv;
loaded.length = 0;
vi.restoreAllMocks();
});
afterAll(() => {
rmSync(dir, { recursive: true, force: true });
});
describe("bin/quak.ts session loading", () => {
it("loads the session for backup with the unattended retry options", async () => {
await quak("backup", dir);
// `vi.resetModules()` gave `bin/quak.ts` its own copy of
// `src/retry.ts`, whose `sleep` is a different function, so the
// options are compared by their numbers.
const { attempts, baseDelayMs, maxDelayMs } = UNATTENDED_RETRY_OPTIONS;
expect(loaded).toEqual([
{
retry: expect.objectContaining({
attempts,
baseDelayMs,
maxDelayMs,
}),
},
]);
});
it("loads the session for another command with the default options", async () => {
await quak("collections");
expect(loaded).toEqual([undefined]);
});
});
+80 -14
View File
@@ -230,20 +230,30 @@ describe("session file", () => {
expect(JSON.parse(readFileSync(path, "utf-8"))).toEqual(snapshot);
});
it("a missing session exits 1 with 'Not logged in'", async () => {
it("a missing session exits 3 with 'Not logged in' from every command that needs one", async () => {
const ctx = { ...context(), loadSession };
expect(await whoamiCommand(ctx)).toBe(1);
expect(stderr.text).toBe(
const dir = join(root, "backup");
expect(await whoamiCommand(ctx)).toBe(3);
expect(await collectionsCommand(ctx, {})).toBe(3);
expect(await filesCommand(ctx, { collection: "1" })).toBe(3);
expect(await getCommand(ctx, "100", {})).toBe(3);
expect(await getThumbCommand(ctx, "100", {})).toBe(3);
expect(await backupMetadataCommand(ctx, dir, {})).toBe(3);
expect(await backupCommand(ctx, dir, {})).toBe(3);
expect(await listMissingThumbnailsCommand(ctx, {})).toBe(3);
expect(await fixMissingThumbnailsCommand(ctx, {})).toBe(3);
const notLoggedIn =
`Not logged in. Run "quak login" first.\n` +
`Session file: ${join(ctx.sessionDir, "session.json")}\n`,
);
`Session file: ${join(ctx.sessionDir, "session.json")}\n`;
expect(stderr.text).toBe(notLoggedIn.repeat(9));
expect(stdout.text).toBe("");
expect(existsSync(dir)).toBe(false);
});
it("a corrupt session exits 1 and says it is corrupt", async () => {
it("a corrupt session exits 3 and says it is corrupt", async () => {
const ctx = { ...context(), loadSession };
saveSession(ctx.sessionDir, snapshot);
expect(await collectionsCommand(ctx, {})).toBe(1);
expect(await collectionsCommand(ctx, {})).toBe(3);
expect(stderr.text).toContain("is corrupt");
expect(stderr.text).toContain(
`Run "quak logout" and then "quak login" to replace it.\n`,
@@ -732,15 +742,28 @@ describe("backup", () => {
expect(stderr.text).toBe("Starting backup...\n");
});
it("exits 1 with the error on one line when the refresh fails", async () => {
it("exits 1 and lists each file when the ML data fetch fails", async () => {
const client = {
...fakeClient(),
collectionsSince: async () => {
throw new Error("HTTP 401 from server");
fetchMLData: async () => {
throw new Error("HTTP 503 from server");
},
} as unknown as Client;
const dir = join(root, "backup");
// Through `run`, as `bin/quak.ts` does, which prints a thrown error.
expect(
await backupCommand(context(client), join(root, "backup"), {}),
).toBe(1);
expect(stderr.text).toContain(" Failed: 3\n");
expect(stderr.text).toContain(
" [Vacation] beach.jpg (id 100): ML data: HTTP 503 from server\n",
);
});
// Runs `backup` through `run`, as `bin/quak.ts` does, which prints a thrown
// error; returns the exit code and what `run` printed.
const backupThroughRun = async (
ctx: CliContext,
dir: string,
): Promise<{ code: number; runText: string }> => {
const runStderr = new PassThrough();
let runText = "";
runStderr.on("data", (chunk: Buffer) => {
@@ -748,14 +771,57 @@ describe("backup", () => {
});
const code = await new Promise<number>((resolve) => {
void run(
backupCommand(context(client), dir, {}),
backupCommand(ctx, dir, {}),
new PassThrough(),
runStderr,
resolve,
);
});
return { code, runText };
};
it("exits 1 with the error on one line when the refresh fails", async () => {
const client = {
...fakeClient(),
collectionsSince: async () => {
throw new Error("HTTP 503 from server");
},
} as unknown as Client;
const dir = join(root, "backup");
const { code, runText } = await backupThroughRun(context(client), dir);
expect(code).toBe(1);
expect(runText).toBe("quak: HTTP 401 from server\n");
expect(runText).toBe("quak: HTTP 503 from server\n");
expect(stderr.text).toBe("Starting backup...\nRefreshing library...\n");
expect(existsSync(dir)).toBe(false);
});
// A real saved session, read back by `loadSession`, whose server answers
// every request with 401, as it does once it no longer accepts the token.
// The context's prompts throw, so a prompt would end the run with another
// line and exit 1.
it("exits 3 with one line saying to log in again when the server answers 401", async () => {
const key = toBase64(new Uint8Array(32));
saveSession(join(root, "session"), {
email: "cli@example.com",
userID: USER_ID,
token: "expired",
masterKey: key,
secretKey: key,
publicKey: key,
});
const unauthorized = async (): Promise<Response> =>
new Response(null, { status: 401 });
const ctx = {
...context(),
loadSession: (path: string) =>
loadSession(path, { fetch: unauthorized }),
};
const dir = join(root, "backup");
const { code, runText } = await backupThroughRun(ctx, dir);
expect(code).toBe(3);
expect(runText).toBe(
`quak: the saved session is no longer valid; run "quak login"\n`,
);
expect(stderr.text).toBe("Starting backup...\nRefreshing library...\n");
expect(existsSync(dir)).toBe(false);
});
+63
View File
@@ -67,6 +67,51 @@ const TIFF_ALTITUDE_WITHOUT_REF = [
...[0x00, 0x00, 0x00, 0x19, 0x00, 0x00, 0x00, 0x02], // 25/2, at 44
];
// A big-endian TIFF block holding a GPSLatitude of 33° 30' 0" and a
// GPSLatitudeRef of "S".
const TIFF_SOUTHERN_LATITUDE = [
...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8
// The first IFD, at 8: one entry, then no next IFD.
...[0x00, 0x01],
// The GPS IFD's offset (0x8825), LONG, 26.
...[0x88, 0x25, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x1a],
...[0x00, 0x00, 0x00, 0x00],
// The GPS IFD, at 26: two entries, then no next IFD.
...[0x00, 0x02],
// GPSLatitudeRef (0x0001), 2 ASCII bytes: "S".
...[0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02, 0x53, 0x00, 0x00, 0x00],
// GPSLatitude (0x0002), three RATIONALs at 56.
...[0x00, 0x02, 0x00, 0x05, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x38],
...[0x00, 0x00, 0x00, 0x00],
...[0x00, 0x00, 0x00, 0x21, 0x00, 0x00, 0x00, 0x01], // 33/1, at 56
...[0x00, 0x00, 0x00, 0x1e, 0x00, 0x00, 0x00, 0x01], // 30/1
...[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01], // 0/1
];
// A big-endian TIFF block holding a GPSLatitude of 40° 26' 46" and a
// GPSLongitude of 79° 58' 56", and neither GPSLatitudeRef nor GPSLongitudeRef.
const TIFF_POSITION_WITHOUT_REFS = [
...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8
// The first IFD, at 8: one entry, then no next IFD.
...[0x00, 0x01],
// The GPS IFD's offset (0x8825), LONG, 26.
...[0x88, 0x25, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x1a],
...[0x00, 0x00, 0x00, 0x00],
// The GPS IFD, at 26: two entries, then no next IFD.
...[0x00, 0x02],
// GPSLatitude (0x0002), three RATIONALs at 56.
...[0x00, 0x02, 0x00, 0x05, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x38],
// GPSLongitude (0x0004), three RATIONALs at 80.
...[0x00, 0x04, 0x00, 0x05, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x50],
...[0x00, 0x00, 0x00, 0x00],
...[0x00, 0x00, 0x00, 0x28, 0x00, 0x00, 0x00, 0x01], // 40/1, at 56
...[0x00, 0x00, 0x00, 0x1a, 0x00, 0x00, 0x00, 0x01], // 26/1
...[0x00, 0x00, 0x00, 0x2e, 0x00, 0x00, 0x00, 0x01], // 46/1
...[0x00, 0x00, 0x00, 0x4f, 0x00, 0x00, 0x00, 0x01], // 79/1, at 80
...[0x00, 0x00, 0x00, 0x3a, 0x00, 0x00, 0x00, 0x01], // 58/1
...[0x00, 0x00, 0x00, 0x38, 0x00, 0x00, 0x00, 0x01], // 56/1
];
// A big-endian TIFF block holding Orientation 6 and a Make whose value lies
// past the end of the file.
const TIFF_MAKE_PAST_END = [
@@ -182,6 +227,24 @@ describe("readPhotoExif", () => {
});
});
it("reads a GPSLatitude with GPSLatitudeRef S as south of the equator", () => {
const data = [...EXIF_HEADER, ...TIFF_SOUTHERN_LATITUDE];
expect(
readPhotoExif(readAllExifTags(bytes(SOI, app1(data), SOS))),
).toStrictEqual({
gpsLatitude: -33.5,
});
});
it("gives no gpsLatitude or gpsLongitude without their reference tags", () => {
const data = [...EXIF_HEADER, ...TIFF_POSITION_WITHOUT_REFS];
const tags = readAllExifTags(bytes(SOI, app1(data), SOS));
// The position is read; only its hemisphere is unknown.
expect(tags.GPSLatitude?.computed).toStrictEqual([40, 26, 46]);
expect(tags.GPSLongitude?.computed).toStrictEqual([79, 58, 56]);
expect(readPhotoExif(tags)).toStrictEqual({});
});
it("gives no make for a Make whose value lies past the end of the file", () => {
const data = [...EXIF_HEADER, ...TIFF_MAKE_PAST_END];
expect(
+23
View File
@@ -5,6 +5,7 @@
import { PassThrough } from "node:stream";
import { describe, it, expect } from "vitest";
import { ApiError } from "../../src/api/client.js";
import { run } from "../../src/cli-run.js";
// A stream whose written text is kept in `text`; writes finish at once, so
@@ -55,4 +56,26 @@ describe("run", () => {
stderr: "quak: offline\n",
});
});
it("on a 401 from the server says to run quak login, on one line, and exits 3", async () => {
const result = await runToExit(
Promise.reject(new ApiError("unauthorized", 401)),
);
expect(result).toEqual({
code: 3,
stdout: "",
stderr: `quak: the saved session is no longer valid; run "quak login"\n`,
});
});
it("prints another HTTP error as it is and exits 1", async () => {
const result = await runToExit(
Promise.reject(new ApiError("forbidden", 403)),
);
expect(result).toEqual({
code: 1,
stdout: "",
stderr: "quak: forbidden\n",
});
});
});
+18
View File
@@ -12,6 +12,10 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { init, toBase64 } from "../../src/crypto/index.js";
import { Client, type ClientSnapshot } from "../../src/client.js";
import { loadSession } from "../../src/cli-session.js";
import {
DEFAULT_RETRY_OPTIONS,
UNATTENDED_RETRY_OPTIONS,
} from "../../src/retry.js";
const validSnapshot = (): ClientSnapshot => {
const kp = sodium.crypto_box_keypair();
@@ -176,6 +180,20 @@ describe("loadSession", () => {
});
});
it("gives the restored client the retry options it is passed", () => {
const path = join(dir, "retry.json");
writeFileSync(path, JSON.stringify(validSnapshot()));
const retryOptions = (client: Client | null) =>
client!.getApiClient().getRetryOptions();
expect(
retryOptions(
loadSession(path, { retry: UNATTENDED_RETRY_OPTIONS }),
),
).toEqual(UNATTENDED_RETRY_OPTIONS);
expect(retryOptions(loadSession(path))).toEqual(DEFAULT_RETRY_OPTIONS);
});
it("says the file is corrupt when it is not JSON", () => {
const path = join(dir, "truncated.json");
writeFileSync(path, '{"email": "user@exa');
-9
View File
@@ -1883,15 +1883,6 @@ describe("downloadFile live photos", () => {
expect(readdirSync(t.dir).sort()).toEqual(["f.JPG", "f.bin"]);
});
it("replaces what was at the destination, such as an earlier ZIP of the two", async () => {
const t = setup(livePhotoZip(), livePhoto);
writeFileSync(t.outPath, livePhotoZip());
await t.run();
expect(readdirSync(t.dir).sort()).toEqual(["f.heic", "f.mov"]);
});
it("renames the image and then the video into place, each from its own temp file", async () => {
const t = setup(livePhotoZip(), livePhoto);
+3
View File
@@ -162,6 +162,9 @@ describe("examples/download-albums.ts", () => {
refreshIntervalSeconds: 3600,
precacheThumbnails: false,
precacheOriginals: false,
// The test counts fetches, so the free space of the disk it
// runs on must not shrink the cache and evict photo 1.
freeBelowBytes: 0,
});
const first = await open();
+17 -3
View File
@@ -176,7 +176,7 @@ describe("Library content wiring", () => {
await lib.close();
});
it("removes a live photo's ZIP an earlier version cached when it opens, and precaches its image and video", async () => {
it("does not take a live photo's image or video with no JSON file as its original when it opens, and precaches both", async () => {
const { file: live, body } = await asLivePhoto(file(1, 1));
class LiveClient extends MockClient {
override async filesSince(): Promise<FilesPage> {
@@ -197,7 +197,8 @@ describe("Library content wiring", () => {
// A first run records the library, so the next one knows that file 1
// is a live photo when it opens the cache.
await (await open({})).close();
writeFileSync(join(originals, "1.jpg"), livePhotoZip());
writeFileSync(join(originals, "1.heic"), "an image");
writeFileSync(join(originals, "1.mov"), "a video");
let precached!: () => void;
const done = new Promise<void>((r) => (precached = r));
@@ -208,7 +209,9 @@ describe("Library content wiring", () => {
precached();
},
});
expect(existsSync(join(originals, "1.jpg"))).toBe(false);
expect(
lib.photos.byID({ fileID: 1 })!.record().originalPath,
).toBeUndefined();
await done;
expect(readdirSync(originals).sort()).toEqual([
@@ -216,6 +219,17 @@ describe("Library content wiring", () => {
"1.livephoto.json",
"1.mov",
]);
expect(readFileSync(join(originals, "1.heic"))).toEqual(
Buffer.from(IMAGE),
);
expect(readFileSync(join(originals, "1.mov"))).toEqual(
Buffer.from(VIDEO),
);
expect(
JSON.parse(
readFileSync(join(originals, "1.livephoto.json"), "utf-8"),
),
).toEqual({ image: "1.heic", video: "1.mov" });
expect(lib.photos.byID({ fileID: 1 })!.record().originalPath).toBe(
join(originals, "1.heic"),
);
+30 -37
View File
@@ -566,43 +566,6 @@ describe("ContentCache live photos", () => {
expect(events).toEqual(["skipped"]);
});
it("replaces a live photo an earlier version stored as a ZIP under the image's name", async () => {
const { file: live, body } = await asLivePhoto(file(5, "IMG_5.HEIC"));
mkdirSync(originals(), { recursive: true });
writeFileSync(join(originals(), "5.HEIC"), livePhotoZip());
const cache = cacheOf([live], new Map([[5, body]]));
// Opened without being told that file 5 is a live photo, the cache
// records the ZIP, and does not serve it.
await cache.open();
const result = await cache.original(5);
expect(result.videoPath).toBe(join(originals(), "5.mov"));
expect(readdirSync(originals()).sort()).toEqual([
"5.heic",
"5.livephoto.json",
"5.mov",
]);
});
it("removes a live photo's ZIP an earlier version stored when it opens, so the precache fetches the image and video", async () => {
const { file: live, body } = await asLivePhoto(file(5, "IMG_5.HEIC"));
mkdirSync(originals(), { recursive: true });
writeFileSync(join(originals(), "5.HEIC"), livePhotoZip());
const cache = cacheOf([live], new Map([[5, body]]));
await cache.open((fileID) => fileID === 5);
expect(readdirSync(originals())).toEqual([]);
expect(cache.pathsFor(5)).toEqual({});
const [fetched] = await cache.ensureOriginals({ fileIDs: [5] });
expect(fetched).toEqual({
fileID: 5,
path: join(originals(), "5.heic"),
});
expect(cache.pathsFor(5)).toEqual({ originalPath: fetched!.path });
});
it("leaves the image and video another process has just stored when it opens before their JSON file is written", async () => {
const { file: live, body } = await asLivePhoto(file(5, "IMG_5.HEIC"));
const server = cdnSource(new Map([[5, body]]));
@@ -633,6 +596,36 @@ describe("ContentCache live photos", () => {
expect(second!.pathsFor(5)).toEqual({});
});
it("fetches a live photo's image and video again when the cache opened before knowing it is a live photo and no JSON file names them", async () => {
const { file: live, body } = await asLivePhoto(file(5, "IMG_5.HEIC"));
mkdirSync(originals(), { recursive: true });
writeFileSync(join(originals(), "5.heic"), "an image");
writeFileSync(join(originals(), "5.mov"), "a video");
const cache = cacheOf([live], new Map([[5, body]]));
// Opened without being told that file 5 is a live photo, the cache
// records one of the two files as its original, with no video.
await cache.open();
const events: string[] = [];
const result = await cache.original(5, {
onProgress: (e) => events.push(e.status),
});
expect(events.at(-1)).toBe("done");
expect(result).toEqual({
path: join(originals(), "5.heic"),
videoPath: join(originals(), "5.mov"),
bytes: IMAGE.length,
});
expect(readFileSync(result.path)).toEqual(Buffer.from(IMAGE));
expect(readFileSync(result.videoPath!)).toEqual(Buffer.from(VIDEO));
expect(
JSON.parse(
readFileSync(join(originals(), "5.livephoto.json"), "utf-8"),
),
).toEqual({ image: "5.heic", video: "5.mov" });
});
it.each(["missing", "empty"])(
"fetches a live photo again when the video its JSON file names is %s",
async (state) => {
+26 -12
View File
@@ -9,8 +9,10 @@
// Excluding too much: Prettier 3 reads `.gitignore` as a default ignore file,
// so dropping it from the context silently changes which files the lint
// phase's prettier check looks at compared to `make fmt-check` on the host.
// And without `.git`, a `docker build .` given no `VERSION` build arg cannot
// derive the version (`script/version`) and stamps `package.json`'s instead.
//
// Neither shows up as a build failure, so they are asserted here.
// None of these shows up as a build failure, so they are asserted here.
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
@@ -27,18 +29,19 @@ const patterns = (name: string): string[] =>
const dockerignore = patterns(".dockerignore");
describe(".dockerignore", () => {
// Everything here is either generated, enormous, or secret. `.claude/` is
// the correctness one: see the header comment and issue #25.
// Everything here is either generated, enormous, or secret. `.claude` is
// the correctness one: see the header comment and issue #25. The leading
// `/` anchors an entry at the root of the context.
it.each([
".claude/",
".quak/",
"bin/quak",
"node_modules",
"coverage",
"dist",
".vitest-cache/",
".nyc_output/",
"*.tsbuildinfo",
".claude",
"/.quak",
"/bin/quak",
"**/node_modules",
"/coverage",
"/dist",
"/.vitest-cache",
"/.nyc_output",
"/*.tsbuildinfo",
])("keeps %s out of the build context", (pattern) => {
expect(dockerignore).toContain(pattern);
});
@@ -47,6 +50,17 @@ describe(".dockerignore", () => {
expect(dockerignore).not.toContain(".gitignore");
});
it("leaves .git in the build context for the version", () => {
expect(dockerignore).not.toContain(".git");
expect(dockerignore).not.toContain(".git/");
});
// The build stage is the final image, so a .git/config sent in would
// ship the clone's remote URL and any credential in it.
it("sends .git without its config", () => {
expect(dockerignore).toContain("**/.git/config");
});
// BuildKit lets a `Dockerfile.dockerignore` shadow the root one; such a
// file would silently give the build a different, unreviewed context —
// and eslint's flat config does not ignore dot-directories, so a stray
+137
View File
@@ -0,0 +1,137 @@
// `script/version` prints the version `script/build` stamps into
// `dist/package.json`, which is what `quak --version` reports from a build.
// A `docker build .` of a clone is given no `VERSION` build arg, so the
// version has to come from the `.git` in its context: the tag on a tagged
// commit; the tag, the commits since it and the short commit on a later commit;
// the short commit when no tag is reachable. A checkout with `.git` that still
// yields no usable version must fail the build, not ship a version nobody can
// trace back to its commit.
//
// Each test copies the script into a fresh directory, which the script then
// treats as the checkout, and executes it there.
import { afterEach, describe, expect, it } from "vitest";
import { execFileSync, spawnSync } from "node:child_process";
import {
chmodSync,
copyFileSync,
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
let checkout = "";
afterEach(() => {
rmSync(checkout, { recursive: true, force: true });
});
// A checkout holding the script and a package.json that declares 0.0.0, with
// no .git yet.
const makeCheckout = (): void => {
checkout = mkdtempSync(join(tmpdir(), "quak-version-"));
mkdirSync(join(checkout, "script"));
copyFileSync(
join(repoRoot, "script/version"),
join(checkout, "script/version"),
);
chmodSync(join(checkout, "script/version"), 0o755);
writeFileSync(join(checkout, "package.json"), '{ "version": "0.0.0" }\n');
};
// git in the checkout, with an identity and no commit signing, whatever the
// host's own git config says.
const git = (...args: string[]): string =>
execFileSync(
"git",
[
"-c",
"user.name=quak",
"-c",
"user.email=quak@example.invalid",
"-c",
"commit.gpgsign=false",
...args,
],
{ cwd: checkout, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] },
).trim();
const makeCommittedCheckout = (): void => {
makeCheckout();
git("init", "-q");
git("add", "package.json");
git("commit", "-q", "-m", "first");
};
// Runs the script with nothing in its environment but PATH and, when given,
// VERSION.
const runVersion = (version?: string) =>
spawnSync(join(checkout, "script/version"), {
cwd: checkout,
encoding: "utf-8",
env: { PATH: process.env.PATH, VERSION: version },
});
describe("script/version", () => {
it("prints the short commit of an untagged commit", () => {
makeCommittedCheckout();
expect(runVersion().stdout.trim()).toBe(
git("rev-parse", "--short", "HEAD"),
);
});
it("prints the tag of a tagged commit", () => {
makeCommittedCheckout();
git("tag", "v1.2.3");
expect(runVersion().stdout.trim()).toBe("v1.2.3");
});
// script/docker and script/cibuild pass the version they resolve on the
// host as the VERSION build arg.
it("prints the VERSION it is given over what git would derive", () => {
makeCommittedCheckout();
expect(runVersion("x").stdout.trim()).toBe("x");
});
// `--build-arg VERSION=` must not stamp an empty version.
it("treats an empty VERSION as unset", () => {
makeCommittedCheckout();
expect(runVersion("").stdout.trim()).toBe(
git("rev-parse", "--short", "HEAD"),
);
});
// A source tarball has no .git: it keeps the version package.json
// declares, and must still build.
it("prints package.json's version where there is no .git", () => {
makeCheckout();
const result = runVersion();
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe("0.0.0");
});
// A repository with no commits stands in for any .git that git cannot
// describe: git missing from the image, or refusing to read the checkout.
it("fails where .git yields no version", () => {
makeCheckout();
git("init", "-q");
const result = runVersion();
expect(result.status).not.toBe(0);
expect(result.stdout).toBe("");
});
it.each(["dev", "unknown"])(
"fails where there is .git and the version is %s",
(version) => {
makeCommittedCheckout();
const result = runVersion(version);
expect(result.status).not.toBe(0);
expect(result.stdout).toBe("");
},
);
});
+31
View File
@@ -43,6 +43,7 @@ import {
isRetryable,
isSafeToReplay,
resolveRetryOptions,
UNATTENDED_RETRY_OPTIONS,
withRetry,
} from "../../src/retry.js";
import { ApiError, TruncatedStreamError } from "../../src/errors.js";
@@ -639,3 +640,33 @@ describe("retry defaults", () => {
}
});
});
describe("unattended retry options", () => {
it("allow ten attempts, a 1 s base delay and a 60 s cap", () => {
// The numbers the README documents for `quak backup`.
expect(UNATTENDED_RETRY_OPTIONS.attempts).toBe(10);
expect(UNATTENDED_RETRY_OPTIONS.baseDelayMs).toBe(1_000);
expect(UNATTENDED_RETRY_OPTIONS.maxDelayMs).toBe(60_000);
});
it("give up on a request that keeps failing after at most 243 s of waiting", async () => {
// `random: () => 1` makes every wait its ceiling, the worst case.
const { sleep, delays } = recordingSleep();
let calls = 0;
await expect(
withRetry(
() => {
calls++;
return Promise.reject(new ApiError("HTTP 503", 503));
},
{ ...UNATTENDED_RETRY_OPTIONS, sleep, random: () => 1 },
),
).rejects.toThrow("HTTP 503");
expect(calls).toBe(10);
expect(delays).toEqual([
1_000, 2_000, 4_000, 8_000, 16_000, 32_000, 60_000, 60_000, 60_000,
]);
expect(delays.reduce((sum, ms) => sum + ms, 0)).toBe(243_000);
});
});