From d4498e05760a49e0367155217d8fe0bc926cc3de Mon Sep 17 00:00:00 2001 From: betegon Date: Wed, 28 Oct 2026 12:23:02 +0100 Subject: [PATCH] feat(cli): resume api pagination without repeating arguments --- apps/cli-docs/src/fragments/commands/api.md | 18 +- .../sentry-cli/skills/sentry-cli/SKILL.md | 2 +- .../skills/sentry-cli/references/api.md | 13 +- packages/cli/src/commands/api.ts | 160 ++++++++++++++-- packages/cli/src/lib/db/pagination.ts | 90 +++++++-- packages/cli/src/lib/db/schema.ts | 10 +- packages/cli/test/e2e/api.test.ts | 179 +++++++++++++++--- packages/cli/test/e2e/library.test.ts | 66 ++++++- packages/cli/test/lib/db/pagination.test.ts | 149 ++++++++++++++- packages/cli/test/lib/db/schema.test.ts | 37 ++++ 10 files changed, 648 insertions(+), 76 deletions(-) diff --git a/apps/cli-docs/src/fragments/commands/api.md b/apps/cli-docs/src/fragments/commands/api.md index 4e33faa53..394a0c3dc 100644 --- a/apps/cli-docs/src/fragments/commands/api.md +++ b/apps/cli-docs/src/fragments/commands/api.md @@ -18,8 +18,22 @@ When querying the Events API (`/events/` endpoint), valid dataset values are: `s 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. +and is scoped to the request, authenticated identity, configured host, and working +directory. Keep the same query parameters and headers when navigating explicitly. + +When there is exactly one recent paginated query in that scope, omit the endpoint +and request arguments with `--cursor next`, `prev`, or `first`. The CLI recovers +the original endpoint and filters, including repeated parameters. Detail requests +without pagination do not replace that query. If several paginated queries were +run recently, repeat the endpoint and filters to select one; the CLI does not +choose the most recent query or switch to another when a query reaches its end. + +Only successful GETs save a query. The saved URL expires with its cursor history +after five minutes. Explicit headers (`-H`) and request bodies are not saved; +requests that need them must repeat their endpoint and options. Current configured +headers are used again and must match the saved query's configuration. Output +flags such as `--json`, `--fields`, and `--silent` apply only to the current call. +Request-changing flags require an explicit endpoint, as do raw or custom cursors. 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, diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 2b289e54b..38f0b1bed 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -459,7 +459,7 @@ View, list, and send Sentry events Make an authenticated API request -- `sentry api ` — Make an authenticated API request +- `sentry api []` — Make an authenticated API request → Full flags and examples: `references/api.md` 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 2bbe4fe35..6ec32d11f 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 @@ -11,7 +11,7 @@ requires: Make an authenticated API request -### `sentry api ` +### `sentry api []` Make an authenticated API request @@ -19,7 +19,7 @@ Make an authenticated API request | Argument | Description | |----------|-------------| -| `` | API endpoint relative to /api/0/, or an absolute HTTP(S) URL | +| `[]` | API endpoint relative to /api/0/, or an absolute HTTP(S) URL; omit to resume a recent query | **Flags:** - `-X, --method - The HTTP method for the request - (default: "GET")` @@ -42,11 +42,10 @@ 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 +# Navigate a recent query without repeating its arguments +sentry api organizations/ -f per_page=2 --json +sentry api --cursor next --json +sentry api --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 8832df74f..4531759a5 100644 --- a/packages/cli/src/commands/api.ts +++ b/packages/cli/src/commands/api.ts @@ -6,6 +6,7 @@ */ import { access, readFile } from "node:fs/promises"; +import { resolve } from "node:path"; // 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"; @@ -26,10 +27,12 @@ import { advancePaginationState, buildPaginationContextKey, type CursorDirection, + getPaginationRequests, hasPreviousPage, resolveCursor, } from "../lib/db/pagination.js"; import { + ContextError, OutputError, stringifyUnknown, ValidationError, @@ -38,9 +41,10 @@ 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 { CURSOR_KEYWORDS, LIST_CURSOR_FLAG } from "../lib/list-command.js"; import { logger } from "../lib/logger.js"; import { buildCacheKey } from "../lib/response-cache.js"; +import { getDefaultSdkConfig } from "../lib/sentry-client.js"; import { canRenderKitty, canRenderSixel, @@ -1256,6 +1260,10 @@ type ApiPagination = { prepared: PreparedRawApiRequest; /** Identity-scoped fingerprint of the caller's request, excluding cursor. */ contextKey: string; + /** Cursor-free caller URL, before any bookmark overrides the page size. */ + requestUrl: string; + /** Directory, host and identity scope used to recover this query. */ + requestScope: string; /** Navigation operation to commit after a successful response. */ direction: CursorDirection; /** Original cursor, including any bookmark, saved for backward navigation. */ @@ -1264,6 +1272,82 @@ type ApiPagination = { position: PagePosition; }; +/** Scope resumable API queries to the current identity, configured host and directory. */ +async function getApiRequestScope(cwd: string): Promise { + return await buildCacheKey( + "GET", + getDefaultSdkConfig().baseUrl, + JSON.stringify([getIdentityFingerprint(), resolve(cwd)]), + ); +} + +/** + * Recover one recent query without choosing between concurrent walks. + * Request modifiers require an explicit endpoint; output flags belong to the + * current invocation. Stored URLs are revalidated by the normal request path. + */ +function restoreApiRequest(flags: ApiFlags, scope: string) { + if (!flags.cursor) { + throw new ContextError("API endpoint", "sentry api ", []); + } + if (!CURSOR_KEYWORDS.has(flags.cursor)) { + throw new ValidationError( + "An endpoint is required unless --cursor is next, prev, or first.", + "endpoint", + ); + } + if ( + flags.data !== undefined || + flags.input !== undefined || + flags.field?.length || + flags["raw-field"]?.length || + flags.header?.length + ) { + throw new ValidationError( + "Request options require an endpoint. To resume unchanged, use sentry api --cursor " + + flags.cursor + + ".", + "endpoint", + ); + } + const requests = getPaginationRequests(PAGINATION_KEY, scope); + if (requests.length > 1) { + throw new ValidationError( + "More than one recent API query is saved in this directory. Repeat the endpoint and filters to choose which query to continue.", + "endpoint", + ); + } + const request = requests[0]; + if (!request) { + throw new ValidationError( + "No recent API query is saved in this directory. Run the request with its endpoint and filters first.", + "endpoint", + ); + } + if (!request.url) { + throw new ValidationError( + "The saved API query requires explicit headers or a body. Repeat the endpoint and request options to continue.", + "endpoint", + ); + } + const url = URL.canParse(request.url) ? new URL(request.url) : undefined; + if ( + !url || + (url.protocol !== "https:" && url.protocol !== "http:") || + url.username || + url.password || + url.hash || + !url.pathname.includes(API_PREFIX_WITH_SLASH) || + url.searchParams.has("cursor") + ) { + throw new ValidationError( + "The saved API query is invalid. Repeat the endpoint and filters to continue.", + "endpoint", + ); + } + return { endpoint: request.url, contextKey: request.contextKey }; +} + /** * Resolve CLI navigation and custom cursors while retaining a single request. * Context uses the caller's query before cursor-derived page-size overrides, so @@ -1274,6 +1358,8 @@ async function resolveApiNavigation( endpoint: string, cursorFlag: string | undefined, request: NonNullable[1]>, + scope: string, + expectedContextKey?: string, ): Promise { const queryIndex = endpoint.indexOf("?"); const query = new URLSearchParams( @@ -1289,15 +1375,17 @@ async function resolveApiNavigation( effectiveHeaders.delete("authorization"); effectiveHeaders.delete("sentry-trace"); effectiveHeaders.delete("baggage"); - const identity = JSON.stringify([ - getIdentityFingerprint(), - [...effectiveHeaders], - request.body, - ]); + const identity = JSON.stringify([scope, [...effectiveHeaders], request.body]); const contextKey = buildPaginationContextKey( "api", await buildCacheKey("GET", contextUrl, identity), ); + if (expectedContextKey !== undefined && contextKey !== expectedContextKey) { + throw new ValidationError( + "The saved API query no longer matches the current request configuration. Repeat the endpoint and request options to continue.", + "endpoint", + ); + } const { cursor, direction } = resolveCursor( cursorFlag, PAGINATION_KEY, @@ -1314,6 +1402,8 @@ async function resolveApiNavigation( per_page: position.perPage, }), contextKey, + requestUrl: contextUrl, + requestScope: scope, direction, currentCursor, position, @@ -1602,7 +1692,9 @@ export const apiCommand = buildCommand({ "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" + + "Each invocation fetches one page. With one recent query in the current " + + "directory, omit the endpoint and filters when using next, prev, or first. " + + "If several queries are saved, repeat their endpoint and filters.\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' + @@ -1622,12 +1714,12 @@ export const apiCommand = buildCommand({ 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: "Navigate a recent query without repeating its arguments", + command: [ + "sentry api organizations/ -f per_page=2 --json", + "sentry api --cursor next --json", + "sentry api --cursor prev --json", + ], }, { description: "Get a specific issue", @@ -1691,9 +1783,11 @@ export const apiCommand = buildCommand({ kind: "tuple", parameters: [ { - brief: "API endpoint relative to /api/0/, or an absolute HTTP(S) URL", + brief: + "API endpoint relative to /api/0/, or an absolute HTTP(S) URL; omit to resume a recent query", parse: String, placeholder: "endpoint", + optional: true, }, ], }, @@ -1768,7 +1862,7 @@ export const apiCommand = buildCommand({ n: "dry-run", }, }, - async *func(this: SentryContext, flags: ApiFlags, endpoint: string) { + async *func(this: SentryContext, flags: ApiFlags, endpoint?: string) { const { stdin } = this; if (flags.cursor !== undefined && flags.method !== "GET") { throw new ValidationError( @@ -1777,8 +1871,17 @@ export const apiCommand = buildCommand({ ); } + const scope = + flags.method === "GET" ? await getApiRequestScope(this.cwd) : undefined; + const target = + endpoint === undefined && scope !== undefined + ? restoreApiRequest(flags, scope) + : { endpoint, contextKey: undefined }; + if (target.endpoint === undefined) { + throw new ContextError("API endpoint", "sentry api ", []); + } const { normalizedEndpoint, requestBaseUrl, strippedApiPrefix } = - resolveApiTarget(endpoint); + resolveApiTarget(target.endpoint); tagOrgFromApiPath(normalizedEndpoint); if (strippedApiPrefix) { // Silent auto-fix — not a warning. Users commonly copy/paste URLs @@ -1803,8 +1906,14 @@ export const apiCommand = buildCommand({ baseUrl: requestBaseUrl, }; const navigation = - flags.method === "GET" - ? await resolveApiNavigation(composedEndpoint, flags.cursor, request) + scope !== undefined + ? await resolveApiNavigation( + composedEndpoint, + flags.cursor, + request, + scope, + target.contextKey, + ) : undefined; const prepared = navigation?.prepared ?? @@ -1883,7 +1992,20 @@ export const apiCommand = buildCommand({ navigation.contextKey, navigation.direction, pagination.nextCursor, - navigation.currentCursor ?? "", + { + currentCursor: navigation.currentCursor ?? "", + request: + response.headers.has("link") || + navigation.currentCursor !== undefined + ? { + scope: navigation.requestScope, + url: + body === undefined && !flags.header?.length + ? navigation.requestUrl + : undefined, + } + : undefined, + }, ); pagination.hasPrev = hasPreviousPage( PAGINATION_KEY, diff --git a/packages/cli/src/lib/db/pagination.ts b/packages/cli/src/lib/db/pagination.ts index 0f70515cc..8ec903b98 100644 --- a/packages/cli/src/lib/db/pagination.ts +++ b/packages/cli/src/lib/db/pagination.ts @@ -52,6 +52,48 @@ export type PaginationState = { index: number; }; +/** Request metadata stored with a cursor walk for endpoint-free navigation. */ +export type PaginationRequest = { + /** Opaque identity, host, and working-directory scope. */ + scope: string; + /** Omit when the request cannot be reconstructed without explicit inputs. */ + url?: string; +}; + +/** Remove expired requests together with the cursor state that owns them. */ +function clearExpiredPaginationRequests(commandKey: string, now: number): void { + getDatabase() + .query( + "DELETE FROM pagination_cursors WHERE command_key = ? AND request_scope IS NOT NULL AND expires_at <= ?", + ) + .run(commandKey, now); +} + +/** + * Find up to two live requests in a scope, enough to detect ambiguity. + * Exhausted walks and requests without a reconstructible URL still count: + * dropping either could silently select a different request. + */ +export function getPaginationRequests( + commandKey: string, + scope: string, +): { contextKey: string; url?: string }[] { + const now = Date.now(); + clearExpiredPaginationRequests(commandKey, now); + const rows = getDatabase() + .query( + "SELECT context, request_url FROM pagination_cursors WHERE command_key = ? AND request_scope = ? AND expires_at > ? LIMIT 2", + ) + .all(commandKey, scope, now) as { + context: string; + request_url: string | null; + }[]; + return rows.map((row) => ({ + contextKey: row.context, + url: row.request_url ?? undefined, + })); +} + // --------------------------------------------------------------------------- // Stack read / write // --------------------------------------------------------------------------- @@ -109,14 +151,18 @@ export function getPaginationState( * @param commandKey - Command identifier * @param contextKey - Serialized query context * @param state - The pagination state to persist + * @param request - Request metadata to replace, or undefined to preserve it * @param ttlMs - Time-to-live in milliseconds (default: 5 minutes) */ function savePaginationState( commandKey: string, contextKey: string, state: PaginationState, + request?: PaginationRequest, ttlMs = CURSOR_TTL_MS, ): void { + const now = Date.now(); + clearExpiredPaginationRequests(commandKey, now); const db = getDatabase(); runUpsert( db, @@ -126,7 +172,11 @@ function savePaginationState( context: contextKey, cursor_stack: JSON.stringify(state.stack), page_index: state.index, - expires_at: Date.now() + ttlMs, + expires_at: now + ttlMs, + ...(request && { + request_scope: request.scope, + request_url: request.url ?? null, + }), }, ["command_key", "context"], ); @@ -239,23 +289,28 @@ 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. + * @param options - Current cursor and request metadata to replace atomically; + * omitted fields retain their existing values. */ export function advancePaginationState( commandKey: string, contextKey: string, direction: CursorDirection, nextCursor: string | undefined, - currentCursor?: string, + options: { + /** Exact page-start cursor including bookmarks; "" means the first page. */ + currentCursor?: string; + request?: PaginationRequest; + } = {}, ): void { + const { currentCursor, request } = options; const state = getPaginationState(commandKey, contextKey); if (!state) { // First page ever — initialise the stack const first = currentCursor ?? ""; const stack = nextCursor ? [first, nextCursor] : [first]; - savePaginationState(commandKey, contextKey, { stack, index: 0 }); + savePaginationState(commandKey, contextKey, { stack, index: 0 }, request); return; } @@ -270,10 +325,12 @@ export function advancePaginationState( if (nextCursor) { stack[newIndex + 1] = nextCursor; } - savePaginationState(commandKey, contextKey, { - stack, - index: newIndex, - }); + savePaginationState( + commandKey, + contextKey, + { stack, index: newIndex }, + request, + ); break; } @@ -288,17 +345,22 @@ export function advancePaginationState( stack[newIndex + 1] = nextCursor; } // Truncate beyond newIndex + 2 to keep only the immediate next - savePaginationState(commandKey, contextKey, { - stack: stack.slice(0, nextCursor ? newIndex + 2 : newIndex + 1), - index: newIndex, - }); + savePaginationState( + commandKey, + contextKey, + { + stack: stack.slice(0, nextCursor ? newIndex + 2 : newIndex + 1), + index: newIndex, + }, + request, + ); break; } case "first": { const first = currentCursor ?? ""; const stack = nextCursor ? [first, nextCursor] : [first]; - savePaginationState(commandKey, contextKey, { stack, index: 0 }); + savePaginationState(commandKey, contextKey, { stack, index: 0 }, request); break; } diff --git a/packages/cli/src/lib/db/schema.ts b/packages/cli/src/lib/db/schema.ts index 4b4632acf..16fc2519b 100644 --- a/packages/cli/src/lib/db/schema.ts +++ b/packages/cli/src/lib/db/schema.ts @@ -21,7 +21,7 @@ import type { Database } from "./sqlite.js"; const _require = createRequire(import.meta.url); -export const CURRENT_SCHEMA_VERSION = 20; +export const CURRENT_SCHEMA_VERSION = 21; /** Environment variable to disable auto-repair */ const NO_AUTO_REPAIR_ENV = "SENTRY_CLI_NO_AUTO_REPAIR"; @@ -189,6 +189,8 @@ export const TABLE_SCHEMAS: Record = { cursor_stack: { type: "TEXT", notNull: true }, page_index: { type: "INTEGER", notNull: true, default: "0" }, expires_at: { type: "INTEGER", notNull: true }, + request_scope: { type: "TEXT", addedInVersion: 21 }, + request_url: { type: "TEXT", addedInVersion: 21 }, }, compositePrimaryKey: ["command_key", "context"], }, @@ -1179,6 +1181,12 @@ export function runMigrations(db: Database): void { db.exec(EXPECTED_TABLES.completion_cache as string); } + // Migration 20 -> 21: Keep resumable request metadata on its cursor stack. + if (currentVersion < 21) { + addColumnIfMissing(db, "pagination_cursors", "request_scope", "TEXT"); + addColumnIfMissing(db, "pagination_cursors", "request_url", "TEXT"); + } + if (currentVersion < CURRENT_SCHEMA_VERSION) { db.query("UPDATE schema_version SET version = ?").run( CURRENT_SCHEMA_VERSION, diff --git a/packages/cli/test/e2e/api.test.ts b/packages/cli/test/e2e/api.test.ts index 65ca24e62..e558fbec6 100644 --- a/packages/cli/test/e2e/api.test.ts +++ b/packages/cli/test/e2e/api.test.ts @@ -245,7 +245,7 @@ describe("sentry api", () => { }); }); - test("navigates next, prev and first while preserving filters and --fields", async () => { + test("navigates without repeating the endpoint or query arguments", async () => { await ctx.setAuthToken(TEST_TOKEN); const args = [ "api", @@ -261,14 +261,18 @@ describe("sentry api", () => { "name", ]; - for (const [cursor, start, hasPrev] of [ - ["first", 0, false], - ["next", 2, true], - ["prev", 0, false], - ["next", 2, true], - ["first", 0, false], - ] as const) { - const result = await ctx.run([...args, "-c", cursor]); + for (const [index, [cursor, start, hasPrev]] of ( + [ + ["first", 0, false], + ["next", 2, true], + ["prev", 0, false], + ["next", 2, true], + ["first", 0, false], + ] as const + ).entries()) { + const navigationArgs = + index === 0 ? args : ["api", "--json", "--fields", "name"]; + const result = await ctx.run([...navigationArgs, "-c", cursor]); expect(result.exitCode, result.stderr + result.stdout).toBe(0); const page = JSON.parse(result.stdout); expect(page).toEqual({ @@ -279,6 +283,14 @@ describe("sentry api", () => { .map(({ name }) => ({ name })), pagination: hasPrev ? { prevCursor, hasPrev } : { nextCursor, hasPrev }, }); + if (index === 0) { + const detail = await ctx.run([ + "api", + "organizations/test-org/", + "--json", + ]); + expect(detail.exitCode, detail.stderr + detail.stdout).toBe(0); + } } expect( paginationRequests.map((url) => url.searchParams.getAll("cursor")), @@ -290,25 +302,45 @@ describe("sentry api", () => { } }); + test("restores query parameters supplied as GET data", async () => { + await ctx.setAuthToken(TEST_TOKEN); + const first = await ctx.run([ + "api", + `${mockServer.url}/api/0/api-pagination/?project=1&project=2`, + "-d", + '{"query":"level:error","per_page":2}', + "--json", + ]); + expect(first.exitCode, first.stderr + first.stdout).toBe(0); + + const second = await ctx.run(["api", "-c", "next", "--json"]); + expect(second.exitCode, second.stderr + second.stdout).toBe(0); + expect(JSON.parse(second.stdout).body).toEqual( + cursorOrganizations.slice(2), + ); + expect(paginationRequests).toHaveLength(2); + for (const request of paginationRequests) { + expect(request.searchParams.get("query")).toBe("level:error"); + expect(request.searchParams.get("per_page")).toBe("2"); + expect(request.searchParams.getAll("project")).toEqual(["1", "2"]); + } + }); + test("dry-runs and failed requests do not advance pagination history", async () => { await ctx.setAuthToken(TEST_TOKEN); const request = ["api", "api-pagination/", "-f", "per_page=2"]; const args = [...request, "--json"]; + const nextArgs = ["api", "--cursor", "next", "--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"]); + const missing = await ctx.run(nextArgs); expect(missing.exitCode).toBe(EXIT.VALIDATION); expect(paginationRequests).toHaveLength(0); const first = await ctx.run([...request, "--silent"]); expect(first.exitCode, first.stderr + first.stdout).toBe(0); expect(first.stdout).toBe(""); - const nextPreview = await ctx.run([ - ...args, - "--cursor", - "next", - "--dry-run", - ]); + const nextPreview = await ctx.run([...nextArgs, "--dry-run"]); expect(nextPreview.exitCode, nextPreview.stderr + nextPreview.stdout).toBe( 0, ); @@ -318,10 +350,10 @@ describe("sentry api", () => { expect(paginationRequests).toHaveLength(1); failNextPage = true; - const failed = await ctx.run([...args, "--cursor", "next"]); + const failed = await ctx.run(nextArgs); expect(failed.exitCode).toBe(EXIT.OUTPUT_ERROR); expect(JSON.parse(failed.stdout).status).toBe(403); - const retried = await ctx.run([...args, "--cursor", "next"]); + const retried = await ctx.run(nextArgs); expect(retried.exitCode, retried.stderr + retried.stdout).toBe(0); expect(JSON.parse(retried.stdout).body).toEqual( cursorOrganizations.slice(2), @@ -329,12 +361,12 @@ describe("sentry api", () => { expect( paginationRequests.map((url) => url.searchParams.get("cursor")), ).toEqual([null, nextCursor, nextCursor]); - const exhausted = await ctx.run([...args, "--cursor", "next"]); + const exhausted = await ctx.run(nextArgs); expect(exhausted.exitCode).toBe(EXIT.VALIDATION); expect(paginationRequests).toHaveLength(3); }); - test.each(["query", "host", "identity"])( + test.each(["query", "host", "identity", "headers"])( "does not reuse pagination history after changing the %s", async (change) => { const args = ["api", "api-pagination/", "-f", "per_page=2", "--json"]; @@ -344,14 +376,15 @@ describe("sentry api", () => { SENTRY_CONFIG_DIR: testConfigDir, SENTRY_URL: mockServer.url, SENTRY_CLI_NO_TELEMETRY: "1", + SENTRY_CUSTOM_HEADERS: "X-Test-Query: initial", }; const first = await runCli(args, { env }); expect(first.exitCode, first.stderr + first.stdout).toBe(0); const changed = await runCli( [ - ...args, + ...(change === "query" ? args : ["api", "--json"]), "--cursor", - "next", + change === "headers" ? "first" : "next", ...(change === "query" ? ["-f", "query=other"] : []), ], { @@ -363,12 +396,18 @@ describe("sentry api", () => { : mockServer.url, SENTRY_AUTH_TOKEN: change === "identity" ? alternateToken : TEST_TOKEN, + SENTRY_CUSTOM_HEADERS: + change === "headers" + ? "X-Test-Query: changed" + : env.SENTRY_CUSTOM_HEADERS, }, }, ); expect(changed.exitCode).toBe(EXIT.VALIDATION); expect(paginationRequests).toHaveLength(1); - const resumed = await runCli([...args, "--cursor", "next"], { env }); + const resumed = await runCli(["api", "--cursor", "next", "--json"], { + env, + }); expect(resumed.exitCode, resumed.stderr + resumed.stdout).toBe(0); expect(JSON.parse(resumed.stdout).body).toEqual( cursorOrganizations.slice(2), @@ -377,6 +416,93 @@ describe("sentry api", () => { }, ); + test("requires an explicit query when another saved query is exhausted", async () => { + await ctx.setAuthToken(TEST_TOKEN); + const firstArgs = ["api", "api-pagination/?query=first", "--json"]; + const first = await ctx.run(firstArgs); + expect(first.exitCode, first.stderr + first.stdout).toBe(0); + const exhausted = await ctx.run(["api", "-c", "next", "--json"]); + expect(exhausted.exitCode, exhausted.stderr + exhausted.stdout).toBe(0); + expect(JSON.parse(exhausted.stdout).pagination.nextCursor).toBeUndefined(); + + const other = await ctx.run([ + "api", + "api-pagination/?query=other", + "--json", + ]); + expect(other.exitCode, other.stderr + other.stdout).toBe(0); + const ambiguous = await ctx.run(["api", "-c", "next", "--json"]); + expect(ambiguous.exitCode).toBe(EXIT.VALIDATION); + expect(ambiguous.stderr + ambiguous.stdout).toMatch( + /multiple|more than one|ambiguous/i, + ); + expect(paginationRequests).toHaveLength(3); + + const previous = await ctx.run([...firstArgs, "-c", "prev"]); + expect(previous.exitCode, previous.stderr + previous.stdout).toBe(0); + expect(JSON.parse(previous.stdout).body).toEqual( + cursorOrganizations.slice(0, 2), + ); + expect(paginationRequests.at(-1)?.searchParams.get("query")).toBe("first"); + }); + + test("does not resume past a saved request that used explicit headers", async () => { + await ctx.setAuthToken(TEST_TOKEN); + const protectedRequest = await ctx.run([ + "api", + "api-pagination/?query=protected", + "-H", + "X-Test-Query: private-value", + "--json", + ]); + expect( + protectedRequest.exitCode, + protectedRequest.stderr + protectedRequest.stdout, + ).toBe(0); + const unsupported = await ctx.run(["api", "-c", "next", "--json"]); + expect(unsupported.exitCode).toBe(EXIT.VALIDATION); + expect(unsupported.stderr + unsupported.stdout).toMatch(/endpoint|header/i); + expect(paginationRequests).toHaveLength(1); + + const other = await ctx.run([ + "api", + "api-pagination/?query=other", + "--json", + ]); + expect(other.exitCode, other.stderr + other.stdout).toBe(0); + const ambiguous = await ctx.run(["api", "-c", "next", "--json"]); + expect(ambiguous.exitCode).toBe(EXIT.VALIDATION); + expect(ambiguous.stderr + ambiguous.stdout).toMatch( + /multiple|more than one|ambiguous/i, + ); + expect(paginationRequests).toHaveLength(2); + }); + + test.each([ + { name: "no cursor", args: [] }, + { name: "a mutation without a cursor", args: ["-X", "POST"] }, + { name: "a raw cursor", args: ["-c", nextCursor] }, + { name: "GET data", args: ["-c", "next", "-d", "query=other"] }, + { name: "a typed field", args: ["-c", "next", "-F", "per_page=1"] }, + { name: "a raw field", args: ["-c", "next", "-f", "query=other"] }, + { name: "a header", args: ["-c", "next", "-H", "X-Test: value"] }, + { name: "stdin input", args: ["-c", "next", "--input", "-"] }, + { name: "a mutation", args: ["-c", "next", "-X", "POST"] }, + ])( + "rejects $name without an endpoint before sending another request", + async ({ args }) => { + await ctx.setAuthToken(TEST_TOKEN); + const first = await ctx.run(["api", "api-pagination/", "--json"]); + expect(first.exitCode, first.stderr + first.stdout).toBe(0); + const result = await ctx.run(["api", ...args, "--json"]); + expect(result.exitCode).toBe( + args.includes("-c") ? EXIT.VALIDATION : EXIT.CONTEXT_MISSING, + ); + expect(result.stderr + result.stdout).toMatch(/endpoint|GET/i); + expect(paginationRequests).toHaveLength(1); + }, + ); + test.each([ { format: "shared", envelope: false }, { format: "legacy", envelope: true }, @@ -411,7 +537,12 @@ describe("sentry api", () => { ["next", rows.slice(2), true], ["prev", rows.slice(1, 2), false], ] as const) { - const result = await ctx.run([...args, "--cursor", pageCursor]); + const navigationArgs = pageCursor === cursor ? args : ["api", "--json"]; + const result = await ctx.run([ + ...navigationArgs, + "--cursor", + pageCursor, + ]); expect(result.exitCode, result.stderr + result.stdout).toBe(0); const page = JSON.parse(result.stdout); expect(page.body).toEqual( diff --git a/packages/cli/test/e2e/library.test.ts b/packages/cli/test/e2e/library.test.ts index 076114b81..63e8aa45f 100644 --- a/packages/cli/test/e2e/library.test.ts +++ b/packages/cli/test/e2e/library.test.ts @@ -206,7 +206,7 @@ describe("library mode (bundled)", () => { // --- Typed SDK --- - test("sdk.api accepts an opaque cursor and continues with shared navigation", async () => { + test("sdk.api restores a saved query without repeating its arguments", async () => { const { stdout } = await runNodeScriptOk(` const { createSentrySDK } = require('./dist/index.cjs'); const requests = []; @@ -223,17 +223,27 @@ describe("library mode (bundled)", () => { url: 'http://localhost:9000', cwd: ${JSON.stringify(getConfigDir())}, }); - const endpoint = 'organizations/'; - sdk.api({ endpoint, cursor: 'page-token+/=:1' }).then(async response => { - const next = await sdk.api({ endpoint, cursor: 'next' }); - console.log(JSON.stringify({ requests, response, next })); - }).catch(error => { + const endpoint = 'organizations/?query=sample'; + (async () => { + const missing = await sdk.api({ cursor: 'next' }).catch(error => error.exitCode); + const response = await sdk.api({ endpoint, cursor: 'page-token+/=:1' }); + const otherDirectory = createSentrySDK({ + token: 'test-sdk-token', + url: 'http://localhost:9000', + cwd: ${JSON.stringify(join(getConfigDir(), "another-project"))}, + }); + const isolated = await otherDirectory.api({ cursor: 'next' }).catch(error => error.exitCode); + const next = await sdk.api({ cursor: 'next' }); + console.log(JSON.stringify({ requests, missing, isolated, response, next })); + })().catch(error => { console.error(error); process.exitCode = 1; }); `); const result = JSON.parse(stdout.trim()); + expect(result.missing).toBe(EXIT.VALIDATION); + expect(result.isolated).toBe(EXIT.VALIDATION); expect( result.requests.map((url: string) => new URL(url).searchParams.get("cursor"), @@ -244,6 +254,50 @@ describe("library mode (bundled)", () => { pagination: { nextCursor: "100:2:0", hasPrev: false }, }); expect(result.next.pagination.hasPrev).toBe(true); + expect(new URL(result.requests[1]).searchParams.get("query")).toBe( + "sample", + ); + }); + + test("sdk.api refuses ambiguous queries from concurrent calls", 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[0]])}, { + headers: { + Link: '; rel="next"; results="true"; cursor="100:1:0"', + }, + }); + }; + const sdk = createSentrySDK({ + token: 'test-sdk-token', + url: 'http://localhost:9000', + cwd: ${JSON.stringify(getConfigDir())}, + }); + (async () => { + await Promise.all([ + sdk.api({ endpoint: 'organizations/?query=first' }), + sdk.api({ endpoint: 'organizations/?query=second' }), + ]); + const ambiguous = await sdk.api({ cursor: 'next' }).catch(error => ({ + exitCode: error.exitCode, + message: error.message, + })); + console.log(JSON.stringify({ requests, ambiguous })); + })().catch(error => { + console.error(error); + process.exitCode = 1; + }); + `); + + const result = JSON.parse(stdout.trim()); + expect(result.requests).toHaveLength(2); + expect(result.ambiguous.exitCode).toBe(EXIT.VALIDATION); + expect(result.ambiguous.message).toMatch( + /multiple|more than one|ambiguous/i, + ); }); test("sourcemap upload rejects a split token without leaking it or fetching", async () => { diff --git a/packages/cli/test/lib/db/pagination.test.ts b/packages/cli/test/lib/db/pagination.test.ts index 22274009c..21aede795 100644 --- a/packages/cli/test/lib/db/pagination.test.ts +++ b/packages/cli/test/lib/db/pagination.test.ts @@ -1,13 +1,158 @@ /** - * Unit tests for pagination context key builders. + * Pagination context keys and persisted request metadata. + * Cursor-stack invariants live in pagination.model-based.test.ts. */ -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { clearAuth } from "../../../src/lib/db/auth.js"; +import { getDatabase } from "../../../src/lib/db/index.js"; import { + advancePaginationState, buildMultiTargetContextKey, buildOrgContextKey, buildPaginationContextKey, + getPaginationRequests, + getPaginationState, } from "../../../src/lib/db/pagination.js"; +import { useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("pagination-requests-"); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("pagination request metadata", () => { + const url = + "https://sentry.io/api/0/organizations/example/issues/?query=open"; + + test("stores the request with its page and preserves it through navigation", () => { + const now = vi.spyOn(Date, "now").mockReturnValue(1_000_000); + advancePaginationState("api", "query", "first", "page-2", { + currentCursor: "", + request: { scope: "workspace", url }, + }); + + expect( + getDatabase() + .query( + "SELECT cursor_stack, page_index, expires_at, request_scope, request_url FROM pagination_cursors WHERE command_key = 'api' AND context = 'query'", + ) + .get(), + ).toEqual({ + cursor_stack: JSON.stringify(["", "page-2"]), + page_index: 0, + expires_at: 1_300_000, + request_scope: "workspace", + request_url: url, + }); + + now.mockReturnValue(1_200_000); + advancePaginationState("api", "query", "next", undefined, { + currentCursor: "page-2", + }); + now.mockReturnValue(1_400_000); + expect(getPaginationRequests("api", "workspace")).toEqual([ + { contextKey: "query", url }, + ]); + expect(getPaginationState("api", "query")).toEqual({ + stack: ["", "page-2"], + index: 1, + }); + + advancePaginationState("api", "query", "prev", "page-2", { + currentCursor: "", + }); + advancePaginationState("api", "query", "first", "page-2", { + currentCursor: "", + }); + expect(getPaginationRequests("api", "workspace")).toEqual([ + { contextKey: "query", url }, + ]); + }); + + test("replaces a request with an opaque blocker without retaining its old URL", () => { + advancePaginationState("api", "query", "first", "page-2", { + currentCursor: "", + request: { scope: "workspace", url }, + }); + advancePaginationState("api", "query", "next", undefined, { + currentCursor: "page-2", + request: { scope: "new-workspace" }, + }); + + expect(getPaginationRequests("api", "workspace")).toEqual([]); + expect(getPaginationRequests("api", "new-workspace")).toEqual([ + { contextKey: "query", url: undefined }, + ]); + expect( + getDatabase() + .query("SELECT page_index, request_url FROM pagination_cursors") + .get(), + ).toEqual({ page_index: 1, request_url: null }); + }); + + test("keeps exhausted walks and opaque requests when detecting ambiguity", () => { + advancePaginationState("api", "exhausted", "first", undefined, { + request: { scope: "workspace", url }, + }); + advancePaginationState("api", "opaque", "first", "page-2", { + request: { scope: "workspace" }, + }); + advancePaginationState("api", "another-scope", "first", "page-2", { + request: { scope: "other-workspace", url }, + }); + advancePaginationState("other", "another-command", "first", "next", { + request: { scope: "workspace", url }, + }); + advancePaginationState("api", "legacy", "first", "page-2"); + + expect(getPaginationRequests("api", "workspace")).toEqual( + expect.arrayContaining([ + { contextKey: "exhausted", url }, + { contextKey: "opaque", url: undefined }, + ]), + ); + advancePaginationState("api", "third", "first", "page-2", { + request: { scope: "workspace", url }, + }); + expect(getPaginationRequests("api", "workspace")).toHaveLength(2); + }); + + test.each(["lookup", "write"])( + "removes expired request metadata during %s", + (operation) => { + const now = vi.spyOn(Date, "now").mockReturnValue(1_000_000); + advancePaginationState("api", "expired", "first", "page-2", { + request: { scope: "old-workspace", url }, + }); + now.mockReturnValue(1_300_000); + + if (operation === "lookup") { + expect(getPaginationRequests("api", "workspace")).toEqual([]); + } else { + advancePaginationState("api", "new", "first", "page-2"); + } + + expect( + getDatabase() + .query("SELECT context FROM pagination_cursors WHERE context = ?") + .get("expired"), + ).toBeNull(); + }, + ); + + test("logout removes the saved request together with its cursor stack", async () => { + advancePaginationState("api", "query", "first", "page-2", { + request: { scope: "workspace", url }, + }); + + await clearAuth(); + + expect(getPaginationRequests("api", "workspace")).toEqual([]); + expect(getPaginationState("api", "query")).toBeUndefined(); + }); +}); describe("buildPaginationContextKey", () => { test("builds simple org-scoped key", () => { diff --git a/packages/cli/test/lib/db/schema.test.ts b/packages/cli/test/lib/db/schema.test.ts index d7d42f71c..6f3af8c8b 100644 --- a/packages/cli/test/lib/db/schema.test.ts +++ b/packages/cli/test/lib/db/schema.test.ts @@ -317,6 +317,43 @@ describe("isReadonlyError", () => { }); describe("runMigrations", () => { + test("migration 20→21 preserves cursor stacks without assigning a request", () => { + const db = new Database(join(getTestDir(), "test.db")); + initSchema(db); + db.exec("DROP TABLE pagination_cursors"); + db.exec(generatePreMigrationTableDDL("pagination_cursors")); + const legacyRow = { + command_key: "api", + context: "existing-query", + cursor_stack: JSON.stringify(["", "page-2", "page-3"]), + page_index: 1, + expires_at: Date.now() + 60_000, + }; + db.query( + "INSERT INTO pagination_cursors (command_key, context, cursor_stack, page_index, expires_at) VALUES (?, ?, ?, ?, ?)", + ).run( + legacyRow.command_key, + legacyRow.context, + legacyRow.cursor_stack, + legacyRow.page_index, + legacyRow.expires_at, + ); + db.query("UPDATE schema_version SET version = 20").run(); + + runMigrations(db); + + expect(db.query("SELECT * FROM pagination_cursors").get()).toEqual({ + ...legacyRow, + request_scope: null, + request_url: null, + }); + expect(db.query("SELECT version FROM schema_version").get()).toEqual({ + version: CURRENT_SCHEMA_VERSION, + }); + expect(getSchemaIssues(db)).toEqual([]); + db.close(); + }); + test("no-op when already at current version", () => { const db = new Database(join(getTestDir(), "test.db")); initSchema(db);