Skip to content

Commit a1b61e0

Browse files
os-zhuangclaude
andauthored
fix(spec,rest,runtime): request bodies are checked against the schemas the catalog declares (#3899) (#4322)
* fix(spec,rest,runtime): request bodies are checked against the schemas the catalog declares (#3899) The API catalog (plugin-rest-api.zod.ts) declared requestSchema on 16 routes while almost no mounted entry point validated its body — a malformed request did not 400, it executed different semantics (an unfiltered full read on /query, markRead(userId, []) on a misnamed key, a flow registered under the key `undefined`, a one-letter-off toggle body ENABLING the flow it meant to disable). Wire the declared schemas at the real entry points, make the catalog stop promising what nothing performs, and gate both directions: - rest: POST /data/:object/query, POST /data/:object, PATCH /data/:object/:id, POST /data/:object/batch and POST /data/:object/createMany now safeParse the declared contract and answer 400 VALIDATION_FAILED + fields[] (the #3933/#3944 pattern); the query route also pins the PATH object into the forwarded query. - runtime: POST /notifications/read validates MarkNotificationsReadRequestSchema; the automation registerFlow / updateFlow / toggle bodies get strict hand-written guards (keys.ts pattern); the analytics-entry validationFailure helper is hoisted to validation-failure.ts and shared. - spec: the catalog drops the four ghost notification endpoints (#3612 removed the routes), fixes the automation trigger path to /trigger/:name and drops its never-true request schema, adds the POST /:object/query entry, repoints create/update at the schemas the routes actually validate, and drops requestSchema from bodyless GET/DELETE entries. - gates: schema-name references must resolve to real exports and sit on body-carrying methods (spec); every declared requestSchema on a mounted route has a violating-body -> 400 conformance case with a completeness ratchet (rest + runtime request-schema-gate suites). Suites: spec 7132, rest 536, runtime 977 all green; check:generated 8/8. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EynH7cngDczRGMkGMudJpC * 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 * docs(changeset): record the QuerySchema search-contract widening Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EynH7cngDczRGMkGMudJpC * chore(spec): classify query/search (now a union leaf) and query/searchFields in the liveness ledger The search-contract repair turned `search` into a union the liveness walker does not descend, and declared `searchFields` — both landed UNCLASSIFIED on the freshly-seeded query ledger (#4286). `search` becomes a leaf entry (the object form's experimental flags stay audited by their own describe markers inside FullTextSearchSchema); `searchFields` gets its live entry pointing at the resolveSearchFields intersection and the ADR-0061 dogfood proof. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EynH7cngDczRGMkGMudJpC * fix(metadata-protocol): QUERY_AST_KEYS names searchFields, as its type demands `QUERY_AST_KEYS` is typed `Record<keyof QueryAST, true>` precisely so a key added to the spec is a compile error here rather than silent drift at the REST boundary — and it did its job: declaring `searchFields` on QuerySchema turned the whole build red until the key was listed. The hand-maintained `'searchFields'` entry in RESERVED_LIST_QUERY_PARAMS goes with it. It was there as a "transport-only extra the AST does not name"; the AST names it now, so it arrives through the `QUERY_AST_KEYS` spread and the type-level pin covers it. Keeping both would have been the second source that list exists to avoid. No behavior change on either path: the GET normalizer already accepted `?searchFields` / `$searchFields` and the engine already read `ast.searchFields` (ADR-0061). metadata-protocol 122, objectql 1373, full build 71/71 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EynH7cngDczRGMkGMudJpC * docs(query): the search contract's canonical spelling appears in the hand-written docs Both hand-written query pages taught only the structured `search: {query, fields}` form — `query-syntax.mdx` even typed it `search?: FullTextSearch`, which is now literally wrong. The bare string is the canonical ADR-0061 spelling (D1: the client says what to search for, the server decides which fields), it is what every surface sends, and it is what the dogfood proof asserts; the top-level `searchFields` narrowing was undocumented entirely. Same gap this PR exists to close, one layer up: a reader following these pages learned half the contract. Both forms are now shown, with the structured one named as equivalent for the two members that drive the expansion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EynH7cngDczRGMkGMudJpC * test(driver-mongodb): a hung MongoDB binary download skips the suite instead of stopping Test Core These three suites start a real mongod via mongodb-memory-server, which fetches a ~123 MB binary from fastdl.mongodb.org at module load. Each already anticipated that fetch FAILING — try/catch, warn, skip — precisely so a blocked download costs a skipped suite rather than the monorepo test job. A catch cannot express the other half. When the fetch HANGS, the top-level await never settles: the suite neither runs nor skips, it stops. Observed twice on this branch (Test Core attempts 1 and 2 of run 30610017310), both frozen at the identical point with zero output from this package and no skip warning, both force-killed 10 minutes later by the #4250 stall guard with `@objectstack/driver-mongodb#test` as the surviving task. `instance .launchTimeout` does not cover it — that bounds spawning mongod once the binary is on disk, a later phase than the fetch. `createTestMongod` puts a 120s deadline on the wait so a hang lands in the skip branch the suites already had. Not a workaround for this PR's diff: the same task on main 40 minutes earlier logged `Downloading MongoDB "8.2.6": 0% … 100%` and passed on a cache miss, so this is the network changing under an unbounded wait. Any PR touching @objectstack/spec invalidates the cache and runs straight into it. Verified: 160/160 still pass locally on the success path (binary cached), and a mocked never-settling create() resolves to undefined at the deadline rather than hanging. The helper stays out of dist — tsup entry is src/index.ts only, which does not reference it (mongodb-memory-server is a devDependency). Refs #4250. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EynH7cngDczRGMkGMudJpC * fix(driver-mongodb): declare Node's globals — the test helper is typechecked source `src/test-mongod.ts` uses setTimeout / clearTimeout / console and turned the package's `tsc --noEmit` red. The `.test.ts` files that call it have always used the same globals, but tsconfig excludes `**/*.test.ts`, so this package never needed `types: ["node"]` until a shared helper became typechecked source — and it cannot be named `*.test.ts`, or vitest would collect it as a suite with no tests. Same one-line declaration a dozen sibling packages already carry (client, core, lint, cli, metadata-protocol, …); @types/node is already a devDep here. I missed this locally by verifying with build + eslint + vitest: tsup's dts step only covers the `src/index.ts` entry graph, which this helper is deliberately outside of, so nothing I ran typechecked it. Re-verified with the command CI actually uses — `turbo run build --filter='./packages/*'`, 57/57 — plus `tsc --noEmit` in this package and its 160 tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EynH7cngDczRGMkGMudJpC --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 411116e commit a1b61e0

28 files changed

Lines changed: 1200 additions & 183 deletions
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/rest': minor
4+
'@objectstack/runtime': minor
5+
---
6+
7+
Request bodies are now checked against the schemas the API catalog declares for them (#3899, the request-side dual of #3877).
8+
9+
**Routes that now answer `400 VALIDATION_FAILED` + `fields[]` for a body violating their declared `requestSchema`** (previously the body was consumed raw, and a malformed one silently executed different semantics):
10+
11+
- `POST /data/:object/query` — body must be a QueryAST (`FindDataRequestSchema`); a garbage body used to degrade into an unfiltered full read. The path `object` is now pinned into the forwarded query (a body `object` can no longer contradict the path).
12+
- `POST /data/:object` / `PATCH /data/:object/:id` — body must be a record object (`CreateDataRequestSchema` / `UpdateDataRequestSchema`).
13+
- `POST /data/:object/batch` — body must be a `BatchUpdateRequestSchema` (`operation` + `records[]`).
14+
- `POST /data/:object/createMany` — body must be a bare JSON array of records (`CreateManyDataRequestSchema`); `{ records: [...] }` (updateMany's envelope) is rejected with a pointer.
15+
- `POST /notifications/read` — body must be `{ ids: string[] }` (`MarkNotificationsReadRequestSchema`); a misnamed key used to become `markRead(userId, [])` — a 200 no-op that never cleared the badge.
16+
17+
**Dispatcher automation routes now validate their bodies** (no catalog schema; hand-written guards):
18+
19+
- `POST /automation` and `PUT /automation/:name` require a flow-definition object, and POST requires a non-empty `name` — a mistyped `name` used to register the flow under the key `undefined` and echo 200.
20+
- `POST /automation/:name/toggle` is strictly `{ enabled?: boolean }``{"enable": false}` (one letter off) used to ENABLE the flow and answer 200 `{enabled: true}`; it is now a 400 naming the offending key. An empty body still means enable.
21+
22+
**`QuerySchema` now declares the search contract ADR-0061 actually serves** (additive): `search` accepts the canonical bare query string as well as the structured `FullTextSearch` form, and the server-validated `searchFields` narrowing is formally declared. Previously the schema declared only the object form while every surface (and the ADR's own conformance proof) sent the string — drift that surfaced the moment request bodies started being validated.
23+
24+
**Catalog corrections in `@objectstack/spec` (`plugin-rest-api.zod.ts`)** — documentation-only tables:
25+
26+
- `DEFAULT_NOTIFICATION_ROUTES` drops the four device/preferences endpoints — those server routes were removed in #3612 (never built), yet the table kept declaring them, `requestSchema` and all.
27+
- `DEFAULT_AUTOMATION_ROUTES`' trigger endpoint path is corrected `/trigger``/trigger/:name` (the mounted path; the flow name rides the path) and its `AutomationTriggerRequestSchema` declaration is removed — that schema never described this route's wire shape.
28+
- `DEFAULT_DATA_CRUD_ROUTES` gains the `POST /:object/query` entry (mounted since forever, previously undeclared), repoints create/update to the schemas the routes actually validate (`CreateDataRequestSchema` / `UpdateDataRequestSchema` — the old `CreateRequestSchema`/`UpdateRequestSchema` names described a `{ data }` envelope the wire never had), and drops `requestSchema` from GET/DELETE entries (path/query-bound inputs; nothing can violate them as a body).
29+
- New gates: catalog `requestSchema`/`responseSchema` strings must resolve to real exported Zod schemas, `requestSchema` may only sit on body-carrying methods, and every declared `requestSchema` on a mounted route has a violating-body → 400 conformance case (`packages/rest` + `packages/runtime` request-schema-gate suites).
30+
31+
Migration: clients that already send the documented shapes are unaffected. If you relied on a malformed body being silently accepted (e.g. posting `{ records: [...] }` to `createMany`, a non-boolean `enabled` to toggle, or an off-schema analytics/query body), fix the request to the declared shape — the 400's `fields[]` names each offending key.

content/docs/data-modeling/queries.mdx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,21 @@ dotted `fields` path (`'customer.name'`) for a single related column:
432432

433433
## Full-Text Search
434434

435+
`search` takes the query text; the server resolves which fields to search from object
436+
metadata (ADR-0061). Pass `searchFields` to narrow that set — it is intersected with what
437+
the object allows, so it can only narrow, never widen.
438+
439+
```typescript
440+
{
441+
object: 'article',
442+
search: 'kubernetes deployment',
443+
searchFields: ['title', 'body', 'tags'] // optional
444+
}
445+
```
446+
447+
The structured form is equivalent and carries the experimental knobs below — `query` and
448+
`fields` mean exactly what `search` and `searchFields` do:
449+
435450
```typescript
436451
{
437452
object: 'article',
@@ -444,8 +459,8 @@ dotted `fields` path (`'customer.name'`) for a single related column:
444459

445460
| Property | Type | Description |
446461
|:---|:---|:---|
447-
| `query` | `string` | Search text |
448-
| `fields` | `string[]` | Fields to search (optional — defaults to all searchable) |
462+
| `query` | `string` | Search text (the bare `search: '…'` string form) |
463+
| `fields` | `string[]` | Fields to search (optional — defaults to all searchable; the top-level spelling is `searchFields`) |
449464
| `fuzzy` | `boolean` | `[EXPERIMENTAL — not enforced]` Fuzzy matching for typo tolerance |
450465
| `operator` | `'and' \| 'or'` | `[EXPERIMENTAL — not enforced]` How to combine search terms |
451466
| `boost` | `Record<string, number>` | `[EXPERIMENTAL — not enforced]` Field relevance weights |

content/docs/protocol/objectql/query-syntax.mdx

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@ interface QueryAST {
6464
object: string; // Target object (required)
6565
fields?: FieldNode[]; // Projection (SELECT) — field names
6666
where?: FilterCondition; // Filtering (WHERE) — MongoDB-style $op
67-
search?: FullTextSearch; // Full-text search ($search)
67+
search?: string | FullTextSearch; // Full-text search — the query text (canonical), or the structured form
68+
searchFields?: string[]; // Narrow the search (server-intersected — narrows only, never widens)
6869
orderBy?: SortNode[]; // Ordering (ORDER BY)
6970
limit?: number; // Max records (LIMIT)
7071
offset?: number; // Skip records (OFFSET)
@@ -751,25 +752,36 @@ an `$or` of `$contains` predicates across the object's server-resolved searchabl
751752
already runs `$or`/`$contains`, so no driver support is needed (`SqlDriver` reports
752753
`supports.fullTextSearch: false`).
753754

755+
`search` takes the **query text itself** — that is the canonical spelling (ADR-0061 D1:
756+
the client says *what* to search for, the server decides *which fields*), and it is what
757+
every surface sends. The structured form is equivalent for the two members that drive
758+
the expansion, and carries the experimental knobs below:
759+
754760
```typescript
761+
// Canonical — the server resolves the fields from object metadata
755762
const query: QueryAST = {
756763
object: 'article',
757-
search: {
758-
query: 'ObjectStack tutorial',
759-
fields: ['title', 'content'],
760-
},
764+
search: 'ObjectStack tutorial',
765+
searchFields: ['title', 'content'], // optional narrowing
766+
limit: 10,
767+
};
768+
769+
// Structured form — `query` + `fields` mean exactly the same thing
770+
const structured: QueryAST = {
771+
object: 'article',
772+
search: { query: 'ObjectStack tutorial', fields: ['title', 'content'] },
761773
limit: 10,
762774
};
763775
```
764776

765-
Field resolution is server-side and never client-trusted: `search.fields` is
766-
**intersected** with the object's declared `searchableFields` (or, absent those, an
767-
auto-default of the name field plus short-text/enum fields), so naming a field outside
768-
that set can never widen the search — and over the REST/protocol ingress it is
769-
`400 INVALID_FIELD` outright (#4254), because the engine-side intersection alone used to
770-
drop the unknown name and fall back to scanning the full searchable set. Internal
771-
callers reaching `engine.find()` directly keep the tolerant intersection.
772-
Multiple whitespace-separated terms are AND-ed and
777+
Field resolution is server-side and never client-trusted: the requested fields
778+
(`searchFields`, or `search.fields` in the structured form) are **intersected** with the
779+
object's declared `searchableFields` (or, absent those, an auto-default of the name field
780+
plus short-text/enum fields), so naming a field outside that set can never widen the
781+
search — and over the REST/protocol ingress it is `400 INVALID_FIELD` outright (#4254),
782+
because the engine-side intersection alone used to drop the unknown name and fall back to
783+
scanning the full searchable set. Internal callers reaching `engine.find()` directly keep
784+
the tolerant intersection. Multiple whitespace-separated terms are AND-ed and
773785
fields are OR-ed. Case sensitivity is the **driver's**, not the expansion's: the
774786
expansion emits a plain `$contains`, which `SqlDriver` compiles to a parameterised
775787
`LIKE '%…%'` with no case folding — so the dialect's own `LIKE`/collation rules decide —

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/metadata-protocol/src/protocol.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -840,10 +840,10 @@ function mergeDroppedFieldEvents(events: DroppedFieldsEvent[]): DroppedFieldsEve
840840
* which is a behavior change to call out in that changeset.
841841
*/
842842
const QUERY_AST_KEYS: Readonly<Record<keyof QueryAST, true>> = {
843-
object: true, fields: true, where: true, search: true, orderBy: true,
844-
limit: true, offset: true, top: true, cursor: true, joins: true,
845-
aggregations: true, groupBy: true, having: true, windowFunctions: true,
846-
distinct: true, expand: true,
843+
object: true, fields: true, where: true, search: true, searchFields: true,
844+
orderBy: true, limit: true, offset: true, top: true, cursor: true,
845+
joins: true, aggregations: true, groupBy: true, having: true,
846+
windowFunctions: true, distinct: true, expand: true,
847847
};
848848

849849
/**
@@ -882,7 +882,10 @@ const RESERVED_LIST_QUERY_PARAMS: ReadonlySet<string> = new Set([
882882
...Object.keys(QUERY_AST_KEYS),
883883
// Transport-only extras the normalizer consumes but the AST does not name.
884884
'count', // ?count / $count — response flag, not a projection
885-
'searchFields', // ?searchFields / $searchFields — ADR-0061 override
885+
// `searchFields` used to be listed here as such an extra. It is a named
886+
// AST key since #3899 declared it (ADR-0061 P1), so it now arrives through
887+
// the spread above and the type-level pin covers it — the hand-maintained
888+
// copy would have been a second source that could silently fall out of step.
886889
// Server-derived, never caller input (stripped then re-set from `request`).
887890
'context',
888891
]);

packages/plugins/driver-mongodb/src/mongodb-datetime-storage.test.ts

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,11 @@
2020
*/
2121

2222
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
23-
import { MongoMemoryServer } from 'mongodb-memory-server';
23+
import type { MongoMemoryServer } from 'mongodb-memory-server';
2424
import { MongoDBDriver } from './mongodb-driver.js';
25+
import { createTestMongod } from './test-mongod.js';
2526

26-
let sharedMongod: MongoMemoryServer | undefined;
27-
try {
28-
sharedMongod = await MongoMemoryServer.create({ instance: { launchTimeout: 60_000 } });
29-
} catch (err) {
30-
console.warn(
31-
'[driver-mongodb] Skipping datetime-storage suite — mongodb-memory-server could not start: ' +
32-
`${(err as Error)?.message ?? String(err)}`,
33-
);
34-
}
27+
const sharedMongod: MongoMemoryServer | undefined = await createTestMongod('datetime-storage');
3528

3629
const ids = (rows: any[]) => rows.map((r: any) => r.id).sort();
3730

0 commit comments

Comments
 (0)