Synced state (CRDT)
The default Store is last-writer-wins: when two clients edit the same Resource at once, the last write to land wins — and clobbers the other, even if they touched different fields. For true multiplayer, reach for @super-line/store-sync: a CRDT Store where concurrent writes merge instead of overwriting. It's backed by Yjs (via super-store), but you never touch Yjs directly — the ACLs, the handle, and useResource are exactly the same as the LWW store. Only the consistency model changes.
The one-line swap
store-sync is the same server + client pair as any Store — swap it in by name, change nothing else:
// server — was: stores: { docs: memoryStoreServer() }
import { syncStoreServer } from '@super-line/store-sync'
stores: { docs: syncStoreServer() }// client — was: stores: { docs: memoryStoreClient() }
import { syncStoreClient } from '@super-line/store-sync'
stores: { docs: syncStoreClient() }syncStoreServer() takes no options; syncStoreClient() takes an optional { origin } — a per-writer id used to break echoes, generated for you if you omit it.
What merge buys you
With LWW, two simultaneous edits race and one loses. With the CRDT store they converge — both edits survive on every replica:
// alice and bob open the same doc, then edit at the "same time", each a different field:
alice.update({ a: 1 })
bob.update({ b: 2 })
// LWW: whoever lands last wins → { a: 1 } OR { b: 2 } (one edit is lost)
// CRDT: both merge everywhere → { a: 1, b: 2 } on alice, bob, and the serverFields nobody touched are preserved, and the document converges to the same value on every node regardless of the order updates arrive in. That's the whole reason to pay for a CRDT.
How it stays CRDT-agnostic
On the wire, a CRDT update is an opaque base64 delta — super-line relays it without ever parsing the document. The merge logic lives entirely inside the store package, so swapping Yjs for another CRDT would be a store-package change, not a wire change. (See ADR-0002 and ADR-0003.)
The server as a co-writer
The server holds the canonical copy of every Resource — which makes it the place to persist state, and lets it edit alongside clients. A server write of a partial object merges its top-level keys into the document (it doesn't replace it), then fans out to every subscriber tagged origin: 'server':
// a co-writer contributes a field; every other field in the doc is left untouched
await srv.store('docs').write('plan', { priority: 5 })Authority is reactive, not preventive
A CRDT can't reject part of a merge, so the server can't veto a client edit — as the hub it can only react: observe the merged state and emit a compensating edit. Treat synced-document authority as eventually-consistent last-word correction; route anything that needs a hard gate (money, permissions) through a normal request. (See ADR-0003.)
Catch-up & reconnect
open(id) seeds the replica from the server's current canonical state — the full CRDT document, sent once — then merges live deltas on top. On reconnect the handle re-seeds automatically. Like events and topics, live delivery is at-most-once: a client that was offline misses the deltas it didn't receive and recovers by re-snapshotting on reconnect (which the handle does for you).
In React
Nothing changes from the LWW store — useResource gives you the merged value and a write-through set / update:
const { data, set } = useResource<JsonValue>('docs', 'plan')
if (data === undefined) return <p>connecting…</p>
// every edit merges live across tabs; concurrent edits to different fields both survive
return <JsonEditor value={data} onChange={set} height={420} />Run it
The store-sync-json example is a collaborative JSON editor over the CRDT Store: a @visual-json editor bound to one shared Resource via useResource. Open it in two tabs (or add ?name=bob), edit any field, and watch edits merge live — concurrent edits to different fields both survive. Hit Server nudge to see the server co-write a field.
pnpm --filter @super-line/example-store-sync-json dev # http://localhost:5273Roll your own (without the Store seam)
store-sync is the batteries-included path. If you'd rather own the wire — custom rooms, your own message shapes, no Store abstraction — super-line is also a fine transport for a CRDT you drive yourself. Keep a CRDT document per room and relay its opaque update bytes over a shared event: the bus never parses the document, and the server holds the canonical copy (so it can persist and co-write). This is, in effect, what the CRDT Store does for you under the hood.
Three messages carry it: a joinDoc request that returns the current state to catch up, a pushUpdate request for local edits, and a shared update event to fan merges out — with an origin tag to break the echo.
defineContract({
shared: {
serverToClient: {
update: { payload: z.object({ docId: z.string(), update: z.string(), origin: z.enum(['peer', 'server']) }) },
},
},
roles: {
user: {
clientToServer: {
joinDoc: { input: z.object({ docId: z.string() }), output: z.object({ snapshot: z.string() }) },
pushUpdate: { input: z.object({ docId: z.string(), update: z.string() }), output: z.object({ ok: z.boolean() }) },
},
},
},
})On the server, materialize one document per room and make the doc's own update observer the single fan-out + persist point — it fires for both client merges and the server's own edits, so the server co-writes just by mutating the doc. On the client, push only locally-originated updates and apply everything else. (CRDT updates are binary and super-line's default serializer is JSON, so base64-wrap them — btoa / atob are global in the browser and modern Node.)
The synced-canvas-yjs and synced-canvas-automerge examples implement this end to end — a collaborative canvas where tabs and the server co-edit one document, with a debug panel logging each patch by origin. (The contract above is the Yjs example's; the Automerge one ships an array of change blobs per edit instead of a single update — but the relay pattern is identical, because super-line never parses the bytes either way.)
pnpm --filter @super-line/example-synced-canvas-yjs dev # Yjs
pnpm --filter @super-line/example-synced-canvas-automerge dev # AutomergeNext: Roles & auth.