Skip to content

Commit 74aae1e

Browse files
fix(spec)!: the published searchFields declarations refuse a value naming no field, as the Data API doors do (#27725)
domain:spec#3 · os-support-ai · session_01WGufzgbJhFoXYsLSe5WSPP <!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBCNbr1niqMMAugbuJbWkmUX)_ Fixes #27683 Clause-②: no (narrowing) — the published declarations of the search-field override (`QueryTransportParamsSchema.$searchFields`, `QuerySchema.searchFields`, `FullTextSearchSchema.fields`, reached through `QueryWithTransportSchema` and `FindDataRequestSchema.query`) refuse a value naming no field, which they admitted before. The doors already refused it with a 400 (#27484 / PR #27670); nothing is widened and no key is added, removed or renamed. Metadata-protocol: none Skills-docs: none — every place under `skills/**` that teaches the narrowed members still holds (`git grep -n "searchFields\|search\.fields" -- skills` at this head): `skills/objectstack-query/SKILL.md` § Full-Text Search teaches a non-empty `searchFields` and "Omit it to search the object's declared `searchableFields`", its key tables (lines 25-26) list the engine option, which this PR does not narrow, and its eval and `skills/objectstack-data/SKILL.md:125` emit only named overrides. No skill emits or recommends an empty override. `Metadata-protocol: none` was measured, not assumed: no metadata type embeds `QuerySchema`, `FullTextSearchSchema` or the transport bag (`git grep` of every `*.zod.ts` under `packages/spec/src/{ui,automation,ai,system,kernel,security}` for the three schema names and for `searchFields`: only prose comments in `ui/component.zod.ts`). The readers are request bodies, the driver and API contracts, and codegen. PR #27670 (#27484) merged at `86f2afeb` before this branch started, so there is no landing order to name; its predicate was measured on `main`. ## 维护者速读 - 改了什么:已发布的三处 `searchFields` 声明(`QueryTransportParamsSchema.$searchFields`、`QuerySchema.searchFields`、`FullTextSearchSchema.fields`)不再接受“一个字段都没写”的值(空串、只有逗号和空格、空数组、对象形 `search.fields: []`),三处描述带同一句接受集说明;`POST /data/:object/query` 因此在请求体 schema 处拒收,回 400 `VALIDATION_FAILED`(原为门内的 `VALIDATION_ERROR`),与 `select: []` 一致。 - 为什么改:数据接口自 #27484 起已拒收这些值,但发布的 Zod 与 JSON Schema 仍声明接受,按 schema 生成的客户端和 AI 会写出被门拒收的请求;`select` 的同类声明早已收紧(#26909)。 - 风险与回滚:只收窄声明,接受集与门完全一致(有逐值对照测试);`POST /query` 的错误码从 `VALIDATION_ERROR` 变 `VALIDATION_FAILED`,仍是 400、仍不读库。引擎侧声明不动(下一阶段卡)。回滚即还原本 PR。 - 先看:`packages/spec/src/data/search-fields-names-a-field.ts`(接受集与两条臂),`packages/rest/src/data-empty-search-fields-door.test.ts`(POST 行迁到 schema 拒收表、末尾 parity 用例),`packages/spec/src/data/query.zod.ts`(两处成员)。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run src/data/search-fields-names-a-field.test.ts`,应见 39 个用例全绿。 - 需要你做的:无,席位落地(进队前欠一次契约复核)。 ## What changes ### Measured first, on `origin/main` `bfdaba43` (#27670 merged) Each declaration parsed with the four spellings of "names no field", next to what the protocol door (`assertSearchFieldsAreSearchable`, the gate `findData` calls for the standalone slot and for the object-form `search.fields`) answers for the same value. The door column is the registry-less host, so it is the shape rule alone; a registered object answers the same for these values, because the empty check runs before the field-map lookup. | value | `$searchFields` | `QuerySchema.searchFields` | `FullTextSearchSchema.fields` | `FindDataRequestSchema` `query.$searchFields` / `query.searchFields` / `query.search.fields` | `EngineQueryOptionsSchema` / `EngineAggregateOptionsSchema` / `EngineCountOptionsSchema` `.searchFields` | protocol door (standalone / `search.fields`) | |:--|:--|:--|:--|:--|:--|:--| | `''` | accept | string refused (array-only) | n/a | accept / string refused / n/a | n/a (array-only) | 400 `VALIDATION_ERROR` / 400 `VALIDATION_ERROR` | | `' , '` | accept | string refused | n/a | accept / string refused / n/a | n/a | 400 `VALIDATION_ERROR` / 400 `VALIDATION_ERROR` | | `[]` | accept | accept | accept | accept / accept / accept | accept / accept / accept | 400 `VALIDATION_ERROR` / 400 `VALIDATION_ERROR` | | `search: { fields: [] }` | (see `FullTextSearchSchema.fields`) | | accept | accept | accept (via the shared `FullTextSearchSchema`) | 400 `VALIDATION_ERROR` | | `['']` (control) | accept | accept | accept | accept | accept | accepted as a value; refused as an unknown field once the object is known | | `'name'` / `['name']` (control) | accept | accept (array) | accept | accept | accept | accepted | The door's predicate, read at `packages/metadata-protocol/src/protocol-query-validation.ts` on `main`: a string is `split(',')`, trimmed and emptied of blank members; an array is kept as sent; the override is refused as naming no field iff that leaves zero names. That is the set used here, exactly. ### After, at this PR's head | value | `$searchFields` | `QuerySchema.searchFields` | `FullTextSearchSchema.fields` | `FindDataRequestSchema` (all three slots) | engine-side declarations | protocol door | |:--|:--|:--|:--|:--|:--|:--| | `''`, `' '`, `','`, `' , '` | **refused** (`invalid_format`, "Name at least one field, or leave the key out to search the default search fields") | string refused, as before | n/a | **refused** at `query.$searchFields` | unchanged | unchanged (400 `VALIDATION_ERROR`) | | `[]` | **refused** (`too_small`, same sentence) | **refused** | **refused** | **refused** at `query.searchFields` / `query.$searchFields` / `query.search.fields` | `searchFields` unchanged (still admits `[]`); `search.fields: []` refused through the shared `FullTextSearchSchema` | unchanged | | `['']`, `'name'`, `' name , email '`, `['name']` | accept | accept | accept | accept | accept | unchanged | ### The declarations (`packages/spec/src/data/`) - New `search-fields-names-a-field.ts`, outside the `data` barrel (the `projection-names-a-field.ts` pattern): the refusal sentence `SEARCH_FIELDS_NAMES_NO_FIELD`, the shared accept-set sentence `SEARCH_FIELDS_ACCEPT_SET` ("at least one field name; a value naming no field (blank, only commas and spaces, or an empty list) is refused"), the string arm (`regex(/[^\s,]/)`: holds iff some comma segment has a non-whitespace character, `\s` being the set `trim()` strips) and the array arm (`min(1)`). Its own module because `query.zod.ts` cannot import `data-engine.zod.ts`. Both are a pattern and a minimum rather than refinements, so the published JSON Schema states them (`pattern`, `minItems`), and `dropped-refinements.baseline.json` does not move. - `QueryTransportParamsSchema.$searchFields` (`data-engine.zod.ts`): `z.union([searchFieldsString(), searchFieldsArray()])`, still `.nullable().optional()`, so an explicit `null` is still a withdrawal. - `QuerySchema.searchFields` and `FullTextSearchSchema.fields` (`query.zod.ts`): `searchFieldsArray().optional()`. - Each of the three `.describe()` texts ends with the shared accept-set sentence; the references (`content/docs/references/{api/contract,api/protocol,data/data-engine,data/query}.mdx`) were regenerated with `check:generated --fix`. - A blank member (`['']`) is deliberately outside the set. Unlike the projection, whose door refuses a blank member through the same "names no field" envelope, the search-field door keeps it as a name and then refuses it as an unknown field when the object is known (`INVALID_FIELD`). Refusing it at the schema would make the schema refuse a value the door's "names no field" predicate admits, so the two would no longer refuse the same set. ### The stage boundary (what the engine card owns) Measured item 2 of the order: neither `EngineQueryOptionsSchema.searchFields` (`data-engine.zod.ts` ~:189) nor the aggregate (~:513) and count (~:558) siblings is a declaration the door parses. `POST /data/:object/query` parses `FindDataRequestSchema` (whose `query` is `QueryWithTransportSchema`, built from `QuerySchema` and `QueryTransportParamsSchema`) at `packages/rest/src/crud-routes.ts` and in the dispatcher (`packages/runtime/src/domains/data.ts`); `GET /data/:object` and export reach `findData` with no schema at all. The three engine-side declarations are the in-process engine's options (`IDataEngine.find` / `aggregate` / `count`), so they stay, and so does the engine's tolerance of an empty override: the next stage, in the #27008 shape. One coupling the engine card should know about: `EngineQueryOptionsSchema.search` (and its aggregate and count siblings) declare their structured form as the shared `FullTextSearchSchema`, so `search: { query, fields: [] }` is now refused by those engine-side declarations too. The engine does not parse `search` (`fillQueryAstDefaults` in `packages/objectql/src/engine.ts` says so and why), so no runtime answer moves. Likewise `EngineQueryOptionsSchema.expand` values are `QuerySchema`, so an `expand` entry's `searchFields: []` is refused by the declaration; the engine does not parse `searchFields` on an expand entry either. The `stage boundary` describe in the new spec test pins the three `searchFields` members still admitting `[]`, so the engine card flips it on purpose. ### `POST /data/:object/query` (`packages/rest/src/data-empty-search-fields-door.test.ts`) Cleared by the seat's claim amendment on #27683 (the file was outside the dispatched surface). The two POST rows #27670 pinned at the door's `400 VALIDATION_ERROR` now answer at the body schema, `400 VALIDATION_FAILED`, with `fields[0].field` naming the key: `query.searchFields`, `query.$searchFields` or `query.search.fields`. That is how the route already answers `select: []` (#26909). The new `REFUSED_POST_AT_SCHEMA` table covers `searchFields: []`, `$searchFields: ""`, `$searchFields: " , "`, `$searchFields: []` and `search: { query, fields: [] }`, each with "no read of the object". The GET list and export rows are unchanged: those doors have no body schema and still answer the door's `VALIDATION_ERROR`. ## Tests - New `packages/spec/src/data/search-fields-names-a-field.test.ts` (39 cases): per declaration, each empty spelling refused with the sentence at the member's path (`$searchFields`: four strings and `[]`; `QuerySchema.searchFields` and `FullTextSearchSchema.fields`: `[]`), and controls kept unchanged (one name, a spaced comma list, a list, a blank member); the same refusals through `QueryWithTransportSchema` and `FindDataRequestSchema.query` for `$searchFields`, `searchFields`, `search.fields` and `$search.fields`; an explicit `null` still a withdrawal; a kept comma list lowered onto `searchFields`; each `.describe()` carrying the shared sentence; and the stage-boundary pin above. - `packages/rest/src/data-empty-search-fields-door.test.ts`: the POST rows move to `REFUSED_POST_AT_SCHEMA` as above, and a new **parity** case holds the schema door (`FindDataRequestSchema`, which the POST route parses) and the protocol door (`protocol.findData`, which the GET and export routes reach with no schema) to one refused set, probe for probe, over a real SQLite object: 8 strings and 4 lists on `$searchFields`, the 4 lists on `searchFields` and on `search.fields` (20 probes). The schema refuses a probe iff the door answers `VALIDATION_ERROR` "names no field"; the verdict counts are pinned (8 refused, 9 served, 3 `INVALID_FIELD` for the blank member), so neither side can pass vacuously. ### Ablation At head `7b3667ba` (fix and tests committed), through `scripts/ablation-replace.mjs` (trap restore), under the lock, against `src` (the spec suite imports the schemas by relative path, so no build or dist step is involved): - Leg 1, array arm: `z.array(z.string()).min(1, SEARCH_FIELDS_NAMES_NO_FIELD)` → `z.array(z.string()).min(0, 'ABLATED-27683')`. Landed: anchor 1 → 0, blob `4cb5ab59` → `288d09ac`. `search-fields-names-a-field.test.ts`: 7 failed / 32 passed (every `[]` refusal on all three declarations and all four body spellings). Restored: blob `4cb5ab59` = HEAD, `git diff HEAD` empty. - Leg 2, string arm: `regex(/[^\s,]/, …)` → `regex(/ABLATED-27683|/, …)` (matches everything). Landed: anchor 1 → 0, blob `4cb5ab59` → `e13506da`. 4 failed / 35 passed: the four `$searchFields` string refusals on `QueryTransportParamsSchema`. The same strings through `QueryWithTransportSchema` stayed refused, because the fold lowers `''` / `' , '` to `searchFields: []` and the array arm on `QuerySchema.searchFields` catches it: the two arms back each other on the transport-aware slot. Restored: blob = HEAD, `git diff HEAD` empty. - A first leg-1 attempt used a replacement (`z.array(z.string())`) that already occurred inside the anchor, so the tool refused it (replacement count did not rise) and restored before running anything; it was redone with the marker above. - Not ablated at the rest layer (it reads the spec through `dist`, so each leg would need a rebuild). What stands in for it: the first run of the new POST case on the narrowed spec was red on one row for the right reason (the structured form answered `query.search.fields`, not the `query.search` first written), so the case reads the live schema answer; and the parity case compares each of its 20 probes against the door's own answer with pinned verdict counts. ## Acceptance notes - Two pre-existing schema/door differences outside "names no field", unchanged and not widened here: the door accepts a comma STRING in the canonical `searchFields` slot and in the structured `search.fields` ("the engine accepts the comma-string and array forms from either slot"), while `QuerySchema.searchFields` and `FullTextSearchSchema.fields` declare arrays only, so `POST {"searchFields":"label"}` is refused at the body schema as it was on `main` (measured: `query.searchFields` `invalid_type`). Recorded for the engine card's reading; not a finding filed from here. - The `#27484` changeset's "Not yet narrowed" bullet becomes false in the same release; `check-empty-changeset` refuses an edit to a changeset the merge base carries, so this PR's changeset carries a "Supersedes, in the same release" paragraph instead. - No caller in the repository sends an empty override through these schemas: the Console and the bundled view plugins send `$searchFields` only when it holds a field (#27484's measurement), and `git grep` for `searchFields: []` / `fields: []` beside `search` in `*.test.ts` finds only the rest door file (updated) and objectql's `query-expression-conformance.test.ts`, which calls `protocol.findData` directly and so is unaffected. ## Verification All at head `4ccc5167` (this PR's head: the two commits plus a merge of `origin/main` `a75ddba1` through `scripts/pm/os-regen-merge.sh`, which kept the branch's bytes of every generated reference since `main` had not moved them), under `scripts/pm/os-verify-lock.sh` on the shared box. <details><summary>Measurement script (before and after)</summary> - A throwaway `tsx` probe (not committed) parsed every declaration in the tables above with `''`, `' , '`, `[]`, `['']`, `'name'`, `['name']`, and called `assertSearchFieldsAreSearchable` (registry-less host) for the standalone slot and for `search.fields`. On `bfdaba43` it produced the "Measured first" table; on this head the diff against that output is exactly the rows marked **refused** in the "After" table, and the engine-side `searchFields` rows and the door rows are byte-identical. </details> <details><summary>Suites: spec, rest and the full dogfood battery, all exit 0</summary> - `pnpm build --concurrency=2` (full turbo build, 114 tasks): `Tasks: 114 successful, 114 total`, lock VERDICT command-exit 0 (17m43s). - One locked chain, VERDICT command-exit 0 (46m52s): - `pnpm --filter @objectstack/dogfood test`: exit 0, `Test Files 352 passed | 3 skipped (355)`, `Tests 2844 passed | 20 skipped (2864)`. No failures. - `pnpm --filter @objectstack/spec test`: exit 0, `Test Files 754 passed (754)`, `Tests 22430 passed | 1 todo (22431)`. - `pnpm --filter @objectstack/spec typecheck`: exit 0. - `pnpm --filter @objectstack/rest test`: exit 0, `Test Files 340 passed (340)`, `Tests 6671 passed | 326 skipped (6997)`. - `pnpm --filter @objectstack/rest typecheck`: exit 0. - `pnpm --filter @objectstack/spec check:generated` (after `--fix`): all 14 artifacts current; `--fix` regenerated only `content/docs/references/**` (4 files). - `pnpm check:adr-0087-registration`: exit 0, `1 declared-breaking changeset(s), each carrying an ADR-0087 disposition` (`not-required (no-migration-prescription)`). - `spec test` does not run the `test:repo` project; CI's `Test Core` does. </details> <details><summary>Derived gates: 104 derived, 104 run, all exit 0</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `4ccc5167`: 104 families (the same list as at `7b3667ba`, before the merge). - Serial pass after the full build and `git fetch origin 621a487` (the teardown-shape self-test's pinned fixture): 104 exit 0, none exit 3. - `dispatch-gates --ran`: `104 derived, 104 run, 0 NOT-MEASURED, 0 UNRUN`, exit 0. </details> `origin/main` moved 21 commits after the merge; none touches a file in this diff (`git diff --name-only HEAD...origin/main` against the ten paths), so no second merge was made, and the dogfood reading above is on the PR's final head. --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufziW1GcMSZeg21L26Xk)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3702be4 commit 74aae1e

10 files changed

Lines changed: 318 additions & 36 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec)!: `QueryTransportParamsSchema.$searchFields`, `QuerySchema.searchFields` and `FullTextSearchSchema.fields` declare the search-field override the Data API doors accept, instead of any string or list (#27683)
6+
7+
Clause-②: no (narrowing) — the published declarations of the search-field override (`$searchFields`, the canonical `searchFields`, and the structured `search.fields`, reached through `QueryWithTransportSchema` and `FindDataRequestSchema.query`) refuse a value naming no field, which they admitted before. The doors already refused it with a 400; nothing is widened and no key is added, removed or renamed.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped and no metadata type or stored shape moves: these are query declarations no metadata type embeds, never stored, and the values they stop admitting are values the Data API doors already refused. A caller names at least one field or leaves the key out; there is nothing for a migration to rewrite and no tombstone. -->
11+
12+
**BREAKING** accept-set narrowing of published query declarations (`@objectstack/spec/data`, and `@objectstack/spec/api` through `FindDataRequestSchema`), read by the `POST /data/:object/query` body gate, codegen, the reference docs and clients.
13+
14+
- **Before.** `$searchFields` admitted any string or list: a blank, whitespace-only or comma-only string, and `[]`. `QuerySchema.searchFields` and `FullTextSearchSchema.fields` admitted `[]`. The published JSON Schema admitted what the doors refuse.
15+
- **Now.** A string must name at least one field after the door's own split and trim (published as the pattern `[^\s,]`), and a list must hold at least one name. That is exactly the set the door refuses as naming no field. A list with a blank member is still admitted by the schema, as the door admits it as a value and then refuses the blank name as an unknown field.
16+
- **`POST /data/:object/query`.** That route parses its body with `FindDataRequestSchema`, so an empty `searchFields`, `$searchFields` or structured `search.fields` is now refused at the body schema: still `400`, still before any read, but `VALIDATION_FAILED` with `fields[0].field` naming `query.searchFields`, `query.$searchFields` or `query.search.fields`, where it was `VALIDATION_ERROR` from the list door. This is how that route already answers an empty `select`.
17+
- **Unchanged.** `GET /data/:object` and `GET /data/:object/export` still answer an empty `$searchFields` / `searchFields` with `400 VALIDATION_ERROR` naming it; a named override (`label`, `["label", "note"]`, spaces and empty members between commas trimmed by the door) and an explicit `null` (a withdrawal) are accepted as before. The engine-side declarations (`EngineQueryOptionsSchema.searchFields` and its aggregate and count siblings) still admit `[]`, and the in-process engine still reads an empty override as absent; moving them is a separate change. The structured `search` form those engine declarations share is `FullTextSearchSchema`, so its `fields: []` is refused there too, though the engine itself does not parse `search`.
18+
- **Who sees it.** A caller of `POST /data/:object/query` that branches on the `VALIDATION_ERROR` code for an empty search-field override, code that parses a query bag with these schemas and sent an empty override, or a generated client that trusted the wider declaration.
19+
20+
**Supersedes, in the same release.** The `#27484` entry's "Not yet narrowed" bullet no longer holds: the three declarations it names other than `EngineQueryOptionsSchema.searchFields` refuse `''` and `[]` now, and on `POST /data/:object/query` an empty `searchFields` is the body schema's `400 VALIDATION_FAILED`, the same answer `select: []` gets.
21+
22+
**The remedy.** Send the override naming at least one field, or leave it out to search the object's default search fields.

‎content/docs/references/api/contract.mdx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -587,7 +587,7 @@ const result = ApiErrorSchema.parse(data);
587587
| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns, at least one: omit `fields` to read every column, because an empty list or a blank name is refused rather than read as every column. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. |
588588
| **where** | `any` | optional | Filtering criteria (WHERE) |
589589
| **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration |
590-
| **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) |
590+
| **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) — at least one field name; a value naming no field (blank, only commas and spaces, or an empty list) is refused |
591591
| **orderBy** | `{ field: string; order?: Enum<'asc' \| 'desc'> }[]` | optional | Sorting instructions (ORDER BY) |
592592
| **limit** | `number` | optional | Max records to return (LIMIT) |
593593
| **offset** | `number` | optional | Records to skip (OFFSET) |
@@ -606,7 +606,7 @@ const result = ApiErrorSchema.parse(data);
606606
| Property | Type | Required | Description |
607607
| :--- | :--- | :--- | :--- |
608608
| **query** | `string` | ✅ | Search query text |
609-
| **fields** | `string[]` | optional | Fields to search in (if not specified, searches all text fields) |
609+
| **fields** | `string[]` | optional | Fields to search in (if not specified, searches all text fields) — at least one field name; a value naming no field (blank, only commas and spaces, or an empty list) is refused |
610610
| **fuzzy** | `boolean` | optional (default: `false`) | [EXPERIMENTAL — not enforced] Fuzzy matching (tolerate typos). The ADR-0061 expansion reads only `query` + `fields`; no executor receives this flag. |
611611
| **operator** | `Enum<'and' \| 'or'>` | optional (default: `"or"`) | [EXPERIMENTAL — not enforced] Logical operator between terms. The ADR-0061 expansion applies its own term semantics; no executor receives this flag. |
612612
| **boost** | `Record<string, number>` | optional | [EXPERIMENTAL — not enforced] Field-specific relevance boosting (field name -> boost factor). No executor scores results. |
@@ -640,7 +640,7 @@ const result = ApiErrorSchema.parse(data);
640640
| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns, at least one: omit `fields` to read every column, because an empty list or a blank name is refused rather than read as every column. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. |
641641
| **where** | `any` | optional | Filtering criteria (WHERE) |
642642
| **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration |
643-
| **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) |
643+
| **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) — at least one field name; a value naming no field (blank, only commas and spaces, or an empty list) is refused |
644644
| **orderBy** | `{ field: string; order?: Enum<'asc' \| 'desc'> }[]` | optional | Sorting instructions (ORDER BY) |
645645
| **limit** | `number` | optional | Max records to return (LIMIT) |
646646
| **offset** | `number` | optional | Records to skip (OFFSET) |

