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

# Schemas reference

> `CollectionSchema`, RBAC types, and the pre-built drop-in collections.

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.

```ts theme={null}
import type {
  CollectionSchema,
  ColumnDefinition,
  ColumnInterpretation,
  RolePermissions,
  PermissionLevel,
} from 'deepspace/worker'
import {
  USERS_COLUMNS, BASE_USERS_SCHEMA,
  CHANNELS_SCHEMA, MESSAGES_SCHEMA, REACTIONS_SCHEMA,
  CHANNEL_MEMBERS_SCHEMA, CHANNEL_INVITATIONS_SCHEMA, READ_RECEIPTS_SCHEMA,
  CONVERSATION_SCHEMAS,
  DIRECTORY_SCHEMAS, VOTING_SCHEMAS,
  WORKSPACE_SCHEMAS, workspaceTeamsSchema,
  SYSTEM_COLLECTIONS,
  GLOBAL_DO_TYPES, GLOBAL_DO_TYPE_NAMES,
  getGlobalDOType, getGlobalDOSchemas,
  RESERVED_COLLECTION_NAMES,
  AI_CHATS_SCHEMA, AI_MESSAGES_SCHEMA,
} from 'deepspace/worker'

// Role constants live on the client entry point (shared module):
import { ROLES, ROLE_CONFIG, type Role } from 'deepspace'
```

## `CollectionSchema`

```ts theme={null}
interface CollectionSchema {
  name: string
  /** Column definitions - every collection is stored in a typed SQL table. */
  columns: ColumnDefinition[]
  /** Composite uniqueness constraint (e.g., ['userId', 'taskId']). */
  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`

```ts theme={null}
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.

## `ColumnInterpretation`

```ts theme={null}
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`

```ts theme={null}
interface RolePermissions {
  read:   PermissionLevel
  create: boolean
  update: PermissionLevel
  delete: PermissionLevel
  /** If set, only these columns can be updated by this role. */
  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.

See [Concepts → Permissions](/concepts/permissions) for the semantics of each level.

## Roles

```ts theme={null}
// 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

```ts theme={null}
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`            | Channel definitions (public, private, dm) |
| `MESSAGES_SCHEMA`            | `messages`            | Channel messages                          |
| `REACTIONS_SCHEMA`           | `reactions`           | Emoji reactions on messages               |
| `CHANNEL_MEMBERS_SCHEMA`     | `channel_members`     | Explicit channel membership               |
| `CHANNEL_INVITATIONS_SCHEMA` | `channel_invitations` | Pending invites                           |
| `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.).

## Drop-in schemas - Conversations (`conv:<id>` DOs)

```ts theme={null}
const CONVERSATION_SCHEMAS: CollectionSchema[]
```

Array of the `conv_messages` / `conv_reactions` / `conv_members` / `conv_read_cursors` collections. Pass as `schemas` to a conversation-scope `RecordRoom` to enable `useConversation`.

## Drop-in schemas - Directory (cross-app)

```ts theme={null}
const DIRECTORY_SCHEMAS: CollectionSchema[]      // conversations, conversation_state, communities, memberships, posts
const VOTING_SCHEMAS: CollectionSchema[]         // poll-related collections
```

Hosted on the platform's directory DO. Rarely instantiated by an app directly - apps mount them via `sharedScopes`.

## Drop-in schemas - Workspace (cross-app shared)

```ts theme={null}
const WORKSPACE_SCHEMAS: CollectionSchema[]      // email handles, teams, etc.
const workspaceTeamsSchema: CollectionSchema     // teams collection on its own
```

Mount via `sharedScopes` for `workspace:default`.

## Drop-in schemas - AI chat

```ts theme={null}
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).

## Global DO type registry

```ts theme={null}
const GLOBAL_DO_TYPES: GlobalDOType[]
const GLOBAL_DO_TYPE_NAMES: readonly string[]   // ['workspace', 'conv', 'dir']

function getGlobalDOType(name: string): GlobalDOType | null
function getGlobalDOSchemas(typeName: string): CollectionSchema[]
```

Runtime lookups for what schemas a given global DO scope expects. `getGlobalDOType` returns `null` (not `undefined`) when the name isn't registered.

## Reserved names

```ts theme={null}
const RESERVED_COLLECTION_NAMES: Set<string>     // names you cannot use in app-defined schemas
const SYSTEM_COLLECTIONS: Set<string>            // SDK-internal collections (Yjs state, metadata)
```

Naming a custom collection that collides with either set is rejected at validation time.

## Pattern: a typical schema

```ts theme={null}
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]`. Warnings do **not** block boot - 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.

```ts theme={null}
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`).

<Warning>
  This is a privacy foot-gun, not a typo. A schema that triggers this warning will silently leak draft / private rows to everyone who can read the collection.
</Warning>

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

```ts theme={null}
{
  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](/concepts/data-model) - collections and envelopes
* [Permissions concepts](/concepts/permissions) - rules and visibility
* [Records reference](/sdk-reference/client/records) - client-side hooks
