From c2e1fc23fe359ec5296d40250a40ad857d1636fb Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Tue, 8 Sep 2026 19:57:44 +0200 Subject: [PATCH] feat(server): the sync endpoints, on Node 22 with no build step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- package-lock.json | 256 +++++++++++++++++++++++++++++++++++- package.json | 3 +- server/package.json | 20 +++ server/sql/001-schema.sql | 45 +++++++ server/src/db.ts | 113 ++++++++++++++++ server/src/main.ts | 87 ++++++++++++ server/tsconfig.json | 19 +++ test/sync/roundtrip.test.ts | 212 +++++++++++++++++++++++++++++ tsconfig.json | 4 + 9 files changed, 754 insertions(+), 5 deletions(-) create mode 100644 server/package.json create mode 100644 server/sql/001-schema.sql create mode 100644 server/src/db.ts create mode 100644 server/src/main.ts create mode 100644 server/tsconfig.json create mode 100644 test/sync/roundtrip.test.ts diff --git a/package-lock.json b/package-lock.json index e036e3f..07ae16f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,8 @@ "name": "hankan", "version": "0.1.0", "workspaces": [ - "app" + "app", + "server" ], "devDependencies": { "@eslint/js": "^9.17.0", @@ -45,6 +46,27 @@ "vite-plugin-pwa": "^1.0.0" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.124.0.tgz", + "integrity": "sha512-cN5O8i9UVxHeOQAzj/XjshWXG8KiibJDw9OGpH2Z/eR3n/RBxdoLxDJOcfqAJWvjaMDFfHTBADU04hWRJVkDyA==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -1615,7 +1637,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2335,6 +2356,22 @@ "resolved": "app", "link": true }, + "node_modules/@hankan/server": { + "resolved": "server", + "link": true + }, + "node_modules/@hono/node-server": { + "version": "1.19.17", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", + "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -3146,6 +3183,12 @@ "node": ">=22" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@stencil/core": { "version": "4.44.2", "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.44.2.tgz", @@ -3386,6 +3429,18 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/react": { "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", @@ -5150,6 +5205,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fast-uri": { "version": "3.1.7", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", @@ -5714,6 +5775,15 @@ "hermes-estree": "0.25.1" } }, + "node_modules/hono": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz", + "integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/idb": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", @@ -6382,6 +6452,19 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -6922,6 +7005,95 @@ "dev": true, "license": "MIT" }, + "node_modules/pg": { + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", + "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", + "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6996,6 +7168,45 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -7699,7 +7910,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "dev": true, "license": "ISC", "engines": { "node": ">= 10.x" @@ -7718,6 +7928,16 @@ "dev": true, "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -8136,6 +8356,12 @@ "tree-kill": "cli.js" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -9217,6 +9443,15 @@ "node": ">=8.0" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -9252,7 +9487,7 @@ "version": "4.5.4", "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -9270,6 +9505,19 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "server": { + "name": "@hankan/server", + "version": "0.1.0", + "dependencies": { + "@anthropic-ai/sdk": "^0.124.0", + "@hono/node-server": "^1.14.0", + "hono": "^4.13.7", + "pg": "^8.13.1" + }, + "devDependencies": { + "@types/pg": "^8.11.10" + } } } } diff --git a/package.json b/package.json index da5d27b..8505c57 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "type": "module", "description": "Hankan — a Korean reading tutor for manhwa. Offline-first, web + Android from one codebase.", "workspaces": [ - "app" + "app", + "server" ], "engines": { "node": ">=22" diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..d46a22a --- /dev/null +++ b/server/package.json @@ -0,0 +1,20 @@ +{ + "name": "@hankan/server", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "node --experimental-strip-types --watch src/main.ts", + "start": "node --experimental-strip-types src/main.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.124.0", + "@hono/node-server": "^1.14.0", + "hono": "^4.13.7", + "pg": "^8.13.1" + }, + "devDependencies": { + "@types/pg": "^8.11.10" + } +} diff --git a/server/sql/001-schema.sql b/server/sql/001-schema.sql new file mode 100644 index 0000000..6890fe2 --- /dev/null +++ b/server/sql/001-schema.sql @@ -0,0 +1,45 @@ +-- Hankan sync schema. +-- +-- Mirrors the client's syncable tables, plus the two columns the client does +-- not have: change_seq, which the server assigns and clients use as a +-- cursor, and user_id, which is one value today but keeps a second device or +-- person from being a migration. +-- +-- Rows are stored generically: the primary key as text, the row body as +-- JSONB. The alternative — six typed tables kept in lockstep with the +-- client's migrations — buys nothing here, because the server never reads +-- inside a row. It stores and orders them; the client interprets them. + +CREATE TABLE IF NOT EXISTS sync_row ( + user_id TEXT NOT NULL, + tbl TEXT NOT NULL, + pk TEXT NOT NULL, + data JSONB NOT NULL, + -- The client's wall clock, and the field last-write-wins compares. + updated_at BIGINT NOT NULL, + -- A delete. Kept as a row so it can be handed to a device that was + -- offline when it happened. + deleted BOOLEAN NOT NULL DEFAULT FALSE, + -- Server-assigned and monotonic. The cursor a client pages from. + change_seq BIGSERIAL NOT NULL, + PRIMARY KEY (user_id, tbl, pk) +); + +-- The pull query is exactly this: everything newer than the client's cursor, +-- in assignment order. +CREATE INDEX IF NOT EXISTS sync_row_cursor ON sync_row (user_id, change_seq); + +-- change_seq must advance on every update, or a row edited after a client +-- last pulled would sit below that client's cursor and never be delivered. +-- Doing it in a trigger means no write path can forget. +CREATE OR REPLACE FUNCTION sync_row_bump() RETURNS trigger AS $$ +BEGIN + NEW.change_seq := nextval('sync_row_change_seq_seq'); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS sync_row_bump_trg ON sync_row; +CREATE TRIGGER sync_row_bump_trg + BEFORE UPDATE ON sync_row + FOR EACH ROW EXECUTE FUNCTION sync_row_bump(); diff --git a/server/src/db.ts b/server/src/db.ts new file mode 100644 index 0000000..3b353bb --- /dev/null +++ b/server/src/db.ts @@ -0,0 +1,113 @@ +/* Postgres access, and the two queries sync is made of. */ + +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import pg from "pg"; + +export interface SyncRow { + tbl: string; + data: Record; + updated_at: number; + deleted?: boolean; +} + +export interface Store { + pull(userId: string, cursor: number, limit: number): Promise<{ rows: SyncRow[]; cursor: number; more: boolean }>; + push(userId: string, rows: SyncRow[]): Promise; + /** Test-only; the route that calls it exists only under HANKAN_TEST_MODE. */ + reset(userId: string): Promise; + close(): Promise; +} + +/** + * The row's primary key as a single string. + * + * The client sends `{pk}` for a delete and the full row otherwise, so a + * delete already carries its key and a live row needs one derived from the + * table's key columns. Both sides use the same rule, from the shared + * protocol module. + */ +function keyOf(row: SyncRow, pkCols: string[]): string { + if (row.deleted && row.data.pk != null) return String(row.data.pk); + return pkCols.map((c) => String(row.data[c])).join(" "); +} + +export async function openStore(connectionString: string, pkFor: (tbl: string) => string[] | null): Promise { + const pool = new pg.Pool({ connectionString, max: 4 }); + + const schema = await readFile(fileURLToPath(new URL("../sql/001-schema.sql", import.meta.url)), "utf8"); + await pool.query(schema); + + return { + async pull(userId, cursor, limit) { + // One extra row tells us whether another page exists without a count. + const { rows } = await pool.query( + `SELECT tbl, pk, data, updated_at, deleted, change_seq + FROM sync_row + WHERE user_id = $1 AND change_seq > $2 + ORDER BY change_seq + LIMIT $3`, + [userId, cursor, limit + 1], + ); + + const more = rows.length > limit; + const page = more ? rows.slice(0, limit) : rows; + + return { + rows: page.map((r) => ({ + tbl: r.tbl as string, + data: r.deleted ? { pk: r.pk as string } : (r.data as Record), + updated_at: Number(r.updated_at), + ...(r.deleted ? { deleted: true } : {}), + })), + cursor: page.length ? Number(page[page.length - 1].change_seq) : cursor, + more, + }; + }, + + async push(userId, rows) { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + for (const row of rows) { + const pkCols = pkFor(row.tbl); + if (!pkCols) continue; // a table this server does not know about + + // Last-write-wins, resolved in the database so two devices pushing + // at once cannot interleave a read and a write around it. + await client.query( + `INSERT INTO sync_row (user_id, tbl, pk, data, updated_at, deleted) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (user_id, tbl, pk) DO UPDATE + SET data = EXCLUDED.data, + updated_at = EXCLUDED.updated_at, + deleted = EXCLUDED.deleted + WHERE EXCLUDED.updated_at > sync_row.updated_at`, + [userId, row.tbl, keyOf(row, pkCols), JSON.stringify(row.data), row.updated_at, row.deleted ?? false], + ); + } + + const { rows: top } = await client.query( + "SELECT COALESCE(max(change_seq), 0) AS c FROM sync_row WHERE user_id = $1", + [userId], + ); + await client.query("COMMIT"); + return Number(top[0].c); + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } + }, + + async reset(userId) { + await pool.query("DELETE FROM sync_row WHERE user_id = $1", [userId]); + }, + + async close() { + await pool.end(); + }, + }; +} diff --git a/server/src/main.ts b/server/src/main.ts new file mode 100644 index 0000000..5117846 --- /dev/null +++ b/server/src/main.ts @@ -0,0 +1,87 @@ +/* Hankan's server: sync, and the tutor. + + Both endpoints are stateless. The client owns its database and its + transcript; this process owns a Postgres table and an API key. Nothing + here needs to survive a restart. */ + +import { serve } from "@hono/node-server"; +import { Hono } from "hono"; + +import { pkFor, PAGE_SIZE } from "../../shared/sync-protocol.mjs"; +import { openStore, type SyncRow } from "./db.ts"; +import { tutorRoute } from "./tutor.ts"; + +const PORT = Number(process.env.PORT ?? 8787); +const TOKEN = process.env.HANKAN_TOKEN ?? ""; +const DATABASE_URL = process.env.DATABASE_URL ?? ""; +// One learner. The column exists so a second is a config change, not a +// migration. +const USER_ID = process.env.HANKAN_USER ?? "default"; + +if (!TOKEN) { + console.error("HANKAN_TOKEN is required — refusing to start an unauthenticated sync endpoint."); + process.exit(1); +} +if (!DATABASE_URL) { + console.error("DATABASE_URL is required."); + process.exit(1); +} + +const store = await openStore(DATABASE_URL, pkFor); + +const app = new Hono(); + +/* Bearer auth on everything except the health check, which has to be + reachable by a container healthcheck that holds no secret. */ +app.use("/api/*", async (c, next) => { + const header = c.req.header("authorization") ?? ""; + const presented = header.startsWith("Bearer ") ? header.slice(7) : ""; + // Constant-time-ish: compare lengths first, then every byte, so a wrong + // token cannot be narrowed down by timing the response. + const ok = + presented.length === TOKEN.length && + presented.split("").reduce((acc, ch, i) => acc & (ch === TOKEN[i] ? 1 : 0), 1) === 1; + if (!ok) return c.json({ error: "unauthorized" }, 401); + await next(); +}); + +app.get("/health", (c) => c.json({ ok: true })); + +/* ── sync ────────────────────────────────────────────────────────────── */ + +app.get("/api/sync", async (c) => { + const cursor = Number(c.req.query("cursor") ?? 0); + if (!Number.isFinite(cursor) || cursor < 0) return c.json({ error: "bad cursor" }, 400); + return c.json(await store.pull(USER_ID, cursor, PAGE_SIZE)); +}); + +app.post("/api/sync", async (c) => { + const body = (await c.req.json()) as { rows?: SyncRow[] }; + const rows = Array.isArray(body.rows) ? body.rows : []; + if (rows.length > PAGE_SIZE * 4) return c.json({ error: "too many rows" }, 413); + return c.json({ cursor: await store.push(USER_ID, rows) }); +}); + +/* Wipes this user's rows. Exists only when HANKAN_TEST_MODE is set, so it + cannot be reached on the Pi even if the token leaks. */ +if (process.env.HANKAN_TEST_MODE === "1") { + app.post("/api/test/reset", async (c) => { + await store.reset(USER_ID); + return c.json({ ok: true }); + }); + console.warn("HANKAN_TEST_MODE — /api/test/reset is enabled. Never set this in production."); +} + +/* ── tutor ───────────────────────────────────────────────────────────── */ + +app.route("/api/tutor", tutorRoute()); + +const server = serve({ fetch: app.fetch, port: PORT }, (info) => { + console.log(`hankan server on :${info.port}`); +}); + +for (const sig of ["SIGINT", "SIGTERM"] as const) { + process.on(sig, () => { + server.close(() => void store.close().then(() => process.exit(0))); + }); +} diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..51c1a78 --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023"], + "module": "NodeNext", + "moduleResolution": "nodenext", + "types": ["node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "noEmit": true, + "allowJs": true, + "checkJs": false + }, + "include": ["src", "../shared/sync-protocol.mjs"] +} diff --git a/test/sync/roundtrip.test.ts b/test/sync/roundtrip.test.ts new file mode 100644 index 0000000..7f1a0fa --- /dev/null +++ b/test/sync/roundtrip.test.ts @@ -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 { + 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 { + 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(); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 552c3e5..f67f61d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,6 +15,10 @@ "jsx": "react-jsx", "resolveJsonModule": true, "isolatedModules": true, + // The server runs its .ts directly under Node's type stripping, so its + // imports carry .ts extensions. noEmit is on, so this only affects + // resolution. + "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "skipLibCheck": true, "esModuleInterop": true,