Skip to content

Offline First Sync Model

SemionVlad edited this page Sep 18, 2026 · 1 revision

Offline-First Sync Model

localStorage is the offline authority. Supabase is an optional cross-device layer. Reconciliation happens client-side, in pure functions, before any PostgREST upsert — so it is deterministic, testable without a database, and usable before the network returns.

The central idea: one merge rule for all state is a bug. Different slices have different semantics, and "newest blob wins" quietly destroys data in at least three of them.

Source: src/lib/sync/ — merge.js (the rules), adapters.js (row shapes), syncMeta.js (the baseline).

Merge strategies by slice

State slice Strategy Why
Daily stats Additive delta from a synced baseline Repeated syncs stay idempotent without losing offline activity.
SRS cards Per-card LWW on lastReviewed Reviewing one card must not overwrite another card's state.
Settings Whole-record LWW on settingsUpdatedAt Ordinary preferences can share a clock — with explicit carve-outs below.
CEFR level (+ placement) Independent LWW clock (levelUpdatedAt) A newer unrelated setting must not roll B1 back to A1.
Learned words Union A word learned on either device stays learned.
Deck mastery Union per deck Same reasoning, scoped per deck.
Streak freezes Union of frozenDays A freeze earned on one device cannot be dropped by the other's older write.
Best streak Max A record is a high-water mark, not a value to overwrite.
Custom decks Per-deck LWW + tombstones Offline deletion competes with edits by timestamp instead of resurrecting removed decks.

Ties resolve to remote (the server), consistently across mergeSrs and mergeDecks.

Why settings needs carve-outs

mergeSettings picks a whole-record winner by settingsUpdatedAt, then overrides specific fields. Each override exists because of a real regression:

Level has its own clock. A device that had been offline pushed a stale settings record whose settingsUpdatedAt happened to be newer, and it clobbered a correct server-side level — in production. Level now carries levelUpdatedAt and is compared independently of everything else in the record. placement follows the same clock, so a stale device cannot pair an old placement score with a newer CEFR code.

Learned words union. Under whole-record LWW, studying on your phone and then opening your laptop would discard the phone's newly learned words. Learning is monotonic; the merge has to be too.

Freezes union, best streak max. Same class of bug, different slice: an older LWW write on one device could drop a freeze the other device had earned.

The general lesson is in the code comment on mergeDecks: one shared clock over independent records is exactly the shape that produces clobbering. Records with their own primary key get their own clock.

Deletion, and why tombstones

This is the interesting edge case.

An upsert-only system cannot express absence. If a device deletes a custom deck offline and another device still has it, the next sync re-uploads it and the deck comes back from the dead.

So deletion is not a removal — it is a write. A deleted deck keeps a timestamped tombstone (decks.deleted_at), and the ordinary per-deck LWW comparison decides whether an edit or a deletion is newer. Because a tombstone is just "the record whose latest write happened to be a removal", mergeDecks needed no special case for it at all.

Deck-scoped learned words

learnedWords was originally keyed by the word itself, which collided badly: roughly a quarter of the lexicon shares an id with something else. The map is now scoped per deck (settings.learned_by_deck).

It is a database column rather than a key inside the settings data blob for a specific reason: settingsToRow's allowlist would let an older client, which has never heard of the new key, write back a data object that silently erases it. A column cannot be erased that way.

Sync is not gated on connectivity

Nothing in the app checks for development, localhost, or navigator.onLine. The only gate is VITE_SYNC_ENABLED === 'true', and it behaves identically on a laptop and in production.

That is deliberate. navigator.onLine reports whether a network interface is attached, not whether Supabase is reachable — and gating on it would stop the sync engine from ever starting for someone who opens the app offline, which is precisely the case the offline-first design exists to serve. Writes go to localStorage first and reconcile when a push succeeds.

If sync appears dead locally, it is configuration rather than a code path — see Local Development.

Testing

Merge functions are tested independently of the sync orchestrator, because the rules are the part that must be right. The orchestrator is tested separately for scheduling and dirty-marking.

Database-side protection is covered by adversarial RLS tests that attack real PostgREST endpoints as anonymous users, owners, and non-owners — see Security & Role Architecture.

Clone this wiki locally