Repository navigation
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).
| 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.
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.
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.
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.
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.
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.
Design
Working on it