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:
-- 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:
-- 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.