Skip to content

Why nothing derived is stored

The first chess application written for DAI stored the board. It was a reasonable thing to do in an ordinary application and exactly the wrong thing in a shared one, and the difference between it and the correct version teaches four constraints at once.

That first version was never kept. The one below is a deliberate reconstruction, labeled as such in examples/chess-foil, written to make the same mistakes. The lint refuses it for exactly those reasons, and a test holds it to that.

The two schemas

The foil:

sql
-- THE FOIL. Deliberately wrong; see README.md. The correct schema is
-- tests/fixture/chess/schema.sql.

-- dai:replicated
CREATE TABLE IF NOT EXISTS games (
  white_name      TEXT NOT NULL,
  black_name      TEXT NOT NULL,
  fen             TEXT NOT NULL,             -- WRONG: the board, derivable from moves
  turn            TEXT NOT NULL CHECK (turn IN ('w','b')),  -- WRONG: derivable, and a CHECK
  result          TEXT NOT NULL DEFAULT '*', -- WRONG: derivable
  updated_at      TEXT NOT NULL DEFAULT (datetime('now')), -- WRONG: a second opinion about order
  selected_square TEXT,                      -- WRONG: this copy's UI state, shared
  -- WRONG: theme is this copy's setting, shared
  theme           TEXT NOT NULL DEFAULT 'system'
);

-- dai:replicated
CREATE TABLE IF NOT EXISTS moves (
  game_id TEXT NOT NULL,
  ply     INTEGER NOT NULL,
  san     TEXT NOT NULL,
  -- WRONG: refuses the conflict a merge exists to show
  UNIQUE (game_id, ply)
);

The application that works — tests/fixture/chess:

sql
-- Velvet Chess · schema version 2 (replicated)
--
-- Three replicated tables hold everything both players must agree on.
-- They are append-only: the compiler adds the _r_* columns, the composite
-- primary key, the immutability triggers and the *_heads / *_current /
-- *_conflicts views. The application never writes an _r_* column and never
-- runs UPDATE or DELETE against these tables.
--
-- Rules the author followed, and why:
--   * No PRIMARY KEY, UNIQUE or CHECK on replicated tables. The key is the
--     replica's; a UNIQUE(game_id, ply) would refuse the very rows that
--     union merge exists to surface as a conflict; a CHECK that differs
--     between two copies shows up as rejected rows, not a refused merge.
--   * No board, no turn, no result, no timestamp. All of it is derived by
--     replaying `moves` through the engine in `ply` order.
--   * A game's identity is the entity of its `games` row. `game_id` in the
--     other two tables is that entity, as hex.
--
-- Profile (Track 3): each game is a session of two, and either player may end
--   one (close=any — a resignation, and later a retire). The creator seats
--   itself and leaves an open seat; the invitee binds it. A forwarded copy that
--   opens an already-bound invite contests the seat rather than entering, and
--   the app says so. Every row of a game carries its session.
-- dai:profile session max_parties=2 close=any

-- dai:replicated
CREATE TABLE IF NOT EXISTS games (
  white_name    TEXT NOT NULL,
  black_name    TEXT NOT NULL,
  creator_color TEXT NOT NULL,   -- 'w' | 'b'
  initial_fen   TEXT NOT NULL
);

-- dai:replicated
CREATE TABLE IF NOT EXISTS moves (
  game_id    TEXT NOT NULL,      -- hex entity of the games row
  ply        INTEGER NOT NULL,   -- 1-based; the move's own ordinal, the only ordering key
  color      TEXT NOT NULL,      -- 'w' | 'b' — the side that claims to have moved
  from_sq    TEXT NOT NULL,
  to_sq      TEXT NOT NULL,
  promotion  TEXT,               -- 'q' | 'r' | 'b' | 'n' | NULL
  san        TEXT NOT NULL,      -- for display only; the engine recomputes it
  draw_offer INTEGER NOT NULL DEFAULT 0
);

-- dai:replicated
CREATE TABLE IF NOT EXISTS game_events (
  game_id   TEXT NOT NULL,
  after_ply INTEGER NOT NULL,    -- the ply count the event was made at; only valid at that count
  color     TEXT NOT NULL,       -- the side acting
  kind      TEXT NOT NULL,       -- 'resign' | 'draw-accept' | 'draw-decline' | 'claim'
  detail    TEXT NOT NULL DEFAULT ''
);

-- Local tables. Never merged; they describe this copy, not the game.

CREATE TABLE IF NOT EXISTS settings (
  id             INTEGER PRIMARY KEY CHECK (id = 1),
  theme          TEXT NOT NULL DEFAULT 'system' CHECK (theme IN ('system','light','dark')),
  animations     INTEGER NOT NULL DEFAULT 1 CHECK (animations IN (0,1)),
  active_game_id TEXT,
  seed_completed INTEGER NOT NULL DEFAULT 0,
  setup_you      TEXT NOT NULL DEFAULT '',
  setup_them     TEXT NOT NULL DEFAULT '',
  setup_color    TEXT NOT NULL DEFAULT 'random' CHECK (setup_color IN ('random','w','b'))
);

