Files
Hankan/test/sync/roundtrip.test.ts
MechaCat02 9ab5aba6b3 feat(sync): protocol 2 — hydration, a counter not a clock, no silent shrinking
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>
2026-09-16 20:28:35 +02:00

349 lines
13 KiB
TypeScript

/* Two devices, one server — protocol 2, and the three gates.
Runs against a real Postgres and a real server: a fake would not exercise
the compare-and-swap, the advisory locks or the change_seq trigger, which
is where sync actually goes wrong.
Skipped unless HANKAN_TEST_SERVER is set, so the suite still runs on a
machine without Docker:
docker run -d --rm --name hankan-pg-test -e POSTGRES_PASSWORD=test \
-e POSTGRES_DB=hankan -p 127.0.0.1:55433:5432 postgres:16-alpine
DATABASE_URL=postgres://postgres:test@127.0.0.1:55433/hankan \
HANKAN_TOKEN=test-token HANKAN_TEST_MODE=1 HANKAN_TUTOR_BACKEND=echo \
PORT=8788 node --experimental-strip-types server/src/main.ts
HANKAN_TEST_SERVER=http://localhost:8788 npm test
*/
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest";
import { SqliteWasmDb } from "@app/db/sqlite-wasm-core.js";
import { migrate } from "@app/db/migrate.js";
import type { Db } from "@app/db/types.js";
import {
editCard,
editCardReset,
editChatClear,
editChatTurn,
editMeta,
editPeek,
editReset,
editStudyLog,
editUnitConfidence,
editUnitDone,
seedCard,
seedMeta,
seedProgress,
} from "@app/db/writes.js";
import { syncOnce, type SyncConfig } from "@app/sync/client.js";
import { unitIndex } from "@app/domain/gate.js";
import { newCard } from "@lib/srs.js";
import { lemmaId } from "@shared/lemma-id.mjs";
import { PROTOCOL_HEADER } from "@shared/sync-protocol.mjs";
const BASE = process.env.HANKAN_TEST_SERVER;
const TOKEN = process.env.HANKAN_TEST_TOKEN ?? "test-token";
const suite = BASE ? describe : describe.skip;
const RICE = lemmaId("밥", "noun");
const SCHOOL = lemmaId("학교", "noun");
const WATER = lemmaId("물", "noun");
/** A fresh device: its own database, migrated, with lemmas to hang cards on. */
async function device(): Promise<Db> {
const db = await SqliteWasmDb.open({ memory: true });
await migrate(db);
await db.run(
`INSERT INTO lemma (id, headword, pos, gloss_en, source) VALUES
(?, '밥', 'noun', 'rice', 'curated'), (?, '학교', 'noun', 'school', 'curated'),
(?, '물', 'noun', 'water', 'curated')`,
[RICE, SCHOOL, WATER],
);
return db;
}
const one = async <T>(db: Db, sql: string, params: (string | number)[] = []) => db.get<T>(sql, params);
const turns = async (db: Db) =>
(await db.all<{ body: string }>("SELECT body FROM chat ORDER BY created_at, id")).map((t) => t.body);
suite("sync — protocol 2, two devices, one server", () => {
let cfg: SyncConfig;
const sync = (db: Db) => syncOnce(db, cfg, { unitIndex });
const open: Db[] = [];
const make = async () => {
const db = await device();
open.push(db);
return db;
};
beforeAll(() => {
cfg = { baseUrl: BASE!, token: TOKEN };
});
beforeEach(async () => {
await fetch(`${cfg.baseUrl}/api/test/reset`, {
method: "POST",
headers: { authorization: `Bearer ${cfg.token}` },
});
});
afterEach(async () => {
vi.restoreAllMocks();
while (open.length) await open.pop()!.close();
});
it("carries an edit from one device to the other", async () => {
const a = await make();
const b = await make();
await editUnitConfidence(a, "1.1", 64);
await sync(a);
await sync(b);
expect(await one(b, "SELECT confidence FROM progress WHERE unit_id = '1.1'")).toEqual({ confidence: 64 });
});
/* The artifact's first bug: a fresh device's defaults clobbering real
progress. Seeds are never dirty, so they never leave the device. */
it("never lets a fresh device's seeded state overwrite real progress", async () => {
const a = await make();
await editUnitConfidence(a, "1.1", 88);
await editMeta(a, "road.unit", "1.3");
await editCard(a, RICE, { ...newCard(), state: 2, interval: 21, due: 20_050, reps: 9 });
await sync(a);
const fresh = await make();
await seedProgress(fresh, "1.1");
await seedMeta(fresh, "prefs.newPerDay", "10");
await seedCard(fresh, RICE, newCard());
const result = await sync(fresh);
expect(result.pushed, "a seed is not news").toBe(0);
expect(await one(fresh, "SELECT v FROM meta WHERE k = 'road.unit'")).toEqual({ v: "1.3" });
expect(await one(fresh, "SELECT interval FROM card WHERE lemma_id = ?", [RICE])).toEqual({ interval: 21 });
await sync(a);
expect(await one(a, "SELECT confidence FROM progress WHERE unit_id = '1.1'")).toEqual({ confidence: 88 });
});
/* 14 Sep. A laptop with a week-old copy booted, re-stamped that copy, and
pushed it over a week of phone work. Gate 1: it pulls first, so the
phone's week arrives before anything leaves the laptop — and the laptop's
stale re-stamp meets the week as a conflict, which the week wins. */
it("hydrates before it pushes: a week-old laptop cannot overwrite a week of work", async () => {
const laptop = await make();
const phone = await make();
await editUnitConfidence(laptop, "1.1", 40);
await sync(laptop);
await sync(phone);
// A week on the phone.
await editUnitDone(phone, "1.1");
await editUnitConfidence(phone, "1.1", 91);
await editMeta(phone, "road.unit", "1.2");
await editChatTurn(phone, "user", "a week of lessons");
await editCard(phone, WATER, { ...newCard(), state: 2, interval: 8, due: 20_010, reps: 6 });
await sync(phone);
// The laptop boots and a stale write stamps the old data as new.
await editUnitConfidence(laptop, "1.1", 40);
await editMeta(laptop, "road.unit", "1.1");
await sync(laptop);
await sync(phone);
for (const d of [phone, laptop]) {
expect(await one(d, "SELECT done, confidence FROM progress WHERE unit_id = '1.1'")).toEqual({
done: 1,
confidence: 91,
});
expect(await one(d, "SELECT v FROM meta WHERE k = 'road.unit'")).toEqual({ v: "1.2" });
expect(await turns(d)).toEqual(["a week of lessons"]);
expect(await one(d, "SELECT reps FROM card WHERE lemma_id = ?", [WATER])).toEqual({ reps: 6 });
}
});
/* Gate 2. Protocol 1 compared timestamps, so a device whose clock ran an
hour fast won every conflict for an hour. The counter does not care. */
it("orders edits by the server's counter, not by either device's clock", async () => {
const fast = await make();
const slow = await make();
const real = Date.now();
vi.spyOn(Date, "now").mockReturnValue(real + 60 * 60_000);
await editMeta(fast, "prefs.goal", "40");
vi.restoreAllMocks();
await sync(fast);
await sync(slow);
// Later in fact, earlier by its own clock.
vi.spyOn(Date, "now").mockReturnValue(real);
await editMeta(slow, "prefs.goal", "25");
vi.restoreAllMocks();
await sync(slow);
await sync(fast);
expect(await one(fast, "SELECT v FROM meta WHERE k = 'prefs.goal'")).toEqual({ v: "25" });
expect(await one(slow, "SELECT v FROM meta WHERE k = 'prefs.goal'")).toEqual({ v: "25" });
});
it("keeps both devices' offline turns — ids that cannot collide", async () => {
const a = await make();
const b = await make();
await sync(a);
await sync(b);
await editChatTurn(a, "user", "a1");
await editChatTurn(a, "assistant", "a2");
await editChatTurn(b, "user", "b1");
await editChatTurn(b, "assistant", "b2");
await sync(a);
await sync(b);
await sync(a);
for (const d of [a, b]) expect((await turns(d)).sort()).toEqual(["a1", "a2", "b1", "b2"]);
});
/* Gate 3. A cleared lesson is a deliberate shrink: the other device obeys
it, even holding more turns than the device that cleared. */
it("keeps a cleared lesson cleared against a device holding a longer transcript", async () => {
const a = await make();
const b = await make();
for (const t of ["one", "two", "three"]) await editChatTurn(a, "user", t);
await sync(a);
await sync(b);
await editChatTurn(b, "user", "offline four");
await editChatTurn(b, "user", "offline five");
await editChatClear(a);
await editChatTurn(a, "user", "a new lesson");
await sync(a);
await sync(b);
await sync(a);
expect(await turns(b)).toEqual(["a new lesson"]);
expect(await turns(a)).toEqual(["a new lesson"]);
});
it("honours a full reset on a device that had not heard of it, its own offline work included", async () => {
const a = await make();
const b = await make();
await editCard(a, RICE, { ...newCard(), state: 1, reps: 1 });
await editUnitConfidence(a, "1.1", 50);
await sync(a);
await sync(b);
await editCard(b, SCHOOL, { ...newCard(), state: 1, reps: 1 }); // a card a never saw
await editUnitConfidence(b, "1.2", 30);
await editReset(a, "everything");
await sync(a);
await sync(b);
await sync(a);
for (const d of [a, b]) {
expect(await one(d, "SELECT count(*) AS n FROM card")).toEqual({ n: 0 });
expect(await one(d, "SELECT count(*) AS n FROM progress")).toEqual({ n: 0 });
}
});
it("keeps a forgotten card forgotten against a review made before hearing of it", async () => {
const a = await make();
const b = await make();
await editCard(a, WATER, { ...newCard(), state: 2, interval: 10, reps: 3 });
await sync(a);
await sync(b);
await editCardReset(a, WATER);
await sync(a);
await editCard(b, WATER, { ...newCard(), state: 2, interval: 20, reps: 4 });
await sync(b);
await sync(a);
for (const d of [a, b]) expect(await one(d, "SELECT lemma_id FROM card WHERE lemma_id = ?", [WATER])).toBeUndefined();
});
it("re-creates a card forgotten elsewhere, once this device has heard of the forgetting", async () => {
const a = await make();
const b = await make();
await editCard(a, RICE, { ...newCard(), state: 1, reps: 1 });
await sync(a);
await sync(b);
await editCardReset(a, RICE);
await sync(a);
await sync(b); // b hears of it
await editCard(b, RICE, { ...newCard(), state: 1, reps: 1 }); // and studies it again
await sync(b);
await sync(a);
expect(await one(a, "SELECT reps FROM card WHERE lemma_id = ?", [RICE])).toEqual({ reps: 1 });
});
/* Protocol 1 joined a key's values with a space and split them back on
one; 몇 명 came back as two values and stopped every pull at that row. */
it("carries and deletes a row whose key contains a space", async () => {
const a = await make();
const b = await make();
await editPeek(a, "몇 명");
await editPeek(a, "몇 명");
await sync(a);
await sync(b);
expect(await one(b, "SELECT SUM(count) AS n FROM peek WHERE form = '몇 명'")).toEqual({ n: 2 });
await editReset(a, "everything");
await sync(a);
await sync(b);
expect(await one(b, "SELECT count(*) AS n FROM peek")).toEqual({ n: 0 });
});
it("counts two devices' reviews of the same day, rather than keeping one", async () => {
const a = await make();
const b = await make();
await editStudyLog(a, 20_000, { reviews: 2 });
await editStudyLog(b, 20_000, { reviews: 3 });
await sync(a);
await sync(b);
await sync(a);
for (const d of [a, b]) {
expect(await one(d, "SELECT SUM(reviews) AS n FROM study_log WHERE day = 20000")).toEqual({ n: 5 });
}
});
it("never syncs device-local meta", async () => {
const a = await make();
const b = await make();
await editMeta(a, "prefs.goal", "40");
await editMeta(a, "server.token", "secret");
await a.run("INSERT OR REPLACE INTO meta (k, v, updated_at, dirty) VALUES ('dict.loadedBands', '[0,1,2]', 1, 1)");
await sync(a);
await sync(b);
expect(await one(b, "SELECT v FROM meta WHERE k = 'prefs.goal'")).toEqual({ v: "40" });
expect(await one(b, "SELECT v FROM meta WHERE k = 'server.token'")).toBeUndefined();
expect(await one(b, "SELECT v FROM meta WHERE k = 'dict.loadedBands'")).toBeUndefined();
});
it("re-hydrates when the server has lost what it held, and offers its data back", async () => {
const a = await make();
await editUnitConfidence(a, "2.1", 70);
await sync(a);
await fetch(`${cfg.baseUrl}/api/test/reset`, {
method: "POST",
headers: { authorization: `Bearer ${cfg.token}` },
});
const again = await sync(a);
expect(again.rehydrated).toBe(true);
expect(again.pushed).toBeGreaterThan(0);
const b = await make();
await sync(b);
expect(await one(b, "SELECT confidence FROM progress WHERE unit_id = '2.1'")).toEqual({ confidence: 70 });
});
it("refuses a client that does not speak protocol 2", async () => {
const res = await fetch(`${cfg.baseUrl}/api/sync?cursor=0`, {
headers: { authorization: `Bearer ${cfg.token}` },
});
expect(res.status).toBe(426);
const ok = await fetch(`${cfg.baseUrl}/api/sync?cursor=0`, {
headers: { authorization: `Bearer ${cfg.token}`, [PROTOCOL_HEADER]: "2" },
});
expect(ok.status).toBe(200);
});
});