MaCH repo
This commit is contained in:
876
llms-full.txt
Normal file
876
llms-full.txt
Normal file
@@ -0,0 +1,876 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user