diff --git a/.changeset/declaration-boot-guard-boundaries.md b/.changeset/declaration-boot-guard-boundaries.md new file mode 100644 index 0000000000..c4b9a1485c --- /dev/null +++ b/.changeset/declaration-boot-guard-boundaries.md @@ -0,0 +1,12 @@ +--- +"@objectstack/cli": patch +--- + +fix(cli): close the declaration-boot write guard's two named boundaries — engine-held drivers and immediate DDL (#14126) + +`os migrate plan` / `os migrate apply` boot host plugins for their declarations behind a guard that refuses the contract's row writes and, since #13332 / #14053, prints "a plan writes nothing" only when that held. Two boundaries were left open and named in the guard's own census; both are now covered, under ONE outcome-line rule: + +- **Engine-held drivers.** Only the default datasource is published as `driver.*`; every other driver reaches the engine through `engine.registerDriver` alone (`DatasourceConnectionService.connect()`, `AppPlugin`'s `drivers.register`, `ObjectQL.create`), so a hook writing to an object bound to a second datasource landed during a plan. The guard now shadows `registerDriver` on the engine instance the kernel publishes (`objectql` / `data`) for the length of the boot, arms each driver instance in place as it is registered — forwarding the SAME instance, never a wrapper, never a second registration under a held name — reaches drivers the engine already held through its public accessors, and restores the engine on `disarm()`. Such a write is now refused and reported as `via engine.`. +- **Immediate DDL.** `dropTable()` / `rotateShards()` are not held back by the schema deferral and execute immediately. They are still not refused (refusing DDL an operator's own hook asked for is out of this guard's scope) — but they now get `execute()`'s treatment: forwarded, counted per driver/method/object, warned once per driver on stderr, named in the notes, and the run no longer claims "a plan writes nothing". + +The rule, decided once: the claim prints only when it held across everything the guard can see — every write refused, nothing forwarded (raw `execute()`, immediate DDL), and no instance that refused the override (a frozen driver, an engine that could not be shadowed). Each of those is named in the notes and withholds the line. An embedder with no data plane, and read/log-only hooks, are untouched: a quiet boot still renders byte-identically. diff --git a/packages/cli/src/utils/schema-migrate.deferred-ddl.integration.test.ts b/packages/cli/src/utils/schema-migrate.deferred-ddl.integration.test.ts index e8648113d1..f00ef1f133 100644 --- a/packages/cli/src/utils/schema-migrate.deferred-ddl.integration.test.ts +++ b/packages/cli/src/utils/schema-migrate.deferred-ddl.integration.test.ts @@ -18,7 +18,9 @@ import { mkdtempSync, writeFileSync, mkdirSync, rmSync, existsSync, statSync } f import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { SqlDriver } from '@objectstack/driver-sql'; +import type { Plugin, PluginContext } from '@objectstack/core'; import { bootSchemaStack } from './schema-migrate.js'; +import { composeForDeclarations } from './schema-migration-plugins.js'; const ARTIFACT = { // #8687: manifest fields under `manifest:` — the flat spelling is refused. @@ -166,4 +168,62 @@ describe('bootSchemaStack({ deferSchemaDdl }) — the boot writes nothing (#3917 await stack.shutdown(); } }, 60_000); + + it('a hook that drops a table during the declaration boot: the DDL EXECUTES (forwarded), and the notes say so instead of claiming the boot wrote nothing (#14126)', async () => { + // The guard's census names `dropTable()` as immediate DDL: it runs + // `assertSchemaMutable`, not the deferral, and executes. Measured here on + // the real driver over the real file — the deferral held back every + // CREATE/ALTER of the two cases above, and this DROP goes straight + // through. What #14126 changes is not the execution but its silence: the + // call is counted and named, and the run does not print "a plan writes + // nothing" over a table it dropped. + const before = await inspect(); + expect(before.tables).toEqual(['defer_widget']); + + let via = ''; + const dropsATable: Plugin = { + name: 'com.example.drops-a-table-from-a-hook', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.hook('kernel:ready', async () => { + for (const [name, service] of ctx.getServices().entries()) { + if (name.startsWith('driver.') && typeof service?.dropTable === 'function') { + via = name; + await service.dropTable('defer_widget'); + return; + } + } + throw new Error('no driver.* service with dropTable() to drop through'); + }); + }, + }; + + const stack = await bootSchemaStack({ + jsonOutput: false, + databaseUrl: `file:${dbFile}`, + deferSchemaDdl: true, + projectRoot: dir, + // The artifact makes this a COMPOSED boot, which is what arms the guard. + composeHostStack: true, + extraPlugins: [composeForDeclarations(dropsATable)], + }); + try { + const after = await inspect(); + expect(via).not.toBe(''); + // FORWARDED: the table is really gone. + expect(after.tables).not.toContain('defer_widget'); + // COUNTED, on the guard's own surface… + expect(stack.composition.writeGuard?.immediateDdl).toEqual([ + { driver: via, method: 'dropTable', object: 'defer_widget', count: 1 }, + ]); + // …and REPORTED in the notes the plan prints, with the claim withheld. + const notes = stack.composition.notes.join('\n'); + expect(notes).toContain( + `Immediate DDL was called 1 time(s) during the declaration boot (dropTable() on defer_widget via ${via})`, + ); + expect(notes).not.toContain('a plan writes nothing'); + } finally { + await stack.shutdown(); + } + }, 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 fa29300844..4594cfc9a1 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 @@ -1,8 +1,10 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { ObjectKernel } from '@objectstack/core'; import type { Plugin, PluginContext } from '@objectstack/core'; +import { ObjectQL } from '@objectstack/objectql'; +import type { IDataDriver } from '@objectstack/spec/contracts'; import { composeForDeclarations, createDeclarationBootWriteGuard, @@ -48,11 +50,17 @@ interface RecordedWrite { * shadowing a prototype method, restored by `delete` rather than by rewrite. */ class RecordingDriver { - name = 'recording'; + name: string; version = '1.0.0'; writes: RecordedWrite[] = []; /** Raw commands `execute()` actually RAN — the escape hatch is forwarded, not refused. */ executed: unknown[] = []; + /** Immediate DDL that actually RAN — `dropTable()` / `rotateShards()` are forwarded, not refused (#14126). */ + ddl: RecordedWrite[] = []; + + constructor(name = 'recording') { + this.name = name; + } async execute(command: unknown): Promise { this.executed.push(command); @@ -110,6 +118,19 @@ class RecordingDriver { async find(_object: string): Promise[]> { return []; } + + /** `IDataDriver.dropTable` — immediate DDL, forwarded (#14126). */ + async dropTable(object: string): Promise { + this.ddl.push({ method: 'dropTable', object }); + } + + /** driver-sql's `rotateShards(objectDef, nowMs)` — takes the object DEFINITION, not its name. */ + async rotateShards( + objectDef: { name: string }, + ): Promise<{ object: string; current: string; shards: string[]; dropped: string[] }> { + this.ddl.push({ method: 'rotateShards', object: objectDef.name }); + return { object: objectDef.name, current: `${objectDef.name}__r2026`, shards: [], dropped: [`${objectDef.name}__r2025`] }; + } } /** @@ -421,3 +442,492 @@ describe('the declaration boot writes nothing (#13332)', () => { await kernel.shutdown(); }); }); + +/** + * #14126 — the two boundaries the header census named after #13332 / PR + * #14053, closed under ONE outcome-line rule. + * + * Residue 1: only the default datasource is published as `driver.*`; every + * other driver reaches the engine through `engine.registerDriver` alone, and + * the engine's driver map is private with no public enumerator. These cases + * boot a REAL `ObjectQL` — the class `ObjectQLPlugin.init()` publishes as + * `objectql` / `data` — behind stand-ins for the two seams that matter (the + * engine plugin that publishes it, and the datasource plugin that registers + * the default THROUGH it and then republishes it as `driver.*`), so the + * shadow is measured on the instance the CLI boots, not on a fake. + * + * Residue 2: `dropTable()` / `rotateShards()` run `assertSchemaMutable`, not + * the deferral, and execute immediately. They get `execute()`'s treatment. + */ +describe('the two named boundaries (#14126)', () => { + const silentLogger = { + debug() { /* silent */ }, info() { /* silent */ }, warn() { /* silent */ }, error() { /* silent */ }, + }; + const newEngine = () => new ObjectQL({ logger: silentLogger }); + /** The seam `DatasourceConnectionService.connect()` uses: straight to the engine. */ + const registerWith = (engine: ObjectQL, driver: RecordingDriver, isDefault = false): void => { + engine.registerDriver(driver as unknown as IDataDriver, isDefault); + }; + /** Declares an object, bound to `datasource` when given, the way a host's `init()` does. */ + const declare = (engine: ObjectQL, name: string, datasource?: string): void => { + engine.registerObject({ + name, + label: name, + ...(datasource ? { datasource } : {}), + fields: { name: { type: 'text', label: 'Name' } }, + } as unknown as Parameters[0]); + }; + + /** Stands in for `ObjectQLPlugin`: publishes the engine in `init()`, discovers `driver.*` in `start()`. */ + function enginePlugin(engine: ObjectQL): Plugin { + return { + name: 'com.objectstack.engine.objectql', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.registerService('objectql', engine); + ctx.registerService('data', engine); + }, + start: async (ctx: PluginContext) => { + for (const [name, service] of ctx.getServices().entries()) { + if (name.startsWith('driver.')) engine.registerDriver(service); + } + }, + }; + } + + /** + * Stands in for `DefaultDatasourcePlugin.init()`: registers the default + * THROUGH the engine, then republishes the same instance as `driver.` + * — the real plugin's order, which is what leaves the engine already + * holding the default when the guard's `init()` runs. `also` runs in the + * same `init()`, i.e. BEFORE the guard arms. + */ + function engineDatasourcePlugin( + engine: ObjectQL, + driver: RecordingDriver, + also: (engine: ObjectQL) => void = () => { /* nothing else */ }, + ): Plugin { + return { + name: 'com.objectstack.runtime.default-datasource', + version: '1.0.0', + dependencies: ['com.objectstack.engine.objectql'], + init: async (ctx: PluginContext) => { + registerWith(engine, driver, true); + ctx.registerService(`driver.${driver.name}`, driver); + also(engine); + }, + }; + } + + /** + * cloud's shape for residue 1: a host plugin whose `init()` connects a + * SECOND datasource the way `DatasourceConnectionService.connect()` does — + * straight to `engine.registerDriver`, never `driver.*` — declares an + * object bound to it, and writes to that object from a `kernel:ready` hook + * through the engine's own routing. Plus a log-only hook, the shape that + * must keep running. + */ + function secondDatasourceHost(reporting: RecordingDriver, log: HookLog): Plugin { + return { + name: 'com.example.connects-a-second-datasource', + version: '1.0.0', + init: async (ctx: PluginContext) => { + const engine = ctx.getService('objectql'); + registerWith(engine, reporting); + declare(engine, 'report_row', reporting.name); + ctx.hook('kernel:ready', async () => { + log.ran.push('log-only:kernel:ready'); + }); + ctx.hook('kernel:ready', async () => { + // objectql-mediated: the engine resolves `report_row` to the + // instance it holds under `reporting`. `insert()` funnels into the + // same private `getDriver()` and the same call-time `.create` + // lookup on that instance. + const driver = engine.getDriverForObject('report_row'); + expect(driver).toBe(reporting); + await driver!.create('report_row', { name: 'from-kernel:ready' }); + log.ran.push('write:kernel:ready'); + }); + }, + }; + } + + it('RESIDUE 1 — POSITIVE CONTROL: without the guard, a write to an object bound to an engine-registered driver LANDS on a declaration boot', async () => { + // The fixture must be able to write before a refusal means anything: the + // same stack, declaration-composed, and no guard at all. This is also the + // defect as shipped — the `driver.*` scan alone never saw `reporting`. + const engine = newEngine(); + const dflt = new RecordingDriver(); + const reporting = new RecordingDriver('reporting'); + const log: HookLog = { ran: [] }; + const kernel = newKernel(); + + await kernel.use(enginePlugin(engine)); + await kernel.use(engineDatasourcePlugin(engine, dflt)); + await kernel.use(composeForDeclarations(secondDatasourceHost(reporting, log))); + await kernel.bootstrap(); + await kernel.shutdown(); + + expect(reporting.writes).toEqual([{ method: 'create', object: 'report_row' }]); + expect(log.ran).toContain('write:kernel:ready'); + }); + + it('RESIDUE 1 — THE FIX: the engine instance is shadowed, the driver is armed AS it is registered, the SAME instance is held, and the write is refused and reported', async () => { + const engine = newEngine(); + const dflt = new RecordingDriver(); + const reporting = new RecordingDriver('reporting'); + const log: HookLog = { ran: [] }; + const guard = createDeclarationBootWriteGuard(); + const kernel = newKernel(); + + // The measurement the in-lane shape rests on, taken on the real class: + // an ordinary extensible instance whose `registerDriver` resolves through + // the prototype — nothing frozen, nothing copied. + expect(Object.isFrozen(engine)).toBe(false); + expect(Object.isExtensible(engine)).toBe(true); + expect(Object.getOwnPropertyDescriptor(engine, 'registerDriver')).toBeUndefined(); + expect(engine.registerDriver).toBe(ObjectQL.prototype.registerDriver); + + await kernel.use(enginePlugin(engine)); + await kernel.use(engineDatasourcePlugin(engine, dflt)); + await kernel.use(guard.plugin as Plugin); + await kernel.use(composeForDeclarations(secondDatasourceHost(reporting, log))); + await kernel.bootstrap(); + + // One engine under two service names, shadowed ONCE — as an own property + // on the instance, which is what every caller's call-time lookup finds. + expect(guard.shadowedEngines).toEqual(['objectql']); + expect(Object.getOwnPropertyDescriptor(engine, 'registerDriver')).toBeDefined(); + // The SAME instance was forwarded: the engine holds `reporting` itself, + // not a wrapper — identity is what the engine's held-name rule decides on. + expect(engine.getDriverByName('reporting')).toBe(reporting); + expect(engine.getDefaultDriverName()).toBe('recording'); + + // Nothing reached either driver… + expect(reporting.writes).toEqual([]); + expect(dflt.writes).toEqual([]); + // …the hooks themselves still ran… + expect(log.ran).toEqual(['log-only:kernel:ready', 'write:kernel:ready']); + // …and the refusal is reported under the label of the path that reached + // the driver — the engine, not a `driver.*` service it never had. + expect(guard.refusals).toEqual([ + { driver: 'engine.reporting', method: 'create', object: 'report_row', count: 1 }, + ]); + + // HELD: nothing was forwarded and nothing refused the override, so the + // claim prints — the same sentence the `driver.*` path prints. + const note = guard.disarm(); + expect(note).toContain( + 'Refused 1 write(s) during the declaration boot — a plan writes nothing: create() on report_row.', + ); + + // disarm() gave the engine its prototype method back, and the driver its own. + expect(guard.shadowedEngines).toEqual([]); + expect(Object.getOwnPropertyDescriptor(engine, 'registerDriver')).toBeUndefined(); + expect(engine.registerDriver).toBe(ObjectQL.prototype.registerDriver); + expect(Object.getOwnPropertyDescriptor(reporting, 'create')).toBeUndefined(); + await reporting.create('report_row', { name: 'after-disarm' }); + expect(reporting.writes).toEqual([{ method: 'create', object: 'report_row' }]); + + await kernel.shutdown(); + }); + + it('RESIDUE 1 — a driver the engine ALREADY held when the guard armed is armed too, through the object that resolves to it', async () => { + // Registered from the plugin ordered BEFORE the guard — never through + // `driver.*`, and before any shadow existed — with an object bound to it. + // The guard reaches it through `getDriverForObject()`, the accessor the + // engine makes public. + const engine = newEngine(); + const dflt = new RecordingDriver(); + const archive = new RecordingDriver('archive'); + const guard = createDeclarationBootWriteGuard(); + const kernel = newKernel(); + + const writer: Plugin = { + name: 'com.example.writes-to-archive', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.hook('kernel:bootstrapped', async () => { + const ql = ctx.getService('objectql'); + await ql.getDriverForObject('archive_row')!.update('archive_row', 'id1', { name: 'closed' }); + }); + }, + }; + + await kernel.use(enginePlugin(engine)); + await kernel.use(engineDatasourcePlugin(engine, dflt, (e) => { + registerWith(e, archive); + declare(e, 'archive_row', archive.name); + })); + await kernel.use(guard.plugin as Plugin); + await kernel.use(composeForDeclarations(writer)); + await kernel.bootstrap(); + + expect(archive.writes).toEqual([]); + expect(guard.refusals).toEqual([ + { driver: 'engine.archive', method: 'update', object: 'archive_row', count: 1 }, + ]); + expect(guard.disarm()).toContain('a plan writes nothing'); + await kernel.shutdown(); + }); + + it('RESIDUE 1 — the driver.* default path is unchanged: the default keeps its driver.* label and is armed once, whichever caller reaches it', async () => { + const engine = newEngine(); + const dflt = new RecordingDriver(); + const guard = createDeclarationBootWriteGuard(); + const kernel = newKernel(); + + const writer: Plugin = { + name: 'com.example.writes-to-the-default-both-ways', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.hook('kernel:ready', async () => { + // The two callers the instance guard covers, on one object: the + // plugin resolving `driver.*` directly, and the engine routing to + // the same instance. + await ctx.getService('driver.recording').create('sys_thing', {}); + const ql = ctx.getService('objectql'); + expect(ql.getDriverForObject('sys_thing')).toBe(dflt); + await ql.getDriverForObject('sys_thing')!.create('sys_thing', {}); + }); + }, + }; + + await kernel.use(enginePlugin(engine)); + await kernel.use(engineDatasourcePlugin(engine, dflt, (e) => declare(e, 'sys_thing'))); + await kernel.use(guard.plugin as Plugin); + await kernel.use(composeForDeclarations(writer)); + await kernel.bootstrap(); + + expect(dflt.writes).toEqual([]); + // One label — the one it always had. The engine's view of the same + // instance (already held as the default, and re-registered by the + // discovery loop in Phase 2) neither re-armed nor relabelled it. + expect(guard.refusals).toEqual([ + { driver: 'driver.recording', method: 'create', object: 'sys_thing', count: 2 }, + ]); + expect(guard.disarm()).toContain( + 'Refused 2 write(s) during the declaration boot — a plan writes nothing: create() on sys_thing x2.', + ); + await kernel.shutdown(); + }); + + it('RESIDUE 2 — dropTable()/rotateShards() are FORWARDED and REPORTED, warned once per driver, and the outcome line is withheld', async () => { + const driver = new RecordingDriver(); + const guard = createDeclarationBootWriteGuard(); + const kernel = newKernel(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => { /* captured */ }); + const seen: unknown[] = []; + + const ddlCaller: Plugin = { + name: 'com.example.issues-ddl-from-a-hook', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.hook('kernel:ready', async () => { + const d = ctx.getService('driver.recording'); + seen.push(await d.dropTable('sys_old_table')); + seen.push(await d.rotateShards({ name: 'sys_log' })); + seen.push(await d.dropTable('sys_old_table')); + // An in-run control: the guarded surface still refuses. + await d.create('sys_thing', { name: 'refused' }); + }); + }, + }; + + try { + await kernel.use(datasourcePlugin(driver)); + await kernel.use(guard.plugin as Plugin); + await kernel.use(composeForDeclarations(ddlCaller)); + await kernel.bootstrap(); + + // FORWARDED: the real methods ran, and their own return values came back. + expect(driver.ddl).toEqual([ + { method: 'dropTable', object: 'sys_old_table' }, + { method: 'rotateShards', object: 'sys_log' }, + { method: 'dropTable', object: 'sys_old_table' }, + ]); + expect(seen).toEqual([ + undefined, + { object: 'sys_log', current: 'sys_log__r2026', shards: [], dropped: ['sys_log__r2025'] }, + undefined, + ]); + // …while the contract surface was refused in the same boot. + expect(driver.writes).toEqual([]); + + // COUNTED per driver/method/object — `rotateShards` names its object + // through the DEFINITION it takes, not a string. + expect(guard.immediateDdl).toEqual([ + { driver: 'driver.recording', method: 'dropTable', object: 'sys_old_table', count: 2 }, + { driver: 'driver.recording', method: 'rotateShards', object: 'sys_log', count: 1 }, + ]); + + // WARNED once per driver on stderr, not once per call. + const ddlWarnings = warn.mock.calls + .map(([m]) => m) + .filter((m): m is string => typeof m === 'string' && m.includes('Immediate DDL')); + expect(ddlWarnings).toHaveLength(1); + expect(ddlWarnings[0]).toContain('dropTable() on sys_old_table called via driver.recording during the declaration boot'); + + // REPORTED — and the claim WITHHELD, in the refusal half and everywhere else. + const note = guard.disarm(); + expect(note).toContain('Refused 1 write(s) during the declaration boot: create() on sys_thing.'); + expect(note).toContain( + 'Immediate DDL was called 3 time(s) during the declaration boot ' + + '(dropTable() on sys_old_table via driver.recording x2, rotateShards() on sys_log via driver.recording)', + ); + expect(note).toContain('those calls EXECUTED'); + expect(note).not.toContain('a plan writes nothing'); + } finally { + warn.mockRestore(); + await kernel.shutdown(); + } + }); + + it('RESIDUE 2 — a forwarded DDL call with no refusal at all is still named: a boot that dropped a table is never a quiet boot', async () => { + const driver = new RecordingDriver(); + const guard = createDeclarationBootWriteGuard(); + const kernel = newKernel(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => { /* captured */ }); + + const dropper: Plugin = { + name: 'com.example.only-drops', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.hook('kernel:listening', async () => { + await ctx.getService('driver.recording').dropTable('sys_old_table'); + }); + }, + }; + + try { + await kernel.use(datasourcePlugin(driver)); + await kernel.use(guard.plugin as Plugin); + await kernel.use(composeForDeclarations(dropper)); + await kernel.bootstrap(); + + expect(driver.ddl).toEqual([{ method: 'dropTable', object: 'sys_old_table' }]); + const note = guard.disarm(); + // Before #14126 this boot produced NO note at all — indistinguishable + // from a boot in which nothing happened. + expect(note).not.toBeNull(); + expect(note).toContain('Immediate DDL was called 1 time(s) during the declaration boot (dropTable() on sys_old_table via driver.recording)'); + expect(note).not.toContain('writes nothing'); + } finally { + warn.mockRestore(); + await kernel.shutdown(); + } + }); + + it('ONE RULE — a driver the guard could not arm is named and withholds the claim, even though every other write was refused', async () => { + // A frozen instance refuses the own-property shadow; its PROTOTYPE methods + // still run, so a write through it LANDS — and the run must say so rather + // than print the claim over it. + const dflt = new RecordingDriver(); + const frozen = Object.freeze(new RecordingDriver('frozen')); + const guard = createDeclarationBootWriteGuard(); + const kernel = newKernel(); + + const publishesBoth: Plugin = { + name: 'com.objectstack.runtime.default-datasource', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.registerService('driver.recording', dflt); + ctx.registerService('driver.frozen', frozen); + }, + }; + const writer: Plugin = { + name: 'com.example.writes-to-both', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.hook('kernel:ready', async () => { + await ctx.getService('driver.recording').create('sys_thing', {}); + await ctx.getService('driver.frozen').create('sys_thing', {}); + }); + }, + }; + + await kernel.use(publishesBoth); + await kernel.use(guard.plugin as Plugin); + await kernel.use(composeForDeclarations(writer)); + await kernel.bootstrap(); + + expect(dflt.writes).toEqual([]); + expect(frozen.writes).toEqual([{ method: 'create', object: 'sys_thing' }]); // landed — and said so below + const note = guard.disarm(); + expect(note).toContain('Refused 1 write(s) during the declaration boot: create() on sys_thing.'); + expect(note).toContain('Could NOT guard '); + expect(note).toContain('driver.frozen.create'); + expect(note).toContain('does NOT claim to have written nothing'); + expect(note).not.toContain('a plan writes nothing'); + await kernel.shutdown(); + }); + + it('ONE RULE — an engine that refuses the shadow is named and withholds the claim', async () => { + class SealedEngine { + registerDriver(_driver: unknown): void { /* a host engine the guard cannot shadow */ } + } + const engine = Object.freeze(new SealedEngine()); + const driver = new RecordingDriver(); + const guard = createDeclarationBootWriteGuard(); + const kernel = newKernel(); + + const publishesEngine: Plugin = { + name: 'com.example.publishes-a-sealed-engine', + version: '1.0.0', + init: async (ctx: PluginContext) => { ctx.registerService('objectql', engine); }, + }; + const writer: Plugin = { + name: 'com.example.writes-to-the-default', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.hook('kernel:ready', async () => { + await ctx.getService('driver.recording').create('sys_thing', {}); + }); + }, + }; + + await kernel.use(publishesEngine); + await kernel.use(datasourcePlugin(driver)); + await kernel.use(guard.plugin as Plugin); + await kernel.use(composeForDeclarations(writer)); + await kernel.bootstrap(); + + expect(guard.shadowedEngines).toEqual([]); + expect(driver.writes).toEqual([]); + const note = guard.disarm(); + expect(note).toContain('Refused 1 write(s) during the declaration boot: create() on sys_thing.'); + expect(note).toContain('Could NOT shadow objectql.registerDriver'); + expect(note).not.toContain('a plan writes nothing'); + 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 () => { + const guard = createDeclarationBootWriteGuard(); + const kernel = newKernel(); + const log: HookLog = { ran: [] }; + + const logOnly: Plugin = { + name: 'com.example.only-logs', + version: '1.0.0', + init: async (ctx: PluginContext) => { + for (const phase of ['kernel:ready', 'kernel:bootstrapped', 'kernel:listening'] as const) { + ctx.hook(phase, async () => { log.ran.push(`log-only:${phase}`); }); + } + }, + start: async () => { log.ran.push('start'); }, + }; + + await kernel.use(guard.plugin as Plugin); + await kernel.use(composeForDeclarations(logOnly)); + await kernel.bootstrap(); + + expect(guard.shadowedEngines).toEqual([]); + 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']); + // No note at all: a quiet boot renders byte-identically to before any of this existed. + expect(guard.disarm()).toBeNull(); + await kernel.shutdown(); + }); +}); diff --git a/packages/cli/src/utils/schema-migration-plugins.ts b/packages/cli/src/utils/schema-migration-plugins.ts index cfa3fcf02e..2f92252fb4 100644 --- a/packages/cli/src/utils/schema-migration-plugins.ts +++ b/packages/cli/src/utils/schema-migration-plugins.ts @@ -131,21 +131,45 @@ import { isAppPluginLike } from './graft-runtime-hooks.js'; * path any measured instance of this defect took. * - **DDL — and its members split.** `deferSchemaDdl` holds back the * `initObjects`/`syncSchema` path, which `os migrate apply` FLUSHES on - * purpose once the operator confirms. `dropTable` (and driver-sql's - * `rotateShards`) are NOT held back by that deferral: they run - * `assertSchemaMutable` — a schemaMode/dialect gate, not a deferral check — - * and then execute immediately, so a hook calling `driver.dropTable(...)` - * on a managed datasource during a declaration boot executes, today as - * before this guard. A genuinely open boundary, stated. - * - **Engine-held drivers for non-default datasources.** The guard's scan - * covers the `driver.*` services the kernel publishes, and the only such - * registration repo-wide is the DEFAULT datasource's - * (`packages/runtime/src/default-datasource-plugin.ts`). - * `DatasourceConnectionService.connect()` hands every OTHER datasource's - * driver straight to `engine.registerDriver`, never through `driver.*` — - * so a host stack that connects a second datasource during a composed boot - * holds an engine-side driver this guard cannot see, and objectql-mediated - * writes to objects bound to it would land. + * purpose once the operator confirms. `dropTable` (a REQUIRED member of + * `IDataDriver`) and driver-sql's `rotateShards` are NOT held back by that + * deferral: they run `assertSchemaMutable` — a schemaMode/dialect gate, + * not a deferral check — and then execute immediately. They are NOT + * refused here either (#14126): refusing DDL an operator's own hook asked + * for is a behaviour change beyond what this guard exists for. So a + * boot-window call gets exactly the treatment `execute()` gets — FORWARDED + * (it executes, today as before), counted per driver/method/object, warned + * once per driver on stderr, named in the composition notes, and the notes + * withhold "a plan writes nothing" for that run + * ({@link DRIVER_IMMEDIATE_DDL_METHODS}). The execution itself stays an + * open boundary; what closed is its silence. + * - **Engine-held drivers — armed as they are registered (#14126).** The + * guard's scan covers the `driver.*` services the kernel publishes, and + * the only such registration repo-wide is the DEFAULT datasource's + * (`packages/runtime/src/default-datasource-plugin.ts`). Every OTHER + * datasource's driver goes straight to `engine.registerDriver` — + * `DatasourceConnectionService.connect()`, `AppPlugin`'s + * `drivers.register`, `ObjectQL.create` — never through `driver.*`, and + * the engine's driver map is private with no public enumerator. So the + * guard shadows `registerDriver` on the engine INSTANCE the kernel + * publishes (`objectql` / `data`, one object under two names) for the + * length of the boot: each driver instance is armed in place as it is + * registered, and the SAME instance is forwarded to the engine's own + * method — never a wrapper, never a second registration (the engine keeps + * the first instance under a held name and discards the second; identity + * decides). Drivers the engine ALREADY holds when the guard arms are + * reached through its public accessors: the default via + * `getDefaultDriverName()` / `getDriverByName()`, and every driver a + * registered object resolves to via `getDriverForObject()`. Measured on + * this CLI's boot: that set is exactly the default, which the `driver.*` + * scan armed first (`DefaultDatasourcePlugin.init()` registers it through + * the engine and republishes it as `driver.` before this guard's + * `init()` is ordered), so the default keeps its `driver.*` label. What + * remains open, stated: an engine the kernel never publishes as a service, + * and a non-default driver registered BEFORE the guard armed that no + * registered object resolves to at either scan — neither is reachable + * from here without an engine change, and neither occurs in this repo's + * boot. * - writes a plugin makes outside the database entirely (filesystem, * network). * - work a hook defers past the end of the bootstrap; the guard covers the @@ -245,15 +269,17 @@ export function composeForDeclarations(plugin: T): T { * goes stale LOUDLY: adding a write to `IDataDriver` is a spec diff, and * every driver in the repo has to implement it. * - * Two contract members that can write are deliberately NOT in this list, and - * both are stated in the module header's residue census rather than silently - * excluded: `execute()` — the contract's raw-execution escape hatch, which is + * Three groups of members that can write are deliberately NOT in this list, + * and each is stated in the module header's residue census rather than + * silently excluded: `execute()` — the contract's raw-execution escape hatch, * forwarded and REPORTED ({@link DRIVER_RAW_EXECUTION_METHODS}) because a raw - * command cannot be classified as read-vs-write reliably — and DDL, whose - * members split: `deferSchemaDdl` holds back the `initObjects`/`syncSchema` - * path (which `os migrate apply` FLUSHES once the operator confirms — guarding - * it here would refuse the one write these commands exist to make), while - * `dropTable`/`rotateShards` are NOT deferred and stay a stated open boundary. + * command cannot be classified as read-vs-write reliably; the IMMEDIATE DDL + * members `dropTable`/`rotateShards`, forwarded and REPORTED the same way + * ({@link DRIVER_IMMEDIATE_DDL_METHODS}) because refusing DDL a hook asked for + * is not this guard's call to make; and the DEFERRED DDL path + * `initObjects`/`syncSchema`, which `deferSchemaDdl` holds back and + * `os migrate apply` FLUSHES once the operator confirms — guarding it here + * would refuse the one write these commands exist to make. */ const DRIVER_ROW_WRITE_METHODS = [ 'create', @@ -287,9 +313,49 @@ const DRIVER_ROW_WRITE_METHODS = [ */ const DRIVER_RAW_EXECUTION_METHODS = ['execute'] as const; +/** + * The DDL members that execute IMMEDIATELY during a declaration boot — + * `IDataDriver.dropTable()` (a REQUIRED contract member: "Drop the underlying + * table or collection (destructive)") and driver-sql's `rotateShards()` + * (`packages/drivers/driver-sql/src/sql-driver.ts`, the lifecycle rotation + * extension `LifecycleService` calls; it DROPs expired shards). Neither is + * held back by `deferSchemaDdl`: both run `assertSchemaMutable` — a + * schemaMode/dialect gate, not a deferral check — and then issue their DDL. + * + * FORWARDED and REPORTED, never refused, for a reason weighed on #14126: + * refusing DDL an operator's own hook asked for is a behaviour change beyond + * what this guard exists for (keeping a dry run from writing rows behind the + * operator's back, and saying so), and a served boot of the same stack is + * entitled to drop a table it declared. What must never happen is the SILENT + * half — a `DROP TABLE` landing while the run prints an unqualified "a plan + * writes nothing". So a boot-window call is counted per driver/method/object + * (unlike a raw command, a DDL call names its object, and an object name is + * already what the refusal notes echo), warned once per driver on stderr, + * named in the composition notes, and the notes withhold the claim for that + * run — the treatment `execute()` gets, decided ONCE for every path the guard + * forwards rather than refuses ({@link DeclarationBootWriteGuard}). + */ +const DRIVER_IMMEDIATE_DDL_METHODS = ['dropTable', 'rotateShards'] as const; + +/** + * The kernel services under which `ObjectQLPlugin.init()` publishes the + * engine (`packages/objectql/src/plugin.ts` — `providesServices` names both, + * and both point at the same `ObjectQL` instance). The guard shadows + * `registerDriver` on whichever of these it finds, keyed by instance so one + * engine under two names is shadowed once. + */ +const ENGINE_SERVICES = ['objectql', 'data'] as const; + /** One refused write, as the plan reports it. */ export interface RefusedDeclarationWrite { - /** The `driver.*` kernel service the call was made on. */ + /** + * The `driver.*` kernel service the call was made on — or `engine.` + * for a driver the guard reached through the engine instead: registered via + * `engine.registerDriver` during the boot, or already held by the engine + * when the guard armed (#14126). One label per instance, decided by which + * path reached it first; the `driver.*` scan runs first, so the default + * keeps the label it always had. + */ driver: string; /** The contract method the caller reached for. */ method: string; @@ -307,20 +373,43 @@ export interface RefusedDeclarationWrite { * text into an operator-facing note would leak whatever the caller inlined. */ export interface ForwardedRawExecution { - /** The `driver.*` kernel service the call was made on. */ + /** The driver the call was made on, labelled as {@link RefusedDeclarationWrite.driver} is. */ driver: string; /** How many `execute()` calls this driver saw during the guarded window. */ count: number; } +/** + * One immediate-DDL call the boot forwarded, as the plan reports it — + * `dropTable()` / `rotateShards()` ({@link DRIVER_IMMEDIATE_DDL_METHODS}). + * Unlike a raw command, a DDL call names its object, so it is keyed like a + * refusal: per driver/method/object, with a count. + */ +export interface ForwardedImmediateDdl { + /** The driver the call was made on, labelled as {@link RefusedDeclarationWrite.driver} is. */ + driver: string; + /** `dropTable` or `rotateShards`. */ + method: string; + /** The object (table) named in the call, or `(unknown)` when the call carried none. */ + object: string; + /** How many times this exact driver/method/object triple was forwarded. */ + count: number; +} + /** * The declaration boot's write guard — the mechanism behind the sentence the * plan's notes print when the boot is over and it held, "a plan writes - * nothing" (#13332). The sentence is an OUTCOME, so {@link disarm} owns it: - * it is claimed when every write the boot attempted was refused, and dropped - * when a raw `execute()` was forwarded (#14053 review, R1 — a claim printed - * over a write the guard let through is the defect this module exists to - * close). + * nothing" (#13332). The sentence is an OUTCOME, so {@link disarm} owns it, + * under ONE rule for every path (#14053 review, R1; #14126): it is claimed + * only when it held across everything the guard can see — every write it + * saw was refused, nothing was forwarded rather than refused (a raw + * `execute()`, an immediate `dropTable()`/`rotateShards()`), and nothing it + * set out to guard refused the override (a frozen driver, an engine whose + * `registerDriver` could not be shadowed). Each of those is NAMED in the + * notes and withholds the unqualified line. A claim printed over a write the + * guard let through is the defect this module exists to close, and fixing + * one such path while leaving another reproduces it at a smaller radius — + * which is why the rule is decided once, here, and not per path. * * ## Why the guard sits at the DRIVER, not at the plugin * @@ -354,6 +443,27 @@ export interface ForwardedRawExecution { * it — because there is only ever one object, and the engine's write path is a * call-time property lookup on it. * + * ## Why the ENGINE instance is shadowed too (#14126) + * + * Only the default datasource is ever published as `driver.*`; every other + * driver reaches the engine through `engine.registerDriver` alone + * (`DatasourceConnectionService.connect()`, `AppPlugin`'s `drivers.register`, + * `ObjectQL.create`), and the engine's driver map is private with no public + * enumerator. The same in-place technique answers it: `registerDriver` is a + * prototype method on an ordinary, extensible instance that every caller + * reaches by a call-time property lookup on the object the kernel publishes + * (measured on `ObjectQL`: no `freeze`/`seal`, no bound copy of the method + * anywhere in the repo's runtime), so an own property on that instance is + * what they all call. The shadow arms the driver instance FIRST and then + * forwards the SAME instance to the engine's own method — never a wrapper in + * its place, never a second registration under a held name (the engine keeps + * the first and discards the second; identity decides) — and `disarm()` + * deletes the own property so the instance resolves through its prototype + * again. Drivers the engine already held when the guard armed are reached + * through its public accessors (the default by name, the rest via the + * objects that resolve to them); the module header's census states what that + * cannot reach. + * * ## What a refusal does, and why it does not throw * * `context.trigger()` dispatches boot hooks PROPAGATING @@ -371,7 +481,9 @@ export interface DeclarationBootWriteGuard { * Compose this into the boot's plugin list. It arms in Phase 1 — ordered * after `DefaultDatasourcePlugin`, which is what registers `driver.*` — and * re-scans in Phase 2 so a driver registered by a later `init()` is covered - * before any hook phase can fire. + * before any hook phase can fire. Both scans also shadow `registerDriver` + * on the engine the kernel publishes, so a driver registered at ANY later + * point of the boot is armed on arrival (#14126). */ readonly plugin: unknown; /** Every refusal recorded so far. */ @@ -384,7 +496,24 @@ export interface DeclarationBootWriteGuard { */ readonly rawExecutions: readonly ForwardedRawExecution[]; /** - * Restore every guarded driver to the methods it had, and return the line + * Every boot-window `dropTable()` / `rotateShards()` call seen so far — + * forwarded and reported rather than refused + * ({@link DRIVER_IMMEDIATE_DDL_METHODS}). Non-empty means the run's notes + * must not (and do not) claim an unqualified "a plan writes nothing". + */ + readonly immediateDdl: readonly ForwardedImmediateDdl[]; + /** + * The kernel service names under which the guard found — and is currently + * shadowing `registerDriver` on — an engine instance, one entry per + * distinct instance (`objectql` and `data` are one object, so one entry). + * Empty on an embedder with no data plane, and after {@link disarm}. + * Exposed so a pin can assert the shadow is a fact of THIS boot rather + * than an assumption about it. + */ + readonly shadowedEngines: readonly string[]; + /** + * Restore every guarded driver — and every shadowed engine — to the + * methods it had, and return the line * for {@link SchemaMigrationComposition.notes} — or `null` when there is * nothing to report, so a boot in which nothing tried to write renders * byte-identically to before this existed. @@ -434,16 +563,77 @@ function refusalValue(method: string, args: readonly unknown[]): unknown { export function createDeclarationBootWriteGuard(): DeclarationBootWriteGuard { const refusals = new Map(); const rawExecutions = new Map(); - /** Guarded instance -> method -> the own descriptor it had, `undefined` when it had none. */ + const immediateDdl = new Map(); + /** + * Guarded instance -> member -> the own descriptor it had, `undefined` when + * it had none. Drivers AND engines: the restore is the same act for both. + */ const armed = new Map>(); - /** Instances a runtime refused to let us guard — reported, never swallowed. */ + /** Driver members a runtime refused to let us guard — reported, never swallowed. */ const unguardable = new Set(); + /** Engines whose `registerDriver` refused the shadow — likewise reported. */ + const unshadowable = new Set(); + /** Engine instance -> the service name it was reached through. */ + const shadowedEngines = new Map(); const warned = new Set(); const warnedExec = new Set(); + const warnedDdl = new Set(); + + /** + * The object a call names. Row writes take the object NAME first; + * driver-sql's `rotateShards(objectDef, nowMs)` takes the object + * DEFINITION, so its name is read off that. + */ + const objectOf = (args: readonly unknown[]): string => { + const first = args[0]; + if (typeof first === 'string') return first; + const name = (first as { name?: unknown } | null | undefined)?.name; + return typeof name === 'string' ? name : '(unknown)'; + }; + + /** + * Install `value` as an own property shadowing `method` on `target`, + * remembering what was there so {@link disarm} can put it back. `true` + * when the shadow is in place — including when it already was. + */ + const shadow = ( + label: string, + target: Record, + method: string, + value: (...args: unknown[]) => unknown, + refused: Set, + ): boolean => { + let saved = armed.get(target); + if (!saved) { + saved = new Map(); + armed.set(target, saved); + } + if (saved.has(method)) return true; // already guarded + if (typeof target[method] !== 'function') return false; // member this instance lacks + const original = Object.getOwnPropertyDescriptor(target, method); + try { + Object.defineProperty(target, method, { + value, + writable: true, + configurable: true, + // Prototype methods are non-enumerable; an own property that shadows + // one must not start showing up in `for…in` / spread. + enumerable: original?.enumerable ?? false, + }); + } catch { + // A frozen or non-configurable instance. The guarantee cannot be made + // for it, and saying so beats a silent hole — it is named in the notes + // and withholds the outcome line. + refused.add(`${label}.${method}`); + return false; + } + saved.set(method, original); + return true; + }; const refuse = (driverName: string, method: string) => async function refusedWrite(...args: unknown[]): Promise { - const object = typeof args[0] === 'string' ? args[0] : '(unknown)'; + const object = objectOf(args); const key = `${driverName}|${method}|${object}`; const seen = refusals.get(key); if (seen) seen.count += 1; @@ -489,52 +679,153 @@ export function createDeclarationBootWriteGuard(): DeclarationBootWriteGuard { return Reflect.apply(original, this ?? target, args); }; - const armDriver = (serviceName: string, driver: unknown): void => { - if (!driver || typeof driver !== 'object') return; - const target = driver as Record; - let existing = armed.get(target); - if (!existing) { - existing = new Map(); - armed.set(target, existing); - } - const saved = existing; - const shadow = (method: string, value: (...args: unknown[]) => unknown): void => { - if (saved.has(method)) return; // already guarded - if (typeof target[method] !== 'function') return; // member this driver lacks - const original = Object.getOwnPropertyDescriptor(target, method); - try { - Object.defineProperty(target, method, { - value, - writable: true, - configurable: true, - // Prototype methods are non-enumerable; an own property that shadows - // one must not start showing up in `for…in` / spread. - enumerable: original?.enumerable ?? false, - }); - } catch { - // A frozen or non-configurable driver. The guarantee cannot be made - // for it, and saying so beats a silent hole. - unguardable.add(`${serviceName}.${method}`); - return; + /** + * Immediate DDL is forwarded, never refused — see + * {@link DRIVER_IMMEDIATE_DDL_METHODS} for the weighed reason — and, like + * `execute()`, it is COUNTED and SAID: the DDL executed, and the run's + * notes must not claim otherwise. + */ + const forwardImmediateDdl = ( + driverName: string, + method: string, + original: (...args: unknown[]) => unknown, + target: object, + ) => + function forwardedDdl(this: unknown, ...args: unknown[]): unknown { + const object = objectOf(args); + const key = `${driverName}|${method}|${object}`; + const seen = immediateDdl.get(key); + if (seen) seen.count += 1; + else immediateDdl.set(key, { driver: driverName, method, object, count: 1 }); + if (!warnedDdl.has(driverName)) { + warnedDdl.add(driverName); + // eslint-disable-next-line no-console + console.warn( + `[migrate] ⚠ ${method}() on ${object} called via ${driverName} during the declaration boot. ` + + 'Immediate DDL is not held back by the schema deferral, so it was FORWARDED, not ' + + 'refused — it executed, this boot changed the physical schema, and the plan\'s notes ' + + 'say so. Move DDL out of the boot path, or behind the served boot it belongs to.', + ); } - saved.set(method, original); + return Reflect.apply(original, this ?? target, args); }; + + const armDriver = (label: string, driver: unknown): void => { + if (!driver || typeof driver !== 'object') return; + const target = driver as Record; for (const method of DRIVER_ROW_WRITE_METHODS) { - shadow(method, refuse(serviceName, method)); + shadow(label, target, method, refuse(label, method), unguardable); } for (const method of DRIVER_RAW_EXECUTION_METHODS) { const original = target[method]; if (typeof original !== 'function') continue; - shadow(method, forwardRawExecution(serviceName, original as (...args: unknown[]) => unknown, target)); + shadow( + label, target, method, + forwardRawExecution(label, original as (...args: unknown[]) => unknown, target), + unguardable, + ); } + for (const method of DRIVER_IMMEDIATE_DDL_METHODS) { + const original = target[method]; + if (typeof original !== 'function') continue; + shadow( + label, target, method, + forwardImmediateDdl(label, method, original as (...args: unknown[]) => unknown, target), + unguardable, + ); + } + }; + + /** How a driver reached through the engine is labelled — `engine.`. */ + const engineLabel = (driver: unknown): string => { + const name = (driver as { name?: unknown } | null | undefined)?.name; + return `engine.${typeof name === 'string' && name.length > 0 ? name : '(unnamed)'}`; + }; + + /** + * Arm what the engine ALREADY holds, through the accessors it makes + * public: the default by name, and every driver a registered object + * resolves to. Read-only probes — `getDriverForObject` swallows its own + * resolution errors and records nothing. Idempotent, so the Phase 2 re-scan + * reaches the objects host `init()`s declared without re-arming anything. + */ + const armHeldDrivers = (engine: Record): void => { + const e = engine as { + getDefaultDriverName?: () => unknown; + getDriverByName?: (name: string) => unknown; + getDriverForObject?: (name: string) => unknown; + registry?: { getAllObjects?: () => unknown }; + }; + try { + const name = e.getDefaultDriverName?.(); + if (typeof name === 'string') armDriver(`engine.${name}`, e.getDriverByName?.(name)); + } catch { + /* an accessor that throws holds nothing this guard can reach */ + } + if (typeof e.getDriverForObject !== 'function') return; + let objects: unknown; + try { + objects = e.registry?.getAllObjects?.(); + } catch { + return; + } + if (!Array.isArray(objects)) return; + for (const obj of objects) { + const objectName = (obj as { name?: unknown } | null | undefined)?.name; + if (typeof objectName !== 'string') continue; + let driver: unknown; + try { + driver = e.getDriverForObject(objectName); + } catch { + continue; + } + armDriver(engineLabel(driver), driver); + } + }; + + /** + * Shadow `registerDriver` on the engine instance the kernel publishes, so a + * driver registered by any path — `DatasourceConnectionService.connect()`, + * `AppPlugin`'s `drivers.register`, `ObjectQL.create` — is armed on + * arrival. The SAME instance is forwarded: the engine discards a second + * instance under a held name, so identity is what keeps the engine's + * registry and the guard's coverage the same set. + */ + const shadowEngine = (serviceName: string, engine: unknown): void => { + if (!engine || typeof engine !== 'object') return; + if (shadowedEngines.has(engine)) return; // `objectql` and `data` are one instance + const target = engine as Record; + const original = target.registerDriver; + if (typeof original !== 'function') return; // not an engine that registers drivers + const installed = shadow( + serviceName, + target, + 'registerDriver', + function guardedRegisterDriver(this: unknown, ...args: unknown[]): unknown { + // Arm BEFORE forwarding: the engine's write path is a call-time + // lookup on the instance, and a hook may write in the same tick the + // registration returns. + armDriver(engineLabel(args[0]), args[0]); + return Reflect.apply(original as (...a: unknown[]) => unknown, this ?? target, args); + }, + unshadowable, + ); + if (!installed) return; // named in `unshadowable`; the outcome line is withheld + shadowedEngines.set(engine, serviceName); + armHeldDrivers(target); }; const scan = (ctx: any): void => { const services: Map | undefined = ctx?.getServices?.(); if (!services || typeof services.entries !== 'function') return; + // `driver.*` FIRST, so the default keeps the label it always had; the + // engine's own view of that instance is then already armed and skipped. for (const [name, service] of services.entries()) { if (typeof name === 'string' && name.startsWith('driver.')) armDriver(name, service); } + for (const [name, service] of services.entries()) { + if ((ENGINE_SERVICES as readonly string[]).includes(name)) shadowEngine(name, service); + } }; const plugin = { @@ -560,11 +851,15 @@ export function createDeclarationBootWriteGuard(): DeclarationBootWriteGuard { const describe = (): string | null => { const parts: string[] = []; - // The flat claim is made only when it is TRUE of this run: with any raw - // execute() forwarded, whether the boot wrote is not this guard's to - // assert, and the sentence must not print over a write it let through + // ONE rule for every path (#14126): the flat claim is printed only when it + // held across everything the guard can see — nothing forwarded rather + // than refused (raw execute(), immediate DDL) and nothing it set out to + // guard refused the override. Each is named below and withholds the + // sentence; a claim over a write the guard let through is the defect // (#14053 review, R1). - const writesNothingHeld = rawExecutions.size === 0; + const writesNothingHeld = + rawExecutions.size === 0 && immediateDdl.size === 0 + && unguardable.size === 0 && unshadowable.size === 0; if (refusals.size > 0) { const total = [...refusals.values()].reduce((n, r) => n + r.count, 0); const detail = [...refusals.values()] @@ -592,10 +887,31 @@ export function createDeclarationBootWriteGuard(): DeclarationBootWriteGuard { + 'or move raw commands out of the boot path.', ); } + if (immediateDdl.size > 0) { + const total = [...immediateDdl.values()].reduce((n, d) => n + d.count, 0); + const detail = [...immediateDdl.values()] + .map((d) => `${d.method}() on ${d.object} via ${d.driver}${d.count > 1 ? ` x${d.count}` : ''}`) + .join(', '); + parts.push( + `Immediate DDL was called ${total} time(s) during the declaration boot (${detail}) and ` + + 'FORWARDED, not refused: dropTable()/rotateShards() are not held back by the schema ' + + 'deferral, so those calls EXECUTED — this boot changed the physical schema, and this run ' + + 'does NOT claim to have written nothing. A plugin in this stack issues DDL from a hook that ' + + 'runs on a plan; move it out of the boot path, or behind the served boot it belongs to.', + ); + } if (unguardable.size > 0) { parts.push( `Could NOT guard ${[...unguardable].sort().join(', ')} — the driver refused the override, ` - + 'so calls through those members were neither refused nor reported on this run.', + + 'so calls through those members were neither refused nor reported on this run, and this ' + + 'run does NOT claim to have written nothing.', + ); + } + if (unshadowable.size > 0) { + parts.push( + `Could NOT shadow ${[...unshadowable].sort().join(', ')} — the engine refused the override, ` + + 'so drivers registered through it during this boot were neither guarded nor reported, and ' + + 'this run does NOT claim to have written nothing.', ); } return parts.length > 0 ? parts.join(' ') : null; @@ -605,6 +921,8 @@ export function createDeclarationBootWriteGuard(): DeclarationBootWriteGuard { plugin, get refusals(): readonly RefusedDeclarationWrite[] { return [...refusals.values()]; }, get rawExecutions(): readonly ForwardedRawExecution[] { return [...rawExecutions.values()]; }, + get immediateDdl(): readonly ForwardedImmediateDdl[] { return [...immediateDdl.values()]; }, + get shadowedEngines(): readonly string[] { return [...shadowedEngines.values()]; }, disarm(): string | null { for (const [target, saved] of armed) { for (const [method, original] of saved) { @@ -613,6 +931,7 @@ export function createDeclarationBootWriteGuard(): DeclarationBootWriteGuard { } } armed.clear(); + shadowedEngines.clear(); return describe(); }, };