Files
mach_examples/README.md
2026-03-30 19:19:23 -05:00

54 KiB

MACH

Why MACH

MACH (Modern Asynchronous C Hypermedia) is a platform for high-level, asynchronous, reactive, declarative web development in C.

  • Zero Boilerplate: No build scripts, package managers, or ORMs to configure. It compiles and hot-reloads automatically.
  • Safe by Default: Automatically handles memory, concurrency, and async I/O. No need to call malloc or free. No threads, mutexes, or locks. The runtime handles this for you. All database queries use prepared statements. Tasks and events are durable by default, with automatic recovery on restart. All code automatically emits OpenTelemetry spans, logs, and errors.
  • Batteries Included: Built-in SSE support and modules for Datastar, HTMX, Tailwind, SQLite, Postgres, MySQL, Redis/Valkey, DuckDB, and auth. Native multi-tenant database support.

Table of Contents


Quick Start

Everything runs in Docker, no other local dependencies needed.

mkdir myapp && cd myapp
wget https://docker.nightshadecoder.dev/mach/compose.yml

# Dev server on :3000, telemetry on :4000
# Includes file watching, auto compilation, hot code reloading, HMR
docker compose up

Attach to the TUI with docker compose attach mach

The containerized environment includes a TUI editor with AI, LSP, and console. You can also use your own editor. MACH watches the project directory.

Create main.c with the example below to see hot-reloading work.

At A Glance

#include <mach.h>
#include <sqlite.h>

// returns the app configuration, runs once at boot
config mach(){
  return (config) {
    // {{}} defines an array
    .resources = {{
      {"home", "/",
        // pipeline: query the db, then render the result
        .get = {{
          // single query uses double braces (struct > query)
          query((d){
            .set = "greeting",
            .db = "hello_db",
              "select name "
              "from greetings "
              "limit 1;"
          }),
          // mustache template using "greeting" from context
          render((r){
            "<html>"
              "<body>"
                "{{#greeting}}"
                  "<p>Hello {{name}}</p>"
                "{{/greeting}}"
              "</body>"
            "</html>"
          })
        }}
      }
    }},

    .databases = {{
      .engine = sqlite_db,
      .name = "hello_db",
      .connect = "file:hello.db?mode=rwc",
      .migrations = {
        "CREATE TABLE greetings ("
          "id INTEGER PRIMARY KEY AUTOINCREMENT,"
          "name TEXT NOT NULL"
        ");"
      },
      .seeds = {
        "INSERT INTO greetings(name)"
        "VALUES('World');"
      }
    }},

    .modules = {sqlite}
  };
}

Philosophy

Applications are data transformations: input from sources, business logic transforms data, output data to the client. MACH keeps each piece standard. Data comes from raw SQL, HTTP fetch, and JSON instead of ORMs. Business logic is plain C functions. Output is standard HTML, CSS, JS via Mustache templates.

These pieces compose inside pipelines: ordered lists of steps that transform a request into a response.

Tooling is standard: lldb for debugging, Playwright and Criterion for testing, OpenTelemetry for observability. All built in, nothing to configure.

IDEAL Philosophy

MACH's design rejects object-oriented complexity in favor of data-oriented composition. Where SOLID guides OOP abstraction, IDEAL guides data and behavior composition, drawing from A Philosophy of Software Design (deep interfaces, complexity reduction), CUPID (domain-centered, composable properties), and Data-Oriented Design (data transforms over object hierarchies).

  • Interfaces are Deep: Minimize API surface, maximize capability per call. query() handles SQL execution, prepared statements, concurrency, and error triggering in one step.
  • Domain Centered: Each module owns exactly one domain. A todos module defines everything related to todos and nothing else.
  • Encapsulated: Hide implementation, expose data. Pipeline context is always readable and inspectable; module internals are not.
  • Autonomous: Modules are fully self-contained. Each carries its own schemas, migrations, seeds, and event contracts. Modules cannot call each other directly (enforced by compiler). They interact only through the hypermedia layer or events (pub/sub messaging).
  • Local: Co-locate all related code. A module's SQL, templates, and handlers live together.

Everything is a String

The web is text: HTTP, HTML, JSON, SQL. MACH leans into this. The pipeline context stores and passes all data as safe, arena-backed strings, organized into scopes: input: for raw request parameters, error: for validation/error data, and app scope (no prefix) for validated and application data. If your application needs to parse a value into a specific C type, use an invoke() step to isolate that conversion in your business logic.


How It Works

Data-Oriented Pipelines

The mach() function runs once at boot. Its returned config is processed into an optimized execution graph with precompiled pipelines, queries, and templates. Each incoming request then executes its matching pipeline as a sequence of pre-warmed steps.

Boot-Time Compilation

Request Pipeline Flow

Multi-Reactor Architecture

MACH runs two types of reactors backed by a shared queue backed thread pool. Request reactors handle HTTP traffic: each gets its own dedicated CPU core and event loop. Task reactors manage background work: each gets its own dedicated core, monitors the task database for pending and incomplete tasks, and handles .cron schedule processing. The shared thread pool uses the remaining cores. The request/task/cpu ratio can be specified in compose.yml to control how many cores are allocated to request reactors, task reactors, and the thread pool. When any reactor's pipeline executes an invoke() step, the work is dispatched to the shared thread pool, immediately releasing the reactor to handle other work. When the invocation completes, the pipeline resumes on the original reactor. The task() step adds jobs to the task database, where they are picked up by task reactors. Tasks can call task() themselves to enqueue additional work.

Multi-Reactor Architecture

Memory Management

Each reactor maintains a pool of arena allocators. When a request arrives, the pipeline is assigned an arena from the pool. All allocations during that pipeline draw from the arena. When the pipeline completes, the arena is instantly cleared and returned to the pool. No per-object cleanup, no garbage collection, no manual memory management.

