Paginate GET /api/v1/messages with limit and an opaque cursor - #35
Conversation
…e cursor Add `limit` (1 to 100, default 100) and an opaque, versioned keyset `cursor` to `GET /api/v1/messages`. Rows are ordered by `COALESCE(received_at, sent_at, created_at) DESC, id DESC`, and the query reads one extra row to learn whether another page follows. The response body stays the existing JSON array of MessageSummary; a further page is advertised through an RFC 8288 `Link: <url>; rel="next"` header that keeps `mailboxId`, `folder`, `search`, and `limit`. The last page carries no `Link` header. Message cursors use version tag `m1`, so a conversation cursor (version `1`) never decodes as a message cursor. Both lists now share one keyset codec while keeping their own version tag and error code. A malformed limit returns `INVALID_LIMIT` and a malformed or foreign cursor returns `INVALID_CURSOR`, both 400 in the standard error envelope. The mailbox-access filter is applied to every page and is never relaxed by a cursor. Migration 0012 adds expression indexes on the activity order. Before it, EXPLAIN QUERY PLAN showed `USE TEMP B-TREE FOR ORDER BY` for the list query; after it, the list, keyset, and single-mailbox plans are served from an index. Regenerate the OpenAPI contract and the Postman collection, and describe the paging rules in the published Agent Skill. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bermanto
left a comment
There was a problem hiding this comment.
Thanks for this careful implementation. The pagination contract, access filtering, cursor versioning, and integration coverage look good. I found one focused blocker in the index migration: the default multi-mailbox query can still build a temporary order B-tree because the planner has no statistics for the new indexes. Please add PRAGMA optimize and cover the no-folder, multi-mailbox production query shape in the migration plan test. I would keep all three indexes; the global activity index is useful for broad unfiltered listings once the planner can choose it.
…s query shape Per review: with two readable mailboxes and no folder filter — the route's default shape, since it always adds mailbox_id IN (...) — the planner kept messages_mailbox_idx and a temporary B-tree until it had statistics for the new ordering indexes. The plan test now seeds both mailboxes and asserts that shape is index-served; the assertion fails when PRAGMA optimize is removed.
|
Done in the latest commit:
|
Part 1 of 2 from #11 (pagination only, per your contract). Spec-first companion: HQBase/hqbase-site#10
Summary
GET /api/v1/messagesacceptslimit(integer 1–100, default 100) and an opaque, versionedcursorover(activity_at, id).COALESCE(received_at, sent_at, created_at) DESC, id DESC; fetcheslimit + 1to detect a next page.MessageSummary). When another page exists the response carriesLink: <absolute-url>; rel="next"preservingmailboxId,folder,search,limitpluscursor; noLinkon the final page.limit/cursor→400 INVALID_LIMIT/400 INVALID_CURSORin the standard error envelope. The mailbox-access predicate is built independently of the cursor and applies to every page."m1"), so a conversation cursor is rejected here and vice versa. The base64url keyset codec is factored intoworker/features/messages/keyset-cursor.tsand conversations were moved onto it — wire format andINVALID_CONVERSATION_CURSORbehaviour are byte-for-byte unchanged (a shipped path, so worth a glance in review).updatedSince, a changes endpoint, or a change-journal migration.listMessagesstill returns the array and delegates to the new page query).Index
EXPLAIN QUERY PLANon a 200k-row replica showedUSE TEMP B-TREE FOR ORDER BYfor every filter shape (existing indexes are(col, created_at)), so migration0012_message_activity_index.sqladds expression indexes on the activity order — one each for unfiltered, mailbox-filtered and folder-filtered listings — after which list, keyset and single-mailbox plans are index-served with no temp B-tree, and that holds on a fresh database withoutANALYZE.Two things I'd like your call on: (a) three indexes is 3 extra B-tree writes per inserted message;
messages_folder_activity_idx+messages_mailbox_activity_idxcover every shape the web app issues, andmessages_activity_idxonly earns its keep for a multi-mailbox listing with no folder — happy to trim to two. (b) A multi-valuemailbox_id IN (…)with no folder filter still sorts via a temp B-tree unlesssqlite_stat1exists (SQLite won't sort-merge acrossINvalues without stats); D1 never runsANALYZEon its own. I did not addANALYZEto the migration.Tests
Integration (
test/integration/worker/mail-api.test.ts, new "message pagination" block): equal activity timestamps split across a page boundary (exact per-page contents asserted, so a missing id tiebreak or an off-by-one shows as a duplicate/dropped row); noLinkon the final page; filter preservation in the next URL, then followed; an unreadable mailbox never appears on any page; a hand-built cursor pointing into an unreadable mailbox resumes correctly without leaking it; default and cap of 100; invalid limits (0,101,abc,-1,1.5, empty) →INVALID_LIMIT; malformed cursor and a genuine conversation cursor →INVALID_CURSOR.Migration coverage:
message-activity-index.test.ts(upgrade: plan uses a temp B-tree at 0011, does not after 0012; re-apply idempotent) andlocal-reset.test.ts(fresh install: the three indexes exist).OpenAPI edited surgically (
limit,cursor,Linkheader, 400 codes); Postman regenerated and verified. Agent Skill text updated to describe message cursors.Validation
pnpm check: biome, typecheck, api:check, integration (11 files / 60 tests), coverage, architecture, build pass. Only failure is the pre-existinguse-draft-autosave.test.tsxlocalStorage case under Node 26.5 (reproduces on pristinemain). Architecture check now warns thatqueries.tsis over 300 lines (still passes); say if you'd like the paging query split into its own module.🤖 Generated with Claude Code