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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/empty-env-disabled-package-seed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@objectstack/runtime": patch
---

fix(runtime): disabled packages no longer come back enabled after an empty-env restart (#5047)

An operator who disables a package has that decision persisted to
`<OS_HOME>/package-state/<environmentId>.json`, and boot replays it by seeding
the registry's initial-disabled set **before** any package is registered — so
every registration path (boot-artifact decomposition, `sys_packages`
rehydration, HTTP install) installs those packages disabled.

That seed ran inside `AppPlugin.init` **after** the empty-env early return. An
empty environment is one whose artifact carries no app payload — which is
exactly the environment where every package arrives later, from
`PackageServicePlugin`'s Phase 2 replay of `sys_packages` or from an HTTP
install. So on precisely those DB-driven environments the initial-disabled set
stayed empty, and a package the administrator had disabled came back **enabled**
on every restart, with no error anywhere: the disable had persisted correctly,
it was simply never read.

The seed now runs before that return, alongside the default hook/action body
runners and the authored-translation sync, which are before it for the same
reason. Non-empty environments are unaffected — the seed still lands before the
manifest is decomposed — and the seed remains best-effort, degrading silently on
kernels with no engine.
1 change: 1 addition & 0 deletions packages/runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"@objectstack/service-datasource": "workspace:*",
"@objectstack/service-job": "workspace:*",
"@objectstack/service-messaging": "workspace:*",
"@objectstack/service-package": "workspace:*",
"typescript": "^6.0.3",
"vitest": "^4.1.10"
},
Expand Down
227 changes: 227 additions & 0 deletions packages/runtime/src/app-plugin.disabled-seed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Persisted package disable-state must survive a restart of an EMPTY env (#5047).
*
* The seed that carries an operator's "disable this package" decision across a
* restart works by filling the registry's initial-disabled set BEFORE the first
* `installPackage` call, so that every registration path installs those
* packages disabled. It used to run AFTER `AppPlugin.init`'s empty-env early
* return — and an empty env (an artifact with no app payload) is precisely the
* environment whose packages ALL arrive later, from `sys_packages` hydration or
* an HTTP install. So on exactly those envs the set stayed empty and every
* disabled package came back ENABLED on each restart.
*
* These tests boot a REAL kernel (LiteKernel + ObjectQLPlugin + AppPlugin) over
* a REAL state file, and drive the REAL `PackageServicePlugin` rehydration, so
* the regression is pinned end to end rather than against a re-implementation.
*
* Reverse verification for the fix: move `seedPersistedDisabledPackages(ctx)`
* back below the `if (this.empty)` return in `app-plugin.ts` and every
* empty-env case here fails (`installed` / `enabled: true`), while the
* non-empty case stays green.
*/

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { LiteKernel } from '@objectstack/core';
import { ObjectQLPlugin } from '@objectstack/objectql';
import { PackageServicePlugin } from '@objectstack/service-package';

import { AppPlugin } from './app-plugin.js';
import { setPackageDisabled } from './package-state-store.js';

const ENVIRONMENT_ID = 'env_disabled_seed';
const DISABLED_ID = 'com.acme.reporting';
const ENABLED_ID = 'com.acme.billing';

interface InstalledPackageView {
status?: string;
enabled?: boolean;
}
interface TestRegistry {
installPackage(manifest: Record<string, unknown>): unknown;
getPackage(id: string): InstalledPackageView | undefined;
}

let home: string;
const envSnapshot = { OS_HOME: process.env.OS_HOME, OS_ENVIRONMENT_ID: process.env.OS_ENVIRONMENT_ID };

function manifestFor(id: string) {
return { id, name: id, version: '1.0.0', type: 'application' };
}

/**
* Boot the composition an empty environment actually runs: the engine plus an
* AppPlugin whose bundle carries no app payload.
*/
async function bootEmptyEnv(): Promise<{ kernel: LiteKernel; registry: TestRegistry }> {
const kernel = new LiteKernel({ logger: { level: 'error' } });
kernel.use(new ObjectQLPlugin({}));
kernel.use(new AppPlugin({}, { environmentId: ENVIRONMENT_ID, organizationId: 'org_test' }));
await kernel.bootstrap();
const ql = kernel.getService<{ registry: TestRegistry }>('objectql');
return { kernel, registry: ql.registry };
}

/** A PluginContext for PackageServicePlugin whose engine shares `registry`. */
function packageServiceCtx(registry: TestRegistry, rows: Array<Record<string, unknown>>) {
const execute = vi.fn(async ({ sql }: { sql: string }) => {
if (/SELECT \* FROM sys_packages/i.test(sql)) return { rows };
return { rows: [] }; // CREATE TABLE / INDEX / …
});
const services = new Map<string, unknown>([['objectql', { execute, registry }]]);
return {
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
getService: (n: string) => services.get(n),
registerService: (n: string, s: unknown) => services.set(n, s),
} as never;
}

function sysPackagesRow(manifest: Record<string, unknown>) {
return {
id: manifest.id,
version: manifest.version,
manifest: JSON.stringify(manifest),
metadata: '{}',
hash: 'h',
created_at: 't',
updated_at: 't',
};
}

beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'os-disabled-seed-'));
process.env.OS_HOME = home;
delete process.env.OS_ENVIRONMENT_ID;
});

