# Reading a collection

Homespun apps are local-first. At boot the SDK mirrors each declared collection into the browser and serves it synchronously, kept live over the websocket. That covers most reads with no network round trip. For a collection too large to hold in the browser, or for a filtered slice of one, the query API (`list()` and `count()`) reads the server instead: asynchronous, paginated, and permission-checked per request.

This page is the map of that read model: which reads are synchronous and which are network reads, what happens when a collection outgrows the mirror, and the exact pagination contract the query API returns. For the precise method signatures see the [SDK reference](/agents/sdk-reference/); for the HTTP surface underneath see the [HTTP API reference](/reference/api/); for the manifest keys mentioned here see the [manifest reference](/agents/manifest-reference/).

## The synchronous mirror

[Section titled “The synchronous mirror”](#the-synchronous-mirror)

Every collection the manifest declares is mirrored into the browser at boot. Two reads work off that mirror, both synchronous and neither ever hitting the network:

* `snapshot(name)` returns the collection’s rows as an array.
* `get(name, key)` returns one row by key, or `undefined`.

The mirror is live. As rows are created, updated, and deleted (by this user or anyone else sharing the app), the relay pushes deltas over the websocket and the mirror updates in place. Subscribe to those deltas with `on(name, handler)` to re-render on change. `snapshot()` and `get()` never throw and never block, so you can call them straight from render.

## The bounded mirror window

[Section titled “The bounded mirror window”](#the-bounded-mirror-window)

The browser cannot hold an unbounded collection, so each collection mirrors only up to a per-account cap. The cap has two dimensions and the collection stops mirroring at whichever it hits first: a **row count** and a **byte size**. Both are plan-driven and delivered to the client at boot in the websocket hello, so a paid tier can raise them without an app change. The defaults are 10,000 rows and 25 MiB per collection.

The important property is that reading never breaks when a collection exceeds the cap. `snapshot()` stays synchronous and still never throws. Past the cap it simply returns the mirrored **window**, the newest rows that fit, rather than the whole collection. What it does not do is silently pretend the window is everything, and that is what `status()` is for:

```
const { serverBacked, mirroredRows, mirroredBytes } = homespun.collections.status("orders");
if (serverBacked) {
  // snapshot("orders") is only the newest window. Use list()/count() for the rest.
}
```

`status(name)` returns `{ serverBacked, mirroredRows, mirroredBytes }`. `serverBacked` is `false` while the whole collection fits in the mirror and flips to `true` the moment the collection hits its cap. The `mirroredRows` and `mirroredBytes` count only the window’s own contents, not the collection’s true size. See the [SDK reference](/agents/sdk-reference/) for the exact signature.

A collection can cross the cap **mid-session**, not just at boot: a collection that starts small and grows past its cap while the app is open flips `serverBacked` at the crossing. When it does, any `on(name, handler)` listener receives a status delta at that instant:

```
homespun.collections.on("orders", (delta) => {
  if (delta.kind === "status" && delta.serverBacked) {
    // The collection just outgrew its window. Switch this view to list()/count().
  }
});
```

So a correct reader treats `serverBacked` as a runtime signal, not a boot-time constant: check it, and react to the `status` delta if the view must stay right as data grows.

## Mirror modes

[Section titled “Mirror modes”](#mirror-modes)

The default behaviour above (`auto`) is per-collection overridable in the manifest under the collection’s `mirror` key:

* **`auto`** (default): mirror up to the cap, then go server-backed. The behaviour described above.
* **`eager`**: always mirror the whole collection, ignoring the cap. `serverBacked` never flips. Use this only for a collection you know stays small and want fully in-browser.
* **`server`**: never mirror. The collection is marked server-backed at boot and `snapshot()` is always empty, so every read goes through `list()` / `count()`. Use this for a collection that is large by design.

See the [manifest reference](/agents/manifest-reference/) for the `mirror` key and its values.

## The query API and its pagination contract

[Section titled “The query API and its pagination contract”](#the-query-api-and-its-pagination-contract)

When a collection is server-backed, or when you want a filtered or sorted slice without pulling the whole thing into the browser, read it with `list()`. Unlike `snapshot()`, this is a network read: it is asynchronous, the relay applies read permission and author scoping before returning anything, and it comes back one page at a time.

Every page has the same shape:

```
{ "rows": [ ... ], "next_cursor": "<opaque>", "has_more": true }
```

* `rows` is this page of rows.
* `has_more` is `true` when more rows remain past this page.
* `next_cursor` is an **opaque** continuation token (do not parse it). It is `null` on the last page.

To page forward, pass the previous page’s `next_cursor` back as the `since` parameter on the next request. Over HTTP that is `GET /v1/apps/:id/collections/:name?since=<next_cursor>` (see the [HTTP API reference](/reference/api/)); the same `{ rows, next_cursor, has_more }` envelope is what the SDK’s `list()` resolves to.

Page size is bounded on both ends. When you do not ask for a `limit`, a page holds up to **100** rows. You may request more with an explicit `limit`, up to a hard ceiling of **1000** rows per page (`ROW_PAGE_MAX`); a larger request is clamped to the ceiling. These are the current defaults; treat the [HTTP API reference](/reference/api/) as authoritative if they move.

## Sorting and cursor pagination are exclusive

[Section titled “Sorting and cursor pagination are exclusive”](#sorting-and-cursor-pagination-are-exclusive)

`list()` accepts a custom `sort`, and it accepts cursor pagination through `since`, but **not both at once**. Cursor pagination is defined for the default order only (newest first). Asking for a custom `sort` together with a `since` cursor is rejected with a clean `400`; a custom-sorted read returns a single page with no continuation cursor. So today you either sort a collection or you page through it in default order, and you pick one per read.

## Counting without reading

[Section titled “Counting without reading”](#counting-without-reading)

A `list()` page deliberately carries **no total**. Getting a collection’s live row count is a separate, opt-in read: `count(name)`.

`count()` is gated by its own manifest permission, `countRead`, independent of read permission. A collection only exposes a count if its manifest declares a `countRead` role list, and the caller must hold one of those roles. This is the “3 spots left” shape: a visitor who may not read the rows can still be shown how many there are, without leaking their contents. A collection that did not opt in rejects the count. See the [manifest reference](/agents/manifest-reference/) for the `countRead` key.

`count()` is reached from the page-side SDK (over the browser data door). It is not part of the `/v1` agent HTTP surface today, so it is available to the app running in the browser rather than to an agent calling the HTTP API directly.

## The read contract, in one line

[Section titled “The read contract, in one line”](#the-read-contract-in-one-line)

Read the mirror synchronously with `snapshot()` / `get()`; watch `status()` and the `status` delta to know when the mirror is only a window; and for anything past that window, or any filtered slice, read the server with `list()` and its `{ rows, next_cursor, has_more }` pages, using `count()` when you need a total. That collections door is the whole read surface. An older `records` API appears in some internal specs; it is superseded by the collections contract described here, and the collections door is authoritative.