Safe by Default

MACH prevents common C and web vulnerabilities at the framework level.

Memory Safety

Application code never calls malloc or free. Each request pipeline gets a dedicated arena allocator, instantly reclaimed on completion. No leaks, double-frees, or use-after-free. All framework data structures (tables, records, strings) enforce bounds checking; out-of-bounds reads and missing context values return nullptr instead of faulting. Pipelines that exceed their memory limit (default 5MB, configurable via compose.yml) safely abort with a 500 error, preventing OOM denial-of-service.

SQL Injection Prevention

Interpolations like {{user_id}} inside query() or find() are natively bound as parameterized inputs to prepared statements. SQL injection is structurally impossible through standard pipeline execution.

XSS Prevention

The render() step auto-escapes all context values in Mustache templates. Malicious input is rendered as text. Raw HTML requires an explicit opt-in via the {{raw:field}} helper.

Thread Safety

Application code never manages threads, mutexes, or locks. The multi-reactor architecture isolates request state to the pipeline's context. invoke() dispatches work to a shared thread pool transparently; the reactor is released and resumes automatically on completion.

String Interpolation

All strings in MACH are interpolatable. Anywhere you see a string field (SQL queries, .connect, .channel, .url, .data, format()) you can use {{context_key}} to reference values from the pipeline context. Scoped values are accessed with their prefix: {{input:field}} for raw request parameters, {{error:field}}, {{error_code:field}}, {{error_message:field}} for errors. App-scope values use no prefix: {{field}}. In query() and find() steps, interpolated values are bound as prepared statement parameters, not string-concatenated.


Tooling

Introspection

app_info                    # view topology
app_info resources          # list all resources
app_info pipelines          # inspect pipelines
app_info events             # view pub/sub map
app_info databases          # inspect schemas

Testing

Built-in test runners for unit and end-to-end testing. No external framework setup required.

unit_tests                  # fast, criterion-based tests
e2e_tests                   # playwright-powered browser tests

Debugging

app_debug                   # interactive debugger in the TUI

Built on lldb with pipeline-aware commands. Halt on individual pipeline steps, step through execution, and inspect the full pipeline context including nested tables and records.

Deployment

app_build                   # outputs slim, optimized production Docker image

MACH deploys as a standard Docker container. It does not terminate TLS; production deployments should place MACH behind a reverse proxy or load balancer (e.g., Nginx, Caddy, AWS ALB) to handle HTTPS.

Observability

Every pipeline step automatically emits OpenTelemetry spans. Logs, traces, errors, and auto-profiling are visualized on the telemetry server at port 4000. No manual instrumentation required.

Development Environment

Built-in TUI editor with HMR, LSP support, and a topology-aware AI assistant.


Building with MACH

Notation

Each pipeline step takes a compound literal of its step config type, passed as a positional argument.

