Pagination Contract
Checkstack ships a single shared pagination contract from
@checkstack/common. Every paginated list endpoint - across every
*-common package - is expected to consume this contract. Inconsistent
shapes (page/pageSize vs limit/offset vs bare limit) made client-side
pagination controls and shared list components painful to write, so we
collapsed them onto one canonical pair.
Canonical shape
Section titled “Canonical shape”import { z } from "zod";import { PaginationInput, PaginatedResult } from "@checkstack/common";
// Inputconst ListNotificationsInput = PaginationInput;// → { limit: number (1-100, default 20), offset: number (>= 0, default 0) }
// Output (factory — pass the per-item schema)const ListNotificationsOutput = PaginatedResult(NotificationSchema);// → { items: Notification[], total: number, limit: number, offset: number }limitis an integer in[1, 100], defaulting to20.offsetis a non-negative integer, defaulting to0.totalechoes the unpaginated row count so the client can render page indicators.limitandoffsetare echoed on the response so the client always knows what the server actually applied (including when defaults kicked in).
Why offset-based, not page-based
Section titled “Why offset-based, not page-based”limit + offset was chosen over page + pageSize because:
- It composes with cursor-style cursors later. Adding an optional
cursorfield next tooffsetis straightforward; switching the primary key frompageto a cursor is not. - It is unambiguous when
limitchanges mid-session. A user changing the page size while browsing keeps a stable scroll position under offset; under page-based pagination the same change silently shifts which items are visible. - Backends already speak SQL
LIMIT/OFFSET. No translation layer in the handler. - No
1-vs-0indexing confusion. Offsets are always zero-based.
The page-based shape that previously lived in integration-common
({ page, pageSize }) has been removed; there is no page /
pageSize alias on the canonical schema.
Extending with domain extras
Section titled “Extending with domain extras”When a list endpoint needs extra filters (e.g. unreadOnly on
notifications, severity on incidents) compose with .extend({...}).
Do not redefine limit / offset.
import { z } from "zod";import { PaginationInput, PaginatedResult } from "@checkstack/common";
export const ListNotificationsInput = PaginationInput.extend({ unreadOnly: z.boolean().default(false),});
export const ListNotificationsOutput = PaginatedResult(NotificationSchema);This keeps the canonical fields stable while the domain extras live next to the procedure that owns them.
Anti-patterns
Section titled “Anti-patterns”- Do not add
page/pageSizealiases on top ofPaginationInput. Pick one shape and stick with it - back-compat aliasing brings the inconsistency back through the side door. - Do not raise the upper bound past
100without a platform-wide discussion. The cap is intentional - it prevents an accidentallimit=10000from blowing the response budget on heavy procedures. - Do not redefine
totalsemantics. It is always the unpaginated row count for the same filter criteria. - Do not silently transform an outer
{ items, total }into the canonical four-field result without also returninglimitandoffset. The echoed pagination state is part of the contract.
Status: rollout
Section titled “Status: rollout”The canonical schema is exported from @checkstack/common and is the
single source of truth for paginated list inputs and outputs across
every *-common package. The deprecated PaginationInputSchema,
paginatedOutput, and PaginatedResponse symbols have been removed -
new code must consume the canonical exports above.
Migrated procedures
Section titled “Migrated procedures”The following procedures were converted to the canonical
{ limit, offset } input and { items, total, limit, offset } output
in the v1 pagination sweep:
@checkstack/notification-common
Section titled “@checkstack/notification-common”getNotifications- input was the legacyPaginationInputSchema({ limit, offset, unreadOnly }) with output{ notifications, total }. Now consumesPaginationInput.extend({ unreadOnly })(exported asListNotificationsInputSchema) and returnsPaginatedResult(NotificationSchema). The output key renamed fromnotificationstoitems.
@checkstack/integration-common
Section titled “@checkstack/integration-common”listSubscriptions- input was inline page-based{ page, pageSize, providerId?, eventType?, enabled? }with output{ subscriptions, total }. Now consumesPaginationInput.extend({ providerId?, eventType?, enabled? })and returnsPaginatedResult(WebhookSubscriptionSchema). The output key renamed fromsubscriptionstoitems.getDeliveryLogs- input wasDeliveryLogQueryInputSchema({ subscriptionId?, eventType?, status?, page, pageSize }) with output{ logs, total }. The schema now extendsPaginationInputwith the same domain filters and returnsPaginatedResult(DeliveryLogSchema). The output key renamed fromlogstoitems.
Out of scope for v1
Section titled “Out of scope for v1”slo-commongetDowntimeEvents/getRecentMilestonesandanomaly-commongetAnomalies- bare-limittop-N feeds, not paginated lists (nototal, no offset, no page UI). They retain their existing shape.cache-commonlistEntriesandqueue-commonlistJobs- already use{ limit, offset }but withlimit.max(200)(domain-specific upper bound) and an additivehasMore/ nullabletotalin the response envelope to support backends that cannot cheaply count. These are intentionally not on the canonical contract.healthcheck-commongetHistory/getDetailedHistory- already use{ limit, offset }withtotalin the response. Output key isruns(semantically meaningful) and the input default islimit=10rather than the canonical20. Migration is deferred to avoid churning every history call site for a contract that is already substantively correct.