/* 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): 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); }); }); }); }