# Sluurp > Sluurp is one executable that serves a backend and its app: collections of data in SQLite with rules enforced in SQL, a REST, realtime and sync API, authentication, an admin UI at `/_/`, server-drawn pages and islands, and a UI kit, with no build step and no `npm install`. Each page below links to its Markdown source. `/llms-full.txt` holds every page's text in one file. ## How to work with Sluurp - **An app is a folder**, served with `sluurp serve --public ./my-app`. The folder a file sits in decides what it does. `routes/` holds pages drawn on the server, `islands/` holds components that come alive in the browser, and `functions/`, `hooks/`, `jobs/`, `agents/` and `views/` hold server code. The data model is in `schema.json`. There is no config file. - **There is no build.** TypeScript and JSX are compiled as they are served. Imports are bare names from the import map Sluurp writes into every page: `sluurp` (the client), `sluurp/reactive` (signals), `sluurp/ui` (JSX runtime and templates), `sluurp/kit` (components in the shadcn style), `sluurp/sync`, `sluurp/pages`, and others. npm packages are added with `sluurp add `, which vendors their source into `vendor/`. - **Styling is Tailwind classes**, written straight into markup. The stylesheet is made from the classes the app uses. Theme colours are CSS variables, as in shadcn. - **Data** goes through the `sluurp` client: `sluurp.collection("todos").list({ filter, sort })`, `.create()`, `.update()`, `.delete()`. A list that stays current by itself is a Sync shape (`sluurp/sync`). Every call is checked against the collection's rules, which are compiled into SQL. On the server, code gets the same API through `server` and acts as the caller. - **Server code** runs in a QuickJS sandbox, with no file system, no `process`, and network access only through `fetch` to public addresses. Each call has a memory ceiling and a deadline. ## How to write a website with Sluurp - **A Markdown page** is a `.md` file under `routes/`; `routes/docs/sync.md` is `/docs/sync`. Its front matter gives `title`, `description`, and optionally `section` and `order` for a sidebar. Code fences can carry a `title="file.ts"` caption, and are highlighted on the server. - **A TSX page** is `routes/*.tsx`. Its default export is drawn on the server. It can load data first (`export async function load()`) and include islands. - **Layouts** are `_layout.js` files: `render(state, html)` wraps every page in that folder and the folders below it. `state.pages` lists the Markdown pages beside it, with their front matter, to make a sidebar from. - **Islands** are `islands/.tsx`, placed in a page with ``. Only islands send JavaScript to the browser. - **Publishing:** `sluurp static --public ./site --out dist` writes the site out as plain files for any file host, with its islands still working. Or serve it with `sluurp serve` behind a TLS proxy. - **Each Markdown page's source** is served at its URL plus `.md`, and `/llms.txt` is made from those pages, as this file was. ## Start # Getting started

Sluurp is one executable. It keeps its data in a folder of SQLite files and serves an API, an admin UI and your app. Nothing else needs installing.

## Install On macOS and Linux: ```sh title="Terminal" curl -fsSL https://raw.githubusercontent.com/SluurpHQ/sluurp/master/install.sh | sh ``` On Windows, in PowerShell: ```sh title="PowerShell" irm https://raw.githubusercontent.com/SluurpHQ/sluurp/master/install.ps1 | iex ``` Either puts the `sluurp` binary in `~/.sluurp/bin`. `SLUURP_VERSION=v0.2.0` picks a release, `SLUURP_INSTALL` another folder. To build it yourself, see [Development](/docs/development). ## Run it ```sh title="Terminal" sluurp superuser you@example.com a-long-password sluurp serve --public ./my-app ``` | Path | What | |---|---| | `/` | your app: the folder given to `--public` | | `/api/` | the REST, realtime and sync API | | `/_/` | the admin UI: collections, records, rules, logs, SQL, backups, jobs | | `/sluurp.js` | the browser client, imported as `"sluurp"` | ## From a git repository `--public` also takes a repository's address. It is cloned into the current folder, one commit deep, as `git clone` would, and served from there; run it again and the clone is brought up to date first. ```sh title="Terminal" sluurp serve --public https://github.com/SluurpHQ/sluurp/tree/master/website ``` A folder inside the repository can follow its address (`…/repo/reports`), and a branch or tag the end (`…/repo@v2`). Without a folder, the repository is the app, or its `app/` folder if the repository itself is not one. It is your own `git` that fetches, so a private repository works wherever `git clone` does. Changes you make in the clone are kept: it is not updated over them. ## A first collection Make one in the admin UI, or write the schema down and apply it. The file is the source of truth: applying it adds what is new and drops nothing. ```json title="schema.json" { "collections": [{ "name": "todos", "schema": [ { "name": "title", "type": "text", "required": true }, { "name": "done", "type": "bool" }, { "name": "author", "type": "relation", "relation": "users" } ], "rules": { "list": "author = @request.auth.id", "create": "author = @request.auth.id", "update": "author = @request.auth.id" } }] } ``` `sluurp serve --public ./my-app` applies the app's `schema.json` (inside the folder or beside it) when it starts, adding what is new and dropping nothing. `sluurp schema apply schema.json` does the same by hand, and `--drop` also removes what the file no longer has. ## A first page There is no bundler and no `npm install`. The page imports `sluurp` through the import map Sluurp writes into it. ```html title="my-app/index.html" ``` From here you can add a server-rendered page in [routes/](/docs/server-components), a function the browser can call with ["use server"](/docs/server-functions), or data kept current with [sync](/docs/sync). ## Ship it `sluurp compile` builds one binary that holds the server and your app. `sluurp push` and `deploy` store an app as an immutable bundle and point a channel at it; `rollback` returns a channel to the version before.
# An app's shape

An app is a folder. The folder a file is in decides what it does, so there is no config to write.

```text title="my-app/" index.html # a page the browser loads as it is pages/ app.tsx # browser modules, TypeScript compiled as served routes/ # server-drawn pages: docs/sync.md is /docs/sync islands/ # components a server page hands to the browser functions/ # /api/fn/, run in the sandbox hooks/ # .ts: beforeCreate, beforeUpdate… jobs/ # on a schedule: schedule = "0 7 * * *" agents/ # watch a collection, act on each change views/ # live views: state on the server i18n.json # the app's words, in every language sluurp-deps.json # packages from sluurp add, with their hashes vendor/ # those packages' sources schema.json # its collections, fields and rules ``` ## Where server code runs Functions, hooks, jobs, agents, views and server pages run in a QuickJS sandbox. It has no file system, no network and no `process`, and each call has a memory ceiling and a deadline. Server code reaches data only through `server`, the same collections API the browser has. It acts as the caller, so every rule still applies. A server file may import the app's own modules by relative path (`../lib/marks.ts`, `./room`, a folder's `index.ts`) and JSON files. Sluurp bundles them, from wherever the file itself was read, into the one module the sandbox runs. The sandbox gains no new access. ## Several apps, one server ```sh title="Terminal" sluurp serve --public ./school --public reports=./reports --public wiki=./wiki ``` The first app is served at `/`. Each named one is served at its own path (`/reports/`, `/wiki/`), with its own functions and jobs. ## The UI kit `sluurp/kit` is a set of components in the shadcn style: Button, Card, Dialog, Select, Tabs, Table, Command and Sidebar among them. They are built on `sluurp/ui`, a small signals-and-JSX runtime. Tailwind classes work without a build step, because the stylesheet is generated from the classes the app uses. See [UI kit](/docs/ui-kit) for every component, and to try them.
# Why SQLite

What Sluurp offers is one binary, one file and nothing to run beside it. That depends on an embedded database: a backend that first asks you to stand up Postgres has given up that promise before it starts.

## What an embedded database buys - **No network between the app and its data.** A query is a function call inside the process, not a round trip to another server. For the small indexed reads most pages make, that is most of the cost gone. - **Nothing to operate.** No database server to install, upgrade, tune, secure or wake up at night. `sluurp serve` is the whole deployment. - **A project is a file.** Each project is its own SQLite file, and nothing spans two. Tenancy is a folder of files, and a backup is a copy of one (see Backups in the admin UI). Moving a tenant is moving a file. - **Readers don't wait for each other.** In WAL mode, reads run concurrently with each other and with the writer. Sluurp's connection pool follows the CPU count, so reads scale with cores. - **The same engine in the browser.** [Sync](/docs/sync) can keep rows in SQLite in the browser, the official WebAssembly build, so SQL works the same on both sides, `FOR SYSTEM_TIME AS OF` included. - **Collections are real tables.** A collection is a table with real indexes, uniqueness and SQLite's query planner, not rows in a generic key-value blob. Rules and filters are compiled into that SQL with bound parameters. ## What it costs **One writer at a time.** SQLite serialises writes. Reads scale with cores, but writes don't. A write in Sluurp is a short transaction (the row, its history where the collection keeps one, its rule checked again), so the queue moves quickly. For most apps (a school, a shop, an internal tool, a SaaS with tenants in their own files) that's far more headroom than they use. An app that has to take tens of thousands of writes a second into one table needs something else. ## Why not something else | | Why not | |---|---| | **Postgres** | A server to run beside the binary, which is the thing Sluurp exists to remove. It stays the escape hatch for an install that outgrows one file: storage is behind a trait, so a Postgres driver is a driver, not a rewrite. | | **CockroachDB** | Written in Go, so it has no library form to embed. Since v24.3 the self-hosted Core edition is gone, and the source-available licence restricts redistribution. | | **pgrust** | Postgres rewritten in Rust, and progressing fast. It is AGPL-3.0, warns against storing data you care about in it, and its embeddable build is still on the roadmap. | | **Turso** | The Rust rewrite of SQLite: MIT, in-process, compatible with SQLite's file format, with async I/O and concurrent writes. The likely future engine, and switching to it would be a driver change, not a migration. That's why storage sits behind a trait. | ## How fast `sluurp bench` measures collection access where Sluurp does it: through its storage layer, as a request's handler calls it. That covers the rule compiled into the query, the filter parsed and bound, SQLite, and the row back as JSON. It runs in a fresh data directory that it throws away afterwards, and reports each operation per second, with the median and the 99th percentile of one call. ```sh title="Terminal" sluurp bench # a table, and a score sluurp bench --json # the same, for a page to show ``` On AMD Ryzen 7 9800X3D 8-Core Processor, 16 threads, Windows, 10,000 rows (Sluurp 0.1.0): | Operation | Callers at once | Per second | Median | p99 | |---|---|---|---|---| | Create, one caller | one | 6,308 | 0.124 ms | 0.336 ms | | Create | 32 | 3,939 | 4.656 ms | 60.301 ms | | Read one row by id, one caller | one | 21,728 | 0.04 ms | 0.098 ms | | List 20, filtered on an index, one caller | one | 7,975 | 0.116 ms | 0.199 ms | | Read one row by id | 32 | 25,847 | 0.66 ms | 14.915 ms | | List 20, filtered on an index | 32 | 23,222 | 1.354 ms | 3.417 ms | | Count by group (10000 rows) | 32 | 24,041 | 1.084 ms | 5.323 ms | | List 20 under a row rule | 32 | 41,618 | 0.681 ms | 2.423 ms | | Update one field | 32 | 7,721 | 2.186 ms | 41.342 ms | | Create with history kept | 32 | 2,946 | 6.399 ms | 72.496 ms | | List 20 as it stood (asOf) | 32 | 6,513 | 4.044 ms | 29.912 ms | | Delete | 32 | 1,912 | 10.319 ms | 83.838 ms | **Score: 2,048.** The score is the geometric mean of each measure's speed relative to a reference run on this same machine, times 1,000. Every measure counts alike, whatever its scale, and a machine twice as fast at everything scores 2,000. Two runs on one machine differ by a few percent. This machine scores about twice the reference because the reference run came before lists "as it stood" were made faster, and before writers stopped sleeping through their turn at the lock. ### Against bare SQLite The same operations straight through SQLite (rusqlite, the same settings, one caller, prepared statements), with no rules, no filter parsing, no pool and no JSON. It shows what Sluurp adds on top of the engine: | Operation, one caller | Per second | Median | p99 | |---|---|---|---| | Create, one at a time | 11,404 | 0.065 ms | 0.161 ms | | Read one row by id, one caller | 214,210 | 0.005 ms | 0.005 ms | | List 20, filtered on an index, one caller | 47,475 | 0.021 ms | 0.03 ms | | Count by group (10000 rows), one caller | 4,651 | 0.21 ms | 0.348 ms | A row read by id takes about 5 µs in bare SQLite and about 45 µs through Sluurp. The difference is the view rule compiled into the query, the call handed to a pooled connection, and the row turned into JSON. A create costs about 0.07 ms bare and 0.11 ms through Sluurp, which also checks the create rule again inside the transaction. How to read the table: - **Reads don't wait for each other.** A row by id, a page filtered on an index, a page under a row rule and an aggregate over 10,000 rows each take about a millisecond, and tens of thousands run a second across cores. - **Writes take turns.** One caller creates a row in about 0.1 ms. Many callers at once share SQLite's single writer, so their throughput is about the same and each one waits its turn. - **The past is quick too.** A list "as it stood" (`asOf`) is rebuilt by SQLite in one statement from the change log, and kept on the connection until something at or before that moment changes. It used to take about a second on this data. - **Deletes keep up.** A delete also writes the row to the recycle bin, and still runs about as fast as a create with history kept. Writers waiting their turn used to sleep up to 100 ms between tries, while the lock was free for most of that; they now try again within a fraction of a millisecond, which made 32 deletes at once two and a half times faster than before (778 a second), and updates twice as fast. ## Beyond one machine Several Sluurp nodes can serve one data directory behind a load balancer. Each node is told where it can be reached (`--advertise`, `--node-id`), and a tab stays on one node through a cookie the balancer pins on. Every node needs the same `--dir`, on storage they can all read and write, and that storage is the part with real constraints: the README's "Sharing the data directory" section covers them. Projects are separate files, so spreading them over more machines later is a routing problem (move a file, update a map), not a distributed-join problem.
## Data # Collections and rules

