---
title: Sync
description: Shapes of collections kept current in the browser over one WebSocket, in memory or SQLite.
section: Data
order: 3
---

# Sync

<p class="lead">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.</p>

```tsx title="Browser"
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: 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, `<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.

```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.
