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/replay can reconstruct a state that differs from the live in-memory state and its rooms.state snapshot. The snapshot is the source of truth; replay is a debug / audit / spectate convenience, not a consistency oracle.
  • recordAction allocates seq via SELECT COALESCE(MAX(seq),0)+1 with a UNIQUE(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-authoritative RoomManager would 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.