The Time Machine — the commit path
How a write to a campaign, map or character becomes a commit, as of v0.100.0 — the store, the serialiser, the one line on a route that records a write, restore as a forked timeline, and the guard that holds every write router to one of two sets.
This is the page for a contributor adding a write route ("does my route
need versioned(...), and what does it name?"), a module author reading
history through api.history, or an operator asking what the four tables
hold. The roadmap pages are the Time Machine
(decisions TM-D1–D4) and history core.
The model
Every campaign, map and character has a history. A token has none of its own: it is a contained row of its map, so a token move is a commit of the map — which is where Undo lives. Chat is not versioned: a message is its own record.
commits (id, entity_type, entity_id, timeline_id, parent_id,
author_user_id, at, reason, snapshot_hash, diff_summary, diff)
snapshots (hash, body, created_at) -- content-addressed canonical JSON
timelines (id, entity_type, entity_id, name, head_commit_id,
created_from_commit_id, current, created_at)
tags (id, entity_type, entity_id, name, commit_id, created_by, created_at)
A commit is a pointer to a snapshot plus its parent; the snapshot is the
whole entity (TM-D2: a snapshot per commit, deduplicated by hash, so a state
seen twice is stored once). Every entity has a main timeline; a restore
forks a new one (below). A tag is a name on a commit — what a character
"version" is (H.13).
Both dialects: pg migration 0038_time_machine.sql, sqlite boot DDL in
db.ts. Ids sort in the order they were made (a per-process sequence
before the random tail), so two commits in one millisecond list in order.
The serialiser — what "the entity" is
services/history/serialize.ts — serialize(type, id) is the canonical
JSON of the contained set: the main row and each contained table's rows,
keys sorted, undefined dropped, rows ordered by id. JSON columns keep
their stored order (a sheet's powers array is authored order; alphabetising
it would be a change of its own). writeSnapshot is the inverse: update
the main row, delete the contained rows in reverse load order, insert the
snapshot's.
| Entity | Contained (rolls back with it) |
|---|---|
| campaign | its row; members; its grants; every reference-data family (conditions, actions, functions, property definitions, bars, roll tables and entries, damage types, currencies, light-source and vision types, prop types, attribute groups, roles); factions, parties and their links; grants on its parties and factions |
| map | its row; layers; tokens; drawings; VBL and fog shapes; objects; its grants and its tokens' grants and impersonation permissions |
| character | its row (every named sub-collection is a JSON column of it); equipment; relationships on either side; character permissions; its grants |
Owner grants are excepted throughout — ownership is a column, not history. Maps and characters are linked to a campaign, not contained: a campaign's restore does not roll them back unless the caller cascades. A campaign–character link belongs to neither snapshot, so restoring either never unlinks the other.
Left out of the main row: deleted_at, deleted_by, updated_at. A
restore must never resurrect or re-delete — a delete is its own commit
(below) — and the update stamp is set by the write that restores. A
contained row keeps its deleted_at: that is how a restore un-deletes a
token, and the diff reads a stamp alone as "removed" / "restored".
The diff
services/history/diff.ts — diff(before, after) → { changes, summary }.
A change is { path, before, after }: row.<column> for the main row;
tables.<table>[<id>] for a contained row added or removed; tables.<table>[<id>].<column>
for one column of one. Rows are matched by id, never by position. Deep
values compare as canonical JSON and change as a whole.
The summary is the one line the commit list shows: the row's named columns
first (name, slug), then per table what happened — a row whose only
changed columns are x/y/rotation/z counts as moved, one whose only
changed columns are the soft-delete stamp as removed or restored:
"name, 2 tokens moved, 1 drawing added". Rendering the change list as YAML
is the UI's job (TM-D1).
commit and versioned(...)
services/history/commit.ts — the recipe as one function:
state before = the current timeline's head snapshot (or serialise, for the first commit)
run the mutation
state after = serialise
unchanged? → no commit (unless `always`, below)
store the snapshot (dedup by hash) → write the commit with its parent → advance the head
Per entity, commits are serialised through runExclusive, so two writes
racing on one map cannot both claim the same parent. There is no
transaction — no write in this codebase runs in one — so this runs around
the mutation rather than inside it; a crash between the two leaves a write
without a commit, and the next commit of the entity records both changes as
one. An entity that predates its history gets a baseline commit first
("history begins": its state before the write), so the first edit after
v0.100.0 is one Undo can reach.
On a route it is one line, the way the Gatekeeper is one line on a read:
router.patch("/:id", versioned(mapOfToken, "token updated"), async (req, res) => { … })
router.post("/", versioned(mapFromBody, "token added"), async (req, res) => { … })
router.post("/", versioned(fromResponseId("map"), "map created"), async (req, res) => { … })
router.delete("/:id", versioned(ofParam("map"), "map deleted", { always: true, summary: "deleted" }), …)
services/history/versioned.ts resolves the entity before the handler
runs (a token's map is looked up now, because after a delete the row is
gone), lets the handler run unchanged, and on a 2xx finish serialises the
state after and records the commit — the response never waits on it and
never fails for it. A create cannot name its entity before the handler; its
resolver answers null and is asked again with the response body.
services/history/resolvers.ts has the resolvers: ofParam(type, name),
fromResponseId(type), mapFromBody, parentOfRow(type, table, column, { name, from, key })
and the named ones (mapOfToken, characterOfEquipment, entityOfGrant —
a token's grant is its map's, a party's or faction's its campaign's).
{ always, summary } records a commit although the snapshot is unchanged:
a soft delete or an undelete changes only deleted_at, which the snapshot
leaves out, and the commit is the record that it happened.
After every commit, history:commit (the row, with restoreOf) is pushed
to the entity's audience — a map's campaign room, a character's viewstate
audience — so a client's history list and its Undo/Redo state stay current
without a refetch per write.
The API
routes/history.ts, at /api/history. TM-D4: anyone who can read the
entity sees its history and diffs; a restore is an edit of it, and of
every entity in the cascade. Blame is a username, never an email.
| Route | What |
|---|---|
GET /:type/:id?limit&before&timeline |
The commits newest first (each with authorName, restoreOf), the timelines, and current: { timelineId, headCommitId, stateCommitId } |
GET /:type/:id/diff?a&b |
The change list from a to b (a omitted: from nothing) |
GET /:type/:id/:commitId |
One commit, its snapshot, its stored change list |
POST /:type/:id/restore { commit, cascade?: [{ type, id, commit }] } |
Restore (below) |
GET /deleted?type= |
Every entity whose current head is a delete commit — the "deleted" query over commits (H.14) |
:type is campaign, map or character; a token's history is its map's.
Restore — a forked timeline
services/history/restore.ts — a restore to commit C writes C's snapshot
back, creates a new timeline from C, makes it the entity's current one,
and records the restore on it as commit R: snapshot C's (stored once),
parent C, reason restored to C. The old timeline keeps its head; nothing
is ever deleted. Restoring to the state the entity is already in writes
nothing.
Which commit is "the state": the head is the last commit; the state is
the head's snapshot — which, for R, is C's. The API says so as restoreOf
on such a commit and as current.stateCommitId. A commit is a restore
exactly when its parent is the commit its timeline was created from. The
client's Undo restores the parent of stateCommitId (restoring to C
again would be a no-op); Redo restores forward to the commit Undo left,
kept on a per-map stack (stores/historyRedoStore.ts) that any other
arriving commit clears — Redo after a new edit is impossible by construction,
as in git.
A cascade restores each named linked entity to its own commit through
its own restore. Every step is validated (existence, commit ownership,
edit on each) before the first is written; a failure mid-way is reported
with how many landed (no transaction — see above).
A restore announces the viewstate entity history (client: every family
under the entity is stale at once) plus the row's own entity, and for a
campaign every reference family.
Tags — character versions
services/history/tags.ts. POST /api/characters/:id/versions { tag }
names the current head (an entity with no commit yet gets one, "tagged
{ tag, commit } an older commit; { untag } removes;
POST …/versions/activate { tag } is a restore to the tagged commit — the
tag stays where it was. GET …/versions lists tags beside the v0.21.0
sibling rows, which the same routes keep serving for bodies without tag.
The guard
routes/viewstateCoverage.test.ts has a second column: every router with
write routes is in COMMITS (every write registration carries
versioned(...), save the named non-writes — an export, a roll, a
permission preview, a purge) or UNVERSIONED with a full-sentence
reason (chat, channels, the user's own settings, admin tools, the dead
deleted_items table…). Adding a write router means adding a line; adding
it to UNVERSIONED means writing down why. apps/web's
useMapHistory.mapCoverage.test.ts reads the same map routers from the
client's side — an edit that is not a commit is one Undo cannot see.
For a module or an action
Actions read history through api.history
(Action API 0.3): commits(ref?, { limit }) and diff(b, a?, ref?),
read-only, over the user's own access. Module server routers that write a
versioned entity should carry versioned(...) like the platform's; the
guard does not see module code, so the reason to do it is the user's Undo.