Writing data
Reads are on Querying data. This page is the other half: everything that changes a row.
Every method here goes through the same pipeline a write from anywhere else does — callbacks, relations and row-level security all still apply. None of it is a shortcut past your own rules; what these options buy is a round trip, a transaction, or a race you no longer have to lose.
Single-row writes
Section titled “Single-row writes”Create
Section titled “Create”const newProduct = await client.data.products.create({ name: "New Product", price: 29.99, active: true});
// With a specific IDconst newProduct = await client.data.products.create( { name: "Custom ID Product" }, "my-custom-id");Upsert
Section titled “Upsert”Insert the row, or update the one already occupying its key:
await client.data.products.upsert( { sku: "LAMP-01", name: "Desk lamp", price: 39 }, { onConflict: ["sku"] });The server decides which inside one transaction, and falls back on
INSERT … ON CONFLICT DO UPDATE, so unlike a findById followed by
create-or-update it cannot lose the race between the two, and unlike
create it does not fail when the row is already there.
When the row is there, the upsert is an update of it. The fields you send are
written and the ones you leave out keep their stored values — they are not reset
to their defaultValue. beforeSave and afterSave see status: "existing"
with the previous values, and history records an update. A key that names a row
outside the caller’s beforeQuery scope is a 404.
onConflict defaults to the primary key, which is the wrong target for most of
the writes an upsert is reached for: keyed on a serial id that is a plain
insert, because the caller does not know the id — so a re-runnable import
duplicates every row on the second run. Name the natural key instead. It must
carry a uniqueness guarantee the database can match on — validation: { unique: true } on the property, or the columns of a unique: true
index — and anything else is a 400 listing the targets
that do exist, rather than an error raised from inside a transaction.
The on_create timestamp and the user_on_create creator of a row that already
existed are left alone: a conflict means its creation is a fact about the past.
Not on the users collection
Section titled “Not on the users collection”Since 0.25 upsert() on the users collection (the one with auth enabled) is refused
with a 400 (INVALID_CONFLICT_TARGET), and so is createMany with upsert
there. Creating a user and changing one follow different rules — a create
hashes the password and sends the invitation, an update may not demote the last
administrator — and an upsert only learns which it is inside the write. Call
create or update instead.
Update
Section titled “Update”const updated = await client.data.products.update(42, { name: "Updated Name", price: 39.99});Field operations
Section titled “Field operations”A value may be an operation on the stored value instead:
await client.data.posts.update(postId, { views: { $inc: 1 }, tags: { $push: "featured" }, meta: { $merge: { lastSeen: Date.now() } }});| Operator | Property type | Meaning |
|---|---|---|
$inc |
number |
add (negative to subtract) |
$push |
array |
append one value, or each of an array of values |
$pull |
array |
remove every occurrence of a value |
$merge |
map |
shallow-merge an object in |
The reason to reach for them is the read you no longer make, and the race that
read opens. views = current + 1 means fetching current first, and two
requests that each read 4 both write 5 — an increment is lost and neither
response says so. Compiled into the statement, the arithmetic happens inside the
row lock.
Exactly one operator per field, and only on an update: over a row that does not
exist yet there is nothing to operate on, so an operation in a create,
createMany or upsert is a 400. An operator on the wrong property type, or a
misspelled $operator, is a 400 naming the field — never a JSON document
written into the column.
An operation answers to the property’s validation as a value does, and that
includes the value it produces. { stock: { $inc: -1 } } on a stock with
validation: { min: 0 } succeeds while the stock is at least 1 and is refused
at 0 with the same 400 (VALIDATION_CONSTRAINT) that { stock: -1 } gets. The
same goes for max, moreThan, lessThan, positive and negative, and for
an array’s min and max items after a $push or $pull. The check is part
of the update statement, so two concurrent decrements of a stock of 1 cannot
both succeed, and a refused update writes nothing.
While offline they are refused rather than queued: an operation is evaluated against a stored value the device has no current copy of, and an optimistic row could only show the marker itself until the queue drained.
Delete
Section titled “Delete”await client.data.products.delete(42);On a collection with soft delete this stamps the
row instead of removing it. client.data.products.trash lists, counts, restores
and purges the stamped rows.
Conditional writes
Section titled “Conditional writes”update and delete take an ifMatch, so a write is refused when the row has
changed since you read it:
import { etagOf } from "@rebasepro/client";
const post = await client.data.posts.get(1);await client.data.posts.update(1, { title: "New" }, { ifMatch: etagOf(post) });// → RebaseApiError, status 412, if somebody edited it in betweenWithout it, read-modify-write is last-writer-wins over everything you did not send: two editors a second apart both succeed, and the first one’s change is gone with no error anywhere.
etagOf(row) reads the version off a row that came from findById/get. It
lives on a non-enumerable key, so it never enters the generated Row type, a
JSON.stringify, or a spread into the next update body. It is undefined for a
row from find(), from the offline cache, or from a server that sends no
ETag — and passing undefined sends no precondition, so the call above
degrades to an ordinary update rather than throwing.
Since 0.25 A row from update() carries the version that write produced, so
conditional edits chain without a read in between. A row a listenById()
callback receives carries the version that frame shows:
import { etagOf } from "@rebasepro/client";
let shown: Record<string, unknown> | undefined;client.data.posts.listenById(1, (post) => { shown = post; // etagOf(shown) is the version this frame shows});
// On save: refused with 412 if the row has moved on since that frameawait client.data.posts.update(1, { title: "New" }, { ifMatch: etagOf(shown) });From a browser on another origin — a Vite frontend on its own port, an app.
host calling an api. host — the ETag is readable only because the server
names it in Access-Control-Expose-Headers. The Rebase runtime does, along
with Retry-After, X-Request-ID, the X-RateLimit-* headers and
Preference-Applied; on 0.23 it exposes none of them, so etagOf is
undefined cross-origin there. A backend that wires its own cors() has to expose the
same list, or etagOf is always undefined there and every conditional write
goes out unconditional.
Skipping the response
Section titled “Skipping the response”Every write resolves to the row it wrote. Pass { returning: false } when you
do not need it:
await client.data.events.create({ kind: "page_view" }, undefined, { returning: false });That sends Prefer: return=minimal; the server answers 204 for a single write
and the ids only for a batch. The method then resolves to undefined (or []),
so you cannot accidentally use a row the server never sent. Worth it on imports
and fire-and-forget writes — the default is the row because it carries what the
server decided.
Batch Writes
Section titled “Batch Writes”Three operations write many rows in a single request and a single transaction. Every row still runs the normal pipeline — callbacks, relations, row-level security — so a batch is not a shortcut past your own rules; the win is one round trip and one transaction instead of N of each.
All three are all-or-nothing. If any row is rejected, none of them land and the error names the offending index.
// Createawait client.data.products.createMany([ { name: "Widget", price: 9.99 }, { name: "Gadget", price: 19.99 }]);
// Update — each entry names its row and the fields to changeawait client.data.orders.updateMany([ { id: "o-1", data: { status: "shipped" } }, { id: "o-2", data: { status: "shipped" } }]);
// Delete — by idawait client.data.sessions.deleteMany(["s-1", "s-2"]);Why { id, data } rather than flat rows
Section titled “Why { id, data } rather than flat rows”createMany takes flat rows because a row being created is its columns.
updateMany names the address separately, because on a table keyed on something
other than id — a sku, a composite key — a flat row cannot say whether a
column is the address or a value to write. This mirrors single-row
update(id, data) exactly.
Why deleteMany takes ids, not a filter
Section titled “Why deleteMany takes ids, not a filter”A filter-shaped bulk delete is a different and far more dangerous operation: the failure mode is an omitted or mistyped condition emptying a table, and it cannot be reviewed at the call site the way an explicit list can. Read first, then pass the ids you meant:
const stale = await client.data.sessions.findAll({ where: { expiresAt: ["<", cutoff] }});await client.data.sessions.deleteMany(stale.map(s => s.id as string));Retries and duplicates
Section titled “Retries and duplicates”A client that never sees the response cannot know whether the batch committed, so it retries — and without a key the server cannot tell that retry from a second genuine batch. Pass an idempotency key on anything that may be resent:
const attemptKey = crypto.randomUUID();await client.data.products.createMany(rows, { idempotencyKey: attemptKey });A key names one request, not a job: it is recorded against the method, the path
and the body it was sent with. Re-sending that exact request replays its answer;
the same key on a different request is refused with IDEMPOTENCY_KEY_REUSED
(422). So mint one per call rather than reusing a business id — an importId
shared by the createMany and the deleteMany of one import would leave the
delete silently unperformed.
A retry that arrives while the first attempt is still being answered gets
IDEMPOTENCY_KEY_IN_PROGRESS (409): send it again, and it will be answered with
the first attempt’s result once that lands. Keys are honoured for 24 hours, and
only for a signed-in caller — there is no principal to scope one to otherwise.
The offline queue sets a key automatically on every replay.
Limits
Section titled “Limits”Batches are capped server-side (1000 rows by default), because one batch holds
its locks for the whole transaction. Going over is a BULK_TOO_LARGE error that
names both the limit and your row count, so chunk to it:
for (const chunk of chunks(rows, 1000)) { await client.data.products.createMany(chunk, { upsert: true });}A data source that cannot write atomically reports BULK_UNSUPPORTED rather
than quietly looping single writes — which would give you neither the atomicity
nor the single round trip you reached for a batch to get.
Writing across collections
Section titled “Writing across collections”createMany and friends are one collection at a time. client.batch() is the
cross-collection form: one request, one transaction, all of it or none.
const result = await client.batch([ { op: "create", collection: "orders", values: { total: 40 }, ref: "order" }, { op: "create", collection: "orderItems", values: { orderId: { $ref: "order.id" }, sku: "A-1" } }, { op: "update", collection: "stock", id: "A-1", values: { count: { $inc: -1 } } }, { op: "delete", collection: "carts", id: "c-9" }]);
result.data; // [ order, item, stock, null ] — aligned to the operationsresult.meta; // { operations: 4 }op is create, update, upsert or delete, and collection narrows
values to that collection’s generated Insert or Update shape — a column
the collection does not have is a compile error, and so is a create missing a
required one. collection is the accessor, the name client.data.<accessor>
takes (orderItems for the slug order_items); the client sends the slug,
through the collections dictionary it was created with. On 0.23 a typed batch
sends the accessor as written, so a snake_case collection cannot be reached
through it, and a misspelt column is not a compile error. Every
operation runs the pipeline its single-row equivalent runs — the same
validation, callbacks and row-level security, as the same user.
An operation may name itself with ref; a later one may stand
{ $ref: "<name>.<field>" } wherever a value goes, including as an id and at
any depth inside values. It resolves to that field of the row the named
operation wrote.
This is why the method exists rather than a loop over createMany: the child’s
foreign key does not exist until the parent is inserted, so the two would have
to be separate requests — and separate requests can half-succeed. The recovery
from that (read back, work out which half landed, undo it) is code nobody
writes.
Only backward references resolve. A forward one is refused before the transaction opens, along with unknown collections, unknown fields, illegal field operations and ineligible conflict targets — because finding a mistake at operation 40 would otherwise cost the rollback of the 39 writes before it.
Limits and failures
Section titled “Limits and failures”The same 1000-operation cap as a bulk write, for the same reason: one batch
holds its locks for the whole transaction. An update or delete naming a row
that does not exist fails the whole batch with a 404. A backend whose driver
cannot make it atomic answers BATCH_UNSUPPORTED rather than looping.
idempotencyKey and returning work as they do on every other write.