feat(db): a roadmap without 'now' rows, chat ids two devices can share, the learner-model tables

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>
This commit is contained in:
MechaCat02
2026-09-16 20:10:58 +02:00
parent 48987b96ae
commit bf9b5950da
10 changed files with 391 additions and 79 deletions

33
app/src/db/ids.ts Normal file
View File

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

View File

@@ -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:<device>:<n>`, 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<void> {
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.
*

View File

@@ -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<void
/** First-run roadmap position. Unit 1.1 is where everyone starts. */
export async function seedProgress(db: Db, unitId: string): Promise<void> {
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<voi
/** The tutor's opening turn, which the app supplies rather than the model. */
export async function seedChatTurn(db: Db, role: string, body: string, at: number): Promise<void> {
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<void> {
});
}
/** 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<void> {
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<void> {
/** A unit finished. Nothing un-finishes one short of a reset. */
export async function editUnitDone(db: Db, unitId: string): Promise<void> {
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<void> {
await editMeta(db, "road.unit", unitId);
}
/** A preference the learner changed. */
export async function editMeta(db: Db, k: string, v: string): Promise<void> {
await db.run(
@@ -154,15 +156,15 @@ export async function editMeta(db: Db, k: string, v: string): Promise<void> {
export async function editChatTurn(db: Db, role: string, body: string): Promise<void> {
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<void> {
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<void> {
/** Trim the transcript. The artifact kept the last 26 turns. */
export async function editChatTrim(db: Db, keep: number): Promise<void> {
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<voi
export type ResetScope = "roadmap" | "everything";
/** Meta keys holding the roadmap: where he is, what he has done recently. */
const ROAD_META = "k LIKE 'road.%'";
/** Meta keys holding the learner himself: preferences, notes, counters. */
const LEARNER_META = "k LIKE 'prefs.%' OR k LIKE 'grammar.%' OR k LIKE 'trainer.%' OR k LIKE 'learner.%'";
export async function editReset(db: Db, scope: ResetScope): Promise<void> {
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<void> {
"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}`);
}
});
}

View File

@@ -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<ProgressState> {
const done: Record<string, boolean> = {};
const confidence: Record<string, number> = {};
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<stri
const next = nextUnit(progress);
if (!next) return null;
await db.tx(async (tx) => {
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<void> {
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. */

View File

@@ -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<T>(items: T[], seed: number): T[] {
@@ -45,7 +52,7 @@ const LABEL: Record<Task["type"], string> = {
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],
);

View File

@@ -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<Turn[]> => {
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]);