diff --git a/README.md b/README.md index 111022c..7ecf238 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ See [Resource Pipelines](#resource-pipelines) and [Templates](#templates). ### 2. Show Data -Bring in SQLite with `#include `, declare a database with `sqlite_database(...)`, and read with `sqlite_query()`. SQL files are assets like templates: `get_todos.sql` becomes the asset `get_todos`. +Bring in SQLite with `#include `, declare a database with `sqlite_config(...)`, and read with `sqlite_query()`. SQL files are assets like templates: `get_todos.sql` becomes the asset `get_todos`. Three new SQL files: @@ -193,11 +193,11 @@ Wire up the module, database, and query: +#include config(app){ -+ sqlite_database( -+ .name = "todos_db", -+ .connect = "file:todos.db?mode=rwc", -+ .migrations = {"create_todos_table"}, -+ .seeds = {"seed_todos"} ++ sqlite_config( ++ "todos_db", ++ "file:todos.db?mode=rwc", ++ {"create_todos_table"}, ++ {"seed_todos"} + ); + resource("home", "/", @@ -259,11 +259,11 @@ Add a `.post` verb and an `.errors` handler: #include config(app){ - sqlite_database( - .name = "todos_db", - .connect = "file:todos.db?mode=rwc", - .migrations = {"create_todos_table"}, - .seeds = {"seed_todos"} + sqlite_config( + "todos_db", + "file:todos.db?mode=rwc", + {"create_todos_table"}, + {"seed_todos"} ); resource("home", "/", @@ -281,18 +281,18 @@ Add a `.post` verb and an `.errors` handler: - } + }, + .post = { -+ input({"title", m_not_empty}), ++ input({"title", n_not_empty}), + sqlite_query({"todos_db", "create_todo"}), + redirect("todos") + }, + .errors = { -+ {m_bad_request, {reroute("todos")}} ++ {n_bad_request, {reroute("todos")}} + } ); } ``` -`input()` validates and promotes `title` to app scope; the `{{title}}` in `create_todo.sql` binds as a prepared-statement parameter. On failure, `m_bad_request` triggers the handler, which `reroute`s back into the GET pipeline in-process. The `input:` and `error:` scopes survive the reroute, so the form repopulates with `{{input:title}}` and shows `{{error_message:title}}`. See [input](#input), [Error and Repair Pipelines](#error-and-repair-pipelines), and [redirect and reroute](#redirect-and-reroute). +`input()` validates and promotes `title` to app scope; the `{{title}}` in `create_todo.sql` binds as a prepared-statement parameter. On failure, `n_bad_request` triggers the handler, which `reroute`s back into the GET pipeline in-process. The `input:` and `error:` scopes survive the reroute, so the form repopulates with `{{input:title}}` and shows `{{error_message:title}}`. See [input](#input), [Error and Repair Pipelines](#error-and-repair-pipelines), and [redirect and reroute](#redirect-and-reroute). ### 4. Nested Data @@ -356,12 +356,12 @@ Register the migration and add a `todo` resource: #include config(app){ - sqlite_database( - .name = "todos_db", - .connect = "file:todos.db?mode=rwc", -- .migrations = {"create_todos_table"}, -+ .migrations = {"create_todos_table", "create_comments_table"}, - .seeds = {"seed_todos"} + sqlite_config( + "todos_db", + "file:todos.db?mode=rwc", +- {"create_todos_table"}, ++ {"create_todos_table", "create_comments_table"}, + {"seed_todos"} ); resource("home", "/", @@ -378,20 +378,20 @@ Register the migration and add a `todo` resource: respond("todos_s") }, .post = { - input({"title", m_not_empty}), + input({"title", n_not_empty}), sqlite_query({"todos_db", "create_todo"}), redirect("todos") }, .errors = { - {m_bad_request, {reroute("todos")}} + {n_bad_request, {reroute("todos")}} } ); + + resource("todo", "/todos/:id", + .get = { -+ input({"id", m_integer}), ++ input({"id", n_int}), + sqlite_query( -+ {"todos_db", "get_todo", "todo_data", .must_exist = true}, ++ {"todos_db", "get_todo", "todo_data", .err_on_empty = true}, + {"todos_db", "get_comments", "comments"} + ), + join("todo_data", "id", "comments", "todo_id"), @@ -402,7 +402,7 @@ Register the migration and add a `todo` resource: } ``` -Both queries in one `sqlite_query()` call run concurrently. `join()` lifts `comments` inside each `todo_data` record, so the template reaches `{{#comments}}` from within `{{#todo_data}}`. `.must_exist = true` returns 404 when the id matches nothing. See [join](#join) and [query](#query). +Both queries in one `sqlite_query()` call run concurrently. `join()` lifts `comments` inside each `todo_data` record, so the template reaches `{{#comments}}` from within `{{#todo_data}}`. `.err_on_empty = true` returns 404 when the id matches nothing. See [join](#join) and [query](#query). ### 5. Calling APIs @@ -441,18 +441,18 @@ Fetch both services concurrently before rendering: #include config(app){ - sqlite_database( - .name = "todos_db", - .connect = "file:todos.db?mode=rwc", - .migrations = {"create_todos_table", "create_comments_table"}, - .seeds = {"seed_todos"} + sqlite_config( + "todos_db", + "file:todos.db?mode=rwc", + {"create_todos_table", "create_comments_table"}, + {"seed_todos"} ); resource("home", "/", .get = { + fetch( -+ {"https://api.quotes.dev/random", "quote"}, -+ {"https://api.weather.dev/now", "weather"} ++ {n_get, "https://api.quotes.dev/random", "quote"}, ++ {n_get, "https://api.weather.dev/now", "weather"} + ), mustache("home", "home_s"), respond("home_s") @@ -464,7 +464,7 @@ Both requests run concurrently under one `fetch()` call. The JSON parses into co ### 6. Tasks -A task is a named, reusable pipeline. Define it once with optional `.cron`; dispatch durable background runs with `dispatch_task("name")` (from `dispatch.h`). +A task is a named, reusable pipeline. Define it once with optional `.cron`; dispatch durable background runs with `dispatch("name")` (from `dispatch.h`). Two new SQL files: @@ -491,12 +491,12 @@ Register the migration, define the tasks, dispatch them from the POST: +#include config(app){ - sqlite_database( - .name = "todos_db", - .connect = "file:todos.db?mode=rwc", -- .migrations = {"create_todos_table", "create_comments_table"}, -+ .migrations = {"create_todos_table", "create_comments_table", "create_daily_stats_table"}, - .seeds = {"seed_todos"} + sqlite_config( + "todos_db", + "file:todos.db?mode=rwc", +- {"create_todos_table", "create_comments_table"}, ++ {"create_todos_table", "create_comments_table", "create_daily_stats_table"}, + {"seed_todos"} ); + task("record_daily_stats", { @@ -505,8 +505,8 @@ Register the migration, define the tasks, dispatch them from the POST: + + task("notify_new_todo", { + fetch({ ++ n_post, + "https://api.push.dev/notify", -+ .method = m_post, + .json = "{\"text\":\"New todo: {{title}}\"}" + }) + }, .accepts = {"title"}); @@ -514,8 +514,8 @@ Register the migration, define the tasks, dispatch them from the POST: resource("home", "/", .get = { fetch( - {"https://api.quotes.dev/random", "quote"}, - {"https://api.weather.dev/now", "weather"} + {n_get, "https://api.quotes.dev/random", "quote"}, + {n_get, "https://api.weather.dev/now", "weather"} ), mustache("home", "home_s"), respond("home_s") @@ -529,21 +529,21 @@ Register the migration, define the tasks, dispatch them from the POST: respond("todos_s") }, .post = { - input({"title", m_not_empty}), + input({"title", n_not_empty}), sqlite_query({"todos_db", "create_todo"}), -+ dispatch_task("notify_new_todo"), ++ dispatch("notify_new_todo"), redirect("todos") }, .errors = { - {m_bad_request, {reroute("todos")}} + {n_bad_request, {reroute("todos")}} } ); resource("todo", "/todos/:id", .get = { - input({"id", m_integer}), + input({"id", n_int}), sqlite_query( - {"todos_db", "get_todo", "todo_data", .must_exist = true}, + {"todos_db", "get_todo", "todo_data", .err_on_empty = true}, {"todos_db", "get_comments", "comments"} ), join("todo_data", "id", "comments", "todo_id"), @@ -554,7 +554,7 @@ Register the migration, define the tasks, dispatch them from the POST: } ``` -`.cron` and `dispatch_task(...)` both run the task on a task reactor, off the request reactors, so the POST returns immediately. Dispatched tasks are durable: a crash mid-task resumes on the next boot. To hand values to a task, list them under `.accepts`; `notify_new_todo` pulls in `title` that way. `dispatch_task()` comes from `dispatch.h`. See [Task Pipelines](#task-pipelines). +`.cron` and `dispatch(...)` both run the task on a task reactor, off the request reactors, so the POST returns immediately. Dispatched tasks are durable: a crash mid-task resumes on the next boot. To hand values to a task, list them under `.accepts`; `notify_new_todo` pulls in `title` that way. `dispatch()` comes from `dispatch.h`. See [Task Pipelines](#task-pipelines). ### 7. Modules and Events @@ -591,11 +591,11 @@ This step moves todos into its own module and adds an `activity` module that rec -#include config(app){ -- sqlite_database( -- .name = "todos_db", -- .connect = "file:todos.db?mode=rwc", -- .migrations = {"create_todos_table", "create_comments_table", "create_daily_stats_table"}, -- .seeds = {"seed_todos"} +- sqlite_config( +- "todos_db", +- "file:todos.db?mode=rwc", +- {"create_todos_table", "create_comments_table", "create_daily_stats_table"}, +- {"seed_todos"} - ); - - task("record_daily_stats", { @@ -604,8 +604,8 @@ This step moves todos into its own module and adds an `activity` module that rec - - task("notify_new_todo", { - fetch({ +- n_post, - "https://api.push.dev/notify", -- .method = m_post, - .json = "{\"text\":\"New todo: {{title}}\"}" - }) - }, .accepts = {"title"}); @@ -613,8 +613,8 @@ This step moves todos into its own module and adds an `activity` module that rec resource("home", "/", .get = { fetch( - {"https://api.quotes.dev/random", "quote"}, - {"https://api.weather.dev/now", "weather"} + {n_get, "https://api.quotes.dev/random", "quote"}, + {n_get, "https://api.weather.dev/now", "weather"} ), mustache("home", "home_s"), respond("home_s") @@ -628,21 +628,21 @@ This step moves todos into its own module and adds an `activity` module that rec - respond("todos_s") - }, - .post = { -- input({"title", m_not_empty}), +- input({"title", n_not_empty}), - sqlite_query({"todos_db", "create_todo"}), -- dispatch_task("notify_new_todo"), +- dispatch("notify_new_todo"), - redirect("todos") - }, - .errors = { -- {m_bad_request, {reroute("todos")}} +- {n_bad_request, {reroute("todos")}} - } - ); - - resource("todo", "/todos/:id", - .get = { -- input({"id", m_integer}), +- input({"id", n_int}), - sqlite_query( -- {"todos_db", "get_todo", "todo_data", .must_exist = true}, +- {"todos_db", "get_todo", "todo_data", .err_on_empty = true}, - {"todos_db", "get_comments", "comments"} - ), - join("todo_data", "id", "comments", "todo_id"), @@ -686,11 +686,11 @@ The todos logic moves into the module unchanged, gaining a `publish()` and an `e #include config(todos){ - sqlite_database( - .name = "todos_db", - .connect = "file:todos.db?mode=rwc", - .migrations = {"create_todos_table", "create_comments_table", "create_daily_stats_table"}, - .seeds = {"seed_todos"} + sqlite_config( + "todos_db", + "file:todos.db?mode=rwc", + {"create_todos_table", "create_comments_table", "create_daily_stats_table"}, + {"seed_todos"} ); publish("todo_created", @@ -703,8 +703,8 @@ config(todos){ task("notify_new_todo", { fetch({ + n_post, "https://api.push.dev/notify", - .method = m_post, .json = "{\"text\":\"New todo: {{title}}\"}" }) }, .accepts = {"title"}); @@ -716,22 +716,22 @@ config(todos){ respond("todos_s") }, .post = { - input({"title", m_not_empty}), + input({"title", n_not_empty}), sqlite_query({"todos_db", "create_todo"}), - dispatch_task("notify_new_todo"), + dispatch("notify_new_todo"), emit("todo_created"), redirect("todos") }, .errors = { - {m_bad_request, {reroute("todos")}} + {n_bad_request, {reroute("todos")}} } ); resource("todo", "/todos/:id", .get = { - input({"id", m_integer}), + input({"id", n_int}), sqlite_query( - {"todos_db", "get_todo", "todo_data", .must_exist = true}, + {"todos_db", "get_todo", "todo_data", .err_on_empty = true}, {"todos_db", "get_comments", "comments"} ), join("todo_data", "id", "comments", "todo_id"), @@ -785,10 +785,10 @@ select kind, ref, created_at from activities order by created_at desc; #include config(activity){ - sqlite_database( - .name = "activity_db", - .connect = "file:activity.db?mode=rwc", - .migrations = {"create_activity_table"} + sqlite_config( + "activity_db", + "file:activity.db?mode=rwc", + {"create_activity_table"} ); subscribe("todo_created", { @@ -865,7 +865,7 @@ Built-in helpers use `{{helper:args}}` syntax. Arguments are colon-separated, in {{/error:title}} ``` -**`{{error_message:field}}`**: human-readable message for a field error, from `input()`'s message or from `error_set()`. +**`{{error_message:field}}`**: human-readable message for a field error, from `input()`'s message or from `err_set()`. ```html {{error_message:title}} ``` @@ -954,18 +954,18 @@ In dev, the scan is live: editing an asset reloads that file into every module h ### Databases -Each database engine is a module: `#include` its header (e.g. `#include `) to activate it, then register one or more databases with `_database(...)`. Migrations and seeds are forward-only and index-based: they run in array order, each applied once, with new ones appended to the end. Both are tracked in a `nerak_meta` table. +Each database engine is a module: `#include` its header (e.g. `#include `) to activate it, then register one or more databases with `_config(...)`. Migrations and seeds are forward-only and index-based: they run in array order, each applied once, with new ones appended to the end. Both are tracked in a `nerak_meta` table. -Multi-tenant databases use `{{interpolation}}` in `.connect`. Connections are pooled with LRU eviction. +Multi-tenant databases use `{{interpolation}}` in `.conn`. Connections are pooled with LRU eviction. **`.name`**: identifier referenced by the first value of `query()` steps. ```c .name = "todos_db" ``` -**`.connect`**: engine-specific connection string. Supports `{{interpolation}}` for multi-tenancy. +**`.conn`**: engine-specific connection string. Supports `{{interpolation}}` for multi-tenancy. ```c -.connect = "file:{{user_id}}_todo.db?mode=rwc" +.conn = "file:{{user_id}}_todo.db?mode=rwc" ``` **`.migrations`**: array of SQL migration entries, applied once each in order. Each entry is a context key holding the SQL. @@ -982,15 +982,15 @@ Combined: ```c #include -sqlite_database( +sqlite_config( .name = "blog_db", - .connect = "file:{{user_id}}_blog.db?mode=rwc", + .conn = "file:{{user_id}}_blog.db?mode=rwc", .migrations = {"create_blogs_table", "create_comments_table"}, .seeds = {"seed_blogs"} ); ``` -**Engine include / query / register:** `#include ` + `sqlite_query()` + `sqlite_database()`, and likewise `postgres_*`, `mysql_*`, `redis_*`, `duckdb_*`. +**Engine include / query / register:** `#include ` + `sqlite_query()` + `sqlite_config()`, and likewise `postgres_*`, `mysql_*`, `redis_*`, `duckdb_*`. ![Database Multi-Tenancy](./images/09-database-multi-tenancy.svg) @@ -1013,15 +1013,15 @@ resource("todo", "/todos/:id", .get = { ... }); **`.all`**: shared steps that run before every verb pipeline on the resource. ```c resource("todo", "/todos/:id", - .all = { input({"id", m_integer, "must be a number"}) }, + .all = { input({"id", n_int, "must be a number"}) }, .get = { ... }, .delete = { ... } ); ``` -**`.mime`**: default response content type. Values: `m_html`, `m_txt`, `m_sse`, `m_json`, `m_js` (default `m_html`). +**`.mime`**: default response content type. Values: `n_html`, `n_txt`, `n_es`, `n_json`, `n_js` (default `n_html`). ```c -resource("feed", "/feed.json", .mime = m_json, .get = { ... }); +resource("feed", "/feed.json", .mime = n_json, .get = { ... }); ``` **`.get` `.post` `.put` `.patch` `.delete`**: verb pipelines: ordered arrays of steps that transform a request into a response. @@ -1033,7 +1033,7 @@ resource("todos", "/todos", respond("todos_s") }, .post = { - input({"title", m_not_empty}), + input({"title", n_not_empty}), redirect("todos") } ); @@ -1053,7 +1053,7 @@ resource("todos", "/todos", ```c resource("todos", "/todos", .post = { ... }, - .errors = {{m_bad_request, { + .errors = {{n_bad_request, { mustache("form", "form_s"), respond("form_s") }}} @@ -1063,14 +1063,14 @@ resource("todos", "/todos", Combined: ```c resource("todo", "/todos/:id", - .all = {input({"id", m_positive, "must be a number"})}, + .all = {input({"id", n_positive, "must be a number"})}, .get = { - sqlite_query({"todos_db", "get_todo", "todo", .must_exist = true}), + sqlite_query({"todos_db", "get_todo", "todo", .err_on_empty = true}), mustache("todo", "todo_s"), respond("todo_s") }, .patch = { - input({"title", m_not_empty, "required"}), + input({"title", n_not_empty, "required"}), sqlite_query({"todos_db", "update_todo"}), redirect("todo") }, @@ -1079,7 +1079,7 @@ resource("todo", "/todos/:id", redirect("todos") }, .sse = {"todo:{{id}}", sse(.event = "ready")}, - .errors = {{m_not_found, { + .errors = {{n_not_found, { mustache("404", "not_found_s"), respond("not_found_s") }}} @@ -1094,24 +1094,24 @@ When a step fails, execution halts and Nerak looks for a handler matching the er Errors are terminal: the handler sends a response and ends the request. Repairs are resumable: they fix the context and resume the original pipeline at the step after the failure. Repairs resolve first; if no matching repair is found, resolution falls through to errors. Unhandled errors fall through to Nerak's internal handler, which looks for a context template named after the error code, otherwise renders the error message as `text/plain` with the error code as the HTTP status, and surfaces in the TUI console and telemetry. -The `error` scope is shared across `input()` failures and `error_set()` calls: `{{error:name}}`, `{{error_code:name}}`, `{{error_message:name}}`. The raw input value remains in `input:name` for re-rendering forms. +The `error` scope is shared across `input()` failures and `err_set()` calls: `{{error:name}}`, `{{error_code:name}}`, `{{error_message:name}}`. The raw input value remains in `input:name` for re-rendering forms. **Resource-scoped (`.errors` / `.repairs` fields):** ```c resource("todos", "/todos", .post = { ... }, .errors = { - {m_not_found, { + {n_not_found, { mustache("404", "not_found_s"), respond("not_found_s") }}, - {m_bad_request, { + {n_bad_request, { mustache("form", "form_s"), respond("form_s") }} }, .repairs = { - {m_not_authorized, {run(.call = refresh_session_token)}} + {n_not_authorized, {run(.call = refresh_session_token)}} } ); ``` @@ -1119,20 +1119,20 @@ resource("todos", "/todos", **Module-scoped (`error()` / `repair()` calls):** ```c config(todos){ - error(m_error, { + error(n_error, { mustache("5xx", "error_s"), respond("error_s") }); - error(m_not_found, { + error(n_not_found, { mustache("404", "not_found_s"), respond("not_found_s") }); - repair(m_not_authorized, {run(.call = refresh_session_token)}); + repair(n_not_authorized, {run(.call = refresh_session_token)}); // ... resources ... } ``` -**Built-in error codes:** `m_bad_request` (400), `m_not_authorized` (401), `m_not_found` (404), `m_error` (500). Any integer works; the `m_*` constants are convenience names. Define your own for domain-specific errors, e.g. `#define err_quota_exceeded 723`. +**Built-in error codes:** `n_bad_request` (400), `n_not_authorized` (401), `n_not_found` (404), `n_error` (500). Any integer works; the `n_*` constants are convenience names. Define your own for domain-specific errors, e.g. `#define err_quota_exceeded 723`. ![Error Resolution](./images/04-error-resolution.svg) @@ -1165,7 +1165,7 @@ emit("todo_created") ```c subscribe("todo_created", { sqlite_query({"activity_db", "insert_activity"}) -}, .errors = {{m_error, {run(.call = log_subscriber_failure)}}}); +}, .errors = {{n_error, {run(.call = log_subscriber_failure)}}}); ``` Combined: @@ -1181,7 +1181,7 @@ config(todos){ resource("todos", "/todos", .post = { - input({"title", m_not_empty}), + input({"title", n_not_empty}), sqlite_query({"todos_db", "insert_todo"}), emit("todo_created"), redirect("todos") @@ -1204,13 +1204,13 @@ config(activity){ ### Task Pipelines -A task is a named, reusable pipeline, defined inside a module with `task(name, { pipeline }, ...)`. Registration and invocation are separate. `run_task("name")` runs a task inline as a step in the calling pipeline, for reusable pipelines composed into workflows. `dispatch_task("name")` runs it as a durable background job and returns immediately; requires `#include `. `.cron` runs it in the background on a schedule, no caller. +A task is a named, reusable pipeline, defined inside a module with `task(name, { pipeline }, ...)`. Registration and invocation are separate. `run_task("name")` runs a task inline as a step in the calling pipeline, for reusable pipelines composed into workflows. `dispatch("name")` runs it as a durable background job and returns immediately; requires `#include `. `.cron` runs it in the background on a schedule, no caller. Dispatched tasks are durable: the dispatch module creates the persistent task tables and checkpoints context after each step, so a crash mid-task resumes at the step where it stopped on the next boot. -Any pipeline or task can call `run()`, `run_worker()`, `run_task()`, and `dispatch_task()` (the last requires `dispatch.h`). +Any pipeline or task can call `run()`, `run_worker()`, `run_task()`, and `dispatch()` (the last requires `dispatch.h`). -**Task name *(by order)***: task identifier, invoked via `run_task("name")` or `dispatch_task("name")`. +**Task name *(by order)***: task identifier, invoked via `run_task("name")` or `dispatch("name")`. ```c task("recount", { sqlite_query({"db", "recount_todos"}) @@ -1222,7 +1222,7 @@ task("recount", { task("name", { sqlite_query({...}), emit("done"), - dispatch_task("followup") + dispatch("followup") }); ``` @@ -1243,13 +1243,13 @@ task("daily_digest", { **`.errors` / `.repairs`** *(per task)*: each task can declare its own handlers, resolved the same way as resource pipelines (the task's own handlers, then its module's). See [Error and Repair Pipelines](#error-and-repair-pipelines). ```c task("send_invoice", { - fetch({"https://api.billing.dev/invoices/{{invoice_id}}", "inv"}) -}, .repairs = {{m_not_authorized, {run(.call = refresh_billing_token)}}}); + fetch({.url = "https://api.billing.dev/invoices/{{invoice_id}}", .ctx_key = "inv"}) +}, .repairs = {{n_not_authorized, {run(.call = refresh_billing_token)}}}); ``` Combined: ```c -// on-demand: dispatched via dispatch_task("recount_todos") +// on-demand: dispatched via dispatch("recount_todos") task("recount_todos", { sqlite_query({"todos_db", "recount"}) }, .accepts = {"user_id"}); @@ -1263,7 +1263,7 @@ task("daily_digest", { ### Pipeline Steps -Steps are the units of work in a pipeline. Each receives the current context, acts on it, passes control to the next. All steps accept `.if_context`/`.unless_context` for [conditional execution](#conditionals), and `.map`/`.item` for concurrent fan-out across rows of a context table (see [Iteration](#iteration)). +Steps are the units of work in a pipeline. Each receives the current context, acts on it, passes control to the next. All steps accept `.if_ctx`/`.not_ctx` for [conditional execution](#conditionals), and `.map`/`.map_key` for concurrent fan-out across rows of a context table (see [Iteration](#iteration)). * [input](#input) * [query](#query) @@ -1273,7 +1273,7 @@ Steps are the units of work in a pipeline. Each receives the current context, ac * [run_worker](#run_worker) * [emit](#emit) * [run_task](#run_task) -* [dispatch_task](#dispatch_task) +* [dispatch](#dispatch) * [sse](#sse) * [render](#render) * [respond](#respond) @@ -1285,95 +1285,95 @@ Steps are the units of work in a pipeline. Each receives the current context, ac Checks request parameters (query string, form body, URL params) against regex patterns. On success, each value is promoted from `input:name` to app scope. On failure, errors land in `error:name` and a `400 Bad Request` triggers the nearest [error/repair pipeline](#error-and-repair-pipelines). All validations in one call complete before the error fires, so all errors are available together for form re-rendering. -Built-in regex macros are defined in `nerak.h`; define your own the same way: `#define m_zipcode "^\\d{5}$"`. +Built-in regex macros are defined in `nerak.h`; define your own the same way: `#define n_zipcode "^\\d{5}$"`. -**`.param_key` *(by order)***: name of the parameter to validate. +**`.ctx_key` *(by order)***: name of the parameter to validate. ```c input({"title", "^\\S+$", "required"}) ``` -**`.matches` *(by order)***: regex pattern, or a built-in validator macro. +**`.regex` *(by order)***: regex pattern, or a built-in validator macro. ```c -input({"email", m_email, "bad email"}) +input({"email", n_email, "bad email"}) ``` -**`.message` *(by order)***: human-readable error shown via `{{error_message:name}}`. +**`.err_msg` *(by order)***: human-readable error shown via `{{error_message:name}}`. ```c -input({"age", m_integer, "must be a number"}) +input({"age", n_int, "must be a number"}) ``` -**`.optional`**: skip validation when the parameter is absent. +**`.opt`**: skip validation when the parameter is absent. ```c -input({"filter", "^(active|done)$", .optional = true}) +input({"filter", "^(active|done)$", .opt = true}) ``` -**`.fallback`**: default value injected when the parameter is absent. +**`.def`**: default value injected when the parameter is absent. ```c -input({"page", m_integer, .fallback = "1"}) +input({"page", n_int, .def = "1"}) ``` Combined: ```c input( - {"email", m_email, "must be a valid email"}, - {"title", m_not_empty, "cannot be empty"}, - {"page", m_integer, "must be a number", .fallback = "1"}, - {"filter", "^(active|done)$", "must be 'active' or 'done'", .optional = true}, - {"username", m_username, "must be alphanumeric"} + {"email", n_email, "must be a valid email"}, + {"title", n_not_empty, "cannot be empty"}, + {"page", n_int, "must be a number", .def = "1"}, + {"filter", "^(active|done)$", "must be 'active' or 'done'", .opt = true}, + {"username", n_user, "must be alphanumeric"} ) ``` For checks beyond regex (uniqueness, cross-field rules, lookups), pair `input()` with a query and `run()`: ```c -input({"username", m_username, "must be alphanumeric"}), +input({"username", n_user, "must be alphanumeric"}), sqlite_query({"users_db", "find_username", "existing"}), run(^(){ auto rows = get("existing"); - if (rows && table_count(rows) > 0) - error_set("username", (error){m_bad_request, "already taken"}); + if (rows && tbl_len(rows) > 0) + err_set("username", (err){n_bad_request, "already taken"}); }) ``` **Built-in validators:** -- Strings: `m_not_empty`, `m_alpha`, `m_alphanumeric`, `m_slug`, `m_no_html` -- Numbers: `m_integer`, `m_positive`, `m_float`, `m_percentage` -- Identity: `m_email`, `m_uuid`, `m_username` -- Dates & times: `m_date`, `m_time`, `m_datetime` -- Web: `m_url`, `m_ipv4`, `m_hex_color` -- Codes: `m_zipcode_us`, `m_phone_e164`, `m_cron` -- Security: `m_token`, `m_base64` -- Boolean: `m_boolean`, `m_yes_no`, `m_on_off` +- Strings: `n_not_empty`, `n_alpha`, `n_alphanum`, `n_slug`, `n_no_html` +- Numbers: `n_int`, `n_positive`, `n_float`, `n_percent` +- Identity: `n_email`, `n_uuid`, `n_user` +- Dates & times: `n_date`, `n_time`, `n_datetime` +- Web: `n_url`, `n_ipv4`, `n_hex_color` +- Codes: `n_zip`, `n_phone`, `n_cron` +- Security: `n_token`, `n_base64` +- Boolean: `n_bool`, `n_yes_no`, `n_on_off` #### query -Each engine provides its own query step: `sqlite_query()`, `postgres_query()`, `mysql_query()`, `redis_query()`, `duckdb_query()`. All share the same `query_config` shape. By order: first value is the database `.name`, second is the context key holding the SQL, third is the `.set_key` for the result table (even single-row results are tables). Multiple items in one step run **concurrently**. Queries use prepared statements; interpolated `{{values}}` are bound, not spliced. For transactions, put `BEGIN`/`COMMIT`/`ROLLBACK` in the SQL. +Each engine provides its own query step: `sqlite_query()`, `postgres_query()`, `mysql_query()`, `redis_query()`, `duckdb_query()`. All share the same `query_config` shape. By order: first value is the database `.name`, second is the context key holding the SQL, third is the `.ctx_key` for the result table (even single-row results are tables). Multiple items in one step run **concurrently**. Queries use prepared statements; interpolated `{{values}}` are bound, not spliced. For transactions, put `BEGIN`/`COMMIT`/`ROLLBACK` in the SQL. -**`.db` *(by order)***: database name, matching the name a `_database(...)` was registered with. +**`.db` *(by order)***: database name, matching the name a `_config(...)` was registered with. ```c sqlite_query({"todos_db", "get_todos", "todos_data"}) ``` -**`.query` *(by order)***: context key holding the SQL to run. +**`.q` *(by order)***: context key holding the SQL to run. ```c sqlite_query({"todos_db", "get_todos", "todos_data"}) ``` -**`.set_key` *(by order)***: context key for the result table. Optional; omit when the result isn't needed (e.g. an insert without `RETURNING`). +**`.ctx_key` *(by order)***: context key for the result table. Optional; omit when the result isn't needed (e.g. an insert without `RETURNING`). ```c sqlite_query({"todos_db", "create_todo"}) // no result captured sqlite_query({"todos_db", "get_todos", "todos_data"}) // result under "todos_data" ``` -**`.must_exist`**: when true, raise `404 Not Found` if the query affects/returns zero rows. Default false. +**`.err_on_empty`**: when true, raise `404 Not Found` if the query affects/returns zero rows. Default false. ```c -sqlite_query({"todos_db", "get_todo", "todo", .must_exist = true}) +sqlite_query({"todos_db", "get_todo", "todo", .err_on_empty = true}) ``` -**`.if_context` / `.unless_context`** *(per item)*: conditionally include or skip individual queries while running the others concurrently. +**`.if_ctx` / `.not_ctx`** *(per item)*: conditionally include or skip individual queries while running the others concurrently. ```c sqlite_query( {"db", "get_todos", "todos_data"}, - {"db", "get_urgent", "urgent", .if_context = "show_urgent"} + {"db", "get_urgent", "urgent", .if_ctx = "show_urgent"} ) ``` @@ -1381,8 +1381,8 @@ Combined: ```c sqlite_query( {"todos_db", "get_todos", "todos_data"}, - {"todos_db", "get_todo", "todo", .must_exist = true}, - {"todos_db", "get_urgent", "urgent", .if_context = "show_urgent"} + {"todos_db", "get_todo", "todo", .err_on_empty = true}, + {"todos_db", "get_urgent", "urgent", .if_ctx = "show_urgent"} ) ``` @@ -1390,19 +1390,19 @@ sqlite_query( Nests records from one context table into each matching record of another, like a SQL JOIN in memory. Useful when records come from separate databases or queries. After the step, each outer record gains a new field holding its matched inner records. -**`.parent_key`**: outer table whose records receive nested children. +**`.parent_ctx_key`**: outer table whose records receive nested children. ```c -.parent_key = "projects" +.parent_ctx_key = "projects" ``` -**`.field_key`**: field on the outer table to match against. +**`.parent_field_key`**: field on the outer table to match against. ```c -.field_key = "id" +.parent_field_key = "id" ``` -**`.child_key`**: inner table whose records get nested. +**`.child_ctx_key`**: inner table whose records get nested. ```c -.child_key = "todos" +.child_ctx_key = "todos" ``` **`.child_field_key`**: field on the inner table that points at the outer. @@ -1410,9 +1410,9 @@ Nests records from one context table into each matching record of another, like .child_field_key = "project_id" ``` -**`.join_field_key`**: new field on outer records holding the matched inner records. (defaults to `.child_key`) +**`.parent_join_key`**: new field on outer records holding the matched inner records. (defaults to the child table name) ```c -.join_field_key = "todos" +.parent_join_key = "todos" ``` Combined: @@ -1441,7 +1441,7 @@ join("projects", "id", "todos", "project_id") ```c resource("blog", "/blogs/:id", .get = { - input({"id", m_integer}), + input({"id", n_int}), // Fetch both concurrently: one query() call, two items sqlite_query( @@ -1473,51 +1473,51 @@ after join(): { blog: [{id, title, content, Makes one or more HTTP requests and stores responses in context. JSON parses into tables and records (nested tables for nested JSON); plain-text responses are stored as strings. Like `query()`, multiple items in one step run **concurrently**. -**`.url` *(by order)***: request URL; supports `{{interpolation}}`. +**`.url`**: request URL; supports `{{interpolation}}`. ```c -fetch({"https://api.weather.dev/forecast?city={{city}}", "w"}) +fetch({.url = "https://api.weather.dev/forecast?city={{city}}", .ctx_key = "w"}) ``` -**`.set_key`**: context key for the response. +**`.ctx_key`**: context key for the response. ```c -fetch({"https://api.weather.dev/now", "weather"}) +fetch({.url = "https://api.weather.dev/now", .ctx_key = "weather"}) ``` -**`.method`**: HTTP method. Defaults to `m_get`. Values: `m_get`, `m_post`, `m_put`, `m_patch`, `m_delete`, `m_sse_method`. +**`.method`**: HTTP method. Defaults to `n_get`. Values: `n_get`, `n_post`, `n_put`, `n_patch`, `n_delete`, `n_sse`. ```c -fetch({"https://api.dev/charge", "r", m_post}) +fetch({.url = "https://api.dev/charge", .ctx_key = "r", .method = n_post}) ``` **`.headers`**: array of name/value pairs. ```c -fetch({"https://api.dev/me", "r", .headers = {{"Authorization", "Bearer {{token}}"}}}) +fetch({.url = "https://api.dev/me", .ctx_key = "r", .headers = {{"Authorization", "Bearer {{token}}"}}}) ``` -**`.json`**: context key serialized as the JSON request body. +**`.json_ctx_key`** / **`.json`**: JSON request body. `.json_ctx_key` names a context key whose value is serialized; `.json` is a literal JSON string (supports `{{interpolation}}`). ```c -fetch({"https://api.dev/charge", "receipt", m_post, "order"}) +fetch({.url = "https://api.dev/charge", .ctx_key = "receipt", .method = n_post, .json_ctx_key = "order"}) ``` -**`.text`**: context key sent as the plain-text request body. +**`.txt`**: context key sent as the plain-text request body. ```c -fetch({"https://api.dev/log", "r", m_post, .text = "raw_body"}) +fetch({.url = "https://api.dev/log", .ctx_key = "r", .method = n_post, .txt = "raw_body"}) ``` -**`.if_context` / `.unless_context`** *(per item)*: conditionally include or skip individual requests while running others concurrently. +**`.if_ctx` / `.not_ctx`** *(per item)*: conditionally include or skip individual requests while running others concurrently. ```c fetch( - {"https://api.weather.dev/now", "weather"}, - {"https://api.quotes.dev/random", "quote", .if_context = "show_quote"} + {.url = "https://api.weather.dev/now", .ctx_key = "weather"}, + {.url = "https://api.quotes.dev/random", .ctx_key = "quote", .if_ctx = "show_quote"} ) ``` Combined, single request: ```c -fetch({"https://api.payments.dev/charge", - "receipt", - m_post, - "order", - { +fetch({.url = "https://api.payments.dev/charge", + .ctx_key = "receipt", + .method = n_post, + .json_ctx_key = "order", + .headers = { {"Authorization", "Bearer {{api_key}}"}, {"Idempotency-Key", "{{order_id}}"} } @@ -1527,24 +1527,24 @@ fetch({"https://api.payments.dev/charge", Combined, concurrent fan-out: ```c fetch( - {"https://api.weather.dev/now?city={{city}}", "weather"}, - {"https://api.news.dev/headlines?topic={{topic}}", "news"}, - {"https://api.quotes.dev/random", "quote"} + {.url = "https://api.weather.dev/now?city={{city}}", .ctx_key = "weather"}, + {.url = "https://api.news.dev/headlines?topic={{topic}}", .ctx_key = "news"}, + {.url = "https://api.quotes.dev/random", .ctx_key = "quote"} ) ``` #### run -`run()` calls a C function or block inline on the reactor, with access to context via the [Imperative API](#imperative-api). It is where business logic and data shaping lives: enriching query results, aggregating, transforming data between steps, setting flags for [conditional](#conditionals) downstream steps. For short, non-blocking work; call `error_set()` to trigger an error/repair pipeline. Use `run_worker()` instead when the body would stall the reactor. +`run()` calls a C function or block inline on the reactor, with access to context via the [Imperative API](#imperative-api). It is where business logic and data shaping lives: enriching query results, aggregating, transforming data between steps, setting flags for [conditional](#conditionals) downstream steps. For short, non-blocking work; call `err_set()` to trigger an error/repair pipeline. Use `run_worker()` instead when the body would stall the reactor. **Block *(by order)***: inline block, for short logic specific to this pipeline. Here, attaching each challenger's opponent id so the template can render two voting forms with the right winner/loser pairing: ```c run(^(){ 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")); + auto const p0 = tbl_get(t, 0); + auto const p1 = tbl_get(t, 1); + rec_set(p0, "opponent_id", rec_get(p1, "id")); + rec_set(p1, "opponent_id", rec_get(p0, "id")); }) ``` @@ -1591,20 +1591,20 @@ Runs a named task inline as a step in the calling pipeline; control returns to t run_task("recount_todos") ``` -#### dispatch_task +#### dispatch Enqueues a named task as a durable background job; the calling pipeline continues immediately. Task reactors pick up queued jobs and execute their pipelines. The task is checkpointed after each step, so a crash mid-task resumes where it stopped. Requires `#include `, which provides the persistent task tables. The task must be defined with `task(name, { ... })`. See [Task Pipelines](#task-pipelines). **Task name *(by order)***: name of a defined task. ```c -dispatch_task("record_daily_stats") +dispatch("record_daily_stats") ``` #### sse -Pushes a Server-Sent Event. With `.channel`, the event broadcasts to all clients on that channel. Without it, the event returns to the requesting client. See [Resource Pipelines](#resource-pipelines). +Pushes a Server-Sent Event. With `.chan`, the event broadcasts to all clients on that channel. Without it, the event returns to the requesting client. See [Resource Pipelines](#resource-pipelines). -**`.channel` *(by order)***: channel to broadcast on; supports `{{interpolation}}`. +**`.chan` *(by order)***: channel to broadcast on; supports `{{interpolation}}`. ```c sse("todos:{{user_id}}", .event = "new_todo", .data = {"{{todo}}"}) ``` @@ -1637,12 +1637,12 @@ sse("todos:{{user_id}}", Renders a template into the pipeline context. `mustache()` renders Mustache; `mdm()` renders Markdown-with-Mustache; `json()` renders JSON. All take the same `render_config`. -**`.template_key` *(by order)***: context key holding the template string to render. +**`.template_ctx_key` *(by order)***: context key holding the template string to render. ```c mustache("todos", "todos_s") ``` -**`.set_key` *(by order)***: context key to write the rendered output to. +**`.ctx_key` *(by order)***: context key to write the rendered output to. ```c mustache("todos", "todos_s") ``` @@ -1662,25 +1662,25 @@ mdm("welcome", "welcome_s") Sends a pipeline context value as the HTTP response. -**`.context_key` *(by order)***: key of the rendered content to send. +**`.ctx_key` *(by order)***: key of the rendered content to send. ```c respond("todos_s") ``` -**`.status`**: HTTP response status (default `m_ok`). Values: `m_ok` (200), `m_created` (201), `m_redirect` (302), `m_bad_request` (400), `m_not_authorized` (401), `m_not_found` (404), `m_error` (500). +**`.status`**: HTTP response status (default `n_ok`). Values: `n_ok` (200), `n_created` (201), `n_redirect` (302), `n_bad_request` (400), `n_not_authorized` (401), `n_not_found` (404), `n_error` (500). ```c -respond("not_found_s", .status = m_not_found) +respond("not_found_s", .status = n_not_found) ``` -**`.mime`**: override the response content type. Values: `m_html`, `m_txt`, `m_sse`, `m_json`, `m_js`. +**`.mime`**: override the response content type. Values: `n_html`, `n_txt`, `n_es`, `n_json`, `n_js`. ```c -respond("plain_s", .mime = m_txt) +respond("plain_s", .mime = n_txt) ``` Combined: ```c mustache("not_found", "not_found_s"), -respond("not_found_s", .status = m_not_found) +respond("not_found_s", .status = n_not_found) ``` #### headers and cookies @@ -1721,24 +1721,24 @@ reroute("todo") // run that pipeline in-process, id read from context #### nest -Groups multiple steps into a single composite step. Useful when applying one `.if_context`/`.unless_context` to several steps without repeating it. +Groups multiple steps into a single composite step. Useful when applying one `.if_ctx`/`.not_ctx` to several steps without repeating it. **`.steps` *(by order)***: array of steps that run as a unit. ```c nest({sqlite_query({...}), emit("urgent_todo"), mustache("urgent", "urgent_s"), respond("urgent_s")}) ``` -**`.if_context` / `.unless_context`**: condition applied to the whole group. +**`.if_ctx` / `.not_ctx`**: condition applied to the whole group. ```c nest({sqlite_query({...}), emit("urgent_todo"), mustache("urgent", "urgent_s"), respond("urgent_s")}, - .if_context = "is_urgent") + .if_ctx = "is_urgent") ``` --- ### Imperative API -Functions called from `run()`/`run_worker()` blocks and `.call` functions to read and write context, allocate memory, raise errors, and manipulate tables and records. +Functions called from `run()`/`run_worker()` blocks and `.call` functions to read and write context, alloc memory, raise errors, and manipulate tables and records. * [context](#context-1) * [memory](#memory) @@ -1765,18 +1765,18 @@ set("is_urgent", "1"); if (has("user_id")) { ... } ``` -**`format(fmt)`**: returns `fmt` with `{{name}}` interpolations resolved against the current context. Same scopes and helpers as templates. +**`fmt(fmtstr)`**: returns `fmtstr` with `{{name}}` interpolations resolved against the current context. Same scopes and helpers as templates. ```c -auto greeting = format("Hello, {{user_name}}"); +auto greeting = fmt("Hello, {{user_name}}"); ``` Combined: ```c run(^(){ auto rows = get("todos"); - if (table_count(rows) > 5) { + if (tbl_len(rows) > 5) { set("is_urgent", "1"); - set("banner", format("{{user_name}} has more than 5 open todos")); + set("banner", fmt("{{user_name}} has more than 5 open todos")); } }) ``` @@ -1785,9 +1785,9 @@ run(^(){ Pipeline-arena allocation and deferred cleanup of foreign pointers. Both clear when the request completes. -**`allocate(bytes)`**: returns a buffer from the pipeline arena. Reclaimed automatically on request completion. +**`alloc(bytes)`**: returns a buffer from the pipeline arena. Reclaimed automatically on request completion. ```c -auto buf = allocate(256); +auto buf = alloc(256); ``` **`defer_free(ptr)`**: schedules `free()` for a pointer returned by an external library. Runs when the arena is released. @@ -1799,7 +1799,7 @@ defer_free(out); Combined: ```c run_worker(^(){ - auto url = allocate(512); + auto url = alloc(512); build_signed_url(url, 512, get("path")); set("signed_url", url); @@ -1813,19 +1813,19 @@ run_worker(^(){ Raise field-scoped errors from `run()` to trigger error/repair pipelines. Keys land in the `error:name` scope, visible to templates as `{{error:name}}`, `{{error_code:name}}`, and `{{error_message:name}}`. -**`error_set(name, err)`**: associates an error with `name` and triggers the nearest [error or repair pipeline](#error-and-repair-pipelines). +**`err_set(name, err)`**: associates an error with `name` and triggers the nearest [error or repair pipeline](#error-and-repair-pipelines). ```c -error_set("token", (error){ m_bad_request, "token has expired" }); +err_set("token", (err){ n_bad_request, "token has expired" }); ``` -**`error_get(name)`**: returns the `error` previously set on `name`. +**`err_get(name)`**: returns the `err` previously set on `name`. ```c -auto e = error_get("token"); +auto e = err_get("token"); ``` -**`error_has(name)`**: returns true when `name` has an error. +**`err_has(name)`**: returns true when `name` has an error. ```c -if (error_has("token")) { ... } +if (err_has("token")) { ... } ``` Combined: @@ -1833,8 +1833,8 @@ Combined: run(^(){ auto token = get("token"); if (!token || strlen(token) < 16) { - error_set("token", (error){ - m_bad_request, + err_set("token", (err){ + n_bad_request, "token must be at least 16 characters" }); } @@ -1845,46 +1845,46 @@ run(^(){ Tables are ordered collections of records, the shape `query()` produces and `fetch()` parses JSON into. Use these to build derived results. -**`table_new()`**: returns an empty table in the pipeline arena. +**`tbl_new()`**: returns an empty table in the pipeline arena. ```c -auto t = table_new(); +auto t = tbl_new(); ``` -**`table_count(t)`**: number of records in `t`. +**`tbl_len(t)`**: number of records in `t`. ```c -auto n = table_count(get("todos")); +auto n = tbl_len(get("todos")); ``` -**`table_get(t, i)`**: record at index `i`, or `nullptr` if out of range. +**`tbl_get(t, i)`**: record at index `i`, or `nullptr` if out of range. ```c -auto first = table_get(get("todos"), 0); +auto first = tbl_get(get("todos"), 0); ``` -**`table_add(t, r)`**: appends `r` to `t`. +**`tbl_add(t, r)`**: appends `r` to `t`. ```c -table_add(t, record_new()); +tbl_add(t, rec_new()); ``` -**`table_remove(t, r)`**: removes record `r` from `t`. +**`tbl_rem(t, r)`**: removes record `r` from `t`. ```c -table_remove(t, r); +tbl_rem(t, r); ``` -**`table_remove_at(t, i)`**: removes the record at index `i`. +**`tbl_rem_at(t, i)`**: removes the record at index `i`. ```c -table_remove_at(t, 0); +tbl_rem_at(t, 0); ``` Combined: ```c run(^(){ auto source = get("raw_users"); - auto active = table_new(); - for (int i = 0; i < table_count(source); i++) { - auto u = table_get(source, i); - auto status = record_get(u, "status"); + auto active = tbl_new(); + for (int i = 0; i < tbl_len(source); i++) { + auto u = tbl_get(source, i); + auto status = rec_get(u, "status"); if (status && strcmp(status, "active") == 0) { - table_add(active, u); + tbl_add(active, u); } } set("active_users", active); @@ -1895,35 +1895,35 @@ run(^(){ Records are name-value bags, the shape of one row from `query()` or one object from `fetch()`. All values are strings; see [Everything is a String](#everything-is-a-string). -**`record_new()`**: returns an empty record in the pipeline arena. +**`rec_new()`**: returns an empty record in the pipeline arena. ```c -auto r = record_new(); +auto r = rec_new(); ``` -**`record_get(r, name)`**: string value of `name`, or `nullptr` if absent. +**`rec_get(r, name)`**: string value of `name`, or `nullptr` if absent. ```c -auto title = record_get(r, "title"); +auto title = rec_get(r, "title"); ``` -**`record_set(r, name, value)`**: writes `value` to `name` on `r`. +**`rec_set(r, name, value)`**: writes `value` to `name` on `r`. ```c -record_set(r, "title", "New title"); +rec_set(r, "title", "New title"); ``` -**`record_remove(r, name)`**: removes `name` from `r`. +**`rec_rem(r, name)`**: removes `name` from `r`. ```c -record_remove(r, "draft"); +rec_rem(r, "draft"); ``` Combined: ```c run(^(){ auto todos = get("todos"); - for (int i = 0; i < table_count(todos); i++) { - auto t = table_get(todos, i); - auto title = record_get(t, "title"); + for (int i = 0; i < tbl_len(todos); i++) { + auto t = tbl_get(todos, i); + auto title = rec_get(t, "title"); if (title && strlen(title) > 40) { - record_set(t, "is_long", "1"); + rec_set(t, "is_long", "1"); } } }) @@ -1931,45 +1931,45 @@ run(^(){ ### Conditionals -Every step accepts `.if_context` and `.unless_context`, naming a context variable. They work for any context value: validated inputs, query results, framework flags like `is_htmx`, or flags set from `run()`. +Every step accepts `.if_ctx` and `.not_ctx`, naming a context variable. They work for any context value: validated inputs, query results, framework flags like `is_htmx`, or flags set from `run()`. -**`.if_context`**: context key. Step runs only when the value is present. +**`.if_ctx`**: context key. Step runs only when the value is present. ```c -mustache("fragment", "frag_s", .if_context = "is_htmx") +mustache("fragment", "frag_s", .if_ctx = "is_htmx") ``` -**`.unless_context`**: context key. Step runs only when the value is absent. +**`.not_ctx`**: context key. Step runs only when the value is absent. ```c -mustache("full_page", "page_s", .unless_context = "is_htmx") +mustache("full_page", "page_s", .not_ctx = "is_htmx") ``` For multi-state branching, set context flags from `run()`, then key downstream steps off them: ```c run(.call = classify_todo), -mustache("urgent_confirmation", "urgent_s", .if_context = "is_urgent"), -respond("urgent_s", .if_context = "is_urgent"), -mustache("standard_confirmation", "standard_s", .unless_context = "is_urgent"), -respond("standard_s", .unless_context = "is_urgent") +mustache("urgent_confirmation", "urgent_s", .if_ctx = "is_urgent"), +respond("urgent_s", .if_ctx = "is_urgent"), +mustache("standard_confirmation", "standard_s", .not_ctx = "is_urgent"), +respond("standard_s", .not_ctx = "is_urgent") ``` ### Iteration -`.map` and `.item` run a step once per row of a context table, all rows **concurrently**, like multiple items in `query()` or `fetch()`. With a `.set_key`, results are collected into a table aligned with the input, one entry per row. They differ in how each row reaches the step body: `.map` puts the row's fields in scope as bare `{{interpolations}}`; `.item` binds the row as a single-row table under a named key. +`.map` and `.map_key` run a step once per row of a context table, all rows **concurrently**, like multiple items in `query()` or `fetch()`. With a `.ctx_key`, results are collected into a table aligned with the input, one entry per row. They differ in how each row reaches the step body: `.map` puts the row's fields in scope as bare `{{interpolations}}`; `.map_key` binds the row as a single-row table under a named key. **`.map`**: name of a context table to iterate over. The row's fields land in scope as bare interpolations. ```c // One request per row in `users`, all concurrent. // Each row's `id` fills the URL; responses collected into `profiles`, aligned with `users`. -fetch({"https://api.users.dev/{{id}}", "profiles", .map = "users"}) +fetch({.url = "https://api.users.dev/{{id}}", .ctx_key = "profiles", .map = "users"}) ``` -**`.item`**: context key under which the current row is exposed as a single-row table. Pairs with `.map`. +**`.map_key`**: context key under which the current row is exposed as a single-row table. Pairs with `.map`. ```c // Render the `todo` template once per row of `todos`. -// `.item = "todo_d"` presents the current row as the single-row table `todo_d` +// `.map_key = "todo_d"` presents the current row as the single-row table `todo_d` // Rendered fragments are collected into `todo_s`, aligned with `todos`. -mustache("todo", "todo_s", .map = "todos", .item = "todo_d") +mustache("todo", "todo_s", .map = "todos", .map_key = "todo_d") ``` ### Modules and Composition @@ -2011,23 +2011,23 @@ config(todos){ respond("todos_s") }, .post = { - input({"title", m_not_empty}), + input({"title", n_not_empty}), sqlite_query({"todos_db", "create_todo"}), redirect("todos") } ); resource("todo", "/todos/:id", - .all = {input({"id", m_positive})}, + .all = {input({"id", n_positive})}, .delete = { - sqlite_query({"todos_db", "delete_todo", .must_exist = true}), + sqlite_query({"todos_db", "delete_todo", .err_on_empty = true}), redirect("todos") } ); } ``` -For `GET /todos/5` the executed order is: `input({"id", ...})` (resource `.all`), `logged_in()`, `session()` (module `middleware`), then the verb pipeline `sqlite_query({"get_todo", ..., .must_exist = true})`, `mustache("todo", "todo_s")`, `respond("todo_s")`. +For `GET /todos/5` the executed order is: `input({"id", ...})` (resource `.all`), `logged_in()`, `session()` (module `middleware`), then the verb pipeline `sqlite_query({"get_todo", ..., .err_on_empty = true})`, `mustache("todo", "todo_s")`, `respond("todo_s")`. **Complete module file.** A `blogs/blogs.c`: @@ -2036,9 +2036,9 @@ For `GET /todos/5` the executed order is: `input({"id", ...})` (resource `.all`) #include config(blogs){ - sqlite_database( + sqlite_config( .name = "blog_db", - .connect = "file:blogs.db?mode=rwc", + .conn = "file:blogs.db?mode=rwc", .migrations = {"create_blogs_table", "create_comments_table"} ); @@ -2085,7 +2085,7 @@ Bundled modules. Activate each by `#include`ing its header. #### htmx -Activate with `#include `. Serves the htmx runtime as the `{{> htmx }}` partial, and sets the `is_htmx` context flag on requests carrying the `HX-Request` header. Pair the flag with `.if_context`/`.unless_context` to return a fragment to htmx and a full page to a direct visit, or use `hx-boost` to upgrade ordinary links and forms into AJAX swaps. +Activate with `#include `. Serves the htmx runtime as the `{{> htmx }}` partial, and sets the `is_htmx` context flag on requests carrying the `HX-Request` header. Pair the flag with `.if_ctx`/`.not_ctx` to return a fragment to htmx and a full page to a direct visit, or use `hx-boost` to upgrade ordinary links and forms into AJAX swaps. ```c #include @@ -2095,10 +2095,10 @@ config(todos){ resource("todos", "/todos", .get = { sqlite_query({"todos_db", "get_todos", "todos_data"}), - mustache("todos_fragment", "frag_s", .if_context = "is_htmx"), - respond("frag_s", .if_context = "is_htmx"), - mustache("todos_page", "page_s", .unless_context = "is_htmx"), - respond("page_s", .unless_context = "is_htmx") + mustache("todos_fragment", "frag_s", .if_ctx = "is_htmx"), + respond("frag_s", .if_ctx = "is_htmx"), + mustache("todos_page", "page_s", .not_ctx = "is_htmx"), + respond("page_s", .not_ctx = "is_htmx") } ); } @@ -2112,27 +2112,27 @@ Include the runtime once in the page ``: #### datastar -Activate with `#include `. Serves the Datastar runtime as the `{{> datastar }}` partial and provides `datastar_sse()` for pushing reactive fragment and signal patches over an SSE channel. A page opens an SSE connection (a resource `.sse` channel); pipelines push patches to that channel, and Datastar applies them in the DOM. +Activate with `#include `. Serves the Datastar runtime as the `{{> datastar }}` partial and provides `datastar()` for pushing reactive fragment and signal patches over an SSE channel. A page opens an SSE connection (a resource `.sse` channel); pipelines push patches to that channel, and Datastar applies them in the DOM. -`datastar_sse()` patches a context value into the page by CSS selector. The first value is the channel (supports `{{interpolation}}`). +`datastar()` patches a context value into the page by target (element id or CSS selector). The first value is the channel (supports `{{interpolation}}`). -**`.channel` *(by order)***: channel to push to. +**`.chan` *(by order)***: channel to push to. ```c mustache("todo_row", "todo_row_s"), -datastar_sse("todos:{{user_id}}", .target = "#todo-list", .mode = mode_append, .elements = "todo_row_s") +datastar("todos:{{user_id}}", .target = "#todo-list", .mode = ds_append, .elements = "todo_row_s") ``` -**`.target`**: CSS selector for the element to patch; supports `{{interpolation}}`. +**`.target`**: element id or CSS selector for the element to patch; supports `{{interpolation}}`. ```c .target = "#todo-{{id}}" ``` -**`.mode`**: how the rendered fragment is applied to the target (a `datastar_mode`). +**`.mode`**: how the rendered fragment is applied to the target (a `datastar_m`). ```c -.mode = mode_replace +.mode = ds_replace ``` -**`.elements`**: context key holding the rendered HTML fragment to patch in. Not required for `mode_remove`. +**`.elements`**: context key holding the rendered HTML fragment to patch in. Not required for `ds_remove`. ```c .elements = "todo_row_s" ``` @@ -2147,7 +2147,7 @@ datastar_sse("todos:{{user_id}}", .target = "#todo-list", .mode = mode_append, . .js = "window.scrollTo(0, document.body.scrollHeight)" ``` -**Patch modes (`datastar_mode`):** `mode_outer`, `mode_inner`, `mode_replace`, `mode_prepend`, `mode_append`, `mode_before`, `mode_after`, `mode_remove`. +**Patch modes (`datastar_m`):** `ds_outer`, `ds_inner`, `ds_replace`, `ds_prepend`, `ds_append`, `ds_before`, `ds_after`, `ds_remove`. Worked example: a POST inserts a row, returns it with `RETURNING`, appends it to every connected client's list. @@ -2157,14 +2157,14 @@ resource("todos", "/todos", .sse = {"todos:{{user_id}}"}, .post = { - input({"title", m_not_empty}), + input({"title", n_not_empty}), // RETURNING gives the new row back; capture it under "todo". - sqlite_query({"todos_db", "insert_todo", "todo", .must_exist = true}), + sqlite_query({"todos_db", "insert_todo", "todo", .err_on_empty = true}), // Render the new row, then patch it into the list for everyone on the channel. mustache("todo_row", "todo_row_s"), - datastar_sse("todos:{{user_id}}", + datastar("todos:{{user_id}}", .target = "#todo-list", - .mode = mode_append, + .mode = ds_append, .elements = "todo_row_s" ) } @@ -2173,10 +2173,10 @@ resource("todos", "/todos", Removing an element needs only a selector: ```c -datastar_sse("todos:{{user_id}}", .target = "#todo-{{id}}", .mode = mode_remove) +datastar("todos:{{user_id}}", .target = "#todo-{{id}}", .mode = ds_remove) ``` -Datastar sets a context flag on requests it originates, usable with `.if_context`. +Datastar sets a context flag on requests it originates, usable with `.if_ctx`. Include the runtime once in the page ``: ```html @@ -2281,14 +2281,14 @@ config(app){ #### database engines -Each engine is its own module: `#include` its header, then use `_database(...)` to register and `_query({...})` as a pipeline step. They share `database_config` and `query_config` from [Databases](#databases) and [query](#query); only `.connect` is engine-specific. +Each engine is its own module: `#include` its header, then use `_config(...)` to register and `_query({...})` as a pipeline step. They share `database_config` and `query_config` from [Databases](#databases) and [query](#query); only `.conn` is engine-specific. ```c -#include // sqlite_database("...", "file:app.db?mode=rwc", ...); sqlite_query({...}); -#include // postgres_database("...", "postgres://...", ...); postgres_query({...}); -#include // mysql_database("...", "mysql://...", ...); mysql_query({...}); -#include // redis_database("...", "redis://...", ...); redis_query({...}); -#include // duckdb_database("...", "duckdb:analytics.db", ...); duckdb_query({...}); +#include // sqlite_config("...", "file:app.db?mode=rwc", ...); sqlite_query({...}); +#include // postgres_config("...", "postgres://...", ...); postgres_query({...}); +#include // mysql_config("...", "mysql://...", ...); mysql_query({...}); +#include // redis_config("...", "redis://...", ...); redis_query({...}); +#include // duckdb_config("...", "duckdb:analytics.db", ...); duckdb_query({...}); ``` ### Static Files @@ -2347,7 +2347,7 @@ For dependencies that aren't plain source (system packages, build tooling), prov ### Data-Oriented Pipelines -Each module's `config(name)` runs once at boot. Registration calls (`resource()`, `sqlite_database()`, `task()`, `middleware()`, `publish()`, etc.) are processed into an execution graph with precompiled pipelines, queries, and templates. Each incoming request executes its matching pipeline as a sequence of pre-warmed steps. +Each module's `config(name)` runs once at boot. Registration calls (`resource()`, `sqlite_config()`, `task()`, `middleware()`, `publish()`, etc.) are processed into an execution graph with precompiled pipelines, queries, and templates. Each incoming request executes its matching pipeline as a sequence of pre-warmed steps. ![Boot-Time Compilation](./images/10-boot-time-compilation.svg) @@ -2359,7 +2359,7 @@ Nerak runs two types of reactors backed by a shared thread pool. The request/tas - **Task reactors** handle background work; each gets a dedicated core and runs cron schedules and dispatched jobs from the task database. - **Shared thread pool** handles CPU-bound and blocking I/O work on the remaining cores. -A `run_worker()` step dispatches work to the shared pool, releasing the reactor; the pipeline resumes on the original reactor when the call completes. `run()` runs inline on the reactor for short, non-blocking logic. `run_task()` runs a named task inline as part of the pipeline. `dispatch_task()` adds a durable job to the task database, picked up by task reactors. Any pipeline or task can call all four. +A `run_worker()` step dispatches work to the shared pool, releasing the reactor; the pipeline resumes on the original reactor when the call completes. `run()` runs inline on the reactor for short, non-blocking logic. `run_task()` runs a named task inline as part of the pipeline. `dispatch()` adds a durable job to the task database, picked up by task reactors. Any pipeline or task can call all four. Application code does not manage threads, mutexes, or locks. The architecture isolates request state to the pipeline's context. @@ -2371,7 +2371,7 @@ Nerak prevents common C and web vulnerabilities at the framework level. #### Memory Safety -Each reactor maintains a pool of arena allocators. When a request arrives, the pipeline is assigned an arena, and all allocations draw from it. When the pipeline completes, the arena is cleared and returned to the pool. Application code does not call `malloc` or `free` (use `allocate()` and `defer_free()` from the [Imperative API](#memory) for raw buffers), avoiding leaks, double-frees, and use-after-free. +Each reactor maintains a pool of arena allocators. When a request arrives, the pipeline is assigned an arena, and all allocations draw from it. When the pipeline completes, the arena is cleared and returned to the pool. Application code does not call `malloc` or `free` (use `alloc()` and `defer_free()` from the [Imperative API](#memory) for raw buffers), avoiding leaks, double-frees, and use-after-free. All framework data structures (tables, records, strings) enforce bounds checking. Out-of-bounds reads and missing context values return `nullptr` rather than faulting. Pipelines exceeding their memory limit (default 5MB, configurable in `compose.yml`) abort with a 500, mitigating OOM denial-of-service. diff --git a/llms-full.txt b/llms-full.txt index 8ceeba5..3eb8fa5 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -31,7 +31,7 @@ Every non-`.c` file is an asset, loaded into context under its **basename** (the ## Templates (Mustache + MDM) -Full Mustache base spec EXCEPT dot notation. Steps: `mustache(template_key, set_key)` (Mustache), `mdm(template_key, set_key)` (Markdown+Mustache), `json(template_key, set_key)` (JSON). All auto-escape (XSS-safe) except explicit unescape. +Full Mustache base spec EXCEPT dot notation. Steps: `mustache(template_ctx_key, ctx_key)` (Mustache), `mdm(template_ctx_key, ctx_key)` (Markdown+Mustache), `json(template_ctx_key, ctx_key)` (JSON). All auto-escape (XSS-safe) except explicit unescape. - Interpolation: `{{name}}` (HTML-escaped), `{{{name}}}` or `{{&name}}` (raw). - Sections: `{{#name}}...{{/name}}` (truthy; iterates arrays). Inverted: `{{^name}}...{{/name}}` (falsy/empty). @@ -98,7 +98,7 @@ Every step accepts `.if_ctx` / `.not_ctx` (conditionals) and `.map` / `.map_key` ### input — validate request params On success promotes `input:name` → app scope. On failure writes `error:name` and raises `n_bad_request` (400) to the nearest error/repair pipeline. All validations run before the error fires, so all field errors are available together. -By order: `{param_key, matches, message}`. Named: `.opt` (skip if absent), `.def` (default when absent). `matches` is a regex string or a built-in macro. Define custom macros: `#define n_zipcode "^\\d{5}$"`. +By order: `{ctx_key, regex, err_msg}`. Named: `.opt` (skip if absent), `.def` (default when absent). `regex` is a regex string or a built-in macro. Define custom macros: `#define n_zipcode "^\\d{5}$"`. ```c input( {"email", n_email, "must be a valid email"}, @@ -112,7 +112,7 @@ For non-regex checks (uniqueness, cross-field), pair with a query + `run()` call ### query — `_query(...)` `sqlite_query`, `postgres_query`, `mysql_query`, `redis_query`, `duckdb_query`. Multiple items in one call run CONCURRENTLY. Prepared statements: interpolated `{{values}}` are bound, not spliced (SQL-injection-safe). Transactions: put `BEGIN`/`COMMIT`/`ROLLBACK` in SQL. Results are always tables (even single rows). -By order per item: `{db_name, sql_key, set_key}`. `set_key` optional (omit for inserts without `RETURNING`). Named: `.err_on_empty = true` (404 if zero rows), `.if_ctx`/`.not_ctx` (per item). +By order per item: `{db, q, ctx_key}`. `ctx_key` optional (omit for inserts without `RETURNING`). Named: `.err_on_empty = true` (404 if zero rows), `.if_ctx`/`.not_ctx` (per item). ```c sqlite_query( {"todos_db", "get_todos", "todos_data"}, @@ -124,7 +124,7 @@ sqlite_query({"todos_db", "create_todo"}) // no result captured SQL file uses bound params: `select id, title from todos where id = {{id}};` and `insert into todos(title) values({{title}});`. ### join — nest one table's records into another (in-memory) -By order: `join(parent_key, parent_field, child_key, child_field)`. Optional `.parent_join_key` (new field name on parent records; defaults to the child table name). After it, each parent record gains a field holding its matched child records. +By order: `join(parent_ctx_key, parent_field_key, child_ctx_key, child_field_key)`. Optional `.parent_join_key` (new field name on parent records; defaults to the child table name). After it, each parent record gains a field holding its matched child records. ```c // before: { blog:[{id,...}], comments:[{id,blog_id,...}] } join("blog", "id", "comments", "blog_id") @@ -132,13 +132,13 @@ join("blog", "id", "comments", "blog_id") ``` ### fetch — outbound HTTP; JSON parses into tables/records -Multiple items run CONCURRENTLY. Fields (all named): `.url` (interpolation ok), `.ctx_key` (response key), `.meth` (`n_get` default, `n_post n_put n_patch n_delete n_sse`), `.headers` (array of `{name,value}`), `.json_ctx_key` (context key serialized as JSON body), `.json` (literal JSON body string, interpolation ok), `.txt` (context key as plain-text body), `.if_ctx`/`.not_ctx`. +Multiple items run CONCURRENTLY. Fields (all named): `.url` (interpolation ok), `.ctx_key` (response key), `.method` (`n_get` default, `n_post n_put n_patch n_delete n_sse`), `.headers` (array of `{name,value}`), `.json_ctx_key` (context key serialized as JSON body), `.json` (literal JSON body string, interpolation ok), `.txt` (context key as plain-text body), `.if_ctx`/`.not_ctx`. ```c fetch( {.url = "https://api.weather.dev/now?city={{city}}", .ctx_key = "weather"}, {.url = "https://api.news.dev/headlines?topic={{topic}}", .ctx_key = "news"} ) -fetch({.url = "https://api.payments.dev/charge", .ctx_key = "receipt", .meth = n_post, .json_ctx_key = "order", +fetch({.url = "https://api.payments.dev/charge", .ctx_key = "receipt", .method = n_post, .json_ctx_key = "order", .headers = {{"Authorization","Bearer {{api_key}}"}, {"Idempotency-Key","{{order_id}}"}}}) ``` @@ -165,16 +165,16 @@ run(.call = assign_opponents) `dispatch("task_name")`. Enqueues a named task as a durable background job and returns immediately; task reactors pick it up. Checkpointed after each step, so a crash mid-task resumes where it stopped. Requires `#include `. Task must be defined with `task(...)`. ### sse — push a Server-Sent Event -With `.chan` (by order, first value, supports interpolation) broadcasts to all clients on the channel; without it, returns to the requester. Named: `.evt` (event line), `.d` (array of strings, one per data line), `.cmt` (comment/keep-alive line). +With `.chan` (by order, first value, supports interpolation) broadcasts to all clients on the channel; without it, returns to the requester. Named: `.event` (event line), `.data` (array of strings, one per data line), `.comment` (comment/keep-alive line). ```c -sse("todos:{{user_id}}", .evt = "todo_updated", .d = {"id: {{todo_id}}", "title: {{title}}"}) +sse("todos:{{user_id}}", .event = "todo_updated", .data = {"id: {{todo_id}}", "title: {{title}}"}) ``` ### render — mustache / mdm / json -`mustache(template_key, set_key)`, `mdm(...)`, `json(...)`. By order: template context key, then set_key for output. (See Templates.) +`mustache(template_ctx_key, ctx_key)`, `mdm(...)`, `json(...)`. By order: template context key, then `ctx_key` for output. (See Templates.) ### respond — send the response -By order: `respond(context_key)`. Named: `.status` (default `n_ok`; values `n_ok` 200, `n_created` 201, `n_redirect` 302, `n_bad_request` 400, `n_not_authorized` 401, `n_not_found` 404, `n_error` 500), `.mime` (override; same values as resource `.mime`). +By order: `respond(ctx_key)`. Named: `.status` (default `n_ok`; values `n_ok` 200, `n_created` 201, `n_redirect` 302, `n_bad_request` 400, `n_not_authorized` 401, `n_not_found` 404, `n_error` 500), `.mime` (override; same values as resource `.mime`). ```c mustache("404","not_found_s"), respond("not_found_s", .status = n_not_found) ``` @@ -222,9 +222,9 @@ respond("page_s", .not_ctx = "is_htmx") ## Iteration -`.map = "table"` runs a step once per row, ALL ROWS CONCURRENTLY; the row's fields land in scope as bare `{{interpolations}}`. `.map_key = "key"` (pairs with `.map`) exposes the current row as a single-row table under `key` (useful for `mustache`). With a `set_key`, results collect into a table aligned with the input (one entry per row). +`.map = "table"` runs a step once per row, ALL ROWS CONCURRENTLY; the row's fields land in scope as bare `{{interpolations}}`. `.map_key = "key"` (pairs with `.map`) exposes the current row as a single-row table under `key` (useful for `mustache`). With a `ctx_key`, results collect into a table aligned with the input (one entry per row). ```c -fetch({"https://api.users.dev/{{id}}", "profiles", .map = "users"}) // per-row fan-out +fetch({.url = "https://api.users.dev/{{id}}", .ctx_key = "profiles", .map = "users"}) // per-row fan-out mustache("todo","todo_s", .map = "todos", .map_key = "todo_d") // render per row, collect into todo_s ``` @@ -262,7 +262,7 @@ A task is a named, reusable pipeline defined inside a module with `task("name", // reusable, run inline via run_task("recount_todos"): task("recount_todos", { sqlite_query({"todos_db","recount"}) }, .accepts = {"user_id"}); // durable background job via dispatch("notify_new_todo") (needs #include ): -task("notify_new_todo", { fetch({.url = "https://api.push.dev/notify", .meth = n_post, .json = "{\"text\":\"New todo: {{title}}\"}"}) }, .accepts = {"title"}); +task("notify_new_todo", { fetch({.url = "https://api.push.dev/notify", .method = n_post, .json = "{\"text\":\"New todo: {{title}}\"}"}) }, .accepts = {"title"}); // recurring on a schedule, no caller: task("daily_digest", { sqlite_query({"todos_db","digest"}), emit("digest_ready") }, .cron = "0 8 * * *"); ```