Skip to content

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.

const newProduct = await client.data.products.create({
name: "New Product",
price: 29.99,
active: true
});
// With a specific ID
const newProduct = await client.data.products.create(
{ name: "Custom ID Product" },
"my-custom-id"
);

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.

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.

const updated = await client.data.products.update(42, {
name: "Updated Name",
price: 39.99
});

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.

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.

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 between

Without 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 frame
await 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.

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.

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.

// Create
await client.data.products.createMany([
{ name: "Widget", price: 9.99 },
{ name: "Gadget", price: 19.99 }
]);
// Update — each entry names its row and the fields to change
await client.data.orders.updateMany([
{ id: "o-1", data: { status: "shipped" } },
{ id: "o-2", data: { status: "shipped" } }
]);
// Delete — by id
await client.data.sessions.deleteMany(["s-1", "s-2"]);

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.

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));

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.

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.

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 operations
result.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.

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.