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
4 changes: 4 additions & 0 deletions docs/guides/deploying.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ removes the resources but the production Branch itself always survives.
Destroy never creates: tearing down a stage that was never deployed fails
with "nothing deployed" rather than provisioning one first.

Destroying production also removes the app's Project once nothing is left in
it, so hand-run stacks don't pile up as empty Projects in your workspace. If
the Project still holds another stage's resources, it's left in place.

## CI

Nothing is CI-specific — set the two variables as CI secrets, build, run the
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { describe, expect, test } from 'bun:test';
import { describe, expect, spyOn, test } from 'bun:test';
import type { ManagementApiClient } from '@internal/lowering';
import { CliError } from '../cli-error.ts';
import { deleteStageBranch, ensureContainers, validateStageName } from '../ensure-containers.ts';
import {
deleteAppProject,
deleteStageBranch,
ensureContainers,
validateStageName,
} from '../ensure-containers.ts';

interface FakeProject {
id: string;
Expand All @@ -24,6 +29,9 @@ interface FakeState {
deleteBranchCalls: string[];
/** Overrides the DELETE response status — defaults to a 204 success. */
deleteBranchResponseStatus?: number;
deleteProjectCalls: string[];
/** Overrides the DELETE response status — defaults to a 204 success. */
deleteProjectResponseStatus?: number;
}

const newFakeState = (overrides: Partial<FakeState> = {}): FakeState => ({
Expand All @@ -32,6 +40,7 @@ const newFakeState = (overrides: Partial<FakeState> = {}): FakeState => ({
projectCreateCalls: 0,
branchCreateCalls: 0,
deleteBranchCalls: [],
deleteProjectCalls: [],
...overrides,
});

Expand Down Expand Up @@ -117,6 +126,16 @@ const fakeClient = (state: FakeState): ManagementApiClient => {
: { data: undefined, error: undefined, response: new Response(null, { status }) },
);
}
if (path === '/v1/projects/{id}') {
const id = init.params?.path?.['id'] ?? '';
state.deleteProjectCalls.push(id);
const status = state.deleteProjectResponseStatus ?? 204;
return Promise.resolve(
status >= 400
? errorResponse(status)
: { data: undefined, error: undefined, response: new Response(null, { status }) },
);
}
throw new Error(`fakeClient: unexpected DELETE ${path}`);
};

Expand Down Expand Up @@ -327,3 +346,71 @@ describe('deleteStageBranch()', () => {
expect((error as CliError).message).toContain('Failed to delete the stage Branch');
});
});

describe('deleteAppProject()', () => {
test('missing PRISMA_SERVICE_TOKEN (no injected client) logs a warning and does not throw', async () => {
const warnSpy = spyOn(console, 'warn').mockImplementation(() => {});
try {
await expect(deleteAppProject({ projectId: 'proj-1', env: {} })).resolves.toBeUndefined();
expect(warnSpy.mock.calls.join(' ')).toContain('PRISMA_SERVICE_TOKEN');
} finally {
warnSpy.mockRestore();
}
});

test('with an injected client, calls DELETE with the projectId', async () => {
const state = newFakeState();

await deleteAppProject({ projectId: 'proj-1' }, { client: fakeClient(state) });

expect(state.deleteProjectCalls).toEqual(['proj-1']);
});

test('success removes the project (logged), does not throw', async () => {
const state = newFakeState();
const logSpy = spyOn(console, 'log').mockImplementation(() => {});
try {
await expect(
deleteAppProject({ projectId: 'proj-1' }, { client: fakeClient(state) }),
).resolves.toBeUndefined();
expect(logSpy.mock.calls.join(' ')).toContain('Removed the Project');
} finally {
logSpy.mockRestore();
}
});

test('tolerates a 404 (already gone) without throwing', async () => {
const state = newFakeState({ deleteProjectResponseStatus: 404 });

await expect(
deleteAppProject({ projectId: 'proj-1' }, { client: fakeClient(state) }),
).resolves.toBeUndefined();
expect(state.deleteProjectCalls).toEqual(['proj-1']);
});

test("a 400 (still has another stage's resources) keeps the project and does not throw", async () => {
const state = newFakeState({ deleteProjectResponseStatus: 400 });
const logSpy = spyOn(console, 'log').mockImplementation(() => {});
try {
await expect(
deleteAppProject({ projectId: 'proj-1' }, { client: fakeClient(state) }),
).resolves.toBeUndefined();
expect(logSpy.mock.calls.join(' ')).toContain('Kept the Project');
} finally {
logSpy.mockRestore();
}
});

test('any other API error logs a warning and does not throw', async () => {
const state = newFakeState({ deleteProjectResponseStatus: 500 });
const warnSpy = spyOn(console, 'warn').mockImplementation(() => {});
try {
await expect(
deleteAppProject({ projectId: 'proj-1' }, { client: fakeClient(state) }),
).resolves.toBeUndefined();
expect(warnSpy.mock.calls.join(' ')).toContain('Could not remove the Project');
} finally {
warnSpy.mockRestore();
}
});
});
84 changes: 84 additions & 0 deletions packages/0-framework/3-tooling/cli/src/__tests__/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,7 @@ describe('run() — the full pipeline over fakes', () => {
runAssembler: fakeAssembler,
ensureContainers: fakeEnsureContainers,
alchemy: () => 0,
deleteProject: async () => {},
});

