Skip to content

Get started with TypeScript

A bookshop server with four operations, and a client that reads a book, buys a copy and handles a sold-out title. The finished project is examples/typescript. You need Node.js 22 or later.

1. Create the project

sh
mkdir bookshop && cd bookshop
npm init -y
npm pkg set type=module
npm install @rayfold/server @rayfold/client @rayfold/explorer
npm install --save-dev tsx @rayfold/cli

2. Describe the API

Save the schema as src/bookshop.rayfold:

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

Check it whenever you change it. A mistake is reported with its line and what was probably meant:

sh
npx rayfold check src/bookshop.rayfold

3. Write the resolvers

src/resolvers.ts, after the data and types (see the whole file):

ts
export function resolvers(store: Store): Resolvers {
  const find = (id: string) => {
    const book = store.books.get(id);
    if (!book) throw new RayfoldError("not_found", `No book ${id}`);
    return book;
  };

  return {
    Query: {
      book: ({ id }: { id: string }) => store.books.get(id) ?? null,
      books: ({ page }: { page: { first: number; after?: string | null } }) => {
        const all = [...store.books.values()];
        const start = page.after ? all.findIndex((b) => b.id === page.after) + 1 : 0;
        const items = all.slice(start, start + page.first);
        return { items, total: all.length, hasMore: start + items.length < all.length, cursor: items.at(-1)?.id ?? null };
      },
    },

    Command: {
      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 } }] });
      },
      restock: ({ bookId, qty }: { bookId: string; qty: number }) => {
        const book = find(bookId);
        book.stock += qty;
        return ok(book, { emit: [{ event: "StockChanged", payload: { bookId, stock: book.stock } }] });
      },
    },

    // 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),
    },
  };
}
  • Query and Command have one function per operation, named as in the schema. Arguments arrive already checked against it, @range included.
  • Book.author is a loader. It receives every book in the result at once and returns their authors in the same order, so a page of 50 books looks up authors once.
  • RayfoldError.domain("OutOfStock", ...) is the error the schema declared. The client receives it by name, with its data.
  • ok(book, { emit }) returns the changed book, which the server turns into a patch for every client cache, and announces StockChanged.

4. Say who is calling

ts
// Stands in for real authentication: check your session cookie or JWT here instead.
export function viewerFrom(authorization: string | undefined): Viewer | null {
  if (authorization === "Bearer customer") return { id: "u1", role: "customer" };
  if (authorization === "Bearer staff") return { id: "s1", role: "staff" };
  return null;
}

What this returns is viewer in the schema's @allow rules. The resolvers never check permissions themselves.

5. Start the server

src/bookshop.ts serves the endpoint at /rayfold, with the explorer beside it:

ts
export function bookshopHttp(server: RayfoldServer): Server {
  const endpoint = createHttpHandler(server, { viewer: (req) => viewerFrom(req.headers.authorization) });
  const explorer = createExplorerHandler({ endpoint: "/rayfold", title: "Bookshop" });

  return createServer((req, res) => {
    if (explorer(req, res)) return;
    if (req.url?.startsWith("/rayfold")) return void endpoint(req, res);
    res.writeHead(404).end();
  });
}

src/server.ts starts it:

ts
import { bookshopHttp, createBookshop } from "./bookshop.ts";

const { server } = createBookshop();

bookshopHttp(server).listen(4000, () => {
  console.log("Rayfold on http://localhost:4000/rayfold");
  console.log("Explorer on http://localhost:4000/rayfold/explorer");
});
sh
npx tsx src/server.ts

Open http://localhost:4000/rayfold/explorer to browse the operations and send requests. Paste Bearer customer or Bearer staff as the token to try the commands. Or call it with curl, marking the request as a read with rayfold-safe:

sh
curl -s localhost:4000/rayfold -H 'content-type: application/rayfold+json' -H 'rayfold-safe: true' \
  -d '{"rayfold":"0.1","ops":[{"id":1,"op":"book","args":{"id":"b1"},"shape":"{ title stock author { name } }"}]}'

6. Call it from TypeScript

src/client.ts:

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`);

The shape asks for exactly the fields this code uses. Leave it out and the book's default fields come back.

A command's result updates the client cache, so anything watching that book sees the change without another request:

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();

Errors the schema declares arrive typed:

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`);
}

Run it while the server is up:

sh
npx tsx src/client.ts

Next

Released under the Apache-2.0 license.