Files
Hankan/test/db/conformance.ts
MechaCat02 f8183d786f feat(dict): stable lemma ids — a card names its word, not a build position
Cards point at lemmas by id, and an id was the entry's position in the
sorted build. One word added near the top of the dictionary would have
moved every card below it onto a different word — silently, because loaded
bands were recorded by number and a rebuilt dictionary never reached an
existing install anyway. A custom word took max(id)+1 on whichever device
added it, so the same id meant different words on a phone and a laptop.

An id is now lemmaId(headword, pos), a 53-bit hash defined once in
shared/ and used by the build, the loader, custom words and the migration.
The build asserts all 30,520 entries are collision-free, and a test pins the
function itself, since changing it re-keys every card.

Band files are format 2: they carry no ids at all. The loader derives each
id from the word, and a surface names its lemma by row index in the same
file. Writing hashed ids out cost 0.5 MB of incompressible digits; leaving
them out makes the files smaller than before (1.1 MB -> 1.0 MB).

The loaded dictionary is now versioned by its band hashes, so a rebuild
reloads on the next boot — safe only now that a reload cannot move a card.

Migration 6 re-keys an existing install without stamping anything: cards
and their tombstones move through the lemma rows still loaded, custom words
become custom_word rows (the learner's data, which can travel) carrying the
time their card was made, and the dictionary is dropped to reload.

Sync is paused until the protocol that replaces it lands: the server still
holds rows under the old ids, and exchanging them would plant cards that
name no word.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 19:59:14 +02:00

269 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,
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"] 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, "2.1");
const seeded = await db.get<{ updated_at: number }>(
"SELECT updated_at FROM progress WHERE unit_id='2.1'",
);
expect(seeded?.updated_at).toBe(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'",
);
expect(edited?.confidence).toBe(55);
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'",
);
expect(row?.confidence).toBe(90);
expect(row?.updated_at).toBeGreaterThan(0);
});
});
});
}