Skip to content

List envelope and paging

Collection endpoints in ALYT return the same paging shape, so a client that can read one collection can read the rest. The shared response envelope is { "items": [ ... ], "nextCursor": "eyJ..." }, and nextCursor is null on the last page. Clients branch on nextCursor, not on items.length, because a full page can still be the last page and an empty page does not prove there is nothing after it.

Every collection endpoint answers with the same envelope when it grows with tenant data:

{ "items": [ ... ], "nextCursor": "eyJ..." }

The meaning is the same everywhere:

  • items contains the rows for the current page.
  • nextCursor points to the next page.
  • nextCursor: null means there is no next page.

Do not use items.length to decide whether there is more data. A page can be full and still be the last one.

Paging uses limit and cursor query parameters:

GET /devices?tenantId=…&limit=50
GET /devices?tenantId=…&limit=50&cursor=<nextCursor from the previous response>

Rules for these parameters:

  • limit defaults to 50.
  • limit is capped at 200.
  • Values outside that range are clamped, not rejected.
  • cursor is opaque. Do not parse it or build one.
  • A cursor that was not issued by ALYT, or one whose row no longer exists, is treated as if paging starts from the beginning. It is not an error.

This makes paging safe for callers. The API does not expect clients to understand cursor contents.

Paging by offset is fragile because inserts shift rows by position. If a device is registered while someone is paging a fleet, later rows move down and one can be skipped. A cursor avoids that by naming the last row seen, so new rows do not corrupt the sequence.

This matters most where paging matters most: telemetry, the audit log, and voice turns are all written continuously.

A cursor includes a sort key and an id. The id matters because sort keys are not always unique. Two rooms can share a name, and two telemetry rows can share the same millisecond. Without the tie-break, a page boundary can skip a row or repeat one forever.

Ordering is deliberate and depends on the collection.

CollectionOrder
/devicesoldest first, by creation
/tenants, /tenants/:id/clients, /rooms, /propertiesby name
telemetry, activity, voice turns, registrations, firmwarenewest first

The paging helper supports that by taking a direction and a key type, so the collection keeps its own order instead of being forced into one global order.

/devices/alerts, /devices/commissioning, and /devices/rollouts are derived from current state rather than read directly from a table. They still return the same envelope.

That matters because a client must not need to know whether a collection is derived. These collections page in memory, and the cursor locates a row by identity rather than by position. That lets alerts keep their severity-first ordering instead of being forced into time order for the sake of paging.

When you add a new collection endpoint, it should follow the same pattern:

  • Resolve limit with the shared paging helper.
  • Decode cursor with the shared paging helper.
  • Fetch limit + 1 rows.
  • Return the page envelope.
  • Declare the query parameters in the contract so the published OpenAPI document shows them.

Fetching limit + 1 is required. The extra row tells the API whether there is another page without a second count query. That is what makes nextCursor reliable.

Two endpoints return { items } and do not include nextCursor on purpose:

  • GET /capabilities
  • GET /rooms/suggestions

These are fixed catalogues, not collections. The capability catalogue is static for a platform build and is served with an ETag. The room suggestions are a fixed list of names. Neither grows with tenant data, so there is nothing to page through.

The rule is not “does it return a list”. The rule is “can this grow without a deploy”. If the answer is yes, it pages by cursor. If the answer is no, a bare { items } is acceptable.

The console unwraps the envelope in one place and fails loudly if an endpoint does not return it, so a migrated service does not look like an empty screen. home-agent does the same.

At present, the console renders the first page only and does not follow nextCursor yet. That means large tenants can see truncated lists in the UI. httpPage() is available for the later follow-up, and because the envelope is unwrapped in one place, that change is small when it lands.

DEV and LIVE keys, and local-first behavior

Section titled “DEV and LIVE keys, and local-first behavior”

A DEV key works in a sandbox. A LIVE key works only for its own connector. Keep that in mind when testing list behavior, because the API surface and paging rules are the same, but the data scope is not.

ALYT is local-first, so the house keeps working when the cloud is down. A household sees less than staff do, but the paging contract stays the same in both cases: collections use the envelope, cursors are opaque, and clients follow nextCursor to reach the next page.

Last updated