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
16 changes: 14 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,13 @@ jobs:
- name: Test
env:
STATE_TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres
run: pnpm test
run: pnpm turbo run test --filter='!@internal/local-target'
# local-target's drift suite talks to the machine-global postgres emulator, the
# same daemon the dev-emulators suite restarts, so it runs alone after the rest.
- name: Test local-target
env:
STATE_TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres
run: pnpm turbo run test --filter=@internal/local-target
- name: Type-only tests (vitest --typecheck)
run: pnpm turbo run test:types
- name: Test scripts (cast-ratchet unit tests)
Expand Down Expand Up @@ -134,7 +140,13 @@ jobs:
if: runner.os != 'Windows'
env:
STATE_TEST_DATABASE_URL: ${{ steps.postgres.outputs.connection-uri }}
run: pnpm test
run: pnpm turbo run test --filter='!@internal/local-target'
# Same daemon-sharing reason as the Linux job: local-target runs alone after the rest.
- name: Test local-target
if: runner.os != 'Windows'
env:
STATE_TEST_DATABASE_URL: ${{ steps.postgres.outputs.connection-uri }}
run: pnpm turbo run test --filter=@internal/local-target
# Local dev/log are not supported on Windows.
- name: Test changed packages on Windows
if: runner.os == 'Windows'
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/e2e-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ on:
permissions:
contents: read

env:
# Every job here creates a fresh per-run Project, and a new Project needs a region.
PRISMA_REGION: us-east-1

