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
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/cli2. Describe the API
Save the schema as src/bookshop.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:
npx rayfold check src/bookshop.rayfold3. Write the resolvers
src/resolvers.ts, after the data and types (see the whole file):
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),
},
};
}QueryandCommandhave one function per operation, named as in the schema. Arguments arrive already checked against it,@rangeincluded.Book.authoris 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 announcesStockChanged.
4. Say who is calling
// 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:
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:
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");
});npx tsx src/server.tsOpen 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:
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:
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:
// 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:
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:
npx tsx src/client.tsNext
- Put a UI on it: React.
- How shapes, loaders and pages work: Queries and shapes.
- Everything the client does with a command's result: Commands and errors.
- The same requests, without installing anything: the playground.