Conventions

What the binary does, what your Lua and markup may do, and how to check a change without opening a browser. Seventeen sections, 169 KiB, plain text at /llms.txt.

The binary carries the same text for the version you installed. effortless docs eql prints one section, effortless docs --search sync_rows every paragraph that mentions a term, and effortless docs all of it.

Plain text AGENTS.md Open in Claude Open in ChatGPT

Effortless is a single-binary web framework. Crystal owns routing, SQLite, templating, auth and safety. You write small Lua files and HTML templates, and the binary picks them up on the next request: no build step, no restart in development.

  • Queries are EQL, always parameterized.
  • Markup escapes every value unless it is written {@html}.
  • A write from another origin is refused before a handler runs.
  • effortless check after every edit, effortless test to finish (section 9).

0. Start here

effortless new myapp && cd myapp
effortless serve                 # http://localhost:3000 — edits are live, no restart

The scaffold is a working app:

  • A todos table in app/schema/schema.lua, beside the users table the framework declares.
  • /login, /logout, /forgot, /reset, /invite and /accept under routes/.
  • A layout, routes/_layout.x.html.
  • app/theme.css, whose first line turns on effortless-ui (section 16): the classes and components the pages are drawn with, held in the binary.
  • A tests/ suite, a one-line config.lua, .env.example naming every option, and a Dockerfile.

First steps:

  • Copy .env.example to .env and put your address in SUPERADMINS to get /_admin.
  • There is no open sign-up. Create the first account with effortless eval 'return auth.register("[email protected]", "a-long-password", "You")'.
  • Set that account's role to "admin" to reach /invite, and invite everyone else.

Adding a feature is three files:

  1. Table: add it to app/schema/schema.lua (section 5). The database converges on the next request; there are no migration files.
  2. Route: routes/deals/deals.lua returns data and routes/deals/deals.x.html renders it (sections 2 and 4). Section 8 has a complete pair to copy.
  3. Test: tests/deals_test.lua with one success and one failure case (section 8).

Then effortless check and effortless test (section 9).

1. How a request flows, and where things live

A request hits a route directory:

  1. A signed-out request is sent to /login unless the route's own .lua says public = true.
  2. guard runs, the folders' first and then the route's (section 2).
  3. A GET calls get(request) and renders the .x.html with the table it returns.
  4. A POST calls post, or the action in actions the form names. By default it answers 303 back to the same page, which renders again from get.

Data crosses in request (form, params, query, user) and reaches EQL as $form.x, $params.id, $query.q and $auth.id. Validation lives in the schema and runs on every write. A failed write renders the page again with errors and old. Domain code goes in app/lib/; nothing is autoloaded.

myapp/
├── config.lua              -- overrides only; built-in options come from .env
├── .env                    -- secrets and per-host values (never committed)
├── routes/                 -- file-based routing (section 2)
│   ├── _layout.x.html      -- wraps every page; a folder's own _layout.x.html wraps that folder's pages
│   ├── _layout.lua         -- load(request): data the layout and every page below it read
│   ├── index.x.html        -- view for /
│   ├── index.lua           -- data/handlers for /
│   └── todos/
│       ├── _todo-row.x.html-- <todo-row>: a component only /todos and below use
│       ├── todos.x.html    -- view for /todos
│       └── todos.lua       -- handlers for /todos
├── app/
│   ├── schema/schema.lua   -- THE schema (desired state; no migration files)
│   ├── lib/                -- shared domain modules, loaded with require "deals"
│   ├── components/         -- <post-card>: markup components the app shares; a <script> makes one an island
│   ├── mail/               -- mail bodies: invite.x.html is mail.send { template = "invite" }
│   ├── jobs/               -- background work, queued with jobs.enqueue
│   ├── cron/               -- scheduled jobs, enqueued by the `serve` process
│   └── commands/           -- named CLI operations, run with `effortless cmd name`
├── tests/                  -- Lua test suites, run by `effortless test`
├── public/                 -- static files, served as-is; dotfiles are not, except .well-known/
├── uploads/                -- saved uploads, served at /uploads/ (auto-created)
└── data/effortless.db      -- SQLite (auto-created)

Moving a folder. paths in config.lua moves any of them: paths = { jobs = "workers/jobs" }. The keys are routes, tests, public, schema, components, lib, mail, jobs, cron and commands, each relative to the project root. effortless docs then prints this reference with the moved paths.

Configuration

Precedence: CLI flags, then config.lua, then the environment, then defaults. A project-root .env is loaded into the environment first, and the shell wins over it.

  • In development, serve reads the configuration again when .env or config.lua changes. A config.lua edit, or a key newly added to .env, applies on the next request.
  • A changed .env value needs a restart, because .env never overwrites a variable already in the environment.
  • Production needs a restart for any change.
  • Keep config.lua for values the app computes (one data directory for the database and the uploads) or states in code (uploads.max_size_mb, http.allow_hosts).

Every option, as ENVIRONMENT (config.lua key):

Option Default Sets
APP_NAME (app.name) the folder's name the app's name, shown in the admin
TIME_ZONE (app.time_zone) none the zone a view writes a datetime in: a name such as Europe/Belgrade, or browser for each visitor's own (section 4, Time zones). The config.lua of a new project sets "browser"
PORT (server.port) 3000 the port serve listens on. In development, with none named here or by --port, a 3000 that is in use gives way to the next free port, and serve says which
HOST (server.host) localhost the address it binds
WORKERS (server.workers) 2 server processes
LUA_TIMEOUT (server.lua_timeout) 30 seconds of Lua and queries one request may run; 0 for no limit
LUA_MEMORY (server.lua_memory) 256 megabytes one Lua state may hold; 0 for no limit
BASE_URL (server.base_url) where serve listens the app's absolute URL, for mailed links
TUNNEL_NAME (server.tunnel) the named tunnel serve --public runs
TUNNEL_ZONE (server.tunnel_zone) the domain named tunnels live under
TUNNEL_URL (server.tunnel_url) a named tunnel's hostname, stated outright
DATABASE_PATH (database.path) data/effortless.db the SQLite file
DATABASE_SYNCHRONOUS (database.synchronous) normal full syncs every commit to disk
UPLOAD_MAX_MB (uploads.max_size_mb) 10 the largest upload
UPLOAD_ROOT (uploads.root) uploads where local uploads are stored
UPLOAD_DRIVER (uploads.driver) local local or r2
MEDIA_URL (uploads.media_url) the public base URL of stored files
R2_ACCOUNT_ID (uploads.r2.account_id) R2 account
R2_BUCKET (uploads.r2.bucket) R2 bucket
R2_ACCESS_KEY_ID (uploads.r2.access_key_id) R2 key id
R2_SECRET_ACCESS_KEY (uploads.r2.secret_access_key) R2 secret
R2_KEY_PREFIX (uploads.r2.key_prefix) a prefix for every object key
R2_PUBLIC_BASE_URL (uploads.r2.public_base_url) the bucket's public URL
CRON_ENABLED (cron.enabled) true whether serve runs cron and the job queue
CRON_TICK_SECONDS (cron.tick_seconds) 30 how often cron looks for due scripts
CRON_TIMEOUT_SECONDS (cron.timeout_seconds) 300 the longest one cron run or job may take
JOBS_CONCURRENCY (jobs.concurrency) 1 jobs run at once
JOBS_TICK_SECONDS (jobs.tick_seconds) 5 how often the queue looks for due jobs
MAIL_DRIVER (mail.driver) log log, resend or cloudflare
MAIL_FROM (mail.from) the sender
MAIL_CATCH_ALL (mail.catch_all) one address that receives every mail in place of its recipients
MAIL_QUEUE (mail.queue) false deliver through the send_mail job
RESEND_API_KEY (mail.resend.api_key) Resend key
RESEND_BASE_URL (mail.resend.base_url) https://api.resend.com Resend endpoint
CLOUDFLARE_ACCOUNT_ID (mail.cloudflare.account_id) Cloudflare account, for mail
CLOUDFLARE_EMAIL_TOKEN (mail.cloudflare.token) Cloudflare email token
CLOUDFLARE_EMAIL_BASE_URL (mail.cloudflare.base_url) https://api.cloudflare.com/client/v4 Cloudflare endpoint
AI_DRIVER (ai.driver) log log or openai_compatible
AI_MODEL (ai.model) the model, as provider/model
AI_API_KEY (ai.api_key) the model host's key
AI_BASE_URL (ai.base_url) the Vercel AI Gateway the model host
AI_TIMEOUT (ai.timeout) 60 seconds one model call may take
AI_HEADERS (ai.headers) headers sent on every model request
AI_EXTRA_BODY (ai.extra_body) fields merged into every chat request body
HTTP_ALLOW_PRIVATE (http.allow_private) false let http reach private addresses
HTTP_ALLOW_HOSTS (http.allow_hosts) private hosts http may reach
ADMIN_HOSTS (admin.hosts) hosts /_admin answers on
SUPERADMINS (admin.superadmins) emails that may use /_admin
SNAPSHOT_SCRUB (snapshot.scrub) app/schema/scrub.sql the SQL file a snapshot is scrubbed with
PULL_URL (pull.url) the deployment effortless pull copies from
SENTRY_DSN (errors.sentry_dsn) where errors are sent
SENTRY_ENVIRONMENT (errors.sentry_environment) EFFORTLESS_ENV the environment an event names
SENTRY_RELEASE (errors.sentry_release) the release an event names
SENTRY_THROTTLE (errors.sentry_throttle) 3600 seconds between two reports of one error; 0 sends every one

Three options are set in config.lua only, since each is a decision in the repository:

  • proc.commands: the programs proc.run may start.
  • files.paths: the folders files.read and files.import read from.
  • routes.view_only: when true, check accepts a view under routes/ with no .lua, for an app whose Crystal handlers render those views.

A driver left unnamed is inferred from the credentials present: R2_BUCKET selects r2, RESEND_API_KEY resend, CLOUDFLARE_EMAIL_TOKEN cloudflare when there is no Resend key, and AI_API_KEY openai_compatible.

Limits on a request

  • Time. A request answers 500 once its Lua and its queries have run for LUA_TIMEOUT seconds together. Waiting on an HTTP call, a subprocess, a model call or a mail send is not counted. Once the budget is spent every later query and Lua step raises too, so a pcall cannot carry on and a transaction commits nothing.
  • effortless eval and each test run on the same limit. A command has none. A job and a cron run are limited by CRON_TIMEOUT_SECONDS.
  • Work that runs long belongs in a job.
  • Memory. A Lua state holds at most LUA_MEMORY megabytes. Past it the run fails with "not enough memory" and the limit.
  • Behind a proxy, request.ip is the proxy's address. The client's is in the proxy's header, request.headers["cf-connecting-ip"] behind Cloudflare. Trust it only when the server is reachable through the proxy alone, since a client can send the header too.

Serving to a phone

effortless serve --public runs a cloudflared tunnel to the port it serves on, and stops it when serve exits.

  • Quick tunnel, with no tunnel named: no Cloudflare account, and a new https://….trycloudflare.com address each run, printed as → public:. base_url follows it. Anyone with the address reaches the app.
  • Named tunnel, one address that stays: TUNNEL_NAME=dfdb in .env gives a cloudflared tunnel named dfdb-local, created on the first run.
  • TUNNEL_ZONE=example.dev is the domain named tunnels live under, so dfdb resolves as https://dfdb.example.dev. It belongs to the machine's cloudflared tunnel login, so export it in a shell profile.
  • TUNNEL_URL names the hostname outright and wins over the zone.
  • Routing the hostname is one command, printed when the tunnel is created: cloudflared tunnel route dns dfdb-local dfdb.example.dev.
  • serve stops before it binds, naming the fix, when TUNNEL_URL has no tunnel name, cloudflared is not installed, or the machine has never run cloudflared tunnel login.
  • base_url follows a named tunnel when TUNNEL_URL says where it resolves. An explicit BASE_URL still wins.
  • Cloudflare's free plan caps a request body at 100 MB.

Put Cloudflare Access in front of the hostname. The tunnel puts the development database and /_admin on the public internet. Access is free to 50 users. It also blocks non-browser clients unless a service token is issued.

Admin

/_admin is row editing for the signed-in users whose email is in SUPERADMINS (comma-separated). With no list there is no admin.

  • An address is trusted by its email alone. One in the list with no account yet is claimed by whoever registers it first. Register the account before adding its address, and take an address off the list before changing it. serve warns at startup about each listed address with no account.
  • The role column is the app's, not the admin's.
  • The schema is app/schema/schema.lua, not the admin.
  • A row is named by its label = true column, or a name- or title-like text column.
  • The drawer's Related section adds and removes related rows in place, join tables included.
  • An upload into a file table takes any file for a t.file column and images for a t.image.
  • The sidebar keeps link tables and the framework's own tables under a collapsed "More" group.

The grid's search box takes words or an EQL where, run on Enter:

  • Words each match any readable text column, or the label of a row the table references. "two words" is one word; !word excludes one.
  • Text that starts with . or not is the where of select <table>: .price > 100, not .thumbnail, .product_id.name like '%oak%'. Hidden and secret columns are refused.

/_admin/ask turns a plain-language question into one read-only EQL statement. It needs AI_API_KEY and can only select. A result can be saved, re-run without a model call, and downloaded as CSV.

2. Routing and handlers

A route is a directory under routes/ whose file names match it:

