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

256
package-lock.json generated
View File

@@ -8,7 +8,8 @@
"name": "hankan", "name": "hankan",
"version": "0.1.0", "version": "0.1.0",
"workspaces": [ "workspaces": [
"app" "app",
"server"
], ],
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.17.0", "@eslint/js": "^9.17.0",
@@ -45,6 +46,27 @@
"vite-plugin-pwa": "^1.0.0" "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": { "node_modules/@babel/code-frame": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -1615,7 +1637,6 @@
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -2335,6 +2356,22 @@
"resolved": "app", "resolved": "app",
"link": true "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": { "node_modules/@humanfs/core": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -3146,6 +3183,12 @@
"node": ">=22" "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": { "node_modules/@stencil/core": {
"version": "4.44.2", "version": "4.44.2",
"resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.44.2.tgz", "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.44.2.tgz",
@@ -3386,6 +3429,18 @@
"undici-types": "~6.21.0" "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": { "node_modules/@types/react": {
"version": "19.2.18", "version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
@@ -5150,6 +5205,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/fast-uri": {
"version": "3.1.7", "version": "3.1.7",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
@@ -5714,6 +5775,15 @@
"hermes-estree": "0.25.1" "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": { "node_modules/idb": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
@@ -6382,6 +6452,19 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/json-schema-traverse": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -6922,6 +7005,95 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -6996,6 +7168,45 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -7699,7 +7910,6 @@
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"dev": true,
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": ">= 10.x" "node": ">= 10.x"
@@ -7718,6 +7928,16 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/std-env": {
"version": "3.10.0", "version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
@@ -8136,6 +8356,12 @@
"tree-kill": "cli.js" "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": { "node_modules/ts-api-utils": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -9217,6 +9443,15 @@
"node": ">=8.0" "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": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -9252,7 +9487,7 @@
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz",
"integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
@@ -9270,6 +9505,19 @@
"peerDependencies": { "peerDependencies": {
"zod": "^3.25.0 || ^4.0.0" "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"
}
} }
} }
} }

View File

@@ -5,7 +5,8 @@
"type": "module", "type": "module",
"description": "Hankan — a Korean reading tutor for manhwa. Offline-first, web + Android from one codebase.", "description": "Hankan — a Korean reading tutor for manhwa. Offline-first, web + Android from one codebase.",
"workspaces": [ "workspaces": [
"app" "app",
"server"
], ],
"engines": { "engines": {
"node": ">=22" "node": ">=22"

20
server/package.json Normal file
View File

@@ -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"
}
}

45
server/sql/001-schema.sql Normal file
View File

@@ -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();

113
server/src/db.ts Normal file
View File

@@ -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<string, string | number | null>;
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<number>;
/** Test-only; the route that calls it exists only under HANKAN_TEST_MODE. */
reset(userId: string): Promise<void>;
close(): Promise<void>;
}
/**
* 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<Store> {
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<string, string | number | null>),
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();
},
};
}

87
server/src/main.ts Normal file
View File

@@ -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)));
});
}

19
server/tsconfig.json Normal file
View File

@@ -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"]
}

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();
});
});

View File

@@ -15,6 +15,10 @@
"jsx": "react-jsx", "jsx": "react-jsx",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": 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, "verbatimModuleSyntax": true,
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": true, "esModuleInterop": true,