Writing data
Writes are purpose-built endpoints, not a generic document PUT: property values go through the owner-scoped set, dataset records through /modify (or upsert for repeatable imports), and the chat and editor modules through their own handlers. Every one of them returns the same receipt. No write adds a missing owner: set the object's type and collections first, then write their values.
The write receipt
{ "versionId": "<change VersionId>",
"changeId": "<changeId>",
"recordIds": ["<id>"],
"rejections": [] }
| Field | Meaning |
|---|---|
versionId |
The change's position in the object's DAG on this peer — not comparable across peers. Stamp _ver.<path> = versionId on the records you just wrote so the matching live event is recognised as your own and not double-applied. |
changeId |
The CID of the change — also the handle for version history. Empty for local-scope writes. |
recordIds |
Mirrors the input record order; recordIds[0] is the derived id of a created record. |
rejections |
When present, per-op refusals: {recordIndex, recordId?, opIndex, reason}. opIndex: -1 means the whole record was rejected. The write itself succeeded for everything else. |
No write returns the record body. Read it back through /query.
Why it matters. A write is a local append to the object's change DAG. It succeeds offline, is visible to the next query immediately, and syncs when a peer is reachable.
versionIdis what lets a client order its own writes against remote ones without a server clock. Readrejectionseven on a 200: the request succeeded for everything else.
Property values
Values are stored at record[ownerId][propId], the owner being the object's type or one of its collections. Write them with the owner-scoped set — $OWNER is that id, $PROP_AUTHOR / $PROP_YEAR a string and a number property declared on it:
curl -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/properties/$OBJ/set/$OWNER \
-H 'Content-Type: application/json' \
-d '{"patch": {"'$PROP_AUTHOR'": "Frank Herbert", "'$PROP_YEAR'": 1965}}'
- Key by
propId, never byxKey. The server never sees xKeys; a patch keyed by one fails withproperty.not_found. ResolvexKey → propIdfromGET …/types/:typeId/propertiesorGET …/collections/:collectionId/propertiesfirst. - No write creates its own owner. A value is admitted only while the object has that type or that collection; otherwise the write is refused. Set the type and file the collections first (Objects).
- The endpoint auto-routes by the property's declared scope. Every propId in one patch must resolve to the same scope — mixed-scope or unknown keys are rejected.
- A value whose kind differs from the declared
kindis400 property.kind_mismatch; a value that does not fit the property'sxFormatslug is400 property.format_violation. - Built-in paths use literal keys:
…/set/anywith{"patch": {"name": "Dune"}}. A tree move is this route on the wiki collection —…/set/<wikiCollectionId>with{"patch": {"<parentIdPropId>": "…", "<posPropId>": "…"}}(Objects).
Initial values ride object create instead — initialProperties keyed the same way (see Objects).
Dataset records: /modify
POST /v1/spaces/:spaceId/modify is the generic write over one object's dataset — a batch of records, each a list of ops, all landing in one change. Below, $TYPE declares an auto-id runtime dataset notes with title, n, tags and draft fields, $OBJ is of that type and $NOTE an existing record in it (Runtime datasets builds one).
curl -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/modify \
-H 'Content-Type: application/json' \
-d '{
"objectId": "'$OBJ'",
"dataset": "'$TYPE'_notes",
"records": [
{ "id": "", "upsert": true,
"ops": [
{ "type": "$set", "path": "", "value": { "title": "x", "n": 1 } },
{ "type": "$addToSet", "path": "tags", "value": "idea" } ] },
{ "id": "'$NOTE'",
"ops": [ { "type": "$inc", "path": "n", "value": 1 },
{ "type": "$unset", "path": "draft" } ] }
],
"traceIds": ["import-42"]
}'
| Op | Effect |
|---|---|
$set |
Assign value at path. An empty path with an object value sets several top-level keys at once. |
$unset |
Remove the field at path. |
$inc |
Add a number to the field at path. |
$addToSet |
Append value to the array at path if absent. |
$pull |
Remove value from the array at path. |
path is a dotted field path ("style.level" touches one sub-field; "style" replaces the object; the empty path with an object value sets several root fields at once). A record with id: "" and upsert: true is created with a derived id; a named id with upsert creates-or-updates; a named id without upsert updates only an existing record and rejects a missing one. traceIds are opaque labels stored on the change and filterable in history.
dataset is a storage collection name — <typeId>_<key> for a runtime dataset — that a part of the object's type declares, and the object must have that type — otherwise 400 dataset.not_declared (a name the space does not serve at all is 400 dataset.unknown). Collections declare no datasets. Module storage collections (chat_messages, editor_blocks) are written through their own handlers, which stamp derived fields and enforce authorship; /modify is for runtime datasets and other dynamic ones. Runtime-dataset rules (required fields, write-once, author-only) are enforced on apply — see Runtime datasets.
Local-scope writes
Add "scope": "local" to write fields the dataset schema declares local — device-only, no DAG change, never synced, still delivered to query/subscribe with a locally-minted versionId and an empty changeId:
{ "objectId": "<chat>", "dataset": "chat_messages", "scope": "local",
"records": [ { "id": "<msgId>",
"ops": [ { "type": "$set", "path": "unread", "value": false } ] } ] }
Constraints (400 request.schema): explicit record ids, no upsert (local fields annotate records the synced route created), no traceIds, and not the objects dataset — local property values go through …/properties/:objectId/set/:ownerId. An op targeting a non-local field comes back in rejections; the reverse — a synced write touching a local field — fails whole with 400 dataset.validation. account scope is not writable here.
Delete records
curl -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/delete-records \
-H 'Content-Type: application/json' \
-d '{"objectId": "'$OBJ'", "dataset": "'$TYPE'_notes", "recordIds": ["'$NOTE'"]}'
A record delete is a sticky tombstone — the id can never be re-created. A later /modify addressing it still answers 200, with a whole-record rejections entry (opIndex: -1) and nothing stored, so an ensure-by-fixed-id always inspects rejections. Subscribers see the id in removed.
Idempotency
POSTs are not idempotent: each call produces a new change, so a blind retry of /modify after an uncertain response can duplicate an auto-id record or run $inc twice. There is no Idempotency-Key. The one exception is /upsert, where the caller-supplied record id is the idempotency key and an identical re-run writes nothing.
Preflight, don't hope
- Check the object's
any.typebefore a dataset write — a part of that type must declare the storage collection; the wrong type is a400 dataset.not_declared, not a silent no-op. - Resolve property ids from the owner's definitions — the type's or the collection's — and validate kinds client-side.
- Read back through
/query; never expect a write to echo the record.