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
const client = new RayfoldClient({
  transport: createFetchTransport({
    url: "http://localhost:4000/rayfold",
    headers: () => ({ authorization: "Bearer customer" }),
  }),
});

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 {
    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
Rayfold-Safe: true

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

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 cost against the caller'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.

The shapes chapter of the specification has the full grammar.

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. Asking for it by name is different: the customer gets permission_denied. Default views make a bare call from curl or an AI agent return something useful, and never leak.

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 and returns their authors in the same order, null where there is none. 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 } } }"

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" }.

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 caller's 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.