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
18 changes: 18 additions & 0 deletions .changeset/client-environments-delete-purge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@objectstack/client": minor
---

feat(client): `environments.delete` gains `purge` and documents the hosted control plane's two-step delete (#17636)

The hosted control plane's `DELETE /api/v1/cloud/environments/:id` follows cloud ADR-0014: a live environment is **archived**, and only a second call with `?purge=1` on the now-archived environment tears it down. `?force=1` confirms a production environment and is never a purge. The SDK sent `force` only, so an SDK caller could archive an environment but never purge one.

- `opts.purge?: boolean` sends `?purge=1`. It combines with `force`: a production environment is torn down with `{ force: true }`, then `{ force: true, purge: true }`. Calls that pass no options, or `force` alone, build exactly the URL they built before.
- The return type declares the two answers the route actually sends, discriminated by `deleted`:
- archive: `{ environmentId, deleted: false, archived: true, purgeDeferred, retentionDays, warnings, message }`
- teardown: `{ environmentId, deleted: true, purged: true, warnings }`

Both members carry every key the old declaration named (`deleted`, `environmentId`, `warnings`), so existing reads still compile.
- The JSDoc no longer describes a one-call cascade delete: a live environment is archived, `purge` acts only on an archived environment, `force` is the production confirmation, and a `failed` environment is torn down in one call.
- `organizations.delete`'s JSDoc no longer claims that server-side hooks tear down the organization's environments. No hook does; delete each environment first.

