Skip to content

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

rayfold
"""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

ts
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 } }] });
},
kotlin
if (qty > book.stock) {
    throw RayfoldException.domain(
        "OutOfStock",
        buildJsonObject {
            put("bookId", book.id)
            put("available", book.stock)
        },
        "Only ${book.stock} left of ${book.title}",
    )
}
java
if (qty > current.stock()) {
    throw Rayfold.domainError("OutOfStock",
        Map.of("bookId", current.id(), "available", current.stock()),
        "Only " + current.stock() + " left of " + current.title());
}
java
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

json
{
  "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

ts
// 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();
tsx
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>}
    </>
  );
}
kotlin
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()
}
http
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:

json
{"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:

json
{"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:

json
{"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:

json
{"id":1,"error":{"code":"domain","message":"Only 0 left","type":"OutOfStock","data":{"bookId":"b2","available":0}},"fin":true}
ts
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`);
}
tsx
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:

json
{"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

Released under the Apache-2.0 license.