# Effortless — conventions for AI (and humans) Effortless is a single-binary web framework: Crystal owns routing, SQLite, templating, auth, and safety; you write small Lua files and HTML templates that the binary picks up on the next request — no build step, no restart in dev. SQL injection is inexpressible (queries are EQL, always parameterized), XSS is opt-in (`| raw`), CSRF is framework-level. **After every change, verify with the ladder: `effortless check` → `effortless request` → `effortless test` (section 10).** ## 1. Project layout ``` myapp/ ├── config.lua -- overrides only; built-in options come from .env ├── _schema/schema.lua -- THE schema (desired state; no migration files) ├── _tests/ -- Lua test suites, run by `effortless test` ├── lib/ -- shared domain modules, loaded with require "deals" ├── _commands/ -- named CLI operations, run with `effortless cmd name` ├── _cron/ -- scheduled jobs, run by the `serve` process ├── layouts/base.x.html -- named layouts, referenced by {{ layout "base" }} ├── routes/ -- file-based routing (section 2) │ ├── index.x.html -- view for / │ ├── index.lua -- data/handlers for / │ └── todos/ │ ├── todos.x.html -- view for /todos │ ├── todos.lua -- handlers for /todos │ └── todos.export.lua-- extra action module "export" ├── public/ -- static files, served as-is ├── uploads/ -- saved uploads, served at /uploads/ (auto-created) └── data/effortless.db -- SQLite (auto-created) ``` Built-in: `/_admin` (row CRUD) for the signed-in users whose email is listed in `SUPERADMINS` (comma-separated, in `.env` or the host environment; `admin = { superadmins = { ... } }` in `config.lua` overrides it); no list, no admin. The `role` column is the app's, not the admin's. Schema is `_schema/schema.lua`, not the admin UI. The drawer's Related section adds and removes related rows in place (join tables included). `/_admin/ask` turns a plain-language question into one read-only EQL statement and runs it — it needs `ai.driver` configured, and it can only `select`. **Configuration.** Precedence is CLI flags, then `config.lua`, then the environment (a project-root `.env` is loaded first; the shell wins over it), then defaults. Every built-in option has an environment name: `PORT`, `HOST`, `DATABASE_PATH`, `UPLOAD_MAX_MB`, `UPLOAD_ROOT`, `UPLOAD_DRIVER`, `MEDIA_URL`, `R2_ACCOUNT_ID`, `R2_BUCKET`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_KEY_PREFIX`, `R2_PUBLIC_BASE_URL`, `CRON_ENABLED`, `CRON_TICK_SECONDS`, `CRON_TIMEOUT_SECONDS`, `MAIL_DRIVER`, `MAIL_FROM`, `BENTO_SITE_UUID`, `BENTO_PUBLISHABLE_KEY`, `BENTO_SECRET_KEY`, `BENTO_BASE_URL`, `AI_DRIVER`, `AI_MODEL`, `AI_API_KEY`, `AI_BASE_URL`, `AI_TIMEOUT`, `HTTP_ALLOW_PRIVATE`, `HTTP_ALLOW_HOSTS`, `ADMIN_HOSTS`, `SUPERADMINS`, `SNAPSHOT_SCRUB`, `PULL_URL`. A driver left unnamed is inferred from the credentials present: `R2_BUCKET` selects r2, `BENTO_SECRET_KEY` bento, `AI_API_KEY` openai_compatible against the Vercel AI Gateway. Keep `config.lua` for values the app computes (a data directory that holds both the database and uploads) or states in code (`uploads.max_size_mb`, `http.allow_hosts`); secrets and per-host values go in `.env`. ## 2. Routing (file conventions ARE the router) If you know Next.js/PHP file routing, this is that: | File | URL | Notes | |---|---|---| | `routes/index.x.html` + `index.lua` | `/` | | | `routes/todos/todos.x.html` + `todos.lua` | `/todos` | dir name = file name = route | | `routes/todos/[id]/[id].lua` | `/todos/:id` | `request.params.id` | | `routes/docs/[...path]/[...path].lua` | `/docs/*` | catch-all, must be last segment | | `routes/todos/todos.export.lua` | action `export` on `/todos` | action module (section 3) | | `routes/middleware.lua` | all routes | root only; define `before(request)` | - A route can have a view (`.x.html`), a handler (`.lua`), or both. View-only = static page. Handler-only = JSON API (section 3). - `.x.html` renders on GET. POST/PUT/PATCH/DELETE need a handler. - HTML forms can override the method: `` on a POST form. - Duplicate route definitions are errors. Do not define both `routes/about.lua` and `routes/about/about.lua`, or two views/handlers/actions that resolve to the same URL/action. **Route-local Lua modules** are files under `routes/` that are never routed, for domain code that belongs to one route subtree rather than to the whole app: | File | Required as | | |---|---|---| | `routes/products/_form.lua` | `require "_form"` | underscore-prefixed sibling | | `routes/products/import/lib/page.lua` | `require "lib.page"` | inside a route-local `lib/` dir | A `lib` directory under `routes/` contributes no URL segment, and nothing inside it is routable. Both forms resolve by walking up from the requiring file toward `routes/`, so a module is visible to the route that owns it and to every route beneath it. Put shared code in the project's top-level `lib/` instead; that is on `package.path` and is what `_tests/` and `effortless eval` can require by bare name. A route-local module is reachable from a test by its path from the project root: `require "routes.products.import.lib.page"`. ## 3. Route handlers (Lua) One function per HTTP method. Compare to a Rails controller: `get` = `show`, `actions.add` = a named POST action, `guard` = `before_action :authenticate`. ```lua local auth = require "effortless.auth" local eql = require "effortless.eql" guard = auth.check -- same as function guard(request) return auth.check() end function get(request) return { todos = eql "select todos where .user_id = $auth.id order by created_at desc", } end actions = { add = eql.mutate "insert todos { title: $form.title, user_id: $auth.id }", delete = eql.mutate "delete todos where .id = $form.id and .user_id = $auth.id", } ``` `user` and `logged_in` are auto-injected into every template — do not pass `user = request.user` from handlers. List selects always return an array (possibly empty), including `limit 1`. Use `eql.one` for a single row. Do not write `or {}` after list queries. **Computed display fields:** augment query rows in place — `for _, c in ipairs(contacts) do c.initials = initials(c.name) end` — never rebuild row tables field-by-field just to add one derived key. **Multi-tenant apps:** a table declares its owner column with `owner = true` (`owner_id = t.ref "users" { required = true, owner = true }`); those tables are linted by `effortless check` — every select/update/delete must filter `.owner_id = $auth.id` (or equivalent), and inserts must set `owner_id: $auth.id`. Intentional cross-tenant queries: add `-- eql: unscoped` on the line above the call. **`eql.mutate()` update mode** uses the query verb, not `form.id` — `eql.mutate "update todos set { completed: true } where .id = $params.id"` validates as an update when the URL carries `:id`, even without a hidden `form.id`. **Schema enums in Lua:** `local schema = require "effortless.schema"` · `schema.one_of("deals", "stage")` for dropdowns (single source of truth with `t.enum { ... }` — or an explicit `one_of = {...}` rule — in `_schema/schema.lua`). **Insert defaults:** on insert only, an omitted or empty `$form.*` field on a column with a schema `default = ...` binds that default (not SQL NULL). Updates still treat empty as NULL. Also available: `function post(request)`, `put`, `patch`, `delete` for method-level handlers. An action module file (`todos.export.lua`) contains `return function(request) ... end` and registers as `actions.export`. POST named actions may come from `request.form.action` or `request.json.action`; if both are strings, they must match. **`request` fields:** `method`, `path`, `params` (URL segments), `query`, `query_all` (table of arrays for repeated parameters), `form`, `files` (section 8), `headers`, `cookies`, `ip`, `user` (row or nil), `json` (parsed body for `application/json` requests, else nil), `json_error`. **Return shapes** (what the handler returns decides the response): | Return | Result | |---|---| | `{ todos = ..., title = ... }` | keys become template variables; view renders | | `response.redirect("/todos")` | 302 (paths only — external URLs are rejected); `local response = require "effortless.response"` | | `response.json(data, 201)` | JSON with status | | `response.error(404, "Not found")` | error page / JSON error | | `response.csv(rows, { filename = "x.csv" })` | CSV download (section 9 recipe) | | `{ _validation_errors = errs, _old = request.form }` | 422; GET re-runs; view gets `errors` + `old` (section 7) | | plain data from POST/PUT/PATCH/DELETE on a page route | 303 PRG back to the request path | | `return data, response` from an action | the non-nil second response wins | **A message you compute in `post` (failed login, business-rule rejection) can only reach the page through the `_validation_errors` bag.** Returning `{ error = msg }` as plain data from a POST on a page route is silently discarded by the 303 PRG — the user gets a clean re-render with no message. Return `{ _validation_errors = { auth = msg }, _old = request.form }` and render `{{ errors.auth }}` (section 7). Handler-only routes (no `.x.html`) return their table as JSON automatically — that's the JSON-API convention, including mutating methods. Keys starting with `_` are stripped from JSON output. **Guards:** return `true` to proceed; `"forbidden"` → 403; anything else → redirect to `/login?next=`. `auth.require()` and `auth.require("admin")` produce exactly these values. **`eql.mutate(query, opts)`** runs the same validation path as direct `eql()` inserts/updates and returns `result, err`. A one-line page action still redirects through the router's 303 PRG default. Custom actions capture both values, return `err` when present, then use `result` (including mutation `returning` rows) before allowing PRG. The only option is `{ validate = false }` (uses `eql.unchecked`); response shaping belongs to `response.redirect(...)` / `response.json(...)`, and the removed `redirect` / `json` options raise loudly. API actions that want a body should return a table or use `returning` and shape an explicit response. ## 4. EQL — the query language (you know SQL; here's the diff) In route files: `local eql = require "effortless.eql"` at the top, then call `eql "..."`. (In `effortless eval` one-liners and `_tests/*.lua`, `eql` is already a global.) | SQL | EQL | |---|---| | `SELECT * FROM todos WHERE user_id = ?` | `select todos where .user_id = $user_id` | | `SELECT id, title FROM todos` | `select todos { id, title }` | | `SELECT ... LIMIT 1` (one row) | `eql.one "select …"` → row or nil (forces LIMIT 1; never raises on >1) | | `SELECT COUNT(*)` | `eql.count "select todos where …"` → number | | `SELECT COUNT(*), SUM(done) FROM t` | `select t { count(), sum(done) }` → keys `count`, `sum_done` (use `eql.one` if you want the single aggregate row) | | `SELECT SUM(price * qty) AS subtotal` | `select t { sum(unit_price * quantity) as subtotal }` — `as alias` required when the arg is not a bare column | | `INSERT INTO todos (title) VALUES (?) RETURNING *` | `insert todos { title: $title }` → **whole row** (default; no `returning` needed) | | `INSERT INTO t (a,b,c) SELECT …` (caller map + server fields) | `insert t { $attrs, session_id: $sid }` — one spread + explicit fields; **explicit keys win** | | `INSERT ... ON CONFLICT (email) DO UPDATE SET name = excluded.name, …` | `insert users { email: $e, name: $n } on conflict email update` — bare `update` = all inserted cols via `excluded.*` (minus conflict target, pk, `created_at`) | | `INSERT ... ON CONFLICT (email) DO UPDATE SET name = ?` | `insert users { email: $e } on conflict email update { name: $n }` — explicit SET block still works | | `INSERT ... ON CONFLICT DO NOTHING` | `... on conflict email ignore` — success returns the row; ignored conflict returns **nil** | | `UPDATE todos SET done = 1 WHERE id = ?` | `update todos set { completed: true } where .id = $id` → affected count | | `... RETURNING id` (narrow) | `insert todos { title: $t } returning { id }` — optional; omit `returning` for the full row | | `DELETE FROM todos WHERE id = ?` | `delete todos where .id = $id` → affected count | | `GROUP BY user_id HAVING COUNT(*) > 3` | `group by .user_id having count() > 3` | | `WHERE ... AND (col LIKE ? OR ...)` (soft search) | `where ... and? (.name like $q or .email like $q)` — skipped when `$q` is null/empty; LIKE auto-wraps `%q%` | | Value list | `select products where .id in ($ids)` — `$ids` is a Lua array (`{1, 2, 3}`), bound as one parameter. An empty array matches nothing. Passing a scalar raises. Use this whenever the *column* is fixed and only the list varies. | | Dynamic filter dictionary | `select products where matches($filters)` — use when the **columns** are decided at runtime (admin grids, saved filters); for a fixed column and a variable list use `in ($ids)`. `$filters` is `{ status = "published", price = { gte = 10 } }`; `nil` or `{}` skips (`1=1`). Operators: `eq`, `ne`, `in`, `not_in`, `gt`, `gte`, `lt`, `lte`, `between`, `contains`, `starts_with`, `is_null`, `before`, `after`, plus combinators `any` / `none`. Bare values are equality. Relative dates (`today`, `-30d`, `start_of_month`) work with `before`/`after`. Unknown columns error when a schema is attached. | | `WHERE EXISTS (SELECT 1 FROM brands WHERE brands.id = products.brand_id AND brands.name LIKE ?)` | `select products where .brands.name like $q` — a **relation path**: filter by a related table's column, any number of hops | | `WHERE EXISTS (SELECT 1 FROM product_images WHERE product_id = products.id)` | `select products where exists .product_images` (`not exists` negates) | | `WHERE (SELECT COUNT(*) FROM product_images WHERE product_id = products.id) > 2` | `select products where count(.product_images) > 2` | | `WHERE NOT EXISTS (SELECT 1 FROM … WHERE tags.name = ?)` | `select products where none(.product_tags.tags.name = $tag)` | | `(SELECT b.name FROM brands b WHERE b.id = products.brand_id)` | `select products { id, .brands.name }` — a belongs-to path read as a **value**; the leaf column names it unless `as` says otherwise | | `(SELECT image FROM product_images WHERE product_id = products.id ORDER BY … LIMIT 1)` | `select products { first(.product_images.image order by thumbnail desc, sort_order) as thumb }` — one related row's column; `first(.x.col where … order by …)` reads the leaf table's own columns, and with no `order by` its primary key decides | | `(SELECT SUM(amount) FROM lines WHERE …)` | `select orders { id, sum(.lines.amount) }` — `sum` / `min` / `max` / `avg` over a path, aliased `sum_amount` like `count(.x)`; one subquery per row, so the outer select is not grouped | | `first_name \|\| ' ' \|\| last_name` | same: `select users { first_name \|\| " " \|\| last_name as name }`. `NULL \|\| x` is `NULL` — wrap operands in `coalesce`, or use `printf` | | JOIN for parent→children | `select users { id, name, todos { title, completed } }` — nested (max depth 2), follows schema foreign keys, returns real nested tables; nested projections list columns/relations, not aggregates, and a grouped or aggregated select cannot nest a relation at all. Direction decides the shape: parent→children nests an array; child→parent (the foreign key on the outer table) nests one row, or nil when the key is null | Rules that differ from SQL: - **Columns in `where`/`order by`/`group by` take a leading dot**: `.user_id`, `.created_at`. Field lists `{ id, title }` don't. - `select` keyword is optional: `todos where .id = $id limit 1` works. - No field list = all columns. - Operators: `= != < > <= >= like / not like / in (a, b) / in ($ids) with a Lua array / not in / between x and y / is null / is not null`, arithmetic `+ - * /` and string concatenation `||`, relation paths (`.brands.name` as a filter or a value, `exists .images`, `count(.images)`, `sum/min/max/avg(.images.width)`, `first(.images.url)`, `any/none/all`), plus `and? (expr)` for soft WHERE filters (empty params drop the group; use explicit `or`/`and` inside), `matches($filters)` for a dynamic filter dictionary, combined with `and` / `or` / `not`, parentheses OK. - `order by` takes a column or a projected alias: `select orders { customer_id, count() as n } group by .customer_id order by .n desc`. - `update ... set { completed: not .completed }` — expressions over columns work in `set`. - Booleans are real: `where .completed = false`, and rows come back with true/false (the schema knows the types). - There is **no raw SQL**. Everything binds as parameters; string-concatenating a query is never needed and won't work. **Relation paths in `where` / `having`.** A dotted path filters by a related table's column: `where .brands.name like $q`. Each segment is either a foreign-key column (`.brand_id`, exact) or a related table name (`.brands`, either direction, and it must resolve to exactly one foreign key — otherwise you get `ambiguous relation 'users' from 'messages': use a column (recipient_id, sender_id)`). Up to 6 hops; cycles are fine (`.parent_id.parent_id.title`). Each hop compiles to a correlated `EXISTS`, so the outer row count never changes and `count()` on the outer query still counts rows, not joined pairs. A path through only belongs-to hops is a plain join condition and takes any operator, and reads as a value where a value goes (`{ .brands.name }`, `where .brands.name = $q`). One value path is one subquery: `{ .brands.name, .brands.code }` is two seeks to `brands` per row where `brands { name, code }` is one — a value path is for a single column, a nested block for several. A path through a **has-many** hop is a *set* — `first(...)`, `count(...)`, `sum(...)` or a comparison, never a bare value — and a positive comparison on a set is true when **any** element matches: `where .product_tags.tags.name = $tag` means "some tag matches". **Negative operators on a has-many path are a compile error**, because they have two readings. Say which you mean with a quantifier: ``` any(expr) some related row satisfies expr (identity; this is what unlocks negatives) none(expr) no related row satisfies expr all(expr) every related row satisfies expr (vacuously true when there are none) ``` ```lua -- some tag is not "red" vs no tag is "red" eql "select products where any(.product_tags.tags.name != $tag)" eql "select products where none(.product_tags.tags.name = $tag)" -- "only red", which needs the set to be non-empty too eql "select products where exists .product_tags and all(.product_tags.tags.name = $tag)" ``` One has-many path per quantifier (belongs-to hops inside it are fine). `matches($filters)` takes the same paths as dotted keys: `{ ["brands.name"] = { contains = "nunc" } }`. **A path predicate is not a tenant filter.** `where .todos.user_id = $auth.id` scopes the *subquery*, not the outer table; the tenancy lint still wants the owner column on the outer query. A handle's row scope is repeated inside every hop table that carries the scoped columns, so a path cannot answer "does a related row exist" across tenants. **Parameters — `$name` auto-capture.** `$title` is captured from your Lua locals/upvalues/globals by name — explicit param tables are optional. Five request namespaces always work: `$form.title`, `$json.title`, `$params.id`, `$query.page`, and `$auth.id` (the logged-in user). JSON capture keeps native number/boolean/string values; missing write fields follow the dotted-nil rules below. A typo fails loudly with suggestions — trust the error message. Explicit params override capture: `eql("... where .id = $id", { id = 5 })`. **Tail calls and capture:** `return eql "..."` is a Lua tail call — the caller's locals are gone before capture runs. Assign the result before returning, use `return (eql "...")` (parens defeat TCO), or pass an explicit params table. `effortless check` flags direct returns that depend on local auto-capture. **Nil / empty rules (write vs where):** - Omitted or empty **`$form.*`** fields bind SQL `NULL` on updates; on inserts they bind the column's schema default when one exists. - Other dotted fields in **insert/update value blocks** (`$attrs.image_path`) with Lua `nil` → schema default if any, else SQL `NULL`. You do **not** need `attrs.x or eql.NULL` for write positions. - The same dotted nil in a **`where`** clause is still a **hard error** (guards against silently unscoped queries). Simple-name typos (`$sesion_id`) also stay hard errors. - Empty string stays a real value for non-form bases (`attrs.notes = ""` writes `""`, not NULL). - For NULL outside those paths (locals you must force, spread tables), use `eql.NULL` — Lua's `nil` vanishes from tables: ```lua local attachment = request.files.attachment and require("effortless.files").save(request.files.attachment) or eql.NULL eql "insert todos { title: $form.title, user_id: $auth.id, attachment: $attachment }" ``` **Searching** — use `and? (...)` in WHERE for soft filters. Write explicit `or` / `and` inside the parentheses; the whole group is skipped when all referenced params are null or empty. `LIKE` operands auto-wrap `%value%` — pass raw `request.query.q`, no `eql.like` needed: ```lua local rows = eql([[ select contacts where .owner_id = $auth.id and? (.name like $q or .email like $q) order by created_at desc ]], { q = request.query.q }) ``` For explicit patterns or non-optional queries, `eql.like(q)` returns `"%q%"` for a non-empty query, else `eql.NULL`. Pair with `where ($pattern is null or .col like $pattern)`: ```lua local rows = eql([[ select todos where .user_id = $auth.id and ($pattern is null or .title like $pattern) order by created_at desc ]], { pattern = eql.like(q) }) ``` **Spread insert** for whole tables of values: `insert users { $data }` with `local data = { email = e, name = n }` — only spread-writable schema columns are written; unknown keys and columns marked `protected = true` are dropped before validation and SQL. Protected columns remain readable and can still be validated and written explicitly (`insert users { $data, role: $role }` / `update users set { role: $role }`). Passing `request.form` straight through (or forwarding it into a `lib/` function that spreads) is the intended use — extra keys like `action` are filtered out; do not hand-copy form fields into a fresh table one by one. Mix with server-controlled fields in any order: ```lua eql "insert cart_items { $attrs, design_session_id: $session_id, status: \"calculated\" }" -- explicit keys always win over the same key from $attrs ``` **Validation on every write.** Direct `eql("insert …")` / `eql("update …")` validate against schema rules by default (insert = create mode; update = only present keys), including string→number/bool coercion for typed columns. Failures raise a structured table `{ __eql_validation = true, table, errors }` (readable via `tostring`). Uncaught validation in a route becomes the normal page error bag or an API `422` body `{ "errors": {...} }`; validation caught with `pcall` remains application-owned. Escape hatch: `eql.unchecked(query, params)` skips validation/coercion (backfills, system writes). `eql.mutate` uses the same path and still returns the 422 bag as `{ _validation_errors, _old }`. **Transactions.** `eql.transaction(function() ... end)` pins all enclosed EQL work to one SQLite connection. A normal return commits; an uncaught Lua error rolls back and is rethrown as the original value (including structured validation errors). Nested transactions use savepoints, so code may catch an inner failure and continue the outer transaction. Transactions are synchronous and do not roll back files, mail, HTTP, or other external effects. **Explicit database handles.** Trusted application modules can target another SQLite file without changing the default database. The optional schema file supplies relation, validation, default, hidden-field, and result-typing metadata; it is parsed but not migrated automatically. Handles are closed when their Lua stack ends, and may be closed earlier. Transactions cannot nest across handles. ```lua local db = require "effortless.db" local eql = require "effortless.eql" local handle = db.open("data/content.db", "_schema/content.lua") local content = eql.on(handle) local rows = content "select products order by created_at desc" local filtered = content("select products where matches($filters)", { filters = { status = "published", price = { gte = 10 } }, }) handle:close() ``` **Default row scope.** A handle can carry a default `WHERE` that the framework ANDs into queries on it — `{ eq = { status = "published" }, is_null = { "deleted_at" } }`. Columns the queried table does not have are skipped, so one scope can serve a whole schema. `eql.unscoped(...)` opts a single query out. Core has no Lua call that sets one; an addon shard attaches the scope to the handle it opened (sait's `schema.attach`). **A row scope applies to SELECT only.** Insert, update and delete are never scoped — `Database.apply_row_scope` returns the query untouched for anything that is not a `SelectQuery`. This is deliberate: a scope silently rewriting a `delete` would make the statement you read not the statement that runs. It is also a sharp edge, because a scope reads like isolation and is only half of it. **Writes must carry their own predicate.** Where a scope is a tenant boundary, keep the tenant column in the write's own text and set it explicitly on insert; the scope is a read-side backstop, not the boundary. The scope is a property of the handle, fixed for the handle's life, so it cannot express a value that varies per request on a shared handle — including `Database.default_handle`. An application whose tenant varies per request opens its own handle for that tenant (`db.default_path()` names the default database when the path is not otherwise known — under the test runner it is a temporary file, not `config.database.path`). **Foreign keys.** On insert/update FK failure you get the same structured shape: `errors.design_session_id = "no design_sessions row with id '…'"` — no need to `exists()` pre-check. Lookup runs only on failure. **Idiomatic `lib/` write** (validation lives in `_schema/schema.lua`): ```lua -- schema: quantity integer min=1 default=1; unit_price real min=0 required; -- product_name text required; … function cart.add_item(session_id, attrs) return (eql "insert cart_items { $attrs, design_session_id: $session_id }") end ``` ## 5. Templates (`.x.html`) — Liquid-style, auto-escaped | Jinja/Liquid | Effortless | |---|---| | `{{ user.name }}` | `{{ user.name }}` (escaped by default) | | `{{ html \| safe }}` | `{{ html \| raw }}` | | `{% if x %}…{% elif %}…{% else %}…{% endif %}` | `{{ if x }}…{{ elseif y }}…{{ else }}…{{ /if }}` | | `{% for t in todos %}…{% endfor %}` | `{{ for t in todos }}…{{ /for }}` | | for-else (empty list) | `{{ for t in todos }}…{{ empty }}

