Skip to content

Changing a schema safely ​

A Rayfold schema has no version number, and there is no /v2. You keep one schema and change it only in ways that clients already in the field survive. rayfold check refuses the other changes. This page shows which changes are safe, how to retire a field, and how to run the check in CI with a lockfile.

Record what shipped ​

rayfold lock writes the schema as it is now to rayfold.lock.json: the whole schema, its hash, the field ordinals and when it was locked. Commit that file with the release.

sh
npx rayfold lock src/bookshop.rayfold
# wrote rayfold.lock.json (hash 683ba13db169)

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

From then on rayfold check src/bookshop.rayfold compares the schema against rayfold.lock.json in the current directory. To compare against another file, name it with --against; an older copy of the .rayfold file works too. The check exits with status 1 when a change would break a client, so the same command fails a CI build. Run rayfold lock again when the next release ships.

Add freely ​

A client never asks for what it does not know about, so adding is safe. Here the bookshop gets a biography for authors, a page count for books, an author query and an optional filter on books:

rayfold
entity Author {
  id: ID
  name: String
  bio: 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")
  pages: Int?
}

query books(page: PageArgs = { first: 20 }, inStock: Boolean = false): Page<Book>
query author(id: ID): Author?
ok        Author.bio: field added [field-added]
ok        Book.pages: field added [field-added]
ok        books().inStock: optional argument added [arg-added]
ok        author(): query author added [op-added]

OK: compatible with rayfold.lock.json (4 changes)

Put new fields where they read best ​

The lockfile records each field's number (its ordinal), and a field keeps its number by name. Adding pages between title and stock is as compatible as adding it at the end. Against rayfold.lock.json the check reports only Book.pages: field added. A change of number is what breaks: an @ordinal(n) that disagrees with the lock, or a new field given a number the lock already assigned. Locking again keeps the numbers. pages gets the next number the type has not used, and title and stock keep theirs.

An older copy of the .rayfold file has no record of the numbers. Checked against one, the fields after pages are reported as moved:

warning   Book.stock: moved from position 3 to 4; against a lockfile it keeps its ordinal by name [ordinal-shifted]
warning   Book.author: moved from position 4 to 5; against a lockfile it keeps its ordinal by name [ordinal-shifted]
warning   Book.costPrice: moved from position 5 to 6; against a lockfile it keeps its ordinal by name [ordinal-shifted]
ok        Book.pages: field added [field-added]

OK: compatible with bookshop.old.rayfold (4 changes)

What breaks a client ​

This version of the schema makes four mistakes at once:

rayfold
entity Book @cache(maxAge: 60s, scope: public) {
  id: ID
  title: String?
  stock: Int
  author: Author
}

command buy(bookId: ID, qty: Int = 1 @range(min: 1, max: 10), note: String): Book
  throws OutOfStock
  emits StockChanged
  @allow(write: viewer != null)

command addStock(bookId: ID, qty: Int @range(min: 1, max: 1000)): Book
  emits StockChanged
  @allow(write: viewer.role == "staff")
BREAKING  Book.title: String -> String? [field-nullable]
BREAKING  Book.costPrice: field Book.costPrice removed (deprecate with a sunset date first) [field-removed]
BREAKING  buy().note: required argument added without a default [arg-added-required]
BREAKING  restock(): command restock removed (deprecate with a sunset date first) [op-removed]
ok        addStock(): command addStock added [op-added]

FAILED: breaking changes against rayfold.lock.json
  • title: String?: code that shows the title without checking for null would now fail. The other direction, a nullable result field becoming non-null, is allowed. For arguments and input fields the rule flips: optional to required breaks callers.
  • Removing costPrice breaks every client that still reads it. Deprecate it with a sunset date first.
  • note: String has no default, so every existing buy call is now missing an argument.
  • A rename is a removal plus an addition. Add addStock, deprecate restock, and remove it after its sunset.

Changing a field's type and removing an enum value or union member are breaking too.

Changes that need a look ​

