docs

Database

Data model

An object is not one document. It is a set of types it implements and a set of datasets it owns: one row in the space-wide objects collection for its property values, and any number of per-object record collections (chat_messages, editor_blocks, your own runtime datasets…). Each dataset has its own schema, its own write path and the same read path.

One picture

Space
 ├─ types                      user types + built-ins (any, page, chat, editor, type, …)
 ├─ objects  (one collection)  one row per object: property values keyed <typeId>.<propId>
 └─ Object
     ├─ any.types = [pageTypeId, chatTypeId, movieTypeId, …]   N types, no inheritance
     └─ datasets                                                N collections
          ├─ chat_messages    ← contributed by the built-in `chat` type
          ├─ editor_blocks    ← contributed by the built-in `editor` type
          ├─ reviews          ← a runtime dataset declared on the user type `movie`
          └─ payloads         ← files attached to this object

Two consequences fall out of this shape:

  • Types coexist. An object can be a page and a chat and a movie at once. Adopting a type appends its id to any.types; each type brings its property namespace and, if it declares any, its datasets. There is no inheritance and no "primary" type.
  • Collections are per object. A chat's messages are a collection on that chat object, not rows in a space-wide messages table. The space-wide collection is objects only — the row per object that holds property values and the system stamps.

Why it matters. In a hosted document database you model "a document with comments" as two tables joined by a foreign key, and the server owns both. Here the object is the unit of sync and access: its datasets travel with it, merge as CRDTs with it, and are encrypted with the space it belongs to. There is nothing to join across.

The objects collection

Every regular object has exactly one row here, id = the object id. Values sit at {typeId}.{propId}; the human property name lives only on the definition, so renaming a property never touches stored values.

{
  "id": "bafy…obj",
  "any":            { "name": "Heat", "types": ["page", "bafy…movie"] },
  "bafy…movie":     { "Y9Hxx5xmYmF": ["personA", "personB"], "EwyHGrtTdxB": 1995 },
  "nav":            { "type": "page", "parentId": "…", "pos": "a0" },
  "createdAt":      { "$date": "2026-08-01T10:00:00.000Z" },
  "modifiedAt":     { "$date": "2026-08-05T17:00:00.000Z" }
}

Cross-object questions ("every movie from 1995", "all pages under this folder") are queries over this collection — Reading data. Properties are written through the typed set route — Writing data. The reserved paths (any.*, nav.*, _ver, _addSeq, the stamps) are catalogued in System fields.

Per-object datasets

A dataset is a Mongo-like record collection scoped to one object. Where it comes from decides who defines its shape:

Dataset Contributed by Schema owner Write path
chat_messages built-in type chat the server's chat handler POST …/objects/:o/chat/messages and friends — Chat
editor_blocks built-in type editor the server's editor handler POST …/objects/:o/editor/blocks, the markdown bridge — Editor
payloads files the SDK POST …/objects/:o/filesFiles
<your name> a runtime dataset declared on a user type you, via the declaration generic POST …/modify and POST …/upsertRuntime datasets

A dataset write is accepted only on an object that carries the contributing type in any.types (400 dataset.validation otherwise), so attach types at create:

curl -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/objects \
  -H 'Content-Type: application/json' \
  -d '{"name": "Heat", "types": ["page", "chat", "'$MOVIE'"]}'

That single object now renders as a document, hosts a discussion, and carries movie properties — and each concern is a separate collection with separate ordering, indexes and handlers.

One read path for every dataset

Whatever produced a dataset, it is read the same way: the per-object query with a dataset name, and its /subscribe twin for liveness.

curl -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/query \
  -H 'Content-Type: application/json' \
  -d '{"objectId": "'$OBJ'", "dataset": "chat_messages", "sort": ["-_ver.id"], "limit": 50}'

curl -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/query \
  -H 'Content-Type: application/json' \
  -d '{"objectId": "'$OBJ'", "dataset": "reviews", "filter": {"score": {"$gte": 8}}}'

any query $SPACE $OBJ chat_messages is the CLI form. Aggregation pipelines run over a dataset the same way — Aggregation.

Schemas and scopes

Every dataset the space hosts is discoverable with its JSON Schema:

curl http://127.0.0.1:7001/v1/spaces/$SPACE/datasets
# → { "datasets": [ { "name": "chat_messages", "typeId": "chat", "schema": {…} }, … ] }
any datasets $SPACE
{
  "type": "object",
  "additionalProperties": true,
  "properties": {
    "text":       { "type": "string",  "x-scope": "synced" },
    "creator":    { "type": "string",  "x-scope": "derived" },
    "createdAt":  { "x-scope": "derived" },
    "unread":     { "type": "boolean", "x-scope": "local" }
  }
}

The x-scope keyword says who writes a field and how far it travels:

Scope Written by Travels to
synced clients, through the object's CRDT change every member of the space
derived the handler at apply time (creator, timestamps) — client writes rejected computed identically on every peer
local this device, through the local-scope modify route; never enters the DAG nowhere
account this account, through the private tech space this account's other devices only

additionalProperties: true marks a dynamic dataset: undeclared keys are allowed and default to synced. Runtime datasets add behavioral keywords on top — required, x-mutable-by, x-stamp, x-delete-by, x-id, x-search — described in Runtime datasets. typeId on an entry names the owning type: records exist only on objects carrying it, which is also what the search indexer keys eviction on.

Account-wide, GET /v1/datasets lists the tech-space system datasets (spaces, profile) behind the space list.

Note. Type objects are themselves rows with the __type__ marker in any.types; they carry the properties and datasets datasets that hold your definitions. You never write those directly — the types API does — but they follow the same model, which is why a definition change is a CRDT write that every member converges on.