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:
storage | What 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:
| Interpretation | Typical storage | Notes |
|---|
'plain' | either | Pass-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:
- Optimistic local apply. The new record is added to the in-memory store; React re-renders.
- WebSocket dispatch. A typed message is sent to the
AppRecordRoom Durable Object.
- RBAC check. The DO checks the caller’s role (established from their JWT at connect time) against the collection’s
permissions.
- SQLite write. The DO persists the record in its local SQLite database.
- Broadcast. The DO sends a
core.record_change envelope (with changeType: 'create' | 'update' | 'delete') to every connected client whose RBAC allows read access.
- 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