diff --git a/.changeset/30962-host-package-state-store.md b/.changeset/30962-host-package-state-store.md new file mode 100644 index 00000000000..6418c8c16c3 --- /dev/null +++ b/.changeset/30962-host-package-state-store.md @@ -0,0 +1,23 @@ +--- +'@objectstack/runtime': patch +--- + +feat(runtime): a host can supply where module enable/disable state lives — `createPackageStateStorePlugin(store)` — and a store that refuses a write makes the enable or disable door fail with nothing changed + +Clause-②: yes (widening) +Metadata-protocol: none + +Package lifecycle state (which packages an operator has disabled) has lived only in a file under `OS_HOME`, which a hosted container loses when it is replaced. `@objectstack/runtime` now exports the seam a host uses to keep it elsewhere: + +- **`PackageStateStore`**: `loadDisabledPackageIds(environmentId)` and `setPackageDisabled(environmentId, packageId, disabled)`, either may return a promise. +- **`createPackageStateStorePlugin(store)`** (and the `PackageStateStorePlugin` class it builds): add it to the kernel's plugins. It registers the store as the `PACKAGE_STATE_STORE_SERVICE` (`'package-state-store'`) service under the plugin name `PACKAGE_STATE_STORE_PLUGIN_NAME`, which `AppPlugin` lists in its `optionalDependencies`, so the store is in place before the boot seed reads it whatever slot it is composed in. A store missing either method is refused with a `TypeError`. + +With a host store composed, the boot seed reads it instead of the file, and the package doors (install, `PATCH /packages/:id/enable`, `PATCH /packages/:id/disable`, `DELETE /packages/:id`) write to it; the file under `OS_HOME` is neither read nor written. A host store that throws: + +- on a write from the enable or disable door: the door changes nothing in memory and answers `503 SERVICE_UNAVAILABLE`, saying the switch did not happen and can be retried. The doors now write the store before they switch the package. +- on a write from install or delete, whose change has already landed: logged at `error`, naming what the next boot will read. +- on the boot read: the boot fails, naming the store, rather than bring every disabled package back enabled. + +A store registered under the service name by a plugin of the host's own, which nothing orders ahead of `AppPlugin`, fails `AppPlugin.start()` with the fix (use `createPackageStateStorePlugin`), instead of serving a boot seeded from the file while the doors write to the store. + +Without a host store nothing changes: the same file, the same bytes, and a failed file write still only warns. diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index d87dbf37b52..a7b1c990707 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -30,6 +30,7 @@ import { mergeSeedDatasets, readSeedDatasets, registerSeedReplayerOnce } from '. import { describeSeedLocale, resolveSeedLocaleForLoad } from './seed-locale.js'; import { declareSeedSource, recordOrganizationReplay, type SeedLandingReport } from './seed-settlement.js'; import { loadDisabledPackageIds } from './package-state-store.js'; +import { asPackageStateStore, PACKAGE_STATE_STORE_PLUGIN_NAME } from './package-state-host-store.js'; import type { IFieldFileService, IMetadataService, IObjectQLEngine, II18nService } from '@objectstack/spec/contracts'; import { FIELD_FILE_SERVICE } from '@objectstack/spec/contracts'; import { normalizeFlowFunctionEntry, type NormalizedFlowFunction } from '@objectstack/spec/automation'; @@ -171,12 +172,19 @@ export class AppPlugin implements Plugin { * `start()`, and a database it refuses must be left as it found it — so this * plugin's `start()`, which loads the inline seed, runs only after the guard * has judged. Order-if-present: every other kernel is unchanged. + * + * The host's package-state store (`PACKAGE_STATE_STORE_PLUGIN_NAME`, + * `./package-state-host-store.ts`, spelled as a literal for the same + * reason): `init()` seeds the registry's disabled set from it, so it must + * be registered first. Order-if-present: a kernel without it seeds from + * the file under `OS_HOME`, as before. */ optionalDependencies = [ 'com.objectstack.engine.objectql', 'com.objectstack.organization-ownership-boot-guard', 'com.objectstack.auth', 'com.objectstack.auth.identity-objects', + 'com.objectstack.package-state-store', ]; /** * The one init-time service this plugin CANNOT degrade without: a @@ -233,6 +241,14 @@ export class AppPlugin implements Plugin { * disagree about which datasources this artifact registers from code — and * the residual-owner warning prints once, at whichever read comes first. */ + /** + * Which store the boot seed read: `'host'` (the registered + * `package-state-store` service) or `'file'` (the file under `OS_HOME`), + * `undefined` when there was no engine to seed. `start()` holds a `'file'` + * seed against a host store that registered after it — see + * {@link refuseLatePackageStateStore}. + */ + private packageStateSeedSource?: 'host' | 'file'; private codeDatasourceOwners?: Array<{ datasource: any; owner: { packageId?: string; packageVersion?: string } }>; /** * [#23649] Set once {@link installDatasourceMapping} has handed the stack's @@ -561,7 +577,7 @@ export class AppPlugin implements Plugin { // envs, and a package an operator had disabled came back ENABLED on // every restart. The seed must land before ANY registration path runs, // which is Phase 1, unconditionally. - this.seedPersistedDisabledPackages(ctx); + await this.seedPersistedDisabledPackages(ctx); if (this.empty) { ctx.logger.debug('[AppPlugin] empty env — no app payload, skipping init', { pluginName: this.name, @@ -686,22 +702,63 @@ export class AppPlugin implements Plugin { * because that decomposition happens at the `manifest.register()` call at * the end of init. * - * Best-effort — never block boot on this. Degrades silently on kernels - * with no engine (metadata-only one-shot commands, mock-engine tests). + * The store is the host's when one is registered as the + * `package-state-store` service (`createPackageStateStorePlugin`, + * `./package-state-host-store.ts`, ordered ahead of this plugin by + * `optionalDependencies`), and the file under `OS_HOME` otherwise. + * + * The file read is best-effort, exactly as before: it never blocks boot. + * A HOST store's read that throws fails the boot instead, because seeding + * nothing would bring every module its operator switched off back on, and + * nothing would look wrong (ADR-0025 §3.5 M1 makes the same call for the + * installed-plugin loader: a disable that cannot be read is not read as + * "nothing is disabled"). Degrades silently on kernels with no engine + * (metadata-only one-shot commands, mock-engine tests). */ - private seedPersistedDisabledPackages(ctx: PluginContext): void { + private async seedPersistedDisabledPackages(ctx: PluginContext): Promise { + let setter: ((ids: Iterable) => void) | undefined; + let ql: { registry?: { setInitialDisabledPackageIds?: (ids: Iterable) => void } } | undefined; try { - const ql = ctx.getService<{ registry?: { setInitialDisabledPackageIds?: (ids: Iterable) => void } }>('objectql'); - const setter = ql?.registry?.setInitialDisabledPackageIds; - if (typeof setter === 'function') { - const disabled = loadDisabledPackageIds(this.projectContext?.environmentId); - if (disabled.size > 0) { - setter.call(ql!.registry, disabled); - ctx.logger.info('[AppPlugin] seeded persisted disabled packages', { - environmentId: this.projectContext?.environmentId, - disabled: Array.from(disabled), - }); - } + ql = ctx.getService<{ registry?: { setInitialDisabledPackageIds?: (ids: Iterable) => void } }>('objectql'); + setter = ql?.registry?.setInitialDisabledPackageIds; + } catch { + // No engine on this kernel — nothing to seed. + } + if (typeof setter !== 'function') return; + const hostStore = this.registeredPackageStateStore(ctx); + this.packageStateSeedSource = hostStore ? 'host' : 'file'; + if (hostStore) { + let disabled: Set; + try { + disabled = new Set( + Array.from(await hostStore.loadDisabledPackageIds(this.projectContext?.environmentId)) + .filter((id): id is string => typeof id === 'string'), + ); + } catch (err) { + throw new Error( + `[AppPlugin] the host's package-state store (${PACKAGE_STATE_STORE_PLUGIN_NAME}) could not read ` + + `which packages are disabled, so the boot stops rather than bring them back enabled: ` + + `${(err as Error)?.message ?? String(err)}`, + ); + } + if (disabled.size > 0) { + setter.call(ql!.registry, disabled); + ctx.logger.info('[AppPlugin] seeded persisted disabled packages', { + environmentId: this.projectContext?.environmentId, + store: 'host', + disabled: Array.from(disabled), + }); + } + return; + } + try { + const disabled = loadDisabledPackageIds(this.projectContext?.environmentId); + if (disabled.size > 0) { + setter.call(ql!.registry, disabled); + ctx.logger.info('[AppPlugin] seeded persisted disabled packages', { + environmentId: this.projectContext?.environmentId, + disabled: Array.from(disabled), + }); } } catch (err) { ctx.logger.warn('[AppPlugin] failed to seed persisted package state', { @@ -710,6 +767,39 @@ export class AppPlugin implements Plugin { } } + /** + * The host's package-state store, or `undefined` when this kernel has + * none. The service name is a literal so `check:init-service-contract` + * can hold this init-time read to the `optionalDependencies` entry above. + */ + private registeredPackageStateStore(ctx: PluginContext) { + let registered: unknown; + try { + registered = ctx.getService('package-state-store'); + } catch { + return undefined; + } + return asPackageStateStore(registered); + } + + /** + * A boot that seeded from the FILE, on a kernel where a host store is + * registered by now, read the wrong store: the host's plugin initialized + * after this one, so the disables it holds were never seeded while the + * package doors will write to it. Refused by name, with the fix, rather + * than served with two stores in play. + */ + private refuseLatePackageStateStore(ctx: PluginContext): void { + if (this.packageStateSeedSource !== 'file') return; + if (this.registeredPackageStateStore(ctx) === undefined) return; + throw new Error( + `[AppPlugin] a package-state store was registered as the 'package-state-store' service after this ` + + `plugin's boot seed had already read the file under OS_HOME, so the packages that store holds as ` + + `disabled would come back enabled. Register it with createPackageStateStorePlugin(store) from ` + + `@objectstack/runtime, which is ordered ahead of AppPlugin.`, + ); + } + /** * Install the engine's DEFAULT hook body runner (`engine.setDefaultBodyRunner`). * @@ -1240,6 +1330,7 @@ export class AppPlugin implements Plugin { } start = async (ctx: PluginContext) => { + this.refuseLatePackageStateStore(ctx); if (this.empty) { ctx.logger.debug('[AppPlugin] empty env — no app payload, skipping start', { pluginName: this.name, diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index b1cec174829..2f5b879b9c2 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -152,6 +152,9 @@ import { repeatedQueryParamMessage, unknownQueryParamRefusalMessage } from '@obj // defect (#6928) this module exists to stop anyone writing again. import { parseBooleanParam } from '../query-param.js'; import { setPackageDisabled } from '../package-state-store.js'; +// [#30962] The host seam for the same state: a store a host registers as the +// `package-state-store` service replaces the file at every door below. +import { asPackageStateStore, PACKAGE_STATE_STORE_SERVICE } from '../package-state-host-store.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; import { readDispatchedBody } from '../deferred-request-body.js'; @@ -759,6 +762,99 @@ interface PackageRowRegistryHost { }; } +/** + * [#30962] A host's package-state store refused a write (or its lookup + * faulted). Raised by {@link writePackageState} only for a HOST store: the + * file's own failure is rethrown as it is, so the file default's handling + * stays exactly what it was. + */ +class HostPackageStateRefusal extends Error { + constructor(readonly packageId: string, cause: unknown) { + super((cause as Error)?.message ?? String(cause)); + this.name = 'HostPackageStateRefusal'; + } +} + +/** + * [#30962] Write `packageId`'s disabled flag to this environment's package-state + * store: the host's when one is registered as the `package-state-store` + * service (`../package-state-host-store.ts`), the file under `OS_HOME` + * otherwise. Resolved through the CLASSIFIED lookup, so a host store whose + * factory throws refuses the write instead of reading as "no host store" and + * landing the write in a file nobody reads. + */ +async function writePackageState( + deps: DomainHandlerDeps, + context: HttpProtocolContext, + packageId: string, + disabled: boolean, +): Promise { + let host; + try { + host = asPackageStateStore(await deps.resolveServiceOrLoud(context, PACKAGE_STATE_STORE_SERVICE)); + } catch (err) { + throw new HostPackageStateRefusal(packageId, err); + } + if (!host) { + setPackageDisabled(context?.environmentId, packageId, disabled); + return; + } + try { + await host.setPackageDisabled(context?.environmentId, packageId, disabled); + } catch (err) { + throw new HostPackageStateRefusal(packageId, err); + } +} + +/** + * [#30962, cloud#3636 ruling acceptance line 1] The enable and disable doors' + * answer to a host store that refused: nothing changed in memory (the doors + * write BEFORE they switch the package), so the door says so, and that the + * same request can be retried, instead of answering a switch the store does + * not hold. The standard `SERVICE_UNAVAILABLE`: the store, not the request, + * is what failed. + */ +function hostPackageStateRefused( + deps: DomainHandlerDeps, + refusal: HostPackageStateRefusal, + verb: 'enabled' | 'disabled', +): HttpDispatcherResult { + return { + handled: true, + response: deps.error( + `Package '${refusal.packageId}' was not ${verb}: the package-state store refused to record it, so ` + + `nothing changed. Retry once the store accepts writes. (${refusal.message})`, + 503, + { code: 'SERVICE_UNAVAILABLE' }, + ), + }; +} + +/** + * [#30962] {@link writePackageState} for the doors whose package change has + * ALREADY landed when they write (install, delete): the request is not undone + * over the state store. A host store's refusal is logged at `error` here, once + * per failed write, naming what is not durable; the file's failure is rethrown + * to the caller's existing `warn`, unchanged. + */ +async function writePackageStateAfterChange( + deps: DomainHandlerDeps, + context: HttpProtocolContext, + packageId: string, + disabled: boolean, +): Promise { + try { + await writePackageState(deps, context, packageId, disabled); + } catch (err) { + if (!(err instanceof HostPackageStateRefusal)) throw err; + console.error( + `[handlePackages] the package-state store refused to record package '${packageId}' as ` + + `${disabled ? 'disabled' : 'enabled'}: this process serves it that way, but the next boot reads the ` + + `store and will not. Switch it again once the store accepts writes. (${err.message})`, + ); + } +} + /** * [#30377] An enable of a disabled package recomputes the roll-up summaries the * package adds to other packages' objects by extension (`ObjectQL`'s @@ -2248,12 +2344,14 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // explicit action left there. #18058's «每一次 install 都持久化它返回 // 的状态» is unchanged; what changed is which state that is. // - // Same best-effort try/catch as `PATCH /packages/:id/enable` below: - // the in-memory install already succeeded, so a state-file failure - // must not turn a 201 into a 500. + // Best-effort: the in-memory install already succeeded, so a + // state-store failure must not turn a 201 into a 500. [#30962] A + // host store's refusal is logged at `error` instead + // (`writePackageStateAfterChange`); only the enable and disable + // doors, which write before they switch, answer it. const rowDisabled = pkg?.enabled === false; try { - setPackageDisabled(_context?.environmentId, pkgId, rowDisabled); + await writePackageStateAfterChange(deps, _context, pkgId, rowDisabled); } catch (err) { console.warn('[handlePackages] failed to persist enableOnInstall', { id: pkgId, disabled: rowDisabled, error: (err as Error)?.message }); } @@ -2280,14 +2378,20 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin const walled = await refuseWalledCatalogPackageWrite(deps, _context, `Enabling package "${id}"`); if (walled) return walled; const queryRefused = refusePackageWriteDoorQuery(deps, query, PACKAGE_WRITE_NO_QUERY_PARAMS); if (queryRefused) return queryRefused; - const pkg = registry.enablePackage(id); - if (!pkg) return { handled: true, response: deps.error(`Package '${id}' not found`, 404) }; - await summaryRefreshSettled(qlService); + // [#30962] The state store FIRST, the registry second: a host store + // that refuses leaves the package exactly as it was, and the door + // answers the refusal (`hostPackageStateRefused`). The file keeps + // its best-effort `warn`, as before. + if (!registry.getPackage(id)) return { handled: true, response: deps.error(`Package '${id}' not found`, 404) }; try { - setPackageDisabled(_context?.environmentId, id, false); + await writePackageState(deps, _context, id, false); } catch (err) { + if (err instanceof HostPackageStateRefusal) return hostPackageStateRefused(deps, err, 'enabled'); console.warn('[handlePackages] failed to persist enable state', { id, error: (err as Error)?.message }); } + const pkg = registry.enablePackage(id); + if (!pkg) return { handled: true, response: deps.error(`Package '${id}' not found`, 404) }; + await summaryRefreshSettled(qlService); return { handled: true, response: deps.success(pkg) }; } @@ -2335,13 +2439,18 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin ), }; } - const pkg = registry.disablePackage(id); - if (!pkg) return { handled: true, response: deps.error(`Package '${id}' not found`, 404) }; + // [#30962] The state store FIRST, the registry second, exactly as + // the enable door above: a host store that refuses leaves the + // package enabled and served, and the door answers the refusal. + if (!registry.getPackage(id)) return { handled: true, response: deps.error(`Package '${id}' not found`, 404) }; try { - setPackageDisabled(_context?.environmentId, id, true); + await writePackageState(deps, _context, id, true); } catch (err) { + if (err instanceof HostPackageStateRefusal) return hostPackageStateRefused(deps, err, 'disabled'); console.warn('[handlePackages] failed to persist disable state', { id, error: (err as Error)?.message }); } + const pkg = registry.disablePackage(id); + if (!pkg) return { handled: true, response: deps.error(`Package '${id}' not found`, 404) }; return { handled: true, response: deps.success(pkg) }; } @@ -3492,12 +3601,12 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // whenever its stored row is gone, even if the running registry // refused the withdrawal above. // - // Same best-effort try/catch as the install and PATCH arms above: - // the uninstall itself already happened, so a state-file failure - // must not turn it into a 500. + // Same best-effort try/catch as the install arm above: the + // uninstall itself already happened, so a state-store failure must + // not turn it into a 500. if (persists ? existed : registryRemoved) { try { - setPackageDisabled(_context?.environmentId, id, false); + await writePackageStateAfterChange(deps, _context, id, false); } catch (err) { console.warn('[handlePackages] failed to clear persisted disable state on delete', { id, error: (err as Error)?.message }); } diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index cfb0373b9aa..5a14a4cf8c2 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -11,6 +11,17 @@ export type { RuntimeConfig } from './runtime.js'; export { createStandaloneStack, resolveObjectStackHome, resolveStandaloneDatabase, sqliteFilenameFromUrl } from './standalone-stack.js'; export type { StandaloneStackConfig, StandaloneStackResult, ResolvedStandaloneDatabase } from './standalone-stack.js'; +// [#30962] The host seam for package lifecycle state (module enable/disable): +// a host whose `OS_HOME` does not outlive its container supplies the store, +// and every kernel without one keeps the file under `OS_HOME`. +export { + PACKAGE_STATE_STORE_SERVICE, + PACKAGE_STATE_STORE_PLUGIN_NAME, + PackageStateStorePlugin, + createPackageStateStorePlugin, +} from './package-state-host-store.js'; +export type { PackageStateStore } from './package-state-host-store.js'; + // The dev schema self-heal decision (#21733) — public because `@objectstack/cli` // is a reader, not a second home: its config-load fallback and its `telemetry` // sibling read the SAME answer the standalone stack's `default` datasource does. diff --git a/packages/runtime/src/package-state-host-store.test.ts b/packages/runtime/src/package-state-host-store.test.ts new file mode 100644 index 00000000000..bc791387526 --- /dev/null +++ b/packages/runtime/src/package-state-host-store.test.ts @@ -0,0 +1,278 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30962] The host seam for package lifecycle state — the platform half (F2) + * of cloud#3636, under the maintainer's ruling 「AAAA + 三条验收(推荐)」. + * + * A hosted container loses the file under `OS_HOME` when it is replaced, so a + * host supplies the store (`createPackageStateStorePlugin`). The card's three + * acceptance lines, each pinned through the shipped pieces — a real + * `ObjectKernel`, the real `ObjectQLPlugin` and `AppPlugin`, and the real + * `HttpDispatcher` answering the package doors: + * + * 1. A host-injected store sees every enable/disable write and seeds boot. + * 2. A refusing store makes the door fail visibly, with in-memory state + * unchanged. + * 3. Without injection, behaviour is unchanged: the file is written as before, + * and its write failure still only warns. + * + * The negative pins (2, and the file half of 3) were each ablated once — the + * forbidden behaviour put back and watched go red — and the outputs are cited + * in the PR, not kept as permanent cases. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import { resolveThrownHttpError } from '@objectstack/types'; +// Paid during collection, not inside a case's clock: `AppPlugin` loads it lazily. +import '@objectstack/metadata'; + +import { AppPlugin } from './app-plugin.js'; +import { HttpDispatcher } from './http-dispatcher.js'; +import { + PACKAGE_STATE_STORE_PLUGIN_NAME, + PACKAGE_STATE_STORE_SERVICE, + createPackageStateStorePlugin, + type PackageStateStore, +} from './package-state-host-store.js'; +import { packageStateFileName, setPackageDisabled } from './package-state-store.js'; + +const ENVIRONMENT_ID = 'env_host_store'; +const PKG = 'com.acme.reporting'; +const OTHER = 'com.acme.billing'; +const ADMIN = { + userId: 'u_admin', tenantId: 'org_qa', isSystem: false, + positions: [], permissions: [], systemPermissions: ['manage_metadata'], +}; + +interface Row { enabled?: boolean; status?: string } +interface Registry { + installPackage(manifest: Record, source: 'code' | 'marketplace' | 'authored'): unknown; + getPackage(id: string): Row | undefined; +} + +/** An in-memory host store that records every write, and can be told to refuse. */ +class RecordingStore implements PackageStateStore { + readonly disabled = new Map>(); + readonly writes: Array<[string | undefined, string, boolean]> = []; + readonly loads: Array = []; + refuseWrites = false; + refuseLoads = false; + + async loadDisabledPackageIds(environmentId: string | undefined): Promise> { + this.loads.push(environmentId); + if (this.refuseLoads) throw new Error('hosted store offline'); + return [...(this.disabled.get(environmentId ?? '') ?? [])]; + } + + async setPackageDisabled(environmentId: string | undefined, packageId: string, disabled: boolean): Promise { + if (this.refuseWrites) throw new Error('hosted store offline'); + this.writes.push([environmentId, packageId, disabled]); + const ids = this.disabled.get(environmentId ?? '') ?? new Set(); + if (disabled) ids.add(packageId); + else ids.delete(packageId); + this.disabled.set(environmentId ?? '', ids); + } +} + +let home: string; +const envSnapshot = { OS_HOME: process.env.OS_HOME, OS_ENVIRONMENT_ID: process.env.OS_ENVIRONMENT_ID }; +const kernels: ObjectKernel[] = []; + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'os-host-package-state-')); + process.env.OS_HOME = home; + delete process.env.OS_ENVIRONMENT_ID; +}); + +afterEach(async () => { + for (const kernel of kernels.splice(0)) { + if (kernel.getState() === 'running') await kernel.shutdown(); + } + vi.restoreAllMocks(); + rmSync(home, { recursive: true, force: true }); + if (envSnapshot.OS_HOME === undefined) delete process.env.OS_HOME; + else process.env.OS_HOME = envSnapshot.OS_HOME; + if (envSnapshot.OS_ENVIRONMENT_ID === undefined) delete process.env.OS_ENVIRONMENT_ID; + else process.env.OS_ENVIRONMENT_ID = envSnapshot.OS_ENVIRONMENT_ID; +}); + +const stateFile = (): string => join(home, 'package-state', packageStateFileName(ENVIRONMENT_ID, process.cwd())); +const manifestFor = (id: string) => ({ id, name: id, version: '1.0.0', type: 'module' }); + +/** + * Boot an empty environment, with `extra` plugins composed AFTER `AppPlugin` + * in `use()` order — so a store that still seeds proves the declared ordering, + * not the slot it was given. + */ +async function boot(extra: Plugin[] = []): Promise<{ kernel: ObjectKernel; registry: Registry }> { + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + kernels.push(kernel); + await kernel.use(new ObjectQLPlugin({})); + await kernel.use(new AppPlugin({}, { environmentId: ENVIRONMENT_ID, organizationId: 'org_qa' })); + for (const plugin of extra) await kernel.use(plugin); + await kernel.bootstrap(); + return { kernel, registry: kernel.getService<{ registry: Registry }>('objectql').registry }; +} + +const codeOf = (body: any): unknown => (body?.error && typeof body.error === 'object' ? body.error.code : body?.code); + +/** What the runtime dispatcher answers the admin on `PATCH /packages/:id/`. */ +async function patch(kernel: ObjectKernel, id: string, verb: 'enable' | 'disable') { + const path = `/packages/${encodeURIComponent(id)}/${verb}`; + const dispatcher = new HttpDispatcher(kernel as any, undefined, { enforceProjectMembership: false }); + (dispatcher as any).timedResolveExecutionContext = async () => ({ ...ADMIN }); + const request = new Request(`http://localhost/api/v1${path}`, { + method: 'PATCH', headers: { 'content-type': 'application/json' }, body: '{}', + }); + try { + const out: any = await dispatcher.dispatch('PATCH', path, {}, {}, { request, environmentId: ENVIRONMENT_ID } as any, '/api/v1'); + return { status: out.response?.status as number | undefined, code: codeOf(out.response?.body), message: out.response?.body?.error?.message as string | undefined }; + } catch (e) { + const thrown = resolveThrownHttpError(e); + return { status: thrown.status as number | undefined, code: thrown.code as unknown, message: undefined }; + } +} + +describe('[#30962] a host-injected package-state store', () => { + it('seeds the boot from the host store — not from the file — even when composed after AppPlugin', async () => { + const store = new RecordingStore(); + store.disabled.set(ENVIRONMENT_ID, new Set([PKG])); + // The file says the OPPOSITE: a host store must be the only source. + setPackageDisabled(ENVIRONMENT_ID, OTHER, true); + + const { registry } = await boot([createPackageStateStorePlugin(store)]); + registry.installPackage(manifestFor(PKG), 'authored'); + registry.installPackage(manifestFor(OTHER), 'authored'); + + expect(store.loads).toEqual([ENVIRONMENT_ID]); + expect(registry.getPackage(PKG)).toMatchObject({ enabled: false, status: 'disabled' }); + expect(registry.getPackage(OTHER)).toMatchObject({ enabled: true }); + }); + + it('sees every enable and disable write, and the file under OS_HOME is never written', async () => { + const store = new RecordingStore(); + const { kernel, registry } = await boot([createPackageStateStorePlugin(store)]); + registry.installPackage(manifestFor(PKG), 'authored'); + + expect(await patch(kernel, PKG, 'disable')).toMatchObject({ status: 200 }); + expect(registry.getPackage(PKG)?.enabled).toBe(false); + expect(await patch(kernel, PKG, 'enable')).toMatchObject({ status: 200 }); + expect(registry.getPackage(PKG)?.enabled).toBe(true); + + expect(store.writes).toEqual([ + [ENVIRONMENT_ID, PKG, true], + [ENVIRONMENT_ID, PKG, false], + ]); + expect(existsSync(join(home, 'package-state'))).toBe(false); + }); + + it('a disable written to the host store is what the NEXT boot seeds', async () => { + const store = new RecordingStore(); + const first = await boot([createPackageStateStorePlugin(store)]); + first.registry.installPackage(manifestFor(PKG), 'authored'); + expect(await patch(first.kernel, PKG, 'disable')).toMatchObject({ status: 200 }); + await first.kernel.shutdown(); + + // A replaced container: a fresh OS_HOME, the same host store. + process.env.OS_HOME = mkdtempSync(join(home, 'replaced-')); + const second = await boot([createPackageStateStorePlugin(store)]); + second.registry.installPackage(manifestFor(PKG), 'authored'); + expect(second.registry.getPackage(PKG)).toMatchObject({ enabled: false, status: 'disabled' }); + }); + + it('a refusing store fails the disable door visibly, and the package stays enabled', async () => { + const store = new RecordingStore(); + const { kernel, registry } = await boot([createPackageStateStorePlugin(store)]); + registry.installPackage(manifestFor(PKG), 'authored'); + store.refuseWrites = true; + + const answer = await patch(kernel, PKG, 'disable'); + + expect(answer).toMatchObject({ status: 503, code: 'SERVICE_UNAVAILABLE' }); + expect(answer.message).toMatch(/^Package 'com\.acme\.reporting' was not disabled: the package-state store refused/); + expect(registry.getPackage(PKG)).toMatchObject({ enabled: true, status: 'installed' }); + expect(store.disabled.get(ENVIRONMENT_ID)).toBeUndefined(); + expect(existsSync(join(home, 'package-state'))).toBe(false); + }); + + it('a refusing store fails the enable door visibly, and the package stays disabled', async () => { + const store = new RecordingStore(); + const { kernel, registry } = await boot([createPackageStateStorePlugin(store)]); + registry.installPackage(manifestFor(PKG), 'authored'); + expect(await patch(kernel, PKG, 'disable')).toMatchObject({ status: 200 }); + store.refuseWrites = true; + + const answer = await patch(kernel, PKG, 'enable'); + + expect(answer).toMatchObject({ status: 503, code: 'SERVICE_UNAVAILABLE' }); + expect(registry.getPackage(PKG)).toMatchObject({ enabled: false, status: 'disabled' }); + expect([...(store.disabled.get(ENVIRONMENT_ID) ?? [])]).toEqual([PKG]); + }); + + it('an unknown package still answers 404 and writes nothing to the store', async () => { + const store = new RecordingStore(); + const { kernel } = await boot([createPackageStateStorePlugin(store)]); + + expect(await patch(kernel, 'com.acme.absent', 'disable')).toMatchObject({ status: 404 }); + expect(await patch(kernel, 'com.acme.absent', 'enable')).toMatchObject({ status: 404 }); + expect(store.writes).toEqual([]); + }); + + it('a host store whose boot read throws fails the boot, naming the store', async () => { + const store = new RecordingStore(); + store.refuseLoads = true; + + await expect(boot([createPackageStateStorePlugin(store)])).rejects.toThrow( + new RegExp(`package-state store \\(${PACKAGE_STATE_STORE_PLUGIN_NAME.replace(/\./g, '\\.')}\\) could not read`), + ); + }); + + it('a store registered under the service name by a plugin AppPlugin is not ordered after is refused at start', async () => { + const store = new RecordingStore(); + const late: Plugin = { + name: 'qa.host-registers-its-own-store', + init: (ctx: PluginContext) => { ctx.registerService(PACKAGE_STATE_STORE_SERVICE, store); }, + }; + + await expect(boot([late])).rejects.toThrow(/createPackageStateStorePlugin/); + }); + + it('createPackageStateStorePlugin refuses a store missing either method', () => { + expect(() => createPackageStateStorePlugin({ loadDisabledPackageIds: () => [] } as never)).toThrow(TypeError); + expect(() => createPackageStateStorePlugin(null as never)).toThrow(TypeError); + }); +}); + +describe('[#30962] without a host store, behaviour is unchanged', () => { + it('the disable and enable doors write the file under OS_HOME exactly as before', async () => { + const { kernel, registry } = await boot(); + registry.installPackage(manifestFor(PKG), 'authored'); + + expect(await patch(kernel, PKG, 'disable')).toMatchObject({ status: 200 }); + expect(readFileSync(stateFile(), 'utf8')).toBe(`{\n "disabled": [\n "${PKG}"\n ]\n}\n`); + expect(registry.getPackage(PKG)?.enabled).toBe(false); + + expect(await patch(kernel, PKG, 'enable')).toMatchObject({ status: 200 }); + expect(readFileSync(stateFile(), 'utf8')).toBe(`{\n "disabled": []\n}\n`); + expect(registry.getPackage(PKG)?.enabled).toBe(true); + }); + + it('a file that cannot be written still only warns: the disable lands in memory and answers 200', async () => { + const { kernel, registry } = await boot(); + registry.installPackage(manifestFor(PKG), 'authored'); + // An OS_HOME that is a FILE: the state directory under it cannot be made. + const blocked = join(home, 'not-a-directory'); + writeFileSync(blocked, ''); + process.env.OS_HOME = blocked; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + expect(await patch(kernel, PKG, 'disable')).toMatchObject({ status: 200 }); + expect(registry.getPackage(PKG)).toMatchObject({ enabled: false, status: 'disabled' }); + expect(warn).toHaveBeenCalledWith('[handlePackages] failed to persist disable state', expect.objectContaining({ id: PKG })); + }); +}); diff --git a/packages/runtime/src/package-state-host-store.ts b/packages/runtime/src/package-state-host-store.ts new file mode 100644 index 00000000000..6c7163ef87f --- /dev/null +++ b/packages/runtime/src/package-state-host-store.ts @@ -0,0 +1,139 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The host seam for package lifecycle state: where a module's enable/disable + * choice lives when the HOST says so, instead of the file under `OS_HOME`. + * + * ## Why a seam + * + * The default store (`./package-state-store.ts`) is a JSON file under the + * ObjectStack home directory. A hosted container loses that file when it is + * replaced, and a second container never sees it at all, so a module an admin + * switched off came back on. The host owns durable storage there, so the host + * supplies the store, and the platform asks it at the three places that read + * or write the state: the boot seed (`AppPlugin.seedPersistedDisabledPackages`) + * and the package doors in `./domains/packages.ts` (install, enable, disable, + * delete). + * + * ## How a host supplies it + * + * Through the kernel's service registry, the seam every host already composes: + * add `createPackageStateStorePlugin(store)` to the kernel's plugins. That + * plugin registers the store as the {@link PACKAGE_STATE_STORE_SERVICE} + * service, and `AppPlugin` names {@link PACKAGE_STATE_STORE_PLUGIN_NAME} in its + * `optionalDependencies` (ADR-0116), so the store is registered before the boot + * seed reads it, whatever slot the host composes it in. A kernel without it + * keeps the file, byte for byte. + * + * ⛔ Register the store through this plugin, not under the service name from a + * plugin of your own: nothing orders an arbitrary plugin ahead of `AppPlugin`, + * so its boot seed would read the file instead. `AppPlugin.start()` refuses + * that composition by name rather than boot with two stores in play. + * + * ## What a refusal means + * + * A host store that throws refuses the write. The enable and disable doors + * then change nothing in memory and answer `503 SERVICE_UNAVAILABLE`, so a + * module is never shown as switched off while the store still holds it on + * (cloud#3636 ruling, acceptance line 1). A store whose boot read throws fails + * the boot. The file default keeps its own behaviour on both counts. + */ + +import type { Plugin, PluginContext } from '@objectstack/core'; + +import { loadDisabledPackageIds, setPackageDisabled } from './package-state-store.js'; + +/** + * The kernel service a host's package-state store is registered under. + * + * ⛔ Spelled as a literal at the init-time read in `app-plugin.ts` too, because + * `check:init-service-contract` judges literal service names only. + */ +export const PACKAGE_STATE_STORE_SERVICE = 'package-state-store'; + +/** + * The name of the plugin {@link createPackageStateStorePlugin} builds. It is + * listed in `AppPlugin.optionalDependencies`, which is what orders the store + * ahead of the boot seed. + */ +export const PACKAGE_STATE_STORE_PLUGIN_NAME = 'com.objectstack.package-state-store'; + +/** + * Where package lifecycle state lives: the ids an operator has disabled, per + * environment. The default is the file under `OS_HOME`; a host supplies its + * own through {@link createPackageStateStorePlugin}. + * + * `environmentId` is the environment the state belongs to, `undefined` when + * the boot or the request carries none (the file store then reads + * `OS_ENVIRONMENT_ID`, else `default`). + */ +export interface PackageStateStore { + /** + * The package ids persisted as disabled for this environment. Read once + * per boot, before any package is registered. Throwing fails the boot. + */ + loadDisabledPackageIds(environmentId: string | undefined): Iterable | Promise>; + /** + * Persist one package's disabled flag. Throwing refuses the write: the + * enable and disable doors then leave the running registry unchanged and + * answer the failure. + */ + setPackageDisabled(environmentId: string | undefined, packageId: string, disabled: boolean): void | Promise; +} + +function assertPackageStateStore(store: unknown): asserts store is PackageStateStore { + const candidate = store as Partial | null | undefined; + if ( + !candidate + || typeof candidate.loadDisabledPackageIds !== 'function' + || typeof candidate.setPackageDisabled !== 'function' + ) { + throw new TypeError( + `${PACKAGE_STATE_STORE_PLUGIN_NAME}: the package-state store must implement both ` + + '`loadDisabledPackageIds(environmentId)` and `setPackageDisabled(environmentId, packageId, disabled)`.', + ); + } +} + +/** Registers a host's {@link PackageStateStore}; see the module note. */ +export class PackageStateStorePlugin implements Plugin { + readonly name = PACKAGE_STATE_STORE_PLUGIN_NAME; + readonly type = 'standard' as const; + /** Literal for the same reason as {@link PACKAGE_STATE_STORE_SERVICE}. */ + readonly providesServices = ['package-state-store']; + + constructor(private readonly store: PackageStateStore) { + assertPackageStateStore(store); + } + + init = (ctx: PluginContext): void => { + ctx.registerService(PACKAGE_STATE_STORE_SERVICE, this.store); + }; +} + +/** + * The plugin a host adds to its kernel so package lifecycle state lives in + * `store` instead of the file under `OS_HOME`. Throws a `TypeError` when + * `store` does not implement {@link PackageStateStore}. + */ +export function createPackageStateStorePlugin(store: PackageStateStore): PackageStateStorePlugin { + return new PackageStateStorePlugin(store); +} + +/** The file under `OS_HOME`, as a {@link PackageStateStore}: what every kernel without a host store uses. */ +export const FILE_PACKAGE_STATE_STORE: PackageStateStore = { + loadDisabledPackageIds: (environmentId) => loadDisabledPackageIds(environmentId), + setPackageDisabled, +}; + +/** + * What a lookup of {@link PACKAGE_STATE_STORE_SERVICE} answered, as a store: + * `undefined` when nothing is registered. A registration that is not a + * {@link PackageStateStore} throws, so a store the host meant to supply is + * never silently replaced by the file. + */ +export function asPackageStateStore(registered: unknown): PackageStateStore | undefined { + if (registered == null) return undefined; + assertPackageStateStore(registered); + return registered; +}