feat(server): the sync endpoints, on Node 22 with no build step

Hono and pg, run under --experimental-strip-types, so the deployed thing
is the source. GET /api/sync?cursor=N pages rows above the cursor;
POST /api/sync upserts last-write-wins. Bearer token on everything under
/api; /health is open, for the container healthcheck.

Rows are stored generically — primary key as text, body as JSONB —
because the server never reads inside a row. It stores and orders them and
the client interprets them, which keeps the two schemas from having to
move in lockstep.

change_seq is bumped by a BEFORE UPDATE trigger rather than by the write
path. A row edited after a client last pulled would otherwise keep its old
sequence, sit below that client's cursor, and never be delivered; putting
it in the database means no future write path can forget.

The last-write-wins comparison is in the ON CONFLICT clause itself, so a
losing row is not written at all and does not bump change_seq — a
conflict does not become traffic for every other device.

test/sync/roundtrip.test.ts runs two clients against a real Postgres and
asserts what actually goes wrong in sync: that a fresh client's seeded rows
cannot overwrite the server's history (the artifact's bug, as an executable
test), that a delete propagates, and that dict.loadedBands never crosses
the wire. It skips without HANKAN_TEST_SERVER, so npm test still runs
anywhere.

POST /api/test/reset exists only when HANKAN_TEST_MODE=1, so it cannot be
reached on the Pi even if the token leaks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-08 19:57:44 +02:00
parent f447f881c0
commit c2e1fc23fe
9 changed files with 754 additions and 5 deletions

212
test/sync/roundtrip.test.ts Normal file
View File

@@ -0,0 +1,212 @@
/* Two devices, one server.
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.
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
HANKAN_TEST_SERVER=http://localhost:8788 npm test
*/
import { describe, it, expect, beforeAll, beforeEach } 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,
editMeta,
editUnitConfidence,
seedCard,
seedMeta,
seedProgress,
} from "@app/db/writes.js";
import { syncOnce, type SyncConfig } from "@app/sync/client.js";
import { newCard } from "@lib/srs.js";
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. */
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');
`);
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}` },
});
}
suite("sync — two devices, one server", () => {
let cfg: SyncConfig;
beforeAll(() => {
cfg = { baseUrl: BASE!, token: TOKEN };
});
beforeEach(async () => {
await resetServer(cfg);
});
it("carries an edit from one device to the other", async () => {
const a = await device();
const b = await device();
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();
});
/* 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();
await editUnitConfidence(a, "1.1", 88);
await editCard(a, 1, { ...newCard(), state: 2, interval: 21, due: 20_050, reps: 9 });
await syncOnce(a, cfg);
// A brand-new device does exactly what boot does: seeds defaults.
const fresh = await device();
await seedProgress(fresh, "1.1");
await seedMeta(fresh, "prefs.newPerDay", "10");
await seedCard(fresh, 1, newCard());
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();
});
it("last write wins on a genuine conflict", async () => {
const a = await device();
const b = await device();
await editUnitConfidence(a, "2.1", 30);
await syncOnce(a, cfg);
await syncOnce(b, cfg);
// 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);
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();
});
it("propagates a delete through a tombstone", async () => {
const a = await device();
const b = await device();
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();
// 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);
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();
});
/* 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();
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 syncOnce(a, cfg);
await syncOnce(b, cfg);
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();
await a.close();
await b.close();
});
});