Skip to content

Commit 0b788da

Browse files
os-litantclaude
andauthored
feat(spec): declare the query transport dialect as the flattened spelling of the QueryAST (#18704)
Fixes #16066 Clause-②: yes (widening) Authored by Claude Code, session https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho The `findData` slot declared one query dialect and accepted two. `FindDataRequestSchema.query` was `QuerySchema` — the canonical QueryAST — while the shipped door also folded `$filter` / `$top` / `$skip` / `$orderby` / `$select` / `$expand`, the plural `filters` and the bare aliases `filter` / `select` / `sort` / `skip` / `populate`, from a table that lived module-private inside `@objectstack/metadata-protocol` and whose own comment called them "the wire-only spellings no schema declares". Two dialects, one slot, one of them declared: unverifiable at build time, unrejected at runtime. Director seat ruling, decision batch #129 item 4 (2026-09-13, comment 5651810333). Maintainer, verbatim: 「你是项目总监,9306 这种为什么你不直接决策呢?而且不是说要以协议为准吗?还需要立卡问 spec 多次一举啊。这个也需要更新skills。其他同意。」 — 「其他同意」 covers this card. ## What landed **1. `packages/spec` declares the transport dialect, once.** In `src/data/data-engine.zod.ts`, beside `RPC_QUERY_ALIAS_SLOTS` — "the ONE place the alias to canonical mapping is declared": | export | what it is | |---|---| | `QueryTransportParamsSchema` + `QueryTransportParams` / `QueryTransportParamsParsed` | every spelling the tables name that is not itself a QueryAST key, each carrying the value set the canonical slot serves | | `QUERY_TRANSPORT_ALIAS_SLOTS` | `RPC_QUERY_ALIAS_SLOTS` extended with the transport-only spellings (`filters`/`$filter` onto `where`, `$expand` onto `expand`) | | `QUERY_TRANSPORT_DOLLAR_ALIASES` | the `$`-to-bare pairs that fold in two hops (`$top` onto `top` onto `limit`) | | `QUERY_TRANSPORT_DOLLAR_PARAMS` | the `$` set a boundary quotes when it refuses an undeclared one | | `QueryWithTransportSchema` + `QueryWithTransport` / `QueryWithTransportParsed` | the query slot itself: input is the AST or its transport spelling, output is the AST plus the `count` flag | 9 exports added, 0 removed (`api-surface/data.json`). `top` is the one alias the tables name that the transport schema does NOT declare: `BaseQuerySchema` already carries it beside `limit`, and re-declaring it would widen a canonical member rather than a transport one. **2. `FindDataRequestSchema.query` is that slot.** `z.input` admits the canonical AST, the transport spelling, or a bag carrying both. `z.output` is `QueryAST & { count?: boolean }`, and it is CONSTRUCTED rather than asserted: the fold's result is parsed by the AST schema and that parse's result is what leaves the transform. Prime Directive #12 is kept the way the ruling specifies — the transport form is the FLATTENED SPELLING of the same AST with a 1:1 alias table, never a second semantics — so `QuerySchema` itself is untouched and still drops a `$` key as unknown. The hint table in `metadata-protocol` (`QUERY_PARAM_NEAR_MISS`) is untouched and still accepts nothing as input. **3. `metadata-protocol` folds by the spec export.** `WIRE_QUERY_ALIAS_SLOTS` / `WIRE_DOLLAR_ALIASES` are gone; the `UNSUPPORTED_QUERY_PARAM` refusal now quotes `QUERY_TRANSPORT_DOLLAR_PARAMS` instead of a hand-copied sentence. **4. `scripts/check-filter-alias-parity.mjs` follows the hoist.** Its own header named this hoist as the open option and said the script "can be deleted rather than migrated" once BOTH sides derive. Only one side does: `packages/rest`'s `FILTER_SLOT_QUERY_PARAMS` still names `filters` / `$filter` literally, so the drift the gate exists for is still reachable and the gate was **re-pointed, not deleted** — its readers now read the spec file. Self-test green (7 batteries), gate green over the real tree: `4 transport spelling(s) of the 'where' slot, identical on both sides`. ## The dropped-refinement ledger, declared ⚠️ Added by the `domain:spec` seat after the fix round — `os-dev.md:56` reserves this body to the PR-open write, so the round named the wording and the seat writes it. This PR introduces two NEW published schemas that drop a refinement, and moves an existing entry: | ledger entry | sites | |---|---| | `data/QueryTransportParams` | 4, added | | `data/QueryWithTransport` | 4, added | | `api/FindDataRequest` | re-sited from the single `query.where.lazy` to the four `query.in.*` the new query slot produces | Header totals move **243 / 737 to 245 / 748**, taken from `gen:schema`'s own printed line — ⛔ not retyped and ⛔ not computed by arithmetic over the file. `refinementSitesThatDidProject` (0) and `refinementSitesWithNoJsonFormToCompare` (3) are unmoved. ⛔ **No `.refine()` or `.superRefine()` was touched, added or weakened.** The ledger is a visibility ratchet; the published JSON Schema stays exactly as wide as it was. The gate's own words: 「The refinement itself is correct; ⛔ do not delete or weaken it to make this line go away.」 ⭐ **Why this only appeared now, and it was not this PR going wrong.** The gate landed on `main` via #18729 (`a49e8ae963`, 2026-09-17T20:51:30Z) — twenty minutes AFTER this branch last merged main at `fa423f55b0e` (20:31:14Z). The ratchet is absent from the branch tree at every head it has had, so no build of this head could ever have run it. CI builds the **merge ref**, not the branch head, so the run before this one was computed against a `main` without the gate and the run after against a `main` with it. The failing run proved it itself: it printed `- query.where.lazy` for `api/FindDataRequest`, a site that exists only in main's copy of a file this branch head does not contain. ⇒ what flipped the checks red across a markdown-only commit was the BASE moving, not the diff. Full measurement in the seat's comment on this PR. **Evidence at `53591e48be2a`:** spec `build` 0; `test` (project `local`) 488 files / 14113 tests; `test:repo` (project `repo`) 31 files / 537 tests; `typecheck` 0; `check:generated` 0 (all 15 artifacts up to date); `check:authorable-surface` 0. The ledger pin `scripts/dropped-refinements.test.ts` was run on its own — 27 tests, exit 0, including 「names at least one site per entry, and its header totals match its body」. ## One semantics is a claim about VALUES, and that is what this round fixes The first two rounds made the KEYS 1:1 and left the VALUES apart: the transport arm admitted value grammars the canonical arm of the same schema refused, so one slot had two acceptance grammars selected by spelling — and the output type was a cast the transform never honoured. Measured at `5c072bbe63` with `QueryWithTransportSchema.safeParse`, and re-measured at this head with the same probe: | input | at `5c072bbe63` | now | |---|---|---| | `{$orderby: 'name'}` | OK, `{orderBy: 'name'}` | refused | | `{orderBy: 'name'}` | refused | refused | | `{$top: 'abc'}` | OK, `{limit: 'abc'}` | refused, one issue at `limit` naming `'$top'` | | `{$top: ''}` | OK, `{limit: ''}` | refused | | `{$top: '50'}` | OK, `{limit: 50}` | OK, `{limit: 50}` | | `{limit: '50'}` | refused | refused | | `{$filter: 'not json'}` | OK, `{where: 'not json'}` | refused | | `{$filter: ['status','=','open']}` | OK, `{where: ['status','=','open']}` | OK, `{where: {status: 'open'}}` | | `{where: ['status','=','open']}` | refused | OK, `{where: {status: 'open'}}` | | `{$count: true}` | OK, `count` undeclared on the output | OK, `count` declared on the output | | `{where: {a:1}, $filter: {b:2}}` | OK, **output still carried `$filter`** | refused, one issue at `where` naming `'$filter'` | Every value shape a transport member admits now either LOWERS to the canonical member's declared shape or FAILS the parse. What lowers: a stringly-typed `$top` / `$skip`, a comma list on `$select` / `$searchFields` / `$expand`, a `{field: direction}` sort record, a relation-name list on `populate`, `'true'` / `'false'` on `$count`, and the input-only `FilterArray` sugar on every spelling of the filter slot — `where` included. **The `FilterArray` route is a deliberate deviation from the review's letter, and #5158 is why.** The review prescribed declaring the ObjectQL array on the canonical member in `query.zod.ts`. That is rejected option A of maintainer ruling C on #5158 — 「widen `where` to accept the array dialect, so every driver and transport maintains two compilers forever」 — and `packages/spec/src/data/filter-array-declaration.test.ts` pins the negative half explicitly: *"a query `where` does NOT accept the array dialect … a future 'helpful' widening of the protocol face turns this red"*. So the array is declared on the TRANSPORT-AWARE SLOT, on every spelling of the filter slot including canonical `where`, and lowered through `parseFilterAST` — ruling C's own sink. Both halves are measured: the four spellings agree (`§3` of the spec suite), and `QuerySchema.where` still refuses the array (that file is green, 13 cases). **What is REFUSED at the parse**, because lowering it would mean parsing the spec must not do: - a non-numeric `$top` / `$skip` — the card's own defect class: `$top: 'abc'` passed POST validation and reached the engine as `limit: null`, an UNBOUNDED read under a `200`; `$top: ''` as `limit: 0`; - a JSON-encoded `$filter` string; - an OData sort EXPRESSION on `$orderby` / `sort` (`'name desc'`, `'-created_at'`, `['name']`) — the record and `SortNode[]` forms are unaffected. This is NOT the only refusal that takes a body from `200` to `400`. Five shapes were read off the forwarded ORIGINAL body and SERVED CORRECTLY before this change, and now answer `400 VALIDATION_FAILED` at the ingress: `{$orderby: 'name desc'}`, `{sort: '-created_at'}`, `{$orderby: ['name']}`, `{$filter: '{"status":"open"}'}`, and that same JSON string on `filters` / `filter`. Two more went `200` to `400` for the opposite reason — `$top: 'abc'` reached the engine as an unbounded read and `$top: ''` as `limit: 0`, a wrong answer under a `200`. The changeset carries each of the five with its FROM to TO. The door parses those strings and the spec must not own a second parser for them, and refusing is what makes `sort` and canonical `orderBy` accept one set without widening the canonical arm. The GET querystring path and in-process `findData` are unchanged — neither parses through this schema; - a filter array no lowering can express, such as the INFIX join `[condA, 'and', condB]`. `isFilterAST` refuses it, `parseFilterAST` lowers it to nothing, and the engine already answered `400` for it naming the prefix form. `rest-server-repeated-filter-param.test.ts` §3 pinned that body as forwarded with a MOCKED `findData`, so nothing downstream ever ran; that case now uses the prefix form `['and', condA, condB]` and a new case pins the infix one as refused at the ingress; - a `$count` that is neither the boolean nor `'true'` / `'false'`; - a CONFLICT — two spellings of one slot with different values — reported at the canonical path via `aliasConflictIssue`, quoting the spelling the caller actually wrote (`$orderby`, not `orderBy`), exactly as `foldRpcQueryOptions` does in the same file. The previous shape left the conflict UNFOLDED, which is how `$filter` stayed on a parsed output that claimed to be an AST. None of these narrows a DECLARED surface: no such value shape was ever declared. What five of them DO narrow is what the door SERVES — see the changeset, which carries the FROM to TO for each. The rest only narrow how far an unservable body travels before it is refused. **One cast remains, and it restates the INPUT only.** `QuerySchema` is annotated as a `z.ZodType` carrying `QueryAST` and `QueryInput` as its two type arguments, for its recursion, so `.extend` on it is reachable only through a cast that erases both type arguments; without restating the input, `z.input` of this slot would admit `$sort` and a query with no `object`. The OUTPUT type is inferred from the transform's return type, which is the return type of the AST schema's own `safeParse` — nothing asserts what this schema emits. `§5` of the spec suite pins both halves at the type level. ⚠️ **Why the fold parses in the transform instead of `.transform(fold).pipe(…)`.** Same construction, measured consequence: `packages/spec/scripts/build-schemas.ts` publishes a schema's OUTPUT shape whenever that shape has a JSON form and falls back to the INPUT shape only when it does not. Adding the pipe gives the output a JSON form, so `data/QueryWithTransport.json` starts publishing the canonical AST and all 32 transport keys DISAPPEAR from `authorable-surface/data.json` — refused by that file's own deletion gate, with `gen:schema` exiting 1. The authorable surface of this slot is the transport vocabulary, so the parse happens one level in. ## Ruling item 4 — the two things the card never measured **`getData` / the `*Many` siblings do NOT carry the same split**, so there is nothing to declare the same way: - `getData(request)` takes `id` / `select` / `expand` directly and has no `query` slot at all; its body reads `request.select` / `request.expand` and folds nothing (`protocol.ts` `async getData`). - `UpdateManyRequestSchema` takes `records[]`, `DeleteManyRequestSchema` takes `ids[]` (`packages/spec/src/api/batch.zod.ts`). Neither carries a query. **No caller outside `packages/rest` speaks a spelling the table does not name.** Census over `packages` + `examples` + `apps`, excluding `packages/rest`, tests, `dist` and the driver packages (whose `$skip`/`$limit`/`$match` are MongoDB aggregation-pipeline stages, a different namespace): the transport `$` keys in use are `$top`, `$filter`, `$skip`, `$select`, `$orderby`, `$expand`, `$count`, `$search`, `$searchFields` — all nine named by the table. An undeclared `$` spelling was, and stays, refused loudly: `400 UNSUPPORTED_QUERY_PARAM`, pinned in §4 of the new suite and at the type level. ## The generated-docs regression is closed `expand` is RECURSIVE, so `z.toJSONSchema` hoists it into `$defs` and renders the property as a bare `$ref` — which carries no sibling `description`. This slot publishes its INPUT shape (it is a transform), and in that direction the row rendered with an EMPTY description cell. Re-describing the member on the extended shape, with the text READ from `QuerySchema` rather than re-typed, puts it back. Measured with `grep -c '^| \*\*expand\*\* .*| |$'`, exit code read from `$?` after a redirect: | file | `origin/main` | at `5c072bbe63` | now | |---|---|---|---| | `content/docs/references/api/protocol.mdx` | 0 | 1 | **0** | | `content/docs/references/data/data-engine.mdx` | 5 | 8 | **5** | The `data-engine.mdx` reading is the same command's hitting control for the `protocol.mdx` zero. ## The byte-equality pin, and it goes red `packages/metadata-protocol/src/protocol.query-transport-dialect.test.ts` §1 holds the two exported tables, and the refusal sentence, against the values the module-private ones RESOLVED TO on `origin/main` at `6dfa3ea772` — a frozen BEFORE reading, transcribed from that tree, not a restatement to be kept in sync. §2 drives every alias the tables declare through the REAL normalizer and asserts the option bag `engine.find` receives equals the canonical spelling's, with the COUNT call and the response envelope in the comparison and with an explicit guard that the canonical leg SERVED, so no pair can agree by both being refused. Both ablation legs and their restore are recorded in the round that landed it; 24/24 green at this head. **This round's ablation**, one-shot, on the fold itself. `packages/spec/src/data/query-transport.test.ts` imports `./data-engine.zod` RELATIVELY, so its verdict is a function of source, not of `dist` — no rebuild mediates it: | leg | on-disk proof | result | |---|---|---| | mutate: the fold reverted to its `5c072bbe63` body | marker `which is not a number.` 1 → 0 occurrences in the source; blob `f388a53f` → `e46141954` | **20 failed / 58**, exit 1 | | restore: `git checkout HEAD -- PATH` | blob back to `f388a53f`, `git diff HEAD` on the target exits 0 | **58 passed / 58**, exit 0 | ## Tests - `packages/spec/src/data/query-transport.test.ts` — 58 cases. §1 derives the declared key set from the two tables MINUS `QuerySchema`'s own shape (so `count` survives the subtraction for a stated reason), §2 every alias to its canonical slot, **§3 the totality of the fold — every assertion reads the OUTPUT**, §4 `QuerySchema` still dropping `$filter` / `$top`, §5 the declared input and output types. The previous §3 asserted `.success` alone, which is exactly how every non-AST output above went unmeasured. - `packages/metadata-protocol/src/protocol.query-transport-dialect.test.ts` — 24/24, plus `protocol.query-param-arity` / `protocol.orderby-vocabulary` / `protocol.malformed-filter` / `protocol.count-opt-out`: 102 passed / 5 files. - `packages/rest` — `rest-server-canonical-query-ast` / `rest-server-repeated-filter-param` / `list-view-grouping-query-door` / `rest-server-closed-query-params` / `rest-server-query-multiplicity`: 25 + 147 across the set, green after the §3 update above. - `packages/spec/src/data/filter-array-declaration.test.ts` — 13/13, the #5158 negative half included. Full suites and the lint union, all at this branch's head `e695f895c8` (the final commit), each exit code captured from `$?` after a redirect: | run | result | |---|---| | `pnpm --filter @objectstack/spec typecheck` | exit 0 | | `pnpm --filter @objectstack/spec test` | **486 files / 13926 passed**, exit 0 | | `pnpm --filter @objectstack/metadata-protocol test` | **179 passed + 3 skipped / 182 files · 2572 passed, 19 skipped**, exit 0 | | `pnpm --filter @objectstack/rest test` | **193 files / 3230 passed, 1 skipped**, exit 0 | | `eslint . --no-inline-config --format json` | **6820 files examined, 0 errors, 0 warnings**, exit 0 — the whole union, not a narrowing | Gates run locally, each exit code captured from `$?` after a redirect, never through a pipe: `check:generated` (15/15), `check:docs`, `check:api-surface`, `check:export-origins`, `check:declaration-map`, `check:strictness-ledger`, `check:authorable-surface`, `check:filter-alias-parity`, `check:nul-bytes`, `check:spec-parsed-alias`, `check:published-files`, `check:pm-widening-tells`, `check:query-options-erasure`, `check:objectql-double-limit`, `check:where-matcher`, `check:parse-guard`, `check:test-source-alias`, `check:cross-package-test-inputs`, `check:type-check-coverage`, `check:closing-keyword-parity`, `check:ci-filter-parity`, `check:pm-dispatch-gates`, `check-changeset-no-major --base origin/main`, `check-adr-0087-registration --base origin/main` — all exit 0. `scripts/pm/check-clause2-carriers.mjs --pair 18704` exits **4**: `needs:contract-review` is owed a re-hang because the head moved past the review that cleared it. Reported, not acted on — hanging or clearing a review gate is a seat's act. **NOT MEASURED, stated rather than implied:** `check:type-check-debt` returned its `PREREQUISITE NOT MET` exit 3 (the whole `packages/*` closure is not built in this checkout) — nothing was measured, and it is neither a pass nor a finding. The remaining families of the 127 `dispatch-gates` derives for this change set are CI's run; that derivation also warns the tree is behind `origin/main` and that 13 of the files it derives from moved in that range, so CI will run families this list does not name. The GET querystring path end to end, and objectui's runtime, were not exercised. ## Acceptance notes Noted, not filed — nothing here is a reproducible defect, a contract violation, or a metadata-authoring trap: - `ODataQuerySchema` (`packages/spec/src/api/odata.zod.ts`) declares `$format` and `$apply`, which the `findData` door refuses with `UNSUPPORTED_QUERY_PARAM`. It is a separate surface and no caller routes it into this door. Who would meet it: a future OData adapter card. - `getData`'s implementation signature accepts `select` / `expand` as `string | string[]` while `GetDataRequestSchema` declares arrays only. Pre-existing, different axis. Who would meet it: whoever next touches the single-record read path. - `packages/rest`'s `FILTER_SLOT_QUERY_PARAMS` could now derive all four filter spellings from `QUERY_TRANSPORT_ALIAS_SLOTS`, which would make `check:filter-alias-parity` deletable exactly as its header prescribes. Left alone: it moves runtime code in a package this card does not land in. Who would meet it: the next `packages/rest` query-ingress card. - `{top: 6, $top: 5}` folds to `limit: 6` and discards the `5` without a diagnostic, in the spec fold and at the door alike. Byte-equal to before and now stated in the table's docstring rather than raised, because raising it here and not at the door would make a POST body and a GET querystring answer the same request differently. Who would meet it: the next card that touches the door's `$`-to-bare hop. - The transport members still accept input sugar the canonical member does not (`$select: 'a,b'` against `fields`, `$top: '50'` against `limit`). That asymmetry is the transport/canonical distinction itself — the querystring carries strings — and every such shape lowers, so the OUTPUT grammar is one. Widening the canonical members to match would declare the looser grammar rather than close it. Who would meet it: nobody, unless a future card moves the GET querystring path through this schema. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b0b5f31 commit 0b788da

21 files changed

Lines changed: 1809 additions & 171 deletions
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/metadata-protocol": patch
4+
---
5+
6+
The query TRANSPORT dialect is declared — keys AND values — and the `findData` fold now derives from that one declaration.
7+
8+
`FindDataRequestSchema.query` declared `QuerySchema` — the canonical QueryAST — while the shipped `findData` door also accepted a second spelling of the same query through the same slot: `$filter` / `$top` / `$skip` / `$orderby` / `$select` / `$expand` and the plural `filters`. `@objectstack/metadata-protocol` folded them from a module-private table whose own comment called them "the wire-only spellings no schema declares". Two dialects, one slot, one of them declared — so every caller speaking the second was unverifiable at build time and unrejected at runtime.
9+
10+
**New in `@objectstack/spec/data`** (9 exports, 0 removed):
11+
12+
- `QueryTransportParamsSchema` / `QueryTransportParams` / `QueryTransportParamsParsed` — the transport parameters, each carrying the value of the canonical slot it folds onto.
13+
- `QUERY_TRANSPORT_ALIAS_SLOTS` — `RPC_QUERY_ALIAS_SLOTS` extended with the transport-only spellings (`filters` / `$filter` onto `where`, `$expand` onto `expand`).
14+
- `QUERY_TRANSPORT_DOLLAR_ALIASES` — the `$`-to-bare pairs that fold in two hops (`$top` onto `top` onto `limit`).
15+
- `QUERY_TRANSPORT_DOLLAR_PARAMS` — the `$` spellings a boundary quotes when it refuses an undeclared one.
16+
- `QueryWithTransportSchema` / `QueryWithTransport` / `QueryWithTransportParsed` — the query slot whose declared input is the AST or its transport spelling and whose parsed output is the AST plus the `count` flag.
17+
18+
**`FindDataRequestSchema.query` is that slot now.** Its `z.input` admits the canonical AST, the transport spelling, or a bag carrying both. Its `z.output` is `QueryAST & { count?: boolean }` — the canonical AST, plus the response total-count flag, which rides inside this slot on the wire and is read off it by `findData` rather than passed to the engine. The output is CONSTRUCTED: the fold's result is parsed by the AST schema and that parse's result is what leaves the transform, so a transport key or a non-AST value cannot reach a consumer. The transport form is the FLATTENED SPELLING of the canonical AST with a 1:1 alias table — never a second semantics — so `QuerySchema` itself is untouched and still drops a `$` key as unknown.
19+
20+
**One semantics means one set of VALUES, not only one set of keys, and that is what this declaration now enforces.** Every spelling of a slot accepts the same value shapes; each is lowered to the canonical member's declared shape, or refused. What lowers: a stringly-typed `$top` / `$skip` (`'50'` becomes `50`), a comma list on `$select` / `$searchFields` / `$expand`, a `{field: direction}` sort record, a relation-name list on `populate`, `'true'` / `'false'` on `$count`, and the input-only `FilterArray` sugar (`['status', '=', 'open']`) on every spelling of the filter slot — `where` included — lowered through `parseFilterAST`, the one declared sink (#5158 ruling C; `QuerySchema.where` still refuses the array).
21+
22+
**What is REFUSED at the parse**, because lowering it would mean parsing the spec must not do, and because emitting it would put a value under the AST type that the AST does not declare:
23+
24+
- a non-numeric `$top` / `$skip` (`$top: 'abc'`, `$top: ''`) — `400` instead of an engine call with `limit: null`, i.e. an UNBOUNDED read under a `200`, or `limit: 0`;
25+
- a JSON-encoded `$filter` string (`'{"status":"open"}'`);
26+
- an OData sort EXPRESSION on `$orderby` / `sort` (`'name desc'`, `'-created_at'`, `['name']`) — the record and `SortNode[]` forms are unaffected;
27+
- a filter array no lowering can express, such as the INFIX join `[condA, 'and', condB]` — the prefix form `['and', condA, condB]` is the one the platform reads, and the engine already answered `400` for the infix one;
28+
- a `$count` that is neither the boolean nor `'true'` / `'false'`;
29+
- two spellings of one slot carrying different values — reported at the canonical path, quoting the spelling the caller actually wrote (`$orderby`, not `orderBy`).
30+
31+
These refusals narrow no DECLARED surface: none of these value shapes was ever declared — `FindDataRequestSchema.query` was `QuerySchema`, which STRIPPED every one of these keys rather than declaring it.
32+
33+
**Five of them were nonetheless SERVED, and now answer `400 VALIDATION_FAILED` at the ingress.** The route forwards the ORIGINAL body, not the parse output, so a key the old schema stripped still reached the door, which read it and answered `200`. A `POST /data/:object/query` body written one of these five ways stops working; each has a declared spelling that means the same thing:
34+
35+
| body that now answers `400` | what the door served it as | write instead |
36+
|---|---|---|
37+
| `{ $orderby: 'name desc' }` | `orderBy: [{ field: 'name', order: 'desc' }]` | `{ $orderby: { name: 'desc' } }` — or `{ orderBy: [{ field: 'name', order: 'desc' }] }` |
38+
| `{ sort: '-created_at' }` | `orderBy: [{ field: 'created_at', order: 'desc' }]` | `{ sort: { created_at: 'desc' } }` — or `{ sort: [{ field: 'created_at', order: 'desc' }] }` |
39+
| `{ $orderby: ['name'] }` | `orderBy: [{ field: 'name', order: 'asc' }]` | `{ $orderby: { name: 'asc' } }` — or the `SortNode[]` form |
40+
| `{ $filter: '{"status":"open"}' }` | `where: { status: 'open' }` | `{ $filter: { status: 'open' } }` |
41+
| that same JSON string on `filters` or `filter` | `where: { status: 'open' }` | the object form on whichever of the two keys you write |
42+
43+
**`GET /data/:object` still serves every one of those shapes.** The querystring path does not parse through this schema at all — `FindDataRequestSchema` is parsed at exactly one call site, the POST handler — so `?$orderby=name desc`, `?sort=-created_at` and `?$filter={"status":"open"}` answer exactly as before. What narrowed is the POST body alone — the platform has not stopped accepting these spellings everywhere.
44+
45+
The remaining refusals in the list narrow nothing that was served correctly; they move an unservable body's refusal earlier — from the engine, or from a wrong answer under a `200`, to the ingress that can name the parameter to fix.
46+
47+
**`@objectstack/metadata-protocol` folds by the spec export** instead of its own table, and both resolved tables — plus the `$`-parameter list its `UNSUPPORTED_QUERY_PARAM` refusal quotes — are pinned byte-equal to their pre-change values. An undeclared `$` spelling is still refused loudly with the same `400 UNSUPPORTED_QUERY_PARAM`; the sentence now quotes `QUERY_TRANSPORT_DOLLAR_PARAMS` rather than a hand-copied list, so a spelling added to the table cannot leave the refusal naming a set the door no longer has.
48+
49+
Measured and unchanged: `getData` takes `select` / `expand` directly and carries no `query` slot, and `updateManyData` / `deleteManyData` take `records[]` / `ids[]` — none of the three has a transport-dialect split to declare.
50+
51+
Clause-②: yes (widening)

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

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -867,18 +867,18 @@ Enable package response
867867
| Property | Type | Required | Description |
868868
| :--- | :--- | :--- | :--- |
869869
| **object** | `string` | ✅ | The unique machine name of the object to query (e.g. "account"). |
870-
| **query** | `{ object: string; fields?: string[]; where?: any; search?: string \| object; … }` | optional | Structured query definition (filter, sort, select, pagination). |
870+
| **query** | `{ object: string; fields?: string[]; where?: [string, string, any] \| [string, string] \| [string, object, ...object[]] \| object[] \| Record<string, any> \| any; search?: string \| object; … }` | optional | Structured query definition (filter, sort, select, pagination) — the canonical QueryAST or its transport spelling. |
871871

872872
### Nested Shape: `FindDataRequest.query`
873873

874874
| Property | Type | Required | Description |
875875
| :--- | :--- | :--- | :--- |
876876
| **object** | `string` | ✅ | Object name (e.g. account) |
877877
| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. 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. |
878-
| **where** | `any` | optional | Filtering criteria (WHERE) |
879-
| **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 |
878+
| **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. |
879+
| **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 |
880880
| **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) |
881-
| **orderBy** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sorting instructions (ORDER BY) |
881+
| **orderBy** | `{ field: string; order?: Enum<'asc' \| 'desc'> }[]` | optional | Sorting instructions (ORDER BY) |
882882
| **limit** | `number` | optional | Max records to return (LIMIT) |
883883
| **offset** | `number` | optional | Records to skip (OFFSET) |
884884
| **top** | `number` | optional | Alias for limit (OData compatibility) |
@@ -890,6 +890,22 @@ Enable package response
890890
| **windowFunctions** | `never` | optional | [REMOVED] `query.windowFunctions` was removed in @objectstack/spec 17 (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`. |
891891
| **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. |
892892
| **expand** | `Record<string, { object: string; fields?: string[]; where?: any; search?: string \| 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. |
893+
| **$filter** | `[string, string, any] \| [string, string] \| [string, object, ...object[]] \| object[] \| Record<string, any> \| any \| null` | optional | Transport spelling of `where` (OData `$filter`) |
894+
| **filters** | `[string, string, any] \| [string, string] \| [string, object, ...object[]] \| object[] \| Record<string, any> \| any \| null` | optional | Transport spelling of `where` (plural of `filter`) |
895+
| **$top** | `number \| string \| null` | optional | Transport spelling of `limit` (OData `$top`) — a number, or the digits a querystring carries it as |
896+
| **$skip** | `number \| string \| null` | optional | Transport spelling of `offset` (OData `$skip`) — a number, or the digits a querystring carries it as |
897+
| **$orderby** | `Record<string, Enum<'asc' \| 'desc'>> \| Record<string, 1 \| -1> \| { field: string; order?: Enum<'asc' \| 'desc'> }[] \| null` | optional | Transport spelling of `orderBy` (OData `$orderby`) |
898+
| **$select** | `string \| string[] \| null` | optional | Transport spelling of `fields` (OData `$select`) |
899+
| **$expand** | `string \| string[] \| Record<string, { object: string; fields?: string[]; where?: any; search?: string \| object; … }> \| null` | optional | Transport spelling of `expand` (OData `$expand`) |
900+
| **$search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … } \| null` | optional | Transport spelling of `search` (OData `$search`) |
901+
| **$searchFields** | `string \| string[] \| null` | optional | Transport spelling of `searchFields` (OData `$searchFields`) |
902+
| **$count** | `boolean \| Enum<'true' \| 'false'> \| null` | optional | Transport spelling of the response total-count flag (OData `$count`) |
903+
| **count** | `boolean \| Enum<'true' \| 'false'> \| null` | optional | Response total-count flag — only an explicit `false` skips the COUNT query |
904+
| **filter** | `[string, string, any] \| [string, string] \| [string, object, ...object[]] \| object[] \| Record<string, any> \| any \| null` | optional | Transport spelling of `where` |
905+
| **select** | `string \| string[] \| null` | optional | Transport spelling of `fields` |
906+
| **sort** | `Record<string, Enum<'asc' \| 'desc'>> \| Record<string, 1 \| -1> \| { field: string; order?: Enum<'asc' \| 'desc'> }[] \| null` | optional | Transport spelling of `orderBy` |
907+
| **skip** | `number \| string \| null` | optional | Transport spelling of `offset` |
908+
| **populate** | `string \| string[] \| null` | optional | Transport spelling of `expand` |
893909

894910

895911
---

0 commit comments

Comments
 (0)