1. Architecture: local-first, PDS as backup

The IndexedDB on your device is the source of truth. The AT Protocol PDS is a read-replica + multi-device merge point. All edits land locally first; sync is an out-of-band drainer/reader pair.

Device A browser IndexedDB notes · decks · reviews Outbox pending writes Pack stores packedNotes · packedRS drainOutbox (push) runReadSync (pull) PDS your AT-proto repo cards.decay.flashcard.* 13 collections + blobs monotonic repo rev rate-limited XRPC Device B another browser IndexedDB Outbox Pack stores

Write path

Every local mutation enqueues a WriteOp in the outbox. A background drainer turns those into applyWrites batches (≤ 200 ops, ≤ 5 MB) or single-record putRecord calls when a blob upload is involved.

Read path

runReadSync compares the stored repo revision to the PDS. If unchanged, it returns early. Otherwise it lists each collection, pre-materializes packs, then merges per-record updates on top.

Always pull, then push

Every sync cycle calls runReadSync first, then drainOutbox. This avoids the classic read-after-write race where a local write would shadow the very record we'd just imported.

2. What gets synced

Thirteen lexicons live under cards.decay.flashcard.*. Each one declares a single merge strategy.

CollectionWhat it isMerge
noteTypeCard templates — fields, front/back layouts.union
deckFolder of notes; soft-delete via deletedAt; carries currentPack.LWW
noteOne card — deck + noteType + field values.LWW
reviewLogImmutable event: "I reviewed note X at time T, answered Good."append-only
reviewStateCurrent scheduling state per (note, template).after-state
cardFlagSuspended / buried / flagged flags.LWW
deckSettingsPer-deck algorithm parameters.LWW
mediaImage attachment + blob ref.LWW
settingsGlobal app config (singleton).LWW
shareDeckSharing metadata.LWW
forkDeckFork lineage. Immutable.append-only
studySummaryDaily stats (counts, time spent).rebuild
deckPack / deckPartBulk compressed blob of notes + reviewState baseline.immutable blob

3. Sync flow

Four triggers, one scheduler, and a single-flight guard.

app open / sign-in immediate outbox write debounced 5s 5-minute interval passive heartbeat tab visibility user returns runSync() single-flight gate backoff: 5s → 5min 1. read 2. push runReadSync - check stored repo rev - pre-materialize deckPacks - list each collection - diff & merge per-strategy - run deck cascade cleanup drainOutbox - coalesce pending writes - upload blob-bearing puts - batch the rest (applyWrites) - dead-letter permanent errors - backoff transient ones

Why pull-then-push?

If the drain ran first, a remote update that landed between cycles would briefly disappear: the device would overwrite it with its own local copy. By reading first, the merger has a chance to absorb the remote update, and the drain that follows ships only genuinely new local changes (which now know about the remote state).

4. Merge strategies, by example

Each strategy answers one question: "two records claim to be the same — which fields win?"

LWW  Last-write-wins

Used by deck, note, media, cardFlag, settings, deckSettings, shareDeck. Compares the record's updatedAt. Newer wins, whole-record.

Twist: on deck, the currentPack field is immutable. If remote tries to change it, local keeps its own value but accepts the rest of the remote update.

append-only

Used by reviewLog and forkDeck. Records are dedup'd by tid and never modified. Deletes don't propagate. If a log exists on either side it stays.

This is the foundation for everything else: reviewLogs are the only ground truth for scheduling history.

union  NoteType element-wise

NoteTypes are containers of fields and templates. Each child element has its own id and updatedAt. The merger walks the union of child ids and picks per-element winners.

Deletions don't propagate — if either device has a field, the field survives. (This bias toward retention is intentional: losing a field would orphan note data.)

after-state  reviewState

reviewState is derived, not authoritative. Scheduling fields (phase, interval, easeFactor, etc.) come from the latest reviewLog's "after" snapshot. Non-scheduling flags (suspended, buried) merge per-flag by their own timestamps.

The packedAt cutoff matters: any reviewLog older than the deck's deckPack packedAt is already baked into the pack baseline and is ignored at merge time. Otherwise stale pre-pack logs would silently clobber the freshly-loaded baseline on every sync.

rebuild  studySummary

Daily stats (review count, time spent, again/hard/good/easy buckets) are rebuilt from scratch by re-aggregating that date's reviewLogs. The remote summary is essentially advisory; if local has logs, local recomputes.

This makes the summary structurally consistent with its backing log set — no drift between "what happened" and "what we said happened."

Deletes

There are no tombstones. A LWW record that vanishes from the remote listing is hard-deleted locally during the diff phase. Soft-deleted decks carry their own deletedAt and trigger an idempotent cascade that strips child notes, reviewStates, deckPacks, etc.

5. The pack system

A deckPack is one compressed blob holding thousands of notes and their scheduling state — the deltas layer on top.

The problem packs solve

