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 989aa48
Browse filesBrowse the repository at this point in the historyBrowse files
fix(objectql): an uninstall refused because another package extends one of its objects answers 409 RESOURCE_CONFLICT with the way out, not 500 INTERNAL_ERROR (#23522)
Fixes#23505
Clause-②: no
Dispatched by the `domain:spec` seat 4 PM (`sunhaolin`) to session
`session_0121ooBYRmS8ziFXm9ai5YHm`, branch
`claude/issue-23505-extension-refusal-409`, claim comment 6172782630.
## What changed
`SchemaRegistry.assertPackageUninstallable`
(`packages/objectql/src/registry.ts`) threw a bare `Error` with no
`code` and no `status` when the package owns an object another package
extends (ADR-0029). `deletePackage` step 3 rethrows it as is, so `DELETE
/api/v1/packages/:id` fell through `resolveThrownHttpError` to its `500
INTERNAL_ERROR` fallback. The caller could change the state that blocked
the uninstall, but the answer said the server failed.
The fix is at the producer. The refusal is now an unexported
`PackageUninstallExtendedError`, in the registry's house shape (`code`,
`status` and `httpStatus`, like `NamespaceConflictError` and
`ObjectOwnershipConflictError`):
- **`code: 'RESOURCE_CONFLICT'`, `status: 409`.** No new code: this is
the standard catalog member that the verb's sibling refusals already
answer for environment state (`dropStorageRemainingRowsRefusal`, and the
shared-object pre-flight in PR #23504).
- **The way out comes first**, because a 4xx message can be truncated.
The diagnostic sentence the refusal always carried follows, and it now
names every extended object (the first five, then a count of the rest),
each with its extenders in registration order. Measured at the door:
> Uninstall the package that extends an object of "com.owner" first
(com.ext), then uninstall "com.owner" again: a package cannot be
uninstalled while another package extends an object it owns
(objectExtensions, ADR-0029). Cannot uninstall package "com.owner":
object "alpha" is extended by com.ext.
- **Structured fields** for in-process callers: `packageId`,
`extendedObjects`, `extenders`.
The decision itself is unchanged: a package is refused in the same cases
as before, and before any mutation.
`packages/metadata-protocol/src/protocol.ts` is not edited. Step 3
already rethrows the registry's error as is, so the door gets the status
without touching steps 1–4, which PR #23504 holds.
## Every caller, measured
| Caller | How it meets the throw | What changes |
|---|---|---|
| `deletePackage` step 3, then `DELETE /api/v1/packages/:id`
(`errorFromThrown(e, 500)`) | rethrown as is | `500 INTERNAL_ERROR`
becomes `409 RESOURCE_CONFLICT` with the prescription. Nothing changes
in storage or the registry (pinned below). |
| The same door on a host with no persisted half (the door calls
`registry.uninstallPackage(id)` itself; terminal `errorFromThrown`) |
thrown through `uninstallPackage` | Also `500` to `409
RESOURCE_CONFLICT`. The package and its object stay registered. Measured
with a one-off probe, deleted after reading. Not pinned. |
| The door's safety net after a persisted delete, and `deletePackage`
step 6 | caught; `console.warn` with the message | Log text only. |
| `@objectstack/cloud-connection` install-local withdrawal | caught;
reads `err.message` only | The `cleanups` outcome is unchanged; only the
log text changes. |
| `MetadataFacade.unregisterPackage`, `ObjectQL.unregisterObject(name,
packageId)` | propagate | No production caller in the tree (`git grep`).
|
| `ObjectQL.removePackage` | `force: true` | Never asks. |
## Tests
Every reading below is at `fdc2dc05fc` (this branch after merging
`origin/main` at `222293f60a`), except the full objectql run, which is
noted.
-
**`packages/runtime/src/package-uninstall-store-refusal.integration.test.ts`**
(the existing pin). It runs on a real stack: `ObjectQL` over
`SqlDriver`, `PackageServicePlugin`, the protocol and `HttpDispatcher`,
booted twice over one SQLite file.
- The extender case moves from `{ status: 500, code: 'INTERNAL_ERROR' }`
to `{ status: 409, code: 'RESOURCE_CONFLICT' }`. It also asserts the
opening of the prescription and the object's name.
- Every "nothing changes" assertion is kept: stored rows, registry entry
and disable record, in the same process and after a restart.
- New **CONTROL** case: the extender is installed as a real package, so
the first uninstall is refused `409`. Then `DELETE` of the extender
through the same door, and the same uninstall answers `200` and stays
gone after a restart.
- Result: 5/5 pass.
-
**`packages/objectql/src/registry-assert-package-uninstallable.test.ts`**
pins:
- the envelope;
- the prescription first;
- every object and extender named, in walk order;
- the singular wording;
- the five-then-count cap;
- the same refusal through `unregisterObjectsByPackage` and
`uninstallPackage`.
- **`packages/objectql/src/registry.test.ts`**: the order pin added for
the refusal-pass ordering fix now reads the order off the structured
refusal (first refusable object first, its extenders in registration
order) instead of the old sentence.
| Run | Result |
|---|---|
| runtime `package-uninstall-*` + `domains/packages-*` | 29 files / 537
tests pass |
| runtime pin + `package-uninstall-org-scope` | 2 / 11 pass |
| objectql `registry*.test.ts` + the two objectql tests the merge
brought | 37 / 480 pass |
| metadata-protocol `deletePackage` tests | 10 / 140 pass |
| objectql `vitest run --project local` (at `a9a88b1e34`; the merge
changed no objectql source, only the two tests re-run above) | 415 /
8095 pass |
| `pnpm --filter @objectstack/objectql typecheck`, same for runtime |
exit 0; test-typecheck debt held |
## Ablation (the producer's `code` and `status` renamed away)
The mutation went through `scripts/ablation-replace.mjs` (anchor hit 1
time; blob `69df6079e5a7` to `7d89ea3120fa`). objectql was rebuilt, and
`ablation-dist-preflight` found the planted marker in 4 dist files.
- **Re-pinned case: red on exactly the status and code assertion.** It
expected `{ status: 409, code: 'RESOURCE_CONFLICT' }` and received `{
status: 500, code: 'INTERNAL_ERROR' }`, which is the pre-fix answer.
- **CONTROL: red on its precondition** (`expected 500 to be 409`).
- **Envelope test in `registry-assert-package-uninstallable.test.ts`:
red.**
- **Restore.** The blob equals HEAD, `git diff HEAD` is empty and `git
status --porcelain` is empty. After an objectql rebuild, `--absent`
found the marker in 0 of 14 dist files, and the pin passed 5/5 again.
## Gates
`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--ran` at `fdc2dc05fc`: 68 derived, 67 run with `exit 0`, 1
NOT-MEASURED, 0 UNRUN.
- **NOT MEASURED: `pnpm check:dual-build-cjs-loads`.** It answered
`PREREQUISITE NOT MET` (exit 3) because it reads every package's `dist/`
and no full workspace build was run locally. CI runs it.
- **The families this change reads most directly:**
- `check:dispatcher-error-vocabulary` is green. `RESOURCE_CONFLICT` is a
standard member, so no table row is owed.
- `check:error-status-conformance` is green. Its deriver records
`RESOURCE_CONFLICT`/409 from `class PackageUninstallExtendedError`; I
measured this by calling `deriveRuntimeStatuses` on `registry.ts`,
because the report shows only four sites per code.
- `check-adr-0087-registration` reports "no declared-breaking changeset
(1 non-breaking changeset(s) seen)".
- `check-changeset-no-major` and `check-empty-changeset` are green.
- **Extra readings outside the derived set:**
`check:error-code-provenance` and `check:error-code-casing`, both `exit
0`.
The changeset is `.changeset/23505-extension-refusal-409.md`:
`@objectstack/objectql` patch, with FROM, TO and Unchanged sections.
## Acceptance notes
- **Two comments now say `500` where the door answers `409`.** Both are
outside this claim's file surface and change no behaviour:
- In `packages/metadata-protocol/src/protocol.ts`, `deletePackage` step
3: "The HTTP door answers it through its `catch` around this verb:
`500`, nothing changed." PR #23504 holds that block.
- In `packages/runtime/src/domains/packages.ts`, in the DELETE arm:
"which the `catch` above answers — `500`, nothing changed."
- Suggested wording for both: `409 RESOURCE_CONFLICT`, the refusal's own
envelope, nothing changed. Carrier: none.
- **A test fixture keeps the old text.**
`packages/metadata-protocol/src/protocol.package-delete-refusal.test.ts`
builds its fake refusal with the old sentence. It pins pass-through
identity, not the text, so it stays valid.
- **"Detach" is not offered as a way out.** No verb detaches an
extension short of uninstalling the extending package, or installing a
version without it (not measured), so the message names only the
measured remedy.
- **The no-persisted-half door path** (second row of the table) moves
`500` to `409` too. It was measured, not pinned.
---
_Generated by [Claude
Code](https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(objectql): an uninstall refused because another package extends one of its objects answers `409 RESOURCE_CONFLICT` with the way out, instead of `500 INTERNAL_ERROR` (ADR-0029)
6
+
7
+
Clause-②: no
8
+
9
+
`SchemaRegistry.assertPackageUninstallable` refuses to uninstall a package while another package extends an object it owns (a manifest's `objectExtensions`, ADR-0029). `unregisterObjectsByPackage` and `uninstallPackage` raise the same refusal, because they ask it. When the refusal happens does not change, and a refused uninstall still removes nothing. Only the error it throws changes.
10
+
11
+
**FROM.** The error was a bare `Error` with no `code` and no `status`. Its message, `Cannot uninstall package "P": object "O" is extended by E. Uninstall extenders first.`, named only the first extended object. `DELETE /api/v1/packages/P` answered `500 INTERNAL_ERROR`, a server failure, although the caller can change the state that blocks the uninstall.
12
+
13
+
**TO.** The error carries `code: 'RESOURCE_CONFLICT'` and `status: 409` (and `httpStatus: 409`), so `DELETE /api/v1/packages/P` answers `409 RESOURCE_CONFLICT`. The message starts with the way out: `Uninstall the package that extends an object of "P" first (E), then uninstall "P" again: …`. It then names every extended object with the packages that extend it: the first five objects, then a count of the rest. The thrown error also carries `packageId`, `extendedObjects` and `extenders`.
14
+
15
+
**Unchanged.** A package is refused in the same cases as before. A refused uninstall changes nothing: the `sys_packages` row, the registry entry, the stored metadata rows and the disable record all stay. `unregisterObjectsByPackage(id, true)` still does not ask.
16
+
17
+
**If you matched the old text.** Match `error.code === 'RESOURCE_CONFLICT'` (HTTP `409`) instead. The message still contains `Cannot uninstall package "P": object "O" is extended by E`, after the way out. The closing `Uninstall extenders first.` is gone.
Copy file name to clipboardExpand all lines: packages/objectql/src/registry.ts
+91-7Lines changed: 91 additions & 7 deletions
Original file line number
Diff line number
Diff line change
@@ -2002,6 +2002,82 @@ export class ObjectOwnershipConflictError extends Error {
2002
2002
}
2003
2003
}
2004
2004
2005
+
/** One object {@link PackageUninstallExtendedError} names, with the packages extending it. */
2006
+
interfaceExtendedObjectOfUninstall{
2007
+
/** The object's fully-qualified name, as the registry keys it. */
2008
+
readonlyobjectName: string;
2009
+
/** Every OTHER package contributing an `extend` to it, in registration order. */
2010
+
readonlyextenders: readonlystring[];
2011
+
}
2012
+
2013
+
/** How many extended objects the refusal spells out before it counts the rest. */
2014
+
constEXTENDED_OBJECTS_LISTED=5;
2015
+
2016
+
/**
2017
+
* [#23505] The uninstall refusal of ADR-0029: a package cannot leave while
2018
+
* another package `extend`s an object it owns (a manifest's
2019
+
* `objectExtensions`, registered by `ObjectQL.registerApp`). Raised by
2020
+
* {@link SchemaRegistry.assertPackageUninstallable}, and so by
2021
+
* {@link SchemaRegistry.unregisterObjectsByPackage} and
2022
+
* {@link SchemaRegistry.uninstallPackage}, which ask it.
2023
+
*
2024
+
* Carries the ADR-0112 envelope (`code` + `status`), like the registry's other
2025
+
* refusals above. Before it carried one, this was a bare `Error`, and
2026
+
* `DELETE /api/v1/packages/:id` — which reaches it through `deletePackage`'s
2027
+
* pre-flight, or through `uninstallPackage` on a host with no persisted half —
2028
+
* fell through `resolveThrownHttpError` to the door's `500` fallback: measured
2029
+
* on the real stack as `500 INTERNAL_ERROR`, which tells a client, an AI author
2030
+
* or an operator that the server failed, invites a retry, and changes nothing.
2031
+
* The state is the caller's to change: uninstall the extending packages first.
2032
+
*
2033
+
* `RESOURCE_CONFLICT` / 409, no code minted: the standard catalog's member for
2034
+
* a refusal that comes from environment state rather than the request (the
2035
+
* same uninstall is accepted once the extenders are gone), as the uninstall
2036
+
* verb's sibling refusals in `@objectstack/metadata-protocol` answer. The
2037
+
* prescription comes first, because a 4xx message can be truncated at a REST
2038
+
* boundary; the diagnostic sentence the refusal always carried follows it.
2039
+
*
2040
+
* Every extended object is named (the first {@link EXTENDED_OBJECTS_LISTED},
2041
+
* then a count), in the order the registry walks them, each with its extenders
2042
+
* in registration order — and the prescription names every extending package,
2043
+
* so one answer is the whole way out rather than the first step of several.
2044
+
*
2045
+
* ⛔ Unexported, like its siblings: a consumer compares `code`, never
2046
+
* `instanceof` (see the note over the `*_CODE` constants).
2047
+
*/
2048
+
classPackageUninstallExtendedErrorextendsError{
2049
+
readonlycode='RESOURCE_CONFLICT';
2050
+
readonlystatus=409;
2051
+
/** The same number under ADR-0112 D5's spelling — what a consumer holding the THROWN error reads (the CLI `--json` envelope). `status` stays for the HTTP doors, which read it. */
2052
+
readonlyhttpStatus=409;
2053
+
/** The package whose uninstall this refusal stopped. */
2054
+
readonlypackageId: string;
2055
+
/** Every object of `packageId` another package extends, in walk order. */
0 commit comments