hraness

storage quotas and encrypted sync: budgeting bytes on someone else's machine

persist, estimate, and design for eviction

Drafted by an AI agent at Ben Guo's direct request from the Hraness source repositories, and checked against those sources before publication.

A local-first app stores data in two places it does not own. The browser’s IndexedDB sits on the user’s machine but under the browser’s eviction policy: the platform can refuse a write, abort a transaction, or reclaim the whole origin’s storage under pressure. The sync backend sits on the provider’s machines under an account quota that turns every retained byte into a bill. Budgeting bytes on someone else’s machine means designing both of those budgets as product constraints, not error paths.

This article works through how Soundfish does it: hard account quotas expressed as typed contract constants, a failure taxonomy that keeps a quota refusal distinct from a crash, durable local evidence for every remote transition, and a precise account of what encryption does and does not cover.

two budgets, not one

The browser side and the server side have opposite failure shapes. IndexedDB quota is vague by design: the platform decides an origin’s allowance from device pressure, storage pressure, and browser policy, and it can evict “best-effort” data without asking. The only honest posture is to treat local persistence as provisional: cap what you keep, classify refusals, and never let a failed write leave half a state behind.

The server side has the opposite problem: the quota is yours, so it has to be a number. Soundfish writes its numbers into the shared contract in lib/library/model.ts, the same constants the Convex functions import, so the browser model and the backend agree by construction rather than by convention. An account gets MAX_LIBRARY_LOOPS_PER_ACCOUNT = 256 loops, MAX_LIBRARY_VERSIONS_PER_LOOP = 256 versions each, MAX_LIBRARY_VERSIONS_PER_ACCOUNT = 4_096 retained versions overall, MAX_LIBRARY_BYTES_PER_ACCOUNT = 64 MiB of referenced canonical bytes, and MAX_LIBRARY_WRITES_PER_HOUR = 600 checkpoint attempts per rolling window. Every number is a product decision that the schema enforces, and every refusal returns quota_exceeded naming which quota tripped (bytes, loops, versions, or writes), so the client can tell the user the truth.

browser storage is a lease

The browser half of the design is in lib/storage/ and lib/library/local-library.ts. Three rules carry it:

  1. Every logical mutation is one IndexedDB transaction. A save, a delete, an import, a sync-attempt proof, or a deletion fence each commit atomically: a failed commit is retryable without duplicates or partial state. The transaction, not the UI, is the unit of “did this happen.”
  2. Failures are classified, not swallowed. QuotaExceededError and Firefox’s NS_ERROR_DOM_QUOTA_REACHED map to { kind: "quota" }; SecurityError, AbortError, decode failures, and transaction failures each get their own kind. A quota refusal is a different remedy than a corrupted record, and the caller sees which one it got.
  3. Stored values are decoded through codecs, not trusted. Every store in the local library (heads, snapshots, versions, syncAttempts, deletionFences, state) reads through a Zod codec, and the transaction aborts on a codec failure. Storage you cannot decode is corruption evidence, not data.

what encryption covers and what it does not

“Encrypted sync” deserves precision, because two different claims get bundled under it.

What is encrypted in Soundfish’s sync path: transport and credentials. Cloud calls run over HTTPS under an authenticated session; the Suite OIDC access token stays inside a server boundary sealed into an AES-GCM–encrypted HttpOnly cookie (12-byte IV, 128-bit tag, additional authenticated data) rather than exposed to the page or to IndexedDB. Every cloud request is also bound to the browser’s expected account through the x-soundfish-suite-account header as well as the HttpOnly session, so an account change returns a typed conflict before any sync operation runs.

What is not encrypted end-to-end: the document bytes. Cloud snapshots are the canonical #s1. fragments. The server stores canonical document bytes keyed by transport digest so it can serve them back to your other devices and verify the MusicCID. The honest statement is that Soundfish sync is authenticated and encrypted in transit and at the session boundary, but it is not end-to-end-encrypted content; the service can read the loops it stores. A local-first app that wants content the provider cannot read has to encrypt the payload before the digest and give up server-side verification of what it stores. Soundfish chose the verifiable design and scoped the trust accordingly.

the account quota as contract

The quota table is the short version of the whole sync product:

Constant Limit Refusal kind
MAX_LIBRARY_LOOPS_PER_ACCOUNT 256 loops quota: "loops"
MAX_LIBRARY_VERSIONS_PER_LOOP 256 versions quota: "versions"
MAX_LIBRARY_VERSIONS_PER_ACCOUNT 4,096 retained quota: "versions"
MAX_LIBRARY_BYTES_PER_ACCOUNT 64 MiB quota: "bytes"
MAX_LIBRARY_WRITES_PER_HOUR 600 checkpoints quota: "writes"

