Skip to content

Commit f20f669

Browse files
fix(cli): os migrate plan / apply run no app onEnable and no post-declaration host hooks (#21138)
Fixes #21054 Clause-②: yes (widening) ## What was wrong `os migrate plan` and `os migrate apply` boot the host's stack to read what it declares. That boot also ran host code that has nothing to do with declarations: - the config's `onEnable`, which runs from the `AppPlugin` the migrate composition builds out of `objectstack.config.ts`. That `AppPlugin` is not wrapped by `composeForDeclarations`. - every `kernel:bootstrapped` / `kernel:listening` hook that a host plugin registers from `init()`. `composeForDeclarations` suppressed `start()` and nothing else. `examples/app-crm`'s `onEnable` hooks `kernel:bootstrapped` and reads `sys_position` / `sys_permission_set`. The plan's composition never declares those tables. The write guard from the earlier write-suppression work refuses writes and lets hooks run, so it cannot stop a read. Every plan therefore printed 6 `DATABASE_ERROR` lines on stderr and 6 `position binding lookup failed` warnings, on a migrated file and on an absent one. ## The fix: one point per door, for every app Host code enters a declaration boot through exactly two doors, and each is closed where it enters: 1. **The config's `onEnable`.** `AppPlugin` gets a `skipOnEnable` option, a sibling of the existing `skipSeedData`, which serves the same commands. With it set, `start()` does not run `onEnable`. It logs `runtime.onEnable NOT executed …` and reports the skip through `onEnableWithheld`. The migrate composition sets the option on the app it builds. - The option lives in the executor (`packages/runtime/src/app-plugin.ts`) and not in a stripped copy of the bundle. The executor is what resolves which object carries the hook (`bundle.default` before the bundle itself). A copy would re-state that rule at the CLI call site. The boot would then also log "No runtime.onEnable function found" about an app that has one. - A compiled artifact cannot carry `onEnable` at all: it is JSON, and its runtime module contributes `functions` only. A host plugin that is itself an `AppPlugin` is already wrapped, so its whole `start()` is suppressed. 2. **A host plugin's `init()`.** `composeForDeclarations` now forwards `init` with a context whose `hook()` does not register `kernel:bootstrapped` or `kernel:listening`. A host that keeps that context and registers later is declined too. - The two phases come from the kernel contract (`IPluginLifecycleEvents`). `kernel:bootstrapped` is for "reconcile/backfill work that consumes" data. `kernel:listening` comes after every plugin "has had a chance to register routes / services / middleware during `kernel:ready`". Both say that registration is over. - `kernel:ready` is deliberately kept. The contract puts late registration there, and a host that provisions its tables from a `kernel:ready` hook is a measured shape whose tables the plan must see. The write guard still refuses row writes on `kernel:ready`. - `kernel:shutdown` hooks, data hooks and custom events register as before. This repo's own plugins (the data stack, `PlatformObjectsPlugin`, the guard, and `extraPlugins`) are not host code and are untouched. The plan still prints the value-shape gate announcement that the engine makes from its own `kernel:bootstrapped` hook. No in-repo plugin registers a post-declaration hook from `init()`: all seven sites are in `start()`. The plan's notes, and `composition.notes` in `--json`, carry one line naming what was not run. A host with nothing withheld gets no line. **Stop clause (does the plan need an app hook for its declarations?)** No. On app-crm, the table list, the pending DDL, the drift and `--json` (all but `notes`) are identical before and after; see below. ### The earlier design, and how this changes it The write-suppression card chose to refuse writes at the driver over neutralising `init()`-registered hooks. One reason was that a log-only hook should keep running on the plan path. Triage's direction on this card (comment 5924795251) sets the boundary more narrowly: the declaration boot does not fire app `onEnable` / `kernel:bootstrapped` hooks. The guard stays the write guarantee on every phase. Only the two post-declaration phases are now withheld for host code. The existing pins that asserted host log-only hooks run on those two phases were inverted in place, and their writers moved to `kernel:ready` where the case was about the guard rather than the phase. A new pin keeps the guard's phase-agnostic property: a writer the composition does not wrap is refused on all three phases and in `start()`. ## Release grading `@objectstack/runtime` takes `minor`, and this PR declares `Clause-②: yes (widening)`. `AppPlugin`, exported from the package root, gains the optional constructor option `skipOnEnable` (default `false`) and the read-only getter `onEnableWithheld`. That is an additive widening of a published surface, which takes at least `minor`. `@objectstack/cli` stays `patch`. This was re-graded from `patch` / `Clause-②: no` after the contract review record 5928867906, in the changeset-only commit `afc44ba5bf`. ## Measured on `examples/app-crm` `node packages/cli/bin/run.js migrate …` from `examples/app-crm`, with no `dist/` artifact. Base is `origin/main` `9c8b65aa23`, built. Fix is `af9ac5ded3`, with runtime and cli rebuilt. | run | base: `DATABASE_ERROR` (stderr) | base: `position binding lookup failed` | base: onEnable executed | fix: `DATABASE_ERROR` | fix: lookup failed | fix: onEnable executed | |---|---|---|---|---|---|---| | `plan` on an absent file | 6 | 6 | yes | 0 | 0 | no (withheld, logged) | | `plan --json`, absent file | 6 | 6 | yes | 0 | 0 | no | | `apply --yes` | 6 | 6 | yes | 0 | 0 | no | | `plan` on the migrated file | 6 | 6 | yes | 0 | 0 | no | | `plan --json`, migrated file | 6 | 6 | yes | 0 | 0 | no | - On the base, stderr carries those 6 errors plus 2 "Paged read … NOT deterministic" warns from the same hook: 8 lines. On the fix, stderr is empty on every run. - The plan output does not change. The non-log stdout differs by exactly one added notes line (`diff` shows 1 line added and 0 removed, for plan absent, plan migrated and apply). `--json` is identical except `composition.notes` (2 to 3 entries): `pending` 15/15 (absent) and 0/0 (migrated), `total` 0, `managedTables` 15. `Examined 15 managed table(s)` holds on both. The absent file is not created. ## Tests - `@objectstack/runtime` `src/app-plugin.test.ts`: the `skipOnEnable` pins. The hook is withheld, logged and reported, including when it sits on `bundle.default`. A bundle with no `onEnable` reports nothing withheld. - `@objectstack/cli` unit, `schema-migration-plugins.test.ts`: - the `init()` context declines the two phases and forwards `kernel:ready`, `kernel:shutdown`, data hooks and every other member; - the composed app carries `skipOnEnable`, its `onEnable` does not run, and the lifecycle names it. - `@objectstack/cli` integration, `schema-migration-plugins.declaration-boot-write-guard.test.ts`, using a real `ObjectKernel`: - the positive control fires all three phases; - the fix fires only `kernel:ready` for host code, keeps the teardown, and leaves an unwrapped platform plugin on all three phases in the same boot; - a host that registers later from `kernel:ready` is declined; - the existing write-guard pins were updated as described above. - `@objectstack/cli` integration, `schema-migrate.host-composition.integration.test.ts`, new block for this card. It uses an app-crm-shaped fixture: a stack with one object, a named `onEnable` that hooks `kernel:bootstrapped` and reads the two undeclared tables, and a host plugin with a reading `init()`-registered `kernel:bootstrapped` hook. - POSITIVE CONTROL: the same code composed as `serve` composes it prints the lines. - CONTROL: `apply`'s confirmed DDL flush still creates the app's table, and the coverage pass still examines it. - The plan on the migrated file prints 0 `DATABASE_ERROR`, runs neither hook, and still runs the host's `kernel:ready` hook. - The plan on an absent file prints 0 `DATABASE_ERROR` and leaves no file behind. Runs: - At `3781713631`: - runtime `vitest run --project local`: 297 files, 4252 passed, 5 skipped. - cli `--project unit`: 240 files, 3422 passed. - cli `--project integration` over the 11 migrate-related files: 59 passed. - After merging `origin/main`: - at `5bd79b1b3c`: runtime `app-plugin.test.ts` 35/35; cli unit file 32/32; write-guard, host-composition and `plan.deferred-reads` 36/36 (integration); `typecheck` (including `check:test-typecheck`) green for runtime and cli; - at `6d4ef7c9aa`: host-composition 14/14 and cli `typecheck` green, after the test-only fix that `check:test-source-alias` asked for; - the head `dc1c40ec39` differs from `6d4ef7c9aa` by one comment line. **Gates, at `dc1c40ec39`.** - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 64 commands. All 64 ran with their exit codes recorded before any pipe, and all 64 exited 0. `--ran` reports "64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN". - `check:dual-build-cjs-loads` and `check:i18n-coverage` measured, after the nine packages they read were built. - `check:test-source-alias` turned red on the first pass: a dynamic `import('@objectstack/runtime')` sat inside a test body. Moving it to module top made it green. **Gates, at `afc44ba5bf` (changeset-only commit).** The diff from `dc1c40ec39` is the one changeset file, so the code families keep their `dc1c40ec39` results. - The 19 families whose derivation names the changeset path, or that declare a whole-tree population, ran again: 19 of 19 exited 0. - `check-changeset-no-major` in event mode with this body: the level axis is green ("`@objectstack/runtime: minor` … the declared widening is accounted for"). The control, the same body against `dc1c40ec39` where runtime was `patch`, exits 1. - `--ran` over the fresh 19 plus the 45 carried from `dc1c40ec39`: "64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN". **Lint, a proven narrowing.** - Command: `eslint --no-inline-config --format json` over the 7 touched TypeScript files. - Result: 7 files, 0 errors, 0 warnings. - `--print-config` resolves a config for all 7, with 5 to 6 rules and no `parserOptions.project`. - The repo's `eslint.config.mjs` enables no type-aware linting, so this diff cannot move the verdict on any untouched file. The full `pnpm lint` is left to CI. **Ablations.** The fix was committed first. Each run went through `scripts/ablation-replace.mjs`: the anchor moved 1 to 0, the blob changed, and the restore brought the blob back equal to HEAD with an empty `git diff HEAD`. - A1: `POST_DECLARATION_PHASES` emptied in the CLI source. - Red: unit 2/32. Integration 9/34: the write-guard DEFECT, FIX and embedder cases, both #21054 kernel cases, the #13332 FIX and R1 cases, and both #21054 plan cases. The plan cases fail on `DATABASE_ERROR … 'sys_position'` from the host hook. - Green: every positive control, the phase-agnostic pin and the apply control. - A2: `skipOnEnable: true` changed to `false` at the composition. - Red: the unit compose case, and both #21054 plan cases (`DATABASE_ERROR` on `sys_position` and `sys_permission_set` from `onEnable`). - Green: everything else. - A3: the executor branch in `app-plugin.ts` neutralised with a planted marker. The runtime was rebuilt, and `ablation-dist-preflight` found the marker in 2 built files. - Red: runtime 2/35 (the two withhold cases), the cli unit compose case, and both #21054 plan cases. - Restore leg: rebuilt; `--absent` found the marker in none of the 6 built files and the tree clean. Everything was green again (35, 32, 34). ## Acceptance notes - **The pin uses an app-crm-shaped fixture, not `examples/app-crm` itself.** A cli test that reads another package's tree is a cross-package test input. Declaring it would mean editing `scripts/cross-package-test-inputs.mjs` and `turbo.json`, which are outside this card's file surface. The real app-crm is measured by the CLI runs in the table above. - **Residue, stated in the module header.** A host that registers a hook without the context its `init()` received is outside the composition's reach: through `getKernel()`, or from a service factory, which the kernel calls with its own context. Its writes still meet the guard. - **Residue: declarations on a post-declaration phase.** A host that declares objects from a `kernel:bootstrapped` / `kernel:listening` hook would lose them from the plan. The contract says registration is over by then, and no in-repo plugin does it. - `examples/**` and driver-sql are untouched. `#20821` is not reopened here; its deferred-DDL demotion is unchanged. --- _Generated by [Claude Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 58a77db commit f20f669

8 files changed

Lines changed: 913 additions & 60 deletions
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
'@objectstack/cli': patch
3+
'@objectstack/runtime': minor
4+
---
5+
6+
fix(cli): `os migrate plan` / `apply` no longer run the app's `onEnable` or a host plugin's post-declaration hooks during their boot
7+
8+
Clause-②: yes (widening)
9+
10+
The two schema commands boot the host's stack to read what it declares. That boot ran the
11+
config's `onEnable`, and every `kernel:bootstrapped` / `kernel:listening` hook a host plugin
12+
registered from `init()`. A hook that reads a table the plan does not declare then failed on
13+
every plan. On `examples/app-crm`, whose `onEnable` binds positions to permission sets, each
14+
plan printed six `[sql-driver] DATABASE_ERROR` lines and six `position binding lookup failed`
15+
warnings, on a database `apply` had just migrated as well as on an absent one.
16+
17+
The boot now composes host code for its declarations only:
18+
19+
- `AppPlugin` takes a new `skipOnEnable` option. When it is set, `start()` does not run the
20+
bundle's `onEnable`, logs that it withheld it, and reports it through `onEnableWithheld`. The
21+
migrate commands set it on the app they compose from `objectstack.config.ts`.
22+
- A host plugin's `init()` gets a context that does not register `kernel:bootstrapped` or
23+
`kernel:listening` hooks. The kernel contract defines those phases as work after registration
24+
ends: reconcile/backfill, and opening listeners. `kernel:ready` hooks still run, and the
25+
write guard still refuses their row writes. `kernel:shutdown` hooks and data hooks register
26+
as before.
27+
- The plan's notes, and the `--json` payload's `composition.notes`, carry one line naming what
28+
was not run.
29+
30+
The plan itself is unchanged: the same tables, the same pending DDL, the same drift. `apply`
31+
still flushes the DDL the operator confirms and still runs the coverage pass. The platform's own
32+
plugins are untouched, so the value-shape gate announcement still prints.
33+
34+
`@objectstack/runtime` widens its public surface, additively: `AppPlugin`, exported from the
35+
package root, gains the optional constructor option `skipOnEnable` (default `false`) and the
36+
read-only getter `onEnableWithheld`. A composition that does not pass the option gets exactly
37+
the behaviour it had, `onEnable` included.

‎packages/cli/src/utils/schema-migrate.host-composition.integration.test.ts‎

Lines changed: 245 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
4-
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, readFileSync, symlinkSync, rmSync } from 'node:fs';
3+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
4+
import { existsSync, mkdtempSync, mkdirSync, writeFileSync, appendFileSync, readFileSync, symlinkSync, rmSync } from 'node:fs';
55
import { createRequire } from 'node:module';
66
import { tmpdir } from 'node:os';
77
import { dirname, join, resolve } from 'node:path';
8+
// Loaded at module top, so the first transform is paid during collection
9+
// rather than inside a clocked test body (`check:test-source-alias`).
10+
import { AppPlugin } from '@objectstack/runtime';
11+
import { loadConfig } from './config.js';
812
import { bootSchemaStack } from './schema-migrate.js';
913