A collection is a real SQLite table. Its rules are compiled into the same query that fetches its rows, not checked afterwards.

## Fields The field types are `text`, `number`, `bool`, `email`, `url`, `date`, `json`, `select`, `relation` and `file`. A collection may declare composite and unique indexes. A collection that declares `"extends": "_users"` holds an app's own profile of a person, under the same id as their identity. ## Five rules ```json title="schema.json" "rules": { "list": "@request.auth.id != null", "view": "@request.auth.id != null", "create": "author = @request.auth.id", "update": "author = @request.auth.id", "delete": "author = @request.auth.id || @request.auth.staff = true" } ``` - With no rule, only superusers have access; `""` means anybody. - A list rule filters: a caller gets only the rows they may see, and the counts stay correct. - View, update and delete answer 404 rather than 403, so a rule can't be used to learn that a row exists. - After a write, the row is checked again inside its transaction. A write that would move a row out of the writer's reach is rolled back. Fields can have their own rules too, such as who may read a pupil's grade or change a status. They are enforced in the same SQL. ## The API ```http title="HTTP" GET /api/collections/todos/records?filter=done=false&sort=-created POST /api/collections/todos/records PATCH /api/collections/todos/records/:id DELETE /api/collections/todos/records/:id // server-sent events, each row read as you GET /api/realtime?subscribe=todos ``` ```ts title="Browser" import { Sluurp } from "sluurp"; const sluurp = new Sluurp(); await sluurp.collection("todos").create({ title: "Milk", author: sluurp.auth.id }); const open = await sluurp.collection("todos").list({ filter: "done = false" }); ``` Filters are parsed and compiled with bound parameters. A filter that names a field that doesn't exist is refused before any SQL is built. ## Projects Each project is its own database file, and no query spans two. An app is pinned to one project, so its functions can't reach another app's data.
# History and AS OF

A collection that keeps its history logs every change in the same transaction as the write: the record as it became, who changed it, when, and why.

## Turn it on Turn it on in the admin UI, or set `"history": true` in the schema. The rows the collection already has are logged once, as they stand, so the history starts complete. ## Read the past ```ts title="Browser" // the collection as it stood at the end of June await sluurp.collection("grades").list({ asOf: "2026-06-30" }); // one record's versions, newest first; and bringing one back await sluurp.collection("grades").history(id); await sluurp.collection("grades").restore(id, seq, { reason: "Undo" }); ``` ```http title="HTTP" GET /api/collections/grades/aggregate?op=avg&field=value&group=subject&asOf=2026-06-30 // every change, in order GET /api/collections/grades/changes?since=0 ``` Each past version is read through the collection's view rule, as it applies to that version. History never shows a reader anything they could not have seen. A moment of the past is rebuilt once and kept, so paging through it or asking again is fast. The server keeps a few moments per collection. If too many people ask for new moments at once, the extra requests get `429 Too Many Requests` and can try again a second later, so looking at the past never slows everyone else down. ## Say why ```ts title="Browser" await sluurp.collection("grades").update(id, { value: 7 }, { reason: "Re-marked after appeal" }); ``` Over HTTP, the `X-Sluurp-Reason` header does the same. A [batch](/docs/batch) gives one reason for all of its changes. ## In SQL The SQL console in the admin UI understands SQL:2011's temporal syntax. Plain SQL still reads the latest rows. ```sql title="SQL" SELECT subject, avg(value) FROM grades FOR SYSTEM_TIME AS OF '2026-06-30' GROUP BY subject; ``` Before SQLite sees the query, each such table is replaced by its rows as they stood, rebuilt from the change log under the table's own name. The same works in the browser on rows [synced into SQLite](/docs/sync). ## Pages Pages' version history is the same log: who changed a page, when, and the reason they gave. Restoring a page means restoring one of its versions.
# Sync

A shape is a collection narrowed by a filter. The server sends its rows, then every row that enters, changes or leaves it, so the page never fetches, refetches or patches a list by hand.

```tsx title="Browser" import { Sync } from "sluurp/sync"; const sync = new Sync(); const marks = sync.shape("grades", { filter: 'class = "class4a0000000"' });
    {() => marks.rows().map((g) =>
  • {g.value}
  • )}
// shown at once await marks.update(id, { value: 8 }, { reason: "Re-marked" }); ``` - **One WebSocket** (`/api/sync`) carries every shape on a page, and reconnects by itself. - **Every row is read as the person signed in,** through the collection's rules. Each change is read again for each client before it is sent, so no update can reveal a row that person may not see. - **An older row never replaces a newer one,** because the server sends the row as it is now, not the event that changed it. - **Writes show at once** and go through the ordinary API. If the server refuses one, it is taken back. - **`keep: true` shows the last rows at once on the next visit,** from the browser's storage, until the server's arrive. It is off by default, since the rows stay on the device. They are kept in IndexedDB, and only while a shape is small (up to 2,000 rows): a larger one is simply sent again. What is kept is kept per person, so the next person at a shared computer never sees the last one's rows, and signing out removes it all. ## SQL in the browser By default, rows live in a plain in-memory store. `sluurp/sync/sqlite` keeps them in SQLite in the browser instead (the official WebAssembly build, vendored), with live queries over them. ```ts title="Browser" // only on the page that needs it const { openSqlite } = await import("sluurp/sync/sqlite"); const db = await openSqlite(); sync.shape("study_events", { filter: `page = "${id}"`, store: db.store("study_events") }); const recent = db.live("SELECT author_name, text FROM study_events ORDER BY created DESC LIMIT 20"); ``` ## The past, synced once A shape with `asOf` is sent once, as it stood, into a table named after its collection and that moment, `@`: `grades` as of `2026-06-30` is the table `grades@2026-06-30`. Local SQL can then use `FOR SYSTEM_TIME AS OF`, as the server's console does. ```ts title="Browser" sync.shape("grades", { asOf: "2026-06-30", store: db.store("grades", { asOf: "2026-06-30" }) }); db.live("SELECT avg(value) FROM grades FOR SYSTEM_TIME AS OF '2026-06-30'"); ``` A chat that agents answer is built this way: the conversation and the tables behind it are live queries over synced rows, while [agents](/docs/agents) answer on the server. ## Cursors `sluurp/cursors` shows everybody's pointer over a page, each person in a colour of their own with a name beside it. Positions are sent over `/api/ws` without being stored, at most once a frame. Each one is eased toward where it was last seen on every frame, so the glide stays smooth when the network isn't. ```ts title="Browser" import { liveCursors } from "sluurp/cursors"; const stop = liveCursors("todos:*", { over: document.querySelector("main") }); ``` A topic is a record (`lists:abc`), or a whole collection (`todos:*`) that anyone who may list it can join.
# Batches and reasons

A batch makes several writes as one: either all of them happen or none do. A reason can go with them, and it is kept with every change in collections that keep history.

```http title="HTTP" POST /api/batch X-Sluurp-Reason: Term two timetable { "requests": [ { "method": "POST", "url": "/api/collections/lessons/records", "body": { "day": "mon", "slot": 1 } }, { "method": "PATCH", "url": "/api/collections/lessons/records/l0000000000001", "body": { "slot": 2 } }, { "method": "DELETE", "url": "/api/collections/lessons/records/l0000000000002" } ] } ``` - A batch is one transaction. If any request is refused, by a rule or by validation, nothing is written, and the answer says which request failed. - Each request is checked as the caller, as it would be on its own. - A batch refuses collections that have a write hook, because a hook runs outside the transaction. - The reason, given as the header or as `"reason"` in the body, is kept with every change the batch makes. ## One write at a time ```ts title="Browser" await sluurp.collection("grades").update(id, { value: 7 }, { reason: "Re-marked after appeal" }); await sluurp.collection("grades").delete(id, { reason: "Entered twice" }); ``` Reasons show in a record's [history](/docs/history), beside who made the change and when.
# Sign-in and users

People are a collection like any other, with sign-in built in. There is no auth service to add: accounts, sessions and permissions live in the same binary as the data they protect.

## Signing in ```ts title="app.ts" const users = sluurp.collection("users"); await users.authWithPassword(email, password); sluurp.authStore.record; // who is signed in sluurp.logout(); ``` A sign-in answers a token that lasts 14 days; `authRefresh()` swaps it for a fresh one. Passwords are at least 8 characters and stored as Argon2 hashes. | | | |---|---| | **A link by mail** | `requestSigninLink(email)`: no password at all. The link works once, and expires | | **Password reset** | `requestPasswordReset(email)` mails a link; `confirmPasswordReset(token, password)` sets the new one | | **Other providers** | Google, GitHub, Microsoft, GitLab, or any OpenID Connect provider, set up in the admin UI under **Sign-in**. `location.href = users.oauthUrl("google")`, and `captureOAuthToken(sluurp)` on the page it comes back to | | **Two-factor codes** | Six digits from an authenticator app, after the first factor. Five wrong tries and the attempt is over | There are no recovery codes: someone who loses their phone asks an administrator, who turns the second factor off from their record and signs them out everywhere. ## Who may join A setting on the app, enforced by the server: - `invite`, the default: only with an invitation. - `open`: anyone may sign up, under the collection's create rule. - `closed`: nobody, for accounts that come from a directory. An invitation is filled in twice. Whoever invites decides the fields that are theirs to decide, such as the organisation and the role, and the sign-up can't change them. The person joining fills in the rest: their name, a password. ```ts title="app.ts" await users.invite("parent@example.com", { fields: { org: school.id, roles: ["parent"] }, collect: ["first_name", "last_name"], }); ``` ## Roles A person's `roles` is a list that [rules](/docs/rules) can ask about: `@request.auth.roles ~ "teacher"`. It matches whole roles only, so "admin" never matches "superadmin". ## For administrators - **Impersonate** someone from their record in the admin UI, to see the app as they do. - **Sign out everywhere**: revoking a person's tokens ends every session they have, at once. - **Superusers** are separate from the app's people: `sluurp superuser EMAIL PASSWORD` makes one.
# Files and images

A file field keeps an upload with its record, under the same rules. Images are resized, cropped and converted when asked for, so a thumbnail costs a thumbnail's bytes.

## Uploading ```ts title="app.ts" const photos = sluurp.collection("photos"); await photos.upload(record.id, "image", input.files[0]); // or make the record and upload in one step await photos.createWithFile({ caption: "Sports day" }, "image", file); ``` Over HTTP it is `POST /api/files/{collection}/{id}/{field}`, multipart, in a part called `file`. Uploading is a change to the record, so its update rule decides; reading the file is reading the record, so its view rule does. ## Images at the size they're shown ```ts title="app.ts" img.src = photos.fileUrl(record.id, "image", { w: 320, h: 320, fit: "cover" }); img.srcset = photos.srcset(record.id, "image", [400, 800, 1200]); ``` | Parameter | | |---|---| | `w`, `h` | Width and height, up to 4000 | | `fit` | `contain` (the default: all of it, inside the box) or `cover` (fill the box, cropped) | | `format` | `jpeg`, `png` or `webp` | | `q` | Quality, for `jpeg` and `webp` | A resized image is made once and kept, keyed by the file and the size, so a new upload never shows the old picture. The kept copies can be deleted at any time; they are made again when asked for. A file that isn't public is fetched with the sign-in: `fileObjectUrl(id, field)` gives an address an `` can use, and `fileText(id, field)` its text. ## Where files are kept On disk, beside the database. For more than one server, set a bucket on any S3-compatible service (AWS, Cloudflare R2, MinIO, Backblaze) in the environment. Every upload then goes to the bucket too, and a server that doesn't have a file fetches it from there: ```sh title="Terminal" SLUURP_S3_BUCKET=school-files SLUURP_S3_ENDPOINT=https://.r2.cloudflarestorage.com SLUURP_S3_REGION=auto SLUURP_S3_ACCESS_KEY_ID=… SLUURP_S3_SECRET_ACCESS_KEY=… ``` ## How much may be stored A project can have a storage allowance, and each person one too, set in the admin UI. An upload that would go past it is refused before it's written. Without one, there's no limit. The admin UI shows an image as itself in its record, and lets you replace or remove it.
# Existing SQLite databases

