Mutations
Writes. One transaction each, and how to refuse a stale one.
A mutation writes tenant data. The handler runs inside one transaction: throw, and nothing is written — there is no partial state to clean up.
import { mutation } from "@highx/core";
export const saveNote = mutation({
name: "notes.save",
args: noteArgs,
returns: noteId,
auth: can("notes:write"),
handler: (ctx, args) => ctx.db.table("notes").insert({
...args,
authorId: ctx.identity.userId,
createdAt: ctx.now(),
}),
});What the handler returns is what the caller receives — over HTTP and over the socket alike. An inserted id, a new version, whatever is useful.
Row operations
const id = ctx.db.table("notes").insert({ title: "New" });
const note = ctx.db.table("notes").get(id);
ctx.db.table("notes").update(id, { title: "Renamed" });
ctx.db.table("notes").delete(id);
ctx.db.table("notes").list(); // every row — see Queries on scansNo network I/O
A mutation cannot fetch. That restriction is what lets it be a transaction at
all — a transaction that awaits a third party holds storage open for as long as
that party takes. Reaching outside is an action's job.
Writes are serialized per tenant
A tenant's data lives in one Durable Object, and a Durable Object is single-threaded. Two mutations to the same tenant therefore run one after the other, and a read-modify-write inside a handler cannot have another write land in the middle of it.
That is what makes the next pattern a genuine compare-and-swap rather than an approximation.
Refusing a stale write
A shared record should take the version the edit was based on, and refuse a write built on a version it has already moved past:
const existing = ctx.db.table("notes").get(args.id);
if (existing === undefined) throw new HighXError("not_found", "That note is gone");
if (args.baseUpdatedAt !== existing["updatedAt"]) {
throw new HighXError("conflict", `${existing["editedBy"]} changed this first`);
}
ctx.db.table("notes").update(args.id, { ...args.changes, updatedAt: ctx.now() });Six lines, and they are the difference between collaborative and lossy. A client that was offline replays its queued writes on reconnect — idempotently, under the same request ID — and idempotent is not the same as safe: without this check, that replay lands on top of everything written while it was away. The write succeeds, the work is gone, and nothing reports a problem.
Make the version required, not optional. An optional guard is not a guard — an old tab, a script, or a forgotten field silently gets unconditional overwrite, which is the exact behaviour it exists to prevent.
This is the right shape for a record. A field several people edit at once wants a collaborative field instead, where the merge is the point and a refusal would be the failure.
On the client, useDraft turns the refusal into a choice the
user can answer.
Errors a client should act on
Throw HighXError(code, message) for any outcome the
interface has to explain. A plain Error is reported as "an unexpected error
occurred" — right for a bug, wrong for a conflict.
Identity, never arguments
// Wrong — a client can send any tenantId it likes.
handler: (ctx, args) => ctx.db.query("notes").withIndex("byTenant", { tenantId: args.tenantId })
// Right — the session decided this.
handler: (ctx) => ctx.db.query("notes").withIndex("byTenant", { tenantId: ctx.identity.tenantId })A well-formed tenant ID from an attacker is still an attacker's tenant ID.