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.
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.
| Collection | What it is | Merge |
|---|---|---|
noteType | Card templates — fields, front/back layouts. | union |
deck | Folder of notes; soft-delete via deletedAt; carries currentPack. | LWW |
note | One card — deck + noteType + field values. | LWW |
reviewLog | Immutable event: "I reviewed note X at time T, answered Good." | append-only |
reviewState | Current scheduling state per (note, template). | after-state |
cardFlag | Suspended / buried / flagged flags. | LWW |
deckSettings | Per-deck algorithm parameters. | LWW |
media | Image attachment + blob ref. | LWW |
settings | Global app config (singleton). | LWW |
shareDeck | Sharing metadata. | LWW |
forkDeck | Fork lineage. Immutable. | append-only |
studySummary | Daily stats (counts, time spent). | rebuild |
deckPack / deckPart | Bulk compressed blob of notes + reviewState baseline. | immutable blob |
3. Sync flow
Four triggers, one scheduler, and a single-flight guard.
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.
Shipping a pack
- Encode the payload — JSON, then zstd, then chunk into ≤ 50 MB pieces.
- Mint one
deckPartrecord per chunk (each carries a blob). - Queue those parts first, then queue the parent
deckPackrecord (which points to them via apartsarray). - The drainer routes blob-bearing puts through individual
putRecord+upload calls and the parent record throughapplyWrites— preserving the order.
Reading a pack
- Pre-pass: every
deckPackis materialized before the per-collection merge loop runs. - Fetch & concatenate the deckPart blobs → verify the
contentHash→ decompress → fan out intopackedNotesandpackedReviewStatestores. - Then the regular merge loop runs and per-record updates layer on top via
updatedAtLWW.
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
| Question | Answer |
|---|---|
| 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. |