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.
import { Sync } from "sluurp/sync";
const sync = new Sync();
const marks = sync.shape("grades", { filter: 'class = "class4a0000000"' });
<ul>{() => marks.rows().map((g) => <li>{g.value}</li>)}</ul>
// 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: trueshows 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.
// 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, <collection>@<asOf>: 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.
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 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.
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.