Already have a SQLite file? Attach it, and each of its tables becomes a collection, with an API, rules, live updates and the admin UI, read and written where it is. Nothing is copied, and nothing is added to the file.

```sh title="Terminal" sluurp serve --attach shop=./northwind.db ``` That's all it takes to explore it: no app needed. Open the admin UI at `/_/`, where each table is a collection to browse, filter, edit and query with SQL. Add `--public ./app` when you also have a frontend to serve. - `products` in `shop` becomes the collection `shop_products`; a column `ProductName` becomes the field `product_name`. - Writes go straight to your table, so other programs using the file see them at once. - A new collection starts closed: only administrators see it until you give it [rules](/docs/collections). - Attach several files by repeating `--attach`. - A table without a text `id` column keeps its ids in Sluurp's own file, beside each row's rowid. A row written by another program shows its rowid as its id. The file is attached to each connection under its name, and each table is seen through a temporary view with triggers that write through to it, made as a connection opens and gone when it closes. That is why nothing is added to your file. History, search and the recycle bin apply to what is written through Sluurp; a change another program makes to the file directly is seen, but not recorded.
# Migrations

An app's migrations/ folder holds its data model and its data, one version per file. Each runs once, in order, when the server starts. A new install gets every version; an existing one gets the ones it hasn't had yet.

```text title="migrations/" V1__init.json # the collections, as schema.json has them V2__sample_data.json # records: leave the file out and there is no sample data V3__school_year.js # data made up or moved, in JavaScript V4__trips_price.json # a change to the data model, as a patch V5__tidy.sql # SQL, when that is the simplest thing to say R__views.sql # repeatable: runs again whenever it changes ``` ## Versions A file is `V__`: `V1`, `V2`, `V2_1` (between `V2` and `V3`). What ran is recorded in the database with a checksum of the file. **A file that already ran must not change**: if it does, the server stops before running anything after it and says which file. Put the change in a new version instead. An `R__` file runs after the versioned ones, and again each time its contents change. `migrations/` sits in the app's folder, or beside it, where it is shared by the apps of one repository (an app and its `reports/`, say) and runs once for them all. ## Kinds of file **`.json`** is an import document, in the same form as `schema.json`: `collections`, then `records`. Records go in the order their relations need, so a class exists before the absences that name it. Besides records, an import document can hold what isn't a collection: `settings`, `conversations` with their messages, `pages` with their blocks and rows, social `feeds`, and `posts` in them. A post has an `id`, a `feed`, an `author` and a `body`. It can also have `reply_to` (an answer, in its thread's feed), `created`, `likes` (who liked it) and `pinned`. Posts go in the same way as ones made in the app, so their counts, `#tags` and search agree. A page that is already there is left as it is, unless the document says `"merge": true`: then its folder settings, values, template, navigation, sign-ups and look are updated, and its words and rows stay. ```json title="migrations/V5__news.json" { "posts": [ { "id": "welcome", "feed": "school", "author": "principal0001", "body": "Welcome back! #backtoschool", "pinned": true }, { "id": "welcome-1", "reply_to": "welcome", "author": "parent0000001", "body": "Thank you!" } ] } ``` **A patch** changes the data model by name. It is a JSON merge patch: an object adds or changes, `null` takes away, along with the data it held. It is the one kind of migration that removes anything, and only what it names. ```json title="migrations/V4__trips_price.json" { "patch": { "trips": { "fields": { "price": { "type": "number" }, "old_note": null } }, "invoices": null } } ``` A file can hold both: the patch runs first, then its collections, then its records, so a version's data model and its data go together. **`.js` or `.ts`** export a function, run like a [job](/docs/jobs), with `ctx.collection("trips").update(…)`. It can also return a document (`{ records: … }` or a `patch`), which is imported as a `.json` file's would be. That is the way to generate a lot of data: ```js title="migrations/V3__school_year.js" export default function () { const grades = pupils.flatMap((p) => marksFor(p)); return { records: { grades } }; } ``` **`.sql`** runs as one script, in one transaction. ## Running them They run when `sluurp serve` starts, before the app's agents. To see what would run, or to run them without serving: ```sh title="Terminal" sluurp migrate ./app --plan sluurp migrate ./app ``` ## All or nothing Before the first migration that's due, Sluurp takes a snapshot of the database, as a backup does: `snapshots/-migration/`. Then the migrations run in order. If one fails, the snapshot is put back: the collections, the records and the record of what ran, as they were before this run. The error names the file. So nothing is left half done. A `.json` file's collections and records go, and so does every write a `.js` file made before it threw, along with the versions before it in the same run. An app starts with all of its new migrations, or none of them. Fix the file and start again. The snapshot is kept either way, a backup from just before the upgrade. The admin UI's **Backups** lists it. An app with only a `schema.json` keeps working as before: on every start its collections are added or changed, and nothing is removed.
# REST API

Every collection has an API the moment it exists. The browser client (sluurp.js) is a thin layer over it, so anything the client does, a script or another program can do with plain HTTP.

A request is made as whoever its token says: `Authorization: Bearer `, from signing in. Without one it is made as a visitor. Either way, the collection's [rules](/docs/collections) decide what it may read and write. ## Records | Method and path | What it does | |---|---| | `GET /api/collections/{name}/records` | A page of records | | `POST /api/collections/{name}/records` | Create one; answers `201` and the record | | `GET /api/collections/{name}/records/{id}` | One record | | `PATCH /api/collections/{name}/records/{id}` | Change the fields given, and only those | | `DELETE /api/collections/{name}/records/{id}` | Delete it, into the recycle bin; answers `{ "bin": … }` | | `GET /api/collections/{name}/aggregate` | Count, sum, average, minimum or maximum, by group | A list takes these in its query string: | Parameter | | |---|---| | `filter` | Which records: `status = "open" && priority > 2` (see below) | | `sort` | Fields, comma separated, `-` for descending: `-created,title` | | `page`, `perPage` | Which page, and how many on it | | `search` | Words to find in the collection's searchable fields | | `asOf` | The collection as it stood then, for one that keeps [history](/docs/history): `2026-06-30` | | `near`, `on` | Rows closest in meaning to these words first ([Search by meaning](/docs/search)); `on` names the fields compared | ```http title="HTTP" GET /api/collections/grades/records?filter=value%20%3E%3D%208&sort=-date&perPage=20 Authorization: Bearer eyJ… ``` A page answers with the records and where they are in the whole: `items`, `page`, `perPage`, `totalItems`, `totalPages`. ## Filters A small language, compiled into the query, so a filter never reaches the database as text of its own. Field names are checked against the collection, and values are always bound. | | | |---|---| | Compare | `=` `!=` `>` `>=` `<` `<=` | | Contains, or doesn't | `~` `!~`: `title ~ "trip"` | | Combine | `&&` `\|\|` and parentheses | | Values | `"text"` or `'text'`, numbers, `true`, `false`, `null` | ```text title="filter" (status = "open" || status = "waiting") && priority >= 2 && title !~ "test" ``` ## Search by meaning `near=museum trips` answers the rows closest in meaning first, each with its `_score` (1 is the same). It compares the collection's searchable fields, or the ones named in `on=title,body`. Rules and `filter` apply as on any list. See [Search by meaning](/docs/search) for how it works, and for setting up an embeddings model. ```js title="client" const { items } = await sluurp.collection("notes").list({ near: "museum trips" }); ``` ## Counting and summing `GET /api/collections/{name}/aggregate` takes `op` (`count`, `sum`, `avg`, `min`, `max`), `field` for all but `count`, `group` (a field to group by, and more to group further), and `filter` and `asOf` as a list does: ```http title="HTTP" GET /api/collections/grades/aggregate?op=avg&field=value&group=subject ``` ## History and the recycle bin For a collection that keeps its history: | Method and path | | |---|---| | `GET …/records/{id}/history` | Every version of a record, newest first, with who and why | | `POST …/records/{id}/history/{seq}/restore` | Put a version back | | `GET /api/collections/{name}/changes?since=N` | Every change since the one numbered `N`, in order, to follow along | A write can say why it was made, in an `X-Sluurp-Reason` header, and the reason is kept with the version. A deleted record goes into the recycle bin, and `POST /api/bin/{bin}/restore` puts it back, with what the delete took with it. ## Files | Method and path | | |---|---| | `GET /api/files/{collection}/{id}/{field}` | The file. For an image, `?w=`, `?h=`, `?fit=`, `?format=` and `?q=` resize and convert it | | `POST /api/files/{collection}/{id}/{field}` | Upload one, as multipart form data in a part called `file` | | `DELETE /api/files/{collection}/{id}/{field}` | Remove it | ## Signing in | Method and path | | |---|---| | `POST /api/collections/{name}/auth-with-password` | `{ "identity", "password" }`: answers a token and the record | | `POST /api/collections/{name}/auth-refresh` | A fresh token for the one sent | | `POST /api/collections/{name}/signup` | Make an account, where the collection allows it | | `POST /api/collections/{name}/request-password-reset` | Mail a link to set a new password | ## Errors An error is a status and a sentence: `{ "status": 404, "message": "record grades/x not found" }`. `400` is a request that doesn't make sense, `401` needs signing in, `403` is refused by a rule, `404` isn't there (or isn't there for you), `409` conflicts with what exists, and `429` is too much at once: try again shortly. ## And more Several writes as one, all or none: [`POST /api/batch`](/docs/batch). A server function: `GET` or `POST /api/fn/{name}` ([Server functions](/docs/server-functions)). Live changes, over one WebSocket: [Sync](/docs/sync). Events from outside: [`POST /api/events/{name}`](/docs/hooks-and-events).
# Rules

A rule is a filter that names the caller. It is compiled into the SQL that reads or writes the rows, so a row somebody may not see never leaves SQLite.

## Where rules go Each collection has five, one per action. Leave one out and only superusers may do it; `""` lets anybody. ```json title="migrations/V1__init.json" "rules": { "list": "@request.auth.id != null", "view": "@request.auth.id != null", "create": "author = @request.auth.id", "update": "author = @request.auth.id", "delete": "author = @request.auth.id || @request.auth.staff = true" } ``` A field can have two of its own: `visible`, who may read it, and `writable`, who may set it. Both default to whoever the collection already let in. ```json title="migrations/V1__init.json" { "name": "mark", "type": "number", "visible": "@request.auth.id = pupil || @request.auth.staff = true", "writable": "@request.auth.roles ~ \"examiner\"" } ``` ## The language The same one [filters](/docs/api#filters) use: | | | |---|---| | Compare | `=` `!=` `>` `>=` `<` `<=` | | Contains, or doesn't | `~` `!~` | | Combine | `&&` `\|\|` and parentheses | | Values | `"text"`, numbers, `true`, `false`, `null` | | The record | its field names: `author`, `org`, `status` | | The caller | `@request.auth.…` | | Moments | `@now`, `@days_ago.30`, `@years_ago.13` | ## What a rule can say about the caller - `@request.auth.id`, `collection`, `email` and `superuser` come from the token and cost nothing. - Any other field of the caller's own record works too: `@request.auth.staff`, `@request.auth.org`. Sluurp reads that record only when a rule asks, by its id, in the same transaction. - `@request.auth.roles` is a set. `@request.auth.roles ~ "admin"` asks whether "admin" is one of them, so it never matches "superadmin". - For somebody who isn't signed in, every one of these is `null`. A rule written for members is simply false for them, never an error. ```text title="rule" @request.auth.staff = true && org = @request.auth.org && id != @request.auth.id ``` Staff may delete people in their own organisation, but not themselves. ## How they are enforced - **Lists filter.** A caller gets the rows they may see, and the counts are right. - **View, update and delete answer 404** when the rule says no, so a rule can't be used to find out that a row exists. - **Writes are checked twice.** The row is checked before the change and again inside the transaction after it. A change that would put a row out of the writer's own reach is rolled back. - **A rule about the caller alone is settled before the query.** `@request.auth.roles ~ "admin"` becomes true or false and the query never sees it. A rule about the record becomes part of the query's `WHERE`. - **Field rules too.** A field that nobody in the caller's position may see is not selected. One that depends on the row is blanked row by row, in SQL. ## Asking before trying `GET /api/acl` answers, for the signed-in caller, what each collection lets them do: `allow`, `deny`, or `conditional` (they may, for some rows, such as their own). A screen uses it to show only the buttons that can work. It is the same rules asked in advance, not a second set, so the two can't disagree.
# Browser client

