Buildable example

Golf simulator booking — ZigBase examples

A realistic app with hooks, business routes, a DB-touching cron, and a Zigapagos frontend from a comptime static directory.

“Airbnb for golf simulators”: hosts list golf simulators, guests book time slots. This is the realistic counterpart to the blog example — where the blog example is a bare packaging proof (one slug hook), golfsim exercises the hard parts of building a real backend on ZigBase as a library.

The golfsim example's signed-in listings page: published simulator listings ('Sunset Tee Times', 'Early Bird Range Hour') with bay, launch monitor, and hourly price, each with start/end time pickers and a 'Hold this slot' booking button
The golfsim example’s signed-in listings page: published simulator listings (‘Sunset Tee Times’, ‘Early Bird Range Hour’) with bay, launch monitor, and hourly price, each with start/end time pickers and a ‘Hold this slot’ booking button

What it proves

Build with zig build -Dimage-thumbnails=true and configure a trusted ImageMagick executable to enable the 320×240 card profile for local PNG/JPEG/WebP listing photos. Request /api/files/listings/:record/:filename/thumbnail/card with the same file authorization as the original. The gallery discovers the compiled profile from the public health route and uses derivatives when enabled, with a one-time original-photo fallback on errors. Disabled builds serve original photos. See image thumbnails for executable setup, resource tuning and conditional caching. No image codec is linked into the application.

The browser frontend consumes Golfsim’s gen-client output directly for collection queries, signup, file handling, RPC paths and record types. CI checks both generated-file freshness and the frontend API’s types; browser transport tests preserve cookies, CSRF and pending-factor behavior. Hook-owned guest/author fields and untyped imperative-route responses remain explicit boundaries rather than duplicated handwritten collection models.

The example also demonstrates compile-time-selected TOTP two-factor authentication: optional enrollment from Account security, required enrollment for operator-selected members, authenticator verification, and single-use recovery codes. Its server-side policy hook combines application requirements with voluntary enrollment. The same hook can evaluate group memberships without imposing a framework-owned group model.

  1. A computed + validating before_create hook on bookings. It reads related data (the target listing) through ctx.records(), rejects invalid input (a 400 to the client), stamps a server-authoritative guest from the authenticated identity, and computes a derived price_total = booked hours × the listing’s price_per_hour.
  2. A custom business route POST /api/bookings/:id/confirm (auth required). It reads the :id path param, loads the booking from the DB, flips its status to "confirmed", and returns the updated record as JSON — or 404 when the booking does not exist.
  3. A DB-touching interval cron job expire-holds (every 15 minutes). It reads and writes through the passed-in *Ctx via ctx.records(), lists stale pending holds whose slot has already started, and marks them "cancelled".
  4. A public smoke route GET /api/golfsim/health returning a small JSON literal.

Everything else (HTTP API, SQLite storage, auth, file storage, the admin UI, the CLI) comes straight from the framework via the public zigbase.* exports.

Pre-1.0: ZigBase is pre-1.0 — the hook/route/job config shapes and the module API may change between releases.

The computed + validating hook (before_create on bookings)

fn prepareBooking(ctx: *zigbase.Ctx, ev: *zigbase.RecordEvent) anyerror!void {
    const rec = &ev.record.object;
    const listing_id = /* read the `listing` relation id from rec */;

    // Read RELATED data through the per-request capability object; reject if it's
    // not bookable. `ctx.records()` manages the pooled connection itself.
    const listing = (try ctx.records().get("listings", listing_id, .{})) orelse return error.ListingNotFound;

    // COMPUTE a derived field — note: mutations allocate with ev.arena.a.
    const price_total = hours * rate;
    try rec.put(ev.arena.a, "price_total", .{ .float = price_total });

    // Stamp the guest from the authenticated identity (server-authoritative).
    try rec.put(ev.arena.a, "guest", .{ .string = guest_id });
    try rec.put(ev.arena.a, "status", .{ .string = "pending" });
}

