Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/28117-disable-app-module.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/runtime': patch
'@objectstack/objectql': patch
'@objectstack/trigger-record-change': patch
---

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)

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.
Metadata-protocol: none

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.

- **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`.
- **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.
- **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.
- **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.

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

### Disabling a module

A customer who does not use one of your modules can switch it off, per
environment, with `PATCH /api/v1/packages/:id/disable`, and back on with
`PATCH /api/v1/packages/:id/enable`. The module qualifies when its manifest
says `type: 'module'` and declares your app in `dependencies`, the way every
module of a suite already does:

```ts
manifest: {
id: 'app.example.suite.orders',
type: 'module',
namespace: 'suite',
dependencies: { 'app.example.suite': '^1.0.0' },
navigationContributions: [/* its menu entries in the app's groups */],
},
```

Disabled has one meaning: the module is hidden, refused on the data plane, and
its data is kept.

- **Data plane.** `/api/v1/data/<object>` and the by-name metadata reads answer
the module's objects `404 OBJECT_PACKAGE_DISABLED`. Records stay in the
database and come back on enable.
- **Navigation.** The module's `navigationContributions` leave the app's menu.
- **Flows.** Record-change flows on the module's objects do not run while it is
disabled, whoever writes the record.
- **Restart.** The choice is kept per environment and survives a restart.
- **Authoring.** A disabled module is still read-only: edit, publish, revert
and delete keep answering `422 WRITABLE_PACKAGE_REQUIRED`.

**What your app's own code meets.** A hook of your app that reads or writes a
module's object through `ctx.api.object('<object>')` runs inside the server,
not through the data plane, and it is **not** refused: a `find` returns the
module's kept records, and a write lands (no record-change flow runs on it).
There is no `OBJECT_PACKAGE_DISABLED` error to catch there. A browser or API
client reading the module's objects does meet the `404 OBJECT_PACKAGE_DISABLED`
refusal while the module is off.

## Related

- [CLI reference](/docs/deployment/cli) — every `os package publish` and `os package install` flag
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, beforeEach } from 'vitest';
import { SchemaRegistry } from './registry';

/**
* [#28117] A DISABLED package's navigation contributions leave the app it
* contributes to, on every read that folds them, and come back on enable.
*
* A suite module (a `type: 'module'` package of an installed app) puts its
* menu entries into the app's groups through `navigationContributions`
* (ADR-0029 D7). Disabling the module must take those entries out of the app:
* "disabled" has one meaning, hidden + refused on the data plane + data kept.
* Before this card the fold (`applyNavContributions`) pushed every registered
* contribution and never asked whether its package was switched off, so the
* module's objects answered 404 `OBJECT_PACKAGE_DISABLED` while the menu kept
* offering them.
*
* The fold is the ONE seam all three readers go through: the registry helpers
* `getApp` / `getAllApps` here, and the protocol's `app` list and by-name reads
* (`getMetaItems` / `getMetaItem` call `registry.applyNavContributions`), which
* the runtime pin `packages/runtime/src/domains/packages-disable-app-module.test.ts`
* drives through the real door. Nothing is cached past the fold: the verdict
* is the package row's state at read time, so a re-enable needs no
* invalidation.
*/
describe('[#28117] navigation contributions of a disabled package', () => {
const APP_PKG = 'com.qa.suite';
const MODULE_PKG = 'com.qa.suite.orders';
const OTHER_PKG = 'com.qa.suite.reports';

let registry: SchemaRegistry;

const suiteApp = () => ({
name: 'qs_app',
label: 'Suite',
navigation: [
{
id: 'qs_group',
type: 'group',
label: 'Sales',
children: [{ id: 'nav_accounts', type: 'object', label: 'Accounts', objectName: 'qs_account' }],
},
],
});

// Each module declares the app, as a suite's modules do: that is also what
// lets it co-own the app's namespace (ADR-0141 D5).
const manifest = (id: string, type: string) =>
({ id, name: id, version: '1.0.0', type, namespace: 'qs', ...(type === 'module' ? { dependencies: { [APP_PKG]: '^1.0.0' } } : {}) }) as any;

/** The ids the folded app's group holds, read the way `reader` reads it. */
const groupIds = (app: any) =>
(app.navigation.find((n: any) => n.id === 'qs_group')?.children ?? []).map((c: any) => c.id);
const topIds = (app: any) => app.navigation.map((n: any) => n.id);

const READERS: ReadonlyArray<{ label: string; read: (r: SchemaRegistry) => any }> = [
{ label: 'getApp', read: (r) => r.getApp('qs_app') },
{ label: 'getAllApps', read: (r) => r.getAllApps().find((a: any) => a.name === 'qs_app') },
{ label: 'applyNavContributions (the protocol app read)', read: (r) => r.applyNavContributions(suiteApp()) },
];

beforeEach(() => {
registry = new SchemaRegistry({ multiTenant: false });
(registry as any).logLevel = 'silent';
registry.installPackage(manifest(APP_PKG, 'app'), 'code');
registry.installPackage(manifest(MODULE_PKG, 'module'), 'code');
registry.installPackage(manifest(OTHER_PKG, 'module'), 'code');
registry.registerApp(suiteApp(), APP_PKG);
registry.registerAppNavContribution(
{ app: 'qs_app', group: 'qs_group', priority: 50, items: [{ id: 'nav_orders', type: 'object', label: 'Orders', objectName: 'qs_order' }] },
MODULE_PKG,
);
// A contribution into a group the app does not declare: the fold relocates
// it to the top level (#14553). Disabled, it must not be relocated either.
registry.registerAppNavContribution(
{ app: 'qs_app', group: 'qs_missing_group', priority: 60, items: [{ id: 'nav_order_help', type: 'url', label: 'Help', url: '/help' }] },
MODULE_PKG,
);
registry.registerAppNavContribution(
{ app: 'qs_app', group: 'qs_group', priority: 70, items: [{ id: 'nav_reports', type: 'object', label: 'Reports', objectName: 'qs_report' }] },
OTHER_PKG,
);
});

for (const { label, read } of READERS) {
it(`${label}: an enabled package's contributions are folded (the control)`, () => {
const app = read(registry);
expect(groupIds(app)).toEqual(['nav_accounts', 'nav_orders', 'nav_reports']);
expect(topIds(app)).toContain('nav_order_help');
});

