You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 1e346e9
Browse filesBrowse the repository at this point in the historyBrowse files
fix(objectql)!: uninstalling a base package is refused while its paired .custom package is installed, naming the pair (409) (#28506)
<!-- claude-projects-attribution: {"github_login":"os-zhuang"} -->
_Requested by **Sam** · [project
thread](https://claude.ai/code/project/chan_01LjZwNRFiG2fkp8SNf6rHNx?thread=cmsg_01LjZwNRFiG2fkp8SNf6rHNxBfysW9RL3L2nLXms3Co9hA)_Fixes#28408
Clause-②: no (narrowing) — `SchemaRegistry.assertPackageUninstallable`,
and so `uninstallPackage`, `unregisterObjectsByPackage` without `force`,
the protocol's `deletePackage` and `DELETE /api/v1/packages/:id`, now
refuse the uninstall of a base while the registry holds its paired
customization package (`BASE.custom`), an uninstall that succeeded
before; nothing is widened.
Metadata-protocol: none
Skills-docs: none — no file under `skills/**` teaches uninstalling a
package, so every taught use stays true.
## 维护者速读
- 改了什么:卸载一个基础包(如 HotCRM)时,只要它的配对定制包 `hotcrm.custom` 还装着,卸载就被拒绝,返回 409
`RESOURCE_CONFLICT`,消息点名 `hotcrm.custom`,并先给出路:先删除它(要保留定制,先用 `GET
/api/v1/packages/hotcrm.custom/export` 导出)。基础包同时被别的包扩展时,一个拒绝同时列出两种原因。
- 为什么改:不拒的话,卸载会把 `hotcrm.custom` 里的全部定制变成孤儿,没人提供、也没人说明原因(ADR-0146
D3,史诗的两分钟验证「卸载 HotCRM 被拒并点名 `hotcrm.custom`」)。
- 风险与回滚:只是给已有的卸载拒绝(ADR-0029 被扩展对象)加一个原因,同一个错误类、同一个 409
信封,没有新错误码;`deletePackage`
在删存储行之前就问,拒绝时什么都不动。卸载配对包本身、`force`、没有配对包的基础包都不变。回滚即 revert 本
PR。另:`DELETE /api/v1/marketplace/install-local/:id`
这扇门会先删账本再撤注册,配对包在时它仍回 200 并留下半卸载的基础包;它不在本卡文件面内,已作为发现交给席位。
- 先看:`packages/objectql/src/registry.ts`(`assertPackageUninstallable`
末尾的配对检查,和 `PackageUninstallExtendedError`
构造函数里的三种消息分支)、`packages/runtime/src/domains/packages-uninstall-refuse-before-mutate.test.ts`(新的
describe
块)、`packages/objectql/src/registry-assert-package-uninstallable.test.ts`(新的
describe 块)。
- 两分钟验证:`pnpm --filter @objectstack/objectql exec vitest run
src/registry-assert-package-uninstallable.test.ts`(需先构建 spec),预期 9
个用例全绿,其中 "the pair present" 用例断言 409 并点名 `hotcrm.custom`。
- 需要你做的:无,席位落地。
## What changes
- **`SchemaRegistry.assertPackageUninstallable(id)`**
(`packages/objectql/src/registry.ts`) also refuses while the registry
holds the pair id of `id`, which it reads from N-08's
`customizationPackageId` (`@objectstack/spec/kernel`). There is no local
copy of the suffix. The pair is found by id alone, so a pair built by
the ensure door (N-13) and an installed exported pair are treated the
same. Uninstalling the pair itself is unaffected, because a `.custom` id
derives no pair. `force` still means not asking.
- **The same refusal, no new code.** `PackageUninstallExtendedError`
(`RESOURCE_CONFLICT` / 409, the ADR-0112 envelope) gains
`customizationPackageId`. Its message opens with the way out: delete the
pair first, exporting it before that to keep its customizations (`GET
/api/v1/packages/PAIR/export`). It then names the pair. When the base is
also extended (ADR-0029), ONE refusal names the extenders and the pair,
and its first sentence is the whole way out. The extender-only message
is byte-identical to before. The class keeps its name because ADR-0141
and ADR-0146 cite it as this family's anchor ("the
`PackageUninstallExtendedError` shape").
- **Every door that asks it refuses before mutating.** These are
`deletePackage` (before its `sys_packages` delete), `uninstallPackage`
and `unregisterObjectsByPackage`, and so `DELETE /api/v1/packages/:id`.
`protocol-package-uninstall.ts` changes only in comments: they now name
the pair cause, and they correct the door's answer from `500` to the
refusal's own `409`.
- **Wanted 3: gate order unchanged.** On `DELETE /api/v1/packages/:id`
the read-only `422 WRITABLE_PACKAGE_REQUIRED` still answers first. A new
test pins it for a booted code package with its pair installed.
## Tests
- `packages/objectql/src/registry-assert-package-uninstallable.test.ts`,
new block (base `hotcrm`, pair `hotcrm.custom`):
- the pair present: `RESOURCE_CONFLICT` / 409 / `httpStatus` 409,
`customizationPackageId: 'hotcrm.custom'`, the way out leading, nothing
mutated; `uninstallPackage` throws the same refusal and removes nothing;
- the pair absent answers as before. So does a `.custom` package that is
not this base's pair (`hotcrmx.custom`), and a dependent that is not the
pair (`hotcrm.extras`, which ADR-0141 D3 still defers). Both declare the
base;
- both causes: one refusal carrying `extendedObjects`, `extenders` and
`customizationPackageId`;
- the pair itself uninstalls, then the base does;
`unregisterObjectsByPackage(base, true)` does not ask.
-
`packages/runtime/src/domains/packages-uninstall-refuse-before-mutate.test.ts`,
new block (real `SchemaRegistry`, real `HttpDispatcher`):
- pair installed, base disabled first through `PATCH
/packages/:id/disable`: the operator's `DELETE` answers 409
`RESOURCE_CONFLICT` naming the pair. The stored rows, the registry entry
(still disabled), its object, the disable record on disk and the pair
are all intact;
- a host with no persisted half: the registry's own uninstall refuses
the same way, nothing moves;
- control: the pair deleted first through the same door (200), then the
base uninstalls (200);
- a booted code package with its pair: `422 WRITABLE_PACKAGE_REQUIRED`
first, `deletePackage` never asked.
- The rig's `protocol` double now asks
`registry.assertPackageUninstallable` before its store delete, as
`deletePackage` does, and deletes only the base's rows. The existing
arms are unchanged and still green.
## Acceptance notes
- **Wanted 3, measured: one door does uninstall a managed base.**
`DELETE /api/v1/marketplace/install-local/:manifestId`
(`packages/cloud-connection/src/marketplace-install-local-plugin.ts`,
`handleUninstall`) uninstalls a marketplace-installed base. It removes
the ledger entry first and asks no refusal before that. The registry's
refusal reaches it only at the withdrawal, as a failed
`registry.uninstallPackage` cleanup inside a 200. Measured at this head
with a real `SchemaRegistry` (a scratch test, not committed):
- pair installed: `200`, `success: true`, ledger entry gone, base still
registered until restart, `cleanups: [{ name:
'registry.uninstallPackage', success: false }]`, and no response field
names the pair. Only the operator log does.
- no pair (control): `200` and the base is withdrawn.
So on that door the two-minute check does not hold. After a restart the
base is gone and its pair is stranded: the half-removed base ADR-0146 D3
rules out. The ADR-0029 extender refusal has the same gap there today,
and `marketplace-install-local-uninstall-withdrawal.test.ts` pins it as
declared ("a refused withdrawal is one failed outcome on the response").
The fix is to ask `assertPackageUninstallable` before the ledger removal
and answer its 409. That is outside this card's file surface
(`packages/cloud-connection`), so it is not changed here. It is reported
to the seat for a card. The gates on `DELETE /api/v1/packages/:id` are
not reordered.
- **The prescription.** The card says the refusal "prescribes exporting
it … or deleting it first". Exporting alone does not unblock the
uninstall, so the message says to delete the pair first and to export it
before that to keep its customizations.
## Verification
Readings at head `d79d71aa7` (branch base `ea74ebf93`).
<details><summary>Package tests, typecheck and lint</summary>
- `pnpm --filter @objectstack/objectql typecheck` and `test`: exit 0;
Test Files 509 passed (509), Tests 9865 passed (9865).
- `pnpm --filter @objectstack/metadata-protocol typecheck` and `test`:
exit 0; Test Files 288 passed, 4 skipped (292), Tests 21888 passed, 26
skipped.
- `pnpm --filter @objectstack/runtime typecheck` and `test`: exit 0;
Test Files 496 passed (496), Tests 8057 passed, 19 skipped.
- The two touched files alone: objectql 9/9, runtime 9/9 (4 new each).
- `pnpm lint` (repo-wide, as the dispatch asked): exit 0.
- All of the above ran under `scripts/pm/os-verify-lock.sh`, VERDICT
command-exit 0, after `pnpm --filter '@objectstack/dogfood^...' build`
(exit 0). The runtime suite reads `@objectstack/objectql` from `dist/`
(no alias), so it ran on the rebuilt package.
</details>
<details><summary>Ablations (scripts/ablation-replace.mjs on
packages/objectql/src/registry.ts; objectql rebuilt and
scripts/ablation-dist-preflight.mjs exit 0 on each mutated and each
restored leg)</summary>
Each leg ran both touched files. Each restore leg was proven by the
restored blob equal to the HEAD blob, `git diff HEAD` empty, the marker
absent from `dist/` (`--absent`, exit 0), and a clean tree at the end.
- A1, the pair is never a cause (`installedPair` always undefined):
objectql 2 failed (the pair present; both causes), runtime 2 failed (the
409 with everything intact; the host with no persisted half).
- A2, the registry is not asked whether the pair is installed (refuse
whenever an id derives a pair): objectql 5 failed (the pair absent, the
pair itself then the base, and the three older arms whose ids derive a
pair); runtime 3 failed (both operator uninstalls and the pair-first
control).
- A3, with extenders present the pair is dropped from the refusal:
objectql 1 failed (both causes); runtime green, as it carries no
both-causes door case.
</details>
<details><summary>Dispatch gates</summary>
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 63 commands from 5 paths against
merge base `ea74ebf93` (323 changed lines). Every command was run with
stdin from `/dev/null`, and each exit code was written to disk before
any pipe. `--ran` reconciliation: 63 derived, 63 run, 0 NOT-MEASURED, 0
UNRUN.
- `pnpm check:dual-build-cjs-loads` first exited 3 (PREREQUISITE NOT
MET: 7 packages outside the dogfood closure had no `dist/`). After
building those 7 it exited 0 (113 require entry points across 70
packages load).
- Every other derived command exited 0, including `pnpm
check:type-check-debt`, `pnpm check:engine-double-contract` and `node
scripts/check-adr-0087-registration.mjs --base origin/main`.
- The clone was unshallowed (`git fetch --unshallow origin main`) before
deriving.
</details>
<details><summary>Full dogfood battery (Clause-② carries the narrowing
arm)</summary>
At head `d79d71aa7`: `pnpm --filter @objectstack/dogfood test` under the
verify lock, with no `OS_TEST_SHARD` and no path. VERDICT command-exit 0
(held 20m29s). Test Files 363 passed, 3 skipped (366). Tests 2926
passed, 28 skipped (2954). No failures.
</details>
<details><summary>Wanted 3 measurement</summary>
This was a scratch test in `packages/cloud-connection/src` and was
deleted before any commit. It booted the real
`MarketplaceInstallLocalPlugin` over a real `SchemaRegistry` holding
`com.example.tasksapp` (source `marketplace`, scope `cloud`) and, in one
arm, `com.example.tasksapp.custom`. Then it called the door's DELETE
route.
- No pair: status 200, ledger entry removed, base withdrawn.
- Pair installed: status 200, `success: true`, ledger entry removed,
base and pair both still registered, `cleanups` was `[{ name:
'registry.uninstallPackage', success: false, removed: 0 }]`. No response
field names the pair; the operator warn does.
</details>
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
(No `packages/spec` change.)
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes.
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: not touched, nothing to regenerate.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English (the maintainer brief
is in Chinese, per AGENTS.md).
---
_Generated by [Claude
Code](https://claude.ai/code/session_01QUs3Bs4LxJh9Md3CJueiEQ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(objectql)!: uninstalling a base package is refused while its paired `.custom` package is installed, naming the pair (409, the extender-refusal family) (#28408)
6
+
7
+
Clause-②: no (narrowing) — `SchemaRegistry.assertPackageUninstallable`, and so `uninstallPackage`, `unregisterObjectsByPackage` without `force`, the protocol's `deletePackage` and `DELETE /api/v1/packages/:id`, now refuse the uninstall of a base while the registry holds its paired customization package (`BASE.custom`), an uninstall that succeeded before; nothing is widened.
8
+
Metadata-protocol: none
9
+
Skills-docs: none — no file under `skills/**` teaches uninstalling a package, so every taught use stays true.
10
+
11
+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: a registry state that refuses an uninstall gains one more cause, answered with the existing RESOURCE_CONFLICT / 409 refusal, so there is nothing for a migration to rewrite and no tombstone. -->
12
+
13
+
**BREAKING** accept-set narrowing at the package uninstall (`@objectstack/objectql`, ADR-0146 D3).
14
+
15
+
-**Before.** A base package uninstalled while its paired customization package (`hotcrm.custom` for `hotcrm`) was installed. Every customization in the pair was stranded: nothing served it, and nothing said why.
16
+
-**Now.** The uninstall is refused `409 RESOURCE_CONFLICT`, in the same refusal an extended object already gets (ADR-0029), before anything is removed. The message names the pair and opens with the way out: delete the pair first, exporting it before that to keep its customizations (`GET /api/v1/packages/PAIR/export`). When the base is also extended, one refusal names the extenders and the pair. The thrown error carries the pair as `customizationPackageId`.
17
+
-**Unchanged.** Uninstalling the pair itself; a base with no installed pair; `force`, which still means not asking; a dependent that is not the base's own pair (ADR-0141 D3 still defers it). On `DELETE /api/v1/packages/:id` a base the deployment ships is still refused `422 WRITABLE_PACKAGE_REQUIRED` first.
18
+
19
+
**The remedy.** Export the pair if you want to keep its customizations, delete it, then uninstall the base.
0 commit comments