49 KiB
MACH (Modern Asynchronous C Hypermedia)
Declarative framework for asynchronous, reactive web apps in C23. Below is a tutorial guide that builds a working app step by step, then a complete reference. Every code block is real, compilable code (not pseudo-code).
Critical rules
- Templates use Mustache sections, not dot paths. Anything stored under a
.set_keyis a TABLE — query results, find results, fetch JSON responses, joined data. Tables require opening a section to reach their fields, even if the table has only one row:{{#blog}}{{title}}{{/blog}}, never{{blog.title}}or bare{{title}}at root. Bare{{key}}works ONLY for top-level scalars:.contextvalues, validated inputs (aftervalidate()promotes them frominput:to app scope), and framework values likeuser_id. - Inline templates are C string literals. Every Mustache tag, including section open/close tags on their own lines, must be inside quotes.
- Modules are composed by
#include "module/module.c"frommain.cand listing them in.modules. Never useexterndeclarations. - Modules communicate only through pub/sub events (
emit()+.publishes+.events). They never call each other directly. - Resource fields like
.get,.url,.mimeare not top-level config fields — they belong inside entries of.resources. - Multiple items in a single
query()orfetch()call run concurrently. Two back-to-back calls run serially. - SQL
{{interpolation}}is bound as prepared-statement parameters. Never spliced. render()auto-escapes context values. Use{{raw:field}}only when the value is trusted HTML.- Application code never calls
malloc/freeand never manages threads. Useallocate(bytes)anddefer_free(ptr)for arena-managed memory insideexec().
Guide
A walkthrough that builds a working todo app in nine steps, introducing one concept at a time.
1. A Page
Two resources, each with a GET pipeline. The home page links to the todos page with {{url:todos}}, which resolves to the target resource's URL at render time.
#include <mach.h>
config mach(){
return (config) {
.resources = {
{"home", "/",
.get = {
render(.template =
"<html><body>"
"<h1>Welcome</h1>"
"<a href='{{url:todos}}'>My Todos</a>"
"</body></html>"
)
}
},
{"todos", "/todos",
.get = {
render(.template =
"<html><body>"
"<h1>My Todos</h1>"
"<p>Nothing yet.</p>"
"</body></html>"
)
}
}
}
};
}
Each resource names itself ("home", "todos") so other pages reference it by name, not hardcoded path.
2. Show Data
Add a SQLite database with one migration and one seed, then read from it in the GET pipeline.
#include <mach.h>
#include <sqlite.h>
config mach(){
return (config) {
.resources = {
// ... home resource unchanged from step 1
{"todos", "/todos",
.get = {
query({.set_key = "todos", .db = "todos_db",
.query = "select id, title from todos;"}),
render(.template =
"<html><body>"
"<h1>My Todos</h1>"
"<ul>{{#todos}}<li>{{title}}</li>{{/todos}}</ul>"
"</body></html>"
)
}
}
},
.databases = {{
.engine = sqlite_db,
.name = "todos_db",
.connect = "file:todos.db?mode=rwc",
.migrations = {
"CREATE TABLE todos ("
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
"title TEXT NOT NULL"
");"
},
.seeds = {"INSERT INTO todos(title) VALUES('Learn MACH');"}
}},
.modules = {sqlite}
};
}
query runs the SELECT and stores the rows under todos in pipeline context. render walks the section with {{#todos}}...{{/todos}}. Migrations run on the first connection.
Concurrent queries. Multiple items in a single
query()run concurrently. Two separatequery({...})steps run serially.query( {.set_key = "todos", .db = "todos_db", .query = "select id, title from todos;"}, {.set_key = "count", .db = "todos_db", .query = "select count(*) as n from todos;"} )
Sections, not dot paths. Write
{{#todos}}{{title}}{{/todos}}, not{{todos.title}}. Same syntax for single-row records:{{#count}}{{n}}{{/count}}. Dot paths render empty.
3. Accept Input
Add a POST verb that validates a title parameter, inserts it, and redirects back to GET (POST-redirect-GET).
config mach(){
return (config) {
.resources = {
// ... home resource unchanged
{"todos", "/todos",
.get = {
// ... unchanged from step 2; template gains a form (see below)
},
.post = {
validate({"title",
.validation = validate_not_empty,
.message = "title cannot be empty"}),
query({.db = "todos_db",
.query = "insert into todos(title) values({{title}});"}),
redirect("todos")
}
}
},
// ... .databases and .modules unchanged from step 2
};
}
The GET template gains a form pointing at {{url:todos}}:
"<form method='post' action='{{url:todos}}'>"
"<input name='title' value='{{input:title}}'>"
"<button>Add</button>"
"</form>"
The POST pipeline validates first; on success, the title is promoted from input:title to app scope. Interpolated {{title}} is bound as a prepared-statement parameter, not spliced. redirect("todos") returns 302 to /todos.
4. Handle Errors
Validation failure raises http_bad_request. Add a resource-scoped error handler that re-enters the GET pipeline with reroute("todos"), and add error markup to the form template.
{"todos", "/todos",
.get = {
query({.set_key = "todos", .db = "todos_db",
.query = "select id, title from todos;"}),
render(.template =
"<html><body>"
"<h1>My Todos</h1>"
"<ul>{{#todos}}<li>{{title}}</li>{{/todos}}</ul>"
"<form method='post' action='{{url:todos}}'>"
"<input name='title' value='{{input:title}}'>"
"{{#error:title}}<span>{{error_message:title}}</span>{{/error:title}}"
"<button>Add</button>"
"</form>"
"</body></html>"
)
},
.post = {
// ... unchanged from step 3
},
.errors = {
{http_bad_request, { reroute("todos") }}
}
}
reroute("todos") re-enters the GET pipeline in-process. The input: and error: scopes persist across the reroute, so {{input:title}} repopulates the field and {{#error:title}} renders the message.
5. Nested Data
Add a /todos/:id page that fetches a todo and its comments concurrently, nests comments inside the todo record, and renders them together. Comments belong to the same domain as todos, so the new comments table is added as a migration on the existing todos_db.
The todos list also gains links: <ul>{{#todos}}<li><a href='{{url:todo:id}}'>{{title}}</a></li>{{/todos}}</ul>.
{"todo", "/todos/:id",
.get = {
validate({"id", .validation = validate_integer,
.message = "must be an integer"}),
query(
{.set_key = "todo", .db = "todos_db",
.query = "select id, title from todos where id = {{id}};"},
{.set_key = "comments", .db = "todos_db",
.query = "select id, todo_id, body from comments where todo_id = {{id}};"}
),
join(
.target_table_key = "todo",
.target_field_key = "id",
.nested_table_key = "comments",
.nested_field_key = "todo_id",
.target_join_field_key = "comments"
),
render(.template =
"<html><body>"
"{{#todo}}"
"<h1>{{title}}</h1>"
"<h2>Comments</h2>"
"<ul>{{#comments}}<li>{{body}}</li>{{/comments}}</ul>"
"{{/todo}}"
"</body></html>"
)
}
}
New migration appended to todos_db.migrations:
"CREATE TABLE comments ("
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
"todo_id INTEGER NOT NULL REFERENCES todos(id),"
"body TEXT NOT NULL"
");"
Two queries run in parallel under one query() call. join() lifts comments inside each todo record. The template enters {{#todo}} first and reaches {{#comments}} from within. Iterating {{#comments}} at root would skip the nesting; dot paths like {{todo.title}} do not resolve.
6. Tasks
Tasks are named pipelines that run asynchronously on task reactors. Triggered on a cron schedule or enqueued from another pipeline with task("name"). Add a nightly task that records the current todo count, and re-run it from POST so stats stay fresh.
config mach(){
return (config) {
.resources = {
// ... home, todo/:id unchanged
{"todos", "/todos",
.get = { /* unchanged from step 4 */ },
.post = {
validate({"title",
.validation = validate_not_empty,
.message = "title cannot be empty"}),
query({.db = "todos_db",
.query = "insert into todos(title) values({{title}});"}),
task("record_daily_stats"),
redirect("todos")
},
.errors = { {http_bad_request, { reroute("todos") }} }
}
},
.tasks = {
{"record_daily_stats", {
query({.db = "todos_db",
.query = "insert into daily_stats(todo_count) "
"select count(*) from todos;"})
}, .cron = "0 0 * * *"}
},
.databases = {{
.engine = sqlite_db,
.name = "todos_db",
.connect = "file:todos.db?mode=rwc",
.migrations = {
// ... existing todos and comments tables, plus:
"CREATE TABLE daily_stats ("
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
"recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,"
"todo_count INTEGER NOT NULL"
");"
},
.seeds = {"INSERT INTO todos(title) VALUES('Learn MACH');"}
}},
.modules = {sqlite}
};
}
The same task definition is reused two ways: .cron runs it nightly, task("record_daily_stats") enqueues it on demand from the POST. Both invocations land on a task reactor, on separate cores from request reactors, so POST returns immediately. If a task needs values from the calling context, list them in .accepts and reference inside the task with {{user_id}} interpolation.
Tasks are durable: if the process crashes mid-task, MACH checkpoints context after each step and resumes at the step where it left off.
7. Modules & Events
Features split into self-contained modules. A module declares its own resources, databases, migrations, context, error/repair handlers, tasks, and event subscribers. Modules communicate through pub/sub events.
Modules are plain C files. Each defines a function returning config. main.c pulls them in with #include and registers in .modules.
.
├── activity/
│ └── activity.c
├── todos/
│ └── todos.c
└── main.c
main.c — thin root, composes modules and handles cross-cutting concerns.
#include <mach.h>
#include <sqlite.h>
#include "todos/todos.c"
#include "activity/activity.c"
config mach(){
return (config){
.resources = {
{"home", "/",
.get = {
render(.template =
"<html><body>"
"<h1>Welcome</h1>"
"<a href='{{url:todos}}'>My Todos</a> · "
"<a href='{{url:activity}}'>Activity</a>"
"</body></html>"
)
}
}
},
.modules = {todos, activity, sqlite}
};
}
todos/todos.c — the existing todos config from step 6, wrapped as config todos() with .name and .publishes. POST gains emit("todo_created").
#include <mach.h>
#include <sqlite.h>
config todos(){
return (config){
.name = "todos",
.publishes = {
{"todo_created", .with = {"title"}}
},
.resources = {
// ... todos and todo/:id resources from step 6
// POST gains: emit("todo_created") between query() and redirect()
},
.tasks = {
// ... record_daily_stats from step 6
},
.databases = {{
// ... todos_db with todos, comments, daily_stats from step 6
}},
.modules = {sqlite}
};
}
activity/activity.c — new subscriber module with its own database, its own resource, and an event handler.
#include <mach.h>
#include <sqlite.h>
config activity(){
return (config){
.name = "activity",
.resources = {
{"activity", "/activity",
.get = {
query({.set_key = "activities", .db = "activity_db",
.query = "select kind, ref, created_at from activities "
"order by created_at desc;"}),
render(.template =
"<html><body>"
"<h1>Activity</h1>"
"<ul>{{#activities}}"
"<li>{{created_at}}: {{kind}} — {{ref}}</li>"
"{{/activities}}</ul>"
"</body></html>"
)
}
}
},
.events = {
{"todo_created", {
query({.db = "activity_db",
.query = "insert into activities(kind, ref) "
"values('created', {{title}});"})
}}
},
.databases = {{
.engine = sqlite_db,
.name = "activity_db",
.connect = "file:activity.db?mode=rwc",
.migrations = {
"CREATE TABLE activities ("
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
"kind TEXT NOT NULL,"
"ref TEXT NOT NULL,"
"created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP"
");"
}
}},
.modules = {sqlite}
};
}
When POST in todos calls emit("todo_created"), MACH propagates title from current context to every subscriber's pipeline. Neither module references the other; they only agree on the event name and payload.
Events are durable. With .publishes defined, MACH tracks delivery in a mach_events database — undelivered events replay on next boot. Adding a third subscriber is a new file with .events; the publisher doesn't change.
8. External Assets
Once templates and SQL grow past a few lines, extract them into their own files and load with (asset){#embed "..."} in .context, then reference by name.
.
├── activity/
│ ├── activity.c
│ ├── activity.mustache.html
│ ├── create_activities_table.sql
│ ├── get_activities.sql
│ └── insert_activity.sql
├── static/
│ └── home.mustache.html
├── todos/
│ ├── create_todos_table.sql
│ ├── create_comments_table.sql
│ ├── create_daily_stats_table.sql
│ ├── get_todos.sql
│ ├── create_todo.sql
│ ├── get_todo.sql
│ ├── get_comments.sql
│ ├── record_daily_stats.sql
│ ├── todos.c
│ ├── todos_list.mustache.html
│ └── todo_detail.mustache.html
└── main.c
static/ is not a module (no .c file) — it's a plain directory for root-level templates.
main.c with assets:
#include <mach.h>
#include <sqlite.h>
#include "todos/todos.c"
#include "activity/activity.c"
config mach(){
return (config){
.resources = {
{"home", "/",
.get = {
render("home")
}
}
},
.context = {
{"home", (asset){#embed "static/home.mustache.html"}}
},
.modules = {todos, activity, sqlite}
};
}
todos/todos.c with assets — pipelines reference assets by name; .context lists every asset; .migrations accept assets directly.
config todos(){
return (config){
.name = "todos",
.publishes = {{"todo_created", .with = {"title"}}},
.resources = {
{"todos", "/todos",
.get = {
query({"get_todos", .set_key = "todos", .db = "todos_db"}),
render("todos_list")
},
.post = {
validate({"title",
.validation = validate_not_empty,
.message = "title cannot be empty"}),
query({"create_todo", .db = "todos_db"}),
task("record_daily_stats"),
emit("todo_created"),
redirect("todos")
},
.errors = {{http_bad_request, { reroute("todos") }}}
},
{"todo", "/todos/:id",
.get = {
validate({"id", .validation = validate_integer,
.message = "must be an integer"}),
query(
{"get_todo", .set_key = "todo", .db = "todos_db"},
{"get_comments", .set_key = "comments", .db = "todos_db"}
),
join(
.target_table_key = "todo",
.target_field_key = "id",
.nested_table_key = "comments",
.nested_field_key = "todo_id",
.target_join_field_key = "comments"
),
render("todo_detail")
}
}
},
.tasks = {
{"record_daily_stats", {
query({"record_daily_stats", .db = "todos_db"})
}, .cron = "0 0 * * *"}
},
.context = {
{"todos_list", (asset){#embed "todos_list.mustache.html"}},
{"todo_detail", (asset){#embed "todo_detail.mustache.html"}},
{"get_todos", (asset){#embed "get_todos.sql"}},
{"create_todo", (asset){#embed "create_todo.sql"}},
{"get_todo", (asset){#embed "get_todo.sql"}},
{"get_comments", (asset){#embed "get_comments.sql"}},
{"record_daily_stats", (asset){#embed "record_daily_stats.sql"}}
},
.databases = {{
.engine = sqlite_db,
.name = "todos_db",
.connect = "file:todos.db?mode=rwc",
.migrations = {
(asset){#embed "create_todos_table.sql"},
(asset){#embed "create_comments_table.sql"},
(asset){#embed "create_daily_stats_table.sql"}
},
.seeds = {"INSERT INTO todos(title) VALUES('Learn MACH');"}
}},
.modules = {sqlite}
};
}
activity/activity.c follows the same pattern: extract its template and SQL into activity/, reference from .context.
SQL interpolation ({{title}}) still works the same as inline — bound as prepared-statement parameters. Templates are still Mustache (sections, not dot paths). The pipeline shape is unchanged across files; only where the strings live has moved.
9. External Data
Pipelines reach external HTTP services with fetch(). JSON is parsed into tables and records (nested JSON → nested context tables); plain text stored as a string. Add a quote of the day to the home page.
static/home.mustache.html
<html><body>
<h1>Welcome</h1>
{{#quote}}
<blockquote>{{content}} — {{author}}</blockquote>
{{/quote}}
<a href='{{url:todos}}'>My Todos</a> · <a href='{{url:activity}}'>Activity</a>
</body></html>
main.c
config mach(){
return (config){
.resources = {
{"home", "/",
.get = {
fetch("https://api.quotable.io/random",
.set_key = "quote"),
render("home")
}
}
},
.context = {
{"home", (asset){#embed "static/home.mustache.html"}}
},
.modules = {todos, activity, sqlite}
};
}
The Quotable API returns {"author": "...", "content": "..."}. MACH parses it into a single-row table under quote. Template opens {{#quote}}...{{/quote}} to read fields, same as query results.
Like query(), multiple items in a single fetch() run concurrently. fetch() also supports POST/PUT/PATCH/DELETE, custom headers, JSON or text request bodies, and URLs with {{interpolation}}.
Reference
Notation
C designated initializers at different brace depths:
| Braces | Meaning | Example |
|---|---|---|
{} |
Single value or struct | .get = { ... } |
{{}} |
Array of structs | .databases = {{ ... }} |
Multiple array elements: comma-separate inner braces: .databases = {{...}, {...}}.
Inside steps, each {} initializes one item. Steps that accept multiple items (such as query and validate) use comma-separated items: query({...}, {...}).
Fields are only reachable from inside their section. Templates use Mustache sections, not dot paths. Open the section first, even for single-row queries.
- ✅
{{#blog}}{{title}}{{/blog}}works for single-row or multi-row queries- ❌
{{title}}at root: no top-leveltitleexists- ❌
{{blog.title}}: dot paths are not supported
Context
Pipelines read from and write to a shared context: a scoped key-value store living for the duration of a request.
.context seeds at the root with variables and assets available on every request. Templates and SQL stored here are referenced by name in render(), query(), and find(). Use (asset){#embed "file"} to bake files into the binary at compile time. Docker secrets exposed to the container are available in context.
Three scopes:
input:xxxfor raw request parameterserror:xxxfor validation/error data- unprefixed names for app scope (query results, validated inputs, context variables)
validate() bridges input to app scope.
.context = {
{"site_name", "MACH App"},
{"version", "1.2.0"},
{"layout", (asset){#embed "static/layout.mustache.html"}},
{"home", (asset){#embed "static/home.mustache.html"}},
{"get_todos", (asset){#embed "todos/get_todos.sql"}},
{"create_todo", (asset){#embed "todos/create_todo.sql"}}
}
Databases
Each .databases entry defines a data store. Migrations are forward-only and index-based: they run in array order, each applied once, with new migrations appended to the end. Seeds are idempotent and safe to re-run. Both are tracked in a mach_meta table.
Multi-tenant databases use {{interpolation}} in .connect. Connections are pooled with LRU eviction.
| Field | Description |
|---|---|
.engine |
Database engine constant from a module: sqlite_db, postgres_db, mysql_db, redis_db, duckdb_db |
.name |
Identifier referenced by .db in query()/find() |
.connect |
Engine-specific connection string with {{interpolation}} |
.migrations |
Array of SQL strings or assets, applied once each in order |
.seeds |
Array of idempotent statements, safe to re-run on every boot |
.databases = {{
.engine = sqlite_db,
.name = "blog_db",
.connect = "file:{{user_id}}_blog.db?mode=rwc",
.migrations = {
"CREATE TABLE blogs ("
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
"title TEXT NOT NULL,"
"content TEXT NOT NULL"
");",
"CREATE TABLE comments ("
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
"blog_id INTEGER NOT NULL REFERENCES blogs(id),"
"body TEXT NOT NULL"
");"
},
.seeds = {
"INSERT OR IGNORE INTO blogs(id, title, content) VALUES(1, 'Hello', 'First post');"
}
}}
A single database can contain multiple tables; declare each as a separate migration in array order.
One database = one domain, many tables. A database maps to a domain (
todos_db,blog_db), not to a single entity. Related tables go as additional migrations on the same database. Reach for a second database when the domain is genuinely separate: audit logs, analytics, third-party cache.
Resource Pipelines
Each entry in .resources defines a named URL endpoint with HTTP verb pipelines. Resources are identified by name: {{url:name}}, redirect(), and reroute() all take a name[:arg1:arg2...] identifier with colon-separated positional args. Args fill the :params of the URL pattern in order. Path specificity is automatic: exact matches (/todos/active) beat parameterized matches (/todos/:id) regardless of definition order.
{{url:name}}with URL params. Arguments after the name are positional, colon-separated, and can be literals or context keys:
- ✅
{{url:todo:5}}resolves to/todos/5- ✅
{{url:todo:id}}readsidfrom current scope- ✅
{{url:org_todo:acme:5}}fills multiple:paramsin URL-pattern order
Clients select a verb via the request method, or by passing http_method as a query/form parameter. This lets HTML forms (limited to GET/POST) reach any verb, and gives SSE a connection path: /todos?http_method=sse.
| Field | Description |
|---|---|
.name (pos) |
Resource identifier used by {{url:name}}, redirect(), reroute() |
.url (pos) |
URL pattern with optional :params |
.steps (pos) |
Shared steps run before every verb pipeline (unnamed positional brace block after URL) |
.mime |
Default response content type |
.get .post .put .patch .delete |
Verb pipelines (ordered step arrays) |
.sse |
Persistent SSE channel: {"channel/{{interp}}", steps...} |
.errors |
Resource-scoped terminal error handlers |
.repairs |
Resource-scoped resumable repair handlers |
Combined:
{"todo", "/todos/:id", {
validate({"id", .validation = "^\\d+$", .message = "must be a number"})
},
.mime = mime_html,
.get = { find({"get_todo", .set_key = "todo", .db = "todos_db"}),
render("todo") },
.patch = { validate({"title", .validation = validate_not_empty, .message = "required"}),
query({.db = "todos_db",
.query = "update todos set title = {{title}} where id = {{id}};"}),
redirect("todo:{{id}}") },
.delete = { query({.db = "todos_db",
.query = "delete from todos where id = {{id}};"}),
redirect("todos") },
.sse = {"todo/{{id}}", sse(.event = "ready") },
.errors = {{http_not_found, { render("404") }}}
}
MIME types (for .mime): mime_html, mime_txt, mime_sse, mime_json, mime_js
Template Helpers
Helpers use {{helper:args}} syntax. Args are positional, colon-separated; each can be a literal or a context key.
| Helper | Description |
|---|---|
{{raw:field}} |
Emit context value WITHOUT HTML-escaping (render() escapes by default) |
{{precision:field:N}} |
Format numeric value with N decimal places |
{{input:field}} |
Raw, unvalidated request parameter (form repopulation) |
{{error:field}} |
Truthy when field has error (use as Mustache section) |
{{error_message:field}} |
Human-readable message for field error |
{{error_code:field}} |
HTTP status code for field error |
{{url:name[:arg1:...]}} |
Resolve resource identifier to URL |
{{asset:filename}} |
Resolve file in public/ to cache-busted URL |
{{csrf:token}} |
Random hash, sets httponly/secure/samesite cookie, outputs same value inline |
{{csrf:input}} |
Same as csrf:token but as hidden <input> for forms |
CSRF verification is automatic. MACH checks that the incoming token (from form field or query parameter) matches the value stored in the CSRF cookie and rejects mismatches with a 403. Nothing beyond emitting
{{csrf:token}}or{{csrf:input}}in the rendered response is required.
render(.template =
"<link rel='stylesheet' href='{{asset:styles.css}}'>"
"<article>"
"{{#post}}"
"<h2>{{title}}</h2>"
"<p>Rating: {{precision:score:1}}/5</p>"
"<div>{{raw:body_html}}</div>"
"{{/post}}"
"<form method='post' action='{{url:comments}}'>"
"{{csrf:input}}"
"<input name='body' value='{{input:body}}'>"
"{{#error:body}}<span>{{error_message:body}}</span>{{/error:body}}"
"<button>Comment</button>"
"</form>"
"<a href='{{url:logout}}?csrf={{csrf:token}}'>Log out</a>"
"</article>"
)
Pipeline Steps
Every step accepts .if_context and .unless_context for conditional execution.
validate
Checks request parameters (query string, form body, URL params) against regex patterns. On success, each value is promoted from input:name to app scope. On failure, errors land in error:name and a 400 Bad Request triggers the nearest error/repair pipeline. All validations in one call complete before the error fires.
| Field | Description |
|---|---|
.param_key (pos) |
Parameter name |
.validation |
Regex pattern or built-in validator macro |
.message |
Human-readable error |
.optional |
Skip if parameter absent |
.fallback |
Default value if absent |
validate(
{"email", .validation = validate_email, .message = "must be a valid email"},
{"title", .validation = validate_not_empty, .message = "cannot be empty"},
{"page", .fallback = "1",
.validation = "^\\d+$", .message = "must be a number"},
{"filter", .optional = true,
.validation = "^(active|done)$", .message = "must be 'active' or 'done'"}
)
Built-in validators (define your own with #define validate_zipcode "^\\d{5}$"):
- Strings:
validate_not_empty,validate_alpha,validate_alphanumeric,validate_slug,validate_no_html - Numbers:
validate_integer,validate_positive,validate_float,validate_percentage - Identity:
validate_email,validate_uuid,validate_username - Dates & times:
validate_date,validate_time,validate_datetime - Web:
validate_url,validate_ipv4,validate_hex_color - Codes:
validate_zipcode_us,validate_phone_e164,validate_cron - Security:
validate_no_sqli,validate_token,validate_base64 - Boolean:
validate_boolean,validate_yes_no,validate_on_off
find & query
Both run database queries. .db selects the database, .set_key stores the result in context as a table (even single-row). Templates open the table as a section to reach fields. SQL is either inlined with .query or referenced by name as the positional, in which case it is loaded from .context. Multiple items in a single step run concurrently. Queries use prepared statements; interpolated {{values}} are bound, not spliced. For transactions, use BEGIN/COMMIT/ROLLBACK directly.
The only difference: find() raises 404 Not Found when zero rows; query() does not.
| Field | Description |
|---|---|
.template_key (pos) |
SQL asset name in .context (mutually exclusive with .query) |
.query |
Inline SQL with {{interpolation}} (mutually exclusive with positional asset name) |
.set_key |
Context key for result table |
.db |
Database name matching a .databases entry |
.if_context / .unless_context (per item) |
Conditionally include/skip individual queries |
Positional asset name OR
.query, not both.
- ✅
query({"get_todos", .set_key = "todos", .db = "todos_db"})- ✅
query({.set_key = "todos", .db = "todos_db", .query = "select id, title from todos;"})- ❌ Combining the two is rejected
Concurrency = multiple items in one step, not multiple steps.
query({...}, {...})runs both in parallel. Two back-to-backquery({...})steps run serially.
query(
{"get_todos", .set_key = "todos", .db = "todos_db"},
{.set_key = "count", .db = "todos_db",
.query = "select count(*) as n from todos where user_id = {{user_id}};"},
{.if_context = "show_urgent", .set_key = "urgent", .db = "todos_db",
.query = "select id, title from todos where user_id = {{user_id}} and priority = 'high';"}
)
Reading query results in templates. The result of
query()/find()is always a TABLE under the.set_key, even for single-row queries. To read fields, OPEN THE SECTION FIRST. This applies even when there's only one row.
- ✅ Multi-row query:
query({.set_key = "todos", ...})→{{#todos}}<li>{{title}}</li>{{/todos}}- ✅ Single-row query:
query({.set_key = "user", .query = "select name from users where id = {{id}};"})→{{#user}}<h1>{{name}}</h1>{{/user}}- ✅ Single-row scalar:
query({.set_key = "count", .query = "select count(*) as n from todos;"})→{{#count}}{{n}}{{/count}}- ❌
<h1>{{name}}</h1>after the user query —nameis inside theusertable, not at root- ❌
{{count.n}}or{{user.name}}— dot paths never resolve
join
Nests records from one context table into each matching record of another (in-memory JOIN). After the step, each outer record gains a new field holding its matched inner records.
This nesting only works if render() opens the outer table as a section. A template that iterates both tables as siblings at root treats join() as a no-op.
| Field | Description |
|---|---|
.target_table_key |
Outer table receiving nested children |
.target_field_key |
Field on outer to match against |
.nested_table_key |
Inner table to nest |
.nested_field_key |
Field on inner pointing at outer |
.target_join_field_key |
New field on outer holding matched inner records |
join(
.target_table_key = "blog",
.target_field_key = "id",
.nested_table_key = "comments",
.nested_field_key = "blog_id",
.target_join_field_key = "comments"
)
Context shape:
after query(): { blog: [{id, title, content}],
comments: [{id, blog_id, body}, ...] }
after join(): { blog: [{id, title, content,
comments: [{id, blog_id, body}, ...]}] }
Common failure modes (both produce empty-looking output):
- Assuming the blog's fields are flat at root because
bloghas one row. - Iterating
{{#comments}}at root instead of from within{{#blog}}.
// ❌ Wrong — fields assumed flat at root, comments iterated at root:
render(.template =
"<article>"
"<h1>{{title}}</h1>" // empty
"<div>{{content}}</div>" // empty
"<ul>{{#comments}}<li>{{body}}</li>{{/comments}}</ul>" // empty or unnested
"</article>"
)
// ✅ Right — enter {{#blog}} first; reach comments from within:
render(.template =
"<article>"
"{{#blog}}"
"<h1>{{title}}</h1>"
"<div>{{content}}</div>"
"<ul>{{#comments}}<li>{{body}}</li>{{/comments}}</ul>"
"{{/blog}}"
"</article>"
)
fetch
Makes an HTTP request and stores the response in context. JSON is parsed into tables and records (with nested tables for nested JSON); plain-text responses are stored as a string.
| Field | Description |
|---|---|
.url (pos) |
Request URL with {{interpolation}} |
.set_key |
Context key for the response |
.method |
HTTP method (default http_get) |
.headers |
Array of {name, value} pairs |
.json |
Context key serialized as JSON request body |
.text |
Context key sent as plain-text request body |
fetch("https://api.payments.dev/charge",
.set_key = "receipt",
.method = http_post,
.headers = {
{"Authorization", "Bearer {{api_key}}"},
{"Idempotency-Key", "{{order_id}}"}
},
.json = "order"
)
HTTP methods (for .method): http_get, http_post, http_put, http_patch, http_delete, http_sse_method
exec
Calls a C function or block with access to the context via the Imperative API. Execution is dispatched to the shared thread pool, which releases the reactor; the pipeline resumes on the original reactor when the call returns. Suitable for blocking I/O and CPU-heavy work. To trigger an error/repair pipeline from inside, call error_set().
| Field | Description |
|---|---|
| Block (pos) | Inline ^(){ ... } block |
.call |
Reference to a named C function |
exec(^(){
auto t = get("challengers");
record_set(table_get(t, 0), "opponent_id",
record_get(table_get(t, 1), "id"));
})
exec(.call = assign_opponents)
Imperative API (available from exec blocks and functions):
- Context:
get(name),set(name, value),has(name),format(fmt) - Memory:
allocate(bytes),defer_free(ptr) - Errors:
error_set(name, err),error_get(name),error_has(name) - Tables:
table_new(),table_count(t),table_get(t, i),table_add(t, r),table_remove(t, r),table_remove_at(t, i) - Records:
record_new(),record_set(r, name, value),record_get(r, name),record_remove(r, name)
emit
Triggers an internal pub/sub event. Subscribers in other modules react in their .events pipelines.
emit("todo_created")
task
Adds a named job to the task database and continues immediately. Fire-and-forget. Task reactors pick up queued jobs.
task("recount_todos")
sse
Pushes a Server-Sent Event. With .channel: broadcast to all clients on that channel. Without: returned to the requesting client.
| Field | Description |
|---|---|
.channel (pos) |
Broadcast channel with {{interpolation}} |
.event |
SSE event: line value |
.data |
Array of strings, one per data: line |
.comment |
SSE : comment line |
sse(
.channel = "todos/{{user_id}}",
.event = "todo_updated",
.data = {"id: {{todo_id}}", "title: {{title}}"},
.comment = "broadcast at {{timestamp}}"
)
ds_sse
Datastar-formatted SSE for DOM updates and reactive client state. Provided by the datastar module. With channel: broadcast. Without: requesting client only.
| Field | Description |
|---|---|
.channel (pos) |
Broadcast channel with {{interpolation}} |
.target |
DOM element id for the update |
.mode |
Fragment insertion mode |
.elements |
render_config for the DOM fragment (positional asset name; supports .template, .engine) |
.signals |
JSON string updating Datastar reactive client state without touching DOM |
.js |
JS snippet evaluated on client |
ds_sse("todos/{{user_id}}",
.target = "todo-list",
.mode = mode_prepend,
.elements = {"todo_row"},
.signals = "{\"count\": {{count}}}",
.js = "window.scrollTo(0, 0)"
)
Modes: mode_outer, mode_inner, mode_replace, mode_prepend, mode_append, mode_before, mode_after, mode_remove
render
Outputs a Mustache template using the current context. Templates are referenced by name from .context or inlined. Sections only, no dot paths.
| Field | Description |
|---|---|
.template_key (pos) |
Asset name in .context |
.template |
Inline Mustache template string |
.status |
HTTP response status (default http_ok) |
.mime |
Override response content type |
.engine |
mustache (default) or mdm (Markdown-with-Mustache); accepts bare identifier or string |
.json_table_key |
Context table to serialize as JSON response (auto-sets application/json) |
render("todos")
render(.template = "<h1>{{site_name}}</h1>") // {{site_name}} is from .context (top-level scalar)
render("not_found", .status = http_not_found)
render(.engine = mdm, .template = "# Welcome, {{user_name}}") // {{user_name}} is from validate() or .context
render(.json_table_key = "todos")
Bare
{{key}}only works for top-level scalars —.contextvalues, validated inputs (aftervalidate()promotes them frominput:to app scope), and framework values likeuser_id. Anything stored under a.set_key(query/find results, fetch JSON, joined data) is a TABLE and must be opened as a section first:{{#blog}}{{title}}{{/blog}}. Even single-row queries return a table.
Mustache tags live inside C string literals. Inline templates are concatenated by adjacent-string-literal rules. Every Mustache tag, including section open/close tags on their own lines, must be inside quotes.
- ✅ Multi-line with section tags on their own quoted lines (the canonical format):
"<article>" "{{#blog}}" "<h1>{{title}}</h1>" "<ul>{{#comments}}<li>{{body}}</li>{{/comments}}</ul>" "{{/blog}}" "</article>"
- ❌ Section tags on their own lines without quotes — compile error:
"<article>" {{#blog}} // NOT in quotes "<h1>{{title}}</h1>" {{/blog}} "</article>"
HTTP statuses (for .status): http_ok (200), http_created (201), http_redirect (302), http_bad_request (400), http_not_authorized (401), http_not_found (404), http_error (500)
MIME types (for .mime): mime_html, mime_txt, mime_sse, mime_json, mime_js
headers & cookies
Set HTTP response headers and cookies declaratively. Both accept an array of name/value pairs; values support {{interpolation}}.
headers({{"X-Request-Id", "{{request_id}}"}, {"Cache-Control", "no-store"}})
cookies({{"session", "{{session_id}}"}})
redirect & reroute
redirect() returns a 302 to the client (browser navigates). reroute() re-enters the router server-side, executing another resource's pipeline within the same request. Both take a resource identifier in the same name[:arg1:arg2...] format as {{url:name}}. Args can be literals or context keys, and support {{interpolation}}. Context persists across reroute (input/error scopes preserved).
redirect("todos") // 302 to /todos
redirect("todo:5") // 302 to /todos/5
redirect("todo:{{id}}") // 302 to /todos/{{id}} from context
redirect("org_todo:acme:5") // 302 to /orgs/acme/todos/5
reroute("todo:{{id}}") // run that pipeline in-process
nest
Groups multiple steps into a single composite step. Apply one .if_context/.unless_context to a group instead of repeating per step.
nest({query({...}), emit("urgent_todo"), render("urgent")},
.if_context = "is_urgent")
Conditionals
Every step accepts .if_context and .unless_context, which name a context variable. Works for any context value: validated inputs, query results, framework flags such as is_htmx, or flags set from exec().
render("fragment", .if_context = "is_htmx")
render("full_page", .unless_context = "is_htmx")
For multi-state branching, set context flags from exec(), then key downstream steps off them:
exec(.call = classify_todo),
render("urgent_confirmation", .if_context = "is_urgent"),
render("standard_confirmation", .unless_context = "is_urgent")
Error and Repair Pipelines
When a pipeline step fails, execution halts and MACH searches for a handler bottom-up: resource → module → root. Errors are terminal: the matching pipeline sends a response and ends the request. Repairs are resumable: they fix the context and resume the original pipeline at the step after the failure. If no matching repair is found, resolution falls through to errors.
The error scope is shared across validate() failures and error_set() calls: {{error:name}}, {{error_code:name}}, {{error_message:name}}. Raw input remains in input:name for re-rendering forms.
.errors = {
{http_not_found, { render("404") }},
{http_bad_request, { render("form") }},
{http_error, { render("500") }}
},
.repairs = {
{http_not_authorized, { exec(.call = refresh_session_token) }}
}
Built-in error codes (for the .error_code positional): http_ok (200), http_created (201), http_redirect (302), http_bad_request (400), http_not_authorized (401), http_not_found (404), http_error (500). Any integer works; the http_* constants are convenience names. Define your own for domain errors, e.g. #define err_quota_exceeded 723.
Event Pipelines
Internal pub/sub for cross-module communication. The publisher doesn't know who listens; the subscriber doesn't know who emits. Adding a subscriber is a new module with an .events entry, no changes to the publisher.
Events are durable by default. When .publishes is defined anywhere, MACH creates a mach_events database to track delivery. Process crash → undelivered events replay on next boot.
// publisher
.publishes = {
{"todo_created", .with = {"user_id", "title"}}
},
.resources = {
{"todos", "/todos",
.post = {
validate({"title", .validation = validate_not_empty}),
query({"insert_todo", .db = "todos_db"}),
emit("todo_created"),
redirect("todos")
}
}
}
// subscriber (separate module)
.events = {
{"todo_created", {
query({.db = "activity_db",
.query = "insert into activities(kind, user_id, ref) "
"values('created', {{user_id}}, {{title}});"})
}}
}
Task Pipelines
Tasks are named pipelines that run asynchronously on task reactors. Fire-and-forget. Defined at module or root level. Triggered on demand with task("name") or on a schedule via .cron. Tasks can enqueue more tasks via task().
Tasks are durable by default. When .tasks is defined, MACH creates a mach_tasks database and checkpoints context after each step. Crash mid-task → resumes at the exact step where it left off.
| Field | Description |
|---|---|
.name (pos) |
Task identifier called via task("name") |
| Steps (pos) | Pipeline body (second positional brace block, before designated fields) |
.accepts |
Context keys to pull from caller into the task |
.cron |
Standard cron schedule for recurring tasks (no caller required) |
.tasks = {
// on-demand: enqueued via task("recount_todos")
{"recount_todos", {
query({.db = "todos_db",
.query = "update users set todo_count = "
"(select count(*) from todos where user_id = users.id) "
"where id = {{user_id}};"})
}, .accepts = {"user_id"}},
// recurring: runs on schedule, no caller
{"daily_digest", {
query({.db = "todos_db",
.query = "insert into digest_reports(generated_at) values(now());"}),
emit("digest_ready")
}, .cron = "0 8 * * *"}
}
Modules & Composition
Every MACH app and module returns a config struct. The root main.c must define a function named mach(); modules define their own functions with any name and register them in .modules by bare function reference. A module owns its own resources, databases, migrations, templates, and event contracts.
When the root and a module both define something with the same name (a context variable, a database, an error handler), the root wins. Modules don't call each other directly; they communicate through pub/sub events.
| Field | Description |
|---|---|
.name |
Module identifier |
.modules |
Other modules to compose into this one (root or nested) |
Bring the module into scope by #includeing its .c file from main.c, then register it with .modules.
// main.c
#include <mach.h>
#include "blogs/blogs.c"
config mach(){ return (config){ .modules = {blogs, sqlite} }; }
A module returns a config with the same shape as the root app (.resources, .databases, .events, etc.), plus a .name for identity. Resource fields like .url, .mime, .get are not top-level config fields; they belong inside entries of .resources.
#include <mach.h>
#include <sqlite.h>
config blogs(){
return (config){
.name = "blogs",
.resources = {
{"blog", "/blogs/:id",
.get = { /* validate → query → join → render */ }
}
},
.databases = {{
.engine = sqlite_db,
.name = "blog_db",
.connect = "file:blogs.db?mode=rwc",
.migrations = {
"CREATE TABLE blogs ("
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
"title TEXT NOT NULL,"
"content TEXT NOT NULL"
");",
"CREATE TABLE comments ("
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
"blog_id INTEGER NOT NULL REFERENCES blogs(id),"
"body TEXT NOT NULL"
");"
}
}}
};
}
Project layout:
todos/ # todos module
├── todos.c # config todos() { ... }
├── todos.mustache.html
├── create_todos_table.sql
└── get_todos.sql
activity/ # activity module
└── activity.c
static/ # root-level templates (NOT a module)
├── layout.mustache.html
└── home.mustache.html
public/ # static files served directly
└── favicon.png
main.c # registers modules
Bundled modules (add the initializer to .modules to use): sqlite, postgres, mysql, redis, duckdb, htmx, datastar, tailwind, session_auth
Module-provided steps. Modules can ship step functions that plug into pipelines alongside built-in steps. The session_auth module provides:
session()— attaches the current session to context (setsuser_id, etc.); no-op when unauthenticatedlogged_in()— guard that raiseshttp_not_authorizedwhen there's no active sessionlogin(),logout(),signup()— use inside POST pipelines to perform the auth action
Common pattern: drop into a resource's shared .steps slot as middleware:
{"dashboard", "/dashboard", {session(), logged_in()},
.get = { render("dashboard") }
}
Static Files
Files placed in public/ at the project root are served directly. Use it for images, fonts, pre-built CSS/JS. Reference them with {{asset:filename}}, which resolves to a URL with a content-based checksum and immutable cache headers.
<link rel="icon" href="{{asset:favicon.png}}">
<link rel="stylesheet" href="{{asset:styles.css}}">
<script src="{{asset:app.js}}"></script>
External Dependencies
MACH expects a containerized dev environment. Standard C23 against MACH APIs; no local toolchain required.
/vendor directory: drop headers and libraries (.so, .a); the auto-compiler discovers, includes, and links them.
/vendor/
├── libsodium.h
└── libsodium.so
Custom Dockerfile: inherit from the MACH base image and apt-get install system deps; reference from compose.yml.
FROM mach:latest
RUN apt-get update && apt-get install -y libsodium-dev
allocate(bytes): buffer from the pipeline arena, reclaimed on request completion.
char *buf = allocate(256);
defer_free(ptr): schedules cleanup for pointers from external libraries (e.g. via malloc); runs when the arena is released.
char *out = third_party_alloc(256);
defer_free(out);