Skip to content

fix(api): Keep per_page stable when listPage walks offset cursors - #149

Merged
BYK merged 1 commit into
mainfrom
fix/api-list-page-offset
Nov 13, 2026
Merged

BYK merged 1 commit into
mainfrom
fix/api-list-page-offset

Conversation

@sentry-junior

@sentry-junior sentry-junior Bot commented Nov 13, 2026

Copy link
Copy Markdown
Contributor

Ported from getsentry/sentry#130518, per @BYK's request there: get the docs aligned here first, then move @sentry/api into getsentry/sentry. The commit applied cleanly on top of 2.0.0. Cursor Bugbot found the bug while reviewing the move (thread). It ships in the published @sentry/api 2.0.0.

Bug

listPage asked each later page for only the rows still missing: per_page = min(100, limit - items.length). listProjects, listTeams and listOrganizations are served by Sentry's OffsetPaginator. Its cursor is <page size>:<page>:<is_prev>. When per_page doesn't match the cursor's page size, it reads the cursor's window and keeps only the last per_page rows (src/sentry/api/paginator.py in getsentry/sentry). So 100:1:0 with per_page=50 returns rows 150–199, and rows 100–149 are lost. Any limit above 100 dropped rows without an error.

Fix

listPage now walks through the package's existing paginate, which:

  • uses one per_page for the whole walk. A fresh walk splits limit evenly: 150 becomes 2 × 75.
  • reuses the page size that an offset cursor encodes when resuming, whatever the new limit is.
  • returns a cursor that resumes at the first row not returned, including partway through a page.

listPage still validates each page with the schema, and still rejects a page with more rows than requested or a repeated cursor. One behavior changes: if a walk reaches MAX_LIST_PAGES, it now returns the rows it has and a resume cursor instead of throwing. Before this change, a stable page size couldn't hit that cap in a normal walk. Now a resumed small-page cursor can, for example 25:1:0 with limit: 5000.

Tests

  • test/api-client/core.test.ts models OffsetPaginator. It confirms that 100:1:0 with per_page=50 reads rows 150–199. Then it checks two things:
    • a 150-row walk and its resume return rows 0–299 in order, all with per_page=75;
    • resuming 100:1:0 with limit: 50 returns rows 100–149, then 150–199, both requested with per_page=100.
  • The listOrganizations test now expects per_page to stay at 3 on the second request. It was 1, which was the bug.

pnpm build, pnpm typecheck and pnpm test pass locally (53 files, 675 tests).

No UI change.

Co-Authored-By: BYK 126780+BYK@users.noreply.github.com

--

View Session [Sentry]

listPage asked each later page for only the rows still missing. Sentry's
OffsetPaginator reads a cursor's window with the page size the cursor
encodes, so pairing 100:1:0 with per_page=50 returned rows 150-199 and
dropped 100-149. listProjects, listTeams and listOrganizations lost rows
whenever limit was above 100.

listPage now walks through paginate, which keeps one page size per walk,
reuses the size an offset cursor encodes, and returns a cursor that resumes
mid-page.

Co-Authored-By: BYK <126780+BYK@users.noreply.github.com>
@BYK
BYK merged commit c08ef49 into main Nov 13, 2026
13 of 14 checks passed
@BYK
BYK deleted the fix/api-list-page-offset branch November 13, 2026 14:32
BYK added a commit that referenced this pull request Nov 13, 2026
Stacked on #149. Ported from getsentry/sentry#130538, per @BYK's request
there: get the docs aligned here first, then move `@sentry/api` into
getsentry/sentry.

This adds the data sentry-docs needs to build the API reference from the
`@sentry/api` version it pins, so it no longer has to read
`openapi-derefed.json` from a pinned commit of this repository. The
sentry-docs adapter is getsentry/sentry-docs#20679.

## Changes from the sentry version

