docs

Database

Indexes

Every query runs against a local any-store database, so "cost" means local disk work, not a network round-trip. The difference between an indexed read and a scan still matters in a large space — this page says which is which.

What is indexed

Built-in datasets declare indexes for their hot paths:

Dataset Index Serves
editor_blocks (nav.parentId, nav.pos) Listing a document's blocks in order; finding the tail position for appends.
chat_messages (_ver.id) Chronological paging — sort: ["-_ver.id"] with a _ver.id cursor.
chat_messages idx_mentions (sparse, multikey) {"mentions": "<identity>"} filters.

The objects collection has no per-property indexes. A cross-object filter or sort on <typeId>.<propId> is a scan proportional to the space size. There is no create-index API for user properties; when a per-property read becomes hot, the options are an indexed built-in field, a dedicated per-object dataset, or a search over the FTS / vector index, which is maintained separately from the query engine.

Cheap patterns

Page on an indexed cursor, not offset. A cursor filter on an indexed monotonic field returns straight from the index and is stable under concurrent writes:

{ "objectId": "<chat>", "dataset": "chat_messages",
  "filter": { "_ver.id": { "$lt": "<oldestSeen>" } },
  "sort": ["-_ver.id"], "limit": 50 }

List a folder through nav. {"filter": {"nav.parentId": "<folder>"}, "sort": ["nav.pos"]} on editor_blocks hits the tree index directly.

Put $match first in a pipeline. Aggregation pushes a leading $match down to the index plan; tombstone exclusion folds into the same prefix so it stays index-planned.

Scope by type. {"any.types": "<typeId>"} does not make a scan indexed, but it stops negation operators ($ne, $nin, $exists: false) from matching every field-less row in the space.

Always set a limit. A scan that stops after 20 matches is still bounded work; an unbounded one materialises the whole result.

Ask for the plan. An aggregation body with "explain": true returns {plan} instead of records, which shows whether the leading $match was pushed to an index or the stage runs as a collection scan:

any aggregate $SPACE $OBJ --dataset chat_messages --explain \
  --pipeline '[{"$match": {"creator": "A5…"}}, {"$count": "n"}]'

Instants are index-keyable

Datetime values are stored as native instants (unix milliseconds), so a range filter or sort on createdAt, modifiedAt or a date / datetime property is memcmp-orderable and can key an index where one exists. Wrap literals in {"$date": …} — see Data types.

Runtime datasets

A runtime dataset declared on a user type is a per-object collection like chat_messages, so its reads are bounded by that object's records rather than the whole space. An idRule: user dataset is keyed by the caller-supplied id, which is what upsert diffs against.

The search index is separate

POST /v1/spaces/:spaceId/search queries a consumer-side index (BM25 full-text plus an optional vector leg) built from the change feed. It covers editor text, chat text and property values by default, and stays eventually consistent with the query engine — a hit can outlive its object briefly. Use it for "find text anywhere"; use /query for exact filters. See Search.

Why it matters. Both the query engine and the search index are on-device. Indexing your own data never sends it anywhere: the embedder can run in-process, and the FTS tables live next to the encrypted store in your data directory.