Skip to content

Commit 7abe151

Browse files
feat(runtime): an admin can disable a type:module package of an installed app; its nav contributions and record-change flows stop (#28117) (#28308)
domain:server#2 · os-project-manager · session_01FEkQ5otJRNvRGLG91cCJr7 <!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XMKwZYa8zJsAxCsvLkrMXaw)_ Part of #28117 Clause-②: yes (widening) — `PATCH /api/v1/packages/:id/disable` now accepts a module of an installed app, which it refused `422 WRITABLE_PACKAGE_REQUIRED` before (measured red on the base tree, both install paths). Nothing is narrowed. Metadata-protocol: none Skills-docs: updated here — `content/docs/deployment/marketplace-handbook.mdx` (step 7 corrected, new "Disabling a module" section, including what an app hook meets). No `skills/**` page names the disable door or `navigationContributions` (`git grep` in `skills/`: 0 hits). The ADR-0070 D2 addendum line (card item 6) is the seat's follow-up #28285; this PR does not touch `docs/adr/**`. ## 维护者速读 - **改了什么**:管理员(有 `manage_metadata`)现在可以停用一个已安装应用下的 `type: 'module'` 子包;停用后它的导航项从应用菜单里消失,绑在它对象上的记录触发流程也不再运行。其余只读包(应用本身、插件、平台包、没有所属应用的模块)照旧答 422,模块依旧不能被编辑、发布、删除。 - **为什么改**:维护者裁决「大包 + 设置里启用模块」(方案 A):客户自己的管理员按环境开关模块,「停用」只有一种含义:隐藏、数据面拒绝、数据保留。之前停用门对套件里的每个包都答 422,模块根本关不掉。 - **风险与回滚**:低。停用门只多放行一类包(`type: 'module'` 且直接依赖一个已安装的 `type: 'app'` 包,且不是 system/cloud 范围),其他门不变;导航折叠和触发器只在包已停用时才跳过。回滚即 revert。 - **先看**:`packages/runtime/src/domains/packages.ts`(`isDisableableAppModule` 与停用门那一行);`packages/objectql/src/registry.ts`(`applyNavContributions` 里新增的一行);`packages/triggers/trigger-record-change/src/record-change-trigger.ts`(handler 里新增的判断)。 - **两分钟验证**:`pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/domains/packages-disable-app-module.test.ts`,预期 26/26 通过(两条安装路径各一套:停用 200、对象 404 `OBJECT_PACKAGE_DISABLED`、导航消失、重新启用后恢复、其余七类包 422、重启后保持停用)。 - **需要你做的**:无,席位落地。另有一个需要你拍板的问题在席位的报告里:应用自己的 hook 经 `ctx.api.object()` 读已停用模块的对象,实测**不会被拒**(拿到保留的数据),与补充说明 6523684730 的预期不同;本 PR 不改这个行为,只钉住并写进文档。 ## What changes ### 1. The disable door (`@objectstack/runtime`) `PATCH /packages/:id/disable` asked `requireWritablePackage` alone, and `isWritablePackage` answers read-only for every package of a booted or installed artifact (`engine.manifests`), so every suite module answered `422 WRITABLE_PACKAGE_REQUIRED`. The door now reads one exemption first, `isDisableableAppModule(engine, id)`, and only for this verb: - the row's `manifest.type` is `'module'`; - its `manifest.dependencies` DIRECTLY name a package that is installed here and whose `manifest.type` is `'app'` (read with `declaredDependencyIds` from `@objectstack/core`, the reader the registry's co-owner gate uses); - its own `manifest.scope` is not `system` / `cloud` (`READ_ONLY_PACKAGE_SCOPES`): a module the platform delivers stays the platform's. `isWritablePackage` is unchanged, so `writable` still means "may author into" and stays `false` for a module; edit, publish, revert, delete and overwrite still refuse it (pinned below). Everything else answers the disable door exactly as before. **The tie, measured on both install paths.** A code boot (`AppPlugin` → `manifest.register`, `source: 'code'`) and a marketplace install (`manifest.register(…, { source: 'marketplace' })`) leave the same three keys on the module's registry row: `type: 'module'`, `dependencies: { APP: RANGE }`, `namespace` (the app's). Both put the module in `engine.manifests` (read-only for authoring). The artifact's own package list (`ArtifactInstallScope.packageIds`) is NOT on the row: it lives only for the length of the `registerApp` call. So the declared dependency on an installed app is the tie the registry actually carries; the namespace is carried too but is not used, because sharing a namespace is itself derived from that declaration (ADR-0141 D5). The HotCRM read in addendum 6523684730 has every module declaring `dependencies: { 'app.objectstack.hotcrm': '^6.0.0' }`; `examples/app-multi-package`'s module declares its core app the same way. ### 2. Navigation (`@objectstack/objectql`) `SchemaRegistry.applyNavContributions` skips a contribution whose `packageId` is disabled (`isPackageDisabled`), before the group lookup, so a disabled package's contribution is neither folded, relocated to the top level, nor diagnosed as `nav_contribution_group_missing`. That one fold serves every reader: `getApp`, `getAllApps`, and the protocol's `app` list and by-name reads (`protocol.ts` L10072 / L11175 call `registry.applyNavContributions`). Nothing is cached past the fold (`meta-overlay-cache.ts` caches only the `sys_metadata` row set), so a re-enable restores the items with nothing to invalidate. The stored app is never edited. ### 3. Record-change flows (`@objectstack/trigger-record-change`) Measured first, as the card asks. Writes through the data plane to a disabled package's object were already refused (`404 OBJECT_PACKAGE_DISABLED`), so no flow fired from them. But the engine itself has no disabled-package check: a write made IN-PROCESS (an app hook's `ctx.api.object(...)`, another flow's data node, a service) lands and fires the object's lifecycle hooks. On the base tree, a `record-after-create` flow on such an object stamped `done` (red below). `RecordChangeTrigger`'s handler now returns before dispatch when `engine.registry.isObjectPackageDisabled(object)` is true (the question the data plane's refusal asks, `binding.object ?? ctx.object` as `buildContext` reads it). The write itself is untouched, so the data is kept; the check is per write, so an enable resumes the flow with nothing to re-bind. `RecordChangeDataEngine` gains an optional structural `registry` member; an engine without it dispatches as before. ### 4. Served verdict: a fork, not written here The Console decides whether to offer Disable from `writable || !enabled` (`ui/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx` L1054), and `writable` stays `false` for a module, so the Console cannot offer this disable yet. The answer "may this package be disabled here?" needs its own key beside `writable` in `SERVED_ROW_VERDICTS` (`packages/spec/src/api/package-api-assembled.zod.ts` L155-166), which is a spec-lane edit, so it is NOT written here and `writable` is not overloaded. `Seam: spec:SERVED_ROW_VERDICTS (a new served key beside writable) → runtime:withWritableVerdict (packages/runtime/src/domains/packages.ts, would read isDisableableAppModule) | renderer:PackagesPage.tsx L1054` `isDisableableAppModule` is the one predicate both the door and that future served key read, so the Console never re-derives the rule (#8146). ### 5. Docs `content/docs/deployment/marketplace-handbook.mdx`: step 7 said every package "provided by code or by an installed app" answers the disable door 422, which this PR makes false; it now names the module exemption. A new "Disabling a module" section under "Extras: dependencies and modules" gives the manifest shape, the one meaning of disabled (data plane, navigation, flows, restart, authoring), and what an app's own hook meets (addendum 6523684730 item 2). ## Tests All three suites import the code under change from `src/` except where noted; red-first readings are on the base tree `69ce01d7e`. - `packages/runtime/src/domains/packages-disable-app-module.test.ts` (new, 26 tests). Real `ObjectKernel` with `MetadataPlugin`, `ObjectQLPlugin`, `AppPlugin` and a SQLite `:memory:` `SqlDriver`, driven through the runtime dispatcher's `dispatch()`. A suite artifact composed with `composeStacks(…, { manifest: 'preserve' })` (app, module with a `navigationContributions` entry, a plugin, a module of the plugin), plus four packages registered alone. Booted twice, once per install path (`code`, `marketplace`): - the tie on the row (type, dependencies, namespace, `source`, in `engine.manifests`); - enabled control: data 201/200, nav `nav_accounts, nav_orders` through `GET /meta/app/qs_app` and `registry.getApp`; - `PATCH …/disable` 200, `enabled: false`; `GET /data/qs_order` 404 `OBJECT_PACKAGE_DISABLED`; nav `nav_accounts` only, on both readers; the app's own object still 200; - edit (`PATCH /packages/:id`), publish, delete on the module: 422 `WRITABLE_PACKAGE_REQUIRED`, manifest unchanged; - `PATCH …/enable` 200: data back with the kept record, nav back; - still 422 and still enabled: the app, the plugin, a module of the plugin, an orphan module (no dependency), a module whose app is not installed, a `system`-scoped module of the installed app, a `system`-scoped plugin. - Restart: disable through the door, shut down, boot a second kernel on the same `OS_HOME` and environment: the module boots disabled (404 on the data plane, nav gone); enable, boot a third: enabled, nav back. - Addendum measurement (see below). - Base tree: 6 failed / 20 passed (the two disable cases answer 422, so do the restart and measurement cases that disable first; the 14 refusal cases and the tie readings were already green, as a control should be). - `packages/objectql/src/registry-nav-contribution-disabled-package.test.ts` (new, 11 tests, real `SchemaRegistry`): for each of `getApp`, `getAllApps`, `applyNavContributions`: enabled control, disabled drop (in its group and its relocated top-level item, other packages' items stay), re-enable restore; no missing-group diagnostic for a disabled package; the stored app unchanged. Base tree: 4 failed / 7 passed. - `packages/triggers/trigger-record-change/src/record-change-integration.test.ts`, new `[#28117]` block (2 tests, real kernel with ObjectQL, automation, the trigger plugin and SQLite): enabled control stamps `done`; disabled, an in-process user insert writes the record and the flow does not stamp; another package's object in the same window still stamps; re-enabled, it stamps again. Base tree: 1 failed / 1 passed (`expected 'done' to be null`). ### What an app hook meets (addendum 6523684730 item 2) Measured and pinned in `packages-disable-app-module.test.ts`: with the module disabled through the door, an `afterInsert` hook registered for the APP package on `qs_account` calls `ctx.api.object('qs_order').find({})`; a `POST /data/qs_account` fires it, and the hook gets the module's kept rows (`[{ rows: 1 }]`), no error. In the same window `GET /data/qs_order` answers `404 OBJECT_PACKAGE_DISABLED`. The hook's `ctx.api` is a `ScopedContext` over the engine (`buildHookApi`, `engine.ts`), and the disabled-package refusal lives in the protocol's data plane (`assertObjectRegistered`), never in the engine. This PR changes no answer for any caller (addendum item 1); the handbook says there is no `OBJECT_PACKAGE_DISABLED` to catch in-process. <details><summary>Ablations (one-time, restored)</summary> Each through `node scripts/ablation-replace.mjs` under the verify lock; every suite imports the mutated file from `src/`, so no build sits between mutation and run. 1. Door: `const readOnly = isDisableableAppModule(qlService, id)` → `const readOnly = false && isDisableableAppModule(qlService, id)` (the unconditional `requireWritablePackage`). Blob `53825564f8bb` → `e14712be2677`. `packages-disable-app-module.test.ts`: 4 failed / 22 passed (both "disables the module (200)", the restart, the measurement). Restored: blob = HEAD, `git diff HEAD` empty. 2. Nav: `--delete` the line `if (this.isPackageDisabled(c.packageId)) continue;`. Blob `0d8ee0e6b325` → `4629981b5d26`. `registry-nav-contribution-disabled-package.test.ts`: 4 failed / 7 passed (the three disabled-drop cases and the diagnostic case). Restored: blob = HEAD. 3. Trigger: `if (written && this.engine.registry?.isObjectPackageDisabled?.(written) === true) {` → `if (written && false && …) {`. Blob `9ce4a4d903d3` → `a08bcd2662b8`. The `[#28117]` block: 1 failed / 1 passed. Restored: blob = HEAD. </details> <details><summary>Package suites and typecheck</summary> Under the verify lock, after building `@objectstack/runtime...` and `@objectstack/trigger-record-change...`: - `pnpm --filter @objectstack/objectql --filter @objectstack/trigger-record-change --filter @objectstack/runtime --no-bail test` (at `9808a8fd`): objectql `Test Files 501 passed (501)`, `Tests 9761 passed (9761)`; trigger-record-change `11 passed (11)`, `116 passed (116)`; runtime `488 passed (488)`, `Tests 7932 passed | 19 skipped (7951)`; `VERDICT command-exit 0`. - The trigger file again at `c403c6bd` after typing its slot lookups: `Tests 11 passed (11)`. - `pnpm … run typecheck` on the three packages: exit 0 (`tsc --noEmit` plus each package's test program). - `pnpm exec eslint --no-inline-config` on the six touched source and test files: exit 0. </details> ## Acceptance notes - **Not in this PR (routed):** card item 6, the ADR-0070 D2 addendum line, is #28285 (Tier H). The served-verdict key is the Seam above. - **Read from source, not measured:** a disabled module's own CODE hooks (`hooks` in its manifest) still run on an in-process write to its objects, and a disabled module's record-change flows bound to an ENABLED package's object (e.g. a module flow on an app object) still fire: the engine and the automation engine read no package state, and this card's item 3 names flows on the disabled package's objects only. Both sit with the in-process question in the report. - `Clause-②` measured: the door's 422 for these rows was the published answer (the handbook's step 7 named it), and the request now accepted is a new accept; no refusal is added anywhere. ## Verification Gates from `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on this diff (93 commands), each run on the final head `c403c6bd` with its exit code captured before any pipe: all 93 exit 0. `dispatch-gates --ran`: "93 derived famil(ies) accounted for — 93 run, 0 NOT-MEASURED". On the first head `9808a8fd` one was red, `check:slot-lookup` (the new trigger test block added two `as any` slot lookups, erasure count 11 → 13); `c403c6bd` types them and the ratchet holds. The families CI runs are cited from this PR's check-runs in the report on the card. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (`packages/spec` untouched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes (scoped to the touched files). - [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: n/a. - [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 维护者速读 block is the seat's required Chinese summary). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5otJRNvRGLG91cCJr7)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 501c864 commit 7abe151

8 files changed

Lines changed: 774 additions & 7 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/objectql': patch
4+
'@objectstack/trigger-record-change': patch
5+
---
6+
7+
feat(runtime): an admin can disable a `type: 'module'` package of an installed app, and a disabled package's navigation contributions and record-change flows stop (#28117)
8+
9+
Clause-②: yes (widening) — `PATCH /api/v1/packages/:id/disable` now accepts a module of an installed app, which it refused `422 WRITABLE_PACKAGE_REQUIRED` before. Nothing is narrowed.
10+
Metadata-protocol: none
11+
12+
A suite ships one artifact: its `type: 'app'` package and several `type: 'module'` packages. A customer who does not use a module can now switch it off in their environment, and "disabled" keeps its one meaning: hidden, refused on the data plane, data kept.
13+
14+
- **The disable door** (`@objectstack/runtime`). An admin with `manage_metadata` may disable a package whose `manifest.type` is `'module'` and whose `manifest.dependencies` directly name an installed package of `manifest.type` `'app'`, unless its own `manifest.scope` is `system` or `cloud`. This holds for a suite booted from code and for one installed from the marketplace: both leave the same row. Every other read-only package answers the disable door `422 WRITABLE_PACKAGE_REQUIRED` as before: the app itself, a plugin, a platform package, a module that declares no app or whose app is not installed. The exemption is for disable only. The module stays read-only for authoring, so edit, publish, revert, delete and overwrite still refuse it, and its served row still says `writable: false`.
15+
- **Navigation** (`@objectstack/objectql`). `SchemaRegistry.applyNavContributions` drops the contributions of a disabled package, so `getApp`, `getAllApps` and the protocol's `app` reads (`GET /api/v1/meta/app`, `GET /api/v1/meta/app/:name`) no longer list a disabled module's menu entries. They come back on enable. A disabled package's contribution into a missing group is not relocated and raises no `nav_contribution_group_missing` diagnostic.
16+
- **Record-change flows** (`@objectstack/trigger-record-change`). A flow bound to an object whose owning package is disabled is not run for a write made in-process (an app hook's `ctx.api.object(...)`, another flow, a service), which the engine still accepts. The record is written; the flow runs again once the package is enabled. Writes through the data plane were already refused.
17+
- **Already in place**, and now pinned on the module path: the data plane's `404 OBJECT_PACKAGE_DISABLED` for the module's objects, and the per-environment state file that replays a disable at boot.
18+
19+
An app hook that reads a disabled module's object through `ctx.api.object(name)` is served the kept records: the in-process read is not refused, and this release does not change that. The marketplace handbook says so under "Disabling a module".

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

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,8 +214,12 @@ the detail.
214214
`POST /api/v1/marketplace/install-local/:manifestId/reseed-sample-data` and
215215
`…/purge-sample-data`, both requiring `manage_metadata`.
216216
7. **Enable and disable.** **Setup → Installed Apps** lists the apps installed
217-
from the marketplace. **Disable** is offered only for a writable package:
218-
for a package provided by code or by an installed app, the runtime answers
217+
from the marketplace. An admin with `manage_metadata` may disable a writable
218+
package, and a **module of an installed app**: a `type: 'module'` package
219+
that declares an installed `type: 'app'` package in its
220+
`manifest.dependencies` ([Disabling a module](#disabling-a-module) below).
221+
For every other package provided by code or by an installed app (the app
222+
itself, a plugin, a platform package), the runtime answers
219223
`PATCH /api/v1/packages/:id/disable` with `422 WRITABLE_PACKAGE_REQUIRED`.
220224
To take an installed app away, uninstall it.
221225
8. **Upgrading.** You publish a new version; nothing running changes. An
@@ -255,6 +259,45 @@ An app ships extras in one of two shapes:
255259
composed at build time, which travels inside your App rather than as a
256260
listing of its own ([A project is a multi-package artifact](/docs/getting-started/examples#a-project-is-a-multi-package-artifact)).
257261

262+
### Disabling a module
263+
264+
A customer who does not use one of your modules can switch it off, per
265+
environment, with `PATCH /api/v1/packages/:id/disable`, and back on with
266+
`PATCH /api/v1/packages/:id/enable`. The module qualifies when its manifest
267+
says `type: 'module'` and declares your app in `dependencies`, the way every
268+
module of a suite already does:
269+
270+
```ts
271+
manifest: {
272+
id: 'app.example.suite.orders',
273+
type: 'module',
274+
namespace: 'suite',
275+
dependencies: { 'app.example.suite': '^1.0.0' },
276+
navigationContributions: [/* its menu entries in the app's groups */],
277+
},
278+
```
279+
280+
Disabled has one meaning: the module is hidden, refused on the data plane, and
281+
its data is kept.
282+
283+
- **Data plane.** `/api/v1/data/<object>` and the by-name metadata reads answer
284+
the module's objects `404 OBJECT_PACKAGE_DISABLED`. Records stay in the
285+
database and come back on enable.
286+
- **Navigation.** The module's `navigationContributions` leave the app's menu.
287+
- **Flows.** Record-change flows on the module's objects do not run while it is
288+
disabled, whoever writes the record.
289+
- **Restart.** The choice is kept per environment and survives a restart.
290+
- **Authoring.** A disabled module is still read-only: edit, publish, revert
291+
and delete keep answering `422 WRITABLE_PACKAGE_REQUIRED`.
292+
293+
**What your app's own code meets.** A hook of your app that reads or writes a
294+
module's object through `ctx.api.object('<object>')` runs inside the server,
295+
not through the data plane, and it is **not** refused: a `find` returns the
296+
module's kept records, and a write lands (no record-change flow runs on it).
297+
There is no `OBJECT_PACKAGE_DISABLED` error to catch there. A browser or API
298+
client reading the module's objects does meet the `404 OBJECT_PACKAGE_DISABLED`
299+
refusal while the module is off.
300+
258301
## Related
259302

260303
- [CLI reference](/docs/deployment/cli) — every `os package publish` and `os package install` flag
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, beforeEach } from 'vitest';
4+
import { SchemaRegistry } from './registry';
5+
6+
/**
7+
* [#28117] A DISABLED package's navigation contributions leave the app it
8+
* contributes to, on every read that folds them, and come back on enable.
9+
*
10+
* A suite module (a `type: 'module'` package of an installed app) puts its
11+
* menu entries into the app's groups through `navigationContributions`
12+
* (ADR-0029 D7). Disabling the module must take those entries out of the app:
13+
* "disabled" has one meaning, hidden + refused on the data plane + data kept.
14+
* Before this card the fold (`applyNavContributions`) pushed every registered
15+
* contribution and never asked whether its package was switched off, so the
16+
* module's objects answered 404 `OBJECT_PACKAGE_DISABLED` while the menu kept
17+
* offering them.
18+
*
19+
* The fold is the ONE seam all three readers go through: the registry helpers
20+
* `getApp` / `getAllApps` here, and the protocol's `app` list and by-name reads
21+
* (`getMetaItems` / `getMetaItem` call `registry.applyNavContributions`), which
22+
* the runtime pin `packages/runtime/src/domains/packages-disable-app-module.test.ts`
23+
* drives through the real door. Nothing is cached past the fold: the verdict
24+
* is the package row's state at read time, so a re-enable needs no
25+
* invalidation.
26+
*/
27+
describe('[#28117] navigation contributions of a disabled package', () => {
28+
const APP_PKG = 'com.qa.suite';
29+
const MODULE_PKG = 'com.qa.suite.orders';
30+
const OTHER_PKG = 'com.qa.suite.reports';
31+
32+
let registry: SchemaRegistry;
33+
34+
const suiteApp = () => ({
35+
name: 'qs_app',
36+
label: 'Suite',
37+
navigation: [
38+
{
39+
id: 'qs_group',
40+
type: 'group',
41+
label: 'Sales',
42+
children: [{ id: 'nav_accounts', type: 'object', label: 'Accounts', objectName: 'qs_account' }],
43+
},
44+
],
45+
});
46+
47+
// Each module declares the app, as a suite's modules do: that is also what
48+
// lets it co-own the app's namespace (ADR-0141 D5).
49+
const manifest = (id: string, type: string) =>
50+
({ id, name: id, version: '1.0.0', type, namespace: 'qs', ...(type === 'module' ? { dependencies: { [APP_PKG]: '^1.0.0' } } : {}) }) as any;
51+
52+
/** The ids the folded app's group holds, read the way `reader` reads it. */
53+
const groupIds = (app: any) =>
54+
(app.navigation.find((n: any) => n.id === 'qs_group')?.children ?? []).map((c: any) => c.id);
55+
const topIds = (app: any) => app.navigation.map((n: any) => n.id);
56+
57+
const READERS: ReadonlyArray<{ label: string; read: (r: SchemaRegistry) => any }> = [
58+
{ label: 'getApp', read: (r) => r.getApp('qs_app') },
59+
{ label: 'getAllApps', read: (r) => r.getAllApps().find((a: any) => a.name === 'qs_app') },
60+
{ label: 'applyNavContributions (the protocol app read)', read: (r) => r.applyNavContributions(suiteApp()) },
61+
];
62+
63+
beforeEach(() => {
64+
registry = new SchemaRegistry({ multiTenant: false });
65+
(registry as any).logLevel = 'silent';
66+
registry.installPackage(manifest(APP_PKG, 'app'), 'code');
67+
registry.installPackage(manifest(MODULE_PKG, 'module'), 'code');
68+
registry.installPackage(manifest(OTHER_PKG, 'module'), 'code');
69+
registry.registerApp(suiteApp(), APP_PKG);
70+
registry.registerAppNavContribution(
71+
{ app: 'qs_app', group: 'qs_group', priority: 50, items: [{ id: 'nav_orders', type: 'object', label: 'Orders', objectName: 'qs_order' }] },
72+
MODULE_PKG,
73+
);
74+
// A contribution into a group the app does not declare: the fold relocates
75+
// it to the top level (#14553). Disabled, it must not be relocated either.
76+
registry.registerAppNavContribution(
77+
{ app: 'qs_app', group: 'qs_missing_group', priority: 60, items: [{ id: 'nav_order_help', type: 'url', label: 'Help', url: '/help' }] },
78+
MODULE_PKG,
79+
);
80+
registry.registerAppNavContribution(
81+
{ app: 'qs_app', group: 'qs_group', priority: 70, items: [{ id: 'nav_reports', type: 'object', label: 'Reports', objectName: 'qs_report' }] },
82+
OTHER_PKG,
83+
);
84+
});
85+
86+
for (const { label, read } of READERS) {
87+
it(`${label}: an enabled package's contributions are folded (the control)`, () => {
88+
const app = read(registry);
89+
expect(groupIds(app)).toEqual(['nav_accounts', 'nav_orders', 'nav_reports']);
90+
expect(topIds(app)).toContain('nav_order_help');
91+
});
92+
93+
it(`${label}: a disabled package's contributions are dropped, in its group and at the top level, and the others stay`, () => {
94+
expect(registry.disablePackage(MODULE_PKG), 'precondition: disabled').toBeTruthy();
95+
const app = read(registry);
96+
expect(groupIds(app)).toEqual(['nav_accounts', 'nav_reports']);
97+
expect(topIds(app)).not.toContain('nav_order_help');
98+
});
99+
100+
it(`${label}: re-enabling the package restores its contributions`, () => {
101+
registry.disablePackage(MODULE_PKG);
102+
read(registry);
103+
expect(registry.enablePackage(MODULE_PKG), 'precondition: enabled').toBeTruthy();
104+
const app = read(registry);
105+
expect(groupIds(app)).toEqual(['nav_accounts', 'nav_orders', 'nav_reports']);
106+
expect(topIds(app)).toContain('nav_order_help');
107+
});
108+
}
109+
110+
it('records no missing-group diagnostic for a disabled package (its contribution is not folded at all)', () => {
111+
registry.disablePackage(MODULE_PKG);
112+
registry.getApp('qs_app');
113+
expect(registry.getAppNavDiagnostics('qs_app').map((d: any) => d.packageId)).not.toContain(MODULE_PKG);
114+
});
115+
116+
it('keeps the stored app untouched: disabling never edits the app the module contributes to', () => {
117+
registry.disablePackage(MODULE_PKG);
118+
registry.getApp('qs_app');
119+
const stored = (registry as any).getItem('app', 'qs_app');
120+
expect(groupIds(stored)).toEqual(['nav_accounts']);
121+
});
122+
});

‎packages/objectql/src/registry.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6795,9 +6795,10 @@ export class SchemaRegistry {
67956795
}
67966796

67976797
/**
6798-
* Return a copy of `app` with all registered navigation contributions
6799-
* merged into its `navigation` tree. The stored app is never mutated, so
6800-
* repeated reads stay idempotent.
6798+
* Return a copy of `app` with the registered navigation contributions of
6799+
* every ENABLED package merged into its `navigation` tree (#28117: a disabled
6800+
* package's are dropped). The stored app is never mutated, so repeated reads
6801+
* stay idempotent.
68016802
*
68026803
* Public so the protocol serving path (`getMetaItems` / `getMetaItem` for
68036804
* `app`) can merge contributions the same way `getApp` / `getAllApps` do —
@@ -6815,6 +6816,12 @@ export class SchemaRegistry {
68156816
const sorted = [...contributions].sort((a, b) => a.priority - b.priority);
68166817
for (const c of sorted) {
68176818
if (!c.items.length) continue;
6819+
// [#28117] A disabled package's contributions leave the app, as its
6820+
// objects leave the data plane: "disabled" has one meaning (hidden +
6821+
// refused + data kept). Asked per read, never cached, so an enable
6822+
// restores them with nothing to invalidate; skipped BEFORE the group
6823+
// lookup, so a disabled contribution is not relocated or diagnosed either.
6824+
if (this.isPackageDisabled(c.packageId)) continue;
68186825
if (c.group) {
68196826
const group = this.findNavGroup(nav, c.group);
68206827
if (group) {

0 commit comments

Comments
 (0)