Skip to content

Commit 78adc2e

Browse files
fix(runtime): seed persisted disabled packages before the empty-env early return (#5047) (#5117)
`AppPlugin.init` seeds the registry's initial-disabled set from `<OS_HOME>/package-state/<environmentId>.json` so every registration path installs operator-disabled packages disabled. That seed ran AFTER the empty-env early return, and an empty env (no app payload in the artifact) is exactly the hydration-only scenario: its packages all arrive later from `sys_packages` replay or an HTTP install. Result: on DB-driven environments the initial-disabled set stayed empty and disabled packages came back enabled on every restart. Move the seed above the return, next to the hook/action body runners and the authored-translation sync that are already hoisted for the same reason, and extract it into `seedPersistedDisabledPackages()` with the rationale attached. Tests (the blind spot that hid this — `package-state-store` and `setInitialDisabledPackageIds` had zero coverage repo-wide): - `package-state-store.test.ts` — round trip, per-environment isolation, missing/corrupt file degradation, env-id sanitization. - `app-plugin.disabled-seed.test.ts` — real LiteKernel + ObjectQLPlugin + empty-env AppPlugin over a real state file, asserting post-boot registration and the REAL `PackageServicePlugin` `sys_packages` replay both land disabled; plus the non-empty env's existing behavior. Reverse-verified: with the seed moved back below the return, exactly the two empty-env cases fail (`installed` / `enabled: true`) and the non-empty case stays green. Claude-Session: https://claude.ai/code/session_018iARDqtrhQgz6fVHDeDkbQ Co-authored-by: Claude <noreply@anthropic.com>
1 parent ecc61ab commit 78adc2e

7 files changed

Lines changed: 439 additions & 7 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
fix(runtime): disabled packages no longer come back enabled after an empty-env restart (#5047)
6+
7+
An operator who disables a package has that decision persisted to
8+
`<OS_HOME>/package-state/<environmentId>.json`, and boot replays it by seeding
9+
the registry's initial-disabled set **before** any package is registered — so
10+
every registration path (boot-artifact decomposition, `sys_packages`
11+
rehydration, HTTP install) installs those packages disabled.
12+
13+
That seed ran inside `AppPlugin.init` **after** the empty-env early return. An
14+
empty environment is one whose artifact carries no app payload — which is
15+
exactly the environment where every package arrives later, from
16+
`PackageServicePlugin`'s Phase 2 replay of `sys_packages` or from an HTTP
17+
install. So on precisely those DB-driven environments the initial-disabled set
18+
stayed empty, and a package the administrator had disabled came back **enabled**
19+
on every restart, with no error anywhere: the disable had persisted correctly,
20+
it was simply never read.
21+
22+
The seed now runs before that return, alongside the default hook/action body
23+
runners and the authored-translation sync, which are before it for the same
24+
reason. Non-empty environments are unaffected — the seed still lands before the
25+
manifest is decomposed — and the seed remains best-effort, degrading silently on
26+
kernels with no engine.

packages/runtime/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"@objectstack/service-datasource": "workspace:*",
5151
"@objectstack/service-job": "workspace:*",
5252
"@objectstack/service-messaging": "workspace:*",
53+
"@objectstack/service-package": "workspace:*",
5354
"typescript": "^6.0.3",
5455
"vitest": "^4.1.10"
5556
},
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Persisted package disable-state must survive a restart of an EMPTY env (#5047).
5+
*
6+
* The seed that carries an operator's "disable this package" decision across a
7+
* restart works by filling the registry's initial-disabled set BEFORE the first
8+
* `installPackage` call, so that every registration path installs those
9+
* packages disabled. It used to run AFTER `AppPlugin.init`'s empty-env early
10+
* return — and an empty env (an artifact with no app payload) is precisely the
11+
* environment whose packages ALL arrive later, from `sys_packages` hydration or
12+
* an HTTP install. So on exactly those envs the set stayed empty and every
13+
* disabled package came back ENABLED on each restart.
14+
*
15+
* These tests boot a REAL kernel (LiteKernel + ObjectQLPlugin + AppPlugin) over
16+
* a REAL state file, and drive the REAL `PackageServicePlugin` rehydration, so
17+
* the regression is pinned end to end rather than against a re-implementation.
18+
*
19+
* Reverse verification for the fix: move `seedPersistedDisabledPackages(ctx)`
20+
* back below the `if (this.empty)` return in `app-plugin.ts` and every
21+
* empty-env case here fails (`installed` / `enabled: true`), while the
22+
* non-empty case stays green.
23+
*/
24+
25+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
26+
import { mkdtempSync, rmSync } from 'node:fs';
27+
import { tmpdir } from 'node:os';
28+
import { join } from 'node:path';
29+
30+
import { LiteKernel } from '@objectstack/core';
31+
import { ObjectQLPlugin } from '@objectstack/objectql';
32+
import { PackageServicePlugin } from '@objectstack/service-package';
33+
34+
import { AppPlugin } from './app-plugin.js';
35+
import { setPackageDisabled } from './package-state-store.js';
36+
37+
const ENVIRONMENT_ID = 'env_disabled_seed';
38+
const DISABLED_ID = 'com.acme.reporting';
39+
const ENABLED_ID = 'com.acme.billing';
40+
41+
interface InstalledPackageView {
42+
status?: string;
43+
enabled?: boolean;
44+
}
45+
interface TestRegistry {
46+
installPackage(manifest: Record<string, unknown>): unknown;
47+
getPackage(id: string): InstalledPackageView | undefined;
48+
}
49+
50+
let home: string;
51+
const envSnapshot = { OS_HOME: process.env.OS_HOME, OS_ENVIRONMENT_ID: process.env.OS_ENVIRONMENT_ID };
52+
53+
function manifestFor(id: string) {
54+
return { id, name: id, version: '1.0.0', type: 'application' };
55+
}
56+
57+
/**
58+
* Boot the composition an empty environment actually runs: the engine plus an
59+
* AppPlugin whose bundle carries no app payload.
60+
*/
61+
async function bootEmptyEnv(): Promise<{ kernel: LiteKernel; registry: TestRegistry }> {
62+
const kernel = new LiteKernel({ logger: { level: 'error' } });
63+
kernel.use(new ObjectQLPlugin({}));
64+
kernel.use(new AppPlugin({}, { environmentId: ENVIRONMENT_ID, organizationId: 'org_test' }));
65+
await kernel.bootstrap();
66+
const ql = kernel.getService<{ registry: TestRegistry }>('objectql');
67+
return { kernel, registry: ql.registry };
68+
}
69+
70+
/** A PluginContext for PackageServicePlugin whose engine shares `registry`. */
71+
function packageServiceCtx(registry: TestRegistry, rows: Array<Record<string, unknown>>) {
72+
const execute = vi.fn(async ({ sql }: { sql: string }) => {
73+
if (/SELECT \* FROM sys_packages/i.test(sql)) return { rows };
74+
return { rows: [] }; // CREATE TABLE / INDEX / …
75+
});
76+
const services = new Map<string, unknown>([['objectql', { execute, registry }]]);
77+
return {
78+
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
79+
getService: (n: string) => services.get(n),
80+
registerService: (n: string, s: unknown) => services.set(n, s),
81+
} as never;
82+
}
83+
84+
function sysPackagesRow(manifest: Record<string, unknown>) {
85+
return {
86+
id: manifest.id,
87+
version: manifest.version,
88+
manifest: JSON.stringify(manifest),
89+
metadata: '{}',
90+
hash: 'h',
91+
created_at: 't',
92+
updated_at: 't',
93+
};
94+
}
95+
96+
beforeEach(() => {
97+
home = mkdtempSync(join(tmpdir(), 'os-disabled-seed-'));
98+
process.env.OS_HOME = home;
99+
delete process.env.OS_ENVIRONMENT_ID;
100+
});
101+
102+
afterEach(() => {
103+
rmSync(home, { recursive: true, force: true });
104+
if (envSnapshot.OS_HOME === undefined) delete process.env.OS_HOME;
105+
else process.env.OS_HOME = envSnapshot.OS_HOME;
106+
if (envSnapshot.OS_ENVIRONMENT_ID === undefined) delete process.env.OS_ENVIRONMENT_ID;
107+
else process.env.OS_ENVIRONMENT_ID = envSnapshot.OS_ENVIRONMENT_ID;
108+
});
109+
110+
describe('empty-env boot seeds persisted package disable-state (#5047)', () => {
111+
it('a package registered after boot lands DISABLED — the hydration-only path', async () => {
112+
setPackageDisabled(ENVIRONMENT_ID, DISABLED_ID, true); // operator disabled it last run
113+
114+
const { kernel, registry } = await bootEmptyEnv();
115+
// Nothing came from the (empty) artifact; this is the post-boot
116+
// registration every package in such an env goes through.
117+
registry.installPackage(manifestFor(DISABLED_ID));
118+
119+
expect(registry.getPackage(DISABLED_ID)).toMatchObject({
120+
status: 'disabled',
121+
enabled: false,
122+
});
123+
124+
await kernel.shutdown();
125+
});
126+
127+
it('seeds only the persisted ids — other packages still install enabled', async () => {
128+
setPackageDisabled(ENVIRONMENT_ID, DISABLED_ID, true);
129+
130+
const { kernel, registry } = await bootEmptyEnv();
131+
registry.installPackage(manifestFor(ENABLED_ID));
132+
133+
expect(registry.getPackage(ENABLED_ID)).toMatchObject({
134+
status: 'installed',
135+
enabled: true,
136+
});
137+
138+
await kernel.shutdown();
139+
});
140+
141+
it('a package replayed from sys_packages by PackageServicePlugin lands DISABLED', async () => {
142+
setPackageDisabled(ENVIRONMENT_ID, DISABLED_ID, true);
143+
144+
// Phase 1: the empty-env kernel boots and seeds the registry.
145+
const { kernel, registry } = await bootEmptyEnv();
146+
147+
// Phase 2: the real rehydration replays the durable row into that same
148+
// registry (ADR-0033 consolidation).
149+
await new PackageServicePlugin().start(
150+
packageServiceCtx(registry, [
151+
sysPackagesRow(manifestFor(DISABLED_ID)),
152+
sysPackagesRow(manifestFor(ENABLED_ID)),
153+
]),
154+
);
155+
156+
expect(registry.getPackage(DISABLED_ID)).toMatchObject({
157+
status: 'disabled',
158+
enabled: false,
159+
});
160+
expect(registry.getPackage(ENABLED_ID)).toMatchObject({
161+
status: 'installed',
162+
enabled: true,
163+
});
164+
165+
await kernel.shutdown();
166+
});
167+
168+
it('re-enabling clears the persisted state — the package comes back enabled', async () => {
169+
setPackageDisabled(ENVIRONMENT_ID, DISABLED_ID, true);
170+
setPackageDisabled(ENVIRONMENT_ID, DISABLED_ID, false);
171+
172+
const { kernel, registry } = await bootEmptyEnv();
173+
registry.installPackage(manifestFor(DISABLED_ID));
174+
175+
expect(registry.getPackage(DISABLED_ID)).toMatchObject({
176+
status: 'installed',
177+
enabled: true,
178+
});
179+
180+
await kernel.shutdown();
181+
});
182+
183+
it('boots an empty env with no persisted state at all (nothing to seed)', async () => {
184+
const { kernel, registry } = await bootEmptyEnv();
185+
registry.installPackage(manifestFor(DISABLED_ID));
186+
187+
expect(registry.getPackage(DISABLED_ID)).toMatchObject({ enabled: true });
188+
189+
await kernel.shutdown();
190+
});
191+
192+
// Guards the other direction of the reorder: moving the seed earlier must
193+
// not change what a NON-empty env already did.
194+
it('non-empty env keeps its existing behavior — bundle package installs disabled', async () => {
195+
setPackageDisabled(ENVIRONMENT_ID, DISABLED_ID, true);
196+
197+
const kernel = new LiteKernel({ logger: { level: 'error' } });
198+
kernel.use(new ObjectQLPlugin({}));
199+
kernel.use(
200+
new AppPlugin(
201+
{ id: DISABLED_ID, name: DISABLED_ID, version: '1.0.0', objects: [] },
202+
{ environmentId: ENVIRONMENT_ID, organizationId: 'org_test' },
203+
),
204+
);
205+
await kernel.bootstrap();
206+
207+
const registry = kernel.getService<{ registry: TestRegistry }>('objectql').registry;
208+
expect(registry.getPackage(DISABLED_ID)).toMatchObject({
209+
status: 'disabled',
210+
enabled: false,
211+
});
212+
213+
await kernel.shutdown();
214+
});
215+
216+
// The seed resolves `objectql` through getService; a kernel without an
217+
// engine (metadata-only one-shot commands) must still boot.
218+
it('degrades silently on a kernel with no engine at all', async () => {
219+
setPackageDisabled(ENVIRONMENT_ID, DISABLED_ID, true);
220+
221+
const kernel = new LiteKernel({ logger: { level: 'error' } });
222+
kernel.use(new AppPlugin({}, { environmentId: ENVIRONMENT_ID, organizationId: 'org_test' }));
223+
224+
await expect(kernel.bootstrap()).resolves.toBeUndefined();
225+
await kernel.shutdown();
226+
});
227+
});

packages/runtime/src/app-plugin.ts

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,16 @@ export class AppPlugin implements Plugin {
193193
// up with (the core in-memory fallback included); idempotent across
194194
// multiple wirers via the ownership marker in core.
195195
wireAuthoredTranslationSync(ctx as any);
196+
// Seed persisted package disable-state — also BEFORE the empty-env
197+
// return (#5047). An empty env is EXACTLY the hydration-only scenario:
198+
// the artifact ships no app payload, so every package in that
199+
// environment arrives later from `sys_packages` (PackageServicePlugin's
200+
// Phase 2 rehydrate) or from an HTTP install. Seeding after the return
201+
// meant the registry's initial-disabled set stayed empty on those
202+
// envs, and a package an operator had disabled came back ENABLED on
203+
// every restart. The seed must land before ANY registration path runs,
204+
// which is Phase 1, unconditionally.
205+
this.seedPersistedDisabledPackages(ctx);
196206
if (this.empty) {
197207
ctx.logger.debug('[AppPlugin] empty env — no app payload, skipping init', {
198208
pluginName: this.name,
@@ -223,11 +233,27 @@ export class AppPlugin implements Plugin {
223233
? { ...this.bundle.manifest, ...this.bundle }
224234
: this.bundle;
225235

226-
// Seed persisted package disable-state into the registry BEFORE the
227-
// manifest is decomposed, so disabled packages are installed disabled
228-
// and stay hidden after restart. Honors every later registration path
229-
// (boot artifact, marketplace rehydrate, import) via the registry's
230-
// initial-disabled set. Best-effort — never block boot on this.
236+
ctx.getService<{ register(m: any): void }>('manifest').register(servicePayload);
237+
}
238+
239+
/**
240+
* Seed persisted package disable-state into the registry's initial-disabled
241+
* set, so every later registration path — boot artifact decomposition,
242+
* marketplace / `sys_packages` rehydrate, local import — installs those
243+
* packages DISABLED and they stay hidden after a restart.
244+
*
245+
* Runs in init (Phase 1) and BEFORE the empty-env return (#5047), for the
246+
* same reason the runners above do: the seed only works if it is in place
247+
* before the FIRST `installPackage` call, and on an empty env every
248+
* package arrives from Phase 2 hydration rather than from this bundle.
249+
* On a non-empty env it still lands before the manifest is decomposed,
250+
* because that decomposition happens at the `manifest.register()` call at
251+
* the end of init.
252+
*
253+
* Best-effort — never block boot on this. Degrades silently on kernels
254+
* with no engine (metadata-only one-shot commands, mock-engine tests).
255+
*/
256+
private seedPersistedDisabledPackages(ctx: PluginContext): void {
231257
try {
232258
const ql = ctx.getService<{ registry?: { setInitialDisabledPackageIds?: (ids: Iterable<string>) => void } }>('objectql');
233259
const setter = ql?.registry?.setInitialDisabledPackageIds;
@@ -246,8 +272,6 @@ export class AppPlugin implements Plugin {
246272
error: (err as Error)?.message ?? String(err),
247273
});
248274
}
249-
250-
ctx.getService<{ register(m: any): void }>('manifest').register(servicePayload);
251275
}
252276

253277
/**

0 commit comments

Comments
 (0)