36 KiB
MACH
C23 web framework. App = config mach() returning (config){...} of resources, databases, modules. Each request runs a pipeline of steps over a per-request context. Memory, threads, I/O managed by the framework. Tasks and events are durable.
CRITICAL RULES — read before writing any code
Rule 1 — NEVER use . between {{ and }}
{{a.b}} renders empty. Always use sections to enter nested scope: {{#name}}...{{/name}}. Template shape mirrors context shape.
Helpers use : not . — these are fine: {{url:name}}, {{input:title}}, {{error:title}}, {{precision:total:2}}.
Pattern A — Single root scalar
.context = {{"site_name", "MACH App"}}
✅ <h1>{{site_name}}</h1>
Pattern B — query() / find() result is a TABLE, even single-row
find({.set_key = "todo", .db = "todos_db",
.query = "select id, title from todos where id = {{id}};"})
Context: { todo: [{id: 5, title: "Learn MACH"}] }. Open the section to read fields:
✅ {{#todo}}<h1>{{title}}</h1>{{/todo}}
❌ {{todo.title}} dot — renders ""
❌ <h1>{{title}}</h1> not inside #todo — renders ""
Pattern C — After join(): parent + nested children (most common; most-failed)
query() produces sibling tables. join() MOVES children inside parents — children no longer exist at root.
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 after join: { project: [{id, name, tasks: [{id, project_id, title}, ...]}] }. Tasks are now INSIDE project.
✅ {{#project}}
<h1>{{name}}</h1>
<ul>{{#tasks}}<li>{{title}}</li>{{/tasks}}</ul>
{{/project}}
❌ Every one of these renders empty:
{{project.name}}— dot{{project.tasks.title}}— dot{{#project.tasks}}...{{/project.tasks}}— dot in section name{{#tasks}}<li>{{title}}</li>{{/tasks}}— at root after join, tasks lives inside project, not at root<h1>{{name}}</h1>then{{#tasks}}...{{/tasks}}separately — name is inside project record too; no partial entry. EVERY field that came fromset_key="X"requires{{#X}}first, including the parent's own scalars.
Rule of thumb: if a field came from query().set_key = "X", it must be inside {{#X}}. No partial entry — you're inside the section or at root.
Pattern D — 3+ levels nested
{ org: [{name, projects: [{title, tasks: [{label}]}]}] }:
✅ {{#org}}{{name}}{{#projects}}{{title}}{{#tasks}}{{label}}{{/tasks}}{{/projects}}{{/org}}
❌ {{org.projects.tasks.label}} dot
❌ {{#org.projects}}... dot in section name
Pattern E — Iteration: {{#name}} auto-loops when name is array
{ projects: [{title:"A", tasks:[{label:"x"}]}, {title:"B", tasks:[{label:"z"}]}] }
✅ {{#projects}}<li>{{title}}<ul>{{#tasks}}<li>{{label}}</li>{{/tasks}}</ul></li>{{/projects}}
Template Checklist — run on every {{...}} BEFORE emitting
- Well-formed: exactly two
{and two}, no whitespace inside delimiters.{{/x}(one}),{x}},{{{x}}},{ {x} }are all broken. After typing every{{/...}}, count the closers:}}. - No dot: any
.between{{and}}→ STOP, add a section. - Field reachable: is the bare name at the current scope? At root, only top-level keys. Inside
{{#X}}, only fields ofX's record. Afterjoin(child→parent), child no longer at root — must be inside{{#parent}}. - Depth = wrappers: N nesting levels = N section pairs.
- Section balance + nesting: every
{{#x}}has exactly one{{/x}}later, and vice versa. Sections nest like parens, never overlap.{{#a}}{{#b}}{{/a}}{{/b}}is broken even though counts match. Each named section opens once in a given scope.
Rule 2 — ONE database per DOMAIN, not one per table
A "domain" is what one MODULE owns — a feature slice — not a noun in your data model.
A projects module owns everything about projects: project records, tasks attached to them, comments on those tasks, tags. All of that lives in ONE database (projects_db) with many migrations (one per table). Tasks aren't their own domain just because "tasks" is a noun — they're part of projects because they belong to projects.
A new database appears only when a new module appears. New table inside the existing module → new migration in the existing db, NOT a new db.
✅ Correct
.databases = {{
.engine = sqlite_db, .name = "projects_db",
.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);"
}
}}
❌ Wrong — both forms
// Form A: one db per table
.databases = {{...projects_db...}, {...tasks_db...}, {...comments_db...}}
// Form B (subtler, most common): parent + child split into separate dbs
.databases = {{...projects_db (with projects only)...},
{...tasks_db (tasks should live in projects_db)...}}
query({.db = "projects_db", ...}, {.db = "tasks_db", ...}) // wrong db
Parent-child relations are ONE domain, ONE database. project+tasks, blog+comments, order+line_items, user+sessions, todo+comments — all one domain each. If child has parent_id REFERENCES parent, they belong in the same db.
Rationalizations to REJECT:
- "X and Y are different concepts so they should be different domains" → Wrong. The foreign-key relation IS what makes them one domain.
- "More normalized / cleaner separation" → MACH's separation unit is the module, not the table.
- "Microservices use one db per service" → Right analogy, wrong unit. In MACH, "service" = module, not table. One module = one db.
- "My data model has X entities so I need X dbs" → No. Number of dbs = number of modules. A module owns 3–10 tables.
Self-check before adding a 2nd .databases entry: "Am I adding a new module?" If no, you don't need a new db — add a migration to the existing one.
Rule 3 — Concurrent queries: ONE step, MANY items (works across dbs too)
query() and fetch() items run in parallel. Two separate query() steps run serially. If items don't depend on each other, put them in ONE step.
✅ // Same db, concurrent
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 dbs — STILL one step, still concurrent
query(
{.set_key="user", .db="users_db", .query="..."},
{.set_key="orders", .db="commerce_db", .query="..."},
{.set_key="activity", .db="activity_db", .query="..."}
)
❌ // Three serial round-trips for independent queries
query({.set_key="user", .db="users_db", ...}),
query({.set_key="orders", .db="commerce_db", ...}),
query({.set_key="activity", .db="activity_db", ...})
Use separate steps only when a later query depends on a value the earlier one produced.
Rule 4 — SQL {{values}} are prepared-statement parameters
Auto-bound, never spliced. SQL injection impossible. Don't pre-quote, don't 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: BEGIN/COMMIT/ROLLBACK directly in queries.
Rule 5 — query()/find() item: asset name OR .query. Pick one. The asset must exist.
✅ query({.set_key="todos", .db="todos_db", .query="select ..."}) // inline; works anywhere
✅ query({"get_todos", .set_key="todos", .db="todos_db"}) // asset by name
// requires: .context = {{"get_todos", (asset){#embed "get_todos.sql"}}}
// WITHOUT that .context entry, this is a phantom reference — fails.
❌ query({"get_todos", .set_key="todos", .db="todos_db", .query="..."}) // both → boot rejection
❌ query({"get_todos", .set_key="todos", .db="todos_db"}) // when no .context defines "get_todos"
If your snippet has no .context block embedding SQL files, use inline .query only. Don't reach for the positional name to avoid writing the SQL — that produces a phantom reference. The asset-name form is for when SQL grows large and you've extracted it; not for casual use.
Same rule applies to find() and render() (asset name in .context vs .template inline).
Rule 6 — find() raises http_not_found on zero rows; query() does not
Otherwise identical. find() for "must exist" lookups (detail pages, by-id reads). query() for lists, counts, writes, anywhere zero rows is normal.
Rule 7 — No malloc/free, no threads, no mutexes
Per-request arena handles memory. Reactors + thread pool handle concurrency. App code never calls these.
For a buffer: char *buf = allocate(256); (reclaimed at request end). For external-lib pointer cleanup: defer_free(out);.
Rule 8 — Resource-based, not route-based
Resources referenced by name, never 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
C SYNTAX PATTERNS — required for compilation
Three patterns trip up small models even when MACH semantics are correct.
Pattern 1 — Adjacent string literals concatenate. ONE quoted line per source line.
C joins adjacent strings at compile time. Use this for every multi-line SQL and every inline template. Never put a raw newline inside a single quoted string. Never mix quoted and bare text on one line.
✅ "CREATE TABLE projects (" // each line its own
"id INTEGER PRIMARY KEY," // properly terminated string
"name TEXT NOT NULL" // C concatenates them
");"
❌ "CREATE TABLE projects ( // raw newline = compile error
id INTEGER PRIMARY KEY, ...
❌ "<ul>"
{{#tasks}}<li>{{title}}</li>{{/tasks}} // bare Mustache, not in string
"</ul>"
A MACH inline template is not a heredoc — it's a stack of small C string literals the compiler glues. Every fragment, every section marker, every HTML scrap must be inside its own "...".
Pattern 2 — Any " inside a C string ends it. Use ' for HTML, no quotes for SQL identifiers.
2a — SQL identifiers: don't quote them
✅ "CREATE TABLE projects (id INTEGER PRIMARY KEY, name TEXT NOT NULL);"
❌ "CREATE TABLE projects ("id" INTEGER ..., "name" TEXT ...);" // " ends C string
SQLite/Postgres/MySQL all accept unquoted identifiers for normal names. Never quote in MACH SQL unless reserved word (then escape with \").
2b — HTML attributes: USE SINGLE QUOTES EXCLUSIVELY. Never \".
✅ "<li class='task' data-id='{{id}}'>{{title}}</li>" // single quotes — nothing to escape
⚠️ "<li class=\"task\" data-id=\"{{id}}\">{{title}}</li>"
// works, but every \" is a slip waiting to happen.
// Drop one backslash on one attribute → compile error.
❌ "<li class=\"task\" data-id="{{id}}">{{title}}</li>"
// ↑ bare " — ends C string. The most-recurring HTML failure
// is mixing escaped \" and bare " in one template. Don't escape; use ' everywhere.
Rule: if your inline template contains any \", you took the harder route. Switch every HTML attribute to '...' before emitting.
Pattern 3 — All .fields go INSIDE one (config){...} block. Brace tracking is the most-regressed bug.
There is no file-scope config in MACH. .databases, .modules, .resources are NOT standalone declarations — they are fields of the single struct value mach() returns. Designated-initializer syntax (.field = value) is meaningful only inside a struct initializer.
Mechanical rule: in the entire
mach()body there must be exactly one};(closing(config){...}+ return) and exactly one}after it (closing the function). No others.
Three failure modes (all close (config){...} at the wrong point)
✅ config mach(){
return (config){ // depth 1 opens here
.resources = {...}, // , separator
.databases = {{...}}, // , separator
.modules = {sqlite} // last field, no comma
}; // ONE }; closes (config){...} + return
} // ONE } closes function
❌ A: premature `};` mid-function
.resources = {...}
}; // ← ends return early
.databases = {...}; // ← orphan inside function body
.modules = {sqlite};
❌ B: fields placed AFTER mach() closes (file scope)
};
} // function ends here
.databases = {...}; // ← floating at file scope = compile error
.modules = {sqlite}
// Giveaway: comment near here saying "global config" or "module-level" — there's no such thing.
❌ C: `},` closes (config){...} too early mid-function (most-recurring)
.resources = {...
}
}, // ← `},` = `}` closed (config){...} + stray `,`
.databases = {{...}}, // ← orphan in function body
.modules = {sqlite}
}; // orphan close
Post-field-block boundary check
After every .field = ... block closes with }, the next non-whitespace token must be:
,→ another.field = ...follows (still inside(config){...}). ✅};→ this was the last field, end of return. ✅},→ BUG: extra}closed(config){...}early plus stray,. Failure mode C. Delete one}.
Trouble signals (any of these = bug):
| You see | What broke |
|---|---|
Two }; lines in mach() |
Failure mode A |
}; } then .field = lines below |
Failure mode B (file-scope orphans) |
}, mid-function with .field = lines below |
Failure mode C |
.engine = sqlite_db but no .modules = {sqlite} |
Missing bundled module (see Modules) |
Indentation says fields are inside (config){...} but braces say otherwise |
Trust braces, not indent |
Self-check before emitting any snippet
- Strings: every line of every multi-line SQL and template starts with
"and ends with". No raw newlines or bare HTML/SQL between strings. - Bare
": no SQL identifiers in"...". No HTML attributes inattr="..."— use'...'or\"...\". Shortcut: any\"in inline template → switch whole template to single quotes; faster than auditing. - Brace count: exactly one
};(closes(config){...}+ return) and one}(closes function). All.fieldsinside that one initializer, comma-separated. - No file-scope fields: scan from closing
}ofmach()to end of file. Nothing between except otherconfigfunctions or#include. If you see.databases/.modules/.resources/etc. floating, move them inside. - Modules registered: every
.engine = X_dbrequiresXin.modules. Search snippet for.modules— zero matches = bug.
CANONICAL WORKED SNIPPET — copy this shape for parent + children pipelines
This is the right shape for: project+tasks, blog+comments, order+line_items, user+sessions, playlist+tracks, anything with a parent-child relation. Substitute names freely; do not change shape.
#include <mach.h>
#include <sqlite.h>
config mach(){
return (config){ // ← (config){ opens — depth 1
.resources = { // .resources opens — depth 2
{"project", "/projects/:id",
.get = {
validate({"id", .validation = validate_integer,
.message = "must be an integer"}),
// Rule 3: ONE step, TWO items, SAME db (Rule 2: parent + child = one domain)
query(
{.set_key="project", .db="projects_db",
.query="select id, name from projects where id = {{id}};"},
{.set_key="tasks", .db="projects_db", // ← SAME 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"),
// Rule 1 Pattern C: enter {{#project}} for ALL parent fields,
// including its own scalars (name, id) and nested children (tasks).
render(.template =
"{{#project}}"
"<h1>{{name}}</h1>"
"<p>Project ID: {{id}}</p>"
"<ul class='tasks'>"
"{{#tasks}}<li class='task'>{{title}}</li>{{/tasks}}"
"</ul>"
"{{/project}}")
}
}
}, // ← close .resources (depth 2 → 1), comma to continue
// ⚠️ depth IS 1 here. NEXT line must be another
// `.field =` OR `};`. Writing `},` here = Failure mode C.
// Rule 2: ONE db, TWO migrations
.databases = {{
.engine = sqlite_db,
.name = "projects_db",
.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"
");"
}
}}, // ← close .databases (depth 2 → 1), comma to continue
.modules = {sqlite} // ← REQUIRED. `.engine = sqlite_db` above forces this.
// Every .engine = X_db needs X in .modules.
// Never omit "for brevity" — there is no elsewhere.
}; // ← `};` closes (config){...} + return (depth 1 → 0).
// This MUST be the only `};` in the function.
} // ← `}` closes function body.
What the shape requires (all immutable regardless of names):
- ONE
.databasesentry containing migrations for parent AND child - ONE
query()step with multiple items, all SAME.db - ONE
join()between them - Template wraps everything in
{{#parent}}...{{/parent}}, children iterated inside .modules = {<engine>}listed once- Everything inside ONE
(config){...}block, ONE};, ONE}
MODULES — two different things called "module"
1. User-defined modules =
config foo(){...}functions you write to split an app into features. Skip these for small/single-file snippets — put everything inconfig mach(). Defining one without registering under.modules = {foo, ...}makes it dead code.2. Bundled modules = engine and feature modules shipped with MACH. MUST be registered in
.moduleswhenever you use what they provide..modulesis never optional.
Module-registration trigger rule (mechanical)
Every .engine = X_db line requires X in .modules:
.engine = |
.modules must include |
|---|---|
sqlite_db |
sqlite |
postgres_db |
postgres |
mysql_db |
mysql |
redis_db |
redis |
duckdb_db |
duckdb |
Feature modules:
| You use | Add to .modules |
|---|---|
ds_sse(...) |
datastar |
session(), logged_in(), login(), signup() |
session_auth |
is_htmx flag, HTMX-aware rendering |
htmx |
Pre-emit check: search your output for the literal string .modules. Zero matches = bug. If .modules exists but missing an entry for an engine you used = bug. Apply at point of typing — memory-level reminders don't reliably fire mid-generation.
REFERENCE
Notation
{} = single value/struct. {{}} = array of structs. query({...}, {...}) = multiple items in one step.
Context
Per-request key-value store. Three scopes:
input:xxx— raw request paramserror:xxx— validation/error data- (unprefixed) — app scope: query results, validated inputs,
.contextvalues
validate() promotes input: → app scope on success. .context seeds variables and assets at root, 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 forward-only, index-based, applied once each in array order, tracked in mach_meta. Seeds idempotent. Multi-tenant via {{interpolation}} in .connect; connections pooled with LRU.
Engines: sqlite_db, postgres_db, mysql_db, redis_db, duckdb_db
.databases = {{
.engine = sqlite_db, .name = "blog_db",
.connect = "file:{{user_id}}_blog.db?mode=rwc", // multi-tenant
.migrations = {"CREATE TABLE blogs (...);", "CREATE TABLE comments (...);"},
.seeds = {"INSERT OR IGNORE INTO blogs(...) VALUES(...);"}
}}
Resource Pipelines
Each .resources entry is a named URL endpoint. Identified by name in {{url:name}}, redirect(), reroute() with colon-separated args (name:arg1:arg2).
URL helpers: {{url:todos}}, {{url:todo:5}} (literal), {{url:todo:id}} (context), {{url:org_todo:acme:5}} (multi-arg).
Path specificity automatic: /todos/active beats /todos/:id. Verb selection: HTTP method, or ?http_method=... (lets HTML forms reach PATCH/DELETE/SSE).
Fields:
.name(pos),.url(pos),.steps(pos) — middleware run before every verb.mime— default response content type.get,.post,.put,.patch,.delete— verb pipelines.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+$"})},
.mime = mime_html,
.get = { find({"get_todo", .set_key="todo", .db="todos_db"}), render("todo") },
.patch = { validate({"title", .validation=validate_not_empty}),
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: mime_html, mime_txt, mime_sse, mime_json, mime_js
Template Helpers
{{helper:args}} — colon-separated, never dot.
| Helper | Purpose |
|---|---|
{{raw:field}} |
unescaped output (default escapes) |
{{precision:field:N}} |
numeric format, N decimals |
{{input:field}} |
raw request param (form repop) |
{{error:field}} |
truthy when field has error (use as section) |
{{error_message:field}}, {{error_code:field}} |
error message / status code |
{{url:name[:args]}} |
resource URL with positional args |
{{asset:filename}} |
cache-busted URL for public/ file |
{{csrf:token}}, {{csrf:input}} |
CSRF token / hidden input (auto-verified) |
The table is exhaustive. Helpers not in it do not exist. Other Mustache implementations have
{{length}},{{count}},{{size}},{{first}},{{last}},{{index}},{{#if}},{{#each}},{{else}}, lambdas, partials. None work in MACH.Supported template features: field interpolation,
{{#name}}(truthy/iteration),{{^name}}(falsy/empty), helpers above. HTML comments<!--...-->work; Mustache comments{{!...}}do NOT — use HTML.Doing common things you might reach for missing helpers:
- Counts: compute in SQL.
{.set_key="stats", .query="select count(*) as n from tasks where project_id = {{id}};"}then{{#stats}}{{n}} tasks{{/stats}}.- Conditionals:
{{#has_x}}...{{/has_x}}wherehas_xis set by query/exec(). There is no{{#if x}}.- First/last: different SQL —
limit 1,order by ... desc limit 1.- Index in iteration:
select row_number() over (...) as nthen{{n}}.The template layer is a renderer, not a programming language. If you need a feature not listed, do it in SQL or
exec(), then render the result.
Pipeline Steps
Every step accepts .if_context and .unless_context.
validate
Regex-checks request params. On success, promotes input:name → app scope. On failure, sets error:name, raises http_bad_request. All validations in one call complete before the error fires.
.param_key (pos), .validation (regex/macro), .message, .optional, .fallback.
validate(
{"email", .validation=validate_email, .message="must be email"},
{"title", .validation=validate_not_empty, .message="cannot be empty"},
{"page", .fallback="1", .validation="^\\d+$"},
{"filter", .optional=true, .validation="^(active|done)$"}
)
Built-ins: validate_not_empty, validate_alpha, validate_alphanumeric, validate_slug, validate_no_html, validate_integer, validate_positive, validate_float, validate_percentage, validate_email, validate_uuid, validate_username, validate_date, validate_time, validate_datetime, validate_url, validate_ipv4, validate_hex_color, validate_zipcode_us, validate_phone_e164, validate_cron, validate_no_sqli, validate_token, validate_base64, validate_boolean, validate_yes_no, validate_on_off. Define your own: #define validate_zipcode "^\\d{5}$".
find & query
Both run DB queries. find() raises http_not_found on zero rows; query() does not. Otherwise identical.
.set_key stores result as TABLE in context (always). SQL inlined with .query OR loaded by name from .context as positional (Rule 5 — asset must exist). Multiple items in one step run concurrently (Rule 3), even across dbs. {{values}} parameter-bound (Rule 4). Transactions: BEGIN/COMMIT/ROLLBACK.
.template_key (pos), .query, .set_key, .db, .if_context/.unless_context (per item).
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 matching records of another, in memory. After join(), inner records live INSIDE outer records. Templates must enter outer section.
.target_table_key, .target_field_key, .nested_table_key, .nested_field_key, .target_join_field_key — see canonical snippet.
fetch
HTTP request → context. JSON parsed into tables/records; text stored as string. Multiple items run concurrently.
.url (pos), .set_key, .method (default http_get), .headers (array of {name, value}), .json/.text (context key as body).
fetch("https://api.x.dev/charge",
.set_key = "receipt", .method = http_post,
.headers = {{"Authorization", "Bearer {{api_key}}"}, {"Idempotency-Key", "{{order_id}}"}},
.json = "order")
Methods: http_get, http_post, http_put, http_patch, http_delete, http_sse_method
exec
Calls C function/block with imperative context access. Dispatched to thread pool. Use for blocking I/O or CPU work. Trigger error pipeline via error_set().
exec(^(){ auto t = get("challengers"); record_set(table_get(t,0), "x", record_get(table_get(t,1), "id")); })
exec(.call = assign_opponents)
Imperative API: get/set/has/format, allocate/defer_free, error_set/get/has, table_new/count/get/add/remove/remove_at, record_new/set/get/remove.
emit / task
emit("todo_created") // pub/sub event
task("recount_todos") // enqueue async job
sse / ds_sse
sse(.channel="todos/{{user_id}}", .event="updated", .data={"id: {{id}}"}) — broadcasts on channel; without channel, sends only to requester.
ds_sse(...) — Datastar SSE (DOM updates + reactive state). Requires .modules = {datastar}. Fields: .channel, .target, .mode (mode_outer/inner/replace/prepend/append/before/after/remove), .elements, .signals (JSON), .js.
render
Outputs Mustache template. Auto-escapes (use {{raw:field}} to opt out). All field access follows Rule 1 — sections, never dot. Run Template Checklist on every {{...}} before emitting.
.template_key (pos), .template, .status, .mime, .engine (mustache default, mdm for Markdown-with-Mustache), .json_table_key (serialize table as JSON).
render("todos")
render(.template = "<h1>{{site_name}}</h1>")
render("not_found", .status = http_not_found)
render(.json_table_key = "todos")
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).
Use either positional asset name (must exist in
.context) OR inline.template. Don't reference phantom asset names.
headers / cookies / redirect / reroute / nest
headers({{"X-Request-Id", "{{request_id}}"}, {"Cache-Control", "no-store"}})
cookies({{"session", "{{session_id}}"}})
redirect("todos") // 302 /todos
redirect("todo:{{id}}") // 302 /todos/{id}
reroute("todo:{{id}}") // server-side, in-process
nest({query({...}), render("urgent")}, .if_context="is_urgent") // shared conditional
Conditionals
Every step accepts .if_context (run when key present) / .unless_context (when absent). Works on validated inputs, query results, framework flags (is_htmx), or flags set by exec().
render("fragment", .if_context="is_htmx")
render("full_page", .unless_context="is_htmx")
Errors and Repairs
On failure, MACH searches handlers bottom-up: resource → module → root.
- Errors terminal: send response, end request.
- Repairs resumable: fix context, resume original pipeline at step after failure.
If no matching repair, falls through to errors. Error scope 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)}}}
Codes: built-ins above; any int works. #define err_quota_exceeded 723 for domain-specific.
Events (cross-module pub/sub)
When .publishes exists anywhere, MACH creates mach_events to track delivery. Crashes don't drop events; replay on next boot.
// publisher
.publishes = {{"todo_created", .with = {"user_id", "title"}}},
.resources = {{"todos", "/todos", .post = {validate(...), query({"insert_todo", .db="todos_db"}),
emit("todo_created"), redirect("todos")}}}
// subscriber (different module, OWN db per Rule 2)
.events = {{"todo_created", {query({.db="activity_db",
.query="insert into activities(kind, user_id) values('created', {{user_id}});"})}}}
Tasks
Async pipelines on task reactors. Triggered by task("name") or .cron. Durable: mach_tasks checkpoints context after each step; crash mid-task resumes at failed step.
.tasks = {
{"recount_todos",
{query({.db="todos_db", .query="update users set todo_count = ... where id = {{user_id}};"})},
.accepts = {"user_id"}},
{"daily_digest",
{query({...}), emit("digest_ready")},
.cron = "0 8 * * *"}
}
Modules & Composition
Every app/module returns config. Root main.c defines mach(); modules define functions with any name. Module owns its resources, its own db (one per domain), migrations, templates, event contracts. Same-name conflicts: root wins. Modules communicate ONLY via pub/sub events.
.name — module identifier. .modules — modules to compose.
A module file:
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 (...);",
"CREATE TABLE comments (... REFERENCES blogs(id) ...);"
}
}}
};
}
Bring in from main.c:
#include "blogs/blogs.c"
config mach(){ return (config){ .modules = {blogs, sqlite} }; }
Bundled modules (add to .modules): sqlite, postgres, mysql, redis, duckdb, htmx, datastar, tailwind, session_auth.
session_auth provides: session() (attaches session, sets user_id), logged_in() (guard, raises http_not_authorized), login(), logout(), signup(). Common as resource middleware:
{"dashboard", "/dashboard", {session(), logged_in()}, .get = {render("dashboard")}}
Static Files / External Deps
public/ files served directly. Reference with {{asset:filename}} (content-checksummed URL, immutable cache).
External C libs: drop into /vendor (auto-discovered/linked), or use custom Dockerfile (FROM mach:latest, apt-get install).
Memory bridges: allocate(N) (per-request arena), defer_free(ptr) (cleanup at request end).
Architecture (brief)
- Boot once:
mach()runs at boot; config compiled into execution graph. - Multi-reactor: request reactors (one/CPU), task reactors (one/CPU), shared thread pool for
exec()and blocking I/O. - Memory: per-request arena allocators; cleared at request end. Pipelines >5MB abort with 500.
- Safety: SQL injection prevented by parameter binding (Rule 4). XSS prevented by
render()auto-escape. CSRF prevented by{{csrf:token}}/{{csrf:input}}.
PRE-EMIT SCAN — search your output for these strings before returning
| Search for | Failure |
|---|---|
. between {{ and }} (e.g. {{x.y}}) |
Rule 1: dot notation |
Two _db", lines with different prefixes for ONE domain (e.g. projects_db + tasks_db) |
Rule 2: parent+child split |
Multiple query({ calls in one pipeline that don't depend on each other |
Rule 3: serial when concurrent needed |
Positional asset name in query()/find() with no matching .context entry |
Rule 5: phantom asset |
config X() not appearing in any .modules = {...} |
Unregistered user module (dead code) |
.engine = X_db with no X in .modules |
Missing bundled module (most-recurring regression) |
{{#child}} at root scope after a join(child→parent) |
Pattern C: orphan section |
Bare {{name}} at root when name came from set_key="X" table |
Pattern C: missing {{#X}} wrapper |
\" in inline template |
Pattern 2b: switch whole template to '...' |
{{length}}, {{count}}, {{#if}}, {{#each}}, etc. |
Improvised helpers — not in MACH |
{{#a}} opened twice in same scope |
Overlapping sections (Check 4) |
{{/x} (one }), {x}}, {{{x}}}, { {x} } |
Malformed tag delimiters (Check 0) |
Two }; in one mach() |
Pattern 3 Failure A |
}; then } then .field = lines |
Pattern 3 Failure B (file-scope orphans) |
}, between fields where } closed (config){...} |
Pattern 3 Failure C |
{{/x}} with no matching {{#x}} earlier |
Section balance (Check 4) |
validate() step missing when prompt asked for one |
Prompt-following: re-read prompt |
If any match: stop and fix. When in doubt about parent + child: copy the canonical worked snippet above and rename.