diff --git a/.drive/deferred.md b/.drive/deferred.md index c71f21dd..c24861f2 100644 --- a/.drive/deferred.md +++ b/.drive/deferred.md @@ -175,3 +175,23 @@ 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..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 @@ -39,6 +39,14 @@ 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; + /** When set, GET /v1/projects reports hasMore but returns no nextCursor — more pages that cannot be fetched. */ + projectsCursorMissing?: boolean; } const newFakeState = (overrides: Partial = {}): FakeState => ({ @@ -78,8 +86,32 @@ 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 } }), + ); + } + 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( - 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 +309,84 @@ 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'); + }); + + 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/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..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'), @@ -63,3 +69,35 @@ 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; + if (!page.pagination.hasMore) return; + const next = page.pagination.nextCursor; + if (next === null) { + throw brokenPaginationError(description, 'reported more pages but returned no cursor'); + } + 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..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 @@ -31,6 +31,14 @@ 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; + /** 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. */ @@ -42,8 +50,23 @@ 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 } + : 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: rows, pagination: { nextCursor: null, hasMore: false } }, + data: { data, pagination }, error: undefined, response: new Response(null, { status: 200 }), }; @@ -432,4 +455,60 @@ 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/); + }); + + 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/); + }); + }); }); 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; } /**