Skip to content

feat: Add cursor pagination to list endpoints; remove offset pagination#42

Merged
xernobyl merged 6 commits into
mainfrom
chore/cursor_pagination
Mar 5, 2026
Merged

feat: Add cursor pagination to list endpoints; remove offset pagination#42
xernobyl merged 6 commits into
mainfrom
chore/cursor_pagination

Conversation

@xernobyl
Copy link
Copy Markdown
Contributor

@xernobyl xernobyl commented Mar 4, 2026

Removes offset+limit pagination and keeps only cursor-based (keyset) pagination across:

  • Feedback records list (GET /v1/feedback-records)
  • Webhooks list (GET /v1/webhooks)
  • Semantic search (POST /v1/feedback-records/search/semantic)
  • Similar feedback (GET /v1/feedback-records/{id}/similar)

Summary of changes

  • Models: Dropped Offset from filters and Total/Offset from responses; added Cursor and NextCursor.
  • Services: Single pagination path—cursor empty → first page (List/Nearest); cursor set → ListAfterCursor / NearestFeedbackRecordsByEmbeddingAfterCursor.
  • Repositories: Removed OFFSET from list queries; added ListAfterCursor for feedback records and webhooks; removed Count() from feedback records; removed offset from embeddings nearest-neighbor.
  • Handlers: Added cursor.ErrInvalidCursor handling for 400 responses; removed offset parsing.
  • OpenAPI: Removed offset query params and total/offset from response schemas.
  • Tests: Adjusted integration and unit tests for cursor-only pagination.

Breaking changes

  • offset query param is no longer supported; clients must use cursor and next_cursor.
  • Response fields total and offset are removed.
  • Migration 006 keyset indexes are used; no new migrations.

API examples

Before (removed):

GET /v1/feedback-records?tenant_id=org-123&limit=100&offset=200

After:

GET /v1/feedback-records?tenant_id=org-123&limit=100
GET /v1/feedback-records?tenant_id=org-123&limit=100&cursor=<next_cursor from previous response>

Response (before):

{"data": [...], "total": 500, "limit": 100, "offset": 200}

Response (after):

{"data": [...], "limit": 100, "next_cursor": "eyJjIjoiMjAyNC0wMS0xNSIsImkiOiIuLi4ifQ=="}

How should this be tested?

  • Run make build and make fmt — should succeed.
  • Run make tests — integration tests pass (including cursor pagination).
  • Run go test ./internal/... ./pkg/... — unit tests pass.
  • Call list/search endpoints without offset; use next_cursor for subsequent pages.
  • Call with invalid cursor — expect 400.

Checklist

Required

  • Filled out the "How to test" section in this PR
  • Read Repository Guidelines
  • Self-reviewed my own code
  • Commented on my code in hard-to-understand bits
  • Ran make build
  • Ran make tests (integration tests in tests/)
  • Ran make fmt and make lint; no new warnings
  • Removed debug prints / temporary logging
  • Merged the latest changes from main onto my branch with git pull origin main
  • If database schema changed: N/A (no schema changes; migration 006 already provides keyset indexes)

Appreciated

  • If API changed: OpenAPI spec updated
  • If API behavior changed: request/response examples included above
  • Updated docs in docs/ if changes were necessary
  • Ran make tests-coverage for meaningful logic changes

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Mar 4, 2026

✱ Stainless preview builds

This PR will update the hub SDKs with the following commit message.

feat: Add keyset (cursor) pagination to list endpoints
hub-openapi studio · code

Your SDK built successfully.
generate ✅

hub-typescript studio · code

Your SDK built successfully.
generate ✅build ✅lint ✅test ✅


This comment is auto-generated by GitHub Actions and is automatically kept up to date as you push.
If you push custom code to the preview branch, re-run this workflow to update the comment.
Last updated: 2026-03-05 12:50:54 UTC

@xernobyl xernobyl marked this pull request as ready for review March 4, 2026 12:16
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 4, 2026

Walkthrough

