Skip to content

fix: paginate list endpoints so large fleets are not silently truncated - #50

Open
dhruva-vapi wants to merge 2 commits into
mainfrom
feat/paginate-list-endpoints
Open

fix: paginate list endpoints so large fleets are not silently truncated#50
dhruva-vapi wants to merge 2 commits into
mainfrom
feat/paginate-list-endpoints

Conversation

@dhruva-vapi

@dhruva-vapi dhruva-vapi commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Linear ticket

https://linear.app/vapi/issue/PRISM-1381/gitops-paginate-list-endpoints-so-100-resource-fleets-are-not-silently

Fixes PRISM-1381

Value

Customers with more than 100 resources of any one type were being managed off a truncated inventory, silently. Vapi list endpoints cap a response at 100 items and offer no page cursor, and the engine made exactly one request per type — so everything that decides what exists from a listing was wrong past the first hundred: push's stale-mapping detection (which drops state mappings), delete's orphan sweep, audit, and the credential reverse-map, where a truncated list leaves raw UUIDs in YAML the map exists to resolve.

The engine now pages through every resource of a type, and when it cannot prove it saw everything it says so instead of pretending. Small orgs pay nothing — a short first response still costs one request.

Small edit since first push: the drift-triangle vocabulary in docs/learnings/sync-behavior.md used L/B for the local file and baseline but P for the dashboard resource's hash, while D named the resource itself. D now covers both, matching how L already worked, and the artifact table states the convention so a fourth letter cannot creep back.

The behaviour this enables

flowchart TD
  A["GET /tool?limit=100"] --> B{"came back full?<br/>100 items"}
  B -->|no| C["COMPLETE — the whole type fits<br/>one request, same cost as before"]
  B -->|yes| D["GET ?limit=100&createdAtLe=oldest seen"]
  D --> E{"short page?"}
  E -->|yes| F["COMPLETE — reached the end"]
  E -->|no| G{"new ids, and the<br/>cursor moved?"}
  G -->|yes| D
  G -->|no| H["UNPROVEN — warn on stderr"]
  F --> OK["every consumer sees the whole type:<br/>pull, push orphan detection,<br/>delete sweep, audit, credentials"]
  C --> OK
  H --> NO["absence is not evidence of deletion<br/>for this type"]
  classDef ok fill:#e8f5ee,stroke:#1f7a5c,color:#0b2b1f
  classDef warn fill:#fdf3e2,stroke:#9a6212,color:#3a2a08
  class C,F,OK ok
  class H,NO warn
Loading

Before this, the engine made exactly one unpaginated request per type, so anything past the first hundred was invisible — and every feature that reads "absent from the listing" as "deleted" inherited that blind spot.

Evidence of value

Verified against the live API across all nine list endpoints: each accepts ?limit= and ?createdAtLe=, and createdAtLe genuinely filters. A run with the page size lowered to 5 walked five pages over 21 real assistants and recovered all 21, matching ground truth exactly. Capping the walk mid-way produced the incomplete verdict and the warning, as designed.

Nine new tests in tests/list-pagination.test.ts assert on the requests the engine makes, not only on what it does with responses. That distinction caught two real defects:

  • The first request originally sent no limit, so completeness was decided by comparing a response capped at the API's default page size against the engine's own page size — correct only while those two numbers happen to be equal. Had the API default ever dropped below it, a capped response would have read as complete.
  • The cursor was exclusive (createdAtLt), which drops every item sharing the boundary timestamp with the previous page's oldest. Bulk-created fleets do collide on timestamps, and a dropped item looks deleted to anything inferring absence.

API Changes (including webhooks + events)

  • Is this changing the public API?

    • Yes
    • No
  • If yes, is it backward‐compatible?

    • Yes
    • No

Non backward-compatible changes might break customers' agents. Please proceed with care and notify the team.

Testing plan

npm run build and npm test are green (the 20 failures in the suite are pre-existing on main and unrelated — upsertState merge semantics). The nine new pagination tests cover: the first request carrying limit, a short first page needing exactly one request, a full page paging to a short page, multi-page cursor advancement, the inclusive-cursor boundary case, an endpoint that ignores the cursor, a payload with no createdAt, an endpoint that rejects limit, and the { results } wrapper shape.

Post-rollout signal: the listing may be incomplete warning on stderr. It should never appear against the real API today, since all nine endpoints support the cursor. If it starts appearing, an endpoint's contract changed and orphan detection for that type must not be trusted until it is fixed.

Regression signal: a customer fork with a >100 fleet reporting resources being flagged as missing or orphaned when they exist.

Vapi list endpoints cap a response at 100 items and expose no page cursor,
only createdAt comparison filters. fetchAllResources issued one unpaginated
GET per type, so any org with more than 100 resources of a type operated on a
truncated inventory — and every consumer that decides what exists from a
listing inherited it: push's missing_remote detection, delete's orphan sweep,
audit, and the credential reverse-map.

fetchPagedList now walks backwards through createdAt until a short page proves
the end, dedupes by id, and reports whether it could prove completeness.

Two details are load-bearing:
- The first request carries ?limit too. Without it, completeness was decided by
  comparing a response capped at the API's default page size against
  LIST_PAGE_SIZE, which is only correct while those numbers happen to match.
- The cursor is createdAtLe, not createdAtLt. An exclusive cursor drops items
  sharing the boundary timestamp with the previous page's oldest, and
  bulk-created fleets do collide.
The drift table used L for the local file, B for the baseline, and D for the
dashboard resource — then switched to P for the dashboard's hash. L already
doubled as file-and-hash, so the extra letter was arbitrary: a reader meeting
"P vs B" had no way to know P was D.

D now covers both the dashboard resource and its hash, matching how L works,
and the artifact table states the convention outright so a fourth letter does
not creep back in.
@dhruva-vapi
dhruva-vapi requested a review from vtkovapi August 1, 2026 19:12
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