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

and events comes back as a JSON string
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

routes/groups/groups.lua
local groups = eql [[
  select groups { id, name, events { title, starts_at } }
]]
what comes back
[{ "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

string-building, and the % wraps you must remember
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

routes/contacts/contacts.lua
local rows = eql([[select contacts where .owner_id = $auth.id
  and? (.name like $q or .email like $q)
  order by .created_at desc]], { q = request.query.q })
same query, both states
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

hand-copy the safe fields, then upsert
-- 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

app/commands/import_contacts.lua
eql [[
  insert contacts { $row, owner_id: $owner_id }
  on conflict email update
]]
given a row carrying junk
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.

Grammar, relations, aggregates, transactions

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.

app/schema/schema.lua one rename, one new column
  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" },
  },
zsh
$ 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.

zsh
$ 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.

zsh
$ 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.

zsh
$ 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.

zsh
$ 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.

what the agent sets the host injects PORT
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.