Every Sluurp server serves its client, and every app's import map already names it: import { Sluurp } from "sluurp". Nothing to install. It speaks the REST API and keeps the sign-in.

```ts title="app.ts" import { Sluurp } from "sluurp"; const sluurp = new Sluurp(); const todos = sluurp.collection("todos"); const { items } = await todos.list({ filter: "done = false", sort: "-created" }); const todo = await todos.create({ title: "Milk" }); await todos.update(todo.id, { done: true }, { reason: "bought" }); ``` `new Sluurp()` talks to the server that served the page. Give it an origin, `new Sluurp("https://school.example.com")`, to talk to another, and `{ project }` to address a project other than the default. ## Collections `sluurp.collection(name)` has: | | | |---|---| | `list({ page, perPage, sort, filter, asOf })` | A page: `{ items, page, perPage, totalItems, totalPages }` | | `listAll(options)` | Every page, as one array | | `getOne(id)`, `getFirst(filter)` | One record | | `create(data)`, `update(id, data)`, `delete(id)` | Writes. Each takes `{ reason }`, kept with the change | | `history(id)`, `version(id, seq)`, `restore(id, seq)` | A record's past, for a collection that keeps it | | `changes(since)` | Every change after one, in order | ## Files | | | |---|---| | `upload(id, field, file)` | Put a `File` or `Blob` in a record's field | | `createWithFile(data, field, file)` | Make the record and upload in one step | | `fileUrl(id, field, { w, h, fit, format })` | An address for an ``, resized as asked | | `srcset(id, field, [400, 800, 1200])` | The `srcset` for the same, at those widths | | `fileObjectUrl(id, field)`, `fileText(id, field)` | A protected file, fetched with the sign-in | ## Signing in On the collection that holds people, usually `users`: ```ts title="app.ts" const users = sluurp.collection("users"); await users.authWithPassword(email, password); sluurp.authStore.isValid; // signed in sluurp.authStore.record; // who sluurp.logout(); ``` There are also `signUp`, `requestPasswordReset`, `requestSigninLink` (a link by mail), `verifyTwoFactor`, `authRefresh`, and `oauthUrl("google")` for a provider, with `captureOAuthToken(sluurp)` on the page it comes back to. The sign-in is kept in `localStorage`, shared by every page of the site. `sluurp.onAuthFailure` is called when the server stops accepting it. ## What may I do? ```ts title="app.ts" const may = await sluurp.permissions(); may.can("grades", "update"); // for some rows at least may.certainly("grades", "delete"); // for every row ``` The collections' own [rules](/docs/rules), asked in advance, to show only the buttons that will work. ## Errors A failed call throws a `SluurpError` with `status`, `message` (the server's sentence), `body`, and `isAuthError` (401) and `isForbidden` (403) to tell them apart. ## Live `sluurp.socket({ subscribe: ["messages"] })` opens one WebSocket, with every change to those collections as it happens, read as you. `on(type, listener)` listens; `join(topic)` and `leave(topic)` for presence; `emit(topic, event, data)` says something without keeping it. For a list that stays current by itself, use [Sync](/docs/sync). ## And the rest `sluurp.conversation(id)` and `sluurp.conversations` for chat, `sluurp.pages` for [Pages](/docs/pages), `sluurp.payments` and `sluurp.billing` for [Payments](/docs/payments), `sluurp.ai` for the models, `sluurp.social` for a feed. And `sluurp.send(path, { method, body, query })` for any endpoint, with the sign-in and the app attached.
# Realtime

Ask for a collection and you are told of every create, update and delete in it as it happens. Each change is read through the watcher's own rules, so nobody is told about a row they couldn't fetch.

## Server-sent events One request, and the browser's own `EventSource`: ```ts title="app.ts" const events = new EventSource("/api/realtime?subscribe=messages,notices"); events.addEventListener("record", (e) => { const { action, collection, record } = JSON.parse(e.data); // action: "create", "update" or "delete" }); ``` Leave out `subscribe` for every collection the caller can see. The first event is `connected`, with what was subscribed. `EventSource` can't send headers, so a signed-in page adds its token to the address: `&token=…`. ## A WebSocket `sluurp.socket()` in the [browser client](/docs/client) does the same over one WebSocket (`/api/ws`), and more: ```ts title="app.ts" const socket = sluurp.socket({ subscribe: ["messages"] }); socket.on("record", ({ action, record }) => show(record)); socket.join("room:4b"); // be counted among those present socket.emit("room:4b", "typing", { by: me }); // said, not kept socket.send("messages", { body: "Hello" }); // a write, under the same rules as over HTTP ``` It reconnects by itself and joins its topics again. ## What it costs Watchers are grouped before anything is read. A rule that doesn't name the caller gives everyone the same answer, so it is asked once for all of them: ten thousand anonymous watchers cost one query. Only a rule like `author = @request.auth.id` is asked per person, and people who are the same person share it. ## Or let it keep itself Most screens don't want events; they want a list that is right. [Sync](/docs/sync) keeps a filtered collection current in the browser from the same changes, and [live views](/docs/live-views) keep server-rendered HTML current.
# Search by meaning

Ask a collection for the rows nearest to some words, and they come back closest first. With an embeddings model, "excursion" finds the museum trip. Without one, it still works on the words. The vectors are kept in the same SQLite file as the rows, and there is no index to build.

```js title="client" const { items } = await sluurp.collection("notes").list({ near: "museum trips" }); ``` ```http title="HTTP" GET /api/collections/notes/records?near=museum%20trips ``` Each row comes with a `_score`: 1 is the same, and lower is further away. Everything else about a list still applies. The caller sees only the rows the [rules](/docs/rules) let them list, and `filter` narrows them before they are ranked: ```js title="client" await sluurp.collection("notes").list({ near: "a pupil who is struggling", filter: `class = "4B"`, perPage: 10, }); ``` ## Which fields are compared By default, the collection's searchable fields are compared: those with `"searchable": true` in the schema, the same fields `search=` looks in. A collection with none compares its text, email and URL fields. `on` names others: ```js title="client" await sluurp.collection("posts").list({ near: "school trip", on: "title,body" }); ``` ## Meaning, or words What the vectors capture depends on the AI settings in the admin (**Platform → AI**). - **With an embeddings model**, near means near in meaning. It can be any server that speaks the OpenAI embeddings API: Ollama on your own machine, OpenAI, or others. On Ollama, run `ollama pull nomic-embed-text`, set the base URL to `http://localhost:11434/v1` and the embeddings model to `nomic-embed-text`. Rows then leave the server only for that machine. - **Without one**, the vectors are made from the words and their three-letter pieces. "museum trips" still finds "Museum trip — 4B", and a typo is forgiven, but "excursion" does not find it. This needs nothing and sends nothing anywhere. Changing the model is safe. Vectors are kept per model, so the new one makes its own as they are needed. ## `near` and `search` | | `search=` | `near=` | |---|---|---| | Finds | rows containing these words | rows about the same thing | | Order | as `sort` says | closest first | | Behind it | SQLite's full-text index | vectors, compared one by one | | Good for | names, codes, exact phrases | questions, topics, "anything like this" | Use `search` when the reader knows the words, and `near` when they know what they mean. ## How it works - **A vector is made the first time a row is searched**, and kept with a hash of the words it was made from. When those words change, it is made again. Nothing is done when rows are written, so writes cost what they always did. - **Vectors are rows in an ordinary table** in the project's database (`_vectors_`), and are backed up with everything else. They are compared by [sqlite-vector](https://github.com/sqliteai/sqlite-vector) (Apache-2.0), which is compiled into the binary. - **The comparison is exact.** Every row the caller may list, up to 5,000 after `filter`, is compared, so there is no approximate index to tune and no row missed by one. For a larger collection, use `filter` to narrow it first: a class, a term, the last year. - **A page has at most 500 rows**, as any list. ## The social feed Posts are found the same way, from the feeds the reader may read: ```js title="client" const { items } = await sluurp.social.feed({ near: "lost property" }); ``` ```http title="HTTP" GET /api/social/feed?near=lost%20property ``` MikroSchool uses this in its search palette (Ctrl-K), where posts are found by what they are about as well as by their words.
## Server # Functions and "use server"

Server code in Sluurp is JavaScript or TypeScript that runs in a sandbox as the person who called it, so every rule still applies.

## "use server" Write a function in a page and mark it with `"use server"`; the browser calls it like any other function. In the copy of the page sent to the browser, the function's body is replaced by a call to `/api/rpc`. ```ts title="islands/add-note.tsx" export async function addNote(title: string, at: Date) { "use server"; // who may call it; without it, anyone "allow: @request.auth.staff = true"; const made = await server.collection("notes").create({ title }); return { made, next: new Date(at.getTime() + 86_400_000), tags: new Set(["new"]) }; } // in the browser // a Date and a Set, as themselves const { next, tags } = await addNote("Milk", new Date()); ``` - Arguments and return values travel as [devalue](https://github.com/sveltejs/devalue), so Dates, Maps, Sets, BigInts, `undefined` and repeated objects arrive intact. - Without `"allow: …"`, anyone may call it. It acts as the caller, so each collection's rules still decide what it can read and write. - When a server component calls it during rendering, it runs in place, with no request at all. - The body runs on its own, without the rest of its file, so names the page imports at the top aren't available inside it. ## Endpoints A file in `functions/` becomes an endpoint at `/api/fn/`. It is versioned with the app, and rolls back with it. ```ts title="functions/register-summary.ts" // the app's own modules import { average } from "../lib/marks.ts"; import terms from "../data/terms.json" with { type: "json" }; export const rule = "@request.auth.id != null"; export const method = "GET"; export default async function (ctx) { const page = await ctx.collection("grades").list({ filter: `student = "${ctx.auth.id}"` }); return { average: average(page.items.map((g) => g.value)), term: terms.autumn }; } ``` `export const runAs = "system"` lets a function read past the caller's rules, for totals anybody may know that nobody may read row by row. The function is then the policy, so it should return counts, never rows. ## Hooks and jobs A file in `hooks/` runs before every write to its collection, however the write was made. Its reads bypass the rules, because it is policy rather than user code. That lets it enforce what a rule can't, such as a count or a comparison across rows. ```ts title="hooks/classes.ts" export async function beforeCreate({ record, db, reject }) { const held = await db.count("classes", `responsible = "${record.responsible}"`); if (held >= 2) reject(`already responsible for ${held} classes`); // the record to write return { ...record, name: record.name.trim() }; } ``` ```ts title="jobs/nightly.ts" export const schedule = "0 2 * * *"; export default async function (ctx) { /* … */ } ``` ## The sandbox Each call gets a fresh QuickJS context with a memory ceiling, a deadline, the web platform's globals (`URL`, `TextEncoder`, `crypto.randomUUID`…) and `ctx`. There is no file system, no network and no `process`. Imports of the app's own files are bundled in before the call starts.
# Server components and islands

A file in routes/ is a page rendered to HTML on the server. A .tsx page uses the same JSX runtime, kit and SDK as the browser; a .md page is Markdown. The browser receives only the islands' JavaScript.

```tsx title="routes/notes.tsx" import { Card, Badge } from "sluurp/kit"; import { Sluurp } from "sluurp"; import AddNote, { total } from "../islands/add-note.tsx"; // a component may wait for its data async function Newest() { const page = await new Sluurp().collection("notes").list({ sort: "title" }); return
    {page.items.map((n) =>
  • {n.title}
  • )}
; } export default async function Page() { // a "use server" function, called in place const count = await total(); return (

Notes

{count} notes Written on the server.
); } ``` ## Routes | File | URL | |---|---| | `routes/index.tsx` | `/` | | `routes/docs/sync.md` | `/docs/sync` | | `routes/posts/[slug].tsx` | `/posts/anything`, as `params.slug` | | `routes/files/[...rest].tsx` | `/files/a/b/c` | | `routes/_layout.js` | the document every page is wrapped in | | `routes/docs/_layout.js` | wraps the pages in `docs/`, inside the one above | A page needs no declarations. It is public unless it exports a `rule` (`export const rule = "@request.auth.id != null"`), and its title is its first `

