Commands and errors
A command changes data. Three things set it apart from a POST endpoint or a GraphQL mutation: 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, and 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 {
val client = RayfoldClient(
HttpTransport("http://localhost:4000/rayfold", headers = { mapOf("Authorization" to "Bearer customer") }),
)
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 customer
{"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. 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.
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 the guarantee holds across a fleet: a retry that lands on another instance replays the first answer, and two retries that arrive together take the key with one statement, so one of them runs the command and the other waits for it.
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, and since that code is retryable the client would come back with a fresh key and buy a second copy.
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. Anything else reaches the client as internal, and the details stay in the server's log.
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, which lets a form preview a result or an AI agent check a plan before acting. Without the annotation, a dry run is refused with failed_precondition.
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.