Skip to content

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.

ts
// 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() },
})
ts
// 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:

ts
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 principalidentify(conn) (stable across reconnects), or the random conn.id when identify isn't set. (It's the same hook presence uses.)

ts
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 attemptWhen it failsError
open / read a Resourceprincipal lacks readFORBIDDEN
write a Resourceprincipal lacks writeFORBIDDEN
any op on an unknown idthe Resource doesn't existNOT_FOUND
store(name)the name isn't configuredNOT_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.

ts
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 closes

Writes 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:

ts
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:

ts
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:

tsx
const { data, set, update } = useResource<Note>('docs', 'note-1')
// data is undefined until catch-up; set replaces, update merges a partial

Scaling & 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. A self store 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 as store.write / store.grant / store.revoke / store.subscribe events under the Store filter. The write payload — the only one carrying arbitrary user data — is safe-snapshotted and inspector.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.

bash
pnpm --filter @super-line/example-store start

Next: Synced state (CRDT) — the merging Store for true multiplayer.

Released under the MIT License.