Skip to content

Commit 1c4270f

Browse files
hotlongclaude
andauthored
feat(client): environments.delete gains purge and documents the two-step delete (#17642)
The hosted control plane's DELETE /api/v1/cloud/environments/:id archives a live environment and tears down only an archived one on ?purge=1 (cloud ADR-0014); ?force=1 is the production confirmation and never a purge. The SDK sent force only, so an SDK caller could archive but never purge. - opts.purge sends ?purge=1 and combines with force - the return type declares the route's two 200 answers, discriminated by deleted - the JSDoc describes the two-step semantics instead of a one-call cascade - organizations.delete's JSDoc no longer claims hooks tear environments down Claude-Session: https://claude.ai/code/session_c5c0ce54-bb9c-478c-9e5b-cf44b80d4569 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2eb4724 commit 1c4270f

3 files changed

Lines changed: 304 additions & 10 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/client": minor
3+
---
4+
5+
feat(client): `environments.delete` gains `purge` and documents the hosted control plane's two-step delete (#17636)
6+
7+
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.
8+
9+
- `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.
10+
- The return type declares the two answers the route actually sends, discriminated by `deleted`:
11+
- archive: `{ environmentId, deleted: false, archived: true, purgeDeferred, retentionDays, warnings, message }`
12+
- teardown: `{ environmentId, deleted: true, purged: true, warnings }`
13+
14+
Both members carry every key the old declaration named (`deleted`, `environmentId`, `warnings`), so existing reads still compile.
15+
- 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.
16+
- `organizations.delete`'s JSDoc no longer claims that server-side hooks tear down the organization's environments. No hook does; delete each environment first.
17+
18+
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.
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `client.environments.delete` under the hosted control plane's two-step
5+
* delete (cloud ADR-0014) — #17636.
6+
*
7+
* ## The producer, measured
8+
*
9+
* `DELETE /api/v1/cloud/environments/:id` is served by `objectstack-ai/cloud`,
10+
* `packages/service-cloud/src/routes/environment-lifecycle.ts`, read at cloud
11+
* `eeac7b22` (cloud#2188). The route reads two query flags, `force` and
12+
* `purge`, independently, and a 200 carries exactly one of two bodies:
13+
*
14+
* archive — a live environment whatever the flags, or an archived one
15+
* without `purge`:
16+
* `{ environmentId, deleted: false, archived: true,
17+
* purgeDeferred, retentionDays, warnings: [], message }`
18+
* teardown — an archived environment with `purge`, or a `failed` one:
19+
* `{ environmentId, deleted: true, purged: true, warnings }`
20+
*
21+
* Every other outcome is a non-2xx error envelope, which this client's
22+
* `fetch` wrapper throws — it never reaches the declared return type.
23+
*
24+
* ## What each pin asserts
25+
*
26+
* - The URL for every combination of the two options. `force` and `purge` are
27+
* independent confirmations and a production teardown needs both on ONE
28+
* call, so a builder that let one option shadow the other would leave that
29+
* teardown unreachable from the SDK.
30+
* - Both 200 bodies relay untouched, key for key.
31+
* - A refusal rejects, carrying the envelope's `code` and the response status.
32+
* - The TYPE pins: the declared answer is the discriminated union of the two
33+
* bodies, no more and no less. They are compiled — `tsconfig.test.json`
34+
* includes `src/**` and the package's `typecheck` script names it — so each
35+
* `@ts-expect-error` is a real check.
36+
*/
37+
38+
import { describe, it, expect, vi } from 'vitest';
39+
import { ObjectStackClient } from './index';
40+
41+
type EnvironmentsNamespace = ObjectStackClient['environments'];
42+
type DeleteAnswer = Awaited<ReturnType<EnvironmentsNamespace['delete']>>;
43+
type DeleteOptions = NonNullable<Parameters<EnvironmentsNamespace['delete']>[1]>;
44+
45+
/**
46+
* The archive answer. `deleted: false` selects it, and each key is declared at
47+
* its wire type: assigning into typed locals is the pin, so a key weakened to
48+
* optional or retyped goes red here rather than in a caller.
49+
*/
50+
export function archiveAnswerDeclaresItsKeys(answer: DeleteAnswer): void {
51+
if (answer.deleted) return;
52+
const environmentId: string = answer.environmentId;
53+
const archived: true = answer.archived;
54+
const purgeDeferred: boolean = answer.purgeDeferred;
55+
const retentionDays: number = answer.retentionDays;
56+
const warnings: string[] = answer.warnings;
57+
const message: string = answer.message;
58+
void [environmentId, archived, purgeDeferred, retentionDays, warnings, message];
59+
// @ts-expect-error an archive tears nothing down; the archive answer carries no `purged`
60+
void answer.purged;
61+
}
62+
63+
/** The teardown answer. `deleted: true` selects it, and it carries none of the archive-only keys. */
64+
export function teardownAnswerDeclaresItsKeys(answer: DeleteAnswer): void {
65+
if (!answer.deleted) return;
66+
const environmentId: string = answer.environmentId;
67+
const purged: true = answer.purged;
68+
const warnings: string[] = answer.warnings;
69+
void [environmentId, purged, warnings];
70+
// @ts-expect-error the teardown answer carries no `archived`
71+
void answer.archived;
72+
// @ts-expect-error the teardown answer carries no `purgeDeferred`
73+
void answer.purgeDeferred;
74+
// @ts-expect-error the teardown answer carries no `retentionDays`
75+
void answer.retentionDays;
76+
// @ts-expect-error the teardown answer carries no `message`
77+
void answer.message;
78+
}
79+
80+
/** The three keys the previous declaration named sit on BOTH members, so a read written against it still compiles. */
81+
export function previouslyDeclaredKeysStayReadable(answer: DeleteAnswer): void {
82+
const deleted: boolean = answer.deleted;
83+
const environmentId: string = answer.environmentId;
84+
const warnings: string[] = answer.warnings;
85+
void [deleted, environmentId, warnings];
86+
}
87+
88+
/** `force` and `purge` are both declared options and may be passed together. */
89+
export function bothOptionsAreDeclared(): void {
90+
const productionTeardown: DeleteOptions = { force: true, purge: true };
91+
void productionTeardown;
92+
}
93+
94+
/** A client whose `fetch` answers one canned response. */
95+
function clientAnswering(status: number, body: unknown) {
96+
const fetchMock = vi.fn().mockResolvedValue({
97+
ok: status >= 200 && status < 300,
98+
status,
99+
statusText: status === 200 ? 'OK' : 'Conflict',
100+
json: async () => body,
101+
headers: new Headers(),
102+
});
103+
const client = new ObjectStackClient({ baseUrl: 'http://localhost:3000', fetch: fetchMock });
104+
return { client, fetchMock };
105+
}
106+
107+
const ARCHIVED = {
108+
environmentId: 'env_1',
109+
deleted: false,
110+
archived: true,
111+
purgeDeferred: false,
112+
retentionDays: 30,
113+
warnings: [],
114+
message: 'Environment archived (soft-deleted).',
115+
};
116+
117+
const TORN_DOWN = {
118+
environmentId: 'env_1',
119+
deleted: true,
120+
purged: true,
121+
warnings: ['attachment storage sweep failed: timeout'],
122+
};
123+
124+
describe('client.environments.delete — the two-step delete (cloud ADR-0014)', () => {
125+
const CASES: Array<{ opts: DeleteOptions | undefined; query: string }> = [
126+
{ opts: undefined, query: '' },
127+
{ opts: {}, query: '' },
128+
{ opts: { force: false, purge: false }, query: '' },
129+
{ opts: { force: true }, query: '?force=1' },
130+
{ opts: { force: true, purge: false }, query: '?force=1' },
131+
{ opts: { purge: true }, query: '?purge=1' },
132+
{ opts: { force: false, purge: true }, query: '?purge=1' },
133+
{ opts: { force: true, purge: true }, query: '?force=1&purge=1' },
134+
];
135+
136+
for (const { opts, query } of CASES) {
137+
const label = opts === undefined ? 'no options' : JSON.stringify(opts);
138+
it(`${label} → DELETE …/environments/env_1${query}`, async () => {
139+
const { client, fetchMock } = clientAnswering(200, { success: true, data: ARCHIVED });
140+
141+
await client.environments.delete('env_1', opts);
142+
143+
expect(fetchMock).toHaveBeenCalledTimes(1);
144+
expect(fetchMock.mock.calls[0][0]).toBe(`http://localhost:3000/api/v1/cloud/environments/env_1${query}`);
145+
expect(fetchMock.mock.calls[0][1].method).toBe('DELETE');
146+
});
147+
}
148+
149+
it('encodes the id and keeps both flags after it', async () => {
150+
const { client, fetchMock } = clientAnswering(200, { success: true, data: TORN_DOWN });
151+
152+
await client.environments.delete('env/1 x', { force: true, purge: true });
153+
154+
expect(fetchMock.mock.calls[0][0]).toBe(
155+
'http://localhost:3000/api/v1/cloud/environments/env%2F1%20x?force=1&purge=1',
156+
);
157+
});
158+
159+
it('relays the archive answer key for key, and `deleted: false` narrows to it', async () => {
160+
const { client } = clientAnswering(200, { success: true, data: ARCHIVED });
161+
162+
const answer = await client.environments.delete('env_1');
163+
164+
expect(Object.keys(answer).sort()).toEqual([
165+
'archived', 'deleted', 'environmentId', 'message', 'purgeDeferred', 'retentionDays', 'warnings',
166+
]);
167+
if (answer.deleted) throw new Error('expected the archive answer');
168+
expect(answer.archived).toBe(true);
169+
expect(answer.purgeDeferred).toBe(false);
170+
expect(answer.retentionDays).toBe(30);
171+
expect(answer.warnings).toEqual([]);
172+
});
173+
174+
it('relays a deferred purge: a LIVE environment asked to purge is archived with `purgeDeferred: true`', async () => {
175+
const { client } = clientAnswering(200, { success: true, data: { ...ARCHIVED, purgeDeferred: true } });
176+
177+
const answer = await client.environments.delete('env_1', { purge: true });
178+
179+
if (answer.deleted) throw new Error('expected the archive answer');
180+
expect(answer.archived).toBe(true);
181+
expect(answer.purgeDeferred).toBe(true);
182+
});
183+
184+
it('relays the teardown answer key for key, and `deleted: true` narrows to it', async () => {
185+
const { client } = clientAnswering(200, { success: true, data: TORN_DOWN });
186+
187+
const answer = await client.environments.delete('env_1', { purge: true });
188+
189+
expect(Object.keys(answer).sort()).toEqual(['deleted', 'environmentId', 'purged', 'warnings']);
190+
if (!answer.deleted) throw new Error('expected the teardown answer');
191+
expect(answer.purged).toBe(true);
192+
expect(answer.warnings).toEqual(['attachment storage sweep failed: timeout']);
193+
});
194+
195+
it('rejects a refusal instead of resolving it: a production environment deleted without `force`', async () => {
196+
const { client } = clientAnswering(409, {
197+
success: false,
198+
error: {
199+
code: 'RESOURCE_CONFLICT',
200+
message: 'This is the organization\'s production environment. Re-run with force to delete it.',
201+
httpStatus: 409,
202+
},
203+
});
204+
205+
await expect(client.environments.delete('env_prod')).rejects.toMatchObject({
206+
code: 'RESOURCE_CONFLICT',
207+
httpStatus: 409,
208+
});
209+
});
210+
211+
it('anti-vacuity: the type pins above are real bindings in this module', () => {
212+
expect(typeof archiveAnswerDeclaresItsKeys).toBe('function');
213+
expect(typeof teardownAnswerDeclaresItsKeys).toBe('function');
214+
expect(typeof previouslyDeclaredKeysStayReadable).toBe('function');
215+
expect(typeof bothOptionsAreDeclared).toBe('function');
216+
});
217+
});

packages/client/src/index.ts

Lines changed: 69 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2915,17 +2915,73 @@ export class ObjectStackClient {
29152915
},
29162916

29172917
/**
2918-
* Cascade-delete an environment: cleans up credential/member/package_installation
2919-
* rows, releases the physical database via the provisioning adapter, and
2920-
* removes the `sys_environment` row. Default environments require `force: true`.
2921-
*/
2922-
delete: async (id: string, opts?: { force?: boolean }) => {
2923-
const qs = opts?.force ? '?force=1' : '';
2918+
* Delete an environment — `DELETE /api/v1/cloud/environments/:id` — in the
2919+
* hosted control plane's TWO steps (cloud ADR-0014): no live environment is
2920+
* ever one call from irreversible destruction.
2921+
*
2922+
* 1. **Archive.** A live environment (active, provisioning, suspended, …) is
2923+
* archived, whatever the flags. Its row and data are retained and it stays
2924+
* recoverable until the retention window (`retentionDays`) ends, when the
2925+
* control plane reclaims it. An archived environment deleted again without
2926+
* `purge` stays archived and gets the same answer. The answer is
2927+
* `deleted: false, archived: true`.
2928+
* 2. **Purge.** `purge: true` on an environment that is ALREADY archived tears
2929+
* it down now, irreversibly — dependent rows, the physical database,
2930+
* domains and attachments. The answer is `deleted: true, purged: true`.
2931+
* `purge` on a live environment is deferred, never honoured: that call
2932+
* archives it and answers `purgeDeferred: true`; delete again with `purge`
2933+
* to tear it down.
2934+
*
2935+
* A `failed` environment (provisioning never completed) is torn down in ONE
2936+
* call, with or without `purge`.
2937+
*
2938+
* `force` and `purge` are independent confirmations; neither implies the
2939+
* other. `force: true` confirms the organization's PRODUCTION environment
2940+
* (for a legacy row with no `environment_type`, its default environment). It
2941+
* is required on every delete of one — the archiving call and the purging
2942+
* call — and it is never a purge: tearing down a production environment is
2943+
* `{ force: true }`, then `{ force: true, purge: true }`.
2944+
*
2945+
* Refusals reject instead of resolving (read `err.httpStatus` / `err.code`):
2946+
* `409` for a production environment without `force` and for a system
2947+
* environment, `404` for an id the caller cannot see, `403` for an
2948+
* organization member who is neither an owner/admin nor the environment's
2949+
* creator.
2950+
*
2951+
* The resolved value is one of the route's two 200 answers, discriminated by
2952+
* `deleted`; each member declares exactly the keys that answer carries.
2953+
*/
2954+
delete: async (id: string, opts?: { force?: boolean; purge?: boolean }) => {
2955+
const params = new URLSearchParams();
2956+
if (opts?.force) params.set('force', '1');
2957+
if (opts?.purge) params.set('purge', '1');
2958+
const qs = params.toString();
29242959
const res = await this.fetch(
2925-
`${this.baseUrl}/api/v1/cloud/environments/${encodeURIComponent(id)}${qs}`,
2960+
`${this.baseUrl}/api/v1/cloud/environments/${encodeURIComponent(id)}${qs ? `?${qs}` : ''}`,
29262961
{ method: 'DELETE' },
29272962
);
2928-
return this.unwrapResponse<{ deleted: boolean; environmentId: string; warnings: string[] }>(res);
2963+
return this.unwrapResponse<
2964+
| {
2965+
environmentId: string;
2966+
deleted: false;
2967+
archived: true;
2968+
/** `true` when `purge` was asked of a LIVE environment: it was archived instead — delete again with `purge`. */
2969+
purgeDeferred: boolean;
2970+
/** Days the archived environment is retained before the control plane reclaims it. */
2971+
retentionDays: number;
2972+
/** Always empty on an archive. */
2973+
warnings: string[];
2974+
/** The control plane's account of what happened and what to do next. */
2975+
message: string;
2976+
}
2977+
| {
2978+
environmentId: string;
2979+
deleted: true;
2980+
purged: true;
2981+
/** Best-effort cleanup steps that failed after the teardown itself succeeded. */
2982+
warnings: string[];
2983+
}
2984+
>(res);
29292985
},
29302986

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

0 commit comments

Comments
 (0)