How it works, for whoever reviews the code.
The query language, the schema, the browser components and the commands the agent runs after each edit. You can direct an agent without reading this page; it is here for whoever checks what the agent wrote.
Simple queries read like SQL. The rest is shorter.
A plain select is SQL with a dot before the column name. The differences show up where SQL needs glue code around it.
The basics
| SQL | EQL |
|---|---|
| SELECT * FROM events WHERE group_id = ? | select events where .group_id = $group_id |
| SELECT id, title FROM events | select events { id, title } |
| SELECT e.* FROM events e JOIN groups g ON g.id = e.group_id WHERE g.city = ? | select events where .group_id.city = $city |
| INSERT INTO rsvps (event_id, user_id) VALUES (?, ?) RETURNING * | insert rsvps { event_id: $event_id } |
| UPDATE rsvps SET status = 'going' WHERE id = ? | update rsvps set { status: "going" } where .id = $id |
| DELETE FROM rsvps WHERE id = ? | delete rsvps where .id = $id |
Parents with their children, in one query.
Nested projections follow the schema's foreign keys and return nested tables. No join to dedupe, no N+1, no JSON string to decode.
What you write otherwise
select groups.id, groups.name,
(select json_group_array(json_object(
'title', e.title,
'starts_at', e.starts_at))
from events e
where e.group_id = groups.id) as events
from groups;
With EQL
local groups = eql [[ select groups { id, name, events { title, starts_at } } ]]
[{ "id": 2, "name": "Lisbon Run Club", "events": [
{ "title": "Sunrise 10K along the river", "starts_at": "2026-10-18 07:30:00" }
] }, … ]
A search box that's usually empty.
and? marks a filter as optional: the group is dropped when every
parameter in it is null or empty. One query serves the empty and the filled search box, so no
WHERE clause is assembled by hand.
What you write otherwise
local sql = "select * from contacts where owner_id = ?" local args = { user.id } if q and q ~= "" then sql = sql .. " and (name like ? or email like ?)" args[#args + 1] = "%" .. q .. "%" args[#args + 1] = "%" .. q .. "%" end sql = sql .. " order by created_at desc" local rows = db:query(sql, args)
With EQL
local rows = eql([[select contacts where .owner_id = $auth.id and? (.name like $q or .email like $q) order by .created_at desc]], { q = request.query.q })
q = "" → Alan Turing, Katherine Johnson, Radia Perlman q = "ala" → Alan Turing
Re-importing a spreadsheet, safely.
$row spreads an untrusted row: unknown keys and
protected columns are dropped before validation, explicit keys win over
spread keys, and on conflict … update makes the second import an update.
What you write otherwise
-- never pass the CSV row straight through
local safe = {
name = row.name,
email = row.email,
}
db:exec([[
insert into contacts (owner_id, name, email)
values (?, ?, ?)
on conflict (email) do update set
name = excluded.name,
email = excluded.email
]], { owner_id, safe.name, safe.email })
With EQL
eql [[ insert contacts { $row, owner_id: $owner_id } on conflict email update ]]
row = { name, email, role = "admin", action = "add" } ✓ role (protected) and action dropped · owner_id set from $owner_id ✓ second import updates the row, does not duplicate it
Every write is validated against the schema before it runs, unless it is written with
eql.unchecked, and every value is a bound parameter. Relation paths filter
across up to six hops. There is no raw SQL escape hatch.
The schema file is the migration.
Declare the desired state. The reconciler diffs it against the database and applies the
difference: when the file changes in development, and when the server starts in production. A
change that drops data waits for effortless migrate --force.
events = { id = t.pk, group_id = t.ref "groups" { required = true, on_delete = "cascade" }, title = t.text { required = true, min = 3, max = 120 }, starts_at = t.datetime { required = true }, capacity = t.integer { min = 1, default = 40 }, description = t.text { was = "about" }, level = t.enum { "everyone", "beginners", "experienced", default = "everyone" }, created_at = t.now, indexes = { "group_id", "starts_at" }, },
$ effortless migrate --dry-run ~ rename column events.about → events.description + column events.level TEXT $ effortless migrate --dry-run --json {"steps":[{"op":"rename_column","table":"events", "detail":"~ rename column events.about → events.description", "destructive":false,"data_loss":[]}, …],"destructive":false}
No migrations folder. A rename keeps its data with was = "about".
Destructive steps name the columns and tables they drop, require --force, and write a
backup first.
Svelte's component model, compiled by the server.
If you have written Svelte 4 you have written most of an Inlay component: one file,
let for state, export let for props,
bind:, use: and event modifiers. Control flow and
events are attributes on the element instead of blocks.
Svelte and Inlay
| Svelte 4 | Inlay |
|---|---|
| let count = 0 · export let name | the same |
| {#if a}<p>…</p>{:else}<p>…</p>{/if} | <p @if={a}>…</p><p @else>…</p> |
| {#each items as item (item.id)}<li>…</li>{/each} | <li @each={items as item (item.id)}>…</li> |
| on:click|preventDefault={save} | @click.prevent={save} |
| $: total = a + b | function total() { return a + b } |
| <svelte:window on:keydown={close} /> | @keydown.window={close} |
| import { format } from './dates.js' | the same, from a .js file in the project |
One grammar on both sides
A component without a script renders on the server where a view uses it. Add a
<script> and it runs in the browser. Its attributes are its props
either way.
Navigation without reloads
/_inlay.js fetches same-origin links and form posts and swaps the body,
keeping the scroll and focus of elements whose id is on both pages. State in a shared module
carries across pages.
Checked like the server code
check reports a name the markup reads that the script never declares,
an event modifier the compiler does not know, and a dialog trigger whose id nothing renders.
Icons from your own SVGs
<icon name="pin" /> writes app/icons/pin.svg
inline, so it takes currentColor and the classes around it. Scripts and
outside references are stripped from the file.
Dates in the page's language
formatDate('d MMMM yyyy') names the month in the
lang of the page's <html>.
formatRange turns two dates into "3–4 October".
Assets cached for a year
?v={asset_version} on a file in public/ is a hash
of the folder. A request that carries the current hash is served
immutable.
The commands the agent runs after each edit.
Each runs once and exits. check, request,
test and migrate take --json,
and eval prints JSON. They let the agent
check its work without a browser; you still review the page in one. For an agent that drives a
browser, effortless login-link prints a URL that signs it in, so it never
types a password.
01
effortless check
Static analysis over the whole project: Lua syntax, schema declarations, EQL literals against the schema, markup and islands, each view against what its handler returns, owner scoping.
$ effortless check ✓ No issues found $ effortless check --json {"ok":true,"errors":[]}
02
effortless eval
One Lua snippet in the app's context, with the database attached. Prints JSON. Outside a request nothing is scoped, so it sees every row.
$ effortless eval 'return eql.count [[select rsvps where .status = "waitlist"]]' 10
03
effortless request
A request through the router, in-process, with no server running.
--data prints the data the view gets instead of the HTML;
--as signs the request in.
$ effortless request GET /events/5 --data --as [email protected] HTTP 200 … {"id":"5","path":"/events/5","method":"GET",…,"user":{"id":1,"email":"[email protected]","name":"Sofia Reis","role":"user",…},"logged_in":true,"waiting":4,"organizer":false,"attendees":[{"status":"going","name":"Rui Reis","initials":"RR"},…],"going":12,"mine":"","left":0,"event":{"title":"First climb: bouldering for beginners",…,"capacity":12,…}}
04
effortless test
The project's tests/*.lua, each test against a fresh temporary database.
The scaffold ships tests for password reset and invitations; the other seven are the meetup site's, one of them the design check.
$ effortless test ✓ a reset link sets a new password once (tests/auth_test.lua) ✓ an unknown address gives nothing away (tests/auth_test.lua) ✓ a reset holds to the password rule (tests/auth_test.lua) ✓ an invitation creates the account and its first password (tests/auth_test.lua) ✓ every page uses the styleguide's buttons and fields (tests/design_test.lua) ✓ anyone can sign up and lands signed in (tests/events_test.lua) ✓ a short password is refused on sign-up (tests/events_test.lua) ✓ past capacity a new RSVP joins the waitlist (tests/events_test.lua) ✓ a cancelled place goes to the first person waiting, by email (tests/events_test.lua) ✓ only a group's organizers can post its events (tests/events_test.lua) ✓ an organizer emails everyone going; a member cannot (tests/events_test.lua) 11 passed, 0 failed
In production.
What the agent sets when it deploys, how the app scales, and where errors go.
EFFORTLESS_ENV=production # no dev reloads or error pages HOST=0.0.0.0 # reachable from outside DATABASE_PATH=/data/app.db # on the volume UPLOAD_ROOT=/data/uploads # on the volume SUPERADMINS[email protected] # who opens /_admin
More CPU, more workers
WORKERS is the number of server processes, 2 by default; set it to the CPU
count the host gives you. On Railway, a busier app gets more CPU
and memory in the service settings and a matching WORKERS, with no code
change. A small app uses about 40 MB per worker.
Uploads on the volume or in a bucket
Uploaded files stay on the Railway volume. Railway Buckets are next; until then, Cloudflare R2
works when R2_BUCKET and its keys are set.
Errors reported
With SENTRY_DSN set, request errors, failed jobs and island errors in
the browser go to Sentry.
Up, on one machine.
One server process per CPU, set with WORKERS: reads run in parallel and writes take
turns on one SQLite file. On Railway, scaling up is a bigger machine and a higher
WORKERS.
Out, with Turso, next.
Turso support is planned: when one machine is not enough, the database moves to Turso and several instances serve the same data. Until then, scale up.
Pages that feel like an app.
Inlay swaps the page on each link and form without a full reload. Islands mount again from the new page, and state in a shared module carries from page to page.
Upgrades before 1.0.
Before 1.0, APIs change between releases, and the release notes name each change. After an
upgrade, check names each line that uses a removed API and the docs
section to read, so the agent can fix it. The source opens at 1.0.
Install and run.
One program, one database file, no build step.