~14 min · change-data-capture, triggers, optimistic-concurrency, migrations, retention
Level 0Loose Note
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
The Event Log That Was Not Enough
Coop's ledger already had a history table before the engine arrived: coop_events, one row per lifecycle transition. Taken, done, released. It reads like an audit trail, and for lifecycles it is one. But a reply added to a thread, a history entry edited in place, a learning entry rewritten: the domain records no event for any of them, so none of them lands there. Even the one edit it does log, the edit event from edit_todo, names which fields changed and never their new values. Anything that wants to mirror the ledger, a workspace view, Almanac's projection, a future replica, needs every mutation, not just the dramatic ones. The architecture says it flatly: the old event table cannot alone power the new projection feed.
One Trigger per Table per Operation
So the engine installs a second family of triggers next to the fences from lesson 1. For each of the nineteen tracked tables and each of INSERT, UPDATE and DELETE there is an AFTER trigger named coop_track_<table>_<op>. When it fires, it does two things:
It appends a row to engine_changes: the table, the row id as text, the operation, the whole new row as json_object(...) of every column (NULL for a delete), the row's next revision number, and the actor, session and causation, read from coop_context(), another function that exists only on the engine's connections.
It upserts engine_revisions, raising that row's revision by one.
Because these are triggers, they run inside the same transaction as the write that fired them. A change is in the feed if and only if the write committed: no second system to forget, no gap between "saved" and "announced". For any write a caller asked for, the causation is the command key from the last lesson, so that feed row can name the request that caused it. The engine's own housekeeping is the exception: rows that maintain() changes carry the causation maintain, with no command receipt behind them.
A Trigger Is a Photograph of Its Columns
Here is the subtle part. The json_object(...) inside a trigger lists the table's columns as they were when the trigger was created. The engine adds columns of its own at start-up (since then it has grown session activity fields, an assignment's host, a reasoning effort), and each such migration would leave the old trigger body photographing the old table: the new field changes in the database and silently never reaches the feed.
The hardening commit 2512cf9 (2026-09-29) closed it. At every engine start, _triggers recomputes each body from PRAGMA table_info; if the stored trigger SQL no longer contains that exact body, it drops the trigger and creates it again. The test adds a column called later_field through a plain connection, reopens the engine, updates the row, and checks the feed carries later_field. The code comment says why in one line: so a migration can never silently drop a field from the change feed.
Revisions Are the Expected-Revision Check
The revision counter is not decoration. It is how the engine knows work did not change under a worker's feet. Preparing a dispatch refuses unless each item is open and at the revision the caller saw (Work changed; refresh its revision). Acceptance checks again (Work changed before acceptance) and stores the post-take revision on the binding. A report refuses if the row has moved since (Accepted work revision changed; reconcile before reporting). Because the counter lives in a trigger, every write path bumps it, including ones written by someone who never thought about revisions. The one deliberate exception is Dad's ruling: an edit_todo or a reply on bound work moves the binding to the new revision, so the worker's later report still matches the amended contract.
Reading the Feed, and Its Honest Limits
A reader asks changes(after=cursor) for a page of at most a thousand rows. Each store carries an epoch, a random id minted once, and every page returns it. A reader that sends the epoch back with its cursor cannot have that cursor quietly applied to another store's numbering, because a wrong epoch fails. The check is opt-in, though: a request that sends no epoch is not checked at all. A cursor past the end, or a nonzero cursor older than what is retained, fails too, all with the same instruction: obtain a snapshot. The one cursor never refused is after=0, which means "from the beginning". Once pruning has removed the oldest rows, it quietly starts at the oldest one left. A snapshot returns every tracked table, the revisions and a watermark from one read transaction, and the reader resumes from that watermark.
Retention is thirty days. maintain() prunes older feed rows, and its docstring is candid: the change feed is one which no consumer reads yet. It is built, served and tested, and nothing subscribes to it today. That is fine. The canonical rows are never pruned, and a reader that falls behind holding a real cursor is told so explicitly instead of being handed a feed with a hole in it. So a new reader starts from a snapshot's watermark, never from zero.
Capture changes where the write happens, in the write's own transaction. Then the feed cannot disagree with the store, and every migration has one more thing to keep honest: the photograph of the columns.
Code
Coop's engine: the feed triggers and the feed reader (trimmed excerpt)·python
# Coop's engine: the feed triggers and the feed reader (trimmed excerpt)
def _triggers(self, conn, fence):
# All legacy mutations, including edits without a lifecycle event, enter the feed.
# A trigger body is a snapshot of the table's columns when it was
# created; one that no longer lists every column is rebuilt so a
# migration can never silently drop a field from the change feed.
conn.execute('BEGIN IMMEDIATE')
for table in TRACKED:
columns = [r['name'] for r in conn.execute(f'PRAGMA table_info({table})')]
for op in ('INSERT', 'UPDATE', 'DELETE'):
name = f'coop_track_{table}_{op.lower()}'
ref = 'OLD' if op == 'DELETE' else 'NEW'
body = 'NULL' if op == 'DELETE' else 'json_object(' + ','.join(f"'{c}',{ref}.{c}" for c in columns) + ')'
existing = conn.execute("SELECT sql FROM sqlite_master WHERE type='trigger' AND name=?", (name,)).fetchone()
if existing and op != 'DELETE' and body not in existing['sql']:
conn.execute(f'DROP TRIGGER {name}')
conn.execute(f'''CREATE TRIGGER IF NOT EXISTS {name} AFTER {op} ON {table} BEGIN
INSERT INTO engine_changes(entity,row_id,operation,body,revision,actor,session,causation,created)
VALUES ('{table}',CAST({ref}.id AS TEXT),'{op.lower()}',{body},COALESCE((SELECT revision FROM engine_revisions WHERE entity='{table}' AND row_id=CAST({ref}.id AS TEXT)),0)+1,coop_context('id'),coop_context('session'),coop_context('causation'),unixepoch('subsec'));
INSERT INTO engine_revisions VALUES ('{table}',CAST({ref}.id AS TEXT),1)
ON CONFLICT(entity,row_id) DO UPDATE SET revision=revision+1;
END''')
... # the coop_fence_* BEFORE trigger from lesson 1
conn.commit()
def changes(self, after=0, limit=500, epoch=None):
if after < 0 or not 1 <= limit <= 1000 or epoch not in (None, self.epoch):
raise Conflict('Invalid cursor or store epoch; obtain a snapshot')
...
if after > maximum:
raise Conflict('Cursor is beyond this store; obtain a snapshot')
if minimum and after and after < minimum - 1:
raise Conflict('Cursor precedes the retained changes; obtain a snapshot')
return {'epoch': self.epoch, 'changes': rows, 'cursor': rows[-1]['id'] if rows else after, 'watermark': maximum}
A minimal sketch you can run: a feed, a revision check, and a stale trigger rebuilt·python
"""A minimal sketch of Coop's change feed: AFTER triggers copy every mutation
into a feed with a per-row revision, and a trigger whose column snapshot has
gone stale is rebuilt."""
import json
import sqlite3
conn = sqlite3.connect(":memory:", isolation_level=None)
conn.executescript("""
CREATE TABLE todos (id INTEGER PRIMARY KEY, title TEXT, status TEXT DEFAULT 'open');
CREATE TABLE changes (id INTEGER PRIMARY KEY AUTOINCREMENT, entity TEXT, row_id TEXT,
operation TEXT, body TEXT, revision INTEGER);
CREATE TABLE revisions (entity TEXT, row_id TEXT, revision INTEGER, PRIMARY KEY(entity, row_id));
""")
def install_feed(table):
columns = [row[1] for row in conn.execute(f"PRAGMA table_info({table})")]
for op in ("INSERT", "UPDATE", "DELETE"):
name = f"track_{table}_{op.lower()}"
ref = "OLD" if op == "DELETE" else "NEW"
body = "NULL" if op == "DELETE" else "json_object(" + ",".join(f"'{c}',{ref}.{c}" for c in columns) + ")"
existing = conn.execute("SELECT sql FROM sqlite_master WHERE type='trigger' AND name=?", (name,)).fetchone()
if existing and op != "DELETE" and body not in existing[0]:
conn.execute(f"DROP TRIGGER {name}") # its column snapshot is stale
conn.execute(f"""CREATE TRIGGER IF NOT EXISTS {name} AFTER {op} ON {table} BEGIN
INSERT INTO changes(entity, row_id, operation, body, revision)
VALUES ('{table}', CAST({ref}.id AS TEXT), '{op.lower()}', {body},
COALESCE((SELECT revision FROM revisions
WHERE entity='{table}' AND row_id=CAST({ref}.id AS TEXT)), 0) + 1);
INSERT INTO revisions VALUES ('{table}', CAST({ref}.id AS TEXT), 1)
ON CONFLICT(entity, row_id) DO UPDATE SET revision = revision + 1;
END""")
def revision(row_id):
row = conn.execute("SELECT revision FROM revisions WHERE entity='todos' AND row_id=?", (str(row_id),)).fetchone()
return row[0] if row else 0
def last_body():
return json.loads(conn.execute("SELECT body FROM changes ORDER BY id DESC LIMIT 1").fetchone()[0])
install_feed("todos")
todo = conn.execute("INSERT INTO todos(title) VALUES ('Write the lesson')").lastrowid
accepted = revision(todo) # a worker accepts the work at this revision
conn.execute("UPDATE todos SET title='Write the lesson in both locales' WHERE id=?", (todo,))
print("accepted at", accepted, "| now", revision(todo), "| report",
"refused: reconcile first" if revision(todo) != accepted else "accepted")
conn.execute("ALTER TABLE todos ADD COLUMN note TEXT") # a migration adds a field
conn.execute("UPDATE todos SET note='first' WHERE id=?", (todo,))
print("stale trigger :", last_body())
install_feed("todos") # the engine re-runs this at every start
conn.execute("UPDATE todos SET note='second' WHERE id=?", (todo,))
print("rebuilt trigger:", last_body())
print("feed:", conn.execute("SELECT id, operation, revision FROM changes").fetchall())
# Expected output:
# accepted at 1 | now 2 | report refused: reconcile first
# stale trigger : {'id': 1, 'title': 'Write the lesson in both locales', 'status': 'open'}
# rebuilt trigger: {'id': 1, 'title': 'Write the lesson in both locales', 'status': 'open', 'note': 'second'}
# feed: [(1, 'insert', 1), (2, 'update', 2), (3, 'update', 3), (4, 'update', 4)]
Run the sketch, then break it on purpose: comment out the second install_feed('todos') call and rerun. Note which feed row lost the note field. Then add a delete of the row and check what the feed records for it. Finally, write the one-line check you would run in CI to catch a stale capture trigger in your own database.
Hint
Without the rebuild, the last update still bumps the revision while its body omits note: the feed looks healthy and is wrong. For the CI check, compare each trigger's SQL with the expected json_object(...) built from PRAGMA table_info, exactly as the engine does at start-up.
Progress
Progress is local-only — sign in to sync across devices.