# Fixed group (not per-ref): only one real-cloud deploy runs at a time.
# cancel-in-progress stays false so a kill mid-deploy/destroy can't orphan resources.
concurrency:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
ContainerNotFoundError,
deleteBranch,
deleteProject,
type ProjectRegion,
resolveContainer,
} from '../container.ts';
import { PrismaApiError } from '../http.ts';
Expand Down Expand Up @@ -213,7 +214,13 @@ const fakeClient = (state: FakeState): ManagementApiClient => {

const run = (
state: FakeState,
opts: { workspaceId: string; appName: string; stage?: string; ensure?: boolean },
opts: {
workspaceId: string;
appName: string;
stage?: string;
ensure?: boolean;
region?: ProjectRegion;
},
) =>
Effect.runPromise(
resolveContainer(opts).pipe(Effect.provideService(ManagementClient, fakeClient(state))),
Expand All @@ -227,7 +234,11 @@ describe('resolveContainer — Project resolution', () => {
});

test('no matching project creates one, resolving its default Branch id', async () => {
const result = await run(state, { workspaceId: 'ws-1', appName: 'storefront' });
const result = await run(state, {
workspaceId: 'ws-1',
appName: 'storefront',
region: 'us-east-1',
});

expect(result.projectId).toBe('proj-1');
expect(result.defaultBranchId).toBe('br-default-proj-1');
Expand All @@ -236,11 +247,46 @@ describe('resolveContainer — Project resolution', () => {
});

test('project creation opts out of the platform default database', async () => {
await run(state, { workspaceId: 'ws-1', appName: 'storefront' });
await run(state, { workspaceId: 'ws-1', appName: 'storefront', region: 'us-east-1' });

expect(state.projectCreateBodies[0]?.['createDatabase']).toBe(false);
});

test('project creation sends the configured region to the platform', async () => {
await run(state, { workspaceId: 'ws-1', appName: 'storefront', region: 'ap-southeast-1' });

expect(state.projectCreateBodies[0]?.['region']).toBe('ap-southeast-1');
});

test('no region when a new project is needed fails with an actionable error', async () => {
const error: unknown = await run(state, { workspaceId: 'ws-1', appName: 'storefront' }).catch(
(e: unknown) => e,
);

expect(error).toBeInstanceOf(PrismaApiError);
expect((error as PrismaApiError).status).toBe(0);
expect((error as PrismaApiError).message).toContain('"storefront"');
expect((error as PrismaApiError).message).toContain('prismaCloud({ region:');
expect((error as PrismaApiError).message).toContain('PRISMA_REGION');
});

test('an existing project resolves without a region — region is not required for find', async () => {
state.projects.push({
id: 'proj-existing',
name: 'storefront',
createdAt: new Date(1).toISOString(),
workspace: { id: 'ws-1' },
});
state.branches['proj-existing'] = [
{ id: 'br-default', gitName: 'main', isDefault: true, createdAt: new Date(1).toISOString() },
];

const result = await run(state, { workspaceId: 'ws-1', appName: 'storefront' });

expect(result.projectId).toBe('proj-existing');
expect(state.projectCreateCalls).toBe(0);
});

test('adopt-oldest: several projects share the name — the oldest is adopted, none created', async () => {
state.projects.push(
{
Expand Down Expand Up @@ -280,7 +326,11 @@ describe('resolveContainer — Project resolution', () => {
workspace: { id: 'ws-2' },
});

const result = await run(state, { workspaceId: 'ws-1', appName: 'storefront' });
const result = await run(state, {
workspaceId: 'ws-1',
appName: 'storefront',
region: 'us-east-1',
});

expect(result.projectId).toBe('proj-1');
expect(state.projectCreateCalls).toBe(1);
Expand All @@ -294,7 +344,11 @@ describe('resolveContainer — Project resolution', () => {
workspace: { id: 'ws-1' },
});

const result = await run(state, { workspaceId: 'ws-1', appName: 'storefront' });
const result = await run(state, {
workspaceId: 'ws-1',
appName: 'storefront',
region: 'us-east-1',
});

expect(result.projectId).toBe('proj-1');
expect(state.projectCreateCalls).toBe(1);
Expand Down Expand Up @@ -439,17 +493,19 @@ describe('resolveContainer — Project resolution', () => {
});

test('project creation sends the module name as the logical id', async () => {
await run(state, { workspaceId: 'ws-1', appName: 'storefront' });
await run(state, { workspaceId: 'ws-1', appName: 'storefront', region: 'us-east-1' });

expect(state.projectCreateBodies[0]?.['logicalId']).toBe('storefront');
});

test('a 409 on project create surfaces a clear name-conflict error', async () => {
state.projectCreateConflict = true;

const error: unknown = await run(state, { workspaceId: 'ws-1', appName: 'storefront' }).catch(
(e: unknown) => e,
);
const error: unknown = await run(state, {
workspaceId: 'ws-1',
appName: 'storefront',
region: 'us-east-1',
}).catch((e: unknown) => e);

expect(error).toBeInstanceOf(PrismaApiError);
expect((error as PrismaApiError).status).toBe(409);
Expand Down
35 changes: 33 additions & 2 deletions packages/1-prisma-cloud/0-lowering/lowering/src/container.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import type { paths } from '@prisma/management-api-sdk';
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 { collectPages, drivePages } from './pagination.ts';

/** The region ids the Management API accepts for a new Project — the platform's published contract, not a Composer-owned list. */
export type ProjectRegion = NonNullable<
NonNullable<paths['/v1/projects']['post']['requestBody']>['content']['application/json']['region']
>;

export interface ResolveContainerOptions {
/** The workspace to resolve the Project in. */
readonly workspaceId: string;
Expand All @@ -13,6 +19,12 @@ export interface ResolveContainerOptions {
readonly stage?: string;
/** Create the Project/Branch if absent (default `true`). `false` finds only — used by `destroy`. */
readonly ensure?: boolean;
/**
* The region to stamp on the Project at creation time. Required when creating a new Project
* (`ensure: true` and no matching Project found). Omit for find-only (`ensure: false`) or when
* the Project already exists — the platform's stored default region is used then.
*/
readonly region?: ProjectRegion;
}

/** Raised with `ensure: false` when the app's Project (or a named stage's Branch) doesn't exist. */
Expand Down Expand Up @@ -72,6 +84,7 @@ const resolveProject = (
workspaceId: string,
appName: string,
ensure: boolean,
region: ProjectRegion | undefined,
): Effect.Effect<string, PrismaApiError | ContainerNotFoundError> =>
Effect.gen(function* () {
const projects = yield* listAllProjects(client);
Expand All @@ -91,13 +104,25 @@ const resolveProject = (

if (!ensure) return yield* Effect.fail(new ContainerNotFoundError({ appName }));

if (region === undefined) {
return yield* Effect.fail(
new PrismaApiError({
status: 0,
message:
`project "${appName}" does not exist yet and no deploy region is configured. ` +
"Set the region via prismaCloud({ region: '<id>' }) in your config, or set the " +
'PRISMA_REGION environment variable',
}),
);
}

// createDatabase: false — the platform default database is never used
// (composer claims DATABASE_URL with a placeholder at provision), so don't create it. The
// API 403s this for user actors, but deploys authenticate as workspace
// actors (service tokens), which are allowed.
const created = yield* call(() =>
client.POST('/v1/projects', {
body: { name: appName, workspaceId, createDatabase: false, logicalId: appName },
body: { name: appName, workspaceId, createDatabase: false, logicalId: appName, region },
}),
).pipe(
Effect.catch((err) =>
Expand Down Expand Up @@ -218,7 +243,13 @@ export const resolveContainer = (
Effect.gen(function* () {
const client = yield* ManagementClient;
const ensure = opts.ensure ?? true;
const projectId = yield* resolveProject(client, opts.workspaceId, opts.appName, ensure);
const projectId = yield* resolveProject(
client,
opts.workspaceId,
opts.appName,
ensure,
opts.region,
);
if (opts.stage === undefined) {
const defaultBranchId = yield* resolveDefaultBranchId(client, projectId);
return { projectId, defaultBranchId };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ interface FakeState {
projects: FakeProject[];
branches: Record<string, FakeBranch[]>;
projectCreateCalls: number;
projectCreateBodies: Array<Record<string, unknown>>;
branchCreateCalls: number;
deleteBranchCalls: string[];
/** Overrides the DELETE response status — defaults to a 204 success. */
Expand All @@ -38,6 +39,7 @@ const newFakeState = (overrides: Partial<FakeState> = {}): FakeState => ({
projects: [],
branches: {},
projectCreateCalls: 0,
projectCreateBodies: [],
branchCreateCalls: 0,
deleteBranchCalls: [],
deleteProjectCalls: [],
Expand Down Expand Up @@ -85,6 +87,7 @@ const fakeClient = (state: FakeState): ManagementApiClient => {
) => {
if (path === '/v1/projects') {
state.projectCreateCalls++;
state.projectCreateBodies.push(init.body ?? {});
const id = `proj-${state.projectCreateCalls}`;
const project: FakeProject = {
id,
Expand Down Expand Up @@ -188,14 +191,18 @@ describe('containerDescriptor().ensure()', () => {
const state = newFakeState();

await withEnv(baseEnv, async () => {
const descriptor = containerDescriptor({ client: fakeClient(state) });
const descriptor = containerDescriptor({
client: fakeClient(state),
region: () => 'us-east-1',
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const instance = await descriptor.ensure({ appName: 'storefront', stage: 'staging' });

expect(isPrismaCloudContainer(instance)).toBe(true);
expect(instance.projectId).toBe('proj-1');
expect(instance.branchId).toBe('br-proj-1-1');
expect(instance.alchemyStage).toBe('br-proj-1-1');
expect(state.projectCreateCalls).toBe(1);
expect(state.projectCreateBodies[0]?.['region']).toBe('us-east-1');
expect(state.branchCreateCalls).toBe(1);
});
});
Expand All @@ -204,13 +211,17 @@ describe('containerDescriptor().ensure()', () => {
const state = newFakeState();

await withEnv(baseEnv, async () => {
const descriptor = containerDescriptor({ client: fakeClient(state) });
const descriptor = containerDescriptor({
client: fakeClient(state),
region: () => 'us-east-1',
});
const instance = await descriptor.ensure({ appName: 'storefront', stage: undefined });

expect(instance.projectId).toBe('proj-1');
expect(instance.branchId).toBeUndefined();
expect(instance.defaultBranchId).toBe('br-default-proj-1');
expect(instance.alchemyStage).toBe('br-default-proj-1');
expect(state.projectCreateBodies[0]?.['region']).toBe('us-east-1');
expect(state.branchCreateCalls).toBe(0);
});
});
Expand Down
Loading
Loading