Skip to content

Commit 35f2055

Browse files
fix(spec)!: engine-side searchFields declarations refuse an empty list, as FullTextSearchSchema.fields does (#28154)
domain:spec#1 · os-tesla · session_01L9qibwb6hBqszPTYJobitm <!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoDByYUjCh5NqsyFHj21fAy6)_ Fixes #28059 Clause-②: no (narrowing) Metadata-protocol: none ## 维护者速读 - 改了什么:引擎侧三处 `searchFields` 声明(`EngineQueryOptionsSchema`、`EngineAggregateOptionsSchema`、`EngineCountOptionsSchema`)改用共享的搜索字段数组规则,空数组 `[]` 被拒收,并带上与 `QuerySchema.searchFields` 相同的说明文字。 - 为什么改:同一个 schema 上 `search: { fields: [] }` 已被拒收,`searchFields: []` 却被接受,而 Data API 入口和引擎(#27732 进行中)都拒收它;按发布的类型或 JSON Schema 写代码的嵌入方和 AI 会被误导。 - 风险与回滚:只收窄声明,TypeScript 类型不变(仍是 `string[] | undefined`);引擎运行时不用这三个 schema 解析选项,所以直接调用引擎的行为本 PR 不变。回滚即 revert 本 PR。 - 先看:`packages/spec/src/data/data-engine.zod.ts`(三处声明)、`packages/spec/src/data/search-fields-names-a-field.test.ts`(原「仍接受空数组」的阶段边界测试翻转为拒收测试)、`.changeset/28059-engine-search-fields-names-a-field.md`(BREAKING 说明与 ADR-0087 处置)。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run src/data/search-fields-names-a-field.test.ts`,预期全部通过,其中包括 “EngineQueryOptionsSchema.searchFields refuses an empty list with the accept-set sentence”。 - 需要你做的:无。席内契约复审 PASS(评论 6525975157),由席位落地。 ## What changes - `EngineQueryOptionsSchema.searchFields`, `EngineAggregateOptionsSchema.searchFields` and `EngineCountOptionsSchema.searchFields` change from `z.array(z.string()).optional()` to `searchFieldsArray().optional()`, the array arm `QuerySchema.searchFields` and `FullTextSearchSchema.fields` already use (`./search-fields-names-a-field.ts`, imported by this file already). An empty list is refused at `searchFields` with the shared sentence. A list with a blank member is still admitted, as the shared arm admits it. One accept set, no fresh `.min(1)`. - Each of the three now carries a `.describe()` ending in the shared `SEARCH_FIELDS_ACCEPT_SET`, so the published JSON Schema and the reference docs state the rule. `content/docs/references/data/data-engine.mdx` is regenerated (3 lines). - The module docblock of `search-fields-names-a-field.ts` drops "the engine-side declarations do not take it yet". - Changeset `28059-engine-search-fields-names-a-field.md`: `@objectstack/spec` patch (`Metadata-protocol: none`), `fix(spec)!`, both declaration lines, ADR-0087 disposition `not-required (no-migration-prescription)`, and a "Supersedes" note for the unreleased #27683 entry's bullet that said these declarations still admit `[]`. **Engine behaviour on `main`.** The in-process engine does not parse its options with these schemas, but since #28108 (`6a648ac2`, #27732, merged before this PR and shipping in the same release) it refuses `searchFields: []` and `search.fields: []` itself with `400 VALIDATION_ERROR` before any driver read. This branch's merge base predates that commit, so on this branch's own tree the engine still reads `[]` as absent; after the merge the declaration, the Data API doors and the engine refuse the same value. (Seat correction: the earlier sentence said the engine refusal was still in flight.) ## Tests `packages/spec/src/data/search-fields-names-a-field.test.ts`: - The stage-boundary block "the engine-side searchFields declarations are the next stage" (which asserted `[]` is still admitted) is replaced by "the engine-side searchFields declarations": - `%s.searchFields refuses an empty list with the accept-set sentence`, for each of the three schemas: `success: false`, issue `path` equal to `['searchFields']`, message equal to `SEARCH_FIELDS_NAMES_NO_FIELD`. - `%s.searchFields answers [] as its structured search.fields does`: the bare and structured spellings on the same schema give the same message. - `keeps %s unchanged` (one name, two names, a blank member) and `still admits no searchFields at all`, for each schema. - "the declarations state the accept set they enforce" gains the three engine members. <details><summary>Ablation: removing the narrowing turns the new refusal cases red</summary> On committed head `fca20448`, all three declarations were reverted to `z.array(z.string()).optional()` (3 anchors replaced, 0 fixed spellings left, counted with `grep -c`). `vitest run src/data/search-fields-names-a-field.test.ts`: 6 failed | 51 passed (57), exactly the 3 refusal and 3 same-answer cases. The file was restored with `git checkout HEAD --`, and `git diff HEAD` was empty. The describe-sentence cases stayed green because the mutation kept the `.describe()`. </details> <details><summary>Local runs</summary> - `pnpm --filter @objectstack/spec typecheck`: exit 0. `pnpm --filter @objectstack/spec exec vitest run` on `c4560f09`: Test Files 843 passed (843), Tests 23976 passed, 1 todo. - `pnpm --filter @objectstack/spec check:generated`: 1 of 14 stale (`content/docs/references/**`) before `--fix`; after `--fix` up to date. `check:api-surface` was not stale: the types did not change. - Full dogfood (owed by the `(narrowing)` arm), `pnpm --filter @objectstack/dogfood test` under the verify lock on `c4560f09`: lock VERDICT command-exit 0; Test Files 358 passed | 3 skipped (361), Tests 2883 passed | 20 skipped (2903). </details> ## Acceptance notes - The claim writes `Clause-②: no`. The order and triage `6522171038` write `no (narrowing)`, and this is a narrowing, so this body and the changeset carry `no (narrowing)`. - A whitespace-only or blank member (`['']`) is still admitted by all three declarations, as by `QuerySchema.searchFields`. The doors refuse a blank member as an unknown field. This PR leaves that alone, as #27683 did. - No in-repo caller parses an engine option bag holding `searchFields: []` with these schemas (`git grep` of `searchFields: []` outside the spec test: only door-level tests in rest and objectql, which go through `findData`, not these schemas). ## Verification - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 104 commands. All 104 ran with stdin from /dev/null, exit codes captured before any pipe: 104 exited 0. 7 first refused with exit 3 (PREREQUISITE NOT MET: no workspace build, and one self-test needing pinned commit `621a4876`); after the package build and fetching that commit, all 7 exited 0. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran FILE`: "104 derived famil(ies) accounted for — 104 run, 0 NOT-MEASURED". - The gate sweep was taken on `d38c42e6`; the merge with `origin/main` (`c4560f09`) brought in 9 commits, none touching `packages/spec`. - CI on this PR: not yet read; the seat reads the check-runs. --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibybNsmrKsxbKJHwo36)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 139bc18 commit 35f2055

5 files changed

Lines changed: 78 additions & 19 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec)!: `EngineQueryOptionsSchema.searchFields` and its aggregate and count siblings refuse an empty list, as `FullTextSearchSchema.fields` does (#28059)
6+
7+
Clause-②: no (narrowing) — the engine-side declarations of the search-field override refuse `searchFields: []`, which they admitted before. 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 in-process engine option declarations no metadata type embeds, never stored, and the value they stop admitting is the one the Data API doors and the shared FullTextSearchSchema.fields already refuse. 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 engine option declarations (`@objectstack/spec/data`), read by codegen, the reference docs and any caller that parses an engine option bag with them.
13+
14+
- **Before.** `EngineQueryOptionsSchema.searchFields`, `EngineAggregateOptionsSchema.searchFields` and `EngineCountOptionsSchema.searchFields` admitted `[]`, while the structured `search.fields` on the same schemas (it reuses `FullTextSearchSchema`) refused it. One schema said two things about one value.
15+
- **Now.** All three take the shared search-field array arm: a list must hold at least one name, refused with the same sentence (`Name at least one field, or leave the key out to search the default search fields`) at `searchFields`. A list with a blank member is still admitted, as the Data API doors admit it and then refuse the blank name as an unknown field.
16+
- **Unchanged.** The Data API doors already refused an empty override, and in the same release the in-process engine refuses it too (#27732): `engine.find` / `findOne` / `count` / `aggregate` called directly with `searchFields: []` or `search.fields: []` answer `400 VALIDATION_ERROR` with the same sentence. The engine does not parse its options with these schemas, so the declarations now state the refusal it enforces. The TypeScript types are unchanged (`string[] | undefined`).
17+
- **Who sees it.** Code that parses an engine option bag with one of these schemas and sent `searchFields: []`, or a generated client that trusted the wider declaration.
18+
19+
**Supersedes, in the same release.** Two earlier entries no longer hold for these declarations: the `#27683` entry's "Unchanged" bullet saying the engine-side declarations still admit `[]`, and the `#27484` entry's "Not yet narrowed" bullet, whose remaining member `EngineQueryOptionsSchema.searchFields` (with its aggregate and count siblings) refuses `[]` now. A string `''` was never part of these array declarations; it is the transport spelling `$searchFields`, narrowed by `#27683`.
20+
21+
**The remedy.** Name at least one field in `searchFields`, or leave it out to search the object's default search fields.

‎content/docs/references/data/data-engine.mdx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -486,7 +486,7 @@ QueryAST-aligned options for DataEngine.aggregate operations
486486
| **having** | `any` | optional | HAVING — filter over the aggregated rows (aggregation aliases + groupBy projections); applied engine-side after aggregation |
487487
| **timezone** | `string` | optional | |
488488
| **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | |
489-
| **searchFields** | `string[]` | optional | |
489+
| **searchFields** | `string[]` | optional | Fields the `search` expansion may match against, intersected with the object's searchable set (ADR-0061) — at least one field name; a value naming no field (blank, only commas and spaces, or an empty list) is refused |
490490

491491
### Nested Shape: `EngineAggregateOptions.context`
492492

@@ -571,7 +571,7 @@ QueryAST-aligned options for DataEngine.count operations
571571
| **context** | `{ userId?: string; actor?: string; attributedUserId?: string; email?: string; … }` | optional | |
572572
| **where** | `Record<string, any> \| any` | optional | |
573573
| **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | |
574-
| **searchFields** | `string[]` | optional | |
574+
| **searchFields** | `string[]` | optional | Fields the `search` expansion may match against, intersected with the object's searchable set (ADR-0061) — at least one field name; a value naming no field (blank, only commas and spaces, or an empty list) is refused |
575575

576576
### Nested Shape: `EngineCountOptions.context`
577577

@@ -697,7 +697,7 @@ QueryAST-aligned query options for IDataEngine.find() operations
697697
| **top** | `number` | optional | |
698698
| **cursor** | `never` | optional | [REMOVED] `query.cursor` was removed in @objectstack/spec 17 (ADR-0049) — no driver ever implemented keyset pagination, so the cursor was accepted and ignored and every page came back identical (a caller looping "until hasMore is false" never terminates). Delete the key; `QueryBuilder.cursor()` was removed with it. Express the keyset as an ordinary `where` predicate on your sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` — which every driver executes with canonicalised comparands. A first-class cursor, if ever built, will be a response-minted opaque token, not this caller-built record. |
699699
| **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | |
700-
| **searchFields** | `string[]` | optional | |
700+
| **searchFields** | `string[]` | optional | Fields the `search` expansion may match against, intersected with the object's searchable set (ADR-0061) — at least one field name; a value naming no field (blank, only commas and spaces, or an empty list) is refused |
701701
| **expand** | `Record<string, { object: string; fields?: string[]; where?: any; search?: string \| object; … }>` | optional | |
702702
| **distinct** | `never` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 17 (ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. |
703703

‎packages/spec/src/data/data-engine.zod.ts‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -185,9 +185,13 @@ export const EngineQueryOptionsSchema = lazySchema(() => BaseEngineOptionsSchema
185185
* object's declared/derived searchable set (ADR-0061). Read by the engine's
186186
* `$search` → cross-field `$or` expansion and sent by the protocol layer
187187
* ever since; it was enforced but undeclared until #4371 (option 2) made
188-
* the engine reject undeclared option keys.
188+
* the engine reject undeclared option keys. [#28059] It takes the accept
189+
* set of `QuerySchema.searchFields` and `FullTextSearchSchema.fields`
190+
* (`./search-fields-names-a-field.ts`): an empty list names no field and is
191+
* refused, so `searchFields: []` and `search: { fields: [] }` get one answer.
189192
*/
190-
searchFields: z.array(z.string()).optional(),
193+
searchFields: searchFieldsArray().optional()
194+
.describe(`Fields the \`search\` expansion may match against, intersected with the object's searchable set (ADR-0061) — ${SEARCH_FIELDS_ACCEPT_SET}`),
191195

192196
/**
193197
* Recursive relation loading map (expand).
@@ -509,9 +513,11 @@ export const EngineAggregateOptionsSchema = lazySchema(() => BaseEngineOptionsSc
509513
/**
510514
* Fields the `search` expansion may match against — declared exactly as
511515
* {@link EngineQueryOptionsSchema}'s `searchFields`: intersected with the
512-
* object's declared/derived searchable set (ADR-0061), never widened.
516+
* object's declared/derived searchable set (ADR-0061), never widened, and
517+
* an empty list refused (#28059).
513518
*/
514-
searchFields: z.array(z.string()).optional(),
519+
searchFields: searchFieldsArray().optional()
520+
.describe(`Fields the \`search\` expansion may match against, intersected with the object's searchable set (ADR-0061) — ${SEARCH_FIELDS_ACCEPT_SET}`),
515521
}).describe('QueryAST-aligned options for DataEngine.aggregate operations'));
516522

