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.
The synchronous mirror
Section titled “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, orundefined.
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 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.
Mirror modes
Section titled “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.serverBackednever 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 andsnapshot()is always empty, so every read goes throughlist()/count(). Use this for a collection that is large by design.
See the 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”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 }rowsis this page of rows.has_moreistruewhen more rows remain past this page.next_cursoris an opaque continuation token (do not parse it). It isnullon 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.
Counting without reading
Section titled “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 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”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.