1014
/**
@@ -694,21 +698,38 @@ describe('a plan writes nothing even when the host writes from init() (#13332)',
694698

695699
// The property (b) was chosen for: the hooks RAN — the log-only ones
696700
// included — on the path an operator reads before a production apply.
701+
// The `extra` plugin is handed straight to the kernel (not host code
702+
// composed for declarations), so every phase of it runs and meets the
703+
// guard: the guard is phase-agnostic.
697704
for (const phase of PHASES) {
698-
expect(log).toContain(`host|log-only|${phase}`);
699705
expect(log).toContain(`extra|log-only|${phase}`);
700706
// …and the writing hooks got all the way to their `create()` call,
701707
// which returned instead of throwing: the line after it was reached.
702-
expect(log).toContain(`host|write|${phase}`);
703708
expect(log).toContain(`extra|write|${phase}`);
704709
}
710+
// The HOST config's plugin keeps `kernel:ready` — the phase the contract
711+
// leaves registration in — and (#21054) never registers its
712+
// post-declaration hooks at all.
713+
expect(log).toContain('host|log-only|kernel:ready');
714+
expect(log).toContain('host|write|kernel:ready');
715+
for (const phase of ['kernel:bootstrapped', 'kernel:listening']) {
716+
expect(log).not.toContain(`host|log-only|${phase}`);
717+
expect(log).not.toContain(`host|write|${phase}`);
718+
}
705719

706720
// The refusals are REPORTED, not swallowed — this is the line the plan
707721
// prints and `--json` carries. No raw execute() went through on this
708722
// boot, so the outcome claim HELD and is printed with the report.
723+
// 4 = the host's `kernel:ready` write + the extra plugin's three.
709724
const notes = stack.composition.notes.join(' ');
710-
expect(notes).toContain('Refused 6 write(s) during the declaration boot — a plan writes nothing');
725+
expect(notes).toContain('Refused 4 write(s) during the declaration boot — a plan writes nothing');
711726
expect(notes).toContain('create() on sys_metadata');
727+
// …and what was not run is said too.
728+
expect(notes).toContain(
729+
'did not register 4 host hook(s) on post-declaration phases '
730+
+ '(com.example.host-writes-from-init on kernel:bootstrapped x2, '
731+
+ 'com.example.host-writes-from-init on kernel:listening x2)',
732+
);
712733
} finally {
713734
await stack.shutdown();
714735
}
@@ -770,10 +791,11 @@ describe('a plan writes nothing even when the host writes from init() (#13332)',
770791
// …and the run SAYS so. The refusal line drops the flat claim (the
771792
// colon directly after "boot" is the dropped phrase), the forwarded
772793
// call is named with its count, and no note in the run claims the
773-
// plan wrote nothing. 4 refusals: the host config's plugin on three
774-
// phases, plus this fixture's in-run control.
794+
// plan wrote nothing. 2 refusals: the host config's plugin on
795+
// `kernel:ready` (its post-declaration hooks are never registered,
796+
// #21054), plus this fixture's in-run control.
775797
const notes = stack.composition.notes.join(' ');
776-
expect(notes).toContain('Refused 4 write(s) during the declaration boot:');
798+
expect(notes).toContain('Refused 2 write(s) during the declaration boot:');
777799
expect(notes).toContain('Raw execute() was called 1 time(s) during the declaration boot');
778800
expect(notes).not.toContain('a plan writes nothing');
779801

@@ -786,3 +808,218 @@ describe('a plan writes nothing even when the host writes from init() (#13332)',
786808
}
787809
}, 60_000);
788810
});
811+
812+
/**
813+
* #21054 — a plan's declaration boot runs no app lifecycle hook.
814+
*
815+
* `examples/app-crm` measured it: its config's `onEnable` hooks
816+
* `kernel:bootstrapped` and reads `sys_position` / `sys_permission_set`,
817+
* tables the plan's composition never declares. Every plan — on a database
818+
* `apply` had just migrated, and on one that does not exist — printed six
819+
* `[sql-driver] DATABASE_ERROR` lines and six `position binding lookup failed`
820+
* warnings. The write guard could not help: the hook only READS.
821+
*
822+
* The fixture is that shape, built here rather than read from
823+
* `examples/app-crm` (a test reading another package's tree is an undeclared
824+
* cross-package input): a stack with one object, a named `onEnable` export
825+
* that hooks `kernel:bootstrapped` and reads the two undeclared tables, and a
826+
* host plugin whose `init()` registers a reading `kernel:bootstrapped` hook
827+
* beside a `kernel:ready` one.
828+
*
829+
* Pinned, in order: the POSITIVE CONTROL (the same code, composed the way a
830+
* served boot composes it, prints the lines); `apply`'s confirmed work after
831+
* the boot is unchanged (the DDL flush creates the app's table, the #13028
832+
* coverage pass examines it); then the plan on the migrated file and on an
833+
* absent one prints zero `DATABASE_ERROR` lines, runs neither hook, and still
834+
* runs the host's `kernel:ready` hook.
835+
*/
836+
describe('a plan runs no app lifecycle hook (#21054)', () => {
837+
let dir: string;
838+
let migratedDb: string;
839+
let hookLog: string;
840+
const savedEnv: Record<string, string | undefined> = {};
841+
let applyFlushed: Array<{ table: string; kind: string }> = [];
842+
let applyExamined = -1;
843+
844+
const PROBE_TABLES = ['sys_position', 'sys_permission_set'];
845+
846+
/** Every driver `DATABASE_ERROR` warning the run printed, whatever channel it took. */
847+
const captureDatabaseErrors = () => {
848+
const lines: string[] = [];
849+
const record = (...args: unknown[]) => {
850+
const text = args.map((a) => (typeof a === 'string' ? a : '')).join(' ');
851+
if (text.includes('DATABASE_ERROR')) lines.push(text);
852+
};
853+
const warn = vi.spyOn(console, 'warn').mockImplementation(record);
854+
const error = vi.spyOn(console, 'error').mockImplementation(record);
855+
return {
856+
lines,
857+
restore: () => { warn.mockRestore(); error.mockRestore(); },
858+
};
859+
};
860+
861+
const bootPlan = (dbFile: string) => bootSchemaStack({
862+
jsonOutput: false,
863+
databaseUrl: `file:${dbFile}`,
864+
deferSchemaDdl: true,
865+
readOnlyProbe: true,
866+
composeHostStack: true,
867+
projectRoot: dir,
868+
});
869+
870+
beforeAll(async () => {
871+
dir = mkdtempSync(join(tmpdir(), 'os-21054-'));
872+
migratedDb = join(dir, 'migrated.db');
873+
hookLog = join(dir, 'hooks.log');
874+
writeFileSync(hookLog, '');
875+
876+
savedEnv.NODE_ENV = process.env.NODE_ENV;
877+
savedEnv.OS_ARTIFACT_PATH = process.env.OS_ARTIFACT_PATH;
878+
process.env.NODE_ENV = 'production';
879+
process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json');
880+
881+
writeFileSync(
882+
join(dir, 'objectstack.config.ts'),
883+
[
884+
"import { appendFileSync } from 'node:fs';",
885+
'',
886+
`const LOG = ${JSON.stringify(hookLog)};`,
887+
"const SYS = { isSystem: true };",
888+
'',
889+
'export default {',
890+
" manifest: { id: 'com.example.os21054', name: 'No app hooks on a plan', version: '0.0.0', type: 'app' },",
891+
" objects: [{ name: 'os21054_account', fields: { name: { type: 'text' } } }],",
892+
' plugins: [{',
893+
" name: 'com.example.os21054-host',",
894+
" version: '1.0.0',",
895+
' init: async (ctx: any) => {',
896+
" ctx.hook('kernel:ready', async () => { appendFileSync(LOG, 'host|kernel:ready\\n'); });",
897+
" ctx.hook('kernel:bootstrapped', async () => {",
898+
" appendFileSync(LOG, 'host|kernel:bootstrapped\\n');",
899+
" try { await ctx.getService('objectql').find('sys_position', { where: { name: 'x' }, limit: 1, context: SYS }); } catch { /* answered */ }",
900+
' });',
901+
' },',
902+
' }],',
903+
'};',
904+
'',
905+
'// The app-crm shape: a named `onEnable` beside the default-exported stack.',
906+
'export const onEnable = async (ctx: any) => {',
907+
" appendFileSync(LOG, 'app|onEnable\\n');",
908+
" ctx.hook('kernel:bootstrapped', async () => {",
909+
" appendFileSync(LOG, 'app|kernel:bootstrapped\\n');",
910+
` for (const object of ${JSON.stringify(PROBE_TABLES)}) {`,
911+
" try { await ctx.ql.find(object, { where: { name: 'x' }, limit: 1, context: SYS }); } catch { /* answered */ }",
912+
' }',
913+
' });',
914+
'};',
915+
'',
916+
].join('\n'),
917+
);
918+
919+
// `os migrate apply`, as the command runs it: boot deferred, then flush
920+
// the confirmed DDL. Its results are the control asserted below.
921+
const apply = await bootSchemaStack({
922+
jsonOutput: false,
923+
databaseUrl: `file:${migratedDb}`,
924+
deferSchemaDdl: true,
925+
composeHostStack: true,
926+
projectRoot: dir,
927+
});
928+
try {
929+
applyFlushed = (await apply.flushSchemaDdl()).map((p) => ({ table: p.table, kind: p.kind }));
930+
applyExamined = apply.composition.coverage?.examinedObjects ?? -1;
931+
} finally {
932+
await apply.shutdown();
933+
}
934+
writeFileSync(hookLog, '');
935+
}, 120_000);
936+
937+
afterAll(() => {
938+
if (savedEnv.NODE_ENV === undefined) delete process.env.NODE_ENV;
939+
else process.env.NODE_ENV = savedEnv.NODE_ENV;
940+
if (savedEnv.OS_ARTIFACT_PATH === undefined) delete process.env.OS_ARTIFACT_PATH;
941+
else process.env.OS_ARTIFACT_PATH = savedEnv.OS_ARTIFACT_PATH;
942+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
943+
});
944+
945+
it('POSITIVE CONTROL: the same code, composed as a served boot composes it, runs both hooks and prints the lines', async () => {
946+
const { config } = await loadConfig(join(dir, 'objectstack.config.ts'));
947+
948+
writeFileSync(hookLog, '');
949+
const captured = captureDatabaseErrors();
950+
const stack = await bootSchemaStack({
951+
jsonOutput: false,
952+
databaseUrl: `file:${migratedDb}`,
953+
deferSchemaDdl: true,
954+
readOnlyProbe: true,
955+
// No declaration composition: the host plugin and the app as `serve`
956+
// composes them, so this leg proves the fixture can print the lines.
957+
composeHostStack: false,
958+
extraPlugins: [...config.plugins, new AppPlugin(config, undefined, { skipSeedData: true })],
959+
projectRoot: dir,
960+
});
961+
try {
962+
const log = readFileSync(hookLog, 'utf8');
963+
expect(log).toContain('app|onEnable');
964+
expect(log).toContain('app|kernel:bootstrapped');
965+
expect(log).toContain('host|kernel:bootstrapped');
966+
for (const table of PROBE_TABLES) {
967+
expect(captured.lines.some((l) => l.includes(`'${table}'`))).toBe(true);
968+
}
969+
} finally {
970+
captured.restore();
971+
await stack.shutdown();
972+
}
973+
}, 60_000);
974+
975+
it('CONTROL: apply\'s confirmed work after the boot is unchanged — the flush creates the app\'s table, the coverage pass examines it', () => {
976+
expect(applyFlushed).toContainEqual({ table: 'os21054_account', kind: 'create_table' });
977+
expect(applyExamined).toBeGreaterThan(0);
978+
});
979+
980+
it('THE FIX, on the migrated file: zero DATABASE_ERROR lines, neither hook runs, kernel:ready still does', async () => {
981+
writeFileSync(hookLog, '');
982+
const captured = captureDatabaseErrors();
983+
const stack = await bootPlan(migratedDb);
984+
try {
985+
expect(captured.lines).toEqual([]);
986+
987+
const log = readFileSync(hookLog, 'utf8');
988+
expect(log).not.toContain('app|onEnable');
989+
expect(log).not.toContain('app|kernel:bootstrapped');
990+
expect(log).not.toContain('host|kernel:bootstrapped');
991+
expect(log).toContain('host|kernel:ready');
992+
993+
// The plan itself: everything apply created is there, nothing pending.
994+
expect(stack.pendingSchemaWork).toEqual([]);
995+
expect(await stack.driver!.detectManagedDrift()).toHaveLength(0);
996+
997+
// And it says what it did not run.
998+
expect(stack.composition.notes.join(' ')).toContain(
999+
'did not execute runtime.onEnable of plugin.app.com.example.os21054, and did not register '
1000+
+ '1 host hook(s) on post-declaration phases (com.example.os21054-host on kernel:bootstrapped)',
1001+
);
1002+
} finally {
1003+
captured.restore();
1004+
await stack.shutdown();
1005+
}
1006+
}, 60_000);
1007+
1008+
it('THE FIX, on an absent file: zero DATABASE_ERROR lines, neither hook runs, and no file is left behind', async () => {
1009+
const absent = join(dir, 'absent.db');
1010+
writeFileSync(hookLog, '');
1011+
const captured = captureDatabaseErrors();
1012+
const stack = await bootPlan(absent);
1013+
try {
1014+
expect(captured.lines).toEqual([]);
1015+
const log = readFileSync(hookLog, 'utf8');
1016+
expect(log).not.toContain('app|onEnable');
1017+
expect(log).not.toContain('host|kernel:bootstrapped');
1018+
expect(stack.pendingSchemaWork.map((p) => p.table)).toContain('os21054_account');
1019+
} finally {
1020+
captured.restore();
1021+
await stack.shutdown();
1022+
}
1023+
expect(existsSync(absent)).toBe(false);
1024+
}, 60_000);
1025+
});