Returning an error from a before_create hook rejects the write → the client gets a 400. Record mutations must allocate with ev.arena.a (ev.arena is a typed request arena; .a is the allocator that owns ev.record).

The business route (path param + DB write)

// Typed route: `void` input, `std.json.Value` output (a dynamic record). The
// thunk serializes the returned record to a 200 JSON body.
fn confirmBooking(req: *zigbase.Req(void)) zigbase.RouteError!std.json.Value {
    const id = req.param("id") orelse return req.fail(400, "Missing booking id.");

    // Read/write through the per-request capability object — `req.ctx.records()`
    // manages the pooled connection itself (no manual acquireWriter / Data wiring).
    const records = req.ctx.records();
    _ = (records.get("bookings", id, .{}) catch return error.RouteFailed) orelse return error.NotFound;
    const updated = (records.update("bookings", id, /* { "status": "confirmed" } */) catch return error.RouteFailed) orelse return error.NotFound;
    return updated; // thunk serializes it to the 200 body
}

The route is registered with .auth = .authed, so the framework enforces authentication before the handler runs.

The frontend’s “My bookings” page drives exactly this flow: a held slot starts out pending (stamped by the hook, with the computed price_total), and the “Confirm (custom route)” button POSTs to /api/bookings/:id/confirm:

The golfsim example's 'My bookings' page: a 'Sunset Tee Times' booking card showing the booked time range, the hook-computed $90.00 price total, a 'pending' status, and a 'Confirm (custom route)' button that hits POST /api/bookings/:id/confirm
The golfsim example’s ‘My bookings’ page: a ‘Sunset Tee Times’ booking card showing the booked time range, the hook-computed $90.00 price total, a ‘pending’ status, and a ‘Confirm (custom route)’ button that hits POST /api/bookings/:id/confirm

The DB-touching cron job

fn expireHolds(ctx: *zigbase.Ctx, ev: *zigbase.events.JobEvent) anyerror!void {
    _ = ev;
    const stale = ctx.records().list("bookings", .{
        .filter = "status = \"pending\" && starts_at < @now",
        .perPage = 200,
    }) catch |err| switch (err) {
        error.UnknownCollection => return, // no-op until provisioning runs
        else => return err,
    };
    for (stale.items) |item| _ = try ctx.records().update("bookings", /* item.id */, /* cancelled */);
}

This is the pattern for real DB access inside a background job: the scheduler hands the job a *Ctx whose records() handle reads and writes through the managed pool — the framework releases any lazily-acquired connection on exit.

Handler signatures (the contract)

FeatureSignature
record hookfn(*zigbase.Ctx, *zigbase.RecordEvent) anyerror!void
typed custom routefn(*zigbase.Req(In)) zigbase.RouteError!Out
untyped custom routefn(*zigbase.Ctx) anyerror!zigbase.http.Response
cron jobfn(*zigbase.Ctx, *zigbase.events.JobEvent) anyerror!void

Frontend (Zigapagos frontend)

frontend/ is a Zigapagos site whose targeted Preact islands drive the whole booking flow: sign in, browse published listings, hold a slot (the beforeCreate hook validates the listing, computes price_total, stamps the guest, and forces status=pending), then confirm it through the custom POST /api/bookings/:id/confirm route.

cd frontend && ./build.sh && cd ..
zig build
# --insecure-cookies: local dev is over plain HTTP, and auth cookies are Secure by default.
# A strong JWT secret is auto-generated and persisted under the data dir on first run.
./zig-out/bin/golfsim serve --insecure-cookies --data-dir ./data
# open http://127.0.0.1:8090/  — same-origin frontend, so no --serve-static or --realtime-origins needed

This demonstrates the comptime-hardcoded static mode: .static_files = .{ .dir = "frontend/dist" } bakes the directory into the binary’s config, and --serve-static is rejected as an unknown flag. The collections (users / simulators / listings / bookings) are provisioned at startup from the comptime .collections schema.

