Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .drive/deferred.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {}): FakeState => ({
Expand Down Expand Up @@ -78,8 +86,32 @@ const fakeClient = (state: FakeState): ManagementApiClient => {
init: { params?: { path?: Record<string, string>; query?: Record<string, string> } } = {},
) => {
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') {
Expand Down Expand Up @@ -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', () => {
Expand Down
21 changes: 8 additions & 13 deletions packages/1-prisma-cloud/0-lowering/lowering/src/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -39,18 +39,13 @@ interface ProjectSummary {
const listAllProjects = (
client: ManagementApiClient,
): Effect.Effect<readonly ProjectSummary[], PrismaApiError> =>
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
48 changes: 43 additions & 5 deletions packages/1-prisma-cloud/0-lowering/lowering/src/pagination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,11 @@ export const collectPages = <T>(

/**
* 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 = <T>(
description: string,
Expand All @@ -53,8 +54,13 @@ export const drivePages = <T>(
}
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'),
Expand All @@ -63,3 +69,35 @@ export const drivePages = <T>(
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<T>(
description: string,
fetchPage: (cursor: string | undefined) => Promise<Page<T>>,
onPage: (data: readonly T[]) => boolean,
): Promise<void> {
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');
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
cursor = next;
}
}
24 changes: 8 additions & 16 deletions packages/1-prisma-cloud/0-lowering/lowering/src/state/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -74,22 +75,13 @@ const listAllConnections = (
client: ManagementApiClient,
databaseId: string,
): Effect.Effect<readonly ConnectionSummary[], PrismaApiError> =>
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 } },
}),
),
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const deleteConnection = (
client: ManagementApiClient,
Expand Down
Loading
Loading