MACH's declarative syntax uses C's designated initializer braces at different depths:

  • Single {}: a single value or struct.
  • Double {{}}: an array of items: pipelines, URLs, context entries, concurrent queries in queries(), etc.
  • (asset){ #embed "file" }: the asset type bakes a file directly into the compiled binary using standard C23 #embed. Used for templates, SQL files, and other static content.
// {{}} wraps an array of pipeline steps
.get = {{
  // single query: just a struct literal
  query((d){
    .set = "todos",
    .db = "todos_db",
    "select * from todos;"
  }),
  // multiple queries run concurrently via queries
  queries((da){{
    {
      .set = "projects",
      .db = "projects_db",
      "select * from projects;"
    },
    {
      .set = "tags",
      .db = "todos_db",
      "select * from tags;"
    }
  }}),
  // {} wraps a single value
  render((r){(asset){
    #embed "todos.mustache.html"
  }})
}}

Modules & Composition

Every MACH app and module returns a config struct. The root main.c includes modules via #include and registers them in .modules. Configurations merge top-down: anything named resolves to the first registration, so root configs can override module defaults.

App Composition Tree

Each module is a directory containing a single .c config file alongside the SQL and template files it references via #embed:

├── activity/                        # activity module
│   ├── activity.c                   # config function
│   ├── activity.mustache.html       # templates
│   ├── create_activity_table.sql    # migrations
│   ├── get_activities.sql           # queries
│   └── insert_activity.sql
├── todos/                           # todos module
│   ├── todos.c
│   ├── todos.mustache.html
│   ├── todo.mustache.html
│   ├── create_todos_table.sql
│   ├── create_todo.sql
│   ├── get_todos.sql
│   ├── update_todo.sql
│   └── delete_todo.sql
├── static/                          # root-level templates (not a module)
│   ├── layout.mustache.html
│   ├── home.mustache.html
│   ├── 404.mustache.html
│   └── 5xx.mustache.html
├── public/                          # served directly
│   └── favicon.png
└── main.c                           # app entry point

The root config defines shared concerns (home page, layout, global middleware, error pages) and composes everything via .modules:

#include "todos/todos.c"
#include "activity/activity.c"

config mach(){
  return (config) {
    .resources = {{
      {"home", "/", .get = {{ /* ... */ }} }},
      .before = {session()},
      .context = {{"layout", (asset){ #embed "static/layout.mustache.html" }}},
      .errors = { /* 404, 5xx handlers */ }
    },
    .modules = {todos, activity, session_auth}
  };
}

Each module owns its resources, databases, and migrations. Modules cannot call each other directly; they communicate through pub/sub events.

External Dependencies

MACH enforces a containerized development environment. You write standard C23 using MACH APIs, no local toolchain required. For third-party C libraries:

  1. The /vendor Directory: Drop headers and libraries (.so, .a) into /vendor in your project root. The auto-compiler will discover, include, and link them.
  2. Custom Dockerfile: For system-level dependencies (e.g., libsodium), create a Dockerfile that inherits from the MACH base image, run apt-get install, and update your compose.yml to build from it.

Memory Management with External Libraries

Third-party memory must be bridged to the arena to maintain safety:

  • allocate(bytes): Provides a buffer from the pipeline's arena, reclaimed automatically on request completion.
  • defer_free(ptr): Schedules cleanup for pointers allocated by external libraries (e.g., via malloc). Executed when the arena is released.

Context

The .context block injects variables and assets into the pipeline context. Values like site names or HTML layouts become available to templates without passing them on every request. Use #embed to bake files into the binary via the asset type. Sensitive data and API keys are handled via standard Docker secrets. Secrets exposed to the container are automatically loaded and available for string interpolation in the pipeline context.

Context Scopes

The pipeline context uses scoped namespaces to separate concerns:

  • input:xxx: Raw request parameters (query string, form body, URL parameters). All incoming request data lands in the input scope. Access in templates with {{input:title}}, {{input:email}}, etc.
  • error:xxx: Validation and error data. Access in templates with {{error:name}}, {{error_code:name}}, {{error_message:name}}.
  • xxx (app scope): Application data: query results, context variables, and validated inputs. No prefix.

The input and error scopes mirror each other: raw input arrives in input:xxx, and if validation fails, the error lands in error:xxx. The validate() step bridges input to app scope: when validation passes, .name promotes the value from input:name to name in app scope. This means downstream steps like query(), render(), and invoke() always work with validated app-scope values, never raw input.

Context Scoping

config mach() {
  return (config) {
    .context = {
      {"base_layout", (asset){
        #embed "layout.mustache.html"
      }}
    }
  };
}

Databases

Each module owns its schema and migrations. When .migrations or .seeds are provided, MACH tracks them in a mach_meta table. Migrations are forward-only and index-based: they run in array order, each applied once, with new migrations appended to the end. Seeds are idempotent and safe to re-run.

All engines follow the same configuration pattern. Set .engine to the appropriate constant: sqlite_db, postgres_db, mysql_db, redis_db (Redis/Valkey), or duckdb_db.

Multi-tenant databases use interpolation in the .connect string. The interpolated value can be a user, organization, or any other scope. Connections are pooled with LRU eviction. Active tenants stay warm, idle connections get reclaimed.

Database Multi-Tenancy

config todos() {
  return (config) {
    .databases = {{
      .engine = sqlite_db,
      .name = "todos_db",
      .connect = "file:{{tenant_id}}_todo.db?mode=rwc",
      .migrations = {(asset){
        #embed "create_todos_table.sql"
      }},
      .seeds = {(asset){
        #embed "seed_todos_table.sql"
      }}
    }}
  };
}

Static Files

Create a public directory in your project root. Files placed there are served directly. Use it for images, fonts, pre-built CSS/JS, and other assets that don't need compilation into the binary.

Reference static files in templates with the {{asset:filename}} helper. It resolves to the file's URL path with a content-based checksum appended for cache-busting. The response is served with immutable, cache-forever headers, so browsers cache aggressively but always fetch the new version when the file changes.

<head>
  <link rel="icon" href="{{asset:favicon.png}}">
  <link rel="stylesheet" href="{{asset:styles.css}}">
</head>
<body>
  <img src="{{asset:logo.png}}">
  <script src="{{asset:app.js}}"></script>
</body>

Middleware Pipelines

Middleware pipelines (.before and .after) execute at the boundaries of the request lifecycle, chaining top-down across all scopes. Define global middleware at the root config, or scope it to specific resources or events.

Middleware Scoping

#include <mach.h>
#include <session_auth.h>

config mach() {
  return (config) {
    .modules = { session_auth },

    // Execute before every resource pipeline
    .before = { session() },

    // Execute after every resource pipeline
    .after = { invoke((i){log_request}) }
  };
}

Resource Pipelines

MACH is resource-based, not route-based. Each entry in .resources defines a named resource at a URL with its supported HTTP verbs (.get, .post, .delete, .sse, etc.) and localized middleware. The first positional field is the resource .name, used by redirect(), reroute(), and {{url:name}} to reference the resource without hard-coding paths.

Clients select the verb through the request method, or by passing http_method as a query parameter, hidden form field, or any other mechanism that places it in context. For example, GET /todos hits .get, POST /todos hits .post, and /todos?http_method=sse connects to the SSE stream.

The router handles path specificity automatically. Exact matches (e.g., /todos/active) are always prioritized before parameterized matches (e.g., /todos/:id), regardless of definition order. File uploads are available in context like any other request parameter.

config todos() {
  return (config) {
    .resources = {{
      { "todos", "/todos",
        .post = {{
          validate((v){
            .name = "title",
            .validation = "^\\S{1,16}$",
            .message = "1-16 chars, no spaces"
          }),
          query((d){
            .db = "todos_db",
            (asset){
              #embed "create_todo.sql"
            }
          }),
          emit((e){"todo_created"}),
          redirect((u){"todos"})
        }},

        .before = { logged_in() }
      }}
    }
  };
}

Event Pipelines

Internal pub/sub for cross-module communication. The publisher doesn't know who listens; the subscriber doesn't know who emits. Adding a new subscriber is a new module with an .events entry. No changes to the publisher.

Events are durable by default. When .publishes is defined anywhere in the config, MACH automatically creates a mach_events database to track event delivery. Every emitted event is recorded and its delivery to each subscriber is tracked. If the process crashes, undelivered events are automatically replayed on the next boot. Event lifecycle events emit OpenTelemetry spans alongside request pipeline telemetry.

A module declares outbound events in .publishes. Each entry accepts .event (positional) and .with (context keys to pass to subscribers). Subscribers define .events pipelines. Each entry accepts .event (positional) and .steps (positional). The emit() step triggers the event from a pipeline.

Event Pub/Sub

// todos.c: publishes "todo_created"
config todos(){
  return (config) {
    .name = "todos",
    .publishes = {
      {"todo_created", .with = {"user_id", "title"}}
    },
    // ...
  };
}

// activity.c: subscribes to "todo_created"
config activity(){
  return (config) {
    .name = "activity",
    .events = {
      {"todo_created", {
        query((d){ .db = "activity_db", "insert into activity ..." })
      }}
    },
    // ...
  };
}

Error and Repair Pipelines

When a pipeline step fails, execution halts. Resolved bottom-up: verb, resource, module, root, stopping at the first match. Unhandled errors surface in the TUI console and telemetry.

.errors are terminal. The matching error pipeline executes, sends the response to the client, and ends the request. Each entry accepts .error_code (positional) and .steps (positional).

.repairs are resumable. The repair pipeline fixes the context, then resumes the original pipeline at the step following the failure. Same entry format as .errors. If no matching repair is found, resolution falls through to .errors.

Error Resolution

config mach(){
  return (config) {
    .errors = {
      {http_not_found, {
        render((r){ (asset){ #embed "404.html" } })
      }}
    },

    .repairs = {
      {http_not_authorized, {
        invoke((i){refresh_session_token})
      }}
    }
  };
}

Task Pipelines

Tasks are named pipelines that run asynchronously on task reactors. Fire-and-forget: the calling pipeline continues immediately without waiting. Resolved bottom-up: verb, resource, module, root.

Tasks are durable by default. When .tasks is defined anywhere in the config, MACH automatically creates a mach_tasks database to track task state. Every task invocation is recorded with its status, context, and progress. If the process crashes, incomplete tasks are automatically recovered and resumed. Task reactors monitor the task database for any incomplete or pending work. Task lifecycle events emit OpenTelemetry spans alongside request pipeline telemetry.

Each entry accepts .name (positional), .accepts (context keys to pull from the caller's context into the task's context), .cron (schedule interval), and .steps (positional). Tasks with .cron are processed by task reactors at the specified interval. Any task can also be triggered from pipelines via the task() step, and tasks can call task() themselves to enqueue additional work.

config todos(){
  return (config) {
    .name = "todos",
    .tasks = {
      {"recount_todos", .accepts = {"user_id"}, {{
        query((d){ .db = "todos_db", "update users set todo_count = ..." })
      }}},

      {"daily_digest", .cron = "0 8 * * *", {{
        query((d){ .db = "todos_db", "insert into digest_reports ..." })
      }}}
    }
  };
}

Pipeline Steps

Steps are the units of work in a pipeline. Each step receives the current request context, acts on it, and passes control to the next step.

validate

validate() validates a request parameter (query string, form body, or URL parameter) from the input scope. On success, the value is promoted from input:name to name in app scope, making it available to downstream steps like query(), render(), and invoke(). On failure, an error is stored in error:name and a 400 Bad Request is triggered, resolved through the nearest error/repair pipeline.

Validate a required parameter with a regex pattern:

.post = {{
  validate((v){
    .name = "title",
    .validation = "^\\S{1,16}$",
    .message = "must be 1-16 characters, no spaces"
  }),
  // input:title validated and promoted to app scope as "title"
  // pipeline continues with {{title}} available
}}

Mark a parameter as optional with .optional so it won't trigger an error when absent:

.get = {{
  validate((v){
    .name = "filter",
    .optional = true,
    .validation = "^(active|done)$",
    .message = "must be 'active' or 'done'"
  }),
  // filter may or may not be in context
}}

Use .fallback to inject a default value when absent. The parameter is always in context after validation:

.get = {{
  validate((v){
    .name = "page",
    .fallback = "1",
    .validation = "^\\d{1,4}$",
    .message = "must be a number"
  }),
  // page is always in context: either the request value or "1"
}}

URL parameters like :id in /todos/:id are also validated with validate():

{"todo", "/todos/:id",
  .before = {
    validate((v){
      .name = "id",
      .validation = "^\\d{1,10}$",
      .message = "must be a number"
    })
  }
}

When validation fails, the error is stored in context under error:name, accessible in templates via {{error:name}}, {{error_code:name}}, and {{error_message:name}}. The raw input value remains available in input:name for re-rendering the form. Validation failure halts the pipeline and triggers the error pipeline. Share a template via .context to re-render the form with errors:

{"todos", "/todos",
  .get = {{
    render((r){.asset = "form"})
  }},
  .post = {{
    validate((v){
      .name = "email",
      .validation = validate_email,
      .message = "must be a valid email"
    }),
    validate((v){
      .name = "title",
      .validation = validate_not_empty,
      .message = "cannot be empty"
    }),
    query((d){
      .db = "todos_db",
      (asset){ #embed "create_todo.sql" }
    }),
    redirect((u){"todos"})
  }},

  .errors = {
    {http_bad_request, {
      render((r){.asset = "form"})
    }}
  },

  .context = {{
    {"form", (asset){
      #embed "todos_form.mustache.html"
    }}
  }}
}
<input name="email" value="{{input:email}}">
{{#error:email}}
  <span>{{error_message:email}}</span>
{{/error:email}}
<input name="title" value="{{input:title}}">
{{#error:title}}
  <span>{{error_message:title}}</span>
{{/error:title}}

find & query

find() and query() each execute a single database query. Both specify .db for the database name, with the SQL string as a positional field. Use .set to store results in context. All queries use prepared statements. Interpolations like {{user_id}} are bound as parameters, not concatenated.

The difference: find() triggers a 404 Not Found if no rows are returned; query() does not.

Use .asset to reference a SQL query stored in .context by name, instead of inlining or embedding.

Fetch a collection:

.get = {{
  query((d){
    .set = "todos",
    .db = "todos_db",
    (asset){
      #embed "get_todos.sql"
    }
  }),
  render((r){(asset){
    #embed "todos.mustache.html"
  }})
}}

Fetch a single record, auto-triggering 404 if missing:

.get = {{
  find((d){
    .set = "todo",
    .db = "todos_db",
    (asset){
      #embed "get_todo.sql"
    }
  }),
  render((r){(asset){
    #embed "todo.mustache.html"
  }})
}}

Execute a write without storing results:

.delete = {{
  query((d){
    .db = "todos_db",
    (asset){
      #embed "delete_todo.sql"
    }
  }),
  redirect((u){"todos"})
}}

queries

queries() executes multiple database queries concurrently. It takes a da config wrapping an array of d query configs. Each query runs independently on the same reactor. All results are available in context after the step completes. If one query fails, the remaining queries still complete and their results remain in context, so error/repair handlers have data available for compensating logic if they resume the pipeline.

.get = {{
  queries((da){{
    {
      .set = "todos",
      .db = "todos_db",
      "select * from todos where user_id = {{user_id}};"
    },
    {
      .set = "count",
      .db = "todos_db",
      "select count(*) as total from todos where user_id = {{user_id}};"
    }
  }}),
  render((r){(asset){
    #embed "todos.mustache.html"
  }})
}}

For transactions, use standard SQL transaction statements directly in your queries (BEGIN, COMMIT, ROLLBACK).

join

join() nests records from one context table into each matching record of another, like a SQL JOIN but in-memory across context tables. Useful when data comes from separate databases or queries and needs to be combined for rendering.

.get = {{
  queries((da){{
    {
      .set = "projects",
      .db = "projects_db",
      "select id, name from projects where user_id = {{user_id}};"
    },
    {
      .set = "todos",
      .db = "todos_db",
      "select id, title, project_id from todos where user_id = {{user_id}};"
    }
  }}),
  join((j){
    .target_table = "projects",
    .target_field = "id",
    .nested_table = "todos",
    .nested_field = "project_id",
    .target_join_field = "todos"
  }),
  render((r){(asset){
    #embed "projects.mustache.html"
  }})
}}

After the join() step, each project record contains a todos field with its matching todo records:

{{#projects}}
  <h2>{{name}}</h2>
  <ul>
    {{#todos}}
      <li>{{title}}</li>
    {{/todos}}
  </ul>
{{/projects}}

fetch

fetch() makes an HTTP request and stores the response in context. JSON responses are automatically parsed into tables and records (with nested tables for nested JSON). Text responses are stored as a plain string.

Fetch data from an external API:

.get = {{
  fetch((f){
    .set = "weather",
    .url = "https://api.weather.dev/forecast?city={{city}}",
  }),
  render((r){.asset = "forecast"})
}}

POST a JSON body built from context tables:

.post = {{
  queries((da){{
    {
      .set = "order",
      .db = "orders_db",
      "select id, total from orders where id = {{order_id}};"
    },
    {
      .set = "items",
      .db = "orders_db",
      "select name, qty, price from order_items where order_id = {{order_id}};"
    }
  }}),
  join((j){
    .target_table = "order",
    .target_field = "id",
    .nested_table = "items",
    .nested_field = "order_id",
    .target_join_field = "items"
  }),
  fetch((f){
    .set = "receipt",
    .url = "https://api.payments.dev/charge",
    .method = http_post,
    .headers = {
      {"Authorization", "Bearer {{api_key}}"}
    },
    .json = "order"
  }),
  render((r){.asset = "receipt"})
}}

invoke

invoke() calls a C function with access to the full pipeline context via the Imperative API. It dispatches execution to the shared thread pool, immediately releasing the reactor to handle other work. When the function completes, the pipeline resumes on the original reactor. This makes invoke() safe for blocking I/O and CPU-heavy work without stalling any reactor.

.get = {{
  query((d){
    .set = "challengers",
    .db = "pokemon_db",
      "select id, name, sprite "
      "from pokemons "
      "order by random() "
      "limit 2;"
  }),
  invoke((i){assign_opponents}),
  render((r){.asset = "home"})
}}
void assign_opponents() {
  auto const t = get("challengers");
  auto const p0 = table_get(t, 0);
  auto const p1 = table_get(t, 1);
  record_set(p0, "opponent_id", record_get(p1, "id"));
  record_set(p1, "opponent_id", record_get(p0, "id"));
}

All values set through the Imperative API become part of the pipeline context.

To trigger an error/repair pipeline from inside an invoke(), use error_set():

void validate_ownership() {
  if (!has("is_owner"))
    error_set("auth", (error){
      .code = http_not_authorized,
      .message = "not the owner"
    });
}

emit

emit() triggers an internal pub/sub event. Other modules subscribe in their .events array and react independently, with no direct dependency on the emitter.

.post = {{
  validate((v){
    .name = "title",
    .validation = "^\\S{1,16}$",
    .message = "1-16 characters"
  }),
  query((d){
    .set = "todo",
    .db = "todos_db",
    (asset){
      #embed "create_todo.sql"
    }
  }),
  emit((e){"todo_created"}),
  redirect((u){"todos"})
}}

task

task() adds a named job to the task database and continues the pipeline immediately. Fire-and-forget: the calling pipeline does not wait. Task reactors pick up queued jobs and execute their pipelines. Tasks can call task() themselves to enqueue additional work.

.post = {{
  validate((v){
    .name = "title",
    .validation = "^\\S{1,16}$",
    .message = "1-16 characters"
  }),
  query((d){
    .db = "todos_db",
    (asset){ #embed "create_todo.sql" }
  }),
  task((t){"recount_todos"}),
  redirect((u){"todos"})
}}

sse

sse() pushes a Server-Sent Event. With .channel, the event is broadcast to all connected clients on that channel. Without .channel, the event is returned directly to the requesting client.

SSE / Datastar Flow

Declare an SSE channel on a resource with .sse. This creates a persistent streaming endpoint. Clients connect using the resource URL with the SSE verb: new EventSource("/todos?http_method=sse"). The .sse block supports .channel, .steps (positional), .before, .after, .context, and .errors. The optional .steps pipeline runs when a client first connects.

{"todos", "/todos",
  .sse = {
    .channel = "todos/{{user_id}}",
    {{
      // steps run on connect, e.g. send initial state
      query((d){
        .set = "todos",
        .db = "todos_db",
        "select * from todos where user_id = {{user_id}};"
      }),
      sse((s){
        .event = "initial",
        .data = {"{{todos}}"}
      })
    }}
  }
}

Push an event from another pipeline:

.post = {{
  // ... validate and query steps ...
  sse((s){
    .channel = "todos/{{user_id}}",
    .event = "new_todo",
    .data = {"{{todo}}"}
  })
}}

The .event and .data fields map directly to the SSE protocol's event: and data: lines. .data is an array for multi-line SSE data. .comment maps to the SSE : comment line.

ds_sse

The datastar module provides ds_sse, which combines SSE with DOM updates by pushing Datastar-formatted events targeting specific elements.

.post = {{
  // ... validate and query steps ...
  ds_sse((ds){
    .target = "todos",
    .mode = mode_prepend,
    .channel = "todos/{{user_id}}",
    .elements = {.asset = "todo"}
  })
}}

.target specifies the DOM element ID. .mode controls fragment insertion: mode_prepend, mode_append, mode_replace, mode_remove, and others. .elements accepts a full r (render) config. Like sse(), without .channel the event goes directly to the requesting client.

Use .signals to update client-side reactive state without touching the DOM:

.post = {{
  // ... validate and query steps ...
  ds_sse((ds){
    .channel = "todos/{{user_id}}",
    .signals = "{\"count\": {{count}}}"
  })
}}

render

render() outputs a Mustache template using the current pipeline context.

A static page using a layout from .context:

.resources = {{
  {"about", "/about",
    .get = {{
      render((r){(asset){
        #embed "about.mustache.html"
      }})
    }}
  }},

  .context = {
    {"layout", (asset){
      #embed "layout.mustache.html"
    }}
  }
}

Where about.mustache.html inherits from the layout:

{{< layout}}
  {{$body}}
    <p>about us</p>
  {{/body}}
{{/layout}}

Query and render:

{"todos", "/todos",
  .get = {{
    query((d){
      .set = "todos",
      .db = "todos_db",
      "select id, title, finished from todos where user_id = {{user_id}};"
    }),
    render((r){
      "{{< layout}}"
        "{{$body}}"
          "{{#todos}}"
              "<div>"
                "<span>{{title}}</span>"
                "{{#finished}}<em>done</em>{{/finished}}"
              "</div>"
          "{{/todos}}"
          "{{^todos}}"
            "<p>no todos yet</p>"
          "{{/todos}}"
        "{{/body}}"
      "{{/layout}}"
    })
  }}
}

Use .asset to reference a template by name in .context:

render((r){.asset = "home"})

Use .json to serialize a context value as a JSON response. The content type is automatically set to application/json. Context tables with nested tables produce structured JSON payloads:

.get = {{
  queries((da){{
    {
      .set = "order",
      .db = "orders_db",
      "select id, total from orders where id = {{order_id}};"
    },
    {
      .set = "items",
      .db = "orders_db",
      "select name, qty, price from order_items where order_id = {{order_id}};"
    }
  }}),
  join((j){
    .target_table = "order",
    .target_field = "id",
    .nested_table = "items",
    .nested_field = "order_id",
    .target_join_field = "items"
  }),
  render((r){
    .json = "order"
  })
}}

Template Engines

MACH includes two template engines: Mustache (default) and MDM (Markdown with Mustache). Use .engine to select a different engine. Additional engines can be added as modules.

Render a list with the default Mustache engine:

render((r){
  "<h1>Hello {{name}}</h1>"
  "<p>You have <strong>{{count}}</strong> todos remaining.</p>"
  "<ul>"
    "{{#todos}}"
      "<li>{{title}}</li>"
    "{{/todos}}"
  "</ul>"
})

Render the same content as Markdown with Mustache interpolation:

render((r){
  .engine = "mdm",
  "# Hello {{name}}\n"
  "\n"
  "You have **{{count}}** todos remaining.\n"
  "\n"
  "{{#todos}}\n"
  "- {{title}}\n"
  "{{/todos}}"
})

headers & cookies

headers() and cookies() set HTTP response headers and cookies declaratively. Both use the p config type with an array of name/value pairs. Values support string interpolation.

.get = {{
  query((d){
    .set = "user",
    .db = "users_db",
    "select id, name from users where id = {{user_id}};"
  }),
  headers((p){{
    {"X-Request-Id", "{{request_id}}"},
    {"Cache-Control", "no-store"}
  }}),
  cookies((p){{
    {"session", "{{session_id}}"}
  }}),
  render((r){.asset = "profile"})
}}

redirect & reroute

redirect() sends a 302 to the client, causing the browser to navigate to a new URL. reroute() re-enters the router server-side, executing the target resource's pipeline within the same request. Both accept a resource name, not a URL path.

Redirect after a form submission (POST-redirect-GET):

.post = {{
  validate((v){
    .name = "title",
    .validation = "^\\S{1,16}$",
    .message = "1-16 characters"
  }),
  query((d){
    .db = "todos_db",
    (asset){
      #embed "create_todo.sql"
    }
  }),
  redirect((u){"todos"})
}}

Reroute to serve another resource's pipeline without a round trip:

{"home", "/",
  .get = {{
    reroute((u){"todos"})
  }}
}

nest

nest() groups multiple pipeline steps into a single composite step. Use it to organize pipeline logic or to apply a single .if_context guard to an entire block of steps at once.

Compose a reusable sequence:

.post = {{
  validate((v){
    .name = "title",
    .validation = validate_not_empty,
    .message = "cannot be empty"
  }),
  query((d){
    .db = "todos_db",
    (asset){ #embed "create_todo.sql" }
  }),
  nest((n){{
    sse((s){
      .channel = "todos/{{user_id}}",
      .event = "new_todo",
      .data = {"{{todo}}"}
    }),
    emit((e){"todo_created"})
  }}),
  redirect((u){"todos"})
}}

Conditionally execute a group with .if_context:

.post = {{
  find((d){
    .set = "todo",
    .db = "todos_db",
    "select * from todos where id = {{id}};"
  }),
  invoke((i){classify_todo}),
  nest((n){
    .if_context = "is_urgent",
    {{
      query((d){
        .db = "todos_db",
        "update todos set priority = 'high' where id = {{id}};"
      }),
      emit((e){"urgent_todo"}),
      render((r){.asset = "urgent_confirmation"})
    }}
  }),
  render((r){
    .if_context = "is_normal",
    .asset = "standard_confirmation"
  })
}}

if_context

Any step supports .if_context, which names a context variable. If present, the step executes. If absent, the step is silently skipped.

This works for any context value: .if_context = "title" runs only if a title form parameter was submitted; .if_context = "todos" runs only if a prior query() stored results under that name.

.post = {{
  find((d){
    .set = "todo",
    .db = "todos_db",
    "select * from todos where id = {{id}};"
  }),
  invoke((i){classify_todo}),
  query((d){
    .if_context = "is_urgent",
    .db = "todos_db",
    "update todos set priority = 'high' where id = {{id}};"
  }),
  emit((e){
    .event = "urgent_todo",
    .if_context = "is_urgent"
  }),
  render((r){
    .if_context = "is_urgent",
    .asset = "urgent_confirmation"
  }),
  render((r){
    .if_context = "is_normal",
    .asset = "standard_confirmation"
  })
}}

void classify_todo() {
  auto const todos = get("todo");
  auto const todo = table_get(todos, 0);
  auto const due = record_get(todo, "due_date");

  if (is_within_24h(due))
    set("is_urgent", "1");
  else
    set("is_normal", "1");
}

When multiple steps share the same .if_context condition, use nest() to apply the condition once to the entire group instead of repeating it on each step.


Reference

Global Configuration

Fields marked (pos) are positional (set by position, not .name = syntax).

Field Description
.name (pos) Application or module identifier
.modules Modules to compose
.databases Data stores (.name, .engine, .connect, .migrations, .seeds)
.publishes Outbound event contracts (.event (pos), .with)
.events Collection of Event Pipelines
.resources Collection of Resource Pipelines
.before / .after Global middleware pipelines
.context Global variables and #embed assets (.name (pos), .value (pos))
.errors Global Error Pipelines (bottom-up fallback)
.repairs Global Repair Pipelines (bottom-up fallback)
.tasks Global Task Pipelines (bottom-up scoping)

Resource Pipelines (.resources)

Each resource entry accepts:

Field Description
.name (pos) Resource identifier (used by redirect(), reroute(), {{url:name}})
.url (pos) URL pattern (e.g. /todos/:id)
.mime Default response content type (defaults to mime_html)
.get .post .put .patch .delete Verb pipelines
.sse SSE endpoint (.channel, .steps (pos), .before, .after, .context, .tasks, .errors, .repairs)
.before / .after Resource-scoped middleware
.context Resource-scoped context
.tasks .errors .repairs Resource-scoped pipelines

Each verb block also accepts .before, .after, .context, .tasks, .errors, and .repairs for verb-scoped middleware and error handling.

Event Pipelines (.events)

Reacts to pub/sub events triggered by emit(). Auto-database: mach_events (created when .publishes is defined).

Field Description
.event (pos) Event name to subscribe to
.steps (pos) Pipeline steps
.before .after .context .tasks .errors .repairs Scoped pipelines

Task Pipelines (.tasks)

Run on task reactors. Fire-and-forget via task(), or scheduled via .cron. Task reactors monitor the task database for pending/incomplete work and handle cron processing. Tasks can enqueue additional tasks via task(). Resolved bottom-up. Auto-database: mach_tasks (created when .tasks is defined).

Field Description
.name (pos) Task identifier
.cron Schedule interval
.accepts Context keys to pull from caller
.steps (pos) Pipeline steps
.before .after .context Scoped pipelines

Error Pipelines (.errors)

Terminal. Sends response and ends request. Resolve bottom-up: verb → resource → module → root.

Field Description
.error_code (pos) HTTP status to match
.steps (pos) Pipeline steps
.before .after .context Scoped pipelines

Repair Pipelines (.repairs)

Resumable. Fixes context, then resumes original pipeline at the next step. Same bottom-up scoping as .errors. Falls through to .errors if no match.

Field Description
.error_code (pos) HTTP status to match
.steps (pos) Pipeline steps
.before .after .context Scoped pipelines

Pipeline API

All steps accept .if_context for conditional execution. Fields marked (pos) are positional.

Step Type Description Fields
validate(v) v Validate input, promote to app scope .name, .optional, .fallback, .validation, .message
find(d) d Single query, 404 if no rows .db, .set, .asset, SQL (pos)
query(d) d Single query .db, .set, .asset, SQL (pos)
queries(da) da Concurrent queries Array of d configs (pos)
join(j) j Nest records across context tables .target_table, .target_field, .nested_table, .nested_field, .target_join_field
fetch(f) f HTTP request, store response .set, .url, .method, .headers, .text, .json
render(r) r Output template or JSON Template (pos), .status, .mime, .engine, .asset, .json
sse(s) s Push SSE data .channel, .event, .comment, .data
invoke(i) i Call C function on shared thread pool Function (pos)
emit(e) e Trigger pub/sub event Event name (pos)
task(t) t Fire-and-forget to task reactors Task name (pos)
headers(p) p Set response headers Array of name/value pairs (pos)
cookies(p) p Set response cookies Array of name/value pairs (pos)
redirect(u) u 302 redirect Resource name (pos)
reroute(u) u Server-side re-route Resource name (pos)
nest(n) n Group steps into one Array of steps (pos)

Validators API

Regex macros for use with validate(). Define your own the same way: #define validate_zipcode "^\\d{5}$".

Category 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 & Times validate_date, validate_time, validate_datetime
Web validate_url, validate_ipv4, validate_hex_color
Codes & IDs validate_zipcode_us, validate_phone_e164, validate_cron
Security validate_no_sqli, validate_token, validate_base64
Boolean-ish validate_boolean, validate_yes_no, validate_on_off

Imperative API

Available inside invoke(). All types defined in mach.h.

Context

Function Description
void* get(string const name) Retrieve value from pipeline context
void set(string const name, void const *value) Set value in pipeline context
bool has(string const name) Check if value exists in pipeline context

Memory & Strings

Function Description
void* allocate(int const bytes) Allocate from pipeline arena
void defer_free(void const *ptr) Schedule cleanup for non-arena memory (e.g. third-party malloc)
string format(string const format_string) Interpolate {{context_key}} placeholders

Errors

Function Description
void error_set(string const name, error const) Trigger error/repair pipeline (.code, .message)
error error_get(string const name) Retrieve error by name
bool error_has(string const name) Check if error exists

Errors from error_set() and validate() share the error scope: {{error:name}}, {{error_code:name}}, {{error_message:name}}.

Data (Tables)

Function Description
table table_new() Create empty table
int table_count(table const) Number of records
record table_get(table const, int const index) Record at index
void table_add(table const, record const) Append record
void table_remove(table const, record const) Remove specific record
void table_remove_at(table const, int const index) Remove record at index
void table_join(j const) In-memory join (.target_table, .target_field, .nested_table, .nested_field, .target_join_field)

Data (Records)

Function Description
record record_new() Create empty record
void record_set(record const, string const name, string const value) Set field
string record_get(record const, string const name) Get field value
void record_remove(record const, string const name) Remove field

Constants & Enums

MIME Types Value
mime_html text/html
mime_txt text/plain
mime_sse text/event-stream
mime_json application/json
mime_js application/javascript
HTTP Methods HTTP Statuses
http_get http_ok 200
http_post http_created 201
http_put http_redirect 302
http_patch http_bad_request 400
http_delete http_not_authorized 401
http_sse_method http_not_found 404
http_error 500

Template Helpers

In addition to standard Mustache interpolation ({{field}}):

Helper Description
{{url:resource_name}} Resolve resource to URL path. Additional args for URL params: {{url:todo:id}}/todos/42
{{asset:filename}} Static file path with cache-busting checksum. Served with immutable headers
{{raw:field}} Output without HTML escaping (trusted HTML only)
{{input:field}} Raw request parameter, HTML-escaped. For repopulating forms on validation failure
{{csrf:cookiename}} CSRF token bound to cookie, enforced by middleware
{{csrf_field:cookiename}} Hidden form field with CSRF token bound to cookie
{{precision:field:N}} Round float string to N decimal places. {{precision:price:2}}"3.14"
{{error:name}} Boolean section, true when name has an error
{{error_code:name}} Error code for context variable
{{error_message:name}} Error message for context variable

Modules

Add a module's initializer function to .modules to use its API.

SQLite (sqlite.h)

Initializer sqlite()
Engine sqlite_db

Postgres (postgres.h)

Initializer postgres()
Engine postgres_db

MySQL (mysql.h)

Initializer mysql()
Engine mysql_db

Redis / Valkey (redis.h)

Initializer redis()
Engine redis_db

API compatible with both Redis and Valkey.

DuckDB (duckdb.h)

Initializer duckdb()
Engine duckdb_db

HTMX (htmx.h)

Initializer htmx()
Template Partial {{> htmx_script}}
Imperative API is_htmx() returns bool

Datastar (datastar.h)

Initializer datastar()
Template Partial {{> datastar_script}}
Pipeline Step ds_sse(ds): .channel, .mode, .target, .elements (r render config), .signals, .js, .if_context
Imperative API is_ds() returns bool
Modes mode_outer, mode_inner, mode_replace, mode_prepend, mode_append, mode_before, mode_after, mode_remove

Tailwind (tailwind.h)

Initializer tailwind()
Template Partial {{> tailwind_script}}

Session Auth (session_auth.h)

Initializer session_auth()
Schema users (user_id, short_name, full_name, password, salt)
Pipeline Steps session(), logged_in(), login(), logout(), signup()
Context Assets Default login and signup templates

License

MACH is licensed under the LGPL.