Shared Pagination Contract

This note defines the shared pagination contract we want to use for listing pages going forward.

It is meant to be reused across list-style endpoints rather than re-designed per page.

The immediate adoption target is:

  • POST /oauth/clients/search
  • POST /admin/user/search
  • POST /oauth/device/search/user
  • POST /oauth/client_credentials/search/app
  • POST /oauth/clients/connected/search
  • POST /oauth/clients/authorized/search
  • POST /account/me/auth/request_log/search
  • POST /admin/auth/request_log/search
  • POST /account/me/session/search
  • POST /admin/session/search

The immediate frontend consumer is:

  • /members/settings/app-registration

This contract is derived from existing first-party list behavior, especially:

  • POST /v1/members/message/template/list in api.post.m7.org
  • drop list endpoints in api.drop.m7.org

Scope

Use this contract for:

  • list endpoints
  • search endpoints
  • inventory pages
  • owner/admin tables
  • any route returning multiple records for a pager UI

Do not use this contract for:

  • single-record view endpoints
  • internal helper lookups that expect one row
  • non-list write endpoints

Goals

  • one request contract for pageable lists
  • one response shape for pageable collections
  • backend-first consistency
  • reusable frontend pager behavior
  • no need to reverse-engineer pagination per endpoint

Constants

Pagination config keys and fallback values should be kept in:

  • thisProject\Constants

Current pagination constants live in:

  • lib/php/thisProject/Constants.php

Use that class as the source of truth for:

  • shared generic pagination config keys
  • endpoint-specific pagination config keys
  • endpoint fallback default limits
  • endpoint fallback max limits

When onboarding a new pageable endpoint:

  • add its config lookup names to thisProject\Constants
  • add its fallback default and max values there
  • update this document if the shared contract changes

Request Contract

All pageable list endpoints should accept these pagination inputs:

  • limit
  • page_number
  • offset
  • cursor

Endpoint-specific filters remain endpoint-specific.

Examples:

  • status
  • name
  • search
  • owner
  • archived

limit

Rules:

  • Optional
  • Default is endpoint-defined
  • Maximum is endpoint-defined
  • Current generic fallback maximum is 100
  • Numeric input is cast to integer
  • Values less than 1 fall back to the default
  • Values greater than the effective max are clamped to that max
  • Non-numeric input falls back to the default

Important behavior:

  • Invalid limit does not fail the request
  • It normalizes to the effective limit instead
  • Clients should rely on the returned limit field as the effective page size

Current examples:

  • Post message-template lists default to 3
  • Drop lists default to 20

page_number

Rules:

  • Optional
  • 1-based
  • Must validate as an integer greater than or equal to 1
  • Invalid values should fail the request

Meaning:

  • 1 = first page
  • 2 = second page

offset

Rules:

  • Optional
  • 0-based
  • Must validate as an integer greater than or equal to 0
  • Invalid values should fail the request

Meaning:

  • 0 = first row
  • 3 with limit = 3 = second page start

cursor

Rules:

  • Optional
  • Treated as opaque by callers
  • Invalid values should fail the request

Important contract note:

  • cursor encoding is not required to be globally identical across services
  • the public contract is that a caller may send a previously returned cursor
  • the backend owns cursor generation, parsing, and validation

Current examples:

  • Post uses a base64-encoded JSON cursor carrying an offset
  • Drop uses a record cursor shaped like created|id

Precedence

When multiple position inputs are supplied, use this precedence:

  • cursor
  • offset
  • page_number
  • implicit first page

That means:

  • if cursor is present, ignore offset and page_number
  • if offset is present, ignore page_number
  • if none are present, use page 1 / offset 0

Response Contract

Successful pageable responses should return a data object with this shape:

{
  "status": 1,
  "comment": "OK",
  "data": {
    "items": [],
    "limit": 3,
    "total_count": 0,
    "next_offset": 3,
    "page_number": 1,
    "page_total": 0,
    "next_cursor": null
  }
}