Two implementation details make the numbers livable rather than merely hard. First, content is stored once per account by transport digest (loopLibraryContents is keyed by digest), and a version is an authorization row pointing at content, so retaining a version costs a row, not a copy; canonical content is deleted only after its final global version reference is gone. Second, ceilings compact rather than freeze: when a loop or the account hits a version ceiling, the backend compacts the oldest prunable version, preserving one authorization row for the current head and each retained checkpoint parent, instead of refusing every future save. The head, version, immutable content, and the loopLibraryUsage account ledger update in one atomic mutation, so a quota check can never disagree with the bytes it counts.

The write budget also fences identity, not merely volume. claimGeneration is an account-wide monotonic counter that turns a lost response into a recoverable case: a create-claim checkpoint carries the generation, an exact lost-response retry stays idempotent across unrelated deletes, and an older claim cannot bind across a delete-and-recreate because the new head is stamped with its creation generation.

The server-side analogue of this accounting is a registry, not a convention. Sponge keeps costs.json at its repository root, a checked manifest of all 166 product data surfaces with a kind (authoritative, derived, telemetry, served), a retention class (ephemeral, ttl:<ISO-8601>, account, tombstone, persistent), an owner module, and a budget on every authoritative entry. A new table, bucket, stream, dynamic route, blob, or provider meter fails check:cost-surfaces until it registers, and the checker walks the backend schema’s defineTable calls, PostHog capture literals, and dynamic routes looking for unregistered surfaces. An authoritative surface must also name its deletion path: the budget for storing a thing includes the path for removing it.

durable evidence for every remote transition

Sync fails in one characteristic way: the request succeeds remotely and the response is lost. The local library therefore writes its intent before it calls, so recovery is reconciliation against evidence rather than inference.

  • Sync attempts are durable. Before an upload crosses the transport, a syncAttempts record lands in IndexedDB inside the same transaction discipline as everything else, capped at MAX_LOCAL_SYNC_ATTEMPTS_PER_LOOP = 16, with the oldest evicted when a newer proof needs the slot. On a lost response, the client reconciles only against exact recorded evidence: which loop, which expected cloud digest and revision. An ambiguous create is never rebound by inference; it is fenced remotely and forked under a fresh logical ID.
  • Deletes fence before they remove. A signed-in delete writes a deletionFences record (one per loop, carrying the tab’s UUID operation token and the account) before local state is removed. Committed fences survive remote success and ambiguous unavailability alike, and a delayed cloud import is rejected while a durable fence protects that account and loop.
  • Recovery fences are absent-only. A lost create may advance the claim generation only through an atomic absent-only fence: if the loop ID turns out to be occupied, the concurrent head is preserved and the local copy reconciles or forks: the fence never overwrites an existing head, and the delete mutation is never reused as a recovery mechanism.

The pattern underneath all three: the durable record is the authority on what the client believed it sent, so an ambiguous network outcome degrades into a typed branch (reconcile, fork, or fenced) instead of a guessed overwrite.

design for eviction

The platform gives you two APIs for the browser budget: navigator.storage.estimate() to read current usage and quota, and navigator.storage.persist() to request the durable, non-evictable bucket. The Soundfish codebase calls neither. That is itself the design position: persistence is a hint the platform may deny, so the model behaves as if eviction will happen.

What that looks like concretely: the crash-recovery journal in app/persistence.ts keeps MAX_REVISIONS = 24 revisions with the newest first inside a MAX_RECOVERY_JOURNAL_BYTES = 2 MiB IndexedDB cap, plus one latest snapshot in local storage, bounded so the journal itself is never the thing that exhausts the quota. Song saves cap at MAX_LOCAL_SONGS = 256 digest-keyed entries. When a quota or corruption failure does arrive, the rule is to preserve stored data: a save that cannot commit reports a typed quota or limit result rather than dropping rows to succeed. And because every document is also its own shareable #s1. URL fragment, the library is a cache of something the user can always carry out; eviction costs convenience, not the data.

The budgeting rule generalizes: cap every surface, name every refusal, write intent before crossing the network, and keep the artifact portable enough that losing the store is survivable. Quotas are only punitive when they are implicit.

sources

  • sound.fish: lib/library/model.ts, lib/library/local-library.ts, lib/storage/, app/persistence.ts
  • sponge: costs.json and the fenced-lease model for server-side mutation budgets

keep reading: free for subscribers

the rest of this lesson is free. add your email once and every subscriber lesson on this site stays unlocked.

already subscribed? enter the same email to unlock.