nerak repo

This commit is contained in:
2026-07-12 22:58:11 -05:00
parent 6cba82f388
commit 3040b94b0a
2 changed files with 452 additions and 433 deletions
+348 -343
View File
File diff suppressed because it is too large Load Diff
+103 -89
View File
@@ -24,7 +24,7 @@ config(app){
Every non-`.c` file is an asset, loaded into context under its **basename** (the part before the first dot), as if `context(name, contents)` had been called in the module. Every non-`.c` file is an asset, loaded into context under its **basename** (the part before the first dot), as if `context(name, contents)` had been called in the module.
- `get_todos.sql` → `get_todos`; `todos.html` → `todos`; `home.md` → `home`. - `get_todos.sql` → `get_todos`; `todos.html` → `todos`; `home.md` → `home`.
- Steps read assets by key: `mustache("todos","todos_s")`, `sqlite_query({"db","get_todos","todos_data"})`, `.migrations = {"create_todos_table"}`. - Steps read assets by key: `mustache("todos","todos_s")`, `sqlite_query({"db","get_todos","todos_data"})`, `sqlite_config("db","file:todo.db?mode=rwc",{"create_todos_table"})`.
- `context(name, value)` seeds the same way from a string literal (for small content): `context("ping","select 1");`. - `context(name, value)` seeds the same way from a string literal (for small content): `context("ping","select 1");`.
- A module is seeded with every asset from its own folder up to the project root. Assets at the root are shared by all modules; assets inside a module's folder are seen only by that module (the scan runs up the tree, never into a sibling module's folder). - A module is seeded with every asset from its own folder up to the project root. Assets at the root are shared by all modules; assets inside a module's folder are seen only by that module (the scan runs up the tree, never into a sibling module's folder).
- In dev, this scan is live: editing an asset reloads that file into every module holding it, and saving a `.c` file recompiles and reloads that module alone. A production build runs the scan once and compiles each module's assets into its binary. - In dev, this scan is live: editing an asset reloads that file into every module holding it, and saving a `.c` file recompiles and reloads that module alone. A production build runs the scan once and compiles each module's assets into its binary.
@@ -52,20 +52,20 @@ Built-in helpers (`{{helper:args}}`, colon-separated, literal or context key):
## Databases ## Databases
Each engine is a module: `#include <engine.h>` then register with `<engine>_database(...)`. Engines: `sqlite`, `postgres`, `mysql`, `redis`, `duckdb`. They share config; only `.connect` differs. Each engine is a module: `#include <engine.h>` then register with `<engine>_config(...)`. Engines: `sqlite`, `postgres`, `mysql`, `redis`, `duckdb`. They share config; only `.conn` differs.
```c ```c
#include <sqlite.h> #include <sqlite.h>
sqlite_database( sqlite_config(
.name = "todos_db", // referenced by query steps "todos_db", // referenced by query steps
.connect = "file:{{user_id}}_todo.db?mode=rwc", // engine-specific; {{interpolation}} = multi-tenant "file:{{user_id}}_todo.db?mode=rwc", // engine-specific; {{interpolation}} = multi-tenant
.migrations = {"create_todos_table", "create_comments_table"}, // context keys holding SQL {"create_todos_table", "create_comments_table"}, // context keys holding SQL
.seeds = {"seed_todos"} // context keys holding SQL {"seed_todos"} // context keys holding SQL
); );
``` ```
Migrations/seeds are forward-only, index-based (run once each in array order, append new ones to the end), tracked in `nerak_meta`. Connections are pooled with LRU eviction. Migrations/seeds are forward-only, index-based (run once each in array order, append new ones to the end), tracked in `nerak_meta`. Connections are pooled with LRU eviction.
Other engines: `postgres_database(.connect="postgres://...")`, `mysql_database(.connect="mysql://...")`, `redis_database(.connect="redis://...")`, `duckdb_database(.connect="duckdb:analytics.db")`. Each has a matching `<engine>_query(...)`. Other engines: `postgres_config("...", "postgres://...", ...)`, `mysql_config("...", "mysql://...", ...)`, `redis_config("...", "redis://...", ...)`, `duckdb_config("...", "duckdb:analytics.db", ...)`. Each has a matching `<engine>_query(...)`.
## Resources ## Resources
@@ -73,58 +73,58 @@ Resource-based, not route-based. `resource("name", "/url/pattern", ...fields)`.
Fields: Fields:
- `.all = {steps}` — run before every verb pipeline on the resource. - `.all = {steps}` — run before every verb pipeline on the resource.
- `.mime = m_html | m_txt | m_sse | m_json | m_js` — default response content type (default `m_html`). - `.mime = n_html | n_txt | n_es | n_json | n_js` — default response content type (default `n_html`).
- `.get` `.post` `.put` `.patch` `.delete` — verb pipelines (ordered step arrays). - `.get` `.post` `.put` `.patch` `.delete` — verb pipelines (ordered step arrays).
- `.sse = {"channel:{{interp}}", ...steps}` — persistent SSE channel; first value is channel name, remaining steps run on connect. - `.sse = {"channel:{{interp}}", ...steps}` — persistent SSE channel; first value is channel name, remaining steps run on connect.
- `.errors` / `.repairs` — resource-scoped handlers (see Error/Repair). - `.errors` / `.repairs` — resource-scoped handlers (see Error/Repair).
```c ```c
resource("todo", "/todos/:id", resource("todo", "/todos/:id",
.all = {input({"id", m_positive, "must be a number"})}, .all = {input({"id", n_positive, "must be a number"})},
.get = { .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"), mustache("todo", "todo_s"),
respond("todo_s") respond("todo_s")
}, },
.patch = { input({"title", m_not_empty, "required"}), sqlite_query({"todos_db", "update_todo"}), redirect("todo") }, .patch = { input({"title", n_not_empty, "required"}), sqlite_query({"todos_db", "update_todo"}), redirect("todo") },
.delete = { sqlite_query({"todos_db", "delete_todo"}), redirect("todos") }, .delete = { sqlite_query({"todos_db", "delete_todo"}), redirect("todos") },
.errors = {{m_not_found, {mustache("404","not_found_s"), respond("not_found_s")}}} .errors = {{n_not_found, {mustache("404","not_found_s"), respond("not_found_s")}}}
); );
``` ```
## Pipeline Steps ## Pipeline Steps
Every step accepts `.if_context` / `.unless_context` (conditionals) and `.map` / `.item` (iteration). Step argument convention: leading positional values are noted *(by order)*; the rest are named (`.field = ...`). Every step accepts `.if_ctx` / `.not_ctx` (conditionals) and `.map` / `.map_key` (iteration). Step argument convention: leading positional values are noted *(by order)*; the rest are named (`.field = ...`).
### input — validate request params ### input — validate request params
On success promotes `input:name` → app scope. On failure writes `error:name` and raises `m_bad_request` (400) to the nearest error/repair pipeline. All validations run before the error fires, so all field errors are available together. 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: `.optional` (skip if absent), `.fallback` (default when absent). `matches` is a regex string or a built-in macro. Define custom macros: `#define m_zipcode "^\\d{5}$"`. 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}$"`.
```c ```c
input( input(
{"email", m_email, "must be a valid email"}, {"email", n_email, "must be a valid email"},
{"title", m_not_empty, "cannot be empty"}, {"title", n_not_empty, "cannot be empty"},
{"page", m_integer, "must be a number", .fallback = "1"}, {"page", n_int, "must be a number", .def = "1"},
{"filter", "^(active|done)$", "must be 'active' or 'done'", .optional = true} {"filter", "^(active|done)$", "must be 'active' or 'done'", .opt = true}
) )
``` ```
Built-in validators: `m_not_empty m_alpha m_alphanumeric m_slug m_no_html` · `m_integer m_positive m_float m_percentage` · `m_email m_uuid m_username` · `m_date m_time m_datetime` · `m_url m_ipv4 m_hex_color` · `m_zipcode_us m_phone_e164 m_cron` · `m_token m_base64` · `m_boolean m_yes_no m_on_off`. Built-in validators: `n_not_empty n_alpha n_alphanum n_slug n_no_html` · `n_int n_positive n_float n_percent` · `n_email n_uuid n_user` · `n_date n_time n_datetime` · `n_url n_ipv4 n_hex_color` · `n_zip n_phone n_cron` · `n_token n_base64` · `n_bool n_yes_no n_on_off`.
For non-regex checks (uniqueness, cross-field), pair with a query + `run()` calling `error_set()`. For non-regex checks (uniqueness, cross-field), pair with a query + `run()` calling `err_set()`.
### query — `<engine>_query(...)` ### query — `<engine>_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). `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: `.must_exist = true` (404 if zero rows), `.if_context`/`.unless_context` (per item). 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).
```c ```c
sqlite_query( sqlite_query(
{"todos_db", "get_todos", "todos_data"}, {"todos_db", "get_todos", "todos_data"},
{"todos_db", "get_todo", "todo", .must_exist = true}, {"todos_db", "get_todo", "todo", .err_on_empty = true},
{"todos_db", "get_urgent","urgent", .if_context = "show_urgent"} {"todos_db", "get_urgent","urgent", .if_ctx = "show_urgent"}
) )
sqlite_query({"todos_db", "create_todo"}) // no result captured 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}});`. 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) ### join — nest one table's records into another (in-memory)
By order: `join(parent_key, parent_field, child_key, child_field)`. Optional `.join_field_key` (new field name on parent records; defaults to `child_key`). After it, each parent record gains a field holding its matched child records. 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.
```c ```c
// before: { blog:[{id,...}], comments:[{id,blog_id,...}] } // before: { blog:[{id,...}], comments:[{id,blog_id,...}] }
join("blog", "id", "comments", "blog_id") join("blog", "id", "comments", "blog_id")
@@ -132,25 +132,25 @@ join("blog", "id", "comments", "blog_id")
``` ```
### fetch — outbound HTTP; JSON parses into tables/records ### fetch — outbound HTTP; JSON parses into tables/records
Multiple items run CONCURRENTLY. By order per item: `{url, set_key, method, json_key, headers}`. Named: `.method` (`m_get` default, `m_post m_put m_patch m_delete m_sse_method`), `.headers` (array of `{name,value}`), `.json` (context key serialized as JSON body), `.text` (context key as plain-text body), `.if_context`/`.unless_context`. 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`.
```c ```c
fetch( fetch(
{"https://api.weather.dev/now?city={{city}}", "weather"}, {.url = "https://api.weather.dev/now?city={{city}}", .ctx_key = "weather"},
{"https://api.news.dev/headlines?topic={{topic}}", "news"} {.url = "https://api.news.dev/headlines?topic={{topic}}", .ctx_key = "news"}
) )
fetch({"https://api.payments.dev/charge", "receipt", m_post, "order", fetch({.url = "https://api.payments.dev/charge", .ctx_key = "receipt", .meth = n_post, .json_ctx_key = "order",
{{"Authorization","Bearer {{api_key}}"}, {"Idempotency-Key","{{order_id}}"}}}) .headers = {{"Authorization","Bearer {{api_key}}"}, {"Idempotency-Key","{{order_id}}"}}})
``` ```
### run / run_worker — run C logic ### run / run_worker — run C logic
`run(^(){ ... })` (inline block) or `run(.call = fn)` (named C function) for short, non-blocking logic between steps (enrich/aggregate/transform results, set flags for conditionals, call `error_set()`). `run_worker(...)` takes the same forms but offloads blocking/CPU-bound work (external libs, heavy compute, blocking I/O) to the shared thread pool, freeing the reactor; the pipeline resumes after it returns. Inside blocks/`.call` use the Imperative API. `run(^(){ ... })` (inline block) or `run(.call = fn)` (named C function) for short, non-blocking logic between steps (enrich/aggregate/transform results, set flags for conditionals, call `err_set()`). `run_worker(...)` takes the same forms but offloads blocking/CPU-bound work (external libs, heavy compute, blocking I/O) to the shared thread pool, freeing the reactor; the pipeline resumes after it returns. Inside blocks/`.call` use the Imperative API.
```c ```c
run(^(){ run(^(){
auto t = get("challengers"); auto t = get("challengers");
auto p0 = table_get(t, 0); auto p0 = tbl_get(t, 0);
auto p1 = table_get(t, 1); auto p1 = tbl_get(t, 1);
record_set(p0, "opponent_id", record_get(p1, "id")); rec_set(p0, "opponent_id", rec_get(p1, "id"));
record_set(p1, "opponent_id", record_get(p0, "id")); rec_set(p1, "opponent_id", rec_get(p0, "id"));
}) })
run(.call = assign_opponents) run(.call = assign_opponents)
``` ```
@@ -161,22 +161,22 @@ run(.call = assign_opponents)
### run_task — run a task inline ### run_task — run a task inline
`run_task("task_name")`. 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. Task must be defined with `task(...)`. `run_task("task_name")`. 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. Task must be defined with `task(...)`.
### dispatch_task — enqueue a durable background job ### dispatch — enqueue a durable background job
`dispatch_task("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 <dispatch.h>`. Task must be defined with `task(...)`. `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 <dispatch.h>`. Task must be defined with `task(...)`.
### sse — push a Server-Sent Event ### sse — push a Server-Sent Event
With `.channel` (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). 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).
```c ```c
sse("todos:{{user_id}}", .event = "todo_updated", .data = {"id: {{todo_id}}", "title: {{title}}"}) sse("todos:{{user_id}}", .evt = "todo_updated", .d = {"id: {{todo_id}}", "title: {{title}}"})
``` ```
### render — mustache / mdm / json ### 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_key, set_key)`, `mdm(...)`, `json(...)`. By order: template context key, then set_key for output. (See Templates.)
### respond — send the response ### respond — send the response
By order: `respond(context_key)`. Named: `.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), `.mime` (override; same values as resource `.mime`). 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`).
```c ```c
mustache("404","not_found_s"), respond("not_found_s", .status = m_not_found) mustache("404","not_found_s"), respond("not_found_s", .status = n_not_found)
``` ```
### headers / cookies — set response headers/cookies ### headers / cookies — set response headers/cookies
@@ -190,56 +190,56 @@ cookies({{"session","{{session_id}}"}, {"theme","{{theme}}"}})
`redirect("name")` returns a 302 (browser navigates). `reroute("name")` re-enters the router server-side, running another resource's pipeline within the same request. Both take only the resource name; `:params` read from context by matching key names. `redirect("name")` returns a 302 (browser navigates). `reroute("name")` re-enters the router server-side, running another resource's pipeline within the same request. Both take only the resource name; `:params` read from context by matching key names.
### nest — group steps as one composite step ### nest — group steps as one composite step
`nest({step, step, ...}, .if_context = "flag")` — apply one condition to several steps without repeating it. `nest({step, step, ...}, .if_ctx = "flag")` — apply one condition to several steps without repeating it.
## Imperative API (inside run/run_worker/.call) ## Imperative API (inside run/run_worker/.call)
Context: `get(name)` → stored value (string for scalars, table for results) or `nullptr`; `set(name, value)`; `has(name)` → bool; `format(fmt)` → string with `{{interpolation}}` resolved against context. Context: `get(name)` → stored value (string for scalars, table for results) or `nullptr`; `set(name, value)`; `has(name)` → bool; `fmt(fmtstr)` → string with `{{interpolation}}` resolved against context.
Memory: `allocate(bytes)` → arena buffer (auto-reclaimed on request end); `defer_free(ptr)` → schedule `free()` for a foreign/library pointer. Do not use `malloc`/`free`. Memory: `alloc(sz)` → arena buffer (auto-reclaimed on request end); `defer_free(ptr)` → schedule `free()` for a foreign/library pointer. Do not use `malloc`/`free`.
Errors: `error_set(name, (error){code, "msg"})` (triggers nearest error/repair pipeline); `error_get(name)`; `error_has(name)`. Errors: `err_set(name, (err){code, "msg"})` (triggers nearest error/repair pipeline); `err_get(name)`; `err_has(name)`.
Tables (ordered record collections): `table_new()`, `table_count(t)`, `table_get(t, i)` (or `nullptr`), `table_add(t, r)`, `table_remove(t, r)`, `table_remove_at(t, i)`. Tables (ordered record collections): `tbl_new()`, `tbl_len(t)`, `tbl_get(t, i)` (or `nullptr`), `tbl_add(t, r)`, `tbl_rem(t, r)`, `tbl_rem_at(t, i)`.
Records (name→string bags): `record_new()`, `record_get(r, name)` (or `nullptr`), `record_set(r, name, value)`, `record_remove(r, name)`. Records (name→string bags): `rec_new()`, `rec_get(r, name)` (or `nullptr`), `rec_set(r, name, value)`, `rec_rem(r, name)`.
```c ```c
run(^(){ run(^(){
auto todos = get("todos"); auto todos = get("todos");
for (int i = 0; i < table_count(todos); i++) { for (int i = 0; i < tbl_len(todos); i++) {
auto t = table_get(todos, i); auto t = tbl_get(todos, i);
auto title = record_get(t, "title"); auto title = rec_get(t, "title");
if (title && strlen(title) > 40) record_set(t, "is_long", "1"); if (title && strlen(title) > 40) rec_set(t, "is_long", "1");
} }
}) })
``` ```
## Conditionals ## Conditionals
`.if_context = "key"` runs the step only when the value is present; `.unless_context = "key"` only when absent. Works on any context value (validated inputs, query results, framework flags like `is_htmx`, or flags set in `run()`). For multi-state branching, set flags in `run()` and key downstream steps off them. `.if_ctx = "key"` runs the step only when the value is present; `.not_ctx = "key"` only when absent. Works on any context value (validated inputs, query results, framework flags like `is_htmx`, or flags set in `run()`). For multi-state branching, set flags in `run()` and key downstream steps off them.
```c ```c
mustache("fragment","frag_s", .if_context = "is_htmx"), mustache("fragment","frag_s", .if_ctx = "is_htmx"),
respond("frag_s", .if_context = "is_htmx"), respond("frag_s", .if_ctx = "is_htmx"),
mustache("full_page","page_s", .unless_context = "is_htmx"), mustache("full_page","page_s", .not_ctx = "is_htmx"),
respond("page_s", .unless_context = "is_htmx") respond("page_s", .not_ctx = "is_htmx")
``` ```
## Iteration ## Iteration
`.map = "table"` runs a step once per row, ALL ROWS CONCURRENTLY; the row's fields land in scope as bare `{{interpolations}}`. `.item = "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 `set_key`, results collect into a table aligned with the input (one entry per row).
```c ```c
fetch({"https://api.users.dev/{{id}}", "profiles", .map = "users"}) // per-row fan-out fetch({"https://api.users.dev/{{id}}", "profiles", .map = "users"}) // per-row fan-out
mustache("todo","todo_s", .map = "todos", .item = "todo_d") // render per row, collect into todo_s mustache("todo","todo_s", .map = "todos", .map_key = "todo_d") // render per row, collect into todo_s
``` ```
## Error and Repair Pipelines ## Error and Repair Pipelines
On failure, Nerak finds a handler by error code: resource `.errors`/`.repairs` first, then the module's `error()`/`repair()`; first match wins (resource overrides module for the same code). **Errors** are terminal (send a response, end request). **Repairs** are resumable (fix context, then resume at the step AFTER the failure). Repairs resolve first; unmatched repairs fall through to errors; unhandled errors fall through to Nerak's internal handler. The `error:` scope is shared by `input()` failures and `error_set()`; raw values stay in `input:name`. On failure, Nerak finds a handler by error code: resource `.errors`/`.repairs` first, then the module's `error()`/`repair()`; first match wins (resource overrides module for the same code). **Errors** are terminal (send a response, end request). **Repairs** are resumable (fix context, then resume at the step AFTER the failure). Repairs resolve first; unmatched repairs fall through to errors; unhandled errors fall through to Nerak's internal handler. The `error:` scope is shared by `input()` failures and `err_set()`; raw values stay in `input:name`.
Built-in codes: `m_bad_request` 400, `m_not_authorized` 401, `m_not_found` 404, `m_error` 500. Any integer works; define your own: `#define err_quota_exceeded 723`. Built-in codes: `n_bad_request` 400, `n_not_authorized` 401, `n_not_found` 404, `n_error` 500. Any integer works; define your own: `#define err_quota_exceeded 723`.
```c ```c
// resource-scoped // resource-scoped
.errors = {{m_not_found, {mustache("404","nf_s"), respond("nf_s")}}, .errors = {{n_not_found, {mustache("404","nf_s"), respond("nf_s")}},
{m_bad_request, {mustache("form","f_s"), respond("f_s")}}}, {n_bad_request, {mustache("form","f_s"), respond("f_s")}}},
.repairs = {{m_not_authorized, {run(.call = refresh_session_token)}}} .repairs = {{n_not_authorized, {run(.call = refresh_session_token)}}}
// module-scoped (inside config(name){}) // module-scoped (inside config(name){})
error(m_error, {mustache("5xx","e_s"), respond("e_s")}); error(n_error, {mustache("5xx","e_s"), respond("e_s")});
repair(m_not_authorized, {run(.call = refresh_session_token)}); repair(n_not_authorized, {run(.call = refresh_session_token)});
``` ```
## Event Pipelines (pub/sub) ## Event Pipelines (pub/sub)
@@ -254,15 +254,15 @@ Add a subscriber = add a new module with `subscribe(...)`; the publisher does no
A task is a named, reusable pipeline defined inside a module with `task("name", { pipeline }, ...)`. Registration and invocation are separate — three ways to invoke: A task is a named, reusable pipeline defined inside a module with `task("name", { pipeline }, ...)`. Registration and invocation are separate — three ways to invoke:
- `run_task("name")` — run it INLINE as a step in the calling pipeline; control returns to the next step when it finishes. For reusable pipelines composed into workflows. - `run_task("name")` — run it INLINE as a step in the calling pipeline; control returns to the next step when it finishes. For reusable pipelines composed into workflows.
- `dispatch_task("name")` — run it as a durable background job, returning immediately; task reactors pick it up. Requires `#include <dispatch.h>`, which provides the persistent task tables and checkpoints context after each step, so a crash mid-task resumes where it stopped. - `dispatch("name")` — run it as a durable background job, returning immediately; task reactors pick it up. Requires `#include <dispatch.h>`, which provides the persistent task tables and checkpoints context after each step, so a crash mid-task resumes where it stopped.
- `.cron = "0 8 * * *"` — run it in the background on a schedule, no caller. - `.cron = "0 8 * * *"` — run it in the background on a schedule, no caller.
`task(...)` fields: `.accepts = {"keys"}` (pull caller context keys into the task), `.cron` (schedule), `.errors` / `.repairs` (own handlers, then its module's). Any pipeline or task can call `run()`, `run_worker()`, `run_task()`, and `dispatch_task()` (the last requires `dispatch.h`). `task(...)` fields: `.accepts = {"keys"}` (pull caller context keys into the task), `.cron` (schedule), `.errors` / `.repairs` (own handlers, then its module's). Any pipeline or task can call `run()`, `run_worker()`, `run_task()`, and `dispatch()` (the last requires `dispatch.h`).
```c ```c
// reusable, run inline via run_task("recount_todos"): // reusable, run inline via run_task("recount_todos"):
task("recount_todos", { sqlite_query({"todos_db","recount"}) }, .accepts = {"user_id"}); task("recount_todos", { sqlite_query({"todos_db","recount"}) }, .accepts = {"user_id"});
// durable background job via dispatch_task("notify_new_todo") (needs #include <dispatch.h>): // durable background job via dispatch("notify_new_todo") (needs #include <dispatch.h>):
task("notify_new_todo", { fetch({"https://api.push.dev/notify", .method = m_post, .json = "{\"text\":\"New todo: {{title}}\"}"}) }, .accepts = {"title"}); task("notify_new_todo", { fetch({.url = "https://api.push.dev/notify", .meth = n_post, .json = "{\"text\":\"New todo: {{title}}\"}"}) }, .accepts = {"title"});
// recurring on a schedule, no caller: // recurring on a schedule, no caller:
task("daily_digest", { sqlite_query({"todos_db","digest"}), emit("digest_ready") }, .cron = "0 8 * * *"); task("daily_digest", { sqlite_query({"todos_db","digest"}), emit("digest_ready") }, .cron = "0 8 * * *");
``` ```
@@ -278,7 +278,7 @@ task("daily_digest", { sqlite_query({"todos_db","digest"}), emit("digest_ready")
#include <nerak.h> #include <nerak.h>
#include <sqlite.h> #include <sqlite.h>
config(blogs){ config(blogs){
sqlite_database(.name="blog_db", .connect="file:blogs.db?mode=rwc", .migrations={"create_blogs_table"}); sqlite_config("blog_db", "file:blogs.db?mode=rwc", {"create_blogs_table"});
resource("blog", "/blogs/:id", .get = { /* ... */ }); resource("blog", "/blogs/:id", .get = { /* ... */ });
} }
``` ```
@@ -286,26 +286,40 @@ config(blogs){
## Bundled Modules ## Bundled Modules
### htmx — `#include <htmx.h>` ### htmx — `#include <htmx.h>`
Serves the runtime as the `{{> htmx }}` partial; sets `is_htmx` on requests with the `HX-Request` header. Return a fragment to htmx and full page to direct visits via `.if_context`/`.unless_context`. Use `hx-boost='true'` to upgrade links/forms. Put `{{> htmx }}` once in `<head>`. Serves the runtime as the `{{> htmx }}` partial; sets `is_htmx` on requests with the `HX-Request` header. Return a fragment to htmx and full page to direct visits via `.if_ctx`/`.not_ctx`. Use `hx-boost='true'` to upgrade links/forms. Put `{{> htmx }}` once in `<head>`.
### datastar — `#include <datastar.h>` ### datastar — `#include <datastar.h>`
Serves `{{> datastar }}` partial; `datastar_sse()` pushes reactive patches over an SSE channel (page opens a resource `.sse` channel; pipelines push patches). First value (by order) = channel (interpolation ok). Named: Serves `{{> datastar }}` partial; `datastar()` pushes reactive patches over an SSE channel (page opens a resource `.sse` channel; pipelines push patches). First value (by order) = channel (interpolation ok). Named:
- `.target` — CSS selector to patch (interpolation ok). - `.target` — target element to patch, an element id or CSS selector (interpolation ok).
- `.mode` — `mode_outer mode_inner mode_replace mode_prepend mode_append mode_before mode_after mode_remove`. - `.mode` — `ds_outer ds_inner ds_replace ds_prepend ds_append ds_before ds_after ds_remove`.
- `.elements` — context key with the rendered HTML fragment (not needed for `mode_remove`). - `.elements` — context key with the rendered HTML fragment (not needed for `ds_remove`).
- `.signals` — context key with signal state to merge into the client store. - `.signals` — context key with signal state to merge into the client store.
- `.js` — JavaScript to run on the client. - `.js` — JavaScript to run on the client.
```c ```c
// create -> prepend, update -> replace, delete -> remove; each pushed to all channel clients (mirrors app.c)
resource("todos", "/todos", resource("todos", "/todos",
.all = {logged_in()},
.sse = {"todos:{{user_id}}"}, // each browser listens here .sse = {"todos:{{user_id}}"}, // each browser listens here
.post = { .post = {
input({"title", m_not_empty}), input({"title", n_not_empty}),
sqlite_query({"todos_db","insert_todo","todo", .must_exist = true}), // RETURNING row sqlite_query({"todos_db","create_todo","todo_data", .err_on_empty = true}), // RETURNING row
mustache("todo_row","todo_row_s"), mustache("todo","todo_s"),
datastar_sse("todos:{{user_id}}", .target = "#todo-list", .mode = mode_append, .elements = "todo_row_s") 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"),
datastar("todos:{{user_id}}", .target = "todo_{{id}}", .mode = ds_replace, .elements = "todo_s")
},
.delete = {
sqlite_query({"todos_db","delete_todo", .err_on_empty = true}),
datastar("todos:{{user_id}}", .target = "todo_{{id}}", .mode = ds_remove)
} }
); );
datastar_sse("todos:{{user_id}}", .target = "#todo-{{id}}", .mode = mode_remove)
``` ```
### tailwind — `#include <tailwind.h>` ### tailwind — `#include <tailwind.h>`
@@ -326,7 +340,7 @@ resource("logout", "/logout", .post = {logout()});
``` ```
### Database engines ### Database engines
`sqlite postgres mysql redis duckdb`. Each: `#include <engine.h>`, `<engine>_database(...)`, `<engine>_query({...})`. Shared config; only `.connect` is engine-specific. `sqlite postgres mysql redis duckdb`. Each: `#include <engine.h>`, `<engine>_config(...)`, `<engine>_query({...})`. Shared config; only `.conn` is engine-specific.
## Static Files & External Dependencies ## Static Files & External Dependencies
@@ -335,7 +349,7 @@ resource("logout", "/logout", .post = {logout()});
## Safety Guarantees (handled by the framework — do not reimplement) ## Safety Guarantees (handled by the framework — do not reimplement)
- Memory: per-request arena allocators; no `malloc`/`free` in app code (use `allocate()`/`defer_free()`). All framework structures bounds-checked; OOB reads / missing keys return `nullptr` rather than faulting. Pipeline memory cap (default 5MB) aborts with 500. - Memory: per-request arena allocators; no `malloc`/`free` in app code (use `alloc()`/`defer_free()`). All framework structures bounds-checked; OOB reads / missing keys return `nullptr` rather than faulting. Pipeline memory cap (default 5MB) aborts with 500.
- SQL injection: `{{interpolation}}` in query SQL is bound as prepared-statement parameters. - SQL injection: `{{interpolation}}` in query SQL is bound as prepared-statement parameters.
- XSS: `mustache()`/`mdm()` auto-escape; raw HTML requires explicit `{{{field}}}`/`{{&field}}`. - XSS: `mustache()`/`mdm()` auto-escape; raw HTML requires explicit `{{{field}}}`/`{{&field}}`.
- CSRF: state-changing requests verified against a per-session token; emit via `{{csrf:input}}` / `{{csrf:param}}`. - CSRF: state-changing requests verified against a per-session token; emit via `{{csrf:input}}` / `{{csrf:param}}`.
@@ -385,8 +399,8 @@ docker compose up
#include <pubsub.h> #include <pubsub.h>
config(todos){ config(todos){
sqlite_database(.name="todos_db", .connect="file:todos.db?mode=rwc", sqlite_config("todos_db", "file:todos.db?mode=rwc",
.migrations={"create_todos_table"}); {"create_todos_table"});
publish("todo_created", .with = {"title"}); publish("todo_created", .with = {"title"});
resource("todos", "/todos", resource("todos", "/todos",
@@ -396,18 +410,18 @@ config(todos){
respond("todos_s") respond("todos_s")
}, },
.post = { .post = {
input({"title", m_not_empty, "title is required"}), input({"title", n_not_empty, "title is required"}),
sqlite_query({"todos_db", "create_todo"}), sqlite_query({"todos_db", "create_todo"}),
emit("todo_created"), emit("todo_created"),
redirect("todos") // POST-redirect-GET redirect("todos") // POST-redirect-GET
}, },
.errors = {{m_bad_request, {reroute("todos")}}} // re-render the GET; form repopulates from input:/error: .errors = {{n_bad_request, {reroute("todos")}}} // re-render the GET; form repopulates from input:/error:
); );
resource("todo", "/todos/:id", resource("todo", "/todos/:id",
.all = {input({"id", m_integer})}, .all = {input({"id", n_int})},
.get = { .get = {
sqlite_query({"todos_db", "get_todo", "todo_data", .must_exist = true}), sqlite_query({"todos_db", "get_todo", "todo_data", .err_on_empty = true}),
mustache("todo", "todo_s"), mustache("todo", "todo_s"),
respond("todo_s") respond("todo_s")
} }