Skip to content

feat(client): environments.delete gains purge and documents the two-step delete - #17642

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-17636-client-environments-delete-purge
Sep 11, 2026
Merged

hotlong merged 1 commit into
mainfrom
claude/issue-17636-client-environments-delete-purge

Conversation

@hotlong

@hotlong hotlong commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Fixes #17636

Clause-②: no — an optional client option plus a doc and return-type correction; no packages/spec/src/** file is touched and no contract accept/reject behaviour changes.

What changed

@objectstack/client, packages/client/src/index.ts:

  • environments.delete(id, opts) gains opts.purge?: boolean, sent as ?purge=1. It is independent of force (?force=1) and both may be passed on one call. The query string is built with URLSearchParams, so every combination yields '', ?force=1, ?purge=1 or ?force=1&purge=1. A call with no options, or with force alone, builds the same URL as before.
  • JSDoc rewritten for the hosted control plane's two-step delete (cloud ADR-0014):
    • a live environment is archived (retained, recoverable within retentionDays);
    • purge tears down only an already-archived environment, and is deferred on a live one (purgeDeferred: true);
    • force is the production confirmation, required on both calls for a production environment, and never a purge;
    • a failed environment is torn down in one call;
    • refusals reject: 409 for production without force or a system environment, 404, 403.
  • Return type is now the discriminated union of the route's two 200 answers (rows 11 and 13 below), keyed by deleted. The three keys the old declaration named (deleted, environmentId, warnings) sit on both members, so existing reads still compile.
  • organizations.delete JSDoc no longer claims that hooks on the organization plugin tear down the organization's environments. No such hook exists (evidence under acceptance item 5). JSDoc only; no behaviour change.

Also added: the test file packages/client/src/environments-delete-two-step.test.ts, and the changeset .changeset/client-environments-delete-purge.md, graded minor (an additive widening of a published method, per the "WHICH LEVEL" rule).

Server truth — objectstack-ai/cloud at eeac7b22b (cloud#2188), read from origin/main

All paths are under packages/service-cloud/src/.

  • Route: routes/environment-lifecycle.ts:1930-1945. Bare line numbers below are in this file.
  • Flags: isDeleteFlagSet (:1711-1712, accepts true, 1, 'true' and '1') reads req.query.force and req.query.purge (:1940-1941).
  • Envelopes: a success goes through ok(), which is { success: true, data } (cloud-artifact-helpers.ts:107). A failure goes through fail(), which is { success: false, error: { code, message, httpStatus } } (cloud-artifact-helpers.ts:141); the client's fetch wrapper throws it.
# Condition Status Body (data for a 200) Where
1 no caller 401 fail routes/types.ts:200, reached from :1931
2 empty id 400 fail :1933
3 unknown id 404 fail :1935
4 environment has no organization_id 409 fail :1326 (gate assertEnvAdminOrCreator, :1312)
5 control driver unavailable, in the gate 503 fail :1329
6 caller is not a member of the environment's organization 404 fail, same words as row 3 :1338
7 member who is not owner/admin/creator 403 fail :1347
8 system environment 409 fail :1769-1771
9 production environment without force 409 fail :1773-1778
10 archive path, metadata CAS fails 503, 500, 404 or 409 fail :1799, from :639, :656, :660, :666
11 archive: a live environment (any flags), or an archived one without purge 200 { environmentId, deleted: false, archived: true, purgeDeferred, retentionDays, warnings: [], message } :1822-1839
12 teardown path, provisioning service unavailable 503 fail :1843-1844
13 teardown: an archived environment with purge, or a failed one 200 { environmentId, deleted: true, purged: true, warnings } :1886
14 teardown throws 409 if the message names a system or default environment, else 500 fail :1887-1891

Notes on the table:

  • Production (row 9): decided by classifyEnvironmentType (plan-entitlements.ts:767), which uses the explicit environment_type, else the legacy is_default.
  • Archive answer (row 11): purgeDeferred is the purge flag (:1818), and retentionDays is ENV_RETENTION_DAYS, which is 30 (environment-deletion-policy.ts:32).
  • Mode selection: decideEnvironmentDeletionMode (environment-deletion-policy.ts:44). A failed environment tears down (:49); an archived one tears down only with purge (:53); anything else archives (:57).
  • Cloud's own pins agree: test/environment-delete-route-two-step.test.ts lines 325, 343, 355, 384, 397, 403, 416, 429 and 444.

Only rows 11 and 13 are 200s, so together they are the whole declared return type.

Acceptance, item by item

All runs are at 0dc866618.

  1. purge sends ?purge=1, combines with force, and every combination is tested.
    • Cases: eight option shapes (undefined, {}, the four true/false pairs, and the two single-flag spellings), plus an id-encoding case.
    • Run: pnpm --filter @objectstack/client exec vitest run --maxWorkers=2 --reporter=verbose src/environments-delete-two-step.test.ts gave Tests 14 passed (14).
    • Ablation, from the committed state: deleting the params.set('purge', '1') line gave Tests 4 failed | 10 passed (14) (the three purge cases and the encoding case). It was then restored with git checkout HEAD -- and proven byte-identical to the HEAD blob a2631376.
  2. The JSDoc follows the two-step semantics. See the environments.delete diff:
    • archive, recoverable within retentionDays;
    • purge only on an archived environment, deferred on a live one;
    • force is the production confirmation, never a purge;
    • a failed environment is torn down in one call.
  3. The return type carries exactly the server's fields. It is the union of rows 11 and 13.
    • Type pins in the test file: typed-local assignments for every declared key; @ts-expect-error on purged for an archive; @ts-expect-error on archived, purgeDeferred, retentionDays and message for a teardown.
    • The file is inside the tsconfig.test.json program: tsc -p tsconfig.test.json --noEmit --listFiles exits 0 and lists it once.
    • Ablation: deleting retentionDays: number; from the archive member made tsc exit 2, with TS2339 at environments-delete-two-step.test.ts(55,42) and (170,23). It was then restored to the HEAD blob.
  4. Docs page. No page documents this method, so there is nothing to sync.
    • grep -rn "environments.delete" over content/, apps/docs/, skills/ and packages/client/README.md gives 0 hits.
    • content/docs/api/client-sdk.mdx names the environments namespace only in passing (lines 18 and 158).
  5. organizations.delete.
    • It calls better-auth's POST /api/v1/auth/organization/delete, not cloud's DELETE /api/v1/cloud/organizations/:id. The cloud route is the one that answers 409 while environments remain (routes/environment-crud.ts:872 and :905), and it is ledgered as an SDK gap (cloud-route-ledger.ts:340).
    • The JSDoc nevertheless claimed that organization-plugin hooks tear down environments. No such hook exists: plugin-auth's organizationHooks (packages/plugins/plugin-auth/src/auth-manager.ts:2951) declares none for delete, and git grep "beforeDeleteOrganization\|afterDeleteOrganization" finds 0 hits in this repo's packages/**/src and in cloud's packages/*/src.
    • The false sentence is replaced. No other method was touched.

