Skip to content

perf(api): add cursor pagination to api_keys/webhook_subscriptions/playbooks list endpoints - #1589

Merged
lavkushry merged 1 commit into
mainfrom
feat/cursor-pagination-1142
Jun 24, 2026
Merged

perf(api): add cursor pagination to api_keys/webhook_subscriptions/playbooks list endpoints#1589
lavkushry merged 1 commit into
mainfrom
feat/cursor-pagination-1142

Conversation

@lavkushry

@lavkushry lavkushry commented Jun 24, 2026

Copy link
Copy Markdown
Owner

Summary

  • GET /v1/api_keys, GET /v1/webhook_subscriptions, GET /v1/playbooks previously returned every row for the tenant, unbounded. Each now supports the existing cursor-pagination convention (?limit=&offset=&cursor=, X-Next-Cursor response header) already used by /v1/receipts, /v1/decisions, /v1/alerts, /v1/incidents, /v1/audit/events.
  • Adds list_api_keys_cursor / list_webhook_subscriptions_cursor / list_playbooks_cursor to StorageBackend alongside (not replacing) the existing non-cursor methods — mirrors the pre-existing list_action_receipts / list_action_receipts_cursor pattern, so no existing caller breaks. In particular src/src/grpc.rs's list_playbooks call is untouched.
  • Postgres has no equivalent to SQLite's implicit rowid (which the cursor queries order by), so lib/storage/migrations_postgres/0002_pagination_rowid_columns.sql adds an explicit rowid BIGSERIAL UNIQUE to the three newly-paginated tables — mirroring action_receipts/decisions/soc_alerts/soc_incidents, which already needed the same treatment.

Why

Progress on #1142. Three list endpoints had zero pagination of any kind (not even offset-based) — every API key, webhook subscription, or playbook a tenant has ever created was returned in one response.

