The reworked bundle's PORT.md makes row-level last-write-wins conditional
on three gates, each learned by losing real data. The port's sync broke
all three, and had four more ways to lose or stall work. Both ends change,
so this is one protocol version, refused by the other side if mismatched.
Gate 1, hydration. A device now pulls every page the server holds before
it may push anything; it used to push first. The 14 Sep laptop — a
week-old copy re-stamped at boot and pushed over a week of phone work —
is now a test, and the phone's week survives it. Boot writes nothing
syncable either: the lesson opens with the app's own words (the artifact's
seeded turn) and waits for Start, instead of stamping a reply and a
progress edit before a server can even be configured.
Gate 2, a counter. Every row remembers the change_seq it last agreed with
(base_seq). The server applies a write only if that still matches —
compare-and-swap under an advisory lock — and otherwise returns its copy
as a conflict. No clock is compared anywhere: a device an hour fast used
to win every conflict for an hour, and a slow one's newer edit was
silently dropped with HTTP 200. dirty and rev replace the timestamp
watermark, which lost edits whenever a clock moved backwards.
Gate 3, no silent shrinking. A conflict is settled by what each copy
holds (sync/resolve.ts): more reviews, more evidence, a finished unit, the
further roadmap position, the union of learned grammar. A deliberate
shrink is explicit: a reset or a cleared lesson raises a marker every
device obeys, including its own unsynced edits, so a reset is not undone
by a device that had not heard of it. Trimming the transcript is local
and tombstones nothing — it used to delete the other device's turns.
Also fixed on the way:
· keys travel as JSON arrays — a space in 몇 명 used to stop every
device's pull at that row, permanently;
· pulls take a shared lock against pushes, so a change_seq committed
out of order can no longer be skipped;
· study_log and peek are per device and summed, so two devices' reviews
of one day both count;
· each user's data has an epoch; a server that lost it is detected,
and the device re-hydrates and offers its data back;
· a protocol-1 client is refused with 426 rather than half-understood.
Migration 9 adds the columns, re-keys the counters and tombstones; the
server drops protocol-1 rows once (none were deployed). Verified: 14
two-device scenarios against a real Postgres, and two browser profiles
syncing a lesson through the UI.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
320 lines
12 KiB
TypeScript
320 lines
12 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,
|
|
editCardReset,
|
|
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 nothing dirty after a full seed, so nothing seeded ever travels", async () => {
|
|
await seedProgress(db, "1.1");
|
|
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 dirty != 0`);
|
|
expect(row?.n, `${table} has a dirty seed row`).toBe(0);
|
|
}
|
|
});
|
|
|
|
it("marks every genuine edit dirty and bumps its revision each time", async () => {
|
|
await db.run(
|
|
"INSERT INTO lemma (id, headword, pos, gloss_en, source) VALUES (3,'책','noun','book','curated')",
|
|
);
|
|
await editCard(db, 3, newCard());
|
|
await editCard(db, 3, grade(newCard(), GOOD, 20_000));
|
|
expect(await db.get("SELECT dirty, rev FROM card WHERE lemma_id = 3")).toEqual({ dirty: 1, rev: 2 });
|
|
});
|
|
|
|
it("carries a deleted row's base_seq into its tombstone", async () => {
|
|
await db.run(
|
|
"INSERT INTO lemma (id, headword, pos, gloss_en, source) VALUES (4,'꽃','noun','flower','curated')",
|
|
);
|
|
await editCard(db, 4, newCard());
|
|
await db.run("UPDATE card SET base_seq = 77, dirty = 0 WHERE lemma_id = 4");
|
|
await editCardReset(db, 4);
|
|
expect(await db.get("SELECT pk, base_seq, dirty FROM tombstone WHERE tbl = 'card'")).toEqual({
|
|
pk: "[4]",
|
|
base_seq: 77,
|
|
dirty: 1,
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|
|
}
|