From af9ac5ded36df6944c30e2f9fdad13bea6bbf772 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 07:34:59 +0000 Subject: [PATCH 1/6] fix(cli): a declaration boot does not run host onEnable or post-declaration hooks os migrate plan / apply compose host code for what it declares. The config's onEnable is withheld by the AppPlugin the composition builds (skipOnEnable), and a host plugin's init() gets a context that does not register kernel:bootstrapped / kernel:listening hooks. The plan's notes name what was withheld. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- packages/cli/src/utils/schema-migrate.ts | 19 +- .../cli/src/utils/schema-migration-plugins.ts | 255 ++++++++++++++++-- packages/runtime/src/app-plugin.ts | 52 +++- 3 files changed, 298 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/utils/schema-migrate.ts b/packages/cli/src/utils/schema-migrate.ts index f9f82111df2..3ad23cf6278 100644 --- a/packages/cli/src/utils/schema-migrate.ts +++ b/packages/cli/src/utils/schema-migrate.ts @@ -367,15 +367,20 @@ export async function bootSchemaStack( // #13332 — the kernel bootstrap is over, and with it the window the // declaration boot's write guard covers. `composeForDeclarations` suppresses - // a host plugin's `start()`, but `kernel.ts` fires `kernel:ready`, - // `kernel:bootstrapped` and `kernel:listening` unconditionally afterwards, so - // a hook REGISTERED from `init()` runs on a plan; the guard refuses those - // writes at the driver instead of at a list of phase names. Everything from - // this line on is work the command was ASKED for — `apply`'s confirmed DDL - // flush, the #13028 coverage pass — so the guard comes off here and reports - // whatever it refused, which the plan prints and `--json` carries. + // a host plugin's `start()` and its post-declaration hooks (#21054), but + // `kernel.ts` fires `kernel:ready` unconditionally afterwards, so a hook + // REGISTERED from `init()` on that phase runs on a plan; the guard refuses + // its writes at the driver instead of at a list of phase names. Everything + // from this line on is work the command was ASKED for — `apply`'s confirmed + // DDL flush, the #13028 coverage pass — so the guard comes off here and + // reports whatever it refused, which the plan prints and `--json` carries. const refusalNote = composition.writeGuard?.disarm() ?? null; if (refusalNote) composition.notes.push(refusalNote); + // #21054 — and what the boot did not run for host code at all: the + // post-declaration hooks its `init()`s asked for, and the config's + // `onEnable`. Read now, after `start()` has decided the latter. + const lifecycleNote = composition.lifecycle?.describe() ?? null; + if (lifecycleNote) composition.notes.push(lifecycleNote); const driver = findSqlDriver(kernel); diff --git a/packages/cli/src/utils/schema-migration-plugins.ts b/packages/cli/src/utils/schema-migration-plugins.ts index 3e4b5acc054..2f397895b35 100644 --- a/packages/cli/src/utils/schema-migration-plugins.ts +++ b/packages/cli/src/utils/schema-migration-plugins.ts @@ -98,11 +98,28 @@ import { stackDeclaresMetadata } from './stack-collections.js'; * ({@link createDeclarationBootWriteGuard}): for the length of the kernel * bootstrap, the row-write members of the data-driver contract are refused on * every `driver.*` instance the kernel publishes. Phase-agnostic by - * construction — a fourth phase is covered on the day it ships — and - * read/log-only hooks still run, which is what an operator reading a plan - * before a production apply needs them to do. Neutralising `init()`-registered - * hooks instead would have been neither necessary (a log-only hook violates - * nothing) nor sufficient (a write arriving by any other path still lands). + * construction — a fourth phase is covered on the day it ships. Neutralising + * `init()`-registered hooks INSTEAD would have been insufficient (a write + * arriving by any other path still lands), and the guard stays the write + * guarantee on every phase. + * + * ## …and why the post-declaration phases do not fire for host code (#21054) + * + * The guard refuses writes and lets the hooks run, so it cannot stop a hook's + * READS — and on a plan a read is not harmless. A host hook that reads a table + * the plan's composition never declares fails on every plan and prints a + * `DATABASE_ERROR` for it: `examples/app-crm`'s `onEnable` hooks + * `kernel:bootstrapped` and reads `sys_position` / `sys_permission_set`, six + * such lines on every plan of a fully migrated database. A declaration boot + * reads DECLARATIONS, so host code does not run where the kernel contract + * says declaring is over: a host plugin's `init()` gets a context that does + * not register `kernel:bootstrapped` / `kernel:listening` hooks, and the + * config's `onEnable` is not executed ({@link DeclarationBootLifecycle} — the + * two doors, why each holds for every app, and what the plan says about it). + * `kernel:ready` host hooks still run: the contract leaves late registration + * there, and a host that provisions its tables from one is a measured shape + * the plan must see. This repo's own plugins are not host code; their hooks on + * every phase are untouched. * * ⚠️ **The residue, stated rather than hidden:** a host plugin that registers * its objects in `start()` instead of `init()` is invisible to this @@ -175,6 +192,11 @@ import { stackDeclaresMetadata } from './stack-collections.js'; * network). * - work a hook defers past the end of the bootstrap; the guard covers the * boot window. + * - (#21054, the post-declaration phases) a host that registers a hook + * without the context its `init()` was handed — through `getKernel()`, + * or from a service factory, which the kernel calls with its own context — + * is outside {@link composeForDeclarations}' reach; its writes still meet + * the guard. * * `PlatformObjectsPlugin` is deliberately NOT suppressed: it is platform * infrastructure this CLI already boots fully under the sibling DATA @@ -211,17 +233,180 @@ async function suppressedStart(): Promise { /* Phase 2 is not run for host plugins — see this module's header. */ } +/** + * The kernel boot phases whose CONTRACT is post-declaration work (#21054), and + * which a declaration boot therefore does not fire for host code. + * + * Read off `IPluginLifecycleEvents` (`packages/spec/src/contracts/` + * `plugin-lifecycle-events.ts`), not off a survey of what hosts do there: + * + * - `kernel:bootstrapped` is "the all synchronous bootstrap has settled" + * anchor, for "reconcile/backfill work that consumes" the data a + * `kernel:ready` handler produced — reads and writes of rows, never a + * declaration; + * - `kernel:listening` is for work that must happen "strictly after every + * other plugin has had a chance to register routes / services / + * middleware during `kernel:ready`" — most notably HTTP `listen()`. + * + * Both say in their own words that registration is OVER by the time they + * fire. `kernel:ready` is deliberately NOT here: the same contract puts late + * registration in it, and a host that provisions its declared objects from a + * `kernel:ready` hook is a measured shape (#13028) whose tables the plan has + * to see. Writes on `kernel:ready` stay refused by the write guard. + * + * `kernel:shutdown` is not here either: it is the teardown of what `init()` + * opened, the symmetric half `composeForDeclarations` forwards `destroy()` for. + */ +const POST_DECLARATION_PHASES = ['kernel:bootstrapped', 'kernel:listening'] as const; + +/** One host hook a declaration boot did not register, as the plan reports it. */ +export interface WithheldHostHook { + /** The host plugin whose `init()` asked for it. */ + plugin: string; + /** The post-declaration phase it asked for ({@link POST_DECLARATION_PHASES}). */ + phase: string; + /** How many registrations this plugin/phase pair asked for. */ + count: number; +} + +/** + * What a declaration boot held back from HOST CODE (#21054) — the record the + * plan's notes are written from. + * + * The write guard ({@link createDeclarationBootWriteGuard}) refuses a host's + * WRITES and still lets its hooks run, which is all a write can be refused + * with. A READ cannot be refused that way, and on a plan it is not harmless: + * a hook that reads a table the plan's composition never declares fails on + * every plan and prints a `DATABASE_ERROR` for it (measured on + * `examples/app-crm`, whose `onEnable` hooks `kernel:bootstrapped` and reads + * `sys_position` / `sys_permission_set`: six such lines per plan, on a fully + * migrated database). So host code reaches a declaration boot through exactly + * two doors, and each is closed at the door, for every app: + * + * - a host plugin from `config.plugins` — {@link composeForDeclarations} + * hands its `init()` a context whose `hook()` does not register the + * {@link POST_DECLARATION_PHASES}, beside the `start()` it already + * suppressed; + * - the config module's own `onEnable` — run by the `AppPlugin` this + * composition constructs, which is told `skipOnEnable` and withholds it. + * (A compiled artifact cannot carry an `onEnable` at all: it is JSON, and + * its runtime module contributes `functions` only.) + * + * The platform's own hooks — this repo's plugins the CLI composes — are not + * host code and are untouched: the plan still prints, for instance, the + * ADR-0104 value-shape gate announcement a `kernel:bootstrapped` hook of the + * engine makes. + */ +export interface DeclarationBootLifecycle { + /** Every host hook not registered so far, per plugin and phase. */ + readonly withheldHooks: readonly WithheldHostHook[]; + /** The `AppPlugin` names whose `onEnable` was withheld, once the boot has started them. */ + readonly withheldOnEnable: readonly string[]; + /** Record an `AppPlugin` this composition constructed with `skipOnEnable`. */ + trackApp(plugin: unknown): void; + /** @internal what {@link composeForDeclarations} calls on a withheld registration. */ + recordWithheldHook(plugin: string, phase: string): void; + /** + * The line for {@link SchemaMigrationComposition.notes} — or `null` when + * nothing was withheld, so a host with no such hook and no `onEnable` renders + * exactly as it did before this existed. Read after the kernel bootstrap. + */ + describe(): string | null; +} + +/** Build the record. See {@link DeclarationBootLifecycle}. */ +export function createDeclarationBootLifecycle(): DeclarationBootLifecycle { + const hooks = new Map(); + const apps: Array<{ name?: unknown; onEnableWithheld?: unknown }> = []; + const withheldOnEnable = (): string[] => apps + .filter((app) => app.onEnableWithheld === true) + .map((app) => (typeof app.name === 'string' ? app.name : '(unnamed app)')); + + return { + get withheldHooks(): readonly WithheldHostHook[] { return [...hooks.values()]; }, + get withheldOnEnable(): readonly string[] { return withheldOnEnable(); }, + trackApp(plugin: unknown): void { + if (plugin && typeof plugin === 'object') apps.push(plugin as (typeof apps)[number]); + }, + recordWithheldHook(plugin: string, phase: string): void { + const key = `${plugin}|${phase}`; + const seen = hooks.get(key); + if (seen) seen.count += 1; + else hooks.set(key, { plugin, phase, count: 1 }); + }, + describe(): string | null { + const parts: string[] = []; + const onEnable = withheldOnEnable(); + if (onEnable.length > 0) { + parts.push(`did not execute runtime.onEnable of ${onEnable.join(', ')}`); + } + if (hooks.size > 0) { + const total = [...hooks.values()].reduce((n, h) => n + h.count, 0); + const detail = [...hooks.values()] + .map((h) => `${h.plugin} on ${h.phase}${h.count > 1 ? ` x${h.count}` : ''}`) + .join(', '); + parts.push(`did not register ${total} host hook(s) on post-declaration phases (${detail})`); + } + if (parts.length === 0) return null; + return ( + `This declaration boot ${parts.join(', and ')}: it composes host code for what it ` + + 'DECLARES, and reconcile/backfill or listener work belongs to a served boot of the same ' + + 'stack — so none of it ran here, and none of it affects the plan below.' + ); + }, + }; +} + +/** + * The context a host plugin's `init()` receives on a declaration boot: the + * kernel's own, with `hook()` declining the {@link POST_DECLARATION_PHASES}. + * + * A Proxy for the reason {@link composeForDeclarations} is one — every other + * member forwarded, exactly one overridden. A hook the host registers on any + * other name (`kernel:ready`, `kernel:shutdown`, a data hook, its own event) + * is registered as before. + */ +function declarationContext( + ctx: unknown, + owner: string, + lifecycle: DeclarationBootLifecycle | undefined, +): unknown { + if (!ctx || typeof ctx !== 'object') return ctx; + const target = ctx as Record; + const hook = (name: unknown, ...rest: unknown[]): unknown => { + if ((POST_DECLARATION_PHASES as readonly unknown[]).includes(name)) { + lifecycle?.recordWithheldHook(owner, String(name)); + return undefined; + } + return Reflect.apply(target.hook as (...a: unknown[]) => unknown, target, [name, ...rest]); + }; + return new Proxy(target, { + get(t, prop) { + if (prop === 'hook' && typeof t.hook === 'function') return hook; + const value = t[prop]; + return typeof value === 'function' ? (value as (...a: unknown[]) => unknown).bind(t) : value; + }, + }); +} + /** * A host plugin composed for its DECLARATIONS: `init()` runs, `start()` does - * not. + * not, and the hooks `init()` registers on the post-declaration phases are + * not registered at all (#21054, {@link POST_DECLARATION_PHASES}). * * A Proxy rather than a hand-copied field list on purpose. The kernel reads * several identity/ordering members off a plugin instance — `name`, `version`, * `type`, `dependencies`, `optionalDependencies`, `requiresServices`, * `providesServices`, and `constructor.name` at more than one presence test — * and a copy that misses one does not fail, it silently mis-orders the boot or - * defeats a de-dup check. Forwarding everything and overriding exactly one - * member is the only shape in which that cannot happen. + * defeats a de-dup check. Forwarding everything and overriding exactly the two + * lifecycle members is the only shape in which that cannot happen: `start` is + * replaced, `init` is forwarded with {@link declarationContext} in place of the + * kernel's context. A host that keeps that context — to register a hook later, + * from a `kernel:ready` handler say — keeps the declaration context with it. + * + * @param lifecycle where a withheld registration is recorded so the plan can + * name it; omitted, the registration is withheld all the same. * * Two details the trap gets right deliberately: * @@ -235,18 +420,31 @@ async function suppressedStart(): Promise { * plugin that connected something during Phase 1 must still be able to close it. * * ⚠️ **This suppression is not, on its own, the "writes nothing" guarantee** - * (#13332). `init()` runs, and every hook it registers fires on the phases - * `kernel.ts` triggers unconditionally after the suppressed start pass. What - * makes the sentence true is {@link createDeclarationBootWriteGuard}, which - * refuses the write itself; this Proxy keeps Phase 2 out of a dry run, which is - * a different and narrower job. + * (#13332). `init()` runs, and a hook it registers on `kernel:ready` — the + * phase the contract leaves open for late registration — still fires after the + * suppressed start pass. What makes the sentence true is + * {@link createDeclarationBootWriteGuard}, which refuses the write itself; this + * Proxy keeps Phase 2 and the post-declaration phases out of a dry run, which + * is a different and narrower job. */ -export function composeForDeclarations(plugin: T): T { +export function composeForDeclarations( + plugin: T, + lifecycle?: DeclarationBootLifecycle, +): T { return new Proxy(plugin, { get(target, prop) { if (prop === 'start') return suppressedStart; // Read through the target so getters see the right `this`. const value = (target as Record)[prop]; + if (prop === 'init' && typeof value === 'function') { + const owner = (target as { name?: unknown }).name; + const label = typeof owner === 'string' && owner.length > 0 ? owner : '(unnamed plugin)'; + return (ctx: unknown, ...rest: unknown[]): unknown => Reflect.apply( + value as (...args: unknown[]) => unknown, + target, + [declarationContext(ctx, label, lifecycle), ...rest], + ); + } if (typeof value === 'function' && prop !== 'constructor') { return (value as (...args: unknown[]) => unknown).bind(target); } @@ -1028,6 +1226,15 @@ export interface SchemaMigrationComposition { * returns and appends the line it hands back to {@link notes}. */ writeGuard?: DeclarationBootWriteGuard; + /** + * What the declaration boot held back from host code (#21054) — the + * post-declaration hooks its `init()`s asked for, and the config's + * `onEnable`. `undefined` on a boot that composed nothing, like + * {@link writeGuard}; `bootSchemaStack` appends its + * {@link DeclarationBootLifecycle.describe} line to {@link notes} once the + * kernel bootstrap returns. + */ + lifecycle?: DeclarationBootLifecycle; } const NOTHING_COMPOSED: SchemaMigrationComposition = Object.freeze({ @@ -1070,6 +1277,9 @@ export async function buildSchemaMigrationPlugins(opts: { // `init()` that writes directly is only refused if the guard is already on // the driver by the time it runs. See {@link DeclarationBootWriteGuard}. const writeGuard = createDeclarationBootWriteGuard(); + // #21054 — what this boot holds back from host code, recorded so the plan + // can say so. See {@link DeclarationBootLifecycle}. + const lifecycle = createDeclarationBootLifecycle(); const plugins: unknown[] = [writeGuard.plugin]; const notes: string[] = []; let hostConfigLoaded = false; @@ -1085,7 +1295,7 @@ export async function buildSchemaMigrationPlugins(opts: { const hostPlugins: unknown[] = Array.isArray(config?.plugins) ? config.plugins : []; for (const plugin of hostPlugins) { - if (plugin && typeof plugin === 'object') plugins.push(composeForDeclarations(plugin)); + if (plugin && typeof plugin === 'object') plugins.push(composeForDeclarations(plugin, lifecycle)); } // `serve` step 3, same predicate: a host config that ALSO carries @@ -1100,7 +1310,16 @@ export async function buildSchemaMigrationPlugins(opts: { const appAlready = hasArtifactApp || hostPlugins.some(isAppPluginLike); if (configHasMetadata && !appAlready) { const { AppPlugin } = await import('@objectstack/runtime'); - plugins.push(new AppPlugin(config, undefined, { skipSeedData: opts.skipSeedData ?? false })); + // #21054 — the config's `onEnable` is the app's imperative code, and + // this is the one door it reaches a declaration boot through: the + // executor withholds it (the AppPlugin owns which object carries the + // hook, so it is told rather than handed a stripped copy). + const app = new AppPlugin(config, undefined, { + skipSeedData: opts.skipSeedData ?? false, + skipOnEnable: true, + }); + lifecycle.trackApp(app); + plugins.push(app); } hostConfigLoaded = true; @@ -1154,7 +1373,9 @@ export async function buildSchemaMigrationPlugins(opts: { notes.push('Composed PlatformObjectsPlugin (the platform floor `os serve` composes unconditionally).'); } - return { plugins, hostConfigPath, hostConfigLoaded, hostConfigError, notes, coverage: null, writeGuard }; + return { + plugins, hostConfigPath, hostConfigLoaded, hostConfigError, notes, coverage: null, writeGuard, lifecycle, + }; } /** diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index 02d93395c8a..b5996d1278b 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -102,7 +102,8 @@ export type AppPluginSecurityMetadataRegistrar = 'app-plugin' | 'artifact-door'; * * Responsibilities: * 1. Register App Manifest as a service (for ObjectQL discovery) - * 2. Execute Runtime `onEnable` hook (for code logic) + * 2. Execute Runtime `onEnable` hook (for code logic) — withheld on a + * declaration boot that passes `skipOnEnable` * 3. Auto-load i18n translation bundles into the kernel's i18n service */ export class AppPlugin implements Plugin { @@ -171,6 +172,26 @@ export class AppPlugin implements Plugin { * it only writes when something calls it. */ private readonly skipSeedData: boolean; + /** + * Do not execute the bundle's `onEnable` (#21054) — the same one-shot + * schema commands as {@link skipSeedData}, for the same reason one step + * further. `os migrate plan` / `apply` compose this app for what it + * DECLARES; `onEnable` is the app's imperative code, and what it does — + * register handlers, drivers and lifecycle hooks, read and write data — + * belongs to a served boot. Measured on `examples/app-crm`: its `onEnable` + * hooks `kernel:bootstrapped` and reads `sys_position` / + * `sys_permission_set`, tables the plan's composition never declares, so + * every plan printed six `DATABASE_ERROR` lines. + * + * Read HERE, by the executor, rather than arranged by stripping the member + * off a copy of the bundle: this method alone resolves which object owns + * the hook (`bundle.default` before the bundle itself), a stripped copy + * would re-state that rule at the call site, and the boot would then log + * "No runtime.onEnable function found" about an app that has one. + */ + private readonly skipOnEnable: boolean; + /** Set by `start()` when {@link skipOnEnable} withheld an `onEnable` the bundle carries. */ + private onEnableWithheldFlag = false; /** * See {@link AppPluginSecurityMetadataRegistrar}. Public and readonly so a * composition test can pin which registrar a boot shape declared. @@ -229,14 +250,29 @@ export class AppPlugin implements Plugin { return this.grantBindingResult; } + /** + * `true` once `start()` found an `onEnable` on this bundle and did NOT run + * it, because the composition passed `skipOnEnable` (#21054). `false` on + * every boot that runs it and on a bundle that carries none — so a caller + * reporting what its boot withheld names only what was really there. + */ + get onEnableWithheld(): boolean { + return this.onEnableWithheldFlag; + } + constructor( bundle: any, projectContext?: AppPluginProjectContext, - opts: { skipSeedData?: boolean; securityMetadataRegistrar?: AppPluginSecurityMetadataRegistrar } = {}, + opts: { + skipSeedData?: boolean; + skipOnEnable?: boolean; + securityMetadataRegistrar?: AppPluginSecurityMetadataRegistrar; + } = {}, ) { this.bundle = bundle; this.projectContext = projectContext; this.skipSeedData = opts.skipSeedData ?? false; + this.skipOnEnable = opts.skipOnEnable ?? false; // Refused loudly rather than defaulted: a misspelt registrar would // otherwise fall through to whichever branch the typo happened to // miss, and both branches are silent about what they did not do. @@ -1015,8 +1051,16 @@ export class AppPlugin implements Plugin { ? stackBundle : this.bundle; - if (runtime && typeof runtime.onEnable === 'function') { - ctx.logger.info('Executing runtime.onEnable', { + if (runtime && typeof runtime.onEnable === 'function' && this.skipOnEnable) { + // [#21054] A declaration boot: the hook exists and is withheld, + // and the boot says so rather than reading as an app without one. + this.onEnableWithheldFlag = true; + ctx.logger.info( + 'runtime.onEnable NOT executed — this boot composes the app for its declarations only (skipOnEnable)', + { appName: this.name, appId }, + ); + } else if (runtime && typeof runtime.onEnable === 'function') { + ctx.logger.info('Executing runtime.onEnable', { appName: this.name, appId }); From 744234f22dfce257d0350e8f02b14bbfcf4a9b50 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 07:43:47 +0000 Subject: [PATCH 2/6] test(cli,runtime): pin that a declaration boot runs no app lifecycle hook Runtime: AppPlugin skipOnEnable withholds onEnable wherever it resolves. CLI unit: composeForDeclarations' init context declines the two post-declaration phases; the composed app carries skipOnEnable. CLI integration: the write-guard pins move host hooks to kernel:ready and keep the guard's phase-agnostic property on an unwrapped writer; an app-crm-shaped fixture prints zero DATABASE_ERROR on a migrated and on an absent file, with a served-composition positive control and the apply flush/coverage control. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- ...grate.host-composition.integration.test.ts | 251 +++++++++++++++++- ...ugins.declaration-boot-write-guard.test.ts | 208 +++++++++++++-- .../utils/schema-migration-plugins.test.ts | 91 +++++++ packages/runtime/src/app-plugin.test.ts | 58 ++++ 4 files changed, 576 insertions(+), 32 deletions(-) diff --git a/packages/cli/src/utils/schema-migrate.host-composition.integration.test.ts b/packages/cli/src/utils/schema-migrate.host-composition.integration.test.ts index d71daf1e11d..0d8395f13b9 100644 --- a/packages/cli/src/utils/schema-migrate.host-composition.integration.test.ts +++ b/packages/cli/src/utils/schema-migrate.host-composition.integration.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, readFileSync, symlinkSync, rmSync } from 'node:fs'; +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { existsSync, mkdtempSync, mkdirSync, writeFileSync, appendFileSync, readFileSync, symlinkSync, rmSync } from 'node:fs'; import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; @@ -694,21 +694,38 @@ describe('a plan writes nothing even when the host writes from init() (#13332)', // The property (b) was chosen for: the hooks RAN — the log-only ones // included — on the path an operator reads before a production apply. + // The `extra` plugin is handed straight to the kernel (not host code + // composed for declarations), so every phase of it runs and meets the + // guard: the guard is phase-agnostic. for (const phase of PHASES) { - expect(log).toContain(`host|log-only|${phase}`); expect(log).toContain(`extra|log-only|${phase}`); // …and the writing hooks got all the way to their `create()` call, // which returned instead of throwing: the line after it was reached. - expect(log).toContain(`host|write|${phase}`); expect(log).toContain(`extra|write|${phase}`); } + // The HOST config's plugin keeps `kernel:ready` — the phase the contract + // leaves registration in — and (#21054) never registers its + // post-declaration hooks at all. + expect(log).toContain('host|log-only|kernel:ready'); + expect(log).toContain('host|write|kernel:ready'); + for (const phase of ['kernel:bootstrapped', 'kernel:listening']) { + expect(log).not.toContain(`host|log-only|${phase}`); + expect(log).not.toContain(`host|write|${phase}`); + } // The refusals are REPORTED, not swallowed — this is the line the plan // prints and `--json` carries. No raw execute() went through on this // boot, so the outcome claim HELD and is printed with the report. + // 4 = the host's `kernel:ready` write + the extra plugin's three. const notes = stack.composition.notes.join(' '); - expect(notes).toContain('Refused 6 write(s) during the declaration boot — a plan writes nothing'); + expect(notes).toContain('Refused 4 write(s) during the declaration boot — a plan writes nothing'); expect(notes).toContain('create() on sys_metadata'); + // …and what was not run is said too. + expect(notes).toContain( + 'did not register 4 host hook(s) on post-declaration phases ' + + '(com.example.host-writes-from-init on kernel:bootstrapped x2, ' + + 'com.example.host-writes-from-init on kernel:listening x2)', + ); } finally { await stack.shutdown(); } @@ -770,10 +787,11 @@ describe('a plan writes nothing even when the host writes from init() (#13332)', // …and the run SAYS so. The refusal line drops the flat claim (the // colon directly after "boot" is the dropped phrase), the forwarded // call is named with its count, and no note in the run claims the - // plan wrote nothing. 4 refusals: the host config's plugin on three - // phases, plus this fixture's in-run control. + // plan wrote nothing. 2 refusals: the host config's plugin on + // `kernel:ready` (its post-declaration hooks are never registered, + // #21054), plus this fixture's in-run control. const notes = stack.composition.notes.join(' '); - expect(notes).toContain('Refused 4 write(s) during the declaration boot:'); + expect(notes).toContain('Refused 2 write(s) during the declaration boot:'); expect(notes).toContain('Raw execute() was called 1 time(s) during the declaration boot'); expect(notes).not.toContain('a plan writes nothing'); @@ -786,3 +804,220 @@ describe('a plan writes nothing even when the host writes from init() (#13332)', } }, 60_000); }); + +/** + * #21054 — a plan's declaration boot runs no app lifecycle hook. + * + * `examples/app-crm` measured it: its config's `onEnable` hooks + * `kernel:bootstrapped` and reads `sys_position` / `sys_permission_set`, + * tables the plan's composition never declares. Every plan — on a database + * `apply` had just migrated, and on one that does not exist — printed six + * `[sql-driver] DATABASE_ERROR` lines and six `position binding lookup failed` + * warnings. The write guard could not help: the hook only READS. + * + * The fixture is that shape, built here rather than read from + * `examples/app-crm` (a test reading another package's tree is an undeclared + * cross-package input): a stack with one object, a named `onEnable` export + * that hooks `kernel:bootstrapped` and reads the two undeclared tables, and a + * host plugin whose `init()` registers a reading `kernel:bootstrapped` hook + * beside a `kernel:ready` one. + * + * Pinned, in order: the POSITIVE CONTROL (the same code, composed the way a + * served boot composes it, prints the lines); `apply`'s confirmed work after + * the boot is unchanged (the DDL flush creates the app's table, the #13028 + * coverage pass examines it); then the plan on the migrated file and on an + * absent one prints zero `DATABASE_ERROR` lines, runs neither hook, and still + * runs the host's `kernel:ready` hook. + */ +describe('a plan runs no app lifecycle hook (#21054)', () => { + let dir: string; + let migratedDb: string; + let hookLog: string; + const savedEnv: Record = {}; + let applyFlushed: Array<{ table: string; kind: string }> = []; + let applyExamined = -1; + + const PROBE_TABLES = ['sys_position', 'sys_permission_set']; + + /** Every driver `DATABASE_ERROR` warning the run printed, whatever channel it took. */ + const captureDatabaseErrors = () => { + const lines: string[] = []; + const record = (...args: unknown[]) => { + const text = args.map((a) => (typeof a === 'string' ? a : '')).join(' '); + if (text.includes('DATABASE_ERROR')) lines.push(text); + }; + const warn = vi.spyOn(console, 'warn').mockImplementation(record); + const error = vi.spyOn(console, 'error').mockImplementation(record); + return { + lines, + restore: () => { warn.mockRestore(); error.mockRestore(); }, + }; + }; + + const bootPlan = (dbFile: string) => bootSchemaStack({ + jsonOutput: false, + databaseUrl: `file:${dbFile}`, + deferSchemaDdl: true, + readOnlyProbe: true, + composeHostStack: true, + projectRoot: dir, + }); + + beforeAll(async () => { + dir = mkdtempSync(join(tmpdir(), 'os-21054-')); + migratedDb = join(dir, 'migrated.db'); + hookLog = join(dir, 'hooks.log'); + writeFileSync(hookLog, ''); + + savedEnv.NODE_ENV = process.env.NODE_ENV; + savedEnv.OS_ARTIFACT_PATH = process.env.OS_ARTIFACT_PATH; + process.env.NODE_ENV = 'production'; + process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json'); + + writeFileSync( + join(dir, 'objectstack.config.ts'), + [ + "import { appendFileSync } from 'node:fs';", + '', + `const LOG = ${JSON.stringify(hookLog)};`, + "const SYS = { isSystem: true };", + '', + 'export default {', + " manifest: { id: 'com.example.os21054', name: 'No app hooks on a plan', version: '0.0.0', type: 'app' },", + " objects: [{ name: 'os21054_account', fields: { name: { type: 'text' } } }],", + ' plugins: [{', + " name: 'com.example.os21054-host',", + " version: '1.0.0',", + ' init: async (ctx: any) => {', + " ctx.hook('kernel:ready', async () => { appendFileSync(LOG, 'host|kernel:ready\\n'); });", + " ctx.hook('kernel:bootstrapped', async () => {", + " appendFileSync(LOG, 'host|kernel:bootstrapped\\n');", + " try { await ctx.getService('objectql').find('sys_position', { where: { name: 'x' }, limit: 1, context: SYS }); } catch { /* answered */ }", + ' });', + ' },', + ' }],', + '};', + '', + '// The app-crm shape: a named `onEnable` beside the default-exported stack.', + 'export const onEnable = async (ctx: any) => {', + " appendFileSync(LOG, 'app|onEnable\\n');", + " ctx.hook('kernel:bootstrapped', async () => {", + " appendFileSync(LOG, 'app|kernel:bootstrapped\\n');", + ` for (const object of ${JSON.stringify(PROBE_TABLES)}) {`, + " try { await ctx.ql.find(object, { where: { name: 'x' }, limit: 1, context: SYS }); } catch { /* answered */ }", + ' }', + ' });', + '};', + '', + ].join('\n'), + ); + + // `os migrate apply`, as the command runs it: boot deferred, then flush + // the confirmed DDL. Its results are the control asserted below. + const apply = await bootSchemaStack({ + jsonOutput: false, + databaseUrl: `file:${migratedDb}`, + deferSchemaDdl: true, + composeHostStack: true, + projectRoot: dir, + }); + try { + applyFlushed = (await apply.flushSchemaDdl()).map((p) => ({ table: p.table, kind: p.kind })); + applyExamined = apply.composition.coverage?.examinedObjects ?? -1; + } finally { + await apply.shutdown(); + } + writeFileSync(hookLog, ''); + }, 120_000); + + afterAll(() => { + if (savedEnv.NODE_ENV === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = savedEnv.NODE_ENV; + if (savedEnv.OS_ARTIFACT_PATH === undefined) delete process.env.OS_ARTIFACT_PATH; + else process.env.OS_ARTIFACT_PATH = savedEnv.OS_ARTIFACT_PATH; + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } + }); + + it('POSITIVE CONTROL: the same code, composed as a served boot composes it, runs both hooks and prints the lines', async () => { + const { loadConfig } = await import('./config.js'); + const { AppPlugin } = await import('@objectstack/runtime'); + const { config } = await loadConfig(join(dir, 'objectstack.config.ts')); + + writeFileSync(hookLog, ''); + const captured = captureDatabaseErrors(); + const stack = await bootSchemaStack({ + jsonOutput: false, + databaseUrl: `file:${migratedDb}`, + deferSchemaDdl: true, + readOnlyProbe: true, + // No declaration composition: the host plugin and the app as `serve` + // composes them, so this leg proves the fixture can print the lines. + composeHostStack: false, + extraPlugins: [...config.plugins, new AppPlugin(config, undefined, { skipSeedData: true })], + projectRoot: dir, + }); + try { + const log = readFileSync(hookLog, 'utf8'); + expect(log).toContain('app|onEnable'); + expect(log).toContain('app|kernel:bootstrapped'); + expect(log).toContain('host|kernel:bootstrapped'); + for (const table of PROBE_TABLES) { + expect(captured.lines.some((l) => l.includes(`'${table}'`))).toBe(true); + } + } finally { + captured.restore(); + await stack.shutdown(); + } + }, 60_000); + + it('CONTROL: apply\'s confirmed work after the boot is unchanged — the flush creates the app\'s table, the coverage pass examines it', () => { + expect(applyFlushed).toContainEqual({ table: 'os21054_account', kind: 'create_table' }); + expect(applyExamined).toBeGreaterThan(0); + }); + + it('THE FIX, on the migrated file: zero DATABASE_ERROR lines, neither hook runs, kernel:ready still does', async () => { + writeFileSync(hookLog, ''); + const captured = captureDatabaseErrors(); + const stack = await bootPlan(migratedDb); + try { + expect(captured.lines).toEqual([]); + + const log = readFileSync(hookLog, 'utf8'); + expect(log).not.toContain('app|onEnable'); + expect(log).not.toContain('app|kernel:bootstrapped'); + expect(log).not.toContain('host|kernel:bootstrapped'); + expect(log).toContain('host|kernel:ready'); + + // The plan itself: everything apply created is there, nothing pending. + expect(stack.pendingSchemaWork).toEqual([]); + expect(await stack.driver!.detectManagedDrift()).toHaveLength(0); + + // And it says what it did not run. + expect(stack.composition.notes.join(' ')).toContain( + 'did not execute runtime.onEnable of plugin.app.com.example.os21054, and did not register ' + + '1 host hook(s) on post-declaration phases (com.example.os21054-host on kernel:bootstrapped)', + ); + } finally { + captured.restore(); + await stack.shutdown(); + } + }, 60_000); + + it('THE FIX, on an absent file: zero DATABASE_ERROR lines, neither hook runs, and no file is left behind', async () => { + const absent = join(dir, 'absent.db'); + writeFileSync(hookLog, ''); + const captured = captureDatabaseErrors(); + const stack = await bootPlan(absent); + try { + expect(captured.lines).toEqual([]); + const log = readFileSync(hookLog, 'utf8'); + expect(log).not.toContain('app|onEnable'); + expect(log).not.toContain('host|kernel:bootstrapped'); + expect(stack.pendingSchemaWork.map((p) => p.table)).toContain('os21054_account'); + } finally { + captured.restore(); + await stack.shutdown(); + } + expect(existsSync(absent)).toBe(false); + }, 60_000); +}); diff --git a/packages/cli/src/utils/schema-migration-plugins.declaration-boot-write-guard.test.ts b/packages/cli/src/utils/schema-migration-plugins.declaration-boot-write-guard.test.ts index 4594cfc9a15..d43625537e1 100644 --- a/packages/cli/src/utils/schema-migration-plugins.declaration-boot-write-guard.test.ts +++ b/packages/cli/src/utils/schema-migration-plugins.declaration-boot-write-guard.test.ts @@ -7,6 +7,7 @@ import { ObjectQL } from '@objectstack/objectql'; import type { IDataDriver } from '@objectstack/spec/contracts'; import { composeForDeclarations, + createDeclarationBootLifecycle, createDeclarationBootWriteGuard, } from './schema-migration-plugins.js'; @@ -35,6 +36,15 @@ import { * - the declaration boot then refuses every one of those writes at the driver * — while the log-only hooks the same plugin registered still run, which is * the property this shape was chosen for over neutralising `init()` hooks. + * + * #21054 narrowed which of a HOST plugin's hooks reach those phases at all: + * `composeForDeclarations` no longer registers its `kernel:bootstrapped` / + * `kernel:listening` hooks (the phases the kernel contract defines as + * post-declaration work), so a host hook still runs — and still meets the + * guard — only on `kernel:ready`. The guard's own phase-agnostic property is + * pinned with a writer the composition does NOT wrap, which is what this + * repo's own plugins are. The lifecycle half has its own block at the foot of + * this file. */ /** The row-write members of the data-driver contract, as the fixture exercises them. */ @@ -218,11 +228,13 @@ describe('the declaration boot writes nothing (#13332)', () => { expect(log.ran).toContain('write:kernel:listening'); }); - it('THE DEFECT: suppressing start() alone leaves all three phases writing', async () => { - // The state of the world before this card: `composeForDeclarations` and - // nothing else. `start()`'s seed is gone; the three `init()`-registered - // hooks are untouched. This is the shape the guarantee was measured - // against, so it is pinned rather than described. + it('THE DEFECT: the declaration composition alone still leaves kernel:ready writing', async () => { + // `composeForDeclarations` and nothing else. `start()`'s seed is gone and + // (#21054) the post-declaration hooks were never registered — but the + // `init()`-registered `kernel:ready` hook is untouched: the contract leaves + // late registration on that phase, so the composition cannot withhold it. + // This is the shape the guarantee was measured against, so it is pinned + // rather than described. const driver = new RecordingDriver(); const log: HookLog = { ran: [] }; const kernel = newKernel(); @@ -233,14 +245,11 @@ describe('the declaration boot writes nothing (#13332)', () => { await kernel.shutdown(); expect(log.ran).not.toContain('write:start'); - expect(driver.writes.map((w) => w.object)).toEqual([ - 'sys_ai_model', - 'sys_ai_model', - 'sys_ai_model', - ]); + expect(driver.writes.map((w) => w.object)).toEqual(['sys_ai_model']); + expect(log.ran).toEqual(['log-only:kernel:ready', 'write:kernel:ready']); }); - it('THE FIX: the guard refuses every one of them, and the log-only hooks still run', async () => { + it('THE FIX: the guard refuses it, and the log-only hook still runs', async () => { const driver = new RecordingDriver(); const log: HookLog = { ran: [] }; const guard = createDeclarationBootWriteGuard(); @@ -256,10 +265,45 @@ describe('the declaration boot writes nothing (#13332)', () => { // The whole point: nothing reached the driver. expect(driver.writes).toEqual([]); - // …and the hooks themselves still RAN. This is what separates suppressing - // the WRITE from neutralising the HOOK: a read/log-only hook keeps working - // on the path an operator reads before a production apply. + // …and the `kernel:ready` hooks themselves still RAN. This is what + // separates suppressing the WRITE from neutralising the HOOK: a + // read/log-only hook on the phase declaring happens in keeps working on + // the path an operator reads before a production apply. The + // post-declaration phases are not this case's — see the #21054 block. + expect(log.ran).toEqual([ + 'log-only:kernel:ready', + 'write:kernel:ready', + ]); + + // The refusal is reported rather than swallowed — and with no raw + // execute() forwarded this run, the outcome claim HELD and is printed. + const note = guard.disarm(); + expect(note).toContain('Refused 1 write(s)'); + expect(note).toContain('a plan writes nothing'); + expect(note).toContain('create() on sys_ai_model'); + + await kernel.shutdown(); + }); + + it('the guard is PHASE-AGNOSTIC: a writer the composition does not wrap is refused on every phase and in start()', async () => { + // The guard sits at the driver, not at a list of phases. A plugin this repo + // composes itself is not wrapped by `composeForDeclarations` — its + // `start()` and every hook it registers run — and its writes still never + // land, on all three phases, which is the property #13332 chose the driver + // seam for. + const driver = new RecordingDriver(); + const log: HookLog = { ran: [] }; + const guard = createDeclarationBootWriteGuard(); + const kernel = newKernel(); + + await kernel.use(datasourcePlugin(driver)); + await kernel.use(guard.plugin as Plugin); + await kernel.use(writingHostPlugin(log)); + await kernel.bootstrap(); + + expect(driver.writes).toEqual([]); expect(log.ran).toEqual([ + 'write:start', 'log-only:kernel:ready', 'write:kernel:ready', 'log-only:kernel:bootstrapped', @@ -267,12 +311,9 @@ describe('the declaration boot writes nothing (#13332)', () => { 'log-only:kernel:listening', 'write:kernel:listening', ]); - - // The refusal is reported, per phase, rather than swallowed — and with no - // raw execute() forwarded this run, the outcome claim HELD and is printed. const note = guard.disarm(); - expect(note).toContain('Refused 3 write(s)'); - expect(note).toContain('a plan writes nothing'); + expect(note).toContain('Refused 4 write(s)'); + expect(note).toContain('create() on sys_permission_set'); expect(note).toContain('create() on sys_ai_model x3'); await kernel.shutdown(); @@ -341,7 +382,9 @@ describe('the declaration boot writes nothing (#13332)', () => { name: 'com.example.exercises-the-contract', version: '1.0.0', init: async (ctx: PluginContext) => { - ctx.hook('kernel:bootstrapped', async () => { + // `kernel:ready`: the phase a host hook still reaches on a declaration + // boot (#21054 withholds the post-declaration ones at registration). + ctx.hook('kernel:ready', async () => { const d = ctx.getService('driver.recording'); await d.create('t', {}); await d.update('t', 'id1', {}); @@ -647,7 +690,8 @@ describe('the two named boundaries (#14126)', () => { name: 'com.example.writes-to-archive', version: '1.0.0', init: async (ctx: PluginContext) => { - ctx.hook('kernel:bootstrapped', async () => { + // `kernel:ready` — see 'covers the whole row-write contract' for why. + ctx.hook('kernel:ready', async () => { const ql = ctx.getService('objectql'); await ql.getDriverForObject('archive_row')!.update('archive_row', 'id1', { name: 'closed' }); }); @@ -793,7 +837,8 @@ describe('the two named boundaries (#14126)', () => { name: 'com.example.only-drops', version: '1.0.0', init: async (ctx: PluginContext) => { - ctx.hook('kernel:listening', async () => { + // `kernel:ready` — see 'covers the whole row-write contract' for why. + ctx.hook('kernel:ready', async () => { await ctx.getService('driver.recording').dropTable('sys_old_table'); }); }, @@ -901,7 +946,7 @@ describe('the two named boundaries (#14126)', () => { await kernel.shutdown(); }); - it('POSITIVE CONTROL — an embedder with no data plane and read/log-only hooks: nothing to arm, nothing to report, hooks untouched', async () => { + it('POSITIVE CONTROL — an embedder with no data plane and read/log-only hooks: nothing to arm, nothing to report, kernel:ready hooks untouched', async () => { const guard = createDeclarationBootWriteGuard(); const kernel = newKernel(); const log: HookLog = { ran: [] }; @@ -925,9 +970,124 @@ describe('the two named boundaries (#14126)', () => { expect(guard.refusals).toEqual([]); expect(guard.rawExecutions).toEqual([]); expect(guard.immediateDdl).toEqual([]); - expect(log.ran).toEqual(['log-only:kernel:ready', 'log-only:kernel:bootstrapped', 'log-only:kernel:listening']); + // The post-declaration hooks are withheld at registration (#21054); the + // `kernel:ready` one runs, and the guard has nothing to say about any of it. + expect(log.ran).toEqual(['log-only:kernel:ready']); // No note at all: a quiet boot renders byte-identically to before any of this existed. expect(guard.disarm()).toBeNull(); await kernel.shutdown(); }); }); + +/** + * #21054 — on a declaration boot, host code does not run where the kernel + * contract says declaring is over. + * + * `examples/app-crm`'s `onEnable` hooks `kernel:bootstrapped` and READS + * `sys_position` / `sys_permission_set` — tables the plan's composition never + * declares — so every plan printed six `DATABASE_ERROR` lines. The guard above + * cannot help: it refuses writes and lets the hook run. So the composition + * does not register a host plugin's `kernel:bootstrapped` / `kernel:listening` + * hooks (and the config's `onEnable` is withheld by its AppPlugin, pinned in + * `@objectstack/runtime` and in `schema-migration-plugins.test.ts`). + * + * Real `ObjectKernel`, phases really fired. The POSITIVE CONTROL first: the + * same host plugin, composed as a served boot composes it, runs on all three. + */ +describe('host hooks on the post-declaration phases are not fired (#21054)', () => { + /** A host plugin that READS from each phase — the app-crm shape — and logs it. */ + function readingHostPlugin(log: HookLog, name = 'com.example.reads-from-init'): Plugin { + return { + name, + version: '1.0.0', + init: async (ctx: PluginContext) => { + for (const phase of ['kernel:ready', 'kernel:bootstrapped', 'kernel:listening'] as const) { + ctx.hook(phase, async () => { + await ctx.getService('driver.recording').find('sys_position'); + log.ran.push(`${name}|read:${phase}`); + }); + } + ctx.hook('kernel:shutdown', async () => { log.ran.push(`${name}|shutdown`); }); + }, + }; + } + + it('POSITIVE CONTROL: a served composition fires the host hooks on all three phases', async () => { + const driver = new RecordingDriver(); + const log: HookLog = { ran: [] }; + const kernel = newKernel(); + + await kernel.use(datasourcePlugin(driver)); + await kernel.use(readingHostPlugin(log)); + await kernel.bootstrap(); + + expect(log.ran).toEqual([ + 'com.example.reads-from-init|read:kernel:ready', + 'com.example.reads-from-init|read:kernel:bootstrapped', + 'com.example.reads-from-init|read:kernel:listening', + ]); + await kernel.shutdown(); + }); + + it('THE FIX: kernel:bootstrapped / kernel:listening host hooks are not fired; kernel:ready and the teardown are; the platform\'s own hooks are untouched', async () => { + const driver = new RecordingDriver(); + const log: HookLog = { ran: [] }; + const guard = createDeclarationBootWriteGuard(); + const lifecycle = createDeclarationBootLifecycle(); + const kernel = newKernel(); + + await kernel.use(datasourcePlugin(driver)); + await kernel.use(guard.plugin as Plugin); + await kernel.use(composeForDeclarations(readingHostPlugin(log), lifecycle)); + // The in-run control: a plugin this repo composes itself (not host code), + // on the very same boot, keeps every phase. + await kernel.use(readingHostPlugin(log, 'com.objectstack.platform-probe')); + await kernel.bootstrap(); + + expect(log.ran).toEqual([ + 'com.example.reads-from-init|read:kernel:ready', + 'com.objectstack.platform-probe|read:kernel:ready', + 'com.objectstack.platform-probe|read:kernel:bootstrapped', + 'com.objectstack.platform-probe|read:kernel:listening', + ]); + expect(lifecycle.withheldHooks).toEqual([ + { plugin: 'com.example.reads-from-init', phase: 'kernel:bootstrapped', count: 1 }, + { plugin: 'com.example.reads-from-init', phase: 'kernel:listening', count: 1 }, + ]); + expect(lifecycle.describe()).toContain('did not register 2 host hook(s) on post-declaration phases'); + // Nothing tried to write, so the guard stays quiet. + expect(guard.disarm()).toBeNull(); + + // `kernel:shutdown` is the teardown of what init() opened — still registered. + await kernel.shutdown(); + expect(log.ran).toContain('com.example.reads-from-init|shutdown'); + }); + + it('a host that keeps its init() context and registers LATER is still declined', async () => { + const driver = new RecordingDriver(); + const log: HookLog = { ran: [] }; + const lifecycle = createDeclarationBootLifecycle(); + const kernel = newKernel(); + + const deferredRegistrar: Plugin = { + name: 'com.example.registers-from-ready', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.hook('kernel:ready', async () => { + log.ran.push('ready'); + ctx.hook('kernel:bootstrapped', async () => { log.ran.push('bootstrapped'); }); + }); + }, + }; + + await kernel.use(datasourcePlugin(driver)); + await kernel.use(composeForDeclarations(deferredRegistrar, lifecycle)); + await kernel.bootstrap(); + + expect(log.ran).toEqual(['ready']); + expect(lifecycle.withheldHooks).toEqual([ + { plugin: 'com.example.registers-from-ready', phase: 'kernel:bootstrapped', count: 1 }, + ]); + await kernel.shutdown(); + }); +}); diff --git a/packages/cli/src/utils/schema-migration-plugins.test.ts b/packages/cli/src/utils/schema-migration-plugins.test.ts index b52336ea82a..35f5b0f745e 100644 --- a/packages/cli/src/utils/schema-migration-plugins.test.ts +++ b/packages/cli/src/utils/schema-migration-plugins.test.ts @@ -7,6 +7,7 @@ import { join } from 'node:path'; import { findHostConfig, composeForDeclarations, + createDeclarationBootLifecycle, buildSchemaMigrationPlugins, measureComposedCoverage, describeUnloadableHostConfig, @@ -121,6 +122,57 @@ describe('composeForDeclarations', () => { } expect(composeForDeclarations(new Private()).read()).toBe('kept'); }); + + // #21054 — host code does not run where the kernel contract says declaring + // is over. The kernel-level half (real ObjectKernel, phases actually fired) + // is in `schema-migration-plugins.declaration-boot-write-guard.test.ts`. + it('hands init() a context that withholds kernel:bootstrapped / kernel:listening and forwards everything else', async () => { + const registered: string[] = []; + const kernelCtx = { + hook: (name: string) => { registered.push(name); }, + getService: (name: string) => `service:${name}`, + }; + let seen: any; + const host = { + name: 'com.example.hooks-from-init', + async init(ctx: any): Promise { + seen = ctx; + for (const phase of ['kernel:ready', 'kernel:bootstrapped', 'kernel:listening', 'kernel:shutdown', 'data:beforeInsert']) { + ctx.hook(phase, async () => { /* never run here */ }); + } + }, + }; + const lifecycle = createDeclarationBootLifecycle(); + + await composeForDeclarations(host, lifecycle).init(kernelCtx as any); + + // Registration is the phase where declaring happens (`kernel:ready`), the + // teardown of what init() opened, and anything that is not a boot phase. + expect(registered).toEqual(['kernel:ready', 'kernel:shutdown', 'data:beforeInsert']); + // Every other member is the kernel's own. + expect(seen.getService('objectql')).toBe('service:objectql'); + expect(lifecycle.withheldHooks).toEqual([ + { plugin: 'com.example.hooks-from-init', phase: 'kernel:bootstrapped', count: 1 }, + { plugin: 'com.example.hooks-from-init', phase: 'kernel:listening', count: 1 }, + ]); + expect(lifecycle.describe()).toContain( + 'did not register 2 host hook(s) on post-declaration phases ' + + '(com.example.hooks-from-init on kernel:bootstrapped, com.example.hooks-from-init on kernel:listening)', + ); + }); + + it('withholds them with no lifecycle to record into, and a quiet boot describes nothing', async () => { + const registered: string[] = []; + const host = { + name: 'com.example.quiet', + async init(ctx: any): Promise { ctx.hook('kernel:bootstrapped', async () => {}); }, + }; + await composeForDeclarations(host).init({ hook: (n: string) => { registered.push(n); } } as any); + expect(registered).toEqual([]); + + // Nothing withheld ⇒ no note, so a host with no such hook renders as before. + expect(createDeclarationBootLifecycle().describe()).toBeNull(); + }); }); describe('buildSchemaMigrationPlugins', () => { @@ -232,6 +284,45 @@ describe('buildSchemaMigrationPlugins', () => { // Loading a real config runs `bundle-require`/esbuild — well past the 5 s // default on a cold, shared box. }, 60_000); + + it('composes the config\'s app WITHOUT its onEnable, and the lifecycle names it (#21054)', async () => { + // `examples/app-crm`'s shape: a stack with metadata, and a named + // `onEnable` export beside it. The AppPlugin this composition builds is + // the one door that hook reaches a declaration boot through. + const dir = tempProject(); + const flag = `__os21054OnEnableRan_${Date.now()}`; + writeFileSync( + join(dir, 'objectstack.config.ts'), + [ + 'export default {', + " manifest: { id: 'com.example.os21054unit', name: 'onEnable withheld', version: '0.0.0', type: 'app' },", + " objects: [{ name: 'os21054_thing', fields: { name: { type: 'text' } } }],", + '};', + `export const onEnable = async () => { (globalThis as any)[${JSON.stringify(flag)}] = true; };`, + '', + ].join('\n'), + ); + + const out = await buildSchemaMigrationPlugins({ basePlugins: [], cwd: dir, skipSeedData: true }); + const app = out.plugins.find((p: any) => p?.name === 'plugin.app.com.example.os21054unit') as any; + expect(app, 'the config-derived AppPlugin must be composed').toBeDefined(); + + // Drive its Phase 2 the way the kernel would, over a minimal context. + const noop = () => { /* logged */ }; + await app.start({ + getService: () => ({ registry: {} }), + hook: noop, + trigger: async () => { /* none */ }, + logger: { info: noop, warn: noop, error: noop, debug: noop }, + }); + + expect((globalThis as any)[flag]).toBeUndefined(); + expect(app.onEnableWithheld).toBe(true); + expect(out.lifecycle?.withheldOnEnable).toEqual(['plugin.app.com.example.os21054unit']); + expect(out.lifecycle?.describe()).toContain( + 'did not execute runtime.onEnable of plugin.app.com.example.os21054unit', + ); + }, 60_000); }); /** diff --git a/packages/runtime/src/app-plugin.test.ts b/packages/runtime/src/app-plugin.test.ts index df575c7a126..cd393a76eed 100644 --- a/packages/runtime/src/app-plugin.test.ts +++ b/packages/runtime/src/app-plugin.test.ts @@ -99,6 +99,64 @@ describe('AppPlugin', () => { // Check context passed to onEnable const callArg = onEnableSpy.mock.calls[0][0]; expect(callArg.ql).toBe(mockQL); + // Nothing was withheld on a boot that did not ask for it. + expect(plugin.onEnableWithheld).toBe(false); + }); + + // [#21054] A declaration boot (`os migrate plan` / `apply`) composes the + // app for what it declares; its `onEnable` is not executed, and the boot + // says so instead of reading as an app without one. + describe('skipOnEnable', () => { + it('withholds onEnable, logs that it did, and reports it', async () => { + const onEnableSpy = vi.fn(); + const plugin = new AppPlugin( + { id: 'com.test.declaring', onEnable: onEnableSpy }, + undefined, + { skipOnEnable: true }, + ); + vi.mocked(mockContext.getService).mockReturnValue({ registry: {} }); + + await plugin.start!(mockContext); + + expect(onEnableSpy).not.toHaveBeenCalled(); + expect(plugin.onEnableWithheld).toBe(true); + expect(mockContext.logger.info).toHaveBeenCalledWith( + expect.stringContaining('runtime.onEnable NOT executed'), + expect.objectContaining({ appId: 'com.test.declaring' }), + ); + expect(mockContext.logger.info).not.toHaveBeenCalledWith( + 'Executing runtime.onEnable', + expect.anything(), + ); + }); + + it('withholds the hook wherever the executor resolves it — `bundle.default` included', async () => { + const onEnableSpy = vi.fn(); + const plugin = new AppPlugin( + { id: 'com.test.module', default: { onEnable: onEnableSpy } }, + undefined, + { skipOnEnable: true }, + ); + vi.mocked(mockContext.getService).mockReturnValue({ registry: {} }); + + await plugin.start!(mockContext); + + expect(onEnableSpy).not.toHaveBeenCalled(); + expect(plugin.onEnableWithheld).toBe(true); + }); + + it('reports nothing withheld for a bundle that carries no onEnable', async () => { + const plugin = new AppPlugin({ id: 'com.test.static' }, undefined, { skipOnEnable: true }); + vi.mocked(mockContext.getService).mockReturnValue({ registry: {} }); + + await plugin.start!(mockContext); + + expect(plugin.onEnableWithheld).toBe(false); + expect(mockContext.logger.debug).toHaveBeenCalledWith( + 'No runtime.onEnable function found', + expect.anything(), + ); + }); }); it('start should warn if objectql not found', async () => { From 378171363128d7fd62867c4889c1503c860fbb43 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 07:52:21 +0000 Subject: [PATCH 3/6] chore(changeset): cli + runtime patch for the declaration boot's app hooks Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- .changeset/21054-plan-runs-no-app-hooks.md | 32 ++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .changeset/21054-plan-runs-no-app-hooks.md diff --git a/.changeset/21054-plan-runs-no-app-hooks.md b/.changeset/21054-plan-runs-no-app-hooks.md new file mode 100644 index 00000000000..09fdfd406c4 --- /dev/null +++ b/.changeset/21054-plan-runs-no-app-hooks.md @@ -0,0 +1,32 @@ +--- +'@objectstack/cli': patch +'@objectstack/runtime': patch +--- + +fix(cli): `os migrate plan` / `apply` no longer run the app's `onEnable` or a host plugin's post-declaration hooks during their boot + +Clause-②: no + +The two schema commands boot the host's stack to read what it declares. That boot ran the +config's `onEnable`, and every `kernel:bootstrapped` / `kernel:listening` hook a host plugin +registered from `init()`. A hook that reads a table the plan does not declare then failed on +every plan. On `examples/app-crm`, whose `onEnable` binds positions to permission sets, each +plan printed six `[sql-driver] DATABASE_ERROR` lines and six `position binding lookup failed` +warnings, on a database `apply` had just migrated as well as on an absent one. + +The boot now composes host code for its declarations only: + +- `AppPlugin` takes a new `skipOnEnable` option. When it is set, `start()` does not run the + bundle's `onEnable`, logs that it withheld it, and reports it through `onEnableWithheld`. The + migrate commands set it on the app they compose from `objectstack.config.ts`. +- A host plugin's `init()` gets a context that does not register `kernel:bootstrapped` or + `kernel:listening` hooks. The kernel contract defines those phases as work after registration + ends: reconcile/backfill, and opening listeners. `kernel:ready` hooks still run, and the + write guard still refuses their row writes. `kernel:shutdown` hooks and data hooks register + as before. +- The plan's notes, and the `--json` payload's `composition.notes`, carry one line naming what + was not run. + +The plan itself is unchanged: the same tables, the same pending DDL, the same drift. `apply` +still flushes the DDL the operator confirms and still runs the coverage pass. The platform's own +plugins are untouched, so the value-shape gate announcement still prints. From 6d4ef7c9aa87a237917fc433d3d2688742c0dbc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 08:49:36 +0000 Subject: [PATCH 4/6] test(cli): load the runtime and the config loader at module top in the 21054 pin check:test-source-alias: a dynamic import of an unaliased dependency inside a test body pays its first transform inside a clocked window. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- .../schema-migrate.host-composition.integration.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/utils/schema-migrate.host-composition.integration.test.ts b/packages/cli/src/utils/schema-migrate.host-composition.integration.test.ts index 0d8395f13b9..2009737a968 100644 --- a/packages/cli/src/utils/schema-migrate.host-composition.integration.test.ts +++ b/packages/cli/src/utils/schema-migrate.host-composition.integration.test.ts @@ -5,6 +5,10 @@ import { existsSync, mkdtempSync, mkdirSync, writeFileSync, appendFileSync, read import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; +// Loaded at module top, so the first transform is paid during collection +// rather than inside a clocked test body (`check:test-source-alias`). +import { AppPlugin } from '@objectstack/runtime'; +import { loadConfig } from './config.js'; import { bootSchemaStack } from './schema-migrate.js'; /** @@ -939,8 +943,6 @@ describe('a plan runs no app lifecycle hook (#21054)', () => { }); it('POSITIVE CONTROL: the same code, composed as a served boot composes it, runs both hooks and prints the lines', async () => { - const { loadConfig } = await import('./config.js'); - const { AppPlugin } = await import('@objectstack/runtime'); const { config } = await loadConfig(join(dir, 'objectstack.config.ts')); writeFileSync(hookLog, ''); From dc1c40ec390c5d9ec795ca2fd2e1bec1b556819b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 09:00:08 +0000 Subject: [PATCH 5/6] docs(cli): quote the kernel:bootstrapped contract wording as written Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- packages/cli/src/utils/schema-migration-plugins.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/utils/schema-migration-plugins.ts b/packages/cli/src/utils/schema-migration-plugins.ts index 2f397895b35..c6f8722f373 100644 --- a/packages/cli/src/utils/schema-migration-plugins.ts +++ b/packages/cli/src/utils/schema-migration-plugins.ts @@ -240,7 +240,7 @@ async function suppressedStart(): Promise { * Read off `IPluginLifecycleEvents` (`packages/spec/src/contracts/` * `plugin-lifecycle-events.ts`), not off a survey of what hosts do there: * - * - `kernel:bootstrapped` is "the all synchronous bootstrap has settled" + * - `kernel:bootstrapped` is the "all synchronous bootstrap has settled" * anchor, for "reconcile/backfill work that consumes" the data a * `kernel:ready` handler produced — reads and writes of rows, never a * declaration; From afc44ba5bf59bd98f4a78050e48b751cb5f03835 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 09:44:51 +0000 Subject: [PATCH 6/6] =?UTF-8?q?chore(changeset):=20grade=20the=20runtime?= =?UTF-8?q?=20widening=20minor,=20Clause-=E2=91=A1=20yes=20(widening)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppPlugin, exported from @objectstack/runtime's root, gains the optional skipOnEnable constructor option and the onEnableWithheld getter: an additive widening of a published surface, which takes at least minor. Contract review record 5928867906. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- .changeset/21054-plan-runs-no-app-hooks.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.changeset/21054-plan-runs-no-app-hooks.md b/.changeset/21054-plan-runs-no-app-hooks.md index 09fdfd406c4..f7dc8213d3e 100644 --- a/.changeset/21054-plan-runs-no-app-hooks.md +++ b/.changeset/21054-plan-runs-no-app-hooks.md @@ -1,11 +1,11 @@ --- '@objectstack/cli': patch -'@objectstack/runtime': patch +'@objectstack/runtime': minor --- fix(cli): `os migrate plan` / `apply` no longer run the app's `onEnable` or a host plugin's post-declaration hooks during their boot -Clause-②: no +Clause-②: yes (widening) The two schema commands boot the host's stack to read what it declares. That boot ran the config's `onEnable`, and every `kernel:bootstrapped` / `kernel:listening` hook a host plugin @@ -30,3 +30,8 @@ The boot now composes host code for its declarations only: The plan itself is unchanged: the same tables, the same pending DDL, the same drift. `apply` still flushes the DDL the operator confirms and still runs the coverage pass. The platform's own plugins are untouched, so the value-shape gate announcement still prints. + +`@objectstack/runtime` widens its public surface, additively: `AppPlugin`, exported from the +package root, gains the optional constructor option `skipOnEnable` (default `false`) and the +read-only getter `onEnableWithheld`. A composition that does not pass the option gets exactly +the behaviour it had, `onEnable` included.