None yet

{{ /for }}` | | `{% include "card" with x=y %}` | `{{ partial "card" x=y }}` (file: `components/card.x.html`) | | `{% set x = v %}` | `{{ set x = v }}` | | `{% extends "base" %}` / block | `{{ layout "base" }}` at top; layout file prints content with `{{ slot }}` | Conditions support `== != < > <= >= and or not` and dotted paths: `{{ if user.role == "admin" }}`. Template `==` coerces numeric strings to numbers (`{{ if old.company_id == c.id }}` works when `old` came from form values). Modifiers (pipe-separated, args after `:`): `default`, `upper`, `lower`, `capitalize`, `truncate:80`, `length`/`count`/`size`, `reverse`, `strip`, `nl2br`, `raw`, `escape`, `json`, `markdown`, `slug`, `first`, `last`, `date:"%Y-%m-%d"`, `timeago`, `sort:"key"`, `where:"key",value`, `plural:"item","items"`, `join:", "`. Example: `{{ title | default:"Untitled" | truncate:80 }}`. An unknown modifier is an error that names the valid ones. The **`markdown` modifier** always renders in safe mode (raw HTML omitted; `javascript:` / `vbscript:` / non-image `data:` links neutralized). XSS is still opt-in via `| raw`, so the idiom is: ``` {{ post.body | markdown | raw }} ``` `check` warns if `| markdown` is not immediately followed by a final `| raw`. There is no `unsafe` escape hatch on the default modifier. Every page automatically has: `csrf_field`, `csrf_token`, `user`, `logged_in`, `path`, `method`, plus URL params. An underscore-prefixed partial is route-local. Resolution starts beside the including template and walks toward `routes/`, so nested routes such as `products/create/create.x.html` and `products/[id]/[id].x.html` can both use `{{ partial "_form" }}` from `products/_form.x.html`. Non-underscore partials continue to resolve from `components/`. ## 6. Schema — `_schema/schema.lua` is the only migration artifact Declare desired state; the framework diffs against the live database and converges (in dev: on every request; in prod: via `effortless migrate`). **There are no migration files. To change the schema, edit this file.** ```lua local t = require "effortless.types" return { users = { id = t.pk, email = t.text { required = true, unique = true }, password_hash = t.secret, -- text + required + hidden + protected }, todos = { id = t.pk, user_id = t.ref "users", -- FK to users.id (or ref "users.id") title = t.text { required = true, min = 3, max = 120 }, status = t.enum { "new", "active", "done", default = "new" }, -- text + one_of in one call amount = t.real { min = 0 }, contact = t.text { email = true, unique = true }, completed = t.boolean { default = false }, created_at = t.now, -- datetime, defaults to now() day = t.text { computed = "date(created_at)" }, -- read-only, derived by the schema indexes = { "user_id", "created_at" }, }, -- Multi-tenant / CRM: owner_id is required in the DB but never posted in forms. -- validate() skips it; you set it in EQL from $auth.id (section 7). visits = { id = t.pk, path = t.text, }, contacts = { id = t.pk, owner_id = t.ref "users" { required = true, owner = true }, company_id = t.ref "companies", -- optional FK; empty {{ if errors.title }}

{{ errors.title }}

{{ /if }} ``` (JSON API requests send the same token in the `X-CSRF-Token` header; forms use the hidden field.) **Auth** (`local auth = require "effortless.auth"`): `auth.check()` → bool · `auth.user()` / `request.user` → row or nil · `auth.login(email, password)` → `{ success, message, user? }` · `auth.register(email, password, name)` → `{ success, message, user_id? }` · `auth.logout()` → `{ success, message }` · `auth.role()`, `auth.has_role("admin")` (exact match) · `auth.require(role?)` for guards · `auth.logout_everywhere()`. Sessions are cookie-based, 7 days, handled for you. Registration always creates `role = "user"`; operators must promote an admin explicitly. The scaffold ships working `/login`, `/register`, `/logout` pages — copy their patterns. `auth.login` and `auth.register` validate their own inputs (empty email/password; register enforces the 8-character password minimum) — do not re-check those in handlers. Only app-level rules like the password-confirm match belong in the route. Auth failures follow the same 422 bag as validation: ```lua function post(request) local result = auth.login(request.form.email, request.form.password) if result.success then return response.redirect(auth.safe_next(request.form.next) or "/") end return { _validation_errors = { auth = result.message }, _old = request.form } end ``` **Validation** comes from the schema rules (section 6) — no duplicate rule declarations in handlers. Direct `eql()` writes and `eql.mutate` both run it for insert/update (section 4). Required FK columns (e.g. `owner_id`) can stay `required = true` in the schema for DB integrity; `validate()` skips them when absent from the form because you set them server-side (`$auth.id`). Columns marked `hidden = true` in the schema are never validated from forms. Manually: ```lua local validate = require "effortless.validate" local errs = validate("todos", request.form) -- nil, or { title = "must be at least 3 characters" } local errs = validate("todos", request.form, { id = 5 }) -- update mode: partial, unique excludes row 5 if errs then return { _validation_errors = errs, _old = request.form } end ``` **Unique on update:** `eql()` reads the exclusion id from `where . = $param` (any param name — `$uid`, `$params.id`, `$form.id`). Keeping your own unique value succeeds. A bulk update without a simple pk equality **skips** the unique check (rather than false-positiving "is already taken"); enforce uniqueness yourself or use a single-row where. The framework turns that return into a 422 re-render of the same page with `errors.` and `old.` available — see the form above. No flash/redirect dance. ## 8. Files, outbound HTTP, subprocesses, JSON, logging ```lua local response = require "effortless.response" local log = require "effortless.log" local json = require "effortless.json" -- uploads: form needs enctype="multipart/form-data"; field arrives in request.files local files = require "effortless.files" local path = files.save(request.files.avatar) -- "uploads/2026/07/ab12cd34.png" local path = files.save(request.files.avatar, "avatars") -- "uploads/avatars/…" local limit = files.max_bytes() -- uploads.max_size_mb, in bytes local url = files.url(row.image) -- resolve a t.file cell, nil when empty local base = files.media_url() -- public media base, "" when local -- an upload is three lines: the column, the save, the render. -- _schema: image = t.image { required = true } -- t.file for non-images -- route: eql("insert product_images { product_id: $p, image: $key }", -- { p = id, key = files.save(request.files.photo) }) -- template: -- the cell holds the key; the framework deletes its object when the row is -- deleted or the cell replaced, so never call files.delete for owned objects. -- `effortless files prune` lists store objects no t.file cell names. -- saved files are public at /; size limit via config.lua uploads.max_size_mb (413 beyond); -- storage dir via uploads.root (default "uploads"; point it at a mounted volume in production); -- set uploads.driver = "r2" for persistent Cloudflare R2 storage on a public custom domain; -- files.save returns the same uploads/... path with either storage driver; -- malformed multipart bodies are 400, not silently treated as empty forms -- outbound HTTP (timeouts default 10s; response body cap is uploads.max_size_mb, -- 10 MB when unset -- a file the app may store is a file it may fetch; -- res.truncated tells you whether the body was cut) -- By default, http.get/post refuse private, loopback, link-local, and other -- non-public resolved addresses (SSRF). Name internal hosts in config: -- http = { allow_hosts = { "internal.svc" } } -- or allow_private = true -- Redirects are never followed: each hop you follow yourself re-enters -- http.get/post and is re-checked against the same policy. local http = require "effortless.http" local res, err = http.get("https://api.example.com/items", { headers = { Authorization = "Bearer …" } }) if res then local data = json.decode(res.body) end local res, err = http.post(url, { json = { text = "hi" } }) -- or { form = {…} } or { body = "…", content_type = "…" } -- subprocess: argv array, NO shell (string-concatenated commands are impossible by design); -- timeout defaults to 30s and returns nil + "timeout" local proc = require "effortless.proc" local r, err = proc.run("magick", { "in.png", "-resize", "800x", "out.png" }, { timeout = 30 }) -- r = { status = 0, stdout = "…", stderr = "…" } -- JSON json.encode(t) / json.decode(s) -- logging: writes to stderr, so `effortless request --json` stays valid JSON on stdout log.info("import finished") ``` **Mail** is deliberately small: `log` is the safe default and writes metadata to stderr; `bento` sends transactional email; `smtp` is reserved but not implemented. Both drivers return `true`, or `nil, err` without turning a request into a 500. ```lua local mail = require "effortless.mail" local ok, err = mail.send { to = "ada@example.com", -- or { "ada@example.com", "lin@example.com" } subject = "Welcome", text = "Your account is ready", -- `html` may be used instead or as well -- personalizations = { name = "Ada" }, -- Bento merge fields } if not ok then log.info("mail failed: " .. err) end ``` Configure it in `.env`; never commit provider credentials. Setting `BENTO_SECRET_KEY` selects the bento driver; `MAIL_FROM` must be a verified Bento author. ``` MAIL_FROM=App BENTO_SITE_UUID=... BENTO_PUBLISHABLE_KEY=... BENTO_SECRET_KEY=... ``` Bento accepts up to 60 recipients per request and does not support attachments; use a link instead. This helper defaults `transactional = true`, appropriate for invites, resets, and alerts. Pass `transactional = false` only for messages where unsubscribe status must apply. Send from an action after the state change succeeds, never from a hot `get` handler: ```lua actions = { invite = function(request) local result = auth.register(request.form.email, request.form.password, request.form.name) if result.success then local ok, err = mail.send { to = request.form.email, subject = "Welcome", text = "Your account is ready." } if not ok then log.info("welcome email failed: " .. err) end end return response.redirect("/users") end, } ``` For a scheduled digest, put the query and send in `_cron/daily_digest.lua`; the cron process isolates slow provider calls from HTTP: ```lua schedule = "daily" function run() local count = eql.count "select deals where .stage = \"won\"" return { ok = mail.send { to = "ops@example.com", subject = "Daily digest", text = "Won deals: " .. count } } end ``` **AI inside the app** is separate from `AGENTS.md`: `AGENTS.md` helps an AI write this project; `effortless.ai` lets the project call a model for its users. The default `log` driver is network-free and deterministic. Use `ai.chat` for text and `ai.json` when the next step needs a decoded object or array. Pass `tools` / `tool_choice` to `ai.chat` when the model should return function calls — the loop stays in your Lua; Crystal only carries definitions out and `tool_calls` back. The `log` driver never emits `tool_calls`. `ai.json` rejects `tools`. ```lua local ai = require "effortless.ai" local reply, err = ai.chat { system = "You write concise CRM follow-ups.", prompt = request.form.notes, temperature = 0.2, } -- Tool calling: definitions go out; tool_calls come back. Execute tools in Lua, then -- append res.message plus role="tool" results and call ai.chat again. local res, err = ai.chat { messages = { { role = "user", content = "List open deals" }, }, tools = { { type = "function", ["function"] = { name = "list_deals", description = "List deals by stage", parameters = { type = "object", properties = { stage = { type = "string" } }, }, }, }, }, tool_choice = "auto", -- "auto" | "none" | "required" | { type = "function", ["function"] = { name = "…" } } } -- res.tool_calls[1] = { id, name, arguments, args | args_error } -- append res.message unchanged on the next turn, then role="tool" results local fields, err = ai.json { system = "Extract CRM fields.", prompt = request.form.notes, schema_name = "crm_fields", schema = { type = "object", properties = { title = { type = "string" }, stage = { type = "string", enum = { "lead", "won", "lost" } }, }, required = { "title", "stage" }, additionalProperties = false, }, } ``` A full JSON Schema (`type`, `properties`, and related keywords) is sent as the provider's strict structured-output format. The older `{ title = "string" }` shorthand remains supported as a prompt hint for compatible providers. Application code must still distrust returned identifiers and validate them against its own database before writing. Configure it in `.env`. `AI_API_KEY` and `AI_MODEL` are all it takes: the default endpoint is the Vercel AI Gateway (`https://ai-gateway.vercel.sh/v1`), which fronts every provider behind the OpenAI wire format, so the model is named `provider/model`. `AI_BASE_URL` points at another compatible server — `https://api.openai.com/v1` for OpenAI direct, or a local Ollama, which may omit the key; `AI_DRIVER=log` keeps it local. The drivers are `openai_compatible` and `log`. Never put keys in route files or commit them. ``` AI_API_KEY=... AI_MODEL=anthropic/claude-sonnet-4.5 # AI_BASE_URL=https://gateway.example.com/v1 # AI_TIMEOUT=60 ``` Keep AI calls in explicit actions, commands, or cron work; do not call models in a frequently hit `get` handler without storing the result. Domain AI belongs in `lib/` as plain modules. A batch classifier is a command or `_cron/` job that selects a bounded set of rows, calls `ai.json` for each, validates the returned fields, and writes only the fields it trusts. There is no agent base class, conversation ORM, or hidden automatic summarization — tool *plumbing* is in `ai.chat`; the agent loop is application Lua. **Streaming** is one option: `on_delta = function(text) ... end` makes `ai.chat` read the reply as it arrives and call back with each piece. The return value is unchanged — the same `text`, `message`, `tool_calls` and `usage` a non-streaming call gives — so a caller that wants both streams to the reader and stores the finished message. Paired with `realtime.publish`, a token reaches the browser as the model produces it: ```lua -- _commands/answer.lua, or any route: the browser is already on a stream local ai = require "effortless.ai" local realtime = require "effortless.realtime" local res, err = ai.chat { messages = messages, on_delta = function(text) realtime.publish("room:" .. room_id, "token", { text = text }) end, } if res then eql "insert messages { room_id: $room_id, body: $body }" -- the row event closes it out end ``` The `log` driver calls back once with the whole echoed reply, so a streaming app is testable without a network. A tool call split across frames is reassembled before it reaches you. **Empty Lua tables are objects, not arrays.** `required = {}` serializes as JSON `{}`. JSON Schema (and OpenAI tool `parameters`) need arrays: omit an empty `required`, or write `required = { "id" }` / `required = { [1] = "id" }`. Never pass an empty `{}` where the wire format expects `[]`. **Realtime** is one SSE connection per open page, and the route that opens it is the authorization. A table opts in with `realtime = true`; every EQL insert, update and delete on it appends to an event outbox in the same transaction, and the server delivers each change to the subscribers whose subscription covers it. `exec_raw` writes no events. ```lua -- _schema/schema.lua messages = { realtime = true, id = t.pk, body = t.text { required = true }, room_id = t.ref "rooms", }, ``` ```lua -- routes/rooms/[id]/stream.lua — the guards above the return ARE the policy local auth = require "effortless.auth" local eql = require "effortless.eql" local realtime = require "effortless.realtime" function get(request) auth.check() local room = eql.one "select rooms where .id = $params.id" if not room then return nil end return realtime.stream { "select messages { id, body, .user { name } } where .room_id = $room", "room:" .. room.id, -- a named channel: typing, AI tokens } end ``` A row subscription is re-run per event with the subscriber's row scope and `and .id = $id`, so a reader receives only rows the same select would have returned to them, shaped the same way — relations included. A `delete` sends `{ id }` to every subscriber of the table. The row is gone, so neither the subscription's `where` nor the row scope can be checked against it, and the writer cannot filter per subscriber because the write may come from another process. A subscriber of one slice of a realtime table therefore learns the ids deleted from the rest of it. Where an id is itself the secret, soft-delete with a flag column instead — the update goes through the subscription's `where` like any other. Named channels carry whatever you publish: ```lua -- a route, a cmd, or a cron job: all three reach the browser the same way realtime.publish("room:" .. id, "token", { text = chunk }) ``` ```html ``` `EventSource` reconnects on its own with `Last-Event-ID`, and the outbox replays what was missed — it keeps ten minutes, which is a reconnect window, not history. Client-to-browser is one direction: a client sends by POSTing a route, and the write it makes produces the event. There is no auth hook, no channel-name convention, no signature, and no configuration. ## 9. Recipes (complete minimal file sets) **CRUD page** — `_schema/schema.lua` gets the table (section 6), then: ```lua -- routes/deals/deals.lua local auth = require "effortless.auth" local eql = require "effortless.eql" function guard(request) return auth.check() end function get(request) return { deals = eql "select deals where .user_id = $auth.id order by created_at desc" } end actions = { add = eql.mutate "insert deals { title: $form.title, user_id: $auth.id }", toggle = eql.mutate "update deals set { completed: not .completed } where .id = $form.id and .user_id = $auth.id", delete = eql.mutate "delete deals where .id = $form.id and .user_id = $auth.id", } ``` ```html {{ layout "base" }} {{ title = "Deals" }}
{{ csrf_field | raw }}
{{ if errors.title }}

{{ errors.title }}

{{ /if }} {{ for deal in deals }}
{{ csrf_field | raw }} {{ deal.title }}
{{ empty }}

No deals yet.

{{ /for }}
``` **Paginated list** — `eql.count` for the total, `limit $per offset $off` for the page: ```lua -- routes/expenses/expenses.lua local auth = require "effortless.auth" local eql = require "effortless.eql" function guard(request) return auth.check() end function get(request) local per = tonumber(request.query.per) or 20 local page = math.max(1, tonumber(request.query.page) or 1) local off = (page - 1) * per return { expenses = eql( "select expenses where .owner_id = $auth.id order by spent_on desc limit $per offset $off", { per = per, off = off }), total = eql.count "select expenses where .owner_id = $auth.id", page = page, per = per, } end ``` ```html {{ layout "base" }} {{ title = "Expenses" }}
{{ for e in expenses }}
{{ e.vendor }} — {{ e.amount }}
{{ /for }}
``` Keep the select as one whole literal so `check` sees every column. Spell `eql.count` out separately (one duplicated where-clause) rather than concatenating a shared prefix — concat puts the EQL string in tail position and `check` cannot validate it. Values go in `$per` / `$off`; concat is only for whitelisted identifiers (next recipe). **Safe dynamic sort** — column names cannot be `$params` (SQL can't either). Whitelist in Lua, then concat the chosen identifier. Put the static prefix (table, columns, tenancy filter) in the string *head* so `check` still validates it: ```lua local cols = { spent_on = true, vendor = true, amount = true } local col = cols[request.query.sort] and request.query.sort or "spent_on" local rows = eql("select expenses where .owner_id = $auth.id order by ." .. col .. " desc") ``` **JSON endpoint** — handler-only route, no view file: ```lua -- routes/api/deals/deals.lua → GET /api/deals returns JSON local eql = require "effortless.eql" function guard(request) return require("effortless.auth").check() end function get(request) return { deals = eql "select deals where .user_id = $auth.id" } end ``` **Detail page with URL param** — `routes/deals/[id]/[id].lua`: ```lua local eql = require "effortless.eql" function get(request) local deal = eql.one "select deals where .id = $params.id and .user_id = $auth.id" if not deal then return require("effortless.response").error(404, "No such deal") end return { deal = deal } end ``` **CSV export** — `routes/deals/export/export.lua`: ```lua local eql = require "effortless.eql" function guard(request) return require("effortless.auth").check() end function get(request) local rows = eql "select deals where .user_id = $auth.id" return require("effortless.response").csv(rows, { filename = "deals.csv", columns = { "id", "title", "created_at" } }) end ``` **Protect a whole route:** `function guard(request) return require("effortless.auth").require("admin") end` **Test the feature** — `_tests/deals_test.lua` (run with `effortless test`): ```lua test("create and list a deal", function(t) local user = t.user("ada@example.com") -- creates/fetches a test user local res = app.post("/deals", { as = user.id, form = { action = "add", title = "Big one" } }) t.eq(res.status, 302) local page = app.get("/deals", { as = user.id }) t.contains(page.body, "Big one") end) test("validation failure re-renders with error", function(t) local user = t.user("ada@example.com") local res = app.post("/deals", { as = user.id, form = { action = "add", title = "x" } }) t.eq(res.status, 422) t.contains(res.body, "must be at least") end) ``` For `application/json` responses, `app.get/post/...` preserves `res.body` and also exposes the decoded value as `res.json`; malformed JSON-labeled responses fail the test loudly. Response cookies are retained in a per-test-file jar and sent on later `app` requests, so login flows work without fabricated cookie headers. ## 10. The verification loop — run it, always You cannot see the browser. These commands are your eyes; each is one-shot. `check`, `request`, `test`, and `migrate --dry-run` support `--json` for machine-readable output; `eval` already prints JSON. ``` effortless check # static analysis: syntax, schema, EQL literals, route conventions effortless request GET /deals --as ada@example.com # run a route in-process, print the response effortless request POST /deals -d action=add -d title=Test --as 1 effortless request GET /deals --data --as 1 # return handler data instead of rendered HTML effortless eval 'return eql.one "select deals"' # one-shot Lua in app context (--as to impersonate) effortless test # run _tests/*.lua against a fresh temp database effortless migrate --dry-run # preview schema changes effortless pull # pull scrubbed production database locally effortless snapshot [dest] # consistent database backup (--scrub, --force) effortless fmt # format Lua and templates in place (--check reports instead) ``` **Formatting.** `effortless fmt`, and the editor's format command over LSP, run two passes over a `.lua` file. Lua is reflowed by [StyLua](https://github.com/JohnnyMorganz/StyLua) when `stylua` is on `PATH` (`brew install stylua`), reading the project's `stylua.toml`, which `effortless new` scaffolds; without the binary Lua is only re-indented, which is why the file is scaffolded and `effortless check` says so when it is missing. EQL inside `eql`, `eql.one`, `eql.count`, `eql.mutate` and `realtime.stream` strings is then reprinted from its parse tree — multiline in a `[[ ]]` host, one line in a `"…"` host. The delimiter is never changed, so a long query stays on one line until you move it to `[[ ]]`; a query that does not parse, or one carrying an EQL comment, is left as written. In `.x.html`, HTML tags and `{{ if / for / else / empty }}` blocks are indented; `