Skip to content

Queries and shapes ​

A query reads data. The caller sends a shape that lists the fields it wants, and the server resolves those fields and nothing else. When a screen needs one more field, the client asks for it: no new endpoint, no field fetched just in case.

Ask for fields ​

ts
// Run the server first (npm run server), then: npm run client
import { RayfoldClient, RayfoldClientError, createFetchTransport } from "@rayfold/client";
import { devToken } from "./auth.ts";

interface Book { id: string; title: string; stock: number; author: { name: string } }

// the access token the user's sign-in produced; a local run signs a customer's with the development key
const accessToken = process.env["TOKEN"] ?? (await devToken("u1", "customer"));

const client = new RayfoldClient({
  transport: createFetchTransport({
    url: "http://localhost:4000/rayfold",
    headers: () => ({ authorization: `Bearer ${accessToken}` }),
  }),
});

const book = await client.query<Book>("book", { id: "b1" }, { shape: "{ id title stock author { name } }" });
console.log(`${book.title} by ${book.author.name}: ${book.stock} in stock`);
tsx
function BookList() {
  const { data, error, loading } = useQuery<{ items: Book[] }>("books", { page: { first: 20 } }, { shape: "{ items { id title author { name } } }" });

  if (error) return <p role="alert">Could not load the books.</p>;
  if (loading && !data) return <p>Loading...</p>;
  return (
    <ul>
      {data?.items.map((book) => (
        <li key={book.id}>
          <strong>{book.title}</strong> by {book.author.name} <Stock id={book.id} /> <BuyButton id={book.id} />
        </li>
      ))}
    </ul>
  );
}
kotlin
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()
}
http
POST /rayfold
Content-Type: application/rayfold+json
Rayfold-Safe: true

{"rayfold":"0.1","ops":[{"id":1,"op":"book","args":{"id":"b1"},"shape":"{ title stock author { name } }"}]}

A read goes out as a POST because a request is a batch: several operations, each with its arguments and shape, which do not fit in a URL. Rayfold-Safe: true marks it as a read. The server then refuses anything in it that is not a query, and the answer may be cached like a GET. HTTP's QUERY method, a POST that is safe by definition, says the same where the client and the proxies support it, and a single query can also be a plain GET by URL. Commands go as a plain POST. Caching has all three forms.

The answer is a frame for operation 1:

json
{
  "id": 1,
  "data": {
    "$type": "Book",
    "title": "A Wizard of Earthsea",
    "stock": 3,
    "author": { "$type": "Author", "name": "Ursula K. Le Guin" }
  },
  "meta": { "cost": 2 },
  "fin": true
}

$type names the type of each entity in the result. meta.cost is what the query counts against the batch's budget, and fin says this operation is finished.

What a shape can say ​

A shape is a list of field names, with nested braces for fields that hold objects:

{ title stock author { name } }
  • A field that takes arguments gets them in parentheses: { reviews(page: { first: 3 }) { items { rating } } }.
  • cheap: price returns the field under another name, which you need when you select one field twice with different arguments.
  • $name in an argument is filled from the operation's vars, so the shape text stays the same for every call and can be cached and registered once.
  • ...Book.card includes a named view: a shape the schema defines once for everyone.
  • @defer { reviews { ... } } sends that part in a later frame, so the rest of the screen can show first.
  • ...on Book { title } selects fields that only exist on one of the types a field can hold.

The shapes chapter of the specification has the full grammar.

One field, many types ​

A field typed as an interface or a union can hold more than one kind of thing, so a shape says what to take from each:

{
  results {
    ...on Book   { id title }
    ...on Author { id name }
  }
}

Fields the types share can be selected once outside the conditions; each ...on adds what is particular to that type. Every result carries its $type, so a client knows which arm it got.

Deliver a field later ​

Not every field has to arrive with the first frame. Four markers control that, and they combine:

