From 53f813482d79945edc28a677417ebaa72ac7ac7d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 01:28:10 +0000 Subject: [PATCH 1/2] =?UTF-8?q?refactor(data)!:=20a=20select-list=20entry?= =?UTF-8?q?=20is=20a=20field=20name=20=E2=80=94=20the=20nested-select=20ob?= =?UTF-8?q?ject=20form=20is=20removed=20(#4196)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `FieldNodeSchema` declared two forms for one entry of `QueryAST['fields']`: a string, and `{ field, fields?, alias? }` for a nested select on a relationship. The object form was declared-but-inert. Nothing produced it, and nothing read `.fields` or `.alias`: - `objectql/engine.ts` — formula projection and both known-field filters treat the list as `string[]`; one already reached for `String(f).split('.')[0]`, which is what defensiveness against an unhandled shape looks like. - `driver-sql` `select()`, `driver-memory` `projectFields` — same. - `driver-mongodb` keyed its projection with the entry itself, so an object entry asked for a column literally named `"[object Object]"`. #4194 taught it to read `.field` because the newly-precise type made the old line a compile error — defensive handling of a shape nothing sends, not evidence of life. - The REST ingress stringified each entry before comparing it to the field map, so the same entry came back as `400 INVALID_FIELD: Unknown field '[object Object]'` — a rejection naming something the caller never wrote. So an author who wrote `fields: [{ field: 'owner', fields: ['name'] }]` got it accepted by validation and then dropped or mangled, depending on the driver (ADR-0078 silently-inert declaration; ADR-0049 enforce-or-remove). The capability it described is already served by `expand`, which the engine resolves through batch `$in` queries. Removing the second spelling rather than lowering it into the first is Prime Directive #12: one capability, one contract. `FieldNode` is now `string`; `FieldNodeSchema` is `z.string()` with an error map that answers an object entry with the FROM → TO prescription instead of zod's "expected string, received object" — and only an object, since telling the author of `fields: [42]` that their entry "was removed" would misinform. `z.input` becomes `string`, so `tsc` fails at the authoring site first. `assertProjectionFieldsExist` gains the shape axis, judged BEFORE the object's field map: the entry is wrong about its shape, not about this object, and a registry-less host (which the name gate gives up on) would otherwise hand it to a driver that cannot read it. Registered as an ADR-0087 D3 semantic migration (`query-field-node-object-form-retired`) rather than a D2 conversion: `QueryAST` is a request shape, never stored in stack metadata, so the chain has no source to rewrite. Same treatment as `EnhancedApiError.fieldErrors` and `BatchOptions.validateOnly` on this step. Two spec tests that pinned the object form as parseable are inverted — they proved the DECLARATION, which is exactly how a declared-but-inert shape survives a green suite. New pins cover the prescription, the non-object mistake keeping zod's own message, and the `expand` replacement; the list path gets three more in the #4226 conformance suite. No runtime behaviour changes for anything that ever worked; the defensive unwrapping the drivers had grown against a shape nothing sends goes with it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EskBioEkCCJpGz3SvPQDxx --- .../query-field-node-object-form-removed.md | 68 ++++++++++++++ content/docs/kernel/contracts/data-engine.mdx | 4 +- .../docs/protocol/objectql/query-syntax.mdx | 21 +++-- content/docs/references/api/contract.mdx | 4 +- content/docs/references/api/protocol.mdx | 2 +- content/docs/references/data/data-engine.mdx | 12 +-- content/docs/references/data/mapping.mdx | 2 +- content/docs/references/data/query.mdx | 28 +----- .../2026-07-unknown-key-strictness-ledger.md | 4 +- packages/metadata-protocol/src/protocol.ts | 33 ++++++- packages/objectql/src/engine.ts | 10 +-- .../src/query-expression-conformance.test.ts | 40 +++++++++ .../driver-memory/src/memory-driver.ts | 2 +- .../driver-mongodb/src/mongodb-driver.ts | 13 ++- packages/plugins/driver-sql/src/sql-driver.ts | 2 +- packages/spec/src/data/query.test.ts | 88 ++++++++++++++----- packages/spec/src/data/query.zod.ts | 79 ++++++++++------- packages/spec/src/migrations/registry.ts | 30 ++++++- 18 files changed, 323 insertions(+), 119 deletions(-) create mode 100644 .changeset/query-field-node-object-form-removed.md diff --git a/.changeset/query-field-node-object-form-removed.md b/.changeset/query-field-node-object-form-removed.md new file mode 100644 index 0000000000..9426795432 --- /dev/null +++ b/.changeset/query-field-node-object-form-removed.md @@ -0,0 +1,68 @@ +--- +"@objectstack/spec": major +"@objectstack/objectql": patch +"@objectstack/metadata-protocol": patch +"@objectstack/driver-mongodb": patch +"@objectstack/driver-sql": patch +"@objectstack/driver-memory": patch +--- + +refactor(data)!: a select-list entry is a field name — the nested-select object form is removed (#4196) + +`FieldNode` declared two forms for one entry of `QueryAST['fields']`: + +```ts +type FieldNode = + | string // "name" + | { field: string; fields?: FieldNode[]; alias?: string }; // nested select +``` + +The object form was **declared-but-inert**. Nothing produced it, and nothing +read `.fields` or `.alias` — every consumer on the path treats the list as +`string[]`: `objectql`'s formula projection and its two known-field filters, +`driver-sql`'s `select()`, `driver-memory`'s `projectFields`. `driver-mongodb` +keyed its projection with the entry itself, so an object entry asked for a +column literally named `"[object Object]"`, and the REST ingress stringified +each entry before comparing it to the field map, so the same entry came back as +`400 INVALID_FIELD: Unknown field '[object Object]'` — a rejection naming +something the caller never wrote. An author who wrote +`fields: [{ field: 'owner', fields: ['name'] }]` got it accepted by validation +and then dropped or mangled, depending on the driver (ADR-0078 silently-inert +declaration; ADR-0049 enforce-or-remove). + +The capability the object form described is already served, by a different key. +Removing the second spelling rather than lowering it into the first is Prime +Directive #12: one capability, one contract. + +**FROM → TO** + +| Was | Now | +| :--- | :--- | +| `fields: [{ field: 'owner', fields: ['name'] }]` | `expand: { owner: { object: 'user', fields: ['name'] } }` | +| `fields: [{ field: 'owner' }]` | `fields: ['owner']` | +| `fields: [{ field: 'owner', fields: ['name'] }]`, one column only | `fields: ['owner.name']` (dotted path) | +| `fields: [{ field: 'total', alias: 't' }]` | `aggregations` / `windowFunctions` — they carry the live `alias` | + +The one-line fix: **a `fields[]` entry is a string.** Move nested selection to +`expand`, which the engine resolves through batch `$in` queries (default max +depth 3). + +There is no `os migrate meta` step, and deliberately so: `QueryAST` is a request +shape, never stored in stack metadata, so the chain has no source to rewrite. It +is registered as an ADR-0087 D3 **semantic** migration +(`query-field-node-object-form-retired`) on the protocol-18 step instead — the +`EnhancedApiError.fieldErrors` / `BatchOptions.validateOnly` precedent. Callers +move their own select lists, and both channels tell them how: + +- **The parse.** `FieldNodeSchema` narrows to `z.string()` with an error map that + answers an object entry with the prescription above, not "expected string, + received object". `z.input` becomes `string`, so `tsc` fails at the authoring + site first. +- **The ingress.** `assertProjectionFieldsExist` judges the entry's *shape* + before consulting the object's field map — it is wrong about the shape, not + about this object, and a registry-less host would otherwise pass it to a driver + that cannot read it. The 400 now names the retired form instead of the field + `"[object Object]"`. + +No runtime behaviour changes for anything that ever worked; the defensive +unwrapping the drivers had grown against a shape nothing sends goes with it. diff --git a/content/docs/kernel/contracts/data-engine.mdx b/content/docs/kernel/contracts/data-engine.mdx index 7b44a3dca9..7d0ec6f46d 100644 --- a/content/docs/kernel/contracts/data-engine.mdx +++ b/content/docs/kernel/contracts/data-engine.mdx @@ -85,7 +85,7 @@ Defined by `EngineQueryOptionsSchema` in `@objectstack/spec`: ```typescript interface EngineQueryOptions { where?: FilterCondition; // WHERE clause — MongoDB-style $op - fields?: FieldNode[]; // SELECT fields + fields?: FieldNode[]; // SELECT — field names ('name', 'owner.name') orderBy?: SortNode[]; // ORDER BY limit?: number; // LIMIT offset?: number; // OFFSET @@ -387,7 +387,7 @@ The following legacy parameter names are accepted by the RPC layer for backward | Legacy (Deprecated) | Canonical | Notes | |:---------------------|:----------|:------| | `filter` | `where` | FilterCondition object | -| `select` | `fields` | Array of FieldNode | +| `select` | `fields` | Array of FieldNode (field-name strings) | | `sort` | `orderBy` | Array of `{ field, order }` | | `skip` | `offset` | Number | | `populate` | `expand` | Record of field → QueryAST | diff --git a/content/docs/protocol/objectql/query-syntax.mdx b/content/docs/protocol/objectql/query-syntax.mdx index db068ac02a..3eb59416f2 100644 --- a/content/docs/protocol/objectql/query-syntax.mdx +++ b/content/docs/protocol/objectql/query-syntax.mdx @@ -62,7 +62,7 @@ import type { QueryAST } from '@objectstack/spec/data'; // QueryAST — full structure interface QueryAST { object: string; // Target object (required) - fields?: FieldNode[]; // Projection (SELECT) + fields?: FieldNode[]; // Projection (SELECT) — field names where?: FilterCondition; // Filtering (WHERE) — MongoDB-style $op search?: FullTextSearch; // Full-text search ($search) orderBy?: SortNode[]; // Ordering (ORDER BY) @@ -97,7 +97,6 @@ on the `find()` path: | `cursor` | Accepted by `EngineQueryOptions`, but no driver implements keyset pagination | | `distinct` | Not applied by `find()`; the SQL and in-memory drivers expose a separate `distinct(object, field, filters?)` method instead | | `windowFunctions` | Not applied by `find()`; `SqlDriver.findWithWindowFunctions()` is a separate method that is not on the `IDataDriver` contract | -| Object `FieldNode`s (`{ field, fields }`) | The engine drops non-string entries from `fields` — use `expand` to project related records | | `search.fuzzy` / `boost` / `operator` / `minScore` / `language` / `highlight` | Parsed by the schema but ignored: only `query` and `fields` drive the expansion | `top` is the exception that *is* honored — the engine normalises it to `limit`. @@ -122,12 +121,16 @@ interface AggregationNode { filter?: FilterCondition; // FILTER WHERE clause — protocol only, never applied } -// FieldNode — field selection -type FieldNode = string | { - field: string; - fields?: FieldNode[]; // nested select — protocol only, dropped by the engine - alias?: string; -}; +// FieldNode — one entry of the select list. A field name, optionally dotted to +// reach through a relationship ('owner.name'). Related *records* come from +// `expand`, not from inside this list. +// +// The `{ field, fields, alias }` nested-select member this union used to carry +// was REMOVED in protocol 18 (#4196): nothing produced it and nothing read +// `.fields`/`.alias`, so it was dropped by the SQL and memory drivers, +// projected as a column named "[object Object]" by MongoDB, and refused as an +// unknown field by the REST ingress. `expand` is the one spelling. +type FieldNode = string; // GroupByNode — GROUP BY target type GroupByNode = string | { @@ -980,7 +983,7 @@ Similarly, the following legacy field names should not be used in new code: | `aggregate` (object map) | `aggregations` (array) | Array of `AggregationNode` objects | | `expand` (string array) | `expand` (Record) | Map of field name → nested QueryAST | | `skip` | `offset` | Number | -| `select` | `fields` | Array of FieldNode | +| `select` | `fields` | Array of FieldNode (field-name strings) | | `populate` | `expand` | Map of field name → nested QueryAST | diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index b89259cc57..b5b9819f0b 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -147,7 +147,7 @@ const result = ApiError.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object name (e.g. account) | -| **fields** | `string \| { field: string; fields?: object[]; alias?: string }[]` | optional | Fields to retrieve | +| **fields** | `string[]` | optional | Fields to retrieve — field names, optionally dotted to reach through a relationship (`owner.name`). Related *records* are selected with `expand`, not from inside this list. | | **where** | `any` | optional | Filtering criteria (WHERE) | | **search** | `{ query: string; fields?: string[]; fuzzy: boolean; operator: Enum<'and' \| 'or'>; … }` | optional | Full-text search configuration ($search parameter) | | **orderBy** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sorting instructions (ORDER BY) | @@ -161,7 +161,7 @@ const result = ApiError.parse(data); | **having** | `any` | optional | HAVING clause for aggregation filtering | | **windowFunctions** | `{ function: Enum<'row_number' \| 'rank' \| 'dense_rank' \| 'percent_rank' \| 'lag' \| 'lead' \| 'first_value' \| 'last_value' \| 'sum' \| 'avg' \| 'count' \| 'min' \| 'max'>; field?: string; alias: string; over: object }[]` | optional | Window functions with OVER clause | | **distinct** | `boolean` | optional | SELECT DISTINCT flag | -| **expand** | `Record` | optional | Recursive relation loading map. Keys are lookup/master_detail field names; values are nested QueryAST objects that control select (`fields`) and filter (`where`, AND-merged with the batch $in), plus further expansion on the related object. The engine resolves expand via batch $in queries (driver-agnostic) with a default max depth of 3; per-parent `limit`/`offset`/`orderBy` are NOT applied on this path. | +| **expand** | `Record` | optional | Recursive relation loading map. Keys are lookup/master_detail field names; values are nested QueryAST objects that control select (`fields`) and filter (`where`, AND-merged with the batch $in), plus further expansion on the related object. The engine resolves expand via batch $in queries (driver-agnostic) with a default max depth of 3; per-parent `limit`/`offset`/`orderBy` are NOT applied on this path. | --- diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index dcaef1b135..d3e7cfdd63 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -529,7 +529,7 @@ const result = AiAgentCapabilities.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | The unique machine name of the object to query (e.g. "account"). | -| **query** | `{ object: string; fields?: string \| { field: string; fields?: object[]; alias?: string }[]; where?: any; search?: object; … }` | optional | Structured query definition (filter, sort, select, pagination). | +| **query** | `{ object: string; fields?: string[]; where?: any; search?: object; … }` | optional | Structured query definition (filter, sort, select, pagination). | --- diff --git a/content/docs/references/data/data-engine.mdx b/content/docs/references/data/data-engine.mdx index 7c6dfd3de2..06c63802ef 100644 --- a/content/docs/references/data/data-engine.mdx +++ b/content/docs/references/data/data-engine.mdx @@ -186,7 +186,7 @@ Reference: any | :--- | :--- | :--- | :--- | | **method** | `'findOne'` | ✅ | | | **object** | `string` | ✅ | | -| **query** | `{ context?: object; where?: Record \| any; fields?: string \| { field: string; fields?: object[]; alias?: string }[]; orderBy?: { field: string; order: Enum<'asc' \| 'desc'> }[]; … }` | optional | | +| **query** | `{ context?: object; where?: Record \| any; fields?: string[]; orderBy?: { field: string; order: Enum<'asc' \| 'desc'> }[]; … }` | optional | | --- @@ -199,7 +199,7 @@ Reference: any | :--- | :--- | :--- | :--- | | **method** | `'find'` | ✅ | | | **object** | `string` | ✅ | | -| **query** | `{ context?: object; where?: Record \| any; fields?: string \| { field: string; fields?: object[]; alias?: string }[]; orderBy?: { field: string; order: Enum<'asc' \| 'desc'> }[]; … }` | optional | | +| **query** | `{ context?: object; where?: Record \| any; fields?: string[]; orderBy?: { field: string; order: Enum<'asc' \| 'desc'> }[]; … }` | optional | | --- @@ -268,7 +268,7 @@ This schema accepts one of the following structures: | :--- | :--- | :--- | :--- | | **method** | `'find'` | ✅ | | | **object** | `string` | ✅ | | -| **query** | `{ context?: object; where?: Record \| any; fields?: string \| { field: string; fields?: object[]; alias?: string }[]; orderBy?: { field: string; order: Enum<'asc' \| 'desc'> }[]; … }` | optional | | +| **query** | `{ context?: object; where?: Record \| any; fields?: string[]; orderBy?: { field: string; order: Enum<'asc' \| 'desc'> }[]; … }` | optional | | --- @@ -280,7 +280,7 @@ This schema accepts one of the following structures: | :--- | :--- | :--- | :--- | | **method** | `'findOne'` | ✅ | | | **object** | `string` | ✅ | | -| **query** | `{ context?: object; where?: Record \| any; fields?: string \| { field: string; fields?: object[]; alias?: string }[]; orderBy?: { field: string; order: Enum<'asc' \| 'desc'> }[]; … }` | optional | | +| **query** | `{ context?: object; where?: Record \| any; fields?: string[]; orderBy?: { field: string; order: Enum<'asc' \| 'desc'> }[]; … }` | optional | | --- @@ -540,14 +540,14 @@ QueryAST-aligned query options for IDataEngine.find() operations | :--- | :--- | :--- | :--- | | **context** | `{ userId?: string; actor?: string; email?: string; tenantId?: string; … }` | optional | | | **where** | `Record \| any` | optional | | -| **fields** | `string \| { field: string; fields?: object[]; alias?: string }[]` | optional | | +| **fields** | `string[]` | optional | | | **orderBy** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **limit** | `number` | optional | | | **offset** | `number` | optional | | | **top** | `number` | optional | | | **cursor** | `Record` | optional | | | **search** | `{ query: string; fields?: string[]; fuzzy: boolean; operator: Enum<'and' \| 'or'>; … }` | optional | | -| **expand** | `Record` | optional | | +| **expand** | `Record` | optional | | | **distinct** | `boolean` | optional | | diff --git a/content/docs/references/data/mapping.mdx b/content/docs/references/data/mapping.mdx index 9e12f350ba..4cd5f73fd9 100644 --- a/content/docs/references/data/mapping.mdx +++ b/content/docs/references/data/mapping.mdx @@ -52,7 +52,7 @@ const result = FieldMapping.parse(data); | **fieldMapping** | `{ source: string \| string[]; target: string \| string[]; transform: Enum<'none' \| 'constant' \| 'lookup' \| 'split' \| 'join' \| 'javascript' \| 'map'>; params?: object }[]` | ✅ | | | **mode** | `Enum<'insert' \| 'update' \| 'upsert'>` | ✅ | | | **upsertKey** | `string[]` | optional | Fields to match for upsert (e.g. email) | -| **extractQuery** | `{ object: string; fields?: string \| { field: string; fields?: object[]; alias?: string }[]; where?: any; search?: object; … }` | optional | Query to run for export only | +| **extractQuery** | `{ object: string; fields?: string[]; where?: any; search?: object; … }` | optional | Query to run for export only | | **errorPolicy** | `Enum<'skip' \| 'abort' \| 'retry'>` | ✅ | | | **batchSize** | `number` | ✅ | | diff --git a/content/docs/references/data/query.mdx b/content/docs/references/data/query.mdx index 62263ab5d0..8a62809d65 100644 --- a/content/docs/references/data/query.mdx +++ b/content/docs/references/data/query.mdx @@ -67,30 +67,6 @@ const result = AggregationFunction.parse(data); * `year` ---- - -## FieldNode - -### Union Options - -This schema accepts one of the following structures: - -#### Option 1 - -Type: `string` - ---- - -#### Option 2 - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **field** | `string` | ✅ | | -| **fields** | `[FieldNode](#fieldnode)[]` | optional | | -| **alias** | `string` | optional | | - --- @@ -152,7 +128,7 @@ Type: `string` | **object** | `string` | ✅ | Object/table to join | | **alias** | `string` | optional | Table alias | | **on** | `any` | ✅ | Join condition | -| **subquery** | `{ object: string; fields?: string \| { field: string; fields?: object[]; alias?: string }[]; where?: any; search?: object; … }` | optional | Subquery instead of object | +| **subquery** | `{ object: string; fields?: string[]; where?: any; search?: object; … }` | optional | Subquery instead of object | --- @@ -188,7 +164,7 @@ Type: `string` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object name (e.g. account) | -| **fields** | `string \| { field: string; fields?: object[]; alias?: string }[]` | optional | Fields to retrieve | +| **fields** | `string[]` | optional | Fields to retrieve — field names, optionally dotted to reach through a relationship (`owner.name`). Related *records* are selected with `expand`, not from inside this list. | | **where** | `any` | optional | Filtering criteria (WHERE) | | **search** | `{ query: string; fields?: string[]; fuzzy: boolean; operator: Enum<'and' \| 'or'>; … }` | optional | Full-text search configuration ($search parameter) | | **orderBy** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sorting instructions (ORDER BY) | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 7d9e1cd122..769dc5054b 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -146,7 +146,7 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts). | `notification.zod.ts` / `offline.zod.ts` / `report.zod.ts` | 3 ea | authorable (p) | | | `sharing.zod.ts` | 2 | authorable (p) | public-sharing config | -### `data/` — 154 sites +### `data/` — 153 sites | File | Sites | Class | Note | |---|---|---|---| @@ -155,7 +155,7 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts). | `external-lookup.zod.ts` | 12 | mixed (p) | authored config + wire results | | `seed-loader.zod.ts` | 12 | mixed (p) | seed file shapes are authored; loader state is runtime | | `field.zod.ts` | 11 | authorable | partially strict | -| `filter.zod.ts` / `query.zod.ts` | 11+10 | open | query dialect — user data flows through; validated semantically elsewhere | +| `filter.zod.ts` / `query.zod.ts` | 11+9 | open | query dialect — user data flows through; validated semantically elsewhere. `query.zod.ts` dropped one site in #4196: `FieldNodeSchema`'s nested-select object form was declared-but-inert and narrowed to `z.string()`, so the union's second member is gone. Class unchanged | | `driver-nosql.zod.ts` / `driver.zod.ts` / `driver-sql.zod.ts` | 10+9+2 | wire | driver capability contracts | | `datasource.zod.ts` | 9 | authorable | **strict as of #4001 data step** — all 9: `DatasourceSchema` (+ `pool` / `healthCheck` / `ssl` / `retryPolicy`), `ExternalDatasourceSettingsSchema` (+ `validation`), `DatasourceCapabilities`, `DriverDefinitionSchema`. `config` + `readReplicas` stay `z.record` by construction (per-driver shapes; the driver's own `configSchema` validates them) — which is precisely why the top level had to close: a connection key written one level too high was stripped, and the datasource then connected on driver defaults instead of failing | | `analytics.zod.ts` | 8 | mixed (p) | | diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 27df5c9de9..63a108f2b2 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -3289,12 +3289,43 @@ export class ObjectStackProtocolImplementation implements * The engine's tolerance is untouched: it guards INTERNAL callers (hooks, * flows, expand sub-reads, registry-less hosts) that never pass through * this ingress, exactly like the object-existence gate above. + * + * [#4196] It also owns the projection's SHAPE, which is a different + * question from its names and is answered first — see below. */ private assertProjectionFieldsExist(object: string, fields: unknown, param: string): void { if (!Array.isArray(fields) || fields.length === 0) return; + // [#4196] A non-string entry is the retired nested-select object form + // (`{ field, fields, alias }`). It is checked BEFORE the field map, + // because it is wrong about the shape rather than about this object — + // and a registry-less host, which returns no gate below, would + // otherwise let it through to a driver that cannot read it. Until now + // it reached `.map(String)` and was refused as the unknown field + // `"[object Object]"`: a 400 naming something the caller never wrote. + const badShape = fields.findIndex((f) => typeof f !== 'string'); + if (badShape !== -1) { + const entry = fields[badShape]; + // Only the shape that used to be legal earns the retirement clause. + const retiredForm = typeof entry === 'object' && entry !== null && !Array.isArray(entry); + const err: any = new Error( + `'${param}' entry #${badShape + 1} on object '${object}' is not a field name.` + + (retiredForm + ? ' The nested-select object form `{ field, fields, alias }` was removed in ' + + '@objectstack/spec 18 (#4196) — no engine or driver ever read it.' + : '') + + " Select related records with `expand` (`expand=owner` / `{ expand: { owner: " + + "{ object: 'user', fields: ['name'] } } }`), or name one related column with a " + + 'dotted path (`select=owner.name`).', + ); + err.code = 'INVALID_FIELD'; + err.status = 400; + err.object = object; + err.param = param; + throw err; + } const gate = this.resolveQueryFields(object); if (!gate) return; - const unknown = fields.map(String).filter((f) => !gate.known.has(f.split('.')[0])); + const unknown = (fields as string[]).filter((f) => !gate.known.has(f.split('.')[0])); if (unknown.length === 0) return; const first = unknown[0]; const err: any = new Error( diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 4ecaaba602..ffc10b2502 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -2833,7 +2833,7 @@ export class ObjectQL implements IDataEngine { delete (ast as any).searchFields; delete (ast as any).$searchFields; } - const _findFormula = planFormulaProjection(_findSchema, ast.fields as string[] | undefined); + const _findFormula = planFormulaProjection(_findSchema, ast.fields); if (_findFormula.projected) ast.fields = _findFormula.projected; // Drop any requested field that doesn't exist on the schema. Without @@ -2851,10 +2851,10 @@ export class ObjectQL implements IDataEngine { known.add('id'); known.add('created_at'); known.add('updated_at'); - const filtered = (ast.fields as string[]).filter(f => { + const filtered = ast.fields.filter(f => { // Keep relationship paths like `owner.name` — the engine will // resolve those via populate; only validate top-level segment. - const head = String(f).split('.')[0]; + const head = f.split('.')[0]; return known.has(head); }); // Guard against an empty projection — fall back to `*` so the @@ -2936,7 +2936,7 @@ export class ObjectQL implements IDataEngine { // Plan formula projection (same as find): rewrite ast.fields so the driver // returns the raw dependency fields, then evaluate formulas after fetch. const _findOneSchema = this._registry.getObject(objectName); - const _findOneFormula = planFormulaProjection(_findOneSchema, ast.fields as string[] | undefined); + const _findOneFormula = planFormulaProjection(_findOneSchema, ast.fields); if (_findOneFormula.projected) ast.fields = _findOneFormula.projected; // Drop unknown fields — see equivalent block in `find()` for rationale. @@ -2947,7 +2947,7 @@ export class ObjectQL implements IDataEngine { known.add('id'); known.add('created_at'); known.add('updated_at'); - const filtered = (ast.fields as string[]).filter(f => known.has(String(f).split('.')[0])); + const filtered = ast.fields.filter(f => known.has(f.split('.')[0])); ast.fields = filtered.length > 0 ? filtered : undefined; } diff --git a/packages/objectql/src/query-expression-conformance.test.ts b/packages/objectql/src/query-expression-conformance.test.ts index 2b2e8cc4b1..f77a6ba9a0 100644 --- a/packages/objectql/src/query-expression-conformance.test.ts +++ b/packages/objectql/src/query-expression-conformance.test.ts @@ -388,6 +388,46 @@ describe('#4226 — sort / select / expand on the list path (real ObjectQL engin .rejects.toMatchObject({ status: 400, code: 'INVALID_FIELD', field: 'no_such_field' }); }); + // [#4196] The retired nested-select object form. Before it was removed from + // `FieldNode`, an entry like this reached `.map(String)` here and came back + // as the unknown field `"[object Object]"` — a 400 naming something the + // caller never wrote, about a shape the spec still said was legal. + it('the removed nested-select object form is refused BY NAME, not as the field "[object Object]"', async () => { + await expect(protocol.findData({ + object: 'showcase_task', + query: { fields: [{ field: 'parent_id', fields: ['title'] }] }, + })).rejects.toMatchObject({ + status: 400, + code: 'INVALID_FIELD', + object: 'showcase_task', + param: 'fields', + }); + await expect(protocol.findData({ + object: 'showcase_task', + query: { fields: [{ field: 'parent_id' }] }, + })).rejects.toThrow(/nested-select object form.*removed.*expand/s); + }); + + it('wrapping a REAL field in the object form is refused too — the shape is wrong, not the name', async () => { + // The gate is about the shape, so `title` being a perfectly good column + // buys the entry nothing. This is also why the shape is judged before + // the field map: a host whose registry cannot answer "is this a field" + // still must not hand `{ field: 'title' }` to a driver. + await expect(protocol.findData({ + object: 'showcase_task', + query: { fields: [{ field: 'title' }] }, + })).rejects.toMatchObject({ status: 400, code: 'INVALID_FIELD', param: 'fields' }); + }); + + it('a dotted path is still accepted — the replacement the rejection prescribes', async () => { + // The string form covers the readable half of what the object form + // claimed: the head segment is validated here, the tail resolved + // downstream. The rejection above points at this and at `expand`. + await expect(protocol.findData({ + object: 'showcase_task', query: { select: 'parent_id.title' }, + })).resolves.toBeDefined(); + }); + it('the system columns the registry injected still project', async () => { // `applySystemFields` adds these at registration — a gate reading only // the AUTHORED field map would reject every one of them. diff --git a/packages/plugins/driver-memory/src/memory-driver.ts b/packages/plugins/driver-memory/src/memory-driver.ts index 81241528c1..77696cfa53 100644 --- a/packages/plugins/driver-memory/src/memory-driver.ts +++ b/packages/plugins/driver-memory/src/memory-driver.ts @@ -342,7 +342,7 @@ export class InMemoryDriver implements IDataDriver { // 5. Field Projection if (query.fields && Array.isArray(query.fields) && query.fields.length > 0) { - results = results.map(record => this.projectFields(record, query.fields as string[])); + results = results.map(record => this.projectFields(record, query.fields!)); } else { // Return shallow copies, never live references into the backing table. // `create()` already honors this contract (`return { ...newRecord }`), diff --git a/packages/plugins/driver-mongodb/src/mongodb-driver.ts b/packages/plugins/driver-mongodb/src/mongodb-driver.ts index 2c5b74047b..7921572658 100644 --- a/packages/plugins/driver-mongodb/src/mongodb-driver.ts +++ b/packages/plugins/driver-mongodb/src/mongodb-driver.ts @@ -223,14 +223,11 @@ export class MongoDBDriver implements IDataDriver { if (query.fields && query.fields.length > 0) { const projection: Document = {}; for (const field of query.fields) { - // A `FieldNode` is either a plain field name or a relationship field - // carrying a nested select. The nested select is resolved by the engine - // (expand → batch `$in` queries), never by the driver, so all the - // projection needs from the object form is the relationship field's own - // name. Indexing with the object itself keyed the projection on - // `"[object Object]"` — invisible until #4171 gave `QueryAST['fields']` - // a real type. - projection[typeof field === 'string' ? field : field.field] = 1; + // A `FieldNode` is a field name. Nested selection is `expand`, resolved + // by the engine (batch `$in` queries), never by the driver — the + // `{ field, fields, alias }` form this loop used to unwrap was removed + // in #4196 once #4171's typing showed it had never had a producer. + projection[field] = 1; } // Always include `id`, never include `_id` projection.id = 1; diff --git a/packages/plugins/driver-sql/src/sql-driver.ts b/packages/plugins/driver-sql/src/sql-driver.ts index 6e21d01184..f2215ff716 100644 --- a/packages/plugins/driver-sql/src/sql-driver.ts +++ b/packages/plugins/driver-sql/src/sql-driver.ts @@ -1351,7 +1351,7 @@ export class SqlDriver implements IDataDriver { // SELECT if (Array.isArray(query.fields) && query.fields.length > 0) { - builder.select((query.fields as string[]).map((f: string) => this.mapSortField(f))); + builder.select(query.fields.map((f) => this.mapSortField(f))); } else { builder.select('*'); } diff --git a/packages/spec/src/data/query.test.ts b/packages/spec/src/data/query.test.ts index 7f8bb39979..8ccbf22c22 100644 --- a/packages/spec/src/data/query.test.ts +++ b/packages/spec/src/data/query.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { QuerySchema, + FieldNodeSchema, AggregationFunction, JoinType, WindowFunction, @@ -111,6 +112,48 @@ describe('QuerySchema - Basic', () => { }); }); +/** + * [#4196] `FieldNode`'s nested-select object form was declared-but-inert — no + * producer, and no consumer that read `.fields`/`.alias` (ADR-0078's silently + * inert declaration; ADR-0049 enforce-or-remove). It is gone, and the rejection + * carries the prescription rather than zod's "expected string, received object": + * the parse error is the channel an upgrading consumer actually hits. + */ +describe('FieldNode — the nested-select object form is REMOVED (#4196)', () => { + it('accepts a field name, and a dotted path through a relationship', () => { + expect(FieldNodeSchema.parse('name')).toBe('name'); + expect(FieldNodeSchema.parse('owner.name')).toBe('owner.name'); + expect(() => QuerySchema.parse({ + object: 'task', fields: ['title', 'owner.name'], + })).not.toThrow(); + }); + + it('rejects the object form with the removal prescription, not a type mismatch', () => { + expect(() => FieldNodeSchema.parse({ field: 'owner', fields: ['name'] })) + .toThrow(/nested-select object form.*removed in @objectstack\/spec 18.*expand/s); + // …and through the query it is reached from, since that is where an author + // writes it. + expect(() => QuerySchema.parse({ + object: 'task', fields: [{ field: 'owner', fields: ['name'] }], + })).toThrow(/nested-select object form.*expand/s); + }); + + it("leaves a non-object mistake with zod's own message", () => { + // The prescription is for the shape that USED to be legal. A number was + // never a FieldNode, so telling its author it was "removed" would misinform. + expect(() => FieldNodeSchema.parse(42)).toThrow(/expected string/i); + expect(() => FieldNodeSchema.parse(42)).not.toThrow(/nested-select/); + }); + + it('the replacement is `expand`, and it still parses', () => { + expect(() => QuerySchema.parse({ + object: 'task', + fields: ['title'], + expand: { owner: { object: 'user', fields: ['name'] } }, + } satisfies QueryAST)).not.toThrow(); + }); +}); + describe('QuerySchema - Aggregations', () => { // ============================================================================ // Basic Aggregation Tests @@ -1660,17 +1703,20 @@ describe('QuerySchema - Edge Cases and Null Handling', () => { expect(() => QuerySchema.parse(query)).not.toThrow(); }); - it('should handle optional alias in field nodes', () => { - const query: QueryAST = { + // [#4196] This case used to assert the opposite — that `{ field, fields, + // alias }` entries parse. They did, and then nothing honoured them: the pin + // proved the DECLARATION, which is exactly how a declared-but-inert shape + // survives a green suite. The `alias` half was the emptiest of all; no + // projection anywhere was ever named by it. + it('refuses relationship field nodes, with or without an alias', () => { + expect(() => QuerySchema.parse({ object: 'account', - fields: [ - 'name', - { field: 'owner', fields: ['name', 'email'] }, - { field: 'manager', fields: ['name'], alias: 'mgr' }, - ], - }; - - expect(() => QuerySchema.parse(query)).not.toThrow(); + fields: ['name', { field: 'owner', fields: ['name', 'email'] }], + })).toThrow(/nested-select object form.*removed/s); + expect(() => QuerySchema.parse({ + object: 'account', + fields: ['name', { field: 'manager', fields: ['name'], alias: 'mgr' }], + })).toThrow(/`alias` has no replacement here/); }); it('should handle aggregation without field for COUNT', () => { @@ -1874,20 +1920,16 @@ describe('QuerySchema - Type Coercion Edge Cases', () => { expect(result.orderBy?.[0].order).toBe('asc'); }); - it('should handle mixed field types', () => { - const query: QueryAST = { + // [#4196] There are no longer two field types to mix — a select list is field + // names. The mixed list is refused at the entry that is not one, and the + // issue `path` points at it so the caller knows which entry to rewrite. + it('refuses a select list that mixes names with object entries, naming the entry', () => { + const result = QuerySchema.safeParse({ object: 'account', - fields: [ - 'simple_field', - { - field: 'related_field', - fields: ['nested_field'], - alias: 'rel', - }, - ], - }; - - expect(() => QuerySchema.parse(query)).not.toThrow(); + fields: ['simple_field', { field: 'related_field', fields: ['nested_field'], alias: 'rel' }], + }); + expect(result.success).toBe(false); + expect(result.error?.issues[0].path).toEqual(['fields', 1]); }); it('should handle deeply nested filters', () => { diff --git a/packages/spec/src/data/query.zod.ts b/packages/spec/src/data/query.zod.ts index 0be048b7da..ded5b51a4e 100644 --- a/packages/spec/src/data/query.zod.ts +++ b/packages/spec/src/data/query.zod.ts @@ -449,39 +449,56 @@ export const WindowFunctionNodeSchema = lazySchema(() => z.object({ })); /** - * One entry of a select list: a plain field name, or a relationship field - * carrying a nested select. + * One entry of a select list: a field name. * - * The TYPE half of {@link FieldNodeSchema} — it is that schema's recursion - * annotation, which used to be `z.ZodType`, making `QueryAST['fields']` - * `any[]` and leaving this one alias away from being a fifth entry in #4171's - * table. + * The whole vocabulary is a column (`'name'`) or a dotted path the engine + * resolves through a relationship field (`'owner.name'`). Related *records* are + * selected with {@link QueryAST.expand}, not from inside this list. + * + * The TYPE half of {@link FieldNodeSchema} — it used to be that schema's + * recursion annotation, back when the union carried a second + * `{ field, fields?, alias? }` member (see the removal note on the schema). + */ +export type FieldNode = string; + +/** + * The prescription for the removed nested-select object form. + * + * The rejection is where an author actually meets a retirement (`retired-key.ts` + * makes the same argument for keys), so the message carries the FROM → TO + * mapping rather than zod's "expected string, received object". */ -export type FieldNode = - | string - | { - /** Relationship field name (e.g. `owner`). */ - field: string; - /** Nested select on the related object. */ - fields?: FieldNode[]; - /** Result alias. */ - alias?: string; - }; +const FIELD_NODE_OBJECT_FORM_REMOVED = + 'A `fields[]` entry is a field name (string). The nested-select object form ' + + '`{ field, fields, alias }` was removed in @objectstack/spec 18 (#4196, ADR-0049) — nothing ' + + 'ever produced it and nothing ever read `.fields`/`.alias`: every consumer on this path ' + + 'treats the list as `string[]`, so the object form was dropped by the SQL and memory drivers, ' + + 'projected as a column literally named "[object Object]" by MongoDB, and refused as an unknown ' + + 'field by the REST ingress. Select related records with `expand` — ' + + "`expand: { owner: { object: 'user', fields: ['name'] } }` — or name a single related column " + + "with a dotted path (`fields: ['owner.name']`). `alias` has no replacement here; an aliased " + + 'projection is an `aggregations` or `windowFunctions` entry, which carry their own `alias`.'; /** * Field Selection Node - * Represents "Select" attributes, including joins. + * Represents "Select" attributes — one field name per entry. + * + * The `{ field, fields?, alias? }` member this union used to carry was REMOVED + * (#4196): it was declared-but-inert, an ADR-0078 silently-inert declaration + * that ADR-0049 requires be enforced or removed. `expand` already expresses + * nested selection, and Prime Directive #12 wants one spelling per capability, + * so the second one goes rather than being lowered into the first. */ -export const FieldNodeSchema: z.ZodType = z.lazy(() => - z.union([ - z.string(), // Primitive field: "name" - z.object({ - field: z.string(), // Relationship field: "owner" - fields: z.array(FieldNodeSchema).optional(), // Nested select: ["name", "email"] - alias: z.string().optional() - }) - ]) -); +export const FieldNodeSchema = z.string({ + // Only the shape that USED to be legal gets the retirement prescription — + // telling the author of `fields: [42]` that their entry "was removed" would + // misinform. Everything else keeps zod's own message. + error: (issue) => + issue.code === 'invalid_type' + && typeof issue.input === 'object' && issue.input !== null && !Array.isArray(issue.input) + ? FIELD_NODE_OBJECT_FORM_REMOVED + : undefined, +}); /** * Full-Text Search Configuration @@ -566,7 +583,7 @@ const BaseQuerySchema = z.object({ object: z.string().describe('Object name (e.g. account)'), /** Select Clause */ - fields: z.array(FieldNodeSchema).optional().describe('Fields to retrieve'), + fields: z.array(FieldNodeSchema).optional().describe('Fields to retrieve — field names, optionally dotted to reach through a relationship (`owner.name`). Related *records* are selected with `expand`, not from inside this list.'), /** Where Clause (Filtering) */ where: FilterConditionSchema.optional().describe('Filtering criteria (WHERE)'), @@ -648,7 +665,9 @@ export type SortNode = z.infer; export type AggregationNode = z.infer; export type GroupByNode = z.infer; export type DateGranularityValue = z.infer; -// `JoinNode` / `FieldNode` are declared next to their schemas — they ARE those -// schemas' annotations, so they cannot be inferred back out of them (#4171). +// `JoinNode` is declared next to its schema — it IS that schema's annotation, so +// it cannot be inferred back out of it (#4171). `FieldNode` sits there too, but +// for a different reason since #4196: it is no longer recursive, it is just the +// name the docs and the engine give to "one entry of a select list". export type WindowFunctionNode = z.infer; export type WindowSpec = z.infer; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 6ddd56f826..1e195c7b93 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -545,9 +545,37 @@ const step18: MigrationStep = { + 'mutation got it executed. That is the dangerous direction of declared ≠ enforced: a flag ' + 'lying about a data-safety guarantee. No dry-run exists today; the schema tombstones the ' + 'key with the prescription. It is HTTP-only (never stored in stack metadata), so the ' - + 'change is one semantic TODO for API callers rather than a stack conversion.', + + 'change is one semantic TODO for API callers rather than a stack conversion.\n\n' + + 'It also narrows `QueryAST.fields` to field names (#4196): the `FieldNode` union carried a ' + + 'second `{ field, fields, alias }` nested-select member that nothing produced and nothing ' + + 'consumed — every reader on the path treats the list as `string[]`, so the object form was ' + + 'dropped by the SQL and memory drivers, projected as a column named "[object Object]" by ' + + 'MongoDB, and refused by the REST ingress as an unknown field of that name. `expand` is the ' + + 'one spelling for nested selection (ADR-0049 enforce-or-remove; Prime Directive #12: one ' + + 'capability, one contract). Like the two above it is a request shape, never stored, so the ' + + 'chain has no source to rewrite.', conversionIds: ['stack-api-require-auth-removed'], semantic: [ + { + id: 'query-field-node-object-form-retired', + surface: 'data.query.fields', + replacement: "expand (`expand: { owner: { object: 'user', fields: ['name'] } }`), or a dotted path for a single related column (`fields: ['owner.name']`)", + reason: + 'The `FieldNode` union declared a nested-select object form `{ field, fields, alias }` that ' + + 'was inert end to end: no producer emitted it, and no consumer read `.fields` or `.alias` ' + + '— objectql\'s formula projection and known-field filters, driver-sql\'s `select()` and ' + + 'driver-memory\'s projection all treat the list as `string[]`, driver-mongodb keyed its ' + + 'projection with the entry itself, and the REST ingress stringified it. Nested selection ' + + 'is `expand`, which the engine resolves via batch `$in` queries. This is a REQUEST ' + + 'surface — `QueryAST` is never stored in stack metadata (no view, dataset or report ' + + 'authors one), so there is no source for the chain to rewrite: the schema narrows to ' + + '`z.string()` and callers move their own select lists. ADR-0049 / ADR-0078, #4196.', + acceptanceCriteria: + 'No caller puts an object in `fields[]`; related records are read through `expand` and ' + + 'single related columns through dotted paths. A `fields` entry that is not a string ' + + 'fails to parse with the removal prescription, and the list/query/export routes answer ' + + '400 INVALID_FIELD naming the retired form instead of the field `"[object Object]"`.', + }, { id: 'batch-options-validate-only-retired', surface: 'api.batchOptions.validateOnly', From 784ed9f3b34a6470e80895d6496963587c1896e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 02:11:59 +0000 Subject: [PATCH 2/2] test(spec): the recursive-schema input pins stop asserting the retired FieldNode form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #4252 landed while this branch was open and pinned the INPUT half of every recursive schema — including two probes that assert the `{ field, fields, alias }` select entry is assignable. Both changes merge without a git conflict and are jointly wrong: the exact shape AGENTS.md's multi-agent discipline #10 warns about. `FieldNode` is no longer recursive, so `FieldNodeSchema` infers both halves itself rather than carrying a `z.ZodType` annotation. Its probes stay anyway — this file's job is that the input side is CHECKED, and a `z.string()` someone re-widens to `z.ZodType` fails here exactly as a dropped type parameter would. The positive probe becomes a dotted path (the replacement the removal prescribes) and the object form joins the negative side. `wiredFieldNode` needs no change: `z.input` is `string`, so `42` is still the error the suppression expects. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EskBioEkCCJpGz3SvPQDxx --- .../src/recursive-schema-input-assertions.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/spec/src/recursive-schema-input-assertions.ts b/packages/spec/src/recursive-schema-input-assertions.ts index a7472ba87b..5e8808baaa 100644 --- a/packages/spec/src/recursive-schema-input-assertions.ts +++ b/packages/spec/src/recursive-schema-input-assertions.ts @@ -73,7 +73,7 @@ import type { /** The authoring shape: only `object` is required, `expand` recurses. */ export const queryInput: QueryInput = { object: 'account', - fields: ['name', { field: 'owner', fields: ['email'] }], + fields: ['name', 'owner.email'], expand: { owner: { object: 'user', fields: ['name'] } }, }; @@ -97,13 +97,25 @@ export const joinNotANumber: JoinNodeInput = 42; // @ts-expect-error — `type` must be one of the four declared join kinds export const joinBadType: JoinNodeInput = { type: 'sideways', object: 'c', on: {} }; -/** A select entry is a bare name or a nested relation select. */ +/** + * A select entry is a field name, optionally dotted through a relationship. + * + * `FieldNode` stopped being recursive in #4196 — the `{ field, fields, alias }` + * member it used to carry was declared-but-inert and is gone, so + * `FieldNodeSchema` infers both halves itself rather than carrying a + * `z.ZodType` annotation. The probes stay: this file's job is that + * the input side is CHECKED, and a `z.string()` that someone re-widens to + * `z.ZodType` fails here exactly as a dropped type parameter would. + */ export const fieldNodeString: FieldNode = 'name'; -export const fieldNodeNested: FieldNode = { field: 'owner', fields: ['email'], alias: 'o' }; +export const fieldNodeDotted: FieldNode = 'owner.email'; // @ts-expect-error — a select entry is not a number export const fieldNodeNotANumber: FieldNode = 42; +// @ts-expect-error — nor the nested-select object form retired in #4196 +export const fieldNodeNotTheRetiredForm: FieldNode = { field: 'owner', fields: ['email'] }; + /* ── data/filter.zod.ts ────────────────────────────────────────────────────── */ /**