Skip to content

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.