From f0a5958ad8a1b275a1b0b619832661f89ccc5890 Mon Sep 17 00:00:00 2001 From: Nick Ricketts Date: Sun, 12 Jul 2026 23:35:40 -0500 Subject: [PATCH] nerak repo --- README.md | 1055 ++++++++++++++++++++++++++--------------------------- 1 file changed, 525 insertions(+), 530 deletions(-) diff --git a/README.md b/README.md index 0ee346a..111022c 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Nerak is a declarative framework for building asynchronous web applications in C Everything runs in Docker. No other local dependencies. -``` +```bash mkdir myapp && cd myapp wget https://docker.nightshadecoder.dev/nerak/compose.yml @@ -38,7 +38,7 @@ docker compose up Create `app.c` with the example below. Nerak watches for changes and hot-reloads on save. Use your own editor, or attach to the built-in TUI with `docker compose attach nerak` for an integrated editor, LSP, and console. -``` +```c #include config(app){ @@ -99,7 +99,7 @@ Each `resource(...)` declares a named URL endpoint; each verb pipeline is a list Both pages share a layout, so `home` doubles as the layout: it declares the nav and a `{{$body}}` block whose default is the welcome page. The `todos` page extends it with `{{< home}}...{{/home}}`, overriding that block. Any template that declares a `{{$block}}` can be a parent; there is no special layout type. **`home.html`** -``` +```html @@ -113,7 +113,7 @@ Both pages share a layout, so `home` doubles as the layout: it declares the nav ``` **`todos.html`** -``` +```html {{< home}} {{$body}}

My Todos

@@ -123,7 +123,7 @@ Both pages share a layout, so `home` doubles as the layout: it declares the nav ``` **`app.c`** -``` +```c #include config(app){ @@ -146,12 +146,12 @@ See [Resource Pipelines](#resource-pipelines) and [Templates](#templates). ### 2. Show Data -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`. +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`. Three new SQL files: **`create_todos_table.sql`** -``` +```sql CREATE TABLE todos ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL @@ -159,19 +159,19 @@ CREATE TABLE todos ( ``` **`seed_todos.sql`** -``` +```sql INSERT INTO todos(title) VALUES('Learn Nerak'); ``` **`get_todos.sql`** -``` +```sql select id, title from todos; ``` Render the rows Nerak stores under `todos_data`: **`todos.html`** -``` +```diff {{< home}} {{$body}}

My Todos

@@ -188,16 +188,16 @@ Render the rows Nerak stores under `todos_data`: Wire up the module, database, and query: **`app.c`** -``` +```diff #include +#include config(app){ -+ sqlite_config( -+ "todos_db", -+ "file:todos.db?mode=rwc", -+ {"create_todos_table"}, -+ {"seed_todos"} ++ sqlite_database( ++ .name = "todos_db", ++ .connect = "file:todos.db?mode=rwc", ++ .migrations = {"create_todos_table"}, ++ .seeds = {"seed_todos"} + ); + resource("home", "/", @@ -223,14 +223,14 @@ Query parameters: database name, SQL asset, context key for the result table (`t Add a `.post` verb that validates, inserts, and redirects (POST-redirect-GET). A resource-scoped `.errors` handler re-renders the form on validation failure. **`create_todo.sql`** -``` +```sql insert into todos(title) values({{title}}); ``` Add the form, repopulating the field and showing the error after a failed submit: **`todos.html`** -``` +```diff {{< home}} {{$body}}

My Todos