2.0.0 (#148) made every path relative to the API base path and trimmed
server URLs to their origin. The export now follows the same contract:
- operation `path`s are relative, such as `/organizations/`, the same as
`@sentry/api/operations`;
- the base path ships as `API_BASE_PATH` (`/api/0`) and as `apiBasePath`
in `extractDocs()` output, so a page builds the full path as
`${API_BASE_PATH}${operation.path}`;
- per-operation `servers` are origins.

`extractDocs()` applies `splitApiBasePath` to a copy of the spec it
gets, so the published data and a preview of a local spec have the same
shape. It accepts both the current spec and one that declares the base
path in `servers`.

**sentry-docs#20679 needs a follow-up for this:** its adapter has to
prepend `API_BASE_PATH` to paths, like sentry-docs#20787 does for 2.0.0.
I'll push that once this shape is settled.

## What it adds

- **`scripts/generate-docs.mjs`**, next to the `operations` generator.
It runs `lib/docs-catalog.mjs` `extractDocs(spec)` on the original
`openapi-derefed.json` and emits `src/docs.ts`. That file holds the data
as a JSON string literal, so tsc doesn't infer a type for 3.7 MB of
data. It ships as **`@sentry/api/docs`** (`dist/docs.js` plus `.d.ts`).
- **Shape** (`lib/docs-types.ts`):
  - `apiBasePath` / `API_BASE_PATH`.
- `tags`: name, description, `sidebarName` (`x-sidebar-name`) and
`displayDescription` (`x-display-description`).
- `operations`, in spec order: `operationId`, method and relative path,
with summary and description copied as written. Also tags, `deprecated`,
`experimental` (`x-sentry-experimental`), server origins, security
requirements, and parameters (path-level merged with operation
overrides) with `in`, `required`, description, `experimental` and
schema. The request body comes from its first content type (content
type, `required`, schema, example), and every response has its status,
description, content type, schema and `example`/`examples`.
- **`extractDocs` is exported too**, so sentry-docs'
`OPENAPI_LOCAL_PATH` preview of an unreleased local spec goes through
the same projection as the published data.
- `lib/http-methods.mjs` holds the HTTP method set that both catalogs
use.

## The export's shape is now a semver contract

sentry-docs, and anything else that imports `@sentry/api/docs`, depends
on these field names and types. Removing or renaming a field, or
changing its type, is a **major** release. Adding fields is a minor
release. The README says so.

## Size

Measured with `npm pack --dry-run` from clean builds of #149 and this
branch, using the spec in this repository:

| | tgz | unpacked | files |
|---|---|---|---|
| #149 | 3,385,825 B | 35,812,862 B | 80 |
| this PR | 3,828,867 B | 39,660,930 B | 86 |
| change | **+443 KB (+13%)** | **+3.85 MB (+11%)** | +6 |

Almost all of it is `dist/docs.js` (3,840,561 B). It's a separate entry
point, so `@sentry/api` and `@sentry/api/client` users like toolkit
don't load it unless they import `./docs`. They only pay for the
download.

## Tests

- `test/docs.test.ts`:
- A fixture test pins the exact output shape. Its spec declares `/api/0`
in `servers` and an operation server override under it, so the test also
covers the relative paths, `apiBasePath` and origin-only servers. It
also covers path-level parameter overrides, experimental flags, multiple
content types, responses without content, named examples and security.
- Drift tests compare the published `@sentry/api/docs` with
`openapi-derefed.json`:
- the export equals `extractDocs(spec)`, and `API_BASE_PATH` is
`/api/0`;
- tag and operation order matches the spec, with `API_BASE_PATH` plus
each relative path equal to the spec's path;
- for every operation, the text, parameters, request schema and example,
and response schemas and examples match the spec directly.
- `test/package.test.ts`: the packed-tarball smoke test imports
`@sentry/api/docs`.

`pnpm build`, `pnpm typecheck` and `pnpm test` pass locally (54 files,
679 tests).

No UI change here. The sentry-docs PR carries the API reference
screenshots.

## Merge order

#149, this PR, the normal release of an `@sentry/api` version with
`./docs`, then getsentry/sentry-docs#20679 with its `API_BASE_PATH`
follow-up. That PR's dependency bump is its last step.

Co-Authored-By: BYK <126780+BYK@users.noreply.github.com>

<!-- junior-session-footer:start -->
<!-- junior-conversation-id:agent%3A69da19b385017e68b7f9eace56e9477f -->

--

[View
Session](https://junior-prod.sentry.dev/conversations/agent%3A69da19b385017e68b7f9eace56e9477f)
[[Sentry]](https://sentry.sentry.io/explore/conversations/agent%3A69da19b385017e68b7f9eace56e9477f/?project=4510944073809921)

<!-- junior-session-footer:end -->

---------

Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com>
Co-authored-by: BYK <126780+BYK@users.noreply.github.com>
sentry-junior Bot added a commit to getsentry/sentry that referenced this pull request Nov 14, 2026
Brings static/packages/api level with getsentry/sentry-api-schema main
(2.3.1):

- @sentry/api/docs exports the API reference data (getsentry/sentry-api-schema#150), with the
  no-typescript-source-strings lint rule, which the api client CI job
  now runs.
- listPage keeps per_page stable across offset cursors (getsentry/sentry-api-schema#149).
- ApiPermissionError carries the scopes from a 403 insufficient_scope
  challenge (getsentry/sentry-api-schema#146).
- Union fields and annotated single-member allOf fields keep their own
  descriptions.

The version moves to 2.3.1, so craft's first api@ release bumps from
sentry-api-schema's latest npm version. The release check no longer cuts
a patch for API ownership stats alone: the asset stays on each release
for ad-hoc use, but nothing reads it on a schedule.

Co-Authored-By: BYK <126780+BYK@users.noreply.github.com>
BYK added a commit to getsentry/sentry that referenced this pull request Nov 14, 2026
…0472)

Moves `@sentry/api`, the generated TypeScript client for Sentry's public
API, from getsentry/sentry-api-schema to `static/packages/api`. It
builds from `tests/apidocs/openapi-derefed.json`, the spec this
repository generates. The npm name stays `@sentry/api`, so
getsentry/toolkit, sentry-docs and other consumers don't change. This PR
replaces the pre-merge check from #130450, which was closed.

This PR doesn't publish anything. Releases from here stay off until a
person cuts the first `api@` tag (see the cutover section).

## Package

- The sources match getsentry/sentry-api-schema `main` at 2.3.1,
including the `@sentry/api/docs` API reference export
(getsentry/sentry-api-schema#150), the `listPage` `per_page` fix
(getsentry/sentry-api-schema#149) and `insufficient_scope` handling on
`ApiPermissionError` (getsentry/sentry-api-schema#146).
- One deliberate difference from sentry-api-schema `main`: the generated
`buildUrl` trims trailing slashes from the base URL before appending
`/api/0` (`withApiBasePath` in `lib/api-client/api-base-url.ts`), so
`https://sentry.io/` no longer requests `https://sentry.io//api/0`.
Published 2.x versions keep that bug; the fix ships with the first
`api@` release from here.
- `package.json` is at 2.3.1, the latest npm version. Craft bumps from
it while there is no `api@` tag, so the first release from here lands
after sentry-api-schema's.
- Differences from sentry-api-schema, all because of where the package
now lives:
- TypeScript 6 through the repo's `@typescript/typescript6` alias, and
zod 4.6.7, the version `static/app`, gsApp, gsAdmin and scraps already
pin. The other dev tools are pinned to exact versions, and oxlint
matches the repo's 1.90.0.
- The build reads the spec files through `lib/spec-files.mjs` and
collects them under `spec/`.
- `source-analysis` uses a path-parameter pattern that can't backtrack
on runs of `{`, for a CodeQL alert the move raised.
- The scripts are `test:vitest` and `lint:js`, not `test` and `lint`.
The repo's `test-ci` and `test-all` pass Jest flags to every workspace's
`test` script, and the repo's `lint:js` runs each workspace's `lint:js`.

## Spec files on GitHub releases

Each `api@x.y.z` GitHub release gets the files that version was built
from as assets. The pack job adds them to the release artifact; craft's
github target attaches every file in it, and its npm target publishes
only the tarball:

- `openapi-derefed.json`, the dereferenced OpenAPI spec.
- `rpc_method_schema.json`, for the openapi-diff/RPC check. It comes
from a new `make build-rpc-schema` target.
- `api_ownership_stats_dont_modify.json`, which `make build-api-docs`
still generates. No known consumer reads it; it is kept for ad-hoc and
future use.

For example:
`https://github.com/getsentry/sentry/releases/download/api@x.y.z/openapi-derefed.json`.
The npm package doesn't include them.

## CI: `api client` is a required check

The new `api-client` job in `backend.yml` builds, typechecks, lints and
tests the client against the PR's own spec. It is part of
`backend-required-check`. A spec change that broke the client once
stalled releases for 2 days with nothing catching it before merge; this
check catches that class of change.

- It reuses the spec from the `api-docs` job (shared as a workflow
artifact), so the spec isn't built twice.
- Both jobs run only when the new `api_client` filter matches: the
existing `api_docs` paths plus `static/packages/api/**`. On other PRs
both jobs are skipped, and `backend-required-check` counts skipped jobs
as passing.

## Releases

- **Pack:** `package-pack.yml` sets up the Python env for `api`, then
builds the spec, the RPC schema and the package before `pnpm pack`.
- **Release on change:** a `release-api-package` job in `openapi.yml`
runs after the master spec build whenever the spec or the package might
have changed. It runs
`.github/workflows/scripts/api-package-release-preflight.sh`, then
`craft prepare` with `getsentry/publish`. The preflight skips the
release when:
- there is no `api@` tag yet, so the job stays inert until the first
release is cut by hand;
- an `api/release/*` branch is still pending. When that release merges
back, its own run releases whatever landed in the meantime;
  - HEAD predates the newest `api@` tag;
- the spec, the RPC schema and the package are unchanged since the last
release. It compares the files against the last release's assets.
- **Version bump:** a spec change is a minor release, as in
sentry-api-schema. A package change uses craft's automatic bump. An
RPC-schema-only change is a patch. An ownership-stats-only change
doesn't release; the file refreshes with the next release.
- **Concurrency:** releases share a concurrency group with
`cancel-in-progress: false`, so a burst of merges produces one release.
- There is no cron. Manual releases go through the Release workflow,
which gets a `static/packages/api` option.
- **sentry-docs:** `bump-sentry-docs.yml` runs on each published `api@`
release. `.craft.yml` lists the npm target before github for
`static/packages/*`, so the version is already on npm when the release
is published (the same order applies to icons, scraps and search-syntax,
and nothing in them needs the tag before the npm publish). It dispatches
sentry-docs' `bump-api-schema-sha.yml` by file name with the existing
`SENTRY_API_SCHEMA_UPDATER` app. sentry-docs' bump reads the version
from npm, so it works the same after the move.
- The mirror push to getsentry/sentry-api-schema still runs, on the same
`api_client` filter, and the release doesn't wait for it.

## Cutover (in this order)

1. Merge #130541 and getsentry/eng-pipes#1116, which retires the `apis`
command. #130541 falls back to sentry-api-schema `main` until an `api@`
release exists.
2. Merge this PR.
3. In getsentry/publish `auto-approve-repos.txt`, replace the
`getsentry/sentry-api-schema` line with this repo's
`static/packages/api` entry.
4. Disable sentry-api-schema's Release workflow and its sentry-docs
dispatch workflow (getsentry/sentry-api-schema#155), so only one repo
publishes.
5. Cut the first `api@` release by hand: run the Release workflow with
workspace `static/packages/api`. Check that the version it picks is
above sentry-api-schema's last npm release. From then on, `openapi.yml`
releases on change.
6. Remove the openapi-diff fallback, the mirror push in `openapi.yml`,
and the sentry-api-schema links in the getsentry/toolkit docs,
getsentry/skills `document-api-endpoint` and devinfra-mcp
`docs/sentry/ci.md`.
7. Archive getsentry/sentry-api-schema. Its copies of the
`SENTRY_API_SCHEMA_UPDATER_*` credentials can go with it.

`static/packages/api/` is owned by @getsentry/owners-api and
@getsentry/ocpo in CODEOWNERS.

This PR touches `static/` as well as backend CI files. The package is a
standalone npm package and isn't part of either deploy, so splitting it
wouldn't change what gets deployed.

No UI change.

<!-- junior-request-attribution:start -->
via @BYK.
<!-- junior-request-attribution:end -->

<!-- junior-session-footer:start -->
<!-- junior-conversation-id:agent%3A69da19b385017e68b7f9eace56e9477f -->

--

[View
Session](https://junior-prod.sentry.dev/conversations/agent%3A69da19b385017e68b7f9eace56e9477f)
[[Sentry]](https://sentry.sentry.io/explore/conversations/agent%3A69da19b385017e68b7f9eace56e9477f/?project=4510944073809921)

<!-- junior-session-footer:end -->

---------

Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com>
Co-authored-by: BYK <126780+BYK@users.noreply.github.com>
Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
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