Records reference
Providers, hooks, and the data layer for reading and writing records.
On this page
The records API is the primary surface for working with collections. Every hook and provider on this page is imported from deepspace.
import {
RecordProvider, RecordScope, ScopeRegistryProvider,
useQuery, useMutations, useUsers, useUserLookup, useRecordContext,
RecordRoomNotReadyError, type WriteError,
} from 'deepspace'
For schemas and column types, see the worker schemas reference. For RBAC rules, see permissions.
Providers#
<RecordProvider>#
Initializes the WebSocket and in-memory record store. Required ancestor of every records hook.
| Prop | Type | Description |
|---|---|---|
roomId | string (optional) | Scope ID for the default room (usually app:<APP_ID> - the scaffold's SCOPE_ID, keyed to the immutable app id, not the name). Omit for multi-scope mode and use <RecordScope> to mount scopes instead. |
schemas | CollectionSchema[] (optional) | All collections this provider tree may query. |
wsUrl | string (optional) | Override the WebSocket URL. Defaults to current origin. |
fetchUser | () => Promise<UserProfile | null> (optional) | Custom user-profile fetcher. Defaults to using the Better Auth session. |
allowAnonymous | boolean (optional) | Connect without a JWT (default false). Required for public pages. See authentication. |
getAuthToken | () => Promise<string | null> (optional) | Custom token fetcher. Defaults to the SDK's. |
onWriteError | (error: WriteError) => void (optional) | Called when the server rejects a fire-and-forget write, or when a write is attempted before the room is ready. See write errors below. |
<RecordProvider allowAnonymous>
<App />
</RecordProvider>
Write errors (onWriteError)#
Fire-and-forget mutations (create / put / remove) resolve before the server answers, so a denied or invalid write surfaces through onWriteError. There is no optimistic local insert: subscribed query state changes only when the accepted broadcast returns. If you don't handle onWriteError, the SDK's default handler logs a deduplicated console.error telling you to wire real UI.
interface WriteError {
/** RBAC denial, data validation/other rejection, or a room that is not ready. */
kind: 'permission' | 'validation' | 'not_ready'
/** Short human-readable summary, safe to show end users. */
title: string
/** Longer human-readable explanation; may be empty. */
detail: string
}
The scaffold wires it to toasts - permission denials as warnings, everything else as errors. Keep this wiring when customizing the layout, and retrofit it into apps scaffolded before the prop existed:
const { error, warning } = useToast() // scaffold's local toast hook
<RecordProvider
allowAnonymous
onWriteError={(e) =>
e.kind === 'permission' ? warning(e.title, e.detail) : error(e.title, e.detail)
}
>
<App />
</RecordProvider>
When the next step depends on the write being accepted, prefer the confirmed mutation variants - they reject instead of routing through onWriteError.
<RecordScope>#
Mounts a specific scope (Durable Object instance). Nest for additional scopes.
| Prop | Type | Description |
|---|---|---|
roomId | string | App-owned scope ID, such as app:app_01HZXYABCDEFGHJKMNPQRSTVWX or chat:thread_123. |
schemas | CollectionSchema[] | Collections in this scope. |
sharedScopes | Array<{ roomId, schemas }> | Additional app-owned scopes to mount alongside the primary. |
wsUrl | string (optional) | Override WebSocket URL. |
wsPathPrefix | string (optional) | Override path prefix (default /ws). |
isolated | boolean | If true, don't register this scope's collections in the shared scope registry - prevents name collisions with other mounted scopes. |
<ScopeRegistryProvider>#
Required once near the root if your app mounts multiple scopes via sharedScopes. Coordinates collection routing between those app-owned rooms.
useQuery<T>(collection, options?)#
Subscribes to a collection. Returns a reactive array of envelopes.
function useQuery<T>(
collection: string,
options?: {
where?: Partial<T>
orderBy?: string
orderDir?: 'asc' | 'desc'
limit?: number
},
): {
records: Envelope<T>[]
status: 'loading' | 'ready' | 'error'
error?: string
}
Subscribe to every record in a collection. The hook re-renders whenever any user mutates a record visible to the caller's permissions.
type Note = { title: string; body: string }
function Notes() {
const { records, status } = useQuery<Note>('notes')
if (status === 'loading') return <Skeleton />
return records.map((r) => <li key={r.recordId}>{r.data.title}</li>)
}
where filters by exact field match. Filtering happens server-side before broadcast, so unauthorized records never leave the worker.
const { records } = useQuery<Note>('notes', {
where: { pinned: true },
})
orderBy accepts any field name including createdAt and updatedAt. limit caps the records sent over the wire.
const { records } = useQuery<Note>('notes', {
orderBy: 'updatedAt',
orderDir: 'desc',
limit: 50,
})
Gate your UI on status rather than records.length. An empty array can mean either "loading" or "loaded with no rows".
const { records, status, error } = useQuery<Note>('notes')
if (status === 'loading') return <SkeletonList />
if (status === 'error') return <ErrorBanner message={error} />
if (records.length === 0) return <EmptyState />
return <NoteList notes={records} />
Envelope shape:
type Envelope<T> = {
recordId: string
data: T
createdBy: string
createdAt: string
updatedAt: string
}
useMutations<T>(collection)#
Returns fire-and-forget and confirmed mutation functions for the given collection. The local query store updates when the server broadcasts an accepted change.
function useMutations<T>(collection: string): {
/** True once the collection's RecordRoom can accept writes. */
ready: boolean
create: (data: T) => Promise<string>
put: (id: string, patch: Partial<T>) => Promise<void>
remove: (id: string) => Promise<void>
createConfirmed: (data: T) => Promise<string>
putConfirmed: (id: string, patch: Partial<T>) => Promise<void>
removeConfirmed: (id: string) => Promise<void>
}
The ready gate#
Every method throws RecordRoomNotReadyError (a Error subclass with code: 'not_ready') when called before the collection's RecordRoom connection is ready - during initial connect and after a disconnect. Disable write controls until ready so users can't trigger the throw:
const { ready, create } = useMutations<Note>('notes')
<button disabled={!ready} onClick={() => create({ title: 'Untitled', body: '', pinned: false })}>
New note
</button>
If you do call a mutation from a code path that can run early, catch the error and check err.code === 'not_ready' to distinguish it from a server rejection.
create takes the full row shape and returns the new recordId. The ID is generated on the client (timestamp + random suffix) before the write is sent, so the promise resolves with the ID immediately while the server processes the mutation in the background.
const { create } = useMutations<Note>('notes')
const id = await create({
title: 'Untitled',
body: '',
pinned: false,
})
If you need to confirm the row was actually persisted (e.g., before navigating away), use createConfirmed instead - it awaits server acknowledgment:
const id = await createConfirmed({ title: 'New', body: '', pinned: false })
navigate(`/notes/${id}`)
put is merge semantics - the server applies { ...existing, ...patch }. Send only the fields you're changing.
const { put } = useMutations<Note>('notes')
await put(noteId, { pinned: true }) // only updates pinned
await put(noteId, { title: 'New title' }) // only updates title
Do not spread the existing record:
// ❌ Wasteful - sends every field
await put(noteId, { ...note.data, pinned: true })
// ✅ Send only what changed
await put(noteId, { pinned: true })
remove is a hard delete. The record is dropped from the DO's SQLite store and broadcast as record_removed to every connected client.
const { remove } = useMutations<Note>('notes')
await remove(noteId)
There is no soft-delete primitive at the records layer. For chat messages, use useMessages().softDelete which sets a tombstone flag instead.
createConfirmed / putConfirmed / removeConfirmed resolve only after the DO has acknowledged the write. Use when the next step depends on server persistence - typically before navigation.
const { createConfirmed } = useMutations<Note>('notes')
const id = await createConfirmed({ title: 'New' })
navigate(`/notes/${id}`) // safe - server has persisted
Plain create resolves with the client-generated ID before the server answers. Subscribed query state changes only after the accepted broadcast returns. If you need to navigate immediately, use the returned ID rather than reading a stale render closure.
| Method | Semantics | Returns |
|---|---|---|
create | Fire-and-forget | Promise<string> (client-generated recordId) |
put | Fire-and-forget, merge | Promise<void> |
remove | Fire-and-forget | Promise<void> |
createConfirmed | Waits for DO ack | Promise<string> |
putConfirmed | Waits for DO ack | Promise<void> |
removeConfirmed | Waits for DO ack | Promise<void> |
useUsers()#
Returns the room's user directory with role-management helpers.
type RoomUser = {
id: string
/** Present only for admin callers. */
email?: string
name: string
imageUrl?: string
role: string
/** Present only for admin callers. */
createdAt?: string
/** Present only for admin callers. */
lastSeenAt?: string
}
function useUsers(): {
users: RoomUser[]
usersLoaded: boolean
setRole: (userId: string, role: string) => void
refresh: () => void
}
setRole accepts a free-form role string (e.g. 'admin', 'intern', or any value your schema understands) and dispatches the change without waiting for an ack. The Durable Object enforces who is allowed to call it. See permissions for how roles map onto collection RBAC rules.
The directory privacy contract#
The directory is filtered server-side, in two steps:
- Row policy. Anonymous sockets receive no directory at all. For authenticated callers, rows first pass the app's
userscollection read policy for the caller's role - the fresh scaffold shipsmember.read: 'own', so a regular member's directory contains only their own row unless the app explicitly broadens the policy. - Field projection. Rows that pass are then projected to public identity for non-admin callers:
{ id, name, imageUrl?, role }. Admins receive the full fields (email,createdAt,lastSeenAt, plus any custom columns) - which is also whyuseUserLookup().getEmailonly resolves emails for admin callers.
useUserLookup()#
O(1) wrapper around useUsers() for resolving userIds to display fields.
type UserInfo = {
id: string
/** Available to admins; ordinary members receive public identity only. */
email?: string
name: string
imageUrl?: string
role: string
}
function useUserLookup(): {
users: RoomUser[]
usersLoaded: boolean
userMap: Map<string, UserInfo>
getUser: (userId: string) => UserInfo | null
getEmail: (userId: string) => string | null
getName: (userId: string) => string | null
}
const { getName } = useUserLookup()
<p>By {getName(message.authorId) ?? 'unknown'}</p>
There is no getRole or getImageUrl - read those off getUser(id)?.role or getUser(id)?.imageUrl.
getEmail resolves an email only when the caller is an admin - non-admin callers receive the public-identity projection, which carries no email, so getEmail returns null for them. Don't build member-facing UI that depends on it.
useRecordContext()#
Low-level access to the record-store context (WebSocket send/receive primitives, ready state, user profile, etc.). Useful for building custom hooks or imperative reads outside React's render cycle. Most apps never need this.
See also#
- Data storage guide - schemas, CRUD, and patterns.
- Data model - collections, envelopes, scopes.
- Permissions - RBAC rules and the
'own'/'shared'/'published'shortcuts. - Real-time sync - mutation pipeline and consistency guarantees.
- Worker schemas reference - the
CollectionSchematype and drop-in collections.