File URL Notes
routes/index.lua + index.x.html /
routes/todos/todos.lua + todos.x.html /todos directory name = file name = route
routes/todos/[id]/[id].lua /todos/:id request.params.id
routes/docs/[...path]/[...path].lua /docs/* catch-all, last segment only
routes/_layout.lua, routes/admin/_layout.lua every route below its folder guard and load; not a route
-- routes/todos/todos.lua
local eql = require "effortless.eql"

function get(request)
  return { todos = eql "select todos order by created_at desc" }
end

actions = {
  add    = eql.mutate "insert todos { title: $form.title }",
  delete = eql.mutate "delete todos where .id = $form.id",
}
  • Every route has a .lua. With a .x.html beside it, it is a page; without one, it answers JSON.
  • A page with no get renders its view for a GET with the data its layouts load. A public page with no handler is public = true alone.
  • Handlers are get, post, put, patch and delete, one function per method. A form overrides its method with <input type="hidden" name="_method" value="delete">.
  • A named POST action lives in the route file's actions table. The form names it in request.form.action, or the JSON body in request.json.action.
  • Two files that resolve to one URL are an error: not both routes/about.lua and routes/about/about.lua.
  • When several routes match, the most specific answers, compared segment by segment: static beats a parameter, and a parameter beats a catch-all.
  • user and logged_in are in scope in every view. Do not pass user = request.user.

Reading rows in a handler:

  • A select returns a list, limit 1 included. Never write or {} after one.
  • eql.one returns one row or nil.
  • eql.find is eql.one for a row the URL names: when nothing matches the request answers 404, so the handler writes no if not row check.

Signed in by default

Every route needs a signed-in user unless its own .lua says public = true at the start of a line.

  • A signed-out request to any other route is redirected to /login?next=<path>, before any guard or line of the route runs.
  • Only the route's own file decides. There is no folder-wide public.
  • A .x.html under routes/ with no .lua beside it is a check error, unless config.lua sets routes = { view_only = true }.

Guards

-- routes/admin/_layout.lua: every route under /admin
local auth = require "effortless.auth"
function guard(request) return auth.require("admin") end

A guard returns:

Return Result
true the request goes on
"forbidden" 403
a response.* value that answer
anything else redirect to /login?next=<path>
  • auth.require() and auth.require("admin") return exactly these.
  • A guard in a folder's _layout.lua covers every route below the folder, whatever its method, JSON included.
  • The folders' guards run outermost first, then the route's own.
  • A guard decides roles. Who is signed in is public = true's question.

request

Field Holds
method, path
params URL segments
query the query string; query_all has every value of a repeated name as a list
form every field; form_all has every value of each field as a list
json the parsed body of an application/json request, else nil
json_error the parser's message for an invalid JSON body
headers lowercase names: request.headers["referer"]
cookies, ip, base_url
time_zone the app's zone for this request: app.time_zone, the visitor's under "browser", nil for none
user the signed-in user's row, or nil
  • Reading any other field raises: request has no field 'parms' (did you mean 'params'?).
  • request.query, request.form and request.params hold what the client sent. A missing key there is nil.
  • Bracketed form names nest: images[0][title] is form.images[1].title, and tags[] is a list.
  • A numbered list is in the order its fields arrive, which is the order the rows stand in on the page. The number only tells the rows apart: images[7] sent before images[2] is form.images[1]. A script that moves a row leaves its names alone, and one that adds a row gives it any number no other row has. A test's form = { … } table is sent by name, a number by its value.
  • An upload is a table under its field's name (section 7).
  • Request text that is not UTF-8 is a 400.

What a handler returns

local response = require "effortless.response"

Return Result
{ todos = ..., title = ... } the keys become the view's names, and the view renders
plain data from a POST, PUT, PATCH or DELETE on a page 303 back to the request's path
response.redirect("/todos") 302; a target that leaves the site goes to /
response.json(data, 201) JSON with a status
response.error(404, "Not found") the error page, or a JSON error
response.invalid(errors, request.form) 422: the page renders again with errors and old (section 6)
response.csv(rows, { filename = "x.csv" }) a CSV download
response.html(body, status), response.empty(204), response.binary(body, content_type) raw responses
response.file(root, path) one file from under root, served as an upload is
return data, response from an action the second value wins when it is not nil
  • A route with no .x.html returns its table as JSON.
  • Only a value a response.* function made answers the request. A table you build is data, whatever its keys are called.
  • A message computed in post (a failed login, a rejected rule) reaches the page through response.invalid({ auth = msg }, request.form), rendered as {errors.auth}. A plain table returned from a POST is dropped by the 303.

eql.mutate(query, opts)

One write as an action. It validates as a direct eql() write does and returns result, err.

actions = {
  add = eql.mutate "insert todos { title: $form.title }",
  rename = function(request)
    local rows, err = eql.mutate("update todos set { title: $form.title } where .id = $form.id returning { id }")(request)
    if err then return err end
    return response.json(rows)
  end,
}
  • As a one-line action it answers with the 303.
  • A custom action returns err when there is one, then uses result.
  • Options: { validate = false }, and { unscoped = true } (section 5).
  • eql.mutate "update todos set { completed: true } where .id = $params.id" validates as an update when the URL carries :id.
  • Shared code goes in app/lib/: app/lib/deals.lua is require "deals", from a route, a job, a test or effortless eval.
  • Route-local modules are files under routes/ that are never routed. routes/products/_form.lua is require "_form", and routes/products/import/lib/page.lua is require "lib.page". Both are visible to their route and everything beneath it.
  • A test reaches a route-local module as require "routes.products.import.lib.page".
  • Schema enums in Lua: require("effortless.schema").one_of("deals", "stage") returns the values of a t.enum, for a dropdown.

Links from the current query (local url = require "effortless.url"):

  • url.with(request, { color = ids, page = false }) is request.path with those names changed and the rest of the query kept. A value replaces, a list repeats the name, false removes it.
  • url.toggle(request, "color", id, { page = false }) adds id to color or takes it out.
  • url.has(request, "color", id) says whether it is there.
  • url.encode(value) percent-encodes one value.
  • Names come out sorted, so one set of filters is one URL.

Names in a Lua file

A made-up name fails where it is written, in check and when the file loads, with the closest real one.

  • A name a file introduces is local: local function total(rows), local eql = require "effortless.eql".
  • A file reads the globals app Lua has (print, pairs, string, request, …). It cannot assign to one.
  • A file defines only the globals the framework reads from its kind:
File Defines
a route get, post, put, patch, delete, guard, actions, load, public
_layout.lua load, guard
a job run, retries, help
a cron script run, schedule, help
a command run, help
an app/lib module, a test none
  • Any other global is a compile error: routes/deals/deals.lua:2: 'eql' is not a local: add local eql = require "effortless.eql".
  • In effortless eval, tests, jobs, cron scripts and commands, eql, auth, response, json and log are globals. A route and an app/lib module require them.
  • A framework module raises on a field it does not have: effortless.eql has no field 'fnd' (did you mean 'find'?).
  • check reports eql.fnd and request.parms before the code runs, where the name is the file's own require or request.
  • A field of a row (deal.titel) or of form data is not checked. It reads as nil.

3. EQL — the query language (you know SQL; here's the diff)

local eql = require "effortless.eql"

local rows  = eql "select todos { id, title } where .completed = false order by .title"
local todo  = eql.one "select todos where .id = $id"            -- a row or nil
local found = eql.find "select todos where .id = $params.id"    -- a row, or the request answers 404
local n     = eql.count "select todos where .completed = true"
local row   = eql "insert todos { title: $form.title }"         -- the inserted row
local count = eql "update todos set { completed: true } where .id = $id"
Function Does
eql(query, params?) runs one statement
eql.one, eql.find, eql.count one row or nil; one row or 404; a number
eql.mutate(query, opts?) a write as a route action (section 2)
eql.page(query, { page, per }) one page of rows and the total (section 8)
eql.unscoped(query, params?) a query that crosses users on purpose (section 5)
eql.unchecked(query, params?) a write without validation, for backfills
eql.transaction(fn) commits when fn returns, rolls back when it raises
eql.sync_ids, eql.sync_rows make join rows, or owned rows, match a list from a form
eql.like(q), eql.NULL a %q% pattern or NULL; a NULL that survives in a Lua table
eql.on(handle) every function above, on a second database file
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 COUNT(*), SUM(done) select t { count(), sum(done) } → keys count, sum_done
SUM(price * qty) AS subtotal { sum(unit_price * quantity) as subtotal }; as is required for an expression
COUNT(DISTINCT hash) select visits { count(distinct hash) } → key count_distinct_hash
INSERT … RETURNING * insert todos { title: $title } → the whole row
RETURNING id insert todos { title: $t } returning { id }
insert a map plus server fields insert t { $attrs, session_id: $sid }
update from a map update t set { $attrs, reviewed: true } where .id = $id
UPDATE … WHERE id = ? update todos set { completed: true } where .id = $id → rows changed
DELETE FROM todos WHERE id = ? delete todos where .id = $id → rows deleted
ON CONFLICT (email) DO UPDATE SET … insert users { email: $e, name: $n } on conflict email update, or update { name: $n }
SET views = views + excluded.views … on conflict k update { views: .views + inserted.views }
ON CONFLICT DO NOTHING … on conflict email ignore; an ignored conflict returns nil
INSERT INTO daily (…) SELECT … insert daily select events { … } group by … (below)
GROUP BY … HAVING COUNT(*) > 3 group by .user_id having count() > 3
IN (?, ?, ?) where .id in ($ids); a Lua list as one parameter, empty matches nothing
CURRENT_TIMESTAMP, datetime('now', '-7 days') now(), now("-7d")
date(col), strftime('%H', col), datetime(col, '+1 hour') utc_date(.col) and shift(.col, 1, "hour") in the query, format.date(value, fmt, zone) in the handler: SQLite's own are refused
first_name || ' ' || last_name the same; NULL || x is NULL, so coalesce the operands
optional search where … and? (.name like $q or .email like $q)
filters decided at run time where matches($filters)
parent with children select users { id, name, todos { title, completed } }
filtered, ordered children todos { title } where .completed = false order by .title limit 5
a related column select products { id, .brands.name }
a related column as a list { id, list(.product_uses.use_id) as use_ids }
filter by a related column select products where .brands.name like $q

How it differs from SQL:

  • A column in where, order by and group by takes a leading dot: .user_id. A field list { id, title } does not.
  • select is optional. No field list means every readable column.
  • Operators: = != < > <= >=, like, not like, in, not in, between x and y, is null, is not null, + - * /, ||, and, or, not, parentheses.
  • order by takes a column or a projected alias: { customer_id, count() as n } group by .customer_id order by .n desc.
  • set takes expressions over columns: set { completed: not .completed }.
  • Booleans are real: where .completed = false, and rows come back with true and false.
  • There is no raw SQL. Every value binds as a parameter, and a query is never built by concatenation.
  • sum, avg, min, max, total and group_concat take distinct, with one argument.

now() is the framework's clock in UTC, as a datetime column holds it. Every now() in one statement is the same instant.

  • where .published_at <= now(), set { read_at: now() }, where .finished_at < now("-7d").
  • The offset is written in the query: a count and d, h, m (months) or y, or today, start_of_month, start_of_year.
  • now("today", "America/Denver") starts the day on a time zone's clock, and start_of_month and start_of_year the same way. The zone is a name in the query or a parameter: now("today", $zone). A nil or empty parameter is UTC. A count of days or hours ("-7d") is the same instant in every zone; months and years are counted on the zone's clock.
  • Use it in place of a $now built with os.date.
  • Not in a field list or a computed column.

Date functions are EQL's own, two of them. SQLite's date, time, datetime, strftime, julianday and timediff are refused with what to write in their place: they take modifiers as text, 'localtime' among them, which is the machine's zone, and write a time in another form than a column holds. unixepoch(.col) takes one value.

Function Is
utc_date(.starts_at) the date in UTC, YYYY-MM-DD
shift(.starts_at, 45, "minute") the time moved by a count of second, minute or hour, as a column holds one. The count may be a column or a parameter: shift(.starts_at, .minutes, "minute")
  • Neither reads a time zone. A day or a month is a zone's: the same time tomorrow is not always 24 hours on, so shift takes no unit longer than an hour.
  • A day's rows in a zone are two bounds the handler makes, found through the column's index: where .starts_at >= $from and .starts_at < $to, with format.utc(day .. " 00:00", zone) for each, or now("today", $zone) for today's start.
  • Rows by a zone's day are grouped in the handler: read the range, and take each row's day with format.date(row.starts_at, "%Y-%m-%d", zone).
  • In a field list a call takes as, and group by takes that name: select slots { utc_date(.starts_at) as day, count() as n } group by .day.
  • Both work in a computed column: day = t.date { computed = "utc_date(created_at)" }, ends_at = t.datetime { computed = "shift(starts_at, minutes, 'minute')" }.
  • Both take a time as a column holds one, or now(). Other text gives NULL, and so does no count. A date alone is read as its midnight in UTC. A number or a time of day alone is not refused and gives a wrong date.
  • A unit that is none, and one not written in the query, is an error when the query is compiled.
  • unixepoch(.a) - unixepoch(.b) is the seconds between two times, in a filter or, with as, in a field list.

Parameters

$name is read from the Lua locals where the query is written. A table of parameters is optional and wins: eql("… where .id = $id", { id = 5 }).

  • Five namespaces always work: $form.x, $json.x, $params.id, $query.page, $auth.id.
  • In a request, $form, $json, $params and $query also work whole, as a spread: insert notes { $form }. $auth does not.
  • A namespace wins over a local of the same name. Pass a cleaned-up form explicitly: eql(q, { form = form }).
  • A parameter table may not name auth.
  • A misspelt name fails with suggestions.
  • A tail call loses the locals. return eql "… $id" fails. Write return (eql "…"), assign first, or pass the parameters. The same goes for return realtime.stream { … }. check reports it.

Nil, empty and NULL

  • In a write, a $form.* field that was not sent, or any dotted field that is nil, binds NULL.
  • An insert leaves out a column that has a default when its value is nil, so the default is written, now() and uuid() included.
  • eql.NULL is a value: it writes NULL where nil would leave the default.
  • An update writes NULL for nil. A required column then fails as missing.
  • In a where, a dotted nil is an error, so a missing value cannot widen a query.
  • A blank "" in a number, date, boolean, reference or enum column is no value. A text column keeps "".
  • A boolean column reads true, 1, on, yes and false, 0, off, no, in any case.
  • Lua's nil vanishes from a table, so use eql.NULL for an optional value you build:
local attachment = request.form.attachment
  and require("effortless.files").save(request.form.attachment) or eql.NULL
eql "insert todos { title: $form.title, attachment: $attachment }"

Spreads

insert users { $data } writes the columns of $data a form may set.

  • Unknown keys, protected = true columns and the table's owner column are dropped before validation.
  • Pass request.form straight through. Do not copy form fields by hand.
  • Explicit keys win, in any order: insert cart_items { $attrs, session_id: $sid, status: "calculated" }.
  • update t set { $attrs } where … filters the same way and validates only the columns it sets.
  • A spread that holds no column it may write is an error.

Validation

Every insert and update is validated against the schema's rules, and strings are coerced to numbers and booleans.

  • An insert checks every rule. An update checks the columns it sets.
  • A failure raises { __eql_validation = true, table, errors }. Uncaught in a route, it becomes the page's errors, or a 422 { "errors": {...} } for JSON.
  • A foreign key that points nowhere fails the same way: errors.session_id = "no sessions row with id '…'". No exists check is needed first.
  • eql.unchecked(query, params) skips validation.

Search and filters

and? (…) is a filter that drops out when its parameters are empty. like wraps the value as %q%, and a % or _ in it matches itself.

local rows = eql([[ select contacts
  where .archived = false and? (.name like $q or .email like $q)
  order by created_at desc ]], { q = request.query.q })

For an explicit pattern, eql.like(q) returns "%q%" or eql.NULL: where ($pattern is null or .col like $pattern).

where matches($filters) filters by columns decided at run time, such as a saved filter: { status = "published", price = { gte = 10 } }.

  • Operators: eq ne in not_in gt gte lt lte between contains contains_seq starts_with is_null before after.
  • Combinators: any, none, and all for a list of documents that must each match.
  • before and after take relative dates: today, -30d, +1h, start_of_month.
  • A key may be a relation path: { ["brands.name"] = { contains = "nunc" } }.
  • Only a nested table holds operators. A text value is compared as that text, so { slug = request.query.slug } stays an equality whatever the client sends.
  • A key that names a hidden or secret column raises.

Relation paths

A dotted path reaches a related table. Each segment is a foreign-key column (.brand_id) or a table name (.brands, in either direction). Up to 6 hops.

Shape Meaning
where .brands.name like $q filter by a related column; the outer row count never changes
{ id, .brands.name } a belongs-to value, named by its leaf column unless as renames it
order by .brands.name sort by a related value
where exists .product_images, not exists a related row exists
where count(.product_images) > 2 count over a has-many path; also sum, min, max, avg
{ first(.product_images.image order by sort_order) as thumb } one related row's column
{ list(.product_uses.use_id) as use_ids } every related row's value
where .product_tags.tags.name = $tag through a has-many, true when any row matches
any(expr), none(expr), all(expr) quantifiers over one has-many path
  • A table name must resolve to one foreign key. With two, the error names both, and the path names the column.
  • For several columns of one related row, use a nested block: brands { name, code }.
  • A nested block is a select of its table. It takes paths, first(), list(), aggregates over a path and *, and where, order by, limit and offset after its braces.
  • A link table is a relation like any other: brands { name, brand_retailers { .retailers.name as name } }.
  • A negative comparison through a has-many needs a quantifier: any(.tags.name != $t) is "some other tag", none(.tags.name = $t) is "no such tag".
  • A path predicate filters the rows the path reaches: select users where .todos.done = false is the users with an open todo.
  • A path or derived column in order by costs one subquery per row.

insert … select

Writes the rows a select computes, in one statement:

eql [[
  insert visit_daily select visit_events {
    site_id, day, dimension: 'page', value: path, views: count(), visitors: count(distinct hash)
  }
  where .day = $day
  group by .site_id, .day, .path
  on conflict (site_id, dimension, day, value) update { views: inserted.views, visitors: inserted.visitors }
]]
  • The projection's names are the target's columns. col: value writes value to col; a bare col is the source's column of that name.
  • inserted.col in on conflict … update { } is the row the insert proposed, and .col the stored one.
  • views: inserted.views replaces the stored count, so a second run writes the same numbers. views: .views + inserted.views adds, so a second run over the same events counts them twice.
  • It returns how many rows it wrote, or with returning the rows.
  • The schema's rules (min, email, one_of) do not run on computed rows. NOT NULL, unique and foreign keys do.
  • It cannot write a t.file column.
  • In a request it cannot write a table with an owner, or a reference to one. Use eql.unscoped or run it outside a request.

Transactions

eql.transaction(function()
  local order = eql "insert orders { total: $total }"
  eql "update stock set { held: .held + 1 } where .id = $item"
end)
  • A normal return commits. An uncaught error rolls back and is raised again. Nested calls are savepoints.
  • A transaction holds the write lock. http.*, proc.run, ai.chat and an unqueued mail.send raise inside one. Call them after it returns, or jobs.enqueue the work, which commits with the rest.
  • Keep uploads out of long transactions: a file saved inside one is stored while the lock is held.
  • A write that waits five seconds for the lock fails with database is locked.

Rows from a form

eql.sync_ids(join_table, parent_fk, parent_id, target_col, ids) makes the join rows of one parent match a list. It inserts the missing pairs and deletes the removed ones. Do not delete all and reinsert.

eql.sync_ids("product_finishes", "product_id", product.id, "finish_id", request.form_all.finish_ids)

eql.sync_rows(table, parent_fk, parent_id, rows, opts?) does the same for rows a parent owns:

  • A row whose id is one of the parent's rows is updated.
  • A row without an id is inserted.
  • A row the parent has that the list lacks is deleted.
  • An id that belongs to another parent is an error.
  • opts.order names a column that gets each row's position.
  • opts.set holds values every row gets by name: { set = { status = "draft" } }.
  • Each row is a spread write: validated, with its upload saved.
  • It runs in one transaction. When any row fails nothing is written, and errors[opts.name or table] is a list with one entry per row.
  • It returns the ids in row order.
<div @each={product.images as image, i}>
  <input type="hidden" name="images[{i}][id]" value={image.id}>
  <input name="images[{i}][title]" value={image.title}>
  <input type="file" name="images[{i}][file]">
</div>
eql.sync_rows("product_images", "product_id", product.id, request.form.images, { name = "images", order = "sort_order" })

Another database file

db is only for a second file. The app's own database is always eql.

  • db.open(path, schema_path?) returns a handle, and eql.on(handle) an eql bound to it.
  • The optional schema supplies relations, validation and typing. It is not migrated.
  • The path is inside the database's data directory.
  • Close the handle when done. A transaction cannot cross handles.

4. Markup (.x.html) — one grammar for server pages and islands

Every .x.html is one grammar, modelled on Svelte. A route view renders on the server. A component in app/components/ renders where it is used, and one with a <script> is an island that runs in the browser (section 10). Values are escaped.

<!-- routes/posts/posts.x.html -->
<title>Posts</title>
<post-card @each={posts as post} post={post} />
<p @if={!posts.length}>No posts yet.</p>
Jinja/Liquid Effortless
{{ user.name }} {user.name} in text; title={user.name} or title="Hi {user.name}" in an attribute
{{ html | safe }} {@html html}
{% if a %}…{% elif b %}…{% else %}…{% endif %} <p @if={a}>…</p><p @elseif={b}>…</p><p @else>…</p> on consecutive elements
{% for t in todos %}…{% endfor %} <li @each={todos as t, i}>…</li>
for-else (empty list) <p @if={!todos.length}>None yet</p> beside the @each
{% include "card" with x=y %} <post-card x={y} /> (file: app/components/post-card.x.html)
{% extends "base" %} / {% block %} routes/_layout.x.html writes <slot /> where the page goes
{% set x = v %} compute it in the handler
| default:"—" {x ?? "—"}
| upper, | length, | join:", " {x.toUpperCase()}, {xs.length}, {xs.join(", ")}
| date {v.formatDate('d MMM')} in the view, or format.date(v, "%d %b") in the handler
| timeago, | markdown, | file_url in the handler: format.timeago(v), format.markdown(text), files.url(key)

{@html} writes only HTML that format.markdown returned in this request, or HTML the app built and passed through format.trusted_html(html). Any other string is a render error. Markdown stored already rendered is run through format.markdown again when read.

Expressions

Expressions are JavaScript: ===, ==, &&, ||, !, ??, ?., ?.[i], ternaries and arithmetic.

  • Methods: length, includes, indexOf, join, slice, startsWith, endsWith, toUpperCase, toLowerCase, trim, replaceAll (a string, not a pattern), toFixed, and formatDate and formatRange on a date.
  • A server file calls no other function and assigns nothing.
  • A name nothing defines is undefined and prints as empty.
  • A member of null or undefined is an error with file and line. Write deal?.owner?.name.
  • [] and {} are truthy. Test xs.length. An empty Lua table reads as an empty list.
  • == coerces a number and a numeric string, so @if={old.company_id == c.id} works with form values.

Attributes

  • A value of false, null or undefined leaves the attribute out, and true writes it bare: disabled={saving}.
  • On aria-* a boolean is the state: aria-pressed={on} writes "true" or "false". null still leaves it out.
  • class:done={t.completed} adds a class. style:width="{w}px" sets one declaration. @show={x} sets display: none.
  • @each cannot share an element with @if.
  • @with={deal.amount * rate as due} names a value for the element's other attributes and what is inside it: {due}. It is read after the element's @if and @each, so it can use the row. It goes on an element, not on a component's tag.
  • A literal { in text is &lbrace;.
  • An unquoted attribute value that contains { is an error. Quote it.

Escaping does not protect an attribute the browser decodes and then runs, so three places refuse or drop data:

  • An inline handler (onclick, any on…) and srcdoc take no {…}. In an island use @click={…}. In a server component keep the handler static and read a data-* attribute.
  • href, src, action, formaction, poster and xlink:href built from data are left out when the value is a javascript:, vbscript: or data: URL. A data:image/… in src or poster is kept.
  • A value inside style="…{x}…" or style:prop={x} that contains ;, { or } is dropped.

A class value from data adds no CSS: the stylesheet holds the classes the templates write (section 10). It can still name any class the page has, so pick classes from values the app wrote, not from user text.

Scope, layouts and layout data

  • A view reads what its handler returns, what the _layout.lua files above it load, the URL params, and user, logged_in, path, method, base_url and time_zone (the zone the page is rendered in, or nothing).
  • After a failed write it also reads errors and old (section 6).
  • A component reads its attributes and the same request names.
  • Markup inside a component's tag renders in the caller's scope, at the component's <slot />.
<!-- routes/_layout.x.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <title>My app</title>
    <link rel="stylesheet" href="/_tailwind.css">
    <script src="/_inlay.js"></script>
  </head>
  <body><slot /></body>
</html>
  • routes/_layout.x.html wraps every page. A _layout.x.html in a folder wraps the pages in that folder and below, inside the layouts above.
  • A layout reads the page's scope, so what the handler returns is there too.
  • A page's top-level <title> replaces the layout's.
  • There is no layouts/ or partials/ directory.

What the layout needs on every page comes from _layout.lua beside it, not from each handler:

-- routes/_layout.lua
local eql = require "effortless.eql"
local auth = require "effortless.auth"
function load(request)
  if not auth.check() then return {} end
  -- the layout writes {open.count}
  return { open = eql.one "select todos { count() } where .completed = false" }
end
  • Every page in that folder and below reads what load returns.
  • The page's own data wins a name both have. A nested _layout.lua wins over the one above it.
  • load runs each time a view renders, and not for a redirect or JSON.
  • load returns data. Refusing a request is guard (section 2).

Components and icons

  • A component in app/components/post-card.x.html is <post-card>.
  • A component one route uses lives beside it: routes/deals/_deal-row.x.html is <deal-row> in routes/deals/ and below.
  • A tag is looked up in the using file's folder, then each folder up to routes/, then app/components/, then effortless-ui (section 16) when the theme imports it.
  • One tag names one island per page.

<icon name="pin" /> writes app/icons/pin.svg in place as an <svg>, so it takes currentColor and classes.

  • The tag's attributes go on the <svg>. size="16" sets width and height.
  • label="Map" names it to a screen reader, which otherwise skips it.
  • The file is sanitised to drawing elements.
  • check reports a missing name, and a written name with no file.
  • effortless icon pin writes app/icons/pin.svg: the icon of that name at lucide.dev/icons, from the Lucide release the binary's own icons are from. Do not write an icon's paths by hand. With effortless-ui on, the icons its components use need no file.

Dates and formatting

Format in the handler, or with the two date methods in the view:

-- routes/posts/posts.lua
local format = require "effortless.format"
function get(request)
  local posts = eql "select posts order by created_at desc"
  for _, p in ipairs(posts) do
    p.body_html = format.markdown(p.body)   -- the view writes {@html post.body_html}
    p.ago = format.timeago(p.created_at)
  end
  return { posts = posts }
end
  • {row.starts_on.formatDate('d. MMMM yyyy')} writes "4. oktobar 2026" under <html lang="sr">. Month and day names follow the page's language.
  • Pattern letters: d/dd day, M/MM month number, MMM/MMMM month name, EEE/EEEE weekday, yy/yyyy year, H/HH hour, mm minute. Text in single quotes is written as it is.
  • {row.starts_on.formatRange(row.ends_on, 'd. MMMM')} writes what both dates share once: "3–4. oktobar". A third argument replaces the dash.
  • Names are available for en, sr, sr-Cyrl, hr, bs, sl, de, fr, es and it, and are English for any other language.
  • formatDate and formatRange run in server files, not in an island's script.
  • format.date(v, "%d %b") in a handler is strftime with English names, in UTC, for fixed formats such as a feed or a CSV. It also takes a unix time, and a time zone third: format.date(v, "%Y-%m-%d %H:%M", site.time_zone).
  • Both read YYYY-MM-DD, optionally with a time and a zone.
  • format.markdown renders at most 256 KB and raises past it. Give the column a max under that.

Time zones

A t.datetime column holds UTC. A view writes it on the clock of a zone, which is an IANA name such as America/Denver.

  • formatDate and formatRange write a datetime in the render's zone. A date alone (YYYY-MM-DD) has no zone and is the day written.
  • The render's zone is time_zone in the page's data, from the handler or a _layout.lua. With none there it is app.time_zone. With neither, a datetime is written with the fields it holds.
  • return { time_zone = user.time_zone } in routes/_layout.lua gives each signed-in user their own. The column is a t.text.
  • A last argument names the zone of one value, for a time that belongs to a place: {slot.starts_at.formatDate('HH:mm', slot.site.time_zone)}, {a.formatRange(b, 'd.M. HH:mm', '–', site.time_zone)}.
  • app.time_zone = "browser" is each visitor's own zone: /_inlay.js writes it to the cookie effortless_zone. The first page a new visitor loads is rendered before the cookie exists: its text has no zone, and a date picker on it picks in the browser's zone all the same.
  • A page rendered in a zone names it in <body data-zone="America/Denver">, and on <html> for a layout that writes no <body>. <ui-datepicker time={true}> reads it: it shows a datetime on that clock and posts UTC, and its zone attribute picks in another (effortless docs ui-datepicker).
  • format.utc(value, zone) in a handler is a date and time on a zone's clock as UTC: format.utc("2026-07-01T09:00", "America/Denver") is "2026-07-01T15:00:00Z". It is for what was not picked in the date picker: a plain <input type="datetime-local">, which posts the time as typed (format.utc(request.form.starts_at, request.time_zone)), an import, and a time built from parts, such as a weekly slot: format.utc(day .. " " .. slot.opens_at, site.time_zone).
  • format.date(value, fmt, zone) is the way back, for a CSV, a feed, or the value of a datetime-local input: format.date(row.starts_at, "%Y-%m-%dT%H:%M", zone).
  • Both take a nil zone as UTC, return a value that is not a time as it is, and raise for a name that is no zone.
  • An hour the clock repeats in autumn is its first pass. An hour it skips in spring is read on the clock from before the change: 02:30 that day is 03:30. The date picker reads both the same way.
  • One day's rows in a zone are two bounds the handler makes, and rows by a zone's day are grouped there (section 3, Date functions). For today, now("today", $zone) is the day's start.
  • The admin shows and edits a t.datetime in app.time_zone and names the zone in the column's heading. Its search box takes a time as the column holds it, .starts_at >= "2026-07-01T08:00:00Z", or a date. A t.date and a t.time are shown as stored.
  • A mail is rendered in the time_zone of its data, else in app.time_zone. Under "browser" it has none, so pass the recipient's.
  • A name that is not a zone is an error: in config.lua when the app starts, in a page when it renders. The cookie's is ignored. A name is spelled as the zone database spells it: Europe/Belgrade, not europe/belgrade.
  • Zones are read from the machine's zone database. A container needs its tzdata package; with app.time_zone set and no database, check, test and serve stop and say so.
  • now(), t.now, t.updated, format.timeago, a job's at, a cron schedule and the dates matches() reads are UTC whatever the zone. Nothing in a handler or a query reads app.time_zone by itself: a zone is passed.
  • A time that reaches a write with no zone is the field's error (section 5, Types), with the message that names format.utc: a datetime-local field posted straight into an insert renders the form again with it.

What check reads in markup

  • Markup is parsed as a browser parses it, so <li>, <p>, <td> and <option> may leave out their end tags.
  • What a browser would repair silently is an error with its line: a stray </div>, an element never closed, <b><i></b></i>.
  • A button that opens a <dialog> or a popover names its id (commandfor="photo-picker", popovertarget="row-menu-{item.id}"). check reports a trigger whose id nothing produces.
  • <button type="button" commandfor="photo-picker" command="show-modal"> opens the dialog with no script, and command="close" closes it; a popover takes show-popover, hide-popover and toggle-popover. closedby="any" on a dialog closes it on a press outside. Browsers from before 2026 know neither attribute, and /_inlay.js does there what they say, so the markup is the same everywhere. Inside a form the button needs type="button".
  • A popover with the class popup opens against the button that names it. CSS anchor positioning places it, and /_inlay.js does in a browser without that (section 16, popup).

5. Schema — app/schema/schema.lua is the only migration artifact

Declare the tables you want. The framework compares the file with the database and converges: in development whenever the file changes, in production with effortless migrate. There are no migration files. To change the schema, edit this file.

local t = require "effortless.types"

return {
  -- Columns added to the framework's users table (see Framework tables).
  users = {
    avatar = t.image,
  },

  -- A user's own rows.
  contacts = {
    access = { owner = "owner_id" },
    id = t.pk,
    owner_id = t.ref "users" { required = true },   -- set from the signed-in user
    company_id = t.ref "companies",                  -- optional; an empty <select> is NULL
    name = t.text { required = true, min = 2, label = true },
    email = t.text { email = true },
    status = t.enum { "new", "active", "done", default = "new" },
    amount = t.real { min = 0 },
    created_at = t.now,
    day = t.date { computed = "utc_date(created_at)" },  -- derived, read-only
    indexes = { "email" },
  },

  -- Everyone reads, admins write.
  companies = {
    access = { read = "anyone", write = "admin" },
    id = t.pk,
    name = t.text { required = true },
  },

  -- A join table: composite key, no id, kept in step with eql.sync_ids.
  contact_tags = {
    access = { owner = "owner_id" },
    owner_id = t.ref "users" { required = true },
    contact_id = t.ref "contacts" { required = true, on_delete = "cascade" },
    tag_id = t.ref "tags" { required = true, on_delete = "cascade" },
    primary = { "contact_id", "tag_id" },
  },
}

Types and attributes

Types: t.integer, t.text, t.real, t.boolean, t.datetime, t.date, t.time, t.blob, t.json, t.uuid. A bare type is the plain column (name = t.text); options go in a table call.

A t.datetime is an instant, written 2026-07-01T09:00:00Z: in the file, in Lua, JSON, a form, a view and a test. It is one text everywhere, in UTC and to the second, so it sorts and compares as time.

  • A time is written with its zone: a Z, or an offset such as +02:00, which is stored as the same instant in UTC. 2026-07-01T14:00:00+02:00 reads back as 2026-07-01T12:00:00Z. A fraction of a second is dropped.
  • A date and time with neither is refused: nothing says which clock it was read from. In a write it is the field's error, as any value the schema refuses is, so a form renders again with errors.starts_at: needs its time zone: "2026-07-01T09:00:00Z" if it is UTC, or format.utc(value, zone) for a time on a zone's clock (section 4, Time zones).
  • The same holds for what a query compares with the column, bound or written in the query: where .starts_at >= $from. This covers = != < > <= >=, between and the comparisons of matches(), on the column or at the end of a relation path. An instant written with its Z compares as it is everywhere else too: in a list (in), with like, and against what shift() or coalesce() gives. One written with an offset is converted only where it is compared with the column itself.
  • A date alone is stored as written: no time is set. Anything else is the field's error, must be a time such as "2026-07-01T09:00:00Z", or a date. A column's default is written as the column holds it: now(), a date, or an instant with its Z.
  • <ui-datepicker time={true}> posts an instant, now() and t.now are instants, and os.date("!%Y-%m-%dT%H:%M:%SZ") writes one.
  • A row written before this holds another text: 2026-07-01 09:00:00, one with no seconds, or an offset. A migration plan has a step for each table with such rows, ~ rewrite the datetimes of 12 rows of events (starts_at) as 2026-07-01T09:00:00Z, which reads a time with no zone as UTC. It is applied with the rest of the plan, after a backup, by effortless migrate and by the schema's load in development. A date alone and text that is no time are left. It is one way: a binary from before this does not compare the rewritten rows right.
Shortcut Is
t.pk an integer key that is never reused after a delete
t.ref "table" a foreign key to that table's id, indexed
t.enum { "a", "b", default = "a" } text limited to those values
t.now a datetime set on insert; a spread cannot set it
t.updated a datetime set on insert and by every update that does not assign it
t.secret text that never leaves the server (below)
t.file, t.image a stored upload's key (below)
Attribute Means
required = true NOT NULL, and a write without it fails; "" counts as without
not_null = true NOT NULL and no more: a write of null fails, and "" is a value in a text column
unique = true no two rows share the value
default = v written when an insert leaves the column out
min = n, max = n length for text, value for numbers
email = true, url = true the value's format
one_of = { … } the allowed values
matches = "^%d+$" a Lua pattern the value fits
hidden = true left out of select t, *, the admin and matches(); read only when a field list names it
protected = true not written by a spread or a form; code sets it by name
private = true read in a request only by the roles the table's write names (below)
label = true the column the admin names a row by
was = "old_name" this column, or table, was called that: a rename that keeps its data
  • A table or column name is letters, digits and underscores, not starting with a digit.
  • A name that starts with _ is the framework's, for a table and for a column.
  • Declarations are checked before they are applied: a default must fit its type, enum values cannot repeat, min cannot exceed max. effortless check reports a misspelt attribute (requird = true).

Access

Every table a request reaches declares who reads its rows and who writes them. A table that declares nothing is reached only outside a request and through eql.unscoped. A query in a request that touches it raises, naming the table and the declarations.

access = Reads Writes
"shared" anyone anyone
{ owner = "user_id" } the row's owner the row's owner
{ owner = "author_id", read = "signed_in" } every signed-in user the row's owner
{ owner = "id", read = "signed_in", write = "manager" } every signed-in user the row's owner, and a manager any row
{ read = "anyone", write = "admin" } anyone an admin
{ read = "manager" } a manager no request
  • read and write take "anyone" (signed out included), "signed_in", a role, or a list: write = { "manager", "admin" }.
  • The owner always reads and writes their own rows. Whoever writes every row reads every row.
  • A read by a user the rule leaves out returns no rows.
  • A write by one raises, naming who writes the table: products is written by manager: this user's role is "customer".
  • A signed-in page is the route's decision (public = true, section 2), not the table's.

An owner table in a request. Every query runs as the signed-in user:

  • A read returns the user's rows, wherever the table enters the query: its own select, a nested block, a relation path, a count.
  • An insert sets the owner column. insert deals { $form } is enough, and naming another user raises.
  • An update or a delete touches only the user's rows.
  • A reference to an owner table must name a row the user can read. Another user's row gets the message a missing one does: deal_id: no deals row with id '9'.
  • Signed out, an owner table reads as empty and a write raises.
  • A child or join table of an owner table declares its own access = { owner = … }. Scoping never passes through a parent.
  • A user whom write names sets the owner as they like, and an insert of theirs that leaves it out is their own.

The owner column is compared with the user's id when it references users. Otherwise it is compared with the user's column that references the same table: access = { owner = "account_id" } with account_id = t.ref "accounts" is the user's account_id.

  • request.user carries that column.
  • A request does not set it on users, since it decides which rows the user reaches. Set it with eql.unscoped or outside a request.

Crossing users on purpose is said in the code:

  • eql.unscoped "…", eql.mutate(q, { unscoped = true }), realtime.stream(list, { unscoped = true }).
  • Outside a request (eval, a command, a cron script, a job, a test's body) nothing is scoped.
  • check reports an eql.unscoped that changes nothing: in app/cron, app/jobs or app/commands, or on a query whose every table is "shared".

Roles are the values of users.role, which the framework declares as t.enum { "user", "admin", default = "user" }.

  • An app with its own roles restates it in users: role = t.enum { "customer", "manager", default = "customer" }.
  • A new user holds the default.
  • A role that the list does not have is an error wherever it is named: in access, in auth.require, auth.has_role and auth.invite, and in a query.
  • A role is not named anyone, signed_in or shared.
  • Once any table's access names a role, a request does not set users.role. Change a role with eql.unscoped, the admin, or outside a request.

Private columns. private = true keeps a column to the roles the table's write names: cost = t.integer { private = true } on a catalog admins write is read by admins.

  • A query by anyone else that names it raises, in a field list, a where, an order by or a returning: users.phone is private: it is read by manager; the signed-in user's own is request.user.phone.
  • * leaves it out.
  • The row's owner does not read it back through a query either. A user's own row is request.user, which carries every stored users column, private ones included.
  • Whoever writes a row may set its private columns.
  • On a "shared" table private is a schema error.

The framework's users is access = { owner = "id", read = "signed_in" } with email private. A signed-in user reads every user's row and writes their own, and no request reads another user's email.

  • write = "manager" in the app's users gives a role that edits users and reads their emails.
  • read = "manager" is for an app whose customers must not see each other.
  • A page that lists everyone's email to everyone uses eql.unscoped.

Secrets

t.secret is text the server sets and never shows: users.password_hash, an API key.

  • A handler reads one by naming it in a field list, and may use it.
  • A response that holds the value is refused with an error naming the column: JSON, a page, a CSV or a redirect's target, also inside other text ("Bearer " .. key) and URL-encoded, as a link carries it.
  • A realtime stream may not select one.
  • A log line, the error page and an error report show [secret] in its place.
  • A value shorter than 8 characters is not matched.
  • A value meant to be shown to a user is t.text { hidden = true }.

File columns

t.file holds the key of a stored upload, on the row that owns it. t.image is a t.file the admin previews.

photos = { access = { owner = "user_id" }, id = t.pk, user_id = t.ref "users" { required = true },
           image = t.image { folder = "photos", meta = true } }
  • Write the upload itself and the write saves it: insert photos { image: $form.image }, a spread of request.form, or a sync_rows row.
  • folder = "photos" says where it is stored.
  • Read it with files.url(key) in the handler.
  • The framework deletes the stored file when the row is deleted or the cell replaced, a cascade included. Do not delete files by hand, and do not keep a separate files table.
  • In a request the column takes an upload of this request, nothing, or the key the row already holds. Any other key is refused: is not an upload of this request.
  • An upload written to a column that is not a file column is a validation error.
  • slug = true keeps a readable form of the uploaded filename in the key (site_plan-9f3a1c7e5b2d4088.dwg), and a download is saved as site_plan.dwg. Use it for documents, since it puts the uploader's filename in a public URL.
  • meta = true adds _image_meta, a hidden JSON column the framework fills: { width, height, size_bytes, mime }. <img src={row.image_url} width={row._image_meta?.width} height={row._image_meta?.height}> reserves the space.
  • _image_meta is NULL for a file this process cannot read, such as one on R2. effortless files meta fills those in, with --remote over HTTP.
  • On R2, deletes go through the delete_upload job, so an unreachable store is retried.

References, keys and indexes

  • on_delete on a t.ref is "cascade", "set_null" or "restrict". Without it, a delete of the parent fails while a child points at it.
  • within narrows a reference: variant_id = t.ref "variants" { within = "product_id" } accepts only a variant whose product_id equals the row's own. Both tables have the column. A write checks it, and the admin's picker offers only matching rows.
  • Composite key: primary = { "product_id", "term_id" } beside indexes. Every key column is not_null: an insert has to name it, and "" is a value in a text one unless it says required = true. A table other tables reference, or whose rows the admin edits, needs id = t.pk and a unique index.
  • Indexes: "email" for one column, { "owner_id", "created_at" } for several, and unique = true inside an entry for a composite unique constraint.
  • A t.ref column is indexed already. index = false on it turns that off.
  • Do not repeat a unique = true column in indexes.
  • order = { "thumbnail desc", "sort_order" } on a table is how its rows are read inside first(...) and a nested block with no order by. A top-level select is not reordered.

Computed columns

computed = "<eql expression>" is a column the schema derives and nothing writes. It reads like any column.

-- this row's own columns: SQLite computes and stores it
size = t.text { computed = "{coalesce(width, 0)}x{coalesce(depth, 0)}" },

-- through a relation: computed on each read
thumb = t.image { computed = "first(.product_images.image order by thumbnail desc, sort_order)" },
  • A string with {...} is a template: text outside the braces is literal, and each brace is one expression. A NULL part renders as empty.
  • An expression over the row's own columns is stored, and may be indexed.
  • An expression with a relation path is computed on each read: no index, no unique, and one subquery per row. Name the fields you need on a hot list.
  • group by on a column computed through a relation is an error.
  • A computed column has no default, required or not_null, and cannot hold now().

Migrating

  1. Edit the schema.
  2. effortless migrate --dry-run prints the plan.
  3. effortless migrate applies it, in one transaction, after a backup to data/backups/migrations/.
  • A change that deletes values is refused with the number of rows that hold them. A person applies it.
  • A removed column plus an added one of the same type is refused as a probable rename. Add was = "old" to the new column.
  • A required column with no default is refused on a table that has rows. Give it a default.

Framework tables

The framework declares users and the tables it keeps for itself, and merges them into the app's schema. They are created, migrated and read like the app's own.

  • An app's users = { … } adds columns, and restates access and role when its own differ.
  • Naming any other framework column there is a schema error.
  • An app table whose name starts with _ is a schema error.
  • A request does not reach the _ tables. Read them outside a request or with eql.unscoped.
-- The tables the framework reads and writes, merged into every app's schema.
-- An app's `users = { … }` adds columns to the users table declared here and
-- names none of these; its `access` replaces the one here. The other tables
-- are the framework's alone; `was` carries the rows over from the names apps
-- used to declare.
local t = require "effortless.types"

return {
  -- A signed-in user reads every user's row and writes their own. `email`
  -- is private: a request reads it only as a role the app's `write` names,
  -- and a user's own is `request.user.email`. An app restates `access` to
  -- change who reads and writes, and `role` to list its own roles.
  users = {
    access = { owner = "id", read = "signed_in" },
    id = t.pk,
    email = t.text { required = true, unique = true, email = true, private = true },
    password_hash = t.secret,
    name = t.text,
    role = t.enum { "user", "admin", default = "user", protected = true },
    created_at = t.now,
  },

  _sessions = {
    was = "sessions",
    id = t.pk,
    user_id = t.ref "users" { required = true, on_delete = "cascade" },
    token = t.secret { unique = true },
    expires_at = t.datetime { required = true },
    created_at = t.now,

    indexes = { "expires_at" },
  },

  -- One-time links: password reset and invitations.
  _user_tokens = {
    was = "user_tokens",
    id = t.pk,
    user_id = t.ref "users" { required = true, on_delete = "cascade" },
    kind = t.enum { "reset", "invite" },
    token = t.secret { unique = true },
    expires_at = t.datetime { required = true },
    used_at = t.datetime,
    created_at = t.now,

    indexes = { "expires_at" },
  },

  -- The background queue, and each cron run as a row named "cron:<name>".
  _jobs = {
    was = "jobs",
    id = t.pk,
    name = t.text { required = true },
    args = t.json,
    state = t.enum { "queued", "running", "done", "failed", default = "queued" },
    run_at = t.datetime { required = true },
    attempts = t.integer { default = 0 },
    max_attempts = t.integer { default = 1 },
    locked_until = t.datetime,
    last_error = t.text,
    result = t.json,
    created_at = t.now,
    started_at = t.datetime,
    finished_at = t.datetime,

    indexes = { { "state", "run_at" } },
  },
}

6. Auth, CSRF, validation — the safety rails you MUST wire

local auth = require "effortless.auth"
local response = require "effortless.response"

function post(request)
  local result = auth.login(request.form.email, request.form.password)
  if result.success then
    return response.redirect(request.form.next or "/")
  end
  return response.invalid({ auth = result.message }, request.form)
end

Auth

Function Returns
auth.check() whether someone is signed in
auth.user(), request.user the user's row, or nil
auth.id() the user's id, or nil
auth.role(), auth.has_role("admin") the role; whether the user holds one
auth.require(role?) a guard's answer (section 2)
auth.login(email, password) { success, message, user? }
auth.register(email, password, name) { success, message, user_id? }
auth.logout(), auth.logout_everywhere()
auth.change_password(current, new) { success, message, field? }; field is "current_password" or "password"
auth.reset_token(email) { token, user }, or nil
auth.reset_password(token, password) { success, message }
auth.invite(email, name?, role?) { token, user_id }
auth.accept_invite(token, password) { success, message }
auth.token_live(token, kind) bool, reason?: whether to show the form
  • Sessions are cookies that last 7 days.
  • A role is one that users.role lists. Any other name raises.
  • auth.login and auth.register validate their own inputs: empty fields, and a password of 8 characters to 71 bytes. Do not check those again. A rule of the app's, such as a confirmation that must match, belongs in the route.
  • A registered user holds the default role. Promote an admin explicitly.
  • A changed password ends the user's other sessions and keeps this one.
  • response.redirect(request.query.next or "/") is safe as written: a target that leaves the site goes to /.
  • The scaffold's /login and /logout are the patterns to copy. A public sign-up route calls auth.register as /login calls auth.login.

Sign-in limits. Past the limit auth.login answers "Too many sign-in attempts. Try again later.".

  • 10 attempts per email address per 15 minutes, cleared by a success.
  • auth.change_password allows 10 attempts per user per 15 minutes.
  • auth.reset_token returns nil past 5 requests an hour for one address.
  • The counts are shared by every worker and survive a restart.
  • A sign-in for an address with no account takes as long as one that exists, so the form does not reveal who is registered.

Password reset and invitations are one-time links.

  • auth.reset_token(email) returns nil for an address nobody has. Answer the caller the same either way.
  • A new reset token replaces the user's earlier unused one.
  • auth.reset_password spends the token and ends every session the user had. A password the 8-character rule refuses leaves the token live.
  • auth.invite creates the account with no usable password, and auth.accept_invite sets the first one.
  • An invitation is the one path that names a role, so keep its route behind auth.require("admin").
  • A reset lasts an hour, an invitation seven days.
  • The plain token goes in the mailed link and nowhere else. The database holds only its SHA-256. Compare a token you hold with require("effortless.crypto").sha256(token).
  • request.base_url (base_url in a view) is the app's absolute URL, for the link.
  • In development no mail is delivered. Issue a token and open the link yourself: effortless eval 'return auth.reset_token("[email protected]").token', then /reset?token=<it>.
  • The scaffold ships /forgot, /reset, /invite, /accept and tests/auth_test.lua covering both flows.

Validation

Rules live in the schema. A direct eql() write and eql.mutate both run them, so a handler declares none.

<form method="POST">
  <ui-field label="Title" error={errors?.title}>
    <input class="input" name="title" value={old?.title}>
  </ui-field>
  <button class="btn">Add</button>
</form>
  • A failed write renders the same page again at 422, with errors.<field> and old.<field>. There is no flash and no redirect.
  • A JSON caller gets { "errors": { … } }.
  • A required owner column (owner_id) is skipped when a form leaves it out, since the framework sets it.
  • A hidden column is never validated from a form.

To validate by hand:

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: only the keys present, unique excludes row 5
if errs then return response.invalid(errs, request.form) end

Unique. A write that breaks a unique constraint fails as validation does: { email = "is already taken" }, or { _base = "combination is already taken" } for a composite index.

  • A write reports one unique violation at a time, after its other rules pass.
  • unique = true is unique across the whole table, every owner's rows included. "is already taken" then tells a user about a row they cannot read.
  • A value unique per owner is a composite index with the owner column: indexes = { { "user_id", "slug", unique = true } }.

Cross-site requests and headers

  • A write a browser marks as coming from another origin is rejected with 403 before the handler runs. There is no token: a form, a fetch and inlay.post need nothing.
  • A request with neither Sec-Fetch-Site nor Origin is not from a browser and is let through, so a script calls a route with its own credentials in a header.
  • Every response carries X-Content-Type-Options: nosniff, X-Frame-Options: SAMEORIGIN, Content-Security-Policy: frame-ancestors 'self' and Referrer-Policy: strict-origin-when-cross-origin.
  • Outside development, a response served over HTTPS also carries Strict-Transport-Security.
  • A handler cannot change these. A page of the app can be framed by the app and by no other site.

7. Files, outbound HTTP, subprocesses, JSON, logging

Uploads

An upload is three lines: the column, the write, the render.

-- schema:   image = t.image { required = true, folder = "products" }   -- t.file for non-images
-- route:    eql "insert product_images { product_id: $params.id, image: $form.image }"
-- handler:  image.url = files.url(image.image), then <img src={image.url}> in the view
  • The form needs enctype="multipart/form-data". The field arrives as a table in request.form, and every file of a repeated field in request.form_all.
  • The write saves the upload and keeps its key. A rollback deletes the saved file, and the framework deletes it with the row (section 5).
  • A file input left empty is no field: request.form.logo is nil. A spread leaves the cell as it is.
  • A named write, update … { logo: $form.logo }, sets an empty one to NULL and deletes the old file. Check request.form.logo first.
  • Files are public at /uploads/….
  • UPLOAD_MAX_MB caps each file. A request past four times that is a 413.
  • R2_BUCKET and its keys switch storage to Cloudflare R2.
local files = require "effortless.files"
local path  = files.save(request.form.avatar)             -- "uploads/2026/07/ab12cd34.png"
local path  = files.save(request.form.avatar, "avatars")  -- "uploads/avatars/…"
local path  = files.import("seed/logo.png", "logos")      -- a file on this machine; outside a request only
local text  = files.read("seed/terms.md")                 -- from a folder files.paths lists; outside a request only
local url   = files.url(row.image)                        -- a t.file cell's URL, nil when empty
local ok    = files.exists(row.image)
local limit = files.max_bytes()                           -- UPLOAD_MAX_MB in bytes
local base  = files.media_url()                           -- "" when local
files.delete(key)                                         -- a file no row owns; false while a t.file cell holds it

effortless files prune lists stored files no t.file cell names.

Outbound HTTP

local http = require "effortless.http"
local json = require "effortless.json"
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 = "…" }
  • res is { status, body, headers, truncated }. A failed call returns nil, err.
  • The timeout is 10 seconds. The body is capped at UPLOAD_MAX_MB, and res.truncated says when it was cut.
  • Private, loopback and link-local addresses are refused unless HTTP_ALLOW_HOSTS names the host or HTTP_ALLOW_PRIVATE=true.
  • Redirects are not followed. Each hop you follow is checked again.
  • In a test, a call is answered from the test's http table and never reaches the network (section 8).

Subprocesses

local proc = require "effortless.proc"
local r, err = proc.run("magick", { "in.png", "-resize", "800x", "out.png" }, { timeout = 30 })
-- r = { status = 0, stdout = "…", stderr = "…" }
  • The program is one that config.lua's proc.commands lists.
  • Arguments are a list. There is no shell.
  • The default timeout is 30 seconds. Past it the call returns nil, "timeout".
  • Request input never reaches an argument unchecked.

JSON and logging

local json = require "effortless.json"
local log = require "effortless.log"
json.encode(t)
local value, err = json.decode(s)   -- malformed JSON: nil and the parser's message; "null": nil, nil
log.info("import finished")         -- to stderr, so `effortless request --json` stays valid JSON

An empty Lua table is a JSON object, not an array. required = {} serializes as {}. Where the format needs an array, leave the key out or write required = { "id" }.

8. Recipes (complete minimal file sets)

A CRUD page

-- app/schema/schema.lua
deals = {
  access = { owner = "user_id" },
  id = t.pk,
  user_id = t.ref "users" { required = true },
  title = t.text { required = true, min = 3 },
  completed = t.boolean { default = false },
  created_at = t.now,
},
-- routes/deals/deals.lua
local eql = require "effortless.eql"

function get(request)
  return { deals = eql "select deals order by created_at desc" }
end

actions = {
  add    = eql.mutate "insert deals { title: $form.title }",
  toggle = eql.mutate "update deals set { completed: not .completed } where .id = $form.id",
  delete = eql.mutate "delete deals where .id = $form.id",
}

The route needs a signed-in user, and each query reaches only that user's deals, because of the table's access. The view is drawn with effortless-ui's classes and ui-field (section 16).

<!-- routes/deals/deals.x.html -->
<title>Deals</title>
<div class="max-w-2xl mx-auto py-8 px-4 flex flex-col gap-6">
  <form method="POST" class="flex flex-col gap-3">
    <input type="hidden" name="action" value="add">
    <ui-field label="New deal" error={errors?.title}>
      <input class="input" name="title" value={old?.title}>
    </ui-field>
    <button class="btn">Add</button>
  </form>
  <form method="POST" class="flex justify-between items-center py-2" @each={deals as deal}>
    <input type="hidden" name="id" value={deal.id}>
    <span>{deal.title}</span>
    <span class="flex gap-2">
      <button class="btn btn-outline" name="action" value="toggle">{deal.completed ? "undo" : "done"}</button>
      <button class="btn btn-danger" name="action" value="delete">delete</button>
    </span>
  </form>
  <p class="text-muted" @if={!deals.length}>No deals yet.</p>
</div>

Its test

-- tests/deals_test.lua
test("add a deal, then mark it done", function(t)
  local user = t.user("[email protected]")          -- creates or fetches a test user
  local page = app.get("/deals", { as = user.id })
  -- The page's own form: its hidden action and the Add button.
  t.eq(page:button("Add"):submit({ title = "Big one" }).status, 303)

  page = app.get("/deals", { as = user.id })
  t.eq(page.data.deals[1].title, "Big one")        -- what the view was given
  t.contains(page:text(), "Big one")                -- what the page shows
  t.eq(page:button("done"):submit().status, 303)
  t.eq(eql.one("select deals { completed }").completed, true)
end)

test("validation failure re-renders with the error", function(t)
  local user = t.user("[email protected]")
  local res = app.get("/deals", { as = user.id }):button("Add"):submit({ title = "x" })
  t.eq(res.status, 422)
  t.contains(res:text(), "must be at least")
end)

More routes

A detail page, routes/deals/[id]/[id].lua:

local eql = require "effortless.eql"
function get(request)
  return { deal = eql.find "select deals where .id = $params.id" }   -- 404 when there is none
end

A JSON endpoint is a route with no view file:

-- routes/api/deals/deals.lua: GET /api/deals answers JSON
local eql = require "effortless.eql"
function get(request)
  return { deals = eql "select deals" }
end

A paginated list. eql.page runs the query and counts it:

function get(request)
  return {
    page = eql.page("select expenses order by spent_on desc",
      { page = request.query.page, per = request.query.per }),
  }
end
<p @each={page.rows as expense}>{expense.title}</p>
<nav class="flex gap-2 mt-6">
  <a href="?page={page.page - 1}&per={page.per}" @if={page.page > 1}>Prev</a>
  <span>{page.from}–{page.to} of {page.total}</span>
  <a href="?page={page.page + 1}&per={page.per}" @if={page.page < page.pages}>Next</a>
</nav>
  • The result is { rows, total, page, pages, per, from, to }. from and to are the 1-based rows this page covers.
  • page and per come straight off the query string. per defaults to 20 and is capped at 200.
  • A page past the end is the last page.
  • The query sets no limit or offset of its own.

A sort the user picks. A column name cannot be a parameter. Choose from a list in Lua and add the name at the end, so check still reads the rest of the query:

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 order by ." .. col .. " desc")

A CSV export, routes/deals/export/export.lua:

local eql = require "effortless.eql"
local response = require "effortless.response"
function get(request)
  local rows = eql "select deals"
  return response.csv(rows, { filename = "deals.csv", columns = { "id", "title", "created_at" } })
end

A text cell that starts with =, +, - or @ is written with a leading ', so a spreadsheet opens it as text and not as a formula.

A route for one role: function guard(request) return require("effortless.auth").require("admin") end

Keep each query one whole literal, so check sees every column.

The test API

Each test starts from an empty database with the schema applied, and no cookies.

  • t.user(email, opts?) creates or fetches a user. opts sets columns: { role = "admin" }.
  • Assertions: t.eq (tables compare by content), t.ne, t.truthy, t.falsy, t.contains, t.not_contains, t.fail(msg).
  • eql is a global in tests, and runs unscoped.
  • AI and mail use the log driver and uploads go to a temporary directory, whatever .env says. A driver named in config.lua is kept.

Requests. app.get, app.post, app.put, app.patch and app.delete take a path and options:

Option Is
as a user id; signs the request in
form the form; a list value repeats the field
json, headers, query
http the answers to its outbound calls (below)
  • A form value { filename = "a.png", content = "…", content_type = "image/png" } is a file.
  • The result has status, body, headers, redirect, data (what the view rendered with), and json for a JSON response.
  • Cookies a response sets are sent on the test's later requests, so a login flow needs no headers.

Query the page, not the body. A response, and every element a query returns, takes:

  • find(selector) → an element or nil; find_all(selector); get(selector), which fails the test when nothing matches.
  • text(selector?) → the visible text; texts(selector) → a list.
  • button(name) → by aria-label, text or value.
  • submit(values?) → submits the form the element is in, as a browser would. Call it on the button a user would click.

An element has tag and attrs. Selectors: a tag, *, #id, .class, [attr], [attr=v] (and ~=, ^=, $=, *=), lists, and the descendant and > combinators.

  • submit sends the form's fields as rendered and the clicked button's name and value. values replaces the fields it names; false unchecks.
  • Naming a field the form lacks fails the test.

Runs, their options and their effects

A request, a job, a cron script and a command are each one run. Each takes the same options and returns what it did:

test("a signup is welcomed, and reminded a week later", function(t)
  t.at("2030-01-01T09:00:00Z")
  local r = app.post("/signup", {
    form = { email = "[email protected]" },
    http = { ["POST hooks.example.com/signups"] = { status = 202 } },
  })
  t.eq(r.mails[1].subject, "Welcome")
  t.eq(r.http[1].json, { email = "[email protected]" })
  t.eq(r.jobs[1].name, "remind")

  t.at(r.jobs[1].run_at)                       -- a week on, when the job is due
  local reminded = app.job(r.jobs[1])
  t.eq(reminded.ok, true)
  t.eq(reminded.mails[1].text, "7 days left")
end)
Run Takes Returns beside the effects
app.get/post/…(path, opts) as, http the response
app.job(job, opts) http, args ok, error, result, state
app.cron(name, opts) http ok, error, result
app.cmd(name, opts) as, http, args ok, error, result
  • t.at(time) holds the clock at an instant, "2030-01-01T09:00:00Z", for the rest of the test. A later call moves it; the next test starts on the wall clock.
  • Every time the framework reads is that instant, in a run and in the test's own body: t.now and t.updated columns, session and token expiry, a job's run_at, format.timeago, now() in a query, os.time() and os.date().
  • http answers the run's outbound calls, keyed "METHOD host/path", with no scheme and no query.
  • An answer is a table of status, body or json, headers, or error for a call that fails to connect.
  • A test never reaches the network. A call with no answer fails in the app, and fails the test even when the app carried on.
  • mails: each { to, from, subject, text, html, queued }, a queued one included.
  • jobs: each { id, name, args, run_at } the run enqueued.
  • http: each { method, url, headers, body, json, status }.
  • app.job takes a job from a run's jobs, or a job's name with opts.args. It runs at the test's time: for a job due later, move the clock first with t.at(job.run_at).
  • A run that returns { ok = false, error = … } is a failure.

What fails a run of effortless test

  • A check finding. The tests do not run.
  • No tests.
  • A test that makes no assertion.
  • A page with no URL parameter that answers 500 to a signed-in user.

9. The verification loop — run it, always

You cannot see the browser. These commands are your eyes. Each is one-shot and works on the project in the current directory; effortless -C path/to/app check runs one in another. check, request, test and migrate --dry-run take --json.

effortless check                # static analysis: Lua, schema, EQL, routes, markup, classes; exits 1 on any finding
effortless test                 # the gate: check, tests/*.lua each against a fresh database, then every page
effortless request GET /deals --as [email protected]          # 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                 # the handler's data instead of the rendered HTML
effortless login-link [email protected] /deals                # a URL that signs a browser in as that user (development only)
effortless eval 'return eql.one "select deals"'             # one-shot Lua in the app's context (--as to impersonate)
effortless eql "select deals limit 5"                       # one query, or a REPL with no argument
effortless migrate --dry-run    # preview schema changes
effortless fmt                  # format Lua and templates in place (--check reports instead)
effortless files prune          # stored files no t.file cell names (--delete removes them)
effortless cmd [name]           # list or run app/commands/*.lua (section 11)
effortless cron [name]          # cron scripts and their last run; a name runs that one now
effortless docs eql             # one section of this reference (--list names them all)
effortless docs --search sync_rows  # every paragraph that mentions a term
  1. After every edit, effortless check. Fix what it reports before anything else.
  2. To see what a query or a handler does, effortless eval or effortless request it.
  3. Every feature gets a test with one success and one failure path. effortless test is green before the work is done (section 8 lists what fails it).
  4. After a schema change, effortless migrate --dry-run, and read the plan.

Reading the output:

  • An error carries its file, its line and often a suggestion. Read the message before changing code.
  • A 403 on a POST means the request came from another origin.
  • A setup error ends the command in one line: config.lua: server.port must be an integer. With --json the same text is in {"error": …}.
  • A backtrace means a bug in the framework, not in the project.
  • check reads each view against what its get returns: a name no return binds, and a column its query does not select, are findings (deals has no column 'titel' (did you mean 'title'?)). A handler that returns something built in app/lib/ is not read this way.

A page in a browser. With effortless serve running, open the URL effortless login-link <email> <path> prints. It signs in once, within ten minutes. The URL is on the port serve took, when that is not 3000.

Formatting. effortless fmt formats Lua with StyLua when stylua is on PATH (brew install stylua), then the spacing of EQL inside query strings, then .x.html: each line's indentation, and each start tag.

<button type="button" class="btn" @click={save}>Save</button>

<input
  class="input"
  name="title"
  value={old?.title ?? deal.title}
  required
>
  • A start tag is one line when its line then ends inside the width. Otherwise each attribute has a line and the > one of its own, with what followed the tag after it. A tag written over several lines that fits is joined.

  • The width is column_width in the project's stylua.toml, so Lua and markup share it; 120 without the file.

  • A tag stays one line when laying it out would not bring its line inside the width: text after it that is longer than a line stays long. Text and end tags are never moved, since a line break beside text is a space on the page.

  • An attribute is written as it stands. One value longer than the width stays on its line, and a value written over several lines keeps their shape.

  • Without stylua, Lua is only re-indented, and check says so.

  • In EQL it moves whitespace and lower-cases keywords. A query that does not parse, or holds a comment, is left as written.

  • <script>, <style>, <pre> and <textarea> bodies and comments are left as they are.

10. Styling — Tailwind

Classes are Tailwind CSS 4 utilities: p-4, flex items-center justify-between, hover:bg-brand, md:p-8, p-[13px]. The binary compiles the classes the project writes into one stylesheet, served at /_tailwind.css, which the layout links. There is no Node, build step or CDN.

  • The sheet holds the classes written in class attributes under routes/ and app/components/: the attribute's words, the string literals its expressions can return ({open ? 'block' : 'hidden'}), and the name in class:name={…}.
  • A class name that reaches the page any other way is not in the sheet and styles nothing: one built from data (class="text-{tone}", 'btn-' + kind), returned by a script's function, or sent from Lua. Write each whole name in a class attribute: {tone == 'red' ? 'text-red-600' : 'text-ink'}.
  • The sheet opens with Tailwind's base styles (preflight).
  • Development rebuilds it when a template or the theme changes. Production builds it once.
  • Not built: @apply, @utility, plugins, a prefix, and theme() in a value. Write the variable: w-[calc(100%-var(--spacing)*4)].

Tokens. app/theme.css holds the design tokens, in @theme { … } blocks over Tailwind's defaults. Without the file the theme is Tailwind's default.

  • --color-brand: #2563eb; makes bg-brand, text-brand and border-brand. --font-*, --text-*, --spacing-*, --radius-*, --shadow-*, --container-* and --breakpoint-* work the same way, and a token with a default's name replaces it.
  • The file takes @theme blocks, @keyframes, @custom-variant dark (…) and the line @import "effortless-ui";. Any other CSS in it is an error. The project's own CSS is a file under public/ that the layout links, reading a token as var(--color-brand). Link it after /_tailwind.css and write its rules inside @layer components { … }: they then apply over the base styles and give way to a utility on the same element (class="btn px-8").
  • A token is written to the sheet only when a class reads it. @theme static { … } always writes its tokens, for CSS under public/ to read.
  • The scaffold's colours are light-dark(light, dark) and its <html> has scheme-light-dark, so each follows the visitor's system setting. data-theme="light" or "dark" on <html> forces one. Change a value in the file and every page follows.
  • dark: follows the system setting too. @custom-variant dark (&:where(.dark, .dark *)); in the file switches it to a class.

Parts. Pages are drawn with effortless-ui (section 16), which the binary holds and @import "effortless-ui"; at the start of app/theme.css turns on:

  • One element takes a class: <button class="btn btn-outline">, <input class="input">, <div class="card">, <div class="alert alert-danger" role="alert">. effortless docs classes lists them.
  • Several elements wired together are a component, used as written with no file in the project: <ui-field label="Email" error={errors?.email}> around a control, <ui-combobox>, <ui-datepicker>, <ui-toaster>. effortless eject lists them.
  • A token in app/theme.css replaces the library's: --color-brand, --radius-field, --font-sans.
  • effortless eject ui-combobox copies a component's source into app/components to change its markup or script. The copy is the project's own.
  • A part effortless-ui does not have is a file of the app's in app/components.

What check reports. It reads every class in routes/ and app/components/.

  • A class written with a variant or a bracket that Tailwind builds no CSS for: md:flx, hover:bg-brnd, w-[10 px], and a mingled class such as p:lg.
  • A plain word that is neither a utility nor a class of the project's own CSS: inline-flx, btn-dangr. A class is the project's own when a selector names it (.btn), in a CSS file under public/ or in a <style> of a template. group, peer, their named forms (group/row) and the class of a @custom-variant dark are known.
  • A class that only a script looks for has no selector and is reported. Mark the element with a data- attribute.
  • CSS in app/theme.css that is not a theme block, with its line.
  • A class built from data is not read.

Client components — Inlay SFCs

A component in app/components/ with a top-level <script> is an island: it renders on the server and runs in the browser. Inlay is modelled on Svelte 4: one file per component, let for state, export let for props.

<script>
  let count = 0
  function inc() { count = count + 1 }
</script>

<button @click={inc}>Clicked {count}</button>

Two things differ from Svelte:

  • Control flow and events are attributes on the element: @if={…}, @each={…}, @click={…}.
  • There is no $:. A derived value is a function, and an effect is inlay.effect(() => …).
Svelte Inlay
let count = 0, export let name, export let size = 'md' same
{expr}, title={expr}, title="Hi {name}" same
class:active={on}, style:width="{w}px", bind:value={q}, bind:checked={on}, bind:this={el} same
<combo-box bind:value={brand_id} /> same: the child assigns its value prop and brand_id follows
onMount(() => …) function onMount() { … } in <script>
{#if a}<p>…</p>{:else}<p>…</p>{/if} <p @if={a}>…</p><p @else>…</p>
{#each items as item, i (item.id)}<li>…</li>{/each} <li @each={items as item, i (item.id)}>…</li> (the key is optional)
on:click={save}, on:click|preventDefault|once={save} @click={save}, @click.prevent.once={save}; modifiers prevent, stop, self, once, capture, passive
<svelte:window on:keydown={f} />, <svelte:document on:…> @keydown.window={f}, @visibilitychange.document={f} on any element
use:tooltip={text} same: tooltip(el, text) runs when the element is in the document, and the function it returns runs when the element goes
$: total = a + b function total() { return a + b }, used as {total()}
<Badge name={x} on:pick={select} /> <user-badge name={x} onPick={select} />: the tag is the file name, a callback is a prop
<Button on:click={save}>, with on:click forwarded inside Button <ui-copy @click={save}>: an event on a component's tag listens on the component's root element
<Card><p>{count}</p></Card>, <slot />, <slot>None</slot> same: <ui-field label="Count"><p>{count}</p></ui-field>. One slot, no names
import { format, parse as parseDate } from './dates.js' same, from a .js file in the project
none @show={expr}: display: none without removing the element
none @input.debounce.300ms={search}: the handler runs 300 ms after the last input
none @keydown.enter.prevent={save}, @keydown.up.down={move(event)}: heard for those keys only, and not while an input method composes a character. Keys: enter, escape, space, tab, backspace, delete, up, down, left, right, home, end, pageup, pagedown
none inlay.changed(field): sends input and change from a field a script wrote, once the page shows its new value. A hidden input an island keeps its value in tells the form this way

Rules:

  • A component has one root element. No wrapper is added, so a component can be a <tr>.
  • A tag with a dash names a component: <post-card> is app/components/post-card.x.html. A dashed tag with no file is an error.
  • Every attribute on a component tag is a prop. Style or show it from an element around it.
  • In an island, an event on a component's tag is not a prop: <ui-copy @click={save}> listens on the component's root element, with the same modifiers as on an element. What the component itself reports is a callback prop: onPick={select}.
  • An island's props are its tag's attributes, evaluated on the server. An island reads no request names, so pass what it needs: user={user}.
  • The server writes an attribute in an island's markup when its value reads only props and its element is outside every @if and @each. <input type="hidden" name={name} value={value}> is in the page with both, so a form posts it before the script has run, and a test's submit can fill it. Text, classes, and an attribute that reads state or calls a function are written when the island mounts, and the island is hidden until then.
  • A route view has no <script>. A page that is all interaction is a view rendering one island: <title>Team</title><team-picker members={members} />.
  • A component without a script renders on the server, and compiles into an island that uses it.
  • Markup expressions are JavaScript without arrow functions, ++, or object and array literals. Declare those in <script>.
  • An effect that needs a bind:this element runs in onMount.
  • A dynamic style="width: {w}px" rewrites the whole attribute, the display that @show sets included. Bind one property with style:.
  • A name an island's markup reads is declared in its <script>, imported, or a browser global. check names any other.
  • An island cannot use {@html}. A route view cannot use events, bind: or use:.
  • In an island, markup inside a component's tag goes to the component's <slot /> and reads the island's state. A <slot> shows its own markup for a tag with nothing inside.
  • A route view can write markup inside the tag of an island that has a <slot />: <ui-dialog title="Edit"><form method="post">…</form></ui-dialog>. The server renders it in the page's scope, and the island places it when it mounts.
  • Markup inside an island's tag is not on the page until the island's script has run. Keep what must be read without it outside the island.
  • In an island, <slot /> cannot be a component's root or carry @each.
  • What a <slot /> holds is built when the component mounts, and again each time an @if around the slot turns on. An input there keeps its text only through bind:value, and an island there starts again.

Shared code is a .js module that components import, not a global.

  • A module exports with export function, export class or export const x =.
  • import x from, import * as, export default, export { … }, re-exports and package names are check errors.
  • Islands on one page share state through a module that exports a reactive object: export const cart = inlay.reactive({ items: [] }).
  • The page gets each module once. An import cycle is an error.

Writes from an island. inlay.post(url, data) sends data as a form does, a File as an upload, and asks for JSON. The handler reads request.form.

  • It resolves to { ok, status, data, errors, error }. errors is a 422's field errors.
  • A { redirect } answer navigates to it.
  • inlay.post(url, data, { signal }) stops when the signal aborts and resolves with aborted: true.
  • inlay.submit(form, extra, { signal }) posts a form's fields with extra added.

Navigation. Pages navigate without reloading: /_inlay.js takes a click on a same-origin link and a form's submit, fetches the page, and replaces <body>.

  • Islands on the old page stop. A module runs once per tab, so its reactive state carries across pages.
  • An element with an id keeps its scroll position and focus when the next page has the same id.
  • A link or form inside data-keep-scroll keeps the window's scroll position, as a filter sidebar wants.
  • A link or form inside data-reload loads in full. So does a response that is not HTML, and a page whose <head> loads a script or stylesheet the current page has not.
  • A <script> in a page body runs on every visit to that page. One that opens a connection or starts a timer closes it on document's inlay:leave event.
  • inlay.visit(url) navigates from a script.
  • Once a navigation's response is on the page, document gets inlay:load with event.detail { url, method, status }: what was asked, and the status of the page shown. A write that worked is a POST with a status under 400. A request that got no answer, and a page loaded in full, send none.
  • In production a server reads each file once, so a deploy restarts it.

Parent and child:

<!-- app/components/user-badge.x.html -->
<script>
  export let name
  export let onPick

  function label() { return name.toUpperCase() }
</script>

<span class="px-2 py-1 rounded-lg bg-page cursor-pointer" @click={onPick(name)}>{name}</span>
<!-- app/components/team-picker.x.html; a view renders it as <team-picker /> -->
<script>
  let members = ["Ada", "Grace", "Linus"]
  let selected = ""
  let first = null

  function select(name) { selected = name }
</script>

<div class="flex gap-2">
  <user-badge bind:this={first} name={members[0]} onPick={select} />
  <div @each={members.slice(1) as m (m)}>
    <user-badge name={m} onPick={select} />
  </div>
  <p class:c:muted={!selected}>Selected: {selected || "nobody"}</p>
</div>

Habits from other frameworks:

Avoid Inlay equivalent
{{ x }} / {% if %} / {{ partial "card" }} / {{ layout "base" }} {x} / @if={…} / <post-card /> / routes/_layout.x.html with <slot /> (section 4)
scope( / <component / use= / inherit= <script> with let and export let, then markup
{#if} / {#each} blocks @if={…} / @each={items as item (key)} on the element
on:click / onclick={…} @click={…}
:title="…" / v-bind title={…}
@if="…" (quoted) @if={…}
:key the key goes in the each: @each={items as item (item.id)}
ref="x" / refs.x bind:this={x} with let x = null
@model / v-model bind:value={…} (bind:checked for a checkbox)
and / or / not in markup && / || / !
$emit( / $store. / setContext( a callback prop, or a shared object passed down as a prop
@effect / @modal / @popover inlay.effect(…) in <script>; native .showModal() and .showPopover(); a menu is <div id="m" popover> opened by <button popovertarget="m">
<template> around the markup nothing: markup follows </script>
this. in <script> bare names
x-data / v-if let in <script>, @if={…}

11. Domain logic, commands, and cron

Modules

Shared logic goes in plain app/lib/ modules. A module is loaded when something requires it; nothing is autoloaded.

-- app/lib/deals.lua
local eql = require "effortless.eql"
local M = {}

function M.close(id)
  return (eql "update deals set { stage: 'won' } where .id = $id")
end

return M
  • require "deals" is app/lib/deals.lua, and require "billing.invoices" is app/lib/billing/invoices.lua.
  • Do not name a module after a Lua standard library (string, table, math) or an effortless.* module.
  • A module requires what it uses, eql included, and defines no global.

Commands

A command is an app/commands/ file that defines run(args).

-- app/commands/close_deal.lua
help = "Close a deal"
function run(args)
  return { ok = true, deal = require("deals").close(args[1]) }
end
  • effortless cmd lists them. effortless cmd close_deal -- 42 passes arguments.
  • --json prints one JSON document. --as <id or email> runs it as that user.
  • A run that returns { ok = false, error = … } fails the command.

Jobs

A job is an app/jobs/ file with a run(args), for work that must not hold up a response.

-- app/jobs/import_product.lua
help = "Fetch a manufacturer page and fill the product"
retries = 3

function run(args)
  return require("importer").fill(args.product_id, args.url)
end
local jobs = require "effortless.jobs"
jobs.enqueue("import_product", { product_id = id, url = url })
jobs.enqueue("remind", { id = id }, { at = "2030-01-08T09:00:00Z" })   -- not before then
  • jobs.enqueue is one insert on the caller's connection. Inside eql.transaction it commits or rolls back with everything else.
  • serve runs the queue. Each job runs in its own process, JOBS_CONCURRENCY at once, so a slow job cannot block HTTP.
  • retries = 3 gives three more attempts, after 1, 5 and 25 minutes. A job out of attempts is failed, with its error.
  • A superadmin retries a failed job by setting its state back to queued in the admin.
  • CRON_TIMEOUT_SECONDS is the longest one run may take. Keep jobs short and resumable.
  • effortless jobs prints the counts, and effortless jobs --drain runs what is due in the foreground.
  • The queue is the framework's _jobs table: effortless eql 'select _jobs where .state = "failed"'.
  • In a test, app.job(job) runs one (section 8).
  • The scaffold ships send_mail, delete_upload, and a cron script that deletes finished job rows after a week.

Cron

A cron script is an app/cron/ file with a schedule and a run().

-- app/cron/nightly_cleanup.lua
schedule = "0 3 * * *"   -- 03:00 UTC; also: hourly, daily, every 15m, every 2h
help = "Remove expired imports"
function run()
  return require("cleanup").expired_imports()
end
  • Schedules are in UTC.
  • A due script runs as a job, in its own process. A run that raises, or returns { ok = false, error = … }, is failed. The next window is the retry.
  • effortless cron lists the scripts with their last run's state. effortless cron <name> runs one now.
  • CRON_ENABLED=false stops cron and the job queue. Queued work waits until a serve with it on, or effortless jobs --drain.
  • A Crystal app registers one with Effortless::Cron.register(name, schedule, help) { … }.

12. Deploy and production data

Deploy

Deploy is one binary and one volume. The scaffold's Dockerfile:

  • installs the release the project was made with (EFFORTLESS_VERSION; raise it after effortless update);
  • installs tzdata, the zone database app.time_zone and a zone passed to formatDate are read from;
  • runs effortless test, so an image is not built from a project that fails the gate;
  • sets EFFORTLESS_ENV=production, DATABASE_PATH=/data/app.db and UPLOAD_ROOT=/data/uploads;
  • runs effortless migrate, then serve.

On a container host:

  • Mount one volume at /data. The database and the uploads both live on it, and neither may be inside public/.
  • Set BASE_URL to the public https:// address. Reset and invitation links are built on it.
  • Set SUPERADMINS.
  • Leave HOST unset and let the host inject PORT. Outside development HOST defaults to 0.0.0.0.
  • The image is linux/amd64.
  • .dockerignore keeps data/, uploads/ and .env out of the image.

Workers. Outside development serve starts WORKERS processes that serve HTTP, and the serve process itself applies the schema and runs cron and the job queue.

  • Set WORKERS to the CPU count the host gives the container. WORKERS=1 serves from the one process.
  • A request stuck in a long query holds up only its own worker.
  • A worker that exits stops the whole server, and the host restarts it.
  • Scale with WORKERS, not a second instance: the database is a file on one volume.

Development and production. EFFORTLESS_ENV=production turns off reloads, error pages with source, and the development sign-in link.

  • In development serve listens only on a loopback address.
  • In production a changed file needs a restart.

Durability. A commit survives a crash of the process. A power loss or an OS crash can drop the last commits.

  • DATABASE_SYNCHRONOUS=full syncs every commit to disk, at the cost of a flush per write. Weigh it for a webhook answered 200 that its sender will not retry.
  • Local uploads on a volume survive a redeploy but not the host. R2_BUCKET and its keys move them to Cloudflare R2, on a public custom domain.
  • R2_KEY_PREFIX is fixed for the life of the deployment.

Production data locally

effortless pull copies a scrubbed snapshot of production into the local database. It is one-way.

  1. A collaborator runs effortless pull key. It writes EFFORTLESS_PULL_KEY to their .env and prints a public hash.
  2. The admin puts that hash in EFFORTLESS_SNAPSHOT_KEY on the server, comma-separated for several people.
  3. With PULL_URL pointing at production, effortless pull downloads the snapshot, verifies it, backs up the local database to data/backups/, and swaps it in.

What a snapshot leaves out:

  • Every t.secret column is replaced with a random value per row, from the schema, so no app ships credentials by forgetting a line.
  • The job queue, the realtime outbox and the sign-in counts are emptied.
  • app/schema/scrub.sql then runs, for what only the app knows is sensitive: UPDATE users SET email = 'user_' || id || '@example.test';.
  • With EFFORTLESS_SNAPSHOT_KEY set, the server refuses to boot without a scrub file.

effortless snapshot [dest] takes a local copy with the secrets cleared the same way. It is not a backup to restore sign-ins from; copy the database file for that.

Errors

Every unhandled 5xx writes one line to stderr: GET /reset/:token 500 ArgumentError: bad row at src/accounts.cr:41.

  • The line has the method, the route's pattern, the status, the exception and the first frame in the app's code.
  • The route is its pattern, never the raw path, since a path can carry a token.
  • A failed job or cron run writes the same shape: job send_mail Lua::Error: … at app/jobs/send_mail.lua:12.
  • A 4xx writes nothing.

SENTRY_DSN also sends each one to Sentry: a failed request, a failed job or cron run, and an island's error in the browser.

  • SENTRY_ENVIRONMENT defaults to EFFORTLESS_ENV. Set SENTRY_RELEASE to the deploy's commit.
  • The same error is sent once per SENTRY_THROTTLE seconds. Every occurrence still writes its stderr line.
  • An event holds the route's pattern, the method, the User-Agent and the user's id. It holds no query string, body, cookies, IP address or email.
  • Messages are sent as they are. Keep secrets out of them; a t.secret value is replaced with [secret].
  • An event carries the last 100 breadcrumbs: each SQL statement's text without its values, each outbound call's method, host and status, and each log.* line.
  • An island's error is reported from the browser, five a page at most.
  • A binary built with --no-debug gives frames with no file or line.

A Crystal app adds tags, and reports what the framework does not see:

# Tags any event this request captures; gone when the request ends.
Effortless::Errors.tag(env, "site", site.slug)
# An error in a fiber the app spawned.
spawn do
  send_mail(message)
rescue ex
  Effortless::Errors.report(ex, {"name" => "mail"})
end
  • Effortless::Errors.breadcrumb(category, message, level: "info", data: {"key" => "value"}) adds a breadcrumb.
  • Effortless::Errors.browser, a proc that gets the request, decides whether browser reports are taken. It answers true by default.
  • Effortless::Auth.on_password_change { |user_id| … } runs when a password changes, inside the transaction that writes it, for an app that keeps credentials of its own.

13. Advanced: mail, AI, realtime

Mail

local mail = require "effortless.mail"
local ok, err = mail.send { to = email, subject = "Welcome", text = "Your account is ready." }
if not ok then log.info("welcome email failed: " .. err) end

mail.send { to, subject, text, html?, reply_to?, template?, data?, queue? } returns true, or nil, err. It never turns a request into a 500.

  • Several recipients are several messages, one per address, up to 100. Nobody sees the list.
  • text and html are both sent when both are given. reply_to is one address. There are no attachments.
  • Send from an action after the state change succeeds, or from a job. Never from a get.

Drivers:

  • log, the default, writes one line to stderr and delivers nothing.
  • RESEND_API_KEY selects Resend. MAIL_FROM is on a domain Resend has verified.
  • CLOUDFLARE_EMAIL_TOKEN with CLOUDFLARE_ACCOUNT_ID selects Cloudflare Email Service when there is no Resend key.
  • MAIL_CATCH_ALL sends every message to that one address, with the real recipients in the subject: [[email protected]] Welcome. Use it in development and staging that hold real credentials.

Queued mail. MAIL_QUEUE=true delivers through the send_mail job and not inside the request.

  • mail.send still validates the message and renders the template, so its errors come back from the call.
  • Inside eql.transaction the mail commits or rolls back with everything else.
  • queue = true or queue = false in a call decides one message.
  • Queueing needs app/jobs/send_mail.lua, which the scaffold ships.
  • Queue a template with mail.send { …, queue = true }, so the request renders it.

Templates. mail.send { to = email, template = "invite", data = { name = name, link = link } } renders app/mail/invite.x.html over data, inside each _layout.x.html from its folder up to app/mail/.

<!-- app/mail/invite.x.html -->
<title>You have been invited</title>
<p style="margin:0 0 16px" @if={name}>Hi {name},</p>
<a href={link} style="display:inline-block;padding:12px 20px;border-radius:6px;background:#2563eb;color:#ffffff;text-decoration:none">Set your password</a>
  • Its <title> is the subject unless subject is passed. base_url is in scope.
  • Mail clients drop linked stylesheets, so a class styles nothing. Style each element with style="…".
  • Outlook ignores display:flex, display:grid and max-width. The width and any columns go in the layout's <table>.
  • Under app/mail/, check reports a class, a <script>, an island, a linked stylesheet, and flex or grid.
  • A <style> block is allowed, for a media query.
  • In development /_mail lists the templates, and /_mail/invite renders one with the table app/mail/invite.lua returns.
  • The scaffold ships app/mail/_layout.x.html, reset.x.html and invite.x.html.

AI

local ai = require "effortless.ai"
local reply, err = ai.chat { system = "You write concise follow-ups.", prompt = request.form.notes, temperature = 0.2 }

local res, err = ai.chat {
  system = "Extract CRM fields.", prompt = request.form.notes, json = true, schema_name = "crm_fields",
  response_schema = { type = "object", properties = { title = { type = "string" }, stage = { type = "string", enum = { "lead", "won", "lost" } } },
                      required = { "title", "stage" }, additionalProperties = false },
}
local fields = res and require("effortless.json").decode(res.text)

AI_API_KEY and AI_MODEL are all it takes. The default host is the Vercel AI Gateway, so the model is provider/model (anthropic/claude-sonnet-4.5).

  • AI_BASE_URL points at another OpenAI-compatible server, such as https://api.openai.com/v1 or a local Ollama.
  • AI_DRIVER=log keeps it local and deterministic. Tests use it.
  • json = true asks for a JSON reply. response_schema, named by schema_name, sends a JSON Schema as strict structured output.
  • tools and tool_choice send function definitions. res.tool_calls comes back; run them in Lua, append res.message and the role = "tool" results, and call again.
  • on_delta = function(text) … end streams the reply piece by piece. The return value is unchanged.
  • extra_body = { … } adds fields to the request body, merged over ai.extra_body from the configuration. It may not set a field ai.chat writes itself, such as model or messages.
  • ai.headers in config.lua is a table of headers sent on every model request; AI_HEADERS takes name: value pairs separated by commas. AI_EXTRA_BODY takes a JSON object.
  • cancel_key = key on a call lets another request stop it with ai.cancel(key). The call then returns nil, "cancelled". Keys are app-wide, so put the user's id in one a user can cancel.

Where to call a model:

  • In an action, a command or a job. Never in a frequently hit get without storing the result.
  • Distrust identifiers a model returns. Check them against your own database before writing.
  • There is no agent base class and no conversation ORM. The loop is application Lua.

Realtime

Realtime is one SSE connection per open page. The route that opens it is the authorization.

-- routes/rooms/[id]/stream/stream.lua
local eql = require "effortless.eql"
local realtime = require "effortless.realtime"

function get(request)
  local room = eql.find "select rooms where .id = $params.id"
  local stream = realtime.stream {
    "select messages { id, body, .users.name } where .room_id = $params.id",
    channels = { "room:" .. room.id },   -- named channels: typing, AI tokens
  }
  return stream
end
<section data-room={room.id}>
  <script>
    const room = document.currentScript.parentElement.dataset.room;
    const stream = new EventSource("/rooms/" + room + "/stream");
    stream.addEventListener("messages.insert", (e) => append(JSON.parse(e.data)));
    stream.addEventListener("room:" + room + ".token", (e) => type(JSON.parse(e.data).text));
    document.addEventListener("inlay:leave", () => stream.close(), { once: true });
  </script>
</section>
  • A table opts in with realtime = true, beside indexes.
  • Every EQL insert, update and delete on it is sent to each subscriber whose select would have returned the row, shaped as that select shapes it.
  • Events are named <table>.insert, <table>.update and <table>.delete.
  • A select runs as the user who opened the stream, so it delivers only rows that user reads. realtime.stream(list, { unscoped = true }) follows every user's.
  • A delete is sent as { id } alone. On a table with an owner it goes only to that owner's subscriptions. Where an id is itself a secret, soft-delete with a flag.
  • A select's $params are read when the route returns the stream.
  • return realtime.stream { … } is a tail call and loses the route's locals. Assign the stream to a local and return that, or use $params and $auth.
  • The list holds selects, and channels the channel names. A name taken from the request is only ever a channel.
  • A stream may not select a t.secret column.
  • The stream ends once its session is gone or the user's role or owner column has changed. The browser's reconnect runs the route again.
  • EventSource reconnects with Last-Event-ID, and events are replayed for ten minutes.
  • realtime.publish("room:" .. id, "token", { text = chunk }) sends on a named channel. With ai.chat { on_delta = … } it streams tokens to the browser.
  • Browser to server is a POST like any other. The write it makes is the event.

14. Best practices

Shape

  • One route directory per page or endpoint. A handler is a few lines that call app/lib/ for anything with a second use.
  • A route-local _module.lua is for code one route subtree owns. app/lib/ is for the app.
  • Prefer a one-line eql.mutate action. Write a custom action only for the result, a redirect elsewhere, or a message in errors.
  • Tables are plural snake_case, and references end in _id.
  • A join table is parent_child with a composite primary and no id, written with eql.sync_ids.
  • Put rules in the schema (required, min, email, one_of, within) and let writes raise. Do not check again in a handler, and do not pcall a write unless you turn the error into something else.

Data

  • Give every table of user data access = { owner = "…" }. Say who else reads and writes with read and write. Keep "shared" for tables any visitor may change.
  • A guard is not a filter: it decides who reaches the route, not which rows a query returns.
  • An id stored in a t.json column is not a reference. Nothing checks it, so check it against the owning row in the one function that writes the column. If the ids need guarantees, make them rows with t.ref.
  • Derive in the right place. A display value made in Lua (initials) is added to the row after the query. A value that is data (day, size, thumb) is a computed column. A value that varies by request is a handler variable.
  • Index what you filter and sort on, such as created_at.
  • hidden for what forms never see, protected for what forms may not set but the app may (status), private for what only the roles that write the table read (email, cost), t.secret for what never leaves the server.
  • Name the fields you need on a list page.
  • Read one related value with a path (.brands.name), several with a nested block, and a has-many with first, count or a nested list.
  • The current time in a query is now().

Slow work

  • A mail send, an outbound fetch, a model call and an import go in a job: jobs.enqueue, and answer the request.
  • Never in a get.

Lua

  • Lua is 5.5. A for loop's variables are read-only: copy one before changing it (local k = key:lower()).
  • Write 5.4 syntax. effortless fmt fails on a file with a global declaration.

Writing and testing

  • One test file per feature, with one success and one failure path.
  • Submit forms through the page (page:button("Save"):submit(values)), and assert on text, data and the rows written, not on body.
  • effortless request --data before writing the view; then the view; then the test.
  • Read the error before changing code. It names the line and often the fix.

15. Hard rules

What the framework cannot check for you:

  1. Never build a query by joining request input into its text. Values are $params; a column a user picks comes from a list you wrote.
  2. Schema changes go in app/schema/schema.lua only.
  3. Request input never reaches a file path or a proc.run argument unchecked. Check a name against a list you wrote first.
  4. Pick CSS classes from values the app wrote, not from user text.
  5. Logs go to stderr. --json stdout is exactly one JSON document.
  6. Commit messages are one line, in the imperative, saying what changed: no body, no attribution, no trailers.

What it refuses, so you need not remember:

  • A table reached in a request with no access, and a route reached signed out without public = true.
  • A response that holds a t.secret value.
  • {@html} of a string the app did not make.
  • A global that is not declared, and a field request or a framework module does not have.
  • A database or an upload folder inside public/, and a database inside the upload folder.
  • A test with no assertion.

What app Lua can reach

App Lua runs without io, debug, dofile, loadfile, package and string.dump. Of os it has os.date, os.time, os.clock, os.difftime and os.getenv. Naming anything else raises, saying what to use.

  • load takes source text only. No C module loads.
  • A global the framework provides is not assigned: _G.print = … raises. getmetatable(_G) is false.
  • The machine is reached through the framework's modules, each declared in config.lua:
To Use Declared by
run a program proc.run proc = { commands = { "ffmpeg" } }
read a file files.read(path), files.import(path), outside a request files = { paths = { "seed" } }
open a second database db.open(path), in the app database's folder, a link followed
call a private host http.get, http.post http = { allow_hosts = { … } }
store an upload files.save, a t.file column
  • These stop a careless line. The code still runs as the user the server runs as.
  • proc.run starts the program without a shell, but an argument that is a path or begins with - is still the caller's to choose.

16. effortless-ui

The classes, components and icons /_admin is drawn with, held in the binary. One line at the start of app/theme.css turns them on for the project, and effortless new writes it:

@import "effortless-ui";
  • A ui- tag with no file in the project is the library's component. Nothing is copied: <ui-combobox> works as written.
  • The library's tokens are under the ones app/theme.css writes, so the file holds only what the app changes.
  • Its classes (btn, input, card, …) end /_tailwind.css. The layout links nothing more.
  • Its icons answer <icon name="…"> when app/icons has no file of that name.
  • Without the line none of this is there, and check reports a ui- tag or a class of the library's as missing.

A tag is looked up in the project first (section 4), so a file of the project's replaces the library's. effortless eject ui-combobox writes that file: the component's source in app/components, to change its markup or its script. It is then the project's own and no release changes it. What it uses still comes from the binary. effortless eject lists the components and marks the ejected ones. A module the components import is ejected by its file name (effortless eject toast.js); the library's own components then run the project's copy too.

A release of the binary can change how a component of the library looks or what it writes. The changelog says so, and a test that reads the markup shows it.

  • A class goes on one native element (btn, input, card): Classes, below.
  • A component with a <script> is an island and runs in the browser. One without is written by the server.
  • A control is written inside ui-field, which gives it its label, hint and error.
  • An island's value posts from a hidden input the server writes, so a test fills it by name: page:button("Save"):submit({ starts_on = "2026-10-14" }).
  • A popup, the list of ui-combobox and the panels of ui-datepicker and ui-timepicker are popovers placed by CSS anchor positioning: Chrome 133, Safari 26, Firefox 147. In a browser that does not know position-area /_inlay.js places them: against the same control, on the other side where there is no room, as wide as the control where the CSS says so, and again when the page scrolls or the window resizes. It needs the popover attribute itself: Chrome 114, Safari 17, Firefox 125. Chrome 129 to 132 know position-area and do not take a popup's button for its anchor: a popup opens where it stands in the page there.
  • The look is shadcn/ui's default theme, and the icons are Lucide's.

Classes

The look of one element is a class on the native element. The classes are the library's ui.css, which ends /_tailwind.css:

<button class="btn btn-outline">Cancel</button>
<a class="btn" href="/new">New</a>
<input class="input" name="email" type="email">

The element keeps every attribute it has: type, disabled, form, popovertarget, aria-*. The sizes, colours and states follow shadcn/ui's components as its site rendered them on 2026-10-11.

ui.css holds the classes that are used without a component. A component's own rules are in a <style> at the end of its file; a page gets them once, with the component's first use.

Changing them

From the widest change to the narrowest:

  • The whole app's colours, corners or control height: a token in app/theme.css.
  • How every button, input or card looks: a rule for its class in the project's own CSS, a file under public/ linked after /_tailwind.css, inside @layer components { … }. It is later in the library's layer, so it wins.
  • A new kind of something: one more class there (.btn-warning), setting the family's variables.
  • One element's size or place on a page: a utility beside the class (class="btn w-full"). A utility wins over a rule of a class.
  • Something inside a component (.combobox-box, a day of the calendar): a rule outside any layer, since the component's own <style> comes later in the page than a linked file. To change its markup or script, eject it.

A class list repeated on several elements is a class in the project's CSS. A colour, radius or size is a token in app/theme.css. effortless check reports a class in markup that neither Tailwind nor a selector in the project's CSS names.

Tokens

Every value the rules read is a token, so a theme is a few lines in app/theme.css, each replacing the library's. A class reads a token by name (bg-brand, rounded-field, shadow-ring) and CSS as var(--color-brand).

To change Token
The page and a raised surface --color-page, --color-surface
Text, and text that matters less --color-ink, --color-muted
A quiet fill, a border, a control's border and its fill --color-soft, --color-line, --color-input, --color-field
The main action and what is written on it --color-brand, --color-on-brand
Danger: its colour, its fill, an invalid control's border --color-danger, --color-danger-soft, --color-danger-line
Success --color-success
The focus ring: its colour, the ring, the red ring --color-ring, --shadow-ring, --shadow-ring-danger
The edge and the shadow of what floats --shadow-edge, --shadow-popup, --shadow-raised, --color-backdrop
Corners: a control, a box, what is inside a control, a chip --radius-field, --radius-box, --radius-inner, --radius-small
A control's height --spacing-field
How fast a control changes colour --motion
The typeface --font-sans

A colour is light-dark(light, dark). --radius-inner and --radius-small are parts of --radius-field, so one edit rounds or squares everything.

btn

On <button> or <a>.

Kinds btn-outline, btn-secondary, btn-ghost, btn-danger, btn-link
Sizes btn-xs, btn-sm, btn-lg
btn-icon makes any size square

A kind sets --btn-bg, --btn-fg and --btn-hover. An <icon> inside takes the size's icon size.

input

On <input>, <select> and <textarea>. It turns red with aria-invalid="true", or inside a ui-field that has an error. On a file input the browser's button reads as text. ui-select draws a select's arrow.

checkbox, radio, switch

  • checkbox on <input type="checkbox">.
  • radio on <input type="radio">.
  • switch on <input type="checkbox" role="switch">: a checkbox that reads as on and off. It posts as a checkbox does, its value while on and nothing while off. switch-sm is the small one.

label

The name of a control, where ui-field does not write it.

popup is what opens over the page from a control, on an element with the popover attribute. A button opens it with popovertarget, and the browser places it against that button with no name between them: under it from its left edge, and over it or from its right edge where there is no room. popup-end starts from the right edge.

Where a popup goes is three custom properties, which the declarations of anchor positioning are written from. A browser that does not know those declarations drops them, and /_inlay.js reads the properties instead, so a place is always set through them:

Property Is A popup has
--popup-area position-area block-end span-inline-end
--popup-anchor position-anchor, for an anchor that is not the button auto
--popup-width width; anchor-size(width) is as wide as the anchor auto
<label class="search" data-anchor="--search"><input class="input" name="q"></label>
<div class="popup search-results" popover="manual">…</div>
.search { anchor-name: --search; }
.search-results { --popup-anchor: --search; --popup-area: block-end; --popup-width: anchor-size(width); }
  • An anchor that is not the button has anchor-name in CSS and the same name in data-anchor, where the script finds it. The name is looked for from the popup outwards, so two of a component on a page each find their own; anchor-scope on the component does the same for the browser.
  • The script does block-end, block-start, inline-end and inline-start, alone (centred on the anchor) or with a span- of the other axis. A popup that does not fit goes to the other side of its anchor, then starts from the anchor's other edge, then both, whatever position-try-fallbacks says.
  • It does not do a corner, the physical words (bottom, span-left), a page written right to left, position-try-order, position-visibility, or anchor() and anchor-size() anywhere else. With several buttons for one popup it takes the first.
  • It places a popup when it opens and when the page scrolls or the window resizes. A popup whose anchor changes while it is open is closed and opened again.
  • A popup that fits in none of its places stays in the first, in both kinds of browser.
  • position-area written on a popup directly places it only where the browser knows it.

popover is a panel and menu a menu:

<button class="btn btn-outline" popovertarget="filters">Filters</button>
<div id="filters" class="popup popover" popover>…</div>

<button class="btn btn-ghost btn-icon" popovertarget="row-7" aria-label="Actions"><icon name="ellipsis" /></button>
<div id="row-7" class="popup popup-end menu" popover focusgroup="menu">
  <p>Invoice 7</p>
  <a href="/invoices/7">Open</a>
  <hr>
  <form method="POST"><button class="menu-danger" name="action" value="delete">Delete</button></form>
</div>

In a menu:

  • An <a> or a <button> is an item, also inside a <form>. A <p> is a heading and an <hr> a line.
  • focusgroup="menu" makes Up and Down move among the items and Tab leave the menu. Without it Tab moves through them.
  • Escape closes, as in any popover.
  • A press on an item does not close the menu: a link or a submit replaces the page. A <button type="button"> that should close it names the menu: popovertarget="row-7" popovertargetaction="hide".

tabs

On a <nav> or <div> around links or buttons. The one of the page or view shown has aria-current="page" (a link) or aria-selected="true" (a button in an island). Each link is a page or a ?tab= of one, and the server renders what that tab holds. tabs-line draws a line under the current one in place of the raised pill.

<nav class="tabs" aria-label="Invoices">
  <a href="/invoices" aria-current="page">Open</a>
  <a href="/invoices?paid=1">Paid</a>
</nav>

Tabs that switch what is on the page, with no request and no script: each tab is a <label> around a radio of one name. The element around the tabs has the class tabbed, with one element after the tabs for each tab, in the same order. The picked radio shows its element and hides the rest.

<div class="tabbed">
  <div class="tabs">
    <label><input type="radio" name="tab" value="profile" checked>Profile</label>
    <label><input type="radio" name="tab" value="notices">Notices</label>
  </div>
  <div>…the profile's fields…</div>
  <div>…the notices' fields…</div>
</div>
  • Everything stays in the page, so one form can spread over the tabs and keeps what was typed in each.
  • Left and Right move between the tabs, as between any radios.
  • A radio with a name and a value posts which tab was open; form="" on it keeps it out of the form.
  • Up to eight tabs.
  • A required field in a tab that is not shown stops the submit with nothing to see: a form over tabs takes novalidate and leaves the check to the server.

prose

Text that format.markdown rendered, on the element around it, and the text in ui-markdown. It sets no size of its own, so it reads at the size of where it stands; its spaces and its headings follow that size.

<div class="prose">{@html post.body_html}</div>

avatar

A person, on a <span>: their initials, or an <img> of them inside it. Sizes: avatar-sm, avatar-lg.

progress, spinner, skeleton

  • progress on <progress value max>: how far along. With no value the end is not known and a part of the bar runs along it; give that one an aria-label.
  • spinner on an <icon name="loader-circle" label="Loading" />: work under way. In a button it takes the button's icon size.
  • skeleton on an empty element: the place of content that is loading. Utilities give it its size and, for a picture, rounded-full.
<progress class="progress" aria-label="Loading"></progress>
<div class="skeleton h-4 w-64"></div>

kbd

A key or a shortcut, on <kbd>: <kbd class="kbd">⌘ K</kbd>. An <icon> in it is drawn at 12px.

empty

What a list or a page shows when it holds nothing: an <icon>, a name in an <h3>, a sentence in a <p>, and its actions in a <div>. empty-outline draws a dashed edge around it.

<div class="empty">
  <icon name="file" />
  <h3>No invoices yet</h3>
  <p>An invoice shows here once a deal is won.</p>
  <div><a class="btn" href="/invoices/new">New invoice</a></div>
</div>

accordion

Sections that open and close, on an element around <details>. The <summary> is the heading and what follows it the content. The same name on each <details> keeps one open at a time, which the browser does. Chrome 131 animates the height; other browsers open at once.

<div class="accordion">
  <details name="faq" open>
    <summary>What does it cost?</summary>
    <p>Nothing for the first three people.</p>
  </details>
</div>

card

A surface for a form or a block of content. Its edge is a ring, not a border, so it adds nothing to the box's size.

dialog

On <dialog>. A button opens it with commandfor="its-id" and command="show-modal", and one inside closes it with command="close". Escape closes it too, and closedby="any" adds a press outside it. No script.

  • Its <header> holds a heading and a paragraph, its <footer> the buttons. A <form> around them changes nothing.
  • With the open attribute it shows in place on the page, which is how a form in one shows its errors after a failed save.
  • dialog-side is a drawer: a dialog at the right edge, as tall as the window, that slides in. What does not fit scrolls, and its <footer> stays at the bottom.
  • dialog-alert is a confirmation: narrower on a phone, its text centred there.

ui-dialog writes the header and the close button, ui-confirm a whole confirmation, and ui-confirmer the one an island asks for.

alert

A message on the page. Kinds: alert-danger, alert-success, each a text colour. An <icon> written first sits beside the text. Give a danger one role="alert" and any other role="status".

badge

A short status beside a name, on <span> or <a>. Kinds: badge-secondary, badge-success, badge-danger, badge-outline, badge-ghost. A kind sets --badge-bg and --badge-fg, and --badge-hover for one that is a link.

table

Rows of records, on <table>. Its cells need no class. A cell keeps to one line, so put the table in an element with overflow-x-auto where its rows can be wider than the page, and whitespace-normal on a cell that should wrap.

ui-field

A label, hint and error around one control, which is written inside the tag. No script.

<ui-field label="Email" error={errors?.email}>
  <input class="input" name="email" type="email" value={old?.email}>
</ui-field>
Prop
label the control's name
error turns the label and the control red, and is read out with the label
hint shows while there is no error

The control is inside the <label>, so it needs no id.

Writing a control for it

A <label> passes every click inside it that lands on no control to the first control it holds, and moves focus there. A component with parts of its own (a panel, a list, chips) therefore:

  • writes the control the label should press first;
  • cancels the click on the rest with @click.prevent.

ui-combobox, ui-datepicker, ui-timepicker, ui-upload and ui-markdown each note where, above their markup.

ui-select

A <select> with the arrow drawn as an icon. No script.

<ui-select>
  <select class="input" name="role">…</select>
</ui-select>

The select is written inside the tag and keeps its own attributes. It is as wide as what holds it: size an element around the tag.

For a long list, or one that is searched, use ui-combobox.

ui-dialog

A dialog over the page, with its heading and a close button. No script: it is a native <dialog> that a button opens with commandfor.

<button class="btn" commandfor="invite" command="show-modal">Invite</button>
<ui-dialog id="invite" title="Invite a person" text="They get a link by mail." open={errors != null}>
  <form method="POST" data-keep-scroll>…<footer><button class="btn">Send</button></footer></form>
</ui-dialog>

What is written inside the tag is its body: a form, with the buttons in a <footer>.

Prop
id what the button names
title the heading
text a line under the heading
open shows it in place on the page
side a drawer: at the right edge, as tall as the window, its <footer> at the bottom

Escape, the close button and a press outside close it.

After a failed save

A dialog is closed when the page is rendered again, with the errors inside it. open={errors != null} shows it in place instead. Shown in place, only the close button closes it, since any press on the page is then outside it.

data-keep-scroll on the form makes the page come back where it was scrolled, so the dialog with its errors is in view.

Drawer

side={true} suits a form too long for a dialog, and a record opened from a list that stays visible behind it.

ui-confirm

A question before an action that cannot be taken back. No script.

<button class="btn btn-danger" commandfor="delete-{deal.id}" command="show-modal">Delete</button>
<ui-confirm id="delete-{deal.id}" title="Delete this deal?" text="It cannot be restored." label="Delete" danger={true}>
  <input type="hidden" name="action" value="delete">
  <input type="hidden" name="id" value={deal.id}>
</ui-confirm>

Confirming posts to the page. What is written inside the tag goes in that form, so the action and the row's id are hidden inputs there.

Prop
id what the button names
title, text the question and a line under it
label the confirming button's text, "Confirm" unless given
danger makes that button the danger kind
cancel renames "Cancel"
toast what ui-toaster says once the action is done

Escape and Cancel close it; a press outside does not.

Keep it beside its button, not inside a menu: a closed menu hides what is in it.

An island that asks before it acts calls confirm(): ui-confirmer.

ui-confirmer

The dialog confirm() opens: a question an island asks and waits on. The layout renders it once, after the page:

<slot />
<ui-confirmer />
import { confirm } from './confirm.js'

async function remove() {
  if (!(await confirm({ title: 'Delete 3 records?', text: 'They cannot be restored.', label: 'Delete 3', danger: true }))) return
  …
}

confirm() resolves true when the question is confirmed, and false when the dialog closes any other way: Cancel, or Escape.

Option
title, text the question and a line under it
label the confirming button's text, "Confirm" unless given
danger makes that button the danger kind
cancel renames "Cancel"
  • Cancel has focus when it opens, so Enter does not confirm.
  • A question asked while one is open takes its place, and the earlier one resolves false.
  • A form that posts needs no island: ui-confirm.

ui-pager

The pages of a list: Previous, the first page, the ones around the current one, the last, and Next. No script.

-- the handler
local url = require "effortless.url"
return {
  page = eql.page("select invoices order by sent_on desc", { page = request.query.page }),
  list = url.with(request, { page = false }),
}
<ui-pager page={page} href={list} />
Prop
page what eql.page returned
href the list's URL without its page, which the handler builds so the filters stay in every link. Each link adds page=N to it. Without it the links are ?page=N, which drops every other name of the query.
previous, next rename the two words; a phone shows only their arrows

Nothing is drawn for a list of one page.

ui-copy

A button that puts a text on the clipboard and shows a tick for two seconds.

<ui-copy text={invite.url} />
<ui-copy text={api_key} label="Copy key" kind="btn-secondary btn-sm" />
Prop
text what is copied
label the button's text. Without it the button is square with the copy icon, named "Copy" for a screen reader and for ui-tooltip.
done what it says once copied, "Copied" unless given
kind the button's classes beside btn: btn-outline unless given

The clipboard takes a write only on a page served over https or from localhost. Anywhere else the press does nothing.

ui-tooltip

The tooltip of every element on the page that has data-tip. The layout renders it once, after the page:

<slot />
<ui-tooltip />

data-tip is the text. With no value the element's aria-label is shown, which is what a button with only an icon needs:

<button class="btn btn-ghost btn-icon" aria-label="Copy" data-tip><icon name="copy" /></button>
<span class="badge" data-tip="Paid on 14 October">Paid</span>
  • It shows 400 ms after the pointer comes to rest on the element, and at once when the keyboard focuses it.
  • It stands above the element, or under it where there is no room above.
  • It goes when the pointer leaves, on a press, on Escape and when the page scrolls.
  • Text of its own is read out as the element's description while it shows.

A finger does not hover, so on a touch screen it never shows: what it says must not be needed to use the page.

ui-toaster

The list of short messages at an edge of the window: "Saved", "Link copied". The layout renders it once, after the page:

<slot />
<ui-toaster />

position says where: top or bottom, then left, center or right, as in <ui-toaster position="top-center" />. It is bottom-right unless given. At the top the newest message is the first and comes in from above. In a window narrower than 40rem the list is as wide as the window, and only top or bottom shows.

From a form

data-toast on the form, or on the submit button that was pressed. No script.

<form method="POST" data-toast="Saved">
<button data-toast="Added to {project.name}" data-toast-action="Open" data-toast-href="/projects/{project.id}">

The message shows on the page the submit lands on when the write worked. It does not show when the page comes back with errors, the server answers an error, or the request fails.

From an island

import { toast } from './toast.js'

toast("Link copied")
toast("Could not reach the server", { kind: "danger" })
toast("Deal archived", { action: "Undo", run: restore })

The path is from an island in app/components; one beside a route names that folder ('../../app/components/toast.js'). The module is the library's unless app/components has a file of that name, which the library's own components then run too. A file of that name that is something else stops the toaster with an error naming it.

Options

Option Attribute
detail data-toast-detail a second line, muted
kind data-toast-kind "success" or "danger": an icon before the text
action data-toast-action the text of a button in the message
href data-toast-href where that button goes
run what that button does, a function
duration milliseconds until it closes, 5000 unless given

Behaviour

  • A message closes after five seconds, or its duration. No timer runs while the pointer is on the list.
  • Three show at once; one more closes the oldest.
  • A message outlives a navigation.
  • What a message's action does has to be reachable on the page too, since the message is gone in seconds.
  • While a modal dialog is open, a message shows above it and cannot be pressed: the dialog makes everything outside it inert.

DURATION and LIMIT are at the top of toast.js.

A message slides in when added, and fades once toast.js marks it data-leaving.

The form path reads the runtime's inlay:load event, which is among Effortless's unreleased changes as of 2026-10-11. On a release without it a form's message shows only when its page loads in full.

ui-combobox

A select for a long list: typing filters it. A short list is a <select> in ui-select.

<ui-field label="Organizer" error={errors?.partner_id}>
  <ui-combobox name="partner_id" options={partners} value={old?.partner_id ?? event.partner_id} />
</ui-field>
Prop
options a list of { value, label }. indent (1, 2, …) on an option sets it in that many levels in the list, for a tree. detail is a muted text at the end of its row, for what tells two options of one name apart.
value the picked one; one that no option has shows as itself
name posts from a hidden input, which sends input and change as a <select> does. An island writes bind:value and needs none.
multiple several values: value is a list and each picked one a chip
src a route the options come from
create, created, onCreate a new option from what was typed
required no clear button, and the browser refuses an empty field
disabled posts nothing
placeholder shows while nothing is picked
empty shows when nothing matches
id the input's, for a <label for>

Several values

Name the field with brackets, and request.form.tag_ids is a list:

<ui-combobox name="tag_ids[]" options={tags} value={old ? old.tag_ids : post.tag_ids} multiple={true} />

With nothing picked the form sends no such field. Read request.form.tag_ids or {}, and after a failed save take the value from old as above: old?.tag_ids ?? post.tag_ids would put back the stored ones when every chip was removed.

The input and change events come from a field that has no name.

Options from the server

src is a route that answers { options = { { value, label }, … } } as JSON. The list asks it for ?q=<what was typed> a moment after each key, and once when it opens. The route decides what matches. A request that fails leaves the list as it was. options then holds only what is picked, for its label.

<ui-combobox name="city_id" src="/cities" options={picked_city} value={deal.city_id} />
-- routes/cities/cities.lua
local eql = require "effortless.eql"

function get(request)
  return { options = eql([[select cities { .id as value, .name as label }
    where true and? (.name like $q) order by .name limit 20]], { q = request.query.q }) }
end

A new option from what was typed

create names a second field. Text that is no option's label is offered as a row, and picking it posts the text under that name for the form's action to store. created puts back what was typed before a save that failed.

<ui-combobox name="tag_ids[]" create="new_tags[]" created={old?.new_tags} options={tags} value={old ? old.tag_ids : post.tag_ids} multiple={true} />

An island passes onCreate={add} in place of create: add gets the text, adds the option and picks it.

The inputs of several values and of created ones are written in the browser, so a test posts those with app.post(path, { form = { … } }).

Typing and keys

  • Without src, typing matches anywhere in a label and ignores case and accents (c finds č).
  • Up and Down move, Enter picks, Escape closes.
  • Backspace in an empty field removes the last chip.
  • Emptying the text clears a value that is not required. Leaving the field puts back the picked label.

ui-datepicker

A date, picked from a month's calendar.

<ui-field label="Starts on" error={errors?.starts_on}>
  <ui-datepicker name="starts_on" value={old?.starts_on ?? event.starts_on} />
</ui-field>
Prop
value YYYY-MM-DD, as a t.date column holds it
name posts from a hidden input, which sends input and change. An island writes bind:value and needs none.
min, max the first and last day that can be picked. The years offered run from min to max, or from a hundred back to ten on.
range, end, endName a first and a last day
time, step, ampm a date and a time
zone with time, the time zone the date and time are picked in, as a name such as America/Denver. Without it, the page's.
required no clear button. A button cannot refuse a submit, so an empty required date is the server's to refuse: required = true on the column.
disabled posts nothing
placeholder shows while no date is picked
id the button's, for a <label for>
<ui-datepicker name="born_on" value={person.born_on} max={today} />

A range

value is the first day and end the last; endName names the field the last one posts as. An island writes bind:end.

<ui-datepicker range={true} name="starts_on" value={trip.starts_on} endName="ends_on" end={trip.ends_on} />

It takes two presses, the first day and then the last; a third starts over. Both are written, and a form hears a change, at the second press: a panel closed after the first keeps the range it had.

A date and a time

time={true} puts a ui-timepicker beside the button, with step for its minutes and ampm for a 12-hour clock. Not with range.

<ui-datepicker time={true} name="starts_at" value={event.starts_at} step={15} />

value is then an instant, "2026-07-01T09:00:00Z", as a t.datetime column gives one, and just the date until a time is picked. It posts an instant, to the minute.

A time zone

A t.datetime is an instant. On a page rendered in a time zone (app.time_zone, or time_zone in the page's data) the picker shows value on that zone's clock and posts the instant that was picked, so the handler writes what the form sends. On a page with no zone the clock is UTC's. The page names its zone in data-zone on its <body>, and on <html>, which the picker reads.

<ui-datepicker time={true} name="starts_at" value={slot.starts_at} zone={site.time_zone} />
  • zone picks in another zone than the page's: the venue's, when the person booking is somewhere else. Say which beside the field: the picker does not.
  • An hour the clock repeats in autumn is its first pass. An hour it skips in spring is read on the clock from before the change: 02:30 that day is 03:30.
  • The zone's rules are the browser's. A zone the browser does not know is no zone.
  • A date without time has no zone.

Language

The button shows the date in the page's language, <html lang>: "October 14, 2026", "14. oktobar 2026.", a range as "Jan 20 – Feb 9, 2026". The same language names the months and weekdays and says which day a week starts on. dates.js holds this and the date arithmetic; no date library is involved.

Keys

In the calendar:

  • the arrows move by a day and a week;
  • Home and End go to the ends of the week;
  • PageUp and PageDown change the month, with Shift the year;
  • Enter or Space picks, Escape closes.

ui-timepicker

A time of day, typed or picked from columns of hours and minutes.

<ui-field label="Opens at" error={errors?.opens_at}>
  <ui-timepicker name="opens_at" value={old?.opens_at ?? shop.opens_at} />
</ui-field>
Prop
value HH:MM on a 24-hour clock, as a t.time column holds it; the seconds of a longer value are not read
name posts from a hidden input, which sends input and change. An island writes bind:value and needs none.
min, max the first and last time that can be picked or typed
step the minutes between the times in the panel, 5 unless given
ampm a 12-hour clock
required the browser refuses an empty field
disabled posts nothing
placeholder shows while no time is picked
id the input's, for a <label for>
<ui-timepicker name="starts_at" step={15} min="08:00" max="18:00" />

The clock

The clock has 24 hours and the field reads "14:30". ampm={true} makes it a 12-hour one: the hours run to 12, a third column holds AM and PM as the page's language, <html lang>, writes them, and the field reads "2:30 PM". What posts is HH:MM on a 24-hour clock in both.

<ui-timepicker name="opens_at" value={shop.opens_at} ampm={true} />

Picking and typing

  • A press in a column changes that part of the time and leaves the panel open. It closes on a press outside, on Enter and on Escape.
  • Any minute between min and max can be typed, on either clock: 9, 930, 9:30, 9.30, 2pm, 2:30 PM. Enter or leaving the field takes it.
  • Text that is no time, or one outside min and max, puts back the time before.
  • Emptying the field clears it, unless required.
  • Down and Up move the time a step later and earlier, and stop at the ends of the day and at min and max.

ui-upload

A file input with an area to drop files on and a list of what was picked: each file's name and size, a picture as itself, and a button that takes it out again.

<form method="POST" enctype="multipart/form-data">
  <ui-field label="Logo" error={errors?.logo}>
    <ui-upload name="logo" accept="image/*" current={partner.logo_url} />
  </ui-field>

The file posts from a real <input type="file">: the form needs enctype="multipart/form-data", and the handler reads the upload as it does any.

Prop
name the field's. The server writes the input, so it is in the page before the script has run.
accept what the picker offers and what a drop keeps: "image/*", "image/png,image/jpeg", ".pdf,.csv"
multiple several files; name it with brackets, "photos[]". A new pick or drop replaces the one before.
current the URL of the file already saved
text what the area says
required the browser refuses a submit with no file, unless current holds one
disabled posts nothing
id the input's, for a <label for>

A saved file

current shows in the list until another file is picked, and an empty submit keeps it: the handler stores a file only when one came. Removing a saved file is a control of the app's own beside this one.

ui-rows and ui-row-tools

Lists of rows in a form that can be added to, removed from and put in another order. The layout renders ui-rows once, after the page:

<slot />
<ui-rows />

It draws nothing. It gives every element on the page that has data-rows what its buttons say. The page writes the rows as it writes any markup, so they are in the page before any script runs and a test submits them.

<div class="rows" data-rows="tickets">
  <div data-row @each={tickets as ticket, i}>
    <input type="hidden" name="tickets[{i}][id]" value={ticket.id}>
    <input class="input" name="tickets[{i}][name]" value={ticket.name}>
    <ui-row-tools />
  </div>
  <template>
    <div data-row>
      <input class="input" name="tickets[new][name]">
      <ui-row-tools />
    </div>
  </template>
  <button type="button" class="btn btn-outline btn-sm" data-add><icon name="plus" />Add a ticket</button>
</div>

What the page writes

Attribute On
data-rows the list what each row's field names start with, before the row's index
data-row a child of the list a row
<template> a child of the list a new row; its index can be any text
data-add a button in the list adds the template's row after the last one
data-remove a button in a row removes the row it is in
data-grip a button in a row dragged, moves its row; focused, Up and Down move it

<ui-row-tools /> writes the grip, drawn at the row's start, and the bin that removes the row. move={false} leaves out the grip, for a list whose order means nothing. label names the bin for a screen reader: "Remove ticket".

What posts

A row keeps the names it was written with wherever it is moved, and a new row gets a number no other row has in place of the template's index. request.form reads a list in the order its fields arrive, which is the order the rows stand in, so the form posts the list in that order: request.form.tickets. The handler stores it with eql.sync_rows(…, { order = "position" }), which updates the rows that came with an id, inserts the ones without, deletes the ones that did not come, and writes each one's place.

The list sends a change event after every change, as a field does.

Behaviour

  • While a row is dragged a copy of it follows the pointer. The row itself, dimmed, stands where it will land: it changes places with each row the pointer passes the middle of.
  • A list inside a row works the same. Its data-rows starts with the row it is in: groups[{i}][options].
  • An island in the template's row is mounted when the row is added.

ui-markdown

A text with headings, lists and links, written as it will read and stored as Markdown.

<ui-field label="Description" error={errors?.body}>
  <ui-markdown name="body" value={old?.body ?? post.body} label="Description" />
</ui-field>
Prop
value Markdown
name posts from a hidden input, which sends a change when focus leaves the editor and its buttons with the text changed. An island writes bind:value and needs none.
label names the editor for a screen reader
placeholder shows while it is empty
disabled posts nothing
id the editor's

Showing the text

The handler renders it, and the page writes it in an element with the prose class, which also styles the text in the editor:

post.body_html = format.markdown(post.body)
<div class="prose">{@html post.body_html}</div>

Editing

  • Buttons: bold, italic, link; heading and subheading (## and ###); bullet list, numbered list, quote.
  • A press on the heading or the quote the cursor is in turns it back into a paragraph.
  • Ctrl or Cmd with B and I; Shift+Enter for a line break; Enter twice to leave a list.
  • The buttons are one Tab stop; Left and Right move among them.
  • The link button opens a panel for the address. Empty, it takes the link off. An address with no scheme gets https://.
  • Pasted text keeps its headings, lists, links and emphasis, and loses every style.

What Markdown is kept

markdown.js turns the value into the editor's elements and reads them back. The server renders what is stored with format.markdown, which is CommonMark.

Read and written: paragraphs, # to ###### headings, bold, italic, code, links, pictures, bullet and numbered lists inside each other, quotes, fenced code blocks with their language, --- lines, and a line break (a backslash at the end of a line). A line break inside a paragraph without one stays as the author wrapped it. The buttons make only some of these; the rest is kept when it is in the value.

Anything else shows as the text it was typed as, and is saved as that text with its signs escaped:

  • a table;
  • raw HTML;
  • a link written by reference;
  • a link's title;
  • a code block made by indenting.

A line that continues a list item or a quote without its indent or its > becomes a paragraph after it.

Limits

  • Editing is the browser's: contenteditable and document.execCommand, which the standard calls obsolete and every browser has. Undo is the browser's too.
  • What its commands leave that Markdown has no word for, a style or a list inside a heading, shows until the page is loaded again and is not in what is saved.