rackspace persistence

Where your patch lives, when it gets written, and how to take it with you.

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 .syx cartridges are parsed into node.data.userPatches; the selected preset name is in node.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.
Generated from packages/web/src/lib/{audio,video}/module-registry.ts · repo