‎packages/cli/src/utils/schema-migrate.ts‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -367,15 +367,20 @@ export async function bootSchemaStack(
367367

368368
// #13332 — the kernel bootstrap is over, and with it the window the
369369
// declaration boot's write guard covers. `composeForDeclarations` suppresses
370-
// a host plugin's `start()`, but `kernel.ts` fires `kernel:ready`,
371-
// `kernel:bootstrapped` and `kernel:listening` unconditionally afterwards, so
372-
// a hook REGISTERED from `init()` runs on a plan; the guard refuses those
373-
// writes at the driver instead of at a list of phase names. Everything from
374-
// this line on is work the command was ASKED for — `apply`'s confirmed DDL
375-
// flush, the #13028 coverage pass — so the guard comes off here and reports
376-
// whatever it refused, which the plan prints and `--json` carries.
370+
// a host plugin's `start()` and its post-declaration hooks (#21054), but
371+
// `kernel.ts` fires `kernel:ready` unconditionally afterwards, so a hook
372+
// REGISTERED from `init()` on that phase runs on a plan; the guard refuses
373+
// its writes at the driver instead of at a list of phase names. Everything
374+
// from this line on is work the command was ASKED for — `apply`'s confirmed
375+
// DDL flush, the #13028 coverage pass — so the guard comes off here and
376+
// reports whatever it refused, which the plan prints and `--json` carries.
377377
const refusalNote = composition.writeGuard?.disarm() ?? null;
378378
if (refusalNote) composition.notes.push(refusalNote);
379+
// #21054 — and what the boot did not run for host code at all: the
380+
// post-declaration hooks its `init()`s asked for, and the config's
381+
// `onEnable`. Read now, after `start()` has decided the latter.
382+
const lifecycleNote = composition.lifecycle?.describe() ?? null;
383+
if (lifecycleNote) composition.notes.push(lifecycleNote);
379384

380385
const driver = findSqlDriver(kernel);
381386

0 commit comments

Comments
 (0)