Sync

A shape is a filtered view of a collection. The server sends its rows, then every row that enters, changes or leaves it, so you never fetch, refetch or patch lists 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>

// optimistic: shows immediately
await marks.update(id, { value: 8 }, { reason: "Re-marked" });
  • One WebSocket (/api/sync) carries every shape on the page and reconnects automatically.
  • Rows are read as the signed-in user, through the collection’s rules. Each change is re-checked per client before sending, so an update can never leak a row the user can’t see.
  • No stale overwrites: the server sends the current row, not the event, so an older version never replaces a newer one.
  • Optimistic writes go through the regular API and are rolled back if the server rejects them.
  • keep: true renders the last rows instantly on the next visit from IndexedDB, until the server’s arrive. Off by default, since data stays on the device. Only small shapes (up to 2,000 rows) are persisted. The cache is per user, so the next person on a shared computer never sees it, and signing out clears it.

Every list kept current

In the browser, listAll is backed by a shape too. The first time a screen runs a query (collection + filter + sort), the rows arrive over the sync socket. After that, the same query is answered locally from rows that are kept up to date, with no request. So in a single-page app, going back to a screen shows its data instantly, including changes other people made in the meantime. Right after the page writes to a collection, the next read goes to the server so it includes the write.

Each kept query is a live subscription: it holds rows in memory and the server re-checks it on every write to that collection. So the cache is bounded by the memory its rows take (about 16 MB, or 32 MB on devices that report 8 GB of RAM or more; phones crash well before a desktop would) and by count (128). When either limit is hit, the least recently read query is dropped. Many small queries fit; a few large ones take the whole budget. Set sluurp.syncedReads = false to always go to the server.

To make UI follow the rows reactively, use live. A value derived from two collections recomputes when either changes, and only then:

import { live } from "sluurp/sync";
import { computed } from "sluurp/reactive";

const marks = live(sluurp.collection("grades"), { filter: 'class = "class4a0000000"' });
const pupils = live(sluurp.collection("users"), { filter: 'class = "class4a0000000"' });
const average = computed(() => mean(marks().filter((m) => pupils().some((p) => p.id === m.pupil)).map((m) => m.value)));

expand embeds each row’s related records, as it does for the API, and keeps them current. Rename a pupil and every mark showing that pupil updates, even though the marks themselves didn’t change:

const marks = live(sluurp.collection("marks"), { filter: `class = "4A"`, expand: "pupil" });
// marks()[0].expand.pupil.name

The server tracks which related records each row shows. A change to one of them sends those rows again, read through the rules as you, so a related record you can’t read never reaches you. (Supabase’s realtime sends only the changed table’s own row, and leaves this join to the client.)

Row-level reactivity

Every row on the page has its own signal, shared by every list containing it. The server says which row changed, and only what read that row recomputes: changing one row never wakes code that read another.

import { liveRow } from "sluurp/sync";

const a1 = liveRow(sluurp.collection("cells"), "demoa1000000000");
const doubled = computed(() => Number(a1()?.formula ?? 0) * 2);

update and delete apply immediately to every list and row that holds the record, before the server responds, and roll back if it rejects them. A created row appears once the server says which lists it belongs to, a moment later. getOne(id) is answered locally from any synced list holding that row, with no request, except right after a write to that collection, when it asks the server.

A row only receives changes while it’s in a list the page holds (via live, listAll or a shape). examples/sheet is a spreadsheet built this way: each cell is a row, and formulas read the rows they reference. Change a value or formula anywhere and every browser recomputes just its dependents, transitively, and nothing else. See Examples.

SQL in the browser

By default rows live in an in-memory store. sluurp/sync/sqlite stores them in SQLite in the browser instead (the official WebAssembly build, vendored), with live queries.

// 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 a snapshot into a table named <collection>@<asOf> (e.g. grades@2026-06-30). Local SQL can then use FOR SYSTEM_TIME AS OF, like the server’s console.

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'");

An agent-driven chat is built this way: the conversation and its tables are live queries over synced rows, while agents respond on the server.

Cursors

sluurp/cursors shows everyone’s pointer over a page, each in their own colour with a name label. Positions go over /api/ws (not stored), at most once per frame, and are interpolated so motion stays smooth on a jittery network. An idle pointer starts fading after three seconds until it disappears, and reappears on movement. Selected text (including in inputs) is quoted next to the pointer, but only within the tracked element.

Apps can share extra state: state is read each frame and sent when it changes, and onState receives everyone else’s with their name and colour. The Sheet uses this to show each user’s selection in their colour:

liveCursors("cells:*", { over: table, state: () => area(), onState: (others) => theirs.set(others) });
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:*); anyone who can list it can join.