expect(status).toBe(0);
Expand All @@ -458,6 +459,7 @@ describe('run() — the full pipeline over fakes', () => {
runAssembler: fakeAssembler,
ensureContainers: fakeEnsureContainers,
alchemy: () => 0,
deleteProject: async () => {},
});

const printed = warnSpy.mock.calls.map((args) => args.join(' ')).join('\n');
Expand All @@ -479,6 +481,7 @@ describe('run() — the full pipeline over fakes', () => {
runAssembler: fakeAssembler,
ensureContainers: fakeEnsureContainers,
alchemy: () => 0,
deleteProject: async () => {},
});

expect(warnSpy).not.toHaveBeenCalled();
Expand Down Expand Up @@ -549,6 +552,7 @@ describe('run() — the full pipeline over fakes', () => {
alchemyCalls.push(input);
return 0;
},
deleteProject: async () => {},
});

expect(status).toBe(0);
Expand Down Expand Up @@ -622,6 +626,7 @@ describe('run() — the full pipeline over fakes', () => {
deleteBranch: async (input) => {
deleteCalls.push(input);
},
deleteProject: async () => {},
});

expect(status).toBe(0);
Expand Down Expand Up @@ -666,4 +671,83 @@ describe('run() — the full pipeline over fakes', () => {
expect(deleteCalls).toEqual([]);
});
});

