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/searchPOST /admin/user/searchPOST /oauth/device/search/userPOST /oauth/client_credentials/search/appPOST /oauth/clients/connected/searchPOST /oauth/clients/authorized/searchPOST /account/me/auth/request_log/searchPOST /admin/auth/request_log/searchPOST /account/me/session/searchPOST /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/listinapi.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
viewendpoints - 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:
limitpage_numberoffsetcursor
Endpoint-specific filters remain endpoint-specific.
Examples:
statusnamesearchownerarchived
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
1fall 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
limitdoes not fail the request - It normalizes to the effective limit instead
- Clients should rely on the returned
limitfield 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 page2= 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 row3withlimit = 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:
cursoroffsetpage_number- implicit first page
That means:
- if
cursoris present, ignoreoffsetandpage_number - if
offsetis present, ignorepage_number - if none are present, use page
1/ offset0
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
- Effective current offset plus
page_number- Effective current page, 1-based
page_total- Total pages as
ceil(total_count / limit) - Can be
0whentotal_countis0
- Total pages as
next_cursor- Cursor for the next page when more rows exist
nullwhen 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, orpage_number - compute
total_countagainst the active filters before paging - fetch
limit + 1rows at the effective offset - use the extra row only to detect whether another page exists
Detailed behavior:
- If more than
limitrows are fetched, trim the extra row before returning items.lengthmust never exceedlimitnext_cursoris only set when more rows existnext_offsetis still returned even on the last pagenext_offsetmay be greater thantotal_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) + 1page_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
cursoras opaque - callers should not build or parse cursor values themselves in app code
Caller Guidance
Classic pager UIs should typically send:
limitpage_number
Forward-only loaders may use:
limitcursor
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 !== nullpage_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
datawill need a shape change to returndata.itemsplus 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
datashape - 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.phpapi.post.m7.org/lib/php/thisProject/service/MessageGroup.phpapi.post.m7.org/lib/php/thisProject/service/MessageGroupTemplate.phpapi.post.m7.org/lib/php/thisProject/service/job/TableManager.phppost.m7.org/public_html/site/at-conf/members/message/template/list/index.jspost.m7.org/assets/pages/members/message/template/list-toolbar.phpapi.drop.m7.org/lib/php/thisProject/sql/drops.phpapi.drop.m7.org/lib/php/thisProject/api/drop/ListItems.phpapi.drop.m7.org/lib/php/thisProject/api/admin/ListItems.php
Notable reference behaviors worth preserving:
- request inputs include
limit,page_number,offset, andcursor - response fields include
items,limit,total_count,next_offset,page_number,page_total, andnext_cursor limitclamps to100cursortakes precedence overoffsetandpage_numbernext_offsetis returned even whennext_cursorisnullpage_totalcan be0for an empty result set- internal cursor strategy may differ by service