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>
This commit is contained in:
MechaCat02
2026-09-16 20:28:35 +02:00
parent bf9b5950da
commit 9ab5aba6b3
21 changed files with 1692 additions and 579 deletions

View File

@@ -14,6 +14,7 @@ import {
seedMeta,
seedProgress,
editCard,
editCardReset,
editCurrentUnit,
editMeta,
editPeek,
@@ -201,6 +202,44 @@ export function conformanceSuite(name: string, open: () => Promise<Db>): void {
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");

View File

@@ -57,7 +57,8 @@ describe("migration 6", () => {
const graves = await db.all<{ pk: string; updated_at: number }>(
"SELECT pk, updated_at FROM tombstone WHERE tbl = 'card'",
);
expect(graves).toEqual([{ pk: String(lemmaId("책", "noun")), updated_at: 333 }]);
// As JSON, once migration 9 has written keys in protocol 2's form.
expect(graves).toEqual([{ pk: JSON.stringify([lemmaId("책", "noun")]), updated_at: 333 }]);
});
it("turns a custom lemma into a custom_word, stamped when its card was made", async () => {

View File

@@ -60,7 +60,8 @@ describe("migration 8", () => {
it("renames a chat tombstone the same way", async () => {
const device = (await db.get<{ v: string }>("SELECT v FROM meta WHERE k = 'sync.device'"))!.v;
expect(await db.all("SELECT pk, updated_at FROM tombstone WHERE tbl = 'chat'")).toEqual([
{ pk: `legacy:${device}:0000000009`, updated_at: 400 },
// As JSON, once migration 9 has written keys in protocol 2's form.
{ pk: JSON.stringify([`legacy:${device}:0000000009`]), updated_at: 400 },
]);
});

View File

@@ -0,0 +1,71 @@
/* Migration 9 — sync protocol 2's columns, per-device counters, JSON keys.
A database as migration 8 left it, carrying protocol 1's bookkeeping and
a tombstone of every shape protocol 1 wrote. */
import { describe, it, expect, beforeEach, afterEach } 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";
let db: Db;
let device: string;
beforeEach(async () => {
db = await SqliteWasmDb.open({ memory: true });
await migrate(db, 8);
device = (await db.get<{ v: string }>("SELECT v FROM meta WHERE k = 'sync.device'"))!.v;
await db.exec(`
INSERT INTO study_log (day, reviews, correct, drills, updated_at) VALUES (20000, 5, 4, 1, 700);
INSERT INTO peek (form, count, updated_at) VALUES ('몇 명', 3, 800);
INSERT INTO card (lemma_id, state, reps, updated_at) VALUES (55855054575946, 1, 2, 900);
INSERT INTO meta (k, v, updated_at) VALUES ('sync.cursor', '41', 0), ('sync.pushedAt', '900', 0);
INSERT INTO tombstone (tbl, pk, updated_at) VALUES
('card', '1434356355562999', 10),
('study_log', '19999', 11),
('peek', '몇 명', 12),
('meta', 'prefs.goal', 13),
('custom_word', '["던전","noun"]', 14);
`);
await migrate(db);
});
afterEach(async () => {
await db.close();
});
describe("migration 9", () => {
it("gives every syncable row base_seq, dirty and rev, all zero", async () => {
expect(await db.get("SELECT base_seq, dirty, rev, updated_at FROM card")).toEqual({
base_seq: 0,
dirty: 0,
rev: 0,
updated_at: 900,
});
});
it("hands this install's counters to this install", async () => {
expect(await db.get("SELECT day, device, reviews, correct, drills, updated_at FROM study_log")).toEqual({
day: 20000,
device,
reviews: 5,
correct: 4,
drills: 1,
updated_at: 700,
});
expect(await db.get("SELECT form, device, count FROM peek")).toEqual({ form: "몇 명", device, count: 3 });
});
it("writes every tombstone key as a JSON array, a space included", async () => {
const graves = await db.all<{ tbl: string; pk: string }>("SELECT tbl, pk FROM tombstone ORDER BY updated_at");
expect(graves).toEqual([
{ tbl: "card", pk: "[1434356355562999]" },
{ tbl: "study_log", pk: JSON.stringify([19999, device]) },
{ tbl: "peek", pk: JSON.stringify(["몇 명", device]) },
{ tbl: "meta", pk: '["prefs.goal"]' },
{ tbl: "custom_word", pk: '["던전","noun"]' },
]);
});
it("drops protocol 1's position, so the next sync hydrates from scratch", async () => {
expect(await db.all("SELECT k FROM meta WHERE k IN ('sync.cursor', 'sync.pushedAt')")).toEqual([]);
});
});

View File

@@ -27,13 +27,16 @@ run("CORS", () => {
headers: {
origin: PHONE_ORIGIN,
"access-control-request-method": "POST",
"access-control-request-headers": "authorization,content-type",
"access-control-request-headers": "authorization,content-type,x-hankan-protocol",
},
});
expect(res.status).not.toBe(401);
expect(res.status).toBeLessThan(300);
expect(res.headers.get("access-control-allow-origin")).toBe(PHONE_ORIGIN);
expect(res.headers.get("access-control-allow-headers")).toMatch(/authorization/i);
// Without this the phone's browser refuses to send the protocol header,
// and every sync from it is answered 426.
expect(res.headers.get("access-control-allow-headers")).toMatch(/x-hankan-protocol/i);
});
it("allows the Authorization header, without which the token cannot be sent", async () => {
@@ -46,7 +49,7 @@ run("CORS", () => {
it("puts the allow-origin header on the real response too", async () => {
const res = await fetch(`${BASE}/api/sync?cursor=0`, {
headers: { origin: PHONE_ORIGIN, authorization: `Bearer ${TOKEN}` },
headers: { origin: PHONE_ORIGIN, authorization: `Bearer ${TOKEN}`, "x-hankan-protocol": "2" },
});
expect(res.status).toBe(200);
expect(res.headers.get("access-control-allow-origin")).toBe(PHONE_ORIGIN);

118
test/sync/resolve.test.ts Normal file
View File

@@ -0,0 +1,118 @@
/* Gate 3 — what happens when two copies of one row disagree.
The rule is PORT.md's: the copy that holds more wins, a tie goes to the
server so every device lands on one copy, and a deliberate delete is
obeyed. These pin each table's reading of "more". */
import { describe, it, expect } from "vitest";
import { resolve, type Resolution, type Side } from "@app/sync/resolve.js";
/** The merged meta value, parsed. */
const mergedValue = (r: Resolution): unknown => {
if (r.kind !== "merge") throw new Error(`expected a merge, got ${r.kind}`);
return JSON.parse(String(r.data.v));
};
const ctx = { unitIndex: (id: string) => ["1.1", "1.2", "1.3", "1.10", "2.1"].indexOf(id) };
const live = (data: Record<string, string | number | null>): Side => ({ deleted: false, data });
const gone: Side = { deleted: true, data: null };
describe("deletes", () => {
it("obeys a delete from the server over an edit made here", () => {
expect(resolve("card", live({ reps: 9 }), gone, ctx)).toEqual({ kind: "adopt" });
});
it("keeps a delete made here over an edit on the server", () => {
expect(resolve("card", gone, live({ reps: 9 }), ctx)).toEqual({ kind: "keep" });
});
});
describe("cards and evidence — more work wins", () => {
it("keeps the card with more reviews behind it", () => {
expect(resolve("card", live({ reps: 6, lapses: 1 }), live({ reps: 5, lapses: 0 }), ctx)).toEqual({ kind: "keep" });
expect(resolve("card", live({ reps: 2, lapses: 0 }), live({ reps: 5, lapses: 0 }), ctx)).toEqual({ kind: "adopt" });
});
it("gives a tie to the server", () => {
expect(resolve("card", live({ reps: 5, lapses: 0 }), live({ reps: 5, lapses: 0 }), ctx)).toEqual({ kind: "adopt" });
});
it("weighs evidence by everything observed, lookups included", () => {
expect(
resolve("evidence", live({ ok: 2, wrong: 1, lookups: 2 }), live({ ok: 3, wrong: 0, lookups: 0 }), ctx),
).toEqual({ kind: "keep" });
});
});
describe("progress — merged, never un-finished", () => {
it("keeps a unit finished if either copy finished it", () => {
const r = resolve(
"progress",
live({ unit_id: "1.1", done: 1, confidence: 60, answers: 4, note: "mine" }),
live({ unit_id: "1.1", done: 0, confidence: 90, answers: 7, note: "theirs" }),
ctx,
);
expect(r).toEqual({
kind: "merge",
data: { unit_id: "1.1", done: 1, confidence: 90, answers: 7, note: "theirs" },
});
});
it("takes confidence from the copy with more answers behind it", () => {
expect(
resolve(
"progress",
live({ unit_id: "1.1", done: 0, confidence: 95, answers: 9, note: "a" }),
live({ unit_id: "1.1", done: 0, confidence: 40, answers: 3, note: "b" }),
ctx,
),
).toEqual({ kind: "keep" });
});
});
describe("meta", () => {
it("puts him on the further unit, by roadmap order and not by string", () => {
// "1.10" sorts before "1.2" as text; on the roadmap it comes after.
expect(resolve("meta", live({ k: "road.unit", v: "1.10" }), live({ k: "road.unit", v: "1.2" }), ctx)).toEqual({
kind: "keep",
});
});
it("never moves a counter backwards", () => {
expect(resolve("meta", live({ k: "learner.round", v: "12" }), live({ k: "learner.round", v: "9" }), ctx)).toEqual({
kind: "keep",
});
expect(resolve("meta", live({ k: "reset.chat", v: "1" }), live({ k: "reset.chat", v: "2" }), ctx)).toEqual({
kind: "adopt",
});
});
it("unites grammar flags learned on two devices", () => {
const r = resolve(
"meta",
live({ k: "grammar.learned", v: JSON.stringify({ a: true }) }),
live({ k: "grammar.learned", v: JSON.stringify({ b: true }) }),
ctx,
);
expect(mergedValue(r)).toEqual({ a: true, b: true });
});
it("keeps the longer of two versions of one note", () => {
const r = resolve(
"meta",
live({ k: "grammar.notes", v: JSON.stringify({ p1: "a long careful note" }) }),
live({ k: "grammar.notes", v: JSON.stringify({ p1: "short", p2: "other" }) }),
ctx,
);
expect(mergedValue(r)).toEqual({
p1: "a long careful note",
p2: "other",
});
});
it("takes the server's copy of a preference", () => {
expect(resolve("meta", live({ k: "prefs.goal", v: "40" }), live({ k: "prefs.goal", v: "20" }), ctx)).toEqual({
kind: "adopt",
});
});
});

View File

@@ -1,21 +1,21 @@
/* Two devices, one server.
/* 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 trigger that advances change_seq, the LWW clause in the upsert, or
the cursor paging, which is where sync actually goes wrong.
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 --name hankan-pg-test -e POSTGRES_PASSWORD=test \
-e POSTGRES_DB=hankan -p 55432:5432 postgres:16-alpine
DATABASE_URL=postgres://postgres:test@localhost:55432/hankan \
HANKAN_TOKEN=test-token PORT=8788 \
node --experimental-strip-types server/src/main.ts
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 } from "vitest";
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";
@@ -23,190 +23,326 @@ 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;
/** A fresh device: its own database, migrated, with the dictionary rows it
needs to hang cards off. */
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.exec(`
INSERT INTO lemma (id, headword, pos, gloss_en, source) VALUES
(1, '밥', 'noun', 'rice', 'curated'),
(2, '학교', 'noun', 'school', 'curated'),
(3, '물', 'noun', 'water', 'curated');
`);
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;
}
/** Wipe the server between tests so each starts from an empty history. */
async function resetServer(cfg: SyncConfig): Promise<void> {
await fetch(`${cfg.baseUrl}/api/test/reset`, {
method: "POST",
headers: { authorization: `Bearer ${cfg.token}` },
});
}
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 — two devices, one server", () => {
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 resetServer(cfg);
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 device();
const b = await device();
const a = await make();
const b = await make();
await editUnitConfidence(a, "1.1", 64);
await syncOnce(a, cfg);
await syncOnce(b, cfg);
const row = await b.get<{ confidence: number }>(
"SELECT confidence FROM progress WHERE unit_id = '1.1'",
);
expect(row?.confidence).toBe(64);
await a.close();
await b.close();
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 BUG, as an executable test.
A fresh device stamped its own empty defaults as newer than the
server's real history and clobbered it. Here the seed rows carry
updated_at = 0, so they are never dirty and never win — the device
converges onto the server's data instead of destroying it. */
it("a fresh device's seeded state never overwrites real progress", async () => {
const a = await device();
/* 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 editCard(a, 1, { ...newCard(), state: 2, interval: 21, due: 20_050, reps: 9 });
await syncOnce(a, cfg);
await editMeta(a, "road.unit", "1.3");
await editCard(a, RICE, { ...newCard(), state: 2, interval: 21, due: 20_050, reps: 9 });
await sync(a);
// A brand-new device does exactly what boot does: seeds defaults.
const fresh = await device();
const fresh = await make();
await seedProgress(fresh, "1.1");
await seedMeta(fresh, "prefs.newPerDay", "10");
await seedCard(fresh, 1, newCard());
await seedCard(fresh, RICE, newCard());
const result = await sync(fresh);
expect(result.pushed, "a seed is not news").toBe(0);
const seeded = await fresh.get<{ n: number }>(
"SELECT count(*) AS n FROM progress WHERE updated_at != 0",
);
expect(seeded?.n, "seed rows must carry no write timestamp").toBe(0);
await syncOnce(fresh, cfg);
// The fresh device adopts the real data…
const onFresh = await fresh.get<{ confidence: number }>(
"SELECT confidence FROM progress WHERE unit_id = '1.1'",
);
expect(onFresh?.confidence).toBe(88);
// …and the original device still has it after a round trip.
await syncOnce(a, cfg);
const onA = await a.get<{ confidence: number }>(
"SELECT confidence FROM progress WHERE unit_id = '1.1'",
);
expect(onA?.confidence, "the server's real history survived").toBe(88);
const card = await a.get<{ interval: number }>("SELECT interval FROM card WHERE lemma_id = 1");
expect(card?.interval).toBe(21);
await a.close();
await fresh.close();
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 });
});
it("last write wins on a genuine conflict", async () => {
const a = await device();
const b = await device();
/* 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);
await editUnitConfidence(a, "2.1", 30);
await syncOnce(a, cfg);
await syncOnce(b, cfg);
// 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);
// b edits later, so b wins.
await new Promise((r) => setTimeout(r, 5));
await editUnitConfidence(b, "2.1", 70);
await syncOnce(b, cfg);
await syncOnce(a, cfg);
// 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);
const onA = await a.get<{ confidence: number }>(
"SELECT confidence FROM progress WHERE unit_id = '2.1'",
);
expect(onA?.confidence).toBe(70);
await a.close();
await b.close();
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 });
}
});
it("propagates a delete through a tombstone", async () => {
const a = await device();
const b = await device();
/* 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();
await editCard(a, 2, { ...newCard(), state: 2, interval: 10 });
await syncOnce(a, cfg);
await syncOnce(b, cfg);
expect(await b.get("SELECT lemma_id FROM card WHERE lemma_id = 2")).toBeDefined();
vi.spyOn(Date, "now").mockReturnValue(real + 60 * 60_000);
await editMeta(fast, "prefs.goal", "40");
vi.restoreAllMocks();
await sync(fast);
await sync(slow);
// Without a tombstone the delete is invisible and b would push the card
// back on its next turn.
await new Promise((r) => setTimeout(r, 5));
await editCardReset(a, 2);
await syncOnce(a, cfg);
await syncOnce(b, cfg);
// 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 b.get("SELECT lemma_id FROM card WHERE lemma_id = 2")).toBeUndefined();
// And it stays deleted after b syncs again — no resurrection.
await syncOnce(b, cfg);
await syncOnce(a, cfg);
expect(await a.get("SELECT lemma_id FROM card WHERE lemma_id = 2")).toBeUndefined();
await a.close();
await b.close();
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 });
}
});
/* Device-local bookkeeping must not travel. dict.loadedBands is the
dangerous one: it would tell a device it holds rows it never
downloaded, and the word rail would then miss words it believes are
present. */
it("never syncs device-local meta", async () => {
const a = await device();
const b = await device();
const a = await make();
const b = await make();
await editMeta(a, "prefs.goal", "40");
// These are written the way the app writes them — unstamped bookkeeping.
await a.run("INSERT OR REPLACE INTO meta (k, v, updated_at) VALUES ('dict.loadedBands','[0,1,2,3,4,5]',?)", [
Date.now(),
]);
await a.run("INSERT OR REPLACE INTO meta (k, v, updated_at) VALUES ('server.token','secret',?)", [
Date.now(),
]);
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);
await syncOnce(a, cfg);
await syncOnce(b, cfg);
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();
});
expect((await b.get<{ v: string }>("SELECT v FROM meta WHERE k='prefs.goal'"))?.v).toBe("40");
expect(await b.get("SELECT v FROM meta WHERE k='dict.loadedBands'")).toBeUndefined();
expect(await b.get("SELECT v FROM meta WHERE k='server.token'")).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 a.close();
await b.close();
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);
});
});