Skip to main content
Every widget on a canvas shares a single storage backend called a RecordRoom. Understanding how it works helps you structure your data, debug issues, and make better decisions about what to ask the Deepspace agent to build.

Where Data Lives

Storage is backed by Cloudflare Durable Objects, each running its own SQLite database. Here’s the key mapping:
  • One canvas = one RecordRoom = one Durable Object = one SQLite database
  • All widgets on the same canvas share the same RecordRoom
  • Each RecordRoom is identified by a roomId (passed to widgets via URL parameter)
Data is organized into collections (like tables). Each collection holds records (like rows). Collections are defined in your widget’s schemas.ts.

What’s Stored

TableContains
recordsAll your application data, across all collections
usersUsers who have accessed this canvas/app
teamsTeam definitions
team_membersTeam membership + pending invites
yjs_docsCollaborative document state (for useYjsText / useYjsField)

Data Isolation

  • Widgets on the same canvas share data. If widget A writes to the tasks collection, widget B can read from it.
  • Widgets on different canvases are fully isolated — separate RecordRooms, separate databases.
  • When a widget is deployed as a standalone site, it gets its own RecordRoom. Data does not carry over from the canvas.

How It Works

Connection

When a widget loads, RecordProvider (from main.tsx) opens a WebSocket to the RecordRoom:
wss://{domain}/ws/{roomId}?token={jwt}
The JWT is obtained differently depending on context:
  • Canvas mode: Token relayed from parent frame via postMessage
  • Standalone mode: Token from Clerk auth

Query Subscriptions

When you call useQuery('tasks', { where: { status: 'active' } }), this is what happens:
  1. Client sends a SUBSCRIBE message over WebSocket with your query
  2. Server runs the query against SQLite and returns matching records
  3. Server keeps the subscription active — when any record in tasks changes, it re-evaluates your query and pushes updates
  4. Client’s RecordStore caches results and triggers React re-renders
Subscriptions are reference-counted. If two components subscribe to the same query, only one WebSocket message is sent. When the last component unmounts, the subscription is cleaned up.

Mutations

When you call create(), put(), or remove():
  1. Client sends the mutation over WebSocket
  2. Server validates against your schema (fields, types, access rules)
  3. Server applies the change to SQLite
  4. Server evaluates all active subscriptions and broadcasts changes to affected clients
  5. All connected clients (across all widgets on the canvas) get real-time updates
There are two flavors:
  • Fire-and-forget (create, put, remove) — Returns immediately, doesn’t wait for server confirmation
  • Confirmed (createConfirmed, putConfirmed, removeConfirmed) — Returns a promise that resolves when the server acknowledges the write, or rejects on error/timeout

Reconnection

If the WebSocket drops (network change, browser backgrounding, etc.), the client automatically reconnects with exponential backoff (up to 30 seconds). On reconnect, all active subscriptions are re-sent and data is refreshed.

Schemas and Access Control

Schemas serve two purposes: they define your data shape, and they control access.
export const schemas: CollectionSchema[] = [
  {
    name: 'tasks',
    fields: {
      title: { type: 'string', required: true },
      completed: { type: 'boolean', default: false },
    },
    access: {
      read: 'viewer',   // Anyone can read
      write: 'member',  // Only members can write
    },
    autoFields: {
      userId: true,      // Auto-set on create
      createdAt: true,   // Auto-set on create
      updatedAt: true,   // Auto-set on create and update
    },
  },
  {
    name: 'private-notes',
    access: {
      read: 'owner',    // Only the record creator can read
      write: 'owner',   // Only the record creator can write
    },
    autoFields: { userId: true },
  },
]

Role Hierarchy

adminmemberviewer
  • admin — Elevated privileges. Can manage users, teams, and admin-level collections.
  • member — Standard user. Can read and write to collections with member access.
  • viewer — Can view but not modify. Good for public-facing reads.
Each level includes the permissions of the levels below it (an admin can do everything a member can, etc.).

Per-Record Ownership

  • owner — Not a role in the hierarchy. When access.read or access.write is 'owner', only the user who created the record (matched by userId) can access it. This works independently of roles — even an admin can’t read another user’s owner-scoped records.

File Storage (R2)

For files (images, PDFs, etc.), widgets use Cloudflare R2 via the useR2Files hook. Files are scoped:
  • 'self' (default) — Widget’s own files
  • 'user' — Current user’s personal files
  • 'widget' — Another widget’s files (requires widgetId)
Files are stored at authenticated URLs and can be listed, uploaded, downloaded, and deleted through the hook.

Collaborative Editing (Yjs)

For real-time collaborative text editing (like Google Docs), the storage system includes Yjs integration. When you use useYjsText or useYjsField:
  1. Client creates a local Yjs document
  2. Server syncs the document state via binary WebSocket messages
  3. Local changes are broadcast to all connected clients editing the same document
  4. Server persists the Yjs state to the yjs_docs table
This is separate from the record system — Yjs handles conflict resolution for concurrent edits automatically.