From 83081088b3648308199217d26507b06fa5cd1c65 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 4 Aug 2026 16:26:31 +0200 Subject: [PATCH 1/2] fix(prisma-cloud): bound the remaining Management API listing loops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #195 introduced the bounded page driver and converted the loops it touched; three hand-rolled, unbounded loops stayed on the deploy path. listAllProjects (container.ts) and listAllConnections (state/bootstrap.ts) now use collectPages. The env-var visibility search in target's preflight speaks the SDK's Promise {data, error} shape directly, so pagination.ts gains drivePagesAsync — a Promise twin of drivePages sharing the page cap and non-advancing-cursor failure, kept as a sibling loop so the caller's own thrown errors (listFailedError) propagate unwrapped — and preflight's short-circuiting search runs through it. pagination.ts is exported from the lowering barrel so target can import it like every other lowering helper. Fakes for /v1/projects and the env-var listing gained real cursor paging plus stuck- and runaway-cursor modes; both failure paths are pinned per package. Signed-off-by: willbot Signed-off-by: Will Madden --- .drive/deferred.md | 6 ++ .../lowering/src/__tests__/container.test.ts | 84 ++++++++++++++++++- .../0-lowering/lowering/src/container.ts | 21 ++--- .../0-lowering/lowering/src/exports/index.ts | 1 + .../0-lowering/lowering/src/pagination.ts | 29 +++++++ .../lowering/src/state/bootstrap.ts | 24 ++---- .../target/src/__tests__/preflight.test.ts | 65 +++++++++++++- .../1-extensions/target/src/preflight.ts | 38 +++++---- 8 files changed, 222 insertions(+), 46 deletions(-) diff --git a/.drive/deferred.md b/.drive/deferred.md index c71f21dd..b6094384 100644 --- a/.drive/deferred.md +++ b/.drive/deferred.md @@ -175,3 +175,9 @@ registry). What we deliberately didn't do: `@effect/vitest: ">=4.0.0-beta.84 || >=4.0.0"` (hard dep), which is what let npm float to an incompatible beta in the first place. Worth an upstream issue asking alchemy to tighten to the betas it actually works with. + +## Remove the composer-demo CI USER workaround (after TML-3157 ships) +`prisma/composer-demo-composer`'s GitHub Actions workflow pins `USER: composer-demo-ci` to dodge the $USER-scoped deploy state bug fixed in prisma/composer#195. Once a release containing that PR is out and the demo upgrades to it, delete the pin — users should never need to know about it. Origin: TML-3157 close-out, 2026-08-03. + +## Convert the remaining Management API listing loops to drivePages +prisma/composer#195 added a bounded page driver (`packages/1-prisma-cloud/0-lowering/lowering/src/pagination.ts`) and converted the three listing loops that PR touched. Three more hand-rolled, unbounded loops remain on the deploy path: `listAllProjects` (`lowering/src/container.ts`), `listAllConnections` (`lowering/src/state/bootstrap.ts`), and the env-var listing in `target/src/preflight.ts`. Straightforward conversion now the driver exists. Origin: reviewer observation, PR #195 round 8, 2026-08-03. DONE in this PR (`fix/bound-remaining-pagination`): all three converted; preflight got a Promise-based `drivePagesAsync` twin. diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/container.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/container.test.ts index 8578b8dd..90a26030 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/container.test.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/container.test.ts @@ -39,6 +39,12 @@ interface FakeState { deleteProjectCalls: string[]; /** Overrides the DELETE response status — defaults to a 204 success. */ deleteProjectResponseStatus?: number; + /** Page size for GET /v1/projects — unset serves everything in one page. */ + projectsPageSize?: number; + /** When set, GET /v1/projects reports hasMore with a nextCursor equal to the request's cursor — a broken, non-advancing pagination. */ + projectsCursorStuck?: boolean; + /** When set, GET /v1/projects always reports hasMore with an ever-advancing nextCursor — pagination that never ends. */ + projectsCursorRunaway?: boolean; } const newFakeState = (overrides: Partial = {}): FakeState => ({ @@ -78,8 +84,27 @@ const fakeClient = (state: FakeState): ManagementApiClient => { init: { params?: { path?: Record; query?: Record } } = {}, ) => { if (path === '/v1/projects') { + const offset = + init.params?.query?.['cursor'] === undefined ? 0 : Number(init.params.query['cursor']); + const pageSize = state.projectsPageSize ?? state.projects.length; + const data = state.projects.slice(offset, offset + pageSize); + if (state.projectsCursorStuck === true) { + return Promise.resolve( + okResponse({ data, pagination: { nextCursor: String(offset), hasMore: true } }), + ); + } + if (state.projectsCursorRunaway === true) { + return Promise.resolve( + okResponse({ data, pagination: { nextCursor: String(offset + 1), hasMore: true } }), + ); + } + const nextOffset = offset + data.length; + const hasMore = nextOffset < state.projects.length; return Promise.resolve( - okResponse({ data: state.projects, pagination: { nextCursor: null, hasMore: false } }), + okResponse({ + data, + pagination: { nextCursor: hasMore ? String(nextOffset) : null, hasMore }, + }), ); } if (path === '/v1/projects/{projectId}/branches') { @@ -277,6 +302,63 @@ describe('resolveContainer — Project resolution', () => { expect(result.projectId).toBe('proj-existing'); expect(state.projectCreateCalls).toBe(0); }); + + test('a project beyond the first listing page is still found', async () => { + state.projectsPageSize = 1; + state.projects.push( + { + id: 'proj-other', + name: 'other-app', + createdAt: new Date(1).toISOString(), + workspace: { id: 'ws-1' }, + }, + { + id: 'proj-wanted', + name: 'storefront', + createdAt: new Date(2).toISOString(), + workspace: { id: 'ws-1' }, + }, + ); + state.branches['proj-wanted'] = [ + { id: 'br-default', gitName: 'main', isDefault: true, createdAt: new Date(2).toISOString() }, + ]; + + const result = await run(state, { workspaceId: 'ws-1', appName: 'storefront' }); + + expect(result.projectId).toBe('proj-wanted'); + expect(state.projectCreateCalls).toBe(0); + }); + + test('a non-advancing project-listing cursor fails as broken pagination instead of looping', async () => { + state.projectsPageSize = 1; + state.projectsCursorStuck = true; + state.projects.push({ + id: 'proj-1', + name: 'storefront', + createdAt: new Date(1).toISOString(), + workspace: { id: 'ws-1' }, + }); + + const error: unknown = await run(state, { workspaceId: 'ws-1', appName: 'storefront' }).catch( + (e: unknown) => e, + ); + + expect(error).toBeInstanceOf(PrismaApiError); + expect((error as PrismaApiError).message).toContain('pagination appears broken'); + expect((error as PrismaApiError).message).toContain('non-advancing cursor'); + }); + + test('project-listing pagination that never ends fails at the page cap instead of hanging', async () => { + state.projectsPageSize = 1; + state.projectsCursorRunaway = true; + + const error: unknown = await run(state, { workspaceId: 'ws-1', appName: 'storefront' }).catch( + (e: unknown) => e, + ); + + expect(error).toBeInstanceOf(PrismaApiError); + expect((error as PrismaApiError).message).toContain('did not finish within 1000 pages'); + }); }); describe('resolveContainer — Branch resolution', () => { diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/container.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/container.ts index 5fc3f6a7..879ce31f 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/container.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/container.ts @@ -2,7 +2,7 @@ import * as Data from 'effect/Data'; import * as Effect from 'effect/Effect'; import { type ManagementApiClient, ManagementClient } from './client.ts'; import { call, callVoid, PrismaApiError } from './http.ts'; -import { drivePages } from './pagination.ts'; +import { collectPages, drivePages } from './pagination.ts'; export interface ResolveContainerOptions { /** The workspace to resolve the Project in. */ @@ -39,18 +39,13 @@ interface ProjectSummary { const listAllProjects = ( client: ManagementApiClient, ): Effect.Effect => - Effect.gen(function* () { - const projects: ProjectSummary[] = []; - let cursor: string | undefined; - for (;;) { - const query = cursor === undefined ? {} : { cursor }; - const page = yield* call(() => client.GET('/v1/projects', { params: { query } })); - projects.push(...page.data); - if (!page.pagination.hasMore || page.pagination.nextCursor === null) break; - cursor = page.pagination.nextCursor; - } - return projects; - }); + collectPages('projects', (cursor) => + call(() => + client.GET('/v1/projects', { + params: { query: cursor === undefined ? {} : { cursor } }, + }), + ), + ); /** * Workspace ids circulate in two shapes: `wksp_`-prefixed and bare. Compare diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/exports/index.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/exports/index.ts index 91ada535..8d401324 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/exports/index.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/exports/index.ts @@ -11,6 +11,7 @@ export { } from '../client.ts'; export * from '../container.ts'; export * from '../credentials.ts'; +export * from '../pagination.ts'; export * from '../providers.ts'; export * from './buckets.ts'; export * from './compute.ts'; diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/pagination.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/pagination.ts index c7d70e7e..388d03d4 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/pagination.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/pagination.ts @@ -63,3 +63,32 @@ export const drivePages = ( cursor = next; } }); + +/** + * {@link drivePages} for Promise-based callers (e.g. target's preflight, + * which speaks the SDK's `{data, error}` shape directly). Same guard, same + * errors; deliberately a sibling loop rather than a wrapper, because routing + * a Promise fetch through Effect and back (`Effect.tryPromise` + + * `runPromise`) would re-wrap the caller's own thrown errors. `fetchPage` + * rejections propagate untouched. + */ +export async function drivePagesAsync( + description: string, + fetchPage: (cursor: string | undefined) => Promise>, + onPage: (data: readonly T[]) => boolean, +): Promise { + let cursor: string | undefined; + for (let pageCount = 0; ; pageCount++) { + if (pageCount >= MAX_PAGES) { + throw brokenPaginationError(description, `did not finish within ${String(MAX_PAGES)} pages`); + } + const page = await fetchPage(cursor); + if (onPage(page.data)) return; + const next = page.pagination.nextCursor; + if (!page.pagination.hasMore || next === null) return; + if (next === cursor) { + throw brokenPaginationError(description, 'returned a non-advancing cursor'); + } + cursor = next; + } +} diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/bootstrap.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/bootstrap.ts index 2a858190..5366610d 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/bootstrap.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/bootstrap.ts @@ -4,6 +4,7 @@ import postgres from 'postgres'; import { type ManagementApiClient, ManagementClient } from '../client.ts'; import type { ResolvedContainer } from '../container.ts'; import { call, callVoid, PrismaApiError } from '../http.ts'; +import { collectPages } from '../pagination.ts'; import { CONNECTION_NAME_PREFIX, createConnection, @@ -74,22 +75,13 @@ const listAllConnections = ( client: ManagementApiClient, databaseId: string, ): Effect.Effect => - Effect.gen(function* () { - const connections: ConnectionSummary[] = []; - let cursor: string | undefined; - for (;;) { - const query = cursor === undefined ? {} : { cursor }; - const page = yield* call(() => - client.GET('/v1/databases/{databaseId}/connections', { - params: { path: { databaseId }, query }, - }), - ); - connections.push(...page.data); - if (!page.pagination.hasMore || page.pagination.nextCursor === null) break; - cursor = page.pagination.nextCursor; - } - return connections; - }); + collectPages(`connections of database ${databaseId}`, (cursor) => + call(() => + client.GET('/v1/databases/{databaseId}/connections', { + params: { path: { databaseId }, query: cursor === undefined ? {} : { cursor } }, + }), + ), + ); const deleteConnection = ( client: ManagementApiClient, diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight.test.ts index 798ce384..272f5a10 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight.test.ts @@ -31,6 +31,12 @@ interface FakeState { posts: Record[]; rows: Row[]; postStatus: number; + /** Page size for the env-var listing — unset serves everything in one page. */ + pageSize?: number; + /** When set, the listing reports hasMore with a nextCursor equal to the request's cursor — a broken, non-advancing pagination. */ + cursorStuck?: boolean; + /** When set, the listing always reports hasMore with an ever-advancing nextCursor — pagination that never ends. */ + cursorRunaway?: boolean; } /** A stubbed Management API client — test file, exempt from the no-bare-cast rule. */ @@ -42,8 +48,21 @@ const fakeClient = (state: FakeState): ManagementApiClient => const rows = state.rows.filter( (r) => r.projectId === q['projectId'] && r.class === q['class'] && r.key === q['key'], ); + const offset = q['cursor'] === undefined ? 0 : Number(q['cursor']); + const pageSize = state.pageSize ?? rows.length; + const data = rows.slice(offset, offset + pageSize); + const pagination = + state.cursorStuck === true + ? { nextCursor: String(offset), hasMore: true } + : state.cursorRunaway === true + ? { nextCursor: String(offset + 1), hasMore: true } + : { + nextCursor: + offset + data.length < rows.length ? String(offset + data.length) : null, + hasMore: offset + data.length < rows.length, + }; return { - data: { data: rows, pagination: { nextCursor: null, hasMore: false } }, + data: { data, pagination }, error: undefined, response: new Response(null, { status: 200 }), }; @@ -432,4 +451,48 @@ describe('runPreflight — secret manifest verification (ADR-0029)', () => { expect(state.gets).toEqual([]); expect(state.posts).toEqual([]); }); + + describe('env-var listing pagination (bounded — drivePagesAsync)', () => { + test('a visible row beyond the first page still counts as present', async () => { + state.pageSize = 1; + state.rows = [ + { projectId: 'proj', class: 'preview', key: 'STRIPE_SECRET_KEY', branchId: 'br-other' }, + { projectId: 'proj', class: 'preview', key: 'STRIPE_SECRET_KEY', branchId: null }, + ]; + + await runPreflight( + { graph: secretGraph(), container: fakeContainer('proj', 'br-1'), stage: 'pr-1' }, + { client: fakeClient(state) }, + ); + + expect(state.gets).toHaveLength(2); + expect(state.posts).toEqual([]); + }); + + test('a non-advancing cursor fails as broken pagination instead of looping', async () => { + // No matching rows: every page is empty, so the search never + // short-circuits and the stuck cursor is what ends it. + state.pageSize = 1; + state.cursorStuck = true; + + await expect( + runPreflight( + { graph: secretGraph(), container: fakeContainer('proj', undefined), stage: undefined }, + { client: fakeClient(state) }, + ), + ).rejects.toThrow(/pagination appears broken.*possibly incomplete listing/); + }); + + test('pagination that never ends fails at the page cap instead of hanging', async () => { + state.pageSize = 1; + state.cursorRunaway = true; + + await expect( + runPreflight( + { graph: secretGraph(), container: fakeContainer('proj', undefined), stage: undefined }, + { client: fakeClient(state) }, + ), + ).rejects.toThrow(/did not finish within 1000 pages/); + }); + }); }); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts b/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts index 5fcec7de..41fea6d6 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts @@ -17,6 +17,7 @@ import type { Graph } from '@internal/core'; import type { PreflightInput } from '@internal/core/config'; import { blindCast } from '@internal/foundation/casts'; import { + drivePagesAsync, fromEnv, type ManagementApiClient, ManagementClient, @@ -89,21 +90,28 @@ async function existsOnPlatform( // The list is paginated: a key with more preview rows (template + many // per-branch overrides) than one page must be followed to the end, or a - // present name is falsely reported missing. Short-circuit as soon as a - // visible row is seen. - let cursor: string | null = null; - do { - const res = await listEnvVars( - client, - cursor === null ? { projectId, class: cls, key } : { projectId, class: cls, key, cursor }, - ); - if (res.error !== undefined) throw listFailedError(key, res.error); - const page = res.data; - if (page === undefined) return false; - if (page.data.some(visible)) return true; - cursor = page.pagination.hasMore ? page.pagination.nextCursor : null; - } while (cursor !== null); - return false; + // present name is falsely reported missing. Short-circuits as soon as a + // visible row is seen; bounded (drivePagesAsync) so broken pagination + // fails loudly instead of looping. + let found = false; + await drivePagesAsync( + `environment variables named "${key}"`, + async (cursor) => { + const res = await listEnvVars( + client, + cursor === undefined + ? { projectId, class: cls, key } + : { projectId, class: cls, key, cursor }, + ); + if (res.error !== undefined) throw listFailedError(key, res.error); + return res.data ?? { data: [], pagination: { nextCursor: null, hasMore: false } }; + }, + (data) => { + found = data.some(visible); + return found; + }, + ); + return found; } /** From 335ca0be0867b1f740e3e0d10e9ffc3c63288cf1 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 5 Aug 2026 08:17:59 +0200 Subject: [PATCH 2/2] fix(prisma-cloud): fail when a listing reports more pages but no cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both page drivers treated `hasMore: true` with `nextCursor: null` as a clean end, so a listing the API itself says is incomplete came back as if it were complete — the exact outcome the drivers exist to prevent. Both now return only when `hasMore` is false, and raise the shared broken- pagination error when more pages are reported without a cursor to fetch them with. The non-advancing-cursor check, the 1000-page cap, and the early stop when `onPage` returns true are unchanged. The lowering and target fakes gained a matching mode, and each driver has a test pinning the new failure. Without the fix, the target test shows the old behaviour concretely: preflight reported a provisioned secret as missing because it stopped reading after the first page. Also rewraps the two newest `.drive/deferred.md` entries to the file's style — blank line after the heading (markdownlint MD022), prose wrapped at 80 columns. Signed-off-by: willbot Signed-off-by: Will Madden --- .drive/deferred.md | 18 ++++++++++-- .../lowering/src/__tests__/container.test.ts | 28 +++++++++++++++++++ .../0-lowering/lowering/src/pagination.ts | 21 ++++++++++---- .../target/src/__tests__/preflight.test.ts | 26 +++++++++++++---- 4 files changed, 80 insertions(+), 13 deletions(-) diff --git a/.drive/deferred.md b/.drive/deferred.md index b6094384..c24861f2 100644 --- a/.drive/deferred.md +++ b/.drive/deferred.md @@ -177,7 +177,21 @@ registry). What we deliberately didn't do: asking alchemy to tighten to the betas it actually works with. ## Remove the composer-demo CI USER workaround (after TML-3157 ships) -`prisma/composer-demo-composer`'s GitHub Actions workflow pins `USER: composer-demo-ci` to dodge the $USER-scoped deploy state bug fixed in prisma/composer#195. Once a release containing that PR is out and the demo upgrades to it, delete the pin — users should never need to know about it. Origin: TML-3157 close-out, 2026-08-03. + +`prisma/composer-demo-composer`'s GitHub Actions workflow pins +`USER: composer-demo-ci` to dodge the $USER-scoped deploy state bug fixed in +prisma/composer#195. Once a release containing that PR is out and the demo +upgrades to it, delete the pin — users should never need to know about it. +Origin: TML-3157 close-out, 2026-08-03. ## Convert the remaining Management API listing loops to drivePages -prisma/composer#195 added a bounded page driver (`packages/1-prisma-cloud/0-lowering/lowering/src/pagination.ts`) and converted the three listing loops that PR touched. Three more hand-rolled, unbounded loops remain on the deploy path: `listAllProjects` (`lowering/src/container.ts`), `listAllConnections` (`lowering/src/state/bootstrap.ts`), and the env-var listing in `target/src/preflight.ts`. Straightforward conversion now the driver exists. Origin: reviewer observation, PR #195 round 8, 2026-08-03. DONE in this PR (`fix/bound-remaining-pagination`): all three converted; preflight got a Promise-based `drivePagesAsync` twin. + +prisma/composer#195 added a bounded page driver +(`packages/1-prisma-cloud/0-lowering/lowering/src/pagination.ts`) and converted +the three listing loops that PR touched. Three more hand-rolled, unbounded +loops remain on the deploy path: `listAllProjects` (`lowering/src/container.ts`), +`listAllConnections` (`lowering/src/state/bootstrap.ts`), and the env-var +listing in `target/src/preflight.ts`. Straightforward conversion now the driver +exists. Origin: reviewer observation, PR #195 round 8, 2026-08-03. DONE in this +PR (`fix/bound-remaining-pagination`): all three converted; preflight got a +Promise-based `drivePagesAsync` twin. diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/container.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/container.test.ts index 90a26030..1befc1cf 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/container.test.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/container.test.ts @@ -45,6 +45,8 @@ interface FakeState { projectsCursorStuck?: boolean; /** When set, GET /v1/projects always reports hasMore with an ever-advancing nextCursor — pagination that never ends. */ projectsCursorRunaway?: boolean; + /** When set, GET /v1/projects reports hasMore but returns no nextCursor — more pages that cannot be fetched. */ + projectsCursorMissing?: boolean; } const newFakeState = (overrides: Partial = {}): FakeState => ({ @@ -98,6 +100,11 @@ const fakeClient = (state: FakeState): ManagementApiClient => { okResponse({ data, pagination: { nextCursor: String(offset + 1), hasMore: true } }), ); } + if (state.projectsCursorMissing === true) { + return Promise.resolve( + okResponse({ data, pagination: { nextCursor: null, hasMore: true } }), + ); + } const nextOffset = offset + data.length; const hasMore = nextOffset < state.projects.length; return Promise.resolve( @@ -359,6 +366,27 @@ describe('resolveContainer — Project resolution', () => { expect(error).toBeInstanceOf(PrismaApiError); expect((error as PrismaApiError).message).toContain('did not finish within 1000 pages'); }); + + test('a project listing reporting more pages without a cursor fails instead of returning a partial listing', async () => { + state.projectsPageSize = 1; + state.projectsCursorMissing = true; + state.projects.push({ + id: 'proj-1', + name: 'storefront', + createdAt: new Date(1).toISOString(), + workspace: { id: 'ws-1' }, + }); + + const error: unknown = await run(state, { workspaceId: 'ws-1', appName: 'storefront' }).catch( + (e: unknown) => e, + ); + + expect(error).toBeInstanceOf(PrismaApiError); + expect((error as PrismaApiError).message).toContain('pagination appears broken'); + expect((error as PrismaApiError).message).toContain( + 'reported more pages but returned no cursor', + ); + }); }); describe('resolveContainer — Branch resolution', () => { diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/pagination.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/pagination.ts index 388d03d4..bb62a662 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/pagination.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/pagination.ts @@ -33,10 +33,11 @@ export const collectPages = ( /** * Drives a cursor-paginated Management API listing with a guard against - * broken pagination: a cursor that does not advance, or more than - * {@link MAX_PAGES} pages, FAILS instead of hanging forever or returning a - * listing known to be incomplete. `onPage` receives each page's rows as they - * arrive; returning `true` stops early (the caller found what it wanted). + * broken pagination: a cursor that does not advance, more pages reported + * without a cursor to fetch them with, or more than {@link MAX_PAGES} pages, + * FAILS instead of hanging forever or returning a listing known to be + * incomplete. `onPage` receives each page's rows as they arrive; returning + * `true` stops early (the caller found what it wanted). */ export const drivePages = ( description: string, @@ -53,8 +54,13 @@ export const drivePages = ( } const page = yield* fetchPage(cursor); if (onPage(page.data)) return; + if (!page.pagination.hasMore) return; const next = page.pagination.nextCursor; - if (!page.pagination.hasMore || next === null) return; + if (next === null) { + return yield* Effect.fail( + brokenPaginationError(description, 'reported more pages but returned no cursor'), + ); + } if (next === cursor) { return yield* Effect.fail( brokenPaginationError(description, 'returned a non-advancing cursor'), @@ -84,8 +90,11 @@ export async function drivePagesAsync( } const page = await fetchPage(cursor); if (onPage(page.data)) return; + if (!page.pagination.hasMore) return; const next = page.pagination.nextCursor; - if (!page.pagination.hasMore || next === null) return; + if (next === null) { + throw brokenPaginationError(description, 'reported more pages but returned no cursor'); + } if (next === cursor) { throw brokenPaginationError(description, 'returned a non-advancing cursor'); } diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight.test.ts index 272f5a10..b97acc02 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight.test.ts @@ -37,6 +37,8 @@ interface FakeState { cursorStuck?: boolean; /** When set, the listing always reports hasMore with an ever-advancing nextCursor — pagination that never ends. */ cursorRunaway?: boolean; + /** When set, the listing reports hasMore but returns no nextCursor — more pages that cannot be fetched. */ + cursorMissing?: boolean; } /** A stubbed Management API client — test file, exempt from the no-bare-cast rule. */ @@ -56,11 +58,13 @@ const fakeClient = (state: FakeState): ManagementApiClient => ? { nextCursor: String(offset), hasMore: true } : state.cursorRunaway === true ? { nextCursor: String(offset + 1), hasMore: true } - : { - nextCursor: - offset + data.length < rows.length ? String(offset + data.length) : null, - hasMore: offset + data.length < rows.length, - }; + : state.cursorMissing === true + ? { nextCursor: null, hasMore: true } + : { + nextCursor: + offset + data.length < rows.length ? String(offset + data.length) : null, + hasMore: offset + data.length < rows.length, + }; return { data: { data, pagination }, error: undefined, @@ -494,5 +498,17 @@ describe('runPreflight — secret manifest verification (ADR-0029)', () => { ), ).rejects.toThrow(/did not finish within 1000 pages/); }); + + test('more pages reported without a cursor fails instead of accepting a partial listing', async () => { + state.pageSize = 1; + state.cursorMissing = true; + + await expect( + runPreflight( + { graph: secretGraph(), container: fakeContainer('proj', undefined), stage: undefined }, + { client: fakeClient(state) }, + ), + ).rejects.toThrow(/reported more pages but returned no cursor/); + }); }); });