‎content/docs/references/api/protocol.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -902,7 +902,7 @@ Enable package response
902902
| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns, at least one: omit `fields` to read every column, because an empty list or a blank name is refused rather than read as every column. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. |
903903
| **where** | `[string, string, any] \| [string, string] \| [string, object, ...object[]] \| object[] \| Record<string, any> \| any` | optional | Filtering criteria (WHERE) — a filter condition, or the input-only `FilterArray` sugar (`['status', '=', 'open']`), which is lowered through `parseFilterAST` before the query is produced. |
904904
| **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration |
905-
| **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) |
905+
| **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) — at least one field name; a value naming no field (blank, only commas and spaces, or an empty list) is refused |
906906
| **orderBy** | `{ field: string; order?: Enum<'asc' \| 'desc'> }[]` | optional | Sorting instructions (ORDER BY) |
907907
| **limit** | `integer` | optional | Max records to return (LIMIT) |
908908
| **offset** | `integer` | optional | Records to skip (OFFSET) |
@@ -923,7 +923,7 @@ Enable package response
923923
| **$select** | `string \| string[] \| null` | optional | Transport spelling of `fields` (OData `$select`) — at least one field name; a value naming no field (blank, only commas and spaces, an empty list, or a list with a blank name) is refused |
924924
| **$expand** | `string \| string[] \| Record<string, { object: string; fields?: string[]; where?: any; search?: string \| object; … }> \| null` | optional | Transport spelling of `expand` (OData `$expand`) |
925925
| **$search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … } \| null` | optional | Transport spelling of `search` (OData `$search`) |
926-
| **$searchFields** | `string \| string[] \| null` | optional | Transport spelling of `searchFields` (OData `$searchFields`) |
926+
| **$searchFields** | `string \| string[] \| null` | optional | Transport spelling of `searchFields` (OData `$searchFields`) — at least one field name; a value naming no field (blank, only commas and spaces, or an empty list) is refused |
927927
| **$count** | `boolean \| Enum<'true' \| 'false'> \| null` | optional | Transport spelling of the response total-count flag (OData `$count`) |
928928
| **count** | `boolean \| Enum<'true' \| 'false'> \| null` | optional | Response total-count flag — only an explicit `false` skips the COUNT query |
929929
| **filter** | `[string, string, any] \| [string, string] \| [string, object, ...object[]] \| object[] \| Record<string, any> \| any \| null` | optional | Transport spelling of `where` |

0 commit comments

Comments
 (0)