Imagine importing a 10,000-card Anki deck. Without packs, you'd mint 20,000 PDS records (notes + reviewStates), each its own write, each subject to rate limits. First sync on another device would paginate forever; every minor edit would still re-walk the full repo on read.

deckPack baseline snapshot at packedAt 10,000 notes (zstd, chunked) 10,000 reviewState rows optional: deckSettings → packedNotes / packedReviewState (local-only) layered by read-sync merge per-record deltas ongoing edits, after packedAt 23 modified notes 147 new reviewLogs 12 updated reviewStates → standard notes / reviewState collections

Shipping a pack

  • Encode the payload — JSON, then zstd, then chunk into ≤ 50 MB pieces.
  • Mint one deckPart record per chunk (each carries a blob).
  • Queue those parts first, then queue the parent deckPack record (which points to them via a parts array).
  • The drainer routes blob-bearing puts through individual putRecord+upload calls and the parent record through applyWrites — preserving the order.

Reading a pack

  • Pre-pass: every deckPack is materialized before the per-collection merge loop runs.
  • Fetch & concatenate the deckPart blobs → verify the contentHash → decompress → fan out into packedNotes and packedReviewState stores.
  • Then the regular merge loop runs and per-record updates layer on top via updatedAt LWW.

Why immutable?

A deckPack's content hash is part of its identity. If the hash differs from what we expect, the read aborts rather than silently corrupting state. The deck's currentPack pointer is the only forward-progress lever; bumping it issues a brand new pack record.

6. Blobs & media

Images, audio, and pack chunks all flow through the AT-proto blob API with a small caching layer.

Upload

uploadBlob() runs through a dedicated uploadLimiter (~2 req/sec) so it can't starve record-level XRPC calls. The returned BlobRef is spliced into the record body just before the putRecord / applyWrites call.

Fetch

fetchBlob() goes through the browser's Cache API first, keyed by (did, cid). Cache miss → com.atproto.sync.getBlob, then stash in the cache for future sessions. This keeps already-seen images instantly available across reloads without a custom IndexedDB store.

Pack blobs

Pack and pack-part blobs live in local packsDb / packPartsDb tables as raw Blob objects. The drainer loads them just before shipping and substitutes a BlobRef into the outbox entry. If the source Blob has been deleted locally since the outbox entry was created, the drainer treats the entry as a no-op rather than crashing.

7. Scheduling & rate limiting

Adaptive: the limiter watches PDS response headers and rescales itself.

Adaptive limiter

Starts at ~20 req/s. After each XRPC response it reads RateLimit-Remaining and RateLimit-Reset and recomputes the interval so it'd just barely use up the budget by reset.

On HTTP 429, honors Retry-After; otherwise falls back to 5s.

Sync-level backoff

A failing runSync() doubles its retry delay: 5s → 10s → 20s … capped at 5 minutes. A clean run resets it. The scheduler refuses to start another cycle until the retryAfter timestamp passes.

Debounce

Local writes fire onOutboxChange; the scheduler waits 5s of quiet before triggering a drain. Bursts of edits coalesce into a single roundtrip.

Single-flight

The scheduler exposes runSync() as an idempotent function: re-entrant calls during an in-flight cycle are dropped (or coalesced into a single follow-up).

8. Migration

From phase 1 (no outbox) to phase 2 (outbox-driven), in one pass.

Users who installed the app before the outbox existed have rows that have never been queued for sync. On first sign-in under the new code, a one-shot walk visits every IDB store in dependency order — reviewLog → reviewState → note → noteType → deck → media → settings → deckSettings — and enqueues each record into the outbox.

The outbox does its own coalescing, so even if the user keeps editing during the walk, duplicates collapse before they ship. A flag in syncState marks the walk complete; subsequent app starts skip it entirely. No network traffic until the first scheduler tick.

9. Cheat sheet

QuestionAnswer
What's the source of truth?IndexedDB. PDS is a backup & multi-device merge point.
How are writes shipped?Outbox → drainer → batched applyWrites (≤ 200 ops, ≤ 5 MB), or blob-upload-then-putRecord for media/packs.
Pull or push first?Pull. Always. runReadSync runs before drainOutbox.
How does it detect "nothing changed"?Stores the last seen repo rev. If unchanged, returns early without listing.
How are conflicts resolved?Per-collection strategy: LWW · append-only · union · after-state · rebuild · immutable-blob.
Where do deletes come from?For LWW/single-record types, absence on remote = local delete during diff. For decks, soft-delete via deletedAt + cascade.
Why both packs and per-record notes?Packs ship the baseline (bulk import, low overhead). Per-record deltas ship ongoing edits.
What stops a pack from corrupting?contentHash verification on read, immutable identity, packedAt cutoff for reviewLogs.
What's local-only?The materialized packedNotes / packedReviewState stores. They're derived from the pack blob and never get their own PDS records.