Gates

Declared unlocked mode. scripts/pm/os-verify-lock.sh found no usable flock on this host (macOS), so every locked run below printed VERDICT command-exit N · UNLOCKED (declared): nothing was serialized.

Command Verdict
pnpm exec turbo run build --filter=@objectstack/client --concurrency=2 Tasks: 33 successful, 33 total · VERDICT command-exit 0
pnpm --filter @objectstack/client test Test Files 42 passed (42) · Tests 506 passed (506) · VERDICT command-exit 0
pnpm --filter @objectstack/client typecheck check:test-typecheck: OK, 0 file(s) / 0 error(s) · VERDICT command-exit 0
pnpm check:nul-bytes check-nul-bytes: OK (scanned 8386 text file(s), no raw control bytes
pnpm check:adr-0087-registration this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)
node scripts/check-changeset-no-major.mjs This diff introduces no major bump; the level axis is not applicable locally (no pull_request payload)
pnpm --filter @objectstack/spec run check:skill-examples, after building @objectstack/client-react 258 prose examples type-check across 3 surface(s)
eslint, narrowed (below) 2 files · 0 errors · 0 warnings

node scripts/pm/dispatch-gates.mjs --commands derived 60 families from the three changed paths. All 60 were run and recorded with exit codes. The --ran reconciliation reads: 60 derived famil(ies) accounted for — 58 run, 2 NOT-MEASURED.

