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>
269 lines
10 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|
|
}
|