From bf9b5950da57bb2138483acca6b9e08f3d0c5908 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Wed, 16 Sep 2026 20:10:58 +0200 Subject: [PATCH] feat(db): a roadmap without 'now' rows, chat ids two devices can share, the learner-model tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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::, 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) --- app/src/db/ids.ts | 33 +++++++++ app/src/db/migrations.ts | 123 ++++++++++++++++++++++++++++++++++ app/src/db/writes.ts | 92 ++++++++++++++----------- app/src/domain/progress.ts | 37 +++++----- app/src/ui/tutor/TaskHost.tsx | 22 ++++-- app/src/ui/tutor/TutorTab.tsx | 6 +- shared/sync-protocol.mjs | 2 +- test/db/conformance.ts | 40 +++++++---- test/db/migration-8.test.ts | 95 ++++++++++++++++++++++++++ test/domain/reset.test.ts | 20 +++++- 10 files changed, 391 insertions(+), 79 deletions(-) create mode 100644 app/src/db/ids.ts create mode 100644 test/db/migration-8.test.ts diff --git a/app/src/db/ids.ts b/app/src/db/ids.ts new file mode 100644 index 0000000..e139055 --- /dev/null +++ b/app/src/db/ids.ts @@ -0,0 +1,33 @@ +/* Row ids that mean the same row on every device. + + A chat turn was `INTEGER PRIMARY KEY`: max(id) + 1 on whichever device + wrote it, restarting at 1 after a clear. Two devices continuing the same + lesson both wrote turn 201, and sync treated the two different turns as + one row — one of them silently replaced the other. And ids carried the + transcript's order, which across two devices meant nothing. + + A UUIDv7 is unique without coordination and still sorts by time, so a + transcript is ordered by (created_at, id) and two devices' turns never + share a key. The clock is passed in: only db/writes.ts may read it. */ + +/** A UUIDv7: a 48-bit millisecond timestamp, then 74 random bits. */ +export function uuidv7(ms: number): string { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + + let t = Math.max(0, Math.floor(ms)); + for (let i = 5; i >= 0; i--) { + bytes[i] = t % 256; + t = Math.floor(t / 256); + } + bytes[6] = (bytes[6]! & 0x0f) | 0x70; // version 7 + bytes[8] = (bytes[8]! & 0x3f) | 0x80; // RFC 9562 variant + + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +/** An opaque random id — for naming an install, where time order means nothing. */ +export function randomId(): string { + return uuidv7(0); +} diff --git a/app/src/db/migrations.ts b/app/src/db/migrations.ts index 3e83c05..2a83bd8 100644 --- a/app/src/db/migrations.ts +++ b/app/src/db/migrations.ts @@ -17,6 +17,7 @@ import type { Db } from "./types.js"; import { lemmaId } from "@shared/lemma-id.mjs"; +import { randomId } from "./ids.js"; export interface Migration { id: number; @@ -190,8 +191,130 @@ export const MIGRATIONS: Migration[] = [ CREATE INDEX IF NOT EXISTS lemma_unit ON lemma(unit_id); `, }, + { + id: 8, + name: "the learner model — a roadmap without 'now' rows, a transcript two devices can share", + sql: /* sql */ ` + -- ── progress ───────────────────────────────────────────────────── + -- "Which unit is current" was a 'now' row per unit: two devices that + -- advanced could leave two of them, and leaving a finished unit wrote + -- it back to 'todo'. Where he IS lives in meta as road.unit now; a + -- unit's row records only what is true of that unit. + CREATE TABLE progress_v8 ( + unit_id TEXT PRIMARY KEY, + done INTEGER NOT NULL DEFAULT 0, + confidence INTEGER NOT NULL DEFAULT 0, -- 0-100, the tutor's read, clamped + answers INTEGER NOT NULL DEFAULT 0, -- exercises answered in this unit + note TEXT NOT NULL DEFAULT '', -- the tutor's last ::progress note + updated_at INTEGER NOT NULL DEFAULT 0 + ) WITHOUT ROWID; + INSERT INTO progress_v8 (unit_id, done, confidence, updated_at) + SELECT unit_id, state = 'done', confidence, updated_at FROM progress; + + -- ── chat ───────────────────────────────────────────────────────── + -- Text ids, unique without coordination — see db/ids.ts. + CREATE TABLE chat_v8 ( + id TEXT PRIMARY KEY, + role TEXT NOT NULL, -- user | assistant + body TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + ) WITHOUT ROWID; + + -- ── recall evidence ────────────────────────────────────────────── + -- lib/srs.js's record, plus the rounds of the first and last CORRECT + -- answer: PORT.md measures the span between those, while lib's + -- first_round / last_round count any outcome at all. + CREATE TABLE IF NOT EXISTS evidence ( + word TEXT PRIMARY KEY, + ok INTEGER NOT NULL DEFAULT 0, + wrong INTEGER NOT NULL DEFAULT 0, + lookups INTEGER NOT NULL DEFAULT 0, + streak INTEGER NOT NULL DEFAULT 0, + first_round INTEGER NOT NULL DEFAULT 0, + last_round INTEGER NOT NULL DEFAULT 0, + last_seen INTEGER NOT NULL DEFAULT 0, + rounds INTEGER NOT NULL DEFAULT 0, + first_ok_round INTEGER NOT NULL DEFAULT 0, + last_ok_round INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + ) WITHOUT ROWID; + + -- What he mistook a word for, as the tutor marked it. + CREATE TABLE IF NOT EXISTS confusion ( + word TEXT PRIMARY KEY, + mistook TEXT NOT NULL, + updated_at INTEGER NOT NULL DEFAULT 0 + ) WITHOUT ROWID; + + -- The 다지기 checklist. confirmed is a flag rather than the row's + -- existence, so putting an item back is an edit, not a delete. + CREATE TABLE IF NOT EXISTS phase_ledger ( + phase INTEGER NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('rule', 'word')), + item TEXT NOT NULL, + confirmed INTEGER NOT NULL DEFAULT 1, + updated_at INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (phase, kind, item) + ) WITHOUT ROWID; + `, + run: remodelRoadAndChat, + }, ]; +/** + * Migration 8's data step. + * + * The current unit moves from its 'now' row to meta, carrying that row's + * stamp — it is the same fact, so it is exactly as new as it was. If an + * earlier sync left several 'now' rows, the most recently written one is + * where he last actually was. + * + * Chat turns get text ids. Existing ones become `legacy::`, the + * number zero-padded so that turns sharing a created_at still sort in the + * order they were written; a chat tombstone is renamed the same way. + */ +async function remodelRoadAndChat(db: Db): Promise { + let device = (await db.get<{ v: string }>("SELECT v FROM meta WHERE k = 'sync.device'"))?.v; + if (!device) { + device = randomId(); + // Describes this install, not the learner: never stamped, never synced. + await db.run("INSERT INTO meta (k, v, updated_at) VALUES ('sync.device', ?, 0)", [device]); + } + + const now = await db.get<{ unit_id: string; updated_at: number }>( + "SELECT unit_id, updated_at FROM progress WHERE state = 'now' ORDER BY updated_at DESC LIMIT 1", + ); + if (now) { + await db.run("INSERT OR IGNORE INTO meta (k, v, updated_at) VALUES ('road.unit', ?, ?)", [ + now.unit_id, + now.updated_at, + ]); + } + + const legacy = (id: string | number) => `legacy:${device}:${String(id).padStart(10, "0")}`; + const turns = await db.all<{ id: number; role: string; body: string; created_at: number; updated_at: number }>( + "SELECT id, role, body, created_at, updated_at FROM chat", + ); + for (const t of turns) { + await db.run( + "INSERT INTO chat_v8 (id, role, body, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", + [legacy(t.id), t.role, t.body, t.created_at, t.updated_at], + ); + } + await db.run("UPDATE tombstone SET pk = 'legacy:' || ? || ':' || substr('0000000000' || pk, -10) WHERE tbl = 'chat'", [ + device, + ]); + + await db.exec(` + DROP TABLE progress; + ALTER TABLE progress_v8 RENAME TO progress; + DROP TABLE chat; + ALTER TABLE chat_v8 RENAME TO chat; + CREATE INDEX IF NOT EXISTS chat_order ON chat(created_at, id); + `); +} + /** * Migration 6's data step. * diff --git a/app/src/db/writes.ts b/app/src/db/writes.ts index f631898..99ae3be 100644 --- a/app/src/db/writes.ts +++ b/app/src/db/writes.ts @@ -27,6 +27,7 @@ import type { Db, Params } from "./types.js"; import type { Card, Grade } from "@lib/srs.js"; import { lemmaId } from "@shared/lemma-id.mjs"; +import { uuidv7 } from "./ids.js"; /* ───────────────────────────────────────────────────────────────────── The clock. This is the ONLY place in src/db/ that reads it. @@ -58,10 +59,7 @@ async function tombstone(db: Db, tbl: string, pk: string | number): Promise { - await db.run( - "INSERT OR IGNORE INTO progress (unit_id, state, confidence) VALUES (?, 'now', 0)", - [unitId], - ); + await db.run("INSERT OR IGNORE INTO meta (k, v) VALUES ('road.unit', ?)", [unitId]); } /** A default preference or bookkeeping value. Does not overwrite a real one. */ @@ -83,7 +81,12 @@ export async function seedCard(db: Db, lemmaId: number, card: Card): Promise { - await db.run("INSERT INTO chat (role, body, created_at) VALUES (?, ?, ?)", [role, body, at]); + await db.run("INSERT INTO chat (id, role, body, created_at) VALUES (?, ?, ?, ?)", [ + uuidv7(at), + role, + body, + at, + ]); } /* ═════════════════════════════════════════════════════════════════════ @@ -111,36 +114,35 @@ export async function editCardReset(db: Db, lemmaId: number): Promise { }); } -/** The tutor's ::progress read, or the learner moving the unit by hand. */ +/** The tutor's ::progress read, or the learner's "not yet". */ export async function editUnitConfidence( db: Db, unitId: string, confidence: number, ): Promise { await db.run( - `INSERT INTO progress (unit_id, state, confidence, updated_at) - VALUES (?, 'now', ?, ?) + `INSERT INTO progress (unit_id, confidence, updated_at) + VALUES (?, ?, ?) ON CONFLICT(unit_id) DO UPDATE SET confidence = excluded.confidence, updated_at = excluded.updated_at`, [unitId, Math.max(0, Math.min(100, Math.round(confidence))), now()], ); } -/** Marking a unit done / current / not-started. */ -export async function editUnitState( - db: Db, - unitId: string, - state: "todo" | "now" | "done", -): Promise { +/** A unit finished. Nothing un-finishes one short of a reset. */ +export async function editUnitDone(db: Db, unitId: string): Promise { await db.run( - `INSERT INTO progress (unit_id, state, confidence, updated_at) - VALUES (?, ?, 0, ?) - ON CONFLICT(unit_id) DO UPDATE SET state = excluded.state, - updated_at = excluded.updated_at`, - [unitId, state, now()], + `INSERT INTO progress (unit_id, done, updated_at) VALUES (?, 1, ?) + ON CONFLICT(unit_id) DO UPDATE SET done = 1, updated_at = excluded.updated_at`, + [unitId, now()], ); } +/** Where he is on the roadmap — one value, never a flag on each unit. */ +export async function editCurrentUnit(db: Db, unitId: string): Promise { + await editMeta(db, "road.unit", unitId); +} + /** A preference the learner changed. */ export async function editMeta(db: Db, k: string, v: string): Promise { await db.run( @@ -154,15 +156,15 @@ export async function editMeta(db: Db, k: string, v: string): Promise { export async function editChatTurn(db: Db, role: string, body: string): Promise { const t = now(); await db.run( - "INSERT INTO chat (role, body, created_at, updated_at) VALUES (?, ?, ?, ?)", - [role, body, t, t], + "INSERT INTO chat (id, role, body, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", + [uuidv7(t), role, body, t, t], ); } /** Wipe the transcript. Progress and cards are untouched. */ export async function editChatClear(db: Db): Promise { await db.tx(async (tx) => { - const rows = await tx.all<{ id: number }>("SELECT id FROM chat"); + const rows = await tx.all<{ id: string }>("SELECT id FROM chat"); await tx.run("DELETE FROM chat"); for (const r of rows) await tombstone(tx, "chat", r.id); }); @@ -171,15 +173,10 @@ export async function editChatClear(db: Db): Promise { /** Trim the transcript. The artifact kept the last 26 turns. */ export async function editChatTrim(db: Db, keep: number): Promise { await db.tx(async (tx) => { - const doomed = await tx.all<{ id: number }>( - `SELECT id FROM chat WHERE id NOT IN (SELECT id FROM chat ORDER BY id DESC LIMIT ?)`, - [keep], - ); + const kept = "SELECT id FROM chat ORDER BY created_at DESC, id DESC LIMIT ?"; + const doomed = await tx.all<{ id: string }>(`SELECT id FROM chat WHERE id NOT IN (${kept})`, [keep]); if (!doomed.length) return; - await tx.run( - `DELETE FROM chat WHERE id NOT IN (SELECT id FROM chat ORDER BY id DESC LIMIT ?)`, - [keep], - ); + await tx.run(`DELETE FROM chat WHERE id NOT IN (${kept})`, [keep]); for (const r of doomed) await tombstone(tx, "chat", r.id); }); } @@ -327,17 +324,31 @@ export async function editRemoveCustomWord(db: Db, lemmaId: number): Promise { await db.tx(async (tx) => { // Tombstone before deleting, while the keys are still readable — a reset // must propagate, or the next pull restores everything it just erased. for (const r of await tx.all<{ unit_id: string }>("SELECT unit_id FROM progress")) await tombstone(tx, "progress", r.unit_id); - for (const r of await tx.all<{ id: number }>("SELECT id FROM chat")) + for (const r of await tx.all<{ id: string }>("SELECT id FROM chat")) await tombstone(tx, "chat", r.id); + for (const r of await tx.all<{ phase: number; kind: string; item: string }>( + "SELECT phase, kind, item FROM phase_ledger", + )) + await tombstone(tx, "phase_ledger", JSON.stringify([r.phase, r.kind, r.item])); + for (const r of await tx.all<{ k: string }>(`SELECT k FROM meta WHERE ${ROAD_META}`)) + await tombstone(tx, "meta", r.k); await tx.run("DELETE FROM progress"); await tx.run("DELETE FROM chat"); + await tx.run("DELETE FROM phase_ledger"); + await tx.run(`DELETE FROM meta WHERE ${ROAD_META}`); if (scope === "everything") { for (const r of await tx.all<{ lemma_id: number }>("SELECT lemma_id FROM card")) @@ -351,20 +362,25 @@ export async function editReset(db: Db, scope: ResetScope): Promise { "SELECT headword, pos FROM custom_word", )) await tombstone(tx, "custom_word", JSON.stringify([r.headword, r.pos])); + for (const r of await tx.all<{ word: string }>("SELECT word FROM evidence")) + await tombstone(tx, "evidence", r.word); + for (const r of await tx.all<{ word: string }>("SELECT word FROM confusion")) + await tombstone(tx, "confusion", r.word); + for (const r of await tx.all<{ k: string }>(`SELECT k FROM meta WHERE ${LEARNER_META}`)) + await tombstone(tx, "meta", r.k); await tx.run("DELETE FROM card"); await tx.run("DELETE FROM study_log"); await tx.run("DELETE FROM peek"); await tx.run("DELETE FROM custom_word"); + await tx.run("DELETE FROM evidence"); + await tx.run("DELETE FROM confusion"); await tx.run("DELETE FROM surface WHERE lemma_id IN (SELECT id FROM lemma WHERE source = 'custom')"); await tx.run("DELETE FROM lemma WHERE source = 'custom'"); - // Preferences, grammar flags and notes, and the trainer score. - // Device bookkeeping (schema_version, dict.*) is left alone — it - // describes this install, not the learner. - await tx.run( - `DELETE FROM meta WHERE k LIKE 'prefs.%' OR k LIKE 'grammar.%' - OR k LIKE 'trainer.%'`, - ); + // Preferences, grammar flags and notes, the trainer score and the + // round counter. Device bookkeeping (schema_version, dict.*, sync.*) + // is left alone — it describes this install, not the learner. + await tx.run(`DELETE FROM meta WHERE ${LEARNER_META}`); } }); } diff --git a/app/src/domain/progress.ts b/app/src/domain/progress.ts index 7d2cdfc..6a57d70 100644 --- a/app/src/domain/progress.ts +++ b/app/src/domain/progress.ts @@ -12,7 +12,12 @@ enforces it. */ import type { Db } from "../db/types.js"; -import { editUnitConfidence, editUnitState, seedProgress } from "../db/writes.js"; +import { + editCurrentUnit, + editUnitConfidence, + editUnitDone, + seedProgress, +} from "../db/writes.js"; import { UNITS, unitIndex } from "./gate.js"; import type { FlatUnit, ProgressState } from "@lib/gate.js"; @@ -32,8 +37,10 @@ export const FIRST_UNIT = UNITS[0]!.id; export interface ProgressRow { unit_id: string; - state: "todo" | "now" | "done"; + done: number; confidence: number; + answers: number; + note: string; updated_at: number; } @@ -42,16 +49,18 @@ export async function readProgress(db: Db): Promise { const done: Record = {}; const confidence: Record = {}; - let current = FIRST_UNIT; for (const r of rows) { - if (r.state === "done") done[r.unit_id] = true; - if (r.state === "now") current = r.unit_id; + if (r.done) done[r.unit_id] = true; confidence[r.unit_id] = r.confidence; } - // A stored unit that no longer exists (curriculum v3 → v4) falls back to - // the furthest finished unit rather than stranding him. + // Where he is lives in one place — see migration 8. + const stored = await db.get<{ v: string }>("SELECT v FROM meta WHERE k = 'road.unit'"); + let current = stored?.v ?? ""; + + // No recorded position, or a unit that no longer exists (curriculum v3 → + // v4): the unit after the furthest finished one, rather than stranding him. if (unitIndex(current) < 0) { const finished = UNITS.filter((u) => done[u.id]); const last = finished[finished.length - 1]; @@ -101,21 +110,17 @@ export async function advanceUnit(db: Db, progress: ProgressState): Promise { - await editUnitState(tx, progress.current, "done"); - await editUnitState(tx, next.id, "now"); + await editUnitDone(tx, progress.current); + await editCurrentUnit(tx, next.id); }); return next.id; } -/** Jump to a unit from the roadmap panel, without marking anything done. */ +/** Jump to a unit from the roadmap panel. Nothing is marked done — or + un-done: a finished unit revisited stays finished. */ export async function goToUnit(db: Db, progress: ProgressState, unitId: string): Promise { if (unitId === progress.current) return; - await db.tx(async (tx) => { - // The unit being left keeps whatever state it had, unless it was current. - const wasDone = progress.done[progress.current]; - await editUnitState(tx, progress.current, wasDone ? "done" : "todo"); - await editUnitState(tx, unitId, "now"); - }); + await editCurrentUnit(db, unitId); } /** "Not yet" — park confidence below the threshold to dismiss the banner. */ diff --git a/app/src/ui/tutor/TaskHost.tsx b/app/src/ui/tutor/TaskHost.tsx index ebbc688..da00ba5 100644 --- a/app/src/ui/tutor/TaskHost.tsx +++ b/app/src/ui/tutor/TaskHost.tsx @@ -21,6 +21,13 @@ import type { import { answerText } from "@lib/blocks.js"; import "./task.css"; +/* A shuffle seed from the turn's id — a string now, see db/ids.ts. */ +function seedOf(id: string): number { + let h = 2166136261; + for (let i = 0; i < id.length; i++) h = Math.imul(h ^ id.charCodeAt(i), 16777619); + return ((h >>> 0) % 2147483646) + 1; +} + /* A deterministic shuffle, seeded by the turn, so a re-render does not reorder the chips under the learner's finger. */ function shuffle(items: T[], seed: number): T[] { @@ -45,7 +52,7 @@ const LABEL: Record = { export interface TaskProps { task: Task; /** Identifies the turn; also seeds the shuffle. */ - turnId: number; + turnId: string; /** Words the learner revealed in the rail, reported with the answer. */ lookups: string[]; onSubmit: (message: string) => void; @@ -150,15 +157,18 @@ interface Pair { function Match({ task, turnId, done, setDone, selected, setSelected, disabled }: { task: MatchTask; - turnId: number; + turnId: string; done: Pair[]; setDone: (p: Pair[]) => void; selected: string | null; setSelected: (s: string | null) => void; disabled: boolean; }) { - const left = useMemo(() => shuffle(task.pairs.map((p) => p.ko), turnId), [task, turnId]); - const right = useMemo(() => shuffle(task.pairs.map((p) => p.gloss), turnId + 7), [task, turnId]); + const left = useMemo(() => shuffle(task.pairs.map((p) => p.ko), seedOf(turnId)), [task, turnId]); + const right = useMemo( + () => shuffle(task.pairs.map((p) => p.gloss), seedOf(turnId) + 7), + [task, turnId], + ); const usedKo = new Set(done.map((d) => d.ko)); const usedGloss = new Set(done.map((d) => d.gloss)); @@ -227,13 +237,13 @@ const DRAG_SEP = "\u0000"; function Build({ task, turnId, placed, setPlaced, disabled }: { task: BuildTask; - turnId: number; + turnId: string; placed: string[][]; setPlaced: (p: string[][]) => void; disabled: boolean; }) { const banks = useMemo( - () => task.items.map((it, i) => shuffle(it.chips, turnId + i * 31)), + () => task.items.map((it, i) => shuffle(it.chips, seedOf(turnId) + i * 31)), [task, turnId], ); diff --git a/app/src/ui/tutor/TutorTab.tsx b/app/src/ui/tutor/TutorTab.tsx index 4ab8d3c..128d3f0 100644 --- a/app/src/ui/tutor/TutorTab.tsx +++ b/app/src/ui/tutor/TutorTab.tsx @@ -64,7 +64,7 @@ const FOCUS_LABELS: [FocusMode, string][] = [ void (FOCUS_LABELS satisfies [keyof typeof FOCUS_MODES, string][]); interface Turn { - id: number; + id: string; role: "user" | "assistant"; body: string; } @@ -139,8 +139,8 @@ export function TutorTab() { /* ── the transcript ── */ const readTurns = useCallback(async (): Promise => { - const rows = await db.all<{ id: number; role: string; body: string }>( - "SELECT id, role, body FROM chat ORDER BY id", + const rows = await db.all<{ id: string; role: string; body: string }>( + "SELECT id, role, body FROM chat ORDER BY created_at, id", ); return rows.map((r) => ({ id: r.id, role: r.role as Turn["role"], body: r.body })); }, [db]); diff --git a/shared/sync-protocol.mjs b/shared/sync-protocol.mjs index db341b8..954f92c 100644 --- a/shared/sync-protocol.mjs +++ b/shared/sync-protocol.mjs @@ -18,7 +18,7 @@ export const SYNC_TABLES = { pk: ["lemma_id"], cols: ["lemma_id", "state", "ease", "interval", "due", "reps", "lapses"], }, - progress: { pk: ["unit_id"], cols: ["unit_id", "state", "confidence"] }, + progress: { pk: ["unit_id"], cols: ["unit_id", "done", "confidence", "answers", "note"] }, chat: { pk: ["id"], cols: ["id", "role", "body", "created_at"] }, meta: { pk: ["k"], cols: ["k", "v"] }, study_log: { pk: ["day"], cols: ["day", "reviews", "correct", "drills"] }, diff --git a/test/db/conformance.ts b/test/db/conformance.ts index 109d756..749adaa 100644 --- a/test/db/conformance.ts +++ b/test/db/conformance.ts @@ -14,6 +14,7 @@ import { seedMeta, seedProgress, editCard, + editCurrentUnit, editMeta, editPeek, editStudyLog, @@ -23,7 +24,18 @@ import { import { newCard, grade, GOOD } from "@lib/srs.js"; /** Every table that carries user data, and therefore a write timestamp. */ -const SYNCABLE = ["card", "progress", "chat", "meta", "study_log", "peek", "custom_word"] as const; +const SYNCABLE = [ + "card", + "progress", + "chat", + "meta", + "study_log", + "peek", + "custom_word", + "evidence", + "confusion", + "phase_ledger", +] as const; export function conformanceSuite(name: string, open: () => Promise): void { describe(`Db conformance — ${name}`, () => { @@ -240,27 +252,27 @@ export function conformanceSuite(name: string, open: () => Promise): void { }); it("an edit on top of a seeded row promotes it from 0", async () => { - await seedProgress(db, "2.1"); - const seeded = await db.get<{ updated_at: number }>( - "SELECT updated_at FROM progress WHERE unit_id='2.1'", + await seedProgress(db, "1.1"); + const seeded = await db.get<{ v: string; updated_at: number }>( + "SELECT v, updated_at FROM meta WHERE k='road.unit'", ); - expect(seeded?.updated_at).toBe(0); + expect(seeded).toEqual({ v: "1.1", updated_at: 0 }); - await editUnitConfidence(db, "2.1", 55); - const edited = await db.get<{ updated_at: number; confidence: number }>( - "SELECT updated_at, confidence FROM progress WHERE unit_id='2.1'", + await editCurrentUnit(db, "2.1"); + const edited = await db.get<{ v: string; updated_at: number }>( + "SELECT v, updated_at FROM meta WHERE k='road.unit'", ); - expect(edited?.confidence).toBe(55); + expect(edited?.v).toBe("2.1"); expect(edited?.updated_at).toBeGreaterThan(0); }); it("seeding never overwrites a real edit", async () => { - await editUnitConfidence(db, "3.1", 90); - await seedProgress(db, "3.1"); // first-run path running again - const row = await db.get<{ confidence: number; updated_at: number }>( - "SELECT confidence, updated_at FROM progress WHERE unit_id='3.1'", + await editCurrentUnit(db, "3.1"); + await seedProgress(db, "1.1"); // first-run path running again + const row = await db.get<{ v: string; updated_at: number }>( + "SELECT v, updated_at FROM meta WHERE k='road.unit'", ); - expect(row?.confidence).toBe(90); + expect(row?.v).toBe("3.1"); expect(row?.updated_at).toBeGreaterThan(0); }); }); diff --git a/test/db/migration-8.test.ts b/test/db/migration-8.test.ts new file mode 100644 index 0000000..e28ffa1 --- /dev/null +++ b/test/db/migration-8.test.ts @@ -0,0 +1,95 @@ +/* Migration 8 — the roadmap loses its 'now' rows; chat turns get ids that + two devices can share. + + A database as the previous build left it, including the state an earlier + sync could produce: two units both marked 'now'. */ + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { SqliteWasmDb } from "@app/db/sqlite-wasm-core.js"; +import { migrate } from "@app/db/migrate.js"; +import { readProgress } from "@app/domain/progress.js"; +import type { Db } from "@app/db/types.js"; + +let db: Db; +beforeEach(async () => { + db = await SqliteWasmDb.open({ memory: true }); + await migrate(db, 7); + await db.exec(` + INSERT INTO progress (unit_id, state, confidence, updated_at) VALUES + ('1.1', 'done', 87, 100), + ('1.2', 'now', 40, 300), + ('1.3', 'now', 10, 200), + ('1.4', 'todo', 0, 0); + INSERT INTO chat (id, role, body, created_at, updated_at) VALUES + (1, 'user', 'Start unit 1.1.', 0, 0), + (2, 'assistant', 'first', 0, 0), + (10, 'assistant', 'tenth', 0, 0), + (11, 'user', 'later', 500, 500); + INSERT INTO tombstone (tbl, pk, updated_at) VALUES ('chat', '9', 400); + `); + await migrate(db); +}); +afterEach(async () => { + await db.close(); +}); + +describe("migration 8", () => { + it("keeps what is true of each unit, and nothing about being current", async () => { + expect(await db.all("SELECT unit_id, done, confidence, updated_at FROM progress ORDER BY unit_id")).toEqual([ + { unit_id: "1.1", done: 1, confidence: 87, updated_at: 100 }, + { unit_id: "1.2", done: 0, confidence: 40, updated_at: 300 }, + { unit_id: "1.3", done: 0, confidence: 10, updated_at: 200 }, + { unit_id: "1.4", done: 0, confidence: 0, updated_at: 0 }, + ]); + }); + + it("puts him where he most recently was, as exactly as new as that was", async () => { + expect(await db.get("SELECT v, updated_at FROM meta WHERE k = 'road.unit'")).toEqual({ + v: "1.2", + updated_at: 300, + }); + expect((await readProgress(db)).current).toBe("1.2"); + }); + + it("gives every turn a text id and keeps the transcript in the order it was written", async () => { + const turns = await db.all<{ id: string; body: string }>("SELECT id, body FROM chat ORDER BY created_at, id"); + expect(turns.map((t) => t.body)).toEqual(["Start unit 1.1.", "first", "tenth", "later"]); + for (const t of turns) expect(t.id).toMatch(/^legacy:[0-9a-f-]{36}:\d{10}$/); + }); + + it("renames a chat tombstone the same way", async () => { + const device = (await db.get<{ v: string }>("SELECT v FROM meta WHERE k = 'sync.device'"))!.v; + expect(await db.all("SELECT pk, updated_at FROM tombstone WHERE tbl = 'chat'")).toEqual([ + { pk: `legacy:${device}:0000000009`, updated_at: 400 }, + ]); + }); + + it("names this install without stamping it", async () => { + expect(await db.get("SELECT updated_at FROM meta WHERE k = 'sync.device'")).toEqual({ updated_at: 0 }); + }); + + it("creates the learner-model tables empty", async () => { + for (const tbl of ["evidence", "confusion", "phase_ledger"]) { + expect(await db.get(`SELECT count(*) AS n FROM ${tbl}`), tbl).toEqual({ n: 0 }); + } + }); +}); + +describe("the new roadmap model", () => { + it("does not un-finish a unit that is revisited", async () => { + const { goToUnit } = await import("@app/domain/progress.js"); + await goToUnit(db, await readProgress(db), "1.1"); + const p = await readProgress(db); + expect(p.current).toBe("1.1"); + expect(p.done["1.1"]).toBe(true); + }); + + it("finishes the current unit and moves on in one step", async () => { + const { advanceUnit } = await import("@app/domain/progress.js"); + const next = await advanceUnit(db, await readProgress(db)); + const p = await readProgress(db); + expect(next).toBe("1.3"); + expect(p.current).toBe("1.3"); + expect(p.done["1.2"]).toBe(true); + }); +}); diff --git a/test/domain/reset.test.ts b/test/domain/reset.test.ts index 0cdf5e5..8944bff 100644 --- a/test/domain/reset.test.ts +++ b/test/domain/reset.test.ts @@ -56,7 +56,14 @@ async function aLearnerWithHistory(): Promise { await editChatTurn(db, "assistant", "좋아요."); await editMeta(db, "prefs.focus", "particles"); await editMeta(db, "grammar.learned", '["p1"]'); + await editMeta(db, "road.unit", "1.10"); + await editMeta(db, "learner.round", "12"); await db.run("INSERT INTO progress (unit_id, confidence, updated_at) VALUES ('1.1', 40, 1)"); + await db.exec(` + INSERT INTO evidence (word, ok, rounds, updated_at) VALUES ('물', 2, 2, 1); + INSERT INTO confusion (word, mistook, updated_at) VALUES ('물', '불', 1); + INSERT INTO phase_ledger (phase, kind, item, updated_at) VALUES (1, 'rule', '연음', 1); + `); } describe("editReset('everything')", () => { @@ -64,11 +71,17 @@ describe("editReset('everything')", () => { await aLearnerWithHistory(); await editReset(db, "everything"); - for (const tbl of ["card", "study_log", "chat", "progress", "peek"]) { + for (const tbl of ["card", "study_log", "chat", "progress", "peek", "evidence", "confusion", "phase_ledger"]) { expect(await count(tbl), `${tbl} should be empty after a full wipe`).toBe(0); } }); + it("forgets where he was and how far the rounds had got", async () => { + await aLearnerWithHistory(); + await editReset(db, "everything"); + expect(await db.all("SELECT k FROM meta WHERE k LIKE 'road.%' OR k LIKE 'learner.%'")).toEqual([]); + }); + it("clears the learner's preferences, grammar flags and notes", async () => { await aLearnerWithHistory(); await editReset(db, "everything"); @@ -127,8 +140,13 @@ describe("editReset('roadmap')", () => { expect(await count("progress")).toBe(0); expect(await count("chat")).toBe(0); + expect(await count("phase_ledger")).toBe(0); + expect(await db.get("SELECT v FROM meta WHERE k = 'road.unit'")).toBeUndefined(); expect(await count("card")).toBe(2); expect(await count("study_log")).toBe(1); + // What he knows about words is not the roadmap. + expect(await count("evidence")).toBe(1); + expect(await db.get("SELECT v FROM meta WHERE k = 'learner.round'")).toEqual({ v: "12" }); }); });