diff --git a/.changeset/26817-http-find-query-params-count-select.md b/.changeset/26817-http-find-query-params-count-select.md new file mode 100644 index 00000000000..e699faf9e0d --- /dev/null +++ b/.changeset/26817-http-find-query-params-count-select.md @@ -0,0 +1,19 @@ +--- +'@objectstack/spec': patch +--- + +fix(spec)!: `HttpFindQueryParamsSchema` declares the `count` and `select` values the list door accepts, instead of a coerced boolean and any string (#26817) + +Clause-②: no (narrowing) — the published `GET /data/:object` querystring declaration refuses a `count` other than true or false and a `select` naming no field, which it admitted before. The door already refused both with a 400; nothing is widened and no key is added, removed or renamed. +Metadata-protocol: none + + + +**BREAKING** accept-set narrowing of a published transport declaration (`@objectstack/spec/api`), read by codegen, the reference docs and clients; it has no runtime reader. + +- **Before.** `count` was `z.coerce.boolean()`: the string `'false'` parsed as `true`, and `'maybe'`, `'1'` or a blank value parsed as a boolean. `select` was any string, so a blank or comma-only value was admitted. +- **Now.** `count` is the door's own accept set, read from `QueryTransportParamsSchema.shape.count` (the boolean, or the strings `'true'` and `'false'`) and parsed to the boolean it means: `'false'` is `false`. `select` must name at least one field: a value that is blank, whitespace-only or only commas and spaces is refused, published as the pattern `[^\s,]`. Both match what the list door answers with `400 VALIDATION_ERROR`. +- **Unchanged.** Every other parameter, `count: true` / `false`, and a named projection (`name,email`, spaces and empty members between commas trimmed by the door as before). +- **Who sees it.** Code that parses a querystring bag with this schema and relied on the coercion, or a generated client that trusted the wider declaration. + +**The remedy.** Send `count` as `true` or `false` (or leave it out), and `select` naming at least one field (or leave it out to read every column). diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index fd5d355ab29..52f2dadbb20 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -2042,7 +2042,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | :--- | :--- | :--- | :--- | | **filter** | `string` | optional | JSON-encoded filter expression (canonical, singular). | | **filters** | `string` | optional | JSON-encoded filter expression (deprecated plural alias). | -| **select** | `string` | optional | Comma-separated list of fields to retrieve. | +| **select** | `string` | optional | Comma-separated list of fields to retrieve — at least one field name; a value naming no field (blank, or only commas and spaces) is refused. | | **sort** | `string` | optional | Sort expression (e.g. "name asc,created_at desc" or "-created_at"). | | **orderBy** | `string` | optional | Alias for sort (OData compatibility). | | **top** | `integer \| string` | optional | Max records to return (limit) — a non-negative integer. | @@ -2050,7 +2050,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **expand** | `string` | optional | Comma-separated list of lookup/master_detail field names to expand. Resolved to populate array and passed to the engine for batch $in expansion. | | **search** | `string` | optional | Full-text search query. | | **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. | -| **count** | `boolean` | optional | Include total count in response. | +| **count** | `boolean \| Enum<'true' \| 'false'>` | optional | Include the total count in the response — `true` or `false`; any other value is refused. | --- diff --git a/packages/spec/src/api/protocol.test.ts b/packages/spec/src/api/protocol.test.ts index 8bf5e971978..eb6d4003060 100644 --- a/packages/spec/src/api/protocol.test.ts +++ b/packages/spec/src/api/protocol.test.ts @@ -692,6 +692,32 @@ describe('HttpFindQueryParamsSchema', () => { expect(result.success).toBe(true); }); + // [#26817] The list door's accept set for `count` and `select` — not + // `z.coerce.boolean()` (which read `'false'` as `true` and admitted + // `'maybe'`) and not any string (which admitted a blank projection). + it.each([['true', true], ['false', false], [true, true], [false, false]])( + 'reads count=%j as %j', + (value, expected) => { + const result = HttpFindQueryParamsSchema.safeParse({ count: value }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.count).toBe(expected); + }, + ); + + it.each(['maybe', '1', '0', '', 'TRUE', 1, null])('refuses count=%j — neither true nor false', (value) => { + expect(HttpFindQueryParamsSchema.safeParse({ count: value }).success).toBe(false); + }); + + it.each(['', ' ', ',', ' , ,', '\t'])('refuses select=%j — names no field', (value) => { + expect(HttpFindQueryParamsSchema.safeParse({ select: value }).success).toBe(false); + }); + + it.each(['name', 'name,email', ' name , email ', 'name,,email'])('keeps select=%j — names a field', (value) => { + const result = HttpFindQueryParamsSchema.safeParse({ select: value }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.select).toBe(value); + }); + it('rejects ?distinct — the querystring spelling of the removed query.distinct', () => { const result = HttpFindQueryParamsSchema.safeParse({ distinct: true }); expect(result.success).toBe(false); diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 29ca838a5f8..3cdf66d0f9d 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -18,7 +18,7 @@ import { retiredKey } from '../shared/retired-key'; // through it, so `api/UpdateAiConversationRequest.json` states it. import { requiredOneOf } from '../shared/refinement-projection'; import { MetadataItemNameSchema } from '../shared/identifiers.zod'; -import { DroppedFieldsEventSchema, QueryPageWindowParamSchema, QueryWithTransportSchema, ValidationAdvisoryEventSchema } from '../data/data-engine.zod'; +import { DroppedFieldsEventSchema, QueryPageWindowParamSchema, QueryTransportParamsSchema, QueryWithTransportSchema, ValidationAdvisoryEventSchema } from '../data/data-engine.zod'; import { AnalyticsQueryRequestSchema, AnalyticsResultResponseSchema, @@ -2369,7 +2369,14 @@ export const HttpFindQueryParamsSchema = lazySchema(() => z.object({ filter: z.string().optional().describe('JSON-encoded filter expression (canonical, singular).'), /** @deprecated Use `filter` (singular). Accepted for backward compatibility. */ filters: z.string().optional().describe('JSON-encoded filter expression (deprecated plural alias).'), - select: z.string().optional().describe('Comma-separated list of fields to retrieve.'), + // [#26817] The projection the list door reads: it splits on commas, trims + // each member and refuses a value naming no field (blank, whitespace-only, + // commas only) with a 400. `[^\s,]` is that rule as a pattern — `\s` is the + // set `trim()` strips — so the published JSON Schema states it too. + select: z.string().regex(/[^\s,]/, 'Name at least one field').optional().describe( + 'Comma-separated list of fields to retrieve — at least one field name; a value naming no field ' + + '(blank, or only commas and spaces) is refused.' + ), sort: z.string().optional().describe('Sort expression (e.g. "name asc,created_at desc" or "-created_at").'), orderBy: z.string().optional().describe('Alias for sort (OData compatibility).'), // [#25269] The page window the list door enforces, not `z.coerce.number()`, @@ -2384,7 +2391,15 @@ export const HttpFindQueryParamsSchema = lazySchema(() => z.object({ search: z.string().optional().describe('Full-text search query.'), /** `?distinct` — REMOVED (#4286): the querystring spelling of `query.distinct`, same tombstone. */ distinct: retiredKey(QUERY_DISTINCT_REMOVED), - count: z.coerce.boolean().optional().describe('Include total count in response.'), + // [#26817] The door's accept set, read from the one declaration the door + // parses (`QueryTransportParamsSchema.count`, which `findData` checks) and + // lowered to the boolean it means — not `z.coerce.boolean()`, which read + // `'false'` as `true` and admitted `'maybe'` that the door refuses with a 400. + // A querystring carries no `null`, so the nullable arm is unwrapped. + count: QueryTransportParamsSchema.shape.count.unwrap().unwrap() + .transform((flag) => flag === true || flag === 'true') + .optional() + .describe('Include the total count in the response — `true` or `false`; any other value is refused.'), })); /**