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>
281 lines
10 KiB
TypeScript
281 lines
10 KiB
TypeScript
/* The driver conformance suite, written once and pointed at a driver.
|
|
|
|
CI runs it against the sqlite-wasm driver in Node. The same export can be
|
|
pointed at the Capacitor driver on a device — that is what "same schema,
|
|
same queries, same migrations on both" has to mean in practice. */
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import type { Db } from "@app/db/types.js";
|
|
import { migrate } from "@app/db/migrate.js";
|
|
import { SCHEMA_VERSION } from "@app/db/migrations.js";
|
|
import {
|
|
seedCard,
|
|
seedChatTurn,
|
|
seedMeta,
|
|
seedProgress,
|
|
editCard,
|
|
editCurrentUnit,
|
|
editMeta,
|
|
editPeek,
|
|
editStudyLog,
|
|
editUnitConfidence,
|
|
insertBand,
|
|
} from "@app/db/writes.js";
|
|
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",
|
|
"evidence",
|
|
"confusion",
|
|
"phase_ledger",
|
|
] as const;
|
|
|
|
export function conformanceSuite(name: string, open: () => Promise<Db>): void {
|
|
describe(`Db conformance — ${name}`, () => {
|
|
let db: Db;
|
|
|
|
beforeEach(async () => {
|
|
db = await open();
|
|
await migrate(db);
|
|
});
|
|
afterEach(async () => {
|
|
await db.close();
|
|
});
|
|
|
|
describe("migrations", () => {
|
|
it("reaches the current schema version", async () => {
|
|
const row = await db.get<{ v: string }>("SELECT v FROM meta WHERE k='schema_version'");
|
|
expect(row?.v).toBe(String(SCHEMA_VERSION));
|
|
});
|
|
|
|
it("creates every table the app expects", async () => {
|
|
const rows = await db.all<{ name: string }>(
|
|
"SELECT name FROM sqlite_master WHERE type='table'",
|
|
);
|
|
const names = new Set(rows.map((r) => r.name));
|
|
for (const t of ["lemma", "surface", ...SYNCABLE]) expect(names.has(t), t).toBe(true);
|
|
});
|
|
|
|
it("is idempotent — running it again changes nothing", async () => {
|
|
const again = await migrate(db);
|
|
expect(again.from).toBe(SCHEMA_VERSION);
|
|
expect(again.to).toBe(SCHEMA_VERSION);
|
|
});
|
|
});
|
|
|
|
describe("queries", () => {
|
|
it("round-trips every value type", async () => {
|
|
await db.run("INSERT INTO lemma (id, headword, pos, freq_rank, gloss_en, source) VALUES (?,?,?,?,?,?)", [
|
|
1,
|
|
"밥",
|
|
"noun",
|
|
null,
|
|
"rice",
|
|
"curated",
|
|
]);
|
|
const row = await db.get<{ headword: string; freq_rank: number | null }>(
|
|
"SELECT headword, freq_rank FROM lemma WHERE id = ?",
|
|
[1],
|
|
);
|
|
expect(row).toEqual({ headword: "밥", freq_rank: null });
|
|
});
|
|
|
|
it("get() returns undefined when nothing matches", async () => {
|
|
expect(await db.get("SELECT 1 AS x WHERE 0")).toBeUndefined();
|
|
});
|
|
|
|
it("all() returns every row, in order", async () => {
|
|
await db.exec(`
|
|
INSERT INTO lemma (id, headword, pos, freq_rank, gloss_en, source) VALUES
|
|
(1,'가','verb',10,'go','curated'),
|
|
(2,'나','pron',20,'I','curated'),
|
|
(3,'다','adv',30,'all','curated');
|
|
`);
|
|
const rows = await db.all<{ headword: string }>(
|
|
"SELECT headword FROM lemma ORDER BY freq_rank",
|
|
);
|
|
expect(rows.map((r) => r.headword)).toEqual(["가", "나", "다"]);
|
|
});
|
|
});
|
|
|
|
describe("transactions", () => {
|
|
it("commits on success", async () => {
|
|
await db.tx(async (tx) => {
|
|
await tx.run("INSERT INTO meta (k, v) VALUES ('a', '1')");
|
|
await tx.run("INSERT INTO meta (k, v) VALUES ('b', '2')");
|
|
});
|
|
const rows = await db.all("SELECT k FROM meta WHERE k IN ('a','b')");
|
|
expect(rows).toHaveLength(2);
|
|
});
|
|
|
|
it("rolls back everything when the callback throws", async () => {
|
|
await expect(
|
|
db.tx(async (tx) => {
|
|
await tx.run("INSERT INTO meta (k, v) VALUES ('c', '3')");
|
|
throw new Error("boom");
|
|
}),
|
|
).rejects.toThrow("boom");
|
|
expect(await db.get("SELECT k FROM meta WHERE k='c'")).toBeUndefined();
|
|
});
|
|
|
|
it("sees its own writes inside the transaction", async () => {
|
|
const seen = await db.tx(async (tx) => {
|
|
await tx.run("INSERT INTO meta (k, v) VALUES ('d', '4')");
|
|
return tx.get<{ v: string }>("SELECT v FROM meta WHERE k='d'");
|
|
});
|
|
expect(seen?.v).toBe("4");
|
|
});
|
|
|
|
it("survives a rollback and keeps working", async () => {
|
|
await db.tx(async (tx) => tx.run("INSERT INTO meta (k,v) VALUES ('e','5')")).catch(() => {});
|
|
await expect(
|
|
db.tx(async (tx) => {
|
|
await tx.run("INSERT INTO meta (k,v) VALUES ('f','6')");
|
|
throw new Error("x");
|
|
}),
|
|
).rejects.toThrow();
|
|
await db.run("INSERT INTO meta (k, v) VALUES ('g', '7')");
|
|
expect((await db.get<{ v: string }>("SELECT v FROM meta WHERE k='g'"))?.v).toBe("7");
|
|
});
|
|
});
|
|
|
|
describe("bulk band insert", () => {
|
|
it("loads lemma and surface rows together", async () => {
|
|
await insertBand(
|
|
db,
|
|
[
|
|
{
|
|
id: 100,
|
|
headword: "먹다",
|
|
pos: "verb",
|
|
freq_rank: 42,
|
|
level: "초급",
|
|
gloss_en: "to eat",
|
|
gloss_ko: "",
|
|
unit_band: 1,
|
|
source: "curated",
|
|
},
|
|
],
|
|
[
|
|
{ form: "먹어", lemma_id: 100, analysis: "반말, from 먹다" },
|
|
{ form: "먹었어", lemma_id: 100, analysis: "반말 past, from 먹다" },
|
|
],
|
|
);
|
|
const hit = await db.get<{ headword: string; analysis: string }>(
|
|
`SELECT l.headword, s.analysis FROM surface s
|
|
JOIN lemma l ON l.id = s.lemma_id WHERE s.form = ?`,
|
|
["먹었어"],
|
|
);
|
|
expect(hit?.headword).toBe("먹다");
|
|
expect(hit?.analysis).toContain("past");
|
|
});
|
|
|
|
it("is re-runnable — reloading a band does not duplicate", async () => {
|
|
const lemma = {
|
|
id: 200,
|
|
headword: "물",
|
|
pos: "noun",
|
|
freq_rank: 7,
|
|
level: null,
|
|
gloss_en: "water",
|
|
gloss_ko: "",
|
|
unit_band: 0,
|
|
source: "curated",
|
|
};
|
|
await insertBand(db, [lemma], [{ form: "물", lemma_id: 200, analysis: "headword" }]);
|
|
await insertBand(db, [lemma], [{ form: "물", lemma_id: 200, analysis: "headword" }]);
|
|
const n = await db.get<{ n: number }>("SELECT count(*) AS n FROM lemma WHERE id = 200");
|
|
expect(n?.n).toBe(1);
|
|
});
|
|
});
|
|
|
|
/* ═══════════════════════════════════════════════════════════════
|
|
The trap. A fresh device must never claim its empty defaults are
|
|
newer than the server's real history.
|
|
═══════════════════════════════════════════════════════════════ */
|
|
describe("seeded state carries no write timestamp", () => {
|
|
it("leaves updated_at at 0 across every syncable table after a full seed", async () => {
|
|
await seedProgress(db, "1.1");
|
|
await seedMeta(db, "focus", "auto");
|
|
await seedMeta(db, "prefs.newPerDay", "10");
|
|
await db.run(
|
|
"INSERT INTO lemma (id, headword, pos, gloss_en, source) VALUES (1,'밥','noun','rice','curated')",
|
|
);
|
|
await seedCard(db, 1, newCard());
|
|
await seedChatTurn(db, "assistant", "안녕! 시작하자.", 0);
|
|
|
|
for (const table of SYNCABLE) {
|
|
const row = await db.get<{ n: number }>(
|
|
`SELECT count(*) AS n FROM ${table} WHERE updated_at != 0`,
|
|
);
|
|
expect(row?.n, `${table} has a stamped seed row`).toBe(0);
|
|
}
|
|
});
|
|
|
|
it("still writes 0 when the migration itself records the schema version", async () => {
|
|
const row = await db.get<{ updated_at: number }>(
|
|
"SELECT updated_at FROM meta WHERE k='schema_version'",
|
|
);
|
|
expect(row?.updated_at).toBe(0);
|
|
});
|
|
|
|
it("but a genuine edit does stamp the clock", async () => {
|
|
await db.run(
|
|
"INSERT INTO lemma (id, headword, pos, gloss_en, source) VALUES (2,'물','noun','water','curated')",
|
|
);
|
|
const before = Date.now();
|
|
await editCard(db, 2, grade(newCard(), GOOD, 20_000));
|
|
await editUnitConfidence(db, "1.1", 40);
|
|
await editMeta(db, "prefs.newPerDay", "20");
|
|
await editStudyLog(db, 20_000, { reviews: 1, correct: 1 });
|
|
await editPeek(db, "물");
|
|
|
|
for (const [table, where] of [
|
|
["card", "lemma_id = 2"],
|
|
["progress", "unit_id = '1.1'"],
|
|
["meta", "k = 'prefs.newPerDay'"],
|
|
["study_log", "day = 20000"],
|
|
["peek", "form = '물'"],
|
|
] as const) {
|
|
const row = await db.get<{ updated_at: number }>(
|
|
`SELECT updated_at FROM ${table} WHERE ${where}`,
|
|
);
|
|
expect(row?.updated_at, `${table} was not stamped`).toBeGreaterThanOrEqual(before);
|
|
}
|
|
});
|
|
|
|
it("an edit on top of a seeded row promotes it from 0", async () => {
|
|
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).toEqual({ v: "1.1", updated_at: 0 });
|
|
|
|
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?.v).toBe("2.1");
|
|
expect(edited?.updated_at).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("seeding never overwrites a real edit", async () => {
|
|
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?.v).toBe("3.1");
|
|
expect(row?.updated_at).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
});
|
|
}
|