afterEach(() => {
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;
});

describe('empty-env boot seeds persisted package disable-state (#5047)', () => {
it('a package registered after boot lands DISABLED — the hydration-only path', async () => {
setPackageDisabled(ENVIRONMENT_ID, DISABLED_ID, true); // operator disabled it last run

const { kernel, registry } = await bootEmptyEnv();
// Nothing came from the (empty) artifact; this is the post-boot
// registration every package in such an env goes through.
registry.installPackage(manifestFor(DISABLED_ID));

expect(registry.getPackage(DISABLED_ID)).toMatchObject({
status: 'disabled',
enabled: false,
});

await kernel.shutdown();
});

it('seeds only the persisted ids — other packages still install enabled', async () => {
setPackageDisabled(ENVIRONMENT_ID, DISABLED_ID, true);

const { kernel, registry } = await bootEmptyEnv();
registry.installPackage(manifestFor(ENABLED_ID));

expect(registry.getPackage(ENABLED_ID)).toMatchObject({
status: 'installed',
enabled: true,
});

await kernel.shutdown();
});

it('a package replayed from sys_packages by PackageServicePlugin lands DISABLED', async () => {
setPackageDisabled(ENVIRONMENT_ID, DISABLED_ID, true);

// Phase 1: the empty-env kernel boots and seeds the registry.
const { kernel, registry } = await bootEmptyEnv();

// Phase 2: the real rehydration replays the durable row into that same
// registry (ADR-0033 consolidation).
await new PackageServicePlugin().start(
packageServiceCtx(registry, [
sysPackagesRow(manifestFor(DISABLED_ID)),
sysPackagesRow(manifestFor(ENABLED_ID)),
]),
);

expect(registry.getPackage(DISABLED_ID)).toMatchObject({
status: 'disabled',
enabled: false,
});
expect(registry.getPackage(ENABLED_ID)).toMatchObject({
status: 'installed',
enabled: true,
});

await kernel.shutdown();
});

it('re-enabling clears the persisted state — the package comes back enabled', async () => {
setPackageDisabled(ENVIRONMENT_ID, DISABLED_ID, true);
setPackageDisabled(ENVIRONMENT_ID, DISABLED_ID, false);

const { kernel, registry } = await bootEmptyEnv();
registry.installPackage(manifestFor(DISABLED_ID));

expect(registry.getPackage(DISABLED_ID)).toMatchObject({
status: 'installed',
enabled: true,
});

await kernel.shutdown();
});

it('boots an empty env with no persisted state at all (nothing to seed)', async () => {
const { kernel, registry } = await bootEmptyEnv();
registry.installPackage(manifestFor(DISABLED_ID));

expect(registry.getPackage(DISABLED_ID)).toMatchObject({ enabled: true });

await kernel.shutdown();
});

// Guards the other direction of the reorder: moving the seed earlier must
// not change what a NON-empty env already did.
it('non-empty env keeps its existing behavior — bundle package installs disabled', async () => {
setPackageDisabled(ENVIRONMENT_ID, DISABLED_ID, true);

const kernel = new LiteKernel({ logger: { level: 'error' } });
kernel.use(new ObjectQLPlugin({}));
kernel.use(
new AppPlugin(
{ id: DISABLED_ID, name: DISABLED_ID, version: '1.0.0', objects: [] },
{ environmentId: ENVIRONMENT_ID, organizationId: 'org_test' },
),
);
await kernel.bootstrap();

const registry = kernel.getService<{ registry: TestRegistry }>('objectql').registry;
expect(registry.getPackage(DISABLED_ID)).toMatchObject({
status: 'disabled',
enabled: false,
});

await kernel.shutdown();
});

// The seed resolves `objectql` through getService; a kernel without an
// engine (metadata-only one-shot commands) must still boot.
it('degrades silently on a kernel with no engine at all', async () => {
setPackageDisabled(ENVIRONMENT_ID, DISABLED_ID, true);

const kernel = new LiteKernel({ logger: { level: 'error' } });
kernel.use(new AppPlugin({}, { environmentId: ENVIRONMENT_ID, organizationId: 'org_test' }));

await expect(kernel.bootstrap()).resolves.toBeUndefined();
await kernel.shutdown();
});
});
38 changes: 31 additions & 7 deletions packages/runtime/src/app-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,16 @@ export class AppPlugin implements Plugin {
// up with (the core in-memory fallback included); idempotent across
// multiple wirers via the ownership marker in core.
wireAuthoredTranslationSync(ctx as any);
// Seed persisted package disable-state — also BEFORE the empty-env
// return (#5047). An empty env is EXACTLY the hydration-only scenario:
// the artifact ships no app payload, so every package in that
// environment arrives later from `sys_packages` (PackageServicePlugin's
// Phase 2 rehydrate) or from an HTTP install. Seeding after the return
// meant the registry's initial-disabled set stayed empty on those
// 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);
if (this.empty) {
ctx.logger.debug('[AppPlugin] empty env — no app payload, skipping init', {
pluginName: this.name,
Expand Down Expand Up @@ -223,11 +233,27 @@ export class AppPlugin implements Plugin {
? { ...this.bundle.manifest, ...this.bundle }
: this.bundle;

// Seed persisted package disable-state into the registry BEFORE the
// manifest is decomposed, so disabled packages are installed disabled
// and stay hidden after restart. Honors every later registration path
// (boot artifact, marketplace rehydrate, import) via the registry's
// initial-disabled set. Best-effort — never block boot on this.
ctx.getService<{ register(m: any): void }>('manifest').register(servicePayload);
}

/**
* Seed persisted package disable-state into the registry's initial-disabled
* set, so every later registration path — boot artifact decomposition,
* marketplace / `sys_packages` rehydrate, local import — installs those
* packages DISABLED and they stay hidden after a restart.
*
* Runs in init (Phase 1) and BEFORE the empty-env return (#5047), for the
* same reason the runners above do: the seed only works if it is in place
* before the FIRST `installPackage` call, and on an empty env every
* package arrives from Phase 2 hydration rather than from this bundle.
* On a non-empty env it still lands before the manifest is decomposed,
* 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).
*/
private seedPersistedDisabledPackages(ctx: PluginContext): void {
try {
const ql = ctx.getService<{ registry?: { setInitialDisabledPackageIds?: (ids: Iterable<string>) => void } }>('objectql');
const setter = ql?.registry?.setInitialDisabledPackageIds;
Expand All @@ -246,8 +272,6 @@ export class AppPlugin implements Plugin {
error: (err as Error)?.message ?? String(err),
});
}

ctx.getService<{ register(m: any): void }>('manifest').register(servicePayload);
}

/**
Expand Down
Loading
Loading