Skip to content

Commit 9052337

Browse files
fix(runtime)!: the package enable door refuses while a rule it would serve reads a field a disabled package withholds (#31018)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoMtM8khPb6BVweLak3Dt7Fg)_ Fixes #30756 domain:server#2 · os-tesla · session_01L9qibxjrKo69n5BEsgi5qA Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: updated here — `content/docs/api/metadata-api.mdx` (the enable door's entry), `content/docs/deployment/marketplace-handbook.mdx` (the Enable-and-disable step and the Disabling-a-module list) and `content/docs/api/error-catalog.mdx` (the code's 409 entry) name the refusal; `skills/**` teaches no package enable door (grep for `packages/:id/enable`, `enablePackage`, `enableOnInstall`: none), so no skill or eval moves. Claim: 6689904888 (amended by the seat: `registry.ts` `asEnabled`, the `enableOnInstall: true` overwrite arm, the three docs pages) · Branch: `claude/project-thread-fyy60n` · Mirror of: #30379 ruling A (record 6670643454), PR #30825 · Error code: reused, `PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE` (#30724) · Serial: #30962 (same handlers, persistence lines untouched here) ## 维护者速读 - 改了什么:模块 X 停用时,再启用一个"有校验规则读 X 加到别的对象上的字段"的包 P,现在会被拒绝(409,沿用停用门的错误码 `PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`),提示逐条列出「包 · 对象 · 规则」并点名要先启用的 X,什么都不改。客户在自己那份对象上关掉的规则算关闭;只判这次启用新带上线的规则。`POST /packages` 以 `enableOnInstall: true` 覆盖一个已停用的包同样判断:装上,但保持停用,答同一个 409。启动时若持久化状态已经落在这种状态(状态文件只记停用),记一条 warn 列出规则和补救办法,不拒绝启动、不改运营者的选择。 - 为什么改:以前这种启用答 200,之后该对象的每一次写入都 400 VALIDATION_FAILED,而且报错说"对象没声明这个字段",管理员看不出是 X 停用造成的。停用门(#30379)已经拦了反方向,这是对称的那一半。 - 风险与回滚:收窄(BREAKING):原来能启用的这一种情况现在被拒。判断读注册表自己的折叠结果(新增 `asEnabled` 选项,不传时折叠结果与现在逐字节相同,有测试钉住)。回滚=revert 本 PR。 - 先看:`packages/runtime/src/domains/packages.ts` 的 `readEnableDependentRules` / `readWithheldFieldReaders`(启用前后两次折叠求差)和启用门、安装门 `true` 分支;`packages/objectql/src/registry.ts` 的 `asEnabled`;`packages/runtime/src/app-plugin.ts` 的 `reportWithheldFieldReadersAtBoot`。 - 两分钟验证:`pnpm --filter @objectstack/runtime exec vitest run src/domains/packages-disable-module-extension-fields.test.ts`,预期 27 条全绿。 - 需要你做的:无,席位落地。 ## What changes **`@objectstack/runtime`: `PATCH /api/v1/packages/:id/enable`** (`domains/packages.ts`) - After the walled-catalog and query checks, and before `registry.enablePackage` and `setPackageDisabled`, the door asks `readEnableDependentRules(registry, id)`. If that is non-empty it answers `409` with `error.code` `PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE` and nothing changes (row, served objects, state file). The message names every rule as `package · object · rule (reads FIELD)` and the disabled package(s) to enable first: `Package 'P' cannot be enabled: 3 active validation rules the enable would serve read fields that a disabled package adds through objectExtensions, which stay withheld — … . Switch off or change those rules first, or enable 'X' first.` No `error.details` beyond the code, as on the disable door. - **Which rules count.** Judged on the registry's own fold, twice: the fold the enable would leave (`getAllObjects(undefined, { asEnabled: id })`: P's `extend` contributions folded in, every other disabled package's withheld) against the fold served now (`getAllObjects()`). A field is withheld from an object when the storage fold (`getStorageObject`, every contribution) declares it and the served fold does not, which is the registry's own reading (`withholdDisabledExtensions`), not re-assembled. A rule counts only when it reads a withheld field after the enable and did not before, so an enable is never refused over a rule it does not bring into service. That is P's own rules (a copy that replaces a served rule by name included) and every rule on an object P owns (which comes into service with it). One rule per name, the copy that wins; a tenant overlay's `active: false` honoured; a rule is named after the last layer that declares it (`ruleLayerLabel`, shared with the disable door). Reads use the disable scan's `addRuleFieldReads` (own column and one hop through a reference field). - **Error code: reused, no new code.** The refused state is the one the disable door refuses: a disabled package's withheld field under an active rule. The enable door refuses entering it from the other side. The code is held as the same wire pair `{ code, status: 409 }`. **`@objectstack/runtime`: `POST /api/v1/packages` with `enableOnInstall: true`** (mirror of the #30825 `false` arm) - The `true` arm asks the same scan before `enablePackage`. It is non-empty only on an overwrite of a DISABLED row (the scan is empty for a package that is not disabled, which covers a fresh install). On a refusal the install has landed, the package stays disabled (row and state file follow `pkg.enabled`), and the door answers the same `409` with the lead `Package 'ID' was installed and stays disabled: enableOnInstall: true cannot enable it`. The shared variable is renamed `installDisableRefusal` → `installLifecycleRefusal`. **`@objectstack/runtime`: the boot** (`app-plugin.ts`, the card's "boot replay path") - Measured reachable: the environment's state file records only DISABLES (`loadDisabledPackageIds` → `setInitialDisabledPackageIds`), so a package that is not listed boots enabled. A state file listing X, with P booting enabled and P's rule reading X's field (a release that added the rule, or a state file written before this release), lands in the refused state without passing any door. Pinned: the harness boots exactly that and a write then answers `400 VALIDATION_FAILED` with the validator's own text "The predicate reads 'qx_credit', which this object does not declare — fix the rule's condition, or declare the field", which points the operator at the wrong repair. - **Decision: say so once, loudly; neither refuse nor rewrite.** The boot has no enable act to refuse, refusing the whole boot would trade one object's writes for the whole runtime, and re-enabling X or disabling P would silently override the operator's recorded choice. `AppPlugin` registers one `kernel:bootstrapped` read per registry (after the `kernel:ready` rehydrates have registered every package, so it is the boot's final reading, not a verdict a later registration contradicts) of `readServedWithheldFieldReaders`, and warns once naming every rule, the objects whose writes are refused, and the three remedies. `warn`, not `error`: every refused write tells its requester, so nothing claims to persist that did not (AGENTS.md degradation rule). **`@objectstack/objectql`: `ObjectExtensionFoldOptions.asEnabled`** (`registry.ts`) - Optional key: `getAllObjects(undefined, { asEnabled: id })` folds a DISABLED package's `extend` contributions in as if it were enabled (`withholdDisabledExtensions(contributors, asEnabled)` exempts it), every other disabled package's stay withheld, uncached, and no registry state moves. Unset, or naming a package that is not disabled, the fold is the cached one, byte for byte (pinned). **Docs**: `metadata-api.mdx` (enable door entry), `marketplace-handbook.mdx` (Enable-and-disable step, Disabling-a-module list), `error-catalog.mdx` (the code's Cause and Fix name the enable door and the install `true` arm). The wire-code count does not move (no new code). **Changeset**: `@objectstack/runtime` and `@objectstack/objectql` patch, BREAKING banner, `Clause-②: no (narrowing)`, `Metadata-protocol: none`, ADR-0087 disposition `not-required (no-migration-prescription)`. ## Tests `packages/runtime/src/domains/packages-disable-module-extension-fields.test.ts` (the #30362/#30379 harness: real `HttpDispatcher` doors over a file-backed SQLite kernel), 27 cases, 7 new or flipped: - **Flipped** (`[#30379]` describe): the pin that measured "a reading package enabled while the field stays withheld refuses writes (400)" now asserts the enable answers `409` with the code, the package stays disabled, and a write lands. - `[#30756] the enable door refuses …`: - **CONTROL**: with the field's package enabled, disable then enable of the rules package answers `200` as before, state file not listing it. - **Refusal**: rules package off, then the field's package off (the order the disable door allows); the enable answers `409` + code, the message is asserted whole (three rules in order, including the hop `account.qx_credit` on the visit, the switched-off rule absent, `enable 'com.qa.extsuite.orders' first`), `error.details` absent, the row stays disabled, the rule is not served, a write lands, the state file still lists the package. - **CONTROL**: tenant overlays switch the three rules off (`active: false`); the enable then answers `200`, the packaged rule is served switched off, a write lands. - `[#30756] POST /packages with enableOnInstall: true …`: an overwrite of a disabled reader with `enableOnInstall: true` answers `409` (message asserted whole), stays disabled, state file lists it, the rule is not served; **CONTROL** with no reading rule: `201`, enabled, state file clear. - `[#30756] a boot whose state file leaves …`: state file disables the field's package, the reader boots enabled; exactly one warning, asserted whole; a write answers `400 VALIDATION_FAILED`; enabling the field's package (one named remedy) lets a write land. **CONTROL**: a boot with nothing disabled warns nothing. `packages/objectql/src/registry-disabled-package-extensions.test.ts`, new top-level describe `[#30756] the asEnabled fold` (19 cases in the file): the named disabled package's field and rule fold in, another disabled package's stay withheld, the package stays disabled and `getObject` is unchanged; **CONTROL**: unset, `{}`, a package that is not disabled and an unknown id each serve `JSON.stringify`-identical folds. **Ablation** (the refusal, once, through `scripts/ablation-replace.mjs`, runtime source resolved directly by the relative import, so no dist leg): `const dependents = readEnableDependentRules(registry, id);` → `const dependents: EnableDependentRule[] = []; void readEnableDependentRules;`, anchor 1 → 0, blob `c38a69765e94` → `072561ead485`. Result: 2 failed | 25 passed, both reading `expected { status: 200, code: undefined } to deeply equal { status: 409, … }` (the flipped pin and the refusal pin; the two controls stay green, as they must). Restored: blob == HEAD `c38a69765e94`, `git diff HEAD` empty. ## Acceptance notes - **Out of scope, not measured:** the cloud route `/api/v1/cloud/environments/:env/packages/:pkg/disable` (and any cloud enable route) — named on the card as unmeasured; carrier: cloud. - **Not covered by this door (noted, not filed):** a FRESH install of a package whose rule reads a field a disabled package withholds lands enabled (an install with no `enableOnInstall`, or `true` on a new id) and reaches the refused state through the install door, not the enable door. The install has already registered the package when any scan could run, so the honest answer there is its own decision (refuse after landing, or land disabled). The boot warning names the state on the next restart. Carrier: seat. - **Spec prose (spec lane):** the ledger comment on `PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE` in `packages/spec/src/api/error-code-ledger.zod.ts` says it "names WHY a disable is refused"; it now also answers the mirror enable. No spec edit here (claim). Carrier: spec lane. - **Duplication:** `readWithheldFieldReaders` repeats the disable scan's per-rule judging loop (about 25 lines) rather than refactoring `readDisableDependentRules`, which the #30379 contract review settled; the two differ in how the withheld set is read (the disable door's fields of one package plus its taken-off rules, here the storage/served difference of the whole state). - **Serial with #30962:** the persistence lines (`setPackageDisabled` try/catch) in both handlers are untouched; whichever PR opens second merges `origin/main` after the first lands. - The assignee (os-tesla, the claim's account) rides on the relay `pr_create`; no reviewers are requested, and the harness's MCP assignee/reviewer step is skipped per the frame. ## Verification <details><summary>Local gates on 08be854 (pre-merge head), 95 derived families</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` → 95 commands, run each with stdin redirected from /dev/null after a full build (`env -u OS_SKIP_DTS pnpm turbo run build --filter='!@objectstack/docs' --filter='!@object-ui/*'`, 82/82). 95 exit 0; `check-plugin-teardown-shape.mjs --self-test` first exited 3 (pinned commit missing from the shallow clone), exit 0 after `git fetch origin 621a487 --depth=1`. `dispatch-gates.mjs --ran` → "95 derived famil(ies) accounted for — 95 run, 0 NOT-MEASURED". Lint: `npx eslint --no-inline-config packages/runtime packages/objectql` exit 0. Typecheck: `pnpm --filter @objectstack/objectql typecheck && pnpm --filter @objectstack/runtime typecheck` VERDICT command-exit 0. Suites: objectql `Test Files 559 passed (559)`, `Tests 11202 passed`; runtime `Test Files 550 passed (550)`, `Tests 8823 passed | 19 skipped`. </details> <details><summary>After merging origin/main (9788f1d)</summary> Reinstall, full rebuild (82/82), both typechecks exit 0, `registry-disabled-package-extensions.test.ts` 19/19, `packages-disable-module-extension-fields.test.ts` 27/27. Full dogfood (`pnpm --filter @objectstack/dogfood test` under the verify lock, no shard, no path) was still running on 9788f1d when this PR opened; its VERDICT line and counts are in the os-dev-report comment on the card. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibxjrKo69n5BEsgi5qA)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L9qibxjrKo69n5BEsgi5qA --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 468e0c1 commit 9052337

9 files changed

Lines changed: 754 additions & 38 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/objectql': patch
4+
---
5+
6+
fix(runtime)!: `PATCH /api/v1/packages/:id/enable` refuses while a validation rule it would serve reads a field a disabled package adds through `objectExtensions`
7+
8+
Clause-②: no (narrowing) — the package enable door, and the install door's `enableOnInstall: true` arm on an overwrite of a disabled package, refuse (`409` `PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`) an enable they accepted before: one that would put into service an active validation rule reading a field another, still disabled package withholds. The only addition is an optional `asEnabled` key on `@objectstack/objectql`'s object-fold options, read by the door; no request shape, response member or authorable key is added, and the code is the disable door's.
9+
Metadata-protocol: none
10+
Skills-docs: updated here — `content/docs/api/metadata-api.mdx` (the enable door's entry), `content/docs/deployment/marketplace-handbook.mdx` (the Enable-and-disable step and the Disabling-a-module list) and `content/docs/api/error-catalog.mdx` (the code's 409 entry) name the refusal; `skills/**` teaches no package enable door (grep for `packages/:id/enable`, `enablePackage`, `enableOnInstall`: none), so no skill or eval moves.
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped: an enable request that would have left an object refusing every write is refused before anything changes, with an error code already registered. Nothing at rest records that answer, so there is nothing for a migration to rewrite and no tombstone. -->
13+
14+
**BREAKING** accept-set narrowing on the package enable door and the install door's `enableOnInstall: true` arm on an overwrite of a disabled package (`@objectstack/runtime`, `domains/packages.ts`), for one case only: the mirror of the disable door's refusal.
15+
16+
- **Before.** With package X disabled, its `objectExtensions` fields are withheld from the object they extend. Enabling a package P whose validation rule reads one of those fields answered `200`, and from then on every write to that object was refused (`400 VALIDATION_FAILED`, the rule failing closed on a field "this object does not declare") for as long as X stayed off.
17+
- **Now.** The door refuses such an enable with `409` and `error.code` `PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`, before anything changes: the package row, the served objects and the environment's stored disable state stay as they were. The message names every rule as `package · object · rule (reads FIELD)` and the disabled package to enable first. The refusal carries no other `error.details`.
18+
- **Which rules count.** The rules the enable puts into service: judged on the fold the object will serve once P is enabled (`getAllObjects(undefined, { asEnabled: P })`, the registry's own fold with P's extensions folded in and every other disabled package's withheld) against the fold served now, so a rule counts only when it reads a withheld field after the enable and did not before. That is P's own rules (including a copy that replaces a served rule by name) and every rule on an object P owns, which comes into service with it. One rule per name, the copy that wins; a tenant overlay's `active` honoured, so a rule the tenant switched off does not refuse the enable. A field P itself also adds stops being withheld and refuses nothing.
19+
- **The install door too, on an overwrite of a disabled package.** `POST /api/v1/packages` with `overwrite` and `enableOnInstall: true` over a disabled package asks the same scan before it enables: the overwrite still lands, the package stays disabled, and the door answers the same `409` instead of a `201` claiming an enable. A fresh install is not disabled and is not judged.
20+
- **The boot.** The stored state records only disables, so a reading package boots enabled beside a disabled field package without passing any door (a release that added the rule, or a state written before this release). The boot neither refuses nor rewrites that state: at `kernel:bootstrapped` it logs one warning naming every such rule, the objects whose writes are refused, and the remedies.
21+
- **`@objectstack/objectql`.** `ObjectExtensionFoldOptions` gains the optional `asEnabled` key: `getAllObjects(undefined, { asEnabled: id })` folds a disabled package's `extend` contributions in as if it were enabled. Unset, or naming a package that is not disabled, the fold is unchanged.
22+
- **Who moves.** An administrator or client enabling such a package, or overwriting a disabled one with `enableOnInstall: true`: a refusal naming the rules and the package to enable first, instead of an enable that left the object refusing every write.
23+
24+
**The remedy.** Enable the named disabled package first, or switch off or change each named rule (a rule switched off on the tenant's copy of the object counts as off), then enable the package.

‎content/docs/api/error-catalog.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -547,8 +547,8 @@ spelling never crosses HTTP.
547547
**Retry:** `retry_backoff`
548548

549549
### `PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`
550-
**Cause:** `PATCH /api/v1/packages/:id/disable` was refused because a validation rule of an enabled package (declared dependent or not, an app's `.custom` pair included, any severity) reads a field this package adds to another object through `objectExtensions`. Disabling it would withhold that field, and every write to the object would then fail the rule. Each rule is judged as the object serves it: one rule per name (the copy a later layer declares wins), with a tenant overlay's switch honoured. The message names each rule as `package · object · rule (reads FIELD)`, one or several, after the layer whose copy is served (`tenant overlay` for a rule the overlay declares itself). Nothing changed. `POST /api/v1/packages` answers the same code for an overwrite of an enabled package with `enableOnInstall: false` that would leave such a package disabled: the overwrite itself lands and the package stays enabled. A fresh install with `enableOnInstall: false` lands disabled, as before.
551-
**Fix:** Switch off or change each named rule first, or disable the package that owns it, then disable this package; or keep it enabled. A formula over the field is no reason to refuse: it answers `null`.
550+
**Cause:** `PATCH /api/v1/packages/:id/disable` was refused because a validation rule of an enabled package (declared dependent or not, an app's `.custom` pair included, any severity) reads a field this package adds to another object through `objectExtensions`. Disabling it would withhold that field, and every write to the object would then fail the rule. Each rule is judged as the object serves it: one rule per name (the copy a later layer declares wins), with a tenant overlay's switch honoured. The message names each rule as `package · object · rule (reads FIELD)`, one or several, after the layer whose copy is served (`tenant overlay` for a rule the overlay declares itself). Nothing changed. `POST /api/v1/packages` answers the same code for an overwrite of an enabled package with `enableOnInstall: false` that would leave such a package disabled: the overwrite itself lands and the package stays enabled. A fresh install with `enableOnInstall: false` lands disabled, as before. The enable door answers the same code for the mirror order: `PATCH /api/v1/packages/:id/enable` is refused while a rule the enable would put into service (the package's own, or one on an object it owns) reads a field a still-disabled package withholds, the message naming each rule and the disabled package to enable first; `POST /api/v1/packages` with `enableOnInstall: true` on an overwrite of a disabled package answers it too, and the package stays disabled.
551+
**Fix:** Switch off or change each named rule first, or disable the package that owns it, then disable this package; or keep it enabled. A formula over the field is no reason to refuse: it answers `null`. On the enable door: enable the named disabled package first, or switch off or change each named rule.
552552
**Retry:** `no_retry`
553553

554554
---

‎content/docs/api/metadata-api.mdx‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,20 @@ before.
178178

179179
Enable a disabled package.
180180

181+
The mirror of the disable door's refusal below: the enable is refused
182+
`409 PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE` while a validation rule it
183+
would put into service (the package's own, or one on an object it owns) reads a
184+
field that another, still disabled package adds through `objectExtensions`:
185+
that field stays withheld, and every write to the object would fail the rule.
186+
The message names each rule as `package · object · rule (reads FIELD)` and the
187+
disabled package to enable first, and nothing changes. A rule is judged as the
188+
object will serve it once the package is enabled: one rule per name, a tenant
189+
overlay's switch honoured. A rule the enable does not bring into service is not
190+
counted. `POST /packages` answers the same `409` for an overwrite of a disabled
191+
package with `enableOnInstall: true`; the overwrite lands and the package stays
192+
disabled. A boot whose stored state leaves such a rule served (the state records
193+
only disables) logs one warning naming every rule and the remedies.
194+
181195
### `PATCH /packages/:id/disable`
182196

183197
Disable a package without uninstalling.

‎content/docs/deployment/marketplace-handbook.mdx‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,8 @@ the detail.
236236
`PATCH /api/v1/packages/:id/disable` with `422 WRITABLE_PACKAGE_REQUIRED`.
237237
A disable that would leave a validation rule reading a field the package
238238
adds is refused with `409 PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`
239-
([below](#disabling-a-module)).
239+
([below](#disabling-a-module)), and so is an enable that would serve a
240+
rule reading a field a still-disabled package adds.
240241
To take an installed app away, uninstall it.
241242
8. **Upgrading.** You publish a new version; nothing running changes. An
242243
environment installed through the cloud shows **Update available → v…** and
@@ -326,6 +327,14 @@ its data is kept.
326327
enabled module with `enableOnInstall: false` that would leave it disabled in
327328
that state answers the same `409`, and the module stays enabled; a fresh
328329
install with `enableOnInstall: false` lands disabled, as before.
330+
- **Enabling a rule over a disabled module's field.** The mirror order is
331+
refused too: while the module is disabled, enabling a package whose
332+
validation rule reads one of the module's fields (or one on an object that
333+
package owns) answers the same `409`, naming each rule and the module to
334+
enable first; nothing changes. Enable the module first, or switch the rule
335+
off on the customer's copy of the object. A boot that lands in that state
336+
anyway (the stored choice records only disables, so a newly shipped rule
337+
boots enabled) logs one warning naming every rule and the remedies.
329338

330339
**What your app's own code meets.** A hook of your app that reads or writes a
331340
module's object through `ctx.api.object('<object>')` runs inside the server,

‎packages/objectql/src/registry-disabled-package-extensions.test.ts‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,3 +156,64 @@ describe('a disabled package\'s objectExtensions', () => {
156156
expect(fieldsOf(registry.getObject(OBJECT))).toEqual(['name', 'qe_credit', 'qe_score', 'qe_shared']);
157157
});
158158
});
159+
160+
/**
161+
* [#30756] `getAllObjects(undefined, { asEnabled })`: the fold the enable of a
162+
* DISABLED package would leave, which the enable door's pre-check reads. That
163+
* package's extension is folded in; every other disabled package's stays
164+
* withheld; the registry's own state does not move; and the option changes
165+
* nothing for a package that is not disabled, or when it is unset.
166+
*/
167+
describe('[#30756] the asEnabled fold', () => {
168+
const APP_PKG = 'com.qa.asen.app';
169+
const MODULE_PKG = 'com.qa.asen.orders';
170+
const OTHER_PKG = 'com.qa.asen.scoring';
171+
const OBJECT = 'qa_account';
172+
const manifest = (id: string, type: string) =>
173+
({ id, name: id, version: '1.0.0', type, namespace: 'qa', ...(type === 'module' ? { dependencies: { [APP_PKG]: '^1.0.0' } } : {}) }) as any;
174+
175+
let registry: SchemaRegistry;
176+
beforeEach(() => {
177+
registry = new SchemaRegistry({ multiTenant: false });
178+
(registry as any).logLevel = 'silent';
179+
registry.installPackage(manifest(APP_PKG, 'app'), 'code');
180+
registry.installPackage(manifest(MODULE_PKG, 'module'), 'code');
181+
registry.installPackage(manifest(OTHER_PKG, 'module'), 'code');
182+
registry.registerObject({
183+
name: OBJECT, label: 'Account', fields: { name: { name: 'name', type: 'text', label: 'Name' } },
184+
} as any, APP_PKG, 'qa', 'own');
185+
registry.registerObject({
186+
name: OBJECT,
187+
fields: { qa_credit: { name: 'qa_credit', type: 'number', label: 'Credit' } },
188+
validations: [{ name: 'qa_credit_positive', type: 'script', severity: 'error', message: 'Credit', condition: 'record.qa_credit < 0' }],
189+
} as any, MODULE_PKG, 'qa', 'extend');
190+
registry.registerObject({
191+
name: OBJECT,
192+
fields: { qa_score: { name: 'qa_score', type: 'number', label: 'Score' } },
193+
validations: [{ name: 'qa_score_capped', type: 'script', severity: 'error', message: 'Score', condition: 'record.qa_score > 10' }],
194+
} as any, OTHER_PKG, 'qa', 'extend');
195+
});
196+
197+
const served = (options?: { asEnabled?: string }) => registry.getAllObjects(undefined, options).find((o) => o.name === OBJECT) as any;
198+
const fieldsOf = (body: any): string[] => Object.keys(body?.fields ?? {}).filter((f) => f.startsWith('qa_') || f === 'name').sort();
199+
const rulesOf = (body: any): string[] => (body?.validations ?? []).map((r: any) => r.name).sort();
200+
201+
it('folds the named disabled package in, keeps every other disabled package withheld, and moves no state', () => {
202+
registry.disablePackage(MODULE_PKG);
203+
registry.disablePackage(OTHER_PKG);
204+
const body = served({ asEnabled: MODULE_PKG });
205+
expect(fieldsOf(body)).toEqual(['name', 'qa_credit']);
206+
expect(rulesOf(body)).toEqual(['qa_credit_positive']);
207+
expect(registry.isPackageDisabled(MODULE_PKG)).toBe(true);
208+
expect(fieldsOf(registry.getObject(OBJECT)), 'the served fold is unchanged by the read').toEqual(['name']);
209+
});
210+
211+
it('control: unset, or naming a package that is not disabled, the fold is today\'s, byte for byte', () => {
212+
registry.disablePackage(MODULE_PKG);
213+
const today = JSON.stringify(served());
214+
expect(JSON.stringify(served({}))).toBe(today);
215+
expect(JSON.stringify(served({ asEnabled: OTHER_PKG }))).toBe(today);
216+
expect(JSON.stringify(served({ asEnabled: 'com.qa.asen.unknown' }))).toBe(today);
217+
expect(fieldsOf(served({ asEnabled: OTHER_PKG }))).toEqual(['name', 'qa_score']);
218+
});
219+
});

‎packages/objectql/src/registry.ts‎

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -748,10 +748,18 @@ type ServedObjectLayer = 'served' | 'code';
748748
*
749749
* Both may be given; a contribution either one leaves out is left out. The
750750
* base layer (`own` / `overlay`) is never left out.
751+
*
752+
* [#30756] `asEnabled` asks the opposite question of a DISABLED package: the
753+
* fold as it will serve once that package is enabled, its `extend`
754+
* contributions folded in instead of withheld ({@link SchemaRegistry.withholdDisabledExtensions}).
755+
* Every other disabled package's stay withheld. The enable door's pre-check
756+
* reads it, as the disable door's reads `withoutExtensionsOf`; for a package
757+
* that is not disabled it changes nothing.
751758
*/
752759
export interface ObjectExtensionFoldOptions {
753760
withoutExtensionsOf?: string;
754761
onlyExtensionsOf?: string;
762+
asEnabled?: string;
755763
}
756764

757765
/** Whether `options` leaves any contribution out (otherwise the fold is the ordinary one). */
@@ -5275,9 +5283,15 @@ export class SchemaRegistry {
52755283
*
52765284
* `undefined` when no extension of the object is withheld, so the ordinary
52775285
* fold runs unchanged.
5286+
*
5287+
* [#30756] `asEnabled` names a package whose contributions are kept although
5288+
* it is disabled: the fold the enable of that package would leave
5289+
* ({@link ObjectExtensionFoldOptions}).
52785290
*/
5279-
private withholdDisabledExtensions(contributors: readonly ObjectContributor[]): WithheldExtensions | undefined {
5280-
const withheld = contributors.filter((c) => c.ownership === 'extend' && this.isPackageDisabled(c.packageId));
5291+
private withholdDisabledExtensions(contributors: readonly ObjectContributor[], asEnabled?: string): WithheldExtensions | undefined {
5292+
const withheld = contributors.filter((c) => c.ownership === 'extend'
5293+
&& this.isPackageDisabled(c.packageId)
5294+
&& (asEnabled === undefined || c.packageId !== asEnabled));
52815295
if (withheld.length === 0) return undefined;
52825296
const kept = contributors.filter((c) => !withheld.includes(c));
52835297
const owner = contributors.find((c) => c.ownership === 'own')?.definition;
@@ -5319,14 +5333,21 @@ export class SchemaRegistry {
53195333
* patches travel twice, the second time as if the owner shipped them.
53205334
*/
53215335
private resolveObjectLeavingExtensionsOut(fqn: string, options: ObjectExtensionFoldOptions | undefined): ServiceObject | undefined {
5322-
const contributors = leavesExtensionsOut(options) ? this.objectContributors.get(fqn) : undefined;
5323-
if (!contributors || !contributors.some((c) => isLeftOutExtension(c, options!))) return this.resolveObject(fqn);
5336+
// [#30756] A disabled package folded in as if enabled; one that is not
5337+
// disabled changes nothing, so it does not leave the cached fold.
5338+
const asEnabled = options?.asEnabled !== undefined && this.isPackageDisabled(options.asEnabled)
5339+
? options.asEnabled
5340+
: undefined;
5341+
const contributors = leavesExtensionsOut(options) || asEnabled !== undefined ? this.objectContributors.get(fqn) : undefined;
5342+
if (!contributors || !contributors.some((c) => isLeftOutExtension(c, options!)
5343+
|| (asEnabled !== undefined && c.ownership === 'extend' && c.packageId === asEnabled))) return this.resolveObject(fqn);
53245344
const kept = contributors.filter((c) => !isLeftOutExtension(c, options!));
53255345
const ownerContrib = kept.find(c => c.ownership === 'own');
53265346
if (!ownerContrib) return undefined;
53275347
const base = kept.find(c => c.ownership === 'overlay') ?? ownerContrib;
5328-
// …and a disabled package's, as on every served fold ({@link withholdDisabledExtensions}).
5329-
const withheld = this.withholdDisabledExtensions(kept);
5348+
// …and a disabled package's, as on every served fold ({@link withholdDisabledExtensions}),
5349+
// except the one `asEnabled` names.
5350+
const withheld = this.withholdDisabledExtensions(kept, asEnabled);
53305351
return withheld
53315352
? this.foldExtendersOntoDefinition(withheld.kept, withoutWithheldMembers(base.definition, withheld))
53325353
: this.foldExtenders(kept, base);

0 commit comments

Comments
 (0)