Skip to content

Commit 81b4599

Browse files
fix(spec)!: transport select / $select and GetDataRequestSchema.select declare the projection the doors accept (#26949)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB85i2if6JKQ1KFLarHMRiWX)_ Fixes #26909 Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: none — `skills/**` teaches only a named projection (`skills/objectui/guides/data-integration.md:82`, `$select: ['name', 'email']`) and never an empty one; the generated reference pages `content/docs/references/{api/protocol,data/data-engine}.mdx` are regenerated in this diff. ADR-0087: not-required (no-migration-prescription) — in the changeset. **BREAKING** (accept-set narrowing of published transport declarations, `patch`): `QueryTransportParamsSchema.select` / `$select` (so `QueryWithTransportSchema` and `FindDataRequestSchema.query`) and `GetDataRequestSchema.select` refuse a projection naming no field, which they admitted. The doors already refused it with a 400. ## 维护者速读 - **改了什么**:数据接口里「选哪些字段」(`select` / `$select`) 的三处公开声明收窄到接口真正接受的范围:空串、纯空格、只有逗号、空数组、含空白字段名的数组都被拒;三处声明共用同一条规则。 - **为什么改**:服务端读列表、读单条时早已对这些值回 400,但请求体侧和按 id 读取的声明还写着「都收」,按声明生成的客户端或 AI 作者会写出运行时被拒的请求;上一张卡 #26817 只修了 GET 查询串那一处。 - **风险与回滚**:唯一的运行时变化在 `POST /data/:object/query`:空投影仍是 400 且不读数据,但错误码从 `VALIDATION_ERROR` 变成请求体 schema 的 `VALIDATION_FAILED`(与该接口对 `limit`、`count` 的既有做法一致,先例 #25269)。回滚 = revert 本 PR。 - **先看**:`packages/spec/src/data/data-engine.zod.ts`(新增的投影规则与 `select` / `$select` 两行)、`packages/spec/src/api/protocol.zod.ts`(`HttpFindQueryParamsSchema.select` 与 `GetDataRequestSchema.select` 改为引用同一规则)、`.changeset/26909-transport-select-empty-projection.md`。 - **两分钟验证**:`pnpm --filter @objectstack/spec exec vitest run src/data/query-transport.test.ts src/api/protocol.test.ts`,预期全绿,其中 `§6 the projection spellings admit exactly what the doors accept` 一组都在。 - **需要你做的**:无,席位落地。 ## What changes - **One rule, three declarations.** `data-engine.zod.ts` gains the projection's accept set beside `QueryTransportParamsSchema`: a string arm `z.string().regex(/[^\s,]/)` (byte-identical to the rule #26817 gave `HttpFindQueryParamsSchema.select`, which the #26901 review proved equal to the door's split / trim / drop-empty) and an array arm `z.array(member.regex(/\S/)).min(1)`. `select` and `$select` use both arms (`$select` keeps `FieldNodeSchema` as its member, so the retired object-form message is unchanged). `HttpFindQueryParamsSchema.select` now reads the string arm off `QueryTransportParamsSchema.shape.select` and `GetDataRequestSchema.select` reads the array arm, as #26901 already read `count`: no new export, no copy. Both are patterns and a length check, not refinements, so they project into JSON Schema (`pattern`, `minItems`) and add no `dropped-refinements` site. - **Array rule, measured.** `projectionFieldsValue` (`metadata-protocol/src/protocol.ts:4648`) keeps every array member and refuses only `[]`; `assertProjectionFieldsExist` then checks every member against the object's fields, where a blank name is never one (`400 INVALID_FIELD`). The declaration refuses `[]` and any blank member, which covers the all-blank list the card names and `['name', '']`, both refused by the door today. - **`.describe()` texts** of the three members now state the accept set; the two reference pages are regenerated. `HttpFindQueryParamsSchema.select`'s row is unchanged. - **Door pins moved (claim amendment).** `POST /data/:object/query` parses its body with `FindDataRequestSchema` (`rest/src/crud-routes.ts:363`, `runtime/src/domains/data.ts:242`), so an empty `select` / `$select` is now refused at the schema: `400 VALIDATION_FAILED`, `fields[0].field` = `query.select` / `query.$select`, still before any read. This follows the #25269 precedent (`rest/src/data-page-window-door.test.ts` header: "the code is the schema door's rather than the normalizer's, because the schema now says what the normalizer enforces"), the same way that route already answers `{"count":"maybe"}`. The two POST rows in `rest/src/data-empty-projection-count-door.test.ts` and `runtime/src/domains/data-empty-projection-count.test.ts` move to new `VALIDATION_FAILED` tests, which also cover `[]` and `['name', '']`. `{"fields": []}` (canonical `QuerySchema.fields`, not in this card) and every GET row stay `VALIDATION_ERROR`, unchanged. ## Why After #26901, two declarations of the one `findData` door disagreed on `select` (the GET querystring strict, the body side loose), and the by-id request admitted `[]` that `getData` refuses. A generated client, a docs reader or an AI author trusting the published schema learned the rule only from a runtime 400. ## Readers measured - `QueryWithTransportSchema` / `FindDataRequestSchema.query`: the `POST /data/:object/query` body gate in rest and the runtime dispatcher. The one runtime behaviour change, above. - RPC alias table / `foldRpcQueryOptions`, `QuerySchema.fields`: untouched; `QuerySchema` does not read these members. - `GetDataRequestSchema`: no runtime parse (the by-id routes hand `select` to `getData`, which refuses through `projectionFieldsValue`); published declaration only. - `HttpFindQueryParamsSchema`: no runtime reader; same regex and message, so #26901's pins stay green unchanged. - Codegen / reference: `content/docs/references/**` regenerated; `check:generated` green on 14 artifacts (key-level ledgers unmoved). - Client SDK: `data.find` sending `?select=` for `select: []` is #26910 (client lane), not touched. - Stored rows: none. No metadata schema embeds `QueryWithTransportSchema`, `QueryTransportParamsSchema` or `GetDataRequestSchema` (only `FindDataRequestSchema.query`), so `Metadata-protocol: none`. - In-repo callers sending an empty projection: none outside the door tests above (examples, ui, client grepped). ## Tests Locally, under `os-verify-lock`: - `@objectstack/spec` test (96f9915; the later commit touches only rest/runtime tests): Test Files 738 passed, Tests 22136 passed, 1 todo. `typecheck`: exit 0. - `@objectstack/metadata-protocol` test (96f9915): Test Files 273 passed, 4 skipped; Tests 21693 passed, 26 skipped. Unchanged, including `protocol.empty-projection-unknown-count.test.ts` (in-process `findData`, no schema). - `@objectstack/rest` (1821443): `data-empty-projection-count-door`, `data-page-window-door`, `zod-union-fields`, `rest-server-canonical-query-ast`: 4 files, 127 passed, 18 skipped. - `@objectstack/runtime` (1821443): `data-empty-projection-count`, `data-write-request-gate`, `data-record-read-status-parity`: 3 files, 96 passed. - New pins: `query-transport.test.ts` §6 (`''`, `' '`, `' , '`, `','`, `[]`, `['', ' ']`, `['name', '']` refused on `select` and `` by `QueryTransportParamsSchema` and `QueryWithTransportSchema`, the issue at the member's own path; string and array lists kept unchanged; `null` still a withdrawal); `protocol.test.ts` (`GetDataRequestSchema.select` refuses `[]`, `['', ' ']`, `['name', '']` and keeps lists; `FindDataRequestSchema.query` refuses the blank forms on both spellings). #26901's `HttpFindQueryParamsSchema` pins are untouched and green. - A first run of the moved door pins found a blank member is refused at its own index (`query.select.1`), not `query.select`; the pins name the index (1821443). - Full dogfood battery (96f9915, `pnpm --filter @objectstack/dogfood test`, no shard, lock VERDICT command-exit 0): Test Files 340 passed, 3 skipped; Tests 2754 passed, 20 skipped; no failures. 1821443 changes only two rest/runtime test files, which dogfood does not import. ## Acceptance notes - The order asked that a runtime refusal stay the door's `VALIDATION_ERROR`; that cannot hold for the POST body without changing the rest and runtime gates, and seat 3 accepted the #25269 route (claim amendment) before the pins moved. ## Verification `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on 1821443: 105 derived families, run serially with no build in flight, each exit captured before any pipe: **105 exit 0**. `--ran` reconciliation: "105 derived famil(ies) accounted for — 105 run, 0 NOT-MEASURED". (The same list on 96f9915 had two built-output gates, `check:skill-examples` and `check:dual-build-cjs-loads`, exit 3 before the dogfood closure build; both exit 0 on the rerun.) `pnpm --filter @objectstack/spec check:generated --fix`: 14 artifacts, only `content/docs/references/**` regenerated. CI families: read from this PR's check-runs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzgm45oXexLiY5mMigQ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7bb8084 commit 81b4599

9 files changed

Lines changed: 189 additions & 17 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec)!: `QueryTransportParamsSchema.select` / `$select` and `GetDataRequestSchema.select` declare the projection the `findData` and `getData` doors accept, instead of any string or list (#26909)
6+
7+
Clause-②: no (narrowing) — the published body-side declarations of the list door's projection (`select`, `$select`, reached through `QueryWithTransportSchema` and `FindDataRequestSchema.query`) and the by-id request's `select` refuse a value naming no field, which they admitted before. The doors already refused it with a 400; nothing is widened and no key is added, removed or renamed.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped and no metadata type or stored shape moves: these are transport request declarations, never stored, and the values they stop admitting are values the Data API doors already refused. A caller names at least one field or leaves the parameter out; there is nothing for a migration to rewrite and no tombstone. -->
11+
12+
**BREAKING** accept-set narrowing of published transport declarations (`@objectstack/spec/data`, `@objectstack/spec/api`), read by the `POST /data/:object/query` body gate, codegen, the reference docs and clients.
13+
14+
- **Before.** `select` / `$select` admitted any string or list: a blank, whitespace-only or comma-only string, `[]`, and a list with a blank name. `GetDataRequestSchema.select` admitted `[]` and blank names.
15+
- **Now.** A string must name at least one field after the door's own split and trim (published as the pattern `[^\s,]`, the same rule `HttpFindQueryParamsSchema.select` states since #26817). A list must hold at least one name, and no name may be blank. `HttpFindQueryParamsSchema.select` and `GetDataRequestSchema.select` read these rules off `QueryTransportParamsSchema.select` rather than restating them.
16+
- **`POST /data/:object/query`.** That route parses its body with `FindDataRequestSchema`, so an empty `select` / `$select` is now refused at the body schema: still `400`, still before any read, but `VALIDATION_FAILED` with `fields[0].field` naming `query.select` / `query.$select`, where it was `VALIDATION_ERROR` from the list door. A list with a blank name (`["name", ""]`) is likewise `400 VALIDATION_FAILED` at the schema, where it was `400 INVALID_FIELD` (an unknown field). This is how that route already answers a malformed `limit` or `count`.
17+
- **Unchanged.** `GET /data/:object` and `GET /data/:object/:id` still answer an empty `select` with `400 VALIDATION_ERROR` naming it; a named projection (`name,email`, `["name", "email"]`, spaces and empty members between commas trimmed by the door) and an explicit `null` (a withdrawal) are accepted as before.
18+
- **Who sees it.** A caller of `POST /data/:object/query` that branches on the `VALIDATION_ERROR` code for an empty projection, code that parses a query bag with these schemas and sent an empty projection, or a generated client that trusted the wider declaration.
19+
20+
**The remedy.** Send `select` naming at least one field, or leave it out to read every column.

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -920,14 +920,14 @@ Enable package response
920920
| **$top** | `integer \| string \| null` | optional | Transport spelling of `limit` (OData `$top`) — a non-negative integer, or the digits a querystring carries it as |
921921
| **$skip** | `integer \| string \| null` | optional | Transport spelling of `offset` (OData `$skip`) — a non-negative integer, or the digits a querystring carries it as |
922922
| **$orderby** | `Record<string, Enum<'asc' \| 'desc'>> \| Record<string, 1 \| -1> \| { field: string; order?: Enum<'asc' \| 'desc'> }[] \| null` | optional | Transport spelling of `orderBy` (OData `$orderby`) |
923-
| **$select** | `string \| string[] \| null` | optional | Transport spelling of `fields` (OData `$select`) |
923+
| **$select** | `string \| string[] \| null` | optional | Transport spelling of `fields` (OData `$select`) — at least one field name; a value naming no field (blank, only commas and spaces, an empty list, or a list with a blank name) is refused |
924924
| **$expand** | `string \| string[] \| Record<string, { object: string; fields?: string[]; where?: any; search?: string \| object; … }> \| null` | optional | Transport spelling of `expand` (OData `$expand`) |
925925
| **$search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … } \| null` | optional | Transport spelling of `search` (OData `$search`) |
926926
| **$searchFields** | `string \| string[] \| null` | optional | Transport spelling of `searchFields` (OData `$searchFields`) |
927927
| **$count** | `boolean \| Enum<'true' \| 'false'> \| null` | optional | Transport spelling of the response total-count flag (OData `$count`) |
928928
| **count** | `boolean \| Enum<'true' \| 'false'> \| null` | optional | Response total-count flag — only an explicit `false` skips the COUNT query |
929929
| **filter** | `[string, string, any] \| [string, string] \| [string, object, ...object[]] \| object[] \| Record<string, any> \| any \| null` | optional | Transport spelling of `where` |
930-
| **select** | `string \| string[] \| null` | optional | Transport spelling of `fields` |
930+
| **select** | `string \| string[] \| null` | optional | Transport spelling of `fields` — at least one field name; a value naming no field (blank, only commas and spaces, an empty list, or a list with a blank name) is refused |
931931
| **sort** | `Record<string, Enum<'asc' \| 'desc'>> \| Record<string, 1 \| -1> \| { field: string; order?: Enum<'asc' \| 'desc'> }[] \| null` | optional | Transport spelling of `orderBy` |
932932
| **skip** | `integer \| string \| null` | optional | Transport spelling of `offset` — a non-negative integer, or the digits a querystring carries it as |
933933
| **populate** | `string \| string[] \| null` | optional | Transport spelling of `expand` |
@@ -1038,7 +1038,7 @@ This schema accepts one of the following structures:
10381038
| :--- | :--- | :--- | :--- |
10391039
| **object** | `string` | ✅ | The object name. |
10401040
| **id** | `string` | ✅ | The unique record identifier (primary key). |
1041-
| **select** | `string[]` | optional | Fields to include in the response (allowlisted query param). |
1041+
| **select** | `string[]` | optional | Fields to include in the response (allowlisted query param) — at least one field name; an empty list, or a list with a blank name, is refused. |
10421042
| **expand** | `string[]` | optional | Lookup/master_detail field names to expand. The engine resolves these via batch $in queries, replacing foreign key IDs with full objects. |
10431043

10441044

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1307,14 +1307,14 @@ Transport spellings of the QueryAST slots — folded to canonical keys at the bo
13071307
| **$top** | `integer \| string \| null` | optional | Transport spelling of `limit` (OData `$top`) — a non-negative integer, or the digits a querystring carries it as |
13081308
| **$skip** | `integer \| string \| null` | optional | Transport spelling of `offset` (OData `$skip`) — a non-negative integer, or the digits a querystring carries it as |
13091309
| **$orderby** | `Record<string, Enum<'asc' \| 'desc'>> \| Record<string, 1 \| -1> \| { field: string; order?: Enum<'asc' \| 'desc'> }[] \| null` | optional | Transport spelling of `orderBy` (OData `$orderby`) |
1310-
| **$select** | `string \| string[] \| null` | optional | Transport spelling of `fields` (OData `$select`) |
1310+
| **$select** | `string \| string[] \| null` | optional | Transport spelling of `fields` (OData `$select`) — at least one field name; a value naming no field (blank, only commas and spaces, an empty list, or a list with a blank name) is refused |
13111311
| **$expand** | `string \| string[] \| Record<string, { object: string; fields?: string[]; where?: any; search?: string \| object; … }> \| null` | optional | Transport spelling of `expand` (OData `$expand`) |
13121312
| **$search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … } \| null` | optional | Transport spelling of `search` (OData `$search`) |
13131313
| **$searchFields** | `string \| string[] \| null` | optional | Transport spelling of `searchFields` (OData `$searchFields`) |
13141314
| **$count** | `boolean \| Enum<'true' \| 'false'> \| null` | optional | Transport spelling of the response total-count flag (OData `$count`) |
13151315
| **count** | `boolean \| Enum<'true' \| 'false'> \| null` | optional | Response total-count flag — only an explicit `false` skips the COUNT query |
13161316
| **filter** | `[string, string, any] \| [string, string] \| [string, object, ...object[]] \| object[] \| Record<string, any> \| any \| null` | optional | Transport spelling of `where` |
1317-
| **select** | `string \| string[] \| null` | optional | Transport spelling of `fields` |
1317+
| **select** | `string \| string[] \| null` | optional | Transport spelling of `fields` — at least one field name; a value naming no field (blank, only commas and spaces, an empty list, or a list with a blank name) is refused |
13181318
| **sort** | `Record<string, Enum<'asc' \| 'desc'>> \| Record<string, 1 \| -1> \| { field: string; order?: Enum<'asc' \| 'desc'> }[] \| null` | optional | Transport spelling of `orderBy` |
13191319
| **skip** | `integer \| string \| null` | optional | Transport spelling of `offset` — a non-negative integer, or the digits a querystring carries it as |
13201320
| **populate** | `string \| string[] \| null` | optional | Transport spelling of `expand` |
@@ -1385,14 +1385,14 @@ Transport spellings of the QueryAST slots — folded to canonical keys at the bo
13851385
| **$top** | `integer \| string \| null` | optional | Transport spelling of `limit` (OData `$top`) — a non-negative integer, or the digits a querystring carries it as |
13861386
| **$skip** | `integer \| string \| null` | optional | Transport spelling of `offset` (OData `$skip`) — a non-negative integer, or the digits a querystring carries it as |
13871387
| **$orderby** | `Record<string, Enum<'asc' \| 'desc'>> \| Record<string, 1 \| -1> \| { field: string; order?: Enum<'asc' \| 'desc'> }[] \| null` | optional | Transport spelling of `orderBy` (OData `$orderby`) |
1388-
| **$select** | `string \| string[] \| null` | optional | Transport spelling of `fields` (OData `$select`) |
1388+
| **$select** | `string \| string[] \| null` | optional | Transport spelling of `fields` (OData `$select`) — at least one field name; a value naming no field (blank, only commas and spaces, an empty list, or a list with a blank name) is refused |
13891389
| **$expand** | `string \| string[] \| Record<string, { object: string; fields?: string[]; where?: any; search?: string \| object; … }> \| null` | optional | Transport spelling of `expand` (OData `$expand`) |
13901390
| **$search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … } \| null` | optional | Transport spelling of `search` (OData `$search`) |
13911391
| **$searchFields** | `string \| string[] \| null` | optional | Transport spelling of `searchFields` (OData `$searchFields`) |
13921392
| **$count** | `boolean \| Enum<'true' \| 'false'> \| null` | optional | Transport spelling of the response total-count flag (OData `$count`) |
13931393
| **count** | `boolean \| Enum<'true' \| 'false'> \| null` | optional | Response total-count flag — only an explicit `false` skips the COUNT query |
13941394
| **filter** | `[string, string, any] \| [string, string] \| [string, object, ...object[]] \| object[] \| Record<string, any> \| any \| null` | optional | Transport spelling of `where` |
1395-
| **select** | `string \| string[] \| null` | optional | Transport spelling of `fields` |
1395+
| **select** | `string \| string[] \| null` | optional | Transport spelling of `fields` — at least one field name; a value naming no field (blank, only commas and spaces, an empty list, or a list with a blank name) is refused |
13961396
| **sort** | `Record<string, Enum<'asc' \| 'desc'>> \| Record<string, 1 \| -1> \| { field: string; order?: Enum<'asc' \| 'desc'> }[] \| null` | optional | Transport spelling of `orderBy` |
13971397
| **skip** | `integer \| string \| null` | optional | Transport spelling of `offset` — a non-negative integer, or the digits a querystring carries it as |
13981398
| **populate** | `string \| string[] \| null` | optional | Transport spelling of `expand` |

‎packages/rest/src/data-empty-projection-count-door.test.ts‎

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
* |:--|:--|:--|
1414
* | `GET ?$select=`, `?$select=%20`, `?$select=,`, `?select=`, `?fields=` | 200, every column | 400 naming the spelling |
1515
* | `GET ?$count=maybe`, `?$count=1`, `?count=` (with `$top`) | 200, `total` reported | 400 naming the spelling |
16-
* | `POST {"select":""}`, `{"fields":[]}` | 200, every column | 400 naming the key |
16+
* | `POST {"select":""}`, `{"fields":[]}` | 200, every column | 400 naming the key (`select` at the body schema, `VALIDATION_FAILED`, since #26909) |
1717
* | `POST {"count":"maybe"}` | 400 `VALIDATION_FAILED` (the body schema) | unchanged |
1818
* | `GET /:id?select=`, `?select=,` | 200, every column | `VALIDATION_ERROR` naming `select`, at this route's 404 (it rewrote every 400, as for `?select=nope`); 400 since #26816 |
1919
* | `GET ?$select=label`, `?$count=true` / `false`, absent; `GET /:id?select=label` | 200 | unchanged |
@@ -59,11 +59,23 @@ const REFUSED_GET: ReadonlyArray<readonly [string, Record<string, string>, strin
5959

6060
/** name · the JSON body `POST /data/:object/query` receives · the key the refusal names. */
6161
const REFUSED_POST: ReadonlyArray<readonly [string, Record<string, unknown>, string]> = [
62-
['select: ""', { select: '' }, 'select'],
63-
['$select: ","', { $select: ',' }, '$select'],
6462
['fields: []', { fields: [] }, 'fields'],
6563
];
6664

65+
/**
66+
* [#26909] The body's published schema (`FindDataRequestSchema`) now declares
67+
* the projection the normalizer enforces, so these are refused where every
68+
* other malformed body is: at the schema, `400 VALIDATION_FAILED`, `fields[0]`
69+
* naming `query.<key>`, and the member for a blank name (the #25269 precedent
70+
* for the page window).
71+
*/
72+
const REFUSED_POST_AT_SCHEMA: ReadonlyArray<readonly [string, Record<string, unknown>, string]> = [
73+
['select: ""', { select: '' }, 'query.select'],
74+
['$select: ","', { $select: ',' }, 'query.$select'],
75+
['select: []', { select: [] }, 'query.select'],
76+
['select: ["label", ""] (the blank member)', { select: ['label', ''] }, 'query.select.1'],
77+
];
78+
6779
function createMockServer() {
6880
const noop = () => {};
6981
return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} };
@@ -161,6 +173,17 @@ describe('[#26644] an empty projection and an unrecognised count flag on the Dat
161173
expect(reads.n - before, 'no read of the object — every refusal precedes the driver').toBe(0);
162174
});
163175

176+
it('POST query: `select` / `$select` naming no field is the schema door\'s 400 VALIDATION_FAILED — no read', async () => {
177+
const before = reads.n;
178+
for (const [name, body, field] of REFUSED_POST_AT_SCHEMA) {
179+
const res = await query(body);
180+
expect(res.status, `${name}: ${JSON.stringify(res.body)}`).toBe(400);
181+
expect(res.body.code, name).toBe('VALIDATION_FAILED');
182+
expect(res.body.fields?.[0]?.field, name).toBe(field);
183+
}
184+
expect(reads.n - before, 'no read of the object — every refusal precedes the driver').toBe(0);
185+
});
186+
164187
it("POST query: an unrecognised count is still the schema door's 400 VALIDATION_FAILED, unchanged", async () => {
165188
const res = await query({ limit: 2, count: 'maybe' });
166189
expect(res.status, JSON.stringify(res.body)).toBe(400);

‎packages/runtime/src/domains/data-empty-projection-count.test.ts‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,6 @@ describe('[#26644] the dispatcher /data doors — an empty projection and an unr
112112
['GET list ?$select=, (the card)', 'GET', '/data/inquiry', { $select: ',' }, undefined, '$select', 'names no field'],
113113
['GET list ?select= (the card)', 'GET', '/data/inquiry', { select: '' }, undefined, 'select', 'names no field'],
114114
['GET list ?$count=maybe (the card)', 'GET', '/data/inquiry', { $top: '2', $count: 'maybe' }, undefined, '$count', 'must be true or false'],
115-
['POST query {"select":""}', 'POST', '/data/inquiry/query', {}, { select: '' }, 'select', 'names no field'],
116115
['POST query {"fields":[]}', 'POST', '/data/inquiry/query', {}, { fields: [] }, 'fields', 'names no field'],
117116
['GET by id ?select=', 'GET', '/data/inquiry/r1', { select: '' }, undefined, 'select', 'names no field'],
118117
['GET by id ?select=,', 'GET', '/data/inquiry/r1', { select: ',' }, undefined, 'select', 'names no field'],
@@ -127,6 +126,29 @@ describe('[#26644] the dispatcher /data doors — an empty projection and an unr
127126
expect(reads.n - before, 'no read of the object — the refusal precedes the driver').toBe(0);
128127
});
129128

129+
// [#26909] The body's published schema (`FindDataRequestSchema`) now
130+
// declares the projection the normalizer enforces, so `select` / `$select`
131+
// naming no field is refused where every other malformed body is: at the
132+
// schema, `400 VALIDATION_FAILED` naming `query.<key>`, and the member for
133+
// a blank name (the #25269 precedent for the page window). Still a 400,
134+
// still before any read.
135+
it.each<[string, Record<string, unknown>, string]>([
136+
['{"select":""}', { select: '' }, 'query.select'],
137+
['{"$select":","}', { $select: ',' }, 'query.$select'],
138+
['{"select":[]}', { select: [] }, 'query.select'],
139+
['{"select":["name",""]} (the blank member)', { select: ['name', ''] }, 'query.select.1'],
140+
])('POST query %s: 400 VALIDATION_FAILED naming the body key at the schema — no read', async (_name, body, field) => {
141+
const before = reads.n;
142+
143+
const answer = await ask('POST', '/data/inquiry/query', {}, body);
144+
145+
expect(answer.status, JSON.stringify(answer)).toBe(400);
146+
expect(answer.code).toBe('VALIDATION_FAILED');
147+
const fields = (answer.body?.error?.details?.fields ?? []).map((f: { field: string }) => f.field);
148+
expect(fields).toEqual([field]);
149+
expect(reads.n - before, 'no read of the object — the refusal precedes the driver').toBe(0);
150+
});
151+
130152
it('CONTROL: a named projection and a true / false / absent count answer exactly as before', async () => {
131153
const projected = await ask('GET', '/data/inquiry', { $select: 'name', $orderby: 'name' });
132154
expect(projected.status, JSON.stringify(projected)).toBe(200);

‎packages/spec/src/api/protocol.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,19 @@ describe('GetDataRequestSchema (select/expand)', () => {
612612
}
613613
});
614614

615+
// [#26909] The by-id door reads `select` through the list door's
616+
// projection rule and refuses a list naming no field; every member is then
617+
// checked against the object's fields, where a blank name is never one.
618+
it.each([[[]], [['', ' ']], [['name', '']]])('refuses select=%j — names no field', (select) => {
619+
expect(GetDataRequestSchema.safeParse({ object: 'contact', id: 'c1', select }).success).toBe(false);
620+
});
621+
622+
it.each([[['name']], [['name', 'email']]])('keeps select=%j unchanged', (select) => {
623+
const result = GetDataRequestSchema.safeParse({ object: 'contact', id: 'c1', select });
624+
expect(result.success).toBe(true);
625+
if (result.success) expect(result.data.select).toEqual(select);
626+
});
627+
615628
it('should strip unknown query parameters via strict parsing', () => {
616629
const result = GetDataRequestSchema.strict().safeParse({
617630
object: 'contact',
@@ -718,6 +731,15 @@ describe('HttpFindQueryParamsSchema', () => {
718731
if (result.success) expect(result.data.select).toBe(value);
719732
});
720733

734+
// [#26909] The body side of the same door (`FindDataRequestSchema.query`,
735+
// the `POST /data/:object/query` body) now refuses what this one refuses.
736+
it.each(['', ' ', ',', ' , ', []])('the body-side declaration refuses select=%j too', (select) => {
737+
for (const key of ['select', '$select']) {
738+
const parsed = FindDataRequestSchema.safeParse({ object: 'contact', query: { object: 'contact', [key]: select } });
739+
expect(parsed.success, key).toBe(false);
740+
}
741+
});
742+
721743
it('rejects ?distinct — the querystring spelling of the removed query.distinct', () => {
722744
const result = HttpFindQueryParamsSchema.safeParse({ distinct: true });
723745
expect(result.success).toBe(false);

0 commit comments

Comments
 (0)