Provisioning the collections

The collections are provisioned automatically at startup via the comptime .collections schema in src/main.zig — no manual API calls needed. The canonical REST-API provisioning script in Recipes → Provisioning your schema remains as an alternative for running the stock binary or fine-grained control.

A quick smoke once the server is up:

curl -s http://127.0.0.1:8090/api/golfsim/health
# -> {"status":"ok","app":"golfsim"}

Generated TypeScript client

golfsim ships a generated, fully type-safe TypeScript client at clients/typescript/zbase.gen.ts. The prerequisite is that src/main.zig exposes pub const App at module scope — the generator reads the schema directly from the comptime App declaration. Regenerate with zig build gen-client; the CI staleness gate (zig build gen-client-check) fails if the committed snapshot is out of date.

# Regenerate the typed client from the schema:
zig build gen-client

# CI staleness gate — fails if the committed zbase.gen.ts is stale:
zig build gen-client-check

# Build @zigbase/client, then typecheck + run the full e2e suite:
cd ../../clients/typescript && npm run build && cd ../../examples/golfsim
npm install && npm run typecheck && npm run test:e2e

The e2e suite drives the generated typed client against a live golfsim binary — covering CRUD, filtering, auth, and realtime with full TypeScript type-checking.

Using zb.rpc.*

The generated client exposes typed methods for each typed custom route under zb.rpc.*:

// bookingsConfirm: POST /api/bookings/:id/confirm → unknown (cast for type-safe access)
const confirmed = await zb.rpc.bookingsConfirm({ id: booking.id }) as Booking;

// bookingsCancel: POST /api/bookings/:id/cancel
await zb.rpc.bookingsCancel({ id: booking.id });

// listingsAvailability: GET /api/listings/:id/availability
const avail = await zb.rpc.listingsAvailability({ id: listing.id });

// golfsimHealth: GET /api/golfsim/health → typed HealthOut
// { status: string; app: string; thumbnail_profile: string | null }
const health = await zb.rpc.golfsimHealth();

unknown outputs correspond to std.json.Value in Zig — cast to your concrete type for field access. The e2e test (test/golfsim.e2e.test.ts) exercises bookingsConfirm live through this surface.

Untyped routes (raw responses)

golfsim also registers an untyped route — GET /api/golfsim/calendar.ics (calendarFeed in src/main.zig). Untyped handlers (fn(*zigbase.Ctx) anyerror!http.Response) own the whole response, so they can set a non-JSON content-type (here text/calendar), cookies, or a redirect — none of which the always-JSON typed thunk can express. Because they carry no typed Input/Output, the generator deliberately leaves them out of zb.rpc.*; call them directly:

const ics = await fetch("/api/golfsim/calendar.ics").then((r) => r.text());
// BEGIN:VCALENDAR … a real text/calendar document, not JSON

Building and running

This example needs Zig 0.16, which you can get via mise (mise exec zig@0.16.0 -- zig ...). From examples/golfsim/:

cd frontend && ./build.sh && cd ..
zig build       # produces ./zig-out/bin/golfsim
./zig-out/bin/golfsim superuser create --email you@example.com --password "<pw>" --data-dir ./data
# --insecure-cookies: local dev is over plain HTTP, and auth cookies are Secure by default.
# A strong JWT secret is auto-generated and persisted under the data dir on first run.
./zig-out/bin/golfsim serve --insecure-cookies --data-dir ./data
# open http://127.0.0.1:8090/  — frontend served automatically (same-origin), no --serve-static flag

Register a guest, create a published listing, and POST /api/collections/bookings/records — the hook fills in guest, status and price_total; POST /api/bookings/:id/confirm flips it to confirmed; and the expire-holds cron sweeps stale pending holds.


View source on GitHub →