Skip to content

Commit 188ccf3

Browse files
committed
fix(spec): QuerySchema declares the search contract ADR-0061 actually serves
The first CI run of the #3899 request-schema gate rejected the dogfood search proof's own wire shape: `{ search: 'retail', searchFields: ['industry'] }` answered 400. The schema was the wrong half — QuerySchema declared only the structured FullTextSearchSchema form while ADR-0061 D1 ("the client sends only the query text"), the engine executor (search-filter.ts), and the search-conformance ledger all serve the bare string plus the validated `searchFields` narrowing. - `search` becomes `string | FullTextSearch` (string is the canonical Tier-1 spelling; the object form keeps the declared Tier-2 knobs) - `searchFields` is formally declared (the ADR's own P1 item), noted as server-intersected — can only narrow, never widen - rest request-schema gate pins the ADR-0061 wire shape as a positive case so entry validation can never 400 it again - regenerated: references docs + authorable-surface (adds `data/Query:searchFields`); all 8 artifact gates green spec suite 7145 green; both previously-failing dogfood files pass locally (showcase-search 4/4, two-factor-lockout 5/5 — the 2FA pair does not reproduce here and is green on main @ 5d21a48's identical job). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EynH7cngDczRGMkGMudJpC
1 parent ab90aa5 commit 188ccf3

8 files changed

Lines changed: 51 additions & 10 deletions

File tree

