Commands and errors
A command changes data. It differs from a POST endpoint or a GraphQL mutation in three ways:
- It always comes back with a patch that tells every client cache what changed.
- It carries an idempotency key, so sending it twice never does it twice.
- The errors it can end with are part of the schema.
Declare it
"""Take copies off the shelf. Fails if there are not enough."""
command buy(bookId: ID, qty: Int = 1 @range(min: 1, max: 10)): Book
throws OutOfStock
emits StockChanged
@allow(write: viewer != null)
error OutOfStock { bookId: ID, available: Int }The arguments, the result, what can go wrong, the events it publishes and who may call it are all in these lines.
Write the resolver
buy: ({ bookId, qty }: { bookId: string; qty: number }) => {
const book = find(bookId);
if (book.stock < qty) {
throw RayfoldError.domain("OutOfStock", { bookId, available: book.stock }, `Only ${book.stock} left`);
}
book.stock -= qty;
return ok(book, { emit: [{ event: "StockChanged", payload: { bookId, stock: book.stock } }] });
},if (qty > book.stock) {
throw RayfoldException.domain(
"OutOfStock",
buildJsonObject {
put("bookId", book.id)
put("available", book.stock)
},
"Only ${book.stock} left of ${book.title}",
)
}if (qty > current.stock()) {
throw Rayfold.domainError("OutOfStock",
Map.of("bookId", current.id(), "available", current.stock()),
"Only " + current.stock() + " left of " + current.title());
}if (qty > current.stock()) {
throw Rayfold.domainError("OutOfStock",
Map.of("bookId", current.id(), "available", current.stock()),
"Only " + current.stock() + " left of " + current.title());
}Throw the declared error when the command cannot go ahead. Otherwise return the entity the command changed, with the events the schema says it emits: ok(book, { emit }) in TypeScript, CommandResult(book, emit = ...) in Kotlin, Rayfold.result(book).emit(...) in Java.
What comes back
{
"id": 1,
"ok": { "$type": "Book", "id": "b3", "title": "Dune", "stock": 6 },
"patch": [
{ "set": "Book:b3", "value": { "$type": "Book", "id": "b3", "title": "Dune", "stock": 6 } }
],
"meta": { "cost": 1 },
"fin": true
}ok is the result. patch lists the entities to update: set gives the new fields of Book:b3 to every client cache that holds that book, so the list, the detail page and the cart badge all show 6 without asking again. You do not write patches by hand; the server derives them from what the command returns.
Call it
// The command returns the changed book, and the client cache applies it: no second request.
const stop = client.watch<Book>("book", { id: "b1" }, { shape: "{ id stock }" }, (b) => console.log("stock is now", b.stock));
await client.command("buy", { bookId: "b1", qty: 1 });
stop();function BuyButton({ id }: { id: string }) {
const [buy, { running, error }] = useCommand<Book>("buy");
const soldOut = (error as RayfoldClientError | undefined)?.is("OutOfStock");
return (
<>
<button disabled={running} onClick={() => buy({ bookId: id })}>
Buy
</button>
{soldOut && <span role="alert"> Sold out</span>}
</>
);
}fun main() = runBlocking {
// the access token the user's sign-in produced; a local run signs a customer's with the development key
val accessToken = System.getenv("TOKEN") ?: devToken("u1", "customer")
val client = RayfoldClient(
HttpTransport("http://localhost:4000/rayfold", headers = { mapOf("Authorization" to "Bearer $accessToken") }),
)
buyOneCopy(client, ::println)
}
suspend fun buyOneCopy(client: RayfoldClient, say: (String) -> Unit) = coroutineScope {
val book = client.query("book", args("id" to "b1"), shape = "{ title stock author { name } }").jsonObject
say("${book.text("title")} by ${book.getValue("author").jsonObject.text("name")}: ${book.text("stock")} in stock")
// the book as the client's cache holds it: now, and again whenever the cache changes it
val stock = Channel<String>(Channel.UNLIMITED)
val watch = launch {
client.watch("book", args("id" to "b1"), shape = "{ id stock }").collect { stock.send(it.jsonObject.text("stock")) }
}
say("watching: ${stock.receive()} in stock")
client.command("buy", args("bookId" to "b1", "qty" to 1), shape = "{ id stock }")
// no second read: the purchase came back with a patch for Book:b1, and the watch saw the cache apply it
say("after buying one: ${stock.receive()} in stock")
watch.cancel()
}POST /rayfold
Content-Type: application/rayfold+json
Authorization: Bearer <access token>
{"rayfold":"0.1","ops":[{"id":1,"op":"buy","args":{"bookId":"b3"},"key":"6c1f0d2e-buy-b3-0001"}]}Safe to retry
Every command carries a key of 16 to 128 characters, unless the schema marks it @idempotent(false) or it arrives through a PUT, PATCH or DELETE REST binding, whose method is already idempotent. Without one the server refuses it:
{"id":1,"error":{"code":"invalid_argument","message":"buy(): commands require an idempotency key of 16-128 characters"},"fin":true}Send the same command with the same key again, after a timeout or a dropped connection, and the server answers with the original result instead of running it a second time. meta.replay says so:
{"id":1,"ok":{"$type":"Book","id":"b3","title":"Dune","stock":6},"patch":[{"set":"Book:b3","value":{"$type":"Book","id":"b3","title":"Dune","stock":6}}],"meta":{"cost":1,"replay":true},"fin":true}The client libraries create a key for each call and keep it when they retry or replay a command queued offline. A command marked @idempotent(false) ignores the key it is sent, so every call runs.
A record belongs to the caller who made it and to the exact command it answered. Send a key with no viewer and the server answers unauthenticated: a replay scope needs someone to scope it to. Send a key that was used for another operation, or the same operation with different arguments, and it answers already_exists. It does not hand back the answer to the other request.
A record is kept for 24 hours unless the store is told otherwise, and it has expired once it is that old: a retry made a millisecond short of a day replays, and one made a day after runs the command again. Both runtimes and every store draw that line at the same instant. The default store tells the time by the server's clock, which a test can move.
Records live in the server's memory by default, which holds for one server. Point every instance at a shared store (PgIdempotencyStore on Node, JdbcIdempotencyStore on the JVM) and retries are safe across a fleet. A retry that lands on another instance replays the first answer. Two retries that arrive together take the key with one statement: one of them runs the command and the other waits for it. A fleet needs a relay too, or the patch a command produced on one instance never reaches the live queries held by the others.
There is one case where a command can still run twice: a server that dies between making the change and writing the record leaves a lease that eventually expires, and the next retry takes the key over and runs it again (spec 12 §4.6).
A command that failed before it changed anything leaves no record, so a retry runs it. When the caller goes away or the deadline passes after the command committed, the record says exactly that:
{"id":1,"error":{"code":"canceled","message":"buy() committed, then the op ended before its result was delivered"},"meta":{"replay":true},"fin":true}The retry is told its effect happened. Replaying the op's own deadline_exceeded would say the opposite. That code is retryable, so the client would come back with a fresh key and buy a second copy.
Conditional writes
To keep a command from overwriting someone else's change, mark the field that says which version of a row you have:
entity Book {
id: ID
title: String
stock: Int
version: Int @version
}Then send the version you read with the command. The resolver checks it against the row with ctx.checkVersion before it writes, and the server refuses the command if the row has moved on since:
const book = await client.query<Book>("book", { id: "b1" }, { shape: "{ id stock version }" });
await client.command("restock", { bookId: "b1", qty: 5 }, { ifVersion: book.version });A stale version comes back as the typed error VersionConflict, carrying the key, the version you expected and the version the row is at. A client can show the current value, or retry against it, without a second round trip. Over a REST binding this is If-Match, and the refusal is 412 Precondition Failed.
A @version field can be an Int, a Long, a String or an Instant; the resolver bumps it on every write. The resolver makes the comparison, because only it reads the row. ctx.checkVersion(key, actual, current) compares the version the command sent with the one the row has, and throws VersionConflict if they differ. A resolver that does not call it writes whatever version the client sent.
restock: ({ bookId, qty }, ctx) => {
const book = store.books.get(bookId);
if (!book) throw new RayfoldError("not_found", `No book ${bookId}`);
ctx.checkVersion(`Book:${bookId}`, book.version, book); // refuses a stale ifVersion, with the current book
book.stock += qty;
book.version += 1;
return book;
},Errors the schema declares
When the resolver throws OutOfStock, the client receives it by name, with the data the schema describes:
{"id":1,"error":{"code":"domain","message":"Only 0 left","type":"OutOfStock","data":{"bookId":"b2","available":0}},"fin":true}try {
await client.command("buy", { bookId: "b2" });
} catch (e) {
if (!(e instanceof RayfoldClientError && e.is("OutOfStock"))) throw e;
const { available } = e.data as { available: number };
console.log(`Sold out: ${available} left`);
}function BuyButton({ id }: { id: string }) {
const [buy, { running, error }] = useCommand<Book>("buy");
const soldOut = (error as RayfoldClientError | undefined)?.is("OutOfStock");
return (
<>
<button disabled={running} onClick={() => buy({ bookId: id })}>
Buy
</button>
{soldOut && <span role="alert"> Sold out</span>}
</>
);
}The message is for people; code branches on type. A resolver may only throw errors its operation declares. A domain error the operation does not declare reaches the client as internal, naming only the operation and the error type (buy raised undeclared error Other). Any other exception becomes internal with the message Internal error, so nothing about the server leaks. The server still sees the original exception: the op hook of an Instrumentation gets it, with its stack, as its outcome's cause. Log it there.
Errors every API shares
Some failures are the same in every Rayfold API, so they have fixed codes that work with retries, alerts and HTTP status codes. The bookshop produces these without any code of its own:
{"id":1,"error":{"code":"invalid_argument","message":"buy().qty: must be <= 10"},"fin":true}
{"id":1,"error":{"code":"unauthenticated","message":"Sign in to access buy()"},"fin":true}
{"id":1,"error":{"code":"permission_denied","message":"Not allowed to access restock()"},"fin":true}Arguments are checked against the schema, @range included, before your resolver runs. Every error type has a page with its causes and what to do.
Dry runs
A command marked @simulate accepts "simulate": true. The resolver sees ctx.simulate and returns what would happen without writing anything. A form can use it to preview a result, and an AI agent to check a plan before acting. Without the annotation, a dry run is refused with failed_precondition. A caller who may not run the command is refused for that first. The clients leave their cache alone for a dry run: its result and patch are not applied.
Next
- Who may run which command: Who can do what.
- See other people's changes as they happen: Live updates.
- Show a command's result before the server answers: Offline and optimistic.