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>
213 lines
7.0 KiB
TypeScript
213 lines
7.0 KiB
TypeScript
/* 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();
|
|
});
|
|
});
|