fix: paginate list endpoints so large fleets are not silently truncated - #50
Open
dhruva-vapi wants to merge 2 commits into
Open
fix: paginate list endpoints so large fleets are not silently truncated#50dhruva-vapi wants to merge 2 commits into
dhruva-vapi wants to merge 2 commits into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.mdusedL/Bfor the local file and baseline butPfor the dashboard resource's hash, whileDnamed the resource itself.Dnow covers both, matching howLalready 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 warnBefore 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=, andcreatedAtLegenuinely 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.tsassert on the requests the engine makes, not only on what it does with responses. That distinction caught two real defects: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.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?
If yes, is it backward‐compatible?
Non backward-compatible changes might break customers' agents. Please proceed with care and notify the team.
Testing plan
npm run buildandnpm testare green (the 20 failures in the suite are pre-existing onmainand unrelated —upsertStatemerge semantics). The nine new pagination tests cover: the first request carryinglimit, 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 nocreatedAt, an endpoint that rejectslimit, and the{ results }wrapper shape.Post-rollout signal: the
listing may be incompletewarning 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.