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 stores app data in collections - typed tables backed by SQLite inside a Durable Object. Each collection is declared in a schema, baked into your worker at deploy time, and exposed to the client through useQuery and useMutations hooks.

Collections and records

A collection is a named table with typed columns. A record is one row, wrapped in an envelope that carries metadata. The SDK exports this shape as RecordData<T>:
type RecordData<T> = {
  recordId: string         // unique ID - the client generates this on create
  data: T                  // your user-defined fields
  createdBy: string        // userId of the creator
  createdAt: string        // ISO timestamp
  updatedAt: string        // ISO timestamp
}
Your own fields live under .data. When you query a todos collection:
const { records } = useQuery<{ title: string; completed: boolean }>('todos')
records[0].data.title       // "Buy milk"
records[0].recordId         // "1714000000000-k3f9x2a"
records[0].title            // undefined - common bug
Access fields under r.data.<field>, not r.<field>. Use r.recordId to pass into put and remove.

Defining a schema

Schemas live under src/schemas/, with the full list exported from src/schemas.ts. Every schema has name, columns, and permissions:
// src/schemas/items-schema.ts
import type { CollectionSchema } from 'deepspace/worker'

export const itemsSchema: CollectionSchema = {
  name: 'items',
  columns: [
    { name: 'title', storage: 'text', interpretation: 'plain' },
    { name: 'status', storage: 'text', interpretation: { kind: 'select', options: ['draft', 'published'] } },
    { name: 'priority', storage: 'number', interpretation: 'plain' },
  ],
  visibilityField: { field: 'status', value: 'published' },
  permissions: {
    viewer: { read: 'published', create: false, update: false, delete: false },
    member: { read: true, create: true, update: 'own', delete: 'own' },
    admin:  { read: true, create: true, update: true, delete: true },
  },
}
Register it:
// src/schemas.ts
export const schemas = [usersSchema, settingsSchema, itemsSchema]
Schemas are baked in at deploy time - there is no runtime schema registry. Adding or changing a schema requires a redeploy.

Column types

Every column has a storage type and an interpretation:
storageWhat it holds
'text'Strings, IDs, ISO timestamps, JSON blobs
'number'Integers, floats, booleans (stored as 0/1), and date/datetime values (stored as Unix seconds)
storage picks the underlying SQLite column type - 'text' becomes a TEXT column, 'number' becomes a REAL column. Pick 'number' for any column you want to range-query, sort numerically, or store as an integer, float, boolean, or Unix timestamp; pick 'text' for everything else. interpretation tells the SDK how to encode/decode the value. It is either the bare string 'plain' or an object with a kind discriminator:
InterpretationTypical storageNotes
'plain'eitherPass-through. Use this for raw numbers and free-form text.
{ kind: 'currency', symbol, decimals }'number'Strips currency symbols / commas on write.
{ kind: 'date', format? }'text' or 'number'ISO date string in text; Unix seconds in number.
{ kind: 'datetime', format? }'text' or 'number'Same coercion as date.
{ kind: 'boolean', trueLabel?, falseLabel? }'number'Stored as 0 / 1.
{ kind: 'percent', decimals? }'number'Accepts "42%" strings; stores 0.42.
{ kind: 'select', options: string[] }'text'Constrained enum.
{ kind: 'multiselect', options: string[] }'text'Constrained enum, multiple values. Stored as text - pass a pre-joined string (or use { kind: 'json' } if you want array round-tripping).
{ kind: 'url' }'text'URL string.
{ kind: 'email' }'text'Email string.
{ kind: 'json' }'text'Auto JSON.stringify on write, auto JSON.parse on read.
{ kind: 'reference', targetTable, displayColumn }'text'Foreign-key-style pointer to another collection.
Import the union as ColumnInterpretation from deepspace/worker if you want the full type for your own helpers:
import type { ColumnInterpretation } from 'deepspace/worker'
Use the object form for any kind that takes options (currency, select, multiselect, reference). The bare-string form is recommended only for 'plain' - kinds without required fields technically resolve too, but the object form keeps the schema readable. There is no 'number' interpretation - express numeric columns as storage: 'number' with interpretation: 'plain'.
{ name: 'tags', storage: 'text', interpretation: { kind: 'json' } }
// On write: pass the array directly - mutations.create({ tags: ['a', 'b'] })
// On read:  record.data.tags is already an array - don't JSON.parse

