Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/26817-http-find-query-params-count-select.md
Original file line number Diff line number Diff line change
@@ -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

<!-- 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: two querystring values the declaration admitted, and the Data API list door already refused, are now refused by the declaration too. A caller drops the parameter or sends a well-formed value; there is nothing for a migration to rewrite and no tombstone. -->

**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).
4 changes: 2 additions & 2 deletions content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2042,15 +2042,15 @@ 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. |
| **skip** | `integer \| string` | optional | Records to skip (offset) — a non-negative integer. |
| **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. |


---
Expand Down
26 changes: 26 additions & 0 deletions packages/spec/src/api/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
21 changes: 18 additions & 3 deletions packages/spec/src/api/protocol.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()`,
Expand All @@ -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.'),
}));

/**
Expand Down
Loading