` unless `load` returns another. What a page shows, it reads as the visitor, so collection rules still decide what that is. ## Markdown pages ```md title="routes/docs/sync.md" --- title: Sync section: Data order: 3 --- # Sync A shape is a collection narrowed by a filter… ``` The frontmatter is the page's data. A folder's `_layout.js` receives every Markdown page beside it, with each page's URL and frontmatter, so this site's sidebar builds itself. Code fences can take a `title="…"`. An island is just its HTML tag, which the browser wakes like any other island. Each Markdown page's source is also served at its URL plus `.md`: `/docs/sync.md`. From the same pages, a site gets [`/llms.txt`](/llms.txt), an index of every page for language models grouped by `section`, and [`/llms-full.txt`](/llms-full.txt), every page's text in one file. Whatever `routes/_llms.md` says goes first: a title, a line on what the site is, how to use it. Its `sections` sets the order of the sections. A site with its own `llms.txt` file keeps it. ## A whole page as an island A route that begins with `"use client"` is drawn on the server and then taken over, whole, by the browser. The browser is sent the file itself, with its `"use server"` bodies left out, so nothing else in it should be secret. Its default export receives `{ data, query, params }` on both sides. ```tsx title="routes/index.tsx" "use client"; export const schema = { todos: { fields: { title: "text!", done: "bool" }, rules: "" } } as const; export async function load() { /* on the server */ } export default function Page({ data }) { /* on the server, then in the browser */ } ``` `export const schema` can go in any route. It is read as a literal, never run, and applied with the app's `schema.json` when the server starts. Each field is a type (with `!` for required) or a field as `schema.json` writes it, and `rules` is one rule for all five or an object with each. Its rows get a type from it, so the fields are written once. Add `as const` to the schema, and import its type in the island: ```tsx title="islands/todos.tsx" import type { RowOf } from "sluurp/sync"; import type { schema } from "../routes/index.tsx"; type Todo = RowOf; // { id: string; title: string; done: boolean } ``` `import type` leaves no import behind, so the island never loads the route. A required field (`"text!"`) is its type; any other may be `null`, except a `bool`, which is always true or false. ## Islands A component imported from `islands/` is rendered on the server with the page, then taken over by the browser. Its props must be data, not functions. They are written as devalue, so a Date prop is still a Date in the browser. JSX children and markup props travel as templates. - `client="visible"` wakes an island when it scrolls into view, `"idle"` when the browser is idle, and `"media:(min-width: 768px)"` when a media query matches. By default (`"load"`), it wakes at once. - The browser adopts what the server drew: text typed, the focus and scroll positions set before an island wakes are all still there after. - `isolated` draws an island inside its own shadow root, with only Sluurp's styles: it looks exactly the same wherever it is embedded, whatever the page around it does. The [Todos Collab](/examples) example on this site is one. - `new Sluurp().collection("notes")` works the same on both sides, reading as the visitor on the server and in the browser. # Agents

An agent is a file in agents/ that watches a collection and acts on each change to it, the way a person at another screen would. Its memory is rows, and what it does depends only on them.

```ts title="agents/tutor.ts" // no watch: a helper, not an agent import { type Act, isAgent, conversation } from "./room.ts"; export const watch = { collection: "study_events" }; export async function act({ change, server, ai, agent }: Act) { const said = change.record; // never answer yourself if (change.action !== "create" || isAgent(said)) return; const room = conversation(server, said.page); const written = await ai( "You are a friendly tutor. Ask ONE short open question.", room.map((e) => `${e.author_name}: ${e.text}`).join("\n"), { type: "object", properties: { question: { type: "string" } }, required: ["question"] }, ); server.collection("study_events").create({ page: said.page, author: `agent:${agent}`, author_name: "Tutor", kind: "question", // no model: still an answer text: written?.question ?? "What do you already know about this?", }); } ``` - **One call per change,** in order, in the functions sandbox. The file is read again each time, so an edit takes effect at the next change. - **It acts as the system** and says who it is in what it writes. It must ignore its own writes, or it would keep answering itself. - **`ai(system, prompt, schema)`** asks the model set up in Settings → AI and returns JSON in the schema's shape, or `null` when no model is set up. Always have an answer for that case. - **What it writes is announced** like any other write. People see it through [sync](/docs/sync), and other agents watching that collection hear it. ## Several agents, one room A study room might have three agents. The Tutor asks the pupil it knows least about. The Scribe reads each answer and writes what it learnt to `study_facts`. The Coach watches the facts rather than the conversation, and gives a tip once a pupil has three. No agent calls another; they communicate only through the rows they write. Agents start with the server. If a collection is added while the server is running, restart it before that collection's agents will hear anything.
# Hooks and events

A hook runs before a write and may change or refuse it. When something worth knowing happens, it says so with an event, and agents elsewhere act on it: send the email, write the audit row, say the next thing. Each piece stays small, and none knows about the others.

## Hooks A file in `hooks/` named after a collection runs before each write to it. `beforeCreate`, `beforeUpdate` and `beforeDelete` each get the record and return it, changed if need be; `reject` refuses the write with a message. ```ts title="hooks/orders.ts" export function beforeCreate({ record, reject, alert }) { if (String(record.note ?? "").includes("" }`. `?app=reports` says the event only to the app mounted as `reports`. On the same machine, without a token, the server can listen over UDP: ```sh title="Terminal" sluurp serve --public ./app --events # from anything else on this machine: sluurp emit door.opened '{"door": 3}' ``` `--events` listens on `127.0.0.1:9900` unless given another address. Each datagram is a map of `name`, `data` and, optionally, `app`, written in JSON, MessagePack or CBOR. A sensor, a script or a cron job can each send one in a line of code.
# Mail

Set up a mail server once, then send from any server code with mail(). Sign-in links, invitations, password resets and notifications use the same one.

## Setting it up In the admin UI, under **Mail**: | | | |---|---| | Host and port | Your provider's SMTP server: `smtp.example.com`, `587` | | Username, password | The password is kept on the server and never sent back out | | From, from name | The address mail comes from, and the name beside it: "Springfield Elementary" rather than a bare address | | Base URL | Where links in mail point. Without one, the address the app is served at | | TLS | STARTTLS, on unless a local relay doesn't speak it | Without a host, mail is off. That is a fine way to run: nothing fails, nothing is sent. ## Sending ```ts title="agents/receipts.ts" export async function act({ event, mail, link }) { const sent = await mail({ to: event.data.email, subject: "Paid, thank you", text: `Your order is paid. See it at ${await link("/orders")}`, }); } ``` - `mail({ to, subject, text })` answers whether it went: `false` when mail isn't set up or the server refused, which is logged, never thrown. - `link(path)` makes a path in the app into a full address for a letter, from the base URL. Mail is plain text on purpose. A notification says there is something in the app and links to it; it doesn't try to be the conversation, so nobody replies to an address that nobody reads. ## What Sluurp sends by itself Sign-in links, password resets and invitations, in the app's language. Threads in [conversations](/docs/client#and-the-rest) mail the people who weren't there when a message arrived; chat rooms never do, so the mail stays worth reading.
# Scheduled jobs

A job runs on a schedule, such as every night at two or every Monday, with the same server API as the rest of your code. There is no cron, queue or worker to set up.

```js title="jobs/tidy-drafts.js" export const schedule = "0 6 * * 1-5"; // weekdays at 06:00 UTC export default async function (ctx) { const stale = await ctx.collection("drafts").list({ filter: "updated < @days_ago.30" }); // tidy up, send the digest, close the day } ``` - One file per job in `jobs/`, beside `functions/`. - Nothing in `jobs/` is served over HTTP. A job runs when the clock says, never when somebody calls it. - A job runs as the system, with the same `ctx` a [server function](/docs/server-functions) gets, and `CRON` as its method. ## The schedule Five fields of standard cron, in UTC: minute, hour, day of month, month, day of week (`0` is Sunday). | | | |---|---| | `0 2 * * *` | Every night at two | | `*/15 * * * *` | Every quarter of an hour | | `0 8 * * 1` | Mondays at eight | | `0 9 1 * *` | The first of each month | | `@hourly`, `@daily`, `@weekly`, `@monthly` | As they say | `*`, `*/n`, `a-b`, `a-b/n` and lists all work. The schedule is read from the file's text, so listing jobs never runs an app's code. ## How they run - Every minute, whatever matches it runs. - One run of a job at a time: if the last one is still going, the next is skipped. - A server that was down doesn't catch up on the minutes it missed, as with cron. The admin UI's **Jobs** lists every job, when it last ran and how that went, and runs one now for trying it out. Backups are a job there too: off until given a schedule and how many to keep.
# Payments and billing

Sluurp includes billing: what you charge for, who owes what, invoices, and payments, online, by bank transfer or in cash. Anyone who bills people can use it, from a school's canteen to a club's membership.

## The pieces | | | |---|---| | **Issuer** | Who bills: a school's committee, the club that runs after-school care. Each has its own bank account, invoice numbers and online payments | | **Item** | A thing in an issuer's catalogue, with a price and a unit: each, day, month or year | | **Account** | Who a charge is for, such as a pupil. Who pays for an account comes from the app's own data (`custodies` by default), so billing keeps no second list of families | | **Charge** | One thing billed to one account: posted by hand, for a whole class at once if need be, or made from a subscription | | **Subscription** | Charged each period: hot meals on Mondays and Thursdays, a monthly fee, a year of chess club | | **Invoice** | An account's open charges, with a number, a due date, and a structured reference that a bank transfer is matched by | | **Payment** | Settles invoices: a transfer, cash at the desk, or online | | **Wallet** | Money paid ahead and spent as it goes. A reserved meal is taken from it at once, and put back if cancelled in time | Every amount is a whole number of cents, never a float. ## Billing a period ```ts title="app.ts" const billing = sluurp.billing; await billing.charge({ account: pupil.id, item: trip.id }); // one charge await billing.run({ dry: true }); // what a run would invoice await billing.run({}); // count subscriptions, invoice open charges ``` `remind(id)` mails a reminder for an unpaid invoice, and `cancel(id)` cancels one. ## Being paid - **Online,** with Stripe or Mollie, whichever the issuer uses; both offer cards and Bancontact. `billing.pay({ invoice, back })` answers a `url` to send the person to, and they come back to `back` with `?paid=`. The provider's webhook records the payment. - **By transfer:** paste the bank statement's lines into `billing.match(lines)`, and each is matched to its invoice by its reference. `dry` shows the matches first. - **In cash:** `billing.record(payment)` at the desk. - **Topping up a wallet:** `billing.pay({ account, issuer, amount_cents })`, between €1 and €1,000. ## Who may do what Managing (the catalogue, charges, runs, recording payments) is a rule, `manage`, in the billing settings: without one, only superusers may. Everyone else sees the accounts they pay for, and their own. `billing.certificate(account, year)` gives what was paid in a year that may be deducted from tax, per issuer: in Belgium, childcare for a child under fourteen. The admin UI's **Payments** screen sets up the Stripe and Mollie accounts.
# AI

A model is optional and off until a superuser sets one up. Everything that uses it has an answer for when there is none, and what a model says comes back as a proposal: a person applies it, or doesn't.

## Setting one up In the admin UI, under **AI**: | | | |---|---| | Provider | `anthropic` for Claude, or `openai` for anything that speaks the OpenAI chat shape: Ollama, LM Studio, llama.cpp's server | | Model | For Claude, `claude-opus-5` unless you name another. Required for `openai` | | Address | For `openai`, where it listens: `http://localhost:11434/v1` for Ollama | | Key | Kept on the server; never sent back out of the API | | Effort | How hard Claude thinks: `low`, `medium` (the default) or `high` | | Per person, per day | How many questions one person may ask; `0` for no limit. Administrators aren't counted | A model on the school's own machine keeps everything on it. ## In the browser ```ts title="app.ts" const { text } = await sluurp.ai.chat([{ role: "user", text: "Summarise this week's notices" }]); // As it is written, piece by piece: await sluurp.ai.stream(turns, { onText: (piece, all) => (out.textContent = all) }); ``` Both take `{ system, signal }`; stopping with the signal keeps what came so far. When a person has asked their day's share, the answer says so instead of throwing. ## In server code `ai(system, prompt, schema)` asks for an answer as JSON in the schema's shape, or `null` when no model is set up: ```ts title="functions/tag-notice.ts" const tags = await ai( "You tag school notices.", notice.body, { type: "object", properties: { tags: { type: "array", items: { type: "string" } } } }, ); if (tags) await collection("notices").update(notice.id, tags); ``` For a model that works in turns, reading what it needs through tools until it has an answer, see [Agents](/docs/agents). ## Where Sluurp uses it [Pages](/docs/pages) offers its AI tools, and `sluurp/ai` an assistant for any app, only when a model is set up.
## Frontend # Pages and charts

