diff --git a/packages/runtime/src/cross-field-refusal-operand-withhold.test.ts b/packages/runtime/src/cross-field-refusal-operand-withhold.test.ts index bd469034f7..8d058add69 100644 --- a/packages/runtime/src/cross-field-refusal-operand-withhold.test.ts +++ b/packages/runtime/src/cross-field-refusal-operand-withhold.test.ts @@ -81,6 +81,7 @@ import type { AggregationNode, Cube, FilterCondition } from '@objectstack/spec/d import { markFilterSubtreeProvenance } from '@objectstack/spec/data'; import { createDispatcherPlugin } from './dispatcher-plugin.js'; +import { captureExpectedCrossFieldRefusalNoise } from './expected-read-refusal-noise.js'; const OBJECT = 'cross_field_deal'; @@ -335,9 +336,25 @@ describe('[#7929] a cross-field refusal keeps its envelope and stops disclosing * caller cannot see, which is the entire premise of this card. */ let crudScope: FilterCondition | null = null; + /** + * [#10983] Every read below that the driver refuses reaches this engine's + * `find()`, so its `catch` logs an `ERROR Find operation failed` frame + * BEFORE rethrowing (`engine.ts`) — a green-test noise instance of the + * same defect class #10629/#10630 closed, just without a table to key on + * (the refusal never reaches `backendStatementFault`; see + * `expected-read-refusal-noise.ts`'s second predicate for why and how). + * Withheld and COUNTED here, never muted — `silentChannels()` and + * `totalFrames()` are asserted in this describe's own `afterAll`. + */ + let crossFieldNoise: ReturnType; beforeAll(async () => { ql = new ObjectQL(); + // Installed before the first read this describe block issues — the + // engine's `logger` is a private field with no setter, same access + // discipline `captureExpectedReadRefusals` documents. + crossFieldNoise = captureExpectedCrossFieldRefusalNoise([OBJECT]); + crossFieldNoise.captureEngine(ql); ql.registerDriver(driver as never, true); await ql.init(); ql.registerObject({ @@ -369,6 +386,19 @@ describe('[#7929] a cross-field refusal keeps its envelope and stops disclosing }); }); + afterAll(() => { + // [#10983] The pin, not the mute: every declared object's channel fired + // at least once, AND the count is exactly what this describe block's + // five `it`s produce — six `ql.find()` calls refused (the sixth test + // below drives the driver directly, bypassing this engine on purpose, + // so it contributes no seventh). A capture that withheld 0, or any + // number other than 6, means either the predicate stopped matching or + // a call this accounting did not expect appeared — either way a + // silent-mute reading is exactly what this assertion exists to catch. + expect(crossFieldNoise.silentChannels()).toEqual([]); + expect(crossFieldNoise.totalFrames()).toBe(6); + }); + const readWithScope = async ( scope: FilterCondition | null, where?: FilterCondition, diff --git a/packages/runtime/src/expected-read-refusal-noise.ts b/packages/runtime/src/expected-read-refusal-noise.ts index 69ba40acb5..cf4bdb6760 100644 --- a/packages/runtime/src/expected-read-refusal-noise.ts +++ b/packages/runtime/src/expected-read-refusal-noise.ts @@ -243,3 +243,175 @@ export function captureExpectedReadRefusals( }, }; } + +/** + * ═══════════════════════════════════════════════════════════════════════════ + * [#10983] A SECOND, independent predicate: cross-field `{ $field }` refusal + * engine noise (#7929) — the sibling {@link captureExpectedReadRefusals} + * cannot recognise, because it has no table to key on + * ═══════════════════════════════════════════════════════════════════════════ + * + * ## Why this cannot reuse the table-keyed predicate above + * + * `captureExpectedReadRefusals` withholds an engine frame only when it sits + * directly above a driver refusal ITS OWN `captureDriver` sink already + * recognised ({@link pending} in that closure) — the engine frame alone never + * carries enough identity to withhold safely, so the driver channel supplies + * it via the caller's declared TABLE plus that table's `no such table` reason. + * + * A cross-field `{ $field }` refusal (#7929, `uncompilableFieldReferenceError` + * in `driver-sql/src/sql-driver.ts`) never goes through that path at all: it is + * a validation refusal raised while COMPILING the filter, not a backend + * statement fault, so it never reaches `SqlDriver.backendStatementFault` and + * there is no driver-channel line — and therefore no `pending` entry — for an + * engine frame to sit above. There is no table involved either: the refusal is + * about which FIELDS a filter compared, not which table was queried. + * + * ## What replaces the table: the engine frame's own message, measured + * + * Unlike the generic "no such table" case, THIS engine frame's `error.message` + * already carries the refusal's own identity — so the message itself is the + * correlation, and no driver-channel line is needed to supply it. Measured + * directly off a real run of `cross-field-refusal-operand-withhold.test.ts` + * (`--reporter=default`, the reporter this feature bypasses regardless — see + * the file's own header): its 6 ERROR frames carry not one message shape but + * TWO, both produced by `uncompilableFieldReferenceError` — + * + * * the WITHHELD generic wording (4 of the 6): `"A cross-field comparison + * ({ \"$field\": … }) in this filter cannot be compiled here. …"`; + * * the [#8220] author-DISCLOSED wording (2 of the 6, from the one case in + * that file where the predicate is positively marked `'author'` and the + * driver restores the full diagnostic): `"Operator \"$gt\" on field + * \"amount\" compares against another field ({ \"$field\": + * \"secret_policy_column\" }), which cannot be compiled here: …"`. + * + * A predicate keyed on the withheld wording alone would silently miss the + * disclosed half — 4 of 6 withheld and 2 loud is not "expected-noise + * withholding", it is a partial mute with a passing assertion sitting on top + * of it. Both strings share exactly two substrings and no third candidate + * does: **`cannot be compiled here`** (verified by source grep to appear + * NOWHERE else in `driver-sql/src/sql-driver.ts` — it is + * `uncompilableFieldReferenceError`'s own wording and no other builder's) and + * **`$field`** (present on every `{ $field }`-shaped refusal, cross-field + * comparison or not). Requiring BOTH — mirroring the sibling predicate's + * "table AND reason" pairing above — is what keeps a sibling `{ $field }` + * family (`bareFieldReferenceError`'s "bare field reference … with no + * operator", which contains `$field` but never "cannot be compiled here") + * OUTSIDE the match: same operand syntax, different refusal, stays loud. + * + * ## ⛔ Still not a mute: the counting dimension moves, the discipline does not + * + * There is no driver-channel table to count against, so what is counted + * changes from "per table, across two correlated channels" to "per OBJECT, + * on the one channel that exists" — the engine frame's own `object` meta, + * scoped to the caller's declared list exactly as the table list scopes the + * sibling predicate. What does NOT change: a frame is withheld only on an + * exact identity match (declared object AND both message markers), the + * withholding is COUNTED per object, and the caller MUST assert those counts + * ({@link ExpectedCrossFieldRefusalCapture.silentChannels}) — a capture + * nobody asserts is a mute, whichever dimension it counts by. + * + * ⛔ Test-only, same reason as above: not exported from `src/index.ts`. + */ + +export interface ExpectedCrossFieldRefusalCapture { + /** Per-object count of engine `Find operation failed` frames withheld. */ + readonly frames: WithheldByTable; + /** Total engine frames withheld, across every declared object. */ + totalFrames(): number; + /** The declared objects that were seen at least once. */ + objectsSeen(): string[]; + /** + * The declared objects whose expected cross-field frame never fired — the + * assertion surface, mirroring + * {@link ExpectedReadRefusalCapture.silentChannels}. ⛔ Repairing a failure + * here means finding out why the refusal stopped firing (or reproducing on + * a new message shape this predicate has not been taught) — NEVER loosening + * the match in {@link captureExpectedCrossFieldRefusalNoise} to make it pass. + * + * @param required the subset that must have fired. Defaults to every + * declared object. + */ + silentChannels(required?: readonly string[]): string[]; + /** + * Install the engine sink. Same access pattern and same call-before-reads + * discipline as {@link ExpectedReadRefusalCapture.captureEngine} — the + * engine's logger is a private field with no setter. + */ + captureEngine(engine: unknown): void; +} + +/** + * Build a capture for the cross-field `{ $field }` refusal family (#7929) on + * a fixture's declared objects. + * + * @param objects the object names whose cross-field `{ $field }` refusals are + * EXPECTED here. Derive them by measurement (as the file header does) + * rather than assumption, so an object this predicate was not told about + * stays loud rather than silently swallowed. + */ +export function captureExpectedCrossFieldRefusalNoise( + objects: readonly string[], +): ExpectedCrossFieldRefusalCapture { + const frames = new Map(); + + const bump = (object: string): void => { + frames.set(object, (frames.get(object) ?? 0) + 1); + }; + + const sum = (): number => { + let n = 0; + for (const v of frames.values()) n += v; + return n; + }; + + /** + * Both markers must be present — see the docblock above for why each one + * alone is either not unique enough (`$field` alone also matches + * `bareFieldReferenceError`) or, on its own, unverified against a message + * shape this predicate has never seen. + */ + const isExpectedCrossFieldRefusal = (detail: string): boolean => + detail.includes('cannot be compiled here') && detail.includes('$field'); + + return { + frames, + totalFrames: () => sum(), + objectsSeen: () => [...frames.keys()].sort(), + + silentChannels(required: readonly string[] = objects): string[] { + const out: string[] = []; + for (const o of required) { + if ((frames.get(o) ?? 0) === 0) { + out.push( + `the engine's 'Find operation failed' frame for the cross-field refusal on '${o}' was never emitted`, + ); + } + } + return out; + }, + + 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 detail = String((err as { message?: string } | undefined)?.message ?? ''); + if ( + msg === 'Find operation failed' && + object !== undefined && + objects.includes(object) && + isExpectedCrossFieldRefusal(detail) + ) { + bump(object); + return; + } + target.error(msg, err, meta); + } + : target[key], + }); + }, + }; +}