scrml.dev v0.7.1
scrml.dev › Learn › Server boundary

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 @contacts as 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 addContact function, the initial SELECT, 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 calls addContact. 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 some surface 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.

← Learn