From 8c80d9c3d20d4184efc6f723bf3d926af740ff88 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 22:06:29 +0000 Subject: [PATCH 1/6] test(service-analytics): pin one comparand verdict per read scope across the three analytics faces (measurement, before the fix) The ObjectQL execute face refuses a read scope the shared comparand faces refuse; the NativeSQL execute face and the /analytics/sql echo lower the same scope through compileScopedFilterToSql. This commit adds the three-face suite alone, so its run on this tree records what each face answers today. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude --- .../read-scope-comparand-three-faces.test.ts | 477 ++++++++++++++++++ 1 file changed, 477 insertions(+) create mode 100644 packages/services/service-analytics/src/__tests__/read-scope-comparand-three-faces.test.ts diff --git a/packages/services/service-analytics/src/__tests__/read-scope-comparand-three-faces.test.ts b/packages/services/service-analytics/src/__tests__/read-scope-comparand-three-faces.test.ts new file mode 100644 index 00000000000..50ca0c931b6 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/read-scope-comparand-three-faces.test.ts @@ -0,0 +1,477 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20018] ONE read scope, ONE verdict on its comparands — on all three + * analytics faces that serve it. + * + * #19995 taught the ObjectQL EXECUTE face to judge a `getReadScope` output with + * the engine's two SHARED comparand faces (`@objectstack/spec/data`'s + * `assertListComparandShapes` and `normalizeFilterComparandTypes`, through + * `assertReadScopeComparandsRunnable`) and to refuse what they refuse as + * `READ_SCOPE_COMPILE_FAILED` / 500, message withheld. The NativeSQL EXECUTE + * face (`NativeSQLStrategy.applyReadScope`) and the `/analytics/sql` ECHO + * (`ObjectQLStrategy.generateSql`) never meet that guard: both lower the scope + * through `compileScopedFilterToSql`, whose own gates are narrower than the two + * faces. So one scope got two answers, chosen by which face served it. + * + * ## MEASURED on the pre-fix tree, rows EXECUTED + * + * One `SqliteWasmDriver`, four fixture rows (`region` NULL on d3), a real + * `ObjectQL` engine behind the ObjectQL face; the echo's SQL is run on the same + * database, so "what the echo shows" is a row set and not a string. Measured on + * `9d81af714f` (`origin/main`, before this change): + * + * | read-scope shape class | ObjectQL execute | echo (SQL run) | native execute | + * |------------------------------------------------|------------------|----------------|-------------------------| + * | plain-object comparand under `$eq` | 500 withheld | compiles; DB refuses | `DATABASE_ERROR` / 500 | + * | null member in `$in` (`['emea', null]`) | 500 withheld | d1 | d1 — the null member matches nothing | + * | null member in `$in` under `$not` | 500 withheld | d3 | d3 — only the NULL row, which the scope excludes | + * | null member in `$nin` (`['apac', null]`) | 500 withheld | d3 | d3 — only the NULL row, which the scope excludes | + * | null comparand under `$gt` / `$lte` | 500 withheld | no rows | no rows | + * | null `$between` bound | 500 withheld | no rows | no rows | + * | blank `$between` bound | 500 withheld | d1 d2 d4 | d1 d2 d4 | + * | plain-object comparand under `$ne` / `$gt` | 500 withheld | compiles; DB refuses | `DATABASE_ERROR` / 500 | + * | bigint beyond 2^53 | 500 withheld | no rows | no rows | + * | binary comparand, binary `$in` member | 500 withheld | no rows | no rows | + * | non-plain object (`Map`) comparand | 500 withheld | compiles; DB refuses | `DATABASE_ERROR` / 500 | + * + * Every control below agreed on all three faces, row for row. The ObjectQL + * column is the #5367 envelope the other two now give as well: the rulings + * that refuse each shape (#7872's comparand types, the null-member ruling of + * 2026-08-31, the null-ordering ruling of 2026-09-01, the blank-bound ruling of + * 2026-09-20) are the shared faces' own, and this file does not re-argue them. + * + * ## Where the judgement stands, and why AFTER the lowering + * + * Inside `compileScopedFilterToSql`, once `compileNode` has returned — so it + * holds for both faces and for any direct consumer of that public export. The + * two walks are pure functions of the scope, so the ORDER cannot change which + * scopes are refused, only which sentence a doubly-refused scope carries. After + * the lowering, a shape the compiler already refused keeps its own message + * (the `#13926` precedent); the shared faces add only what the compiler would + * otherwise have lowered. Both halves of that are pinned below. + * + * ## What must NOT move + * + * - Well-formed scopes, including the null PREDICATE spellings, an emptied + * `$in` beside an own-rows grant (the live RLS composite), and the spelling + * the null-member ruling prescribes: served, the same rows on every face. + * - The caller's own `where`. It never reaches this compiler, and it answers + * exactly as it answers with no scope at all. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; +import { declaredRefusalMessage, resolveThrownHttpError, serverFaultProvenance } from '@objectstack/types'; +import type { Cube, FilterCondition } from '@objectstack/spec/data'; +import type { AnalyticsQuery, StrategyContext } from '@objectstack/spec/contracts'; + +import { AnalyticsService } from '../analytics-service.js'; +import { ObjectQLStrategy } from '../strategies/objectql-strategy.js'; +import { NativeSQLStrategy } from '../strategies/native-sql-strategy.js'; +import { compileScopedFilterToSql } from '../read-scope-sql.js'; + +const OBJECT = 'deal'; + +const FIELDS: Record> = { + id: { type: 'text', name: 'id' }, + region: { type: 'text', name: 'region' }, + owner: { type: 'text', name: 'owner' }, + amount: { type: 'number', name: 'amount' }, +}; + +/** `region` is NULL on d3, so a NULL-matching regression shows up as a wrong id set. */ +const ROWS = [ + { id: 'd1', region: 'emea', owner: 'u_me', amount: 10 }, + { id: 'd2', region: 'apac', owner: 'u_other', amount: 20 }, + { id: 'd3', region: null, owner: 'u_me', amount: 30 }, + { id: 'd4', region: 'amer', owner: null, amount: null }, +]; +const ALL = ['d1', 'd2', 'd3', 'd4']; + +const CUBE: Cube = { + name: 'deals', + sql: OBJECT, + measures: { n: { sql: '*', type: 'count', title: 'n' } }, + dimensions: Object.fromEntries( + ['id', 'region'].map((n) => [n, { name: n, label: n, type: 'string', sql: n }]), + ), + public: false, +} as unknown as Cube; + +const QUERY = { cube: 'deals', dimensions: ['id'], measures: ['n'] } as AnalyticsQuery; + +interface WireBearingError extends Error { + code?: unknown; + status?: unknown; +} + +/** One face's outcome: a refusal, or the sorted ids the face actually served. */ +type Outcome = { refusal?: WireBearingError; admitted?: string[] }; + +const quiet = { + debug() {}, + info() {}, + warn() {}, + error() {}, + child() { + return quiet; + }, +}; + +/** + * Each row: a read-scope shape the shared comparand faces refuse and the + * pre-fix `compileScopedFilterToSql` lowered. Class names only — what each + * pre-fix face answered is the header's table. + */ +const REFUSED: Array<{ name: string; scope: unknown }> = [ + { name: 'a plain-object comparand under $eq', scope: { region: { $eq: { restricted_key: 1 } } } }, + { name: 'a null member in $in', scope: { region: { $in: ['emea', null] } } }, + { name: 'a null member in $in under $not', scope: { $not: { region: { $in: ['apac', null] } } } }, + { name: 'a null member in $nin', scope: { region: { $nin: ['apac', null] } } }, + { name: 'a null comparand under an ordering operator', scope: { amount: { $gt: null } } }, + { name: 'a null $between bound', scope: { amount: { $between: [null, 25] } } }, + { name: 'a blank $between bound', scope: { region: { $between: ['', 'f'] } } }, + { name: 'a plain-object comparand under $ne', scope: { region: { $ne: { restricted_key: 1 } } } }, + { name: 'a bigint comparand beyond 2^53', scope: { amount: 9007199254740993n } }, + { name: 'a binary comparand', scope: { region: { $eq: new Uint8Array([101]) } } }, + { name: 'a binary member in $in', scope: { region: { $in: [new Uint8Array([101])] } } }, + { name: 'a non-plain object comparand', scope: { region: { $eq: new Map([['k', 1]]) } } }, + { + name: 'a null member nested in an $or beside a well-formed arm', + scope: { $or: [{ owner: 'u_me' }, { region: { $in: ['emea', null] } }] }, + }, +]; + +/** Well-formed scopes, and the rows each admits on every face. */ +const SERVED: Array<{ name: string; scope: unknown; rows: string[] }> = [ + { name: 'a membership', scope: { region: { $in: ['emea', 'amer'] } }, rows: ['d1', 'd4'] }, + { name: 'an implicit equality', scope: { owner: 'u_me' }, rows: ['d1', 'd3'] }, + { name: 'an ordering comparison', scope: { amount: { $gt: 15 } }, rows: ['d2', 'd3'] }, + { name: 'a two-bound range', scope: { amount: { $between: [15, 30] } }, rows: ['d2', 'd3'] }, + { name: 'the null predicate (implicit)', scope: { region: null }, rows: ['d3'] }, + { name: 'the null predicate ($ne: null)', scope: { region: { $ne: null } }, rows: ['d1', 'd2', 'd4'] }, + { name: 'a non-empty exclusion (NULL-safe)', scope: { region: { $nin: ['apac'] } }, rows: ['d1', 'd3', 'd4'] }, + { + name: 'an emptied membership beside an own-rows grant (the live RLS composite)', + scope: { $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] }, + rows: ['d1', 'd3'], + }, + { + name: 'the spelling the null-member ruling prescribes for "one of these, or no value"', + scope: { $or: [{ region: { $in: ['emea'] } }, { region: { $null: true } }] }, + rows: ['d1', 'd3'], + }, +]; + +/** A scope that admits every fixture row, so the caller's `where` is the only constraint. */ +const ADMITS_ALL = { id: { $in: ALL } }; + +describe('[#20018] one read scope, one comparand verdict — ObjectQL / echo / native agree', () => { + let driver: SqliteWasmDriver; + let engine: ObjectQL; + /** The ObjectQL EXECUTE face — `withReadScope`'s door (#19995), via the service. */ + let service: AnalyticsService; + /** Swapped per case; the `getReadScope` contract filled by hand, never by the RLS compiler. */ + let readScope: unknown = null; + /** Statements the NATIVE face handed to the database — a refusal must precede execution. */ + let rawStatements = 0; + + const runRawSql = async (sql: string, params: unknown[]): Promise[]> => { + const result = await driver.execute(sql.replace(/\$\d+/g, '?'), params as unknown[]); + if (Array.isArray(result)) return result as Record[]; + if (result && typeof result === 'object' && 'rows' in (result as Record)) { + return (result as { rows: Record[] }).rows; + } + return []; + }; + + beforeAll(async () => { + driver = new SqliteWasmDriver({ filename: ':memory:' }); + (driver as unknown as { logger: unknown }).logger = quiet; + await driver.initObjects([{ name: OBJECT, fields: FIELDS }] as never); + for (const row of ROWS) await driver.create(OBJECT, { ...row }); + + engine = new ObjectQL({ logger: quiet }); + engine.registerDriver(driver as never, true); + await engine.init(); + engine.registerObject({ name: OBJECT, label: 'Deal', fields: FIELDS } as never); + + service = new AnalyticsService({ + cubes: [CUBE], + logger: quiet, + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + getReadScope: () => (readScope ?? undefined) as FilterCondition | undefined, + // The auto-bridge's own mapping (`plugin.ts`): the filter travels + // verbatim, so what answers a served scope is the engine's lowering. + executeAggregate: async (objectName, options) => + (await engine.aggregate(objectName, { + where: options.filter, + groupBy: options.groupBy, + aggregations: options.aggregations?.map((a) => ({ + function: a.method, + field: a.field, + alias: a.alias, + })), + context: options.context, + } as never)) as Record[], + }); + }); + + afterAll(async () => { + await driver?.disconnect?.(); + }); + + /** A hand-filled `StrategyContext` for driving one strategy directly. */ + const directCtx = (nativeSql: boolean): StrategyContext => + ({ + getCube: (name: string) => (name === 'deals' ? CUBE : undefined), + queryCapabilities: () => ({ nativeSql, objectqlAggregate: !nativeSql, inMemory: false }), + getReadScope: () => (readScope ?? undefined) as FilterCondition | undefined, + executeRawSql: (_object: string, sql: string, params: unknown[]) => { + rawStatements += 1; + return runRawSql(sql, params); + }, + }) as unknown as StrategyContext; + + const ids = (rows: Array>): string[] => rows.map((r) => String(r.id)).sort(); + + /** Face 1 — ObjectQL EXECUTE (engine lowering; #19995's door). */ + const objectqlFace = async (scope: unknown, where?: unknown): Promise => { + readScope = scope; + try { + const result = await service.query({ ...QUERY, ...(where ? { where } : {}) } as AnalyticsQuery); + return { admitted: ids(result.rows) }; + } catch (e) { + return { refusal: e as WireBearingError }; + } + }; + + /** Face 2 — the `/analytics/sql` ECHO, its SQL then EXECUTED on the same database. */ + const echoFace = async (scope: unknown, where?: unknown): Promise => { + readScope = scope; + try { + const q = { ...QUERY, ...(where ? { where } : {}) } as AnalyticsQuery; + const { sql, params } = await new ObjectQLStrategy().generateSql(q, directCtx(false)); + return { admitted: ids(await runRawSql(sql, params)) }; + } catch (e) { + return { refusal: e as WireBearingError }; + } + }; + + /** Face 3 — NativeSQL EXECUTE (`applyReadScope` → `ctx.executeRawSql`): real rows. */ + const nativeFace = async (scope: unknown, where?: unknown): Promise => { + readScope = scope; + try { + const q = { ...QUERY, ...(where ? { where } : {}) } as AnalyticsQuery; + const result = await new NativeSQLStrategy().execute(q, directCtx(true)); + return { admitted: ids(result.rows) }; + } catch (e) { + return { refusal: e as WireBearingError }; + } + }; + + const FACES = [ + ['objectql', objectqlFace], + ['echo', echoFace], + ['native', nativeFace], + ] as const; + + /** + * The #5367 envelope, and the reads every analytics HTTP door takes before + * relaying prose: a producer-declared 5xx that is not a declared refusal is + * withheld. + */ + const expectWithheldRefusal = (face: string, o: Outcome): void => { + expect(o.admitted, `${face}: expected a refusal, got rows`).toBeUndefined(); + expect(o.refusal, `${face}: expected an Error`).toBeInstanceOf(Error); + expect(o.refusal?.code, `${face}: envelope code`).toBe('READ_SCOPE_COMPILE_FAILED'); + expect(o.refusal?.status, `${face}: envelope status`).toBe(500); + expect(serverFaultProvenance(resolveThrownHttpError(o.refusal, 500)), `${face}: provenance`).toBe('declared'); + expect(declaredRefusalMessage(o.refusal), `${face}: relayed prose`).toBeUndefined(); + }; + + // ── Fixture honesty ──────────────────────────────────────────────────────── + + it('CONTROL: with no scope, every face serves the whole fixture', async () => { + // Without this, every refusal below could pass on a harness that admits + // nothing, and the echo face would be executing SQL over no rows. + for (const [face, run] of FACES) { + const o = await run(null); + expect(o.refusal?.message, `${face}: fixture control refusal`).toBeUndefined(); + expect(o.admitted, `${face}: fixture control`).toEqual(ALL); + } + }); + + // ── The refused shape classes — one verdict per scope ───────────────────── + + for (const c of REFUSED) { + it(`${c.name} is refused on all three faces, in the one withheld envelope`, async () => { + for (const [face, run] of FACES) { + expectWithheldRefusal(face, await run(c.scope)); + } + }); + } + + it('the native face refuses before any statement reaches the database', async () => { + for (const c of REFUSED) { + const before = rawStatements; + const o = await nativeFace(c.scope); + expect(o.refusal?.code, c.name).toBe('READ_SCOPE_COMPILE_FAILED'); + expect(rawStatements - before, `${c.name}: statements executed`).toBe(0); + } + }); + + // ── Door-distinguishable messages — same verdict, truer sentence ───────── + + it('a shape the compiler already refused keeps ITS OWN sentence on echo and native', async () => { + // The ordering is a choice (the lowering first, the shared faces after), + // pinned here so a later move of the call is visible: both faces also refuse + // a list under `$eq`, and the compiler's sentence names the `$in` remedy. + for (const run of [echoFace, nativeFace]) { + const o = await run({ region: { $eq: ['emea', 'amer'] } }); + expect(o.refusal?.code).toBe('READ_SCOPE_COMPILE_FAILED'); + expect(String(o.refusal?.message)).toContain('array value for "region".$eq'); + expect(String(o.refusal?.message)).not.toContain('carries a comparand the engine refuses'); + } + }); + + it('a shape only the shared faces refuse carries their sentence, the same on all three faces', async () => { + for (const [face, run] of FACES) { + const o = await run({ region: { $in: ['emea', null] } }); + expect(String(o.refusal?.message), face).toContain('carries a comparand the engine refuses'); + expect(String(o.refusal?.message), face).toContain('does not accept null as a list member'); + } + }); + + // ── Served scopes — same ADMISSION on all three faces ──────────────────── + + for (const c of SERVED) { + it(`CONTROL: ${c.name} admits the same rows on every face`, async () => { + for (const [face, run] of FACES) { + const o = await run(c.scope); + expect(o.refusal?.message, `${face}: unexpected refusal`).toBeUndefined(); + expect(o.admitted, `${face}: admitted set`).toEqual(c.rows); + } + }); + } + + // ── The caller's own `where` — untouched ───────────────────────────────── + + it('CONTROL: a well-formed caller `where` composes with a well-formed scope on every face', async () => { + for (const [face, run] of FACES) { + const o = await run({ owner: 'u_me' }, { amount: { $gt: 15 } }); + expect(o.refusal?.message, `${face}: unexpected refusal`).toBeUndefined(); + expect(o.admitted, `${face}: scope AND where`).toEqual(['d3']); + } + }); + + it('CONTROL: a caller `where` in a refused scope shape answers exactly as it does with no scope', async () => { + // The caller's filter never reaches the read-scope compiler, so its answer — + // served or refused, by whichever door owns it — is the same beside a scope + // that admits every row as beside no scope, and it is never attributed to + // the read scope. + const where = { region: { $in: ['emea', null] } }; + for (const [face, run] of FACES) { + const alone = await run(null, where); + const scoped = await run(ADMITS_ALL, where); + expect(scoped.refusal?.code, `${face}: attributed to the read scope`).not.toBe('READ_SCOPE_COMPILE_FAILED'); + expect(scoped.admitted, `${face}: rows`).toEqual(alone.admitted); + expect(scoped.refusal?.code, `${face}: refusal code`).toBe(alone.refusal?.code); + expect(scoped.refusal?.status, `${face}: refusal status`).toBe(alone.refusal?.status); + } + }); +}); + +// ── The joined-object hop on the native strategy ─────────────────────────── + +describe('[#20018] `applyReadScope` judges the JOINED object\'s scope too', () => { + /** + * `applyReadScope` lowers one scope per object in the statement — the base + * table and every joined hop (ADR-0021 D-C per-hop injection) — each through + * `compileScopedFilterToSql`. SQL-build level: the refusal fires before + * `executeRawSql`, so a stub executor proves it can never be reached. + */ + const joinCube: Cube = { + name: 'sales', + title: 'Sales', + sql: 'opportunity', + measures: { revenue: { name: 'revenue', label: 'Revenue', type: 'sum', sql: 'amount' } }, + dimensions: { region: { name: 'region', label: 'Region', type: 'string', sql: 'account.region' } }, + public: false, + }; + + const ctxWith = (accountScope: unknown): StrategyContext => + ({ + getCube: (name: string) => (name === 'sales' ? joinCube : undefined), + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }), + getAllowedRelationships: () => new Set(['account']), + getReadScope: (obj: string) => + (obj === 'opportunity' ? { organization_id: 'org_A' } : accountScope) as FilterCondition, + executeRawSql: async () => { + throw new Error('unreachable: the refusal must fire before execution'); + }, + }) as unknown as StrategyContext; + + const JOIN_QUERY = { + cube: 'sales', + measures: ['revenue'], + dimensions: ['region'], + timezone: 'UTC', + } as AnalyticsQuery; + + it('CONTROL: a well-formed joined-object scope still builds a per-hop-scoped statement', async () => { + const { sql } = await new NativeSQLStrategy().generateSql(JOIN_QUERY, ctxWith({ organization_id: 'org_A' })); + expect(sql).toContain('"opportunity"."organization_id" ='); + expect(sql).toContain('"account"."organization_id" ='); + }); + + it('a joined-object scope with a null membership member is refused, named for the joined hop', async () => { + let err: WireBearingError | undefined; + try { + await new NativeSQLStrategy().generateSql(JOIN_QUERY, ctxWith({ organization_id: { $in: ['org_A', null] } })); + } catch (e) { + err = e as WireBearingError; + } + expect(err).toBeInstanceOf(Error); + expect(err?.code).toBe('READ_SCOPE_COMPILE_FAILED'); + expect(err?.status).toBe(500); + // The BASE object's scope is well-formed, so only the joined hop can refuse. + expect(String(err?.message)).toContain('read scope for "account"'); + }); +}); + +// ── The public export itself ──────────────────────────────────────────────── + +describe('[#20018] `compileScopedFilterToSql` — the public export answers what the faces answer', () => { + const compile = (scope: unknown) => compileScopedFilterToSql(scope as FilterCondition, 'deal'); + + const verdict = (scope: unknown): string => { + try { + compile(scope); + return 'compiled'; + } catch (e) { + const err = e as WireBearingError; + return `${String(err.code)}/${String(err.status)}`; + } + }; + + it('refuses every class the shared faces refuse, in the module envelope', () => { + for (const c of REFUSED) { + expect(verdict(c.scope), c.name).toBe('READ_SCOPE_COMPILE_FAILED/500'); + } + }); + + it('CONTROL: a well-formed scope compiles to the same bound predicate as before', () => { + expect(compile({ region: { $in: ['emea', 'amer'] } })).toEqual({ + sql: '"deal"."region" IN (?, ?)', + params: ['emea', 'amer'], + }); + expect(compile({ region: null })).toEqual({ sql: '"deal"."region" IS NULL', params: [] }); + expect(compile({ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] })).toEqual({ + sql: '(1 = 0 OR "deal"."owner" = ?)', + params: ['u_me'], + }); + }); +}); From 6805864a292379de3ca9c2cb571819c5fded50f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 22:11:22 +0000 Subject: [PATCH 2/6] fix(service-analytics): the read-scope compiler runs the shared comparand faces, so native and echo answer as the ObjectQL face does compileScopedFilterToSql now calls assertReadScopeComparandsRunnable once its own lowering returns. A read scope the shared comparand faces refuse is refused as READ_SCOPE_COMPILE_FAILED / 500 on the NativeSQL execute face and the /analytics/sql echo, as the ObjectQL execute face already refuses it, instead of being lowered and served. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude --- .../service-analytics/src/read-scope-sql.ts | 45 ++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/services/service-analytics/src/read-scope-sql.ts b/packages/services/service-analytics/src/read-scope-sql.ts index 35184098df9..34705e267db 100644 --- a/packages/services/service-analytics/src/read-scope-sql.ts +++ b/packages/services/service-analytics/src/read-scope-sql.ts @@ -2,7 +2,8 @@ import type { FilterCondition } from '@objectstack/spec/data'; // [#19995] The engine's own shared comparand faces — run on a read scope, alone, -// at the ObjectQL merge sites by {@link assertReadScopeComparandsRunnable}. +// at the ObjectQL merge sites by {@link assertReadScopeComparandsRunnable}, and +// [#20018] at the end of {@link compileScopedFilterToSql} by the same function. import { assertListComparandShapes, normalizeFilterComparandTypes } from '@objectstack/spec/data'; import type { RegisteredErrorCode } from '@objectstack/spec/api'; import { type LikeShape } from './like-pattern.js'; @@ -431,6 +432,35 @@ import { * {@link assertReadScopeComparandsRunnable} closes it at the two engine-bound * merge sites. See there for why its refusal set is exactly the engine's, and * for what it deliberately leaves to the engine. + * + * ## …and THIS compiler runs the same two faces, after its own gates (#20018) + * + * The paragraph above held for most shapes, not all of them: this compiler's own + * gates are narrower than the two shared faces, so a class of scopes the + * ObjectQL face refused was LOWERED here and served by the NativeSQL face and + * the echo. Measured on real SQLite (`read-scope-comparand-three-faces.test.ts` + * carries the table): + * + * - a `null` member of `$in` compiled to `IN (…, NULL)`, which matches nothing + * through the NULL — and under `$not`, or as a `$nin` member, the scope + * admitted ONLY the rows whose column is NULL, which it names as excluded; + * - a `null` ordering comparand or `$between` bound compiled to a comparison + * with NULL: zero rows, silently; + * - a blank `$between` bound was bound and served; + * - a bigint beyond 2^53 and a binary comparand (a package-local bindable, + * `comparand-shape.ts`) bound and matched nothing; + * - a plain-object or other non-plain-object comparand in a scalar position + * was bound, and the DATABASE refused the statement (`DATABASE_ERROR`). + * + * {@link compileScopedFilterToSql} now calls + * {@link assertReadScopeComparandsRunnable} once {@link compileNode} returns. + * Both faces are pure walks of the scope, so the order cannot change WHICH + * scopes are refused — the verdict is the union of the two gate sets either + * way — only which sentence a doubly-refused scope carries. After the lowering, + * a shape this compiler already refuses keeps its own message (the #13926 + * ordering, for the same door-distinguishable log), and the faces add only what + * would otherwise have been lowered. The envelope is this module's one + * envelope; nothing here re-argues the rulings the faces carry. */ const IDENT = /^[a-z_][a-z0-9_]*$/i; @@ -533,6 +563,13 @@ export function compileScopedFilterToSql( const quotedAlias = quoteIdent(alias, 'alias'); const params: unknown[] = []; const sql = compileNode(filter, quotedAlias, params, options); + // [#20018] The shared comparand faces, on the scope ALONE: the judgement the + // ObjectQL execute face makes at its merge sites (#19995), made here too, so + // one read scope gets one verdict on all three analytics faces. AFTER the + // lowering on purpose: a shape this compiler already refuses keeps its own + // sentence, and the faces add exactly the shapes it would otherwise have + // lowered. See the module header's #20018 section. + assertReadScopeComparandsRunnable(filter, alias); return { sql, params }; } @@ -680,6 +717,12 @@ export function assertReadScopeCannotVacate(scope: unknown, objectName: string): * around `executeAggregate`: the caller's own `where` still reaches the * engine's doors for some shapes, and those refusals are the caller's to read. * + * [#20018] Its third caller is {@link compileScopedFilterToSql}, after the + * lowering, which is how the NativeSQL face and the `/analytics/sql` echo give + * the same verdict as the two merges above. There `objectName` is the alias the + * scope is compiled for: the object's name on the base table, the join alias on + * a joined hop. + * * ## Why the refusal set is exactly the engine's * * The two faces called below are the ones the engine runs on every From f9ddc3fa81c1eac5f3de0d4ebba44f5fc028cb50 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 22:16:01 +0000 Subject: [PATCH 3/6] test(service-analytics): re-judge the pins that asserted the read-scope lowering binds a shape the shared faces refuse Nine existing cases asserted that compileScopedFilterToSql lowers a null list member or range bound, a binary comparand, a plain-object comparand under a scalar operator, or an object with a non-Object prototype. Each is refused now, as the ObjectQL execute face already refused it; every case keeps what it was there to pin, with a note naming this change. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude --- .../comparand-door-single-source.test.ts | 25 ++++++++-- .../__tests__/comparand-shape-refusal.test.ts | 14 +++++- .../cross-field-reference-refusal.test.ts | 10 +++- .../read-scope-boolean-flag-comparand.test.ts | 12 ++++- .../read-scope-undefined-comparand.test.ts | 46 +++++++++++++------ .../service-analytics/src/comparand-shape.ts | 6 +++ 6 files changed, 94 insertions(+), 19 deletions(-) diff --git a/packages/services/service-analytics/src/__tests__/comparand-door-single-source.test.ts b/packages/services/service-analytics/src/__tests__/comparand-door-single-source.test.ts index e7cd7fd4fc6..ceeaa0a5b01 100644 --- a/packages/services/service-analytics/src/__tests__/comparand-door-single-source.test.ts +++ b/packages/services/service-analytics/src/__tests__/comparand-door-single-source.test.ts @@ -45,6 +45,9 @@ * - **binary** binds but has no faithful text rendering, so it is accepted in * a bind position and refused by the LIKE family. That asymmetry is the * reason the package carries two predicates rather than one with a flag. + * [#20018] On the READ-SCOPE door it is now refused in every position: the + * lowering runs the shared comparand-type face after its own gates, as the + * ObjectQL execute face does. The predicate's own answer is unchanged. * * @see comparand-shape.ts — the predicates and the messages this pins * @see https://github.com/objectstack-ai/objectstack/issues/8186 @@ -124,9 +127,15 @@ const MATRIX: readonly Row[] = [ { label: 'boolean', value: true, bindable: true, renderable: true, whereLike: OK, whereIn: OK, whereEq: OK, scopeLike: OK, scopeIn: OK, scopeEq: OK }, + // [#20018] One cell moved AFTER the #8186 measurement, on purpose: the + // read-scope lowering now runs the shared list-shape face after its own + // gates, so a `null` MEMBER of `$in` is refused there by the null-member + // ruling (2026-08-31), as the ObjectQL execute face already refused it + // (`read-scope-comparand-three-faces.test.ts`). `null` as a scalar or LIKE + // comparand is not a list member and does not move. { label: 'null', value: null, bindable: true, renderable: true, - whereLike: OK, whereIn: OK, whereEq: OK, scopeLike: OK, scopeIn: OK, scopeEq: OK }, + whereLike: OK, whereIn: OK, whereEq: OK, scopeLike: OK, scopeIn: REFUSED_SCOPE, scopeEq: OK }, { label: 'Date', value: new Date('2026-01-01T00:00:00.000Z'), bindable: true, renderable: true, whereLike: OK, whereIn: OK, whereEq: OK, scopeLike: OK, scopeIn: OK, scopeEq: OK }, @@ -139,17 +148,27 @@ const MATRIX: readonly Row[] = [ whereLike: REFUSED_WHERE, whereIn: REFUSED_WHERE, whereEq: REFUSED_WHERE, scopeLike: REFUSED_SCOPE, scopeIn: REFUSED_SCOPE, scopeEq: REFUSED_SCOPE }, // binary: binds, does not render — accepted where it binds, refused by LIKE. + // [#20018] Two cells moved AFTER the #8186 measurement, on purpose: the + // read-scope lowering now runs the shared comparand-type face after its own + // gates, and that face does not admit binary (the predicate above it still + // does — the `where` door keeps the extra, the read scope no longer reaches + // it). The ObjectQL execute face already refused a binary read-scope + // comparand. { label: 'binary', value: new Uint8Array([1, 2]), bindable: true, renderable: false, whereLike: REFUSED_WHERE, whereIn: OK, whereEq: OK, - scopeLike: REFUSED_SCOPE, scopeIn: OK, scopeEq: OK }, + scopeLike: REFUSED_SCOPE, scopeIn: REFUSED_SCOPE, scopeEq: REFUSED_SCOPE }, // ── shapes outside the fence ────────────────────────────────────────────── // `$eq` accepts them on purpose: #5234 left the `{$eq: {…}}` account alone. + // [#20018] …on the `where` door. On the read-scope lowering the shared + // comparand-type face now closes that account (#7872's set), the answer the + // ObjectQL execute face already gave: a plain object bound as a scalar was + // refused by the database at execution, not by this package. { label: 'plain object', value: { foo: 1 }, bindable: false, renderable: false, whereLike: REFUSED_WHERE, whereIn: REFUSED_WHERE, whereEq: OK, - scopeLike: REFUSED_SCOPE, scopeIn: REFUSED_SCOPE, scopeEq: OK }, + scopeLike: REFUSED_SCOPE, scopeIn: REFUSED_SCOPE, scopeEq: REFUSED_SCOPE }, // [#19975] One cell of this row moved AFTER the #8186 measurement, on // purpose: ruling 乙 (#19757) refuses a list in the equality slot, and the // read-scope lowering now refuses it under `$eq` instead of binding the list diff --git a/packages/services/service-analytics/src/__tests__/comparand-shape-refusal.test.ts b/packages/services/service-analytics/src/__tests__/comparand-shape-refusal.test.ts index b6134e3ab46..a42101105cb 100644 --- a/packages/services/service-analytics/src/__tests__/comparand-shape-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/comparand-shape-refusal.test.ts @@ -257,7 +257,19 @@ describe('[#5234] the read-scope lowering refuses the same two shapes, fail-clos describe('the guard is narrow here too', () => { it('keeps binding every legitimate `$in` member', () => { - expect(scope({ status: { $in: ['a', null, 7, true] } }).params).toEqual(['a', null, 7, true]); + expect(scope({ status: { $in: ['a', 7, true] } }).params).toEqual(['a', 7, true]); + }); + + it('[#20018] a `null` member is no longer one of them — refused by the shared list-shape face', () => { + // This row used to bind `null` beside the three members above. The + // null-member ruling (2026-08-31) refuses it at the shared face, and the + // lowering now runs that face after its own gates, as the ObjectQL + // execute face does — so this door's own member gate is still narrow, + // and the refusal carries the face's sentence, not this door's. + const err = refusalOf(() => scope({ status: { $in: ['a', null, 7, true] } })); + expect(err.code).toBe('READ_SCOPE_COMPILE_FAILED'); + expect(err.status).toBe(500); + expect(err.message).toContain('does not accept null as a list member'); }); it('keeps every primitive LIKE comparand', () => { diff --git a/packages/services/service-analytics/src/__tests__/cross-field-reference-refusal.test.ts b/packages/services/service-analytics/src/__tests__/cross-field-reference-refusal.test.ts index d4d7705998d..94ac52f00ad 100644 --- a/packages/services/service-analytics/src/__tests__/cross-field-reference-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/cross-field-reference-refusal.test.ts @@ -408,7 +408,15 @@ describe('[#7598] the field-reference shape is read exactly as `driver-sql` read expect(tree({ amount: { $gt: { $field: 5 } } })).toEqual({ kind: 'leaf', member: 'amount', operator: 'gt', values: [{ $field: 5 }], }); - expect(scope({ amount: { $gt: { $field: 5 } } }).params).toEqual([{ $field: 5 }]); + // [#20018] The read-scope lowering no longer BINDS it: it now runs the + // shared comparand-type face after its own gates, which refuses a plain + // object in a scalar position (the ObjectQL execute face's answer too). It + // is still read as the object it is, not as a reference: the refusal is the + // type face's, not the field-reference gate's. + const err = refusalOf(() => scope({ amount: { $gt: { $field: 5 } } })); + expect(err.code).toBe('READ_SCOPE_COMPILE_FAILED'); + expect(err.message).toContain('is a plain object'); + expect(err.message).not.toContain('compares against the field reference'); }); it('an ordinary object comparand is untouched — #5234 left that account open', () => { diff --git a/packages/services/service-analytics/src/__tests__/read-scope-boolean-flag-comparand.test.ts b/packages/services/service-analytics/src/__tests__/read-scope-boolean-flag-comparand.test.ts index 2b2f4599943..8955f4debec 100644 --- a/packages/services/service-analytics/src/__tests__/read-scope-boolean-flag-comparand.test.ts +++ b/packages/services/service-analytics/src/__tests__/read-scope-boolean-flag-comparand.test.ts @@ -213,7 +213,17 @@ describe('[#6387] the eight measured cells are REFUSED, in this module’s own e // A prototype-borne key is not something an author wrote. const spec = Object.create({ $null: 'false' }) as Record; spec.$eq = 'u1'; - expect(compileScopedFilterToSql({ d: spec } as FilterCondition, ALIAS).sql).toBe('"t"."d" = ?'); + // [#20018] This used to compile to `"t"."d" = ?`. The scope as a whole is + // refused now — by the shared comparand-type face, which runs after this + // module's own gates and reads an object whose prototype is not + // `Object.prototype` as a VALUE, not as an operator spec (the ObjectQL + // execute face refuses it the same way). What this pin is about still + // holds, and still discriminates: this module's gates run FIRST, so a flag + // gate reading the inherited key would have answered with its own sentence. + const err = refusalFor({ d: spec } as FilterCondition); + expect(err?.code).toBe('READ_SCOPE_COMPILE_FAILED'); + expect(String(err?.message)).not.toContain('is not a boolean'); + expect(String(err?.message)).toContain('carries a comparand the engine refuses'); }); }); diff --git a/packages/services/service-analytics/src/__tests__/read-scope-undefined-comparand.test.ts b/packages/services/service-analytics/src/__tests__/read-scope-undefined-comparand.test.ts index 8d3f023d803..a61a7e85476 100644 --- a/packages/services/service-analytics/src/__tests__/read-scope-undefined-comparand.test.ts +++ b/packages/services/service-analytics/src/__tests__/read-scope-undefined-comparand.test.ts @@ -38,6 +38,13 @@ * polarity table in the module. Every row there passes both before and after, * SQL and binds byte for byte. * + * [#20018] Three rows left that group, for a reason that is not this ruling's: + * `null` as a MEMBER of `$in` / `$nin` and as a `$between` BOUND. Those are list + * and range positions, refused by the null-member ruling (2026-08-31) at the + * shared list-shape face, which the lowering now runs after its own gates. + * They are pinned as refused in their own block below. Every null PREDICATE + * spelling stays in the control group, unmoved. + * * `describe('what the sweep deliberately leaves alone')` records the boundary of * the ruling: the positions that are NOT comparands, and the shapes this module * already refuses with a truer diagnosis. @@ -132,19 +139,6 @@ const NULL_CONTROL: Array<{ name: string; filter: FilterCondition; sql: string; { name: '{ d: null } — the implicit null predicate', filter: { d: null }, sql: '"t"."d" IS NULL', params: [] }, { name: '{ $eq: null }', filter: { d: { $eq: null } }, sql: '"t"."d" IS NULL', params: [] }, { name: '{ $ne: null }', filter: { d: { $ne: null } }, sql: '"t"."d" IS NOT NULL', params: [] }, - { name: '{ $in: [null] } — null is a bindable MEMBER', filter: { d: { $in: [null] } }, sql: '"t"."d" IN (?)', params: [null] }, - { - name: '{ $nin: [null] } — NULL-safe negative (#5298)', - filter: { d: { $nin: [null] } }, - sql: '("t"."d" IS NULL OR "t"."d" NOT IN (?))', - params: [null], - }, - { - name: '{ $between: [null, 5] } — null is a bindable BOUND', - filter: { d: { $between: [null, 5] } }, - sql: '"t"."d" BETWEEN ? AND ?', - params: [null, 5], - }, { name: '{ $not: { d: null } } — already total, no guard added', filter: { $not: { d: null } }, sql: 'NOT ("t"."d" IS NULL)', params: [] }, { name: '{ $not: { d: { $ne: null } } } — the #5146 rewrite still leaves it alone', @@ -230,6 +224,32 @@ describe('[#6125] the null control group is UNTOUCHED — SQL and binds, byte fo }); }); +describe('[#20018] null as a LIST MEMBER or RANGE BOUND left the control group — refused by the shared face', () => { + /** + * These three rows were in the control group above, compiling to a bound + * NULL (`IN (?)`, `NOT IN (?)`, `BETWEEN ? AND ?`). The lowering now runs the + * shared list-shape face after its own gates, and the null-member ruling + * (2026-08-31) refuses all three positions there — the answer the ObjectQL + * execute face already gave. Not #6125's refusal: the sentence is the face's, + * and nothing here moves `null` as a comparand or as the null predicate. + */ + const MOVED: Array<{ name: string; filter: FilterCondition; says: string }> = [ + { name: '{ $in: [null] }', filter: { d: { $in: [null] } }, says: 'does not accept null as a list member' }, + { name: '{ $nin: [null] }', filter: { d: { $nin: [null] } }, says: 'does not accept null as a list member' }, + { name: '{ $between: [null, 5] }', filter: { d: { $between: [null, 5] } }, says: 'requires two non-null bounds' }, + ]; + + for (const c of MOVED) { + it(`refused: ${c.name}`, () => { + const err = refusalFor(c.filter); + expect(err?.code).toBe('READ_SCOPE_COMPILE_FAILED'); + expect(err?.status).toBe(500); + expect(String(err?.message)).toContain(c.says); + expect(String(err?.message)).not.toContain('is undefined'); + }); + } +}); + describe('[#6125] what the sweep deliberately leaves alone', () => { it('$null / $exists take a declared BOOLEAN — refused by DOMAIN, not by position', () => { // ⚠️ [#6387] These are the two lines this block said would change "when it is diff --git a/packages/services/service-analytics/src/comparand-shape.ts b/packages/services/service-analytics/src/comparand-shape.ts index dc11cb6956a..0ab884e8dff 100644 --- a/packages/services/service-analytics/src/comparand-shape.ts +++ b/packages/services/service-analytics/src/comparand-shape.ts @@ -150,6 +150,12 @@ import { * engine-level door does not admit, and it is deliberately kept: a blob column * really is comparable on this driver family, and the read path measured it * accepted in every bind position. It is `driver-sql`'s own recorded extra too. + * + * [#20018] On the analytics `where` door, that is. A READ SCOPE no longer + * reaches this extra: `compileScopedFilterToSql` runs the shared comparand-type + * face after its own gates, and that face refuses binary, the answer the + * ObjectQL execute face already gave the same scope. This predicate's own + * answer is unchanged. */ export function isBindableComparand(value: unknown): boolean { // `undefined` — see {@link isRenderableTextComparand}'s note; it is admitted From f293340e94c86ddb74313b96c02ade5d667c64db Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 22:19:52 +0000 Subject: [PATCH 4/6] test(service-analytics): name the one matrix cell the null-member ruling moves on the read-scope door Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude --- .../comparand-door-single-source.test.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/services/service-analytics/src/__tests__/comparand-door-single-source.test.ts b/packages/services/service-analytics/src/__tests__/comparand-door-single-source.test.ts index ceeaa0a5b01..02e7a042109 100644 --- a/packages/services/service-analytics/src/__tests__/comparand-door-single-source.test.ts +++ b/packages/services/service-analytics/src/__tests__/comparand-door-single-source.test.ts @@ -285,9 +285,21 @@ describe('[#8186] the comparand matrix is unchanged by the door reconciliation', 'string', 'number', 'bigint', 'boolean', 'null', 'Date', ]); for (const row of doorTypes) { - for (const cell of [row.whereLike, row.whereIn, row.whereEq, - row.scopeLike, row.scopeIn, row.scopeEq]) { - expect(cell, row.label).toBe(OK); + const cells = { + whereLike: row.whereLike, whereIn: row.whereIn, whereEq: row.whereEq, + scopeLike: row.scopeLike, scopeIn: row.scopeIn, scopeEq: row.scopeEq, + }; + for (const [position, cell] of Object.entries(cells)) { + // [#20018] The one cell a SHAPE ruling moved, not a TYPE verdict: `null` + // is an accepted comparand type, and the null-member ruling (2026-08-31) + // refuses it as an `$in` MEMBER at the shared list-shape face, which the + // read-scope lowering now runs. Named here so no other cell can move + // under this sentence. + if (row.label === 'null' && position === 'scopeIn') { + expect(cell, `${row.label} ${position}`).toBe(REFUSED_SCOPE); + continue; + } + expect(cell, `${row.label} ${position}`).toBe(OK); } } }); From 0b03a53cae6171476ec3595dc5a966a16f208e6b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 22:39:59 +0000 Subject: [PATCH 5/6] chore(changeset): service-analytics minor, breaking narrowing of the read-scope lowering Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude --- .changeset/20018-native-read-scope-faces.md | 58 +++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .changeset/20018-native-read-scope-faces.md diff --git a/.changeset/20018-native-read-scope-faces.md b/.changeset/20018-native-read-scope-faces.md new file mode 100644 index 00000000000..ad0035d337d --- /dev/null +++ b/.changeset/20018-native-read-scope-faces.md @@ -0,0 +1,58 @@ +--- +'@objectstack/service-analytics': minor +--- + +fix(service-analytics)!: the NativeSQL execute face and the `/analytics/sql` echo refuse a read scope the shared comparand faces refuse, as the ObjectQL execute face already does (#20018) + +Clause-②: no (narrowing) + + + +**BREAKING** — an accept-set narrowing on the read-scope lowering, shipped as +`minor` under the launch-window convention (`check-changeset-no-major` refuses +`major` until GA; breaking-ness is carried by this banner and the ADR-0087 +disposition above, not by the level). + +**What changed.** `compileScopedFilterToSql` is the read-scope lowering behind the +NativeSQL execute face (`NativeSQLStrategy.applyReadScope`, base table and every +joined hop) and the `/analytics/sql` echo (`ObjectQLStrategy.generateSql`), and a +public export of this package. Once its own lowering succeeds, it now runs the two +shared comparand faces of `@objectstack/spec/data` on the scope. A scope they +refuse is refused as `READ_SCOPE_COMPILE_FAILED` / 500 with the message withheld +(the #5367 envelope), before any statement is built or executed. That is the +answer the ObjectQL execute face has given the same scope since #19995, so one read +scope now gets one verdict on every analytics face. + +**Which read scopes stop being served.** Each was lowered and executed before, and +each is refused by a standing ruling the shared faces carry. Measured on SQLite: + +| read-scope shape | what the native face and the echo served | +| --- | --- | +| a `null` member of `$in` | only the named non-null values; the NULL matched nothing | +| a `null` member of `$in` under `$not`, or of `$nin` | only the rows whose column is NULL, which the scope excludes | +| a `null` comparand under `$gt` / `$gte` / `$lt` / `$lte`, or a `null` `$between` bound | zero rows | +| a blank (`''`) `$between` bound | the rows inside the half-blank range | +| a bigint beyond ±2^53, or a binary comparand | zero rows | +| a plain-object or other non-plain-object comparand in a scalar position | the database refused the statement (`DATABASE_ERROR` / 500) | + +**Who is affected.** A host `getReadScope` provider, or a direct caller of +`compileScopedFilterToSql`, that produces one of these shapes. The ObjectQL +analytics face already refused all of them. No in-repo read-scope producer emits +them for a policy in this repository. An RLS `using` predicate can still be +written so that it lowers into the null shapes (a literal `null` inside an `in` +list, or an ordering comparison against `null`), and such a policy now gets the +withheld 500 on every analytics face. + +**Fix.** State absence with the null predicate. "One of these values, or no +value" is `{ "$or": [{ "f": { "$in": ["a"] } }, { "f": { "$null": true } }] }`, +which in an RLS predicate is `f in ['a'] || f == null`. A one-sided range is `$gte` +or `$lte`. A comparand is a string, number, bigint within ±2^53, boolean, `null` or +`Date`. + +**Unchanged.** + +- Well-formed scopes compile to the same SQL and admit the same rows. That includes + the null predicates, an emptied `$in` beside an own-rows grant, and the spelling + above. +- A shape the lowering already refused keeps its own log sentence. +- The caller's own `where` never reaches this lowering, and it is untouched. From 0fbed27877e5a70ec591352d32cc3250bf4fe64d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 23:18:12 +0000 Subject: [PATCH 6/6] test(service-analytics): scope the non-string $field note to the where door, where it still binds Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude --- .../src/__tests__/cross-field-reference-refusal.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/services/service-analytics/src/__tests__/cross-field-reference-refusal.test.ts b/packages/services/service-analytics/src/__tests__/cross-field-reference-refusal.test.ts index 6e089b20c52..93063f1f817 100644 --- a/packages/services/service-analytics/src/__tests__/cross-field-reference-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/cross-field-reference-refusal.test.ts @@ -417,8 +417,9 @@ describe('[#7598] the field-reference shape is read exactly as `driver-sql` read it('a NON-STRING `$field` is not a reference — it stays the object account', () => { // `driver-sql`'s `fieldReferenceOf` requires `typeof ref === 'string'`, and // this package mirrors that spelling rather than inventing a third reading. - // It is therefore NOT routed either: it binds as JSON, exactly as any other - // object comparand does, which is the account #5234 left open on purpose. + // It is therefore NOT routed either: on the `where` door it binds as JSON, + // exactly as any other object comparand does, which is the account #5234 + // left open on purpose there. expect(findCrossFieldComparand({ amount: { $gt: { $field: 5 } } })).toBeNull(); expect(tree({ amount: { $gt: { $field: 5 } } })).toEqual({ kind: 'leaf', member: 'amount', operator: 'gt', values: [{ $field: 5 }],