sluurp/pages is a Notion-like editor any app can mount. It has blocks, nested pages, a wiki, books, a graph of links, version history and PDF export, and charts written either as a sentence in a small English-like language or as a few lines of JavaScript.

- Blocks: text, headings, lists, to-dos, quotes, code, tables, images, embeds, charts, and pages within pages. - A reading mode, a books view, and a graph of how pages link to each other. - Every version is kept in the core change log, with who, when and why, and any of them can be restored. - Ask AI and chat with AI about a page, when a model is set up. ## Try a page This one is editable: type `/` for the menu, drag a block by its handle, select words to format them. In an app, a page is saved with its history; this one lives only in this tab. ## Folders: sections made of pages A folder is a page that holds pages and lists them, one row each. It's how a section of an app, such as a school's optionals or the rooms there are to book, is made of pages instead of a screen someone had to code. - **Properties.** The folder says what each page in it has (`props`): a day, a teacher, how many places. Each page fills them in (`values`), under its title. - **Formulas.** A `formula` property is worked out, not filled in, from the page's values and its table: `rows` is how many rows it has, `consent.yes` how many say yes (any field and value, lower case), and `cost.sum` a number column added up. `places - rows` is the places left, and goes down as people sign up. - **New.** The folder names a template, and "New" makes the next page from it, inside the folder. - **In the navigation.** A folder marked `nav` is a section of the app for everyone who may read it, with its own icon. Only its maker or a superuser may set that. - **Icons.** A page's icon is an emoji, one of the app's icons (`icon:calendar`), or an SVG of your own (`svg:`). Before it's drawn, an SVG is cleaned down to shapes and paint: no scripts, handlers, links or styles. ```json title="migrations/V6__optionals.json" { "pages": [ { "id": "optionals", "name": "Optionals", "kind": "folder", "nav": true, "template": "page:optionaltpl", "props": [ { "name": "places", "title": "Places", "type": "number" }, { "name": "places_left", "title": "Places left", "type": "formula", "formula": "places - rows" } ] }, { "id": "chess", "name": "Chess club", "parent": "optionals", "values": { "places": 12 }, "fields": [{ "name": "pupil", "type": "relation", "relation": "users" }] } ] } ``` Property types are `text`, `number`, `date`, `bool`, `select` (with `values`), `person` and `formula`. A table's columns may also be `file`, and its views `table`, `board`, `timeline` or `gallery`. A table's columns are written by their words. In its columns dialog you type what a column asks or holds, "May your child come?", and it gets a short name of its own (`may_your_child_come`) for rules and filters, with the words kept as its heading. That's how a form's questions are its columns. A column's words can be changed at any time; its name stays. The same dialog makes the rest of a form. **Yes or no** is a choice of the two, made at once. **Required** marks a question with a star where families answer, and lists what is still to answer until it is. The arrows move a question up or down. Stored per column in the page's `columns`: `{ "may_your_child_come": { "title": "May your child come?", "required": true } }`. A **file** column holds a picture or a document; its cell shows the picture small, and an editor puts a new one there. A table with a file column can be shown as a **gallery**: its rows as a grid of pictures, each over its title. **Add pictures** takes several at once and makes a row for each. A picture opened is shown large, and the next one is an arrow key or a swipe away. ### Actions A page can have buttons that do what the app can do: an `action` block, `{ "type": "action", "action": "tell" }`. The app says what each action is, and the page only places it: ```ts title="app.ts" configurePages({ actions: () => ({ tell: { label: (page) => (page.values?.told ? "Tell them again" : "Tell the families"), may: () => isStaff(), confirm: () => "Each family gets a message with the trip and the link to answer.", run: async (page) => `${await writeToFamilies(page)} families told`, }, }), }); ``` `may` says who sees the button (whoever may edit the page, if left out), `confirm` asks first, and what `run` returns is shown. MikroSchool's trips use two: telling a class's families, and charging the ones who said yes through Finances. ### Sign-ups A page can take sign-ups: people add a row of their own to its table without being able to edit the page. That's how a pupil joins an optional, or a parent signs up their child. ```json title="A page's join" "join": { "rule": "@request.auth.roles ~ \"pupil\" || @request.auth.roles ~ \"parent\"", "limit": "places", "open": "open", "self": { "field": "pupil", "via": "custodies.child.custodian" } } ``` - `rule` says who may join. - `limit` is how many rows there may be: a number, or one of the page's values. - `open` names the value that must be true for anyone to join. - `label` is what the button says: "Answer" for a form, "Sign up" when left out. - `self` is the field saying who a row is for: the person joining, or, with `via`, somebody they answer for. `custodies.child.custodian` means a custodies row whose `child` is that pupil and whose `custodian` is the person signing up. The server holds to all of it and fills in who added each row (`by`). A joiner can change their own row's answers, but not whom it's for or whose it is. Someone who joined can take their own row back, but no one else's. Staff and editors add and remove anyone. Two people taking the last place at once can't both get it. In the app, a joiner sees a "Sign up" button, with a choice of names when they answer for someone, and their own sign-ups, each with a button to take it back. With `"claim": true`, joiners take rows instead of adding them. Editors set out the times, such as a parents' evening in quarters of an hour or a volunteer rota. A family picks a free time under **Choose a time**, answers on it, and can give it back. They take one each, or up to `limit`. They can't add or delete rows, answer on a time they don't hold, or take one somebody else holds; two people choosing the same time at once can't both get it. Editors see who took each time, and families don't see other families. Over the API, a joiner claims a row with `{"by": ""}` and gives it back with `{"by": null}`. ## The chart language A chart block reads a collection's rows, as whoever is looking, and shapes them for drawing in one of two ways: - **A sentence** such as `mean value by subject sorted`. It is parsed, never run as code, so it needs no worker and cannot do harm. This is the language below. - **A few lines of JavaScript**, for anything a sentence can't say: `return count(rows, "status")`. They run in a worker of their own, with the rows and some helpers and nothing else (not the page, not the session), and are stopped after two seconds. The block tells the two apart by itself: text that starts with a measure and has no code punctuation is a sentence. ```js title="Chart block, as JavaScript" return groupBy(rows, "class").map(([label, list]) => ({ label, value: mean(list, "value") })); ``` The sentence language: ```text title="Chart block" count grades by subject mean value from grades by subject sorted percent by status # stacked, one series per status count by month of date and by status count by subject top 5 # the rest as "Other" mean value by week of date rolling 4 sum (first_half + second_half) / 2 by month of date values value against date where subject = Mathematics # a scatter # as the marks stood then mean value by subject as of 2026-06-30 ``` | Part | Forms | |---|---| | Measures | `count percent sum mean median min max distinct values` | | Groupings | `by field`, `by month\|week\|weekday\|year of date`, `by n every 5`; a second grouping after "and" makes series: `by class and by subject` | | Conditions | `where a = x and b >= 3 and roles has pupil` | | Endings | `top n`, `sorted`, `cumulative`, `rolling n`, `as of 2026-06-30` | Linked records are shown by name; month charts run from the first month with data to the last, and empty months and bins between stay in: a count there is 0, while an average of nothing is a gap in the line, not a fall to 0. The language has its own unit tests, which run in Node with no build. ### Live tables, shared charts A page's table can be **Live**: its editors switch it on in the table's toolbar. A live table reads its rows through [sync](/docs/sync), so everyone's changes appear as they're made. That's what a kanban board, a trip's planning or a sign-up sheet wants when several people work on it at once. While it's small (up to 2,000 rows), it's also kept on the device in IndexedDB, for the person signed in, so a reload shows it at once. Signing out removes it. A table that isn't live is read when the page opens. All the charts on a page share their data: each collection, or the slice of it a chart asks for, is read once for the page. In a chart's settings: - **Only rows where** narrows its first source with the filter language (`class = "class4a0000000"`), so only that slice is read. - **Live** draws it again whenever its rows change, here or at someone else's desk, through sync. Off by default: a chart sums up many rows, and one that doesn't move is easier to read. ### Try it Type a sentence, or pick one, and it is read and drawn over a year of a small shop's orders (category, country, shipper, date, total, shipped), in the spirit of Northwind: ## Charts with Plotly For charts a sentence or the kit does not draw (heatmaps, sunbursts, box plots, maps, 3D), a JavaScript chart block returns a Plotly figure, `{ plotly: { data, layout } }`, and it is drawn in the page's look and palette. Plotly is in the binary, fetched only when such a chart comes into view. Any page or island can draw one too, with `plot()` from `sluurp/plotly`; these three are islands, over the same orders:
# Packages, bundles, budgets

There is no node_modules and no bundler to set up. A package is fetched once into the app's vendor/ and recorded in its sluurp-deps.json — the import map, each version and a hash of every file — and the server bundles everything when it starts.

```sh title="Terminal" # its sources and its dependencies, into vendor/ sluurp add npm:d3-force@3 # a JSR package, through JSR's npm registry sluurp add jsr:@std/path@^1 # wanted and latest, as pnpm reports them sluurp outdated # also removes what only the old version needed sluurp update --latest # checks every file against npm's tarballs sluurp vendor verify sluurp remove d3-force # where the shared store is, and its size; `clear` empties it sluurp cache ``` - Packages are vendored as source, not as a CDN's build. CommonJS is converted to ES modules with rolldown at vendoring time. - Types come with the package, its own or DefinitelyTyped's, along with paths for the editor. - A shared store, keyed by content hash, makes the same package instant for a second app. npm's and pnpm's caches are read when they have the file, and `--offline` works from the caches alone. ## In production When it starts, the server bundles each page's modules with rolldown. Bundles are code-split and minified, and include workers and `new URL(…)` assets. The result is cached on disk by content and precompressed with brotli and gzip. In development, modules are served one at a time, as written. ## A budget ```json title="budget.json" { "first-load-kb": 320, "pages": { "/signup.html": 140 } } ``` `/_sluurp/bundle/report.json` reports how much each page downloads, compressed, before it can run, and flags any page over its budget. Code that only one page needs should be loaded with `import()` from that page.
# UI kit

Sluurp includes a UI kit: the shadcn/ui set of components, rebuilt for Sluurp without React, and served by the binary. Import them in any page or island; there is nothing to install.