CREATE TABLE IF NOT EXISTS ui_state (
  id              INTEGER PRIMARY KEY CHECK (id = 1),
  current_view    TEXT NOT NULL DEFAULT 'board',
  orientation     TEXT NOT NULL DEFAULT 'w',
  selected_square TEXT,
  promotion_from  TEXT,
  promotion_to    TEXT
);

-- A tentative move lives here until the player commits it. It is this copy's
-- private state: it never travels, so nothing about it is a shared fact.
CREATE TABLE IF NOT EXISTS drafts (
  game_id    TEXT PRIMARY KEY,
  from_sq    TEXT NOT NULL,
  to_sq      TEXT NOT NULL,
  promotion  TEXT,
  draw_offer INTEGER NOT NULL DEFAULT 0
);

-- Per-copy facts about a game: which game is the bundled practice board,
-- which games this copy has hidden, and the photos this person attached.
CREATE TABLE IF NOT EXISTS local_games (
  game_id  TEXT PRIMARY KEY,
  is_demo  INTEGER NOT NULL DEFAULT 0,
  hidden   INTEGER NOT NULL DEFAULT 0
);

CREATE TABLE IF NOT EXISTS photos (
  game_id TEXT NOT NULL,
  color   TEXT NOT NULL CHECK (color IN ('w','b')),
  bytes   BLOB NOT NULL,
  PRIMARY KEY (game_id, color)
);

What the difference teaches

Derive, don't store. The foil's games row holds the board (fen), whose turn it is, the result and when it was last updated. Each copy computed those from the moves it had. After two copies merge, neither was computed from the union, so the board on screen disagrees with the moves underneath it — and differently on each copy. The correct version stores only what a person did — a game, its moves, its resignations and draw offers — and replays the moves every time it draws.

SHARED-NO-DERIVED-STATE

Store facts, derive everything else

Store in a shared table only the facts people enter or acts they take — a receipt, a move, a mark. Never store anything computable from them: no board, no score, no turn, no total, no balance, no "last updated", no status that follows from other rows. Compute it from the rows each time it is drawn, preferably in SQL.

Why. A stored total is a second opinion about what the rows say, and after a merge it is wrong: each copy computed it from the rows it had, and neither computed it from the union.

Applies to passable, session · not checked by anything · SHARED-NO-DERIVED-STATE in Constraints

Append, don't change. The foil runs UPDATE games SET fen = … on every move. A shared table is append-only, so the runtime refuses it.

SHARED-WRITE-SURFACE

Write shared rows only through window.dai.replicated

Write to a replicated table only with window.dai.replicated.insert(table, values), .change(table, entity, values) and .remove(table, entity), after await window.dai.openDatabase(). values is an object of your own columns. change takes every one of your columns, not only the ones that changed. Never run INSERT, UPDATE or DELETE against a replicated table — not in JavaScript, not in a kit control, not in a seed block.

Why. Rows are appended and never changed in place: an UPDATE or DELETE is refused with REPLICATED_TABLE_IMMUTABLE, and a raw INSERT fails because it lacks the replication columns only the write surface fills in.

Applies to passable, session · refused at run time; checked by the lint (shared-raw-write) · SHARED-WRITE-SURFACE in Constraints

Local stays local. The foil keeps the selected square and the theme in the shared games table, so one player's tap selects a square on the other player's screen. The correct version has settings, ui_state and drafts, which never travel.

SHARED-LOCAL-STAYS-LOCAL

What belongs to one copy stays local

Keep in ordinary local tables everything about this copy rather than the document: settings, drafts, which item the screen is showing, what this person has hidden, the name this person goes by. Local tables are never merged, so they may use PRIMARY KEY, UNIQUE and CHECK freely. They travel only in a whole-document copy — a file, or the host menu's share — where a person opening it for the first time starts from the sender's local rows. An invite into one session carries none of them, and a copy that already exists keeps its own local rows when another copy's shared rows are merged into it.

Why. A setting in a shared table changes the other person's screen, and a draft in one is sent before it is finished.

Applies to passable, session · not checked by anything · SHARED-LOCAL-STAYS-LOCAL in Constraints

Let the conflict exist. The foil puts UNIQUE (game_id, ply) on moves, so two moves at the same turn — the very thing a merge exists to show — is an error instead of something the players see. The correct version allows both rows and asks which one stands.

SHARED-NO-UNIQUE-CHECK

No UNIQUE, no CHECK

A replicated table declares no UNIQUE and no CHECK constraint, on a column or on the table.

Why. A UNIQUE(game_id, ply) refuses exactly the rows a merge exists to surface: two people acting at the same point is a conflict to show a person, not an error to raise at them. A CHECK that differs between two versions of the application rejects the other copy's honest rows, and they arrive as rejected rows rather than a refused merge, so nobody can see what happened.

Applies to passable, session · checked by the lint (shared-table-constraint) · SHARED-NO-UNIQUE-CHECK in Constraints

The foil also reads the moves table itself instead of moves_current, never listens for dai:merged, and declares no session although it is a closed game of two. All of it traces back to the question it was never asked: Choose a shape.

Released under the MIT License. Dynamic Application Interface standard.