Some changes are safe for most clients but not all. The check reports them as warnings and still passes:

rayfold
error ShelfFull { bookId: ID, space: Int }

query books(page: PageArgs = { first: 20 }): Page<Book> @allow(read: viewer != null)

command restock(bookId: ID, qty: Int @range(min: 1, max: 1000)): Book
  throws ShelfFull
  emits StockChanged
  @allow(write: viewer.role == "staff")
warning   books(): a policy was added where none existed; some callers may now be denied [policy-added]
warning   restock(): now throws ShelfFull; clients with exhaustive handling must be updated [throws-added]
ok        ShelfFull: error ShelfFull added [type-added]

OK: compatible with rayfold.lock.json (3 changes)

Anonymous callers of books are now refused, and a client that handles each error type of restock by name has one more to handle. With --strict, as in npx rayfold check src/bookshop.rayfold --strict, warnings fail the check too: the same three lines print, then FAILED: warnings against rayfold.lock.json (--strict), and the exit status is 1.

Retire a field ​

To remove something, first mark it @deprecated with a sunset date and ship that:

rayfold
  """What the shop paid. Only staff can see it."""
  costPrice: Decimal? @allow(read: viewer.role == "staff")
    @deprecated(reason: "Read it from the staff stock report", sunset: "2026-12-31")

Adding the annotation changes nothing for clients (OK: compatible with rayfold.lock.json (0 changes)). Lock that version. Removing the field before its sunset date still fails:

BREAKING  Book.costPrice: field Book.costPrice removed (deprecate with a sunset date first) [field-removed]

FAILED: breaking changes against rayfold.lock.json

On or after the sunset date the same removal passes. With a locked sunset of 2026-09-01 and the check run on 2026-09-15:

ok        Book.costPrice: field Book.costPrice removed after its sunset date [field-removed-after-sunset]

OK: compatible with rayfold.lock.json (1 change)

The check reads the sunset from the locked schema and compares it with today's date, so the deprecation has to be in the lockfile before the removal.

Find out who still uses it ​

Traffic tells you whether anyone still relies on a field before its sunset date. Give the server a usage sink and it records, per client, each operation and field that requests touch. It records no arguments, values or viewers.

ts
import { readFileSync, writeFileSync } from "node:fs";
import { MemoryUsage, createRayfoldServer } from "@rayfold/server";
import { resolvers, seed } from "./resolvers.ts";

const schema = readFileSync(new URL("./bookshop.rayfold", import.meta.url), "utf8");
const usage = new MemoryUsage();
const server = createRayfoldServer({ schema, resolvers: resolvers(seed()), usage });
writeFileSync("usage.json", JSON.stringify(usage.snapshot(), null, 2)); // later: from an admin route or a timer

Clients name themselves with the Rayfold-Client header, or the client option of the TypeScript client, such as client: "web/1.4.0". Each entry of the snapshot looks like this:

json
{ "op": "book", "path": "Book.costPrice", "client": "admin/0.9.2", "lastSeen": "2026-09-15T08:21:51.201Z", "count": 1 }

Say a web client read { title stock author { name } } and a staff tool read { title costPrice }. Run the check against the schema from the section above, where costPrice carries @deprecated. The still used line reports only on deprecated members:

sh
npx rayfold check src/bookshop.rayfold --unused usage.json --since 30d
unused      books(): no traffic
unused      buy(): no traffic
unused      restock(): no traffic
unused      Author.id: no traffic
unused      Book.id: no traffic
still used  Book.costPrice: admin/0.9.2

5 members with no traffic from 2 clients; 0 records older than the window.

still used lists deprecated members and the clients that asked for them inside the window (30d, 12h, 90m). "Unused" means no traffic was seen, not that nothing could call it. The snapshot should cover every server and a long enough period. MemoryUsage keeps one process's records in memory. In production, pass your own object with a record(event, at) method that forwards to the metrics you already run.

Next ​

Released under the Apache-2.0 license.