Skip to content

Commit d9f8f7f

Browse files
feat(runtime): a host can supply the package-state store (createPackageStateStorePlugin); a refused write fails the enable/disable door with nothing changed (#31008)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoNFPj8KV1KqL2puViUBBKLC)_ Fixes #30962 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:模块启用/停用状态的存放处可由宿主提供。宿主在内核里加 `createPackageStateStorePlugin(store)`,启动种子和启用/停用/安装/删除各门都改读写这个存储;不加则仍是 `OS_HOME` 下的那个文件,字节不变。 - 为什么改:托管云上容器一换,`OS_HOME` 里的文件就没了,管理员在设置里关掉的模块会重新出现(cloud#3636,裁决「AAAA + 三条验收(推荐)」)。平台补上缺的宿主接缝,cloud 只需实现托管存储。 - 风险与回滚:启用/停用门改成先写存储、再改内存;宿主存储拒写时门答 503 且什么都不变。未注入时行为与文件内容不变。回滚即 revert 本 PR。 - 先看:`packages/runtime/src/domains/packages.ts` 的启用、停用两门(先写存储的顺序与 503 分支);`packages/runtime/src/app-plugin.ts` 的 `seedPersistedDisabledPackages` 与 `refuseLatePackageStateStore`;新文件 `packages/runtime/src/package-state-host-store.ts`。 - 两分钟验证:`pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/package-state-host-store.test.ts`,预期 11 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes **The seam (`packages/runtime/src/package-state-host-store.ts`, exported from `@objectstack/runtime`).** `PackageStateStore` is `loadDisabledPackageIds(environmentId)` plus `setPackageDisabled(environmentId, packageId, disabled)`, either may return a promise. A host adds `createPackageStateStorePlugin(store)` to its kernel; that plugin (`com.objectstack.package-state-store`) registers the store as the `package-state-store` kernel service. This is the door a host already uses to hand the runtime anything: `createStandaloneStack` returns a `plugins` list the host composes into its kernel, so no new top-level option was added. `AppPlugin` lists the plugin name in `optionalDependencies` (ADR-0116), so the store is registered before the boot seed reads it whatever slot the host puts it in; `check:init-service-contract` now holds that init-time read to the declaration. **Boot seed (`AppPlugin.seedPersistedDisabledPackages`).** Reads the host store when one is registered, else the file exactly as before (best-effort, warns). A host store whose read throws fails the boot with a named error instead of seeding nothing, which would bring every disabled module back enabled with nothing looking wrong (the ADR-0025 §3.5 M1 call the installed-plugin loader already makes). `AppPlugin.start()` refuses a boot that seeded from the file while a store is registered by then (a host registering under the service name from its own, unordered plugin), naming `createPackageStateStorePlugin` as the fix. **Doors (`domains/packages.ts`).** Every write goes through one `writePackageState`, which looks the store up through the classified lookup (`resolveServiceOrLoud`), so a store factory that throws refuses the write rather than reading as "no host store". - `PATCH /packages/:id/enable` and `/disable` now write the store FIRST and switch the registry second. A host store that refuses: nothing changes in memory and the door answers `503 SERVICE_UNAVAILABLE` (the standard code, no new code), message opening `Package 'ID' was not disabled: the package-state store refused to record it, so nothing changed.` The file default keeps its best-effort `warn` and still switches. - Install and delete, whose change has already landed when they write, keep their best-effort `try`; a host store's refusal there is logged at `error` naming what the next boot will read, the file's failure keeps the existing `warn`. - The disable door's dependent-rule scan (#30825, merged) runs first and is untouched; the store write follows it. ## Tests New `packages/runtime/src/package-state-host-store.test.ts`, a real `ObjectKernel` + `ObjectQLPlugin` + `AppPlugin` and the real `HttpDispatcher` on the package doors, the store plugin composed AFTER `AppPlugin` in `use()` order: - the boot seeds from the host store and not from the file (the file says the opposite); every enable and disable write reaches the store and the `OS_HOME` state directory is never created; a disable written in one boot is what the next boot (fresh `OS_HOME`) seeds; - a refusing store: disable answers 503 `SERVICE_UNAVAILABLE` and the package stays enabled; enable answers 503 and it stays disabled; an unknown id still answers 404 and writes nothing; - a store whose boot read throws fails the boot; an unordered registration is refused at start; a store missing a method is a `TypeError`; - without a host store: the file bytes after disable and enable are pinned exactly, and a file that cannot be written still only warns while the disable lands (200). Head `8e94a195` (after merging `origin/main` at `c77dd17f`): - `pnpm --filter @objectstack/runtime typecheck`: exit 0 (`check:test-typecheck: OK`). - `pnpm --filter @objectstack/runtime test`: Test Files 551 passed (551), Tests 8827 passed, 19 skipped. - eslint `packages/runtime --no-inline-config`: exit 0. <details><summary>Ablations (each once, through scripts/ablation-replace.mjs, each restored with blob == HEAD and git diff HEAD empty; taken on 83af516, before the merge, which did not touch these lines)</summary> A1, the refusal answered, removed (a refusing host store warns and disables anyway): ``` ablation-replace: ok mutation landed: anchor 1 -> 0, blob fd9ac19 -> d9c4bc8efdb9 × a refusing store fails the disable door visibly, and the package stays enabled AssertionError: expected { status: 200, code: undefined, …(1) } to match object { status: 503, …(1) } Tests 1 failed | 10 passed (11) ``` (The first A1 attempt used an empty `--replacement`; the tool refused it as a non-landing mutation before running anything, and the leg was re-run with `--delete`.) A2, the old order back (registry switched before the store write, refusal kept): ``` ablation-replace: ok mutation landed: anchor 1 -> 0, blob fd9ac19 -> d54cbb48382c × a refusing store fails the disable door visibly, and the package stays enabled × an unknown package still answers 404 and writes nothing to the store AssertionError: expected { manifest: { …(4) }, …(7) } to match object { enabled: true, status: 'installed' } Tests 2 failed | 9 passed (11) ``` A3, no host store and the file not written: ``` ablation-replace: ok mutation landed: anchor 1 -> 0, blob fd9ac19 -> a97613d63a2c × the disable and enable doors write the file under OS_HOME exactly as before × a file that cannot be written still only warns: the disable lands in memory and answers 200 Tests 2 failed | 9 passed (11) ``` (Its first attempt was refused by the tool, the replacement text already occurring elsewhere; re-run with `--delete`.) A4, no host store and a file failure made loud: ``` ablation-replace: ok mutation landed: anchor 1 -> 0, blob fd9ac19 -> b4cf3ef91361 × a file that cannot be written still only warns: the disable lands in memory and answers 200 AssertionError: expected { status: 503, …(2) } to match object { status: 200 } Tests 1 failed | 10 passed (11) ``` A5, the start-time refusal of an unordered store removed: ``` ablation-replace: ok mutation landed: anchor 1 -> 0, blob a7b1c99 -> 3b2717f6436c × a store registered under the service name by a plugin AppPlugin is not ordered after is refused at start AssertionError: promise resolved "{ kernel: ObjectKernel{ …(14) }, …(1) }" instead of rejecting ``` A6, the store plugin dropped from `AppPlugin.optionalDependencies`: ``` ablation-replace: ok mutation landed: anchor 1 -> 0, blob a7b1c99 -> 5fa7328f99d5 × seeds the boot from the host store — not from the file — even when composed after AppPlugin … 7 failed | 4 passed (11) ``` </details> ## Acceptance notes - **Card acceptance, line by line.** ① A host-injected store sees every enable/disable write (install, enable, disable, delete) and seeds boot: pinned. ② A refusing store makes the enable and disable doors fail visibly (503) with in-memory state unchanged: pinned and ablated (A1, A2). ③ Without injection behaviour is unchanged: file bytes and the warn-only failure pinned and ablated (A3, A4). - **Cloud C3 (the hosted store, cloud#3636) consumes this**: it implements `PackageStateStore` and adds `createPackageStateStorePlugin(store)` to the hosted kernel. - **Choices the card did not rule, made here; the seat may overrule.** (a) A host store's boot read that throws fails the boot, not a warn. (b) Install and delete stay best-effort on a host refusal (their change has landed), logged at `error`; only enable and disable, which can write first, answer the refusal. (c) The 503 message embeds the store's own error text; the dispatcher's 5xx leak guard still replaces it if it looks like an internal leak. - **Not routed through the seam:** `plugin-installation-store.ts` (the local `os plugin disable | enable` commands and the boot-time installed-plugin loader, ADR-0025 §3.5 M1) still reads and writes the file directly. That loop is local-first CLI state; a hosted container that wants it host-backed is a separate card. - **Overlap:** #30825 merged and is integrated (its dependent scan runs before the store write, unchanged). #30756 (seat thread, branch `fyy60n`) is adding a refusal pre-check to the enable door; whichever PR opens second merges `origin/main` after the first lands. - No `packages/spec` edit, no new error code. Size: 6 files, +681 / −30. ## Verification Derived gates (`dispatch-gates.mjs --commands`, 63 families) plus `check:init-service-contract`, `check:startup-registry-verdict`, `check:error-code-casing`, `check:route-ledger-census`, `check:authz-resolver`, `check:changeset-fixed`, `check:filter-alias-parity` and `check-platform-checklist.mjs --changed-from origin/main`: all 71 exit 0 on `8e94a195`; `dispatch-gates --ran`: 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN. Record: `server-seat-2/r2026-11-06/out/30962-gates-ran-8e94a195.list` in the project files. <details><summary>Notes on the gate run</summary> - On the pre-merge head, `check:test-source-alias` was red on the new test (a clocked lazy `import('@objectstack/metadata')` reached through `AppPlugin`); fixed with a module-top `import '@objectstack/metadata'` in the test, the gate's own prescription. - `check-plugin-teardown-shape --self-test` and `check:dual-build-cjs-loads` first answered exit 3 (prerequisite: pinned commit 621a487 absent from the shallow clone, and unbuilt dists); after `git fetch` of that commit and a full `turbo run build --filter='./packages/**'`, both exit 0. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibvSvYrx9SiZMbSoehj)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6106b73 commit d9f8f7f

6 files changed

Lines changed: 681 additions & 30 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
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
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
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:
11+
12+
- **`PackageStateStore`**: `loadDisabledPackageIds(environmentId)` and `setPackageDisabled(environmentId, packageId, disabled)`, either may return a promise.
13+
- **`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`.
14+
15+
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:
16+
17+
- 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.
18+
- on a write from install or delete, whose change has already landed: logged at `error`, naming what the next boot will read.
19+
- on the boot read: the boot fails, naming the store, rather than bring every disabled package back enabled.
20+
21+
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.
22+
23+
Without a host store nothing changes: the same file, the same bytes, and a failed file write still only warns.

‎packages/runtime/src/app-plugin.ts‎

Lines changed: 106 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { mergeSeedDatasets, readSeedDatasets, registerSeedReplayerOnce } from '.
3030
import { describeSeedLocale, resolveSeedLocaleForLoad } from './seed-locale.js';
3131
import { declareSeedSource, recordOrganizationReplay, type SeedLandingReport } from './seed-settlement.js';
3232
import { loadDisabledPackageIds } from './package-state-store.js';
33+
import { asPackageStateStore, PACKAGE_STATE_STORE_PLUGIN_NAME } from './package-state-host-store.js';
3334
import type { IFieldFileService, IMetadataService, IObjectQLEngine, II18nService } from '@objectstack/spec/contracts';
3435
import { FIELD_FILE_SERVICE } from '@objectstack/spec/contracts';
3536
import { normalizeFlowFunctionEntry, type NormalizedFlowFunction } from '@objectstack/spec/automation';
@@ -171,12 +172,19 @@ export class AppPlugin implements Plugin {
171172
* `start()`, and a database it refuses must be left as it found it — so this
172173
* plugin's `start()`, which loads the inline seed, runs only after the guard
173174
* has judged. Order-if-present: every other kernel is unchanged.
175+
*
176+
* The host's package-state store (`PACKAGE_STATE_STORE_PLUGIN_NAME`,
177+
* `./package-state-host-store.ts`, spelled as a literal for the same
178+
* reason): `init()` seeds the registry's disabled set from it, so it must
179+
* be registered first. Order-if-present: a kernel without it seeds from
180+
* the file under `OS_HOME`, as before.
174181
*/
175182
optionalDependencies = [
176183
'com.objectstack.engine.objectql',
177184
'com.objectstack.organization-ownership-boot-guard',
178185
'com.objectstack.auth',
179186
'com.objectstack.auth.identity-objects',
187+
'com.objectstack.package-state-store',
180188
];
181189
/**
182190
* The one init-time service this plugin CANNOT degrade without: a
@@ -233,6 +241,14 @@ export class AppPlugin implements Plugin {
233241
* disagree about which datasources this artifact registers from code — and
234242
* the residual-owner warning prints once, at whichever read comes first.
235243
*/
244+
/**
245+
* Which store the boot seed read: `'host'` (the registered
246+
* `package-state-store` service) or `'file'` (the file under `OS_HOME`),
247+
* `undefined` when there was no engine to seed. `start()` holds a `'file'`
248+
* seed against a host store that registered after it — see
249+
* {@link refuseLatePackageStateStore}.
250+
*/
251+
private packageStateSeedSource?: 'host' | 'file';
236252
private codeDatasourceOwners?: Array<{ datasource: any; owner: { packageId?: string; packageVersion?: string } }>;
237253
/**
238254
* [#23649] Set once {@link installDatasourceMapping} has handed the stack's
@@ -561,7 +577,7 @@ export class AppPlugin implements Plugin {
561577
// envs, and a package an operator had disabled came back ENABLED on
562578
// every restart. The seed must land before ANY registration path runs,
563579
// which is Phase 1, unconditionally.
564-
this.seedPersistedDisabledPackages(ctx);
580+
await this.seedPersistedDisabledPackages(ctx);
565581
if (this.empty) {
566582
ctx.logger.debug('[AppPlugin] empty env — no app payload, skipping init', {
567583
pluginName: this.name,
@@ -686,22 +702,63 @@ export class AppPlugin implements Plugin {
686702
* because that decomposition happens at the `manifest.register()` call at
687703
* the end of init.
688704
*
689-
* Best-effort — never block boot on this. Degrades silently on kernels
690-
* with no engine (metadata-only one-shot commands, mock-engine tests).
705+
* The store is the host's when one is registered as the
706+
* `package-state-store` service (`createPackageStateStorePlugin`,
707+
* `./package-state-host-store.ts`, ordered ahead of this plugin by
708+
* `optionalDependencies`), and the file under `OS_HOME` otherwise.
709+
*
710+
* The file read is best-effort, exactly as before: it never blocks boot.
711+
* A HOST store's read that throws fails the boot instead, because seeding
712+
* nothing would bring every module its operator switched off back on, and
713+
* nothing would look wrong (ADR-0025 §3.5 M1 makes the same call for the
714+
* installed-plugin loader: a disable that cannot be read is not read as
715+
* "nothing is disabled"). Degrades silently on kernels with no engine
716+
* (metadata-only one-shot commands, mock-engine tests).
691717
*/
692-
private seedPersistedDisabledPackages(ctx: PluginContext): void {
718+
private async seedPersistedDisabledPackages(ctx: PluginContext): Promise<void> {
719+
let setter: ((ids: Iterable<string>) => void) | undefined;
720+
let ql: { registry?: { setInitialDisabledPackageIds?: (ids: Iterable<string>) => void } } | undefined;
693721
try {
694-
const ql = ctx.getService<{ registry?: { setInitialDisabledPackageIds?: (ids: Iterable<string>) => void } }>('objectql');
695-
const setter = ql?.registry?.setInitialDisabledPackageIds;
696-
if (typeof setter === 'function') {
697-
const disabled = loadDisabledPackageIds(this.projectContext?.environmentId);
698-
if (disabled.size > 0) {
699-
setter.call(ql!.registry, disabled);
700-
ctx.logger.info('[AppPlugin] seeded persisted disabled packages', {
701-
environmentId: this.projectContext?.environmentId,
702-
disabled: Array.from(disabled),
703-
});
704-
}
722+
ql = ctx.getService<{ registry?: { setInitialDisabledPackageIds?: (ids: Iterable<string>) => void } }>('objectql');
723+
setter = ql?.registry?.setInitialDisabledPackageIds;
724+
} catch {
725+
// No engine on this kernel — nothing to seed.
726+
}
727+
if (typeof setter !== 'function') return;
728+
const hostStore = this.registeredPackageStateStore(ctx);
729+
this.packageStateSeedSource = hostStore ? 'host' : 'file';
730+
if (hostStore) {
731+
let disabled: Set<string>;
732+
try {
733+
disabled = new Set(
734+
Array.from(await hostStore.loadDisabledPackageIds(this.projectContext?.environmentId))
735+
.filter((id): id is string => typeof id === 'string'),
736+
);
737+
} catch (err) {
738+
throw new Error(
739+
`[AppPlugin] the host's package-state store (${PACKAGE_STATE_STORE_PLUGIN_NAME}) could not read `
740+
+ `which packages are disabled, so the boot stops rather than bring them back enabled: `
741+
+ `${(err as Error)?.message ?? String(err)}`,
742+
);
743+
}
744+
if (disabled.size > 0) {
745+
setter.call(ql!.registry, disabled);
746+
ctx.logger.info('[AppPlugin] seeded persisted disabled packages', {
747+
environmentId: this.projectContext?.environmentId,
748+
store: 'host',
749+
disabled: Array.from(disabled),
750+
});
751+
}
752+
return;
753+
}
754+
try {
755+
const disabled = loadDisabledPackageIds(this.projectContext?.environmentId);
756+
if (disabled.size > 0) {
757+
setter.call(ql!.registry, disabled);
758+
ctx.logger.info('[AppPlugin] seeded persisted disabled packages', {
759+
environmentId: this.projectContext?.environmentId,
760+
disabled: Array.from(disabled),
761+
});
705762
}
706763
} catch (err) {
707764
ctx.logger.warn('[AppPlugin] failed to seed persisted package state', {
@@ -710,6 +767,39 @@ export class AppPlugin implements Plugin {
710767
}
711768
}
712769

770+
/**
771+
* The host's package-state store, or `undefined` when this kernel has
772+
* none. The service name is a literal so `check:init-service-contract`
773+
* can hold this init-time read to the `optionalDependencies` entry above.
774+
*/
775+
private registeredPackageStateStore(ctx: PluginContext) {
776+
let registered: unknown;
777+
try {
778+
registered = ctx.getService('package-state-store');
779+
} catch {
780+
return undefined;
781+
}
782+
return asPackageStateStore(registered);
783+
}
784+
785+
/**
786+
* A boot that seeded from the FILE, on a kernel where a host store is
787+
* registered by now, read the wrong store: the host's plugin initialized
788+
* after this one, so the disables it holds were never seeded while the
789+
* package doors will write to it. Refused by name, with the fix, rather
790+
* than served with two stores in play.
791+
*/
792+
private refuseLatePackageStateStore(ctx: PluginContext): void {
793+
if (this.packageStateSeedSource !== 'file') return;
794+
if (this.registeredPackageStateStore(ctx) === undefined) return;
795+
throw new Error(
796+
`[AppPlugin] a package-state store was registered as the 'package-state-store' service after this `
797+
+ `plugin's boot seed had already read the file under OS_HOME, so the packages that store holds as `
798+
+ `disabled would come back enabled. Register it with createPackageStateStorePlugin(store) from `
799+
+ `@objectstack/runtime, which is ordered ahead of AppPlugin.`,
800+
);
801+
}
802+
713803
/**
714804
* Install the engine's DEFAULT hook body runner (`engine.setDefaultBodyRunner`).
715805
*
@@ -1240,6 +1330,7 @@ export class AppPlugin implements Plugin {
12401330
}
12411331

12421332
start = async (ctx: PluginContext) => {
1333+
this.refuseLatePackageStateStore(ctx);
12431334
if (this.empty) {
12441335
ctx.logger.debug('[AppPlugin] empty env — no app payload, skipping start', {
12451336
pluginName: this.name,

0 commit comments

Comments
 (0)