Database
Upsert
POST /v1/spaces/:spaceId/upsert ingests a batch of records into a runtime dataset declared with idRule: user. Each record is keyed by the id you supply: absent ids are created, present ids are diffed field by field, and identical records are skipped. Re-running an identical batch is a no-op.
This is the surface for imports, syncs from an external system, and any job that runs on a schedule and must not duplicate what it wrote last time.
Request
curl -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/upsert \
-H 'Content-Type: application/json' -d '{
"objectId": "'$OBJ'", "dataset": "articles",
"records": [
{ "id": "a1", "fields": { "title": "Hello", "body": "First draft" } },
{ "id": "a2", "fields": { "title": "World", "body": "…" } }
],
"pageSize": 500,
"traceIds": ["import-42"] }'
any upsert $SPACE $OBJ --dataset articles --records @batch.json
| Field | Meaning |
|---|---|
objectId |
The object hosting the dataset — it must carry the dataset's owning type. |
dataset |
The runtime dataset's name. |
records[] |
{id, fields} — id must match the dataset's idPattern / idMaxLen. |
pageSize |
Records per CRDT change, default 500. |
traceIds |
Optional trace ids stamped on every page's change. |
What happens per record
| Stored state | Action |
|---|---|
| id absent | Created in one multi-field set. |
| id present, some declared-mutable fields differ | Each changed field lands as a single-path set. Write-once fields are compared but never rewritten. |
| id present, identical | Skipped — no change, no DAG growth. |
| id tombstoned | Rejected upsert.record_deleted — ids never reuse. |
One CRDT change is written per page, so a 2 000-record batch at the default page size is four changes, each returned in pages.
Response
The call answers 200 even when some records were rejected — the same partial-success stance as /modify:
{ "pages": [ { "versionId": "…", "changeId": "…", "recordIds": [] } ],
"created": 1, "updated": 1, "skipped": 0,
"rejections": [
{ "index": 3, "id": "a4", "code": "upsert.immutable_field", "reason": "…" } ] }
| Rejection code | Meaning |
|---|---|
upsert.immutable_field |
The payload would change a write-once field. |
upsert.not_author |
Author-mutable field on a record another identity created. |
upsert.record_deleted |
The stored record is a tombstone. |
upsert.rejected |
Creation screening failed — missing required field, id pattern or length violation, undeclared field on a non-dynamic dataset, write to a stamped field. reason carries the specific cause. |
Whole-call errors: 400 upsert.requires_user_ids when the dataset is not declared idRule: user, and 400 dataset.unknown when the name resolves to nothing on that object.
Concurrency
Upsert is not transactional against concurrent writers. The intended deployment is a single ingest writer per dataset — a scheduled program or one importer process. Concurrent creates of the same id by different members are outside the convergence contract; the same member re-running its own batch is exactly the supported case.
Why it matters. A hosted backend gives you idempotency through a server that dedupes on your behalf. Here the dedupe is a local diff against the replica you already hold, so an importer that crashes halfway and restarts, or a cron job that runs twice, converges on the same data without a coordination service — and the changes it skips never cost the network anything.
Note. Only fields declared
mutableBy: authorormutableBy: anyare ever updated on an existing record. If your source system changes a field you declared write-once, the upsert reportsupsert.immutable_fieldfor that record rather than silently ignoring the difference.