Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/28059-engine-search-fields-names-a-field.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@objectstack/spec': patch
---

fix(spec)!: `EngineQueryOptionsSchema.searchFields` and its aggregate and count siblings refuse an empty list, as `FullTextSearchSchema.fields` does (#28059)

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.
Metadata-protocol: none

<!-- 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. -->

**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.

- **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.
- **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.
- **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`).
- **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.

**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`.

**The remedy.** Name at least one field in `searchFields`, or leave it out to search the object's default search fields.
6 changes: 3 additions & 3 deletions content/docs/references/data/data-engine.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,7 @@ QueryAST-aligned options for DataEngine.aggregate operations
| **having** | `any` | optional | HAVING — filter over the aggregated rows (aggregation aliases + groupBy projections); applied engine-side after aggregation |
| **timezone** | `string` | optional | |
| **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | |
| **searchFields** | `string[]` | optional | |
| **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 |

### Nested Shape: `EngineAggregateOptions.context`

Expand Down Expand Up @@ -571,7 +571,7 @@ QueryAST-aligned options for DataEngine.count operations
| **context** | `{ userId?: string; actor?: string; attributedUserId?: string; email?: string; … }` | optional | |
| **where** | `Record<string, any> \| any` | optional | |
| **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | |
| **searchFields** | `string[]` | optional | |
| **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 |

### Nested Shape: `EngineCountOptions.context`

Expand Down Expand Up @@ -697,7 +697,7 @@ QueryAST-aligned query options for IDataEngine.find() operations
| **top** | `number` | optional | |
| **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. |
| **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | |
| **searchFields** | `string[]` | optional | |
| **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 |
| **expand** | `Record<string, { object: string; fields?: string[]; where?: any; search?: string \| object; … }>` | optional | |
| **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. |

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

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

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

// --------------------------------------------------------------------------
Expand Down
44 changes: 37 additions & 7 deletions packages/spec/src/data/search-fields-names-a-field.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
/**
* [#27683] The published search-field override declarations —
* `QueryTransportParamsSchema.$searchFields`, `QuerySchema.searchFields` and
* `FullTextSearchSchema.fields` — refuse a value naming no field, the set the
* `FullTextSearchSchema.fields`, and since #28059 the engine-side
* `searchFields` (`EngineQueryOptionsSchema` and its aggregate and count
* siblings) — refuse a value naming no field, the set the
* protocol door refuses with a 400 since #27484: a blank string, only commas
* and spaces, an empty list, and an empty structured `search.fields`. They used
* to admit all of it, so the published JSON Schema admitted what the door
Expand Down Expand Up @@ -133,18 +135,46 @@ describe('the declarations state the accept set they enforce', () => {
['QueryTransportParamsSchema.$searchFields', QueryTransportParamsSchema.shape.$searchFields],
['QuerySchema.searchFields', (QuerySchema as unknown as { shape: Record<string, { description?: string }> }).shape.searchFields],
['FullTextSearchSchema.fields', FullTextSearchSchema.shape.fields],
['EngineQueryOptionsSchema.searchFields', EngineQueryOptionsSchema.shape.searchFields],
['EngineAggregateOptionsSchema.searchFields', EngineAggregateOptionsSchema.shape.searchFields],
['EngineCountOptionsSchema.searchFields', EngineCountOptionsSchema.shape.searchFields],
];
it.each(members)('%s carries the shared sentence', (_name, member) =>
expect(member?.description).toContain(SEARCH_FIELDS_ACCEPT_SET));
});

describe('stage boundary: the engine-side searchFields declarations are the next stage', () => {
// The in-process engine still reads an empty override as absent, and its own
// declarations still say so; the engine stage moves them together.
it.each([
/**
* [#28059] The engine-side declarations take the same array arm, so the
* declaration never admits what `FullTextSearchSchema.fields` (the structured
* `search` they share) and the engine refuse.
*/
describe('the engine-side searchFields declarations', () => {
const ENGINE_SCHEMAS = [
['EngineQueryOptionsSchema', EngineQueryOptionsSchema],
['EngineAggregateOptionsSchema', EngineAggregateOptionsSchema],
['EngineCountOptionsSchema', EngineCountOptionsSchema],
] as const)('%s.searchFields still admits an empty list', (_name, schema) =>
expect((schema as Schema).safeParse({ searchFields: [] }).success).toBe(true));
] as const;

it.each(ENGINE_SCHEMAS)('%s.searchFields refuses an empty list with the accept-set sentence', (_name, schema) => {
const issue = firstIssue(schema as Schema, { searchFields: [] });
expect(issue.path).toEqual(['searchFields']);
expect(issue.message).toBe(SEARCH_FIELDS_NAMES_NO_FIELD);
});

it.each(ENGINE_SCHEMAS)('%s.searchFields answers `[]` as its structured `search.fields` does', (_name, schema) => {
const bare = firstIssue(schema as Schema, { searchFields: [] });
const structured = firstIssue(schema as Schema, { search: { query: 'x', fields: [] } });
expect(bare.message).toBe(structured.message);
});

for (const [name, schema] of ENGINE_SCHEMAS) {
it.each(KEPT_LISTS)(`${name}.searchFields keeps %s unchanged`, (_label, value) => {
const parsed = (schema as Schema).safeParse({ search: 'x', searchFields: value });
expect(parsed.success).toBe(true);
expect(parsed.data?.searchFields).toEqual(value);
});

it(`${name} still admits no searchFields at all`, () =>
expect((schema as Schema).safeParse({ search: 'x' }).success).toBe(true));
}
});
6 changes: 3 additions & 3 deletions packages/spec/src/data/search-fields-names-a-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ import { z } from 'zod';
* rule lives beside it in `./projection-names-a-field.ts`.
*
* [#27683] The declarations joined the door, which refuses this set since
* #27484. The engine-side `searchFields` declarations
* (`EngineQueryOptionsSchema` and its aggregate and count siblings) do not
* take it yet: the in-process engine still reads an empty override as absent.
* #27484. [#28059] The engine-side `searchFields` declarations
* (`EngineQueryOptionsSchema` and its aggregate and count siblings) take the
* array arm too, so no published declaration of the override admits `[]`.
*/
export const SEARCH_FIELDS_NAMES_NO_FIELD =
'Name at least one field, or leave the key out to search the default search fields';
Expand Down
Loading