Examples
One complete application per shape, each with the decision that led to it stated first — in its own schema.sql, where the next person to change it will read it. These are the same applications the model file hands an assistant, copied from these directories by the build and held to them by a test.
Solo
Beach trip — a packing list for one family, on one phone. examples/packing-list
-- Beach trip · shape: solo
--
-- The decision: one family's packing list, kept on one phone. Nobody else
-- adds to it and no second copy's changes need keeping, so nothing is shared:
-- ordinary tables, written with the kit, keys and all.
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL,
what TEXT NOT NULL,
packed INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS trip (
id INTEGER PRIMARY KEY,
dates TEXT NOT NULL
);Written with the kit and no JavaScript; Your first app walks through it.
Passable
Receipts — two people in one household add the receipts they paid for and hand the document back and forth. examples/receipts
-- Receipts · shape: passable
--
-- The decision: two people in one household both add the receipts they paid
-- for, each on their own phone, and hand the document back and forth. Both
-- copies' receipts must be kept, so the receipts table is shared. Anybody who
-- holds a copy may add to it — there is no fixed group to admit — so this is
-- passable, not a session.
--
-- What that costs, and why:
-- * receipts is append-only. It is written through window.dai.replicated
-- and read through receipts_current, never the table itself.
-- * No PRIMARY KEY, UNIQUE or CHECK on it. A receipt's identity is its
-- entity; two people entering the same receipt is theirs to notice.
-- * No stored total, no balance, no "settled" flag. What each person paid
-- and who owes whom are computed from the rows every time they are drawn;
-- a stored total would be wrong after the first merge.
-- * Which name this copy's person goes by is about this copy, not the
-- document, so it lives in a local table.
-- dai:replicated
CREATE TABLE IF NOT EXISTS receipts (
spent_on TEXT NOT NULL, -- the date on the receipt, YYYY-MM-DD, as entered
store TEXT NOT NULL,
cents INTEGER NOT NULL, -- the amount, in cents
paid_by TEXT NOT NULL -- the name of whoever paid
);
-- Local: never merged. May use keys and checks freely.
CREATE TABLE IF NOT EXISTS me (
id INTEGER PRIMARY KEY CHECK (id = 1),
name TEXT NOT NULL DEFAULT ''
);app.js
// Receipts — a passable document. Shared rows are written through
// window.dai.replicated and read from receipts_current; totals are derived,
// never stored; a merge redraws; a conflict is shown, not decided silently.
const $ = (id) => document.getElementById(id);
let db; // opened by the start-up at the end of this file
const shared = window.dai.replicated;
/** The entity of the receipt being edited, or null when adding. */
let editing = null;
const rows = (sql, bind) => (bind === undefined ? db.selectObjects(sql) : db.selectObjects(sql, bind));
function myName() {
return rows("SELECT name FROM me WHERE id = 1")[0]?.name ?? "";
}
function money(cents) {
return (cents / 100).toLocaleString("en-US", { style: "currency", currency: "USD" });
}
function dayWords(ymd) {
const [y, m, d] = ymd.split("-").map(Number);
const day = new Date(y, m - 1, d);
const today = new Date();
today.setHours(0, 0, 0, 0);
const diff = Math.round((today - day) / 86400000);
if (diff === 0) return "Today";
if (diff === 1) return "Yesterday";
return day.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
}
function todayYmd() {
const now = new Date();
const pad = (n) => String(n).padStart(2, "0");
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
}
function say(text) {
$("notice").textContent = text;
$("notice").hidden = !text;
}
/** Runs a shared write and says what went wrong, rather than failing silently. */
function write(fn) {
try {
fn();
say("");
return true;
} catch (error) {
const message = String(error?.message ?? error);
say(
message.startsWith("WRITE_SURFACE_UNAVAILABLE")
? "Open this document in the DAI opener to add or change receipts."
: `That did not save: ${message}`,
);
return false;
}
}
// ---- drawing ------------------------------------------------------------
function drawBalance() {
// Derived from the rows every time. Nothing here is stored.
const people = rows(
"SELECT paid_by, sum(cents) AS paid FROM receipts_current GROUP BY paid_by ORDER BY paid DESC",
);
const box = $("balance");
box.replaceChildren();
if (people.length === 0) return;
const total = people.reduce((sum, p) => sum + p.paid, 0);
const share = total / people.length;
const headline = document.createElement("p");
headline.className = "total";
headline.textContent = `${money(total)} spent`;
box.append(headline);
for (const person of people) {
const line = document.createElement("p");
const over = person.paid - share;
line.textContent =
people.length === 1
? `${person.paid_by} paid all of it`
: `${person.paid_by} paid ${money(person.paid)} · ${
Math.abs(over) < 1 ? "even" : over > 0 ? `is owed ${money(over)}` : `owes ${money(-over)}`
}`;
box.append(line);
}
}
function drawList() {
const list = $("list");
list.replaceChildren();
const receipts = rows(
`SELECT lower(hex(_r_entity)) AS entity, spent_on, store, cents, paid_by, _r_conflicted AS conflicted
FROM receipts_current
ORDER BY spent_on DESC, store`,
);
$("empty").hidden = receipts.length > 0;
for (const r of receipts) {
const item = document.createElement("li");
item.className = r.conflicted ? "receipt conflicted" : "receipt";
const main = document.createElement("div");
main.className = "what";
const store = document.createElement("strong");
store.textContent = r.store;
const meta = document.createElement("span");
meta.textContent = `${dayWords(r.spent_on)} · ${r.paid_by}`;
main.append(store, meta);
const amount = document.createElement("span");
amount.className = "amount";
amount.textContent = money(r.cents);
const actions = document.createElement("div");
actions.className = "row-actions";
if (r.conflicted) {
const choose = button("Changed twice — choose", () => openConflict(r.entity));
choose.className = "warn";
actions.append(choose);
}
actions.append(
button("Edit", () => startEdit(r)),
button("Delete", () => write(() => shared.remove("receipts", r.entity)) && draw()),
);
item.append(main, amount, actions);
list.append(item);
}
}
function button(label, onClick) {
const b = document.createElement("button");
b.type = "button";
b.className = "quiet";
b.textContent = label;
b.addEventListener("click", onClick);
return b;
}
function draw() {
$("me").value = myName();
if (!editing && !$("paid-by").value) $("paid-by").value = myName();
drawBalance();
drawList();
}
// ---- adding and editing -------------------------------------------------
function readForm() {
const amount = Number.parseFloat($("amount").value.replace(/[^0-9.]/g, ""));
if (!Number.isFinite(amount) || amount <= 0) {
say("Enter the amount as a number, like 12.50.");
return null;
}
return {
spent_on: $("spent-on").value,
store: $("store").value.trim(),
cents: Math.round(amount * 100),
paid_by: $("paid-by").value.trim(),
};
}
function resetForm() {
editing = null;
$("entry").reset();
$("spent-on").value = todayYmd();
$("paid-by").value = myName();
$("entry-title").textContent = "Add a receipt";
$("save-entry").textContent = "Add";
$("cancel-edit").hidden = true;
}
function startEdit(r) {
editing = r.entity;
$("spent-on").value = r.spent_on;
$("store").value = r.store;
$("amount").value = (r.cents / 100).toFixed(2);
$("paid-by").value = r.paid_by;
$("entry-title").textContent = "Edit receipt";
$("save-entry").textContent = "Save changes";
$("cancel-edit").hidden = false;
$("store").focus();
}
$("entry").addEventListener("submit", (event) => {
event.preventDefault();
const values = readForm();
if (!values) return;
// change() takes every column, not only the ones that changed.
const ok = write(() =>
editing ? shared.change("receipts", editing, values) : shared.insert("receipts", values),
);
if (ok) {
resetForm();
draw();
}
});
$("cancel-edit").addEventListener("click", () => resetForm());
$("me").addEventListener("change", () => {
// Local table: an ordinary UPDATE is right here.
db.exec({ sql: "UPDATE me SET name = ? WHERE id = 1", bind: [$("me").value.trim()] });
if (!editing) $("paid-by").value = myName();
});
$("share").addEventListener("click", () => window.dai.requestShare());
// ---- conflicts ----------------------------------------------------------
function openConflict(entity) {
// The competing versions are the current heads of this entity. A tombstone
// among them means the other copy deleted it while this one edited it.
const versions = rows(
`SELECT spent_on, store, cents, paid_by, _r_deleted AS deleted
FROM receipts_heads WHERE lower(hex(_r_entity)) = ?
ORDER BY _r_lc DESC`,
[entity],
);
const list = $("versions");
list.replaceChildren();
for (const v of versions) {
const item = document.createElement("li");
const text = document.createElement("span");
text.textContent = v.deleted
? "Deleted on one copy"
: `${v.store} · ${money(v.cents)} · ${dayWords(v.spent_on)} · ${v.paid_by}`;
const keep = button(v.deleted ? "Delete it" : "Keep this", () => {
// Writing the choice resolves the conflict: a change or a remove names
// every current version as its parent.
const ok = write(() =>
v.deleted
? shared.remove("receipts", entity)
: shared.change("receipts", entity, {
spent_on: v.spent_on,
store: v.store,
cents: v.cents,
paid_by: v.paid_by,
}),
);
if (ok) {
$("conflict").close();
draw();
}
});
item.append(text, keep);
list.append(item);
}
$("conflict").showModal();
}
$("conflict-close").addEventListener("click", () => $("conflict").close());
// ---- the other copy's rows ----------------------------------------------
// Nothing else says the other person's receipts arrived.
window.addEventListener("dai:merged", () => draw());
// Start-up (NO-INPUT-LOST-WHILE-OPENING): nothing can be typed into until this
// has finished, and if it fails the person is told, not left at "Opening…".
try {
db = await window.dai.openDatabase();
resetForm();
draw();
$("opening").hidden = true;
$("app").hidden = false;
$("app").inert = false;
} catch (error) {
$("opening").classList.add("failed");
$("opening").textContent =
`These receipts could not be opened: ${error?.message ?? error}. ` +
"Try opening the document again in the DAI opener, or ask whoever sent it for a new copy.";
}index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Receipts two people share, and who owes whom" />
<meta name="dai:does" content="Add a receipt the moment you pay, on your own phone" />
<meta name="dai:does" content="Pass it back and forth and every receipt from both of you is kept" />
<meta name="dai:does" content="Shows who has paid more, and by how much" />
<meta name="theme-color" content="#f6f3ec" />
<title>Receipts</title>
<link rel="stylesheet" href="./app.css" />
</head>
<body>
<script type="application/sql">
-- Local seed only. Shared rows are never seeded with SQL.
INSERT OR IGNORE INTO me (id) VALUES (1);
</script>
<!-- Nothing to type into until app.js has started: a form on screen before
then takes typing the app cannot handle yet, and loses it. -->
<p id="opening" class="opening" role="status">Opening…</p>
<main id="app" hidden inert>
<header>
<h1>Receipts</h1>
<label class="me">
<span>I am</span>
<input id="me" autocomplete="off" maxlength="30" placeholder="Your name" />
</label>
</header>
<p id="notice" class="notice" role="status" hidden></p>
<section id="balance" class="balance" aria-live="polite"></section>
<form id="entry" class="entry" autocomplete="off">
<h2 id="entry-title">Add a receipt</h2>
<div class="fields">
<label>Date <input id="spent-on" type="date" required /></label>
<label>Store <input id="store" required maxlength="60" placeholder="Where" /></label>
<label>Amount <input id="amount" required inputmode="decimal" placeholder="0.00" /></label>
<label>Paid by <input id="paid-by" required maxlength="30" /></label>
</div>
<div class="actions">
<button id="save-entry" type="submit">Add</button>
<button id="cancel-edit" type="button" class="quiet" hidden>Cancel</button>
</div>
</form>
<section>
<div class="list-head">
<h2>All receipts</h2>
<button id="share" type="button" class="quiet">Share</button>
</div>
<ul id="list" class="list"></ul>
<p id="empty" class="empty" hidden>No receipts yet. Add the first one above, then share this with whoever you split costs with.</p>
</section>
<dialog id="conflict">
<h2>Changed on two copies</h2>
<p>This receipt was edited on both copies before they met. Keep the version that is right.</p>
<ul id="versions" class="versions"></ul>
<button id="conflict-close" type="button" class="quiet">Decide later</button>
</dialog>
</main>
<script type="module" src="./app.js"></script>
</body>
</html>What to look for: every shared write goes through window.dai.replicated; every read is from receipts_current; who owes whom is computed in SQL on every draw; dai:merged redraws; and a receipt edited on both copies is shown with a choice (Show a conflict).
Session
Tic-tac-toe — two people play by sending the document back and forth. examples/tic-tac-toe
-- Tic-tac-toe by message · shape: session
--
-- The decision: two people play one game, each on their own phone, sending
-- the document back and forth. Both copies' marks must be kept, so the tables
-- are shared. And it is a closed group of two — a copy forwarded to a third
-- person must not let them play — so it is a session, not merely passable.
--
-- What that costs, and why:
-- * Every game is its own session. The creator plays X; whoever opens the
-- invite takes the open seat and plays O. Who is X and who is O is read
-- from the seats, never stored.
-- * No board, no turn, no winner. All of it is derived by replaying marks in
-- turn order. Two marks at one turn (the same side moved on two copies) is
-- shown to the players to settle, never decided silently.
-- * No UNIQUE(game_id, turn): it would refuse exactly the rows that make a
-- collision visible.
-- * Which game is showing is about this copy, so it is local.
-- dai:profile session max_parties=2 close=any
-- dai:replicated
CREATE TABLE IF NOT EXISTS games (
x_name TEXT NOT NULL,
o_name TEXT NOT NULL
);
-- dai:replicated
CREATE TABLE IF NOT EXISTS marks (
game_id TEXT NOT NULL, -- the games row's entity, as hex
turn INTEGER NOT NULL, -- 1-based; X plays odd turns, O even
cell INTEGER NOT NULL -- 0..8, left to right, top to bottom
);
-- Local: never merged.
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
active_game TEXT
);Your first two-player app walks through it, with the whole source.
The larger session application in the repository is chess, tests/fixture/chess: three shared tables, a board derived by replaying the moves through an engine, and draw offers, claims and resignations as rows.
Broadcast
No example yet. A broadcast application is built exactly as a solo one today (SHAPE-BROADCAST-CONVENTION), and an example will be added when there is a real one rather than an invented one.
The mistake, beside the fix
Why nothing derived is stored puts a deliberately wrong chess application beside the correct one. The difference between them is four constraints at once.
Both shared examples are tested
tests/examples-shared.spec.ts compiles the receipts and tic-tac-toe applications, opens each on separate devices in the real host, and drives them by clicking, with the rows travelling by file — including the conflict, the invite, a forwarded copy that was never invited, and a seat two people took.