📖 This documentation is also published, web-native, at https://valthon.github.io/zigbase/docs/tutorial — the site is the canonical reading experience.
This walkthrough takes you from an empty database to a working backend for "Airbnb for golf simulators" — hosts list simulators, guests book slots. It ties together every moving part: provisioning collections, setting access rules, registering and logging in a user, creating a record with a file upload, a custom business route, and a scheduled job.
It is meant to be read top to bottom and run as you go. Each step links to the reference docs for depth:
- field types & option shapes → fields.md
- the full curl recipes this tutorial condenses → recipes.md
- endpoint envelopes, rule semantics, realtime, files → api.md
- hooks, custom routes, jobs (the Zig framework) → framework.md
Build the binary and create a superuser (collection management is superuser-only), then start serving:
mise install # Zig 0.16.0, pinned in mise.toml
zig build # -> zig-out/bin/zigbase
./zig-out/bin/zigbase superuser create --email [email protected] --password "a-strong-password" --data-dir ./zb_data
# --insecure-cookies: this tutorial runs over plain HTTP, and auth cookies are Secure by default.
./zig-out/bin/zigbase serve --insecure-cookies --data-dir ./zb_dataThe server listens on http://127.0.0.1:8090 (loopback by default; a random JWT secret is
generated and persisted at zb_data/.jwt_secret on first run). Sanity check:
curl http://127.0.0.1:8090/api/health # {"status":"ok"}Throughout, BASE=http://127.0.0.1:8090.
This walkthrough creates collections with POST /api/collections as the superuser.
For a versioned schema document, use zigbase schema apply instead; it resolves
relation targets by name and coordinates the schema change. See
migration tools. In the REST flow below,
relation fields point at their target by
collection id (the id from the create response, not the name — see
fields.md → the relation gotcha),
so you must create targets first and capture each returned id.
First, log in as the superuser. Superusers live in the built-in _superusers auth
collection, so they use the normal auth endpoint:
TOKEN=$(curl -s -X POST "$BASE/api/collections/_superusers/auth-with-password" \
-H 'Content-Type: application/json' \
-d '{"identity":"[email protected]","password":"a-strong-password"}' | jq -r .token)Create users (auth) and capture its id:
USERS_ID=$(curl -s -X POST "$BASE/api/collections" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{
"name": "users",
"type": "auth",
"fields": [ { "id": "f_name", "name": "name", "type": "text", "options": { "max": 100 } } ],
"listRule": "@public", "viewRule": "@public",
"createRule": "@public",
"updateRule": "@request.auth.id = id",
"deleteRule": "@request.auth.id = id"
}' | jq -r .id)Then simulators (references users by id), then listings (references
simulators). The full four-collection script — including bookings — is in
recipes.md → Provisioning your schema.
Here is listings, which also carries a file field for photos:
SIMS_ID=$(curl -s -X POST "$BASE/api/collections" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d "{
\"name\": \"simulators\", \"type\": \"base\",
\"fields\": [
{ \"id\": \"f_label\", \"name\": \"label\", \"type\": \"text\", \"required\": true, \"options\": { \"min\": 1 } },
{ \"id\": \"f_owner\", \"name\": \"owner\", \"type\": \"relation\", \"required\": true,
\"options\": { \"targetCollectionId\": \"$USERS_ID\", \"cascadeDelete\": true, \"maxSelect\": 1 } }
],
\"listRule\": \"@public\", \"viewRule\": \"@public\",
\"createRule\": \"@request.auth.id != \\\"\\\"\",
\"updateRule\": \"@request.auth.id = owner\",
\"deleteRule\": \"@request.auth.id = owner\"
}" | jq -r .id)
LISTINGS_ID=$(curl -s -X POST "$BASE/api/collections" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d "{
\"name\": \"listings\", \"type\": \"base\",
\"fields\": [
{ \"id\": \"f_title\", \"name\": \"title\", \"type\": \"text\", \"required\": true, \"options\": { \"min\": 1, \"max\": 140 } },
{ \"id\": \"f_price\", \"name\": \"price_per_hour\", \"type\": \"number\", \"required\": true,
\"options\": { \"mode\": \"fixed\", \"scale\": 2, \"min\": 0 } },
{ \"id\": \"f_status\", \"name\": \"status\", \"type\": \"select\", \"required\": true,
\"options\": { \"values\": [\"draft\", \"published\", \"archived\"], \"maxSelect\": 1 } },
{ \"id\": \"f_sim\", \"name\": \"simulator\", \"type\": \"relation\", \"required\": true,
\"options\": { \"targetCollectionId\": \"$SIMS_ID\", \"cascadeDelete\": true, \"maxSelect\": 1 } },
{ \"id\": \"f_photos\", \"name\": \"photos\", \"type\": \"file\",
\"options\": { \"maxSelect\": 6, \"maxSize\": 5242880, \"mimeTypes\": [\"image/png\", \"image/jpeg\"] } }
],
\"listRule\": \"status = \\\"published\\\"\",
\"viewRule\": \"status = \\\"published\\\" || @request.auth.id = simulator.owner\",
\"createRule\": \"@request.auth.id != \\\"\\\"\",
\"updateRule\": \"@request.auth.id = simulator.owner\",
\"deleteRule\": \"@request.auth.id = simulator.owner\"
}" | jq -r .id)The field shapes used here are catalogued in fields.md.
Each collection's five rules (listRule/viewRule/createRule/updateRule/
deleteRule) are filter expressions. Safe-by-default: a blank rule (null or "")
means superuser-only (Locked); the explicit sentinel "@public" means public
(anyone — and ZigBase logs a startup warning for each one); anything else is checked per
request. Highlights from above:
users:createRule: "@public"→ open signup (anyone may create a user). Update/ delete are self-only (@request.auth.id = id).simulators/listings: writes are owner-scoped (@request.auth.id = simulator.owner), which uses relation traversal — following the single-valuesimulatorrelation to read the owner.listingsare publicly listable only whenstatus = "published"; the owner can also view their drafts.
Rule grammar and denial status codes are in
api.md → Access rules; owner-scoped and relation-traversal
patterns (and how the owner field gets set safely) are in
recipes.md → Owner-scoped access rules.
Signup is a record create on the users auth collection (there is no separate
register endpoint). The server hashes the password, strips the plaintext, sets
verified=false, and mints a token key:
curl -s -X POST "$BASE/api/collections/users/records" \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"hostpassword","name":"Demo Host"}'password must be at least minPasswordLength (default 8). Now log in to get a
token and the user's id:
LOGIN=$(curl -s -X POST "$BASE/api/collections/users/auth-with-password" \
-H 'Content-Type: application/json' \
-d '{"identity":"[email protected]","password":"hostpassword"}')
HOST_TOKEN=$(echo "$LOGIN" | jq -r .token)
HOST_ID=$(echo "$LOGIN" | jq -r .record.id)Details: recipes.md → User registration and api.md → Auth.
Create a simulator the host owns:
SIM_ID=$(curl -s -X POST "$BASE/api/collections/simulators/records" \
-H "Authorization: Bearer $HOST_TOKEN" -H 'Content-Type: application/json' \
-d "{\"label\":\"Bay 1 — TrackMan\",\"owner\":\"$HOST_ID\"}" | jq -r .id)Now create a listing with a photo upload. A file field is populated via
multipart/form-data: send the non-file fields as form fields and the file under the
field's name (photos). With curl, use -F:
curl -s -X POST "$BASE/api/collections/listings/records" \
-H "Authorization: Bearer $HOST_TOKEN" \
-F "title=Sunset Tee Times" \
-F "price_per_hour=45.00" \
-F "status=published" \
-F "simulator=$SIM_ID" \
-F "photos=@./bay1.jpg;type=image/jpeg"The uploaded file is stored locally and served from
GET /api/files/listings/:recordId/:filename. Because listings has a public
viewRule for published rows, the photo serves directly; protected collections need
a bearer token, the auth cookie, or a short-lived file token. See
api.md → Files.
Steps 0–4 used the stock binary over REST. From here on you extend it in Zig, so you need ZigBase as a library dependency of your own package. Fetch it:
zig fetch --save git+https://github.com/valthon/zigbaseWire it into your build.zig (ZigBase bundles SQLite's C source and zap, so the
consuming module must link_libc):
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const exe_mod = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.link_libc = true, // required: zigbase carries SQLite + zap transitively
});
const zigbase = b.dependency("zigbase", .{ .target = target, .optimize = optimize });
exe_mod.addImport("zigbase", zigbase.module("zigbase"));
const exe = b.addExecutable(.{ .name = "myapp", .root_module = exe_mod });
b.installArtifact(exe);
}All the handlers below (the route, the job, and the App(.{...}) wiring) live in your
src/main.zig. zig build produces a binary with the same serve / migrate /
superuser create commands as the stock server, plus your extensions. See
framework.md → Add the dependency for the full
reference and examples/blog/build.zig for a complete
working build file.
REST CRUD covers most needs, but "confirm a booking" is business logic. ZigBase is an
embeddable Zig framework: import it, register a custom route, and your binary
is the server. A POST /api/bookings/:id/confirm handler reads the :id path
param, loads the record, and flips its status:
const std = @import("std");
const zigbase = @import("zigbase");
const ConfirmBooking = struct { id: []const u8, user_id: []const u8 };
fn confirmBookingTxn(t: *zigbase.Tx, p: *const ConfirmBooking) anyerror!std.json.Value {
const records = t.records();
const booking = (try records.getAs(struct { listing: []const u8, status: []const u8 }, "bookings", p.id))
orelse return error.BookingNotFound;
const listing = (try records.getAs(struct { simulator: []const u8 }, "listings", booking.listing))
orelse return error.BookingNotFound;
const simulator = (try records.getAs(struct { owner: []const u8 }, "simulators", listing.simulator))
orelse return error.BookingNotFound;
// Trusted record access bypasses REST rules: authorize the complete owner path.
if (!std.mem.eql(u8, simulator.owner, p.user_id)) return error.BookingNotFound;
if (!std.mem.eql(u8, booking.status, "pending")) return error.BookingNotPending;
var patch: std.json.ObjectMap = .empty;
try patch.put(t.arena(), "status", .{ .string = "confirmed" });
return (try records.update("bookings", p.id, .{ .object = patch }))
orelse error.BookingNotFound;
}
fn confirmBooking(ctx: *zigbase.Ctx) anyerror!zigbase.http.Response {
const user = ctx.user() orelse return ctx.jsonError(401, "unauthorized", "Sign in first.");
if (!std.mem.eql(u8, user.collection, "users"))
return ctx.jsonError(403, "forbidden", "A users session is required.");
const id = ctx.request.?.param("id") orelse return ctx.notFound();
const params = ConfirmBooking{ .id = id, .user_id = user.id };
const updated = ctx.txWith(std.json.Value, ¶ms, confirmBookingTxn) catch |err| switch (err) {
error.BookingNotFound => return ctx.notFound(),
error.BookingNotPending => return ctx.jsonError(409, "conflict", "Only pending bookings can be confirmed."),
else => return err,
};
return ctx.json(200, updated);
}ctx (*zigbase.Ctx) supplies the raw request (ctx.request.?) and authenticated
identity (ctx.user()). For independent operations, ctx.records() manages pooled
connections for you. This example instead uses ctx.txWith to hold one writer
transaction: the callback's t.records() runs the ownership reads and status
update on that same connection, and the framework commits or rolls back the unit.
Record operations are trusted server access and do not run REST access rules. The
example therefore verifies the caller's auth collection, walks the ownership
relation, and accepts only the pending-to-confirmed transition inside one SQLite
writer transaction. This tutorial runs one SQLite process; PostgreSQL replicas
also need row locking or a conditional SQL mutation for this read-check-write
sequence. More route guidance is in
recipes.md → custom business route.
A nightly job cancels stale pending bookings. A JobEvent still carries app and
name, but the scheduler now hands the job a *zigbase.Ctx first. You can read and
write through ctx.records(), which manages the pooled connection for you:
fn expireStaleBookings(ctx: *zigbase.Ctx, ev: *zigbase.events.JobEvent) anyerror!void {
const a = ev.app.allocator;
const stale = try ctx.records().list("bookings", .{ .filter = "status = \"pending\"", .perPage = 200 });
for (stale.items) |item| {
const bid = item.object.get("id").?.string;
var patch: std.json.ObjectMap = .empty;
try patch.put(a, "status", .{ .string = "cancelled" });
_ = try ctx.records().update("bookings", bid, .{ .object = patch });
}
}The hook, route, and job come together in one comptime config. This is the whole extended server:
const std = @import("std");
const zigbase = @import("zigbase");
// ... confirmBooking, expireStaleBookings, validateBooking defined above ...
pub fn main(init: std.process.Init) !void {
return zigbase.App(.{
.hooks = .{
.bookings = .{ .beforeCreate = validateBooking }, // overlap check + computed field
},
.routes = .{
.{ .method = .POST, .path = "/api/bookings/:id/confirm", .handler = confirmBooking, .auth = .authed },
},
.pools = .{ .jobs = 2 },
.cron = .{
.{ .name = "expire-bookings",
.schedule = zigbase.schedule.Schedule{ .cron = "0 3 * * *" },
.handler = expireStaleBookings },
},
}).runCli(init);
}runCli gives your binary the same serve / migrate / superuser create / help
commands as the stock server. A misconfigured extension (unknown config key, typo'd
hook phase, wrong-typed handler) is a compile error, so it never reaches runtime.
Build and run your binary exactly as in step 0 (it replaces zig-out/bin/zigbase).
The validateBooking before-hook is shown in full in
recipes.md → computed-field / validation hook.
- Field reference: every type and option → fields.md
- More recipes: provisioning, owner rules, hooks, routes, jobs → recipes.md
- API reference: records query (
filter/sort/expand), realtime, files → api.md - Framework reference: the full hook/route/job/event surface → framework.md
- The worked, buildable example:
examples/blog/ - Caveats: KNOWN_LIMITATIONS.md