Fields

  • items
    • Array of records for the current page
  • limit
    • Effective normalized limit used for the query
  • total_count
    • Total matching rows before paging
  • next_offset
    • Effective current offset plus limit
    • Return it even when there is no next page
  • page_number
    • Effective current page, 1-based
  • page_total
    • Total pages as ceil(total_count / limit)
    • Can be 0 when total_count is 0
  • next_cursor
    • Cursor for the next page when more rows exist
    • null when there is no next page

Query Behavior

To match the reference implementation, pageable list handlers should:

  • normalize limit
  • resolve the effective page position from cursor, offset, or page_number
  • compute total_count against the active filters before paging
  • fetch limit + 1 rows at the effective offset
  • use the extra row only to detect whether another page exists

Detailed behavior:

  • If more than limit rows are fetched, trim the extra row before returning
  • items.length must never exceed limit
  • next_cursor is only set when more rows exist
  • next_offset is still returned even on the last page
  • next_offset may be greater than total_count
  • cursor-backed endpoints may use keyset query conditions internally instead of an SQL offset scan, as long as they still return the shared response shape

Derived values:

  • page_number = floor(offset / limit) + 1
  • page_total = ceil(total_count / limit)
  • next_offset = offset + limit

Cursor Behavior

Rules:

  • generate cursor values from backend-owned state
  • decode and validate on input
  • reject blank or malformed cursor values
  • keep cursor format opaque to callers

Supported implementation styles today:

  • offset cursor
  • keyset / record-position cursor

Important caller guidance:

  • callers should treat cursor as opaque
  • callers should not build or parse cursor values themselves in app code

Caller Guidance

Classic pager UIs should typically send:

  • limit
  • page_number

Forward-only loaders may use:

  • limit
  • cursor

When filters change, callers should:

  • reset to page 1, or
  • omit position inputs so the backend defaults to the first page

Callers should not use next_offset alone as proof that a next page exists.

Prefer one of these checks instead:

  • next_cursor !== null
  • page_number < page_total

Compatibility Notes

This contract is pagination-only.

It does not standardize:

  • filter names
  • search field names
  • sorting fields
  • item payload structure

Important migration note:

  • endpoints that currently return a bare list in data will need a shape change to return data.items plus paging metadata

That is a contract change for current callers and should be applied deliberately.

First api.user Adoption

The first planned api.user adoption is:

  • POST /oauth/clients/search

Target behavior:

  • keep existing ownership/filter semantics
  • add shared pagination request inputs
  • return the shared pageable data shape
  • use a current pageable default limit of 20

This is intended to support:

  • /members/settings/app-registration

Frontend modeling should follow after the backend contract is in place.

Reference Behavior Studied

The contract above was derived from the existing Post implementation and usage:

  • api.post.m7.org/lib/php/thisProject/service/MessageTemplate.php
  • api.post.m7.org/lib/php/thisProject/service/MessageGroup.php
  • api.post.m7.org/lib/php/thisProject/service/MessageGroupTemplate.php
  • api.post.m7.org/lib/php/thisProject/service/job/TableManager.php
  • post.m7.org/public_html/site/at-conf/members/message/template/list/index.js
  • post.m7.org/assets/pages/members/message/template/list-toolbar.php
  • api.drop.m7.org/lib/php/thisProject/sql/drops.php
  • api.drop.m7.org/lib/php/thisProject/api/drop/ListItems.php
  • api.drop.m7.org/lib/php/thisProject/api/admin/ListItems.php

Notable reference behaviors worth preserving:

  • request inputs include limit, page_number, offset, and cursor
  • response fields include items, limit, total_count, next_offset, page_number, page_total, and next_cursor
  • limit clamps to 100
  • cursor takes precedence over offset and page_number
  • next_offset is returned even when next_cursor is null
  • page_total can be 0 for an empty result set
  • internal cursor strategy may differ by service
1