From 0dc866618f114906d15c9a5a98d4affe15bf0f01 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:07:54 +0800 Subject: [PATCH] feat(client): environments.delete gains purge and documents the two-step delete 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 --- .../client-environments-delete-purge.md | 18 ++ .../src/environments-delete-two-step.test.ts | 217 ++++++++++++++++++ packages/client/src/index.ts | 79 ++++++- 3 files changed, 304 insertions(+), 10 deletions(-) create mode 100644 .changeset/client-environments-delete-purge.md create mode 100644 packages/client/src/environments-delete-two-step.test.ts diff --git a/.changeset/client-environments-delete-purge.md b/.changeset/client-environments-delete-purge.md new file mode 100644 index 00000000000..2799547d29a --- /dev/null +++ b/.changeset/client-environments-delete-purge.md @@ -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. diff --git a/packages/client/src/environments-delete-two-step.test.ts b/packages/client/src/environments-delete-two-step.test.ts new file mode 100644 index 00000000000..452914d8a4c --- /dev/null +++ b/packages/client/src/environments-delete-two-step.test.ts @@ -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>; +type DeleteOptions = NonNullable[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'); + }); +}); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index d3a09695fe2..a2631376b7c 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -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); }, /** @@ -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 => { const route = this.getRoute('auth');