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 checkafter every edit,effortless testto 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
todostable inapp/schema/schema.lua, beside theuserstable the framework declares. /login,/logout,/forgot,/reset,/inviteand/acceptunderroutes/.- 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-lineconfig.lua,.env.examplenaming every option, and aDockerfile.
First steps:
- Copy
.env.exampleto.envand put your address inSUPERADMINSto 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
roleto"admin"to reach/invite, and invite everyone else.
Adding a feature is three files:
- Table: add it to
app/schema/schema.lua(section 5). The database converges on the next request; there are no migration files. - Route:
routes/deals/deals.luareturns data androutes/deals/deals.x.htmlrenders it (sections 2 and 4). Section 8 has a complete pair to copy. - Test:
tests/deals_test.luawith 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:
- A signed-out request is sent to
/loginunless the route's own.luasayspublic = true. guardruns, the folders' first and then the route's (section 2).- A GET calls
get(request)and renders the.x.htmlwith the table it returns. - A POST calls
post, or the action inactionsthe form names. By default it answers 303 back to the same page, which renders again fromget.
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,
servereads the configuration again when.envorconfig.luachanges. Aconfig.luaedit, or a key newly added to.env, applies on the next request. - A changed
.envvalue needs a restart, because.envnever overwrites a variable already in the environment. - Production needs a restart for any change.
- Keep
config.luafor 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 programsproc.runmay start.files.paths: the foldersfiles.readandfiles.importread from.routes.view_only: when true,checkaccepts a view underroutes/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_TIMEOUTseconds 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 apcallcannot carry on and a transaction commits nothing. effortless evaland each test run on the same limit. A command has none. A job and a cron run are limited byCRON_TIMEOUT_SECONDS.- Work that runs long belongs in a job.
- Memory. A Lua state holds at most
LUA_MEMORYmegabytes. Past it the run fails with "not enough memory" and the limit. - Behind a proxy,
request.ipis 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.comaddress each run, printed as→ public:.base_urlfollows it. Anyone with the address reaches the app. - Named tunnel, one address that stays:
TUNNEL_NAME=dfdbin.envgives a cloudflared tunnel nameddfdb-local, created on the first run. TUNNEL_ZONE=example.devis the domain named tunnels live under, sodfdbresolves ashttps://dfdb.example.dev. It belongs to the machine'scloudflared tunnel login, so export it in a shell profile.TUNNEL_URLnames 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. servestops before it binds, naming the fix, whenTUNNEL_URLhas no tunnel name,cloudflaredis not installed, or the machine has never runcloudflared tunnel login.base_urlfollows a named tunnel whenTUNNEL_URLsays where it resolves. An explicitBASE_URLstill 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.
servewarns at startup about each listed address with no account. - The
rolecolumn 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 = truecolumn, or aname- ortitle-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.filecolumn and images for at.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;!wordexcludes one. - Text that starts with
.ornotis the where ofselect <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.htmlbeside it, it is a page; without one, it answers JSON. - A page with no
getrenders its view for a GET with the data its layouts load. A public page with no handler ispublic = truealone. - Handlers are
get,post,put,patchanddelete, 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
actionstable. The form names it inrequest.form.action, or the JSON body inrequest.json.action. - Two files that resolve to one URL are an error: not both
routes/about.luaandroutes/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.
userandlogged_inare in scope in every view. Do not passuser = request.user.
Reading rows in a handler:
- A select returns a list,
limit 1included. Never writeor {}after one. eql.onereturns one row or nil.eql.findiseql.onefor a row the URL names: when nothing matches the request answers 404, so the handler writes noif not rowcheck.
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.htmlunderroutes/with no.luabeside it is acheckerror, unlessconfig.luasetsroutes = { 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()andauth.require("admin")return exactly these.- A
guardin a folder's_layout.luacovers 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.formandrequest.paramshold what the client sent. A missing key there is nil.- Bracketed form names nest:
images[0][title]isform.images[1].title, andtags[]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 beforeimages[2]isform.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'sform = { … }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.htmlreturns 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 throughresponse.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
errwhen there is one, then usesresult. - 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.
Modules and links
- Shared code goes in
app/lib/:app/lib/deals.luaisrequire "deals", from a route, a job, a test oreffortless eval. - Route-local modules are files under
routes/that are never routed.routes/products/_form.luaisrequire "_form", androutes/products/import/lib/page.luaisrequire "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 at.enum, for a dropdown.
Links from the current query (local url = require "effortless.url"):
url.with(request, { color = ids, page = false })isrequest.pathwith those names changed and the rest of the query kept. A value replaces, a list repeats the name,falseremoves it.url.toggle(request, "color", id, { page = false })addsidtocoloror 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,jsonandlogare globals. A route and anapp/libmodule require them. - A framework module raises on a field it does not have:
effortless.eql has no field 'fnd' (did you mean 'find'?). checkreportseql.fndandrequest.parmsbefore the code runs, where the name is the file's ownrequireorrequest.- 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 byandgroup bytakes a leading dot:.user_id. A field list{ id, title }does not. selectis 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 bytakes a column or a projected alias:{ customer_id, count() as n } group by .customer_id order by .n desc.settakes expressions over columns:set { completed: not .completed }.- Booleans are real:
where .completed = false, and rows come back withtrueandfalse. - There is no raw SQL. Every value binds as a parameter, and a query is never built by concatenation.
sum,avg,min,max,totalandgroup_concattakedistinct, 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) ory, ortoday,start_of_month,start_of_year. now("today", "America/Denver")starts the day on a time zone's clock, andstart_of_monthandstart_of_yearthe 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
$nowbuilt withos.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
shifttakes 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, withformat.utc(day .. " 00:00", zone)for each, ornow("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, andgroup bytakes 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, withas, 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,$paramsand$queryalso work whole, as a spread:insert notes { $form }.$authdoes 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. Writereturn (eql "…"), assign first, or pass the parameters. The same goes forreturn realtime.stream { … }.checkreports it.
Nil, empty and NULL
- In a write, a
$form.*field that was not sent, or any dotted field that isnil, binds NULL. - An insert leaves out a column that has a default when its value is nil, so the default is written,
now()anduuid()included. eql.NULLis a value: it writes NULL where nil would leave the default.- An update writes NULL for nil. A
requiredcolumn 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,yesandfalse,0,off,no, in any case. - Lua's
nilvanishes from a table, so useeql.NULLfor 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 = truecolumns and the table's owner column are dropped before validation. - Pass
request.formstraight 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'serrors, or a 422{ "errors": {...} }for JSON. - A foreign key that points nowhere fails the same way:
errors.session_id = "no sessions row with id '…'". Noexistscheck 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, andallfor a list of documents that must each match. beforeandaftertake 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*, andwhere,order by,limitandoffsetafter 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 = falseis the users with an open todo. - A path or derived column in
order bycosts 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: valuewritesvaluetocol; a barecolis the source's column of that name. inserted.colinon conflict … update { }is the row the insert proposed, and.colthe stored one.views: inserted.viewsreplaces the stored count, so a second run writes the same numbers.views: .views + inserted.viewsadds, so a second run over the same events counts them twice.- It returns how many rows it wrote, or with
returningthe 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.filecolumn. - In a request it cannot write a table with an owner, or a reference to one. Use
eql.unscopedor 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.chatand an unqueuedmail.sendraise inside one. Call them after it returns, orjobs.enqueuethe 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
idis one of the parent's rows is updated. - A row without an
idis inserted. - A row the parent has that the list lacks is deleted.
- An
idthat belongs to another parent is an error. opts.ordernames a column that gets each row's position.opts.setholds 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, andeql.on(handle)aneqlbound 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, andformatDateandformatRangeon a date. - A server file calls no other function and assigns nothing.
- A name nothing defines is
undefinedand prints as empty. - A member of
nullorundefinedis an error with file and line. Writedeal?.owner?.name. []and{}are truthy. Testxs.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,nullorundefinedleaves the attribute out, andtruewrites it bare:disabled={saving}. - On
aria-*a boolean is the state:aria-pressed={on}writes"true"or"false".nullstill leaves it out. class:done={t.completed}adds a class.style:width="{w}px"sets one declaration.@show={x}setsdisplay: none.@eachcannot 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@ifand@each, so it can use the row. It goes on an element, not on a component's tag.- A literal
{in text is{. - 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, anyon…) andsrcdoctake no{…}. In an island use@click={…}. In a server component keep the handler static and read adata-*attribute. href,src,action,formaction,posterandxlink:hrefbuilt from data are left out when the value is ajavascript:,vbscript:ordata:URL. Adata:image/…insrcorposteris kept.- A value inside
style="…{x}…"orstyle: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.luafiles above it load, the URL params, anduser,logged_in,path,method,base_urlandtime_zone(the zone the page is rendered in, or nothing). - After a failed write it also reads
errorsandold(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.htmlwraps every page. A_layout.x.htmlin 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/orpartials/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
loadreturns. - The page's own data wins a name both have. A nested
_layout.luawins over the one above it. loadruns each time a view renders, and not for a redirect or JSON.loadreturns data. Refusing a request isguard(section 2).
Components and icons
- A component in
app/components/post-card.x.htmlis<post-card>. - A component one route uses lives beside it:
routes/deals/_deal-row.x.htmlis<deal-row>inroutes/deals/and below. - A tag is looked up in the using file's folder, then each folder up to
routes/, thenapp/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.
checkreports a missingname, and a written name with no file.effortless icon pinwritesapp/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/ddday,M/MMmonth number,MMM/MMMMmonth name,EEE/EEEEweekday,yy/yyyyyear,H/HHhour,mmminute. 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.
formatDateandformatRangerun 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.markdownrenders at most 256 KB and raises past it. Give the column amaxunder 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.
formatDateandformatRangewrite 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_zonein the page's data, from the handler or a_layout.lua. With none there it isapp.time_zone. With neither, a datetime is written with the fields it holds. return { time_zone = user.time_zone }inroutes/_layout.luagives each signed-in user their own. The column is at.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.jswrites it to the cookieeffortless_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 itszoneattribute 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 thevalueof adatetime-localinput: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.datetimeinapp.time_zoneand 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. At.dateand at.timeare shown as stored. - A mail is rendered in the
time_zoneof its data, else inapp.time_zone. Under"browser"it has none, so pass the recipient's. - A name that is not a zone is an error: in
config.luawhen 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, noteurope/belgrade. - Zones are read from the machine's zone database. A container needs its
tzdatapackage; withapp.time_zoneset and no database,check,testandservestop and say so. now(),t.now,t.updated,format.timeago, a job'sat, a cron schedule and the datesmatches()reads are UTC whatever the zone. Nothing in a handler or a query readsapp.time_zoneby 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: adatetime-localfield 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}").checkreports a trigger whose id nothing produces. <button type="button" commandfor="photo-picker" command="show-modal">opens the dialog with no script, andcommand="close"closes it; a popover takesshow-popover,hide-popoverandtoggle-popover.closedby="any"on a dialog closes it on a press outside. Browsers from before 2026 know neither attribute, and/_inlay.jsdoes there what they say, so the markup is the same everywhere. Inside a form the button needstype="button".- A popover with the class
popupopens against the button that names it. CSS anchor positioning places it, and/_inlay.jsdoes 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:00reads back as2026-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= != < > <= >=,betweenand the comparisons ofmatches(), on the column or at the end of a relation path. An instant written with itsZcompares as it is everywhere else too: in a list (in), withlike, and against whatshift()orcoalesce()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'sdefaultis written as the column holds it:now(), a date, or an instant with itsZ. <ui-datepicker time={true}>posts an instant,now()andt.noware instants, andos.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, byeffortless migrateand 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,
mincannot exceedmax.effortless checkreports 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 |
readandwritetake"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
writenames 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.usercarries that column.- A request does not set it on
users, since it decides which rows the user reaches. Set it witheql.unscopedor 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. checkreports aneql.unscopedthat changes nothing: inapp/cron,app/jobsorapp/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, inauth.require,auth.has_roleandauth.invite, and in a query. - A role is not named
anyone,signed_inorshared. - Once any table's access names a role, a request does not set
users.role. Change a role witheql.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, anorder byor areturning: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 storeduserscolumn, private ones included. - Whoever writes a row may set its private columns.
- On a
"shared"tableprivateis 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'susersgives 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 ofrequest.form, or async_rowsrow. 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 = truekeeps a readable form of the uploaded filename in the key (site_plan-9f3a1c7e5b2d4088.dwg), and a download is saved assite_plan.dwg. Use it for documents, since it puts the uploader's filename in a public URL.meta = trueadds_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_metais NULL for a file this process cannot read, such as one on R2.effortless files metafills those in, with--remoteover HTTP.- On R2, deletes go through the
delete_uploadjob, so an unreachable store is retried.
References, keys and indexes
on_deleteon at.refis"cascade","set_null"or"restrict". Without it, a delete of the parent fails while a child points at it.withinnarrows a reference:variant_id = t.ref "variants" { within = "product_id" }accepts only a variant whoseproduct_idequals 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" }besideindexes. Every key column isnot_null: an insert has to name it, and""is a value in a text one unless it saysrequired = true. A table other tables reference, or whose rows the admin edits, needsid = t.pkand a unique index. - Indexes:
"email"for one column,{ "owner_id", "created_at" }for several, andunique = trueinside an entry for a composite unique constraint. - A
t.refcolumn is indexed already.index = falseon it turns that off. - Do not repeat a
unique = truecolumn inindexes. order = { "thumbnail desc", "sort_order" }on a table is how its rows are read insidefirst(...)and a nested block with noorder 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 byon a column computed through a relation is an error.- A computed column has no
default,requiredornot_null, and cannot holdnow().
Migrating
- Edit the schema.
effortless migrate --dry-runprints the plan.effortless migrateapplies it, in one transaction, after a backup todata/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 restatesaccessandrolewhen 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 witheql.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.rolelists. Any other name raises. auth.loginandauth.registervalidate 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
/loginand/logoutare the patterns to copy. A public sign-up route callsauth.registeras/logincallsauth.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_passwordallows 10 attempts per user per 15 minutes.auth.reset_tokenreturns 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_passwordspends the token and ends every session the user had. A password the 8-character rule refuses leaves the token live.auth.invitecreates the account with no usable password, andauth.accept_invitesets 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_urlin 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,/acceptandtests/auth_test.luacovering 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>andold.<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
hiddencolumn 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 = trueis 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
fetchandinlay.postneed nothing. - A request with neither
Sec-Fetch-SitenorOriginis 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'andReferrer-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 inrequest.form, and every file of a repeated field inrequest.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.logois 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. Checkrequest.form.logofirst. - Files are public at
/uploads/…. UPLOAD_MAX_MBcaps each file. A request past four times that is a 413.R2_BUCKETand 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 = "…" }
resis{ status, body, headers, truncated }. A failed call returnsnil, err.- The timeout is 10 seconds. The body is capped at
UPLOAD_MAX_MB, andres.truncatedsays when it was cut. - Private, loopback and link-local addresses are refused unless
HTTP_ALLOW_HOSTSnames the host orHTTP_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
httptable 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'sproc.commandslists. - 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 }.fromandtoare the 1-based rows this page covers. pageandpercome straight off the query string.perdefaults to 20 and is capped at 200.- A page past the end is the last page.
- The query sets no
limitoroffsetof 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.optssets columns:{ role = "admin" }.- Assertions:
t.eq(tables compare by content),t.ne,t.truthy,t.falsy,t.contains,t.not_contains,t.fail(msg). eqlis a global in tests, and runs unscoped.- AI and mail use the
logdriver and uploads go to a temporary directory, whatever.envsays. A driver named inconfig.luais 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), andjsonfor 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.
submitsends the form's fields as rendered and the clicked button's name and value.valuesreplaces the fields it names;falseunchecks.- 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.nowandt.updatedcolumns, session and token expiry, a job'srun_at,format.timeago,now()in a query,os.time()andos.date(). httpanswers the run's outbound calls, keyed"METHOD host/path", with no scheme and no query.- An answer is a table of
status,bodyorjson,headers, orerrorfor 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.jobtakes a job from a run'sjobs, or a job's name withopts.args. It runs at the test's time: for a job due later, move the clock first witht.at(job.run_at).- A
runthat returns{ ok = false, error = … }is a failure.
What fails a run of effortless test
- A
checkfinding. 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
- After every edit,
effortless check. Fix what it reports before anything else. - To see what a query or a handler does,
effortless evaloreffortless requestit. - Every feature gets a test with one success and one failure path.
effortless testis green before the work is done (section 8 lists what fails it). - 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--jsonthe same text is in{"error": …}. - A backtrace means a bug in the framework, not in the project.
checkreads each view against what itsgetreturns: 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 inapp/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_widthin the project'sstylua.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, andchecksays 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
classattributes underroutes/andapp/components/: the attribute's words, the string literals its expressions can return ({open ? 'block' : 'hidden'}), and the name inclass: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, andtheme()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;makesbg-brand,text-brandandborder-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
@themeblocks,@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 underpublic/that the layout links, reading a token asvar(--color-brand). Link it after/_tailwind.cssand 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 underpublic/to read. - The scaffold's colours are
light-dark(light, dark)and its<html>hasscheme-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 classeslists 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 ejectlists them. - A token in
app/theme.cssreplaces the library's:--color-brand,--radius-field,--font-sans. effortless eject ui-comboboxcopies a component's source intoapp/componentsto 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 asp: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 underpublic/or in a<style>of a template.group,peer, their named forms (group/row) and the class of a@custom-variant darkare 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.cssthat 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 isinlay.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>isapp/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
@ifand@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'ssubmitcan 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:thiselement runs inonMount. - A dynamic
style="width: {w}px"rewrites the whole attribute, thedisplaythat@showsets included. Bind one property withstyle:. - A name an island's markup reads is declared in its
<script>, imported, or a browser global.checknames any other. - An island cannot use
{@html}. A route view cannot use events,bind:oruse:. - 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@ifaround the slot turns on. An input there keeps its text only throughbind: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 classorexport const x =. import x from,import * as,export default,export { … }, re-exports and package names arecheckerrors.- 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 }.errorsis a 422's field errors. - A
{ redirect }answer navigates to it. inlay.post(url, data, { signal })stops when the signal aborts and resolves withaborted: true.inlay.submit(form, extra, { signal })posts a form's fields withextraadded.
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
idkeeps its scroll position and focus when the next page has the sameid. - A link or form inside
data-keep-scrollkeeps the window's scroll position, as a filter sidebar wants. - A link or form inside
data-reloadloads 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 ondocument'sinlay:leaveevent. inlay.visit(url)navigates from a script.- Once a navigation's response is on the page,
documentgetsinlay:loadwithevent.detail{ url, method, status }: what was asked, and the status of the page shown. A write that worked is aPOSTwith 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"isapp/lib/deals.lua, andrequire "billing.invoices"isapp/lib/billing/invoices.lua.- Do not name a module after a Lua standard library (
string,table,math) or aneffortless.*module. - A module requires what it uses,
eqlincluded, 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 cmdlists them.effortless cmd close_deal -- 42passes arguments.--jsonprints one JSON document.--as <id or email>runs it as that user.- A
runthat 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.enqueueis one insert on the caller's connection. Insideeql.transactionit commits or rolls back with everything else.serveruns the queue. Each job runs in its own process,JOBS_CONCURRENCYat once, so a slow job cannot block HTTP.retries = 3gives three more attempts, after 1, 5 and 25 minutes. A job out of attempts isfailed, with its error.- A superadmin retries a failed job by setting its
stateback toqueuedin the admin. CRON_TIMEOUT_SECONDSis the longest one run may take. Keep jobs short and resumable.effortless jobsprints the counts, andeffortless jobs --drainruns what is due in the foreground.- The queue is the framework's
_jobstable: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 = … }, isfailed. The next window is the retry. effortless cronlists the scripts with their last run's state.effortless cron <name>runs one now.CRON_ENABLED=falsestops cron and the job queue. Queued work waits until aservewith it on, oreffortless 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 aftereffortless update); - installs
tzdata, the zone databaseapp.time_zoneand a zone passed toformatDateare 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.dbandUPLOAD_ROOT=/data/uploads; - runs
effortless migrate, thenserve.
On a container host:
- Mount one volume at
/data. The database and the uploads both live on it, and neither may be insidepublic/. - Set
BASE_URLto the publichttps://address. Reset and invitation links are built on it. - Set
SUPERADMINS. - Leave
HOSTunset and let the host injectPORT. Outside developmentHOSTdefaults to0.0.0.0. - The image is
linux/amd64. .dockerignorekeepsdata/,uploads/and.envout 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
WORKERSto the CPU count the host gives the container.WORKERS=1serves 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
servelistens 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=fullsyncs 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_BUCKETand its keys move them to Cloudflare R2, on a public custom domain. R2_KEY_PREFIXis 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.
- A collaborator runs
effortless pull key. It writesEFFORTLESS_PULL_KEYto their.envand prints a public hash. - The admin puts that hash in
EFFORTLESS_SNAPSHOT_KEYon the server, comma-separated for several people. - With
PULL_URLpointing at production,effortless pulldownloads the snapshot, verifies it, backs up the local database todata/backups/, and swaps it in.
What a snapshot leaves out:
- Every
t.secretcolumn 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.sqlthen runs, for what only the app knows is sensitive:UPDATE users SET email = 'user_' || id || '@example.test';.- With
EFFORTLESS_SNAPSHOT_KEYset, 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_ENVIRONMENTdefaults toEFFORTLESS_ENV. SetSENTRY_RELEASEto the deploy's commit.- The same error is sent once per
SENTRY_THROTTLEseconds. Every occurrence still writes its stderr line. - An event holds the route's pattern, the method, the
User-Agentand 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.secretvalue 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-debuggives 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
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.
textandhtmlare both sent when both are given.reply_tois 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_KEYselects Resend.MAIL_FROMis on a domain Resend has verified.CLOUDFLARE_EMAIL_TOKENwithCLOUDFLARE_ACCOUNT_IDselects Cloudflare Email Service when there is no Resend key.MAIL_CATCH_ALLsends 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.sendstill validates the message and renders the template, so its errors come back from the call.- Inside
eql.transactionthe mail commits or rolls back with everything else. queue = trueorqueue = falsein 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 unlesssubjectis passed.base_urlis in scope. - Mail clients drop linked stylesheets, so a class styles nothing. Style each element with
style="…". - Outlook ignores
display:flex,display:gridandmax-width. The width and any columns go in the layout's<table>. - Under
app/mail/,checkreports aclass, a<script>, an island, a linked stylesheet, and flex or grid. - A
<style>block is allowed, for a media query. - In development
/_maillists the templates, and/_mail/inviterenders one with the tableapp/mail/invite.luareturns. - The scaffold ships
app/mail/_layout.x.html,reset.x.htmlandinvite.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_URLpoints at another OpenAI-compatible server, such ashttps://api.openai.com/v1or a local Ollama.AI_DRIVER=logkeeps it local and deterministic. Tests use it.json = trueasks for a JSON reply.response_schema, named byschema_name, sends a JSON Schema as strict structured output.toolsandtool_choicesend function definitions.res.tool_callscomes back; run them in Lua, appendres.messageand therole = "tool"results, and call again.on_delta = function(text) … endstreams the reply piece by piece. The return value is unchanged.extra_body = { … }adds fields to the request body, merged overai.extra_bodyfrom the configuration. It may not set a fieldai.chatwrites itself, such asmodelormessages.ai.headersinconfig.luais a table of headers sent on every model request;AI_HEADERStakesname: valuepairs separated by commas.AI_EXTRA_BODYtakes a JSON object.cancel_key = keyon a call lets another request stop it withai.cancel(key). The call then returnsnil, "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
getwithout 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, besideindexes. - 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>.updateand<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
$paramsare 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$paramsand$auth.- The list holds selects, and
channelsthe channel names. A name taken from the request is only ever a channel. - A stream may not select a
t.secretcolumn. - 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.
EventSourcereconnects withLast-Event-ID, and events are replayed for ten minutes.realtime.publish("room:" .. id, "token", { text = chunk })sends on a named channel. Withai.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.luais for code one route subtree owns.app/lib/is for the app. - Prefer a one-line
eql.mutateaction. Write a custom action only for the result, a redirect elsewhere, or a message inerrors. - Tables are plural snake_case, and references end in
_id. - A join table is
parent_childwith a compositeprimaryand no id, written witheql.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 notpcalla write unless you turn the error into something else.
Data
- Give every table of user data
access = { owner = "…" }. Say who else reads and writes withreadandwrite. 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.jsoncolumn 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 witht.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. hiddenfor what forms never see,protectedfor what forms may not set but the app may (status),privatefor what only the roles that write the table read (email,cost),t.secretfor 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 withfirst,countor 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
forloop's variables are read-only: copy one before changing it (local k = key:lower()). - Write 5.4 syntax.
effortless fmtfails on a file with aglobaldeclaration.
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 ontext,dataand the rows written, not onbody. effortless request --databefore 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:
- 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. - Schema changes go in
app/schema/schema.luaonly. - Request input never reaches a file path or a
proc.runargument unchecked. Check a name against a list you wrote first. - Pick CSS classes from values the app wrote, not from user text.
- Logs go to stderr.
--jsonstdout is exactly one JSON document. - 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 withoutpublic = true. - A response that holds a
t.secretvalue. {@html}of a string the app did not make.- A global that is not declared, and a field
requestor 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.
loadtakes source text only. No C module loads.- A global the framework provides is not assigned:
_G.print = …raises.getmetatable(_G)isfalse. - 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.runstarts 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.csswrites, 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="…">whenapp/iconshas no file of that name. - Without the line none of this is there, and
checkreports aui-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 ofui-comboboxand the panels ofui-datepickerandui-timepickerare popovers placed by CSS anchor positioning: Chrome 133, Safari 26, Firefox 147. In a browser that does not knowposition-area/_inlay.jsplaces 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 knowposition-areaand do not take a popup's button for its anchor: apopupopens 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
checkboxon<input type="checkbox">.radioon<input type="radio">.switchon<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-smis the small one.
label
The name of a control, where ui-field does not write it.
popup, popover, menu
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-namein CSS and the same name indata-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-scopeon the component does the same for the browser. - The script does
block-end,block-start,inline-endandinline-start, alone (centred on the anchor) or with aspan-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, whateverposition-try-fallbackssays. - It does not do a corner, the physical words (
bottom,span-left), a page written right to left,position-try-order,position-visibility, oranchor()andanchor-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-areawritten 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
novalidateand 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
progresson<progress value max>: how far along. With novaluethe end is not known and a part of the bar runs along it; give that one anaria-label.spinneron an<icon name="loader-circle" label="Loading" />: work under way. In a button it takes the button's icon size.skeletonon 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
openattribute it shows in place on the page, which is how a form in one shows its errors after a failed save. dialog-sideis 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-alertis 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} />
zonepicks 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
timehas 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
minandmaxcan 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
minandmax, 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
minandmax.
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-rowsstarts 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:
contenteditableanddocument.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.