Where it goesWhat it does
@defer { ... }in the shapeSend this part in a later at frame. The caller decides.
@lazyon the fieldThe field arrives in a later frame by default. The schema decides, for a field that is usually expensive and rarely needed.
@eagerin the shapeOverrides @lazy for this request: send it with everything else.
@partialon the fieldThe field may fail on its own (null, and an entry in the frame's errors) instead of failing the operation.

A deferred part arrives as an at frame naming its path, and a client fills it in when it lands. The first frame is what a screen can paint immediately. Put in it what the screen needs before it can show anything, and defer the rest.

Or leave the shape out ​

Without a shape, the result is the type's default view: its own scalar fields, unless the schema names another view default. Here is book b1 without a shape, for a customer and then for staff:

json
{"id":1,"data":{"$type":"Book","id":"b1","title":"A Wizard of Earthsea","stock":3},"meta":{"cost":1},"fin":true}
{"id":1,"data":{"$type":"Book","id":"b1","title":"A Wizard of Earthsea","stock":3,"costPrice":"4.20"},"meta":{"cost":1},"fin":true}

A field the caller may not read is left out of a default view without an error, which is why only staff see costPrice. A customer who asks for it by name gets permission_denied. With default views, a bare call from curl or an AI agent returns something useful and never leaks a field.

Fields that hold other entities are resolved by loaders, and a loader receives every parent at once:

ts
// One call per level: a page of 20 books asks for their authors once, not 20 times.
Book: {
  author: (books: Book[]) => books.map((b) => store.authors.get(b.authorId) ?? null),
},
kotlin
fields = mapOf(
    "Book" to mapOf(
        // called once per level with every book in the result, and answered with one lookup
        "author" to { books, _, _ -> store.authors(books.map { it.string("authorId") }).map { it?.toJson() } },
    ),
),
java
// called once per level with every book in the result, and answered with one lookup
.field("Book", "author", (books, args, ctx) ->
    store.authors(books.stream().map(book -> book.getString("authorId")).toList()))
java
// called once per level with every book in the result, and answered with one lookup
@RayfoldField(type = "Book", field = "author")
public List<Author> author(List<Book> books) {
    return store.authors(books.stream().map(Book::authorId).toList());
}

The server resolves a shape level by level. For a page of 20 books, Book.author is called once with all 20 books. It returns their authors in the same order, one entry per book, with null only where the field's type allows it (author: Author?). On a non-null field a null is an internal error naming the field. You get one lookup per level however many rows there are, without writing a DataLoader.

rayfold explain shows the plan for a query before you run it:

sh
npx rayfold explain src/bookshop.rayfold books --shape "{ items { title author { name } } }"

rayfold comes with @rayfold/cli, installed in the project with npm install --save-dev @rayfold/cli; without it, npx @rayfold/cli runs it instead.

Tuning a loader ​

By default a loader gets every parent at once, which avoids the N+1. @load(single) marks a field whose source can only be asked for one parent at a time. The executor then calls it once per parent.

Pages ​

A query that returns Page<Book> gets a cursor, hasMore and total without any extra schema. Its resolver returns those along with the items; see books in the resolvers.

json
{ "id": 1, "op": "books", "args": { "page": { "first": 2 } }, "shape": "{ items { id title } cursor hasMore total }" }
json
{
  "id": 1,
  "data": {
    "items": [
      { "$type": "Book", "id": "b1", "title": "A Wizard of Earthsea" },
      { "$type": "Book", "id": "b2", "title": "The Left Hand of Darkness" }
    ],
    "cursor": "b2",
    "hasMore": true,
    "total": 3
  },
  "meta": { "cost": 4 },
  "fin": true
}

The next page passes the cursor back: "page": { "first": 2, "after": "b2" }.

Cursors or offsets ​

@page(cursor) is the default, and the one to prefer. A cursor names a position in the result, so a row inserted while the reader is on page two does not shift everything down and show them a row twice. Pass the cursor from one page as after on the next.

@page(offset) numbers pages instead, and PageArgs.offset skips that many rows. Use it when the caller needs to jump to page seven, as in a table with numbered pages. Concurrent inserts can then duplicate or skip a row at a page boundary.

What a query costs ​

Before it runs anything, the server works out what a batch can cost from the page sizes it asks for and the schema's @cost hints, and refuses a batch over the server's per-batch budget (1000 by default) with resource_exhausted. Loading rows costs; reading a scalar field of a row already loaded does not. Each frame reports its cost in meta.cost: 2 for the book with its author above, 4 for the page of two books.

Next ​

Released under the Apache-2.0 license.