content/docs/references/api/contract.mdx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,8 @@ const result = ApiError.parse(data);
149149
| **object** | `string` || Object name (e.g. account) |
150150
| **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. |
151151
| **where** | `any` | optional | Filtering criteria (WHERE) |
152-
| **search** | `{ query: string; fields?: string[]; fuzzy: boolean; operator: Enum<'and' \| 'or'>; … }` | optional | Full-text search configuration ($search parameter) |
152+
| **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 |
153+
| **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) |
153154
| **orderBy** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sorting instructions (ORDER BY) |
154155
| **limit** | `number` | optional | Max records to return (LIMIT) |
155156
| **offset** | `number` | optional | Records to skip (OFFSET) |
@@ -161,7 +162,7 @@ const result = ApiError.parse(data);
161162
| **having** | `any` | optional | HAVING — filter over the AGGREGATED rows (aggregation aliases + groupBy projections); applied engine-side after aggregation |
162163
| **windowFunctions** | `any` | optional | [REMOVED] `query.windowFunctions` was removed in @objectstack/spec 18 (#4286, ADR-0049) — `find()` never applied it: no engine or driver read the key on the query path, so every OVER clause it declared was silently dropped. Delete the key. Window functions are a SQL-driver capability behind `SqlDriver.findWithWindowFunctions(object, query)` (embedder-level; not on the `IDataDriver` contract or the REST surface); request-level analytics are `aggregations` + `groupBy`. |
163164
| **distinct** | `any` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 18 (#4286, 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. |
164-
| **expand** | `Record<string, { object: string; fields?: string[]; where?: any; search?: object; … }>` | 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. |
165+
| **expand** | `Record<string, { object: string; fields?: string[]; where?: any; search?: string \| { query: string; fields?: string[]; fuzzy: boolean; operator: Enum<'and' \| 'or'>; … }; … }>` | 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. |
165166

166167

167168
---

content/docs/references/api/protocol.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -529,7 +529,7 @@ const result = AiAgentCapabilities.parse(data);
529529
| Property | Type | Required | Description |
530530
| :--- | :--- | :--- | :--- |
531531
| **object** | `string` || The unique machine name of the object to query (e.g. "account"). |
532-
| **query** | `{ object: string; fields?: string[]; where?: any; search?: object; … }` | optional | Structured query definition (filter, sort, select, pagination). |
532+
| **query** | `{ object: string; fields?: string[]; where?: any; search?: string \| { query: string; fields?: string[]; fuzzy: boolean; operator: Enum<'and' \| 'or'>; … }; … }` | optional | Structured query definition (filter, sort, select, pagination). |
533533

534534

535535
---

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -548,7 +548,7 @@ QueryAST-aligned query options for IDataEngine.find() operations
548548
| **top** | `number` | optional | |
549549
| **cursor** | `any` | optional | [REMOVED] `query.cursor` was removed in @objectstack/spec 18 (#4286, 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. |
550550
| **search** | `{ query: string; fields?: string[]; fuzzy: boolean; operator: Enum<'and' \| 'or'>; … }` | optional | |
551-
| **expand** | `Record<string, { object: string; fields?: string[]; where?: any; search?: object; … }>` | optional | |
551+
| **expand** | `Record<string, { object: string; fields?: string[]; where?: any; search?: string \| { query: string; fields?: string[]; fuzzy: boolean; operator: Enum<'and' \| 'or'>; … }; … }>` | optional | |
552552
| **distinct** | `any` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 18 (#4286, 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. |
553553

554554

content/docs/references/data/mapping.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ const result = FieldMapping.parse(data);
5252
| **fieldMapping** | `{ source: string \| string[]; target: string \| string[]; transform: Enum<'none' \| 'constant' \| 'lookup' \| 'split' \| 'join' \| 'javascript' \| 'map'>; params?: object }[]` || |
5353
| **mode** | `Enum<'insert' \| 'update' \| 'upsert'>` || |
5454
| **upsertKey** | `string[]` | optional | Fields to match for upsert (e.g. email) |
55-
| **extractQuery** | `{ object: string; fields?: string[]; where?: any; search?: object; … }` | optional | Query to run for export only |
55+
| **extractQuery** | `{ object: string; fields?: string[]; where?: any; search?: string \| { query: string; fields?: string[]; fuzzy: boolean; operator: Enum<'and' \| 'or'>; … }; … }` | optional | Query to run for export only |
5656
| **errorPolicy** | `Enum<'skip' \| 'abort' \| 'retry'>` || |
5757
| **batchSize** | `number` || |
5858

content/docs/references/data/query.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,8 @@ Type: `string`
126126
| **object** | `string` || Object name (e.g. account) |
127127
| **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. |
128128
| **where** | `any` | optional | Filtering criteria (WHERE) |
129-
| **search** | `{ query: string; fields?: string[]; fuzzy: boolean; operator: Enum<'and' \| 'or'>; … }` | optional | Full-text search configuration ($search parameter) |
129+
| **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 |
130+
| **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) |
130131
| **orderBy** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sorting instructions (ORDER BY) |
131132
| **limit** | `number` | optional | Max records to return (LIMIT) |
132133
| **offset** | `number` | optional | Records to skip (OFFSET) |

packages/rest/src/request-schema-gate.conformance.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,20 @@ describe('#3899 ④ — rest routes with a declared requestSchema reject violati
181181
}
182182
});
183183

184+
it('the ADR-0061 search wire contract stays valid: { search: string, searchFields: [...] }', async () => {
185+
// The first CI run of this gate rejected exactly this body: QuerySchema
186+
// declared only the structured `FullTextSearchSchema` form while the
187+
// executor and the ADR-0061 dogfood proof (`showcase-search.dogfood
188+
// .test.ts`) served the bare string + `searchFields`. The schema was the
189+
// wrong half — fixed there; pinned here so entry validation can never
190+
// again 400 the contract the conformance ledger declares enforced.
191+
const ctx = setup();
192+
const res = await drive(ctx.rest, 'POST', '/api/v1/data/:object/query',
193+
{ search: 'retail', searchFields: ['industry'] }, PARAMS);
194+
expect(res.statusCode, JSON.stringify(res.body)).toBe(200);
195+
expect(ctx.findData).toHaveBeenCalledTimes(1);
196+
});
197+
184198
for (const gateCase of CASES) {
185199
const [method, path] = gateCase.route.split(' ') as [string, string];
186200
describe(gateCase.route, () => {

packages/spec/authorable-surface.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3737,6 +3737,7 @@
37373737
"data/Query:offset",
37383738
"data/Query:orderBy",
37393739
"data/Query:search",
3740+
"data/Query:searchFields",
37403741
"data/Query:top",
37413742
"data/Query:where",
37423743
"data/Query:windowFunctions [RETIRED]",

packages/spec/src/data/query.zod.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -343,10 +343,34 @@ const BaseQuerySchema = z.object({
343343

344344
/** Where Clause (Filtering) */
345345
where: FilterConditionSchema.optional().describe('Filtering criteria (WHERE)'),
346-
347-
/** Full-Text Search */
348-
search: FullTextSearchSchema.optional().describe('Full-text search configuration ($search parameter)'),
349-
346+
347+
/**
348+
* Full-Text Search.
349+
*
350+
* The bare string IS the canonical Tier-1 contract (ADR-0061 D1: "the
351+
* client sends only the query text; the server resolves which fields to
352+
* search from object metadata") — it is what every surface sends and what
353+
* the dogfood HTTP proof (`showcase-search.dogfood.test.ts`) pins. The
354+
* structured `FullTextSearchSchema` form remains for the declared Tier-2
355+
* knobs. The union is schema-side drift REPAIR, not a new dialect: the
356+
* schema declared only the object form while the executor and the ADR's own
357+
* conformance ledger served the string — surfaced the moment #3899 started
358+
* validating request bodies against this schema.
359+
*/
360+
search: z.union([z.string(), FullTextSearchSchema]).optional()
361+
.describe('Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration'),
362+
363+
/**
364+
* Per-query narrowing of the searchable field set (ADR-0061 D1).
365+
* Server-validated: intersected with the object's allowed searchable
366+
* fields — it can only narrow, never widen. Formalized here per the ADR's
367+
* P1 ("formalize `$searchFields`"); the enforcement site is
368+
* `objectql/src/search-filter.ts` `resolveSearchFields`, proven at the
369+
* HTTP level by `showcase-search.dogfood.test.ts`.
370+
*/
371+
searchFields: z.array(z.string()).optional()
372+
.describe('Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1)'),
373+
350374
/** Order By Clause (Sorting) */
351375
orderBy: z.array(SortNodeSchema).optional().describe('Sorting instructions (ORDER BY)'),
352376

0 commit comments

Comments
 (0)