docs

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: author or mutableBy: any are ever updated on an existing record. If your source system changes a field you declared write-once, the upsert reports upsert.immutable_field for that record rather than silently ignoring the difference.