This PR migrates list endpoints from offset-based to keyset cursor pagination. It adds a pkg/cursor implementation (encode/decode, ErrInvalidCursor), replaces offset fields with cursor parameters in models and service/handler signatures, and introduces ListAfterCursor methods in repositories and services to run keyset queries (timestamp DESC, id ASC). Handlers map cursor decoding errors to 400 Bad Request. A pagination helper builds NextCursor from the last record. SQL migrations add composite indexes to support keyset scans. OpenAPI and tests are updated to reflect cursor parameters and next_cursor responses.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description check ✅ Passed The PR description is comprehensive and well-structured, covering what the PR does, how to test it, and includes a detailed checklist with most items checked. It includes breaking changes, API examples, and clear before/after comparisons.
Title check ✅ Passed The PR title accurately summarizes the main change: transitioning from offset-based to cursor-based pagination across list endpoints.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/repository/feedback_records_repository.go`:
- Around line 245-314: List and ListAfterCursor duplicate the SELECT statement
and the rows.Scan loop which risks drift; extract a shared helper such as
fetchFeedbackRecords(ctx, query string, args ...interface{})
([]models.FeedbackRecord, error) or split into buildBaseSelect() string plus
scanRowsToFeedbackRecords(rows pgx.Rows) ([]models.FeedbackRecord, error). Move
the SELECT column list into the shared buildBaseSelect or use it inside the
helper, have both List and ListAfterCursor call the helper (pass the assembled
WHERE/LIMIT parts and args), and ensure the helper handles query execution, rows
iteration, scanning into models.FeedbackRecord, error wrapping, and rows.Close
to preserve existing behavior.

In `@internal/service/feedback_records_service.go`:
- Around line 168-188: Extract the duplicated pagination assembly into a shared
helper to avoid repeating the same pointer/next-cursor logic used in
ListFeedbackRecords (internal/service/feedback_records_service.go) and
ListWebhooks (internal/service/webhooks_service.go): create a helper (e.g.,
buildPaginatedMeta or buildPaginationResponse) that accepts the records slice,
total (int), filters (with Limit and Offset), and a cursor encoder function
(cursor.Encode) and returns the computed Total *int, Limit int, Offset *int, and
NextCursor string (or a small struct with those fields); then call this helper
from ListFeedbackRecords and ListWebhooks to populate
models.ListFeedbackRecordsResponse and the webhooks response type respectively,
keeping the existing error handling for cursor.Encode in the helper.

In `@openapi.yaml`:
- Around line 158-165: Add an explicit 400 response for invalid cursor on the
list endpoints that accept the query parameter named "cursor" (e.g., the
/v1/feedback-records and /v1/webhooks paths) so generated clients can surface
cursor validation errors; update each operation object to include a 400 response
entry with a clear description like "Invalid cursor" and a response schema
matching your existing error model (reuse the same error schema used by other
400 responses in the spec) to keep the OpenAPI contract aligned with runtime
behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 49ef504b-5798-4047-b51c-e348d8dfbcdd

📥 Commits

Reviewing files that changed from the base of the PR and between eed1a56 and 840dfd7.

📒 Files selected for processing (15)
  • internal/api/handlers/feedback_records_handler.go
  • internal/api/handlers/webhooks_handler.go
  • internal/models/feedback_records.go
  • internal/models/webhooks.go
  • internal/repository/feedback_records_repository.go
  • internal/repository/webhooks_repository.go
  • internal/service/feedback_records_service.go
  • internal/service/webhook_provider_test.go
  • internal/service/webhook_sender_test.go
  • internal/service/webhooks_service.go
  • internal/service/webhooks_service_test.go
  • migrations/006_list_keyset_indexes.sql
  • openapi.yaml
  • pkg/cursor/cursor.go
  • tests/integration_test.go

Comment thread internal/repository/feedback_records_repository.go
Comment thread internal/service/feedback_records_service.go Outdated
Comment thread openapi.yaml
@xernobyl
Copy link
Copy Markdown
Contributor Author

xernobyl commented Mar 4, 2026

@coderabbitai can you re-review?

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 4, 2026

@xernobyl Sure! I'll kick off a fresh review of the PR right away.

✅ Actions performed

Full review triggered.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/repository/webhooks_repository.go`:
- Around line 149-185: The List and ListAfterCursor methods duplicate query
execution and row-to-model mapping; extract the shared logic into a single
helper such as fetchWebhooks or scanWebhooksRows that accepts ctx, query and
args (and returns ([]models.Webhook, error)), centralizes rows scanning
(including scanning into dbEventTypes, calling parseDBEventTypes, and mapping
fields
ID/URL/SigningKey/Enabled/TenantID/CreatedAt/UpdatedAt/DisabledReason/DisabledAt),
closes rows and checks rows.Err(), and is called by both List and
ListAfterCursor to avoid drift when selected columns change.

