50 KiB
MACH
C23 web framework. App = config mach() returning (config){...} of resources, databases, modules, etc. Each request runs a pipeline of steps over a shared per-request context. Memory, threads, and I/O are managed by the framework. Tasks and events are durable.
CRITICAL RULES — read these BEFORE writing any code
Almost every bug a small model writes in MACH comes from breaking one of these. Skim once, refer back often.
Rule 1 — NEVER use dot notation in {{ }}
The character . is forbidden between {{ and }} anywhere in any
template, SQL string, URL, or interpolated string. The MACH interpreter
treats {{a.b}} as an unknown key and renders it as the empty string.
You access nested data only by entering nested sections with
{{#name}}...{{/name}}. The shape of the template must mirror the shape
of the context.
Self-check before emitting any tag: is there a
.between{{and}}? If yes, the template is wrong. Stop, rewrite with sections.
Pattern A — Single root scalar (from .context)
A scalar seeded into root context is read directly. There is nothing to "dot through" in this case; this pattern just shows the baseline.
.context = {{"site_name", "MACH App"}}
✅ <h1>{{site_name}}</h1>
Pattern B — One-row flat access (single-row find() / query() result)
Even a single-row result is stored as a table. Fields are not at root scope. Open the section, then read the field.
find({.set_key = "todo", .db = "todos_db",
.query = "select id, title from todos where id = {{id}};"})
Context after the step:
{ todo: [ { id: 5, title: "Learn MACH" } ] }
✅ {{#todo}}<h1>{{title}}</h1><p>id={{id}}</p>{{/todo}}
❌ {{todo.title}} renders ""
❌ <h1>{{title}}</h1> renders "" (not inside #todo)
❌ {{#todo}}{{todo.title}}{{/todo}} renders "" (dot still banned)
Pattern C — After join(): parent + nested children
The most common nested-context scenario in MACH. Concurrent query()
produces two sibling tables; join() then moves the children
inside each parent record. After the join, the children are no longer
accessible at root — the template MUST enter the parent section to
reach them.
query(
{.set_key = "project", .db = "projects_db",
.query = "select id, name from projects where id = {{id}};"},
{.set_key = "tasks", .db = "projects_db",
.query = "select id, project_id, title from tasks where project_id = {{id}};"}
),
join(
.target_table_key = "project",
.target_field_key = "id",
.nested_table_key = "tasks",
.nested_field_key = "project_id",
.target_join_field_key = "tasks"
),
Context shape, before vs after the join:
after query(): { project: [{id, name}],
tasks: [{id, project_id, title}, ...] } // siblings
after join(): { project: [{id, name,
tasks: [{id, project_id, title}, ...]}] } // tasks now INSIDE project
✅
{{#project}}
<h1>{{name}}</h1>
<ul>
{{#tasks}}<li>{{title}}</li>{{/tasks}}
</ul>
{{/project}}
❌ {{project.name}} renders "" (dot)
❌ {{project.tasks.title}} renders "" (dot)
❌ {{#project}}{{tasks.title}}{{/project}} renders "" (dot)
❌ {{#project.tasks}}<li>{{title}}</li>{{/project.tasks}} dot in section name
❌ {{#tasks}}<li>{{title}}</li>{{/tasks}} renders nothing — after join(),
tasks lives INSIDE project,
not at root. No dot, but
still wrong: must open
{{#project}} first.
The last counter-example is the subtle one: a join doesn't copy the
children, it moves them. Iterating {{#tasks}} at root after the
join silently produces nothing, even though there's no dot.
Pattern D — 3+ levels of nested sections
After multiple join() steps, or from nested JSON returned by fetch(),
context can be arbitrarily deep. Walk down level by level with one
section per level.
Context shape:
{ org: [ { name: "Acme",
projects: [ { title: "Site",
tasks: [ { label: "Design" }, { label: "Build" } ] } ] } ] }
✅
{{#org}}
<h1>{{name}}</h1>
{{#projects}}
<h2>{{title}}</h2>
<ul>
{{#tasks}}
<li>{{label}}</li>
{{/tasks}}
</ul>
{{/projects}}
{{/org}}
❌ {{org.name}} renders ""
❌ {{org.projects.title}} renders ""
❌ {{org.projects.tasks.label}} renders ""
❌ {{#org}}{{projects.title}}{{/org}} renders ""
❌ {{#org.projects}}{{title}}{{/org.projects}} dot banned in section names too
❌ {{#org}}{{#projects}}{{tasks.label}}{{/projects}}{{/org}} still has a dot
Pattern E — Iterating an array of nested sections
A {{#name}}...{{/name}} block automatically loops when name is an array.
Every iteration enters one record. Nested arrays loop the same way inside.
Context shape:
{ projects: [
{ title: "A", tasks: [{ label: "x" }, { label: "y" }] },
{ title: "B", tasks: [{ label: "z" }] }
] }
✅
<ul>
{{#projects}}
<li>
<strong>{{title}}</strong>
<ul>
{{#tasks}}<li>{{label}}</li>{{/tasks}}
</ul>
</li>
{{/projects}}
</ul>
❌ {{#projects}}{{tasks.label}}{{/projects}} dot banned
❌ {{projects.title}} dot banned
❌ {{#projects.tasks}}...{{/projects.tasks}} dot banned
Recap. All five cases — flat single-row access, post-join()
parent/children, 3+ level nesting, iteration of nested arrays — use
the same one rule: open a section for every level, then read fields
by their bare name. There is never a reason to type a . inside
{{ }}. If you find yourself writing one, the data shape is fine; the
template is wrong. Add a section.
And remember the silent failure mode from Pattern C: after a join(),
the joined-in table is no longer at root. {{#tasks}} at root after
joining tasks into project renders nothing — open {{#project}} first.
Helpers like
{{url:name}},{{input:title}},{{error:title}},{{precision:total:2}}use:(colon), not.(dot) — those are not dot notation and are fully supported.
Template Checklist — run this BEFORE typing any render(.template = ...) or template asset
A small model gets here, holds Rule 1 in mind for half a second, then
slips back into training-set Mustache habits ("{{thing.field}}"). The
checklist exists to catch that slip the moment it happens.
For every {{ ... }} you are about to type, walk these three checks:
Check 1 — Is there a . between {{ and }}?
- YES → STOP. The template is wrong. Do not emit the tag. Add a section instead and read the bare field inside.
- NO → continue to Check 2.
Check 2 — Is the bare name reachable from the current nesting level?
- At root scope, only top-level context keys are reachable.
- Inside
{{#project}}...{{/project}}, only fields of the currentprojectrecord are reachable. - After
join(... → project), the joined-in table (tasks,comments, whatever) moved — it is no longer at root, only insideprojectrecords. - If the field is not at the current level, you need to open one or more sections to enter the right scope. Go to Check 3.
Check 3 — How many levels deep is the field?
- Count the levels: root → table → record-field is 1 section deep
(
{{#table}}{{field}}{{/table}}). - A nested table inside a record (e.g.
tasksinsideprojectafter a join) is 2 sections deep ({{#project}}{{#tasks}}{{title}}{{/tasks}}{{/project}}). - Three nested levels = three section wrappers. Always exactly the
same number of
{{#...}}{{/...}}pairs as levels of nesting.
If any check fails, do not emit the tag — fix it first.
The rule has zero exceptions. Not in
render(.template = ...), not in templates loaded from.context, not in SQL{{interpolation}}, not anywhere a{{appears in MACH source. If you find yourself about to type.between{{and}}, the template is wrong.
Rule 2 — ONE database per DOMAIN, not one database per table
A database holds many tables. Group every table that belongs to the same business domain into one database. Do not create one database per table — that is the most common mistake a small model makes here.
✅ Correct: one database per domain, many tables inside
A projects domain owning projects, tasks, comments, tags:
.databases = {{
.engine = sqlite_db,
.name = "projects_db", // one db for the whole domain
.connect = "file:projects.db?mode=rwc",
.migrations = {
"CREATE TABLE projects ("
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
"name TEXT NOT NULL"
");",
"CREATE TABLE tasks ("
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
"project_id INTEGER NOT NULL REFERENCES projects(id),"
"title TEXT NOT NULL"
");",
"CREATE TABLE comments ("
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
"task_id INTEGER NOT NULL REFERENCES tasks(id),"
"body TEXT NOT NULL"
");",
"CREATE TABLE tags ("
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
"label TEXT NOT NULL"
");"
}
}}
❌ Wrong: one database per table
// DO NOT do this. Four databases for one domain is wrong.
.databases = {
{.engine=sqlite_db, .name="projects_db", .connect="file:projects.db?mode=rwc",
.migrations={"CREATE TABLE projects (...);"}},
{.engine=sqlite_db, .name="tasks_db", .connect="file:tasks.db?mode=rwc",
.migrations={"CREATE TABLE tasks (...);"}},
{.engine=sqlite_db, .name="comments_db", .connect="file:comments.db?mode=rwc",
.migrations={"CREATE TABLE comments (...);"}},
{.engine=sqlite_db, .name="tags_db", .connect="file:tags.db?mode=rwc",
.migrations={"CREATE TABLE tags (...);"}}
}
Parent-child relationships are ONE domain, ONE database. A project and its tasks, a blog and its comments, an order and its line items, a user and its sessions, a todo and its comments — these are all parent-child relations within a single domain. They live as separate migrations on the same database, joined later via
join()if needed. Splitting them acrossprojects_db+tasks_db(orblogs_db+comments_db, etc.) is the wrong model — it's the exact mistake the ❌ example above shows.
Where the boundary actually goes. A new database appears when a new
module appears, because each module owns its domain. The todos
module has one todos_db (containing todos, comments, daily_stats,
etc.). The activity module has its own activity_db. The billing
module has billing_db. One database per domain, one domain per module.
A new database appears with a new module, not with a new table.
Migrations are an array on the same database; they run in order, so a
later table can reference an earlier one with REFERENCES.
Rule 3 — Concurrent queries: ONE step, MANY items (across databases too)
query() and fetch() run their items in parallel. Two separate
query() steps run serially. Whenever you need more than one query
or fetch and they don't depend on each other's results, put them in
one step. This works even when the items hit different databases.
✅ Concurrent — one query() call with multiple items
Same database:
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;"}
)
Across multiple databases — still one step, still concurrent:
query(
{.set_key = "user", .db = "users_db", .query = "select * from users where id = {{id}};"},
{.set_key = "orders", .db = "commerce_db", .query = "select * from orders where user_id = {{id}};"},
{.set_key = "activity", .db = "activity_db", .query = "select * from events where user_id = {{id}};"}
)
Same rule for fetch():
fetch(
{"https://api.x.dev/a", .set_key = "a"},
{"https://api.y.dev/b", .set_key = "b"}
)
❌ Serial — multiple steps, each waiting for the previous
// DO NOT do this when the queries are independent. Three round-trips, in series.
query({.set_key = "user", .db = "users_db", .query = "..."}),
query({.set_key = "orders", .db = "commerce_db", .query = "..."}),
query({.set_key = "activity", .db = "activity_db", .query = "..."})
Use separate steps only when a later query depends on a value the earlier query produced. Otherwise: one step, many items.
Rule 4 — SQL {{values}} are bound as prepared-statement parameters
In query() and find(), {{interpolation}} is bound as a parameter,
never spliced into the SQL string. SQL injection is impossible at the
framework level. Do not pre-quote, do not pre-escape.
✅ query({.db = "db", .query = "insert into todos(title) values({{title}});"})
❌ query({.db = "db", .query = "insert into todos(title) values('{{title}}');"}) // double-quoted
For transactions, use BEGIN / COMMIT / ROLLBACK directly in your queries.
Rule 5 — Each query() / find() item: positional asset name OR .query. Pick exactly one. The asset name must actually exist.
There are two ways to supply SQL to a query item. Pick exactly one
per item, and if you pick the positional asset-name form, the name
must reference an asset that actually exists in .context.
✅ query({.set_key = "todos", .db = "todos_db",
.query = "select id, title from todos;"})
// SQL inlined. No .context entry needed. Works in any snippet.
✅ query({"get_todos", .set_key = "todos", .db = "todos_db"})
// SQL loaded by name from .context. Requires the asset to be defined:
// .context = {{"get_todos", (asset){#embed "get_todos.sql"}}}
// Without that .context entry, this is a phantom reference (see ❌ below).
❌ query({"get_todos", .set_key = "todos", .db = "todos_db",
.query = "select ..."})
// BOTH forms in one item → boot rejection.
❌ query({"get_todos", .set_key = "todos", .db = "todos_db"})
// The name "get_todos" is positional asset reference, but no
// .context = {{"get_todos", (asset){#embed "..."}}} entry exists
// anywhere in the config. The query has NO SQL to run. Boot
// rejection / runtime failure. (Same shape as the ✅ above — the
// ONLY difference is whether .context defined the asset.)
If your config does not have a
.contextsection that embeds SQL files, you MUST use inline.query. The two forms are not interchangeable; the positional form is shorthand that says "this name was already embedded as an asset elsewhere in the config".The asset-name form is a Step-8 optimization (see Guide §8 "External Assets") for when SQL grows too large to keep in the
.cfile. For any inline-only snippet,.queryis the only valid form. Do not reach for the positional name to avoid writing the SQL string — that produces a phantom reference, not a working query.
The same rule applies to find() and to render() (asset name in
.context vs .template inline string).
Rule 6 — find() raises http_not_found on zero rows, query() does not
Otherwise the two are identical. Use find() for "must exist" lookups
(detail pages, by-id reads). Use query() for lists, counts, writes,
and anything where zero rows is a normal outcome.
Rule 7 — No malloc / free, no threads, no mutexes, no locks
Per-request arena handles all memory. Reactors and the shared thread pool handle all concurrency. Application code never calls these.
For a buffer in a pipeline: char *buf = allocate(256); (reclaimed when
the request ends). To clean up a pointer returned by an external library:
defer_free(out); (cleanup runs when the arena releases).
Rule 8 — Resource-based, not route-based
Resources are referenced by name, never by hard-coded path:
{{url:todos}} // → /todos
{{url:todo:5}} // → /todos/5 (literal arg)
{{url:todo:id}} // → /todos/{id from context}
redirect("todo:{{id}}") // 302 to /todos/{id}
reroute("todos") // re-enter pipeline server-side, same request
Changing /todos to /items later means changing one .url field.
Every link, redirect, and reroute follows.
Guide
A todo app, built one concept at a time. Each step shows only the new pieces; carry forward the previous code.
1. A Page
Two resources, each with a GET pipeline. {{url:name}} resolves 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 = "<h1>My Todos</h1><p>Nothing yet.</p>") }
}
}
};
}
2. Show Data
Add SQLite. query() stores rows under todos. The template opens the
section to iterate (Rule 1: section, never dot).
#include <sqlite.h>
// inside todos resource:
.get = {
query({.set_key = "todos", .db = "todos_db",
.query = "select id, title from todos;"}),
render(.template =
"<h1>My Todos</h1>"
"<ul>{{#todos}}<li>{{title}}</li>{{/todos}}</ul>")
}
// inside config:
.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}
To fetch two things at once, put both items in one query() call (Rule 3):
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;"}
)
3. Accept Input
validate() → query() → redirect() (POST-redirect-GET). On success
title is promoted from input:title to app scope and bound as a
prepared parameter (Rule 4) in the SQL.
.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")
}
Add the form to the GET template; {{input:title}} repopulates after errors:
<form method='post' action='{{url:todos}}'>
<input name='title' value='{{input:title}}'>
<button>Add</button>
</form>
4. Handle Errors
A failed validate() raises http_bad_request. A resource-scoped error
handler re-enters the GET pipeline with reroute(). Both input: and
error: scopes survive the reroute.
.errors = {
{http_bad_request, { reroute("todos") }}
}
<input name='title' value='{{input:title}}'>
{{#error:title}}<span>{{error_message:title}}</span>{{/error:title}}
5. Nested Data
Fetch parent + children concurrently in one query() (Rule 3),
join() to nest, then enter the parent section to render. Comments
belong to the same domain as todos, so comments is a new migration
on the existing todos_db (Rule 2), not a new database.
{"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 =
"{{#todo}}"
"<h1>{{title}}</h1>"
"<ul>{{#comments}}<li>{{body}}</li>{{/comments}}</ul>"
"{{/todo}}")
}
}
After join(), comments lives inside each todo record. Reach it
from within {{#todo}}. Iterating {{#comments}} at root after a join
renders nothing.
6. Tasks
Tasks run async on task reactors. Trigger with task("name") or via
.cron. Same task can be both. Tasks are durable: the process can
crash mid-task and resume at the failed step on next boot.
// in POST pipeline:
.post = {
validate({...}),
query({...}),
task("record_daily_stats"),
redirect("todos")
}
// at config level:
.tasks = {
{"record_daily_stats", {
query({.db = "todos_db",
.query = "insert into daily_stats(todo_count) "
"select count(*) from todos;"})
}, .cron = "0 0 * * *"}
}
If the task needs caller context, list keys under .accepts:
{"recount_for_user", {
query({.db="todos_db", .query="update users set ... where id = {{user_id}};"})
}, .accepts = {"user_id"}}
7. Modules & Events
A module is a .c file with a function returning config. It owns its
own resources, its own database (one per domain — Rule 2),
migrations, tasks, and event subscribers. Modules communicate only
through pub/sub events, never direct calls. main.c includes them and
registers them under .modules.
.
├── todos/todos.c // config todos() { ... }
├── activity/activity.c // config activity() { ... }
└── main.c
main.c:
#include <mach.h>
#include <sqlite.h>
#include "todos/todos.c"
#include "activity/activity.c"
config mach(){
return (config){
.resources = {{"home", "/", .get = { render(.template = "<h1>Welcome</h1>") }}},
.modules = {todos, activity, sqlite}
};
}
Publisher declares .publishes and calls emit():
config todos(){
return (config){
.name = "todos",
.publishes = {{"todo_created", .with = {"title"}}},
.resources = {
{"todos", "/todos",
.post = {
validate({"title", .validation = validate_not_empty}),
query({.db = "todos_db",
.query = "insert into todos(title) values({{title}});"}),
emit("todo_created"),
redirect("todos")
}
}
}
// ... own database (todos_db) here
};
}
Subscriber declares an .events entry. Published keys (title)
arrive in context. Subscriber owns its own database (activity_db).
config activity(){
return (config){
.name = "activity",
.events = {
{"todo_created", {
query({.db = "activity_db",
.query = "insert into activities(kind, ref) "
"values('created', {{title}});"})
}}
}
// ... own database (activity_db) here
};
}
When .publishes exists anywhere, MACH creates a mach_events database
and tracks delivery. Undelivered events replay on next boot.
8. External Assets
Once templates and SQL grow, extract them into files. Embed with
(asset){#embed "file"} in .context, then reference by name from
render(), query(), and find(). .migrations accepts assets directly.
todos/
├── todos.c
├── todos_list.mustache.html
├── get_todos.sql
├── create_todo.sql
└── create_todos_table.sql
todos/get_todos.sql
select id, title from todos;
todos/todos.c (excerpt):
.resources = {
{"todos", "/todos",
.get = { query({"get_todos", .set_key = "todos", .db = "todos_db"}),
render("todos_list") },
.post = { validate({"title", .validation = validate_not_empty}),
query({"create_todo", .db = "todos_db"}),
redirect("todos") }
}
},
.context = {
{"todos_list", (asset){#embed "todos_list.mustache.html"}},
{"get_todos", (asset){#embed "get_todos.sql"}},
{"create_todo",(asset){#embed "create_todo.sql"}}
},
.databases = {{
.engine = sqlite_db,
.name = "todos_db",
.connect = "file:todos.db?mode=rwc",
.migrations = {(asset){#embed "create_todos_table.sql"}}
}}
SQL {{interpolation}} works the same as inline (still parameter-bound).
9. External Data
fetch() makes an HTTP request and stores the response in context.
JSON is parsed into tables/records (nested JSON → nested context tables);
plain text stores as a string.
.get = {
fetch("https://api.quotable.io/random", .set_key = "quote"),
render("home")
}
The Quotable API returns {"author":"...","content":"..."}, parsed into
a single-row table under quote. Template opens the section (Rule 1):
{{#quote}}<blockquote>{{content}}, {{author}}</blockquote>{{/quote}}
Multiple items in one fetch() run concurrently, same as query().
fetch() supports POST/PUT/PATCH/DELETE, custom headers, JSON or text
bodies, and {{interpolation}} in URLs.
Reference
Notation
{}— single value or struct:.get = { ... }{{}}— array of structs:.databases = {{ ... }}- Multiple elements:
.databases = {{...}, {...}} - Multiple step items:
query({...}, {...})
Context
Per-request scoped key-value store. Three scopes:
input:xxx— raw request parameterserror:xxx— validation/error data- (unprefixed) — app scope: query results, validated inputs,
.contextvalues
validate() promotes from input: to app scope. Docker secrets land in
context. .context seeds variables and assets at the root. Assets baked
at compile time via (asset){#embed "file"}.
.context = {
{"site_name", "MACH App"},
{"layout", (asset){#embed "static/layout.mustache.html"}},
{"get_todos", (asset){#embed "todos/get_todos.sql"}}
}
Databases
Migrations are forward-only, index-based, applied once each in array
order, tracked in mach_meta. Seeds are idempotent. Multi-tenant via
{{interpolation}} in .connect; connections pooled with LRU eviction.
Reminder (Rule 2): one database per domain, many tables. Related tables go as separate migrations on the same database.
.databases = {{
.engine = sqlite_db,
.name = "blog_db",
.connect = "file:{{user_id}}_blog.db?mode=rwc", // multi-tenant
.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, 'Hi', 'First');"}
}}
Engines: sqlite_db, postgres_db, mysql_db, redis_db, duckdb_db
Resource Pipelines
Each .resources entry is a named URL endpoint. Identified by name in
{{url:name}}, redirect(), reroute() with colon-separated positional
args (name:arg1:arg2) that fill :params in URL-pattern order. Args
can be literals or context keys.
{{url:todos}}→/todos{{url:todo:5}}→/todos/5(literal){{url:todo:id}}→ readsidfrom current scope{{url:org_todo:acme:5}}→ fills multiple:params
Path specificity is automatic: /todos/active beats /todos/:id. Verb
selection: HTTP method, or ?http_method=... parameter (lets HTML forms
reach PATCH/DELETE/SSE).
Fields:
.name(pos) — identifier.url(pos) — pattern with:params.steps(pos) — shared steps run before every verb (middleware slot).mime— default response content type.get .post .put .patch .delete— verb pipelines (arrays of steps).sse— persistent SSE channel; first positional is channel name.errors— terminal handlers keyed by error code.repairs— resumable handlers keyed by error code
{"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: mime_html, mime_txt, mime_sse, mime_json, mime_js
Template Helpers
{{helper:args}} — positional, colon-separated (not dot-separated).
Each arg is a literal or a context key.
| Helper | Purpose | Example |
|---|---|---|
{{raw:field}} |
emit without HTML-escape (default escapes) | <div>{{raw:body_html}}</div> |
{{precision:field:N}} |
numeric format with N decimals | ${{precision:total:2}} |
{{input:field}} |
raw request param (form repopulation) | <input value='{{input:title}}'> |
{{error:field}} |
truthy when field has an error (use as section) | {{#error:title}}!{{/error:title}} |
{{error_message:field}} |
the validation/error message | <span>{{error_message:title}}</span> |
{{error_code:field}} |
HTTP status code for the field error | {{error_code:title}} |
{{url:name[:args]}} |
resource URL by name with positional args | <a href='{{url:todo:id}}'>...</a> |
{{asset:filename}} |
cache-busted URL for public/ file |
<link href='{{asset:styles.css}}'> |
{{csrf:token}} |
CSRF token (for query strings); sets cookie | ?csrf={{csrf:token}} |
{{csrf:input}} |
hidden <input> carrying CSRF token |
<form>{{csrf:input}}...</form> |
CSRF verification is automatic: MACH compares the incoming token to the
cookie (httponly/secure/samesite) and returns 403 on mismatch. Just emit
{{csrf:token}} or {{csrf:input}}.
Pipeline Steps
Every step accepts .if_context and .unless_context.
validate
Regex-checks request parameters. On success, promotes input:name to
app scope. On failure, sets error:name and raises http_bad_request.
All validations in one call complete before the error fires (so all
errors arrive together for form re-rendering). Define your own:
#define validate_zipcode "^\\d{5}$".
.param_key(pos) — name of param.validation— regex string or built-in macro.message— human-readable error.optional— skip when param absent.fallback— default when param 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:
- 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:
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. find() raises http_not_found on zero
rows; query() does not. Otherwise identical.
Before emitting any
query()/find()item, run the Rule 5 check. Each item supplies SQL exactly one way:
- Inline
.query = "select ..."— works in any snippet, no.contextsetup required. Use this by default.- Positional asset name
{"get_todos", ...}— only valid if.contextactually defines"get_todos"as an embedded asset. Without that entry, the query has no SQL and will fail.If your config has no
.contextsection,.queryis the only valid form. Do not type a positional name to "tidy up" — that produces a phantom reference, not a working query.
.set_key stores the result as a table in context (always — even
single-row results, see Rule 1 Pattern B). Templates open the table as
a section to access fields. SQL is either inlined with .query OR
loaded by name from .context as the positional (Rule 5). Multiple
items in one step run concurrently (Rule 3), even across different
databases. Interpolated {{values}} are bound as prepared parameters
(Rule 4). For transactions: BEGIN/COMMIT/ROLLBACK in the SQL.
.template_key(pos) — SQL asset name in.context(vs.query).query— inline SQL string with{{interpolation}}(vs positional).set_key— context key for result table.db— database name from.databases.if_context/.unless_context(per item) — conditionally include while other items run concurrently
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 priority = 'high';"}
)
join
Nests records from one context table into each matching record of
another, in memory. After join(), the inner records live inside
each outer record. Templates must enter the outer section to reach the
nested data. {{#comments}} at root after a join is empty.
.target_table_key— outer table receiving children.target_field_key— outer field to match.nested_table_key— inner table to nest.nested_field_key— inner field that points to outer.target_join_field_key— new field on outer holding matched inner records
Pattern: concurrent query() → join() → render():
query(
{.set_key="blog", .db="blog_db", .query="select id, title from blogs where id = {{id}};"},
{.set_key="comments", .db="blog_db", .query="select id, blog_id, body from comments where blog_id = {{id}};"}
),
join(
.target_table_key="blog", .target_field_key="id",
.nested_table_key="comments", .nested_field_key="blog_id",
.target_join_field_key="comments"
),
render(.template =
"{{#blog}}"
"<h1>{{title}}</h1>"
"<ul>{{#comments}}<li>{{body}}</li>{{/comments}}</ul>"
"{{/blog}}")
Context shape:
after query(): { blog: [{id,title}], comments: [{id,blog_id,body}, ...] }
after join(): { blog: [{id,title, comments: [{id,blog_id,body}, ...]}] }
fetch
HTTP request → context. JSON parsed into tables/records; text stored as string. Multiple items in one step run concurrently.
.url(pos) — URL with{{interpolation}}.set_key— context key for response.method— defaultshttp_get.headers— array of{name, value}pairs.json— context key serialized as JSON request body.text— context key sent as plain-text 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: http_get, http_post, http_put, http_patch, http_delete, http_sse_method
exec
Calls a C function or block with imperative access to context. Dispatched
to the shared thread pool (releases the reactor); pipeline resumes on
the original reactor when done. Use for blocking I/O or CPU work.
Trigger an error pipeline from inside via error_set().
- Block (pos) — inline block
.call— 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 (in exec blocks/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 a pub/sub event. Subscribers in other modules react via their
.events pipelines.
emit("todo_created")
task
Enqueues a named job in the task database; calling pipeline continues immediately.
task("recount_todos")
sse
Pushes a Server-Sent Event. With .channel, broadcasts. Without, sent
only to the requesting client.
.channel(pos) — broadcast channel with{{interpolation}}.event— SSEevent:line.data— array of strings (one perdata:line).comment—:comment line (keep-alives)
sse(
.channel = "todos/{{user_id}}",
.event = "todo_updated",
.data = {"id: {{todo_id}}", "title: {{title}}"},
.comment = "broadcast at {{timestamp}}"
)
ds_sse
Datastar-formatted SSE; provided by datastar module. Pushes DOM updates
and reactive state. Without channel goes to requesting client; with
channel broadcasts.
.channel(pos) — broadcast channel.target— DOM element id.mode— fragment insertion mode.elements— render_config (positional is asset name; supports.template,.engine).signals— JSON updating Datastar reactive state.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. Auto-escapes by default ({{raw:field}}
opts out). All field access follows Rule 1 — sections only, never dot.
Before emitting any
render(.template = ...), run the Template Checklist from Rule 1 against every{{ ... }}in the string. Three checks per tag, in order:
- No
.between{{and}}— if there is, stop and add a section.- The bare name must be reachable from the current nesting level (root, or inside whichever
{{#section}}you are currently in).- Number of
{{#...}}{{/...}}wrappers = number of nesting levels between root and the field.Same rule as Rule 5 applies to the template form itself: use either the positional asset name (which must exist in
.context) OR the inline.templatestring. If your config has no.contextsection embedding template files, use.templateinline — do not reference a phantom asset name.
.template_key(pos) — asset name in.context.template— inline Mustache string.status— HTTP status (defaulthttp_ok).mime— override content type.engine—mustache(default) ormdm(Markdown-with-Mustache).json_table_key— context table to serialize as JSON response (setsapplication/json; nested tables → nested JSON)
render("todos")
render(.template = "<h1>{{site_name}}</h1>")
render("not_found", .status = http_not_found)
render(.engine = mdm, .template = "# Welcome, {{user_name}}")
render(.json_table_key = "todos")
HTTP statuses: http_ok (200), http_created (201), http_redirect (302), http_bad_request (400), http_not_authorized (401), http_not_found (404), http_error (500)
headers & cookies
Set response headers/cookies. Values support {{interpolation}}.
headers({{"X-Request-Id", "{{request_id}}"}, {"Cache-Control", "no-store"}})
cookies({{"session", "{{session_id}}"}})
redirect & reroute
redirect() sends a 302 to the client (browser navigates). reroute()
is server-side: re-enter the router and run another resource's pipeline
within the same request. Both take a resource identifier
name[:arg1:arg2...]. Args can be literals or context keys.
redirect("todos") // 302 /todos
redirect("todo:5") // 302 /todos/5
redirect("todo:{{id}}") // 302 /todos/{id from context}
redirect("org_todo:acme:5") // 302 /orgs/acme/todos/5
reroute("todo:{{id}}") // run pipeline in-process
nest
Group steps under a single shared .if_context / .unless_context.
nest({query({...}), emit("urgent_todo"), render("urgent")},
.if_context = "is_urgent")
Conditionals
Every step accepts .if_context (run when key present) and
.unless_context (run when absent). Works on validated inputs, query
results, framework flags (is_htmx), or flags set from exec().
render("fragment", .if_context = "is_htmx")
render("full_page", .unless_context = "is_htmx")
// multi-state: set flag in exec, then key off it:
exec(.call = classify_todo),
render("urgent_confirmation", .if_context = "is_urgent"),
render("standard_confirmation", .unless_context = "is_urgent")
Error and Repair Pipelines
On failure, MACH searches handlers bottom-up: resource → module → root.
- Errors are terminal: send a response, end the request.
- Repairs are resumable: fix context, then resume the original pipeline at the step after the failure.
If no matching repair, falls through to errors. The error scope is
shared across validate() and error_set(): {{error:name}},
{{error_code:name}}, {{error_message:name}}. Raw input remains in
input:name.
.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: 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 int works;
#define err_quota_exceeded 723 for domain-specific.
Event Pipelines
Internal pub/sub for cross-module communication. Publisher doesn't know
subscribers; subscribers don't know publishers. Adding a subscriber =
new module with .events; publisher unchanged.
When .publishes exists anywhere, MACH creates mach_events to track
delivery. Crashes don't drop events; they replay on next boot.
.publishes— outbound contracts:.eventname,.withkeys to pass.events— subscriber pipelines keyed by event name
// publisher
config todos(){
return (config){
.name = "todos",
.publishes = {
{"todo_created", .with = {"user_id", "title"}},
{"todo_deleted", .with = {"user_id", "todo_id"}}
},
.resources = {
{"todos", "/todos",
.post = {
validate({"title", .validation = validate_not_empty}),
query({"insert_todo", .db = "todos_db"}),
emit("todo_created"),
redirect("todos")
}
}
}
};
}
// subscriber: owns its OWN database (Rule 2)
config activity(){
return (config){
.name = "activity",
.events = {
{"todo_created", {
query({.db = "activity_db",
.query = "insert into activities(kind, user_id, ref) "
"values('created', {{user_id}}, {{title}});"})
}}
}
};
}
Task Pipelines
Named pipelines that run async on task reactors. Fire-and-forget. Defined
at module or root level. Triggered with task("name") or via .cron.
Tasks can enqueue more tasks.
Durable: mach_tasks checkpoints context after each step. Crash mid-task
→ resumes at the failed step.
.name(pos) — identifier, called viatask("name").accepts— context keys to pull from caller into the task.cron— standard cron schedule (no caller required)- Steps (pos) — pipeline body, second positional brace block
.tasks = {
// on-demand
{"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
{"daily_digest", {
query({.db = "todos_db",
.query = "insert into digest_reports(generated_at) values(now());"}),
emit("digest_ready")
}, .cron = "0 8 * * *"}
}
Modules & Composition
Every app and module returns a config. Root main.c defines mach();
modules define their own functions with any name. A module owns its
resources, its own database (Rule 2: one per domain), migrations,
templates, and event contracts. Same-name conflicts: root wins.
Modules communicate ONLY through pub/sub events.
.name— module identifier.modules— other modules to compose (root or nested)
A module file:
#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", // ONE db for the blogs domain
.connect = "file:blogs.db?mode=rwc",
.migrations = { // many tables, all in this db
"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);"
}
}}
};
}
Bring it in from main.c:
#include <mach.h>
#include "blogs/blogs.c"
config mach(){ return (config){ .modules = {blogs, sqlite} }; }
Resource fields like .url, .mime, .get belong inside entries of
.resources, not at the top level of config.
Project layout:
├── todos/ # todos module — owns todos_db
│ ├── todos.c
│ ├── todos.mustache.html
│ ├── create_todos_table.sql
│ └── get_todos.sql
├── activity/ # activity module — owns activity_db
│ └── activity.c
├── static/ # root-level templates (not a module, no .c)
│ └── home.mustache.html
├── public/ # static files served directly
│ └── favicon.png
└── main.c
Bundled modules (add to .modules): sqlite, postgres, mysql,
redis, duckdb, htmx, datastar, tailwind, session_auth
Module-provided steps. Modules can ship step functions that plug into
pipelines like built-ins. session_auth provides:
session()— attaches current session to context (setsuser_id, etc.); no-op when unauthenticatedlogged_in()— guard, raiseshttp_not_authorizedif no sessionlogin(),logout(),signup()— for POST pipelines
Common as resource-level middleware via .steps:
{"dashboard", "/dashboard", {session(), logged_in()},
.get = { render("dashboard") }
}
Static Files
Files in public/ at project root are served directly. Reference with
{{asset:filename}}, which resolves to a content-checksummed URL with
immutable cache headers.
public/
├── favicon.png
├── logo.png
└── styles.css
<link rel="icon" href="{{asset:favicon.png}}">
<link rel="stylesheet" href="{{asset:styles.css}}">
<script src="{{asset:app.js}}"></script>
External Dependencies
Containerized dev environment; no local toolchain. Two ways to bring in third-party C libraries, plus two memory bridges.
/vendor directory — drop in headers and .so/.a; auto-compiler
discovers, includes, and links them.
/vendor/
├── libsodium.h
└── libsodium.so
Custom Dockerfile — inherit from MACH base image, apt-get system
deps; reference from compose.yml.
FROM mach:latest
RUN apt-get update && apt-get install -y libsodium-dev
allocate(bytes) — buffer from pipeline arena, reclaimed on request
completion.
char *buf = allocate(256);
defer_free(ptr) — schedule cleanup for pointers from external libs
(malloc, etc.) when the arena is released.
char *out = third_party_alloc(256);
defer_free(out);
Architecture (brief)
- Boot once.
mach()runs once at boot. The returnedconfigis compiled into an execution graph with prepared queries and templates. - Multi-reactor. Request reactors handle HTTP (one per CPU). Task
reactors handle background jobs (one per CPU). Shared thread pool
handles
exec()and blocking I/O. - Memory. Per-request arena allocators. No
malloc/freein app code. Arena cleared on request end. Pipelines exceeding 5MB (default, configurable) abort with a 500. - Safety by default. SQL injection prevented by parameter binding
(Rule 4). XSS prevented by
render()auto-escape; opt out with{{raw:field}}. CSRF prevented by{{csrf:token}}/{{csrf:input}}. - Tooling. TUI editor with HMR/LSP/AI;
app_info,unit_tests,e2e_tests,app_debug,app_buildcommands; OpenTelemetry on :4000.
Final reminder — the rules a small model breaks most often
- No dot in
{{ }}. Every nested access is a section. Run the Template Checklist (Rule 1) on every tag. (Rule 1) - One database per domain, many tables. Parent + child (project + tasks, blog + comments) is one domain, one db, two migrations. (Rule 2)
- Concurrent = ONE step, MANY items. Even across databases. (Rule 3)
- Asset name OR
.query, never both. AND the asset name must actually exist in.context. If your snippet has no.context, use inline.query. Don't reference phantom assets. (Rule 5) join()moves children. Afterjoin(tasks → project),tasksno longer exists at root. Open{{#project}}first. (Rule 1, Pattern C)
If you catch yourself writing {{a.b}}, declaring projects_db +
tasks_db for one domain, chaining three independent query({...})
steps, naming an asset ({"get_todos", ...}) that you never embedded
in .context, or rendering {{#tasks}} at root after a join — stop
and fix it.