The server boundary disappears
Tutorial — in 4 steps, build a contact book with persistent storage, no HTTP layer to wire, no ORM to configure, no separate schema file to keep in sync.
What you'll have at the end: a single
contacts.scrml
file that compiles to a working full-stack app. Server
handlers, client handlers, SQL schema, validation, all
in one file. The compiler decides what runs where.
Pre-req: you've finished
Getting started
and have
scrml dev
running.
Step 1: declare the schema
Per SPEC §39, the
<schema>
block declares your database structure. It is an immediate child
of <program>
(which carries the db=
connection). A top-level <schema>
outside <program>
fires E-SCHEMA-003.
<program db="./contacts.db">
<schema>
contacts {
id: integer primary key
name: text not null
email: text not null
phone: text
}
</schema>
<h1>Contact book</h1>
</program>
Save the file and run
scrml dev.
On reload the compiler diffs your
<schema>
against the live database at
./contacts.db
and applies the migration (the schema-differ runs during dev reload).
No separate schema-file-and-generator step. (Note:
scrml migrate
is a different command — it applies source-syntax deprecation
rewrites, not database migrations.)
Step 2: read from the database
The
?{ … }
context (SPEC §8) is SQL as syntax. The compiler reads
the SQL string against the
<schema>,
validates the columns, and emits a parameterised query
against the bound database.
<program db="./contacts.db">
<contacts> = ?{`SELECT id, name, email, phone FROM contacts ORDER BY name`}.all()
<h1>Contact book</h1>
<ul>
${ for (c of @contacts) { lift <li>${c.name} — ${c.email}</li> } }
</ul>
</program>
Three things to notice:
-
No
fetch(). The query runs server-side. The result lands in@contactsas a typed array of rows. The compiler placed the handler on the server, serialised the result for the client, and wired the reactive read. You wrote neither side. -
No ORM client.
There's nothing to
prisma generate. The column names came straight from<schema>; the row type is inferred at compile time. -
SQL stays SQL.
If your query needs a
JOIN, a window function, a CTE — you write SQL. No DSL-approximation tier.
Step 3: write to the database
Add a form. The submit handler is a
server function
— the compiler places it on the server (route
inference per §12). The client's onsubmit handler calls
it like a regular function; the compiler wires the RPC
transport.
<program db="./contacts.db">
<contacts> = ?{`SELECT id, name, email, phone FROM contacts ORDER BY name`}.all()
<newName: string req> = ""
<newEmail: string req> = ""
<newPhone: string is some> = ""
${
function addContact(name, email, phone) {
?{`INSERT INTO contacts (name, email, phone) VALUES (${name}, ${email}, ${phone})`}.run()
}
}
function submit() {
addContact(@newName, @newEmail, @newPhone)
reset(@newName)
reset(@newEmail)
reset(@newPhone)
}
<form onsubmit=submit()>
<input bind:value=@newName placeholder="Name">
<input bind:value=@newEmail placeholder="Email">
<input bind:value=@newPhone placeholder="Phone">
<errors of=@newName/>
<errors of=@newEmail/>
<button disabled=!@newName.isValid || !@newEmail.isValid>Add</button>
</form>
</program>
The
${name}
inside the SQL template is a bound parameter, never
string concatenation — SQL injection is structurally
impossible (per §8). The compiler refuses to emit
unbound interpolation. There is no
sql.raw()
escape hatch.
Step 4: see the reactivity
One thing left: after
addContact
inserts, the
@contacts
list should re-fetch. Re-assign the cell to re-run the read —
the compiler re-issues the query through the server boundary:
function submit() {
addContact(@newName, @newEmail, @newPhone)
reset(@newName)
reset(@newEmail)
reset(@newPhone)
@contacts = ?{`SELECT id, name, email, phone FROM contacts ORDER BY name`}.all() // re-run the read
}
For richer loading / error states, model the read as a
<request id=contacts>
element and call
<#contacts>.refetch()
(SPEC §6.7.6) — it exposes
.loading /
.error /
.data alongside the re-fetch.
Save, click "Add" in the browser — the new contact appears in the list without a page reload. The browser never saw the SQL; the server never saw the form HTML. The compiler handled the seam.
What the compiler actually did
From one file, three artifacts:
-
Server bundle.
The
addContactfunction, the initialSELECT, the migration script, and the RPC endpoint for the server-function call. Code that touches?{}blocks landed here automatically. -
Client bundle.
The reactive runtime for
@newName/@newEmail/@newPhone, the<errors>surface, the form submit handler, and the RPC client stub that callsaddContact. No ORM, no fetch wrapper, no framework runtime. -
Migration.
The compile-time diff between
<schema>and the live DB, as DDL ready to apply.
Where to go next
-
Validators tutorial
— deeper coverage of the
req/is somesurface this tutorial touched on lightly. - ?{ } reference — the full SQL-context surface: result-mode methods, multi-database adaptation, error handling.
- The ORM trap — the long-form why behind this design.