Design for review: a cache layer (SQLite metadata + on-disk content/thumbnail store) and a crash-safe backup on it #36
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
This is a plan for your review, not a finished spec — please critique and send rework requests. It covers the two goals you set: (1) a higher-level API over two long-lived caches — a SQLite metadata cache and an on-disk content/thumbnail cache — and (2) a backup tool that is extremely reliable and built on that API. Design rule applied throughout: the simplest thing that is fit for purpose, fewest moving parts, no tunables the goals don't need.
Current state (grounded)
Clientholds only the session keys + token in memory.runBackup(src/backup.ts) andrunMetadataBackup(src/metadata-backup.ts) re-enumerate and re-decrypt the whole account on every run. The only state that survives a run is the files on disk.Client.listCollections()always calls/collections/v2withsinceTime: 0;listFiles()paginates/collections/v2/diffbut always restarts atsinceTime: 0, and both dropisDeletedtombstones. So thediff/hasMore/updationTimeloop is used only for within-run pagination, never to fetch just the changes, and deletions are invisible to callers.backup <dir>layout (the only persistent state):originals/<fileID>.<ext>(content) +originals/<fileID>.json(metadata sidecar);collections/<name>/<title>symlinks into../../originals;collections/<name>.json. Skip rule isexistsSync && size > 0— no integrity check. The sidecar is written only when absent, so edited metadata goes stale. Per-file download failures are logged, counted, and stepped over (errors[], exit 1 if any failed); that list is discarded at process exit.fileIDidentifies content (dedup across collections),(collectionID, fileID)is a membership,updationTime(µs) advances on any change. Your(collectionID, fileID, updationTime)key is exactly right.FileMetadata.hashcarries a plaintext content hash (optional — not on every file).RawEnteFile.info.fileSize/thumbSizeexist on the wire butdecryptFiledrops them (and dropsisDeleted).writeAtomic),TAG_FINALtruncation detection instreamDecrypt, the retry classifier (withRetry/isRetryable/isSafeToReplay), per-request and per-body deadlines.Design — the cache and its API
Where it lives. Inside the backup target directory (recommended): the SQLite file and the content/thumbnail blobs are subdirectories of the user's
backup <dir>. One location, self-contained and portable, multiple independent mirrors, nothing hidden. (Alternative in the open questions: one managed store underenv-paths.) "Long-lived" is a property of invalidation, not location: both caches are invalidated only by the server's own change feed, so they carry no TTL and can persist indefinitely wherever they live.The SQLite metadata cache — what it stores, and why long-lived. Tables (minimal):
collection:id,ownerID,name,type,updationTime,isShared, the three magic-metadata layers (JSON), the decrypted collection key,deleted, and this collection's file-diff cursorsinceTime.file: one row per membership, PK(collectionID, fileID), withupdationTime, the basic + two magic metadata layers (JSON),fileHeader/thumbHeader, the decrypted file key,contentHash,fileSize/thumbSize.content: one row per uniquefileID— presence + verification state of the on-disk original and thumbnail, the storedcontentHash, byte length, extension.failure: durable per-fileID(× kind) ledger — classification (transient/permanent/unknown), message, attempts, last-tried time.metarow: collections-list cursor,userID, schema version.Populated from the diff endpoints, decrypted with the existing
decryptCollection/decryptFile. Invalidation is server-driven, keyed onupdationTime: a diff row newer than the stored one replaces the metadata; iffileHeader/contentHashchanged, thecontentrow is marked stale for re-download. Tombstones (isDeleted) remove the membership (or mark the collection deleted). It is long-lived because nothing else can make it stale — there is no speculative caching here; it is an authoritative local mirror, and keeping it turns every later run into an O(changes) diff instead of an O(account) full re-enumeration + re-decrypt.Secrets note: this DB holds decrypted metadata (titles, GPS) and the decrypted collection/file keys, so it is as sensitive as
session.json— create it0600in a0700dir and treat it like the password. (Alternative: store only the rawencryptedKey/nonce and re-derive keys from the in-memory master key on read; more moving parts for the same on-disk sensitivity — not recommended.)The on-disk content + thumbnail cache — layout, keying, lifetime.
fileID(content dedup — one download regardless of how many collections hold it; matches today). Layout:originals/<fileID>.<ext>andthumbnails/<fileID>.<ext>. Flat directories; no prefix sharding unless a real account proves it necessary.writeAtomic(temp sibling + rename). Thecontentrow is flipped to "present + verified" only after the rename succeeds, so the DB can never claim a file the disk does not fully have.fileID— an edit yields a new version observable as a newupdationTime(and a changedfileHeader/contentHash). A hash-verified original never needs re-fetching unless the diff reports a new version. No TTL.The higher-level API — only what callers and the backup tool need. A cache object, constructed from a
Clientand a directory (the concrete class name is yours to choose). Surface:sync()— pull each collection's diff from its stored cursor, update the DB, apply tombstones; returns what changed. This is the incremental enumeration thrown away today.collections()/files(collectionID)— served from the DB, no network. (Also removes the O(collections) linear scanget/get-thumbdo now.)ensureOriginal(fileID)/ensureThumbnail(fileID)— idempotent: download + verify + store if absent-or-stale, else a no-op; returns the path.verify(fileID)/verifyAll()— re-hash on-disk bytes against the stored hash; mark mismatches stale.pending()— files whose content is absent, stale, or previously failed.pathFor(fileID).That is the whole surface. The only inputs are the
Clientand the directory — no tunables beyond whatApiClientalready exposes. It needs small additions toClient(which owns the keys and decryption): resumable enumerators that accept a startingsinceTime, return the final cursor, and surfaceisDeletedrows — today'slistCollections/listFilesreset to 0 and drop tombstones. ML/EXIF (backup-metadata) is out of scope for the core two caches; it can later read from the same DB.Design — the crash-safe backup tool on the API
runBackupbecomes:sync(), thenensureOriginal(and optionallyensureThumbnail) for eachpending()file, then materialize thecollections/symlink views + collection JSON from the DB. Concretely:pending()is exactly the unfinished set and the persisted cursor means no full re-enumeration.writeAtomicfor bytes; DB mutations in a transaction; the rule "rename the bytes, then record the row" keeps the DB from overstating the disk. The symlink tree and collection JSON are derived views, rebuildable from the DB, so they need no crash-safety of their own — and rebuilding the view also repairs the stale-sidecar and symlink-failure problems.TAG_FINALalready rejects truncation at decrypt time; after decrypt, compare the content hash toFileMetadata.hashwhen present and store it;verifyAll()re-checks disk against the DB on demand. This replaces today'ssize > 0. (Impl note: confirm the exact hash construction — algorithm, and the live-photo combined case — before relying on it; fall back toinfo.fileSizewhenhashis absent.)failurewith its classification and attempt count, and the run continues (as today). The next run retries transient/unknown failures and can skip or de-prioritize permanent ones. Exit non-zero while unresolved failures remain. This turns today's lost in-memoryerrors[]into a durable, queryable ledger.Ordered next steps (smallest set; reuse vs new)
All TDD per the repo workflow (tests first, red commit, branch off
main). The externalbackup <dir>layout and the CLI contract stay unchanged.info.fileSize/thumbSizeandisDeletedthroughdecryptFileintoEnteFile. Tiny; needed for integrity and deletion. Extend existing.Client: variants oflistCollections/listFilesthat take a startingsinceTime, return the final cursor, and includeisDeletedrows. Extend existing;ApiClientalready accepts an arbitrarysinceTime.sync(): the schema above, cursor persistence, tombstone application,updationTimeinvalidation. New; reuses decrypt + enumeration.fileID, atomic write, hash verification, DB state recorded only post-rename, orphan temp reaping on open. New; reuseswriteAtomic,streamDecrypt/TAG_FINAL,downloadFile/downloadThumbnail.sync,collections/files,ensureOriginal/ensureThumbnail,verify,pending,pathFor). Thin façade over 3–4.runBackupon the API + the durablefailureledger; portbackup.test.ts; keep the layout and exit-code contract. Rewrite.Sequencing vs
v1.0.0is yours to set — this subsumes some 1.0.0 items (notably the backup-robustness issue) and realizes the README/TODO "local cache (SQLite) … reliable" goal inside this repo, ahead of the desktop client.Relationship to existing issues
runBackupsymlink crash + partial originals: subsumed by step 6 (derived views cannot abort the run) and step 4 (atomic write + post-rename recording makes partial originals impossible).listFilesinfinite loop on a non-advancing server: the resumable enumerator in step 2 must handle it.streamDecryptbuffering: open question 4 (streaming decrypt-to-disk) resolves the whole-file buffer too.fileIDremoves the hazard for originals; the symlink view still sanitizes titles.Clientsession/keys: step 2 touchesClient; the cache depends on its keys.Open questions
backup <dir>(recommended: portable, self-contained, survives) vs one global store underenv-paths(paths.cacheis unused today;paths.dataholdssession.json). Global suits a future always-on desktop client; in-<dir>suits an explicit, movable backup. Recommend in-<dir>now and revisit for the desktop client.node:sqlite(zero new dependency, best for the hash-pinned supply chain, but still flagged experimental and needs a recent Node baseline) vsbetter-sqlite3(mature, synchronous, a native build). The global "prefer stdlib" rule points tonode:sqliteif your Node baseline supports it and experimental status is acceptable; otherwisebetter-sqlite3. Recommendnode:sqlite, falling back tobetter-sqlite3.ensureOriginal(recommended for "extremely reliable"; an extension ofwriteAtomic+streamDecrypt) vs keep whole-file buffering (simplest). Overlaps issue 21.Model: opus-4-8