@@ -254,16 +254,16 @@ Add the form, repopulating the field and showing the error after a failed submit Add a `.post` verb and an `.errors` handler: **`app.c`** -``` +```diff #include #include config(app){ - sqlite_config( - "todos_db", - "file:todos.db?mode=rwc", - {"create_todos_table"}, - {"seed_todos"} + sqlite_database( + .name = "todos_db", + .connect = "file:todos.db?mode=rwc", + .migrations = {"create_todos_table"}, + .seeds = {"seed_todos"} ); resource("home", "/", @@ -281,18 +281,18 @@ Add a `.post` verb and an `.errors` handler: - } + }, + .post = { -+ input({"title", n_not_empty}), ++ input({"title", m_not_empty}), + sqlite_query({"todos_db", "create_todo"}), + redirect("todos") + }, + .errors = { -+ {n_bad_request, {reroute("todos")}} ++ {m_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, `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). +`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). ### 4. Nested Data @@ -301,7 +301,7 @@ A `/todos/:id` page fetches a todo and its comments concurrently, then nests the Three new SQL files and one new template: **`create_comments_table.sql`** -``` +```sql CREATE TABLE comments ( id INTEGER PRIMARY KEY AUTOINCREMENT, todo_id INTEGER NOT NULL REFERENCES todos(id), @@ -310,19 +310,19 @@ CREATE TABLE comments ( ``` **`get_todo.sql`** -``` +```sql select id, title from todos where id = {{id}}; ``` **`get_comments.sql`** -``` +```sql select id, todo_id, body from comments where todo_id = {{id}}; ``` Enter `{{#todo_data}}` first; after the join, `comments` lives inside each todo record: **`todo.html`** -``` +```html {{< home}} {{$body}} {{#todo_data}} @@ -341,7 +341,7 @@ Enter `{{#todo_data}}` first; after the join, `comments` lives inside each todo Link each list item to its detail page. `{{url:todo}}` resolves to the `todo` resource's pattern (`/todos/:id`) and fills `:id` from the current row, so no argument is needed: **`todos.html`** -``` +```diff {{#todos_data}} -
  • {{title}}
  • +
  • {{title}}
  • @@ -351,17 +351,17 @@ Link each list item to its detail page. `{{url:todo}}` resolves to the `todo` re Register the migration and add a `todo` resource: **`app.c`** -``` +```diff #include #include config(app){ - sqlite_config( - "todos_db", - "file:todos.db?mode=rwc", -- {"create_todos_table"}, -+ {"create_todos_table", "create_comments_table"}, - {"seed_todos"} + 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"} ); resource("home", "/", @@ -378,20 +378,20 @@ Register the migration and add a `todo` resource: respond("todos_s") }, .post = { - input({"title", n_not_empty}), + input({"title", m_not_empty}), sqlite_query({"todos_db", "create_todo"}), redirect("todos") }, .errors = { - {n_bad_request, {reroute("todos")}} + {m_bad_request, {reroute("todos")}} } ); + + resource("todo", "/todos/:id", + .get = { -+ input({"id", n_int}), ++ input({"id", m_integer}), + sqlite_query( -+ {"todos_db", "get_todo", "todo_data", .err_on_empty = true}, ++ {"todos_db", "get_todo", "todo_data", .must_exist = 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}}`. `.err_on_empty = 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}}`. `.must_exist = true` returns 404 when the id matches nothing. See [join](#join) and [query](#query). ### 5. Calling APIs @@ -411,7 +411,7 @@ Both queries in one `sqlite_query()` call run concurrently. `join()` lifts `comm Show the responses on the home page: **`home.html`** -``` +```diff @@ -436,23 +436,23 @@ Show the responses on the home page: Fetch both services concurrently before rendering: **`app.c`** -``` +```diff #include #include config(app){ - sqlite_config( - "todos_db", - "file:todos.db?mode=rwc", - {"create_todos_table", "create_comments_table"}, - {"seed_todos"} + sqlite_database( + .name = "todos_db", + .connect = "file:todos.db?mode=rwc", + .migrations = {"create_todos_table", "create_comments_table"}, + .seeds = {"seed_todos"} ); resource("home", "/", .get = { + fetch( -+ {.url = "https://api.quotes.dev/random", .ctx_key = "quote"}, -+ {.url = "https://api.weather.dev/now", .ctx_key = "weather"} ++ {"https://api.quotes.dev/random", "quote"}, ++ {"https://api.weather.dev/now", "weather"} + ), mustache("home", "home_s"), respond("home_s") @@ -464,12 +464,12 @@ 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("name")` (from `dispatch.h`). +A task is a named, reusable pipeline. Define it once with optional `.cron`; dispatch durable background runs with `dispatch_task("name")` (from `dispatch.h`). Two new SQL files: **`create_daily_stats_table.sql`** -``` +```sql CREATE TABLE daily_stats ( id INTEGER PRIMARY KEY AUTOINCREMENT, recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, @@ -478,25 +478,25 @@ CREATE TABLE daily_stats ( ``` **`record_daily_stats.sql`** -``` +```sql insert into daily_stats(todo_count) select count(*) from todos; ``` Register the migration, define the tasks, dispatch them from the POST: **`app.c`** -``` +```diff #include #include +#include config(app){ - 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"} + 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"} ); + task("record_daily_stats", { @@ -505,8 +505,8 @@ Register the migration, define the tasks, dispatch them from the POST: + + task("notify_new_todo", { + fetch({ -+ .url = "https://api.push.dev/notify", -+ .meth = 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( - {.url = "https://api.quotes.dev/random", .ctx_key = "quote"}, - {.url = "https://api.weather.dev/now", .ctx_key = "weather"} + {"https://api.quotes.dev/random", "quote"}, + {"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", n_not_empty}), + input({"title", m_not_empty}), sqlite_query({"todos_db", "create_todo"}), -+ dispatch("notify_new_todo"), ++ dispatch_task("notify_new_todo"), redirect("todos") }, .errors = { - {n_bad_request, {reroute("todos")}} + {m_bad_request, {reroute("todos")}} } ); resource("todo", "/todos/:id", .get = { - input({"id", n_int}), + input({"id", m_integer}), sqlite_query( - {"todos_db", "get_todo", "todo_data", .err_on_empty = true}, + {"todos_db", "get_todo", "todo_data", .must_exist = 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(...)` 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). +`.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). ### 7. Modules and Events @@ -585,17 +585,17 @@ This step moves todos into its own module and adds an `activity` module that rec ``` **`app.c`** -``` +```diff #include -#include -#include config(app){ -- sqlite_config( -- "todos_db", -- "file:todos.db?mode=rwc", -- {"create_todos_table", "create_comments_table", "create_daily_stats_table"}, -- {"seed_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"} - ); - - 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({ -- .url = "https://api.push.dev/notify", -- .meth = 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( - {.url = "https://api.quotes.dev/random", .ctx_key = "quote"}, - {.url = "https://api.weather.dev/now", .ctx_key = "weather"} + {"https://api.quotes.dev/random", "quote"}, + {"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", n_not_empty}), +- input({"title", m_not_empty}), - sqlite_query({"todos_db", "create_todo"}), -- dispatch("notify_new_todo"), +- dispatch_task("notify_new_todo"), - redirect("todos") - }, - .errors = { -- {n_bad_request, {reroute("todos")}} +- {m_bad_request, {reroute("todos")}} - } - ); - - resource("todo", "/todos/:id", - .get = { -- input({"id", n_int}), +- input({"id", m_integer}), - sqlite_query( -- {"todos_db", "get_todo", "todo_data", .err_on_empty = true}, +- {"todos_db", "get_todo", "todo_data", .must_exist = true}, - {"todos_db", "get_comments", "comments"} - ), - join("todo_data", "id", "comments", "todo_id"), @@ -656,7 +656,7 @@ This step moves todos into its own module and adds an `activity` module that rec Add an Activity link to the shared nav: **`home.html`** -``` +```diff - @@ -679,18 +679,18 @@ Add an Activity link to the shared nav: The todos logic moves into the module unchanged, gaining a `publish()` and an `emit()` step. Both todo resources come along: **`todos/todos.c`** -``` +```c #include #include #include #include config(todos){ - sqlite_config( - "todos_db", - "file:todos.db?mode=rwc", - {"create_todos_table", "create_comments_table", "create_daily_stats_table"}, - {"seed_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"} ); publish("todo_created", @@ -703,8 +703,8 @@ config(todos){ task("notify_new_todo", { fetch({ - .url = "https://api.push.dev/notify", - .meth = 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", n_not_empty}), + input({"title", m_not_empty}), sqlite_query({"todos_db", "create_todo"}), - dispatch("notify_new_todo"), + dispatch_task("notify_new_todo"), emit("todo_created"), redirect("todos") }, .errors = { - {n_bad_request, {reroute("todos")}} + {m_bad_request, {reroute("todos")}} } ); resource("todo", "/todos/:id", .get = { - input({"id", n_int}), + input({"id", m_integer}), sqlite_query( - {"todos_db", "get_todo", "todo_data", .err_on_empty = true}, + {"todos_db", "get_todo", "todo_data", .must_exist = true}, {"todos_db", "get_comments", "comments"} ), join("todo_data", "id", "comments", "todo_id"), @@ -745,7 +745,7 @@ config(todos){ The `activity` module owns its own table, query, template, and subscriber. Nothing in it references the todos module: **`activity/create_activity_table.sql`** -``` +```sql CREATE TABLE activities ( id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT NOT NULL, @@ -755,17 +755,17 @@ CREATE TABLE activities ( ``` **`activity/insert_activity.sql`** -``` +```sql insert into activities(kind, ref) values('created', {{title}}); ``` **`activity/get_activities.sql`** -``` +```sql select kind, ref, created_at from activities order by created_at desc; ``` **`activity/activity.html`** -``` +```html {{< home}} {{$body}}

    Activity

    @@ -779,16 +779,16 @@ select kind, ref, created_at from activities order by created_at desc; ``` **`activity/activity.c`** -``` +```c #include #include #include config(activity){ - sqlite_config( - "activity_db", - "file:activity.db?mode=rwc", - {"create_activity_table"} + sqlite_database( + .name = "activity_db", + .connect = "file:activity.db?mode=rwc", + .migrations = {"create_activity_table"} ); subscribe("todo_created", { @@ -849,34 +849,34 @@ Base-spec features: Built-in helpers use `{{helper:args}}` syntax. Arguments are colon-separated, in order; each can be a literal or a context key. **`{{precision:field:N}}`**: format a numeric value with N decimal places. -``` +```html

    Total: ${{precision:total:2}}

    ``` **`{{input:field}}`**: raw, unvalidated request parameter from the `input` scope. Used to repopulate form fields after a validation error. -``` +```html ``` **`{{error:field}}`**: truthy when `field` has an error. Used as a Mustache section to conditionally render markup. -``` +```html {{#error:title}} invalid {{/error:title}} ``` -**`{{error_message:field}}`**: human-readable message for a field error, from `input()`'s message or from `err_set()`. -``` +**`{{error_message:field}}`**: human-readable message for a field error, from `input()`'s message or from `error_set()`. +```html {{error_message:title}} ``` **`{{error_code:field}}`**: HTTP status code associated with a field error (e.g. `400`, `404`). -``` +```html

    Code: {{error_code:title}}

    ``` **`{{url:name}}`**: resolve a resource name to its URL. `:params` in the URL pattern are read from the current scope by name. -``` +```html All {{#todos_data}} {{title}} @@ -887,17 +887,17 @@ Built-in helpers use `{{helper:args}}` syntax. Arguments are colon-separated, in ``` **`{{asset:filename}}`**: resolve a file in `public/` to a cache-busted URL (content checksum + immutable cache headers). See [Static Files](#static-files). -``` +```html ``` **`{{csrf:param}}`**: emit a CSRF token for URL query strings. Generates a random hash, sets it on an httponly/secure/samesite cookie, outputs `csrf=` inline. -``` +```html Log out ``` **`{{csrf:input}}`**: emit a hidden `` carrying a CSRF token, for `
    ` use. Same cookie behavior as `{{csrf:param}}`. -``` +```html {{csrf:input}} @@ -906,12 +906,12 @@ Built-in helpers use `{{helper:args}}` syntax. Arguments are colon-separated, in ``` **`{{http_verb:param}}`**: emit an `http_method` override for URL query strings, letting a link reach a non-GET verb. One per verb: `{{http_get:param}}`, `{{http_post:param}}`, `{{http_put:param}}`, `{{http_patch:param}}`, `{{http_delete:param}}`, `{{http_sse:param}}`; each outputs `http_method=`. See [Resource Pipelines](#resource-pipelines). -``` +```html Delete ``` **`{{http_verb:input}}`**: emit a hidden `` carrying the `http_method` override, letting a `` (GET/POST only) reach any verb. One per verb: `{{http_get:input}}`, `{{http_post:input}}`, `{{http_put:input}}`, `{{http_patch:input}}`, `{{http_delete:input}}`, `{{http_sse:input}}`; each outputs ``. -``` +```html {{csrf:input}} {{http_delete:input}} @@ -930,7 +930,7 @@ An asset's name is the filename's basename (the part before the first dot). `get `mustache()`, `mdm()`, and the engine `*_query()` steps read a string from context by key and interpret it as a template or SQL. The step interprets whatever is under the key when it runs. `context(name, value)` does the same seeding from a string instead of a file. Useful for content too small to warrant its own file: -``` +```c context("hello", "

    Hello, world!

    "); // then mustache("hello", "hello_s") context("ping", "select 1"); // then sqlite_query({"db", "ping"}) ``` @@ -954,43 +954,43 @@ 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 `_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. +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. -Multi-tenant databases use `{{interpolation}}` in `.conn`. Connections are pooled with LRU eviction. +Multi-tenant databases use `{{interpolation}}` in `.connect`. Connections are pooled with LRU eviction. -**`.nm` *(by order)***: database identifier, referenced by the first value of `query()` steps. -``` -sqlite_config("todos_db", "file:todos.db?mode=rwc", {"create_todos_table"}) +**`.name`**: identifier referenced by the first value of `query()` steps. +```c +.name = "todos_db" ``` -**`.conn` *(by order)***: engine-specific connection string. Supports `{{interpolation}}` for multi-tenancy. -``` -sqlite_config("todos_db", "file:{{user_id}}_todo.db?mode=rwc", {"create_todos_table"}) +**`.connect`**: engine-specific connection string. Supports `{{interpolation}}` for multi-tenancy. +```c +.connect = "file:{{user_id}}_todo.db?mode=rwc" ``` -**`.migrations` *(by order)***: array of SQL migration entries, applied once each in order. Each entry is a context key holding the SQL. -``` -sqlite_config("todos_db", "file:todos.db?mode=rwc", {"create_todos_table", "create_comments_table"}) +**`.migrations`**: array of SQL migration entries, applied once each in order. Each entry is a context key holding the SQL. +```c +.migrations = {"create_todos_table", "create_comments_table"} ``` -**`.seeds` *(by order)***: array of SQL seed entries, applied once each in order. Each entry is a context key holding the SQL. -``` -sqlite_config("todos_db", "file:todos.db?mode=rwc", {"create_todos_table"}, {"seed_todos"}) +**`.seeds`**: array of SQL seed entries, applied once each in order. Each entry is a context key holding the SQL. +```c +.seeds = {"seed_todos"} ``` Combined: -``` +```c #include -sqlite_config( - "blog_db", - "file:{{user_id}}_blog.db?mode=rwc", - {"create_blogs_table", "create_comments_table"}, - {"seed_blogs"} +sqlite_database( + .name = "blog_db", + .connect = "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_config()`, and likewise `postgres_*`, `mysql_*`, `redis_*`, `duckdb_*`. +**Engine include / query / register:** `#include ` + `sqlite_query()` + `sqlite_database()`, and likewise `postgres_*`, `mysql_*`, `redis_*`, `duckdb_*`. ![Database Multi-Tenancy](./images/09-database-multi-tenancy.svg) @@ -1001,31 +1001,31 @@ Nerak is resource-based, not route-based. Each `resource(...)` defines a named U Clients select a verb via the request method, or by passing `http_method` as a query/form parameter. This lets HTML forms (limited to GET/POST) reach any verb, and gives SSE a connection path: `/todos?http_method=sse`. Templates emit it via `{{http_verb:input}}` / `{{http_verb:param}}` (see [Templates](#templates)). **Resource name *(by order)***: identifier used by `{{url:name}}`, `redirect()`, and `reroute()`. -``` +```c resource("todos", "/todos", .get = { ... }); ``` **URL pattern *(by order)***: URL pattern. Supports `:params`. -``` +```c 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", n_int, "must be a number"}) }, + .all = { input({"id", m_integer, "must be a number"}) }, .get = { ... }, .delete = { ... } ); ``` -**`.mime`**: default response content type. Values: `n_html`, `n_txt`, `n_es`, `n_json`, `n_js` (default `n_html`). -``` -resource("feed", "/feed.json", .mime = n_json, .get = { ... }); +**`.mime`**: default response content type. Values: `m_html`, `m_txt`, `m_sse`, `m_json`, `m_js` (default `m_html`). +```c +resource("feed", "/feed.json", .mime = m_json, .get = { ... }); ``` **`.get` `.post` `.put` `.patch` `.delete`**: verb pipelines: ordered arrays of steps that transform a request into a response. -``` +```c resource("todos", "/todos", .get = { sqlite_query({"db", "get_todos", "todos_data"}), @@ -1033,27 +1033,27 @@ resource("todos", "/todos", respond("todos_s") }, .post = { - input({"title", n_not_empty}), + input({"title", m_not_empty}), redirect("todos") } ); ``` **`.sse`**: persistent SSE channel. The first value is the channel name (supports `{{interpolation}}`); any remaining steps run on connect. -``` +```c resource("todos", "/todos", .sse = {"todos:{{user_id}}", sqlite_query({"db", "get_todos", "todos_data"}), - sse(.evt = "initial", .d = {"{{todos_data}}"}) + sse(.event = "initial", .data = {"{{todos_data}}"}) } ); ``` **`.errors` / `.repairs`**: resource-scoped error and repair pipelines. See [Error and Repair Pipelines](#error-and-repair-pipelines). -``` +```c resource("todos", "/todos", .post = { ... }, - .errors = {{n_bad_request, { + .errors = {{m_bad_request, { mustache("form", "form_s"), respond("form_s") }}} @@ -1061,16 +1061,16 @@ resource("todos", "/todos", ``` Combined: -``` +```c resource("todo", "/todos/:id", - .all = {input({"id", n_positive, "must be a number"})}, + .all = {input({"id", m_positive, "must be a number"})}, .get = { - sqlite_query({"todos_db", "get_todo", "todo", .err_on_empty = true}), + sqlite_query({"todos_db", "get_todo", "todo", .must_exist = true}), mustache("todo", "todo_s"), respond("todo_s") }, .patch = { - input({"title", n_not_empty, "required"}), + input({"title", m_not_empty, "required"}), sqlite_query({"todos_db", "update_todo"}), redirect("todo") }, @@ -1078,8 +1078,8 @@ resource("todo", "/todos/:id", sqlite_query({"todos_db", "delete_todo"}), redirect("todos") }, - .sse = {"todo:{{id}}", sse(.evt = "ready")}, - .errors = {{n_not_found, { + .sse = {"todo:{{id}}", sse(.event = "ready")}, + .errors = {{m_not_found, { mustache("404", "not_found_s"), respond("not_found_s") }}} @@ -1094,45 +1094,45 @@ 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 `err_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 `error_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 = { - {n_not_found, { + {m_not_found, { mustache("404", "not_found_s"), respond("not_found_s") }}, - {n_bad_request, { + {m_bad_request, { mustache("form", "form_s"), respond("form_s") }} }, .repairs = { - {n_not_authorized, {run(.call = refresh_session_token)}} + {m_not_authorized, {run(.call = refresh_session_token)}} } ); ``` **Module-scoped (`error()` / `repair()` calls):** -``` +```c config(todos){ - error(n_error, { + error(m_error, { mustache("5xx", "error_s"), respond("error_s") }); - error(n_not_found, { + error(m_not_found, { mustache("404", "not_found_s"), respond("not_found_s") }); - repair(n_not_authorized, {run(.call = refresh_session_token)}); + repair(m_not_authorized, {run(.call = refresh_session_token)}); // ... resources ... } ``` -**Built-in error codes:** `n_bad_request` (400), `n_not_authorized` (401), `n_not_found` (404), `n_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:** `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`. ![Error Resolution](./images/04-error-resolution.svg) @@ -1143,33 +1143,33 @@ Internal pub/sub for cross-module communication. The publisher does not know who Events are durable. When a publisher is declared, Nerak creates a `nerak_events` database to track delivery. If the process crashes, undelivered events replay on the next boot. **`publish(event, .with = {...})`**: declares an outbound event contract. First value is the event name; `.with` lists context keys to pass along. -``` +```c publish("todo_created", .with = {"user_id", "title"} ); ``` **`subscribe(event, { steps })`**: registers a subscriber pipeline keyed by event name. -``` +```c subscribe("todo_created", { sqlite_query({"activity_db", "insert_activity"}) }); ``` **`emit(event)`**: a pipeline step that fires the event (see [emit](#emit)). -``` +```c emit("todo_created") ``` **`.errors` / `.repairs`** *(per subscriber)*: each `subscribe(...)` can declare its own handlers, resolved the same way as resource pipelines (the subscriber's own handlers, then its module's). See [Error and Repair Pipelines](#error-and-repair-pipelines). -``` +```c subscribe("todo_created", { sqlite_query({"activity_db", "insert_activity"}) -}, .errors = {{n_error, {run(.call = log_subscriber_failure)}}}); +}, .errors = {{m_error, {run(.call = log_subscriber_failure)}}}); ``` Combined: -``` +```c // todos/todos.c: publisher config(todos){ publish("todo_created", @@ -1181,7 +1181,7 @@ config(todos){ resource("todos", "/todos", .post = { - input({"title", n_not_empty}), + input({"title", m_not_empty}), sqlite_query({"todos_db", "insert_todo"}), emit("todo_created"), redirect("todos") @@ -1204,52 +1204,52 @@ 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("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_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. 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()` (the last requires `dispatch.h`). +Any pipeline or task can call `run()`, `run_worker()`, `run_task()`, and `dispatch_task()` (the last requires `dispatch.h`). -**Task name *(by order)***: task identifier, invoked via `run_task("name")` or `dispatch("name")`. -``` +**Task name *(by order)***: task identifier, invoked via `run_task("name")` or `dispatch_task("name")`. +```c task("recount", { sqlite_query({"db", "recount_todos"}) }); ``` **Pipeline *(by order)***: the task's pipeline body, a brace block. -``` +```c task("name", { sqlite_query({...}), emit("done"), - dispatch("followup") + dispatch_task("followup") }); ``` **`.accepts`**: context keys to pull from the caller into the task. -``` +```c task("recount_todos", { sqlite_query({"db", "recount"}) }, .accepts = {"user_id"}); ``` **`.cron`**: standard cron schedule for recurring tasks (no caller required). -``` +```c task("daily_digest", { sqlite_query({"db", "digest"}) }, .cron = "0 8 * * *"); ``` **`.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({.url = "https://api.billing.dev/invoices/{{invoice_id}}", .ctx_key = "inv"}) -}, .repairs = {{n_not_authorized, {run(.call = refresh_billing_token)}}}); + fetch({"https://api.billing.dev/invoices/{{invoice_id}}", "inv"}) +}, .repairs = {{m_not_authorized, {run(.call = refresh_billing_token)}}}); ``` Combined: -``` -// on-demand: dispatched via dispatch("recount_todos") +```c +// on-demand: dispatched via dispatch_task("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_ctx`/`.not_ctx` for [conditional execution](#conditionals), and `.map`/`.map_key` 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_context`/`.unless_context` for [conditional execution](#conditionals), and `.map`/`.item` 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](#dispatch) +* [dispatch_task](#dispatch_task) * [sse](#sse) * [render](#render) * [respond](#respond) @@ -1285,104 +1285,104 @@ 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 n_zipcode "^\\d{5}$"`. +Built-in regex macros are defined in `nerak.h`; define your own the same way: `#define m_zipcode "^\\d{5}$"`. -**`.ctx_key` *(by order)***: name of the parameter to validate. -``` +**`.param_key` *(by order)***: name of the parameter to validate. +```c input({"title", "^\\S+$", "required"}) ``` -**`.regex` *(by order)***: regex pattern, or a built-in validator macro. -``` -input({"email", n_email, "bad email"}) +**`.matches` *(by order)***: regex pattern, or a built-in validator macro. +```c +input({"email", m_email, "bad email"}) ``` -**`.err_msg` *(by order)***: human-readable error shown via `{{error_message:name}}`. -``` -input({"age", n_int, "must be a number"}) +**`.message` *(by order)***: human-readable error shown via `{{error_message:name}}`. +```c +input({"age", m_integer, "must be a number"}) ``` -**`.opt`**: skip validation when the parameter is absent. -``` -input({"filter", "^(active|done)$", .opt = true}) +**`.optional`**: skip validation when the parameter is absent. +```c +input({"filter", "^(active|done)$", .optional = true}) ``` -**`.def`**: default value injected when the parameter is absent. -``` -input({"page", n_int, .def = "1"}) +**`.fallback`**: default value injected when the parameter is absent. +```c +input({"page", m_integer, .fallback = "1"}) ``` Combined: -``` +```c input( - {"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"} + {"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"} ) ``` For checks beyond regex (uniqueness, cross-field rules, lookups), pair `input()` with a query and `run()`: -``` -input({"username", n_user, "must be alphanumeric"}), +```c +input({"username", m_username, "must be alphanumeric"}), sqlite_query({"users_db", "find_username", "existing"}), run(^(){ auto rows = get("existing"); - if (rows && tbl_len(rows) > 0) - err_set("username", (err){n_bad_request, "already taken"}); + if (rows && table_count(rows) > 0) + error_set("username", (error){m_bad_request, "already taken"}); }) ``` **Built-in validators:** -- 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` +- 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` #### query -Each engine provides its own query step: `sqlite_query()`, `postgres_query()`, `mysql_query()`, `redis_query()`, `duckdb_query()`. All share the same `query_c` shape. By order: first value is the database name (the `.nm` it was registered with), 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. +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. -**`.db` *(by order)***: database name, matching the name a `_config(...)` was registered with. -``` +**`.db` *(by order)***: database name, matching the name a `_database(...)` was registered with. +```c sqlite_query({"todos_db", "get_todos", "todos_data"}) ``` -**`.q` *(by order)***: context key holding the SQL to run. -``` +**`.query` *(by order)***: context key holding the SQL to run. +```c sqlite_query({"todos_db", "get_todos", "todos_data"}) ``` -**`.ctx_key` *(by order)***: context key for the result table. Optional; omit when the result isn't needed (e.g. an insert without `RETURNING`). -``` +**`.set_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" ``` -**`.err_on_empty`**: when true, raise `404 Not Found` if the query affects/returns zero rows. Default false. -``` -sqlite_query({"todos_db", "get_todo", "todo", .err_on_empty = true}) +**`.must_exist`**: 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}) ``` -**`.if_ctx` / `.not_ctx`** *(per item)*: conditionally include or skip individual queries while running the others concurrently. -``` +**`.if_context` / `.unless_context`** *(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_ctx = "show_urgent"} + {"db", "get_urgent", "urgent", .if_context = "show_urgent"} ) ``` Combined: -``` +```c sqlite_query( {"todos_db", "get_todos", "todos_data"}, - {"todos_db", "get_todo", "todo", .err_on_empty = true}, - {"todos_db", "get_urgent", "urgent", .if_ctx = "show_urgent"} + {"todos_db", "get_todo", "todo", .must_exist = true}, + {"todos_db", "get_urgent", "urgent", .if_context = "show_urgent"} ) ``` @@ -1390,30 +1390,40 @@ 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. -By order: `join(parent_key, parent_field, child_key, child_field)`. - -**parent_key *(by order)***: outer table whose records receive nested children. - -**parent_field *(by order)***: field on the outer table to match against. - -**child_key *(by order)***: inner table whose records get nested. - -**child_field *(by order)***: field on the inner table that points back at the outer. - -**`.parent_join_key`**: name of the new field on outer records holding the matched inner records (defaults to the child table name). +**`.parent_key`**: outer table whose records receive nested children. +```c +.parent_key = "projects" ``` -.parent_join_key = "todos" + +**`.field_key`**: field on the outer table to match against. +```c +.field_key = "id" +``` + +**`.child_key`**: inner table whose records get nested. +```c +.child_key = "todos" +``` + +**`.child_field_key`**: field on the inner table that points at the outer. +```c +.child_field_key = "project_id" +``` + +**`.join_field_key`**: new field on outer records holding the matched inner records. (defaults to `.child_key`) +```c +.join_field_key = "todos" ``` Combined: -``` +```c join("projects", "id", "todos", "project_id") ``` **Full context example.** Concurrent query → `join()` → `mustache()`: fetch parent and children from separate queries, render as one nested structure. Blog + comments, single database: **`blog.html`** -``` +```html
    {{#blog}}

    {{title}}

    @@ -1428,10 +1438,10 @@ join("projects", "id", "todos", "project_id")
    ``` -``` +```c resource("blog", "/blogs/:id", .get = { - input({"id", n_int}), + input({"id", m_integer}), // Fetch both concurrently: one query() call, two items sqlite_query( @@ -1463,59 +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**. -Fields are given by name. `.url` and `.ctx_key` are the common pair; the rest are optional. - -**`.url`**: request URL; supports `{{interpolation}}`. -``` -fetch({.url = "https://api.weather.dev/forecast?city={{city}}", .ctx_key = "w"}) +**`.url` *(by order)***: request URL; supports `{{interpolation}}`. +```c +fetch({"https://api.weather.dev/forecast?city={{city}}", "w"}) ``` -**`.ctx_key`**: context key for the response. -``` -fetch({.url = "https://api.weather.dev/now", .ctx_key = "weather"}) +**`.set_key`**: context key for the response. +```c +fetch({"https://api.weather.dev/now", "weather"}) ``` -**`.meth`**: HTTP method. Defaults to `n_get`. Values: `n_get`, `n_post`, `n_put`, `n_patch`, `n_delete`, `n_sse`. -``` -fetch({.url = "https://api.dev/charge", .ctx_key = "r", .meth = n_post}) +**`.method`**: HTTP method. Defaults to `m_get`. Values: `m_get`, `m_post`, `m_put`, `m_patch`, `m_delete`, `m_sse_method`. +```c +fetch({"https://api.dev/charge", "r", m_post}) ``` **`.headers`**: array of name/value pairs. -``` -fetch({.url = "https://api.dev/me", .ctx_key = "r", .headers = {{"Authorization", "Bearer {{token}}"}}}) +```c +fetch({"https://api.dev/me", "r", .headers = {{"Authorization", "Bearer {{token}}"}}}) ``` -**`.json_ctx_key`**: context key whose value is serialized as the JSON request body. -``` -fetch({.url = "https://api.dev/charge", .ctx_key = "receipt", .meth = n_post, .json_ctx_key = "order"}) +**`.json`**: context key serialized as the JSON request body. +```c +fetch({"https://api.dev/charge", "receipt", m_post, "order"}) ``` -**`.json`**: literal JSON request body; supports `{{interpolation}}`. -``` -fetch({.url = "https://api.push.dev/notify", .meth = n_post, .json = "{\"text\":\"New todo: {{title}}\"}"}) +**`.text`**: context key sent as the plain-text request body. +```c +fetch({"https://api.dev/log", "r", m_post, .text = "raw_body"}) ``` -**`.txt`**: context key sent as the plain-text request body. -``` -fetch({.url = "https://api.dev/log", .ctx_key = "r", .meth = n_post, .txt = "raw_body"}) -``` - -**`.if_ctx` / `.not_ctx`** *(per item)*: conditionally include or skip individual requests while running others concurrently. -``` +**`.if_context` / `.unless_context`** *(per item)*: conditionally include or skip individual requests while running others concurrently. +```c fetch( - {.url = "https://api.weather.dev/now", .ctx_key = "weather"}, - {.url = "https://api.quotes.dev/random", .ctx_key = "quote", .if_ctx = "show_quote"} + {"https://api.weather.dev/now", "weather"}, + {"https://api.quotes.dev/random", "quote", .if_context = "show_quote"} ) ``` Combined, single request: -``` -fetch({ - .url = "https://api.payments.dev/charge", - .ctx_key = "receipt", - .meth = n_post, - .json_ctx_key = "order", - .headers = { +```c +fetch({"https://api.payments.dev/charge", + "receipt", + m_post, + "order", + { {"Authorization", "Bearer {{api_key}}"}, {"Idempotency-Key", "{{order_id}}"} } @@ -1523,31 +1525,31 @@ fetch({ ``` Combined, concurrent fan-out: -``` +```c fetch( - {.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"} + {"https://api.weather.dev/now?city={{city}}", "weather"}, + {"https://api.news.dev/headlines?topic={{topic}}", "news"}, + {"https://api.quotes.dev/random", "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 `err_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 `error_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 = 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")); + 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")); }) ``` **`.call`**: reference to a named C function, for logic reuse across pipelines. -``` +```c run(.call = assign_opponents) ``` @@ -1558,7 +1560,7 @@ Inside blocks and `.call` functions, context, memory, errors, tables, and record `run_worker()` takes the same block or `.call` as `run()` but is for blocking or CPU-bound work: external C libraries, blocking I/O, heavy computation. The work is dispatched to the shared thread pool, releasing the reactor; the pipeline resumes on the original reactor when the call returns. Use it when the body would stall a request reactor. **Block *(by order)***: inline block, run on the shared thread pool. Here, rendering Markdown through an external C library and freeing its buffer when the request completes: -``` +```c run_worker(^(){ auto const raw = third_party_render_md(get("markdown")); defer_free(raw); @@ -1567,7 +1569,7 @@ run_worker(^(){ ``` **`.call`**: reference to a named C function, run on the shared thread pool. -``` +```c run_worker(.call = resize_image) ``` @@ -1576,7 +1578,7 @@ run_worker(.call = resize_image) Triggers an internal pub/sub event. Subscribers in other modules react in their `subscribe()` pipelines, with no direct dependency on the emitter. See [Event Pipelines](#event-pipelines). **Event name *(by order)***: name of the event to publish. -``` +```c emit("todo_created") ``` @@ -1585,73 +1587,73 @@ emit("todo_created") Runs a named task inline as a step in the calling pipeline; control returns to the next step when it finishes. For reusable pipelines composed into workflows. The task must be defined with `task(name, { ... })`. See [Task Pipelines](#task-pipelines). **Task name *(by order)***: name of a defined task. -``` +```c run_task("recount_todos") ``` -#### dispatch +#### dispatch_task 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. -``` -dispatch("record_daily_stats") +```c +dispatch_task("record_daily_stats") ``` #### sse -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). +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). -**`.chan` *(by order)***: channel to broadcast on; supports `{{interpolation}}`. -``` -sse("todos:{{user_id}}", .evt = "new_todo", .d = {"{{todo}}"}) +**`.channel` *(by order)***: channel to broadcast on; supports `{{interpolation}}`. +```c +sse("todos:{{user_id}}", .event = "new_todo", .data = {"{{todo}}"}) ``` -**`.evt`**: SSE `event:` line value. -``` -sse(.evt = "ping") +**`.event`**: SSE `event:` line value. +```c +sse(.event = "ping") ``` -**`.d`**: array of strings, one per SSE `data:` line (multi-line data). -``` -sse(.evt = "msg", .d = {"line one", "line two"}) +**`.data`**: array of strings, one per SSE `data:` line (multi-line data). +```c +sse(.event = "msg", .data = {"line one", "line two"}) ``` -**`.cmt`**: SSE `:` comment line value, useful for keep-alives. -``` -sse(.cmt = "keep-alive") +**`.comment`**: SSE `:` comment line value, useful for keep-alives. +```c +sse(.comment = "keep-alive") ``` Combined: -``` +```c sse("todos:{{user_id}}", - .evt = "todo_updated", - .d = {"id: {{todo_id}}", "title: {{title}}"}, - .cmt = "broadcast at {{timestamp}}" + .event = "todo_updated", + .data = {"id: {{todo_id}}", "title: {{title}}"}, + .comment = "broadcast at {{timestamp}}" ) ``` #### render -Renders a template into the pipeline context. `mustache()` renders Mustache; `mdm()` renders Markdown-with-Mustache; `json()` renders JSON. All take the same `render_c`. +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_ctx_key` *(by order)***: context key holding the template string to render. -``` +**`.template_key` *(by order)***: context key holding the template string to render. +```c mustache("todos", "todos_s") ``` -**`.ctx_key` *(by order)***: context key to write the rendered output to. -``` +**`.set_key` *(by order)***: context key to write the rendered output to. +```c mustache("todos", "todos_s") ``` JSON: -``` +```c json("todos", "todos_j") ``` Markdown-with-Mustache: -``` +```c context("welcome", "# Welcome, {{user_name}}"); mdm("welcome", "welcome_s") ``` @@ -1660,25 +1662,25 @@ mdm("welcome", "welcome_s") Sends a pipeline context value as the HTTP response. -**`.ctx_key` *(by order)***: key of the rendered content to send. -``` +**`.context_key` *(by order)***: key of the rendered content to send. +```c respond("todos_s") ``` -**`.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). -``` -respond("not_found_s", .status = n_not_found) +**`.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). +```c +respond("not_found_s", .status = m_not_found) ``` -**`.mime`**: override the response content type. Values: `n_html`, `n_txt`, `n_es`, `n_json`, `n_js`. -``` -respond("plain_s", .mime = n_txt) +**`.mime`**: override the response content type. Values: `m_html`, `m_txt`, `m_sse`, `m_json`, `m_js`. +```c +respond("plain_s", .mime = m_txt) ``` Combined: -``` +```c mustache("not_found", "not_found_s"), -respond("not_found_s", .status = n_not_found) +respond("not_found_s", .status = m_not_found) ``` #### headers and cookies @@ -1686,15 +1688,15 @@ respond("not_found_s", .status = n_not_found) Set HTTP response headers and cookies declaratively. Both accept an array of name/value pairs; values support `{{interpolation}}`. **Pairs *(by order)***: array of `{name, value}` entries. -``` +```c headers({{"X-Request-Id", "{{request_id}}"}}) ``` -``` +```c cookies({{"session", "{{session_id}}"}}) ``` Combined: -``` +```c headers({ {"X-Request-Id", "{{request_id}}"}, {"Cache-Control", "no-store"} @@ -1710,7 +1712,7 @@ cookies({ `redirect()` returns a 302 to the client, causing the browser to navigate. `reroute()` re-enters the router server-side, executing another resource's pipeline within the same request. Both take only the target resource name. `:params` in the target's URL pattern are read from the current context by matching key names. **Resource name *(by order)***: target resource name. Required `:params` are read from context by name. -``` +```c redirect("todos") // 302 to /todos redirect("todo") // 302 to /todos/{{id}}, id read from context redirect("org_todo") // 302 to /orgs/{{org}}/todos/{{id}}, org and id read from context @@ -1719,17 +1721,17 @@ 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_ctx`/`.not_ctx` to several steps without repeating it. +Groups multiple steps into a single composite step. Useful when applying one `.if_context`/`.unless_context` 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_ctx` / `.not_ctx`**: condition applied to the whole group. -``` +**`.if_context` / `.unless_context`**: condition applied to the whole group. +```c nest({sqlite_query({...}), emit("urgent_todo"), mustache("urgent", "urgent_s"), respond("urgent_s")}, - .if_ctx = "is_urgent") + .if_context = "is_urgent") ``` --- @@ -1749,32 +1751,32 @@ Functions called from `run()`/`run_worker()` blocks and `.call` functions to rea Read, write, and test context keys, and resolve `{{interpolation}}` against the current scope. **`get(name)`**: returns the value stored under `name`, or `nullptr` if absent. The returned pointer is whatever was stored: a `string` for scalars, a `table` for query and fetch results. -``` +```c auto todos = get("todos"); ``` **`set(name, value)`**: writes `value` to `name`, exposing it to downstream steps and templates. -``` +```c set("is_urgent", "1"); ``` **`has(name)`**: returns true when `name` exists in the current scope. -``` +```c if (has("user_id")) { ... } ``` -**`fmt(fmtstr)`**: returns `fmt` with `{{name}}` interpolations resolved against the current context. Same scopes and helpers as templates. -``` -auto greeting = fmt("Hello, {{user_name}}"); +**`format(fmt)`**: returns `fmt` with `{{name}}` interpolations resolved against the current context. Same scopes and helpers as templates. +```c +auto greeting = format("Hello, {{user_name}}"); ``` Combined: -``` +```c run(^(){ auto rows = get("todos"); - if (tbl_len(rows) > 5) { + if (table_count(rows) > 5) { set("is_urgent", "1"); - set("banner", fmt("{{user_name}} has more than 5 open todos")); + set("banner", format("{{user_name}} has more than 5 open todos")); } }) ``` @@ -1783,21 +1785,21 @@ run(^(){ Pipeline-arena allocation and deferred cleanup of foreign pointers. Both clear when the request completes. -**`alloc(sz)`**: returns a buffer from the pipeline arena. Reclaimed automatically on request completion. -``` -auto buf = alloc(256); +**`allocate(bytes)`**: returns a buffer from the pipeline arena. Reclaimed automatically on request completion. +```c +auto buf = allocate(256); ``` **`defer_free(ptr)`**: schedules `free()` for a pointer returned by an external library. Runs when the arena is released. -``` +```c auto out = third_party_alloc(256); defer_free(out); ``` Combined: -``` +```c run_worker(^(){ - auto url = alloc(512); + auto url = allocate(512); build_signed_url(url, 512, get("path")); set("signed_url", url); @@ -1811,28 +1813,28 @@ 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}}`. -**`err_set(name, err)`**: associates an error with `name` and triggers the nearest [error or repair pipeline](#error-and-repair-pipelines). -``` -err_set("token", (err){ n_bad_request, "token has expired" }); +**`error_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_get(name)`**: returns the `err` previously set on `name`. -``` -auto e = err_get("token"); +**`error_get(name)`**: returns the `error` previously set on `name`. +```c +auto e = error_get("token"); ``` -**`err_has(name)`**: returns true when `name` has an error. -``` -if (err_has("token")) { ... } +**`error_has(name)`**: returns true when `name` has an error. +```c +if (error_has("token")) { ... } ``` Combined: -``` +```c run(^(){ auto token = get("token"); if (!token || strlen(token) < 16) { - err_set("token", (err){ - n_bad_request, + error_set("token", (error){ + m_bad_request, "token must be at least 16 characters" }); } @@ -1843,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. -**`tbl_new()`**: returns an empty table in the pipeline arena. -``` -auto t = tbl_new(); +**`table_new()`**: returns an empty table in the pipeline arena. +```c +auto t = table_new(); ``` -**`tbl_len(t)`**: number of records in `t`. -``` -auto n = tbl_len(get("todos")); +**`table_count(t)`**: number of records in `t`. +```c +auto n = table_count(get("todos")); ``` -**`tbl_get(t, i)`**: record at index `i`, or `nullptr` if out of range. -``` -auto first = tbl_get(get("todos"), 0); +**`table_get(t, i)`**: record at index `i`, or `nullptr` if out of range. +```c +auto first = table_get(get("todos"), 0); ``` -**`tbl_add(t, r)`**: appends `r` to `t`. -``` -tbl_add(t, rec_new()); +**`table_add(t, r)`**: appends `r` to `t`. +```c +table_add(t, record_new()); ``` -**`tbl_rem(t, r)`**: removes record `r` from `t`. -``` -tbl_rem(t, r); +**`table_remove(t, r)`**: removes record `r` from `t`. +```c +table_remove(t, r); ``` -**`tbl_rem_at(t, i)`**: removes the record at index `i`. -``` -tbl_rem_at(t, 0); +**`table_remove_at(t, i)`**: removes the record at index `i`. +```c +table_remove_at(t, 0); ``` Combined: -``` +```c run(^(){ auto source = get("raw_users"); - 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"); + 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"); if (status && strcmp(status, "active") == 0) { - tbl_add(active, u); + table_add(active, u); } } set("active_users", active); @@ -1893,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). -**`rec_new()`**: returns an empty record in the pipeline arena. -``` -auto r = rec_new(); +**`record_new()`**: returns an empty record in the pipeline arena. +```c +auto r = record_new(); ``` -**`rec_get(r, name)`**: string value of `name`, or `nullptr` if absent. -``` -auto title = rec_get(r, "title"); +**`record_get(r, name)`**: string value of `name`, or `nullptr` if absent. +```c +auto title = record_get(r, "title"); ``` -**`rec_set(r, name, value)`**: writes `value` to `name` on `r`. -``` -rec_set(r, "title", "New title"); +**`record_set(r, name, value)`**: writes `value` to `name` on `r`. +```c +record_set(r, "title", "New title"); ``` -**`rec_rem(r, name)`**: removes `name` from `r`. -``` -rec_rem(r, "draft"); +**`record_remove(r, name)`**: removes `name` from `r`. +```c +record_remove(r, "draft"); ``` Combined: -``` +```c run(^(){ auto todos = get("todos"); - for (int i = 0; i < tbl_len(todos); i++) { - auto t = tbl_get(todos, i); - auto title = rec_get(t, "title"); + for (int i = 0; i < table_count(todos); i++) { + auto t = table_get(todos, i); + auto title = record_get(t, "title"); if (title && strlen(title) > 40) { - rec_set(t, "is_long", "1"); + record_set(t, "is_long", "1"); } } }) @@ -1929,45 +1931,45 @@ run(^(){ ### Conditionals -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()`. +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()`. -**`.if_ctx`**: context key. Step runs only when the value is present. -``` -mustache("fragment", "frag_s", .if_ctx = "is_htmx") +**`.if_context`**: context key. Step runs only when the value is present. +```c +mustache("fragment", "frag_s", .if_context = "is_htmx") ``` -**`.not_ctx`**: context key. Step runs only when the value is absent. -``` -mustache("full_page", "page_s", .not_ctx = "is_htmx") +**`.unless_context`**: context key. Step runs only when the value is absent. +```c +mustache("full_page", "page_s", .unless_context = "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_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") +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") ``` ### Iteration -`.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` 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`**: 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({.url = "https://api.users.dev/{{id}}", .ctx_key = "profiles", .map = "users"}) +fetch({"https://api.users.dev/{{id}}", "profiles", .map = "users"}) ``` -**`.map_key`**: context key under which the current row is exposed as a single-row table. Pairs with `.map`. -``` +**`.item`**: 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`. -// `.map_key = "todo_d"` presents the current row as the single-row table `todo_d` +// `.item = "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", .map_key = "todo_d") +mustache("todo", "todo_s", .map = "todos", .item = "todo_d") ``` ### Modules and Composition @@ -1977,7 +1979,7 @@ A module is declared with `config(name)` in a `name.c` file, usually inside a ma A module is seeded with the assets in its folder and every asset up to the project root, at startup. See [Assets](#assets) and [Context](#context). **`config(name)`**: declares a module. -``` +```c // todos/todos.c config(todos){ // resources, databases, tasks, subscribers ... @@ -1985,7 +1987,7 @@ config(todos){ ``` **`middleware(steps)`**: registers shared steps that run on every request to a resource in the same module. Cross-cutting setup like session loading or tenant resolution lives here. -``` +```c config(todos){ middleware(session()); /* resources, ... */ } ``` @@ -1993,7 +1995,7 @@ config(todos){ middleware(session()); /* resources, ... */ } **Pipeline composition.** A request runs the resource's `.all` steps first, then the module's `middleware()`, then the verb pipeline. -``` +```c // todos/todos.c: session loads, resources require login #include #include @@ -2009,35 +2011,35 @@ config(todos){ respond("todos_s") }, .post = { - input({"title", n_not_empty}), + input({"title", m_not_empty}), sqlite_query({"todos_db", "create_todo"}), redirect("todos") } ); resource("todo", "/todos/:id", - .all = {input({"id", n_positive})}, + .all = {input({"id", m_positive})}, .delete = { - sqlite_query({"todos_db", "delete_todo", .err_on_empty = true}), + sqlite_query({"todos_db", "delete_todo", .must_exist = 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", ..., .err_on_empty = 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", ..., .must_exist = true})`, `mustache("todo", "todo_s")`, `respond("todo_s")`. **Complete module file.** A `blogs/blogs.c`: -``` +```c #include #include config(blogs){ - sqlite_config( - "blog_db", - "file:blogs.db?mode=rwc", - {"create_blogs_table", "create_comments_table"} + sqlite_database( + .name = "blog_db", + .connect = "file:blogs.db?mode=rwc", + .migrations = {"create_blogs_table", "create_comments_table"} ); resource("blog", "/blogs/:id", @@ -2083,9 +2085,9 @@ 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_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. +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. -``` +```c #include #include @@ -2093,98 +2095,91 @@ config(todos){ resource("todos", "/todos", .get = { sqlite_query({"todos_db", "get_todos", "todos_data"}), - 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") + 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") } ); } ``` Include the runtime once in the page ``: -``` +```html {{> htmx }} ... ``` #### datastar -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. +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. -`datastar()` patches a rendered fragment into the page by target element. The first value is the channel (supports `{{interpolation}}`). +`datastar_sse()` patches a context value into the page by CSS selector. The first value is the channel (supports `{{interpolation}}`). -**`.chan` *(by order)***: channel to push to. -``` -mustache("todo", "todo_s"), -datastar("todos:{{user_id}}", .target = "todos", .mode = ds_append, .elements = "todo_s") +**`.channel` *(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") ``` -**`.target`**: target element to patch, given as an element id or CSS selector; supports `{{interpolation}}`. -``` -.target = "todo_{{id}}" +**`.target`**: 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_m`). -``` -.mode = ds_replace +**`.mode`**: how the rendered fragment is applied to the target (a `datastar_mode`). +```c +.mode = mode_replace ``` -**`.elements`**: context key holding the rendered HTML fragment to patch in. Not required for `ds_remove`. -``` +**`.elements`**: context key holding the rendered HTML fragment to patch in. Not required for `mode_remove`. +```c .elements = "todo_row_s" ``` **`.signals`**: context key holding signal state to merge into the client store. -``` +```c .signals = "ui_state" ``` **`.js`**: JavaScript to execute on the client. -``` +```c .js = "window.scrollTo(0, document.body.scrollHeight)" ``` -**Patch modes (`datastar_m`):** `ds_outer`, `ds_inner`, `ds_replace`, `ds_prepend`, `ds_append`, `ds_before`, `ds_after`, `ds_remove`. +**Patch modes (`datastar_mode`):** `mode_outer`, `mode_inner`, `mode_replace`, `mode_prepend`, `mode_append`, `mode_before`, `mode_after`, `mode_remove`. -Worked example: a create, an update, and a delete, each pushing a patch to every client on the channel. Queries use `RETURNING` so the changed row comes back for rendering. This mirrors the shipped `app.c`. +Worked example: a POST inserts a row, returns it with `RETURNING`, appends it to every connected client's list. -``` +```c resource("todos", "/todos", - .all = {logged_in()}, // Each browser opens this channel and listens for patches. .sse = {"todos:{{user_id}}"}, - .post = { - input({"title", n_not_empty}), - // RETURNING gives the new row back; capture it under "todo_data". - sqlite_query({"todos_db", "create_todo", "todo_data", .err_on_empty = true}), - mustache("todo", "todo_s"), - // Prepend the new row to everyone's list. - datastar("todos:{{user_id}}", .target = "todos", .mode = ds_prepend, .elements = "todo_s") - } -); -resource("todo", "/todos/:id", - .all = {logged_in(), input({"id", n_positive})}, - .patch = { - input({"finished", "1", "must be 1", .opt = true}), - sqlite_query({"todos_db", "update_todo", "todo_data", .err_on_empty = true}), - mustache("todo", "todo_s"), - // Replace just that row for everyone. - datastar("todos:{{user_id}}", .target = "todo_{{id}}", .mode = ds_replace, .elements = "todo_s") - }, - .delete = { - sqlite_query({"todos_db", "delete_todo", .err_on_empty = true}), - // Remove that row for everyone; no fragment needed. - datastar("todos:{{user_id}}", .target = "todo_{{id}}", .mode = ds_remove) + .post = { + input({"title", m_not_empty}), + // RETURNING gives the new row back; capture it under "todo". + sqlite_query({"todos_db", "insert_todo", "todo", .must_exist = 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}}", + .target = "#todo-list", + .mode = mode_append, + .elements = "todo_row_s" + ) } ); ``` -Datastar sets a context flag on requests it originates, usable with `.if_ctx`. +Removing an element needs only a selector: +```c +datastar_sse("todos:{{user_id}}", .target = "#todo-{{id}}", .mode = mode_remove) +``` + +Datastar sets a context flag on requests it originates, usable with `.if_context`. Include the runtime once in the page ``: -``` +```html {{> datastar }} ``` @@ -2194,7 +2189,7 @@ Include the runtime once in the page ``: Activate with `#include `. Compiles Tailwind utility classes used across the project's templates and serves the stylesheet as the `{{> tailwind }}` partial. Use Tailwind classes directly in templates; no build step or config file required. -``` +```html {{> tailwind }}

    Vote for which is roundest

    @@ -2205,7 +2200,7 @@ Activate with `#include `. Compiles Tailwind utility classes used ac Activate with `#include `. Compiles DaisyUI classes used across the project's templates and serves the stylesheet as the `{{> daisyui }}` partial. Use DaisyUI classes directly in templates; no build step or config file required. -``` +```html {{> daisyui }} @@ -2217,17 +2212,17 @@ Activate with `#include `. Compiles DaisyUI classes used across the p Activate with `#include `. Cookie-based authentication as pipeline steps. `session()` loads the current `user` record into context from the session cookie; run it as `middleware()` in each module whose pipelines need to know who is signed in. `logged_in()` guards a resource, redirecting anonymous visitors to the login page. `login()`, `logout()`, and `signup()` perform the corresponding actions. The login page template is the asset named `login`. **`session()`**: loads the current user into context from the session cookie. Use as middleware. -``` +```c middleware(session()); ``` **`logged_in()`**: requires an authenticated session; redirects to login otherwise. Use in a resource `.all`. -``` +```c resource("todos", "/todos", .all = {logged_in()}, .get = { ... }); ``` **`login()` / `logout()` / `signup()`**: authentication actions for the corresponding verb pipelines. -``` +```c resource("login", "/login", .get = { mustache("login", "login_s"), @@ -2246,7 +2241,7 @@ resource("signup", "/signup", ``` Combined: load the session in the module whose resources it gates, then read user fields in that module's templates. -``` +```c // todos/todos.c #include #include @@ -2277,7 +2272,7 @@ config(app){ resource("logout", "/logout", .post = {logout()}); } ``` -``` +```html {{#user}} Hi, {{short_name}} @@ -2286,14 +2281,14 @@ config(app){ #### database engines -Each engine is its own module: `#include` its header, then use `_config(...)` to register and `_query({...})` as a pipeline step. They share `db_c` and `query_c` from [Databases](#databases) and [query](#query); only `.conn` is engine-specific. +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. -``` -#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({...}); +```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({...}); ``` ### Static Files @@ -2307,7 +2302,7 @@ public/ └── logo.svg ``` -``` +```html Logo @@ -2326,7 +2321,7 @@ vendor/ └── cmark.h ``` -``` +```c #include #include "vendor/cmark/cmark.h" @@ -2352,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_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. +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. ![Boot-Time Compilation](./images/10-boot-time-compilation.svg) @@ -2364,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()` 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_task()` 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. @@ -2376,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 `alloc()` 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 `allocate()` 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. @@ -2409,7 +2404,7 @@ Built-in TUI editor with HMR, LSP support, and integrated source control. ### Introspection `/app_info` is a built-in resource in dev builds. Query it like any other endpoint: -``` +```bash curl localhost:3000/app_info # view topology curl localhost:3000/app_info/resources # list all resources curl localhost:3000/app_info/pipelines # inspect pipelines @@ -2420,20 +2415,20 @@ Production builds omit it; see [Deployment](#deployment). ### Testing Built-in runners for unit and end-to-end testing; no external framework setup required. -``` +```bash unit_tests # fast, criterion-based tests e2e_tests # playwright-powered browser tests ``` ### Debugging Pipeline-aware commands. Halt on individual pipeline steps, step through execution, and inspect the full pipeline context including nested tables and records. -``` +```bash app_debug # interactive debugger in the TUI ``` ### Deployment Nerak deploys as a standard Docker container. It does not terminate TLS; production deployments place Nerak behind a reverse proxy or load balancer (Nginx, Caddy, AWS ALB) to handle HTTPS. -``` +```bash app_build # outputs a minimal production Docker image ```