diff --git a/.changeset/runtime-expected-read-refusal-noise.md b/.changeset/runtime-expected-read-refusal-noise.md new file mode 100644 index 0000000000..478cfb4590 --- /dev/null +++ b/.changeset/runtime-expected-read-refusal-noise.md @@ -0,0 +1,37 @@ +--- +"@objectstack/runtime": patch +--- + +**Tests (log hygiene):** the sixteen remaining passing `@objectstack/runtime` +fixtures that printed expected `refused a read on` failures into the shared +shard log now **withhold and assert** that noise instead of emitting it +(#10629). No runtime behaviour changes and no test was skipped, loosened or +removed — the same 78 tests pass, and 268 lines of expected-failure output +(134 `[sql-driver] DATABASE_ERROR — the backend refused a read on ''` +envelopes plus their 134 matching `ERROR Find operation failed` engine frames) +leave the `Test Core` log. + +Why this is worth a release note at all: turbo interleaves package logs without +attribution, so an ERROR-shaped line from a **green** test is indistinguishable +from a real failure in a shard log. Lines of exactly this shape were once +lifted verbatim into a p1 flake signature (#10293) and sent a whole dispatch +cycle at the wrong mechanism. Expected-failure noise from a passing test is a +diagnosis tax on every future red shard. + +Each fixture provokes a **fail-soft probe** — a read the runtime issues to find +out whether something is installed, and whose missing-table answer it swallows +by design: `resolveUserAuthzGrants`' six `sys_*` `tryFind`s, +`ObjectQL.probeInstallOrganizations`, `SeedLoaderService.resolveSoleOrganizationId`, +the lifecycle governance snapshot, `runBuildProbes`' view read, and the boot +metadata load. Every one of them was judged expected rather than diagnostic; +none was silenced on the strength of "it looks like noise". + +⛔ This is not a mute. PR #10630 ruled the shape for this class on two files — +withhold only a line that names an expected table **and** carries that same +table's `no such table` reason, count what was withheld, and assert the counts — +and this applies that shape verbatim through one shared, test-only module, +`packages/runtime/src/expected-read-refusal-noise.ts`. A fixture that stopped +provoking its probe, or whose table started resolving, now goes **red** instead +of merely going quiet; the engine frame is withheld only when it sits directly +above a driver refusal the capture already recognised, so an identically-shaped +fault from any other cause still reaches the log with both halves intact. diff --git a/packages/runtime/src/batch-row-driver-text-real-driver.integration.test.ts b/packages/runtime/src/batch-row-driver-text-real-driver.integration.test.ts index 10ace48e5a..ac27a2c6fa 100644 --- a/packages/runtime/src/batch-row-driver-text-real-driver.integration.test.ts +++ b/packages/runtime/src/batch-row-driver-text-real-driver.integration.test.ts @@ -45,6 +45,10 @@ import { join } from 'node:path'; import { ObjectQL } from '@objectstack/objectql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { SqlDriver } from '@objectstack/driver-sql'; +import { + captureExpectedReadRefusals, + type ExpectedReadRefusalCapture, +} from './expected-read-refusal-noise.js'; import { validationFailureDetails, resolveThrownHttpError } from '@objectstack/types'; const PARENT = { name: 'bd_parent', fields: { name: { type: 'text' } } }; @@ -63,7 +67,20 @@ const NOTE = { }, }; +/** + * [#10629] This fixture provisions its own business objects and nothing else, + * so the engine's single-tenant probe (`ObjectQL.probeInstallOrganizations`, + * memoised once per engine) reads a `sys_organization` that was never created. + * The probe is fail-soft by construction — it catches `isMissingTableError` and + * only that — but the driver and the engine each log the fault on the way out. + * Withheld and asserted rather than muted; `expected-read-refusal-noise.ts` + * says why. + */ +const ABSENT_TENANCY_TABLE = 'sys_organization'; + describe('[#8502] a REAL driver fault is withheld from every batch row', () => { + /** [#10629] The expected-noise capture belonging to the latest rig. */ + let noise: ExpectedReadRefusalCapture | null = null; let dir: string | null = null; let engine: ObjectQL | null = null; @@ -71,6 +88,12 @@ describe('[#8502] a REAL driver fault is withheld from every batch row', () => { try { await engine?.destroy(); } catch { /* noop */ } engine = null; if (dir) { rmSync(dir, { recursive: true, force: true }); dir = null; } + // [#10629] The capture is a PIN, not a mute — asserted after teardown so + // a failure here can never leave the engine running. Every test in this + // file rigs and writes, so the probe fires for each of them: this holds + // for a single `-t` run as well as for the whole file. + expect(noise?.silentChannels() ?? ['no capture was installed']).toEqual([]); + noise = null; }); async function rig() { @@ -80,6 +103,11 @@ describe('[#8502] a REAL driver fault is withheld from every batch row', () => { connection: { filename: join(dir, 'data.sqlite') }, useNullAsDefault: true, }); + // [#10629] Installed on the REAL driver (the one that logs) before it + // runs a statement — the `Object.create(real)` wrapper below resolves + // `logger` through the prototype chain to this sink. + noise = captureExpectedReadRefusals([ABSENT_TENANCY_TABLE]); + noise.captureDriver(real); await real.initObjects([PARENT, CHILD, NOTE]); // Capture the RAW driver error at the seam and let it propagate @@ -94,6 +122,7 @@ describe('[#8502] a REAL driver fault is withheld from every batch row', () => { } engine = new ObjectQL(); + noise.captureEngine(engine); engine.registerDriver(driver, true); await engine.init(); for (const o of [PARENT, CHILD, NOTE]) { diff --git a/packages/runtime/src/batch-row-http-status-real-driver.integration.test.ts b/packages/runtime/src/batch-row-http-status-real-driver.integration.test.ts index e1cb603f8c..a64548bae9 100644 --- a/packages/runtime/src/batch-row-http-status-real-driver.integration.test.ts +++ b/packages/runtime/src/batch-row-http-status-real-driver.integration.test.ts @@ -42,6 +42,10 @@ import { join } from 'node:path'; import { ObjectQL } from '@objectstack/objectql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { SqlDriver } from '@objectstack/driver-sql'; +import { + captureExpectedReadRefusals, + type ExpectedReadRefusalCapture, +} from './expected-read-refusal-noise.js'; import { resolveThrownHttpError, validationFailureDetails } from '@objectstack/types'; /** `maxLength` is what the real record validator rejects against. */ @@ -61,7 +65,29 @@ const CHILD = { }, }; +/** + * [#10629] This fixture provisions its three business objects and nothing else, + * so the engine's single-tenant probe (`ObjectQL.probeInstallOrganizations`, + * memoised once per engine) reads a `sys_organization` that was never created. + * The probe is fail-soft by construction — it catches `isMissingTableError` and + * only that — but the driver and the engine each log the fault on the way out. + * + * ⛔ Asserted in the three tests that WRITE rather than in `afterEach`: the + * probe runs on the system-write org resolution, so the not-found test (which + * only deletes a ghost id) never reaches it. An `afterEach` assertion would + * make that test red for a reason that has nothing to do with it. + * `expected-read-refusal-noise.ts` says why this withholds instead of muting. + */ +const ABSENT_TENANCY_TABLE = 'sys_organization'; + +/** [#10629] The capture is a PIN, not a mute — this is the assertion half. */ +const expectExpectedNoiseWithheld = (noise: ExpectedReadRefusalCapture | null): void => { + expect(noise?.silentChannels() ?? ['no capture was installed']).toEqual([]); +}; + describe('[#8570] a batch row carries the status its producer DECLARED — real driver', () => { + /** [#10629] The expected-noise capture belonging to the latest rig. */ + let noise: ExpectedReadRefusalCapture | null = null; let dir: string | null = null; let engine: ObjectQL | null = null; @@ -78,6 +104,11 @@ describe('[#8570] a batch row carries the status its producer DECLARED — real connection: { filename: join(dir, 'data.sqlite') }, useNullAsDefault: true, }); + // [#10629] Installed on the REAL driver (the one that logs) before it + // runs a statement — the `Object.create(real)` wrapper below resolves + // `logger` through the prototype chain to this sink. + noise = captureExpectedReadRefusals([ABSENT_TENANCY_TABLE]); + noise.captureDriver(real); await real.initObjects([TASK, PARENT, CHILD]); // Capture the RAW error at the seam and let it propagate untouched, so @@ -92,6 +123,7 @@ describe('[#8570] a batch row carries the status its producer DECLARED — real } engine = new ObjectQL(); + noise.captureEngine(engine); engine.registerDriver(driver, true); await engine.init(); for (const o of [TASK, PARENT, CHILD]) { @@ -141,6 +173,8 @@ describe('[#8570] a batch row carries the status its producer DECLARED — real // …and the record is unchanged, so the refusal was a refusal. expect((await engine!.findOne('hs_task', { where: { id: 'ok1' } }))?.name).toBe('ok'); + + expectExpectedNoiseWithheld(noise); }); it('the same row in a MIXED batch — the asymmetry the card measured is gone', async () => { @@ -164,6 +198,8 @@ describe('[#8570] a batch row carries the status its producer DECLARED — real }); expect(res.results[1].errors[0].code).toBe('VALIDATION_FAILED'); expect(res.results[1].errors[0].httpStatus).toBe(400); + + expectExpectedNoiseWithheld(noise); }); it('a REAL driver fault still carries NO status — the over-broad direction', async () => { @@ -195,6 +231,8 @@ describe('[#8570] a batch row carries the status its producer DECLARED — real // causal row's text onto its siblings, so a row-only scan can miss a // live path. expect(JSON.stringify(res)).not.toContain('httpStatus'); + + expectExpectedNoiseWithheld(noise); }); it('a not-found row still answers 404 — the population that already worked', async () => { diff --git a/packages/runtime/src/bulk-write-real-driver.integration.test.ts b/packages/runtime/src/bulk-write-real-driver.integration.test.ts index 6daf3c8dc0..a8700d0f54 100644 --- a/packages/runtime/src/bulk-write-real-driver.integration.test.ts +++ b/packages/runtime/src/bulk-write-real-driver.integration.test.ts @@ -16,6 +16,10 @@ import { join } from 'node:path'; import { ObjectQL } from '@objectstack/objectql'; import { SeedLoaderService } from '@objectstack/metadata-protocol'; import { SqlDriver } from '@objectstack/driver-sql'; +import { + captureExpectedReadRefusals, + type ExpectedReadRefusalCapture, +} from './expected-read-refusal-noise.js'; /** * Wrap a real driver so specific methods can fail the way turso does. Hooks @@ -93,22 +97,48 @@ function metadataFor(objects: any[]) { } as any; } +/** + * [#10629] This fixture provisions its own business objects and nothing else, + * so the engine's single-tenant probe (`ObjectQL.probeInstallOrganizations`, + * memoised once per engine) reads a `sys_organization` that was never created. + * The probe is fail-soft by construction — it catches `isMissingTableError` and + * only that — but the driver and the engine each log the fault on the way out. + * Withheld and asserted rather than muted; `expected-read-refusal-noise.ts` + * says why. + */ +const ABSENT_TENANCY_TABLE = 'sys_organization'; + describe('bulk-write hardening on a REAL SqlDriver (framework#3147–#3152, #3172, #3173)', () => { let dir: string | null = null; let engine: ObjectQL | null = null; + /** [#10629] The expected-noise capture belonging to the latest boot. */ + let noise: ExpectedReadRefusalCapture | null = null; afterEach(async () => { try { await engine?.destroy(); } catch { /* noop */ } engine = null; if (dir) { rmSync(dir, { recursive: true, force: true }); dir = null; } + // [#10629] The capture is a PIN, not a mute — asserted after teardown so a + // failure here can never leave the engine running. Every test in this file + // boots and writes, so the probe fires for each of them: this holds for a + // single `-t` run as well as for the whole file. + expect(noise?.silentChannels() ?? ['no capture was installed']).toEqual([]); + noise = null; }); async function boot(objects: any[], plan: FaultPlan = {}) { dir = mkdtempSync(join(tmpdir(), 'os-bulk-real-')); const real = new SqlDriver({ client: 'better-sqlite3', connection: { filename: join(dir, 'data.sqlite') }, useNullAsDefault: true }); + // [#10629] Installed on the REAL driver (the one that logs) before it runs + // a statement, and on the engine before it issues a read — the two sinks the + // expected refusal travels out on. `wrapDriver` prototype-delegates, so the + // wrapper resolves `logger` through the chain to this sink. + noise = captureExpectedReadRefusals([ABSENT_TENANCY_TABLE]); + noise.captureDriver(real); await real.initObjects(objects); // create real tables + sequences const driver = wrapDriver(real, plan); engine = new ObjectQL(); + noise.captureEngine(engine); engine.registerDriver(driver, true); await engine.init(); // Fast, deterministic summary-retry backoff (framework#3147). diff --git a/packages/runtime/src/cascade-delete-multivalue-lookup-real-driver.integration.test.ts b/packages/runtime/src/cascade-delete-multivalue-lookup-real-driver.integration.test.ts index 3dc4c2772f..ff26c9bc48 100644 --- a/packages/runtime/src/cascade-delete-multivalue-lookup-real-driver.integration.test.ts +++ b/packages/runtime/src/cascade-delete-multivalue-lookup-real-driver.integration.test.ts @@ -37,6 +37,10 @@ import { join } from 'node:path'; import { ObjectQL } from '@objectstack/objectql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { SqlDriver } from '@objectstack/driver-sql'; +import { + captureExpectedReadRefusals, + type ExpectedReadRefusalCapture, +} from './expected-read-refusal-noise.js'; const ACCOUNT = { name: 'zz_account', fields: { name: { type: 'text' } } }; @@ -84,14 +88,33 @@ const SINGLE = { const OWNER_PACKAGE = 'com.objectstack.test.9362'; +/** + * [#10629] This fixture provisions its own business objects and nothing else, + * so the engine's single-tenant probe (`ObjectQL.probeInstallOrganizations`, + * memoised once per engine) reads a `sys_organization` that was never created. + * The probe is fail-soft by construction — it catches `isMissingTableError` and + * only that — but the driver and the engine each log the fault on the way out. + * Withheld and asserted rather than muted; `expected-read-refusal-noise.ts` + * says why. + */ +const ABSENT_TENANCY_TABLE = 'sys_organization'; + describe('[#9362] REST DELETE on an object targeted by a multiple:true lookup — real driver', () => { let dir: string | null = null; let engine: ObjectQL | null = null; + /** [#10629] The expected-noise capture belonging to the latest rig. */ + let noise: ExpectedReadRefusalCapture | null = null; afterEach(async () => { try { await engine?.destroy(); } catch { /* noop */ } engine = null; if (dir) { rmSync(dir, { recursive: true, force: true }); dir = null; } + // [#10629] The capture is a PIN, not a mute — asserted after teardown so + // a failure here can never leave the engine running. Every test in this + // file rigs and writes, so the probe fires for each of them: this holds + // for a single `-t` run as well as for the whole file. + expect(noise?.silentChannels() ?? ['no capture was installed']).toEqual([]); + noise = null; }); async function rig(objects: unknown[]) { @@ -101,8 +124,13 @@ describe('[#9362] REST DELETE on an object targeted by a multiple:true lookup connection: { filename: join(dir, 'data.sqlite') }, useNullAsDefault: true, }); + // [#10629] Installed before the driver runs a statement and before the + // engine issues a read — the two sinks the expected refusal travels out on. + noise = captureExpectedReadRefusals([ABSENT_TENANCY_TABLE]); + noise.captureDriver(real); await real.initObjects(objects as any); engine = new ObjectQL(); + noise.captureEngine(engine); engine.registerDriver(real as any, true); await engine.init(); for (const o of objects) engine.registry.registerObject(o as any, OWNER_PACKAGE); diff --git a/packages/runtime/src/default-datasource-plugin.test.ts b/packages/runtime/src/default-datasource-plugin.test.ts index 8beb074961..415c3cd553 100644 --- a/packages/runtime/src/default-datasource-plugin.test.ts +++ b/packages/runtime/src/default-datasource-plugin.test.ts @@ -11,6 +11,7 @@ import type { IDataEngine } from '@objectstack/spec/contracts'; import { Runtime } from './runtime.js'; import { DefaultDatasourcePlugin } from './default-datasource-plugin.js'; import { AppPlugin } from './app-plugin.js'; +import { captureExpectedReadRefusals } from './expected-read-refusal-noise.js'; // [#10126] Pay the first transform of these dist-resolved workspace deps at MODULE // LOAD. Each is reached below through a dynamic `import()` inside an `it()` body or a @@ -24,6 +25,17 @@ import '@objectstack/service-datasource'; const BOOT_TIMEOUT = 60_000; const ENV = 'OS_ALLOW_DRIVER_CONNECT_FAILURE'; +/** + * [#10629] Exactly one case in this file writes through the booted engine, and + * that write runs the engine's single-tenant probe + * (`ObjectQL.probeInstallOrganizations`) against a `sys_organization` this + * composition never creates. The probe is fail-soft by construction — it + * catches `isMissingTableError` and only that — but the driver and the engine + * each log the fault on the way out. Withheld and asserted in that one case + * rather than muted; `expected-read-refusal-noise.ts` says why. + */ +const ABSENT_TENANCY_TABLE = 'sys_organization'; + async function assemble(opts: { driver?: string; withAdminPlugin?: boolean; @@ -158,6 +170,11 @@ describe('DefaultDatasourcePlugin — the default datasource as a declaration (# const { createPrebuiltDriverFactory } = await import('@objectstack/service-datasource'); const { SqliteWasmDriver } = await import('@objectstack/driver-sqlite-wasm'); const hostBuilt = new SqliteWasmDriver({ filename: ':memory:' }); + // [#10629] Installed before the driver runs a statement; the engine half + // is scoped after bootstrap, because the read it covers is the `insert` + // below rather than anything the boot itself does. + const noise = captureExpectedReadRefusals([ABSENT_TENANCY_TABLE]); + noise.captureDriver(hostBuilt); const kernel = await assemble({ driver: 'turso', // a kind the SHARED factory does not support — proves dispatch factory: createPrebuiltDriverFactory(hostBuilt, { driverId: 'turso' }), @@ -168,6 +185,7 @@ describe('DefaultDatasourcePlugin — the default datasource as a declaration (# }); try { await kernel.bootstrap(); + noise.captureEngine(kernel.getService('objectql')); const engine = kernel.getService('data'); const defaultName = engine.getDefaultDriverName?.(); expect(defaultName).toBeDefined(); @@ -176,6 +194,10 @@ describe('DefaultDatasourcePlugin — the default datasource as a declaration (# await engine.insert('note', { title: 'through-the-adopted-default' }); const rows = await engine.find('note'); expect(rows.map((r: any) => r.title)).toContain('through-the-adopted-default'); + // [#10629] The capture is a PIN, not a mute: the probe's two log lines + // are withheld from the shared shard log and asserted here instead, so + // a probe that stopped running goes red rather than merely quiet. + expect(noise.silentChannels()).toEqual([]); } finally { try { await (kernel as any)?.stop?.(); } catch { /* noop */ } } diff --git a/packages/runtime/src/expand-nested-fields-join-key.integration.test.ts b/packages/runtime/src/expand-nested-fields-join-key.integration.test.ts index b904ed1b72..a3f430e015 100644 --- a/packages/runtime/src/expand-nested-fields-join-key.integration.test.ts +++ b/packages/runtime/src/expand-nested-fields-join-key.integration.test.ts @@ -39,6 +39,10 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; +import { + captureExpectedReadRefusals, + type ExpectedReadRefusalCapture, +} from './expected-read-refusal-noise.js'; const ACCOUNT = { name: 'showcase_account', @@ -73,14 +77,32 @@ const TASK = { }, }; +/** + * [#10629] This fixture provisions the four business objects it queries and nothing else, so the engine's + * own single-tenant probe (`ObjectQL.probeInstallOrganizations`, memoised once + * per engine) reads a `sys_organization` that was never created. The probe is + * fail-soft by construction — it catches `isMissingTableError` and only that — + * but the driver and the engine each log the fault on the way out. Withheld and + * asserted rather than muted; `expected-read-refusal-noise.ts` says why. + */ +const ABSENT_TENANCY_TABLE = 'sys_organization'; + describe('#7537 expand with a nested `fields` that omits the join key (REAL SqlDriver)', () => { let engine: ObjectQL | null = null; let dir: string | null = null; + /** [#10629] The expected-noise capture belonging to the latest boot. */ + let noise: ExpectedReadRefusalCapture | null = null; afterEach(async () => { try { await engine?.destroy(); } catch { /* noop */ } engine = null; if (dir) { rmSync(dir, { recursive: true, force: true }); dir = null; } + // [#10629] The capture is a PIN, not a mute — asserted after teardown so a + // failure here can never leave the engine running. Every test in this file + // boots and writes, so the probe fires for each of them: this holds for a + // single `-t` run as well as for the whole file. + expect(noise?.silentChannels() ?? ['no capture was installed']).toEqual([]); + noise = null; }); async function boot() { @@ -90,8 +112,13 @@ describe('#7537 expand with a nested `fields` that omits the join key (REAL SqlD connection: { filename: join(dir, 'data.sqlite') }, useNullAsDefault: true, }); + // [#10629] Installed before the driver runs a statement and before the + // engine issues a read — the two sinks the expected refusal travels out on. + noise = captureExpectedReadRefusals([ABSENT_TENANCY_TABLE]); + noise.captureDriver(driver); await driver.initObjects([ACCOUNT, INVOICE, PROJECT, TASK]); engine = new ObjectQL(); + noise.captureEngine(engine); engine.registerDriver(driver, true); await engine.init(); for (const obj of [ACCOUNT, INVOICE, PROJECT, TASK]) { diff --git a/packages/runtime/src/expected-read-refusal-noise.ts b/packages/runtime/src/expected-read-refusal-noise.ts new file mode 100644 index 0000000000..69ba40acb5 --- /dev/null +++ b/packages/runtime/src/expected-read-refusal-noise.ts @@ -0,0 +1,245 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ═══════════════════════════════════════════════════════════════════════════ + * [#10629] Expected `refused a read on` noise: WITHHELD from the shared log, + * and ASSERTED instead — the shape PR #10630 landed, factored out + * ═══════════════════════════════════════════════════════════════════════════ + * + * ## The defect this closes + * + * A `@objectstack/runtime` fixture that provisions some objects and not others + * makes the runtime probe the ones it did not provision. Several of those + * probes are **fail-soft by construction** — they exist to answer "is this + * installed?" and treat a missing table as "no": + * + * * `resolveUserAuthzGrants` (`core/src/security/resolve-authz-context.ts`) + * `tryFind`s six `sys_*` tables per grant resolution — the resolver is + * fail-closed and must always resolve; + * * `ObjectQL.probeInstallOrganizations` (`objectql/src/engine.ts`) reads + * `sys_organization` and catches `isMissingTableError` **only**, which its + * own doc comment names as "the one benign cause"; + * * `SeedLoaderService.resolveSoleOrganizationId` + * (`metadata-protocol/src/seed-loader.ts`) and + * `LifecycleService`'s governance snapshot read the same table best-effort; + * * `runBuildProbes` (`metadata-protocol/src/build-probes.ts`) reads the + * object a published view is bound to, and turns a failure into a + * `view_read_failed` publish issue rather than an exception; + * * the boot metadata load reads `sys_metadata` before anything created it. + * + * Each of those swallows the fault — but on the way out the driver and the + * engine have each already logged it: + * + * 1. `[sql-driver] DATABASE_ERROR — the backend refused a read on '
' + * … no such table:
`, from `SqlDriver.backendStatementFault` + * through the driver's own `logger.warn`; + * 2. `ERROR Find operation failed {"object":"
",…}` one frame up + * (`objectql/src/engine.ts`), carrying the same fault as a stack. + * + * Turbo interleaves package logs without attribution, so in the shared shard + * log those are indistinguishable from a real failure. Not a hypothetical: + * lines of exactly this shape were lifted VERBATIM into a p1 flake signature + * (#10293) and sent a whole dispatch cycle at the wrong mechanism. + * Expected-failure noise from a green test is a diagnosis tax on every future + * red shard. + * + * ## ⛔ What this is NOT is a mute + * + * Silencing alone would make a fixture BLIND: if the probed read ever stopped + * happening (the runtime dropped it) or started SUCCEEDING (someone + * provisioned the table), the log would go quiet and NOTHING would notice — a + * live pin quietly demoted to a decoration. So each sink here does two things + * instead of one: + * + * * it withholds ONLY the expected fault — the line must name one of the + * caller's declared tables AND carry that same table's `no such table` + * reason. Any other table, any other reason, any other level and any other + * logger method is forwarded to the real console untouched; + * * it COUNTS what it withheld, per table and per channel, and the caller + * asserts those counts. A capture nobody asserts is a mute. + * + * ⛔ The engine gate is deliberately the narrowest of the two: a frame is + * withheld only when it sits directly above a driver refusal this capture + * already recognised ({@link ExpectedReadRefusalCapture.pending}). A + * `DATABASE_ERROR` on one of the same tables arising from any OTHER cause is + * not recognised by the driver sink, so its frame is not withheld here either + * — it reaches the log with both halves intact. + * + * ⛔ Nothing here reads or relaxes a fixture's own assertions. This is the + * console side-effect only. + * + * ## Why a shared module rather than a copy per fixture + * + * PR #10630 established this shape on two files and wrote it inline in each. + * The enumerated remainder is sixteen more, across four distinct probe sites, + * and sixteen copies of one predicate is sixteen places for it to drift — + * including drifting *looser*, which is the direction that turns a pin back + * into a mute without anything going red. The mechanism below is #10630's + * verbatim: the same two sinks, the same "named table AND named reason" + * predicate, the same pending-refusal gate on the engine frame, the same + * count-and-assert discipline. Only the duplication is gone. + * + * ⛔ Test-only. Nothing in `src/index.ts` imports it, so it is not bundled + * (tsup's single entry is `src/index.ts`); it carries no `vitest` import + * either, so the assertions stay visible in the fixture that owns them. + */ + +/** One channel's tally, keyed by the table the withheld line named. */ +export type WithheldByTable = ReadonlyMap; + +/** + * The driver logger shape this capture installs. It mirrors the default's + * `{ warn, error }` so `SqlDriver.logDurabilityFailure` still finds an `error` + * channel to prefer — a sink with only `warn` would silently re-level every + * durability-degradation message this fixture is not talking about. + */ +interface DriverLoggerSink { + warn: (msg: string, meta?: unknown) => void; + error: (msg: string, meta?: unknown) => void; +} + +export interface ExpectedReadRefusalCapture { + /** Per-table count of driver refusal envelopes withheld. */ + readonly refusals: WithheldByTable; + /** Per-table count of engine `Find operation failed` frames withheld. */ + readonly engineFrames: WithheldByTable; + /** Total refusal envelopes withheld, across every declared table. */ + totalRefusals(): number; + /** Total engine frames withheld, across every declared table. */ + totalEngineFrames(): number; + /** The declared tables that were seen at least once on the driver channel. */ + tablesSeen(): string[]; + /** + * The expected channels that never fired, one sentence each — the assertion + * surface. `expect(capture.silentChannels()).toEqual([])` is one call that + * still makes a silent channel NAME ITSELF in the diff, which is what + * #10630's "one assertion per channel" bought at sixteen times the bulk. + * + * ⛔ Repairing a failure here means re-deriving the declared table list or + * finding out why the probe stopped — NEVER relaxing this: a runtime read + * that stopped happening is a finding, and a table that started resolving + * means the fixture now provisions it. + * + * @param required the subset that must have fired. Defaults to every + * declared table. Narrow it for a table read on only SOME of a file's + * paths — requiring that one would turn a single-test `-t` run red without + * meaning anything, while still withholding it when it does fire. + */ + silentChannels(required?: readonly string[]): string[]; + /** + * Install the driver sink. ⛔ Call it BEFORE the driver runs any statement — + * `logger` is a protected field with a `console` default, and this is the + * idiom its own doc comment names ("Tests inject a spy") and that ~20 sibling + * driver suites already use. + */ + captureDriver(driver: unknown): void; + /** + * Wrap the engine's `error` channel through a Proxy, so every OTHER logger + * method resolves to the engine's own. ⛔ Call it before the expected reads + * happen; the engine's logger is a private field with no setter, which is + * the same access `engine-readonly-when-parent.test.ts` established. + */ + captureEngine(engine: unknown): void; +} + +/** + * Build a capture for the tables a fixture deliberately does not provision. + * + * @param tables the object/table names whose `no such table` read failures are + * EXPECTED here. Derive them by measurement rather than from the prober's + * source, so a read the fixture stops provoking shows up as a changed set + * rather than silently. + */ +export function captureExpectedReadRefusals( + tables: readonly string[], +): ExpectedReadRefusalCapture { + const refusals = new Map(); + const engineFrames = new Map(); + /** Recognised driver refusals not yet consumed by their engine frame. */ + const pending = new Map(); + + const bump = (into: Map, table: string): void => { + into.set(table, (into.get(table) ?? 0) + 1); + }; + + /** + * The refusal envelope AND the dialect reason must name the SAME table. + * Matching the envelope alone would withhold a refusal whose cause is a + * permission denial, a dropped connection or a syntax fault — every one of + * which is a real signal on a table this fixture merely also happens to miss. + */ + const expectedRefusal = (line: string): string | undefined => + tables.find( + (t) => line.includes(`refused a read on '${t}'`) && line.includes(`no such table: ${t}`), + ); + + const sum = (m: Map): number => { + let n = 0; + for (const v of m.values()) n += v; + return n; + }; + + return { + refusals, + engineFrames, + totalRefusals: () => sum(refusals), + totalEngineFrames: () => sum(engineFrames), + tablesSeen: () => [...refusals.keys()].sort(), + + silentChannels(required: readonly string[] = tables): string[] { + const out: string[] = []; + for (const t of required) { + if ((refusals.get(t) ?? 0) === 0) { + out.push(`the driver's read refusal for '${t}' was never emitted`); + } + if ((engineFrames.get(t) ?? 0) === 0) { + out.push(`the engine's 'Find operation failed' frame for '${t}' was never emitted`); + } + } + return out; + }, + + captureDriver(driver: unknown): void { + const sink: DriverLoggerSink = { + warn: (msg: string, meta?: unknown): void => { + const table = expectedRefusal(String(msg)); + if (table !== undefined) { + bump(refusals, table); + bump(pending, table); + return; + } + console.warn(msg, meta ?? ''); + }, + error: (msg: string, meta?: unknown): void => { + console.error(msg, meta ?? ''); + }, + }; + (driver as { logger: unknown }).logger = sink; + }, + + captureEngine(engine: unknown): void { + const base = (engine as { logger: Record }).logger; + (engine as { logger: unknown }).logger = new Proxy(base, { + get: (target: Record, key: string) => + key === 'error' + ? (msg: string, err?: unknown, meta?: unknown) => { + const object = (meta as { object?: string } | undefined)?.object; + const outstanding = object !== undefined ? (pending.get(object) ?? 0) : 0; + const detail = String((err as { message?: string } | undefined)?.message ?? ''); + if ( + msg === 'Find operation failed' && + object !== undefined && + outstanding > 0 && + detail.includes(`refused to run this query for object '${object}'`) + ) { + pending.set(object, outstanding - 1); + bump(engineFrames, object); + return; + } + target.error(msg, err, meta); + } + : target[key], + }); + }, + }; +} diff --git a/packages/runtime/src/federated-boot-binding.test.ts b/packages/runtime/src/federated-boot-binding.test.ts index 1fda629e52..c3afb037d7 100644 --- a/packages/runtime/src/federated-boot-binding.test.ts +++ b/packages/runtime/src/federated-boot-binding.test.ts @@ -42,6 +42,11 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; import { Runtime } from './runtime.js'; import { DriverPlugin } from './driver-plugin.js'; import { AppPlugin } from './app-plugin.js'; +import type { Plugin, PluginContext } from '@objectstack/core'; +import { + captureExpectedReadRefusals, + type ExpectedReadRefusalCapture, +} from './expected-read-refusal-noise.js'; // [#10126] Pay the first transform of these dist-resolved workspace deps at MODULE // LOAD. Each is reached below through a dynamic `import()` inside an `it()` body or a @@ -120,7 +125,44 @@ function orphanArtifact() { }; } -async function boot(bundle: Record) { +/** + * [#10629] The `OS_SKIP_SCHEMA_SYNC` case's expected read failures: WITHHELD, + * and ASSERTED. + * + * With boot schema sync skipped, nothing creates `sys_metadata` — that IS what + * the flag means (DDL managed out of band, and this fixture manages none). The + * boot metadata load reads it anyway and survives the miss by design, but the + * driver and the engine each log the fault on the way out: 5 `refused a read + * on 'sys_metadata'` lines and 5 matching `ERROR Find operation failed` frames, + * out of a test that PASSES. ⛔ Scoped to this one flag-set case on purpose — + * the ordinary boot beside it creates the table and must stay loud if it ever + * stops. + * + * Unlike every other fixture in this class the noisy read happens DURING + * `kernel.bootstrap()`, so the engine cannot be reached with `getService` after + * the fact. The capture rides in on a plugin that declares + * `requiresServices: ['objectql']` — ADR-0116's own ordering contract, which + * makes the kernel hoist `ObjectQLPlugin` ahead of it rather than leaving the + * order to `kernel.use()` position (this file is a boot-ORDER pin; assuming + * list order here would be exactly the mistake it exists to catch). It + * registers no service and has no `start`, so it cannot reorder the two plugins + * whose relative order this file measures. + */ +function noiseCapturePlugin(capture: ExpectedReadRefusalCapture): Plugin { + return { + name: 'com.objectstack.test.10629-noise-capture', + version: '1.0.0', + requiresServices: ['objectql'], + init: async (ctx: PluginContext) => { + capture.captureEngine(ctx.getService('objectql')); + }, + }; +} + +/** The table `OS_SKIP_SCHEMA_SYNC` leaves uncreated in this composition. */ +const ABSENT_METADATA_TABLE = 'sys_metadata'; + +async function boot(bundle: Record, capture?: ExpectedReadRefusalCapture) { const { ObjectQLPlugin } = await import('@objectstack/objectql'); const { DatasourceAdminServicePlugin, createDefaultDatasourceDriverFactory } = await import( '@objectstack/service-datasource' @@ -128,8 +170,13 @@ async function boot(bundle: Record) { const runtime = new Runtime({ cluster: false }); const kernel = runtime.getKernel(); - await kernel.use(new DriverPlugin(await makeDefaultDriver())); + // [#10629] Scoped before the driver runs a statement when the caller asked + // for a capture; the default boot passes none and stays fully loud. + const driver = await makeDefaultDriver(); + capture?.captureDriver(driver); + await kernel.use(new DriverPlugin(driver)); await kernel.use(new ObjectQLPlugin()); + if (capture) await kernel.use(noiseCapturePlugin(capture)); await kernel.use(new AppPlugin(bundle as never)); await kernel.use( new DatasourceAdminServicePlugin({ driverFactory: createDefaultDatasourceDriverFactory() }), @@ -185,8 +232,9 @@ describe('#7737 federated boot binding — declared external objects are bound w it('binds federated objects even when boot schema sync is skipped (OS_SKIP_SCHEMA_SYNC)', async () => { const previous = process.env.OS_SKIP_SCHEMA_SYNC; process.env.OS_SKIP_SCHEMA_SYNC = '1'; + const noise = captureExpectedReadRefusals([ABSENT_METADATA_TABLE]); try { - kernel = await boot(artifact()); + kernel = await boot(artifact(), noise); const engine = kernel.getService('data'); const driver = engine.getDriverByName('fed_ext'); await driver.execute('CREATE TABLE remote_customers (id text primary key, name text)'); @@ -195,6 +243,10 @@ describe('#7737 federated boot binding — declared external objects are bound w await driver.execute("INSERT INTO remote_invoices (id, amount) VALUES ('i1', 100)"); expect((await engine.find('fed_customer')).map((r) => r.name)).toEqual(['Ada']); expect((await engine.find('fed_invoice')).map((r) => r.id)).toEqual(['i1']); + // [#10629] The capture is a PIN, not a mute: if boot stops reading + // `sys_metadata`, or the table starts existing under this flag, the log + // goes quiet AND this goes red. + expect(noise.silentChannels()).toEqual([]); } finally { if (previous === undefined) delete process.env.OS_SKIP_SCHEMA_SYNC; else process.env.OS_SKIP_SCHEMA_SYNC = previous; diff --git a/packages/runtime/src/notifications.hono.integration.test.ts b/packages/runtime/src/notifications.hono.integration.test.ts index dd06e8d245..31bc4246ea 100644 --- a/packages/runtime/src/notifications.hono.integration.test.ts +++ b/packages/runtime/src/notifications.hono.integration.test.ts @@ -10,6 +10,7 @@ import { MessagingServicePlugin, MessagingService } from '@objectstack/service-m import { createDispatcherPlugin } from './dispatcher-plugin.js'; import { DriverPlugin } from './driver-plugin.js'; +import { captureExpectedReadRefusals } from './expected-read-refusal-noise.js'; import type { IHttpServer } from '@objectstack/spec/contracts'; /** @@ -62,10 +63,50 @@ function fakeAuthPlugin(): Plugin { }; } +/** + * [#10629] The authz resolver's expected read failures: WITHHELD, and ASSERTED. + * + * This file is the direct sibling `notification-schema-conformance.integration.test.ts` + * names in its own header, and it carries the same never-provisioned `sys_*` + * authz reads through `resolveUserAuthzGrants` + * (`core/src/security/resolve-authz-context.ts`): the fixture provisions the + * messaging objects and nothing else, so every authenticated request reads six + * `sys_*` tables that were never created. `tryFind` swallows each one by design + * — the resolver is fail-closed and must always resolve — but on the way out + * the driver and the engine each log it. Measured on `origin/main`: 52 + * `refused a read on` lines and 52 matching `ERROR Find operation failed` + * frames, out of a suite whose five tests all PASS. + * + * PR #10630 ruled the shape for this class and applied it to the sibling; this + * is the same shape through the shared `expected-read-refusal-noise.ts`, whose + * header carries the full rationale (⛔ it withholds ONLY a line that names one + * of these tables AND carries that same table's `no such table` reason, and it + * COUNTS what it withheld so the assertions below can be a PIN rather than a + * mute). + */ +const ABSENT_AUTHZ_TABLES = [ + 'sys_user', + 'sys_member', + 'sys_user_position', + 'sys_user_permission_set', + 'sys_position', + 'sys_setting', +] as const; + +/** + * The five read on EVERY grant resolution, i.e. on every authenticated request + * this file makes. `sys_setting` is deliberately NOT here: it is read on only + * some routes, so requiring it would turn a single-test `-t` run red without + * meaning anything — it is still withheld when it does fire. + */ +const ALWAYS_READ_AUTHZ_TABLES = ABSENT_AUTHZ_TABLES.filter((t) => t !== 'sys_setting'); + describe('in-app notifications over a real hono server (integration, #3362)', () => { let kernel: ObjectKernel; let baseUrl: string; let messaging: MessagingService; + /** [#10629] The expected-noise capture, asserted by every authed test below. */ + const noise = captureExpectedReadRefusals([...ABSENT_AUTHZ_TABLES]); beforeAll(async () => { kernel = new ObjectKernel({ logLevel: 'silent' }); @@ -74,7 +115,11 @@ describe('in-app notifications over a real hono server (integration, #3362)', () // MessagingServicePlugin registers the `notification` service the dispatcher // resolves and owns the inbox tables. Inline delivery (reliableDelivery:false) // writes the inbox row synchronously so `emit()` is observable immediately. - await kernel.use(new DriverPlugin(new SqliteWasmDriver({ filename: ':memory:' }))); + // [#10629] The driver is named rather than inlined so its logger can be + // scoped before it ever runs a statement. + const driver = new SqliteWasmDriver({ filename: ':memory:' }); + noise.captureDriver(driver); + await kernel.use(new DriverPlugin(driver)); await kernel.use(new ObjectQLPlugin()); // No app plugin registers `sys_notification` here: MessagingServicePlugin // contributes the L2 event it writes, so this lean kernel needs nothing @@ -90,6 +135,10 @@ describe('in-app notifications over a real hono server (integration, #3362)', () await kernel.bootstrap(); + // [#10629] The engine only exists once the kernel has bootstrapped; the + // reads this scopes all happen later, per request. + noise.captureEngine(kernel.getService('objectql')); + const httpServer = kernel.getService('http.server'); baseUrl = `http://127.0.0.1:${httpServer.getPort!()}`; messaging = kernel.getService('notification'); @@ -162,6 +211,17 @@ describe('in-app notifications over a real hono server (integration, #3362)', () const served = await authed('/api/v1/notifications?limit=5&read=false'); expect(served.status).toBe(200); expect((await served.json() as { success: boolean }).success).toBe(true); + + // ── [#10629] The capture is a PIN, not a mute. These lines used to reach + // the shared `Test Core` log out of a PASSING test and were read there as a + // real failure; they are withheld now and asserted here. Asserted per authed + // test rather than in `afterAll` because two tests in this file resolve no + // grants at all (discovery, and the anonymous 401), and an `afterAll` would + // make a single-test `-t` run of either of them red for no reason. + // ⛔ If one of these goes silent the repair is to re-derive the list above, + // NOT to relax this: a resolver read that stopped happening is a finding, + // and a table that started resolving means this fixture now provisions it. + expect(noise.silentChannels(ALWAYS_READ_AUTHZ_TABLES)).toEqual([]); }); it('lists, marks specific read, then marks all read — flipping receipts and clearing the unread count', async () => { @@ -207,6 +267,17 @@ describe('in-app notifications over a real hono server (integration, #3362)', () }); expect(receipts.length).toBe(2); expect(receipts.every((r: any) => r.state === 'read')).toBe(true); + + // ── [#10629] The capture is a PIN, not a mute. These lines used to reach + // the shared `Test Core` log out of a PASSING test and were read there as a + // real failure; they are withheld now and asserted here. Asserted per authed + // test rather than in `afterAll` because two tests in this file resolve no + // grants at all (discovery, and the anonymous 401), and an `afterAll` would + // make a single-test `-t` run of either of them red for no reason. + // ⛔ If one of these goes silent the repair is to re-derive the list above, + // NOT to relax this: a resolver read that stopped happening is a finding, + // and a table that started resolving means this fixture now provisions it. + expect(noise.silentChannels(ALWAYS_READ_AUTHZ_TABLES)).toEqual([]); }); it('[#6436] mark-all-read clears an inbox LARGER than the list window — no readCount/unreadCount contradiction', async () => { @@ -257,5 +328,16 @@ describe('in-app notifications over a real hono server (integration, #3362)', () }); expect(receipts.length).toBe(TOTAL); expect(receipts.every((r: any) => r.state === 'read')).toBe(true); + + // ── [#10629] The capture is a PIN, not a mute. These lines used to reach + // the shared `Test Core` log out of a PASSING test and were read there as a + // real failure; they are withheld now and asserted here. Asserted per authed + // test rather than in `afterAll` because two tests in this file resolve no + // grants at all (discovery, and the anonymous 401), and an `afterAll` would + // make a single-test `-t` run of either of them red for no reason. + // ⛔ If one of these goes silent the repair is to re-derive the list above, + // NOT to relax this: a resolver read that stopped happening is a finding, + // and a table that started resolving means this fixture now provisions it. + expect(noise.silentChannels(ALWAYS_READ_AUTHZ_TABLES)).toEqual([]); }, 120_000); }); diff --git a/packages/runtime/src/package-list-commits-org-scope.integration.test.ts b/packages/runtime/src/package-list-commits-org-scope.integration.test.ts index d582f35119..becfc655c0 100644 --- a/packages/runtime/src/package-list-commits-org-scope.integration.test.ts +++ b/packages/runtime/src/package-list-commits-org-scope.integration.test.ts @@ -11,6 +11,10 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; +import { + captureExpectedReadRefusals, + type ExpectedReadRefusalCapture, +} from './expected-read-refusal-noise.js'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { SysMetadataObject, @@ -92,10 +96,31 @@ const PLATFORM_PKG = '@objectstack/platform-objects'; const ACTIVE_ORG = 'org_active'; const OTHER_ORG = 'org_other'; +/** + * [#10629] Every publish this fixture makes runs the metadata-protocol build + * probes (`metadata-protocol/src/build-probes.ts`), and the views it publishes + * are bound to the placeholder object `anything` — the #7741 inline arm + * requires an object binding pair, and nothing here creates that table. The + * probe reads it, catches, and files a `view_read_failed` publish issue this + * suite does not read (it asserts `res.success`), but the driver and the engine + * each log the read on the way out. Withheld and asserted rather than muted; + * `expected-read-refusal-noise.ts` says why. + */ +const UNBOUND_PROBE_OBJECT = 'anything'; + let cleanup: Array<() => void> = []; + +/** [#10629] The expected-noise capture belonging to the latest `boot()`. */ +let noise: ExpectedReadRefusalCapture | null = null; afterEach(() => { for (const c of cleanup) c(); cleanup = []; + // [#10629] The capture is a PIN, not a mute — asserted after teardown so a + // failure here can never leave an engine running. Every test in this file publishes at + // least once, so the probe fires for each of them: this holds for a single + // `-t` run as well as for the whole file. + expect(noise?.silentChannels() ?? ['no capture was installed']).toEqual([]); + noise = null; }); /** REAL ObjectQL wired to a REAL SqlDriver over on-disk better-sqlite3. */ @@ -116,9 +141,14 @@ async function boot() { SysMetadataAuditObject, SysMetadataCommitObject, ] as any[]; + // [#10629] Installed before the driver runs a statement and before the + // engine issues a read — the two sinks the expected refusal travels out on. + noise = captureExpectedReadRefusals([UNBOUND_PROBE_OBJECT]); + noise.captureDriver(driver); await driver.initObjects(objects); const engine = new ObjectQL(); + noise.captureEngine(engine); engine.registerDriver(driver as any, true); await engine.init(); // `registerObject(schema, packageId)` — the second argument is REQUIRED. diff --git a/packages/runtime/src/package-revert-commit-attribution-org-scope.integration.test.ts b/packages/runtime/src/package-revert-commit-attribution-org-scope.integration.test.ts index 5dc3b45b03..54073e8b35 100644 --- a/packages/runtime/src/package-revert-commit-attribution-org-scope.integration.test.ts +++ b/packages/runtime/src/package-revert-commit-attribution-org-scope.integration.test.ts @@ -11,6 +11,10 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; +import { + captureExpectedReadRefusals, + type ExpectedReadRefusalCapture, +} from './expected-read-refusal-noise.js'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { SysMetadataObject, @@ -83,10 +87,31 @@ const PLATFORM_PKG = '@objectstack/platform-objects'; const ACTIVE_ORG = 'org_active'; const OTHER_ORG = 'org_other'; +/** + * [#10629] Every publish this fixture makes runs the metadata-protocol build + * probes (`metadata-protocol/src/build-probes.ts`), and the views it publishes + * are bound to the placeholder object `anything` — the #7741 inline arm + * requires an object binding pair, and nothing here creates that table. The + * probe reads it, catches, and files a `view_read_failed` publish issue this + * suite does not read (it asserts `res.success`), but the driver and the engine + * each log the read on the way out. Withheld and asserted rather than muted; + * `expected-read-refusal-noise.ts` says why. + */ +const UNBOUND_PROBE_OBJECT = 'anything'; + let cleanup: Array<() => void> = []; + +/** [#10629] The expected-noise capture belonging to the latest `boot()`. */ +let noise: ExpectedReadRefusalCapture | null = null; afterEach(() => { for (const c of cleanup) c(); cleanup = []; + // [#10629] The capture is a PIN, not a mute — asserted after teardown so a + // failure here can never leave an engine running. Every test in this file publishes at + // least once, so the probe fires for each of them: this holds for a single + // `-t` run as well as for the whole file. + expect(noise?.silentChannels() ?? ['no capture was installed']).toEqual([]); + noise = null; }); /** REAL ObjectQL wired to a REAL SqlDriver over on-disk better-sqlite3. */ @@ -105,9 +130,14 @@ async function boot() { SysMetadataAuditObject, SysMetadataCommitObject, ] as any[]; + // [#10629] Installed before the driver runs a statement and before the + // engine issues a read — the two sinks the expected refusal travels out on. + noise = captureExpectedReadRefusals([UNBOUND_PROBE_OBJECT]); + noise.captureDriver(driver); await driver.initObjects(objects); const engine = new ObjectQL(); + noise.captureEngine(engine); engine.registerDriver(driver as any, true); await engine.init(); for (const o of objects) engine.registry.registerObject(o, PLATFORM_PKG); diff --git a/packages/runtime/src/package-revert-commit-org-scope.integration.test.ts b/packages/runtime/src/package-revert-commit-org-scope.integration.test.ts index 0f19de1851..c0d9872329 100644 --- a/packages/runtime/src/package-revert-commit-org-scope.integration.test.ts +++ b/packages/runtime/src/package-revert-commit-org-scope.integration.test.ts @@ -12,6 +12,10 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; +import { + captureExpectedReadRefusals, + type ExpectedReadRefusalCapture, +} from './expected-read-refusal-noise.js'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { SysMetadataObject, @@ -110,10 +114,31 @@ const PLATFORM_PKG = '@objectstack/platform-objects'; const ACTIVE_ORG = 'org_active'; const OTHER_ORG = 'org_other'; +/** + * [#10629] Every publish this fixture makes runs the metadata-protocol build + * probes (`metadata-protocol/src/build-probes.ts`), and the views it publishes + * are bound to the placeholder object `anything` — the #7741 inline arm + * requires an object binding pair, and nothing here creates that table. The + * probe reads it, catches, and files a `view_read_failed` publish issue this + * suite does not read (it asserts `res.success`), but the driver and the engine + * each log the read on the way out. Withheld and asserted rather than muted; + * `expected-read-refusal-noise.ts` says why. + */ +const UNBOUND_PROBE_OBJECT = 'anything'; + let cleanup: Array<() => void> = []; + +/** [#10629] The expected-noise capture belonging to the latest `boot()`. */ +let noise: ExpectedReadRefusalCapture | null = null; afterEach(() => { for (const c of cleanup) c(); cleanup = []; + // [#10629] The capture is a PIN, not a mute — asserted after teardown so a + // failure here can never leave an engine running. Every test in this file publishes at + // least once, so the probe fires for each of them: this holds for a single + // `-t` run as well as for the whole file. + expect(noise?.silentChannels() ?? ['no capture was installed']).toEqual([]); + noise = null; }); /** REAL ObjectQL wired to a REAL SqlDriver over on-disk better-sqlite3. */ @@ -135,9 +160,14 @@ async function boot() { SysMetadataAuditObject, SysMetadataCommitObject, ] as any[]; + // [#10629] Installed before the driver runs a statement and before the + // engine issues a read — the two sinks the expected refusal travels out on. + noise = captureExpectedReadRefusals([UNBOUND_PROBE_OBJECT]); + noise.captureDriver(driver); await driver.initObjects(objects); const engine = new ObjectQL(); + noise.captureEngine(engine); engine.registerDriver(driver as any, true); await engine.init(); // `registerObject(schema, packageId)` — the second argument is REQUIRED. diff --git a/packages/runtime/src/preserve-audit-real-driver.integration.test.ts b/packages/runtime/src/preserve-audit-real-driver.integration.test.ts index a24e82551f..90097cb92c 100644 --- a/packages/runtime/src/preserve-audit-real-driver.integration.test.ts +++ b/packages/runtime/src/preserve-audit-real-driver.integration.test.ts @@ -28,6 +28,10 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; +import { + captureExpectedReadRefusals, + type ExpectedReadRefusalCapture, +} from './expected-read-refusal-noise.js'; const ISO_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; const HISTORICAL = '2021-03-01T09:00:00.000Z'; @@ -42,21 +46,44 @@ const TICKET = { }, }; +/** + * [#10629] This fixture provisions `ticket` and nothing else, so the engine's + * own single-tenant probe (`ObjectQL.probeInstallOrganizations`, memoised once + * per engine) reads a `sys_organization` that was never created. The probe is + * fail-soft by construction — it catches `isMissingTableError` and only that — + * but the driver and the engine each log the fault on the way out. Withheld and + * asserted rather than muted; `expected-read-refusal-noise.ts` says why. + */ +const ABSENT_TENANCY_TABLE = 'sys_organization'; + describe('preserveAudit end-to-end on a REAL SqlDriver (#3493 / #3549)', () => { let engine: ObjectQL | null = null; let dir: string | null = null; + /** [#10629] The expected-noise capture belonging to the latest boot. */ + let noise: ExpectedReadRefusalCapture | null = null; afterEach(async () => { try { await engine?.destroy(); } catch { /* noop */ } engine = null; if (dir) { rmSync(dir, { recursive: true, force: true }); dir = null; } + // [#10629] The capture is a PIN, not a mute — asserted after teardown so a + // failure here can never leave the engine running. Every test in this file + // boots and writes, so the probe fires for each of them: this holds for a + // single `-t` run as well as for the whole file. + expect(noise?.silentChannels() ?? ['no capture was installed']).toEqual([]); + noise = null; }); async function boot() { dir = mkdtempSync(join(tmpdir(), 'os-preserveaudit-')); const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: join(dir, 'data.sqlite') }, useNullAsDefault: true }); + // [#10629] Installed before the driver runs a statement and before the + // engine issues a read — the two sinks the expected refusal travels out on. + noise = captureExpectedReadRefusals([ABSENT_TENANCY_TABLE]); + noise.captureDriver(driver); await driver.initObjects([TICKET]); // create the real table + audit columns engine = new ObjectQL(); + noise.captureEngine(engine); engine.registerDriver(driver, true); await engine.init(); engine.registry.registerObject(TICKET as any); diff --git a/packages/runtime/src/sandbox/nested-write-real-sqlite.integration.test.ts b/packages/runtime/src/sandbox/nested-write-real-sqlite.integration.test.ts index 2ab14a6438..52f10dbe1e 100644 --- a/packages/runtime/src/sandbox/nested-write-real-sqlite.integration.test.ts +++ b/packages/runtime/src/sandbox/nested-write-real-sqlite.integration.test.ts @@ -32,6 +32,10 @@ import { ObjectQL, bindHooksToEngine } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { hookBodyRunnerFactory } from './body-runner.js'; import { QuickJSScriptRunner } from './quickjs-runner.js'; +import { + captureExpectedReadRefusals, + type ExpectedReadRefusalCapture, +} from '../expected-read-refusal-noise.js'; const EXPENSE_REPORT = { name: 'expense_report', @@ -68,9 +72,22 @@ const ROLLUP_HOOK = { }, }; +/** + * [#10629] This fixture provisions `expense_report` / `expense_line` and + * nothing else, so the engine's own single-tenant probe + * (`ObjectQL.probeInstallOrganizations`, memoised once per engine) reads a + * `sys_organization` that was never created. That read is fail-soft by + * construction — the probe catches `isMissingTableError` and only that — but + * the driver and the engine each log it on the way out. Withheld and asserted + * below rather than muted; the module header explains why. + */ +const ABSENT_TENANCY_TABLE = 'sys_organization'; + describe('#1867 nested cross-object write — REAL SqlDriver (better-sqlite3, on-disk)', () => { let engine: ObjectQL | null = null; let dir: string | null = null; + /** [#10629] The expected-noise capture belonging to the latest {@link boot}. */ + let noise: ExpectedReadRefusalCapture | null = null; afterEach(async () => { try { await engine?.destroy(); } catch { /* noop */ } @@ -81,8 +98,13 @@ describe('#1867 nested cross-object write — REAL SqlDriver (better-sqlite3, on async function boot() { dir = mkdtempSync(join(tmpdir(), 'os-nested-1867-')); const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: join(dir, 'data.sqlite') }, useNullAsDefault: true }); + // [#10629] Installed before the driver runs a statement and before the + // engine issues a read — the two sinks the expected refusal travels out on. + noise = captureExpectedReadRefusals([ABSENT_TENANCY_TABLE]); + noise.captureDriver(driver); await driver.initObjects([EXPENSE_REPORT, EXPENSE_LINE]); // create real tables engine = new ObjectQL(); + noise.captureEngine(engine); engine.registerDriver(driver, true); await engine.init(); for (const o of [EXPENSE_REPORT, EXPENSE_LINE]) engine.registry.registerObject(o as any); @@ -108,5 +130,12 @@ describe('#1867 nested cross-object write — REAL SqlDriver (better-sqlite3, on await e.update('expense_line', { id: line2.id, amount: 75, expense_report: report.id }); parent = (await e.find('expense_report', { where: { id: report.id } }))[0]; expect(parent.total_amount).toBe(175); + + // ── [#10629] The capture is a PIN, not a mute. These two lines used to + // reach the shared `Test Core` log out of a PASSING test and were read + // there as a real failure; they are withheld now and asserted here. If the + // probe stops running, or `sys_organization` starts resolving, the log goes + // quiet AND this goes red — the failure a bare `console` mute would hide. + expect(noise?.silentChannels() ?? ['no capture was installed']).toEqual([]); }, 30000); }); diff --git a/packages/runtime/src/sandbox/undeclared-field-write-driver-split.integration.test.ts b/packages/runtime/src/sandbox/undeclared-field-write-driver-split.integration.test.ts index a180e30dfb..1ee0e021c5 100644 --- a/packages/runtime/src/sandbox/undeclared-field-write-driver-split.integration.test.ts +++ b/packages/runtime/src/sandbox/undeclared-field-write-driver-split.integration.test.ts @@ -149,6 +149,10 @@ import { InMemoryDriver } from '@objectstack/driver-memory'; import { hookBodyRunnerFactory } from './body-runner.js'; import { QuickJSScriptRunner } from './quickjs-runner.js'; import type { EngineQueryOptions } from '@objectstack/spec/data'; +import { + captureExpectedReadRefusals, + type ExpectedReadRefusalCapture, +} from '../expected-read-refusal-noise.js'; /** * The read-back query, TYPED rather than cast. The `as any` reads elsewhere in @@ -211,14 +215,35 @@ const UPDATE_TYPO_HOOK = { body: { language: 'js', source: `ctx.input.stagee = 'won';` }, }; +/** + * [#10629] The SQL half of this fixture provisions `deal` and nothing else, so + * the engine's single-tenant probe (`ObjectQL.probeInstallOrganizations`, + * memoised once per engine) reads a `sys_organization` that was never created. + * The probe is fail-soft by construction — it catches `isMissingTableError` and + * only that — but the driver and the engine each log the fault on the way out. + * The schemaless half has no missing table and so declares NOTHING expected: + * its capture withholds nothing and asserts nothing, which is the honest + * reading rather than a skipped assertion. `expected-read-refusal-noise.ts` + * says why this withholds instead of muting. + */ +const ABSENT_TENANCY_TABLE = 'sys_organization'; + describe('#4271 an undeclared field written by an L2 body — the real runtime split', () => { let engine: ObjectQL | null = null; let dir: string | null = null; + /** [#10629] The expected-noise capture belonging to the latest boot. */ + let noise: ExpectedReadRefusalCapture | null = null; afterEach(async () => { try { await engine?.destroy(); } catch { /* noop */ } engine = null; if (dir) { rmSync(dir, { recursive: true, force: true }); dir = null; } + // [#10629] The capture is a PIN, not a mute — asserted after teardown so a + // failure here can never leave the engine running. Unconditional on purpose: + // a memory boot declares an EMPTY expectation, so this still fails loudly if + // a boot ever forgets to install a capture at all. + expect(noise?.silentChannels() ?? ['no capture was installed']).toEqual([]); + noise = null; }); async function bootSql(hook?: unknown) { @@ -228,16 +253,26 @@ describe('#4271 an undeclared field written by an L2 body — the real runtime s connection: { filename: join(dir, 'data.sqlite') }, useNullAsDefault: true, }); + // [#10629] Installed before the driver runs a statement — the sink the + // expected refusal's first half travels out on. + noise = captureExpectedReadRefusals([ABSENT_TENANCY_TABLE]); + noise.captureDriver(driver); await driver.initObjects([DEAL]); // a REAL table, with only the declared columns return boot(driver, hook); } async function bootMemory(hook?: unknown) { + // [#10629] A schemaless driver never refuses a read on a missing table, so + // this family expects no noise at all — an EMPTY declaration rather than a + // skipped one, which keeps the shared `afterEach` assertion honest. + noise = captureExpectedReadRefusals([]); return boot(new InMemoryDriver(), hook); } async function boot(driver: unknown, hook?: unknown) { engine = new ObjectQL(); + // [#10629] The engine frame that sits directly above the driver's refusal. + noise?.captureEngine(engine); engine.registerDriver(driver as any, true); await engine.init(); engine.registry.registerObject(DEAL as any); diff --git a/packages/runtime/src/seed-loader-driver-text-real-driver.integration.test.ts b/packages/runtime/src/seed-loader-driver-text-real-driver.integration.test.ts index 6b2bd6cba1..e5172461a7 100644 --- a/packages/runtime/src/seed-loader-driver-text-real-driver.integration.test.ts +++ b/packages/runtime/src/seed-loader-driver-text-real-driver.integration.test.ts @@ -55,6 +55,10 @@ import { join } from 'node:path'; import { ObjectQL } from '@objectstack/objectql'; import { SeedLoaderService } from '@objectstack/metadata-protocol'; import { SqlDriver } from '@objectstack/driver-sql'; +import { + captureExpectedReadRefusals, + type ExpectedReadRefusalCapture, +} from './expected-read-refusal-noise.js'; import { validationFailureDetails } from '@objectstack/types'; /** `email` declares UNIQUE, so the real driver — not a validator — rejects the duplicate. */ @@ -81,14 +85,32 @@ function metadataFor(objects: any[]) { } as any; } +/** + * [#10629] This fixture provisions its own business objects and nothing else, + * so the engine's single-tenant probe (`ObjectQL.probeInstallOrganizations`, + * memoised once per engine) reads a `sys_organization` that was never created. + * The probe is fail-soft by construction — it catches `isMissingTableError` and + * only that — but the driver and the engine each log the fault on the way out. + * Withheld and asserted rather than muted; `expected-read-refusal-noise.ts` + * says why. + */ +const ABSENT_TENANCY_TABLE = 'sys_organization'; + describe('[#8442] a REAL driver constraint violation is withheld from the seed response', () => { let dir: string | null = null; let engine: ObjectQL | null = null; + /** [#10629] The expected-noise capture belonging to the latest boot. */ + let noise: ExpectedReadRefusalCapture | null = null; afterEach(async () => { try { await engine?.destroy(); } catch { /* noop */ } engine = null; if (dir) { rmSync(dir, { recursive: true, force: true }); dir = null; } + // [#10629] The capture is a PIN, not a mute — asserted after teardown so a + // failure here can never leave the engine running. The single test in this + // file boots and writes, so the probe fires for it. + expect(noise?.silentChannels() ?? ['no capture was installed']).toEqual([]); + noise = null; }); it('a duplicate on a UNIQUE column leaks neither the SQL nor the seeded values', async () => { @@ -98,6 +120,11 @@ describe('[#8442] a REAL driver constraint violation is withheld from the seed r connection: { filename: join(dir, 'data.sqlite') }, useNullAsDefault: true, }); + // [#10629] Installed on the REAL driver (the one that logs) before it runs + // a statement — the `Object.create(real)` wrapper below resolves `logger` + // through the prototype chain to this sink. + noise = captureExpectedReadRefusals([ABSENT_TENANCY_TABLE]); + noise.captureDriver(real); await real.initObjects([ACCT]); // Capture the RAW driver error at the seam, then let it propagate @@ -113,6 +140,7 @@ describe('[#8442] a REAL driver constraint violation is withheld from the seed r }; engine = new ObjectQL(); + noise.captureEngine(engine); engine.registerDriver(driver, true); await engine.init(); engine.registry.registerObject(ACCT as any, 'com.objectstack.test.8442'); diff --git a/packages/runtime/src/seed-multi-value-lookup-real-driver.integration.test.ts b/packages/runtime/src/seed-multi-value-lookup-real-driver.integration.test.ts index a5b45b249d..303c63e089 100644 --- a/packages/runtime/src/seed-multi-value-lookup-real-driver.integration.test.ts +++ b/packages/runtime/src/seed-multi-value-lookup-real-driver.integration.test.ts @@ -17,6 +17,10 @@ import { join } from 'node:path'; import { ObjectQL } from '@objectstack/objectql'; import { SeedLoaderService } from '@objectstack/metadata-protocol'; import { SqlDriver } from '@objectstack/driver-sql'; +import { + captureExpectedReadRefusals, + type ExpectedReadRefusalCapture, +} from './expected-read-refusal-noise.js'; const AUTHOR = { name: 'author', @@ -59,14 +63,32 @@ const SEEDS = [ }, ]; +/** + * [#10629] This fixture provisions the seeded business objects and nothing else, so the engine's + * own single-tenant probe (`ObjectQL.probeInstallOrganizations`, memoised once + * per engine) reads a `sys_organization` that was never created. The probe is + * fail-soft by construction — it catches `isMissingTableError` and only that — + * but the driver and the engine each log the fault on the way out. Withheld and + * asserted rather than muted; `expected-read-refusal-noise.ts` says why. + */ +const ABSENT_TENANCY_TABLE = 'sys_organization'; + describe('multi-value lookup seeds on a REAL SqlDriver (framework#3911)', () => { let dir: string | null = null; let engine: ObjectQL | null = null; + /** [#10629] The expected-noise capture belonging to the latest boot. */ + let noise: ExpectedReadRefusalCapture | null = null; afterEach(async () => { try { await engine?.destroy(); } catch { /* noop */ } engine = null; if (dir) { rmSync(dir, { recursive: true, force: true }); dir = null; } + // [#10629] The capture is a PIN, not a mute — asserted after teardown so a + // failure here can never leave the engine running. Every test in this file + // boots and writes, so the probe fires for each of them: this holds for a + // single `-t` run as well as for the whole file. + expect(noise?.silentChannels() ?? ['no capture was installed']).toEqual([]); + noise = null; }); async function boot(objects: any[]) { @@ -76,8 +98,13 @@ describe('multi-value lookup seeds on a REAL SqlDriver (framework#3911)', () => connection: { filename: join(dir, 'data.sqlite') }, useNullAsDefault: true, }); + // [#10629] Installed before the driver runs a statement and before the + // engine issues a read — the two sinks the expected refusal travels out on. + noise = captureExpectedReadRefusals([ABSENT_TENANCY_TABLE]); + noise.captureDriver(driver); await driver.initObjects(objects); // real tables, real JSON column engine = new ObjectQL(); + noise.captureEngine(engine); engine.registerDriver(driver, true); await engine.init(); for (const o of objects) engine.registry.registerObject(o as any);