rackspace persistence
tl;dr
You don't need to save manually. While you edit a rackspace, every change auto-syncs to the collaboration server (Hocuspocus) and is debounced into a durable Postgres snapshot a few seconds later. Reload the page, close the tab, come back tomorrow — your rack is exactly as you left it.
To take a rack with you, use the topbar's Export Perf (.zip) button. It writes a single portable .ptperf.zip that carries the WHOLE show — the patch graph +
positions, INLINE assets (PICTUREBOX images, TOYBOX layer images/shaders/OBJs,
SAMSLOOP samples), the actual VIDEOBOX video bytes, CV routes, control-surface
bindings, and MIDI/gamepad maps. Use it for: snapshot-before-an-experiment,
send-this-rack-to-a-friend, version-control the patch alongside your code, or
move a rack between machines. Load Perf (.zip) reads one of
those files back into a fresh rack — no re-pick of assets needed, on any
machine.
(The old in-browser Save / Load patch buttons and the Save Perf / Load Perf browser-slot feature were
retired: the auto-sync above already covers durable per-rack persistence, and
the portable .zip covers cross-machine moves — including the
video bytes the old browser-slot path couldn't carry.)
the three tiers
Browser (per user)
+- Y.Doc (graph/store.ts, syncedStore-wrapped)
| +- ydoc.getMap('nodes') <- node.id -> ModuleNode (incl. node.data)
| +- ydoc.getMap('edges') <- edge.id -> Edge
| +- ydoc.getMap('layouts') <- per-user position overrides
|
+- Hocuspocus WS provider (lib/multiplayer/provider.ts)
<- bidirectional Yjs CRDT updates over WebSocket ->
----------------------------------------------------------
Hocuspocus server (packages/server, Fly.io)
+- onAuthenticate -> Clerk JWT or anon HMAC invite
+- onLoadDocument -> loadSnapshot(rackId) -> Y.applyUpdate
+- onStoreDocument (DEBOUNCED)
+- debounce: 2000 ms
+- maxDebounce: 5000 ms
+- unloadImmediately: true (last-client flush guarantee)
----------------------------------------------------------
Postgres (Neon, db/schema/001_init.sql)
+- racks (id, owner, name, timestamps)
+- rack_members (rack_id, user_id, role)
+- rack_snapshots (rack_id PK, yjs_state bytea, updated_at) what's persisted
Anything stored under node.data or node.params rides the
Y.Doc and is therefore part of the snapshot. That includes:
- Patch graph: nodes, edges, knob positions.
- Per-user node positions (multiplayer doesn't make you fight over layout).
- Sequencer step data (notes, midi, chord mode).
- SCORE pages, ties, dynamics.
- DRUMSEQZ track grids + per-track Euclidean settings.
- POLYSEQZ chord steps (root, quality, inversion, voicing, humanize).
- Sequencer / DRUMSEQZ / SCORE / POLYSEQZ quicksave slots (4 per module, accessible via the transport card).
- PICTUREBOX images — uploaded files are downscaled to 640x480 JPEG and
base64-stored in
node.data.imageBytes, so the image is part of the rack and shows up for everyone. - DX7 user banks — uploaded
.syxcartridges are parsed intonode.data.userPatches; the selected preset name is innode.data.preset.
Things that are intentionally not persisted in the rack:
- The webcam feed from a CAMERA module — local-only by design; only its presence is broadcast as awareness.
- Skin preference — per-browser localStorage today (so the same account can pick different skins per device).
the .imp.json envelope
The portable .ptperf.zip wraps a single JSON patch envelope,
format envelopeVersion: 1 (the same envelope the auto-sync path
and the dev tooling round-trip):
{
"envelopeVersion": 1,
"savedAt": "2026-05-09T12:34:56.000Z",
"moduleSchemas": { "analogVco": 1, "picturebox": 2, "dx7": 1, ... },
"update": "<base64 of Y.encodeStateAsUpdate(ydoc)>"
} The update field is the actual source of truth — the same bytes the
Hocuspocus server stores in rack_snapshots.yjs_state. Loading an
envelope decodes that update into a fresh Y.Doc and atomically swaps the live
rack contents for the loaded ones. moduleSchemas drives per-module
data migrations on load — if a saved patch's PICTUREBOX is at v1 and the running
build is at v2, the v1 -> v2 migration runs before the node is added to the
live store.
limits + future evolution
A maxed-out rack today (8 PICTUREBOX with images, 32 DX7 SYX user banks, ~50
modules, 4 active users with their own layouts) sits at roughly 1.5 MB. Postgres bytea handles that comfortably and the Cloudflare Workers request
body limit (25 MB) leaves an order of magnitude of headroom.
When typical rack sizes cross ~5 MB (think 1080p PICTUREBOX images, video loops,
longer DX7 banks), the persistence path swaps the all-in-one Y.Doc snapshot for a
content-addressed asset table — bytes hash to a row in rack_assets,
the patch keeps a hash reference in node.data. Hashes dedupe across
racks (same image used 4 times = stored once). Beyond ~25 MB, those bytes move to
Cloudflare R2 and the Postgres table holds the URL. Both migrations are additive
and leave the user-facing export/import story unchanged.
see also
- Deploy — Workers / Fly / Neon topology.
- Module catalog — every module's I/O + which fields
live under
node.data.