Skip to content

Commit d887aa9

Browse files
fix(spec)!: HttpFindQueryParamsSchema declares the count and select values the list door accepts (#26901)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB4PUNmhZj9zTbsZNNYma5Qa)_ Fixes #26817 Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: none — `skills/**` teaches only the boolean count (`skills/objectui/guides/data-integration.md:90`, `$count: true`) and never a blank `select`; the generated reference page `content/docs/references/api/protocol.mdx` is regenerated in this diff. **BREAKING** (accept-set narrowing of a published declaration, `patch`): `HttpFindQueryParamsSchema` refuses a `count` other than true/false and a `select` naming no field, which it admitted. The list door already refused both with a 400 (since #26811). ADR-0087 disposition in the changeset: `not-required (no-migration-prescription)`. ## 维护者速读 - **改了什么**:`GET /data/:object` 查询参数的公开声明 `HttpFindQueryParamsSchema` 收窄:`count` 只收 `true`/`false`(字符串 `'false'` 读作 false),`select` 必须至少点名一个字段,空串、纯空格、只有逗号的值都被拒。 - **为什么改**:服务端列表接口早已对这些值回 400,但声明还写着「什么都收」,`'false'` 甚至被读成 true;按声明生成的客户端或 AI 作者会写出运行时被拒的请求。 - **风险与回滚**:只影响用这个 schema 解析查询串的代码;接口运行时行为不变(声明没有运行时读者)。回滚只需还原 `packages/spec/src/api/protocol.zod.ts` 里该块的两行声明。 - **先看**:`packages/spec/src/api/protocol.zod.ts`(`HttpFindQueryParamsSchema` 的 `select`、`count` 两行)、`packages/spec/src/api/protocol.test.ts`(新增用例)、`.changeset/26817-http-find-query-params-count-select.md`。 - **两分钟验证**:`pnpm --filter @objectstack/spec exec vitest run src/api/protocol.test.ts`,预期 215 个用例全绿,其中 `reads count="false" as false`、`refuses count="maybe"`、`refuses select=","` 都在。 - **需要你做的**:无,席位落地。 ## What changes - **`count`** reads the door's accept set from the one declaration the door parses, `QueryTransportParamsSchema.shape.count` (which `findData`'s `countFlagValue` checks): the boolean, or the strings `'true'` / `'false'`. The nullable arm is unwrapped (a querystring carries no `null`), and the value is lowered to the boolean it means, so `'false'` parses as `false`. It replaces `z.coerce.boolean()`, which read `'false'` as `true` and admitted `'maybe'`, `'1'` and a blank value. - **`select`** must name at least one field: `z.string().regex(/[^\s,]/)`. This is the door's own rule measured in `projectionFieldsValue`: it splits on commas, trims each member, drops empty members and refuses only when nothing is left. So a blank, whitespace-only or comma-only value is refused, and `name,,email` or ` name , email ` stay accepted, as at the door. `\s` is the set `trim()` strips. A regex is a string format check, not a `.refine()`, so it is published as a JSON Schema `pattern` and adds no `dropped-refinements` row. - **`.describe()` texts** state the new accept sets; the reference page regenerates from them. ## Tests - `packages/spec/src/api/protocol.test.ts`: `count` `'true'`/`'false'`/`true`/`false` parse to `true`/`false`; `'maybe'`, `'1'`, `'0'`, `''`, `'TRUE'`, `1`, `null` refused; `select` `''`, `' '`, `','`, `' , ,'`, tab refused; `name`, `name,email`, ` name , email `, `name,,email` accepted unchanged. ## Acceptance notes - **Readers of `HttpFindQueryParamsSchema`.** Regenerated: `content/docs/references/api/protocol.mdx` (`gen:docs`, in this diff). Unchanged by construction (key-level, keys unchanged): `authorable-surface`, `api-surface`, `declaration-map`, `export-origins`, `json-schema.manifest`, `dropped-refinements.baseline.json`; `check:generated` and the build are green. Prose only: `packages/client/src/index.ts:371`, `packages/client/src/client.test.ts:1969`, `packages/runtime/src/domains/data.ts:410`, `packages/spec/src/data/query.zod.ts:215`, `ui/packages/app-shell/src/console/organizations/manage/orgEnvironments.ts:64`, CHANGELOG entries. Test reader: `packages/metadata-protocol/src/protocol.page-window-declared.test.ts` (top/skip only), green. - **Clients in this repo.** No client sends `count` at all, and none sends a non-boolean count string (`git grep` over `packages`, `examples`, `apps`). `ObjectStackClient.data.find` and `ScopedEnvironmentClient.data.find` (`packages/client/src/index.ts:7060`, `:7946`) send `select` as `select.join(',')` whenever `select` is truthy, so `select: []` sends `?select=`. Not changed here: the door answers it with a 400 naming `select`, and dropping it client-side would turn an empty projection into "every column", the widening #26811 closed. Reported to the seat for a ruling. - **Siblings, out of scope.** `GetDataRequestSchema.select` (`z.array(z.string())`) is on the by-id door (`getData`), not `findData`, and admits `[]` that `getData` refuses. `QueryTransportParamsSchema.select` / `$select` (and the canonical `fields`) sit on the same `findData` door and admit a blank string or `[]`, but they also feed `QueryWithTransportSchema` and the `POST /data/:object/query` body declaration, outside this card's file surface. Both are reported to the seat, not folded. - **Clause-② and Metadata-protocol.** `no (narrowing)`: only a published accept set shrinks, nothing is widened, no key moves. `Metadata-protocol: none`: this is a querystring transport declaration, not metadata an author stores; no metadata type, key or stored shape changes. ## Verification Final head: `c3c1f10ae` (merge of `origin/main` 5787df4). <details><summary>Spec tests and typecheck</summary> `pnpm --filter @objectstack/spec test` then `typecheck` (lock VERDICT command-exit 0 for both; the chain's third leg needed a full build): Test Files 735 passed (735), Tests 22061 passed, 1 todo. After the full build: `metadata-protocol` `protocol.page-window-declared.test.ts` + `protocol.empty-projection-unknown-count.test.ts`: 2 files, 532 tests passed. Full `pnpm build`: Tasks 116 successful, 116 total. </details> <details><summary>Derived gates (103) at c3c1f10</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 103 families, each run with the exit code captured before any pipe; all 103 exit 0 after the full build. `--ran`: "103 derived famil(ies) accounted for — 103 run, 0 NOT-MEASURED". Local-only: `check:dev-prereqs` exit 0, `check:skill-frame-freshness` exit 0. Before the build, on 794bcd8, 97 exit 0 and 6 exit 3 (unbuilt dist). </details> <details><summary>Full dogfood at c3c1f10</summary> `pnpm --filter @objectstack/dogfood test` under the lock: Test Files 1 failed, 326 passed, 3 skipped (330); Tests 2 failed, 2681 passed, 20 skipped (2703). Both failures are `packaged-action-disabled-dispatch-live.dogfood.test.ts` (acceptance[0] and acceptance[5]/[2]), a file added by #26829 at 18:17Z today. It reads no `count` or `select`, and `HttpFindQueryParamsSchema` has no runtime reader. The same file re-run alone on the same head and dist: 1 file, 8 tests passed. Read as load-dependent flake, not this diff. </details> --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzh5dnFHGxFUBugn12b Co-authored-by: Claude <noreply@anthropic.com>
1 parent 452325e commit d887aa9

4 files changed

Lines changed: 65 additions & 5 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec)!: `HttpFindQueryParamsSchema` declares the `count` and `select` values the list door accepts, instead of a coerced boolean and any string (#26817)
6+
7+
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.
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: 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. -->
11+
12+
**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.
13+
14+
- **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.
15+
- **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`.
16+
- **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).
17+
- **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.
18+
19+
**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).

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2043,15 +2043,15 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own
20432043
| :--- | :--- | :--- | :--- |
20442044
| **filter** | `string` | optional | JSON-encoded filter expression (canonical, singular). |
20452045
| **filters** | `string` | optional | JSON-encoded filter expression (deprecated plural alias). |
2046-
| **select** | `string` | optional | Comma-separated list of fields to retrieve. |
2046+
| **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. |
20472047
| **sort** | `string` | optional | Sort expression (e.g. "name asc,created_at desc" or "-created_at"). |
20482048
| **orderBy** | `string` | optional | Alias for sort (OData compatibility). |
20492049
| **top** | `integer \| string` | optional | Max records to return (limit) — a non-negative integer. |
20502050
| **skip** | `integer \| string` | optional | Records to skip (offset) — a non-negative integer. |
20512051
| **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. |
20522052
| **search** | `string` | optional | Full-text search query. |
20532053
| **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. |
2054-
| **count** | `boolean` | optional | Include total count in response. |
2054+
| **count** | `boolean \| Enum<'true' \| 'false'>` | optional | Include the total count in the response — `true` or `false`; any other value is refused. |
20552055

20562056

20572057
---

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,6 +692,32 @@ describe('HttpFindQueryParamsSchema', () => {
692692
expect(result.success).toBe(true);
693693
});
694694

695+
// [#26817] The list door's accept set for `count` and `select` — not
696+
// `z.coerce.boolean()` (which read `'false'` as `true` and admitted
697+
// `'maybe'`) and not any string (which admitted a blank projection).
698+
it.each([['true', true], ['false', false], [true, true], [false, false]])(
699+
'reads count=%j as %j',
700+
(value, expected) => {
701+
const result = HttpFindQueryParamsSchema.safeParse({ count: value });
702+
expect(result.success).toBe(true);
703+
if (result.success) expect(result.data.count).toBe(expected);
704+
},
705+
);
706+
707+
it.each(['maybe', '1', '0', '', 'TRUE', 1, null])('refuses count=%j — neither true nor false', (value) => {
708+
expect(HttpFindQueryParamsSchema.safeParse({ count: value }).success).toBe(false);
709+
});
710+
711+
it.each(['', ' ', ',', ' , ,', '\t'])('refuses select=%j — names no field', (value) => {
712+
expect(HttpFindQueryParamsSchema.safeParse({ select: value }).success).toBe(false);
713+
});
714+
715+
it.each(['name', 'name,email', ' name , email ', 'name,,email'])('keeps select=%j — names a field', (value) => {
716+
const result = HttpFindQueryParamsSchema.safeParse({ select: value });
717+
expect(result.success).toBe(true);
718+
if (result.success) expect(result.data.select).toBe(value);
719+
});
720+
695721
it('rejects ?distinct — the querystring spelling of the removed query.distinct', () => {
696722
const result = HttpFindQueryParamsSchema.safeParse({ distinct: true });
697723
expect(result.success).toBe(false);

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

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { retiredKey } from '../shared/retired-key';
1818
// through it, so `api/UpdateAiConversationRequest.json` states it.
1919
import { requiredOneOf } from '../shared/refinement-projection';
2020
import { MetadataItemNameSchema } from '../shared/identifiers.zod';
21-
import { DroppedFieldsEventSchema, QueryPageWindowParamSchema, QueryWithTransportSchema, ValidationAdvisoryEventSchema } from '../data/data-engine.zod';
21+
import { DroppedFieldsEventSchema, QueryPageWindowParamSchema, QueryTransportParamsSchema, QueryWithTransportSchema, ValidationAdvisoryEventSchema } from '../data/data-engine.zod';
2222
import {
2323
AnalyticsQueryRequestSchema,
2424
AnalyticsResultResponseSchema,
@@ -2369,7 +2369,14 @@ export const HttpFindQueryParamsSchema = lazySchema(() => z.object({
23692369
filter: z.string().optional().describe('JSON-encoded filter expression (canonical, singular).'),
23702370
/** @deprecated Use `filter` (singular). Accepted for backward compatibility. */
23712371
filters: z.string().optional().describe('JSON-encoded filter expression (deprecated plural alias).'),
2372-
select: z.string().optional().describe('Comma-separated list of fields to retrieve.'),
2372+
// [#26817] The projection the list door reads: it splits on commas, trims
2373+
// each member and refuses a value naming no field (blank, whitespace-only,
2374+
// commas only) with a 400. `[^\s,]` is that rule as a pattern — `\s` is the
2375+
// set `trim()` strips — so the published JSON Schema states it too.
2376+
select: z.string().regex(/[^\s,]/, 'Name at least one field').optional().describe(
2377+
'Comma-separated list of fields to retrieve — at least one field name; a value naming no field '
2378+
+ '(blank, or only commas and spaces) is refused.'
2379+
),
23732380
sort: z.string().optional().describe('Sort expression (e.g. "name asc,created_at desc" or "-created_at").'),
23742381
orderBy: z.string().optional().describe('Alias for sort (OData compatibility).'),
23752382
// [#25269] The page window the list door enforces, not `z.coerce.number()`,
@@ -2384,7 +2391,15 @@ export const HttpFindQueryParamsSchema = lazySchema(() => z.object({
23842391
search: z.string().optional().describe('Full-text search query.'),
23852392
/** `?distinct` — REMOVED (#4286): the querystring spelling of `query.distinct`, same tombstone. */
23862393
distinct: retiredKey(QUERY_DISTINCT_REMOVED),
2387-
count: z.coerce.boolean().optional().describe('Include total count in response.'),
2394+
// [#26817] The door's accept set, read from the one declaration the door
2395+
// parses (`QueryTransportParamsSchema.count`, which `findData` checks) and
2396+
// lowered to the boolean it means — not `z.coerce.boolean()`, which read
2397+
// `'false'` as `true` and admitted `'maybe'` that the door refuses with a 400.
2398+
// A querystring carries no `null`, so the nullable arm is unwrapped.
2399+
count: QueryTransportParamsSchema.shape.count.unwrap().unwrap()
2400+
.transform((flag) => flag === true || flag === 'true')
2401+
.optional()
2402+
.describe('Include the total count in the response — `true` or `false`; any other value is refused.'),
23882403
}));
23892404

23902405
/**

0 commit comments

Comments
 (0)