```tsx title="islands/signup.tsx" import { Button, Input, Dialog } from "sluurp/kit"; ``` It covers what an app is made of: - **Forms:** Input, Textarea, Checkbox, Switch, Radio group, Select, Combobox, Date picker (a day, or a range across two months side by side), Time picker (hours and minutes in columns, AM and PM where the reader's clock has them), Slider, Tag input, one-time code. - **Layout:** Card, Sheet, Drawer, Dialog, Tabs, Accordion, Resizable panels, Sidebar, Scroll area. - **Feedback:** Toasts, Alerts, Tooltips, Hover cards, Progress, Skeletons, Badges, a Notifications stack (grouped cards that spring out into the list, with [Motion](https://motion.dev)). - **Data:** Table, Pagination, Charts, Calendar, Carousel, Avatars, an emoji picker. - **Menus:** Dropdown, Context menu, Menubar, Navigation menu, Command palette. Every component follows the page's light or dark theme, works with a keyboard and a screen reader, and fits an iPhone. The [admin UI](/docs/admin-ui) is built from the same parts. ## Two-way binding Give a field a signal and it's bound both ways, as `v-model` or `bind:value` are elsewhere: the field shows the signal, and typing writes back to it. No `onInput` is needed. ```tsx title="app.tsx" const title = signal(""); ``` The same goes for `Textarea`, `Switch` and `Checkbox` (`checked`), `Select`, `Combobox`, `Slider`, `DatePicker` and `TimePicker`. A number field writes a number. A plain value or a function is read-only, so a derived value can't be typed over by mistake. ## Try them Everything below is the kit, live and in this page's theme: click, type and drag. The whole island is [one file](https://github.com/SluurpHQ/sluurp/blob/master/website/islands/kit-showcase.tsx) of components with some state.
# Translations

An app's words live in i18n.json, one entry per phrase with each language beside it. Translators change them in the admin UI, or on the page itself, and every open page shows the change without a reload.

## The file ```json title="app/i18n.json" { "locales": ["en", "fr", "de"], "classes": { "en": "classes", "fr": "classes", "de": "klassen" }, "welcome": { "en": "welcome to {{app}}, {{name}}", "fr": "bienvenue sur {{app}}, {{name}}" } } ``` - Keys are lower case. - `{{name}}` is filled from the values given. `{{app}}` is always the app's name. - A missing language falls back to English. A key missing from English too shows as `?[key.locale]`, so the gap is visible rather than blank. ## In the app ```tsx title="app/i18n.ts" import { createI18n } from "sluurp/i18n"; import table from "./i18n.json" with { type: "json" }; export const i18n = createI18n({ table, appName: "MikroSchool" }); export const t = i18n.t; i18n.connect(sluurp); // live updates ``` ```tsx title="app/header.tsx"

{() => t("classes|title")}

// "Classes"

{() => t("welcome", { name: me.first_name })}

``` - `|title`, `|upper` and `|lower` change the case, so "classes" and "Classes" don't need two entries. - The arrow keeps a string live: switching language with `i18n.setLocale("fr")`, or a translator's change, redraws it. `t("x")` without the arrow is read once. - The server serves `i18n.json` with the published changes already laid over it, so the import is always the current words. ## Translating - **In the admin UI,** under **Translations**, every key with a field per language. - **On the page:** a translator switches translate mode on, clicks any text, and edits it where it's shown. Clicks go to the editor, not the page, so a link doesn't navigate. - **Together:** a translation session keeps drafts that only its translators see. Each person's cursor is outlined in their colour, typing shows to the others as it happens, and the session is published in one step when it's ready. - **With a link:** a session's link lets someone without an account join as a guest, to edit but not publish. Only people with the `translator` role may translate. The [UI kit's](/docs/ui-kit) own words (Close, Search…, the months of the calendar) come translated already. `sluurp i18n` lists keys that nothing uses any more.
# Hot reload

sluurp serve watches the app's folder. Save a file and every open page changes to match, in place: a counter keeps its count, an open dialog stays open, what you typed stays typed. There is nothing to set up, and no bundler to run.

```sh title="Terminal" sluurp serve --public ./app ``` ## What is swapped in place The new version of the module you saved replaces the old one, and the page keeps its state. Each of these is a case in the hot lab, a test app that saves every kind of edit in turn and checks that the page kept its state: - **Components:** a component's own state, a signal at the top of a module, an open dialog, a list of keyed rows, a child component in another file. - **Plain modules:** a `.ts` file that components import, two imports deep, or on either side of a circular import. - **Data and assets:** a `.json` file, `import.meta.glob` (a file added or deleted too), `?raw` and `?url` imports, `import.meta.env` from `.env`. - **Styles:** a stylesheet, or a CSS module, swapped with no redraw at all. - **Workers:** a TypeScript worker, started again with the new code. ```tsx title="islands/counter.tsx" import { signal } from "sluurp/reactive"; export default function Counter() { const count = signal(0); // Change this label and save: the count stays where it was. return ; } ``` ## What redraws the page - A **Markdown page**, a **layout** or a **route drawn on the server** is drawn again and the page updated, with no reload. - A module that **does something when it loads** (a statement at its top level, not only definitions) reloads the page, since running it twice would do it twice. ## When something breaks A save that doesn't compile, or code that throws, shows in the dev overlay: the error at its line in your source, with a link that opens the file in your editor. Problems that aren't fatal (a refused request, a `console.error`) are counted in a corner of the page instead of interrupting it. Fix the file and save; the overlay clears itself. ## In production Hot reload is for development. Serve with `--no-hot-reload`, or publish the app as a bundle, and nothing is watched and no reload script is sent. The hot lab is [`e2e/hot-lab`](https://github.com/SluurpHQ/sluurp/tree/master/e2e/hot-lab), with one card for each of these cases. Its browser test edits each one and checks it swapped in place and kept its state.
# Live views

A live view keeps its state on the server and renders it there. The browser sends what the person did and gets back only the values that changed, often a few bytes.

It suits anything where the server should hold the truth: a register, a dashboard, a checkout. It isn't for collaborative editing: two people typing in one field overwrite each other, in the order they arrive. ## A view A view is `views/.js` in the app: ```js title="views/counter.js" export const rule = "@request.auth.id != null"; export function mount(ctx) { return { count: 0 }; } export function render(state, html) { return html``; } export function handle(event, payload, state, ctx) { if (event === "bump") return { ...state, count: state.count + 1 }; return state; } ``` - `rule` says who may open it, in the [rules language](/docs/rules). - `mount` makes the first state; `ctx` says who opened the view. - `render` draws the state, with the `html` tag. - `handle` takes an event and returns the next state. ## On the page ```ts title="app.ts" import { mount } from "sluurp/live"; await mount("#counter", "counter"); ``` Elements say which events they send: ```html title="HTML"
…
``` The payload carries the element's value, a form's fields, and any `sluurp-value-*` attributes. `sluurp-ignore` on an element keeps its children as they are across updates, for a component the browser owns. ## Why so little goes over the wire An `html` template is split by JavaScript itself into the parts that never change and the values put into them. Only the values are compared between renders, and only those that differ are sent. A counter going from 41 to 42 sends `42`. Signing in is the ordinary [browser client's](/docs/client), so a view sees the same person the REST API does.
## Operations # Deploying

A Sluurp app in production is one binary, one app folder and one data folder on a machine you control. There is no database server to run beside it, and no build to ship.

## On the server Install the binary, put the app beside it, and make an administrator: ```sh title="Terminal" curl -fsSL https://raw.githubusercontent.com/SluurpHQ/sluurp/master/install.sh | sh sluurp --dir /srv/sluurp/data superuser you@example.com a-long-password sluurp --dir /srv/sluurp/data serve --public /srv/sluurp/app --no-hot-reload ``` - `--dir` is where the data lives: one SQLite file per project. It is the only folder that must be kept. - `--no-hot-reload` stops Sluurp watching the app's files, which a server doesn't need. - `--public` can also be the app's [repository URL](/docs/getting-started): it is cloned on first start, and brought up to date on each restart. Sluurp listens on `127.0.0.1:8090`, so it answers this machine only. Give it `--addr 0.0.0.0:8090` to take requests from outside, or better, put a proxy in front. ## HTTPS Sluurp serves plain HTTP; a reverse proxy in front adds HTTPS. [Caddy](https://caddyserver.com) gets and renews the certificate by itself: ```text title="Caddyfile" school.example.com { reverse_proxy 127.0.0.1:8090 } ``` Live updates, sync and cursors use WebSockets, which Caddy passes through as they are. Behind nginx, pass the `Upgrade` and `Connection` headers. ## Keeping it running A service that starts Sluurp with the machine and restarts it if it stops, with systemd: ```ini title="/etc/systemd/system/sluurp.service" [Unit] Description=Sluurp After=network.target [Service] ExecStart=/home/sluurp/.sluurp/bin/sluurp --dir /srv/sluurp/data serve --public /srv/sluurp/app --no-hot-reload User=sluurp Restart=always [Install] WantedBy=multi-user.target ``` ```sh title="Terminal" sudo systemctl enable --now sluurp ``` ## Backups `sluurp backup` copies every project to a dated snapshot in the data folder's `snapshots/`. In the admin UI, **Backups** schedules them and keeps as many as you say. Copy the snapshots off the machine too: a backup on the same disk doesn't survive that disk. ## Upgrades Replace the binary and restart. Sluurp brings its own tables up to date when it starts, and runs the app's [migrations](/docs/migrations) that it hasn't run yet. Take a backup first; `sluurp migrate APP --plan` shows what will run. ## Or no server at all A site that needs no API, sync or server functions, such as a landing page or docs, can be published as plain files with [`sluurp static`](/docs/static-sites), on GitHub Pages or any file host.
# Admin UI

Every Sluurp server has a dashboard at /_/, like Django's admin: made from your schema, with nothing to write. Each collection gets a table to browse, filter and edit, and the server gets the screens to run it.

## For your data - **A table per collection,** with a filter box (`paid = true && total > 100`), sorting, column choice and pages. - **A form per record** that fits each field: a date picker for dates, a searchable picker for relations, an image preview and upload for files. - **History and undo:** every change with who and why, any version brought back, deleted records in a recycle bin. - **Explore and pivot tables,** charts and formulas over a collection, without exporting it. - **Rules** for who may read and write, written next to the collection. ## For the server Logs, an SQL console, backups, scheduled jobs, rate limits, mail, sign-in providers, payments, forms, feeds, translations and storage, each a screen. Drag the Platform rows into your own order, or move one with Alt and an arrow key. ## Components The **Components** screen is the kit's workshop, like [Storybook](https://storybook.js.org). Each component is drawn alone on a canvas, at a phone's width, a tablet's or the full width, in light or dark. You can change its arguments in **Controls** and copy the code for what's on show from **Code**. **Docs** is a page per component: what it's for, a table of its arguments, and every story drawn under it. Stories are Storybook's own [Component Story Format](https://storybook.js.org/docs/api/csf) (CSF 3), so a story file moves between the two unchanged. A file named `*.stories.tsx` next to a component is found by its name, with no config: ```tsx title="client/kit/button.stories.tsx" import { Button } from "./button.js"; export default { title: "Actions/Button", component: Button, args: { children: "Save changes", disabled: false }, argTypes: { variant: { control: "select", options: ["default", "outline", "destructive"] } }, }; export const Primary = {}; export const Destructive = { args: { variant: "destructive", children: "Delete" } }; ``` With no `render`, a story is `component(args)`. A kit component takes one object of props, as a story's args are. Controls are inferred from each arg's value; `argTypes` is only needed for a choice. A `play({ canvasElement, args })` function runs once the story is drawn. One addition of Sluurp's own, which Storybook ignores: `argTypes: { collection: { control: "collection" } }` picks a collection of the open project, and its rows reach `render(args, { loaded: { rows } })`. A select, a table or a list is then drawn from real data, with this project's words and lengths rather than three made-up ones. Every story is drawn in a test. ## Compared with Django admin **What Sluurp has that Django admin does not:** - Live updates: a record someone else changes changes on your screen. - History, restore and a recycle bin for every collection, built in. - An SQL console, backups, logs, jobs and rate limits in the same place. - Nothing to register or configure: a collection is in the admin as soon as it exists. - Translations edited live, with several people at once. **What Django admin has that Sluurp does not yet:** - Customising a model's admin in code: which columns show, how the form is grouped, read-only fields per screen. - Your own bulk actions, written in code, on the ticked rows. - Editing related records inside the parent's form (inlines). - A date drill-down (years, then months, then days) above a list. The admin UI is built with the same [UI kit](/docs/ui-kit) your app can use.
# Security

The common attacks on a web app, as MDN lists them, and what Sluurp does about each. Most of it needs nothing from you.

## Cross-site scripting (XSS) Text never becomes markup. JSX and `html` templates set text as text, so a name like `` shows as those characters. A `javascript:` URL in `href`, `src`, `action` and the like is replaced, however it is written. A string is never an event handler: `onclick="…"` is refused. The same rules hold on the server, where pages are rendered. ## Cross-site request forgery (CSRF) A request is signed in by its `Authorization` header, never by a cookie. Another site can make the browser send a request, but it cannot add the header, so the request arrives as nobody's. ## Clickjacking No HTML page can be framed by another site: every one is sent with `X-Frame-Options: SAMEORIGIN` and `frame-ancestors 'self'`. A page meant to be embedded elsewhere sets its own header, which is kept. ## Insecure direct object references (IDOR) Ids are random, but that is not what protects a record. Every read and write goes through the collection's [rules](/docs/rules), checked in SQL, down to single fields, so knowing an id gives nothing the rules do not. ## Manipulator in the middle (MITM) Serve behind TLS; see [Deploying](/docs/deploying). Once a browser reaches Sluurp over HTTPS, `Strict-Transport-Security` keeps it there for a year. ## Server-side request forgery (SSRF) What Sluurp fetches on somebody's behalf — a function's `fetch`, a link preview, a post's card — only reaches public addresses. Loopback, private networks and cloud metadata addresses are refused, every redirect is checked, and the address checked is the one connected to, so DNS rebinding does not get round it. A function may reach more hosts through an allow list the superuser keeps. ## Prototype pollution The server reads JSON with Rust types, which have no prototype. In the browser, what islands and RPC receive is parsed by devalue, which refuses a `__proto__` key. ## Cross-site leaks Referrers stop at the origin across sites (`strict-origin-when-cross-origin`), and responses are never sniffed into another type (`nosniff`). ## Supply chain Nothing is installed at run time. The server is one binary. Packages are [vendored](/docs/vendoring) byte for byte into `vendor/`, and served from there, not from a CDN, so what runs is what was reviewed and committed. ## Phishing and subdomain takeover These are about people and DNS rather than code. Offer two-factor sign-in (see [Authentication](/docs/auth)), and remove a DNS record when the server it points at goes away.
# Static sites

When a site needs no server of its own, as a landing page or docs usually don't, Sluurp can write it out as plain files. Pages arrive already drawn, and islands still wake in the browser. This site is published that way.

```sh title="Terminal" sluurp static --public website --public todos=examples/todos/app --out dist ``` It takes `--public` exactly as `serve` does, a folder or a [repository's address](/docs/getting-started), then runs the app on your machine and reads it the way a visitor would. Starting from `/` and each mounted app, it follows every link, script, stylesheet, island, import-map entry, module import and font, and writes what it gets: - a page as `path/index.html`, so `/docs/sync` becomes `docs/sync/index.html`; - everything else at its own path, with modules compiled from TypeScript saved as `.js`, so any file host sends them as scripts. ## What works, and what does not Everything drawn on the server comes through as it is: pages, Markdown docs, highlighted code and the UI kit. Islands keep working when they only draw and react: charts, forms, a Page editor, the chart playground. What needs a server stays behind: the API, sync, `"use server"` functions and live cursors. An island that talks to the server loads, but has nobody to answer it. The Todos Collab example on this site is one of those. ## GitHub Pages The output includes a `.nojekyll` file, so GitHub publishes `/_/`, the folder with the theme and styles, instead of dropping it the way Jekyll does with underscore folders. A workflow to publish on every push: ```yaml title=".github/workflows/pages.yml" name: Pages on: push: branches: [master] permissions: contents: read pages: write id-token: write jobs: deploy: runs-on: ubuntu-latest environment: github-pages steps: - uses: actions/checkout@v4 - run: curl -fsSL https://raw.githubusercontent.com/SluurpHQ/sluurp/master/install.sh | sh - run: ~/.sluurp/bin/sluurp static --public website --out dist - uses: actions/upload-pages-artifact@v3 with: path: dist - uses: actions/deploy-pages@v4 ``` ## Served from a folder A project's GitHub Pages site is served from a folder named after the repository: `username.github.io/repo/`. Give that folder as `--base`: ```sh title="Terminal" sluurp static --public website --out dist --base repo ``` Everything a page or stylesheet names from the root (`/docs`, `/_/theme.css`) is moved under `/repo/`, and each page's import map sends the root's folders there too. What a module imports, and the islands loaded by name, arrive under the base with no module's text changed. At a custom domain or in a `username.github.io` repository, the site is at the root, and no `--base` is needed. Your own code that builds a root path from a string, such as `link.href = "/theme.css"`, isn't seen by the build. Resolve it from the module instead: `new URL("../theme.css", import.meta.url)`.
# Command line

Everything Sluurp does is one binary, sluurp. sluurp --help lists the commands, and sluurp <command> --help every option of one. This page is the map.

Every command takes `--dir `, the folder that holds the data, one SQLite file per project. It is `sluurp_data` next to where you run it, unless you say otherwise. ## Serving | Command | What it does | |---|---| | `serve` | Start the server: the API, the admin UI at `/_/`, and the app given to `--public` | | `static` | Write the app out as plain files for a file host, islands still alive; `--base repo` for a site served from a folder ([Static sites](/docs/static-sites)) | | `compile` | Build a single binary that serves one app, its files inside it | `serve` options: | Option | | |---|---| | `--public DIR` | The app at `/`: a folder, or a [repository's URL](/docs/getting-started). `NAME=DIR` serves another at `/NAME/`; give it more than once | | `--addr HOST:PORT` | Where to listen; `127.0.0.1:8090` by default, this machine only | | `--no-hot-reload` | Don't watch the files; for a server in production | | `--attach NAME=FILE` | Use an existing SQLite database in place ([Existing SQLite databases](/docs/attach)) | | `--events [ADDR]` | Take events over UDP from this machine ([Hooks and events](/docs/hooks-and-events)) | | `--channel NAME`, `--apps` | Serve a published bundle, or every registered app by hostname, instead of a folder | ```sh title="Terminal" sluurp serve --public ./app --public reports=./reports --addr 0.0.0.0:8090 ``` ## Data | Command | What it does | |---|---| | `migrate APP` | Run an app's [migrations](/docs/migrations); `--plan` says what would run | | `schema plan`, `schema apply` | Compare a `schema.json` with the database, or bring the database up to it | | `import FILE` | Load collections and records from an import document | | `superuser EMAIL PASSWORD` | Create an administrator, or reset one's password | | `backup` | Copy every project to a dated snapshot folder | | `migrations` | Show which of Sluurp's own internal migrations a project has had | | `bench` | How fast collections are on this machine ([Why SQLite](/docs/why-sqlite)) | ## Packages | Command | What it does | |---|---| | `add SPEC` | Vendor a package into an app: `add three`, `add npm:date-fns@4`, `add jsr:@std/path`, a CDN URL, or `font:inter:400,600` ([Packages](/docs/vendoring)) | | `remove NAME` | Take a vendored package out again | | `outdated`, `update` | Newer versions of what was added, and moving to them, as `pnpm` has them | | `vendor` | List what an app vendors, `check` its files against their hashes, or `verify` them against where they came from | | `cache` | Where Sluurp's package store and bundles are, and `clear` to empty them | `add` and the others take `--app DIR`, the app to change: the current folder by default. ## Publishing | Command | What it does | |---|---| | `push DIR` | Store an app build as a bundle; `--deploy CHANNEL` points a channel at it straight away | | `bundles` | List the bundles, and what each channel serves | | `deploy CHANNEL BUNDLE` | Point a channel at a bundle | | `promote FROM TO` | Point one channel at whatever another serves: staging to production | | `rollback CHANNEL` | Put a channel back on the version it served before | | `app` | Connect a git repository, so its app is published from it on each push | ## Other | Command | What it does | |---|---| | `emit NAME [JSON]` | Say an event to a server that listens for them | | `i18n` | List the keys in an app's `i18n.json` that nothing uses | ## Development # Development

Working on Sluurp itself: a Rust toolchain builds the binary, Node runs the browser tests, and the website you are reading is a Sluurp app served by what you just built.

## A machine to build on | What | Why | |---|---| | [Rust](https://rustup.rs), stable | the server; edition 2024 | | a C compiler | SQLite, libgit2 and QuickJS are built from source: Xcode's command line tools on macOS, `build-essential` on Linux, Visual Studio's C++ build tools on Windows | | `pkg-config` and `libssl-dev` (Linux only) | libgit2 links the system's OpenSSL | | [Node](https://nodejs.org) 22+ and [pnpm](https://pnpm.io) | the browser tests and the client's unit tests; nothing Node ships in the binary | ```sh title="Terminal" git clone https://github.com/SluurpHQ/sluurp cd sluurp cargo build ``` The first build compiles the three C libraries and takes a few minutes; later ones take seconds. ## Run it while you change it ```sh title="Terminal" cargo run -- superuser you@example.com a-long-password cargo run -- serve --public website ``` `serve` reloads as you work, with nothing to switch on: - a saved `.tsx`, `.ts`, `.js` or `.css` file in the app is sent to the open pages, which swap the module in place; - a saved Markdown page, layout or server-rendered route redraws the page. A change to the Rust code needs a rebuild and a restart. On Windows a running `sluurp.exe` cannot be overwritten, so `node scripts/dev-server.mjs PORT --public DIR` ([the script](https://github.com/SluurpHQ/sluurp/blob/master/scripts/dev-server.mjs)) builds once and runs a copy, and `cargo build` keeps working beside it. [`.claude/launch.json`](https://github.com/SluurpHQ/sluurp/blob/master/.claude/launch.json) names the servers used while developing: the website, MikroSchool, the dashboard alone, and the examples. Errors land in the dev overlay: a page that fails shows its error at its line in the source, with a link into your editor; what is not fatal is counted in a corner. ## Tests ```sh title="Terminal" # The server: unit tests and the integration tests in tests/it, each against a real server on a real file cargo test # The client's pure code, such as the chart language node --test tests/client/*.test.ts # The browser tests, against the binary and a real database cd e2e pnpm install pnpm exec playwright install chromium pnpm test ``` `pnpm run smoke` in [`e2e/`](https://github.com/SluurpHQ/sluurp/tree/master/e2e) runs the quick subset. CI runs all of it on Linux and Windows, then `cargo fmt --check` and `cargo clippy -- -D warnings`. ## Benchmarks ```sh title="Terminal" cargo run --release -- bench ``` It measures the storage layer (creates, reads, lists, updates, deletes, `asOf`), one caller and many, beside the same operations on bare SQLite, and ends with an overall score. `--json` prints the numbers, and `--out FILE` writes them, which is how [Why SQLite](/docs/why-sqlite) gets its table. Measure the release build: the debug one is much slower, and its numbers say little. ## A release Pushing a tag such as `v0.2.0` runs [`.github/workflows/release.yml`](https://github.com/SluurpHQ/sluurp/blob/master/.github/workflows/release.yml). It builds the binary for Linux (x64, arm64), macOS (Intel, Apple silicon) and Windows, and attaches them with their checksums to a GitHub release, where [`install.sh`](https://github.com/SluurpHQ/sluurp/blob/master/install.sh) and [`install.ps1`](https://github.com/SluurpHQ/sluurp/blob/master/install.ps1) find the latest.
## Pages # Docs Sluurp is a backend and a way of building on it. Start with [Getting started](/docs/getting-started), or pick from the list. These pages are Markdown files in [`website/routes/docs/`](https://github.com/SluurpHQ/sluurp/tree/master/website/routes/docs). The sidebar is built from their frontmatter, so a new page is a new file.
# Examples

Each example is a folder in the repository, served with sluurp serve --public and nothing else. Its data model is applied when the server starts.

`--public DIR` serves a folder as the app at `/`: its pages, routes and islands, and its schema, hooks and agents. Given as `NAME=DIR`, it serves another app beside it at `/NAME/`, so one server can run several apps. Each example also runs straight from its address on GitHub, with nothing cloned by hand: Sluurp [clones it](/docs/getting-started) into the current folder and serves it. ## This site [`website/`](https://github.com/SluurpHQ/sluurp/tree/master/website) is the site you are reading. Its landing page is a `.tsx` route drawn on the server; its docs are Markdown files, and a folder layout builds the sidebar from their frontmatter. Code is highlighted on the server, and islands wake only where something moves: the [chart playground](/docs/pages), the [UI kit](/docs/ui-kit), the [editable page](/docs/pages). It is published with [`sluurp static`](/docs/static-sites). ```sh title="Terminal" sluurp serve --public https://github.com/SluurpHQ/sluurp/tree/master/website ``` ## Todos Collab [`examples/todos/app`](https://github.com/SluurpHQ/sluurp/tree/master/examples/todos/app) is a to-do list that everybody edits at once, with everybody's cursor on it, in two files. The route declares the data model with `export const schema` and shows one island, [`islands/todos.tsx`](https://github.com/SluurpHQ/sluurp/blob/master/examples/todos/app/islands/todos.tsx), which keeps the list with [Sync](/docs/sync) and shows the cursors with `sluurp/cursors`. Here it is, running in this page: the app's own island, not a picture of it. Open this page in a second window and watch both lists, and each other's cursors, stay in step. ```sh title="Terminal" sluurp serve --public https://github.com/SluurpHQ/sluurp/tree/master/examples/todos/app ``` ## Server components [`examples/server-components/app`](https://github.com/SluurpHQ/sluurp/tree/master/examples/server-components/app) is a `.tsx` page drawn on the server with the kit: an `async` component, a `"use server"` function called in place, and two islands. One island calls that function from the browser; the other wakes when it scrolls into view. ```sh title="Terminal" sluurp serve --public https://github.com/SluurpHQ/sluurp/tree/master/examples/server-components/app ``` ## A wiki [`examples/wiki/app`](https://github.com/SluurpHQ/sluurp/tree/master/examples/wiki/app) is [Pages](/docs/pages) as a whole app, in one file: nested pages, history, a graph of links, books and PDF export. It adds only sign-in, its words and a header. ```sh title="Terminal" sluurp serve --public https://github.com/SluurpHQ/sluurp/tree/master/examples/wiki/app ``` ## Components [`examples/components`](https://github.com/SluurpHQ/sluurp/tree/master/examples/components) is the UI kit's workshop: galleries of every component, the JSX runtime, TypeScript served as it is written, and benchmarks of the renderer. The browser tests for the kit run against it. ```sh title="Terminal" sluurp serve --public https://github.com/SluurpHQ/sluurp/tree/master/examples/components ```