Graded `minor`: a purely additive widening of a published method's accepted options and declared answer (the "WHICH LEVEL" rule in `.github/workflows/pr-automation.yml`). Nothing is removed or renamed.
217 changes: 217 additions & 0 deletions packages/client/src/environments-delete-two-step.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `client.environments.delete` under the hosted control plane's two-step
* delete (cloud ADR-0014) — #17636.
*
* ## The producer, measured
*
* `DELETE /api/v1/cloud/environments/:id` is served by `objectstack-ai/cloud`,
* `packages/service-cloud/src/routes/environment-lifecycle.ts`, read at cloud
* `eeac7b22` (cloud#2188). The route reads two query flags, `force` and
* `purge`, independently, and a 200 carries exactly one of two bodies:
*
* archive — a live environment whatever the flags, or an archived one
* without `purge`:
* `{ environmentId, deleted: false, archived: true,
* purgeDeferred, retentionDays, warnings: [], message }`
* teardown — an archived environment with `purge`, or a `failed` one:
* `{ environmentId, deleted: true, purged: true, warnings }`
*
* Every other outcome is a non-2xx error envelope, which this client's
* `fetch` wrapper throws — it never reaches the declared return type.
*
* ## What each pin asserts
*
* - The URL for every combination of the two options. `force` and `purge` are
* independent confirmations and a production teardown needs both on ONE
* call, so a builder that let one option shadow the other would leave that
* teardown unreachable from the SDK.
* - Both 200 bodies relay untouched, key for key.
* - A refusal rejects, carrying the envelope's `code` and the response status.
* - The TYPE pins: the declared answer is the discriminated union of the two
* bodies, no more and no less. They are compiled — `tsconfig.test.json`
* includes `src/**` and the package's `typecheck` script names it — so each
* `@ts-expect-error` is a real check.
*/

import { describe, it, expect, vi } from 'vitest';
import { ObjectStackClient } from './index';

type EnvironmentsNamespace = ObjectStackClient['environments'];
type DeleteAnswer = Awaited<ReturnType<EnvironmentsNamespace['delete']>>;
type DeleteOptions = NonNullable<Parameters<EnvironmentsNamespace['delete']>[1]>;

/**
* The archive answer. `deleted: false` selects it, and each key is declared at
* its wire type: assigning into typed locals is the pin, so a key weakened to
* optional or retyped goes red here rather than in a caller.
*/
export function archiveAnswerDeclaresItsKeys(answer: DeleteAnswer): void {
if (answer.deleted) return;
const environmentId: string = answer.environmentId;
const archived: true = answer.archived;
const purgeDeferred: boolean = answer.purgeDeferred;
const retentionDays: number = answer.retentionDays;
const warnings: string[] = answer.warnings;
const message: string = answer.message;
void [environmentId, archived, purgeDeferred, retentionDays, warnings, message];
// @ts-expect-error an archive tears nothing down; the archive answer carries no `purged`
void answer.purged;
}

/** The teardown answer. `deleted: true` selects it, and it carries none of the archive-only keys. */
export function teardownAnswerDeclaresItsKeys(answer: DeleteAnswer): void {
if (!answer.deleted) return;
const environmentId: string = answer.environmentId;
const purged: true = answer.purged;
const warnings: string[] = answer.warnings;
void [environmentId, purged, warnings];
// @ts-expect-error the teardown answer carries no `archived`
void answer.archived;
// @ts-expect-error the teardown answer carries no `purgeDeferred`
void answer.purgeDeferred;
// @ts-expect-error the teardown answer carries no `retentionDays`
void answer.retentionDays;
// @ts-expect-error the teardown answer carries no `message`
void answer.message;
}

/** The three keys the previous declaration named sit on BOTH members, so a read written against it still compiles. */
export function previouslyDeclaredKeysStayReadable(answer: DeleteAnswer): void {
const deleted: boolean = answer.deleted;
const environmentId: string = answer.environmentId;
const warnings: string[] = answer.warnings;
void [deleted, environmentId, warnings];
}

/** `force` and `purge` are both declared options and may be passed together. */
export function bothOptionsAreDeclared(): void {
const productionTeardown: DeleteOptions = { force: true, purge: true };
void productionTeardown;
}

/** A client whose `fetch` answers one canned response. */
function clientAnswering(status: number, body: unknown) {
const fetchMock = vi.fn().mockResolvedValue({
ok: status >= 200 && status < 300,
status,
statusText: status === 200 ? 'OK' : 'Conflict',
json: async () => body,
headers: new Headers(),
});
const client = new ObjectStackClient({ baseUrl: 'http://localhost:3000', fetch: fetchMock });
return { client, fetchMock };
}

const ARCHIVED = {
environmentId: 'env_1',
deleted: false,
archived: true,
purgeDeferred: false,
retentionDays: 30,
warnings: [],
message: 'Environment archived (soft-deleted).',
};

const TORN_DOWN = {
environmentId: 'env_1',
deleted: true,
purged: true,
warnings: ['attachment storage sweep failed: timeout'],
};

describe('client.environments.delete — the two-step delete (cloud ADR-0014)', () => {
const CASES: Array<{ opts: DeleteOptions | undefined; query: string }> = [
{ opts: undefined, query: '' },
{ opts: {}, query: '' },
{ opts: { force: false, purge: false }, query: '' },
{ opts: { force: true }, query: '?force=1' },
{ opts: { force: true, purge: false }, query: '?force=1' },
{ opts: { purge: true }, query: '?purge=1' },
{ opts: { force: false, purge: true }, query: '?purge=1' },
{ opts: { force: true, purge: true }, query: '?force=1&purge=1' },
];

for (const { opts, query } of CASES) {
const label = opts === undefined ? 'no options' : JSON.stringify(opts);
it(`${label} → DELETE …/environments/env_1${query}`, async () => {
const { client, fetchMock } = clientAnswering(200, { success: true, data: ARCHIVED });

await client.environments.delete('env_1', opts);

expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe(`http://localhost:3000/api/v1/cloud/environments/env_1${query}`);
expect(fetchMock.mock.calls[0][1].method).toBe('DELETE');
});
}

it('encodes the id and keeps both flags after it', async () => {
const { client, fetchMock } = clientAnswering(200, { success: true, data: TORN_DOWN });

await client.environments.delete('env/1 x', { force: true, purge: true });

expect(fetchMock.mock.calls[0][0]).toBe(
'http://localhost:3000/api/v1/cloud/environments/env%2F1%20x?force=1&purge=1',
);
});

it('relays the archive answer key for key, and `deleted: false` narrows to it', async () => {
const { client } = clientAnswering(200, { success: true, data: ARCHIVED });

const answer = await client.environments.delete('env_1');

expect(Object.keys(answer).sort()).toEqual([
'archived', 'deleted', 'environmentId', 'message', 'purgeDeferred', 'retentionDays', 'warnings',
]);
if (answer.deleted) throw new Error('expected the archive answer');
expect(answer.archived).toBe(true);
expect(answer.purgeDeferred).toBe(false);
expect(answer.retentionDays).toBe(30);
expect(answer.warnings).toEqual([]);
});

it('relays a deferred purge: a LIVE environment asked to purge is archived with `purgeDeferred: true`', async () => {
const { client } = clientAnswering(200, { success: true, data: { ...ARCHIVED, purgeDeferred: true } });

const answer = await client.environments.delete('env_1', { purge: true });

if (answer.deleted) throw new Error('expected the archive answer');
expect(answer.archived).toBe(true);
expect(answer.purgeDeferred).toBe(true);
});

it('relays the teardown answer key for key, and `deleted: true` narrows to it', async () => {
const { client } = clientAnswering(200, { success: true, data: TORN_DOWN });

const answer = await client.environments.delete('env_1', { purge: true });

expect(Object.keys(answer).sort()).toEqual(['deleted', 'environmentId', 'purged', 'warnings']);
if (!answer.deleted) throw new Error('expected the teardown answer');
expect(answer.purged).toBe(true);
expect(answer.warnings).toEqual(['attachment storage sweep failed: timeout']);
});

it('rejects a refusal instead of resolving it: a production environment deleted without `force`', async () => {
const { client } = clientAnswering(409, {
success: false,
error: {
code: 'RESOURCE_CONFLICT',
message: 'This is the organization\'s production environment. Re-run with force to delete it.',
httpStatus: 409,
},
});

await expect(client.environments.delete('env_prod')).rejects.toMatchObject({
code: 'RESOURCE_CONFLICT',
httpStatus: 409,
});
});

it('anti-vacuity: the type pins above are real bindings in this module', () => {
expect(typeof archiveAnswerDeclaresItsKeys).toBe('function');
expect(typeof teardownAnswerDeclaresItsKeys).toBe('function');
expect(typeof previouslyDeclaredKeysStayReadable).toBe('function');
expect(typeof bothOptionsAreDeclared).toBe('function');
});
});
79 changes: 69 additions & 10 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2915,17 +2915,73 @@ export class ObjectStackClient {
},

/**
* Cascade-delete an environment: cleans up credential/member/package_installation
* rows, releases the physical database via the provisioning adapter, and
* removes the `sys_environment` row. Default environments require `force: true`.
*/
delete: async (id: string, opts?: { force?: boolean }) => {
const qs = opts?.force ? '?force=1' : '';
* Delete an environment — `DELETE /api/v1/cloud/environments/:id` — in the
* hosted control plane's TWO steps (cloud ADR-0014): no live environment is
* ever one call from irreversible destruction.
*
* 1. **Archive.** A live environment (active, provisioning, suspended, …) is
* archived, whatever the flags. Its row and data are retained and it stays
* recoverable until the retention window (`retentionDays`) ends, when the
* control plane reclaims it. An archived environment deleted again without
* `purge` stays archived and gets the same answer. The answer is
* `deleted: false, archived: true`.
* 2. **Purge.** `purge: true` on an environment that is ALREADY archived tears
* it down now, irreversibly — dependent rows, the physical database,
* domains and attachments. The answer is `deleted: true, purged: true`.
* `purge` on a live environment is deferred, never honoured: that call
* archives it and answers `purgeDeferred: true`; delete again with `purge`
* to tear it down.
*
* A `failed` environment (provisioning never completed) is torn down in ONE
* call, with or without `purge`.
*
* `force` and `purge` are independent confirmations; neither implies the
* other. `force: true` confirms the organization's PRODUCTION environment
* (for a legacy row with no `environment_type`, its default environment). It
* is required on every delete of one — the archiving call and the purging
* call — and it is never a purge: tearing down a production environment is
* `{ force: true }`, then `{ force: true, purge: true }`.
*
* Refusals reject instead of resolving (read `err.httpStatus` / `err.code`):
* `409` for a production environment without `force` and for a system
* environment, `404` for an id the caller cannot see, `403` for an
* organization member who is neither an owner/admin nor the environment's
* creator.
*
* The resolved value is one of the route's two 200 answers, discriminated by
* `deleted`; each member declares exactly the keys that answer carries.
*/
delete: async (id: string, opts?: { force?: boolean; purge?: boolean }) => {
const params = new URLSearchParams();
if (opts?.force) params.set('force', '1');
if (opts?.purge) params.set('purge', '1');
const qs = params.toString();
const res = await this.fetch(
`${this.baseUrl}/api/v1/cloud/environments/${encodeURIComponent(id)}${qs}`,
`${this.baseUrl}/api/v1/cloud/environments/${encodeURIComponent(id)}${qs ? `?${qs}` : ''}`,
{ method: 'DELETE' },
);
return this.unwrapResponse<{ deleted: boolean; environmentId: string; warnings: string[] }>(res);
return this.unwrapResponse<
| {
environmentId: string;
deleted: false;
archived: true;
/** `true` when `purge` was asked of a LIVE environment: it was archived instead — delete again with `purge`. */
purgeDeferred: boolean;
/** Days the archived environment is retained before the control plane reclaims it. */
retentionDays: number;
/** Always empty on an archive. */
warnings: string[];
/** The control plane's account of what happened and what to do next. */
message: string;
}
| {
environmentId: string;
deleted: true;
purged: true;
/** Best-effort cleanup steps that failed after the teardown itself succeeded. */
warnings: string[];
}
>(res);
},

/**
Expand Down Expand Up @@ -3577,8 +3633,11 @@ export class ObjectStackClient {
* NOT the bare id string the vendor's OpenAPI stub declares.
*
* better-auth removes the organization row, all members, and all
* pending invitations. Project teardown (per-project DBs, etc.) is
* handled server-side by hooks attached to the organization plugin.
* pending invitations. It deletes NO environment and releases no
* environment database — no organization-plugin hook tears environments
* down. On the hosted control plane, delete each of the organization's
* environments first with {@link ObjectStackClient.environments}`.delete`
* (archive, then `purge`), then delete the organization.
*/
delete: async (organizationId: string): Promise<OrganizationWire> => {
const route = this.getRoute('auth');
Expand Down
Loading