Files
Hankan/test/db/migration-8.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

97 lines
3.9 KiB
TypeScript

/* Migration 8 — the roadmap loses its 'now' rows; chat turns get ids that
two devices can share.
A database as the previous build left it, including the state an earlier
sync could produce: two units both marked 'now'. */
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 { readProgress } from "@app/domain/progress.js";
import type { Db } from "@app/db/types.js";
let db: Db;
beforeEach(async () => {
db = await SqliteWasmDb.open({ memory: true });
await migrate(db, 7);
await db.exec(`
INSERT INTO progress (unit_id, state, confidence, updated_at) VALUES
('1.1', 'done', 87, 100),
('1.2', 'now', 40, 300),
('1.3', 'now', 10, 200),
('1.4', 'todo', 0, 0);
INSERT INTO chat (id, role, body, created_at, updated_at) VALUES
(1, 'user', 'Start unit 1.1.', 0, 0),
(2, 'assistant', 'first', 0, 0),
(10, 'assistant', 'tenth', 0, 0),
(11, 'user', 'later', 500, 500);
INSERT INTO tombstone (tbl, pk, updated_at) VALUES ('chat', '9', 400);
`);
await migrate(db);
});
afterEach(async () => {
await db.close();
});
describe("migration 8", () => {
it("keeps what is true of each unit, and nothing about being current", async () => {
expect(await db.all("SELECT unit_id, done, confidence, updated_at FROM progress ORDER BY unit_id")).toEqual([
{ unit_id: "1.1", done: 1, confidence: 87, updated_at: 100 },
{ unit_id: "1.2", done: 0, confidence: 40, updated_at: 300 },
{ unit_id: "1.3", done: 0, confidence: 10, updated_at: 200 },
{ unit_id: "1.4", done: 0, confidence: 0, updated_at: 0 },
]);
});
it("puts him where he most recently was, as exactly as new as that was", async () => {
expect(await db.get("SELECT v, updated_at FROM meta WHERE k = 'road.unit'")).toEqual({
v: "1.2",
updated_at: 300,
});
expect((await readProgress(db)).current).toBe("1.2");
});
it("gives every turn a text id and keeps the transcript in the order it was written", async () => {
const turns = await db.all<{ id: string; body: string }>("SELECT id, body FROM chat ORDER BY created_at, id");
expect(turns.map((t) => t.body)).toEqual(["Start unit 1.1.", "first", "tenth", "later"]);
for (const t of turns) expect(t.id).toMatch(/^legacy:[0-9a-f-]{36}:\d{10}$/);
});
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([
// As JSON, once migration 9 has written keys in protocol 2's form.
{ pk: JSON.stringify([`legacy:${device}:0000000009`]), updated_at: 400 },
]);
});
it("names this install without stamping it", async () => {
expect(await db.get("SELECT updated_at FROM meta WHERE k = 'sync.device'")).toEqual({ updated_at: 0 });
});
it("creates the learner-model tables empty", async () => {
for (const tbl of ["evidence", "confusion", "phase_ledger"]) {
expect(await db.get(`SELECT count(*) AS n FROM ${tbl}`), tbl).toEqual({ n: 0 });
}
});
});
describe("the new roadmap model", () => {
it("does not un-finish a unit that is revisited", async () => {
const { goToUnit } = await import("@app/domain/progress.js");
await goToUnit(db, await readProgress(db), "1.1");
const p = await readProgress(db);
expect(p.current).toBe("1.1");
expect(p.done["1.1"]).toBe(true);
});
it("finishes the current unit and moves on in one step", async () => {
const { advanceUnit } = await import("@app/domain/progress.js");
const next = await advanceUnit(db, await readProgress(db));
const p = await readProgress(db);
expect(next).toBe("1.3");
expect(p.current).toBe("1.3");
expect(p.done["1.2"]).toBe(true);
});
});