Skip to content

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; for the HTTP surface underneath see the HTTP API reference; for the manifest keys mentioned here see the manifest reference.

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 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 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.

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 for the mirror key and its values.

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); 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 as authoritative if they move.

Sorting and cursor pagination are exclusive

Section titled “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.

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 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.

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.