zypher — Complete Developer Guide
Version:
0.1.0-beta· Zig0.17.0-dev.704+· SQLite 3.53.0 · Apache 2.0 Repo: https://github.com/zypher-org/zypher
This guide covers two completely different ways to use zypher, both with full step-by-step walkthroughs:
- Part A — The Package Approach: add zypher as a Zig dependency to your own project, wire
build.zigby hand, write every file yourself. - Part B — The CLI Approach: install the
zypherbinary, usezypher newto scaffold a project, and use CLI commands to run, migrate, and manage your app.
Each part builds the same application: a polling HTTP chat room with persistent messages in SQLite, session-based usernames, and an admin panel to moderate messages. Comparing them side-by-side reveals the real trade-offs of each approach.
Table of Contents
- Understanding the Two Approaches
- Part A — zypher as a Package (Manual Wiring)
- A-1 Prerequisites
- A-2 Project Initialization
- A-3 Wiring build.zig.zon
- A-4 Wiring build.zig
- A-5 Designing the Chat Schema
- A-6 The Message Model
- A-7 The User Name Form
- A-8 Templates
- A-9 Request Handlers
- A-10 Middleware and Session Configuration
- A-11 The Admin Panel
- A-12 Wiring main.zig
- A-13 Running the Application
- Part B — zypher via the CLI
- B-1 Building and Installing the CLI
- B-2 Scaffolding the Project
- B-3 Understanding What zypher new Creates
- B-4 Designing the Schema Manifest
- B-5 Running makemigrations
- B-6 Running migrate
- B-7 Modifying the Scaffold for Chat
- B-8 Creating the Superuser
- B-9 Running the Application with the CLI
- B-10 Other CLI Commands
- Side-by-Side Comparison
- Complete CLI API Reference
- Complete Library API Reference
1. Understanding the Two Approaches
zypher is, fundamentally, a Zig library. Like any Zig library, it can be imported as a package in your build.zig.zon. That is the package approach: you control every file, every structure, and every dependency decision from scratch.
The zypher repository also ships a standalone executable called zypher (built from src/cli/main.zig). That binary provides scaffolding, migration running, superuser creation, and a development server launcher. That is the CLI approach: you lean on the tool for the repetitive setup work and focus on your application logic.
Critical fact about the CLI approach: zypher new does not generate a project that uses build.zig.zon to fetch zypher from the internet. Instead, the generated build.zig has a zypher-root build option that defaults to "../..", meaning it expects the zypher source tree to be two directories above your project. This is intentional for the current development phase, where zypher is not yet published to a package registry.
This means the CLI workflow assumes you have the zypher source tree on disk. zypher run infers the zypher root from the binary's location and passes it to zig build.
The package approach, by contrast, uses build.zig.zon to declare zypher as a path dependency (pointing at your local clone) or will eventually use a URL hash when zypher is published.
Neither approach is strictly better — they serve different needs. The comparison is laid out in detail in Section 4.
Part A — zypher as a Package (Manual Wiring)
A-1 Prerequisites
You need:
- Zig 0.17.0-dev (build
704+b8cb78023or later). The framework usesstd.Io,std.process.Init, and other APIs that do not exist in older versions. - A local clone of the zypher repository (for its bundled SQLite amalgamation). The SQLite C source at
vendor/sqlite-amalgamation-3530000/sqlite3.cis compiled as a C library by yourbuild.zig. zypher does not rely on a system-installed SQLite. - A text editor and a terminal.
# Clone zypher somewhere on your machine
git clone https://github.com/zypher-org/zypher.git ~/code/zypher
A-2 Project Initialization
Create your project directory next to your zypher clone. The relative path ../zypher will be used in build.zig.zon.
mkdir ~/code/chat-pkg
cd ~/code/chat-pkg
zig init # creates src/main.zig, build.zig, build.zig.zon
Your directory tree so far:
~/code/
├── zypher/ ← the cloned framework
└── chat-pkg/ ← your new project
├── src/
│ └── main.zig
├── build.zig
└── build.zig.zon
A-3 Wiring build.zig.zon
Open build.zig.zon and replace the generated content with:
.{
.name = .chat-pkg,
.version = "0.1.0",
.fingerprint = 0x0000000000000001,
.minimum_zig_version = "0.17.0-dev.704+b8cb78023",
.dependencies = .{
.zypher = .{
// Path dependency: points to your local clone.
// Change this to .url and .hash once zypher is published.
.path = "../zypher",
},
},
.paths = .{
"build.zig",
"build.zig.zon",
"src",
},
}
The .path field resolves relative to your build.zig.zon file. When zypher eventually publishes a package hash, you will replace this with .url and .hash fields.
A-4 Wiring build.zig
Replace the generated build.zig with the following. Every step is explained in the comments.
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{ .default_target = .{ .abi = .musl } });
const optimize = b.standardOptimizeOption(.{});
// 1. Resolve the zypher dependency declared in build.zig.zon.
const zypher_dep = b.dependency("zypher", .{
.target = target,
.optimize = optimize,
});
// 2. Get the "zypher" module that the dependency exposes.
// This module already links sqlite3 and sets link_libc = true internally.
const zypher_mod = zypher_dep.module("zypher");
// 3. Create your application executable.
const exe = b.addExecutable(.{
.name = "chat-pkg",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "zypher", .module = zypher_mod },
},
}),
});
b.installArtifact(exe);
// 4. `zig build run` starts the server.
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
run_cmd.addPassthruArgs();
b.step("run", "Run the chat server").dependOn(&run_cmd.step);
// 5. `zig build test` runs unit tests.
const unit_tests = b.addTest(.{
.root_module = exe.root_module,
});
b.step("test", "Run tests").dependOn(&b.addRunArtifact(unit_tests).step);
// 6. `zig build doc` generates documentation.
const app_docs = b.addTest(.{ .root_module = exe.root_module });
const install_docs = b.addInstallDirectory(.{
.source_dir = app_docs.getEmittedDocs(),
.install_dir = .prefix,
.install_subdir = "docs",
});
b.step("doc", "Generate documentation").dependOn(&install_docs.step);
}
Verify the dependency resolves:
zig build --dry-run 2>&1
# Should print a build graph without errors
A-5 Designing the Chat Schema
Our chat room needs three database tables:
users— the standard zypher user table for the admin panel (username, password_hash, role, is_active). We will usezypher createsuperuserfrom the CLI or hand-write the SQL.chat_users— stores display names keyed by session ID, so people can pick a nickname without registering a full account.messages— the chat messages with content, sender display name, and timestamp.
These tables are separate. The users table holds real accounts used only for the admin. The chat_users table holds lightweight anonymous nicknames for chatting.
A-6 The Message Model
Create src/models.zig:
const zypher = @import("zypher");
const schema = zypher.orm.schema;
const Field = schema.Field;
const Model = schema.Model;
// ── Chat user (nickname + session link) ──────────────────────────────────
pub const ChatUserFields = struct {
id: schema.FieldDef = Field("id", .integer, .{ .primary = true }),
session_id: schema.FieldDef = Field("session_id", .text, .{ .required = true, .unique = true }),
nickname: schema.FieldDef = Field("nickname", .text, .{ .required = true }),
joined_at: schema.FieldDef = Field("joined_at", .integer, .{ .required = true }),
};
pub const ChatUser = Model("chat_users", ChatUserFields);
// ── Message ───────────────────────────────────────────────────────────────
pub const MessageFields = struct {
id: schema.FieldDef = Field("id", .integer, .{ .primary = true }),
nickname: schema.FieldDef = Field("nickname", .text, .{ .required = true }),
body: schema.FieldDef = Field("body", .text, .{ .required = true }),
created_at: schema.FieldDef = Field("created_at", .integer, .{ .required = true }),
};
pub const Message = Model("messages", MessageFields);
Model generates every SQL statement at compile time. You can inspect them:
Message.create_table_sql—"CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY, nickname TEXT NOT NULL, body TEXT NOT NULL, created_at INTEGER NOT NULL)"Message.insert_sql—"INSERT INTO messages (nickname,body,created_at) VALUES (?,?,?)"Message.select_all_sql—"SELECT id, nickname, body, created_at FROM messages"
No runtime string building, no ORM magic — you can read the SQL right out of the type system.
A-7 The User Name Form
Create src/forms.zig to define form types for setting a nickname and submitting a message:
const zypher = @import("zypher");
const form = zypher.forms.form;
const validators = zypher.forms.validators;
// Form for choosing a chat nickname.
// The field named "nickname" uses the minLength and maxLength validators.
const NicknameFields = struct {
nickname: form.FieldDef = form.Field("nickname", .text, .{
.required = true,
.validator = validators.minLength(2),
}),
};
pub const NicknameForm = form.Form("NicknameForm", NicknameFields);
// Form for posting a chat message.
const MessageFields = struct {
body: form.FieldDef = form.Field("body", .text, .{
.required = true,
.validator = validators.maxLength(500),
}),
};
pub const MessageForm = form.Form("MessageForm", MessageFields);
A-8 Templates
Create a templates/ directory at the root of your project. We will embed templates using @embedFile.
templates/layout.html — base template:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="10">
<title>zypher Chat</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 700px; margin: 0 auto; padding: 1rem; }
nav { display: flex; justify-content: space-between; border-bottom: 1px solid #eee; padding-bottom: .5rem; margin-bottom: 1rem; }
.msg { border-left: 3px solid #ddd; padding: .4rem .8rem; margin: .4rem 0; }
.msg strong { color: #555; }
.msg time { font-size: .8em; color: #aaa; margin-left: .5rem; }
form { margin-top: 1rem; }
input[type=text], textarea { width: 100%; padding: .4rem; border: 1px solid #ccc; border-radius: 4px; }
button { padding: .4rem 1rem; background: #0070f3; color: white; border: none; border-radius: 4px; cursor: pointer; }
.error { color: red; font-size: .9em; }
</style>
</head>
<body>
<nav>
<strong>zypher Chat</strong>
{% if has_nickname %}
<span>👤 {{ nickname }} · <a href="/set-name">Change name</a></span>
{% else %}
<a href="/set-name">Set your nickname to chat</a>
{% endif %}
{% if is_admin %}
<a href="/admin/">Admin</a>
{% endif %}
</nav>
{% block content %}{% endblock %}
</body>
</html>
templates/home.html — message list and post form:
{% extends "layout.html" %}
{% block content %}
<h1>Messages</h1>
{% if has_nickname %}
<form method="post" action="/messages">
{{ csrf_field|safe }}
<textarea name="body" rows="3" placeholder="Say something..." required></textarea>
<button type="submit">Send</button>
{% if form_error %}
<p class="error">{{ form_error }}</p>
{% endif %}
</form>
{% else %}
<p><a href="/set-name">Set a nickname</a> to start chatting.</p>
{% endif %}
<div id="messages">
{% for msg in messages %}
<div class="msg">
<strong>{{ msg.nickname }}</strong>
<time>{{ msg.created_at }}</time>
<div>{{ msg.body }}</div>
</div>
{% endfor %}
{% if not messages %}
<p>No messages yet. Be the first!</p>
{% endif %}
</div>
{% endblock %}
templates/set_name.html — nickname picker:
{% extends "layout.html" %}
{% block content %}
<h1>Set Your Nickname</h1>
<form method="post" action="/set-name">
{{ csrf_field|safe }}
<label>
Nickname
<input type="text" name="nickname" value="{{ current_nickname }}" required minlength="2" maxlength="40">
</label>
{% if form_error %}
<p class="error">{{ form_error }}</p>
{% endif %}
<button type="submit">Save</button>
</form>
{% endblock %}
A-9 Request Handlers
Create src/handlers.zig. This file contains one function per route. Each function reads from the database, uses the template engine, and writes a response. State that must persist across requests (database, template engine, session store) is kept in threadlocal variables, a pattern used throughout the zypher demo examples.
const std = @import("std");
const zypher = @import("zypher");
const models = @import("models.zig");
const forms = @import("forms.zig");
const Request = zypher.core.Request;
const Response = zypher.core.Response;
const query = zypher.orm.query;
const sqlite = zypher.orm.sqlite;
const TE = zypher.template.renderer.TemplateEngine;
const Context = zypher.template.renderer.Context;
const Value = zypher.template.renderer.Value;
const Session = zypher.auth.session.Session;
const SessionStore = zypher.auth.session.SessionStore;
const csrf = zypher.middleware.csrf;
// ── Thread-local application state ─────────────────────────────────────────
// Using threadlocal variables is the idiomatic zypher pattern for shared
// mutable state. This works because the server is single-threaded in v0.x.
pub threadlocal var db: ?*sqlite.Db = null;
pub threadlocal var engine: ?*TE = null;
pub threadlocal var store: ?*SessionStore = null;
// ── Helper: get unix timestamp ──────────────────────────────────────────────
fn now() i64 {
var ts: std.posix.timespec = undefined;
_ = std.posix.system.clock_gettime(std.posix.CLOCK.REALTIME, &ts);
return ts.sec;
}
// ── Helper: get session from req.user ──────────────────────────────────────
fn getSession(req: *const Request) ?*Session {
return if (req.user) |ptr| @ptrCast(@alignCast(ptr)) else null;
}
// ── Helper: get nickname from session ──────────────────────────────────────
fn getNickname(req: *const Request) ?[]const u8 {
const s = getSession(req) orelse return null;
return s.get("nickname");
}
// ── Helper: get session ID as hex for chat_users lookup ───────────────────
fn getSessionHexId(req: *const Request) ?[]const u8 {
const s = getSession(req) orelse return null;
// Session IDs are stored in the store keyed by hex — we need to find the
// hex key. We use the nickname as the lookup key via the chat_users table.
return s.get("session_hex");
}
// ── Helper: check if current user is admin ────────────────────────────────
fn isAdmin(req: *const Request) bool {
const s = getSession(req) orelse return false;
const role = s.get("role") orelse return false;
return std.mem.eql(u8, role, "admin");
}
// ── Helper: render a template to a Response ───────────────────────────────
fn render(res: *Response, name: []const u8, ctx: *Context) void {
const eng = engine orelse {
_ = res.status(500);
res.text("no template engine") catch {};
return;
};
var aw = std.Io.Writer.Allocating.init(res.allocator);
defer aw.deinit();
eng.render(name, ctx, &aw.writer) catch {
_ = res.status(500);
res.text("template render failed") catch {};
return;
};
var buf = aw.toArrayList();
const html_content = buf.toOwnedSlice(res.allocator) catch return;
defer res.allocator.free(html_content);
res.html(html_content) catch {};
}
// ── Helper: build common context variables ────────────────────────────────
fn baseCtx(req: *Request, ctx: *Context) void {
const nickname = getNickname(req);
const has_nickname = nickname != null;
ctx.put("has_nickname", .{ .bool = has_nickname }) catch {};
ctx.put("nickname", .{ .string = nickname orelse "" }) catch {};
ctx.put("is_admin", .{ .bool = isAdmin(req) }) catch {};
// CSRF field for forms
const field = csrf.formFieldForRequest(req.allocator, req) catch "";
defer if (field.len > 0) req.allocator.free(field);
ctx.put("csrf_field", .{ .string = field }) catch {};
}
// ── Message list ───────────────────────────────────────────────────────────
// Route: GET /
pub fn home(req: *Request, res: *Response) void {
const gpa = res.allocator;
const d = db orelse {
_ = res.status(500);
res.text("database unavailable") catch {};
return;
};
// Fetch all messages, most recent first.
var rows = query.order(
models.Message, d, gpa,
"created_at DESC",
) catch {
_ = res.status(500);
res.text("failed to load messages") catch {};
return;
};
defer {
for (rows.items) |*r| query.freeRow(models.Message, gpa, r);
rows.deinit(gpa);
}
// Build a list of Value.map for the template.
// Each map has: nickname, body, created_at (formatted)
var msg_list = std.ArrayList(Value).init(gpa);
defer msg_list.deinit();
for (rows.items) |row| {
var msg_ctx = Context.init(gpa);
// row tuple: (id=0, nickname=1, body=2, created_at=3)
msg_ctx.put("nickname", .{ .string = row[1] }) catch continue;
msg_ctx.put("body", .{ .string = row[2] }) catch continue;
var ts_buf: [32]u8 = undefined;
const ts_str = std.fmt.bufPrint(&ts_buf, "{d}", .{row[3]}) catch "0";
msg_ctx.put("created_at", .{ .string = ts_str }) catch continue;
const ctx_ptr = gpa.create(Context) catch continue;
ctx_ptr.* = msg_ctx;
msg_list.append(.{ .map = ctx_ptr }) catch continue;
}
var ctx = Context.init(gpa);
defer ctx.deinit();
baseCtx(req, &ctx);
ctx.put("messages", .{ .list = msg_list.items }) catch {};
ctx.put("form_error", .{ .string = "" }) catch {};
render(res, "home.html", &ctx);
// Free message contexts
for (msg_list.items) |v| {
if (v == .map) {
v.map.deinit();
gpa.destroy(v.map);
}
}
}
// ── Post a message ─────────────────────────────────────────────────────────
// Route: POST /messages
pub fn postMessage(req: *Request, res: *Response) void {
const nickname = getNickname(req) orelse {
res.redirect("/set-name", 302) catch {};
return;
};
const d = db orelse {
_ = res.status(500);
return;
};
// Bind and validate the MessageForm.
var bound = forms.MessageForm.bindRequest(res.allocator, req) catch {
_ = res.status(500);
return;
};
defer bound.deinit();
if (!bound.validate()) {
// Validation failed. Re-render home with error.
const err_msg = bound.errors.get("body") orelse "Invalid message.";
_ = res.status(422);
var ctx = Context.init(res.allocator);
defer ctx.deinit();
baseCtx(req, &ctx);
ctx.put("messages", .{ .list = &.{} }) catch {};
ctx.put("form_error", .{ .string = err_msg }) catch {};
render(res, "home.html", &ctx);
return;
}
const data = bound.cleanedData();
const body = data[0]; // MessageForm: first (and only) field is body
_ = query.create(models.Message, d, &.{
sqlite.Value{ .text = nickname },
sqlite.Value{ .text = body },
sqlite.Value{ .int = now() },
}) catch {
_ = res.status(500);
res.text("failed to save message") catch {};
return;
};
res.redirect("/", 302) catch {};
}
// ── Set nickname (GET) ─────────────────────────────────────────────────────
// Route: GET /set-name
pub fn setNameForm(req: *Request, res: *Response) void {
var ctx = Context.init(res.allocator);
defer ctx.deinit();
baseCtx(req, &ctx);
ctx.put("current_nickname", .{ .string = getNickname(req) orelse "" }) catch {};
ctx.put("form_error", .{ .string = "" }) catch {};
render(res, "set_name.html", &ctx);
}
// ── Set nickname (POST) ────────────────────────────────────────────────────
// Route: POST /set-name
pub fn setName(req: *Request, res: *Response) void {
var bound = forms.NicknameForm.bindRequest(res.allocator, req) catch {
_ = res.status(500);
return;
};
defer bound.deinit();
if (!bound.validate()) {
const err = bound.errors.get("nickname") orelse "Invalid nickname.";
_ = res.status(422);
var ctx = Context.init(res.allocator);
defer ctx.deinit();
baseCtx(req, &ctx);
ctx.put("current_nickname", .{ .string = "" }) catch {};
ctx.put("form_error", .{ .string = err }) catch {};
render(res, "set_name.html", &ctx);
return;
}
const nickname = bound.cleanedData()[0];
// Store the nickname in the session.
if (getSession(req)) |s| {
s.put(res.allocator, "nickname", nickname) catch {};
if (store) |st| st.save(s) catch {};
}
res.redirect("/", 302) catch {};
}
// ── 404 Not Found ──────────────────────────────────────────────────────────
pub fn notFound(_: *Request, res: *Response) void {
_ = res.status(404);
res.text("Page not found.") catch {};
}
A-10 Middleware and Session Configuration
Create src/middleware.zig to centralize the middleware chain and session store setup:
const zypher = @import("zypher");
const handlers = @import("handlers.zig");
const Request = zypher.core.Request;
const Response = zypher.core.Response;
const Router = zypher.router.Router;
const Chain = zypher.middleware.Chain;
// Custom rate limiter: 200 requests per minute.
pub const RL = zypher.middleware.rate_limit.middlewareWith(.{
.max_requests = 200,
.window_seconds = 60,
});
// The full middleware chain.
// Order matters:
// 1. logger — logs every request
// 2. RL.handle — enforces rate limits before expensive handlers run
// 3. session — loads/creates session, sets req.user
// 4. csrf — reads/validates CSRF token (requires session to be loaded first)
// 5. security — adds security headers to every response
pub const MW = Chain(.{
zypher.middleware.logger.middleware,
RL.handle,
zypher.middleware.session.middleware,
zypher.middleware.csrf.middleware,
zypher.middleware.security_headers.middleware,
});
// Thread-local router reference (same pattern as handlers.zig).
threadlocal var router_ptr: ?*const Router = null;
pub fn setRouter(r: *const Router) void {
router_ptr = r;
}
// The terminal handler — dispatches to the router.
pub fn routerDispatch(req: *Request, res: *Response) void {
(router_ptr orelse return).dispatch(req, res);
}
// The middleware dispatcher — entry point for App.middlewareHandler.
pub fn dispatch(req: *Request, res: *Response) void {
MW.run(req, res, routerDispatch);
}
A-11 The Admin Panel
Create src/admin.zig to define which models appear in the admin UI:
const zypher = @import("zypher");
const models = @import("models.zig");
const AdminSite = zypher.admin.AdminSite;
const Registration = zypher.admin.Registration;
// Register both models so admins can view and delete messages
// and inspect the chat_users table.
pub const Site = AdminSite(.{
.messages = Registration(models.Message, .{ .verbose_name_plural = "Messages" }),
.chat_users = Registration(models.ChatUser, .{ .verbose_name_plural = "Chat Users" }),
});
A-12 Wiring main.zig
This is the entry point that connects everything together. Replace the generated src/main.zig:
const std = @import("std");
const zypher = @import("zypher");
const models = @import("models.zig");
const handlers = @import("handlers.zig");
const mw = @import("middleware.zig");
const admin = @import("admin.zig");
const Router = zypher.router.Router;
const Route = zypher.router.Route;
const sqlite = zypher.orm.sqlite;
const TE = zypher.template.renderer.TemplateEngine;
const SessionStore = zypher.auth.session.SessionStore;
pub fn main(init: std.process.Init) !void {
const gpa = init.gpa;
// ── 1. Database ──────────────────────────────────────────────────────
// Open (or create) the SQLite database. The path is relative to where
// the binary is executed from. In development this is your project root.
var database = try sqlite.Db.open(gpa, "chat.db");
defer database.close();
// Create tables if they don't exist. The SQL is generated at compile time.
try database.exec(models.ChatUser.create_table_sql);
try database.exec(models.Message.create_table_sql);
// The admin also needs the standard users table for login.
// We create it manually because the user table is not an ORM model.
try database.exec(
\\CREATE TABLE IF NOT EXISTS users (
\\ id INTEGER PRIMARY KEY AUTOINCREMENT,
\\ username TEXT NOT NULL UNIQUE,
\\ email TEXT UNIQUE,
\\ password_hash TEXT NOT NULL,
\\ role TEXT NOT NULL DEFAULT 'user',
\\ is_active INTEGER NOT NULL DEFAULT 1,
\\ reset_code TEXT,
\\ reset_code_expires_at INTEGER
\\)
);
// Wire the database reference into handlers.
handlers.db = &database;
// ── 2. Template Engine ───────────────────────────────────────────────
// Load all templates from embedded strings. In a larger app you might
// read them from the filesystem at startup using @embedFile.
var tpl_engine = TE.init(gpa);
defer tpl_engine.deinit();
_ = try tpl_engine.load("layout.html", @embedFile("../templates/layout.html"));
_ = try tpl_engine.load("home.html", @embedFile("../templates/home.html"));
_ = try tpl_engine.load("set_name.html", @embedFile("../templates/set_name.html"));
// Load admin templates (the admin panel generates its own HTML).
admin.Site.loadTemplates(&tpl_engine);
// Wire into handlers and the zypher admin subsystem.
handlers.engine = &tpl_engine;
zypher.admin.setDb(&database);
zypher.admin.setEngine(&tpl_engine);
// ── 3. Sessions ──────────────────────────────────────────────────────
var sessions = SessionStore.init(gpa);
defer sessions.deinit();
handlers.store = &sessions;
zypher.middleware.session.setStore(&sessions);
zypher.middleware.session.setCookieConfig(.{
.httponly = true,
// IMPORTANT: set .secure = true in production (HTTPS only).
// In local development over HTTP, secure cookies will not be sent.
.secure = false,
.samesite = "Strict",
.path = "/",
.max_age = 86400 * 7, // 1 week
});
// ── 4. Router ────────────────────────────────────────────────────────
// Application routes come first.
const app_routes = [_]Route{
Router.route(.get, "/", handlers.home),
Router.route(.post, "/messages", handlers.postMessage),
Router.route(.get, "/set-name", handlers.setNameForm),
Router.route(.post, "/set-name", handlers.setName),
};
// Admin routes are generated from the Site definition.
const admin_routes = admin.Site.routes();
// Concatenate at comptime. Note: `++` on arrays requires both to be
// comptime-known types — using initFromSlice works for runtime slices.
const all_routes = app_routes ++ admin_routes;
var router = Router.initFromSlice(&all_routes, handlers.notFound);
// Wire the router into the middleware dispatcher.
mw.setRouter(&router);
// ── 5. Parse port from command-line arguments ─────────────────────
const port = parsePort(init);
// ── 6. Start the App ─────────────────────────────────────────────────
var app = zypher.core.App.init(gpa, .{
.host = "127.0.0.1",
.port = port,
});
defer app.deinit();
app.database(&database);
// middlewareHandler has the highest priority. It wraps the router.
app.middlewareHandler(mw.dispatch);
std.log.info("chat-pkg listening on http://127.0.0.1:{d}/", .{port});
try app.listenAndServe(init.io);
}
fn parsePort(init: std.process.Init) u16 {
var args = std.process.Args.Iterator.init(init.minimal.args);
defer args.deinit();
_ = args.skip(); // skip argv[0] (binary name)
while (args.next()) |arg| {
if (std.mem.eql(u8, arg, "--port")) {
const raw = args.next() orelse return 8080;
return std.fmt.parseInt(u16, raw, 10) catch 8080;
}
}
return 8080;
}
test "models are well-formed" {
// These are compile-time checks that the model SQL is non-empty.
try std.testing.expect(models.Message.create_table_sql.len > 0);
try std.testing.expect(models.ChatUser.create_table_sql.len > 0);
try std.testing.expectEqualStrings("messages", models.Message.table_name);
}
A-13 Running the Application
Your final directory structure:
chat-pkg/
├── build.zig
├── build.zig.zon
├── src/
│ ├── main.zig
│ ├── models.zig
│ ├── forms.zig
│ ├── handlers.zig
│ ├── middleware.zig
│ └── admin.zig
└── templates/
├── layout.html
├── home.html
└── set_name.html
Build and run:
zig build run
# Compiles zypher, sqlite3.c, and your app; then starts the server.
# Output: chat-pkg listening on http://127.0.0.1:8080/
On a different port:
zig build run -- --port 9000
To create an admin user so you can log into the admin panel, you have two options:
Option 1 — use the zypher CLI (if you have built it):
zypher createsuperuser --username admin --email admin@chat.dev --password Admin12345 --db chat.db
Option 2 — insert directly:
# You can write a small Zig script, or use sqlite3 CLI:
sqlite3 chat.db "INSERT INTO users (username, email, password_hash, role, is_active)
VALUES ('admin', 'admin@chat.dev',
'\$pbkdf2-sha256\$100000\$<salt_hex>\$<hash_hex>', 'admin', 1);"
# Note: you need a properly hashed password. Using the CLI tool is easier.
Visit:
http://localhost:8080/— chat roomhttp://localhost:8080/set-name— pick a nicknamehttp://localhost:8080/admin/— admin panel (after creating a superuser)
Run tests:
zig build test
Part B — zypher via the CLI
The CLI approach inverts the workflow. Instead of writing build.zig from scratch, you run zypher new to generate a ready-to-run scaffold, then modify it.
A critical design choice to understand up front: zypher new creates projects that reference zypher by a local path, not by a package registry URL. The generated build.zig looks for the zypher source tree and compiles it directly. This means the CLI is designed to be used while the zypher source is on your machine — typically as a sibling directory of your project.
B-1 Building and Installing the CLI
The CLI is built from the zypher source tree itself. You do not download a separate installer.
# Navigate to your zypher clone
cd ~/code/zypher
# Build everything, including the zypher CLI binary
zig build
# The binary is now at:
ls zig-out/bin/zypher
# Optional: add it to your PATH so you can run it from anywhere
export PATH="$HOME/code/zypher/zig-out/bin:$PATH"
# Verify
zypher --version
# Output: zypher 0.1.0-beta
When you run zypher run from a scaffolded project, the CLI uses inferZypherRoot to locate the zypher source tree. It looks (in order):
- The
ZYPHER_ROOTenvironment variable. - The directory two levels above the
zypherbinary (../../fromzig-out/bin/). - A packaged install in
<prefix>/share/zypher. - The current working directory.
Because the binary is at zypher/zig-out/bin/zypher, and the zypher source root is zypher/, going up two directories from bin/ gives zypher/ — the source root. This is intentional.
If you install the binary somewhere else, you can set ZYPHER_ROOT explicitly:
export ZYPHER_ROOT=/home/you/code/zypher
B-2 Scaffolding the Project
Choose a scaffold template. List available templates:
zypher templates
# Available embedded templates:
# clean-arch
# mvc
# mvp-api
# single-file
# mvc-api
# mvp
# single-file-api
# clean-arch-api
For a chat room, the single-file template is the cleanest starting point — everything in one main.zig file. We will expand it.
# Create the project directory. By convention it should sit next to the
# zypher source, because the generated build.zig defaults to "../.." as
# the zypher root.
cd ~/code
zypher new chat-cli --template single-file
# Output:
# Created project 'chat-cli' from embedded template 'single-file'
# cd chat-cli
# zypher run
cd chat-cli
The project was created at ~/code/chat-cli/.
B-3 Understanding What zypher new Creates
Let's look at exactly what was generated:
chat-cli/
├── .gitignore
├── build.zig
└── src/
└── main.zig
.gitignore:
.zig-cache/
zig-out/
*.db
build.zig (generated verbatim — note {{project_name}} was replaced with chat-cli):
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{ .default_target = .{ .abi = .musl } });
const optimize = b.standardOptimizeOption(.{});
// This is the key: zypher-root defaults to "../.." (the zypher source tree)
// but can be overridden at build time with -Dzypher-root=<path>.
const zypher_root = b.option([]const u8, "zypher-root",
"Path to the Zypher source tree") orelse "../..";
const sqlite_root = b.pathJoin(&.{ zypher_root,
"vendor/sqlite-amalgamation-3530000" });
// Compile SQLite as a C static library.
const sqlite3_mod = b.createModule(.{ .target = target, .optimize = optimize });
sqlite3_mod.addCSourceFiles(.{
.root = .{ .cwd_relative = sqlite_root },
.files = &.{"sqlite3.c"},
.flags = &.{ "-std=c11", "-DSQLITE_THREADSAFE=0",
"-DSQLITE_OMIT_LOAD_EXTENSION" },
});
sqlite3_mod.addIncludePath(.{ .cwd_relative = sqlite_root });
sqlite3_mod.link_libc = true;
const sqlite3_lib = b.addLibrary(.{ .name = "sqlite3",
.root_module = sqlite3_mod });
// Create the zypher module from source.
const zypher_mod = b.createModule(.{
.root_source_file = .{ .cwd_relative =
b.pathJoin(&.{ zypher_root, "src/zypher.zig" }) },
.target = target,
.optimize = optimize,
});
zypher_mod.linkLibrary(sqlite3_lib);
zypher_mod.addIncludePath(.{ .cwd_relative = sqlite_root });
zypher_mod.link_libc = true;
// Your app executable.
const exe = b.addExecutable(.{
.name = "chat-cli",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "zypher", .module = zypher_mod },
},
}),
});
b.installArtifact(exe);
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
run_cmd.addPassthruArgs();
b.step("run", "Run the app").dependOn(&run_cmd.step);
// ... doc and test steps omitted for brevity
}
The critical difference from Part A's build.zig: this file does not use build.zig.zon to fetch zypher. It compiles zypher from source directly using b.pathJoin(&.{ zypher_root, "src/zypher.zig" }). The zypher root defaults to "../.." — two directories up from the chat-cli/ project directory, which is where ~/code/zypher/ sits.
src/main.zig (generated single-file scaffold with {{project_name}} replaced):
The generated file is ~200 lines and includes a working main, a ManagedItem model, the admin panel with login/logout, and the full session/CSRF middleware chain. It is immediately runnable.
Key structures in the generated scaffold (that we will build on):
threadlocal var tl_db: ?*sqlite.Dbandtl_routerfor shared state- The full
Chain(.{session, security_headers})middleware - Admin routes at
/admin/... - Login handlers at
/admin/login - A
parsePortfunction that reads--portfrom argv
B-4 Designing the Schema Manifest
For the CLI's makemigrations command, you write a schema.zypher manifest file instead of (or in addition to) defining Zig ORM models. The manifest describes your tables in a simple whitespace-delimited text format:
Create schema.zypher in the project root:
# chat-cli schema manifest
# Syntax:
# table <table_name>
# field <name> <kind> [primary] [required] [unique] [foreign=<ref>]
#
# Kinds: integer, float, text, boolean
table chat_users
field id integer primary
field session_id text required unique
field nickname text required
field joined_at integer required
table messages
field id integer primary
field nickname text required
field body text required
field created_at integer required
B-5 Running makemigrations
zypher makemigrations
# Reads schema.zypher (default), compares to .zypher_schema (state snapshot).
# On first run, no snapshot exists — generates CREATE TABLE migrations.
# Output:
# Created migration migrations/0001_create_chat_users.sql: create_chat_users
# Created migration migrations/0002_create_messages.sql: create_messages
What was created:
chat-cli/
├── migrations/
│ ├── 0001_create_chat_users.sql
│ └── 0002_create_messages.sql
├── .zypher_schema ← state snapshot (committed to git)
├── schema.zypher
├── build.zig
└── src/main.zig
migrations/0001_create_chat_users.sql:
CREATE TABLE IF NOT EXISTS chat_users (id INTEGER PRIMARY KEY, session_id TEXT NOT NULL UNIQUE, nickname TEXT NOT NULL, joined_at INTEGER NOT NULL);
migrations/0002_create_messages.sql:
CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY, nickname TEXT NOT NULL, body TEXT NOT NULL, created_at INTEGER NOT NULL);
To add a field later (e.g., adding edited_at to messages), add field edited_at integer to schema.zypher and re-run makemigrations. It will detect the diff and generate an ALTER TABLE migration.
# After adding "field edited_at integer" to schema.zypher:
zypher makemigrations
# Created migration migrations/0003_add_edited_at_to_messages.sql: add_edited_at_to_messages
B-6 Running migrate
Now apply the migrations to the database:
zypher migrate
# Reads migrations/ directory, sorts by numeric prefix, applies pending migrations.
# Output:
# zypher: database 'db.sqlite' — applied 2 migration(s), skipped 0
zypher migrate
# Idempotent: already applied migrations are skipped.
# Output:
# zypher: database 'db.sqlite' — applied 0 migration(s), skipped 2
The migration state is tracked in zypher_migrations table in the database. You can pass a custom database path and migrations directory:
zypher migrate --db chat-cli.db --dir migrations
B-7 Modifying the Scaffold for Chat
Now we expand the generated src/main.zig to add chat-specific models, handlers, and forms. The key difference from Part A: rather than creating separate files, the single-file template puts everything in one file. We will split into multiple files here for clarity, exactly as in Part A — the CLI approach does not prevent you from using multiple source files.
Add the same src/models.zig, src/forms.zig, src/handlers.zig, and src/admin.zig from Part A. Then modify the generated src/main.zig to wire these in.
The generated main.zig already has:
- The
Chainanddispatchinfrastructure - The admin site
- Session store setup
- The
parsePortfunction
You will modify these sections:
Replace the ManagedItem admin with your chat admin (same admin.zig as Part A).
Add your app routes before the admin routes:
// In main(), replace the generated app_routes with:
const app_routes = [_]Route{
Router.route(.get, "/", handlers.home),
Router.route(.post, "/messages", handlers.postMessage),
Router.route(.get, "/set-name", handlers.setNameForm),
Router.route(.post, "/set-name", handlers.setName),
};
Add table creation right after sqlite.Db.open:
// The migrate command already ran the SQL from migrations/, but we also
// call exec() for the users table which is managed outside the manifest.
// exec() is idempotent: CREATE TABLE IF NOT EXISTS will do nothing if the
// table already exists.
try db.exec(models.ChatUser.create_table_sql);
try db.exec(models.Message.create_table_sql);
try db.exec(
\\CREATE TABLE IF NOT EXISTS users (
\\ id INTEGER PRIMARY KEY AUTOINCREMENT,
\\ username TEXT NOT NULL UNIQUE,
\\ email TEXT UNIQUE,
\\ password_hash TEXT NOT NULL,
\\ role TEXT NOT NULL DEFAULT 'user',
\\ is_active INTEGER NOT NULL DEFAULT 1,
\\ reset_code TEXT,
\\ reset_code_expires_at INTEGER
\\)
);
Load templates. The CLI approach doesn't change how templates work — you embed them the same way:
_ = try engine.load("layout.html", @embedFile("../templates/layout.html"));
_ = try engine.load("home.html", @embedFile("../templates/home.html"));
_ = try engine.load("set_name.html", @embedFile("../templates/set_name.html"));
Create the templates/ directory and add the same three HTML files from Part A.
B-8 Creating the Superuser
With the CLI, creating an admin user is one command. No need to hash passwords manually:
# Interactive mode — prompts for username, email, and password
zypher createsuperuser
# Or non-interactive with all flags:
zypher createsuperuser \
--username admin \
--email admin@chat.dev \
--password Admin12345 \
--db db.sqlite
# Output: Superuser 'admin' created (id=1)
The createsuperuser command:
- Validates that username is non-empty.
- Validates email format with the same
validators.emailfunction used by the library. - Validates that the password is at least 8 characters with at least one letter and one digit.
- Hashes the password with PBKDF2-HMAC-SHA256 (100,000 iterations, random 16-byte salt).
- Opens the database (defaulting to
db.sqlitein the current directory), creates theuserstable if it doesn't exist, and inserts the row.
B-9 Running the Application with the CLI
# From inside chat-cli/
zypher run
# Equivalent to: zig build -Dzypher-root=<inferred> run -- --port 8080
# The inferred zypher root is found by looking two levels above the zypher binary.
# On a different port:
zypher run --port 9000
# Equivalent to: zig build -Dzypher-root=<inferred> run -- --port 9000
# Pass extra arguments after -- (they go to your app's argv):
zypher run -- --port 9000
# If zypher-root inference fails, specify it:
zypher run --zypher-root /home/you/code/zypher
# Or set the environment variable:
ZYPHER_ROOT=/home/you/code/zypher zypher run
Under the hood, zypher run shells out to zig build -Dzypher-root=<path> run. The zig build run target in the generated build.zig then starts your app. The port argument is passed through addPassthruArgs() to your binary's argv.
After zypher run starts, your app is accessible at http://localhost:8080/.
B-10 Other CLI Commands
zypher shell — opens an arithmetic REPL for quick arithmetic expressions:
zypher shell
# zypher shell
# Context: std, zypher
# Commands: :help, :context, :contract, :quit
# zypher> 2 + 2
# 4
# zypher> 100 * 24 * 60 * 60
# 8640000
# zypher> :quit
# bye
The shell currently evaluates integer arithmetic expressions (+, -, *, /). It is not a Zig REPL — it cannot compile or run Zig code.
zypher doc — builds and serves zypher's own API documentation:
zypher doc
# Building documentation in /home/you/code/zypher
# Serving documentation at http://127.0.0.1:8080/
# (CTRL+C to stop)
zypher doc-user — builds and serves documentation for your app:
cd chat-cli
zypher doc-user .
# Runs `zig build doc` in your project and serves the output at http://127.0.0.1:8080/
zypher demo — scaffolds an MVC demo project:
zypher demo my-demo
# Equivalent to: zypher new my-demo --template mvc
zypher runserver — starts a minimal development server with only a /health endpoint. This is useful for verifying the server itself works before wiring any application code:
zypher runserver --host 127.0.0.1 --port 8080
# Starting zypher server at http://127.0.0.1:8080/
# GET /health → "OK"
# GET / → "zypher server is running"
4. Side-by-Side Comparison
What each approach actually does differently
| Aspect | Package Approach | CLI Approach |
|---|---|---|
| Dependency declaration | build.zig.zon with .path or .url |
Not used; build.zig compiles zypher from source directly |
build.zig authorship |
You write it entirely | Generated by zypher new, references zypher by zypher-root option |
| Project scaffold | None — you create every file | zypher new creates .gitignore, build.zig, and a src/main.zig scaffold |
| Table creation | Call db.exec(Model.create_table_sql) manually in code |
Use schema.zypher manifest + zypher makemigrations + zypher migrate |
| Superuser creation | Write SQL by hand or use CLI tool | zypher createsuperuser command |
| Running the server | zig build run directly |
zypher run (which calls zig build run internally) |
| zypher source location | Resolved by build.zig.zon dependency resolution |
Must be on-disk, inferred from binary location or ZYPHER_ROOT |
| Code organization | You decide (multiple files encouraged) | Scaffold starts in single file, you expand it |
When to use the Package Approach
- You are building a production application and want clean, version-pinned dependencies in
build.zig.zon. - You want to use zypher from a URL/hash rather than a local path.
- You prefer to control every aspect of the project structure.
- You are integrating zypher into an existing Zig project.
- You are comfortable writing
build.zigby hand.
Pros:
- Standard Zig package management — works with
zig build --fetchand lockfiles. - No dependency on a pre-built zypher binary for development.
- Explicit about every dependency, every step.
- Will work even after zypher is published to a package registry.
Cons:
- More initial boilerplate — you write
build.zig, create directories, declare models, set up middleware all from scratch. - No
makemigrationsormigrateworkflow — you manage SQL migrations yourself. createsuperuseris not available unless you have also built the CLI.
When to use the CLI Approach
- You want to get a working zypher app running in under five minutes.
- You prefer the
zypher makemigrations/zypher migrateworkflow for schema evolution. - You are developing alongside the zypher source tree (e.g., contributing to zypher or building something to share as an example).
- You want
zypher createsuperuserfor admin account creation without writing SQL.
Pros:
- Faster initial setup — scaffold is immediately runnable (
zypher new+zypher run). makemigrationsgenerates SQL migration files from a simple text manifest.migrateapplies.sqlfiles idempotently with version tracking.createsuperusereliminates the need to hand-write SQL for admin accounts.runserverprovides a minimal sanity-check server.docanddoc-userserve API documentation locally.
Cons:
- Requires the zypher binary to be built and accessible.
- The
zypher-rootinference can fail if the binary is moved or installed globally without settingZYPHER_ROOT. - The
schema.zyphermanifest is separate from the Zig ORM models — you must keep both in sync manually. makemigrationssupports only CREATE TABLE and ADD COLUMN — it cannot generate DROP, RENAME, or other destructive SQL.zypher runis a thin wrapper aroundzig build run; experienced Zig developers may prefer callingzig build rundirectly.
The migration system in detail
The CLI migration system has specific capabilities and limitations derived directly from the source:
What makemigrations can detect:
- New tables (generates
CREATE TABLE IF NOT EXISTS ...) - New fields added to existing tables (generates
ALTER TABLE ... ADD COLUMN ...)
What makemigrations cannot detect:
- Removed fields (SQLite does not support
DROP COLUMNin older versions) - Renamed fields or tables
- Changed field types or constraints
- Changes to indexes (only basic
PRIMARY KEY,NOT NULL,UNIQUE,REFERENCESfrom the manifest)
For destructive or complex schema changes, write the SQL migration file manually in the migrations/ directory with the next numeric prefix.
How migrate works:
- Reads all
.sqlfiles from themigrations/directory. - Sorts them lexicographically by filename (numeric prefix ensures correct order:
0001_<0002_<0010_). - Parses the numeric ID from the filename prefix.
- Checks
zypher_migrationstable in the database — skips already-applied IDs. - Executes each pending file's SQL against the database.
- Records the migration as applied.
The zypher_migrations table is identical to the one created by migration.MigrationRunner.ensureHistoryTable() in the Zig library, so you can freely mix CLI migrations and programmatic MigrationRunner calls.
5. Complete CLI API Reference
Global Usage
zypher <command> [options]
All commands write informational output to stdout and errors to stderr. Non-zero exit codes indicate failure.
zypher help / zypher --help
Prints the help message listing all available commands.
zypher help
No options.
zypher version / zypher --version
Prints the version string and exits.
zypher version
# Output: zypher 0.1.0-beta
zypher new <path> [options]
Scaffolds a new zypher project from a built-in template.
Arguments:
<path>— the directory path to create. The basename of the path becomes the project name. The project name must contain only letters, digits,_, and-.
Options:
| Flag | Description | Default |
|---|---|---|
--template <name> |
Scaffold template name | single-file |
--style <name> |
Alias for --template |
— |
--template-dir <dir> |
Directory to search for templates (overrides embedded templates) | templates |
--api |
Append -api to the template name (e.g., single-file-api) |
off |
Available embedded templates:
| Template | Structure | Description |
|---|---|---|
single-file |
src/main.zig |
Everything in one file. Best for prototypes. |
single-file-api |
src/main.zig |
Single file, JSON API only, no HTML templates. |
mvc |
src/main.zig, src/models/, src/views/, src/controllers/ |
Model-View-Controller layout. |
mvc-api |
Same as mvc but JSON-only routes | MVC for REST APIs. |
mvp |
src/main.zig, src/models/, src/presenters/, src/views/ |
Model-View-Presenter layout. |
mvp-api |
Same as mvp but JSON-only | MVP for REST APIs. |
clean-arch |
src/main.zig, src/domain/, src/application/, src/infrastructure/, src/presentation/ |
Clean architecture. |
clean-arch-api |
Same as clean-arch but JSON-only | Clean architecture for REST APIs. |
Template substitution: the string {{project_name}} in any file is replaced with the actual project name.
Example:
zypher new my-blog --template mvc
zypher new my-api --template single-file --api # generates single-file-api
zypher new my-app --template-dir ./my-templates # uses custom template dir
What each generated project contains:
build.zig— wires zypher from source with thezypher-rootbuild optionsrc/main.zig— runnable scaffold with sessions, security headers, admin login.gitignore— ignores.zig-cache/,zig-out/,*.db
zypher templates [options]
Lists available scaffold templates.
Options:
| Flag | Description | Default |
|---|---|---|
--template-dir <dir> |
Directory to list instead of embedded | — |
zypher templates
# Available embedded templates:
# clean-arch
# mvc
# mvp-api
# single-file
# mvc-api
# mvp
# single-file-api
# clean-arch-api
zypher templates --template-dir ./my-templates
# Available templates in ./my-templates:
# my-custom-template
zypher run [path] [options]
Runs a zypher project's zig build run step with the correct zypher-root injected.
Arguments:
[path]— project directory to run (default:., the current directory).
Options:
| Flag | Description | Default |
|---|---|---|
--port <n> |
Port to pass to the app via --port <n> in argv |
8080 |
--zypher-root <path> |
Explicit path to the zypher source tree | (inferred from binary location) |
After --: any arguments after -- are passed directly to the app's argv.
zypher run # runs . on port 8080
zypher run --port 9000 # runs . on port 9000
zypher run my-project --port 3000 # runs my-project/ on port 3000
zypher run -- --port 4000 # passes --port to app via --
zypher run --zypher-root /path/to/zypher
How the port is passed: zypher run calls zig build -Dzypher-root=<root> run -- --port <n>. The addPassthruArgs() in build.zig forwards -- --port <n> to your binary's argv. Your app then parses --port from argv using the parsePort pattern shown in the scaffold.
Inference of zypher root: The root is inferred by:
- Checking
ZYPHER_ROOTenvironment variable. - Walking up from the binary's directory looking for
src/zypher.zig. - Checking
<prefix>/share/zypher/src/zypher.zig. - Falling back to
"../..".
zypher makemigrations [options]
Reads a schema manifest file, compares it to the last-known state, and generates SQL migration files for the differences.
Options:
| Flag | Description | Default |
|---|---|---|
--schema <file> |
Schema manifest file | schema.zypher |
--state <file> |
State snapshot file (tracks current schema) | .zypher_schema |
--dir <dir> |
Directory to write migration .sql files |
migrations |
Schema manifest format:
# Lines starting with # are comments
# Blank lines are ignored
table <table_name>
field <field_name> <kind> [flags...]
# Flags (any order):
# primary — marks the field as INTEGER PRIMARY KEY
# required — adds NOT NULL
# unique — adds UNIQUE
# foreign=<table>(<column>) — adds REFERENCES
# Kind values: integer, float, text, boolean
Example manifest:
table posts
field id integer primary
field title text required
field body text required
field author_id integer required foreign=users(id)
field published boolean
table tags
field id integer primary
field name text required unique
What it generates:
On first run (no .zypher_schema): one CREATE TABLE migration per table.
On subsequent runs: one ALTER TABLE ... ADD COLUMN migration per new field. Removed fields, renamed fields, and changed types produce no output — you must write those migrations by hand.
Migration file naming:
- CREATE:
<dir>/<index:04>_create_<table>.sql - ADD COLUMN:
<dir>/<index:04>_add_<field>_to_<table>.sql
The index is sequential within the run, starting at 1. It does not account for existing files in the directory.
zypher makemigrations
zypher makemigrations --schema models/schema.zypher --state .schema_snapshot --dir db/migrations
zypher migrate [options]
Applies pending SQL migration files from the migrations directory to the database.
Options:
| Flag | Description | Default |
|---|---|---|
--db <path> |
SQLite database file | db.sqlite |
--dir <dir> |
Directory containing .sql migration files |
migrations |
zypher migrate
zypher migrate --db chat.db --dir db/migrations
Behavior:
- Creates
zypher_migrationstable in the database if it does not exist. - Reads all
.sqlfiles from<dir>, sorts by filename (lexicographic = numeric order due to zero-padded names). - Extracts the numeric ID from the start of each filename (
0001_create_posts.sql→ ID1). - Skips files whose ID is already in
zypher_migrations. - Executes each pending file's contents as a single SQL statement block via
Db.exec. - Records each successful migration in
zypher_migrationswith its ID and filename. - Prints how many were applied and how many were skipped.
On failure: migration stops immediately. No rollback is performed — you must fix the SQL and re-run.
zypher createsuperuser [options]
Creates an admin user account in the database.
Options:
| Flag | Description | Default |
|---|---|---|
--username <name> |
Username for the admin account | (prompted interactively) |
--email <email> |
Email address | (prompted interactively) |
--password <pass> |
Plain-text password (hashed before storage; never stored in plain text) | (prompted interactively, hidden) |
--db <path> |
Database file | db.sqlite |
Validation rules:
- Username: non-empty, no leading/trailing whitespace.
- Email: must contain exactly one
@, non-empty local part, non-empty domain with at least one.. - Password: at least 8 characters, at least one alphabetic character, at least one digit.
- Passwords must match (in interactive mode, you are asked to confirm).
Password security: passwords are hashed with PBKDF2-HMAC-SHA256, 100,000 iterations, a random 16-byte salt, stored as $pbkdf2-sha256$<iter>$<salt_hex>$<hash_hex>.
# Interactive (prompts for all fields; password input is hidden)
zypher createsuperuser
# Non-interactive
zypher createsuperuser --username admin --email a@b.com --password Secret12
# Custom database
zypher createsuperuser --db myapp.db --username admin --email a@b.com --password Secret12
What it does in the database:
- Creates the
userstable with columnsid,username,email,password_hash,role,is_active,reset_code,reset_code_expires_atif it does not exist. - Ensures
email,reset_code,reset_code_expires_atcolumns exist (viaALTER TABLEif the table was created without them). - Inserts the new user with
role = 'admin'andis_active = 1. - Prints
Superuser '<username>' created (id=<rowid>)on success. - Prints a specific error message on failure (duplicate username, duplicate email, validation failure, etc.).
zypher runserver [options]
Starts a minimal HTTP server (without any app logic) for verifying the server works.
Options:
| Flag | Description | Default |
|---|---|---|
--host <host> |
Bind address | 127.0.0.1 |
--port <n> |
Port | 8080 |
--max-requests <n> |
Stop after serving this many requests (testing) | unlimited |
zypher runserver
zypher runserver --host 0.0.0.0 --port 3000
zypher runserver --max-requests 10 # serves 10 requests then exits
Routes served by runserver:
GET /health→"OK"(200)- Any other path →
"zypher server is running"(200)
The server handles SIGINT (Ctrl+C) for graceful shutdown.
zypher shell [options]
Opens an interactive arithmetic REPL with zypher context metadata.
Options:
| Flag | Description | Default |
|---|---|---|
--eval <expr> |
Evaluate a single expression and exit | (interactive mode) |
zypher shell
# zypher> 1 + 2
# 3
# zypher> 24 * 60 * 60
# 86400
# zypher> :help
# Available commands: :help, :context, :contract, :quit
# zypher> :quit
# bye
zypher shell --eval "365 * 24 * 60 * 60"
# 31536000
Shell commands:
:help/:h— list commands:context— describe available Zig namespaces (std,zypher):contract— clarify what the shell is and is not:quit/:q/exit— end session
Supported expressions: integer arithmetic with +, -, *, / (integer division). Parentheses are not supported. The expression must be completely consumed — trailing characters cause an error.
zypher doc [options]
Builds zypher's own API documentation and serves it via HTTP.
Options:
| Flag | Description | Default |
|---|---|---|
--zypher-root <path> |
Path to zypher source tree | (inferred) |
--host <host> |
Bind address for doc server | 127.0.0.1 |
--port <n> |
Port for doc server | 8080 |
--max-requests <n> |
Stop after N requests | unlimited |
zypher doc
zypher doc --port 9090 --zypher-root /home/you/code/zypher
Runs zig build doc in the zypher source tree, then starts a static file server serving zig-out/docs/.
zypher doc-user [path] [options]
Builds and serves documentation for your own project.
Arguments:
[path]— project directory containingbuild.zig(default:.)
Options:
| Flag | Description | Default |
|---|---|---|
--host <host> |
Bind address | 127.0.0.1 |
--port <n> |
Port | 8080 |
--max-requests <n> |
Stop after N requests | unlimited |
zypher doc-user .
zypher doc-user my-app --port 9090
Runs zig build doc in your project directory (which must have a doc build step), then serves the output.
zypher demo <name>
Scaffolds an MVC demo project. Equivalent to zypher new <name> --template mvc.
zypher demo my-demo
# Same as: zypher new my-demo --template mvc
6. Complete Library API Reference
Module map
| Import | Contents |
|---|---|
zypher.core |
App, Server, Request, Response, Method, Context |
zypher.router |
Router, Route, RouteParams |
zypher.middleware |
Chain, MiddlewareFn, HandlerFn, all built-in middleware |
zypher.template |
lexer, parser, renderer (Template, TemplateEngine, Context, Value), filters |
zypher.orm |
sqlite (Db, Stmt, Value), schema (Model, Field, FieldDef), query, migration |
zypher.forms |
form (Form, Field, BoundForm), validators |
zypher.auth |
session (Session, SessionStore), password, user |
zypher.admin |
AdminSite, Registration, setDb, setEngine |
zypher.errors |
ZypherError, errorToString |
zypher.log |
setLogLevel, setLogWriter, startCapture, stopCapture |
zypher.cli_runner |
dispatchInner (for testing CLI commands programmatically) |
zypher.core.App
The application entry point. Wraps Server, an optional sqlite.Db, and handler registration.
pub const App = struct {
server: Server,
allocator: std.mem.Allocator,
handler_fn: ?Server.HandlerFn = null,
router_handler: ?Server.HandlerFn = null,
middleware_handler: ?Server.HandlerFn = null,
db: ?*sqlite.Db = null,
};
| Method | Signature | Description |
|---|---|---|
init |
(gpa, Server.Config) App |
Create. Does not start listening. |
deinit |
(*App) void |
Closes db if attached. |
handler |
(*App, HandlerFn) void |
Register flat handler (lowest priority). |
routerHandler |
(*App, HandlerFn) void |
Register router handler. |
middlewareHandler |
(*App, HandlerFn) void |
Register middleware+router handler (highest priority). |
database |
(*App, *sqlite.Db) void |
Attach database (closes on deinit). |
handleRequest |
(*App, *Request, *Response) void |
Dispatch without networking (for tests). |
listenAndServe |
(*App, std.Io) !void |
Bind, listen, and serve requests. Blocks. |
shutdown |
(*App, std.Io) void |
Signal graceful shutdown. |
buildRequestFromHead |
(*App, head_buffer) !Request |
Parse a request from raw bytes (for tests). |
Server.Config fields:
| Field | Type | Default |
|---|---|---|
host |
[]const u8 |
"127.0.0.1" |
port |
u16 |
8080 |
read_buffer_size |
usize |
8192 |
write_buffer_size |
usize |
8192 |
max_body_size |
usize |
1_048_576 (1 MiB) |
max_requests |
?usize |
null (unlimited) |
Handler priority: middleware_handler > router_handler > handler_fn > built-in 404.
zypher.core.Request
Parsed HTTP request. Created once per connection, freed after the handler returns. All slices point into the parsed data — no extra copies except for URL-decoded strings.
Fields:
| Field | Type | Description |
|---|---|---|
method |
Method |
.get, .post, .put, etc. |
path |
[]const u8 |
Request path: /users/42 |
query |
StringHashMap([]const u8) |
Parsed query string parameters |
headers |
StringHashMap([]const u8) |
HTTP headers (keys as received, values trimmed) |
body |
[]const u8 |
Raw request body |
body_owned |
bool |
If true, body is heap-allocated and freed in deinit |
params |
RouteParams |
URL parameters extracted by the router |
user |
?*anyopaque |
Cast to *Session when session middleware is used |
allocator |
std.mem.Allocator |
Per-request allocator |
Methods:
| Method | Signature | Description |
|---|---|---|
header |
(*const Request, name) ?[]const u8 |
Case-insensitive header lookup |
queryParam |
(*const Request, name) ?[]const u8 |
Query string lookup |
formValue |
(*const Request, name) ?[]const u8 |
POST form field or query string |
cookie |
(*const Request, name) ?[]const u8 |
Cookie value from Cookie: header |
file |
(*const Request, name) ?FileUpload |
Uploaded file from multipart body |
deinit |
(*Request) void |
Free all owned memory |
Static helpers (called by Server internally):
| Method | Description |
|---|---|
parsePath(target) |
Extracts path before ? |
parseQueryString(gpa, raw) |
Parses key=val&key2=val2 into map |
parseFormUrlEncoded(gpa, body) |
Same as parseQueryString for POST bodies |
parseMultipartFormData(gpa, ct, body) |
Parses multipart/form-data into fields + files |
parseCookies(gpa, header) |
Parses Cookie: header into name=value map |
validateBodySize(len, max) |
Returns error.BodyTooLarge if exceeded |
deinitQueryString(map, gpa) |
Frees decoded key+value strings |
deinitFiles(files, gpa) |
Frees file upload entries |
getHeaderCI(headers, name) |
Case-insensitive header lookup (static) |
FileUpload struct:
| Field | Type |
|---|---|
filename |
[]const u8 |
content_type |
[]const u8 |
data |
[]const u8 |
zypher.core.Response
HTTP response builder. Created with init(gpa) for every request. Headers and body are heap-allocated and freed in deinit. All mutating methods return *Response for chaining.
Fields:
| Field | Type | Initial value |
|---|---|---|
status_code |
u16 |
200 |
reason_phrase |
?[]const u8 |
"OK" |
headers |
StringHashMap([]const u8) |
empty |
set_cookie_headers |
ArrayList([]const u8) |
empty |
body |
?[]const u8 |
null |
use_chunked |
bool |
false |
allocator |
std.mem.Allocator |
per-request allocator |
Methods:
| Method | Signature | Description |
|---|---|---|
init |
(gpa) Response |
Create |
deinit |
(*Response) void |
Free body, headers, cookies |
status |
(*Response, code: u16) *Response |
Set status code + reason phrase |
header |
(*Response, name, value) *Response |
Set a response header. Sanitizes CR/LF. Chainable. |
addSetCookie |
(*Response, value) *Response |
Append raw Set-Cookie header value |
text |
(*Response, content) !void |
Set text/plain; charset=utf-8 body |
html |
(*Response, content) !void |
Set text/html; charset=utf-8 body |
json |
(*Response, content) !void |
Set application/json body. Accepts raw []const u8 or any serializable Zig value. |
stream |
(*Response, content) !void |
Set chunked body with Transfer-Encoding: chunked |
redirect |
(*Response, url, code) !void |
Set Location header and status |
setCookie |
(*Response, Cookie) *Response |
Set-Cookie with all attributes |
deleteCookie |
(*Response, name) *Response |
Set-Cookie with Max-Age=0 |
send |
(*Response, gpa, *ArrayList(u8)) !void |
Serialize full HTTP/1.1 response |
Cookie struct fields:
| Field | Type | Default |
|---|---|---|
name |
[]const u8 |
required |
value |
[]const u8 |
"" |
path |
[]const u8 |
"/" |
domain |
?[]const u8 |
null |
max_age |
?u32 |
null |
secure |
bool |
false |
http_only |
bool |
false |
same_site |
SameSite |
.Lax |
SameSite enum: .Strict, .Lax, .None
zypher.core.Method
pub const Method = enum {
get, post, put, patch, delete, options, head,
pub fn fromStdString(m: std.http.Method) Method;
pub fn toStdString(m: Method) std.http.Method;
};
zypher.router.Router
Compile-time or runtime route table with runtime dispatch.
| Method | Signature | Description |
|---|---|---|
init |
(comptime routes, not_found_fn) Router |
Comptime validation of all patterns |
initFromSlice |
(routes: []const Route, not_found_fn) Router |
Runtime init from slice |
route |
(Method, pattern, handler) Route |
Create a Route value |
group |
(comptime prefix, comptime routes) []const Route |
Prefix all patterns |
dispatch |
(*const Router, *Request, *Response) void |
Route to matching handler |
Route struct:
pub const Route = struct {
method: Method,
pattern: []const u8,
handler: *const fn (*Request, *Response) void,
};
Pattern syntax:
| Segment | Example | Matches | Notes |
|---|---|---|---|
| Static | /users |
/users only |
Exact match |
:param |
/:id |
/<any> |
Extracts as string |
:param[u64] |
/:id[u64] |
/<digits> |
Validates + extracts as u64 string |
* |
/files/* |
/files/<anything> |
Must be last segment |
Dispatch algorithm:
- Test all routes. Among those whose path matches, pick the most specific one by pattern score (static segments score 100,
:paramscore 10,*scores 1). - If path matches but no route has the right method → 405 with
Allowheader. - If nothing matches → call
not_found_fn.
Duplicate param names in a pattern are caught at compile time.
zypher.router.RouteParams
Zero-allocation URL parameter store backed by fixed-size stack arrays.
| Member | Description |
|---|---|
max_params = 16 |
Maximum parameters per route |
init(gpa) |
Create empty params |
put(name, value) !void |
Store param (overwrites on duplicate name) |
get(name) ?[]const u8 |
Lookup by name |
getAs(T, name) !T |
Lookup and parseInt to type T |
count() usize |
Number of params stored |
reset() |
Clear without freeing |
zypher.middleware
Chain(comptime mws: anytype) type
Returns a struct with one method:
fn run(req: *Request, res: *Response, handler: HandlerFn) void
All middleware must match MiddlewareFn:
pub const MiddlewareFn = *const fn (*Request, *Response,
*const fn (*Request, *Response) void) void;
The chain is fully unrolled at compile time — no dispatch table, no heap allocation per request.
Built-in middleware
| Module | Middleware fn | Description |
|---|---|---|
logger |
middleware |
Logs method, path, status, elapsed µs |
cors |
middleware |
Default CORS (allow all origins) |
cors |
middlewareWith(Config) |
CORS with custom config |
csrf |
middleware |
CSRF token check |
csrf |
formFieldForRequest(gpa, req) ![]u8 |
Hidden input HTML with token |
csrf |
ensureToken(req) ![]const u8 |
Get/create CSRF token for session |
csrf |
validateTokenForRequest(req, token) bool |
Constant-time token comparison |
rate_limit |
middleware |
100 req/60s per IP |
rate_limit |
middlewareWith(Config) type |
Custom rate limiter (returns a type) |
session |
middleware |
Load/create session, set req.user |
session |
setStore(*SessionStore) void |
Configure store (call before serving) |
session |
setCookieConfig(CookieConfig) void |
Configure cookie attributes |
session |
resetCookieConfig() void |
Restore defaults |
static |
middleware |
Serve from ./public at / |
static |
middlewareWith(Config) |
Serve with custom config |
compress |
middleware |
Gzip response bodies |
security_headers |
middleware |
X-Content-Type-Options, X-Frame-Options, etc. |
security_headers |
configure(Options) void |
Enable/disable individual headers |
recovery |
middleware |
No-op placeholder (panics are OS-level) |
cors.Config:
| Field | Default |
|---|---|
allowed_origins: ?[]const []const u8 |
null (all) |
allowed_methods: []const u8 |
"GET, POST, PUT, DELETE, PATCH, OPTIONS" |
allowed_headers: []const u8 |
"Content-Type, Authorization, X-CSRF-Token" |
max_age: []const u8 |
"86400" |
allow_credentials: bool |
false |
rate_limit.Config:
| Field | Default |
|---|---|
max_requests: u32 |
100 |
window_seconds: u32 |
60 |
static.Config:
| Field | Default |
|---|---|
root_dir: []const u8 |
"./public" |
prefix: []const u8 |
"/" |
serve_index: bool |
true |
security_headers.Options:
| Field | Default |
|---|---|
x_content_type_options: bool |
true |
x_frame_options: bool |
true |
referrer_policy: bool |
true |
x_xss_protection: bool |
true |
content_security_policy: bool |
false |
strict_transport_security: bool |
false |
custom_headers: []const []const u8 |
&.{} (pairs: name, value, name, value…) |
zypher.template
TemplateEngine
| Method | Signature | Description |
|---|---|---|
init |
(gpa) TemplateEngine |
Create engine |
deinit |
(*TE) void |
Free cache |
load |
(*TE, name, source) !*Template |
Parse and cache |
getTemplate |
(*TE, name) ?*Template |
Look up by name |
render |
(*TE, name, *Context, *Writer) !void |
Render to writer |
Template
| Method | Signature | Description |
|---|---|---|
fromSource |
(gpa, source) !Template |
Parse standalone |
render |
(*Template, *Context, *Writer) !void |
Render to writer |
deinit |
(*Template) void |
Free AST |
Context
| Method | Signature | Description |
|---|---|---|
init |
(gpa) Context |
Create |
deinit |
(*Context) void |
Free map (does not free Value data) |
put |
(*Context, key, Value) !void |
Set variable |
get |
(*Context, key) ?Value |
Read variable |
Value
pub const Value = union(enum) {
string: []const u8,
int: i64,
float: f64,
bool: bool,
list: []const Value,
map: *Context,
null_val: void,
};
isTruthy(): returns false for empty strings, zero, false, empty lists, null_val. True for everything else.
Template syntax
| Syntax | Effect |
|---|---|
{{ varname }} |
Render variable (HTML-escaped) |
{{ varname|safe }} |
Render without escaping |
{{ varname|upper }} |
Apply upper filter |
{{ varname|truncate(100) }} |
Apply truncate filter with argument |
{{ obj.field }} |
Dot-access nested Context |
{{ list.0 }} |
Index into list |
{% if cond %} |
Conditional block |
{% elif cond %} |
Alternative condition |
{% else %} |
Default block |
{% endif %} |
End conditional |
{% for item in list %} |
Loop over list |
{% endfor %} |
End loop |
{% extends "base.html" %} |
Template inheritance (must be first node) |
{% block name %} |
Named block for overriding |
{% endblock %} |
End named block |
{% include "partial.html" %} |
Include another template |
forloop context variables: counter, counter0, first, last.
zypher.orm
sqlite.Db
| Method | Signature | Description |
|---|---|---|
open |
(gpa, path: [:0]const u8) !Db |
Open database (":memory:" for in-memory) |
close |
(*Db) void |
Close connection |
isOpen |
(*Db) bool |
Check if handle is non-null |
exec |
(*Db, sql: [:0]const u8) !void |
Execute statement with no parameters |
prepare |
(*Db, sql: [:0]const u8) !Stmt |
Prepare parameterized statement |
lastInsertRowId |
(*Db) i64 |
Rowid of last INSERT |
changes |
(*Db) i64 |
Row count from last UPDATE/DELETE |
DbError set: OpenFailed, ExecFailed, PrepareFailed, StepFailed, BindFailed, ColumnFailed, ConstraintViolation, UnexpectedResult.
sqlite.Stmt
| Method | Signature | Description |
|---|---|---|
bind |
(*Stmt, Value, idx: c_int) !void |
Bind parameter (1-based) |
step |
(*Stmt) !bool |
true = row ready, false = done |
column |
(*Stmt, ColumnType, idx: c_int) !Value |
Read column |
columnType |
(*Stmt, idx: c_int) !ColumnType |
Column type |
reset |
(*Stmt) !void |
Reset for re-execution |
finalize |
(*Stmt) void |
Free resources |
sqlite.Value union: .int: i64, .float: f64, .text: []const u8, .null: void
ColumnType enum: .integer, .float, .text, .blob, .null
schema
Field(name, kind, opts) → FieldDef
FieldKind: .integer, .float, .text, .boolean
FieldOptions:
| Option | Type | SQL effect |
|---|---|---|
primary: bool |
false |
PRIMARY KEY |
required: bool |
false |
NOT NULL |
unique: bool |
false |
UNIQUE |
foreign: ?[:0]const u8 |
null |
REFERENCES <value> |
default: ?DefaultValue |
null |
DEFAULT <value> |
Model(table, FieldsStruct) → generated type.
Generated SQL constants (all [:0]const u8, all evaluated at compile time):
| Constant | SQL |
|---|---|
create_table_sql |
CREATE TABLE IF NOT EXISTS <table> (...) |
drop_table_sql |
DROP TABLE IF EXISTS <table> |
insert_sql |
INSERT INTO <table> (<non-pk-cols>) VALUES (?,?,...) |
select_all_sql |
SELECT <all-cols> FROM <table> |
select_by_id_sql |
<select_all> WHERE id = ? |
update_by_id_sql |
UPDATE <table> SET <col>=?,... WHERE id = ? |
delete_by_id_sql |
DELETE FROM <table> WHERE id = ? |
Other constants: table_name: [:0]const u8, fields_len: comptime_int, insert_field_count: comptime_int.
query
All functions are generic over a comptime model type M. Row type is a tuple of Zig types matching the model's fields in definition order (primary key is always i64, integers are i64, floats are f64, text is []const u8, boolean is bool).
| Function | Signature | Description |
|---|---|---|
create |
(M, *Db, []Value) !i64 |
INSERT → rowid |
getById |
(M, *Db, gpa, id: i64) !RowType(M) |
SELECT WHERE id=? |
all |
(M, *Db, gpa) !ArrayList(RowType(M)) |
SELECT all |
filter |
(M, *Db, gpa, where: [:0], []Value) !ArrayList |
SELECT WHERE |
filterLimitOffset |
(M, *Db, gpa, where, values, limit, offset) !ArrayList |
Paginated |
filterOrderLimitOffset |
(M, *Db, gpa, where, values, order, limit, offset) !ArrayList |
Full |
order |
(M, *Db, gpa, order_by: []const u8) !ArrayList |
SELECT ORDER BY |
first |
(M, *Db, gpa, where, values) !?RowType(M) |
First match or null |
count |
(M, *Db) !u64 |
SELECT COUNT(*) |
updateById |
(M, *Db, id, []Value) !void |
UPDATE WHERE id=? |
deleteById |
(M, *Db, id) !void |
DELETE WHERE id=? |
save |
(M, *Db, gpa, *RowType(M)) !i64 |
INSERT if id==0, UPDATE otherwise |
freeRow |
(M, gpa, *RowType(M)) void |
Free text column memory |
QuerySet(M) builder:
var qs = query.QuerySet(M).init(&db, gpa);
defer qs.deinit();
var rows = try qs
.filterBy("status = ?", &.{ .{ .text = "active" } })
.orderBy("created_at DESC")
.limit(10)
.offset(0)
.exec();
All methods on QuerySet return *Self for chaining. .exec() executes the built query.
migration
pub const Migration = struct {
id: i64,
name: [:0]const u8,
up_sql: [:0]const u8,
down_sql: [:0]const u8,
};
pub const MigrationRunner = struct {
pub fn init(*Db) MigrationRunner;
pub fn ensureHistoryTable(*MR) !void; // create zypher_migrations if not exists
pub fn migrate(*MR, []Migration) !void; // apply pending
pub fn rollback(*MR, []Migration, n: usize) !void; // rollback last N
pub fn status(*MR, gpa, []Migration) ![]MigrationStatus;
pub fn countApplied(*MR) !u64;
};
zypher.forms
form.Field(name, kind, opts)
FieldKind: .text, .integer, .boolean, .file
opts: { required: bool = false, validator: ?ValidatorFn = null }
form.Form(name, FieldsStruct) → generated type
| Method | Signature | Description |
|---|---|---|
bind |
(gpa, *StringHashMap([]const u8)) !BoundForm |
Bind from map |
bindRequest |
(gpa, *Request) !BoundForm |
Bind from request |
BoundForm:
| Method | Description |
|---|---|
validate() bool |
Run all validators. Populates errors. |
cleanedData() Data |
Typed tuple in field-definition order |
getValue(name) []const u8 |
Raw string value for any field |
errors: StringHashMap([]const u8) |
Field name → error message |
csrfFieldForRequest(req) ![]u8 |
CSRF hidden input HTML |
deinit() void |
Free resources |
Data type is a @Tuple of Zig types in field order: .text → []const u8, .integer → i64, .boolean → bool, .file → []const u8.
validators
| Validator | Signature | Description |
|---|---|---|
required |
([]const u8) ?[]const u8 |
Non-empty |
requiredOptional |
(?[]const u8) ?[]const u8 |
Non-null + non-empty |
minLength(n) |
Returns fn([]const u8) ?[]const u8 |
At least N characters |
maxLength(n) |
Returns fn([]const u8) ?[]const u8 |
At most N characters |
email |
([]const u8) ?[]const u8 |
Exactly one @, non-empty local and domain, dot in domain |
url |
([]const u8) ?[]const u8 |
Must start with http:// or https://, dot in host |
regex(pattern) |
Returns fn([]const u8) ?[]const u8 |
Glob pattern (* any seq, ? any char) |
choices(list) |
Returns fn([]const u8) ?[]const u8 |
Value must be in list |
min(T, n) |
Returns fn(T) ?[]const u8 |
Numeric minimum |
max(T, n) |
Returns fn(T) ?[]const u8 |
Numeric maximum |
custom(T, fn) |
Returns validator function | Wrap arbitrary function |
Fields named "email" automatically receive validators.email in addition to any custom validator.
zypher.auth
session.Session
| Method | Signature | Description |
|---|---|---|
put |
(*Session, gpa, key, value) !void |
Store value |
get |
(*const Session, key) ?[]const u8 |
Read value |
isExpired |
(*const Session) bool |
Check against expires_at |
deinit |
(*Session, gpa) void |
Free data map |
Session ID is [32]u8 (256-bit random) stored as id. Expiry is a Unix timestamp.
session.SessionStore
| Method | Signature | Description |
|---|---|---|
init |
(gpa) SessionStore |
Create |
deinit |
(*SS) void |
Free all sessions |
create |
(*SS) !Session |
New session with 24h expiry |
createWithExpiry |
(*SS, unix_ts: i64) !Session |
Custom expiry |
save |
(*SS, *Session) !void |
Deep-copy into store |
get |
(*SS, raw_id: [32]u8) !?*Session |
Lookup by raw bytes |
getByHexId |
(*SS, hex: []const u8) !?*Session |
Lookup by hex string |
destroy |
(*SS, raw_id) !void |
Delete session |
destroyByHexId |
(*SS, hex) !void |
Delete by hex string |
SESSION_ID_LEN = 32. CookieConfig struct: httponly: bool, secure: bool, samesite: [:0]const u8, path: [:0]const u8, max_age: u32.
password
| Function | Signature | Description |
|---|---|---|
hash |
(gpa, plaintext) ![]const u8 |
PBKDF2-HMAC-SHA256, 100K iters, random salt. Returns $pbkdf2-sha256$... |
verify |
(stored_hash, plaintext) !bool |
Parse + re-derive + constant-time compare |
user.User
| Method | Signature | Description |
|---|---|---|
init |
(gpa, username, plaintext) !User |
Hash password, dupe strings |
deinit |
(*User) void |
Free username and hash |
authenticate |
(*User, plaintext) !bool |
Verify if active and password matches |
setRole |
(*User, role) void |
Update role (frees previous heap role) |
deactivate |
(*User) void |
Set is_active = false |
Auth guard middleware:
| Function | Behavior |
|---|---|
loginRequired(req, res, next) |
302 to /login if req.user == null |
superuserRequired(req, res, next) |
302 to /login if no user; 403 if role ≠ "admin" |
Built-in view handlers (usable as route handlers directly):
| Handler | Method | Description |
|---|---|---|
loginView |
GET+POST | Renders login form; processes POST (stub — sets 200) |
logoutView |
GET | Deletes session cookie, redirects to / |
registerView |
GET+POST | Renders register form; processes POST (stub) |
zypher.admin
| Symbol | Signature | Description |
|---|---|---|
AdminSite(registrations) |
comptime | Generate admin site type |
Registration(Model, opts) |
comptime | Register a model with the admin |
Site.routes() |
() []const Route |
Admin routes |
Site.loadTemplates(*engine) |
void |
Load admin HTML into engine |
setDb(*Db) |
void |
Global admin database |
setEngine(*TE) |
void |
Global admin template engine |
Registration options:
| Field | Type | Default |
|---|---|---|
verbose_name_plural |
[]const u8 |
"" |
list_per_page |
u32 |
20 |
Generated admin routes (prefix: /admin):
| Method | Path | Action |
|---|---|---|
| GET | /admin/ |
Index of all registered models |
| GET | /admin/<model>/ |
List records |
| GET | /admin/<model>/add/ |
Create form |
| POST | /admin/<model>/add/ |
Create record |
| GET | /admin/<model>/<id>/ |
Edit form |
| POST | /admin/<model>/<id>/ |
Update record |
| GET | /admin/<model>/<id>/delete/ |
Delete confirm |
| POST | /admin/<model>/<id>/delete/ |
Delete record |
| GET | /admin/login |
Login form |
| POST | /admin/login |
Process login |
| GET | /admin/forgot-password |
Forgot password form |
| POST | /admin/forgot-password |
Send recovery code |
| GET | /admin/reset-password |
Reset password form |
| POST | /admin/reset-password |
Process reset |
zypher.errors.ZypherError
A comprehensive error union. All variants and their string representations:
| Error | String |
|---|---|
BadRequest |
"Bad Request" |
BodyTooLarge |
"Body Too Large" |
MethodNotAllowed |
"Method Not Allowed" |
NotFound |
"Not Found" |
Timeout |
"Timeout" |
InvalidRoutePattern |
"Invalid Route Pattern" |
AmbiguousRoute |
"Ambiguous Route" |
CsrfValidationFailed |
"CSRF Validation Failed" |
RateLimitExceeded |
"Rate Limit Exceeded" |
CorsBlocked |
"CORS Blocked" |
PathTraversal |
"Path Traversal Detected" |
TemplateNotFound |
"Template Not Found" |
TemplateSyntaxError |
"Template Syntax Error" |
TemplateRenderFailed |
"Template Render Failed" |
DbConnectionFailed |
"Database Connection Failed" |
DbQueryFailed |
"Database Query Failed" |
DbNotFound |
"Not Found" |
DbConstraintViolation |
"Constraint Violation" |
MigrationFailed |
"Migration Failed" |
ValidationFailed |
"Validation Failed" |
AuthenticationFailed |
"Authentication Failed" |
SessionInvalid |
"Session Invalid" |
PermissionDenied |
"Permission Denied" |
PasswordHashFailed |
"Password Hash Failed" |
AdminAccessDenied |
"Admin Access Denied" |
UnknownCommand |
"Unknown Command" |
Use zypher.errors.errorToString(err) to convert any variant to its human-readable string.
zypher.log
Centralised log level and writer control.
| Function | Signature | Description |
|---|---|---|
setLogLevel |
(Level) void |
Set global minimum level |
getLogLevel |
() Level |
Get current level |
setLogWriter |
(?*Writer) void |
Redirect output (null = default std.log) |
startCapture |
(gpa, *ArrayList(u8)) void |
Buffer log output (tests) |
stopCapture |
() void |
Stop buffering |
shouldLog |
(Level) bool |
Check if level would emit |
writeLog |
(Level, scope, msg) void |
Low-level emit |
Level enum: .err = 0, .warn = 1, .info = 2, .debug = 3.
zypher.cli_runner (programmatic CLI dispatch)
Useful for testing CLI commands in unit tests without shelling out:
const cli = @import("zypher").cli_runner;
// Call any CLI command programmatically:
try cli.dispatchInner(
&out_writer.interface,
&err_writer.interface,
init,
"createsuperuser",
args,
"0.1.0-beta",
);
Also exports:
parseRunserverConfig(args) !RunserverConfigvalidateSuperuserCredentials(SuperuserInput) !voidrunCreatesuperuserPrompt(gpa, db_path, reader, out, err) !voidrunShellSession(reader, out, err) !voidparsePort(args) ?u16— extract--portfrom argsinferZypherRoot(gpa, io) ![]const u8buildRunArgv(gpa, zypher_root, port, app_args) ![][]const u8
Build Steps Reference
All commands run from the project root with zig build <step>.
Package approach (chat-pkg/)
| Command | Description |
|---|---|
zig build |
Build and install binary to zig-out/bin/ |
zig build run |
Build and run the server |
zig build run -- --port 9000 |
Run on custom port |
zig build test |
Run unit tests |
zig build doc |
Generate API docs to zig-out/docs/ |
CLI-scaffolded projects (chat-cli/)
| Command | Description |
|---|---|
zypher run |
Build and run via CLI |
zypher run --port 9000 |
Run on custom port |
zig build run |
Direct Zig build (equivalent) |
zig build run -- --port 9000 |
Direct Zig build on custom port |
zig build test |
Run tests |
zig build doc |
Generate docs |
zypher migrate |
Apply pending SQL migrations |
zypher makemigrations |
Generate new migrations from schema.zypher |
zypher createsuperuser |
Create admin account |
zypher shell |
Open arithmetic REPL |
zypher doc |
Serve zypher library docs |
zypher doc-user |
Serve your app's docs |
This document was produced from a complete line-by-line reading of every source file in the zypher repository at https://github.com/zypher-org/zypher (main branch, June 2026), including all source files in src/, src/cli/, the embedded templates in src/cli/embedded_templates.zig, all three example applications, all scaffold templates, the full build.zig, and all documentation in docs/.