Persistence & Replay
The engine’s state is JSON-serializable by contract, which makes persistence a dumb pass-through: the server stores TableState as a single jsonb blob and lets the engine stay the source of truth about its own shape.
B2 — save-on-action
The server persists TableState to Postgres on every action — one row per room, updated in place. Reloads (refresh, reconnect, server restart) restore the room from the row.
create table rooms (
id text primary key,
state jsonb not null, -- serialized TableState
schema_version int not null, -- mirrors state.schemaVersion
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);Schema migrations live in the engine’s schemaVersion field, not in SQL DDL — the server is a dumb persistence layer. The server also boots and runs without Postgres (warns at startup, in-memory rooms only).
B3 — replay-from-initial-state
Every applied action is appended to room_actions, and each room’s creation-time state is captured immutably in rooms.initial_state. GET /api/rooms/:id/replay returns (initialState, actions) → state so any prior state can be reconstructed for debug / audit / spectate.
The initial_state column is set only on the row’s first INSERT — upsertRoom’s ON CONFLICT clause deliberately omits it from the SET list, so action writes can never overwrite the replay base.
Replay is best-effort, not a strict guarantee (accepted constraint)
While the server process is running, the authoritative record of a room is the in-memory TableState; it’s written through to the rooms.state column as a best-effort persisted copy, which is what a reload / reconnect / restart restores from. The action log and the rooms.state snapshot are both persisted fire-and-forget — a failed write is logged but does not fail the response.
Consequences, accepted by design:
- A dropped action-log write means
GET /api/rooms/:id/replaycan reconstruct a state that differs from the live in-memory state and itsrooms.statesnapshot. The snapshot is the source of truth; replay is a debug / audit / spectate convenience, not a consistency oracle. recordActionallocatesseqviaSELECT COALESCE(MAX(seq),0)+1with aUNIQUE(room_id, seq)constraint, serialized per-room by the in-process write chains. This is race-safe within one process. A multi-process deployment sharing one DB is not supported — the in-memory-authoritativeRoomManagerwould already diverge across processes — so the cross-process seq race is out of scope until/unless the server is made horizontally scalable.
Promoting replay to a strict guarantee (await + retry-on-23505 + cold-load reconciliation of log-vs-snapshot) is a deliberate future step, not a bug — intentionally deferred while the single-server snapshot model is authoritative.