517523
// --------------------------------------------------------------------------
@@ -554,9 +560,11 @@ export const EngineCountOptionsSchema = lazySchema(() => BaseEngineOptionsSchema
554560
/**
555561
* Fields the `search` expansion may match against — declared exactly as
556562
* {@link EngineQueryOptionsSchema}'s `searchFields`: intersected with the
557-
* object's declared/derived searchable set (ADR-0061), never widened.
563+
* object's declared/derived searchable set (ADR-0061), never widened, and
564+
* an empty list refused (#28059).
558565
*/
559-
searchFields: z.array(z.string()).optional(),
566+
searchFields: searchFieldsArray().optional()
567+
.describe(`Fields the \`search\` expansion may match against, intersected with the object's searchable set (ADR-0061) — ${SEARCH_FIELDS_ACCEPT_SET}`),
560568
}).describe('QueryAST-aligned options for DataEngine.count operations'));
561569

562570
// --------------------------------------------------------------------------

‎packages/spec/src/data/search-fields-names-a-field.test.ts‎

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
/**
44
* [#27683] The published search-field override declarations —
55
* `QueryTransportParamsSchema.$searchFields`, `QuerySchema.searchFields` and
6-
* `FullTextSearchSchema.fields` — refuse a value naming no field, the set the
6+
* `FullTextSearchSchema.fields`, and since #28059 the engine-side
7+
* `searchFields` (`EngineQueryOptionsSchema` and its aggregate and count
8+
* siblings) — refuse a value naming no field, the set the
79
* protocol door refuses with a 400 since #27484: a blank string, only commas
810
* and spaces, an empty list, and an empty structured `search.fields`. They used
911
* to admit all of it, so the published JSON Schema admitted what the door
@@ -133,18 +135,46 @@ describe('the declarations state the accept set they enforce', () => {
133135
['QueryTransportParamsSchema.$searchFields', QueryTransportParamsSchema.shape.$searchFields],
134136
['QuerySchema.searchFields', (QuerySchema as unknown as { shape: Record<string, { description?: string }> }).shape.searchFields],
135137
['FullTextSearchSchema.fields', FullTextSearchSchema.shape.fields],
138+
['EngineQueryOptionsSchema.searchFields', EngineQueryOptionsSchema.shape.searchFields],
139+
['EngineAggregateOptionsSchema.searchFields', EngineAggregateOptionsSchema.shape.searchFields],
140+
['EngineCountOptionsSchema.searchFields', EngineCountOptionsSchema.shape.searchFields],
136141
];
137142
it.each(members)('%s carries the shared sentence', (_name, member) =>
138143
expect(member?.description).toContain(SEARCH_FIELDS_ACCEPT_SET));
139144
});
140145

141-
describe('stage boundary: the engine-side searchFields declarations are the next stage', () => {
142-
// The in-process engine still reads an empty override as absent, and its own
143-
// declarations still say so; the engine stage moves them together.
144-
it.each([
146+
/**
147+
* [#28059] The engine-side declarations take the same array arm, so the
148+
* declaration never admits what `FullTextSearchSchema.fields` (the structured
149+
* `search` they share) and the engine refuse.
150+
*/
151+
describe('the engine-side searchFields declarations', () => {
152+
const ENGINE_SCHEMAS = [
145153
['EngineQueryOptionsSchema', EngineQueryOptionsSchema],
146154
['EngineAggregateOptionsSchema', EngineAggregateOptionsSchema],
147155
['EngineCountOptionsSchema', EngineCountOptionsSchema],
148-
] as const)('%s.searchFields still admits an empty list', (_name, schema) =>
149-
expect((schema as Schema).safeParse({ searchFields: [] }).success).toBe(true));
156+
] as const;
157+
158+
it.each(ENGINE_SCHEMAS)('%s.searchFields refuses an empty list with the accept-set sentence', (_name, schema) => {
159+
const issue = firstIssue(schema as Schema, { searchFields: [] });
160+
expect(issue.path).toEqual(['searchFields']);
161+
expect(issue.message).toBe(SEARCH_FIELDS_NAMES_NO_FIELD);
162+
});
163+
164+
it.each(ENGINE_SCHEMAS)('%s.searchFields answers `[]` as its structured `search.fields` does', (_name, schema) => {
165+
const bare = firstIssue(schema as Schema, { searchFields: [] });
166+
const structured = firstIssue(schema as Schema, { search: { query: 'x', fields: [] } });
167+
expect(bare.message).toBe(structured.message);
168+
});
169+
170+
for (const [name, schema] of ENGINE_SCHEMAS) {
171+
it.each(KEPT_LISTS)(`${name}.searchFields keeps %s unchanged`, (_label, value) => {
172+
const parsed = (schema as Schema).safeParse({ search: 'x', searchFields: value });
173+
expect(parsed.success).toBe(true);
174+
expect(parsed.data?.searchFields).toEqual(value);
175+
});
176+
177+
it(`${name} still admits no searchFields at all`, () =>
178+
expect((schema as Schema).safeParse({ search: 'x' }).success).toBe(true));
179+
}
150180
});

‎packages/spec/src/data/search-fields-names-a-field.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@ import { z } from 'zod';
2727
* rule lives beside it in `./projection-names-a-field.ts`.
2828
*
2929
* [#27683] The declarations joined the door, which refuses this set since
30-
* #27484. The engine-side `searchFields` declarations
31-
* (`EngineQueryOptionsSchema` and its aggregate and count siblings) do not
32-
* take it yet: the in-process engine still reads an empty override as absent.
30+
* #27484. [#28059] The engine-side `searchFields` declarations
31+
* (`EngineQueryOptionsSchema` and its aggregate and count siblings) take the
32+
* array arm too, so no published declaration of the override admits `[]`.
3333
*/
3434
export const SEARCH_FIELDS_NAMES_NO_FIELD =
3535
'Name at least one field, or leave the key out to search the default search fields';

0 commit comments

Comments
 (0)