# 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 -- ports, db path, upload size ├── _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, zero-config: `/_admin` (CRUD + schema editor, users with `role = "admin"` only). ## 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. ## 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:** tables with `owner_id`/`user_id` referencing `users` 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`. A `visitor_id = t.visitor` column instead requires `.visitor_id = $visitor.id` and explicit inserts from `$visitor.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`, `form`, `files` (section 8), `headers`, `cookies`, `ip`, `user` (row or nil), `visitor` (`{ id = "…" }` on routed requests), `json` (parsed body for `application/json` requests, else nil), `json_error`. The visitor ID is not a template global unless a handler explicitly returns it. **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%` | | 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 | 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) / not in / between x and y / is null / is not null`, arithmetic `+ - * /`, plus `and? (expr)` for soft WHERE filters (empty params drop the group; use explicit `or`/`and` inside), combined with `and` / `or` / `not`, parentheses OK. - `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. **Parameters — `$name` auto-capture.** `$title` is captured from your Lua locals/upvalues/globals by name — explicit param tables are optional. Six request namespaces always work: `$form.title`, `$json.title`, `$params.id`, `$query.page`, `$auth.id` (the logged-in user), and `$visitor.id` (the routed request’s anonymous bearer identity). 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. **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`, `slug`, `first`, `last`, `date:"%Y-%m-%d"`, `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. Every page automatically has: `csrf_field`, `csrf_token`, `user`, `logged_in`, `path`, `method`, plus URL params. ## 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 SQL expression 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, visitor_id = t.visitor, -- text + required + hidden + protected; owner principal is visitor path = t.text, }, contacts = { id = t.pk, owner_id = t.ref "users" { required = 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. The **first registered user becomes admin**. 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/…" -- saved files are public at /; size limit via config.lua uploads.max_size_mb (413 beyond); -- malformed multipart bodies are 400, not silently treated as empty forms -- outbound HTTP (timeouts default 10s; response body cap defaults to 10 MB; -- res.truncated tells you whether the body was cut) 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 ``` Set the sender once in `config.lua`; never commit provider credentials. `config.lua` can read host environment variables: ```lua mail = { driver = "bento", -- "log" locally, "bento" in production from = "App ", -- must be a verified Bento author bento = { site_uuid = os.getenv("BENTO_SITE_UUID"), publishable_key = os.getenv("BENTO_PUBLISHABLE_KEY"), secret_key = os.getenv("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. ```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, } local fields, err = ai.json { system = "Extract CRM fields. Reply with JSON only.", prompt = request.form.notes, schema = { title = "string", amount = "number|null", stage = '"lead"|"won"|"lost"' }, } ``` Configure a network driver explicitly. `openai` defaults to OpenAI's endpoint; `openai_compatible` requires a `base_url` and may omit the key for a local Ollama-style server. Never put keys in route files or commit them. ```lua ai = { driver = "openai", -- "log" | "openai" | "openai_compatible" model = "your-model-id", -- required for network drivers api_key = os.getenv("OPENAI_API_KEY"), 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, tool loop, streaming API, conversation ORM, or hidden automatic summarization. **Realtime** is opt-in server-to-browser delivery. Use SSE for one-way live refresh; use Pusher-compatible WebSockets for presence and client events. Trigger explicitly from Lua, and keep authorization in `_realtime/auth.lua`. Public channels do not start with `private-` or `presence-`; private and presence channels are denied unless the hook returns `true`. ```lua -- config.lua: use a stable secret when commands or one-shot cron jobs trigger events realtime = { enabled = true, secret = os.getenv("EFFORTLESS_REALTIME_SECRET"), key = os.getenv("EFFORTLESS_REALTIME_KEY"), -- required for WebSockets } ``` ```lua -- routes/deals/deals.lua: call after the mutation succeeds local realtime = require "effortless.realtime" realtime.trigger("private-deals." .. request.form.id, "updated", { id = request.form.id }) ``` ```lua -- _realtime/auth.lua: missing file means every private channel is denied local auth = require "effortless.auth" function authorize(request, channel) return auth.check() and channel:match("^private%-deals%.%d+$") ~= nil end function presence_info(request, channel) local user = auth.user() return { user_id = tostring(user.id), user_info = { name = user.name or user.email } } end ``` ```html ``` An unauthorized channel makes the entire SSE request return `403`; do not mix public and private channels in one connection. The stream sends heartbeat comments and reconnects after three seconds. Realtime is single-process fanout: separate `cmd` and one-shot cron executions use the configured secret to send a signed loopback request to the running server. When the server is down, `realtime.trigger` returns `nil, err` rather than crashing the command. For presence or client events, use the standard Pusher browser client over the same server. The auth endpoint requires the normal CSRF header and cookie; do not expose the realtime secret in browser code. `client-*` events are accepted only after a private or presence subscription, are delivered to other subscribers only, and are limited to 10 per second per socket. ```html ``` ## 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 and anonymous visitor 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 ``` **The workflow, prescribed:** 1. After every file edit → `effortless check`. Fix what it reports before anything else. 2. Wrote a query or handler? → `effortless eval` / `effortless request` it. A GET that renders returns HTML; look for your data in it. A redirect shows `302` + location. 3. Every feature gets a `_tests/*.lua` case (success path + one failure path) → `effortless test` must be green before you're done. 4. Changed the schema? → `effortless migrate --dry-run` first; read the plan. Errors are written for you: EQL/template/Lua failures carry file:line and suggestions — read the message before changing code. If a POST unexpectedly returns 403, the form is missing `{{ csrf_field | raw }}`; JSON mutating requests need an `X-CSRF-Token` header matching the `effortless_csrf` cookie. ## 11. Styling — mingled (not Tailwind) The layout loads mingled from CDN. Classes are `property:value`, mirroring CSS property names — if you know the CSS, you can write the class: | Tailwind | mingled | CSS | |---|---|---| | `p-4` / `px-6 py-2` | `p:16` / `px:24 py:8` | padding (px values) | | `mt-4`, `mb-8`, `mx-auto` | `mt:16`, `mb:32`, `mx:auto` | margin | | `text-gray-600` | `c:#4b5563` | color | | `bg-blue-500` / `bg-white` | `bg:blue` / `bg:white` | background | | `text-sm` / `font-bold` | `f:14` / `fw:700` | font-size / weight | | `rounded-lg` | `r:8` | border-radius | | `border border-gray-300` | `b:#d1d5db\|1` | border color\|width | | `w-full` / `max-w-2xl` | `w:full` / `max-w:672` | width | | `flex items-center justify-between` | `flex:between\|center` | flexbox | | `flex items-center` / `gap-2` | `flex:center` / `gap:8` | flexbox / gap | | `shadow` | `shadow:0\|1\|3\|0\|(0,0,0,0.08)` | box-shadow | | `hover:bg-blue-600` | `hover:bg:darkblue` | :hover | | `md:p-8` | `p:32@md` | breakpoint | Numbers are px. Full reference: github.com/softkittens/mingled. Interactivity: Alpine.js is loaded — use `x-data`/`x-show`/`@click` for client-side toggles; server behavior stays in Lua actions. ## 12. Domain logic, commands, and cron Put shared application logic in plain `lib/` modules. They are loaded only when a route, command, test, cron job, or `effortless eval` explicitly requires them; there is no container or autoloading. ```lua -- lib/deals.lua local eql = require "effortless.eql" local M = {} function M.close(id, user_id) return eql "update deals set { stage: \"won\" } where .id = $id and .user_id = $user_id" end return M ``` Use `require "deals"` for `lib/deals.lua` and `require "billing.invoices"` for `lib/billing/invoices.lua`. Do not name app modules after Lua standard libraries (`string`, `table`, `math`, `json`) or `effortless.*` framework modules. Named operations live in flat `_commands/` files. They define `run(args)` and may return a value for `--json` callers: ```lua -- _commands/close_deal.lua help = "Close a deal" function run(args) local deals = require "deals" return { ok = true, deal = deals.close(args[1], args[2]) } end ``` Run `effortless cmd` to list commands, `effortless cmd close_deal -- 42 7` to pass arguments, and `effortless cmd close_deal --json` for one JSON document on stdout. Command names are flat: letters, numbers, `_`, and `-` only. Scheduled work belongs in flat `_cron/` files. `effortless serve` schedules enabled jobs and starts each due job as a separate `effortless cron ` process, so a long or broken Lua job cannot block HTTP requests. Schedules use UTC. ```lua -- _cron/nightly_cleanup.lua schedule = "0 3 * * *" -- 03:00 UTC; also: hourly, daily, every 15m, every 2h help = "Remove expired imports" function run() local cleanup = require "cleanup" return cleanup.expired_imports() end ``` `effortless cron --list` shows state; `effortless cron --force` runs every job once. The SQLite lock is never bypassed, including by `--force`. Cron is enabled by default; configure it only when needed: ```lua cron = { enabled = true, tick_seconds = 30, timeout_seconds = 300 } ``` Keep jobs short. The timeout kills only the child process, so make each run resumable and let the next scheduled window continue the work. ## 13. Hard rules 1. Never build queries by string concatenation — use `$params`; there is no raw SQL and no way to inject. 2. Every mutating form: `{{ csrf_field | raw }}`. 3. Every list/update/delete on user data: scope by `.user_id = $auth.id` in the where clause (visible, auditable). 4. `| raw` only on values the framework produced (`csrf_field`) — never on user input. 5. Schema changes go in `_schema/schema.lua` only; migrations don't exist. 6. One route = one directory; the file name matches the directory name. Duplicate route files are errors, not overrides. 7. Keep logs on stderr. `--json` stdout must be exactly one JSON document. 8. Not done until `effortless check` and `effortless test` are both green.