Three storage changes the reworked app needs, as migration 8.
Progress. "Which unit is current" was a 'now' state on each unit's row. Two
devices that advanced could leave two of them, and leaving a finished unit
through the roadmap panel wrote it back to 'todo' — goToUnit un-finished
work. Where he is now lives in one place, meta road.unit; a unit's row
records only what is true of that unit: done, confidence, and room for the
answer count and the tutor's note that earned progress needs. The migration
carries the most recently written 'now' row across with its own stamp.
Chat. Turn ids were INTEGER PRIMARY KEY — max+1 on whichever device wrote
them, restarting at 1 after a clear — so two devices continuing a lesson
both wrote turn 201 and sync treated two different turns as one row. Ids
are UUIDv7 now, and the transcript is ordered by (created_at, id). Existing
turns become legacy:<device>:<n>, zero-padded so turns sharing a timestamp
keep the order they were written in; their tombstones are renamed with them.
The learner model gets its tables: evidence (lib/srs.js's record, plus the
rounds of the first and last CORRECT answer, which PORT.md measures and lib
does not), confusion, and phase_ledger for the 다지기 checklist, with a
confirmed flag so putting an item back is an edit rather than a delete.
A roadmap reset now clears the ledger and road.*; a full wipe also clears
evidence, confusions and learner.* — the artifact's wipe left its round
counter and confusion list behind. What a learner knows about words
survives a roadmap-only reset, as it should.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Row-level last-write-wins on updated_at, cursor-based on a server-assigned
change_seq. The schema was built for this in step 1, so the work here is
the three things it did not yet have.
Tombstones (migration 4). Row-level sync cannot express a delete: with the
row gone there is nothing to compare timestamps against, so the other
device pushes its still-live copy back and the row silently returns. Every
delete path now writes a tombstone inside the same transaction.
The wire format lives in shared/sync-protocol.mjs and is imported by both
sides, so there is one definition rather than two that drift. It carries
the syncable-meta allowlist, which is the load-bearing part: meta mixes the
learner's preferences with bookkeeping that describes one install, and
replicating dict.loadedBands would tell a phone that had loaded bands 0-2
it holds every row the desktop has — the word rail would then fail to find
words it believes are present.
The sync loop pushes first, then pages the pull. Two details it would be
easy to get wrong, both commented at their site:
- The pull cursor advances only as rows are applied, never from the push
response. The server's newest change_seq includes rows this device has
not seen; adopting it skips them permanently, and nothing ever asks for
that range again.
- Pulled rows advance sync.pushedAt too, bounded by the instant the sync
started. Otherwise they look like local edits and get pushed straight
back, and an edit made during the sync is not swept up with them.
Seeded rows carry updated_at = 0, so a fresh device is never dirty and can
never win a conflict — the artifact's clobbering bug stays unrepresentable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>