Diagram version history
Apollon Standalone gives every diagram a Figma-shaped version history: named versions the user creates as deliberate milestones, plus autosaves the server places automatically at a regular cadence so the user can recover from "I lost an hour ago" mistakes without naming anything.
What ships
Triggers
- Manual: "Save version" in the right-anchored drawer (or ⌘⇧S / Ctrl+Shift+S). Composer is a single freeform area — line 1 becomes the name, lines 2+ the description.
- Automatic (every 30 min): the HEAD PUT path attempts a per-diagram
Redis
SET NX EX 1800ondiagram:{id}:auto-version-marker. On acquire, the server diffs HEAD against the latest snapshot's body viastructuralFingerprint— JSON over a fixed projection (title/type/version/nodes/edges/assessments) with React-Flow's transient/layout/capability fields stripped. The same projection runs client-side inVersionDrawer.tsx(theVOLATILE_KEYSset), pinned by theselection-only churnintegration test. If the fingerprints differ it commits an empty-namekind: "auto"row; if they match the marker stays for the full 30 min so the next 5-second autosave PUT doesn't re-evaluate. - Pre-restore safety: Restore captures the user's pre-restore canvas
as a
kind: "auto"row whosenameis auto-generated (Before restoring 'X'). The non-empty name protects it from eviction priority — see below.
Display rule (named ≠ kind)
Versions live in the same right-rail sidebar regardless of kind. The
visual split is name/description emptiness:
- Named row (
nameordescriptionnon-empty) — full-fat row with thumbnail and meta. Always a milestone, never collapsed. - Unnamed row (both empty) — eligible for collapse. Contiguous unnamed rows fold under an "N auto-saved versions" expander between named milestones, exactly the way Figma does it.
The "Name this version" menu action on an unnamed row is pure metadata:
it sets name (and optionally description) via PATCH, and the row
visually promotes itself out of the collapsed group on the next render.
There is no "kind upgrade," no VERSION_PROMOTED envelope, no
collaborator race. kind records origin only.
Filter toggle
A "Show autosave versions" icon (mirrors Figma's filter) at the top of the sidebar hides every unnamed row when off. Default ON. Implemented purely client-side; the server still keeps the rows.
Eviction priority
The 50-version FIFO cap is single-bucket but eviction picks victims by name-emptiness before age:
need = ZCARD(versionsIndex) - MAX_VERSIONS
pass 1: drop oldest unnamed-meta rows (`name`='' AND `description`='')
pass 2: only if pass 1 was insufficient, drop oldest named rows
Implemented in the Lua function library apollon (evict_with_priority
in standalone/server/src/redis.ts). Both commit_snapshot and
restore_version call it.
This means: a chatty 30-min auto cadence cannot silently displace the user's deliberately-named milestones. Pre-restore rows are protected by their auto-generated name. Truly unnamed autosaves are recovery infrastructure and disposable.
Dirty detection
Save-button enablement uses a structural fingerprint of editor.model,
not the Yjs state vector. The fingerprint is JSON.stringify over a
{ nodes, edges, assessments, title, type } projection with a replacer
that drops volatile fields:
selected, dragging, resizing, hidden, measured,
selectable, draggable, connectable, deletable
Why not the SV directly? React-Flow writes layout noise to the same
Y.Maps as user content — clicking a node fires a dimensions change
that re-stamps measured on the node and bumps the SV. The SV answers
"has anything in the doc been observed" but the question we want is
"has the user actually changed something." Filtering volatile fields out
of a fingerprint is the right semantic — same logic the server uses in
tryAutoVersion's structuralFingerprint.
The drawer captures the fingerprint when the latest saved version's id
changes (after a manual save, server-fired auto-version, or
collaborator's save), then on every subscribeToModelChange event
recomputes and compares. While previewing (useVersionStore.preview !== null) the gate is forced disabled — saving the previewed body would
just duplicate.
Restore (collab)
Restore is currently destructive: HEAD is overwritten and a 10-second
"Undo" snackbar surfaces the pre-restore snapshot. The Y.Doc on every
client is hard-reset by replacing editor.model. The library's preview
mode (editor.setReadonly(true)) handles "view this version" — it's
the existing analogue to Figma's non-destructive restore.
Thumbnails (client-side)
Per-version thumbnails are rendered in the browser by feeding the
snapshot's JSON body through ApollonEditor.exportModelAsSvg. Lazy-
loaded via IntersectionObserver, serialized through a single-flight
queue (each render mounts a temporary 4000×4000 div in the DOM, so we
don't stack them), cached forever in a module-level Map keyed by
[diagramId, versionId]. Snapshots are immutable so the cache never
needs to invalidate.
This explicitly is not a server endpoint. Booting JSDOM + the full
library bundle on the server for a 64×40 thumbnail is the wrong
tradeoff: it adds cold-start latency, doesn't scale with diagram count,
and doesn't work in the no-server deployment. The export endpoints
(POST /converter/{svg,png,pdf} → conversion-worker-thread) still use
ConversionService.convertToSvg server-side — that's a separate
high-quality export use case, not a per-row preview.
Storage model
Per diagram, at most 50 versions with the eviction priority above. Bodies are gzipped before storage.
| Key | Type | TTL |
|---|---|---|
diagram:{<id>} | RedisJSON | 120 d sliding |
diagram:{<id>}:meta | HASH | sliding parity |
diagram:{<id>}:versions | ZSET (score=ms, member=ULID) | 121 d on touch |
diagram:{<id>}:version:{<vid>} | gzipped STRING | 121 d at creation |
diagram:{<id>}:version:{<vid>}:meta | HASH | 121 d at creation |
diagram:{<id>}:auto-version-marker | STRING (1) | 1800 s (interval) |
The Lua function library apollon is loaded at boot via
FUNCTION LOAD REPLACE and exposes:
commit_snapshot— write a snapshot, FIFO-prune viaevict_with_priority.restore_version— atomic auto-snapshot + HEAD swap; same eviction priority.list_versions_before— cursor pagination stable across same-ms ties.
Configuration
| Env | Default | Meaning |
|---|---|---|
MAX_VERSIONS_PER_DIAGRAM | 50 | Single-bucket cap. |
AUTO_VERSION_INTERVAL_SECONDS | 1800 (30 min) | Marker TTL = trigger gap. |
MAX_NAME_LENGTH | 80 | Server-side validation. |
MAX_DESCRIPTION_LENGTH | 240 | Server-side validation. |
VERSION_TTL_SECONDS | 121 d | Snapshot retention. |
DIAGRAM_TTL_SECONDS | 120 d | HEAD retention. |
Consistency contract
-
HEAD writes are last-write-wins. Autosave PUT sends an
If-Match: <lastObservedHeadRev>advisory header. On409 REVISION_MISMATCH, the client refetches HEAD, re-applies its in-memory model on top, and retries — local model wins. Surfaced briefly to the user as "Synced changes from another collaborator." -
Auto-versions are a fire-and-forget side effect of HEAD PUT. They never block the response. Failures are logged and observable but don't surface to the user — HEAD already persisted. The
tryAutoVersionhelper instandalone/server/src/services/autoVersion.tsowns the lifecycle: marker acquire → fingerprint compare → commit + relay. -
Snapshots capture the user's canvas, not Redis HEAD.
POST /versionsaccepts thebodyinline; the server flushes-then-snapshots atomically. -
Restore captures the restoring user's canvas as
kind="auto"with a generated name first, then overwrites HEAD. Other collaborators refetch HEAD via the WS-driven control envelope. Local in-flight Yjs edits on collaborators' canvases are dropped — the auto-snapshot is the documented recovery path. -
All clients including the sender receive
VERSION_*envelopes (single convergence path; clients dedupe byversionId). -
Schema versioning: every snapshot stamps
librarySchemaVersion. On preview/restore, the client routes the body throughimportDiagramfrom@tumaet/apollon(the library's universal v2/v3 → v4 forward-converter) before assigningeditor.model. Bodies that don't match any known shape surface as a toast; the user can fall back to "Export as JSON". -
Durability: Redis is configured with
appendonly yesandappendfsync everysec— at most ~1s of writes can be lost on hard crash. The 50-version cap bounds total Redis memory regardless of write rate. Stricter guarantees require replicas (see operations).
Permissions
The diagram ID grants read access, including version history and embeds. Writes require a signed, diagram-specific capability or the creator's v2 HttpOnly owner cookie. The server checks every diagram and version mutation. The WebSocket relay checks access before joining a room; readers cannot send document updates or transient edit geometry.
Edit, collaboration, and give-feedback links carry the write capability in
#access=.... Feedback mode changes the UI, not the write scope. Review links
and version permalinks omit the capability; changing view= does not grant
write access. The webapp sends capabilities in the HTTP Authorization header
and the WebSocket access handshake, not in URL queries or persisted diagram
models.
Readers do not apply peer Yjs updates. Their documents are seeded from HEAD on
their own, and readers cannot send state, so peer updates would not supersede
the reader's seeded items. Readers reload HEAD after DIAGRAM_UPDATED instead,
with one request in flight and at most one trailing refresh.
If the server rejects the write capability during a session, the editor becomes read-only and shows one message that stays until the user closes it.
A capability remains valid while its diagram exists and the server signing
key is unchanged. Anyone with a write link can pass it on. Delete the diagram
to revoke its links; changing a link's view does not revoke an earlier grant.
The server answers a request with an invalid capability with 401 INVALID_CAPABILITY, also on reads. The webapp then discards that capability
and retries reads without it, so the diagram stays readable.
Links without a capability are read-only. Only the owner cookie that diagram
creation issues proves the creator; the server ignores other owner cookies.
Export or duplicate a read-only diagram and share the new copy to get write
links. Rotating OWNER_SECRET invalidates both owner cookies and write
capabilities; retain the key across normal deployments.
X-Owner-Match still distinguishes creators from collaborators for destructive
confirmation prompts. It is not a substitute for the server's write checks.
Library API additions
import { ApollonEditor } from "@tumaet/apollon"
editor.setReadonly(true) // toggle canvas read-only at runtime
editor.fitView() // safe to call right after a model swap
Local-mode versioning (no-server deployment) is not in scope for this
release; ApollonLocal continues to overwrite a single localStorage
entry on every change.