Skip to main content
Documentation

Data storage

Define a collection, query it from React, and persist realtime writes.

On this page

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 mutation 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/schema'

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 },
  },
}
ts

Register it in src/schemas.ts:

import type { CollectionSchema } from 'deepspace/schema'
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]
ts

Restart npx deepspace dev start if it's running - schemas are picked up at worker startup.

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>
  )
}
tsx

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 fire-and-forget create / put / remove functions plus *Confirmed variants. Query state updates when the accepted server broadcast returns.

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)
tsx
  • 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 returns the client-generated ID before the server answers. createConfirmed waits for the Durable Object ack and rejects the promise on RBAC or validation 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>
  )
}
tsx

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' } }
ts

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']
ts

Per-user privacy#

Permissions are enforced server-side. To make notes private per user:

permissions: {
  member: { read: 'own', create: true, update: 'own', delete: 'own' },
}
ts

'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.

Next steps#