diff --git a/.changeset/19277-in-process-install-honours-enable-on-install.md b/.changeset/19277-in-process-install-honours-enable-on-install.md new file mode 100644 index 00000000000..36cfe8431e2 --- /dev/null +++ b/.changeset/19277-in-process-install-honours-enable-on-install.md @@ -0,0 +1,23 @@ +--- +"@objectstack/metadata-protocol": patch +--- + +The in-process install primitive honours `enableOnInstall` instead of ignoring it (#19277). + +`InstallPackageRequestSchema.enableOnInstall` (`kernel/package-registry.zod.ts`) is the request contract of `ObjectStackProtocol.installPackage` / `MetadataProtocol.installPackage`. The implementation read `request.manifest` and `request.settings` and nothing else, so a caller that asked for `enableOnInstall: false` got an ENABLED install — no refusal, no warning, no effect. That is a declared option the runtime did not deliver, which ADR-0049 (enforce-or-remove) and Prime Directive #10 refuse outright. Ruling batch #153 item 5 letter 1 (#18605) kept this declaration as a COPY of the HTTP request key with the same meaning, so the disposition is enforce, not retire. + +The primitive now applies the same rule the HTTP door applies (maintainer ruling batch #157 item 5 letter C, 「缺省 = 保持,有旗 = 设置」), through the same registry verbs `PATCH /packages/:id/enable` and `PATCH /packages/:id/disable` use: + +```text +enableOnInstall: true ⇒ enablePackage — clears a disable, including a boot-seeded one +enableOnInstall: false ⇒ disablePackage — the row and its `status` both move +enableOnInstall absent ⇒ no lifecycle call at all; the row the registry returned stands +``` + +Absent is a third state, not a synonym for `true`: on a FRESH id the registry still lands the package enabled (the declared default), and on an EXISTING row it preserves whatever that row says (#18877). A non-boolean value is read as absent rather than coerced. + +⚠️ **What this seam does not write, stated rather than implied.** The runtime's durable disabled-package file is keyed by environment (`setPackageDisabled(environmentId, id, disabled)`, `@objectstack/runtime`), and an `InstallPackageRequest` carries no environment, so that record cannot be written from here — the HTTP door owns that half and writes it from the row it returned. `enableOnInstall` through the in-process primitive therefore moves the registry row, which is what every in-process reader serves from, for the life of the process; a caller that needs the choice replayed after a restart goes through the door that owns the durable record. + +No behaviour changes for any caller on the tree: measured across `packages/**`, `examples/**` and `apps/**`, no existing call site sets the key — the HTTP door deliberately calls `installPackage({ manifest, settings })` and performs the flip itself, and `duplicatePackage` passes `{ manifest }` alone. The change is observable only to a caller that sets the key, which until now got silence. + +Clause-②: no diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 0b04d1ff5d3..e7a78741e04 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -22327,6 +22327,13 @@ export class ObjectStackProtocolImplementation implements * The DB write is best-effort and non-fatal: when the `package` service is * absent (e.g. the `marketplace` capability is off) the package is still * registered in-memory and visible for the lifetime of the process. + * + * [#19277] `request.enableOnInstall` is HONOURED here, under the same rule + * the HTTP door implements — 「缺省 = 保持,有旗 = 设置」: `true` enables, + * `false` disables, and an ABSENT key makes no lifecycle call at all. The + * durable disabled-package FILE is not this seam's to write (it is keyed by + * environment, which this request does not carry); see the comment on the + * flag arms below. */ async installPackage(request: InstallPackageRequest): Promise { // #2532 — runtime-created base packages routinely arrive versionless @@ -22363,7 +22370,63 @@ export class ObjectStackProtocolImplementation implements // only); an unparsed range never causes a false rejection. assertProtocolCompat(manifest); - const pkg = this.engine.registry.installPackage(manifest as any, request.settings); + let pkg = this.engine.registry.installPackage(manifest as any, request.settings); + + // [#19277] HONOUR `enableOnInstall` — the key THIS request contract + // declares and this primitive read past. `InstallPackageRequestSchema` + // (`packages/spec/src/kernel/package-registry.zod.ts`) has carried the + // key since it was written, and the implementation here read + // `request.manifest` and `request.settings` and nothing else: a caller + // that switched the option off got an ENABLED install, with no refusal + // and no warning. That is «declared but not enforced» on a published + // option — what ADR-0049 (enforce-or-remove) and Prime Directive #10 + // refuse outright. Ruling batch #153 item 5 letter 1 (#18605) kept the + // kernel declaration as a COPY of the HTTP request key with the SAME + // meaning, so the disposition is ENFORCE, not retire. + // + // ⭐ The contract is 「缺省 = 保持,有旗 = 设置」 — maintainer ruling batch + // #157 item 5 letter C, the same rule the HTTP door implements + // (`packages/runtime/src/domains/packages.ts`). Three states, three + // outcomes, through the SAME registry verbs `PATCH /packages/:id/enable` + // and `PATCH /packages/:id/disable` use: + // + // true ⇒ enablePackage + // false ⇒ disablePackage + // absent ⇒ nothing at all; the row the registry returned stands + // + // ⚠️ The `true` arm is not decoration. `SchemaRegistry.installPackage` + // has preserved an existing row's `enabled` / `status` / + // `statusChangedAt` since #18877, so on a re-install nothing else will + // clear a disable any more — dropping this arm would silently stop + // honouring `true` on exactly the path an upgrade takes. + // + // ⚠️ `=== true` / `=== false`, never a truthiness test and never a `??` + // default: the THREE states of this key are the contract, and + // collapsing absent into either one is the defect. The declaration's own + // `.default(true)` never reaches here — nothing parses an install + // request through `InstallPackageRequestSchema` on this path — so + // absence arrives intact and is read as absence. + // + // ⛔ What this seam does NOT write, recorded so it is not mistaken for + // an oversight: the runtime's durable disabled-package file. That record + // is keyed by ENVIRONMENT (`setPackageDisabled(environmentId, id, + // disabled)`, `packages/runtime/src/package-state-store.ts`) and this + // request carries no environment, so the key cannot even be formed here; + // the module also lives in `@objectstack/runtime`, which depends on this + // package and not the other way round. The HTTP door owns that half and + // writes it from the row it returned. So `enableOnInstall` through this + // primitive moves the registry row — what every in-process reader serves + // from — for the life of the process, and a caller that needs the choice + // to survive a restart goes through the door that owns the durable + // record. + const requestedEnabled = request.enableOnInstall; + if (requestedEnabled === true) { + const enabled = this.engine.registry.enablePackage(manifest.id); + if (enabled) pkg = enabled; + } else if (requestedEnabled === false) { + const disabled = this.engine.registry.disablePackage(manifest.id); + if (disabled) pkg = disabled; + } // Best-effort durable persistence to `sys_packages` (non-fatal by // design — without the `package` service the install stays visible diff --git a/packages/objectql/src/protocol-install-package-enable-on-install.test.ts b/packages/objectql/src/protocol-install-package-enable-on-install.test.ts new file mode 100644 index 00000000000..5e544f40223 --- /dev/null +++ b/packages/objectql/src/protocol-install-package-enable-on-install.test.ts @@ -0,0 +1,285 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#19277] The IN-PROCESS install door honours `enableOnInstall`. + * + * ## The defect this file pins shut + * + * `InstallPackageRequestSchema.enableOnInstall` + * (`packages/spec/src/kernel/package-registry.zod.ts`) is the request contract + * of the in-process `ObjectStackProtocol.installPackage` / + * `MetadataProtocol.installPackage` primitive. The HTTP door has honoured its + * copy of the key since #18058; this primitive read `manifest` + `settings` + * and nothing else. So a caller that asked for `enableOnInstall: false` got an + * ENABLED install, with no refusal and no warning — «declared but not + * enforced» on a published option, which ADR-0049 (enforce-or-remove) and + * Prime Directive #10 exist to end. Ruling batch #153 item 5 letter 1 (#18605) + * kept the kernel declaration as a COPY of the HTTP request key with the SAME + * meaning ⇒ the disposition is ENFORCE, not retire. + * + * ## The matrix, taken from the tree rather than from the card + * + * ⚠️ The card that filed this work describes the target as + * 「`enableOnInstall ?? true` on install AND on re-install」. That sentence was + * written before #19291 landed and it is SPENT: `?? true` on re-install is + * precisely what the HTTP door stopped doing. The live rule is 「缺省 = 保持, + * 有旗 = 设置」 (maintainer ruling batch #157 item 5 letter C), and the cells + * below are the ones + * `packages/runtime/src/domains/packages-install-enable-on-install.test.ts` + * pins on the HTTP door today, read on `origin/main`: + * + * ```text + * FRESH id flag false ⇒ disabled flag true ⇒ enabled absent ⇒ enabled + * EXISTING flag false ⇒ disabled flag true ⇒ enabled absent ⇒ PRESERVE + * SEEDED id flag true ⇒ enabled (the flag outranks the boot seed) + * absent ⇒ disabled (the seed decides for a row that does not exist yet) + * ``` + * + * ## Why these cases use a REAL `SchemaRegistry` + * + * 「缺省 = 保持」 and 「the seed decides only for an id with no row」 are + * `SchemaRegistry.installPackage`'s own behaviour (#18877). A registry double + * that simply returns `{ enabled: true }` makes every preserve/seed cell below + * satisfiable by a door that does nothing at all, so this file drives the real + * registry and the real protocol implementation and reads the state both of + * them end up holding. + * + * ## The one HTTP-door cell with no analogue here + * + * The door's BARE body form (a manifest posted as the whole body, where + * `ManifestSchema`'s strict close refuses the key by name) does not exist at + * this seam: `InstallPackageRequest` always carries `manifest` as a field, so + * there is no second body shape for the key to be spelled on. What IS pinned + * instead is the third state's boundary — a non-boolean value is read as + * ABSENT, never coerced. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SchemaRegistry } from './registry.js'; + +/** A complete manifest — the shape the in-process primitive is reached with. */ +const manifest = (id: string, namespace: string) => ({ + id, + name: `Acme ${namespace}`, + namespace, + version: '1.0.0', + type: 'app', +}); + +function freshRegistry(): SchemaRegistry { + const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' }); + (registry as any).logLevel = 'silent'; + return registry; +} + +/** + * The in-process door, with a `package` service present so the durable + * `sys_packages` half is exercised rather than warned past. That service + * stores the MANIFEST; it carries no lifecycle column, which is why the + * registry row below is the record every assertion reads. + */ +function makeDoor(registry: SchemaRegistry) { + const publish = vi.fn(async () => ({ success: true })); + const services = new Map([['package', { publish }]]); + const protocol = new ObjectStackProtocolImplementation({ registry } as never, () => services); + return { protocol, publish }; +} + +const install = (protocol: ObjectStackProtocolImplementation, request: unknown) => + (protocol as any).installPackage(request) as Promise<{ package: any; message: string }>; + +describe('#19277 — the in-process install door honours `enableOnInstall` (FRESH id)', () => { + let registry: SchemaRegistry; + let protocol: ObjectStackProtocolImplementation; + + beforeEach(() => { + registry = freshRegistry(); + protocol = makeDoor(registry).protocol; + }); + + it('`enableOnInstall: false` installs the package DISABLED, in both records', async () => { + const res = await install(protocol, { + manifest: manifest('com.acme.off', 'off'), + enableOnInstall: false, + }); + + // ⭐ The line this card exists for. Before #19277 both of these read + // `true`, because nothing at this seam read the key. + expect(res.package.enabled, 'the row this primitive RETURNED').toBe(false); + expect(registry.getPackage('com.acme.off')?.enabled, 'the registry the next read serves from').toBe(false); + }); + + it('`enableOnInstall: false` also moves `status`, the way `PATCH /:id/disable` does', async () => { + const res = await install(protocol, { + manifest: manifest('com.acme.status', 'status'), + enableOnInstall: false, + }); + + expect(res.package.status).toBe('disabled'); + expect(registry.getPackage('com.acme.status')?.status).toBe('disabled'); + }); + + it('`enableOnInstall: true` installs it ENABLED — the `false` case is not a door that disables everything', async () => { + const res = await install(protocol, { + manifest: manifest('com.acme.on', 'on'), + enableOnInstall: true, + }); + + expect(res.package.enabled).toBe(true); + expect(res.package.status).toBe('installed'); + expect(registry.getPackage('com.acme.on')?.enabled).toBe(true); + }); + + it('an ABSENT `enableOnInstall` installs a FRESH id enabled — the declared default', async () => { + const res = await install(protocol, { manifest: manifest('com.acme.absent', 'absent') }); + + expect(res.package.enabled).toBe(true); + expect(registry.getPackage('com.acme.absent')?.enabled).toBe(true); + }); + + it('a NON-BOOLEAN value is read as ABSENT — three states, never a truthiness test', async () => { + // `'false'` is truthy and would DISABLE nothing under `=== false`, but + // it would also ENABLE under a `??`/truthiness reading. The declaration + // admits `boolean | undefined` only, so a string is not a fourth state: + // it is an undeclared value, and the door must not act on it. Same + // disposition the HTTP door records for a string-typed flag. + const res = await install(protocol, { + manifest: manifest('com.acme.stringy', 'stringy'), + enableOnInstall: 'false', + }); + + expect(res.package.enabled, 'not coerced into the `false` arm').toBe(true); + expect(registry.getPackage('com.acme.stringy')?.enabled).toBe(true); + }); + + it('the durable `sys_packages` write still happens on the flag arms', async () => { + // The lifecycle flip must not displace the persistence half: the + // manifest still reaches the `package` service on an install that + // carries the key. + const local = freshRegistry(); + const { protocol: door, publish } = makeDoor(local); + await install(door, { manifest: manifest('com.acme.persisted', 'persisted'), enableOnInstall: false }); + + expect(publish).toHaveBeenCalledTimes(1); + expect((publish.mock.calls[0] as unknown[])[0]).toMatchObject({ + manifest: { id: 'com.acme.persisted' }, + }); + expect(local.getPackage('com.acme.persisted')?.enabled).toBe(false); + }); +}); + +describe('#19277 — an EXISTING row: 「缺省 = 保持,有旗 = 设置」', () => { + let registry: SchemaRegistry; + let protocol: ObjectStackProtocolImplementation; + + beforeEach(() => { + registry = freshRegistry(); + protocol = makeDoor(registry).protocol; + }); + + /** Install once with the flag off, and prove the disable really landed. */ + const installDisabled = async (id: string, namespace: string) => { + const first = await install(protocol, { manifest: manifest(id, namespace), enableOnInstall: false }); + expect(first.package.enabled, 'precondition: the first install really disabled it').toBe(false); + }; + + it('[#18877 re-ruled] a re-install with the flag ABSENT PRESERVES the disable', async () => { + // ⚠️ The card's own prose asks for `enableOnInstall ?? true` here, which + // would RE-ENABLE. That is the cell ruling batch #157 item 5 letter C + // re-ruled and #19291 landed on the HTTP door + // (`packages-install-enable-on-install.test.ts`, the + // `[#18877 re-ruled]` cases). An install that asked for nothing must + // leave the operator's last explicit decision standing. + const id = 'com.acme.reinstall.absent'; + await installDisabled(id, 'reinstallabsent'); + + const again = await install(protocol, { manifest: manifest(id, 'reinstallabsent') }); + + expect(again.package.enabled, 'the row this primitive RETURNED').toBe(false); + expect(again.package.status, '`status` is carried over with `enabled`, not recomputed').toBe('disabled'); + expect(registry.getPackage(id)?.enabled, 'the registry the next read serves from').toBe(false); + }); + + it('a re-install with `enableOnInstall: true` CLEARS the disable', async () => { + // The arm that is load-bearing only because `installPackage` preserves: + // nothing else clears a durable disable on a re-install any more. + const id = 'com.acme.reinstall.true'; + await installDisabled(id, 'reinstalltrue'); + + const again = await install(protocol, { + manifest: manifest(id, 'reinstalltrue'), + enableOnInstall: true, + }); + + expect(again.package.enabled).toBe(true); + expect(again.package.status).toBe('installed'); + expect(registry.getPackage(id)?.enabled).toBe(true); + }); + + it('⛔ the disable direction is UNCHANGED — `false` still disables an enabled row', async () => { + const id = 'com.acme.reinstall.staysoff'; + await install(protocol, { manifest: manifest(id, 'reinstallstaysoff') }); + expect(registry.getPackage(id)?.enabled, 'precondition: it started enabled').toBe(true); + + const again = await install(protocol, { + manifest: manifest(id, 'reinstallstaysoff'), + enableOnInstall: false, + }); + + expect(again.package.enabled).toBe(false); + expect(registry.getPackage(id)?.enabled).toBe(false); + }); +}); + +describe('#19277 — a BOOT-SEEDED disable, the durable state this seam can see', () => { + /** + * A restart, spelled exactly as `AppPlugin.seedPersistedDisabledPackages` + * spells it: a registry born empty, then seeded from the persisted disable + * set BEFORE any package registration. + */ + const rebootWithSeed = (ids: string[]) => { + const registry = freshRegistry(); + registry.setInitialDisabledPackageIds(ids); + return { registry, protocol: makeDoor(registry).protocol }; + }; + + it('flag ABSENT on a seeded id: the seed decides, and the primitive reports it honestly', async () => { + const id = 'com.acme.seeded.absent'; + const { registry, protocol } = rebootWithSeed([id]); + + const res = await install(protocol, { manifest: manifest(id, 'seededabsent') }); + + expect(res.package.enabled, 'the row this primitive RETURNED').toBe(false); + expect(res.package.status).toBe('disabled'); + expect(registry.getPackage(id)?.enabled).toBe(false); + }); + + it('[#18877 re-ruled] a seeded id asked for `enableOnInstall: true` is ENABLED — 「有旗 = 设置」', async () => { + // The in-process analogue of the HTTP door's 「re-install with `true` + // clears the durable disable」: an explicit flag outranks the boot seed, + // in both directions (ruling batch #157 item 5 letter C item 2). + const id = 'com.acme.seeded.true'; + const { registry, protocol } = rebootWithSeed([id]); + + const res = await install(protocol, { + manifest: manifest(id, 'seededtrue'), + enableOnInstall: true, + }); + + expect(res.package.enabled, 'the flag was present and it said `true`').toBe(true); + expect(res.package.status).toBe('installed'); + expect(registry.getPackage(id)?.enabled, 'the registry agrees with the row').toBe(true); + }); + + it('⛔ the seed is not a door that disables everything — an id it never named installs enabled', async () => { + // The control leg. Without it every seeded assertion above is + // satisfiable by a registry that simply refuses to enable anything. + const { registry, protocol } = rebootWithSeed(['com.acme.seeded.neighbour']); + + const res = await install(protocol, { manifest: manifest('com.acme.seeded.control', 'seededcontrol') }); + + expect(res.package.enabled).toBe(true); + expect(registry.getPackage('com.acme.seeded.control')?.enabled).toBe(true); + }); +});