Skip to content

The schema

A Rayfold API starts with a schema. It is the contract between server and clients: the server is checked against it, clients learn from it what they can ask for, and the rules about who may do what, what things cost and how long results may be cached live in it too, instead of in code scattered across handlers.

Here is the bookshop every guide on this site builds:

rayfold
entity Author {
  id: ID
  name: String
}

entity Book @cache(maxAge: 60s, scope: public) {
  id: ID
  title: String
  stock: Int
  author: Author
  """What the shop paid. Only staff can see it."""
  costPrice: Decimal? @allow(read: viewer.role == "staff")
}

error OutOfStock { bookId: ID, available: Int }

event StockChanged { bookId: ID, stock: Int }

query book(id: ID): Book?
query books(page: PageArgs = { first: 20 }): Page<Book>

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

command restock(bookId: ID, qty: Int @range(min: 1, max: 1000)): Book
  emits StockChanged
  @allow(write: viewer.role == "staff")

Types

KindWhat it is
entitySomething with identity. It has an id: ID, and caches and patches know it as Book:b1.
objectA value without identity, such as an address. It lives inside the entity that holds it.
inputA structured argument. Inputs contain only scalars, enums and other inputs.
enumA fixed set of names: enum Format { HARDCOVER PAPERBACK EBOOK }.
unionOne of several types: `union SearchHit = Book
errorAn error an operation can end with, and the data it carries.
eventA fact a command publishes, such as StockChanged.

Built-in scalars are ID, String, Int, Long, Float, Boolean, Decimal, Instant, Date, Duration, Bytes and JSON, plus the generic Page<T>. Decimal travels as a string so no digit is lost, and a Long above 2^53 does too.

Required unless marked

A field is required unless its type ends in ?:

rayfold
entity Book {
  id: ID
  title: String        // always there
  subtitle: String?    // may be null
  tags: [String]       // a list, never null, of strings that are never null
  notes: [String?]?    // both may be null
}

This is the other way round from GraphQL, and it matches how APIs are used: most fields are always there, so the schema marks the exceptions.

Operations

rayfold
query book(id: ID): Book?
query books(page: PageArgs = { first: 20 }): Page<Book>

command buy(bookId: ID, qty: Int = 1 @range(min: 1, max: 10)): Book
  throws OutOfStock
  emits StockChanged
  @allow(write: viewer != null)
KindWhat it does
queryReads. Safe to repeat, cacheable, and any query can be kept open with live: true.
commandChanges something. Carries an idempotency key, returns its result with patches, declares its errors with throws and its events with emits.
streamSends items until it finishes, such as a feed of StockChanged events.

An argument with a default is optional for the caller and always has a value in the resolver.

Descriptions

A """triple-quoted""" string before a definition, field or argument is its description. Descriptions are part of the schema that clients and tools receive: the explorer shows them, and an AI agent calling the API through MCP reads them to decide what to call.

Annotations

Annotations put rules and hints next to the thing they apply to. The ones you will use most:

AnnotationMeaning
@allow(read: ..., write: ...)Who may read or change it. See Who can do what.
@range(min: ..., max: ...)Allowed values, or lengths for strings and lists. Checked before any resolver runs.
@cache(maxAge: 60s, scope: public)How long a result may be cached, and whether a shared cache may keep it.
@cost(base: 5, perItem: 1)What a query costs against the caller's budget.
@deprecated(reason: "...", sunset: "2027-01-01")Going away, and from when. Tooling refuses to remove it earlier.
@simulateThe command accepts dry runs.
@partialThe field may fail on its own instead of failing the whole operation.
@http(method: GET, path: "/books/{id}")Also serve the operation as a REST route.

The schema chapter of the specification lists them all.

Views

A view is a named shape for a type. The view called default is what a caller gets when it asks for no fields:

rayfold
view Book.default = { id title stock author { name } }
view Book.card    = { ...Book.default costPrice }

Without a default view, a type's own scalar fields are its default. Clients can include a view in any shape with ...Book.card.

Check it

rayfold check validates a schema and says what is wrong and where:

sh
npx rayfold check bookshop.rayfold
error    unknown-type  Book.stock: Unknown type Integer
 --> bookshop.rayfold:9:3
  |
9 |   stock: Integer
  |   ^^^^^
  = no type named Integer is defined; declare it, or import the document that has it

Run it in CI with --against the previous version of the schema and it also reports breaking changes. Editors get the same diagnostics through the language server.

Types for your code

The schema can generate types for each stack:

sh
npx rayfold gen ts bookshop.rayfold --out src/schema.ts
npx rayfold gen kotlin bookshop.rayfold --package com.example.bookshop --out Bookshop.kt
npx rayfold gen java bookshop.rayfold --package com.example.bookshop --out Bookshop.java

In TypeScript you can also write the schema in code with @rayfold/builder and get the types inferred; see Schema in TypeScript.

Next

Released under the Apache-2.0 license.