Scope (what's intentionally NOT in this PR)

  • list_agents, list_mcp_servers, list_approvals already have offset-based pagination; upgrading them to cursor-based is left for a follow-up.
  • list_detection_rules has no pagination yet, same as the three converted here — left for a follow-up to keep this PR's diff reviewable.
  • list_policies, list_policy_audit_log, list_policy_templates (all in src/src/routes/policy.rs) are deliberately untouched — that file has unrelated work in flight elsewhere right now, and these endpoints can be picked up once that settles.

Test plan

  • New DB-layer unit tests (lib/storage/src/db/{webhooks,tenant,playbooks}.rs): pagination across multiple rows with next_cursor set correctly, plus the established off-by-one boundary regression guard (a page that exactly matches the result-set size must not falsely claim a next page exists).
  • New route-level test (src/src/routes/tenant.rs): list_webhook_subscriptions_route_sets_next_cursor_header drives the full HTTP handler end-to-end — ?limit=1 against 2 rows sets X-Next-Cursor, and following that cursor returns the remaining row with no further header.
  • Updated 3 existing route-level CRUD tests (test_api_key_crud_route, test_webhook_subscription_crud_route, test_playbook_crud_routes) for the new RawQuery parameter — all still green.
  • cargo check --features postgres (compile-only — no live Postgres in this environment, and the postgres feature isn't exercised by any CI job today either; same rigor level as the rest of this repo's existing Postgres scaffolding).
  • cargo fmt --manifest-path src/Cargo.toml -- --check
  • cargo clippy --manifest-path src/Cargo.toml --all-targets -- -D warnings
  • cargo test --manifest-path src/Cargo.toml --workspace — full workspace green (815 tests across all crates, 3 ignored, 0 failed)

Summary by CodeRabbit

  • New Features

    • Added cursor-based pagination to API key, webhook subscription, and playbook list endpoints.
    • List responses now include a next-cursor value when more results are available, and support limit, offset, and cursor query parameters.
  • Bug Fixes

    • Improved page-boundary handling so endpoints no longer report an extra page when the current page ends exactly at the last result.
    • Added regression coverage for pagination behavior across the updated lists.

…aybooks list endpoints (#1142)

GET /v1/api_keys, GET /v1/webhook_subscriptions, and GET /v1/playbooks
previously returned every row for the tenant with no pagination at
all. Add a *_cursor StorageBackend method for each, following the
existing list_action_receipts/list_action_receipts_cursor precedent:
additive new methods alongside the originals, so no existing caller
breaks (including grpc.rs's list_playbooks call, which keeps using the
old method).

Routes opt in via the established parse_cursor/paginated_response
convention (?limit=&offset=&cursor=, X-Next-Cursor response header).

Postgres has no equivalent to SQLite's implicit rowid, so a new
migration adds an explicit rowid BIGSERIAL UNIQUE to the three tables,
mirroring action_receipts/decisions/soc_alerts/soc_incidents which
already had it for the same reason.

list_agents, list_mcp_servers, list_approvals (offset-only) and
list_detection_rules, list_policies, list_policy_audit_log,
list_policy_templates (no pagination) remain for a follow-up; the
policy.rs-owned endpoints in particular were left untouched since that
file had unrelated concurrent edits in flight.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@ecc-tools

ecc-tools Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

ECC bundle files are already tracked in this repository. Skipping generation of another bundle PR.

@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b387ead6-9060-47a5-85f4-dadbc214df49

📥 Commits

Reviewing files that changed from the base of the PR and between 7e3bfbf and da9e388.

📒 Files selected for processing (10)
  • CLAUDE.md
  • lib/storage/migrations_postgres/0002_pagination_rowid_columns.sql
  • lib/storage/src/db/playbooks.rs
  • lib/storage/src/db/tenant.rs
  • lib/storage/src/db/webhooks.rs
  • lib/storage/src/sqlite.rs
  • lib/storage/src/traits.rs
  • src/src/routes/playbook.rs
  • src/src/routes/tenant.rs
  • src/src/routes/webhooks.rs

📝 Walkthrough

Walkthrough

Adds cursor-based pagination to three list endpoints (api_keys, webhook_subscriptions, playbooks). A Postgres migration appends rowid BIGSERIAL columns to the relevant tables. Three new StorageBackend trait methods and corresponding DB-layer functions implement rowid-keyed cursor queries. Route handlers are updated to parse limit/cursor query params and return paginated_response with an X-Next-Cursor header.

Changes

Cursor Pagination for List Endpoints

Layer / File(s) Summary
Postgres migration and StorageBackend trait contracts
lib/storage/migrations_postgres/0002_pagination_rowid_columns.sql, lib/storage/src/traits.rs
Conditionally adds rowid BIGSERIAL UNIQUE to api_keys, webhook_subscriptions, and response_playbooks tables, and declares list_webhook_subscriptions_cursor, list_api_keys_cursor, and list_playbooks_cursor on StorageBackend.
DB-layer cursor query implementations and unit tests
lib/storage/src/db/tenant.rs, lib/storage/src/db/webhooks.rs, lib/storage/src/db/playbooks.rs
Implements the three cursor functions: clamps limit, filters by rowid < cursor, fetches limit+1 rows, delegates to paginate_rows, and handles SQLite vs Postgres paths. Unit tests cover next_cursor presence and off-by-one regression.
SQLite backend wiring
lib/storage/src/sqlite.rs
Adds the three cursor methods to SqlDbStorage's StorageBackend impl, delegating to the DB layer with AegisError::Database error mapping.
Route handler updates and integration tests
src/src/routes/webhooks.rs, src/src/routes/tenant.rs, src/src/routes/playbook.rs
Rewrites list_webhook_subscriptions, list_api_keys, and list_playbooks to accept RawQuery, parse limit/cursor, call cursor storage methods, and return paginated_response. Existing tests pass RawQuery(None); a new test verifies the x-next-cursor two-page flow.
CLAUDE.md feature-history entry
CLAUDE.md
Documents the cursor pagination feature, the rowid migration, and which endpoints were intentionally left unchanged.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant RouteHandler as list_* Handler
  participant Storage as SqlDbStorage
  participant DB as SQLite/Postgres

  Client->>RouteHandler: GET /v1/api_keys?limit=10&cursor=<token>
  RouteHandler->>RouteHandler: parse_pagination, decode_cursor
  RouteHandler->>Storage: list_api_keys_cursor(tenant_id, limit, offset, cursor)
  Storage->>DB: SELECT ... WHERE rowid < cursor LIMIT limit+1
  DB-->>Storage: up to limit+1 rows
  Storage->>Storage: paginate_rows → (page, next_cursor)
  Storage-->>RouteHandler: (Vec<ApiKeyRecord>, Option<i64>)
  RouteHandler-->>Client: JSON body + X-Next-Cursor header (if next_cursor present)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • lavkushry/AegisAgent#1574: Introduces the shared paginate_rows helper and DbPool/Postgres backend plumbing that this PR's cursor implementations directly depend on.

Poem

🐇 Hopping page by page, I never fetch the whole warren,
A rowid cursor guides my paws through rows unboring.
limit + 1 tells me if more tunnels lie ahead,
X-Next-Cursor whispers the path before I've fled.
No offset drift, no chaos—just a tidy, bounded trail! 🥕

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/cursor-pagination-1142

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.

@lavkushry
lavkushry merged commit 85646f5 into main Jun 24, 2026
19 of 23 checks passed
@lavkushry
lavkushry deleted the feat/cursor-pagination-1142 branch June 24, 2026 13:46

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces cursor-based pagination for the api_keys, webhook_subscriptions, and playbooks list endpoints, adding corresponding database queries, a Postgres migration for rowid columns, and route handler updates. The reviewer feedback suggests reducing database query duplication by extracting common logic into a macro, refactoring the list_playbooks route to use the standard parse_cursor helper for consistency, and moving a webhook-related integration test to the correct file.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +57 to +82
match pool {
DbPool::Sqlite(p) => {
let rows = sqlx::query(query)
.bind(tenant_id)
.bind(cursor)
.bind(cursor)
.bind(limit + 1)
.bind(if cursor.is_some() { 0 } else { offset })
.fetch_all(p)
.await?;
super::paginate_rows(rows, limit)
}
#[cfg(feature = "postgres")]
DbPool::Postgres(p) => {
let pg_sql = crate::db::to_postgres_sql(query);
let rows = sqlx::query(&pg_sql)
.bind(tenant_id)
.bind(cursor)
.bind(cursor)
.bind(limit + 1)
.bind(if cursor.is_some() { 0 } else { offset })
.fetch_all(p)
.await?;
super::paginate_rows(rows, limit)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

There's significant code duplication across list_playbooks_cursor, list_api_keys_cursor, and list_webhook_subscriptions_cursor. The match pool { ... } block is nearly identical in all three functions.

To improve maintainability and reduce redundancy, consider extracting this common logic into a new macro in lib/storage/src/db/mod.rs, similar to the existing fetch_all_as! macro. This would centralize the logic for handling both Sqlite and Postgres database pools for paginated queries.

For example, you could create a fetch_all_for_pagination! macro and then simplify the cursor functions.

Comment on lines 77 to 98
pub async fn list_playbooks(
State(state): State<Arc<AppState>>,
TenantId(tenant_id): TenantId,
axum::extract::RawQuery(raw_query): axum::extract::RawQuery,
) -> Result<impl IntoResponse, StatusError> {
let records = state
let (limit, offset) = crate::routes::parse_pagination(raw_query.as_deref());
let cursor = match crate::routes::parse_filter(raw_query.as_deref(), "cursor") {
None => None,
Some(raw) => Some(
crate::routes::decode_cursor(&raw)
.ok_or_else(|| StatusError::bad_request("Invalid cursor"))?,
),
};

let (records, next_cursor) = state
.storage
.list_playbooks(&tenant_id)
.list_playbooks_cursor(&tenant_id, limit, offset, cursor)
.await
.map_err(|e| StatusError::internal(e.to_string()))?;
Ok(Json(records))

Ok(crate::routes::paginated_response(&records, next_cursor))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The implementation for parsing the cursor in list_playbooks is different from list_api_keys and list_webhook_subscriptions. Here, you are using parse_filter and decode_cursor directly, while the other handlers use a parse_cursor helper function.

For consistency and better maintainability, I recommend refactoring this to use parse_cursor, which seems to be the preferred abstraction. This would likely involve changing the function's return type from Result<impl IntoResponse, StatusError> to impl IntoResponse to match the other handlers and improve error logging consistency.

pub async fn list_playbooks(
    State(state): State<Arc<AppState>>,
    TenantId(tenant_id): TenantId,
    axum::extract::RawQuery(raw_query): axum::extract::RawQuery,
) -> impl IntoResponse {
    let (limit, offset) = crate::routes::parse_pagination(raw_query.as_deref());
    let cursor = match crate::routes::parse_cursor(raw_query.as_deref()) {
        Ok(c) => c,
        Err(resp) => return *resp,
    };

    match state
        .storage
        .list_playbooks_cursor(&tenant_id, limit, offset, cursor)
        .await
    {
        Ok((records, next_cursor)) => crate::routes::paginated_response(&records, next_cursor),
        Err(e) => {
            tracing::error!("Failed to list playbooks: {:?}", e);
            StatusError::internal("Database error").into_response()
        }
    }
}

Comment thread src/src/routes/tenant.rs
Comment on lines +713 to +768
/// #1142: `GET /v1/webhook_subscriptions?limit=1` sets the
/// `x-next-cursor` response header end-to-end through the full route
/// handler when more rows exist than the requested page size, and a
/// follow-up request with `?cursor=<that value>` returns the remaining
/// row with no further `x-next-cursor`.
#[tokio::test]
async fn list_webhook_subscriptions_route_sets_next_cursor_header() {
let (state, tenant_id, _) = setup_state("webhook_subscriptions_cursor_header").await;
for i in 0..2 {
let _ = create_webhook_subscription(
State(state.clone()),
TenantId(tenant_id.clone()),
Json(CreateWebhookSubscriptionRequest {
url: format!("https://example.com/hook{i}"),
secret: None,
event_types: "alert,incident".to_string(),
min_severity: None,
format: None,
}),
)
.await
.into_response();
}

let response = list_webhook_subscriptions(
State(state.clone()),
TenantId(tenant_id.clone()),
axum::extract::RawQuery(Some("limit=1".to_string())),
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
let next_cursor = response
.headers()
.get("x-next-cursor")
.expect("a second row exists beyond the page")
.to_str()
.unwrap()
.to_string();
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let page: Vec<WebhookSubscriptionRecord> = serde_json::from_slice(&body).unwrap();
assert_eq!(page.len(), 1);

let response2 = list_webhook_subscriptions(
State(state),
TenantId(tenant_id),
axum::extract::RawQuery(Some(format!("limit=1&cursor={next_cursor}"))),
)
.await
.into_response();
assert_eq!(response2.status(), StatusCode::OK);
assert!(response2.headers().get("x-next-cursor").is_none());
let body2 = to_bytes(response2.into_body(), usize::MAX).await.unwrap();
let page2: Vec<WebhookSubscriptionRecord> = serde_json::from_slice(&body2).unwrap();
assert_eq!(page2.len(), 1);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

This test, list_webhook_subscriptions_route_sets_next_cursor_header, is for webhook subscriptions but it's located in src/src/routes/tenant.rs. It uses create_webhook_subscription and list_webhook_subscriptions, which are from webhooks.rs.

To improve code organization and make it easier to find relevant tests, this test should be moved to src/src/routes/webhooks.rs alongside other webhook-related tests.

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.

1 participant