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:
212
test/sync/roundtrip.test.ts
Normal file
212
test/sync/roundtrip.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user