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:
@@ -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<Db>): void {
|
||||
describe(`Db conformance — ${name}`, () => {
|
||||
@@ -240,27 +252,27 @@ export function conformanceSuite(name: string, open: () => Promise<Db>): 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);
|
||||
});
|
||||
});
|
||||
|
||||
95
test/db/migration-8.test.ts
Normal file
95
test/db/migration-8.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -56,7 +56,14 @@ async function aLearnerWithHistory(): Promise<void> {
|
||||
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" });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user