describe('post-destroy Project cleanup (--production)', () => {
test('destroy --production, on a successful alchemy destroy, deletes the resolved Project', async () => {
const app = makeAppDir();
process.chdir(app.dir);
const deleteCalls: { projectId: string }[] = [];

const status = await run(['destroy', app.entryPath, '--production'], {
config: fakeConfig(),
runAssembler: fakeAssembler,
ensureContainers: fakeEnsureContainers,
alchemy: () => 0,
deleteProject: async (input) => {
deleteCalls.push(input);
},
});

expect(status).toBe(0);
expect(deleteCalls).toEqual([{ projectId: 'proj-fake' }]);
});

test('destroy --stage staging never deletes a Project (only its Branch)', async () => {
const app = makeAppDir();
process.chdir(app.dir);
const deleteCalls: { projectId: string }[] = [];

const status = await run(['destroy', app.entryPath, '--stage', 'staging'], {
config: fakeConfig(),
runAssembler: fakeAssembler,
ensureContainers: fakeEnsureContainers,
alchemy: () => 0,
deleteBranch: async () => {},
deleteProject: async (input) => {
deleteCalls.push(input);
},
});

expect(status).toBe(0);
expect(deleteCalls).toEqual([]);
});

test('destroy --production with a FAILED alchemy destroy does not delete the Project', async () => {
const app = makeAppDir();
process.chdir(app.dir);
const deleteCalls: { projectId: string }[] = [];

const status = await run(['destroy', app.entryPath, '--production'], {
config: fakeConfig(),
runAssembler: fakeAssembler,
ensureContainers: fakeEnsureContainers,
alchemy: () => 1,
deleteProject: async (input) => {
deleteCalls.push(input);
},
});

expect(status).toBe(1);
expect(deleteCalls).toEqual([]);
});

test('deploy never deletes a Project', async () => {
const app = makeAppDir();
process.chdir(app.dir);
const deleteCalls: { projectId: string }[] = [];

const status = await run(['deploy', app.entryPath], {
config: fakeConfig(),
runAssembler: fakeAssembler,
ensureContainers: fakeEnsureContainers,
alchemy: () => 0,
deleteProject: async (input) => {
deleteCalls.push(input);
},
});

expect(status).toBe(0);
expect(deleteCalls).toEqual([]);
});
});
});
43 changes: 43 additions & 0 deletions packages/0-framework/3-tooling/cli/src/ensure-containers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { spawnSync } from 'node:child_process';
import {
deleteBranch,
deleteProject,
fromEnv,
type ManagementApiClient,
ManagementClient,
Expand Down Expand Up @@ -117,3 +118,45 @@ export async function deleteStageBranch(
const outcome = await Effect.runPromise(provided);
if (!outcome.ok) throw new CliError(outcome.message);
}

/**
* Best-effort cleanup after a successful `--production` destroy: removes
* the app's Project so hand-run stacks don't accumulate as empty Projects
* (they eventually hit the workspace's plan limit). Unlike `deleteStageBranch`,
* this never throws: the destroy itself already succeeded, and the API's own
* 400 ("still has dependencies") is the only check that matters — failing
* the command over a cleanup step would be worse than leaving a Project shell.
*/
export async function deleteAppProject(
input: { readonly projectId: string; readonly env?: NodeJS.ProcessEnv },
deps?: { readonly client?: ManagementApiClient },
): Promise<void> {
const env = input.env ?? process.env;
if (deps?.client === undefined && (env['PRISMA_SERVICE_TOKEN'] ?? '').length === 0) {
console.warn(
`Skipped removing the Project (${input.projectId}): PRISMA_SERVICE_TOKEN is not set.`,
);
return;
}
const program = deleteProject(input.projectId).pipe(
Effect.map(() => ({ ok: true as const })),
Effect.catchTag('PrismaApiError', (e) => Effect.succeed({ ok: false as const, error: e })),
);
const provided =
deps?.client !== undefined
? program.pipe(Effect.provideService(ManagementClient, deps.client))
: program.pipe(Effect.provide(managementClientLayer().pipe(Layer.provide(fromEnv()))));

const outcome = await Effect.runPromise(provided);
if (outcome.ok) {
console.log(`Removed the Project (${input.projectId}) — nothing was left in it.`);
return;
}
if (outcome.error.status === 400) {
console.log(`Kept the Project (${input.projectId}) — it still has another stage's resources.`);
return;
}
console.warn(
`Could not remove the Project (${input.projectId}) after destroy: ${outcome.error.message}.`,
);
}
4 changes: 4 additions & 0 deletions packages/0-framework/3-tooling/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { ResolvedContainer } from '@internal/lowering';
import { Cli, Command, Option, UsageError } from 'clipanion';
import { CliError } from './cli-error.ts';
import {
deleteAppProject,
deleteStageBranch,
type EnsureContainersInput,
ensureContainers,
Expand Down Expand Up @@ -136,6 +137,7 @@ export interface RunDeps {
readonly ensureContainers?: (input: EnsureContainersInput) => Promise<ResolvedContainer>;
readonly alchemy?: (input: RunAlchemyInput) => number;
readonly deleteBranch?: (input: { branchId: string }) => Promise<void>;
readonly deleteProject?: (input: { projectId: string }) => Promise<void>;
/** Substituted for the c12 evaluation of the discovered config file (discovery itself still runs — the generated stack file needs the real path). */
readonly config?: PrismaAppConfig;
}
Expand Down Expand Up @@ -298,6 +300,8 @@ export async function run(argv: readonly string[], deps: RunDeps = {}): Promise<
}
if (args.command === 'destroy' && branchId !== undefined) {
await (deps.deleteBranch ?? ((input) => deleteStageBranch(input)))({ branchId });
} else if (args.command === 'destroy') {
await (deps.deleteProject ?? ((input) => deleteAppProject(input)))({ projectId });
}
return status;
} catch (error) {
Expand Down
Loading
Loading