33 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 context.
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; template opens the section to iterate.
#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 concurrently, put both items in one query():
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() then query() then redirect() (POST-redirect-GET). On success, title is promoted from input:title to app scope and bound as a prepared parameter 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 a 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
validate() failure raises http_bad_request. A resource-scoped error handler re-enters the GET pipeline with reroute(). Both input: and error: scopes persist through the reroute.
.errors = {
{http_bad_request, { reroute("todos") }}
}
Show the error in the template:
<input name='title' value='{{input:title}}'>
{{#error:title}}<span>{{error_message:title}}</span>{{/error:title}}
5. Nested Data
Fetch parent + children concurrently, join() to nest, then enter the parent section to render. Comments live in the same database as todos, so add a migration on the existing todos_db.
{"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="db", .query="update users set ... where id = {{user_id}};"})
}, .accepts = {"user_id"}}
7. Modules & Events
Modules are .c files with a function returning config. They have their own resources, databases, migrations, tasks, and event subscribers. They communicate ONLY through pub/sub events, never direct calls. main.c includes them and registers under .modules.
.
├── todos/todos.c // config todos() { ... }
├── activity/activity.c // config activity() { ... }
└── main.c
main.c: includes module sources, registers them.
#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")
}
}
}
// ... databases, etc.
};
}
Subscriber declares an .events entry. The published keys (title) arrive in context.
config activity(){
return (config){
.name = "activity",
.events = {
{"todo_created", {
query({.db = "activity_db",
.query = "insert into activities(kind, ref) "
"values('created', {{title}});"})
}}
}
// ... own database, own resources
};
}
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(), 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.
9. External Data
fetch() makes an HTTP request and stores the response. JSON parses 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:
{{#quote}}<blockquote>{{content}}, {{author}}</blockquote>{{/quote}}
Multiple items in one fetch() run concurrently. fetch() supports POST/PUT/PATCH/DELETE, custom headers, JSON or text bodies, and {{interpolation}} in the URL. See fetch below.
Reference
Notation
{}: single value or struct:.get = { ... }{{}}: array of structs:.databases = {{ ... }}- Multiple elements:
.databases = {{...}, {...}} - Multiple step items:
query({...}, {...})
Context
Pipelines read/write a 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 are available in context.
.context seeds variables and assets at the root. Assets baked at compile time with (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.
.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);",
"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) VALUES(1, 'Hello');"}
}}
One database per domain, many tables. Related tables (blogs, comments) go as separate migrations on the same database.
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). Args fill :params in the URL pattern in 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 pipeline (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 with{{interpolation}}.errors: terminal handlers keyed by error code.repairs: resumable handlers keyed by error code
Example:
{"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. 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 (for repopulating forms) | <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 string | <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 for conditional execution.
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 are available together). Define your own macros: #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.
.set_key stores result as a TABLE in context (always, even single-row results). 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. Multiple items in one step run concurrently. Interpolated {{values}} are bound as prepared-statement parameters. For transactions, use BEGIN/COMMIT/ROLLBACK in your queries.
.template_key(pos): SQL asset name in.context(mutually exclusive with.query).query: inline SQL string with{{interpolation}}(mutually exclusive with positional).set_key: context key for result table.db: database name from.databases.if_context/.unless_context(per item): conditionally include while others 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. 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
join(
.target_table_key = "blog",
.target_field_key = "id",
.nested_table_key = "comments",
.nested_field_key = "blog_id",
.target_join_field_key = "comments"
)
Pattern is 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.
.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 shared thread pool (releases reactor); pipeline resumes on 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.
- Event name (pos)
emit("todo_created")
task
Enqueues a named job in the task database; calling pipeline continues immediately.
- Task name (pos)
task("recount_todos")
sse
Pushes a Server-Sent Event. With .channel, broadcasts. Without, sent to requesting client only.
.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}} to opt out). See Critical Rules #4 for section/field-access semantics.
Fields:
.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 produce 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)
MIME types: mime_html, mime_txt, mime_sse, mime_json, mime_js
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, run another resource's pipeline within the same request. Both take a resource identifier name[:arg1:arg2...]. Args can be literals or context keys with {{interpolation}}.
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 for 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 request.
- Repairs are resumable: fix context, then resume 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 is defined 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
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 database 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, databases, migrations, templates, event contracts. Same-name conflicts: root wins. Modules communicate ONLY through pub/sub events, never direct calls.
.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",
.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);"
}
}}
};
}
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.c
│ ├── todos.mustache.html
│ ├── create_todos_table.sql
│ └── get_todos.sql
├── activity/
│ └── 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 ship from modules and 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 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 libs, plus two memory bridges.
/vendor directory: drop in headers and .so/.a; auto-compiler discovers, includes, links.
/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 arena is released.
char *out = third_party_alloc(256);
defer_free(out);
Critical Rules
Read these before writing any code. Most bugs come from breaking one of these.
-
SQL
{{values}}are bound as prepared-statement parameters, never spliced. Same forfind(). No SQL injection possible; do not pre-quote values. -
query()/find()items take positional asset name OR.query, never both.- ✅
query({"get_todos", .set_key="todos", .db="db"})(loads SQL from.context) - ✅
query({.set_key="todos", .db="db", .query="select ..."})(inline) - Combining the two is rejected at boot.
- ✅
-
Concurrency = multiple items in ONE step.
query({a},{b})runs in parallel. Twoquery({a})query({b})steps run serially. -
Do not write
{{blog.title}}. Do not write{{blog.content}}. Do not write{{blog.author.name}}. MACH does not support Mustache dot notation in any form. These render as empty string, silently.You will be tempted to write dotted references. Every Mustache tutorial teaches them.
mustache.js,pystache,handlebars, Ruby Mustache all support dot notation. MACH does not. MACH uses the Mustach C library, which implements the base Mustache spec without the dotted-name extension. If you write{{x.y}}anywhere in a template, it is wrong, and the output will be silently broken.Before you output any template, scan every
{{...}}tag. If you see a period between the braces, you are using dot notation. Stop and rewrite it using section syntax.To read a field on a record, enter the record's section first and reference the field by its plain name inside the block. A "record" is any value produced by
query(),find(),fetch(), orjoin()under a.set_key. Section syntax does double duty: it scopes field access AND iterates lists. One row or a hundred, the template code is identical.Do (enter the section, reference plain field names inside):
{{#blog}} <h1>{{title}}</h1> <p>{{content}}</p> {{/blog}}Don't (dot notation, any form, renders empty):
<h1>{{blog.title}}</h1> <p>{{blog.content}}</p>Don't (repeated section opens, wasteful, harder to read):
<h1>{{#blog}}{{title}}{{/blog}}</h1> <p>{{#blog}}{{content}}{{/blog}}</p>Don't (plain field at root when data is inside a record, renders empty):
<!-- blog is a record in context, but title is NOT at root --> <h1>{{title}}</h1>Single-row find(), section still required:
find({.set_key="todo", .db="db", .query="select title, body from todos where id={{id}};"}), render(.template = "{{#todo}}" "<h1>{{title}}</h1>" "<p>{{body}}</p>" "{{/todo}}")Multi-row query(), same syntax, Mustache iterates automatically:
query({.set_key="todos", .db="db", .query="select id, title from todos;"}), render(.template = "<ul>{{#todos}}<li>{{title}}</li>{{/todos}}</ul>") -
No
malloc/free, no threads, no mutexes. Per-request arena handles memory; framework handles concurrency. For external buffers:allocate(bytes)anddefer_free(ptr). -
Resource-based, not route-based. Resources are referenced by NAME (
{{url:todos}},redirect("todo:5"),reroute("todos")), not by path.