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>
Cards point at lemmas by id, and an id was the entry's position in the
sorted build. One word added near the top of the dictionary would have
moved every card below it onto a different word — silently, because loaded
bands were recorded by number and a rebuilt dictionary never reached an
existing install anyway. A custom word took max(id)+1 on whichever device
added it, so the same id meant different words on a phone and a laptop.
An id is now lemmaId(headword, pos), a 53-bit hash defined once in
shared/ and used by the build, the loader, custom words and the migration.
The build asserts all 30,520 entries are collision-free, and a test pins the
function itself, since changing it re-keys every card.
Band files are format 2: they carry no ids at all. The loader derives each
id from the word, and a surface names its lemma by row index in the same
file. Writing hashed ids out cost 0.5 MB of incompressible digits; leaving
them out makes the files smaller than before (1.1 MB -> 1.0 MB).
The loaded dictionary is now versioned by its band hashes, so a rebuild
reloads on the next boot — safe only now that a reload cannot move a card.
Migration 6 re-keys an existing install without stamping anything: cards
and their tombstones move through the lemma rows still loaded, custom words
become custom_word rows (the learner's data, which can travel) carrying the
time their card was made, and the dictionary is dropped to reload.
Sync is paused until the protocol that replaces it lands: the server still
holds rows under the old ids, and exchanging them would plant cards that
name no word.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing above db/ knows which driver it got. sqlite.web.ts runs
@sqlite.org/sqlite-wasm in a dedicated Worker; sqlite.native.ts uses the
Capacitor plugin. Both apply the same migration array.
The web driver uses the OPFS SAHPool VFS rather than the plain opfs VFS:
the latter needs COOP/COEP cross-origin isolation headers, which neither a
static host nor the Capacitor webview reliably provides. Same storage,
fewer deployment constraints.
THE TIMESTAMP RULE. The artifact had a sync bug where a fresh device
stamped its own empty defaults as newer than the server's real history and
clobbered it. Three layers make that unrepresentable rather than merely
avoided:
1. updated_at INTEGER NOT NULL DEFAULT 0 on every syncable table, so
forgetting the column is the SAFE failure — a row that loses every
last-write-wins comparison, not one that wins them all.
2. writes.ts splits every mutation into seedX() (never stamps) and
editX() (always stamps), and is the only file allowed to read the clock.
3. An ESLint rule enforces that, and the conformance suite asserts a
freshly seeded database has no non-zero updated_at anywhere.
Two batching limits are sized for the platform we cannot test here: Android
links the system SQLite, historically capped at 999 bound parameters, while
sqlite-wasm allows 32766. A multi-row insert sized for the browser would
fail only on the phone, at first launch, loading the dictionary. Both the
band insert and the word-rail lookup batch under the smaller ceiling, and
test/db/limits.test.ts fails at 3600 if that regresses.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>