877 lines
36 KiB
Plaintext
877 lines
36 KiB
Plaintext
|
|
# MACH (Modern Asynchronous C Hypermedia)
|
||
|
|
|
||
|
|
Declarative framework for asynchronous, reactive web apps in C23. Pipelines transform requests via composable steps. Data flows as arena-backed strings interpolated with `{{key}}`. Memory, concurrency, and async I/O are managed by the framework: no malloc/free, no threads/mutexes/locks in app code.
|
||
|
|
|
||
|
|
Bundled engines/integrations: SQLite, Postgres, MySQL, Redis/Valkey, DuckDB, HTMX, Datastar, Tailwind, session_auth.
|
||
|
|
|
||
|
|
================================================================
|
||
|
|
NOTATION
|
||
|
|
================================================================
|
||
|
|
|
||
|
|
C designated initializers at different brace depths:
|
||
|
|
{} single value or struct .get = { ... }
|
||
|
|
{{}} array of structs .databases = {{ ... }}
|
||
|
|
|
||
|
|
Multiple array elements: comma-separated inner braces: `.databases = {{...}, {...}}`.
|
||
|
|
|
||
|
|
Inside steps that accept multiple items (query, validate): `query({...}, {...})`.
|
||
|
|
|
||
|
|
Templates use Mustache SECTIONS, not dot paths. Open the section first, even for single-row queries:
|
||
|
|
✅ {{#blog}}{{title}}{{/blog}} (single-row or multi-row)
|
||
|
|
✅ {{#blog}}{{#comments}}{{body}}{{/comments}}{{/blog}} (joined data)
|
||
|
|
❌ {{title}} at root (no top-level key)
|
||
|
|
❌ {{blog.title}} (dot paths not supported)
|
||
|
|
|
||
|
|
Mustache tags must live inside C string literals. Section open/close tags on their own lines must be quoted:
|
||
|
|
"{{#blog}}"
|
||
|
|
"<h1>{{title}}</h1>"
|
||
|
|
"{{/blog}}"
|
||
|
|
|
||
|
|
================================================================
|
||
|
|
CONFIG STRUCT (top-level)
|
||
|
|
================================================================
|
||
|
|
|
||
|
|
Every app and module returns `config`. Root must define `mach()`. Modules define their own functions, registered in `.modules` by bare function reference.
|
||
|
|
|
||
|
|
```c
|
||
|
|
config mach(){ return (config){
|
||
|
|
.name = "...", // module identifier
|
||
|
|
.context = {{ "key", value }, ...}, // shared constants/assets
|
||
|
|
.publishes = {{ "event", .with={...} }},
|
||
|
|
.databases = {{ ... }},
|
||
|
|
.tasks = {{ ... }},
|
||
|
|
.events = {{ ... }},
|
||
|
|
.modules = { fn_ref, ... }, // composed modules
|
||
|
|
.resources = {{ ... }},
|
||
|
|
.errors = {{ http_code, { steps } }},
|
||
|
|
.repairs = {{ http_code, { steps } }},
|
||
|
|
};}
|
||
|
|
```
|
||
|
|
|
||
|
|
Module wiring: bring module into scope by `#include "module/module.c"` from main.c, then list it in `.modules`. (NOT `extern` — modules are composed by direct inclusion.)
|
||
|
|
|
||
|
|
```c
|
||
|
|
// main.c
|
||
|
|
#include <mach.h>
|
||
|
|
#include "todos/todos.c"
|
||
|
|
config mach(){ return (config){ .modules = {todos, sqlite} }; }
|
||
|
|
```
|
||
|
|
|
||
|
|
When root and module define the same name (context, database, error handler), ROOT WINS.
|
||
|
|
Modules don't call each other directly — they communicate via pub/sub events.
|
||
|
|
|
||
|
|
================================================================
|
||
|
|
RESOURCES
|
||
|
|
================================================================
|
||
|
|
|
||
|
|
`.resources` defines named URL endpoints with HTTP verb pipelines. Resources are identified by name. `{{url:name}}`, `redirect()`, `reroute()` all take `name[:arg1:arg2...]` identifier. Args fill `:params` of the URL pattern. Path specificity is automatic: exact (`/todos/active`) beats parameterized (`/todos/:id`) regardless of order.
|
||
|
|
|
||
|
|
Verb selection: by HTTP method, or by passing `http_method` as query/form parameter. Lets HTML forms (GET/POST only) reach any verb, gives SSE a connection path: `/todos?http_method=sse`.
|
||
|
|
|
||
|
|
Resource fields:
|
||
|
|
.name (pos) resource identifier
|
||
|
|
.url (pos) URL pattern with optional :params
|
||
|
|
.steps (pos) shared steps run before every verb pipeline (unnamed positional brace block after URL)
|
||
|
|
.mime default response content type for resource
|
||
|
|
.get .post .put .patch .delete verb pipelines (ordered step arrays)
|
||
|
|
.sse persistent SSE channel: { "channel/{{interp}}", steps... }
|
||
|
|
.errors resource-scoped error handlers
|
||
|
|
.repairs resource-scoped repair handlers
|
||
|
|
|
||
|
|
Example with all fields:
|
||
|
|
```c
|
||
|
|
{"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") }}}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
URL helpers with args (positional, colon-separated, literal or context key):
|
||
|
|
{{url:todos}} → /todos
|
||
|
|
{{url:todo:5}} → /todos/5
|
||
|
|
{{url:todo:id}} → /todos/{{id}} (id from current scope)
|
||
|
|
{{url:org_todo:acme:5}} → /orgs/acme/todos/5
|
||
|
|
|
||
|
|
================================================================
|
||
|
|
PIPELINE STEPS
|
||
|
|
================================================================
|
||
|
|
|
||
|
|
Every step accepts `.if_context` and `.unless_context` for conditional execution.
|
||
|
|
|
||
|
|
----------------------------------------------------------------
|
||
|
|
validate
|
||
|
|
----------------------------------------------------------------
|
||
|
|
Checks request parameters (query, form body, URL params) against regex. On success, value promoted from `input:name` to app scope. On failure, errors land in `error:name`, raises `http_bad_request` triggering nearest error/repair handler. All validations in a call complete before error fires (multi-error forms).
|
||
|
|
|
||
|
|
.param_key (pos) parameter name
|
||
|
|
.validation regex pattern or built-in validator macro
|
||
|
|
.message human-readable error
|
||
|
|
.optional skip if parameter absent
|
||
|
|
.fallback default value if absent
|
||
|
|
|
||
|
|
```c
|
||
|
|
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 validator macros (defined in mach.h, define your own the same way):
|
||
|
|
validate_not_empty "^\S+.*$"
|
||
|
|
validate_alpha "^[a-zA-Z]+$"
|
||
|
|
validate_alphanumeric "^[a-zA-Z0-9]+$"
|
||
|
|
validate_slug "^[a-z0-9]+(-[a-z0-9]+)*$"
|
||
|
|
validate_no_html "^[^<>]*$"
|
||
|
|
validate_integer "^-?[0-9]+$"
|
||
|
|
validate_positive "^[1-9][0-9]*$"
|
||
|
|
validate_float "^-?[0-9]+(\.[0-9]+)?$"
|
||
|
|
validate_percentage "^(100|[1-9]?[0-9])$"
|
||
|
|
validate_email "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
|
||
|
|
validate_uuid "^[0-9a-fA-F]{8}-...{4}-...{4}-...{4}-...{12}$"
|
||
|
|
validate_username "^[a-zA-Z][a-zA-Z0-9_]{2,31}$"
|
||
|
|
validate_date "^YYYY-MM-DD$"
|
||
|
|
validate_time "^HH:MM$"
|
||
|
|
validate_datetime "^YYYY-MM-DDTHH:MM(:SS)?$"
|
||
|
|
validate_url "^https?://...$"
|
||
|
|
validate_ipv4 "^N.N.N.N$"
|
||
|
|
validate_hex_color "^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$"
|
||
|
|
validate_zipcode_us "^[0-9]{5}(-[0-9]{4})?$"
|
||
|
|
validate_phone_e164 "^\+[1-9][0-9]{6,14}$"
|
||
|
|
validate_cron cron expression
|
||
|
|
validate_no_sqli "^[^;'\"\\]*$"
|
||
|
|
validate_token "^[a-zA-Z0-9_-]{16,128}$"
|
||
|
|
validate_base64 "^[A-Za-z0-9+/]+=*$"
|
||
|
|
validate_boolean "^(true|false|0|1)$"
|
||
|
|
validate_yes_no "^(yes|no)$"
|
||
|
|
validate_on_off "^(on|off)$"
|
||
|
|
|
||
|
|
----------------------------------------------------------------
|
||
|
|
find & query
|
||
|
|
----------------------------------------------------------------
|
||
|
|
Both run database queries. `.db` selects database. `.set_key` stores result in context as a TABLE (even single-row). Templates open the table as a section to read fields.
|
||
|
|
|
||
|
|
SQL: either inlined via `.query` OR loaded by name as positional from `.context`. NEVER both. Multiple items in single call run CONCURRENTLY. Two back-to-back `query({...})` steps run SERIALLY. For concurrency, pass all items to one call.
|
||
|
|
|
||
|
|
Difference: `find()` raises `http_not_found` (404) when zero rows; `query()` does not.
|
||
|
|
|
||
|
|
.template_key (pos) SQL asset name in .context (mutually exclusive with .query)
|
||
|
|
.query inline SQL string with {{interpolation}} (bound as prepared-statement parameters)
|
||
|
|
.set_key context key for result table
|
||
|
|
.db database name matching .databases entry
|
||
|
|
.if_context / .unless_context (per item) conditionally include/skip individual queries
|
||
|
|
|
||
|
|
```c
|
||
|
|
query(
|
||
|
|
{"get_todos", .set_key = "todos", .db = "todos_db"},
|
||
|
|
{.set_key = "count", .db = "todos_db",
|
||
|
|
.query = "select count(*) as n from todos where user_id = {{user_id}};"},
|
||
|
|
{.if_context = "show_urgent", .set_key = "urgent", .db = "todos_db",
|
||
|
|
.query = "select id, title from todos where user_id = {{user_id}} and priority = 'high';"}
|
||
|
|
)
|
||
|
|
```
|
||
|
|
|
||
|
|
For transactions: use BEGIN/COMMIT/ROLLBACK directly in queries.
|
||
|
|
|
||
|
|
----------------------------------------------------------------
|
||
|
|
join
|
||
|
|
----------------------------------------------------------------
|
||
|
|
Nests records from one context table into each matching record of another (in-memory JOIN). Each outer record gains a new field holding matched inner records.
|
||
|
|
|
||
|
|
CRITICAL: nesting only works if `render()` opens the OUTER table as a section. Iterating both as siblings at root makes join() a no-op.
|
||
|
|
|
||
|
|
.target_table_key outer table receiving nested children
|
||
|
|
.target_field_key field on outer to match against
|
||
|
|
.nested_table_key inner table to nest
|
||
|
|
.nested_field_key field on inner pointing at outer
|
||
|
|
.target_join_field_key new field on outer holding matched inner records
|
||
|
|
|
||
|
|
```c
|
||
|
|
join(
|
||
|
|
.target_table_key = "blog",
|
||
|
|
.target_field_key = "id",
|
||
|
|
.nested_table_key = "comments",
|
||
|
|
.nested_field_key = "blog_id",
|
||
|
|
.target_join_field_key = "comments"
|
||
|
|
)
|
||
|
|
// after: blog: [{id, title, content, comments: [{...}, ...]}]
|
||
|
|
// render: "{{#blog}}<h1>{{title}}</h1>{{#comments}}<li>{{body}}</li>{{/comments}}{{/blog}}"
|
||
|
|
```
|
||
|
|
|
||
|
|
----------------------------------------------------------------
|
||
|
|
fetch
|
||
|
|
----------------------------------------------------------------
|
||
|
|
HTTP request, response stored in context. JSON auto-parsed into tables/records (nested JSON → nested tables). Plain text stored as string.
|
||
|
|
|
||
|
|
.url (pos) request URL with {{interpolation}}
|
||
|
|
.set_key context key for response
|
||
|
|
.method HTTP method (default http_get)
|
||
|
|
.headers array of {name, value} pairs
|
||
|
|
.json context key serialized as JSON request body
|
||
|
|
.text context key sent as plain-text request body
|
||
|
|
|
||
|
|
```c
|
||
|
|
fetch("https://api.payments.dev/charge",
|
||
|
|
.set_key = "receipt",
|
||
|
|
.method = http_post,
|
||
|
|
.headers = {
|
||
|
|
{"Authorization", "Bearer {{api_key}}"},
|
||
|
|
{"Idempotency-Key", "{{order_id}}"}
|
||
|
|
},
|
||
|
|
.json = "order"
|
||
|
|
)
|
||
|
|
```
|
||
|
|
|
||
|
|
Multiple items in one fetch() run concurrently, like query().
|
||
|
|
|
||
|
|
HTTP methods: http_get, http_post, http_put, http_patch, http_delete, http_sse_method
|
||
|
|
|
||
|
|
----------------------------------------------------------------
|
||
|
|
exec
|
||
|
|
----------------------------------------------------------------
|
||
|
|
Calls C function or block with access to context via Imperative API. Dispatched to shared thread pool, releases reactor; pipeline resumes on original reactor when call returns. Suitable for blocking I/O and CPU-heavy work. Trigger error/repair from inside via `error_set()`.
|
||
|
|
|
||
|
|
block (pos) inline ^(){ ... } block
|
||
|
|
.call named C function reference
|
||
|
|
|
||
|
|
```c
|
||
|
|
exec(^(){
|
||
|
|
auto t = get("challengers");
|
||
|
|
record_set(table_get(t, 0), "opponent_id",
|
||
|
|
record_get(table_get(t, 1), "id"));
|
||
|
|
})
|
||
|
|
|
||
|
|
exec(.call = assign_opponents)
|
||
|
|
```
|
||
|
|
|
||
|
|
----------------------------------------------------------------
|
||
|
|
emit
|
||
|
|
----------------------------------------------------------------
|
||
|
|
Triggers internal pub/sub event. Subscribers in other modules react in their `.events` pipelines.
|
||
|
|
|
||
|
|
event_name (pos) event to publish
|
||
|
|
|
||
|
|
```c
|
||
|
|
emit("todo_created")
|
||
|
|
```
|
||
|
|
|
||
|
|
----------------------------------------------------------------
|
||
|
|
task
|
||
|
|
----------------------------------------------------------------
|
||
|
|
Adds named job to task database, continues immediately (fire-and-forget). Task reactors pick up queued jobs.
|
||
|
|
|
||
|
|
task_name (pos) name of task in .tasks
|
||
|
|
|
||
|
|
```c
|
||
|
|
task("recount_todos")
|
||
|
|
```
|
||
|
|
|
||
|
|
----------------------------------------------------------------
|
||
|
|
sse
|
||
|
|
----------------------------------------------------------------
|
||
|
|
Pushes Server-Sent Event. With `.channel`: broadcast to all clients on that channel. Without: returned to requesting client.
|
||
|
|
|
||
|
|
.channel (pos) broadcast channel with {{interpolation}}
|
||
|
|
.event SSE event: line value
|
||
|
|
.data array of strings, one per data: line
|
||
|
|
.comment SSE : comment line
|
||
|
|
|
||
|
|
```c
|
||
|
|
sse(
|
||
|
|
.channel = "todos/{{user_id}}",
|
||
|
|
.event = "todo_updated",
|
||
|
|
.data = {"id: {{todo_id}}", "title: {{title}}"},
|
||
|
|
.comment = "broadcast at {{timestamp}}"
|
||
|
|
)
|
||
|
|
```
|
||
|
|
|
||
|
|
----------------------------------------------------------------
|
||
|
|
ds_sse (provided by datastar module)
|
||
|
|
----------------------------------------------------------------
|
||
|
|
Datastar-formatted SSE for DOM updates and reactive client state. With channel: broadcast. Without: requesting client only.
|
||
|
|
|
||
|
|
.channel (pos) broadcast channel with {{interpolation}}
|
||
|
|
.target DOM element id for update
|
||
|
|
.mode fragment insertion mode
|
||
|
|
.elements render_config for DOM fragment (positional asset name, supports .template/.engine)
|
||
|
|
.signals JSON string updating Datastar reactive client state without touching DOM
|
||
|
|
.js JS snippet evaluated on client
|
||
|
|
|
||
|
|
```c
|
||
|
|
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 Mustache template using current context. Templates referenced by name from .context or inlined. Sections only, no dot paths.
|
||
|
|
|
||
|
|
.template_key (pos) asset name in .context
|
||
|
|
.template inline Mustache template string
|
||
|
|
.status HTTP response status (default http_ok)
|
||
|
|
.mime override response content type
|
||
|
|
.engine template engine: mustache (default) or mdm (Markdown-with-Mustache)
|
||
|
|
Accepts bare identifier or string: mustache, "mustache", mdm, "mdm"
|
||
|
|
.json_table_key context table to serialize as JSON response (auto-sets application/json)
|
||
|
|
|
||
|
|
```c
|
||
|
|
render("todos")
|
||
|
|
render(.template = "<h1>Hello {{name}}</h1>")
|
||
|
|
render("not_found", .status = http_not_found)
|
||
|
|
render(.engine = mdm, .template = "# Hello {{name}}\n\nYou have **{{count}}** todos.")
|
||
|
|
render(.json_table_key = "todos")
|
||
|
|
```
|
||
|
|
|
||
|
|
----------------------------------------------------------------
|
||
|
|
headers & cookies
|
||
|
|
----------------------------------------------------------------
|
||
|
|
Set HTTP response headers/cookies. Array of {name, value} pairs; values support {{interpolation}}.
|
||
|
|
|
||
|
|
```c
|
||
|
|
headers({{"X-Request-Id", "{{request_id}}"}, {"Cache-Control", "no-store"}})
|
||
|
|
cookies({{"session", "{{session_id}}"}})
|
||
|
|
```
|
||
|
|
|
||
|
|
----------------------------------------------------------------
|
||
|
|
redirect & reroute
|
||
|
|
----------------------------------------------------------------
|
||
|
|
`redirect()` returns 302 to client (browser navigates). `reroute()` re-enters router server-side, executes another resource's pipeline within same request. Both take resource identifier `name[:arg1:arg2...]`. Args literal or context key, with {{interpolation}}. CONTEXT PERSISTS across reroute (input/error scopes preserved).
|
||
|
|
|
||
|
|
```c
|
||
|
|
redirect("todos") // 302 to /todos
|
||
|
|
redirect("todo:5") // 302 to /todos/5
|
||
|
|
redirect("todo:{{id}}") // 302 to /todos/{{id}}
|
||
|
|
redirect("org_todo:acme:5") // 302 to /orgs/acme/todos/5
|
||
|
|
reroute("todo:{{id}}") // run that pipeline in-process
|
||
|
|
```
|
||
|
|
|
||
|
|
----------------------------------------------------------------
|
||
|
|
nest
|
||
|
|
----------------------------------------------------------------
|
||
|
|
Groups multiple steps into composite step. Apply one .if_context/.unless_context to a group.
|
||
|
|
|
||
|
|
.steps (pos) array of steps run as unit
|
||
|
|
.if_context / .unless_context
|
||
|
|
|
||
|
|
```c
|
||
|
|
nest({query({...}), emit("urgent_todo"), render("urgent")},
|
||
|
|
.if_context = "is_urgent")
|
||
|
|
```
|
||
|
|
|
||
|
|
================================================================
|
||
|
|
TEMPLATE HELPERS
|
||
|
|
================================================================
|
||
|
|
|
||
|
|
Helpers use `{{helper:args}}` syntax. Args positional, colon-separated, literal or context key.
|
||
|
|
|
||
|
|
{{raw:field}} Emit context value WITHOUT HTML-escaping (render() escapes by default).
|
||
|
|
{{precision:field:N}} Format numeric value with N decimal places.
|
||
|
|
{{input:field}} Raw, unvalidated request parameter (form repopulation after error).
|
||
|
|
{{error:field}} Truthy when field has error (use as Mustache section).
|
||
|
|
{{error_message:field}} Human-readable message for field error.
|
||
|
|
{{error_code:field}} HTTP status code associated with field error.
|
||
|
|
{{url:name[:arg1:...]}} Resolve resource identifier to URL.
|
||
|
|
{{asset:filename}} Resolve file in public/ to cache-busted URL.
|
||
|
|
{{csrf:token}} Random hash, sets httponly/secure/samesite cookie, outputs same value inline.
|
||
|
|
{{csrf:input}} Same as csrf:token but as hidden <input> for forms.
|
||
|
|
|
||
|
|
CSRF: verification automatic. MACH checks submitted token (form/query) matches cookie value, rejects mismatches with 403. Nothing beyond emitting the helper required.
|
||
|
|
|
||
|
|
```c
|
||
|
|
render(.template =
|
||
|
|
"<link rel='stylesheet' href='{{asset:styles.css}}'>"
|
||
|
|
"<article>"
|
||
|
|
"{{#post}}<h2>{{title}}</h2>"
|
||
|
|
"<p>Rating: {{precision:score:1}}/5</p>"
|
||
|
|
"<div>{{raw:body_html}}</div>{{/post}}"
|
||
|
|
"<form method='post' action='{{url:comments}}'>"
|
||
|
|
"{{csrf:input}}"
|
||
|
|
"<input name='body' value='{{input:body}}'>"
|
||
|
|
"{{#error:body}}<span>{{error_message:body}}</span>{{/error:body}}"
|
||
|
|
"<button>Comment</button>"
|
||
|
|
"</form>"
|
||
|
|
"<a href='{{url:logout}}?csrf={{csrf:token}}'>Log out</a>"
|
||
|
|
"</article>"
|
||
|
|
)
|
||
|
|
```
|
||
|
|
|
||
|
|
================================================================
|
||
|
|
CONTEXT
|
||
|
|
================================================================
|
||
|
|
|
||
|
|
Pipelines read/write a shared context: scoped key-value store living for duration of request. Every step draws inputs from context, writes outputs back.
|
||
|
|
|
||
|
|
`.context` seeds at root with variables and assets available on every request. Templates and SQL stored here referenced by name in render(), query(), find().
|
||
|
|
|
||
|
|
Use `(asset){#embed "file"}` to bake files into binary at compile time. Docker secrets exposed to container available in context.
|
||
|
|
|
||
|
|
Three scopes:
|
||
|
|
input:xxx raw request parameters
|
||
|
|
error:xxx validation/error data
|
||
|
|
unprefixed app scope (query results, validated inputs, context variables)
|
||
|
|
|
||
|
|
`validate()` bridges input → app scope.
|
||
|
|
|
||
|
|
```c
|
||
|
|
.context = {
|
||
|
|
{"site_name", "MACH App"},
|
||
|
|
{"version", "1.2.0"},
|
||
|
|
{"layout", (asset){#embed "static/layout.mustache.html"}},
|
||
|
|
{"home", (asset){#embed "static/home.mustache.html"}},
|
||
|
|
{"get_todos", (asset){#embed "todos/get_todos.sql"}},
|
||
|
|
{"create_todo", (asset){#embed "todos/create_todo.sql"}}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
================================================================
|
||
|
|
DATABASES
|
||
|
|
================================================================
|
||
|
|
|
||
|
|
Each `.databases` entry defines a data store. Migrations forward-only, index-based: array order, applied once each, append new at end. Seeds idempotent. Both tracked in `mach_meta` table.
|
||
|
|
|
||
|
|
Multi-tenant: `{{interpolation}}` in `.connect`. Connections pooled with LRU eviction.
|
||
|
|
|
||
|
|
.engine database engine constant from a module (sqlite_db, postgres_db, mysql_db, redis_db, duckdb_db)
|
||
|
|
.name identifier referenced by .db in query()/find()
|
||
|
|
.connect engine-specific connection string with {{interpolation}}
|
||
|
|
.migrations array of SQL strings or assets, applied once each in order
|
||
|
|
.seeds array of idempotent statements, safe to re-run on every boot
|
||
|
|
|
||
|
|
ONE DATABASE = ONE DOMAIN, MANY TABLES. A database maps to a domain (todos_db, blog_db), not an entity. Related tables go as additional migrations on the same database. Reach for second database only for genuinely separate domains (audit logs, analytics, third-party cache).
|
||
|
|
|
||
|
|
```c
|
||
|
|
.databases = {{
|
||
|
|
.engine = sqlite_db,
|
||
|
|
.name = "blog_db",
|
||
|
|
.connect = "file:{{user_id}}_blog.db?mode=rwc",
|
||
|
|
.migrations = {
|
||
|
|
"CREATE TABLE blogs (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, content TEXT NOT NULL);",
|
||
|
|
"CREATE TABLE comments (id INTEGER PRIMARY KEY AUTOINCREMENT, blog_id INTEGER NOT NULL REFERENCES blogs(id), body TEXT NOT NULL);"
|
||
|
|
},
|
||
|
|
.seeds = {"INSERT OR IGNORE INTO blogs(id, title, content) VALUES(1, 'Hello', 'First post');"}
|
||
|
|
}}
|
||
|
|
```
|
||
|
|
|
||
|
|
================================================================
|
||
|
|
ERROR & REPAIR PIPELINES
|
||
|
|
================================================================
|
||
|
|
|
||
|
|
When a step fails, execution halts; MACH searches handler bottom-up: resource → module → root.
|
||
|
|
|
||
|
|
ERRORS are TERMINAL: matching pipeline sends response, ends request.
|
||
|
|
REPAIRS are RESUMABLE: fix context, then resume original pipeline at step AFTER failure.
|
||
|
|
If no matching repair: falls through to errors. Unhandled errors surface in TUI console + telemetry.
|
||
|
|
|
||
|
|
Error scope shared across validate() failures and error_set(): `{{error:name}}`, `{{error_code:name}}`, `{{error_message:name}}`. Raw input remains in `input:name` for form re-rendering.
|
||
|
|
|
||
|
|
```c
|
||
|
|
.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 (any integer works; http_* are convenience names; define your own for domain errors, e.g. `#define err_quota_exceeded 723`):
|
||
|
|
http_ok = 200
|
||
|
|
http_created = 201
|
||
|
|
http_redirect = 302
|
||
|
|
http_bad_request = 400
|
||
|
|
http_not_authorized = 401
|
||
|
|
http_not_found = 404
|
||
|
|
http_error = 500
|
||
|
|
|
||
|
|
================================================================
|
||
|
|
EVENT PIPELINES
|
||
|
|
================================================================
|
||
|
|
|
||
|
|
Internal pub/sub for cross-module communication. Publisher doesn't know subscribers; subscribers don't know emitter. Adding a subscriber = new module with `.events` entry, no changes to publisher.
|
||
|
|
|
||
|
|
DURABLE BY DEFAULT: when `.publishes` is defined anywhere, MACH creates `mach_events` database to track delivery. Process crash → undelivered events replay on next boot.
|
||
|
|
|
||
|
|
.publishes outbound event contracts: {.event = name, .with = {context_keys_to_pass}}
|
||
|
|
.events subscriber pipelines keyed by event name
|
||
|
|
|
||
|
|
```c
|
||
|
|
// publisher (todos module)
|
||
|
|
.publishes = {
|
||
|
|
{"todo_created", .with = {"user_id", "title"}}
|
||
|
|
},
|
||
|
|
.resources = {
|
||
|
|
{"todos", "/todos",
|
||
|
|
.post = {
|
||
|
|
validate({"title", .validation = validate_not_empty}),
|
||
|
|
query({"insert_todo", .db = "todos_db"}),
|
||
|
|
emit("todo_created"),
|
||
|
|
redirect("todos")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// subscriber (activity module)
|
||
|
|
.events = {
|
||
|
|
{"todo_created", {
|
||
|
|
query({.db = "activity_db",
|
||
|
|
.query = "insert into activities(kind, user_id, ref) values('created', {{user_id}}, {{title}});"})
|
||
|
|
}}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
================================================================
|
||
|
|
TASK PIPELINES
|
||
|
|
================================================================
|
||
|
|
|
||
|
|
Named pipelines run asynchronously on task reactors. Fire-and-forget: calling pipeline continues immediately. Defined at module or root level. Triggered on demand with `task("name")` or on schedule via `.cron`. Tasks can enqueue more tasks via task().
|
||
|
|
|
||
|
|
DURABLE BY DEFAULT: when `.tasks` defined, MACH creates `mach_tasks` database, checkpoints context after each step. Crash mid-task → resumes at exact step where left off (5 steps into 8-step pipeline restarts at step 6, not step 1).
|
||
|
|
|
||
|
|
.name (pos) task identifier called via task("name")
|
||
|
|
steps (pos) pipeline body (second positional brace block, before designated fields)
|
||
|
|
.accepts context keys to pull from caller into task
|
||
|
|
.cron standard cron schedule (no caller required)
|
||
|
|
|
||
|
|
```c
|
||
|
|
.tasks = {
|
||
|
|
// on-demand: enqueued via task("recount_todos")
|
||
|
|
{"recount_todos", {
|
||
|
|
query({.db = "todos_db",
|
||
|
|
.query = "update users set todo_count = "
|
||
|
|
"(select count(*) from todos where user_id = users.id) "
|
||
|
|
"where id = {{user_id}};"})
|
||
|
|
}, .accepts = {"user_id"}},
|
||
|
|
|
||
|
|
// recurring: runs on schedule, no caller
|
||
|
|
{"daily_digest", {
|
||
|
|
query({.db = "todos_db",
|
||
|
|
.query = "insert into digest_reports(generated_at) values(now());"}),
|
||
|
|
emit("digest_ready")
|
||
|
|
}, .cron = "0 8 * * *"}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
================================================================
|
||
|
|
MODULES & COMPOSITION
|
||
|
|
================================================================
|
||
|
|
|
||
|
|
Module = self-contained system. Can declare its own resources, databases, migrations, context, error/repair handlers, tasks, event subscribers, nested modules.
|
||
|
|
|
||
|
|
Root main.c must define `mach()`. Modules define their own functions with any name, registered in `.modules` by bare function reference.
|
||
|
|
|
||
|
|
Bring module into scope by `#include`ing its `.c` file from main.c, then register it.
|
||
|
|
|
||
|
|
```c
|
||
|
|
// main.c
|
||
|
|
#include <mach.h>
|
||
|
|
#include "blogs/blogs.c"
|
||
|
|
config mach(){ return (config){ .modules = {blogs, sqlite} }; }
|
||
|
|
```
|
||
|
|
|
||
|
|
A module returns config with same shape as root (`.resources`, `.databases`, `.events`, etc.) plus `.name` for identity. Resource fields like `.url`, `.mime`, `.get` are NOT top-level config fields — they belong inside `.resources` entries.
|
||
|
|
|
||
|
|
When root and module both define same name (context variable, database, error handler): ROOT WINS.
|
||
|
|
Modules don't call each other directly; they communicate through pub/sub events.
|
||
|
|
|
||
|
|
Project layout:
|
||
|
|
```
|
||
|
|
todos/ # todos module
|
||
|
|
├── todos.c # config todos() { ... }
|
||
|
|
├── todos.mustache.html
|
||
|
|
├── create_todos_table.sql
|
||
|
|
└── get_todos.sql
|
||
|
|
activity/ # activity module
|
||
|
|
└── activity.c
|
||
|
|
static/ # root-level templates (NOT a module)
|
||
|
|
├── layout.mustache.html
|
||
|
|
└── home.mustache.html
|
||
|
|
public/ # static files served directly
|
||
|
|
└── favicon.png
|
||
|
|
main.c # registers modules
|
||
|
|
```
|
||
|
|
|
||
|
|
Bundled modules (add initializer to .modules):
|
||
|
|
sqlite, postgres, mysql, redis, duckdb, htmx, datastar, tailwind, session_auth
|
||
|
|
|
||
|
|
MODULE-PROVIDED STEPS (session_auth):
|
||
|
|
session() attach current session to context (sets user_id, etc.); no-op when unauth
|
||
|
|
logged_in() guard, raises http_not_authorized when no active session
|
||
|
|
login() use inside POST pipeline to perform login
|
||
|
|
logout() use inside POST pipeline to perform logout
|
||
|
|
signup() use inside POST pipeline to perform signup
|
||
|
|
|
||
|
|
Common pattern: drop into resource's shared `.steps` slot as middleware:
|
||
|
|
```c
|
||
|
|
{"dashboard", "/dashboard", {session(), logged_in()},
|
||
|
|
.get = { render("dashboard") }
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
================================================================
|
||
|
|
STATIC FILES
|
||
|
|
================================================================
|
||
|
|
|
||
|
|
Files in `public/` at project root served directly. Use for images, fonts, pre-built CSS/JS, assets that don't need binary embedding.
|
||
|
|
|
||
|
|
Reference with `{{asset:filename}}` → URL with content-based checksum + immutable cache headers (browsers cache indefinitely, refresh when content changes).
|
||
|
|
|
||
|
|
```html
|
||
|
|
<link rel="icon" href="{{asset:favicon.png}}">
|
||
|
|
<link rel="stylesheet" href="{{asset:styles.css}}">
|
||
|
|
<script src="{{asset:app.js}}"></script>
|
||
|
|
```
|
||
|
|
|
||
|
|
================================================================
|
||
|
|
EXTERNAL DEPENDENCIES
|
||
|
|
================================================================
|
||
|
|
|
||
|
|
MACH expects containerized dev environment. Standard C23 against MACH APIs; no local toolchain required.
|
||
|
|
|
||
|
|
Two ways to bring in third-party C libraries:
|
||
|
|
|
||
|
|
`/vendor` directory: drop headers and libraries (.so, .a); auto-compiler discovers, includes, links them.
|
||
|
|
```
|
||
|
|
/vendor/
|
||
|
|
├── libsodium.h
|
||
|
|
└── libsodium.so
|
||
|
|
```
|
||
|
|
|
||
|
|
Custom Dockerfile: inherit from MACH base image, apt-get install system deps; reference from compose.yml.
|
||
|
|
```dockerfile
|
||
|
|
FROM mach:latest
|
||
|
|
RUN apt-get update && apt-get install -y libsodium-dev
|
||
|
|
```
|
||
|
|
|
||
|
|
Two helpers for bridging foreign memory back to the arena:
|
||
|
|
|
||
|
|
allocate(bytes) buffer from pipeline arena, reclaimed on request completion
|
||
|
|
defer_free(ptr) schedules cleanup for pointers from external libraries (e.g. malloc); runs when arena released
|
||
|
|
|
||
|
|
```c
|
||
|
|
char *buf = allocate(256);
|
||
|
|
char *out = third_party_alloc(256);
|
||
|
|
defer_free(out);
|
||
|
|
```
|
||
|
|
|
||
|
|
================================================================
|
||
|
|
ARCHITECTURE
|
||
|
|
================================================================
|
||
|
|
|
||
|
|
DATA-ORIENTED PIPELINES: mach() runs once at boot. Returned config processed into execution graph with precompiled pipelines, queries, templates. Each request executes matching pipeline as sequence of pre-warmed steps.
|
||
|
|
|
||
|
|
MULTI-REACTOR ARCHITECTURE:
|
||
|
|
Request Reactors handle HTTP traffic; each gets dedicated CPU core + event loop
|
||
|
|
Task Reactors handle background work; each gets dedicated core, monitors task DB, processes cron
|
||
|
|
Shared Thread Pool CPU-bound and blocking I/O work on remaining cores
|
||
|
|
|
||
|
|
When pipeline executes exec() step, work dispatched to shared thread pool, releases reactor; pipeline resumes on original reactor when call returns. task() step adds jobs to task DB picked up by task reactors. Tasks can call task() to enqueue more work.
|
||
|
|
|
||
|
|
Application code does NOT manage threads, mutexes, or locks. Multi-reactor architecture isolates request state to pipeline's context.
|
||
|
|
|
||
|
|
Request/task/cpu ratio settable in compose.yml.
|
||
|
|
|
||
|
|
MEMORY SAFETY: Each reactor maintains pool of arena allocators. Request → pipeline gets arena, all allocations from that arena. Pipeline complete → arena cleared, returned to pool. App code never calls malloc/free. No leaks, double-frees, use-after-free.
|
||
|
|
|
||
|
|
All framework data structures (tables, records, strings) enforce bounds checking. OOB reads + missing context values return nullptr instead of faulting. Pipelines exceeding memory limit (default 5MB, configurable) abort with 500, mitigates OOM DoS.
|
||
|
|
|
||
|
|
SQL INJECTION PREVENTION: Interpolations like `{{user_id}}` inside query()/find() bound as parameters in prepared statements.
|
||
|
|
|
||
|
|
XSS PREVENTION: render() auto-escapes context values in Mustache templates. Raw HTML requires explicit `{{raw:field}}` opt-in.
|
||
|
|
|
||
|
|
STRING INTERPOLATION: Any string (SQL, URLs, connection strings, templates) can reference context with `{{context_key}}`. Same scopes as Context section apply everywhere.
|
||
|
|
|
||
|
|
================================================================
|
||
|
|
IMPERATIVE API (available from exec blocks/functions)
|
||
|
|
================================================================
|
||
|
|
|
||
|
|
Context:
|
||
|
|
void* get(string name)
|
||
|
|
void set(string name, void const *value)
|
||
|
|
bool has(string name)
|
||
|
|
string format(string format_string)
|
||
|
|
|
||
|
|
Memory:
|
||
|
|
void* allocate(int bytes)
|
||
|
|
void defer_free(void const *ptr)
|
||
|
|
|
||
|
|
Errors:
|
||
|
|
void error_set(string name, error err)
|
||
|
|
error error_get(string name)
|
||
|
|
bool error_has(string name)
|
||
|
|
|
||
|
|
Tables:
|
||
|
|
table table_new()
|
||
|
|
int table_count(table)
|
||
|
|
record table_get(table, int index)
|
||
|
|
void table_add(table, record)
|
||
|
|
void table_remove(table, record)
|
||
|
|
void table_remove_at(table, int index)
|
||
|
|
|
||
|
|
Records:
|
||
|
|
record record_new()
|
||
|
|
void record_set(record, string name, string value)
|
||
|
|
string record_get(record, string name)
|
||
|
|
void record_remove(record, string name)
|
||
|
|
|
||
|
|
Types:
|
||
|
|
string = const char*
|
||
|
|
asset = const char[]
|
||
|
|
table = struct table_s const*
|
||
|
|
record = struct record_s const*
|
||
|
|
config = struct config_i const
|
||
|
|
|
||
|
|
================================================================
|
||
|
|
CONSTANTS REFERENCE
|
||
|
|
================================================================
|
||
|
|
|
||
|
|
MIME types (for .mime):
|
||
|
|
mime_html = "text/html"
|
||
|
|
mime_txt = "text/plain"
|
||
|
|
mime_sse = "text/event-stream"
|
||
|
|
mime_json = "application/json"
|
||
|
|
mime_js = "application/javascript"
|
||
|
|
|
||
|
|
HTTP methods (for fetch .method):
|
||
|
|
http_get, http_post, http_put, http_patch, http_delete, http_sse_method
|
||
|
|
|
||
|
|
HTTP statuses (for render .status, error .error_code):
|
||
|
|
http_ok = 200, http_created = 201, http_redirect = 302
|
||
|
|
http_bad_request = 400, http_not_authorized = 401, http_not_found = 404
|
||
|
|
http_error = 500
|
||
|
|
|
||
|
|
Database engines: sqlite_db, postgres_db, mysql_db, redis_db, duckdb_db
|
||
|
|
|
||
|
|
Render engines: mustache (default), mdm (Markdown-with-Mustache); accepts bare identifier or string
|
||
|
|
|
||
|
|
Datastar modes (ds_sse .mode):
|
||
|
|
mode_outer, mode_inner, mode_replace, mode_prepend, mode_append, mode_before, mode_after, mode_remove
|
||
|
|
|
||
|
|
================================================================
|
||
|
|
KEY PATTERNS
|
||
|
|
================================================================
|
||
|
|
|
||
|
|
PATTERN: POST → validate → insert → redirect (POST-redirect-GET)
|
||
|
|
```c
|
||
|
|
.post = {
|
||
|
|
validate({"title", .validation = validate_not_empty, .message = "title cannot be empty"}),
|
||
|
|
query({.db = "db", .query = "insert into todos(title) values({{title}});"}),
|
||
|
|
redirect("todos")
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
PATTERN: error handler that re-renders form with input/error preserved
|
||
|
|
Use reroute() to re-enter the GET pipeline. input: and error: scopes persist across reroute.
|
||
|
|
```c
|
||
|
|
.get = { query(...), render(.template = "...{{input:title}}...{{#error:title}}{{error_message:title}}{{/error:title}}...") },
|
||
|
|
.post = { validate(...), query(...), redirect("todos") },
|
||
|
|
.errors = { {http_bad_request, { reroute("todos") }} }
|
||
|
|
```
|
||
|
|
|
||
|
|
PATTERN: nested data via concurrent query + join + render
|
||
|
|
```c
|
||
|
|
.get = {
|
||
|
|
query(
|
||
|
|
{.set_key = "blog", .db = "db", .query = "select id, title from blogs where id = {{id}};"},
|
||
|
|
{.set_key = "comments", .db = "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}}")
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
PATTERN: task triggered both ways (cron + on-demand)
|
||
|
|
```c
|
||
|
|
.post = {
|
||
|
|
validate(...), query(...),
|
||
|
|
task("record_daily_stats"), // on-demand from request pipeline
|
||
|
|
redirect("todos")
|
||
|
|
},
|
||
|
|
.tasks = {
|
||
|
|
{"record_daily_stats", {
|
||
|
|
query({.db = "db", .query = "insert into daily_stats(todo_count) select count(*) from todos;"})
|
||
|
|
}, .cron = "0 0 * * *"} // also nightly
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
PATTERN: pub/sub between modules
|
||
|
|
Publisher declares `.publishes = {{"event_name", .with = {keys_to_pass}}}`, calls `emit("event_name")` in pipeline.
|
||
|
|
Subscriber (separate module) declares matching `.events = {{"event_name", {pipeline}}}`. Neither references the other.
|
||
|
|
|
||
|
|
PATTERN: external assets via #embed
|
||
|
|
```c
|
||
|
|
.context = {
|
||
|
|
{"todos_list", (asset){#embed "todos_list.mustache.html"}},
|
||
|
|
{"get_todos", (asset){#embed "get_todos.sql"}}
|
||
|
|
},
|
||
|
|
// then reference by name:
|
||
|
|
query({"get_todos", .set_key = "todos", .db = "todos_db"}),
|
||
|
|
render("todos_list")
|
||
|
|
|
||
|
|
// migrations accept assets directly:
|
||
|
|
.migrations = { (asset){#embed "create_todos_table.sql"} }
|
||
|
|
```
|
||
|
|
|
||
|
|
PATTERN: external HTTP data
|
||
|
|
```c
|
||
|
|
.get = {
|
||
|
|
fetch("https://api.example.com/random", .set_key = "data"),
|
||
|
|
render(.template = "{{#data}}<p>{{field}}</p>{{/data}}")
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
PATTERN: middleware via shared resource steps
|
||
|
|
The unnamed positional brace after the URL holds shared `.steps` that run before every verb pipeline.
|
||
|
|
```c
|
||
|
|
{"dashboard", "/dashboard", {session(), logged_in()},
|
||
|
|
.get = { ... },
|
||
|
|
.post = { ... }
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
================================================================
|
||
|
|
TOOLING (for context only — not API)
|
||
|
|
================================================================
|
||
|
|
|
||
|
|
Built-in TUI editor with HMR, LSP, integrated source control, topology-aware AI assistant. AI uses `app_info` command to inspect topology (routes, pipelines, schemas, event contracts, module boundaries).
|
||
|
|
|
||
|
|
CLI commands:
|
||
|
|
app_info [resources|pipelines|events|databases] inspect topology
|
||
|
|
unit_tests criterion-based unit tests
|
||
|
|
e2e_tests playwright browser tests
|
||
|
|
app_debug interactive debugger in TUI
|
||
|
|
app_build production Docker image
|
||
|
|
|
||
|
|
Deployment: standard Docker container. Does NOT terminate TLS — place behind reverse proxy/LB (Nginx, Caddy, AWS ALB).
|
||
|
|
|
||
|
|
Observability: every pipeline step emits OpenTelemetry spans. Logs/traces/errors/auto-profiling on telemetry server :4000. No manual instrumentation.
|
||
|
|
|
||
|
|
Built with: C23, Docker, libmicrohttpd, libuv, Mustach, Jansson, curl, Fossil, Fresh, clangd, LLDB, Criterion, Playwright, SigNoz + OpenTelemetry, Open Code.
|
||
|
|
|
||
|
|
License: LGPL.
|