NOT MEASURED. Each gate below refused with its own PREREQUISITE NOT MET, exit 3:

  • pnpm check:dual-build-cjs-loads: it reads every workspace package's dist/. 35 packages are unbuilt here, and building them is a whole-workspace build, which this dispatch excludes because the box is shared.
  • pnpm check:type-check-debt: --re-measure needs the whole packages/* closure built (same exclusion). The client test-layer program was measured directly instead, and compiles with the new file in it (acceptance item 3).

Narrowed lint. Repo-wide pnpm lint belongs to CI. The narrowed run was eslint --no-inline-config --format json packages/client/src/index.ts packages/client/src/environments-delete-two-step.test.ts, which exited 0.

  1. Population: both paths came back as linted results, so eslint.config.mjs ignores neither.
  2. Count, from the JSON output: 2 files, 0 errors, 0 warnings.
  3. Invariance: eslint.config.mjs enables no type-aware linting (no parserOptions.project, no projectService), so this diff cannot move any untouched file's verdict.

Acceptance notes

  • Premise divergence (acceptance item 5). The dispatch took organizations.delete to be the cloud route that answers 409. The SDK method is a different route, better-auth's. Its JSDoc was corrected for its own false claim, not for the 409 behaviour.
  • noted, not filed: cloud's route-ledger note for DELETE /api/v1/cloud/environments/:id (cloud-route-ledger.ts:185) says the SDK method sends force only. That goes stale once this lands and cloud pins past it. Carrier: the cloud pin bump that picks this up.
  • noted, not filed: on cloud, deleting an already-archived environment again without purge re-runs the archive branch. It re-stamps archived_at, which restarts the retention clock, and writes another audit row, while environment-deletion-policy.ts calls that case an idempotent no-op. It is never destructive. Recorded for the cloud owner of environment-lifecycle.ts.
  • noted, not filed: the SDK still has no archive or restore method (cloud's ledger rows for those routes are gap). Out of scope.

Generated by Claude Code

…tep 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 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/client, touching 2 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/environment-routing.mdx (via /api/v1/cloud/environments (route, a path literal in environments), /api/v1/cloud/environments/:id (route, a path literal in environments))
  • content/docs/concepts/north-star.mdx (via /api/v1/cloud/environments (route, a path literal in environments), /api/v1/cloud/environments/:id (route, a path literal in environments))
What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 76c9fab30ca406b7b1f06b8ca3db286af9f2bf8bpackageMentionDocs.

Which tree this was computed on

This run read content/docs from d32fdebc5e2aa60480af156b6c308e0a0065c975 — the merge of head 0dc866618f114906d15c9a5a98d4affe15bf0f01 into base 76c9fab30ca406b7b1f06b8ca3db286af9f2bf8b, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d32fdebc5e2aa60480af156b6c308e0a0065c975 && git checkout d32fdebc5e2aa60480af156b6c308e0a0065c975
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 76c9fab30ca406b7b1f06b8ca3db286af9f2bf8b 0dc866618f114906d15c9a5a98d4affe15bf0f01 && git checkout -B drift-repro 76c9fab30ca406b7b1f06b8ca3db286af9f2bf8b && git merge --no-ff 0dc866618f114906d15c9a5a98d4affe15bf0f01

node scripts/docs-audit/affected-docs.mjs --json 76c9fab30ca406b7b1f06b8ca3db286af9f2bf8b

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 76c9fab30ca406b7b1f06b8ca3db286af9f2bf8b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@hotlong

hotlong commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

PM 复核(maintainer direct dispatch,Session c5c0ce54…

对照 PR head 0dc866618 的实际代码,并独立读取 cloud origin/maineeac7b22)的服务端实现逐项核对。

改动面

读数 结论
文件 3 个:changeset、packages/client/src/index.ts、新测试 与卡片范围一致
packages/spec/src/** / 受治理面 0 / 0 Clause-②: no 成立
changeset @objectstack/client: minor(给已发布方法新增可选参数) 非破坏性,定级合理
关闭关键字 Fixes #17636(os-dev.md 的写法) Closes 等效,接受

请求构造

URLSearchParams 分别设置 force=1 / purge=1,两者同时传时为 ?force=1&purge=1,都不传时不带查询串。服务端 DELETE 路由以 isDeleteFlagSet(req.query?.force / purge) 读取(environment-lifecycle.ts:1940-1941)——对得上

返回类型 vs 服务端——逐字段一致

分支 客户端判别联合 服务端 ok({...})
归档 environmentIddeleted: falsearchived: truepurgeDeferredretentionDayswarningsmessage environment-lifecycle.ts 归档分支:同七个键;warnings: [] 恒为空(服务端注释说明正是为本方法的类型声明而设)
拆除 environmentIddeleted: truepurged: truewarnings 拆除分支:{ environmentId, deleted: true, purged: true, warnings }

没有臆造字段,也没有遗漏——避开了本文件注释记录过的 credential 那类缺陷。

JSDoc

两步语义写全了:活跃环境一律归档、purge 只对已归档环境生效(对活跃环境为 purgeDeferred)、failed 一次拆除、force 是生产确认且永不等于 purge、拒绝以 reject 形式抛出(409 / 404 / 403)。

organizations.delete——只改文档,接受

方法体不变(仍 POST {auth}/organization/delete,走 better-auth)。原 JSDoc 声称组织插件钩子会拆除环境;两个仓里都不存在这样的钩子,改为如实说明「不删除任何环境,需先逐个删除环境」。席位同时指出我派发令里的前提有误(我以为它调的是 cloud 返回 409 的那条路由)——更正成立。

范围外发现的去向

  • 已归档环境重复删除会重置 archived_at、契约注释却称 idempotent no-op → 已在 objectstack-ai/cloud#2190 第 ② 项,补充了契约原文与行号
  • cloud 路由台账 cloud-route-ledger.ts:185「SDK 只发 force」在 pin 越过本 PR 后过时 → 已记到 objectstack-ai/cloud#2181(pin bump 时一并改)
  • SDK 缺归档 / 恢复方法:功能空缺而非缺陷,不立卡

下一步:CI 全部结束且无失败 → 转 ready → 入合并队列(以 timeline added_to_merge_queue 为准)。有红按日志回派。

@hotlong
hotlong marked this pull request as ready for review September 11, 2026 07:49
@hotlong
hotlong enabled auto-merge September 11, 2026 07:49
@hotlong
hotlong added this pull request to the merge queue Sep 11, 2026
Merged via the queue into main with commit 1c4270f Sep 11, 2026
35 checks passed
@hotlong
hotlong deleted the claude/issue-17636-client-environments-delete-purge branch September 11, 2026 08:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

1 participant