Schemas reference
`CollectionSchema`, RBAC types, and the pre-built drop-in collections.
On this page
Schemas describe collections - their columns, permissions, and visibility rules. They're declared in src/schemas/, registered in src/schemas.ts, and baked into the worker at deploy time.
import type {
CollectionSchema,
ColumnDefinition,
ColumnInterpretation,
RolePermissions,
PermissionLevel,
} from 'deepspace/schema'
import {
USERS_COLUMNS, BASE_USERS_SCHEMA,
CHANNELS_SCHEMA, MESSAGES_SCHEMA, REACTIONS_SCHEMA,
CHANNEL_MEMBERS_SCHEMA, READ_RECEIPTS_SCHEMA,
AI_CHATS_SCHEMA, AI_MESSAGES_SCHEMA,
} from 'deepspace/schema'
// Worker-internal collection names are only needed in worker code:
import { SYSTEM_COLLECTIONS } from 'deepspace/worker'
// Role constants live on the client entry point (shared module):
import { ROLES, ROLE_CONFIG, type Role } from 'deepspace'
CollectionSchema#
interface CollectionSchema {
name: string
/** Column definitions - every collection is stored in a typed SQL table. */
columns: ColumnDefinition[]
/**
* Composite uniqueness constraint (e.g., ['userId', 'taskId']). Enforced in
* the DO on every write path: a duplicate is refused - the write's ack
* carries `success: false` with "Duplicate: a record with userId=…, taskId=…
* already exists in <collection>" - so one-per-user rules (votes, RSVPs)
* hold against forged client writes when combined with a `userBound` column.
*/
uniqueOn?: string[]
/** Column name used for ownership checks (default: `_created_by`). */
ownerField?: string
/** Column containing JSON array of collaborator user IDs. */
collaboratorsField?: string
/** Column containing team ID for team-based access. */
teamField?: string
/**
* Column controlling per-record read visibility.
* String form: visible when `data[field] === 'public'`.
* Object form: visible when `data[field] === value`.
*/
visibilityField?: string | { field: string; value: unknown }
/** Permissions per role. Use `'*'` for a catch-all fallback. */
permissions: Record<string, RolePermissions>
/** Default role for new users (only on the `users` collection). */
defaultRole?: string
}
ColumnDefinition#
interface ColumnDefinition {
/** Stable ID override (survives renames). Falls back to `col_{name}`. */
id?: string
name: string
storage: 'number' | 'text'
interpretation: ColumnInterpretation | string
/** SQL expression for a computed column (read-only). */
expression?: string
/** Auto-populate with the current user ID on create. */
userBound?: boolean
/** Cannot be changed after initial creation. */
immutable?: boolean
/** Must be provided on create (non-null). */
required?: boolean
/** Default value if not provided on create. */
default?: unknown
/** Auto-set ISO timestamp when the named field changes (optionally to a specific value). */
timestampTrigger?: { field: string; value?: unknown }
}
storage is 'number' | 'text' - these are the only two backing SQLite types the SDK uses. interpretation can be either a string shortcut (e.g. 'plain') or a discriminated-union object.
The empty string is a value#
On a 'text' column, '' is stored as '' and reads back as ''. It is a value, not an absence - clearing a field writes an empty string rather than deleting the key, and default: '' actually materializes on create. (Earlier versions folded '' to NULL on the way in and then dropped the key on the way out, so a field written as '' read back undefined and a default: '' never appeared at all.) On a 'number' column there is nothing to store for '', so it stays NULL.
timestampTrigger writes the column's declared type#
The trigger writes whatever the column's storage says it holds: an ISO 8601 string on a 'text' column, and epoch milliseconds (Date.now()) on a 'number' column. Declare the storage you want to read back - a 'number' column previously received the ISO string and stored whatever Number() made of it, which is how a completedAt ended up holding 2026.
ColumnInterpretation#
type ColumnInterpretation =
| { kind: 'plain' }
| { kind: 'currency'; symbol: string; decimals: number }
| { kind: 'date'; format?: string }
| { kind: 'datetime'; format?: string }
| { kind: 'boolean'; trueLabel?: string; falseLabel?: string }
| { kind: 'percent'; decimals?: number }
| { kind: 'select'; options: string[] }
| { kind: 'multiselect'; options: string[] }
| { kind: 'url' }
| { kind: 'email' }
| { kind: 'json' }
| { kind: 'reference'; targetTable: string; displayColumn: string }
Some kinds carry required fields - currency needs symbol and decimals; select and multiselect need options; reference needs targetTable and displayColumn. The bare string form on ColumnDefinition.interpretation is a shortcut for { kind: <string> }.
RolePermissions#
interface RolePermissions {
read: PermissionLevel
create: boolean
update: PermissionLevel
delete: PermissionLevel
/** If set, this role may only supply the listed columns on create or update. */
writableFields?: string[]
}
type PermissionLevel =
| boolean
| 'own'
| 'unclaimed-or-own'
| 'collaborator'
| 'team'
| 'access'
| 'published'
| 'shared'
All four of read, create, update, delete are required on every role entry. create is boolean-only (you either can or can't create new rows for a role); the others accept the full PermissionLevel union.
writableFields restricts both create and update - the worker runs the same field check on both write paths, rejecting any caller-supplied column outside the list. On update, re-sending a field with its unchanged value is allowed; server-owned writes (column defaults, userBound stamps, timestampTriggers) never count against the list.
See Concepts → Permissions for the semantics of each level.
Roles#
// Imported from `deepspace` (client entry), NOT `deepspace/worker`:
import { ROLES, ROLE_CONFIG, type Role } from 'deepspace'
const ROLES: { VIEWER: 'viewer'; MEMBER: 'member'; ADMIN: 'admin' }
type Role = 'viewer' | 'member' | 'admin'
const ROLE_CONFIG: Record<Role, {
title: string
badgeVariant: 'secondary' | 'default' | 'warning'
description: string
}>
ROLES gives you the three string identifiers used in permissions blocks. ROLE_CONFIG is display metadata for role-badge UIs.
For unauthenticated users, use the '*' key in permissions - there is no anonymous role identifier.
Drop-in schemas - Users#
const USERS_COLUMNS: ColumnDefinition[] // canonical users columns
const BASE_USERS_SCHEMA: CollectionSchema // assembled from USERS_COLUMNS
The scaffold's usersSchema extends BASE_USERS_SCHEMA. Don't replace; extend if you need extra columns.
Drop-in schemas - Messaging#
| Schema | Collection name | Purpose |
|---|---|---|
CHANNELS_SCHEMA | channels | Public channel definitions |
MESSAGES_SCHEMA | messages | Channel messages |
REACTIONS_SCHEMA | reactions | Emoji reactions on messages |
CHANNEL_MEMBERS_SCHEMA | channel-members | Opt-in public-channel membership |
READ_RECEIPTS_SCHEMA | read-receipts | Per-user per-channel read state |
Add to your app's schemas array to enable the corresponding hooks (useChannels, useMessages, etc.).
These schemas intentionally model public chat only. For private messaging, define app-owned collections with a collaboratorsField and server-enforced read: 'shared'; do not rely on membership filtering in the UI.
Drop-in schemas - AI chat#
const AI_CHATS_SCHEMA: CollectionSchema // 'ai-chats'
const AI_MESSAGES_SCHEMA: CollectionSchema // 'ai-messages'
RBAC: members read/update/delete: 'own', create: false - writes only flow through the worker's chat routes. Don't relax create to true (see the AI chat guide for why).
System collection names#
const SYSTEM_COLLECTIONS: Set<string>
SYSTEM_COLLECTIONS contains SDK-internal names used by worker handlers. Most apps never need it.
Pattern: a typical schema#
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: 'tags', storage: 'text', interpretation: { kind: 'json' } },
],
visibilityField: { field: 'status', value: 'published' },
permissions: {
'*': { 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 },
},
}
Schema-lint warnings#
When each schema is registered (worker startup, first DO boot), the SDK runs a lightweight lint and prints any findings to the worker console prefixed [schema-lint]. The CLI also runs the same lint up front: deepspace dev start and deepspace deploy bundle src/schemas.ts and print any findings in the terminal as Schema lint: N warnings in src/schemas.ts, so you see them before a client ever connects (if the file can't be bundled, the CLI says the lint was skipped and the worker still lints at runtime). Warnings do not block boot or deploy - each just flags a declaration that looks like it should enforce something but doesn't. Fix every one before shipping.
lintSchema(schema) is also re-exported from deepspace/worker and returns the warning strings as an array, so you can assert against it in your own tests.
import { lintSchema } from 'deepspace/worker'
import { notesSchema } from './schemas/notes-schema'
// In a unit test:
expect(lintSchema(notesSchema)).toEqual([])
1. visibilityField declared but no role uses 'published' / 'shared'#
[<collection>] visibilityField is declared but no role uses read: 'published' or 'shared'. Roles with read: true (<roles>) will see every row regardless of visibility. Change those to read: 'published' (owner OR public) or 'shared' (owner OR collaborator OR public) to actually enforce the filter, or remove visibilityField if you don't intend to gate reads by it.
Cause. You set visibilityField (intending per-record gating), but every role with read access has read: true. true is unconditional - the visibility column is never consulted, so every row is visible to every reader.
Fix. Either drop visibilityField, or change at least one role's read to 'published' (owner OR matches visibilityField) or 'shared' (owner OR collaborator OR matches visibilityField).
2. ownerField set but the column is not userBound#
[<collection>] ownerField is '<field>' but that column is not marked userBound: true. A client can create a row with someone else's id in this field, bypassing 'own' permission checks. Add userBound: true (and ideally immutable: true) to the column.
Cause. ownerField tells 'own' permission checks which column to read. Without userBound: true on that column, a client can write any user id into it on create - claiming ownership of a row they didn't actually create.
Fix. Add userBound: true (and ideally immutable: true) to the named column. userBound makes the DO overwrite the field with the caller's verified user id on every write.
When the lint deliberately stays quiet. The warning fires only when an ordinary client role (anything other than owner / admin) has create: true without update: 'unclaimed-or-own'. If every ordinary client role has create: false, there is no warning - rows in that collection are written by server code (actions, admin flows), which may legitimately assign the owner column to another user (a recipient or subject id). Don't add userBound mechanically in that shape: it would stamp the writer's id over the intended owner.
{
name: 'todos',
columns: [
{ name: 'assignedTo', storage: 'text', interpretation: 'plain', userBound: true, immutable: true },
// ...
],
ownerField: 'assignedTo',
permissions: {
member: { read: true, create: true, update: 'own', delete: 'own' },
},
}
3. userBound: true on a non-text column#
[<collection>] column '<name>' is userBound but storage is '<storage>'. userBound stamps the user id (a string); use storage: 'text'.
Cause. userBound stamps a user id (a string) into the column. The SDK only coerces strings into 'text' storage; 'number' will fail at write time.
Fix. Change the column's storage to 'text'.
See also#
- Data model concepts - collections and envelopes
- Permissions concepts - rules and visibility
- Records reference - client-side hooks