Hooks: useQuery and useMutations

import { useQuery, useMutations } from 'deepspace'

type Item = { title: string; status: 'draft' | 'published' }

function ItemList() {
  const { records, status } = useQuery<Item>('items', {
    where: { status: 'published' },
    orderBy: 'createdAt',
    orderDir: 'desc',
    limit: 50,
  })

  const { create, put, remove } = useMutations<Item>('items')

  // create(data: Item) → Promise<string>  (the new recordId)
  // put(id, patch: Partial<Item>) → Promise<void>  (merge into existing row)
  // remove(id) → Promise<void>
}
The hooks subscribe to a WebSocket the moment they mount and stream updates in real time. When any user (including you) creates, updates, or deletes a record, every open client sees the change within milliseconds.

Optimistic vs confirmed mutations

create / put / remove apply changes locally first, then sync to the server. They resolve as soon as the local store is updated - usually before the server confirms. For workflows that must wait for the server to accept the write - so RBAC denials or schema validation errors surface before you navigate or trigger downstream work - use the *Confirmed variants:
import { useMutations } from 'deepspace'

const { createConfirmed } = useMutations<Item>('items')
try {
  const recordId = await createConfirmed({ title: 'New', status: 'draft' })
  navigate(`/items/${recordId}`)
} catch (err) {
  // server rejected the write - show an error to the user
}
createConfirmed returns the same client-generated recordId as create, but doesn’t resolve until the server has acknowledged the write.

Scopes

A RecordScope is a single Durable Object that holds all the collections and records mounted inside it. The roomId is the DO’s identifier - picking a different roomId gives you a separate DO with isolated data.
import { RecordProvider, RecordScope } from 'deepspace'
import { APP_NAME, SCOPE_ID } from './constants'
import { schemas } from './schemas'

<RecordProvider>
  <RecordScope roomId={SCOPE_ID} schemas={schemas} appId={APP_NAME}>
    <App />
  </RecordScope>
</RecordProvider>
SCOPE_ID from src/constants.ts defaults to app:${APP_NAME} - your app’s main RecordRoom. Each scope is an independent DO with its own data. Nesting <RecordScope> lets you mount additional rooms - for example, a per-conversation DO:
<RecordScope roomId={`conv:${convId}`} schemas={CONVERSATION_SCHEMAS} appId={APP_NAME}>
  <ChatThread />
</RecordScope>

How writes flow

When you call useMutations.create(...), the SDK runs through six steps:
  1. Optimistic local apply. The new record is added to the in-memory store; React re-renders.
  2. WebSocket dispatch. A typed message is sent to the AppRecordRoom Durable Object.
  3. RBAC check. The DO checks the caller’s role (established from their JWT at connect time) against the collection’s permissions.
  4. SQLite write. The DO persists the record in its local SQLite database.
  5. Broadcast. The DO sends a core.record_change envelope (with changeType: 'create' | 'update' | 'delete') to every connected client whose RBAC allows read access.
  6. Reconcile. Other clients apply the update; the originating client confirms its optimistic state.
On the wire there is a single record-change message: core.record_change, carrying a changeType discriminator. The client store fans this out into internal record_created / record_updated / record_removed notifications for the React subscriptions - those names are SDK-internal and not part of the wire vocabulary. If the server rejects the write - an RBAC denial or a schema validation failure - the optimistic update rolls back automatically.

Beyond records

DeepSpace records are tuned for operational data - collections of hundreds to tens of thousands of small rows, queried by client filters and updated frequently. For larger or analytical workloads:
  • Files / blobs → use R2 file storage.
  • Vector search → declare a custom Vectorize binding and call it from your worker.
  • Analytics → declare a custom Analytics Engine binding, or use the auto-provisioned USAGE_EVENTS dataset with meterUsage.
  • External SQL → declare a custom D1 database (with runMigrations) or a Hyperdrive binding to your own Postgres.

Next steps