Files
Hankan/test/db/conformance.ts
MechaCat02 bbe6302a9b feat(db): one storage interface, two SQLite drivers
Nothing above db/ knows which driver it got. sqlite.web.ts runs
@sqlite.org/sqlite-wasm in a dedicated Worker; sqlite.native.ts uses the
Capacitor plugin. Both apply the same migration array.

The web driver uses the OPFS SAHPool VFS rather than the plain opfs VFS:
the latter needs COOP/COEP cross-origin isolation headers, which neither a
static host nor the Capacitor webview reliably provides. Same storage,
fewer deployment constraints.

THE TIMESTAMP RULE. The artifact had a sync bug where a fresh device
stamped its own empty defaults as newer than the server's real history and
clobbered it. Three layers make that unrepresentable rather than merely
avoided:

  1. updated_at INTEGER NOT NULL DEFAULT 0 on every syncable table, so
     forgetting the column is the SAFE failure — a row that loses every
     last-write-wins comparison, not one that wins them all.
  2. writes.ts splits every mutation into seedX() (never stamps) and
     editX() (always stamps), and is the only file allowed to read the clock.
  3. An ESLint rule enforces that, and the conformance suite asserts a
     freshly seeded database has no non-zero updated_at anywhere.

Two batching limits are sized for the platform we cannot test here: Android
links the system SQLite, historically capped at 999 bound parameters, while
sqlite-wasm allows 32766. A multi-row insert sized for the browser would
fail only on the phone, at first launch, loading the dictionary. Both the
band insert and the word-rail lookup batch under the smaller ceiling, and
test/db/limits.test.ts fails at 3600 if that regresses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 19:12:48 +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"] 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);
});
});
});
}