diff --git a/.changeset/22777-core-relation-condition-sites.md b/.changeset/22777-core-relation-condition-sites.md new file mode 100644 index 00000000000..39fc3beb530 --- /dev/null +++ b/.changeset/22777-core-relation-condition-sites.md @@ -0,0 +1,10 @@ +--- +'@objectstack/core': minor +'@objectstack/metadata-protocol': patch +--- + +feat(core): export `relationConditionSites`, the one walk that finds a filter's nested-relation conditions and each one's target object (#22777) + +`relationConditionSites(fields, filter)` returns every `{ : { : … } }` condition a filter holds, at the top level and under `$and` / `$or` / `$not`, with the reference field, the target object (resolved by the spec's `referenceTargetOf`) and the condition. An id comparand, an operator bag and a non-reference field are not conditions. + +It moved unchanged from `@objectstack/metadata-protocol`, where it was module-private, so that the MCP stdio data bridge (`@objectstack/mcp`) and the generic data door ask each condition's target its exposure over the same sites. `@objectstack/metadata-protocol` now imports it; its behaviour does not change. diff --git a/.changeset/22777-mcp-stdio-relation-condition-target-exposure.md b/.changeset/22777-mcp-stdio-relation-condition-target-exposure.md new file mode 100644 index 00000000000..c0d766169a3 --- /dev/null +++ b/.changeset/22777-mcp-stdio-relation-condition-target-exposure.md @@ -0,0 +1,31 @@ +--- +'@objectstack/mcp': minor +--- + +fix(mcp)!: on the stdio transport, a nested-relation filter condition asks the related object its declared exposure, and is refused when that object does not serve `list` (#22777) + +Clause-②: yes (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +A nested-relation filter condition, `{ : { : … } }`, is evaluated on the RELATED object: the engine queries that object for the matching records and keeps the source rows that point at one. + +**FROM.** On the MCP stdio transport (`OS_MCP_STDIO_API_KEY`), the `query_records` and `aggregate_records` tools judged only the object they address against its `enable` block, so such a condition in `where` was evaluated whatever the related object's `enable` block declared — including a related object those same tools refuse when it is addressed. + +**TO.** The stdio data bridge asks each condition's related object the same exposure gate it applies to an addressed object, for `list` (the operation a query of that object is judged as), before the engine is asked. A related object the gate does not serve refuses the call with the bridge's existing exposure refusal, the one an addressed object gets: + +- `enable.apiEnabled: false` → `OBJECT_API_DISABLED` (404). +- an `enable.apiMethods` whitelist without `list` (for example `['get']`, `['create']` or the deny-all `[]`) → `OBJECT_API_METHOD_NOT_ALLOWED` (405), with the effective operation set. + +The tool's error text names the related object and the condition's position (`where.`), with the remedy. It applies at the top level and under `$and` / `$or` / `$not`. The generic data door has given the same answer for the same condition since #22737. + +**Migration.** Filter on the lookup's stored id (`{ : '' }` or `{ : { $in: [...] } }`), which is the source object's own column, or remove the condition. To keep the condition, declare `list` on the related object's `enable.apiMethods`. + +**Bindings.** + +- **Refused, not withheld.** Dropping a condition would widen the result. +- **One decision, one walk.** The gate is the one the bridge already applies to an addressed object, and the conditions are found by `relationConditionSites` from `@objectstack/core`, the walk the data door uses. +- **The engine is untouched.** A flow's or a hook's own query calls the engine directly and never passes this bridge. +- **No new error code.** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0324073a701..4aeda7bd7a8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -180,6 +180,12 @@ export * from './utils/record-not-found.js'; // resolve with it (one name space for both doors). export * from './utils/object-not-found.js'; +// [#22777] The one walk that finds a filter's nested-relation conditions and +// their target objects: the data door (`@objectstack/metadata-protocol`) and +// the MCP stdio data bridge (`@objectstack/mcp`) ask each target its exposure +// before the engine evaluates the condition, and share no other package. +export * from './utils/relation-condition-sites.js'; + // Export in-memory fallbacks for core-criticality services export * from './fallbacks/index.js'; diff --git a/packages/core/src/security/second-object-read-exposure.pin.test.ts b/packages/core/src/security/second-object-read-exposure.pin.test.ts index cc7223a107c..f70aef4e926 100644 --- a/packages/core/src/security/second-object-read-exposure.pin.test.ts +++ b/packages/core/src/security/second-object-read-exposure.pin.test.ts @@ -41,7 +41,14 @@ * for `aggregate` over `queryObjects`); and the reads whose target is a FIXED * platform object rather than an authored one (a file field's `sys_file` * hydration, the `sys_user` display names the approvals and audit surfaces - * resolve). They are named here, not held. + * resolve). They are named here, not held. Nor does it see a door that reaches a + * classified read through the shared condition walk rather than calling the + * arbiter itself: the MCP stdio data bridge hands a caller's filter to the + * engine's relation lowering (the `relation-filter-lowering.ts` row), and its + * targets are resolved inside `relationConditionSites` (`@objectstack/core`), + * which is the caller classified below. The bridge asks each target the + * decision for `list` (`enforceRelationTargetExposure`, #22777), held by its + * own package's pin rather than by a row here. * * The scan surface and its prefilter follow `row-serving-door-exposure.pin.test.ts` * in this directory: git's authored-file list, never a directory crawl, and `.ts` @@ -161,6 +168,13 @@ const CALLERS: Record = { wiring: { file: 'packages/plugins/plugin-audit/src/audit-writers.ts', call: 'servesSummaryTitleTarget(objectName' }, pin: 'packages/plugins/plugin-audit/src/audit-lookup-summary.test.ts', }, + 'packages/core/src/utils/relation-condition-sites.ts': { + kind: 'not-a-served-read', + reason: + "finds a filter's nested-relation conditions and each one's target; reads no record. Both doors that call it ask " + + "each target the decision before the engine reads it: the data door's `refuseUnservedRelationTarget` and the MCP " + + "stdio data bridge's `enforceRelationTargetExposure`", + }, 'packages/lint/src/object-graph.ts': { kind: 'not-a-served-read', reason: 'authoring-time static analysis of metadata; reads no record', diff --git a/packages/core/src/utils/relation-condition-sites.test.ts b/packages/core/src/utils/relation-condition-sites.test.ts new file mode 100644 index 00000000000..a625b2c82dd --- /dev/null +++ b/packages/core/src/utils/relation-condition-sites.test.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { relationConditionSites } from './relation-condition-sites.js'; + +const FIELDS = { + name: { type: 'text' }, + account: { type: 'lookup', reference: 'rcs_account' }, + owner: { type: 'user' }, + tags: { type: 'lookup', reference: 'rcs_tag', multiple: true }, +}; + +describe('[#22777] relationConditionSites — the one walk both exposure-asking doors share', () => { + it('finds a condition on a related object, at the top level and under every combinator, with its target', () => { + const where = { + account: { name: 'a' }, + $or: [{ owner: { email: 'e' } }, { $not: { tags: { label: 't' } } }], + $and: [{ name: 'n' }], + }; + const sites = relationConditionSites(FIELDS, where).map((s) => [s.field, s.target, s.condition]); + expect(sites.sort()).toEqual([ + ['account', 'rcs_account', { name: 'a' }], + ['owner', 'sys_user', { email: 'e' }], + ['tags', 'rcs_tag', { label: 't' }], + ]); + }); + + it('CONTROL finds no site in an id comparand, an operator bag, a non-reference field or without a field map', () => { + expect(relationConditionSites(FIELDS, { account: 'a1', owner: { $in: ['u1'] }, name: { first: 'x' } })).toEqual([]); + expect(relationConditionSites(undefined, { account: { name: 'a' } })).toEqual([]); + }); +}); diff --git a/packages/core/src/utils/relation-condition-sites.ts b/packages/core/src/utils/relation-condition-sites.ts new file mode 100644 index 00000000000..7ab785d5470 --- /dev/null +++ b/packages/core/src/utils/relation-condition-sites.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { REFERENCE_VALUE_TYPES, referenceTargetOf } from '@objectstack/spec/data'; + +/** One nested-relation condition a filter holds, with the object it is evaluated on. */ +export interface RelationConditionSite { + /** The reference field of the filtered object — the filter key. */ + field: string; + /** The related object the field points at, by the spec's one arbiter (`referenceTargetOf`). */ + target: string; + /** The condition on the related object: the plain record beneath the field. */ + condition: Record; +} + +/** + * [#22646] The NESTED-RELATION conditions a filter holds — a key naming a + * reference field of the object (`REFERENCE_VALUE_TYPES`) whose value is a + * plain record carrying at least one non-`$` key, i.e. a condition on the + * related object rather than an operator bag — with each site's target object. + * Logical combinators (and any other `$` key) are walked as conditions, so a + * site under `$or` / `$not` is found. The engine's own relation lowering + * (`ObjectQL.lowerRelationConditions`) is the authority on whether a site RUNS; + * this only finds what a filter would evaluate on the related object. + * + * [#22777] The ONE walk for every door that asks each site's target the spec's + * exposure decision before handing the filter to the engine: the data door + * (`@objectstack/metadata-protocol`, where it also feeds the internal-field + * refusal) and the MCP stdio data bridge (`@objectstack/mcp`). It lives here + * because the two doors share no other package, and a second walk is how two + * doors come to disagree about which conditions a filter holds. + * + * `fields` is the filtered object's declared field map; with none, there is + * nothing to resolve a target with and no site is found. + */ +export function relationConditionSites( + fields: Record | undefined, + root: unknown, +): RelationConditionSite[] { + const sites: RelationConditionSite[] = []; + if (!fields || typeof fields !== 'object') return sites; + const seen = new WeakSet(); + const pending: unknown[] = [root]; + while (pending.length > 0) { + const node = pending.pop(); + if (node === null || typeof node !== 'object' || seen.has(node)) continue; + seen.add(node); + if (Array.isArray(node)) { + for (const member of node) pending.push(member); + continue; + } + for (const [key, value] of Object.entries(node as Record)) { + if (key.startsWith('$')) { + pending.push(value); + continue; + } + const def = fields[key]; + if (def == null || !REFERENCE_VALUE_TYPES.has(def.type) || !isPlainRecord(value)) continue; + if (!Object.keys(value).some((k) => !k.startsWith('$'))) continue; + const target = referenceTargetOf(def); + if (target) sites.push({ field: key, target, condition: value }); + } + } + return sites; +} + +/** A JSON object — not an array, not `null`. */ +function isPlainRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/mcp/src/stdio-data-bridge.relation-condition-target-exposure.test.ts b/packages/mcp/src/stdio-data-bridge.relation-condition-target-exposure.test.ts new file mode 100644 index 00000000000..6e56811f2d9 --- /dev/null +++ b/packages/mcp/src/stdio-data-bridge.relation-condition-target-exposure.test.ts @@ -0,0 +1,136 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22777] A nested-relation condition in a stdio caller's filter is evaluated + * on its TARGET object (the engine lowers it by reading the target as the + * caller), so the stdio data bridge asks each condition's target the spec's one + * exposure decision for `list` — the same gate an addressed object gets — and + * REFUSES an unserved target before the engine is asked. The data door asks + * the same of the same sites (`@objectstack/metadata-protocol`). Fixture objects + * only; refusals are asserted by envelope (`code` + `status`), never by a bare + * throw, and every refusal asserts the engine was never reached. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { effectiveOperationsArray, resolveEffectiveApiMethods } from '@objectstack/spec/data'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts'; +import { createStdioDataBridge, type McpExposureError } from './stdio-data-bridge.js'; +import { registerObjectTools } from './mcp-http-tools.js'; + +const lookup = (reference: string) => ({ type: 'lookup', reference }); +/** Each source lookup → its target's `enable` block (`undefined`: none declared). */ +const TARGETS: Record | undefined> = { + hidden: { apiEnabled: false }, + closed: { apiMethods: ['create'] }, + getonly: { apiMethods: ['get'] }, + none: { apiMethods: [] }, + listonly: { apiMethods: ['list'] }, + open: undefined, +}; +const DEFS: Record = { + rcx_source: { + name: 'rcx_source', + fields: { name: { type: 'text' }, ...Object.fromEntries(Object.keys(TARGETS).map((rel) => [rel, lookup(`rcx_${rel}`)])) }, + }, + ...Object.fromEntries(Object.entries(TARGETS).map(([rel, enable]) => [ + `rcx_${rel}`, { name: `rcx_${rel}`, fields: { name: { type: 'text' } }, ...(enable ? { enable } : {}) }, + ])), +}; +const REFUSED = [ + ['hidden', 'OBJECT_API_DISABLED', 404], + ['closed', 'OBJECT_API_METHOD_NOT_ALLOWED', 405], + ['getonly', 'OBJECT_API_METHOD_NOT_ALLOWED', 405], + ['none', 'OBJECT_API_METHOD_NOT_ALLOWED', 405], +] as const; + +function makeBridge() { + const engine = { + find: vi.fn(async (_object: string, _query?: { where?: unknown }) => [{ id: 's1' }]), + aggregate: vi.fn(async (_object: string, _query?: { where?: unknown }) => [{ n: 1 }]), + }; + const metadataService = { getObject: vi.fn(async (name: string) => DEFS[name]) }; + const bridge = createStdioDataBridge({ + engine: engine as unknown as IDataEngine, + metadataService: metadataService as unknown as IMetadataService, + resolvePrincipal: async () => ({ userId: 'u1', isSystem: false }) as unknown as ExecutionContext, + }); + return { bridge, engine }; +} + +const VERBS = { + query: (b: ReturnType['bridge'], where: Record) => b.query('rcx_source', { where }), + aggregate: (b: ReturnType['bridge'], where: Record) => + b.aggregate!('rcx_source', { where, aggregations: [{ function: 'count', alias: 'n' }] }), +}; + +describe('[#22777] the stdio bridge refuses a condition on a target the decision does not serve for list', () => { + for (const [verb, run] of Object.entries(VERBS)) { + for (const [rel, code, status] of REFUSED) { + for (const [shape, where] of [ + ['direct', { [rel]: { name: 'x' } }], + ['under $or', { $or: [{ [rel]: { name: 'x' } }, { name: 'y' }] }], + ['under $not', { $not: { [rel]: { name: 'x' } } }], + ] as const) { + it(`${verb}: ${rel} (${shape}) answers ${status} and never reaches the engine`, async () => { + const { bridge, engine } = makeBridge(); + const err = (await run(bridge, where).then(() => null, (e: unknown) => e)) as McpExposureError | null; + expect(err, 'the call resolved: no refusal was raised').toBeTruthy(); + expect(err!.code).toBe(code); + expect(err!.status).toBe(status); + if (status === 405) { + expect(err!.allowedOperations).toEqual(effectiveOperationsArray(resolveEffectiveApiMethods(TARGETS[rel]!))); + } + expect(err!.message).toContain(`'rcx_${rel}'`); + expect(err!.message).toContain(`'where.${rel}'`); + expect(engine.find).not.toHaveBeenCalled(); + expect(engine.aggregate).not.toHaveBeenCalled(); + }); + } + } + + it(`${verb}: CONTROL a served target, an id comparand and an operator bag reach the engine unchanged`, async () => { + for (const where of [ + { listonly: { name: 'x' } }, + { $or: [{ open: { name: 'x' } }] }, + { hidden: 'h1' }, + { hidden: { $in: ['h1'] } }, + ]) { + const { bridge, engine } = makeBridge(); + await run(bridge, where); + const call = (verb === 'query' ? engine.find : engine.aggregate).mock.calls[0]?.[1]; + expect(call?.where, JSON.stringify(where)).toBe(where); + } + }); + } + + it('query_records answers the refusal naming the target and the position; CONTROL a served target is answered', async () => { + const { bridge, engine } = makeBridge(); + const server = new McpServer({ name: 'pin', version: '0' }); + registerObjectTools(server, bridge); + const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); + await server.connect(serverSide); + const client = new Client({ name: 'pin', version: '0' }); + await client.connect(clientSide); + try { + const call = (where: Record) => + client.callTool({ name: 'query_records', arguments: { objectName: 'rcx_source', where } }) as Promise<{ + isError?: boolean; + content: Array<{ text: string }>; + }>; + const refused = await call({ hidden: { name: 'x' } }); + expect(refused.isError).toBe(true); + expect(refused.content[0]!.text).toContain("Object 'rcx_hidden' is not exposed via the API"); + expect(refused.content[0]!.text).toContain("'where.hidden'"); + expect(engine.find).not.toHaveBeenCalled(); + const served = await call({ listonly: { name: 'x' } }); + expect(served.isError).toBeFalsy(); + expect(engine.find).toHaveBeenCalledTimes(1); + } finally { + await client.close(); + } + }); +}); diff --git a/packages/mcp/src/stdio-data-bridge.ts b/packages/mcp/src/stdio-data-bridge.ts index db03321184f..cf59e76683a 100644 --- a/packages/mcp/src/stdio-data-bridge.ts +++ b/packages/mcp/src/stdio-data-bridge.ts @@ -81,7 +81,7 @@ * remains of the divergence above is genuinely not security. */ -import { omitInternalFieldsFromWriteResponse } from '@objectstack/core'; +import { omitInternalFieldsFromWriteResponse, relationConditionSites } from '@objectstack/core'; import { resolveEffectiveApiMethods, isApiOperationAllowed, @@ -271,12 +271,18 @@ function exposureError( * legacy/test doubles. Reading only the nested shape here would let a flat * definition be gated on HTTP and ungated on stdio — the very divergence * this function closes, re-opened one shape down. + * + * [#22777] `condition` is set when `object` is not addressed but is the TARGET + * of a nested-relation condition at that filter position + * ({@link enforceRelationTargetExposure}); the refusal then names the position + * and the way out, as the data door's does for the same condition. */ export async function enforceApiExposure( metadataService: IMetadataService, object: string, action: string, context: ExecutionContext, + condition?: string, ): Promise { if (context?.isSystem) return; @@ -291,10 +297,14 @@ export async function enforceApiExposure( const enable = ( def.enable && typeof def.enable === 'object' ? def.enable : def ) as unknown as EnableLike; + const consequence = condition === undefined + ? '' + : `, so the condition on it at '${condition}' was not evaluated and the query was not run. ` + + 'Filter on the lookup\'s stored id instead, or remove the condition.'; if (enable.apiEnabled === false) { throw exposureError( - `Object '${object}' is not exposed via the API`, + `Object '${object}' is not exposed via the API${consequence}`, OBJECT_API_DISABLED, 404, ); @@ -307,13 +317,44 @@ export async function enforceApiExposure( if (isApiOperationAllowed(eff, operation)) return; throw exposureError( - `API operation '${operation}' is not allowed on object '${object}'`, + `API operation '${operation}' is not allowed on object '${object}'${consequence}`, OBJECT_API_METHOD_NOT_ALLOWED, 405, effectiveOperationsArray(eff), ); } +/** + * [#22777] A NESTED-RELATION condition in the caller's filter is evaluated on + * its TARGET object: the engine lowers it by reading the target as the caller + * (`ObjectQL.lowerRelationConditions`) and keeps the rows whose related record + * matched. So each condition's target is asked {@link enforceApiExposure}'s + * decision for {@link GATED_ACTIONS}`.query` — `list`, because a condition is a + * predicate over the whole target, the read the target's own filtered query + * performs — and an unserved target REFUSES the call: dropping the condition + * would widen the result. The data door asks the same of the same sites + * (`refuseUnservedRelationTarget`), found by the one walk both doors import + * (`relationConditionSites`). One hop, as the engine serves one: a relation + * beneath a condition is refused by the engine before anything is read. + */ +async function enforceRelationTargetExposure( + metadataService: IMetadataService, + object: string, + where: unknown, + context: ExecutionContext, +): Promise { + if (where == null) return; + let def: ObjectDef | null | undefined; + try { + def = (await metadataService.getObject(object)) as ObjectDef | null | undefined; + } catch { + def = undefined; // no field map, no site: the gate above falls open the same way + } + for (const site of relationConditionSites(def?.fields, where)) { + await enforceApiExposure(metadataService, site.target, GATED_ACTIONS.query, context, `where.${site.field}`); + } +} + // --------------------------------------------------------------------------- // [#21207] The stored-metadata-body family, at this engine-only reader // --------------------------------------------------------------------------- @@ -683,6 +724,8 @@ export function createStdioDataBridge(deps: StdioDataBridgeDeps): McpDataBridge async query(object, opts) { const context = await resolvePrincipal(); await enforceApiExposure(metadataService, object, GATED_ACTIONS.query, context); + // [#22777] …and each nested-relation condition's target, before the engine reads it. + await enforceRelationTargetExposure(metadataService, object, opts?.where, context); // [#21207] After the exposure gate, before the engine: a filter or sort // on a stored metadata body is refused rather than evaluated. const bodyRefusal = storedMetadataBodyRefusal(object, { where: opts?.where, orderBy: opts?.orderBy }); @@ -790,6 +833,8 @@ export function createStdioDataBridge(deps: StdioDataBridgeDeps): McpDataBridge // `list` must not leak row statistics through GROUP BY either. The // derivation lives in the spec helpers, so that holds here for free. await enforceApiExposure(metadataService, object, GATED_ACTIONS.aggregate, context); + // [#22777] …and each nested-relation condition's target, as `query` asks it. + await enforceRelationTargetExposure(metadataService, object, opts?.where, context); // [#21207] A grouping, filter or aggregate member on a stored metadata // body computes over the stored bytes, so it is refused, not run. const bodyRefusal = storedMetadataBodyRefusal(object, { diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index b40fc873428..7c2cd7685a1 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -3,7 +3,7 @@ import type { DataProtocol, MetadataProtocol, PackageProtocol, } from '@objectstack/spec/api'; -import { IDataEngine, engineCanRollBack, objectNotFoundError, recordNotFoundError } from '@objectstack/core'; +import { IDataEngine, engineCanRollBack, objectNotFoundError, recordNotFoundError, relationConditionSites } from '@objectstack/core'; import { declaredUserMessage, resolveTenancyPosture, resolveThrownHttpError } from '@objectstack/types'; // [#6285] ADR-0105 D1's authority on "does this deployment wall organizations?". // `resolveMultiOrgEnabled()` is DEMOTED and its own doc comment says answering @@ -4474,47 +4474,6 @@ const EXPANSION_TARGET_OPERATION = 'get'; */ const RELATION_CONDITION_TARGET_OPERATION = 'list'; -/** - * [#22646] The NESTED-RELATION conditions a filter holds — a key naming a - * reference field of the object (`REFERENCE_VALUE_TYPES`) whose value is a - * plain record carrying at least one non-`$` key, i.e. a condition on the - * related object rather than an operator bag — with each site's target object. - * Logical combinators (and any other `$` key) are walked as conditions, so a - * site under `$or` / `$not` is found. Shared by the door's internal-field - * refusal; the engine's own relation lowering is the authority on whether a - * site RUNS, this only finds what a filter would evaluate on the related object. - */ -function relationConditionSites( - fields: Record | undefined, - root: unknown, -): Array<{ field: string; target: string; condition: Record }> { - const sites: Array<{ field: string; target: string; condition: Record }> = []; - if (!fields || typeof fields !== 'object') return sites; - const seen = new WeakSet(); - const pending: unknown[] = [root]; - while (pending.length > 0) { - const node = pending.pop(); - if (node === null || typeof node !== 'object' || seen.has(node)) continue; - seen.add(node); - if (Array.isArray(node)) { - for (const member of node) pending.push(member); - continue; - } - for (const [key, value] of Object.entries(node as Record)) { - if (key.startsWith('$')) { - pending.push(value); - continue; - } - const def = fields[key]; - if (def == null || !REFERENCE_VALUE_TYPES.has(def.type) || !isPlainRecord(value)) continue; - if (!Object.keys(value).some((k) => !k.startsWith('$'))) continue; - const target = referenceTargetOf(def); - if (target) sites.push({ field: key, target, condition: value }); - } - } - return sites; -} - /** * The walk {@link collectStoredMetadataFilterFields} runs over one filter * condition. A node is visited as a CONDITION (its non-`$` keys are columns)