Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.deep.space/llms.txt

Use this file to discover all available pages before exploring further.

DeepSpace syncs data between clients over a persistent WebSocket connected to a Durable Object. This page covers the wire protocol, how writes propagate, and the consistency guarantees the SDK provides.

The model

Every client holds a local replica of the records it has subscribed to. When any client mutates a record, the DO validates the write against permissions, persists it to SQLite, and broadcasts the update to every other connected client within milliseconds. Each RecordRoom is the single source of truth for its data. The DO owns its SQLite database and serializes all writes, so there are no merge conflicts to resolve.

The mutation pipeline

useMutations returns three mutation functions - create, put, remove - plus a *Confirmed variant of each. The plain functions are fire-and-forget over the WebSocket; the *Confirmed variants await a server ACK and reject on failure.
import { useMutations } from 'deepspace'

const { create, put, remove, createConfirmed } = useMutations<Task>('tasks')

// Fire-and-forget. Returns the new recordId immediately.
// The local store updates when the server echoes the change back.
const id = await create({ title: 'New task', completed: false })

// Awaits the server ACK. Rejects if RBAC denies the write.
const id2 = await createConfirmed({ title: 'Important', completed: false })
A call to create:
  1. Generates a recordId client-side and sends a core.put message over the WebSocket
  2. The DO verifies RBAC, writes to SQLite, and broadcasts core.record_change to every connected client whose RBAC allows it (including the sender)
  3. The client store applies the change against any active useQuery subscriptions and re-renders
There’s no local-optimistic apply step - the UI updates when the broadcast comes back. In practice the round-trip to a colocated Durable Object is single-digit milliseconds, so it feels instant. Use the *Confirmed variants when you need to know the server accepted the write - typically to surface a permission error inline, or to wait on a server-validated outcome before navigating.

Subscription scope

A client subscribes to one or more scopes (Durable Object instances). The default scope in the scaffold is app:<APP_NAME> - every record in your app’s main RecordRoom syncs to every connected client whose RBAC allows it. You can mount additional scopes by nesting <RecordScope>:
import { RecordScope } from 'deepspace'
import { CONVERSATION_SCHEMAS } from './schemas'
import { APP_NAME } from './constants'

<RecordScope
  roomId={`conv:${convId}`}
  schemas={CONVERSATION_SCHEMAS}
  appId={APP_NAME}
>
  <ChatThread />
</RecordScope>
Each scope is an independent DO with its own WebSocket. Subscriptions don’t interfere with each other.

What the DO sends

When a client calls useQuery, the SDK sends a core.subscribe message. The DO replies with a core.query_result snapshot containing every record that matches the query and passes the caller’s read check. From that point forward, the DO pushes incremental updates as core.record_change messages, each carrying a changeType: 'create' | 'update' | 'delete' discriminator alongside the record envelope. The client store applies each change against every active subscription. An update can move a record into or out of a query’s where clause, so the same changeType: 'update' can mean different things to different subscriptions:
Wire messageEffect on a subscription
changeType: 'create', record matches whereRecord is added to the result set
changeType: 'update', record matches and was already in the setRecord is updated in place
changeType: 'update', record now matches but wasn’t in the setRecord is added (treated as a create)
changeType: 'update', record no longer matches and was in the setRecord is removed (treated as a delete)
changeType: 'delete'Record is removed if present

Consistency guarantees

  • Writes are serialized inside the DO. Two concurrent puts land in a deterministic order - the second wins on field-level merge.
  • The DO sees all writes before any client. There is no eventual consistency window from the DO’s perspective.
  • WebSocket disconnects trigger an automatic reconnect. Active subscriptions re-subscribe and receive a fresh snapshot; the client store reconciles silently.
  • In-flight *Confirmed calls reject if the socket drops with 'WebSocket disconnected'; calls made while already offline reject with 'WebSocket not connected'. Fire-and-forget mutations sent while disconnected are silently dropped - use createConfirmed / putConfirmed / removeConfirmed when you need delivery guarantees.

Permissions on the wire

Permissions are enforced before the DO broadcasts. A user without read access to a record never sees it on the wire, so client-side filters are not a security boundary - they’re a usability concern. If a schema declares a visibilityField, the DO re-evaluates read access on every update. When a record’s value at that field transitions to the configured “visible” value (default: 'public'), the DO broadcasts a core.record_change to clients that gain read access at that moment. The inverse also happens: making a record private produces a changeType: 'update' that the client store interprets as a delete for users who lose access. See permissions for the full visibility model.

Other room types

The same WebSocket pattern applies to the other DO types exported from deepspace/worker, but the wire vocabulary differs:
  • YjsRoom speaks the Yjs sync protocol. The DO holds the canonical Y.Doc and broadcasts updates.
  • CanvasRoom stores shapes in a Y.Doc Y.Map and sends typed shape and viewport messages on top, optimized for high-frequency cursor and shape updates.
  • PresenceRoom is fire-and-forget - peer state is held in memory only, never persisted. Updates broadcast at full speed.
  • GameRoom runs an authoritative tick loop on the DO, broadcasting state snapshots to all players.
Use useQuery / useMutations for durable, RBAC-filtered records. Use the room-specific hooks - useYjsText, useCanvas, usePresenceRoom, useGameRoom - for CRDT text, canvas shapes, presence, or a server-authoritative tick loop.

Disconnection and reconnection

The SDK handles WebSocket lifecycle transparently:
  • On disconnect, the local store stays intact - UI keeps working with the last known snapshot.
  • The SDK reconnects with exponential backoff (capped at 30s) and on tab refocus.
  • On reconnect, every active subscription re-subscribes and receives a fresh core.query_result snapshot.
  • In-flight *Confirmed calls reject with 'WebSocket disconnected'; calls made after the socket has closed reject with 'WebSocket not connected'. Fire-and-forget calls made while offline are dropped - the SDK does not queue them.
You can observe the connection state on each hook (useQuery’s status returns 'loading' | 'ready' | 'error'; usePresenceRoom exposes a connected boolean).

Next steps