diff --git a/apps/cli-docs/src/fragments/commands/api.md b/apps/cli-docs/src/fragments/commands/api.md index 852926a6b..4e33faa53 100644 --- a/apps/cli-docs/src/fragments/commands/api.md +++ b/apps/cli-docs/src/fragments/commands/api.md @@ -13,6 +13,33 @@ bodies; streaming responses such as SSE are excluded. When querying the Events API (`/events/` endpoint), valid dataset values are: `spans`, `logs`, `errors`, `tracemetrics`, `profile_functions`, and `uptime_results`. +## Pagination + +Each invocation fetches one server page. Repeat the same GET endpoint and filters +with `--cursor next` (`-c next`) to continue, `--cursor prev` to revisit a page, +or `--cursor first` to start again. The shared history expires after five minutes +and is scoped to the request and authenticated identity. Keep the same query +parameters and headers when navigating. The endpoint remains required. + +Raw server cursors and custom cursors from the CLI's shared paginator are also +accepted. Custom cursors preserve the page size and skip rows already returned, +including when navigating back to a bookmarked page. A cursor's encoded page +size takes precedence over `per_page`. Mid-page bookmarks support array bodies +and responses with a `data` array, such as the Events API; envelope metadata is +preserved. Other response bodies remain unchanged for ordinary cursors. + +With `--json`, `pagination.nextCursor` is the CLI continuation and can retain a +custom bookmark; pass it back through `--cursor` to resume exactly. +`pagination.prevCursor` comes from the server, while `pagination.hasPrev` +indicates a previously visited page in local history. `--fields` filters only +the body and preserves this metadata. +The pagination object is omitted when no continuation or local previous page is +available. Its absence does not prove a complete or unsampled dataset. + +`--cursor` replaces cursor parameters from the endpoint and request fields. It is +only supported for GET requests. `--dry-run` previews the resolved request; +dry-runs and failed requests do not advance the saved history. + ## Binary responses Endpoints that return binary data — image attachments, minidumps, debug files — are streamed to stdout as raw bytes, so you can redirect them straight to a file. diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/api.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/api.md index b907d639a..2bbe4fe35 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/api.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/api.md @@ -23,6 +23,7 @@ Make an authenticated API request **Flags:** - `-X, --method - The HTTP method for the request - (default: "GET")` +- `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` - `-d, --data - Inline JSON body for the request (like curl -d)` - `-F, --field ... - Add a typed parameter (key=value, key[sub]=value, key[]=value)` - `-f, --raw-field ... - Add a string parameter without JSON parsing` @@ -38,6 +39,15 @@ Make an authenticated API request # List organizations sentry api organizations/ +# Read a page and its pagination metadata +sentry api organizations/ -f per_page=2 --json + +# Continue the same query using shared pagination +sentry api organizations/ -f per_page=2 --cursor next --json + +# Return to the previously visited page +sentry api organizations/ -f per_page=2 --cursor prev --json + # Get a specific issue sentry api issues/123456789/ diff --git a/packages/cli/src/commands/api.ts b/packages/cli/src/commands/api.ts index aec7b19a6..e3062cf8e 100644 --- a/packages/cli/src/commands/api.ts +++ b/packages/cli/src/commands/api.ts @@ -9,8 +9,26 @@ import { access, readFile } from "node:fs/promises"; // oxlint-disable-next-line sentry-cli/no-namespace-import -- Sentry SDK recommends namespace import import * as Sentry from "@sentry/node"; import type { SentryContext } from "../context.js"; -import { appendSearchParams, rawApiRequest } from "../lib/api-client.js"; +import { + appendSearchParams, + consumePage, + decodePageCursor, + type PagePosition, + parseLinkHeader, + type PreparedRawApiRequest, + prepareRawApiRequest, + rawApiRequest, +} from "../lib/api-client.js"; import { buildCommand } from "../lib/command.js"; +import { applyCustomHeaders } from "../lib/custom-headers.js"; +import { getIdentityFingerprint } from "../lib/db/auth.js"; +import { + advancePaginationState, + buildPaginationContextKey, + type CursorDirection, + hasPreviousPage, + resolveCursor, +} from "../lib/db/pagination.js"; import { OutputError, stringifyUnknown, @@ -20,8 +38,9 @@ import { filterFields } from "../lib/formatters/json.js"; import { CommandOutput } from "../lib/formatters/output.js"; import { validateEndpoint } from "../lib/input-validation.js"; import { imageBytesToKitty } from "../lib/kitty-image.js"; +import { LIST_CURSOR_FLAG } from "../lib/list-command.js"; import { logger } from "../lib/logger.js"; -import { getDefaultSdkConfig, getSdkConfig } from "../lib/sentry-client.js"; +import { buildCacheKey } from "../lib/response-cache.js"; import { canRenderKitty, canRenderSixel, @@ -36,11 +55,14 @@ const log = logger.withTag("api"); const LINE_BREAK_PATTERN = /[ \t]*[\r\n]+[ \t]*/g; const API_PREFIX = "/api/0"; const API_PREFIX_WITH_SLASH = `${API_PREFIX}/`; +const PAGINATION_KEY = "api"; type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; type ApiFlags = { readonly method: HttpMethod; + /** Shared navigation keyword or opaque pagination cursor for GET requests. */ + readonly cursor?: string; readonly data?: string; readonly field?: string[]; readonly "raw-field"?: string[]; @@ -1168,11 +1190,18 @@ function throwApiResponseError( throw new OutputError(response.body); } -/** Add HTTP metadata to textual responses in JSON output mode. */ +/** Server continuations plus navigation available in the local cursor history. */ +type ApiPaginationMetadata = ReturnType & { + /** Whether this query has a previously visited page in the local history. */ + hasPrev?: boolean; +}; + +/** Add HTTP and pagination metadata to textual responses in JSON output mode. */ function formatApiResponseOutput( response: ApiResponseOutput, output: unknown, json: boolean, + pagination: ApiPaginationMetadata, ): unknown { if (!json || output instanceof Uint8Array) { return output; @@ -1181,6 +1210,9 @@ function formatApiResponseOutput( status: response.status, statusText: response.statusText, body: output, + ...(pagination.nextCursor || pagination.prevCursor || pagination.hasPrev + ? { pagination } + : {}), }; } @@ -1207,53 +1239,84 @@ function formatApiResponseJson(data: unknown, fields?: string[]): unknown { } /** - * Resolve the full URL that rawApiRequest would use for a request. - * - * Mirrors the URL construction in rawApiRequest: - * `${baseUrl}/api/0/${endpoint}?${queryString}` - * @internal Exported for testing + * Compose query parameters without URL fragments. */ -export function resolveRequestUrl( +function resolveRequestEndpoint( endpoint: string, - params?: Record, - baseUrl?: string, + params: Record | undefined, ): string { - // Use the same SDK config selection as rawApiRequest to ensure - // trailing slashes are stripped and the URL matches what would be sent. - const { baseUrl: normalizedBaseUrl } = baseUrl - ? getSdkConfig(baseUrl) - : getDefaultSdkConfig(); - const normalizedEndpoint = endpoint.startsWith("/") - ? endpoint.slice(1) - : endpoint; - const endpointWithParams = appendSearchParams(normalizedEndpoint, params); - return `${normalizedBaseUrl}/api/0/${endpointWithParams}`; + const withoutFragment = endpoint.split("#", 1)[0] ?? endpoint; + return appendSearchParams(withoutFragment, params); } +/** Pagination resolved for one GET, without mutating its saved history. */ +type ApiPagination = { + /** SDK-prepared request with the resolved server cursor and page size. */ + prepared: PreparedRawApiRequest; + /** Identity-scoped fingerprint of the caller's request, excluding cursor. */ + contextKey: string; + /** Navigation operation to commit after a successful response. */ + direction: CursorDirection; + /** Original cursor, including any bookmark, saved for backward navigation. */ + currentCursor: string | undefined; + /** Shared decoded position; skip counts rows already returned to the caller. */ + position: PagePosition; +}; + /** - * Resolve effective request headers, mirroring rawApiRequest logic. - * - * Auto-adds Content-Type: application/json for non-string object bodies - * when no Content-Type was explicitly provided. - * - * @internal Exported for testing + * Resolve CLI navigation and custom cursors while retaining a single request. + * Context uses the caller's query before cursor-derived page-size overrides, so + * repeating the same arguments with `next` finds the same walk. Header/body + * values participate in an opaque fingerprint and are never stored as plaintext. */ -export function resolveEffectiveHeaders( - customHeaders: Record | undefined, - body: unknown, -): Record { - // Mirror rawApiRequest exactly: auto-add Content-Type for any non-string, - // non-undefined body when no Content-Type was explicitly provided. - const isStringBody = typeof body === "string"; - // oxlint-disable-next-line unicorn/no-useless-fallback-in-spread -- Retain the existing fallback while migrating lint tools. - const headers = { ...(customHeaders ?? {}) }; - const hasContentType = Object.keys(headers).some( - (k) => k.toLowerCase() === "content-type", +async function resolveApiNavigation( + endpoint: string, + cursorFlag: string | undefined, + request: NonNullable[1]>, +): Promise { + const queryIndex = endpoint.indexOf("?"); + const query = new URLSearchParams( + queryIndex === -1 ? "" : endpoint.slice(queryIndex + 1), ); - if (!(isStringBody || hasContentType) && body !== undefined) { - headers["Content-Type"] = "application/json"; - } - return headers; + const inputCursor = query.getAll("cursor").at(-1); + const context = await prepareRawApiRequest(endpoint, request, { + cursor: null, + }); + const contextUrl = context.request.url; + const effectiveHeaders = new Headers(context.request.headers); + applyCustomHeaders(effectiveHeaders, contextUrl); + effectiveHeaders.delete("authorization"); + effectiveHeaders.delete("sentry-trace"); + effectiveHeaders.delete("baggage"); + const identity = JSON.stringify([ + getIdentityFingerprint(), + [...effectiveHeaders], + request.body, + ]); + const contextKey = buildPaginationContextKey( + "api", + await buildCacheKey("GET", contextUrl, identity), + ); + const { cursor, direction } = resolveCursor( + cursorFlag, + PAGINATION_KEY, + contextKey, + ); + const currentCursor = cursorFlag === undefined ? inputCursor : cursor; + const position = decodePageCursor(currentCursor); + return { + prepared: + cursorFlag === undefined && inputCursor === undefined + ? context + : await prepareRawApiRequest(endpoint, request, { + cursor: position.cursor ?? null, + per_page: position.perPage, + }), + contextKey, + direction, + currentCursor, + position, + }; } /** @@ -1401,10 +1464,10 @@ function recordApiErrorAttributes( /** Log outgoing request details in `> ` curl-verbose style. */ function logRequest( method: string, - endpoint: string, + url: string, headers: Record | undefined, ): void { - log.debug(`> ${method} /api/0/${endpoint}`); + log.debug(`> ${method} ${url}`); if (headers) { for (const [key, value] of Object.entries(headers)) { log.debug(`> ${key}: ${value}`); @@ -1529,6 +1592,11 @@ export const apiCommand = buildCommand({ "Absolute HTTP(S) Sentry URLs are also accepted; their origin is " + "validated against your authenticated host. " + "Authentication is handled automatically using your stored credentials.\n\n" + + "Pagination: --json includes nextCursor/prevCursor and hasPrev in " + + "pagination when navigation is available. Repeat the same GET and " + + "filters with --cursor next, prev, or first, or pass a raw cursor. " + + "Shared custom cursors restore page size and skip rows already read. " + + "Each invocation fetches one page.\n\n" + "Body options:\n" + ' --data/-d \'{"key":"value"}\' Inline JSON body (like curl -d)\n' + ' --input/-i file.json Read body from file (or "-" for stdin)\n\n' + @@ -1543,6 +1611,18 @@ export const apiCommand = buildCommand({ description: "List organizations", command: "sentry api organizations/", }, + { + description: "Read a page and its pagination metadata", + command: "sentry api organizations/ -f per_page=2 --json", + }, + { + description: "Continue the same query using shared pagination", + command: "sentry api organizations/ -f per_page=2 --cursor next --json", + }, + { + description: "Return to the previously visited page", + command: "sentry api organizations/ -f per_page=2 --cursor prev --json", + }, { description: "Get a specific issue", command: "sentry api issues/123456789/", @@ -1619,6 +1699,7 @@ export const apiCommand = buildCommand({ default: "GET" as const, placeholder: "method", }, + cursor: LIST_CURSOR_FLAG, data: { kind: "parsed", parse: String, @@ -1673,6 +1754,7 @@ export const apiCommand = buildCommand({ }, aliases: { X: "method", + c: "cursor", d: "data", F: "field", f: "raw-field", @@ -1682,6 +1764,12 @@ export const apiCommand = buildCommand({ }, async *func(this: SentryContext, flags: ApiFlags, endpoint: string) { const { stdin } = this; + if (flags.cursor !== undefined && flags.method !== "GET") { + throw new ValidationError( + "--cursor is only supported for GET requests", + "cursor", + ); + } const { normalizedEndpoint, requestBaseUrl, strippedApiPrefix } = resolveApiTarget(endpoint); @@ -1696,18 +1784,32 @@ export const apiCommand = buildCommand({ ); } const { body, params } = await resolveBody(flags, stdin); + const composedEndpoint = resolveRequestEndpoint(normalizedEndpoint, params); const headers = flags.header && flags.header.length > 0 ? parseHeaders(flags.header) : undefined; + const request = { + method: flags.method, + body, + headers, + baseUrl: requestBaseUrl, + }; + const navigation = + flags.method === "GET" + ? await resolveApiNavigation(composedEndpoint, flags.cursor, request) + : undefined; + const prepared = + navigation?.prepared ?? + (await prepareRawApiRequest(composedEndpoint, request)); // Dry-run mode: preview the request that would be sent if (flags["dry-run"]) { yield new CommandOutput({ - method: flags.method, - url: resolveRequestUrl(normalizedEndpoint, params, requestBaseUrl), - headers: resolveEffectiveHeaders(headers, body), + method: prepared.request.method, + url: prepared.request.url, + headers: Object.fromEntries(prepared.request.headers), body: body ?? null, }); return; @@ -1716,18 +1818,12 @@ export const apiCommand = buildCommand({ const verbose = flags.verbose && !flags.silent; if (verbose) { - logRequest(flags.method, normalizedEndpoint, headers); + logRequest(flags.method, prepared.request.url, headers); } // The shared authenticated fetch validates requestBaseUrl against the // token host and registered regional origins before adding credentials. - const response = await rawApiRequest(normalizedEndpoint, { - method: flags.method, - body, - params, - headers, - baseUrl: requestBaseUrl, - }); + const response = await rawApiRequest(prepared); const isError = isApiErrorStatus(response.status); @@ -1746,6 +1842,49 @@ export const apiCommand = buildCommand({ ); } + let pagination: ApiPaginationMetadata = parseLinkHeader( + response.headers.get("link"), + ); + if (navigation && !isError) { + const envelope = + response.body !== null && + typeof response.body === "object" && + "data" in response.body && + Array.isArray(response.body.data) + ? response.body + : undefined; + const rows = Array.isArray(response.body) + ? response.body + : envelope?.data; + if (Array.isArray(rows)) { + const page = consumePage( + { data: rows, ...pagination }, + navigation.position, + ); + response.body = envelope ? { ...envelope, data: page.data } : page.data; + pagination = { + nextCursor: page.nextCursor, + prevCursor: pagination.prevCursor, + }; + } else if (navigation.position.skip > 0) { + throw new ValidationError( + "Cannot resume a mid-page cursor: response body is not an array or a data envelope", + "cursor", + ); + } + advancePaginationState( + PAGINATION_KEY, + navigation.contextKey, + navigation.direction, + pagination.nextCursor, + navigation.currentCursor ?? "", + ); + pagination.hasPrev = hasPreviousPage( + PAGINATION_KEY, + navigation.contextKey, + ); + } + const output = resolveApiResponseOutput(response, { silent: flags.silent, isTTY: this.stdout.isTTY, @@ -1777,7 +1916,7 @@ export const apiCommand = buildCommand({ // formatter, no trailing newline). Textual JSON responses expose the HTTP // metadata so callers can distinguish an empty success from an error. return yield new CommandOutput( - formatApiResponseOutput(response, output, flags.json), + formatApiResponseOutput(response, output, flags.json, pagination), ); }, }); diff --git a/packages/cli/src/lib/api-client.ts b/packages/cli/src/lib/api-client.ts index 3dd40f95c..f652704a2 100644 --- a/packages/cli/src/lib/api-client.ts +++ b/packages/cli/src/lib/api-client.ts @@ -89,10 +89,15 @@ export { appendSearchParams, autoPaginate, buildSearchParams, + consumePage, + decodePageCursor, isTextualContentType, ORG_FANOUT_CONCURRENCY, + type PagePosition, type PaginatedResponse, parseLinkHeader, + type PreparedRawApiRequest, + prepareRawApiRequest, rawApiRequest, } from "./api/infrastructure.js"; export { getIssueTag } from "./api/issue-tags.js"; diff --git a/packages/cli/src/lib/api/infrastructure.ts b/packages/cli/src/lib/api/infrastructure.ts index 16b3726b8..5f5d0704f 100644 --- a/packages/cli/src/lib/api/infrastructure.ts +++ b/packages/cli/src/lib/api/infrastructure.ts @@ -9,6 +9,7 @@ import { promisify } from "node:util"; import { zstdCompress as zstdCompressCb } from "node:zlib"; import { + createSentryClient, parseSentryLinkHeader, type SdkResult, SentryApiError, @@ -17,8 +18,10 @@ import { readErrorResponse } from "@sentry/toolkit-core/api-client/response"; import { buildSentryApiUrl } from "@sentry/toolkit-core/api-request"; import { API_MAX_PER_PAGE, + decodePageCursor as decodeCursorPosition, fetchAllPages as fetchEveryPage, type FetchAllPagesFailure, + type PagePosition, paginate as paginateFrom, } from "@sentry/toolkit-core/api-client/pagination"; // oxlint-disable-next-line sentry-cli/no-namespace-import -- Sentry SDK recommends namespace import @@ -411,6 +414,16 @@ export const MAX_PAGINATION_PAGES = Math.max( */ export { API_MAX_PER_PAGE }; +export { + consumePage, + type PagePosition, +} from "@sentry/toolkit-core/api-client/pagination"; + +/** Decode a shared cursor, reporting unreadable legacy bookmarks through the CLI logger. */ +export function decodePageCursor(raw: string | undefined): PagePosition { + return decodeCursorPosition(raw, (message) => logger.warn(message)); +} + /** * Maximum concurrent API requests when fanning out across organizations or regions. * @@ -838,8 +851,56 @@ export function isTextualContentType(contentType: string | null): boolean { return false; } +/** Options for a raw request, retaining the CLI's transport and body policies. */ +type RawApiRequestOptions = ApiRequestOptions & { + /** Explicit caller headers, before authentication and proxy headers. */ + headers?: Record; + /** Caller-selected Sentry origin or instance prefix, checked at dispatch. */ + baseUrl?: string; +}; + +/** A request snapshot and the CLI transport that will authenticate it. */ +export type PreparedRawApiRequest = { + /** SDK-prepared URL, headers, method and body; single-use when sent. */ + request: Request; + /** Authenticated transport, including raw response buffering and host checks. */ + fetch: ReturnType["fetch"]; +}; + /** - * Make a raw API request that returns full response details. + * Prepare a raw request without fetching or refreshing credentials. + * Existing params retain append semantics; query overrides use the SDK's + * replacement semantics for decoded page cursors and page sizes. + */ +export async function prepareRawApiRequest( + endpoint: string, + options: RawApiRequestOptions = {}, + query?: Record, +): Promise { + const { method = "GET", body, params, headers, baseUrl } = options; + const config = baseUrl + ? getSdkConfig(baseUrl, { bufferResponseBody: true }) + : getDefaultSdkConfig({ bufferResponseBody: true }); + const isStringBody = typeof body === "string"; + const request = await createSentryClient(config).client.prepareRequest({ + method, + url: buildSentryApiUrl("", appendSearchParams(endpoint, params)), + query, + body, + headers: isStringBody ? { "Content-Type": null, ...headers } : headers, + ...(isStringBody ? { bodySerializer: null } : {}), + }); + // Raw requests honor an explicit Content-Type even without a body; the SDK + // otherwise removes it for bodyless operations and empty serialized bodies. + const contentType = new Headers(headers).get("content-type"); + if (contentType !== null) { + request.headers.set("content-type", contentType); + } + return { request, fetch: config.fetch }; +} + +/** + * Send a prepared or endpoint-based raw request and return full response details. * Unlike apiRequest, this does not throw on non-2xx responses. * Used by the 'sentry api' command for direct API access. * @@ -847,69 +908,26 @@ export function isTextualContentType(contentType: string | null): boolean { * ({@link isTextualContentType}). Non-textual bodies are returned as * `Uint8Array` so binary downloads stay byte-for-byte intact. * - * @param endpoint - API endpoint path (e.g., "/organizations/") - * @param options - Request options including method, body, params, custom headers, and optional trusted base URL + * @param endpoint - API endpoint path or the prepared request used for its preview + * @param options - Options used when preparing an endpoint-based request * @returns Response status, status text, headers, and parsed body * @throws {AuthError} On authentication failure * @throws {HostScopeError} Before credentials are attached to an untrusted origin */ export async function rawApiRequest( - endpoint: string, - options: ApiRequestOptions & { - headers?: Record; - /** - * Sentry instance or regional base URL. The authenticated fetch rejects - * origins outside the active token's trust scope before adding credentials. - */ - baseUrl?: string; - } = {}, + endpoint: string | PreparedRawApiRequest, + options: RawApiRequestOptions = {}, ): Promise<{ status: number; statusText: string; headers: Headers; body: unknown; }> { - const { - method = "GET", - body, - params, - headers: customHeaders = {}, - baseUrl, - } = options; - - // Both configs share createAuthenticatedFetch(), whose prepareHeaders() - // enforces isRequestOriginTrusted() before attaching Authorization. - const config = baseUrl - ? getSdkConfig(baseUrl, { bufferResponseBody: true }) - : getDefaultSdkConfig({ bufferResponseBody: true }); - - const endpointWithParams = appendSearchParams(endpoint, params); - const url = buildSentryApiUrl(config.baseUrl, endpointWithParams); - - // Build request headers and body. - // String bodies: no Content-Type unless the caller explicitly provides one. - // Object bodies: application/json (auto-stringified). - const isStringBody = typeof body === "string"; - const hasContentType = Object.keys(customHeaders).some( - (k) => k.toLowerCase() === "content-type", - ); - - const headers: Record = { ...customHeaders }; - if (!(isStringBody || hasContentType) && body !== undefined) { - headers["Content-Type"] = "application/json"; - } - - let requestBody: string | undefined; - if (body !== undefined) { - requestBody = isStringBody ? body : JSON.stringify(body); - } - - const fetchFn = config.fetch; - const response = await fetchFn(url, { - method, - headers, - body: requestBody, - }); + const prepared = + typeof endpoint === "string" + ? await prepareRawApiRequest(endpoint, options) + : endpoint; + const response = await prepared.fetch(prepared.request); const contentType = response.headers.get("content-type"); let responseBody: unknown; diff --git a/packages/cli/src/lib/db/pagination.ts b/packages/cli/src/lib/db/pagination.ts index b56811e5d..0f70515cc 100644 --- a/packages/cli/src/lib/db/pagination.ts +++ b/packages/cli/src/lib/db/pagination.ts @@ -39,14 +39,14 @@ type PaginationCursorRow = { * Pagination state for a (command, context) pair. * * The `stack` array contains one entry per visited page: - * - `stack[0]` = `""` (first page, no cursor) + * - `stack[0]` = `""` (first page) or an explicit cursor that began the walk * - `stack[1]` = cursor for page 2 * - `stack[N]` = cursor for page N+1 * * `index` is the page the user is currently viewing (0-based). */ export type PaginationState = { - /** Array of page-start cursors. Index 0 is always `""` (first page). */ + /** Page-start cursors; index 0 is the first visited page, normally `""`. */ stack: string[]; /** Index of the currently viewed page (0-based). */ index: number; @@ -239,18 +239,22 @@ export function resolveCursor( * @param contextKey - Serialized query context * @param direction - Which direction the user navigated * @param nextCursor - Cursor for the page after the one just displayed (undefined if last page) + * @param currentCursor - Exact cursor used for this page, including any bookmark; + * `""` means the first page. Omit to retain the existing stack entry. */ export function advancePaginationState( commandKey: string, contextKey: string, direction: CursorDirection, nextCursor: string | undefined, + currentCursor?: string, ): void { const state = getPaginationState(commandKey, contextKey); if (!state) { // First page ever — initialise the stack - const stack = nextCursor ? ["", nextCursor] : [""]; + const first = currentCursor ?? ""; + const stack = nextCursor ? [first, nextCursor] : [first]; savePaginationState(commandKey, contextKey, { stack, index: 0 }); return; } @@ -260,6 +264,9 @@ export function advancePaginationState( const newIndex = state.index + 1; // Truncate anything beyond the new position (back-then-forward scenario) const stack = state.stack.slice(0, newIndex + 1); + if (currentCursor !== undefined) { + stack[newIndex] = currentCursor; + } if (nextCursor) { stack[newIndex + 1] = nextCursor; } @@ -274,6 +281,9 @@ export function advancePaginationState( const newIndex = Math.max(0, state.index - 1); // Refresh the "next" entry from this position in case data shifted const stack = [...state.stack]; + if (currentCursor !== undefined) { + stack[newIndex] = currentCursor; + } if (nextCursor) { stack[newIndex + 1] = nextCursor; } @@ -286,7 +296,8 @@ export function advancePaginationState( } case "first": { - const stack = nextCursor ? ["", nextCursor] : [""]; + const first = currentCursor ?? ""; + const stack = nextCursor ? [first, nextCursor] : [first]; savePaginationState(commandKey, contextKey, { stack, index: 0 }); break; } diff --git a/packages/cli/test/commands/api.property.test.ts b/packages/cli/test/commands/api.property.test.ts index ff000b301..2fe8f569a 100644 --- a/packages/cli/test/commands/api.property.test.ts +++ b/packages/cli/test/commands/api.property.test.ts @@ -15,7 +15,6 @@ import { oneof, property, record, - string, stringMatching, tuple, uniqueArray, @@ -31,8 +30,6 @@ import { parseFieldValue, parseMethod, resolveBody, - resolveEffectiveHeaders, - resolveRequestUrl, setNestedValue, } from "../../src/commands/api.js"; import { ValidationError } from "../../src/lib/errors.js"; @@ -824,130 +821,3 @@ describe("property: resolveBody", () => { ); }); }); - -// Dry-run property tests - -/** Arbitrary for HTTP methods */ - -/** Arbitrary for clean API endpoint paths (no query string, for dry-run tests) */ -const dryRunEndpointArb = stringMatching( - /^[a-z][a-z0-9-]*\/[a-z0-9-]*\/$/, -).filter((s) => s.length > 3 && s.length < 80); - -/** Arbitrary for query param values */ -const paramValueArb = stringMatching(/^[a-zA-Z0-9_-]+$/).filter( - (s) => s.length > 0 && s.length < 40, -); - -/** Arbitrary for query param maps */ -const paramsArb = dictionary( - stringMatching(/^[a-zA-Z][a-zA-Z0-9_]*$/).filter( - (s) => s.length > 0 && s.length < 20, - ), - paramValueArb, - { minKeys: 0, maxKeys: 3 }, -); - -describe("property: resolveRequestUrl", () => { - test("always contains /api/0/ prefix", () => { - fcAssert( - property(dryRunEndpointArb, (endpoint) => { - const url = resolveRequestUrl(endpoint); - expect(url).toContain("/api/0/"); - }), - { numRuns: DEFAULT_NUM_RUNS }, - ); - }); - - test("always contains the endpoint path", () => { - fcAssert( - property(dryRunEndpointArb, (endpoint) => { - const url = resolveRequestUrl(endpoint); - const normalized = endpoint.startsWith("/") - ? endpoint.slice(1) - : endpoint; - expect(url).toContain(normalized); - }), - { numRuns: DEFAULT_NUM_RUNS }, - ); - }); - - test("includes query string when params provided", () => { - fcAssert( - property(dryRunEndpointArb, paramsArb, (endpoint, params) => { - const url = resolveRequestUrl(endpoint, params); - if (Object.keys(params).length > 0) { - expect(url).toContain("?"); - for (const key of Object.keys(params)) { - expect(url).toContain(key); - } - } - }), - { numRuns: DEFAULT_NUM_RUNS }, - ); - }); -}); - -describe("property: resolveEffectiveHeaders", () => { - /** Arbitrary for custom header entries (non-content-type keys) */ - const headerKeyArb = stringMatching(/^X-[A-Za-z]{1,10}$/); - const headerValueArb = string({ minLength: 1, maxLength: 20 }); - const customHeadersArb = dictionary(headerKeyArb, headerValueArb, { - minKeys: 0, - maxKeys: 3, - }); - - test("preserves all custom headers", () => { - fcAssert( - property(customHeadersArb, (custom) => { - const result = resolveEffectiveHeaders(custom, undefined); - for (const [key, value] of Object.entries(custom)) { - expect(result[key]).toBe(value); - } - }), - { numRuns: DEFAULT_NUM_RUNS }, - ); - }); - - test("auto-adds Content-Type for object bodies without it", () => { - fcAssert( - property(customHeadersArb, jsonValue(), (custom, body) => { - // Ensure body is a non-null object (not string/number/boolean/null) - if (body === null || body === undefined || typeof body !== "object") { - return; - } - const result = resolveEffectiveHeaders(custom, body); - expect(result["Content-Type"]).toBe("application/json"); - }), - { numRuns: DEFAULT_NUM_RUNS }, - ); - }); - - test("never adds Content-Type for string bodies", () => { - fcAssert( - property(customHeadersArb, string(), (custom, body) => { - const result = resolveEffectiveHeaders(custom, body); - // Should not have auto-added Content-Type (only custom headers) - if (!custom["Content-Type"]) { - expect(result["Content-Type"]).toBeUndefined(); - } - }), - { numRuns: DEFAULT_NUM_RUNS }, - ); - }); - - test("does not override explicit Content-Type", () => { - const contentTypeArb = constantFrom( - "text/plain", - "text/xml", - "application/x-www-form-urlencoded", - ); - fcAssert( - property(contentTypeArb, jsonValue(), (ct, body) => { - const result = resolveEffectiveHeaders({ "Content-Type": ct }, body); - expect(result["Content-Type"]).toBe(ct); - }), - { numRuns: DEFAULT_NUM_RUNS }, - ); - }); -}); diff --git a/packages/cli/test/commands/api.test.ts b/packages/cli/test/commands/api.test.ts index 6f90ee7a7..d0d82802f 100644 --- a/packages/cli/test/commands/api.test.ts +++ b/packages/cli/test/commands/api.test.ts @@ -35,8 +35,6 @@ import { resolveApiResponseOutput, resolveBinaryTtyOutput, resolveBody, - resolveEffectiveHeaders, - resolveRequestUrl, setNestedValue, } from "../../src/commands/api.js"; import { OutputError, ValidationError } from "../../src/lib/errors.js"; @@ -1390,59 +1388,6 @@ describe("buildBodyFromInput", () => { }); }); -describe("resolveEffectiveHeaders", () => { - test("auto-adds Content-Type for object bodies", () => { - const headers = resolveEffectiveHeaders(undefined, { key: "value" }); - expect(headers["Content-Type"]).toBe("application/json"); - }); - - test("does not add Content-Type for string bodies", () => { - const headers = resolveEffectiveHeaders(undefined, "raw-string"); - expect(headers["Content-Type"]).toBeUndefined(); - }); - - test("does not override explicit Content-Type", () => { - const headers = resolveEffectiveHeaders( - { "Content-Type": "text/plain" }, - { key: "value" }, - ); - expect(headers["Content-Type"]).toBe("text/plain"); - }); - - test("case-insensitive Content-Type check", () => { - const headers = resolveEffectiveHeaders( - { "content-type": "text/xml" }, - { key: "value" }, - ); - expect(headers["content-type"]).toBe("text/xml"); - expect(headers["Content-Type"]).toBeUndefined(); - }); - - test("preserves custom headers", () => { - const headers = resolveEffectiveHeaders( - { Authorization: "Bearer token", "X-Custom": "value" }, - undefined, - ); - expect(headers.Authorization).toBe("Bearer token"); - expect(headers["X-Custom"]).toBe("value"); - }); - - test("defaults to empty object when no headers provided", () => { - const headers = resolveEffectiveHeaders(undefined, undefined); - expect(headers).toEqual({}); - }); - - test("adds Content-Type for null body (matches rawApiRequest)", () => { - const headers = resolveEffectiveHeaders(undefined, null); - expect(headers["Content-Type"]).toBe("application/json"); - }); - - test("does not add Content-Type for undefined body", () => { - const headers = resolveEffectiveHeaders(undefined, undefined); - expect(headers["Content-Type"]).toBeUndefined(); - }); -}); - // --data/-d and JSON auto-detection (CLI-AF) describe("parseDataBody", () => { @@ -1861,47 +1806,7 @@ describe("dataToQueryParams", () => { // Dry-run tests -describe("resolveRequestUrl", () => { - test("builds URL against an explicit regional origin", () => { - const url = resolveRequestUrl( - "organizations/", - undefined, - "https://de.sentry.io/sentry", - ); - expect(url).toBe("https://de.sentry.io/sentry/api/0/organizations/"); - }); - - test("strips leading slash from endpoint", () => { - const url = resolveRequestUrl("/organizations/"); - expect(url).toMatch(/\/api\/0\/organizations\/$/); - }); - - test("appends query params", () => { - const url = resolveRequestUrl("issues/", { status: "unresolved" }); - expect(url).toContain("?status=unresolved"); - expect(url).toContain("/api/0/issues/"); - }); - - test("handles array params", () => { - const url = resolveRequestUrl("events/", { - field: ["title", "timestamp"], - }); - expect(url).toContain("field=title"); - expect(url).toContain("field=timestamp"); - }); - - test("omits query string when no params", () => { - const url = resolveRequestUrl("projects/"); - expect(url).not.toContain("?"); - }); - - test("merges params with an existing query string", () => { - const url = resolveRequestUrl("attachments/?download=1", { - mode: "raw", - }); - expect(url).toContain("attachments/?download=1&mode=raw"); - }); - +describe("API request preview", () => { test("absolute URL dry-run preserves the regional origin", async () => { let output = ""; const func = await apiCommand.loader(); diff --git a/packages/cli/test/e2e/api.test.ts b/packages/cli/test/e2e/api.test.ts index 8366db5cd..a3ad91215 100644 --- a/packages/cli/test/e2e/api.test.ts +++ b/packages/cli/test/e2e/api.test.ts @@ -14,18 +14,123 @@ import { expect, test, } from "vitest"; +import { paginate } from "../../src/lib/api/infrastructure.js"; import { EXIT } from "../../src/lib/errors.js"; -import { createE2EContext, type E2EContext } from "../fixture.js"; +import { createE2EContext, type E2EContext, runCli } from "../fixture.js"; +import organizationsFixture from "../fixtures/organizations.json"; +import transactionsFixture from "../fixtures/transactions.json"; import { cleanupTestDir, createTestConfigDir } from "../helpers.js"; -import { createSentryMockServer, TEST_TOKEN } from "../mocks/routes.js"; -import type { MockServer } from "../mocks/server.js"; +import { apiRoutes, TEST_TOKEN } from "../mocks/routes.js"; +import { createMockServer, type MockServer } from "../mocks/server.js"; let testConfigDir: string; let mockServer: MockServer; let ctx: E2EContext; +const paginationRequests: URL[] = []; +const preparedRequests: { + url: string; + method: string; + headers: Headers; + body: string; +}[] = []; +const nextCursor = "1:1:0"; +const prevCursor = "1:0:1"; +const alternateToken = "test-api-other-identity"; +const cursorOrganizations = Array.from({ length: 6 }, (_, index) => ({ + ...organizationsFixture[index % organizationsFixture.length], + id: String(100_001 + index), + slug: `test-org-${index + 1}`, + name: `Test Organization ${index + 1}`, +})); +const cursorTransactions = Array.from({ length: 6 }, (_, index) => ({ + ...transactionsFixture.data[index % transactionsFixture.data.length], + id: String(index + 1).padStart(16, "0"), +})); +let failNextPage = false; beforeAll(async () => { - mockServer = createSentryMockServer(); + mockServer = createMockServer( + [ + ...apiRoutes, + { + method: "POST", + path: "/api/0/api-prepared-request/", + response: async (req) => { + preparedRequests.push({ + url: req.url, + method: req.method, + headers: req.headers, + body: await req.text(), + }); + return { + status: 422, + body: { detail: "Unsupported request", meta: { field: "title" } }, + }; + }, + }, + { + method: "GET", + path: "/api/0/api-pagination/", + response: (req, _params, serverUrl) => { + const url = new URL(req.url); + paginationRequests.push(url); + const isSecondPage = url.searchParams.get("cursor") === nextCursor; + if (isSecondPage && failNextPage) { + failNextPage = false; + return { status: 403, body: { detail: "Access denied" } }; + } + return { + body: [organizationsFixture[isSecondPage ? 1 : 0]], + headers: { + "Cache-Control": "no-store", + Link: + `<${serverUrl}/api/0/api-pagination/?cursor=${nextCursor}>; rel="next"; results="${!isSecondPage}"; cursor="${nextCursor}", ` + + `<${serverUrl}/api/0/api-pagination/?cursor=${prevCursor}>; rel="previous"; results="${isSecondPage}"; cursor="${prevCursor}"`, + }, + }; + }, + }, + { + method: "GET", + path: "/api/0/api-custom-cursor/", + response: (req, _params, serverUrl) => { + const url = new URL(req.url); + paginationRequests.push(url); + const perPage = Number(url.searchParams.get("per_page") ?? "2"); + const page = Number( + url.searchParams.get("cursor")?.split(":")[1] ?? "0", + ); + const start = page * perPage; + const next = `${perPage}:${page + 1}:0`; + const previous = `${perPage}:${page - 1}:1`; + const isEventsEnvelope = + url.searchParams.get("envelope") === "events"; + const rows = isEventsEnvelope + ? cursorTransactions + : cursorOrganizations; + const data = rows.slice(start, start + perPage); + return { + body: isEventsEnvelope ? { ...transactionsFixture, data } : data, + headers: { + "Cache-Control": "no-store", + Link: + `<${serverUrl}/api/0/api-custom-cursor/?cursor=${next}>; rel="next"; results="${start + perPage < rows.length}"; cursor="${next}", ` + + `<${serverUrl}/api/0/api-custom-cursor/?cursor=${previous}>; rel="previous"; results="${page > 0}"; cursor="${previous}"`, + }, + }; + }, + }, + { + method: "POST", + path: "/api/0/api-pagination/", + response: (req) => { + paginationRequests.push(new URL(req.url)); + return { body: { accepted: true } }; + }, + }, + ], + { validTokens: [TEST_TOKEN, alternateToken] }, + ); await mockServer.start(); }); @@ -34,6 +139,9 @@ afterAll(() => { }); beforeEach(async () => { + paginationRequests.length = 0; + preparedRequests.length = 0; + failNextPage = false; testConfigDir = await createTestConfigDir("e2e-api-"); ctx = createE2EContext(testConfigDir, mockServer.url); }); @@ -124,6 +232,435 @@ describe("sentry api", () => { expect(JSON.parse(result.stdout)).toEqual({ method: "GET" }); }); + test.each([ + { + name: "JSON data", + input: '{"title":"Overview"}', + body: { title: "Overview" }, + contentType: "application/json", + fromFile: false, + headers: [], + }, + { + name: "raw file", + input: "raw payload", + body: "raw payload", + contentType: "text/plain;charset=UTF-8", + fromFile: true, + headers: [], + }, + { + name: "explicit Content-Type", + input: '{"title":"Overview"}', + body: { title: "Overview" }, + contentType: "application/custom", + fromFile: false, + headers: ["-H", "content-type: application/custom"], + }, + ])( + "dispatches the URL, headers and body shown by the $name preview", + async ({ input, body, contentType, fromFile, headers }) => { + await ctx.setAuthToken(TEST_TOKEN); + let bodyFlags = ["--data", input]; + if (fromFile) { + const file = `${testConfigDir}/request.txt`; + await writeFile(file, input); + bodyFlags = ["--input", file]; + } + const args = [ + "api", + "api-prepared-request/?field=id&field=title&query=a%20b", + "--method", + "POST", + ...bodyFlags, + "-H", + "X-Example: custom-value", + ...headers, + "--json", + ]; + + const preview = await ctx.run([...args, "--dry-run"]); + expect(preview.exitCode, preview.stderr + preview.stdout).toBe(0); + expect(preparedRequests).toHaveLength(0); + const request = JSON.parse(preview.stdout); + const previewHeaders = new Headers(request.headers); + expect(request.body).toEqual(body); + expect(previewHeaders.get("Content-Type")).toBe(contentType); + expect(previewHeaders.get("X-Example")).toBe("custom-value"); + expect(previewHeaders.has("Authorization")).toBe(false); + + const result = await ctx.run(args); + expect(result.exitCode).toBe(EXIT.OUTPUT_ERROR); + expect(preparedRequests).toHaveLength(1); + const sent = preparedRequests[0]; + expect(sent.url).toBe(request.url); + expect(sent.method).toBe(request.method); + expect(sent.body).toBe(input); + for (const [name, value] of previewHeaders) { + expect(sent.headers.get(name)).toBe(value); + } + expect(sent.headers.get("Authorization")).toBe(`Bearer ${TEST_TOKEN}`); + expect(JSON.parse(result.stdout)).toMatchObject({ + status: 422, + body: { detail: "Unsupported request", meta: { field: "title" } }, + }); + }, + ); + + test("navigates next, prev and first while preserving filters and --fields", async () => { + await ctx.setAuthToken(TEST_TOKEN); + const args = [ + "api", + "api-pagination/?project=1&project=2", + "-f", + "query=level:error", + "-f", + "per_page=1", + "--json", + "--fields", + "name", + ]; + + const first = await ctx.run(args); + expect(first.exitCode, first.stderr + first.stdout).toBe(0); + const firstPage = JSON.parse(first.stdout); + expect(firstPage).toMatchObject({ + status: 200, + statusText: "OK", + pagination: { nextCursor, hasPrev: false }, + }); + expect(firstPage.body).toEqual([{ name: organizationsFixture[0].name }]); + expect(paginationRequests).toHaveLength(1); + + const second = await ctx.run([...args, "--cursor", "next"]); + expect(second.exitCode, second.stderr + second.stdout).toBe(0); + const secondPage = JSON.parse(second.stdout); + expect(secondPage).toMatchObject({ + status: 200, + statusText: "OK", + pagination: { prevCursor, hasPrev: true }, + }); + expect(secondPage.body).toEqual([{ name: organizationsFixture[1].name }]); + expect(secondPage.pagination.nextCursor).toBeUndefined(); + expect(paginationRequests).toHaveLength(2); + expect(paginationRequests[1].searchParams.getAll("cursor")).toEqual([ + nextCursor, + ]); + + for (const [cursor, organization] of [ + ["prev", organizationsFixture[0]], + ["next", organizationsFixture[1]], + ["first", organizationsFixture[0]], + ] as const) { + const page = await ctx.run([ + ...args, + ...(cursor === "first" ? ["-f", "cursor=stale"] : []), + "-c", + cursor, + ]); + expect(page.exitCode, page.stderr + page.stdout).toBe(0); + expect(JSON.parse(page.stdout).body).toEqual([ + { name: organization.name }, + ]); + } + expect(paginationRequests).toHaveLength(5); + expect( + paginationRequests.map((url) => url.searchParams.get("cursor")), + ).toEqual([null, nextCursor, null, nextCursor, null]); + for (const url of paginationRequests) { + expect(url.searchParams.get("query")).toBe("level:error"); + expect(url.searchParams.get("per_page")).toBe("1"); + expect(url.searchParams.getAll("project")).toEqual(["1", "2"]); + } + }); + + test("dry-runs and failed requests do not advance pagination history", async () => { + await ctx.setAuthToken(TEST_TOKEN); + const args = ["api", "api-pagination/", "-f", "per_page=1", "--json"]; + const preview = await ctx.run([...args, "--dry-run"]); + expect(preview.exitCode, preview.stderr + preview.stdout).toBe(0); + const missing = await ctx.run([...args, "--cursor", "next"]); + expect(missing.exitCode).toBe(EXIT.VALIDATION); + expect(paginationRequests).toHaveLength(0); + + const first = await ctx.run(args); + expect(first.exitCode, first.stderr + first.stdout).toBe(0); + const nextPreview = await ctx.run([ + ...args, + "--cursor", + "next", + "--dry-run", + ]); + expect(nextPreview.exitCode, nextPreview.stderr + nextPreview.stdout).toBe( + 0, + ); + expect( + new URL(JSON.parse(nextPreview.stdout).url).searchParams.get("cursor"), + ).toBe(nextCursor); + expect(paginationRequests).toHaveLength(1); + + failNextPage = true; + const failed = await ctx.run([...args, "--cursor", "next"]); + expect(failed.exitCode).toBe(EXIT.OUTPUT_ERROR); + expect(JSON.parse(failed.stdout).status).toBe(403); + const retried = await ctx.run([...args, "--cursor", "next"]); + expect(retried.exitCode, retried.stderr + retried.stdout).toBe(0); + expect(JSON.parse(retried.stdout).body).toEqual([organizationsFixture[1]]); + expect( + paginationRequests.map((url) => url.searchParams.get("cursor")), + ).toEqual([null, nextCursor, nextCursor]); + const exhausted = await ctx.run([...args, "--cursor", "next"]); + expect(exhausted.exitCode).toBe(EXIT.VALIDATION); + expect(paginationRequests).toHaveLength(3); + }); + + test.each(["human output", "silent output"])( + "saves pagination history with %s", + async (output) => { + await ctx.setAuthToken(TEST_TOKEN); + const args = ["api", "api-pagination/", "-f", "per_page=1"]; + const first = await ctx.run( + output === "silent output" ? [...args, "--silent"] : args, + ); + expect(first.exitCode, first.stderr + first.stdout).toBe(0); + if (output === "silent output") { + expect(first.stdout).toBe(""); + } else { + expect(JSON.parse(first.stdout)).toEqual([organizationsFixture[0]]); + } + const second = await ctx.run([...args, "--cursor", "next", "--json"]); + expect(second.exitCode, second.stderr + second.stdout).toBe(0); + expect(JSON.parse(second.stdout).body).toEqual([organizationsFixture[1]]); + expect(paginationRequests).toHaveLength(2); + }, + ); + + test.each(["query", "host", "identity"])( + "does not reuse pagination history after changing the %s", + async (change) => { + const args = ["api", "api-pagination/", "-f", "per_page=1", "--json"]; + const env = { + SENTRY_AUTH_TOKEN: TEST_TOKEN, + SENTRY_TOKEN: "", + SENTRY_CONFIG_DIR: testConfigDir, + SENTRY_URL: mockServer.url, + SENTRY_CLI_NO_TELEMETRY: "1", + }; + const first = await runCli(args, { env }); + expect(first.exitCode, first.stderr + first.stdout).toBe(0); + const changed = await runCli( + [ + ...args, + "--cursor", + "next", + ...(change === "query" ? ["-f", "query=other"] : []), + ], + { + env: { + ...env, + SENTRY_URL: + change === "host" + ? mockServer.url.replace("localhost", "127.0.0.1") + : mockServer.url, + SENTRY_AUTH_TOKEN: + change === "identity" ? alternateToken : TEST_TOKEN, + }, + }, + ); + expect(changed.exitCode).toBe(EXIT.VALIDATION); + expect(paginationRequests).toHaveLength(1); + const resumed = await runCli([...args, "--cursor", "next"], { env }); + expect(resumed.exitCode, resumed.stderr + resumed.stdout).toBe(0); + expect(JSON.parse(resumed.stdout).body).toEqual([ + organizationsFixture[1], + ]); + expect(paginationRequests).toHaveLength(2); + }, + ); + + test.each(["shared", "legacy"])( + "resumes a %s mid-page cursor without repeating or skipping rows", + async (format) => { + await ctx.setAuthToken(TEST_TOKEN); + const nativePage = await paginate({ limit: 1, cursor: "2:1:0" }, () => + Promise.resolve({ + data: cursorOrganizations.slice(2, 4), + nextCursor: "2:2:0", + }), + ); + expect(nativePage.nextCursor).toBeDefined(); + const cursor = + format === "legacy" ? "cli1~2~1~2:1:0" : String(nativePage.nextCursor); + const args = ["api", "api-custom-cursor/", "-f", "per_page=9", "--json"]; + const preview = await ctx.run([...args, "--cursor", cursor, "--dry-run"]); + expect(preview.exitCode, preview.stderr + preview.stdout).toBe(0); + const url = new URL(JSON.parse(preview.stdout).url); + expect(url.searchParams.get("per_page")).toBe("2"); + expect(url.searchParams.get("cursor")).toBe("2:1:0"); + expect(paginationRequests).toHaveLength(0); + + const resumed = await ctx.run([...args, "--cursor", cursor]); + expect(resumed.exitCode, resumed.stderr + resumed.stdout).toBe(0); + expect(paginationRequests.map((request) => request.href)).toEqual([ + url.href, + ]); + const resumedPage = JSON.parse(resumed.stdout); + expect(resumedPage.body).toEqual(cursorOrganizations.slice(3, 4)); + expect(resumedPage.pagination.nextCursor).toBe("2:2:0"); + expect(resumedPage.pagination.hasPrev).toBe(false); + const next = await ctx.run([...args, "--cursor", "next"]); + expect(next.exitCode, next.stderr + next.stdout).toBe(0); + const nextPage = JSON.parse(next.stdout); + expect(nextPage.body).toEqual(cursorOrganizations.slice(4)); + expect(nextPage.pagination.hasPrev).toBe(true); + expect([ + ...nativePage.data, + ...resumedPage.body, + ...nextPage.body, + ]).toEqual(cursorOrganizations.slice(2)); + + const previous = await ctx.run([...args, "--cursor", "prev"]); + expect(previous.exitCode, previous.stderr + previous.stdout).toBe(0); + const previousPage = JSON.parse(previous.stdout); + expect(previousPage.body).toEqual(cursorOrganizations.slice(3, 4)); + expect(previousPage.pagination.hasPrev).toBe(false); + expect( + paginationRequests.map((request) => request.searchParams.get("cursor")), + ).toEqual(["2:1:0", "2:2:0", "2:1:0"]); + expect( + paginationRequests.every( + (request) => request.searchParams.get("per_page") === "2", + ), + ).toBe(true); + }, + ); + + test("resumes a shared cursor in an Events data envelope while preserving metadata", async () => { + await ctx.setAuthToken(TEST_TOKEN); + const nativePage = await paginate({ limit: 1, cursor: "2:1:0" }, () => + Promise.resolve({ + data: cursorTransactions.slice(2, 4), + nextCursor: "2:2:0", + }), + ); + expect(nativePage.nextCursor).toBeDefined(); + const args = [ + "api", + "api-custom-cursor/?envelope=events", + "-f", + "per_page=9", + "--json", + ]; + + for (const [cursor, data, hasPrev] of [ + [String(nativePage.nextCursor), cursorTransactions.slice(3, 4), false], + ["next", cursorTransactions.slice(4), true], + ["prev", cursorTransactions.slice(3, 4), false], + ] as const) { + const result = await ctx.run([...args, "--cursor", cursor]); + expect(result.exitCode, result.stderr + result.stdout).toBe(0); + const page = JSON.parse(result.stdout); + expect(page.body).toEqual({ ...transactionsFixture, data }); + expect(page.pagination.hasPrev).toBe(hasPrev); + } + + expect( + paginationRequests.map((request) => request.searchParams.get("cursor")), + ).toEqual(["2:1:0", "2:2:0", "2:1:0"]); + expect( + paginationRequests.every( + (request) => request.searchParams.get("per_page") === "2", + ), + ).toBe(true); + }); + + test("rejects mid-page bookmarks on object responses without saving history", async () => { + await ctx.setAuthToken(TEST_TOKEN); + const args = ["api", "organizations/test-org/", "--json"]; + const result = await ctx.run([...args, "--cursor", "cli1~2~1~"]); + expect(result.exitCode).toBe(EXIT.VALIDATION); + expect(result.stderr + result.stdout).toContain("not an array"); + const next = await ctx.run([...args, "--cursor", "next"]); + expect(next.exitCode).toBe(EXIT.VALIDATION); + expect(next.stderr + next.stdout).toContain("No next page saved"); + }); + + test.each([ + { + name: "typed and raw fields", + flags: ["-F", "cursor=typed-old", "-f", "cursor=raw-old"], + }, + { name: "JSON data", flags: ["-d", '{"cursor":"data-old"}'] }, + { name: "form data", flags: ["-d", "cursor=data-old"] }, + ])( + "--cursor replaces $name and URL cursors in the preview and request", + async ({ flags }) => { + await ctx.setAuthToken(TEST_TOKEN); + const args = [ + "api", + `${mockServer.url}/api/0/api-pagination/?cursor=url-old&cursor=url-older&field=id&field=name&project=1&project=2`, + ...flags, + "--cursor", + nextCursor, + "--json", + ]; + + const preview = await ctx.run([...args, "--dry-run"]); + expect(preview.exitCode, preview.stderr + preview.stdout).toBe(0); + const request = JSON.parse(preview.stdout); + const url = new URL(request.url); + expect(url.searchParams.getAll("cursor")).toEqual([nextCursor]); + expect(url.searchParams.getAll("field")).toEqual(["id", "name"]); + expect(url.searchParams.getAll("project")).toEqual(["1", "2"]); + expect(paginationRequests).toHaveLength(0); + + const result = await ctx.run(args); + expect(result.exitCode, result.stderr + result.stdout).toBe(0); + expect(paginationRequests.map((sent) => sent.href)).toEqual([url.href]); + expect(JSON.parse(result.stdout).body).toEqual([organizationsFixture[1]]); + }, + ); + + test("rejects --cursor for non-GET requests before sending them", async () => { + await ctx.setAuthToken(TEST_TOKEN); + const result = await ctx.run([ + "api", + "api-pagination/", + "--method", + "POST", + "--cursor", + nextCursor, + ]); + + expect(result.exitCode).toBe(EXIT.VALIDATION); + expect(result.stdout + result.stderr).toMatch(/cursor.*GET|GET.*cursor/i); + expect(paginationRequests).toHaveLength(0); + }); + + test.each([ + { endpoint: "api-pagination/?query=foo%23bar#anchor", query: "foo#bar" }, + { endpoint: "api-pagination/#anchor", query: null }, + ])( + "--cursor ignores URL fragments in $endpoint", + async ({ endpoint, query }) => { + await ctx.setAuthToken(TEST_TOKEN); + const args = ["api", endpoint, "--cursor", nextCursor, "--json"]; + + const preview = await ctx.run([...args, "--dry-run"]); + expect(preview.exitCode, preview.stderr + preview.stdout).toBe(0); + const url = new URL(JSON.parse(preview.stdout).url); + expect(url.hash).toBe(""); + expect(url.searchParams.get("query")).toBe(query); + expect(url.searchParams.getAll("cursor")).toEqual([nextCursor]); + expect(paginationRequests).toHaveLength(0); + + const result = await ctx.run(args); + expect(result.exitCode, result.stderr + result.stdout).toBe(0); + expect(paginationRequests.map((sent) => sent.href)).toEqual([url.href]); + expect(JSON.parse(result.stdout).body).toEqual([organizationsFixture[1]]); + }, + ); + test("empty error bodies report the HTTP status and request", async () => { await ctx.setAuthToken(TEST_TOKEN); @@ -261,8 +798,9 @@ describe("sentry api", () => { expect(result.exitCode).toBe(0); // Verbose output goes to stderr via logger.debug() - // consola formats as: [debug] [api] > GET /api/0/organizations/ - expect(result.stderr).toMatch(/> GET \/api\/0\/organizations\//); + expect(result.stderr).toContain( + `> GET ${mockServer.url}/api/0/organizations/`, + ); expect(result.stderr).toMatch(/< HTTP 200 OK/); expect(result.stderr).toMatch(/< content-type:/i); // stdout should still contain the response body diff --git a/packages/cli/test/e2e/library.test.ts b/packages/cli/test/e2e/library.test.ts index c2a6f5392..3a6f13853 100644 --- a/packages/cli/test/e2e/library.test.ts +++ b/packages/cli/test/e2e/library.test.ts @@ -14,6 +14,7 @@ import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { beforeAll, describe, expect, test } from "vitest"; import { EXIT } from "../../src/lib/errors.js"; +import organizationsFixture from "../fixtures/organizations.json"; import { useTestConfigDir } from "../helpers.js"; import { BUNDLE_INDEX_PATH, @@ -205,6 +206,59 @@ describe("library mode (bundled)", () => { // --- Typed SDK --- + test("sdk.api accepts an opaque cursor and continues with shared navigation", async () => { + const { stdout } = await runNodeScriptOk(` + const { createSentrySDK } = require('./dist/index.cjs'); + const requests = []; + globalThis.fetch = async (input, init) => { + requests.push(new Request(input, init).url); + return Response.json(${JSON.stringify([organizationsFixture[1]])}, { + statusText: 'OK', + headers: { + Link: '; rel="next"; results="true"; cursor="100:2:0", ; rel="previous"; results="true"; cursor="100:0:1"', + }, + }); + }; + const sdk = createSentrySDK({ + token: 'test-sdk-token', + url: 'http://localhost:9000', + cwd: ${JSON.stringify(getConfigDir())}, + }); + const options = { + endpoint: 'organizations/?cursor=old', + rawField: ['field=id', 'field=name'], + }; + sdk.api({ ...options, cursor: 'page-token+/=:1' }).then(async response => { + const next = await sdk.api({ ...options, cursor: 'next' }); + console.log(JSON.stringify({ requests, response, next })); + }).catch(error => { + console.error(error); + process.exitCode = 1; + }); + `); + + const result = JSON.parse(stdout.trim()); + expect(result.requests).toHaveLength(2); + const request = new URL(result.requests[0]); + expect(request.searchParams.getAll("cursor")).toEqual(["page-token+/=:1"]); + expect(request.searchParams.getAll("field")).toEqual(["id", "name"]); + expect(result.response).toEqual({ + status: 200, + statusText: "OK", + body: [organizationsFixture[1]], + pagination: { + nextCursor: "100:2:0", + prevCursor: "100:0:1", + hasPrev: false, + }, + }); + const nextRequest = new URL(result.requests[1]); + expect(nextRequest.searchParams.getAll("cursor")).toEqual(["100:2:0"]); + expect(nextRequest.searchParams.get("per_page")).toBe("100"); + expect(nextRequest.searchParams.getAll("field")).toEqual(["id", "name"]); + expect(result.next.pagination.hasPrev).toBe(true); + }); + test("sourcemap upload rejects a split token without leaking it or fetching", async () => { const directory = getConfigDir(); await writeFile( diff --git a/packages/cli/test/lib/api/infrastructure.test.ts b/packages/cli/test/lib/api/infrastructure.test.ts index 4b070e74d..57d8d0f0c 100644 --- a/packages/cli/test/lib/api/infrastructure.test.ts +++ b/packages/cli/test/lib/api/infrastructure.test.ts @@ -738,12 +738,12 @@ describe("rawApiRequest binary handling", () => { host: "https://sentry.example.com", }); globalThis.fetch = mockFetch(async (input, init) => { - expect(input).toBe( + const request = new Request(input, init); + expect(request.url).toBe( "https://sentry.example.com/sentry/api/0/organizations/acme/?cursor=a%3Ab", ); - expect(new Headers(init?.headers).get("Authorization")).toBe( - "Bearer valid-token", - ); + expect(request.headers.get("Authorization")).toBe("Bearer valid-token"); + expect(request.headers.get("Content-Type")).toBe("application/custom"); return new Response("{}", { headers: { "content-type": "application/json" }, }); @@ -752,6 +752,7 @@ describe("rawApiRequest binary handling", () => { await rawApiRequest("/organizations/acme/", { baseUrl: "https://sentry.example.com/sentry", params: { cursor: "a:b" }, + headers: { "content-type": "application/custom" }, }); }); diff --git a/packages/cli/test/lib/paginate.property.test.ts b/packages/cli/test/lib/paginate.property.test.ts index b5e74a3c5..c522da139 100644 --- a/packages/cli/test/lib/paginate.property.test.ts +++ b/packages/cli/test/lib/paginate.property.test.ts @@ -20,6 +20,8 @@ import { import { describe, expect, test } from "vitest"; import { API_MAX_PER_PAGE, + consumePage, + decodePageCursor, type PaginatedResponse, paginate, } from "../../src/lib/api/infrastructure.js"; @@ -120,6 +122,66 @@ const limitsArb = array(integer({ min: 1, max: API_MAX_PER_PAGE * 4 }), { maxLength: 4, }); +test("property: a one-page reader carries a bookmark across smaller server pages", async () => { + await fcAssert( + asyncProperty( + integer({ min: 1, max: API_MAX_PER_PAGE - 1 }), + nat(API_MAX_PER_PAGE * 2), + async (skip, remaining) => { + const total = skip + remaining; + const fetchPage = fixedPageServer(total); + const seen: number[] = []; + let cursor: string | undefined = `cli1~100~${skip}~`; + for (let call = 0; call <= total; call += 1) { + const position = decodePageCursor(cursor); + const result = consumePage( + await fetchPage(position.perPage ?? 100, position.cursor), + position, + ); + seen.push(...result.data); + cursor = result.nextCursor; + if (!cursor) { + break; + } + } + expect(cursor).toBeUndefined(); + expect(seen).toEqual( + Array.from({ length: remaining }, (_, index) => skip + index), + ); + }, + ), + { numRuns: DEFAULT_NUM_RUNS }, + ); +}); + +test("property: a legacy bookmark keeps its skip across opaque server cursors", async () => { + await fcAssert( + asyncProperty( + integer({ min: 1, max: 20 }), + integer({ min: 1, max: 5 }), + integer({ min: 1, max: 20 }), + async (pageSize, skippedPages, limit) => { + const skip = pageSize * skippedPages + 1; + const result = await paginate( + { cursor: `cli1~${pageSize}~${skip}~page-0`, limit }, + (perPage, cursor) => { + expect(perPage).toBe(pageSize); + const offset = Number(cursor?.slice("page-".length)); + return Promise.resolve({ + data: Array.from({ length: pageSize }, (_, i) => offset + i), + nextCursor: `page-${offset + pageSize}`, + }); + }, + ); + expect(result.data).toEqual( + Array.from({ length: limit }, (_, i) => skip + i), + ); + }, + ), + { numRuns: DEFAULT_NUM_RUNS }, + ); +}); + describe.each(paginators)("property: paginate over $name", ({ make }) => { test("fills the limit on a fresh walk", async () => { await fcAssert( diff --git a/packages/toolkit-core/README.md b/packages/toolkit-core/README.md index 840a605f5..6280212bc 100644 --- a/packages/toolkit-core/README.md +++ b/packages/toolkit-core/README.md @@ -85,6 +85,15 @@ the page cap, receives warnings, and turns failures into its own errors. The CLI's `paginate` and `fetchAllPages` in `packages/cli/src/lib/api/infrastructure.ts` are thin wrappers over it. +`paginate` delegates traversal and partial-page consumption to `@sentry/api`. +Toolkit retains its page sizing and cursor encoding so existing bookmarks and +navigation history keep working. The SDK receives decoded server cursors and +row offsets, without depending on Toolkit's bookmark format or storage. + +`decodePageCursor` and `consumePage` expose the same resume logic for callers +that read one server page at a time, such as `sentry api`. They restore the +cursor's page size and remove rows already returned without fetching more pages. + ## Response cache `api-client/response-cache` holds the cache policy for Sentry API GET responses. diff --git a/packages/toolkit-core/package.json b/packages/toolkit-core/package.json index 1e2bc943f..063aece6a 100644 --- a/packages/toolkit-core/package.json +++ b/packages/toolkit-core/package.json @@ -78,6 +78,7 @@ "tsc": "tsc --noEmit && tsc -p tsconfig.browser.json" }, "dependencies": { + "@sentry/api": "catalog:", "@sentry/search-syntax": "catalog:", "@standard-schema/spec": "1.1.0", "http-cache-semantics": "^4.2.0" diff --git a/packages/toolkit-core/src/api-client/pagination.ts b/packages/toolkit-core/src/api-client/pagination.ts index 604ed68cc..14d1a43d6 100644 --- a/packages/toolkit-core/src/api-client/pagination.ts +++ b/packages/toolkit-core/src/api-client/pagination.ts @@ -4,6 +4,8 @@ * fetches pages, validates rows, logs, and maps failures to its own errors. */ +import { consumePage as consumeSdkPage, paginateUpTo } from "@sentry/api"; + /** * Sentry API's maximum items per page. Some endpoints silently cap larger * `per_page` values; others (org project list) return 400. @@ -40,9 +42,12 @@ const CLI_CURSOR_PREFIX = "cli1~"; * page, the page size it must be fetched with, and how many of that page's * rows were already returned. */ -type PagePosition = { +export type PagePosition = { + /** Server cursor to request; undefined starts at the first page. */ cursor: string | undefined; - perPage: number; + /** Required page size in rows, when encoded in the cursor; otherwise unspecified. */ + perPage?: number; + /** Number of rows already returned from this position; zero starts at its first row. */ skip: number; }; @@ -92,12 +97,12 @@ function encodePageCursor(cursor: string | undefined, skip: number): string { * A damaged `cli1~` cursor is ignored with a warning and the walk starts from * the first page, so it never fails a call or reaches the API. */ -function decodePageCursor( +export function decodePageCursor( raw: string | undefined, - warn: Warn, -): Partial & { cursor: string | undefined } { + warn: Warn = () => {}, +): PagePosition { if (!raw) { - return { cursor: undefined }; + return { cursor: undefined, skip: 0 }; } if (raw.startsWith(CLI_CURSOR_PREFIX)) { return decodeCliCursor(raw, warn); @@ -112,10 +117,7 @@ function decodePageCursor( return perPage === undefined ? position : { ...position, perPage }; } -function decodeCliCursor( - raw: string, - warn: Warn, -): Partial & { cursor: string | undefined } { +function decodeCliCursor(raw: string, warn: Warn): PagePosition { const [perPage, skip, ...server] = raw .slice(CLI_CURSOR_PREFIX.length) .split("~"); @@ -136,7 +138,38 @@ function decodeCliCursor( warn( "Ignoring an unreadable pagination cursor; starting from the first page.", ); - return { cursor: undefined }; + return { cursor: undefined, skip: 0 }; +} + +/** + * Consume one fetched page from a decoded cursor position without making requests. + * + * Removes rows already delivered by a previous call. Sentry next-page cursors + * record any unconsumed rows or remaining skip; other cursor shapes are returned + * unchanged and may replay rows on resume. + * + * @param page - Server page fetched using the position's cursor and page size + * @param position - Decoded continuation for this page + * @param limit - Maximum rows to return, a non-negative integer; omitted means all remaining rows + * @returns Remaining rows and continuation metadata + */ +export function consumePage( + page: Page, + position: PagePosition, + limit = Number.POSITIVE_INFINITY, +): Page { + const consumed = consumeSdkPage( + { data: page.data, nextCursor: page.nextCursor || undefined }, + { cursor: position.cursor, offset: position.skip }, + limit, + ); + return { + ...page, + data: consumed.data, + nextCursor: consumed.position + ? encodePageCursor(consumed.position.cursor, consumed.position.offset) + : undefined, + }; } /** @@ -221,42 +254,36 @@ export async function paginate( ) => Promise>, ): Promise> { const { limit, maxPages, warn = () => {} } = options; - const start = decodePageCursor(options.cursor, warn); - let cursor = start.cursor; - let skip = start.skip ?? 0; - // Cursors without a page size accept any, so fetch the skipped rows too. - const perPage = start.perPage ?? pageSizeForLimit(limit + skip); - const rows: T[] = []; - - for (let page = 0; page < maxPages; page += 1) { - const { data, nextCursor } = await fetchPage(perPage, cursor); - const unseen = data.slice(skip); - const remaining = limit - rows.length; - - if (unseen.length > remaining) { - rows.push(...unseen.slice(0, remaining)); - return { - data: rows, - nextCursor: encodePageCursor(cursor, skip + remaining), - stoppedAtPageCap: false, - }; - } - rows.push(...unseen); - if (!nextCursor) { - return { data: rows, stoppedAtPageCap: false }; - } - cursor = nextCursor; - // A resumed walk can fetch pages smaller than the rows it must skip. - skip = Math.max(0, skip - data.length); - if (rows.length === limit) { - break; - } - } - + const position = decodePageCursor(options.cursor, warn); + // Keep the CLI's page sizing and legacy bookmarks while the SDK owns traversal. + const perPage = position.perPage ?? pageSizeForLimit(limit + position.skip); + const result = await paginateUpTo( + async (cursor) => { + const page = await fetchPage(perPage, cursor); + return { data: page.data, nextCursor: page.nextCursor || undefined }; + }, + { + limit, + maxPages, + resume: { + cursor: position.cursor, + offset: position.skip, + requestBudget: limit + position.skip, + }, + }, + "paginate", + ); return { - data: rows, - nextCursor: encodePageCursor(cursor, skip), - stoppedAtPageCap: rows.length < limit, + data: result.data, + ...(result.continuation + ? { + nextCursor: encodePageCursor( + result.continuation.cursor, + result.continuation.offset, + ), + } + : {}), + stoppedAtPageCap: result.stopReason === "maxPages", }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f79e03b94..6232ba520 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,8 +40,8 @@ catalogs: specifier: ^1.2.3 version: 1.2.3 '@sentry/api': - specifier: 0.425.0 - version: 0.425.0 + specifier: 0.428.0 + version: 0.428.0 '@sentry/cloudflare': specifier: 11.6.0 version: 11.6.0 @@ -467,7 +467,7 @@ importers: version: 1.46.0(ai@6.0.230(zod@4.6.7))(express@5.3.0)(zod@4.6.7) '@sentry/api': specifier: 'catalog:' - version: 0.425.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) + version: 0.428.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) '@sentry/core': specifier: 'catalog:' version: 11.6.0 @@ -804,7 +804,7 @@ importers: version: 2.3.0 '@sentry/api': specifier: 'catalog:' - version: 0.425.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) + version: 0.428.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) '@sentry/core': specifier: 'catalog:' version: 11.6.0 @@ -1021,6 +1021,9 @@ importers: packages/toolkit-core: dependencies: + '@sentry/api': + specifier: 'catalog:' + version: 0.428.0(typescript@5.8.3)(valibot@1.4.2(typescript@5.8.3))(zod@4.6.7) '@sentry/search-syntax': specifier: 'catalog:' version: 1.0.0 @@ -4335,8 +4338,8 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} - '@sentry/api@0.425.0': - resolution: {integrity: sha512-XOiZ+//CPEpImDB8tgixVh7rbNwFwN0kxkbQbFNzf62LTSt62S3Oil3TdkmCwYEfilC/rgxeEqz48bigB7mnow==} + '@sentry/api@0.428.0': + resolution: {integrity: sha512-vtxuZYROc+fKemDNnxL0LawbZZ9UgsN0+UwLJxEtsqxK54L4nBnx/cDItiZvLnafUTvON5bMYhtthALMCddd8A==} engines: {node: '>=22'} peerDependencies: typescript: '*' @@ -12533,7 +12536,13 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} - '@sentry/api@0.425.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7)': + '@sentry/api@0.428.0(typescript@5.8.3)(valibot@1.4.2(typescript@5.8.3))(zod@4.6.7)': + optionalDependencies: + typescript: 5.8.3 + valibot: 1.4.2(typescript@5.8.3) + zod: 4.6.7 + + '@sentry/api@0.428.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7)': optionalDependencies: typescript: 5.9.3 valibot: 1.4.2(typescript@5.9.3) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 698e7d363..355aeaec3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -51,7 +51,7 @@ catalog: "@modelcontextprotocol/server": 2.3.0 "@radix-ui/react-accordion": ^1.2.20 "@radix-ui/react-slot": ^1.2.3 - "@sentry/api": 0.425.0 + "@sentry/api": 0.428.0 "@sentry/cloudflare": 11.6.0 "@sentry/core": 11.6.0 "@sentry/node": 11.6.0 @@ -163,6 +163,6 @@ minimumReleaseAgeExclude: - "@oxlint/binding-win32-x64-msvc@1.87.0" - oxfmt@0.72.0 - oxlint@1.87.0 - - "@sentry/api@0.425.0" + - "@sentry/api@0.428.0" - miniflare@5.20261019.1-beta - wrangler@4.151.3