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.

This guide walks through adding a new collection to your app: declaring its schema, querying it from the client, and persisting writes. By the end, you’ll have a working CRUD page backed by a Durable Object that syncs in real time across every connected client. For background on envelopes, scopes, and the optimistic pipeline, see Data model and Real-time sync.

Define the schema

Schemas live under src/schemas/, one file per collection. Create a new collection called notes:
// src/schemas/notes-schema.ts
import type { CollectionSchema } from 'deepspace/worker'

export const notesSchema: CollectionSchema = {
  name: 'notes',
  columns: [
    { name: 'title', storage: 'text', interpretation: 'plain' },
    { name: 'body', storage: 'text', interpretation: 'plain' },
    { name: 'pinned', storage: 'number', interpretation: { kind: 'boolean' } },
  ],
  permissions: {
    member: { read: true, create: true, update: 'own', delete: 'own' },
    admin:  { read: true, create: true, update: true, delete: true },
  },
}
Register it in src/schemas.ts:
import type { CollectionSchema } from 'deepspace/worker'
import { usersSchema } from './schemas/users-schema'
import { settingsSchema } from './schemas/admin-schema'
import { notesSchema } from './schemas/notes-schema'

export const schemas: CollectionSchema[] = [usersSchema, settingsSchema, notesSchema]
Restart npx deepspace dev if it’s running - schemas are picked up at worker startup.
Schemas are baked into your worker at deploy time. There is no runtime schema registry; to add or change a schema in production, redeploy.

Read records

useQuery subscribes to a collection and streams updates over a WebSocket. Each record arrives as an envelope, with your fields under .data:
import { useQuery } from 'deepspace'

type Note = { title: string; body: string; pinned: boolean }

function NotesList() {
  const { records, status, error } = useQuery<Note>('notes', {
    orderBy: 'createdAt',
    orderDir: 'desc',
  })

  if (status === 'loading') return <p>Loading…</p>
  if (status === 'error') return <p>Error: {error}</p>

  return (
    <ul>
      {records.map((note) => (
        <li key={note.recordId}>
          <h3>{note.data.title}</h3>
          <p>{note.data.body}</p>
        </li>
      ))}
    </ul>
  )
}
User fields live under .data. Reading note.title returns undefined - always use note.data.title. TypeScript catches this if you pass a row type to useQuery<Note>.
For filtering, sorting, and limit options, see useQuery options. The Durable Object applies where server-side before broadcasting, so unauthorized records never leave the worker.

Write records

useMutations returns create / put / remove plus *Confirmed variants. Each mutation applies optimistically - the local store updates before the server confirms.
import { useMutations } from 'deepspace'

const { create, put, remove } = useMutations<Note>('notes')

const id = await create({ title: 'Untitled', body: '', pinned: false })
await put(id, { pinned: true })          // merge: only updates pinned
await remove(id)
  • create returns the recordId immediately. The ID is generated client-side (timestamp + random suffix) before the write is sent, so you can navigate to /notes/${id} without waiting for the server.
  • put is merge semantics. The server applies { ...existing, ...patch }. Send only the fields you’re changing - don’t spread the whole row.
  • remove is a hard delete. There’s no soft-delete primitive at the records layer.
  • Use createConfirmed when persistence matters before the next step. Plain create resolves on local update; if the server later rejects (RBAC, validation), the optimistic write rolls back. createConfirmed waits for the Durable Object ack and rejects the promise on server denial.
See the reference for the full method table.

A complete CRUD example

import { useState } from 'react'
import { useQuery, useMutations } from 'deepspace'
import { useToast } from '../components/ui'

type Note = { title: string; body: string; pinned: boolean }

export default function NotesPage() {
  const { records, status } = useQuery<Note>('notes', { orderBy: 'createdAt', orderDir: 'desc' })
  const { create, put, remove } = useMutations<Note>('notes')
  const { success, error } = useToast()
  const [draft, setDraft] = useState('')

  async function addNote() {
    if (!draft.trim()) return
    try {
      await create({ title: draft, body: '', pinned: false })
      setDraft('')
      success('Note created')
    } catch (e) {
      error('Could not create note', String(e))
    }
  }

  if (status === 'loading') return <p>Loading…</p>

  return (
    <div>
      <input value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="New note…" />
      <button onClick={addNote}>Add</button>
      <ul>
        {records.map((note) => (
          <li key={note.recordId}>
            <input
              type="checkbox"
              checked={note.data.pinned}
              onChange={(e) => put(note.recordId, { pinned: e.target.checked })}
            />
            {note.data.title}
            <button onClick={() => remove(note.recordId)}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  )
}
Open the page in two browser windows - changes in one window appear in the other in real time.

JSON columns

For structured field data, use interpretation: { kind: 'json' }:
{ name: 'tags', storage: 'text', interpretation: { kind: 'json' } }
The SDK serializes on write and parses on read - pass and receive the value directly, no JSON.stringify / JSON.parse on either end.
await create({ title: 'Trip', tags: ['vacation', '2024'] })
// later
record.data.tags  // ['vacation', '2024']

Per-user privacy

Permissions are enforced server-side. To make notes private per user:
permissions: {
  member: { read: 'own', create: true, update: 'own', delete: 'own' },
}
'own' resolves against record.createdBy by default; override with ownerField if a different column determines ownership. For published/draft visibility, collaborators, and team rules, see Permissions.

Performance tips

  • Filter at the query. where runs server-side, so unauthorized or unwanted rows never cross the wire.
  • Use limit on long lists. Initial subscription sends every matching record. For thousands of rows, slice by date or category. (Cursor pagination is on the roadmap.)
  • Lift useQuery to a parent. Identical queries deduplicate to one WebSocket subscription, but each mount still re-renders on every store change - fetch once at the top of the page and pass records down via props.
  • Patch, don’t replace. put with a partial patch keeps the wire payload small and avoids overwriting concurrent edits.
The SDK lints each schema at boot and prints findings prefixed [schema-lint] to the worker console. See Schema-lint warnings for the three shapes and their fixes.

Next steps