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 aa31569
Browse filesBrowse the repository at this point in the historyBrowse files
fix(objectql): withdrawing an extender package restores the owner's bridged object copy, so GET /meta/object/NAME drops the extension field at once (#24221)
Fixes#24182
Clause-②: no
## What this changes
After `DELETE /api/v1/marketplace/install-local/:manifestId` of a
package that extends another package's object, `GET
/api/v1/meta/object/NAME` now serves the owner's body without the
withdrawn extension's fields at once. The object listing and the data
plane already did. A restart is no longer needed.
- `packages/objectql/src/plugin.ts` (the bridge's owner):
- `bridgeManifestObjectsToMetadataService` now records, for each package
it bridges as an EXTENDER, the names of the objects whose copy it
rewrote. Those are copies stamped with another package's id (the
owner's) and re-registered with the merged body. The record is the new
field `extendedObjectCopies`.
- New `restoreExtendedObjectCopies(ctx, packageId)`, exposed on the
`manifest` service beside `register`. Once the registry no longer holds
the package, it re-registers each recorded copy with the registry's
current definition (`currentObjectCopyBody`, the same shape the bridge
writes), through the bridge's own verb (`register(..., { notify: false
})`). It adds no second merge.
- Same rule as the bridge's overwrite: only a copy whose `_packageId` is
the registry owner's id is written, so an authored copy stays. A name
the registry no longer serves is left to the owner's own withdrawal (PR
#24181's half). A package the registry still holds is not withdrawn, and
nothing is done.
- Never rejects. A copy that cannot be written is logged once at `error`
with the consequence and the remedy (restart), the level
`followRuntimeObjectCopy` uses for the same registry/copy disagreement.
- `packages/cloud-connection/src/marketplace-install-local-plugin.ts`:
**the claim's declared cross-lane edit, the call only.** Right after
`withdrawObjectCopies`, and only when the registry withdrew, the DELETE
handler calls `manifest.restoreExtendedObjectCopies(manifestId)`. The
DELETE answer is unchanged.
- Pins: `packages/objectql/src/plugin.integration.test.ts` (the bridge
tests, two cases) and
`packages/cloud-connection/src/marketplace-install-local-uninstall-meta-reads.test.ts`
(beside the #24070 pin, one case through the real DELETE). That file's
`install`/`uninstall` helpers now take an optional manifest and id; the
existing four cases call them unchanged.
- `.changeset/24182-extender-withdrawal-restores-owner.md`: `patch` for
`@objectstack/objectql` and `@objectstack/cloud-connection`.
## The mechanism, measured
- **H1, confirmed.** After `registry.uninstallPackage(EXTENDER)` the
registry already serves the owner's body without the extension field.
The objectql pin asserts it: `registry.getObject('crm_contact').fields`
has no `ext_score`. The stale state is only the owner's metadata-service
copy, which the extender's bridge rewrote under the owner's stamp. The
counterpart re-puts the registry's current definition, with no second
merge.
- **H2, falsified: there is no seam.** The install-local DELETE fires no
kernel event: no `ctx.trigger` in the uninstall path, and
`metadata:reloaded` is announced only by the hot install.
`SchemaRegistry.uninstallPackage` emits nothing. And after the
withdrawal the registry no longer says which objects the package
extended. So the bridge has to remember what it rewrote, and the door
that withdrew the package has to call its counterpart. The call is the
one cloud-connection line block named above.
- **H3, read from source, not measured live.** The protocol door `DELETE
/packages/:id` (`packages/runtime/src/domains/packages.ts`) also calls
`registry.uninstallPackage` and does not call the counterpart. It
refuses an install-local package with `422 WRITABLE_PACKAGE_REQUIRED`
(measured by PR #24181). A writable package's objects reach the metadata
service through `followRuntimeObjectCopy`, not through this bridge, so
the record holds nothing for it. Package disable of an extender was not
measured. Both are in Acceptance notes, not widened into this PR.
- **H4, the live re-measure** is below: the card's setup reproduces on
the base, and the head fixes it.
## Tests (head `3a452eeae`)
| run | result |
|:--|:--|
| objectql pin (`-t extender`, 2 cases) and cloud-connection meta-reads
file (5 cases) | all pass |
| ablation: the counterpart's guard line replaced by an unconditional
early return (marker `ABLATION_24182`) via
`scripts/ablation-replace.mjs` (anchor 1 to 0, blob `8fbce83c` to
`baaba360`), `@objectstack/objectql` rebuilt, `ablation-dist-preflight`
marker present (exit 0) | **red**: objectql `serves the owner's body
without the withdrawn field` failed (1 failed / 1 passed);
cloud-connection `owner + extender installed, DELETE the extender`
failed (1 failed / 4 passed). Both controls stayed green |
| restore | `ablation-replace` restored: blob equals HEAD `8fbce83c`,
`git diff HEAD` empty; rebuilt, `ablation-dist-preflight --absent` exit
0 |
| `pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2` |
438 files / 8243 tests passed |
| `pnpm --filter @objectstack/cloud-connection exec vitest run
--maxWorkers=2` | 51 files / 601 tests passed |
| `pnpm --filter @objectstack/objectql typecheck` and `pnpm --filter
@objectstack/cloud-connection typecheck` | exit 0; objectql's test
layer, which compiles `plugin.integration.test.ts` under
`tsconfig.test.json`, held its debt |
| `pnpm exec eslint --no-inline-config` over the four changed `.ts`
files | exit 0 |
The full-suite runs were taken before the last commit, which only types
two slot lookups in the objectql pin. After it, the objectql typecheck
and the whole `plugin.integration.test.ts` (46 / 46) were re-run green.
## The public door, before and after (live)
Setup: `pnpm dev:crm -- --fresh -p PORT` from this worktree with
`OS_CLOUD_URL=off` (the real `MetadataPlugin` and
`MarketplaceInstallLocal`), signed in as the seeded dev admin. Package A
`com.example.crmlite` (objects `crmlite_account`, `crmlite_contact`) and
package B `com.example.crmliteext` (only an `objectExtensions` field
`ext_score` on `crmlite_account`) are installed inline through
install-local. Then B is DELETEd.
- **Base** (`packages/objectql/src/plugin.ts` and the install-local
plugin restored in the tree to `bb7ccde66`'s blobs, both packages
rebuilt, `ablation-dist-preflight --absent` on
`restoreExtendedObjectCopies` exit 0 for both):
- before the DELETE, `GET /meta/object/crmlite_account` is `200` with
`ext_score` and `name`;
- `DELETE` answers `200`, `cleanups: [security.package-permissions:
success]`;
- after it, `GET /meta/object/crmlite_account` is **`200` with
`ext_score`**. `GET /meta/object` lists `crmlite_account` without
`ext_score`, and `GET /data/crmlite_account?fields=ext_score` answers
`400`. This is the card's reading.
- **Head** (rebuilt, preflight marker present in both `dist/`), the same
sequence:
- after the DELETE, `GET /meta/object/crmlite_account` is `200`
**without `ext_score`**, `name` kept, `_packageId: com.example.crmlite`;
- the listing and the data plane read as on the base, and the DELETE
answer is byte-identical.
- One deviation from the card's setup: `os dev` (through `pnpm
dev:crm`), not `os start`. Both arm the late bridge this fix lives in.
- The probe script lived in the session scratchpad. Each server was
stopped by its recorded process group, and the tree was restored to HEAD
(`git diff HEAD` empty) before anything else.
## Gates (after the final commit, `3a452eeae`)
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` (no paths) derived 67 commands. All 67 exit
0, each code captured before any pipe.
- On the first pass, `check:slot-lookup` was red: this PR's objectql pin
added two untyped `getService` lookups (23 to 25). They are typed now
(`IMetadataService`, `ObjectQL`). The ratchet holds at 101 unswept, none
new.
- `check-engine-split-ratio --days 90` (exit 2) and
`check-plugin-teardown-shape --self-test` (exit 3) refused on the
shallow clone. After `git fetch --shallow-since=2026-07-12 origin main`,
both exit 0.
- `dispatch-gates.mjs --ran`: "67 derived, 67 run, 0 NOT-MEASURED, 0
UNRUN", a derived zero (all 67 carry an exit code).
- Not run locally and declared to CI: `test:repo`, the CLI `integration`
tier (no spawn entry touched), the type-check lanes beyond the two
packages' own `typecheck`, the dogfood shards and the repo-wide `pnpm
lint`.
## Acceptance notes
- **Read-only inference, not measured.** A hot upgrade of an extender
whose new version DROPS an extension re-bridges only the objects the new
version still contributes to. The owner's copy would keep the dropped
field until the extender is uninstalled (the record keeps the name, so
this counterpart then restores it) or the runtime restarts. That is the
same family on another door. Not fixed here.
- **Read-only inference, not measured (H3).** `DELETE /packages/:id`
withdraws through the same registry verb without calling this
counterpart. Today no bridged extender reaches it: install-local
packages are refused there, and writable packages are not bridged by
this path. Package disable of an extender was not measured.
- The record is per process. A copy rewritten by the boot-time one-shot
bridge is not recorded. Every install-local package (hot install and
ledger rehydrate) registers after `start()` has armed the late bridge,
so its rewrites are recorded. A boot-time code package is not withdrawn
by this door.
---
_Generated by [Claude
Code](https://claude.ai/code/session_016fHjgVn91KVgq3oTNuGhsu)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(objectql): withdrawing a package that extends another package's object puts the owner's definition back at `GET /api/v1/meta/object/NAME` at once, not at the next restart
9
+
10
+
-**What was wrong.** When a package whose content is an `objectExtensions` field on another package's object is registered on a started kernel, the ObjectQL bridge rewrites the OWNER's copy of that object in the `metadata` service with the merged body, extension included. `DELETE /api/v1/marketplace/install-local/:manifestId` of the extender withdrew its contribution from the registry, so the object listing and the data plane dropped the field, but nothing rewrote the owner's copy. The by-name read asks that copy first, so `GET /api/v1/meta/object/NAME` kept serving the withdrawn field until the runtime restarted.
11
+
-**What changes.** The ObjectQL bridge remembers which owners' copies each package's extensions rewrote, and the `manifest` service gains `restoreExtendedObjectCopies(packageId)`: once the registry no longer holds the package, it re-registers each such copy with the registry's current definition. The install-local DELETE calls it right after the registry withdrawal. The by-name read then serves the owner's body without the extension's fields, as the listing and the data plane already did.
12
+
-**Not touched.** Only a copy stamped with the registry owner's id is rewritten, the same rule the bridge overwrites by, so an authored copy stays. The DELETE answer is unchanged. A copy that cannot be restored is logged once at `error`, naming the objects and the remedy: restart the runtime.
0 commit comments