Stores
A Store is super-line's persisted-state primitive: a named, permissioned collection of JSON Resources — each a { id, accessRules, data } record. The server is authoritative: it creates Resources, grants and revokes per-client access, and validates every read and write. Clients get a reactive handle that catches up to the current value and stays live.
Like a transport, a Store is pluggable and ships as a server + client pair you pass at construction. Two implementations ship today — one plumbing, two consistency models:
@super-line/store-memory— last-writer-wins, in-memory, zero-dependency. The default.@super-line/store-sync— a merging CRDT Store (Yjs via super-store). Concurrent writes to different fields converge instead of clobbering — for true multiplayer.
Both expose the same …StoreServer() / …StoreClient() pair, so switching consistency models is a one-line swap — the wire, ACLs, fan-out, and client handle are identical.
Off-contract by design
Unlike requests, events, and topics, a Store is not declared in defineContract, and its data is not schema-validated by the server — a CRDT update is an opaque merge delta that can't be validated against a JSON schema anyway. Store data is unknown end-to-end; you assert its shape. Route anything that needs a hard, typed gate through a normal request. (See ADR-0003.)
Configure the pair
Pass matching server and client halves, keyed by name. Each name is an independent backend with its own consistency model and persistence — so one app can mix a CRDT scene store and an LWW config store.
// server
import { memoryStoreServer } from '@super-line/store-memory'
const srv = createSuperLineServer(api, {
transports: [webSocketServerTransport({ server })],
authenticate: (h) => ({ role: 'user' as const, ctx: { uid: h.query.uid } }),
identify: (conn) => conn.ctx.uid, // the ACL principal (falls back to conn.id)
stores: { docs: memoryStoreServer() },
})// client
import { memoryStoreClient } from '@super-line/store-memory'
const client = createSuperLineClient(api, {
transport: webSocketClientTransport({ url }),
role: 'user',
params: { uid: 'alice' },
stores: { docs: memoryStoreClient() }, // same name as the server
})The client key must match the server key — store('docs') throws NOT_FOUND if the name isn't configured on that side. To go collaborative, swap the pair for the CRDT one; nothing else changes:
import { syncStoreServer } from '@super-line/store-sync' // server: stores: { docs: syncStoreServer() }
import { syncStoreClient } from '@super-line/store-sync' // client: stores: { docs: syncStoreClient() }Server-authoritative access
The server owns Resources. create / grant / revoke / delete are server-side only — there's no client wire for them, so clients can't create Resources or change access; they read and write only within granted bounds. Access is deny-by-default: a principal absent from a Resource's accessRules gets nothing.
Permissions key off the principal — identify(conn) (stable across reconnects), or the random conn.id when identify isn't set. (It's the same hook presence uses.)
const docs = srv.store('docs')
await docs.create(
'note-1',
{ title: 'Draft', body: '' },
{ alice: { read: true, write: true }, bob: { read: true, write: false } },
)
await docs.grant('note-1', 'carol', { read: true, write: false }) // open access at runtime
await docs.revoke('note-1', 'bob') // remove it
await docs.write('note-1', { title: 'Curated', body: '' }) // server co-write (origin 'server')
const res = await docs.read('note-1') // Resource | undefined (server admin read, no ACL)
const ids = await docs.list() // string[]
await docs.delete('note-1')The wire ops a client can attempt are read/subscribe and write — each ACL-checked against its principal:
| Client attempt | When it fails | Error |
|---|---|---|
open / read a Resource | principal lacks read | FORBIDDEN |
write a Resource | principal lacks write | FORBIDDEN |
| any op on an unknown id | the Resource doesn't exist | NOT_FOUND |
store(name) | the name isn't configured | NOT_FOUND |
The reactive handle
On the client, open(id) returns a handle: a snapshot that fills in after catch-up, live updates, and set / update that write through optimistically. It re-snapshots automatically on reconnect.
const note = client.store('docs').open('note-1')
await note.ready // catch-up complete; getSnapshot() is undefined until then
console.log(note.getSnapshot()) // { title: 'Draft', body: '' }
const off = note.subscribe(() => render(note.getSnapshot()))
note.update({ title: 'Shipping plan' }) // optimistic locally, fanned to other subscribers
note.set({ title: 'Reset', body: '' }) // replace the whole value
off()
note.close() // drops the server subscription when the last handle for this id closesWrites are optimistic and fire-and-forget: the local value changes immediately, then the change is sent up. If the server rejects it (e.g. FORBIDDEN), there's no automatic rollback — the rejection is routed to onStoreError, and the local replica reconciles on the next remote change or re-seed:
const client = createSuperLineClient(api, {
// …
stores: { docs: memoryStoreClient() },
onStoreError: (err, { store, id }) => console.warn('write denied', store, id, err),
})For a one-shot read or write with no handle:
const value = await client.store('docs').read('note-1') // Promise<unknown>
await client.store('docs').write('note-1', { title: 'x', body: '' }) // Promise<void>In React, useResource wraps all of this — open, subscribe, write-through, and close on unmount:
const { data, set, update } = useResource<Note>('docs', 'note-1')
// data is undefined until catch-up; set replaces, update merges a partialScaling & observability
- Cross-node. Each Store declares a clustering mode.
relay(both stores that ship) is node-local: super-line relays every change across nodes over the adapter and converges each node's replica — no extra wiring. Aselfstore owns a shared backend (Redis/Postgres) and handles its own cross-node sync; super-line stays out of it. Echo-break — a writer never re-applies its own change — is automatic in both modes. - Control Center. With
inspector: true, store traffic surfaces asstore.write/store.grant/store.revoke/store.subscribeevents under the Store filter. The write payload — the only one carrying arbitrary user data — is safe-snapshotted andinspector.redact-masked like every other message. (See Control Center.)
Run it
The store example is a permissioned note over the in-memory LWW Store: two users open it, one writes and the other sees it live, a read-only user is denied a write, a third user can't open until the server grants access at runtime, and the server co-writes.
pnpm --filter @super-line/example-store startNext: Synced state (CRDT) — the merging Store for true multiplayer.