v0.100.0 — Time Machine: history core
Shipped 2026-09-20 — merged to main as 6076b988, tagged v0.100.0.
Built on version/0.100.0, seventeen commits. The tenth of the releases
the near-term roadmap sequences from the August 2026
heuristic evaluation; the roadmap page is
time-machine-history-core, part
one of the Time Machine. The developer page is
the commit path.
Campaigns, maps and characters have histories like git commits now: every write is a commit — a content-addressed snapshot of the whole entity with a parent pointer, on a timeline — and a restore to an older commit forks a new timeline, so nothing is ever lost. Everything here is server-side and invisible except through the API and one change on the map: Undo and Redo are restores of the map's previous and next commit, so they survive a reload, work for every kind of map edit, and are the same for every client. Decisions TM-D1 (canonical JSON stored, YAML shown), TM-D2 (a snapshot per commit), TM-D3 (a drag coalesces at the PATCH) and TM-D4 (anyone who can read the entity sees its history; restore needs Edit; blame is a username) at their recommendations.
The store
- Canonical serialisation (H.1) —
serialize(type, id)is the canonical JSON of the entity's contained set: its row and each contained table's rows, keys sorted, rows by id. A campaign contains its members, grants and every reference-data family, its factions and parties and their links; a map its layers, tokens, drawings, shapes, objects and (H.9) its grants; a character its equipment, relationships, permissions and grants. A token has no history of its own — its changes are its map's, which is where Undo lives.writeSnapshotis the inverse. JSON columns keep their stored order (a sheet's powers are authored order; alphabetising would be a change of its own) — the one deviation from the page. - Schema (H.2) —
commits,snapshots,timelines,tags, both dialects (pg0038_time_machine.sql, sqlite boot DDL). - The diff (H.4) — a change list by path (
row.name,tables.tokens[<id>].x) with rows matched by id, never by position, and the one-line summary the commit list shows:"name, 2 tokens moved"— a row whose only changed columns are its position counts as moved; one whose only change is the soft-delete stamp as removed or restored. commit()andversioned(...)(H.3) — the recipe as one function (state before from the timeline's head, mutate, state after, skip if unchanged, store, write the commit, advance the head; serialised per entity), and as one line on a write route's registration, the way the Gatekeeper is one line on a read's:versioned(mapOfToken, "token updated"). The entity is resolved before the handler (a token's map is looked up now, because after a delete the row is gone), the commit recorded after a 2xx — the response never waits on it and never fails for it. No transaction: none of the routes have one, so it runs around the mutation, and that is written down.
Every write is a commit
- Maps (H.5) — maps, layers, drawings, VBL and fog shapes, objects.
- Tokens (H.6) — all twenty write registrations; a drop is one PATCH and so one commit; a bulk-move of a selection one commit that counts every token it moved.
- Characters (H.7) — the character's routes, the loop-registered sub-collections ("power added"), equipment, relationships, sharing (an invite row alone is not in the snapshot and records nothing; its acceptance is "1 permission added").
- Campaigns and their reference data (H.8) — campaigns, membership, the
eleven
/api/campaigns/:id/…reference routers, roles, all four faction routers, and the top-level conditions and parties routers (a party with no campaign has no history). - Grants (H.9) — contained rows of their entity, Owner excepted: a token's grant is its map's, a party's or faction's its campaign's; a channel's has no history.
- Chat is not versioned (H.10) — a message is its own record; said at
the top of
chatMessages.tsand held by the guard. - The guard (H.11) —
viewstateCoverage.test.tshas a second column: every router with write routes is in COMMITS (every write registration carriesversioned(...), save the named reads-wearing-a-POST and purges) or UNVERSIONED with a full-sentence reason. It found two writes (token impersonation permissions) that are now commits. - Soft delete is a commit (H.14) — a delete records "deleted" although
the snapshot leaves the stamp out (a restore never resurrects or
re-deletes), an undelete "undeleted";
GET /api/history/deletedis the "deleted" query over commits. Found:deleted_itemshas been a dead table since soft delete replaced hard delete — the live bin issoftDeleted.ts.
The API, restore, tags
/api/history(H.12) —GET /:type/:id(commits newest first, blame by username,restoreOf, the timelines,current.stateCommitId),/diff?a&b,/:commitId(the snapshot and its change list),POST /restore { commit, cascade? }— reads gatekeptread, restoreediton the entity and on every cascaded one. A restore writes the target's snapshot back, forks a timeline from it, and records commit R (the target's snapshot, parent the target); restoring to the state the entity is already in writes nothing. A cascade is validated whole before the first write. A restore announces a new viewstate entity,history: every family under the entity is stale at once.- Character versions become tags (H.13) —
POST /:id/versions { tag }names the current state,{ untag }removes,/activate { tag }is a restore;GETlists the tags beside the v0.21.0 sibling rows, which the same routes keep serving for bodies withouttag, so the versions tests pass unchanged. Tags get their UI with the Time Machine UI release.
Undo and Redo
undoStore is retired (H.15). useMapHistory reads the map's history and
is kept current by a new history:commit socket push (the commit row, to
the entity's audience, after every commit). Undo restores the parent of
the commit the map is at; Redo restores forward to the commit Undo
left, on a per-map stack that any other arriving commit clears — Redo after
a new edit is impossible by construction, as in git. The palette's controls
and ⌘Z / ⌘⇧Z drive that; no client surface records anything any more.
Found live: the first edit of a map that predates its history had no parent
to undo to, so the first commit of such an entity is preceded by a
baseline ("history begins"). Commit ids carry a per-process sequence so
two commits in one millisecond list in order.
For actions
Action API 0.3 (H.16): api.history.commits(ref?, { limit }) and
api.history.diff(b, a?, ref?) — read-only access to the Time Machine, for
an action that wants to show what changed. Restore is deliberately not
there: it is an edit the user makes, with their name on it. Reference pages
under the Action API.
Guards and verification
Ten route-harness test files under services/history/ through the real
routers (maps, tokens, characters, campaigns, grants, the history API,
tags, soft delete), the serialiser's round-trip, the diff, the commit
recipe; the guard's second column; the client's map-coverage test rewritten
against the routers. Verified live in Chrome against the dev server: a
token PATCH one commit "1 token moved", Undo moving it back and Redo
forward with the buttons' enabled state following the pushes, and the
baseline appearing under a map's first edit.
Decisions and findings
TM-D1–TM-D4 at their recommendations. Made here: JSON sub-collections keep
stored order; a token is contained by its map; deleted_at is left out of
the main row and kept on contained rows; a restore forks rather than moving
the head; the v0.21.0 sibling-row versions stay behind the same routes. Two
findings for later: deleted_items and its panel are dead code, and the
history has no transaction because the routes never had one.