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:
45
server/sql/001-schema.sql
Normal file
45
server/sql/001-schema.sql
Normal 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();
|
||||
Reference in New Issue
Block a user