it(`${label}: a disabled package's contributions are dropped, in its group and at the top level, and the others stay`, () => {
expect(registry.disablePackage(MODULE_PKG), 'precondition: disabled').toBeTruthy();
const app = read(registry);
expect(groupIds(app)).toEqual(['nav_accounts', 'nav_reports']);
expect(topIds(app)).not.toContain('nav_order_help');
});

it(`${label}: re-enabling the package restores its contributions`, () => {
registry.disablePackage(MODULE_PKG);
read(registry);
expect(registry.enablePackage(MODULE_PKG), 'precondition: enabled').toBeTruthy();
const app = read(registry);
expect(groupIds(app)).toEqual(['nav_accounts', 'nav_orders', 'nav_reports']);
expect(topIds(app)).toContain('nav_order_help');
});
}

it('records no missing-group diagnostic for a disabled package (its contribution is not folded at all)', () => {
registry.disablePackage(MODULE_PKG);
registry.getApp('qs_app');
expect(registry.getAppNavDiagnostics('qs_app').map((d: any) => d.packageId)).not.toContain(MODULE_PKG);
});

it('keeps the stored app untouched: disabling never edits the app the module contributes to', () => {
registry.disablePackage(MODULE_PKG);
registry.getApp('qs_app');
const stored = (registry as any).getItem('app', 'qs_app');
expect(groupIds(stored)).toEqual(['nav_accounts']);
});
});
13 changes: 10 additions & 3 deletions packages/objectql/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6795,9 +6795,10 @@ export class SchemaRegistry {
}

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