Database/Collections

Collections

An object is one type and is filed under any number of collections. A collection carries property definitions and nothing else — no parts, no layout, no datasets — so filing an object changes the columns it holds, never how it renders.

A person who is also a contact and an investor is one object: type: person, collections: [contact, investor]. It renders with the person layout and carries three property groups — the person's, the contact's and the investor's — each in its own namespace on the row.

Note. "Collection" on this site always means this object kind. The place records live in — editor_blocks, chat_messages, <typeId>_<key> — is a storage collection, the dataset name on reads and writes. See Runtime datasets.

Type or collection?

Make it a type when Make it a collection when
the thing needs a layout, a body, a chat or any other part — Person, Task, Journal entry it is a facet or a label on objects that keep their own type — Reading list, Q3 launch, Contact
it answers "what is this?" it answers "where does this belong?"
an object can have exactly one an object can have any number

Reading list is a collection: the things in it are pages, people and links that keep being pages, people and links. Person is a type: it has a profile layout and a notes body.

Create a collection

$SPACE is a writable space; the reply's collectionId is $COLL below, and $OBJ is any existing object in the space.

curl -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/collections \
  -H 'Content-Type: application/json' \
  -d '{"name": "Reading list", "xKey": "reading_list"}'
# → 201 {"collectionId": "bafy…"}
any collection create $SPACE --name "Reading list" --xkey reading_list

Body: {name?, description?, iconCid?, xKey, hidden?, meta?} — the type body minus layout. xKey is required: the stable handle the collection resolves by, a slug of the name that survives renames. An empty one is 400 type.xkey_required.

Types and collections share one handle namespace. An xKey (or id) a type already holds cannot be taken by a collection and the other way round — 409 type.xkey_conflict, whose details name existingTypeId or existingCollectionId.

Property definitions are not part of the create body; a properties key, like any unknown key, is 400 request.unknown_field. Create the collection, then add each column.

List and read

curl http://127.0.0.1:7001/v1/spaces/$SPACE/collections
any collection list $SPACE

{"collections": [{id, name?, description?, iconCid?, xKey?, builtIn?, hidden?, meta?}]} — the synthetic meta row collection (the shape of collection objects themselves; a picker skips it), then the registered built-ins, then your own. Flags are omitted when false. Hidden collections appear only with ?includeHidden=true; GET …/collections/:collectionId resolves them always.

PATCH …/collections/:collectionId takes {name?, description?, iconCid?, hidden?, meta?}, at least one, and answers 204. A built-in refuses it with 400 collection.registered. Deleting a collection is not implemented (501 sdk.not_implemented) — hide it instead.

Passing a user type's id to any of these routes is 400 collection.not_a_collection; an id the space has no collection for — including a built-in type such as page — is 404 collection.not_found.

Columns

Property definitions are one surface: the four routes under …/collections/:collectionId/properties take the same bodies, the same {set, unset} patch grammar and answer the same codes as their …/types/:typeId/properties twins (Types and properties).

curl -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/collections/$COLL/properties \
  -H 'Content-Type: application/json' \
  -d '{"name": "Finished", "xKey": "finished", "kind": "boolean",
       "xFormat": {"type": "checkbox"}}'
# → 201 {"propId": "bafy…"}
any collection property add $SPACE $COLL --name Finished --xkey finished --kind boolean

kind is required and pinned; xKey is unique within the collection; values are stored and written by propId. A column write on a built-in collection is 400 type.registered.

File and unfile

curl -X POST   http://127.0.0.1:7001/v1/spaces/$SPACE/properties/$OBJ/collections/$COLL
curl -X DELETE http://127.0.0.1:7001/v1/spaces/$SPACE/properties/$OBJ/collections/$COLL
any object collection attach $SPACE $OBJ $COLL
any object collection detach $SPACE $OBJ $COLL

Both take no body, both are idempotent, and both return the write receipt. Filing appends to any.collections and admits writes to that collection's columns; unfiling removes the id. The one route with a side effect is bin: a repeated move re-stamps movedAt / movedBy even though membership does not change.

The POST pre-flights its ids — 404 object.not_found, 404 collection.not_found, and 400 collection.not_a_collection when the id names a user type (a built-in type such as page is 404 collection.not_found) — because any.collections is a synced CRDT write with no validation behind it, so a typo would replicate permanently. The DELETE pre-flights nothing on purpose: it is the repair path for a row that already carries a bogus id.

Unfiling is not a delete. That collection's values stay on the row as orphan data, read-tolerant, and filing the object again brings them back into view — writes to that group are refused in between. Filter a member list on any.collections, never on a property value alone: the orphan values still match.

An object that is new takes its collections in the create body instead — Objects. $PERSON is the type, $CONTACT the collection, $STATUS a choice property declared on it:

curl -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/objects \
  -H 'Content-Type: application/json' \
  -d '{"type": "'$PERSON'", "collections": ["'$CONTACT'"],
       "initialProperties": {"any": {"name": "Ada Lovelace"},
                             "'$CONTACT'": {"'$STATUS'": ["active"]}}}'

Query the members

any.collections is an array, so it filters by membership: a scalar matches any element, $all demands several, $nin excludes.

{"any.collections": "<collectionId>"}                     // filed under it
{"any.collections": {"$all": ["<c1>", "<c2>"]}}           // filed under both
{"any.collections": {"$nin": ["bin"]}}                    // not in the bin
curl -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/objects/query \
  -H 'Content-Type: application/json' \
  -d '{"filter": {"$and": [{"any.collections": "'$COLL'"},
                           {"any.collections": {"$nin": ["bin"]}}]},
       "sort": ["-modifiedAt"], "limit": 50}'

A collection definition's own row carries the marker __collection__ in any.type and its handle at collection.xkey, never its own id in either slot — so it never matches a member query and no marker exclusion is needed. The path is indexed (sparse) on the objects storage collection; a column value under <collectionId>.<propId> is not, so put the membership clause in the filter and let the index narrow the scan (Indexes).

Built-in collections

miniapp and bin are registered collections, present in every space, hidden from the default listing and static — 400 collection.registered on a metadata write. An object opts into them; nothing stamps them.

miniapp is the sidebar. Members carry bundle (the id of the installed app, absent on an object the user pinned), pos (a client-allocated lexid) and hidden (out of the sidebar, install kept). Pin and unpin are the plain filing routes; values go through POST …/properties/:objectId/set/miniapp.

curl -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/objects/query \
  -H 'Content-Type: application/json' \
  -d '{"filter": {"$and": [{"any.collections": "miniapp"},
                           {"any.collections": {"$nin": ["bin"]}},
                           {"miniapp.hidden": {"$ne": true}}]},
       "sort": ["miniapp.pos"]}'

bin is move-to-bin. Filing under it stamps bin.movedAt and bin.movedBy in the same change as the membership op, so a bin member never lacks its stamps; unfiling clears them. The object keeps its type and its other collections throughout — restore brings it back exactly as it was. Permanent deletion is DELETE …/objects/:objectId.

any object collection attach $SPACE $OBJ bin     # to the bin
any object collection detach $SPACE $OBJ bin     # restore

List the bin with {"any.collections": "bin"} sorted ["-bin.movedAt"]; exclude its members from ordinary lists with {"any.collections": {"$nin": ["bin"]}}.

Collections the product ships

A collection your app depends on belongs in a bundle, not in a client-side create: the wiki, and the contact / investor / customer / partner / vendor / cofounder / candidate facets are catalog collections, installed by POST /v1/catalog/:usecaseId/setup so every device and member resolves the same id and the same property ids (Bundles). Minting one client-side converges only by luck and races on 409 type.xkey_conflict.