In `@internal/service/feedback_records_service.go`:
- Around line 121-126: The code dereferences the filters parameter
(filters.Limit and filters.Cursor) without a nil check; add a defensive nil
guard at the start of the function that owns this code in
internal/service/feedback_records_service.go: if filters == nil, initialize a
default filters value (or assign a local default struct) before using it so the
subsequent logic that sets filters.Limit (default 100) and uses
strings.TrimSpace(filters.Cursor) cannot panic; ensure the rest of the function
uses that non-nil filters variable.

In `@internal/service/pagination.go`:
- Around line 11-26: BuildListPaginationMeta currently treats recordCount==limit
as "more pages" which causes a spurious NextCursor when the final page contains
exactly limit items; to fix, change the callers (e.g., the repository methods
such as in webhooks_repository.go) to fetch limit+1 rows as a sentinel, trim the
returned results down to limit before returning, and pass the full fetched count
into BuildListPaginationMeta; then update BuildListPaginationMeta usage to only
emit NextCursor when recordCount > limit (i.e., a true extra item was fetched)
and keep meta.Limit as the original limit. Ensure encodeLast() is only called
when recordCount > limit and repositories drop the extra item before returning
the result set.

In `@migrations/006_list_keyset_indexes.sql`:
- Around line 1-21: Do not modify migrations/006_list_keyset_indexes.sql;
instead create a new sequential migration file (e.g.,
007_keyset_pagination_indexes.sql) containing the same DDL changes (the
CONCURRENTLY DROP/CREATE for idx_feedback_records_tenant_collected_at_id,
idx_webhooks_tenant_created_at_id and recreation of
idx_feedback_records_tenant_collected_at in the Down), include the same goose
headers (+goose Up and +goose Down and NO TRANSACTION), and leave migration 006
unchanged so environments that already ran 006 will receive these index
operations via the new migration.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5b592d3a-e6f3-4cd6-8ea1-52671d3dd6db

📥 Commits

Reviewing files that changed from the base of the PR and between eed1a56 and dfa4984.

📒 Files selected for processing (21)
  • internal/api/handlers/feedback_records_handler.go
  • internal/api/handlers/search_handler.go
  • internal/api/handlers/search_handler_test.go
  • internal/api/handlers/webhooks_handler.go
  • internal/models/feedback_records.go
  • internal/models/webhooks.go
  • internal/repository/embeddings_repository.go
  • internal/repository/feedback_records_repository.go
  • internal/repository/webhooks_repository.go
  • internal/service/feedback_records_service.go
  • internal/service/pagination.go
  • internal/service/search_service.go
  • internal/service/search_service_test.go
  • internal/service/webhook_provider_test.go
  • internal/service/webhook_sender_test.go
  • internal/service/webhooks_service.go
  • internal/service/webhooks_service_test.go
  • migrations/006_list_keyset_indexes.sql
  • openapi.yaml
  • pkg/cursor/cursor.go
  • tests/integration_test.go

Comment thread internal/repository/webhooks_repository.go Outdated
Comment thread internal/service/feedback_records_service.go
Comment thread internal/service/pagination.go
Comment thread migrations/006_list_keyset_indexes.sql
@xernobyl xernobyl changed the title feat: Add keyset (cursor) pagination to list endpoints feat: Add cursor pagination to list endpoints; remove offset pagination Mar 4, 2026
Copy link
Copy Markdown
Member

@mattinannt mattinannt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a Codex AI review.

Thanks for the cursor migration work overall. I’m requesting changes for two release-blocking issues:

  1. ListEnabled() is now implicitly capped to 100 rows because it delegates to the paginated List() default limit. This can silently drop enabled webhooks from delivery.
  2. Removing offset without rejecting unknown query params means existing clients can send offset and get page 1 repeatedly with no explicit error.

Please address both before public release.

Comment thread internal/repository/webhooks_repository.go Outdated
Comment thread internal/models/feedback_records.go
@xernobyl xernobyl requested a review from mattinannt March 4, 2026 15:50
Copy link
Copy Markdown
Member

@mattinannt mattinannt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a Codex review with human approval.

Requesting changes for one remaining issue before merge.

Comment thread internal/repository/webhooks_repository.go
@xernobyl xernobyl added this pull request to the merge queue Mar 5, 2026
Merged via the queue into main with commit 512a619 Mar 5, 2026
9 checks passed
@xernobyl xernobyl deleted the chore/cursor_pagination branch March 5, 2026 12:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants