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" }); }); });