Skip to content

Commit 49d652e

Browse files
fix(spec)!: EngineQueryOptionsSchema.fields and the RPC request's select refuse an empty projection, as their transport siblings do (#27007)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBSHirnFJasE7wztBSe558R2)_ Fixes #26954 Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: none — the skills teach only a non-empty engine `fields` projection (`skills/objectstack-query/rules/filters.md:105`, `fields: ['id']`; `skills/objectstack-query/SKILL.md:25` lists `fields` among the legal `find` option keys) and never an empty one, so no skill sentence goes false; the one transport data-read line, `skills/objectui/guides/data-integration.md:82` (`$select: ['name', 'email']`), stays true too. ## 维护者速读 - 改了什么:引擎侧四份「选哪些字段」的公开声明(`EngineQueryOptionsSchema.fields`、RPC 请求里已弃用的 `select`、旧版 `DataEngineQueryOptionsSchema.select`、向量检索请求的 `fields`)不再接受空数组和带空白名的数组,与 #26949 收窄的三份兄弟声明共用同一条规则(同一处定义,未复制)。 - 为什么改:兄弟声明已拒绝空投影,这四份仍写着「空也收」,按声明生成的客户端或 AI 作者会学到相反的规则。 - 风险与回滚:仓内没有任何运行时用这四份 schema 解析请求,线上请求零变化;受影响的只有仓外直接用这些导出 schema 解析、且发了空投影的代码。进程内 `engine.find(obj, { fields: [] })` 仍读全部列,不变。回滚 = revert 本 PR。 - 先看:`packages/spec/src/data/data-engine.zod.ts`(共享规则上移到 `EngineQueryOptionsSchema` 之前,以及四个成员)、`packages/spec/src/data/data-engine.test.ts`(新 describe 块)、`.changeset/26954-engine-projection-empty.md`。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run src/data/data-engine.test.ts -t 'engine-side projection'`,预期 44 个用例全绿:空数组与空白名被拒且报错文字与兄弟声明一致,非空列表通过。 - 需要你做的:无,席位落地。 ## What changes - `packages/spec/src/data/data-engine.zod.ts`: - The projection rule block from #26949 (`PROJECTION_NAMES_NO_FIELD`, `TransportProjectionStringSchema`, `transportProjectionArray`, `PROJECTION_ACCEPT_SET` and its docblock) **moves** from just above the page-window block to just above `EngineQueryOptionsSchema`. It is not copied. It has to move: `RpcQueryOptionsSchema` is a non-lazy `EngineQueryOptionsSchema.extend(...)`, so importing the module runs the lazy factory before any later `const` is initialised (a TDZ `ReferenceError`). The docblock gains one paragraph that says so and names the four new readers. - `EngineQueryOptionsSchema.fields`: `z.array(FieldNodeSchema)` becomes `transportProjectionArray(FieldNodeSchema)`, the same construction as `QueryTransportParamsSchema.$select`. - `RpcQueryOptionsSchema.select` (`@deprecated`): `z.array(z.string())` becomes `transportProjectionArray(z.string())`, the same construction as `QueryTransportParamsSchema.select` and the arm `GetDataRequestSchema.select` reads. - Folded in at the seat's request (same file, same family, same ruling): `DataEngineQueryOptionsSchema.select` (`@deprecated` legacy options) and `DataEngineVectorFindRequestSchema.fields` (the `vectorFind` arm of `DataEngineRequestSchema`). Both change from `z.array(z.string())` to `transportProjectionArray(z.string())`. - `packages/spec/src/data/data-engine.test.ts`: one new `describe` block (44 cases), described under Tests. - `.changeset/26954-engine-projection-empty.md`: `@objectstack/spec` patch, with both declaration lines, one ADR-0087 marker and a BREAKING banner with Before / Now / Unchanged. No `.describe()` changes. None of the four members had one, and `check:generated` reports all 14 artifacts up to date on the head, so no reference page regenerates. A sentence saying "is refused" would also be false of the in-process engine (see Acceptance notes). **The ADR-0049 note** on `DataEngineRequestSchema` ("every arm below now has zero readers in this repo … an ADR-0049 enforce-or-remove candidate") is unchanged. I re-measured it on this head: no code outside `packages/spec` parses `DataEngineRequestSchema`, `DataEngineFindRequestSchema`, `DataEngineFindOneRequestSchema` or `DataEngineContractSchema` (`git grep`, non-test: the hits outside `data-engine.zod.ts` are the generated reference page's import line, the export ledgers and CHANGELOG prose). So every sentence in it is still true. The note moved down by 33 lines with the block. ## Why After #26817 and #26909, every published transport declaration of a read's projection refuses an empty one. These four engine-side declarations still admitted `[]` and blank names, so a client generated from their JSON Schema, or an AI author reading them, learned the opposite rule. Seat ruling on #26954: align, do not retire. ## Readers and producers (measured on `origin/main` 3217fc5) - **Runtime parsers:** none, for any of the four members. `EngineQueryOptionsSchema` is read at runtime only for its key list (`runtime/src/action-execution.ts:2047`, `.shape` keys) and by objectql's `engine-unknown-option.test.ts` (keys). Key sets do not change. `RpcQueryOptionsSchema` is module-private and reached only through the three request schemas above, which have no parser outside spec. `DataEngineQueryOptionsSchema` and `DataEngineVectorFindRequestSchema` have no reference outside `packages/spec` except the generated reference page's import line and one prose sentence in `content/docs/kernel/contracts/data-engine.mdx:637`, which names the legacy schema as deprecated and is still true. - **In-process engine:** `ObjectQL.find` / `findOne` never parse their options with this schema. `engine.ts:13563` projects only when `ast.fields.length > 0`, and `:13573` falls back to `*` on an empty projection. So `engine.find(obj, { fields: [] })` reads every column, as before. - **Types:** `z.input` / `z.infer` stay `string[]` for all four members, so no TypeScript caller changes. `pnpm --filter @objectstack/spec typecheck` passes on the head. - **Client SDK / codegen / OpenAPI:** `packages/client` imports none of these schemas. JSON Schema gains `minItems: 1` and a per-item `\S` pattern. Both are a `min_length` and a `string_format`, not `custom`, so there is no `dropped-refinements` row. `api-surface`, `declaration-map`, `export-origins` and `authorable-surface` are key- or export-level and unchanged (`check:generated`: 14/14 up to date). `gen:openapi` is ungated. - **In-repo callers sending `[]`:** none. `git grep` over `packages`, `ui`, `examples`, `apps` and `skills` (non-test) for `fields: []` / `select: []`: every hit is UI, flow, analytics or result metadata, not an engine query. - **Stored or authored data:** none of the four members is authorable metadata. No metadata type embeds `EngineQueryOptionsSchema`, `DataEngineQueryOptionsSchema` or the request schemas (`git grep` over `packages/spec/src`). The authored projection is `QuerySchema.fields` (views, reports), which this PR does not touch. So `Metadata-protocol: none`, and ADR-0087 is `not-required (no-migration-prescription)`. ## Tests `packages/spec/src/data/data-engine.test.ts`, `describe('the engine-side projection declarations admit exactly what their transport siblings admit')`: - `EngineQueryOptionsSchema.fields` refuses `[]`, `['name', '']` and `['name', ' ']`. The issue path is `['fields']` or `['fields', 1]`. The message equals the one `QueryTransportParamsSchema.$select` gives for the same value, and that sibling's path is asserted too. It keeps `['name']` and `['name', 'email']` unchanged. - For each of `DataEngineFindRequestSchema`, `DataEngineFindOneRequestSchema` and the `DataEngineRequestSchema` union: - `query.select` refuses the same three values at `['query', 'select', …]`, with the bare `select` sibling's message; - `query.fields` refuses them at `['query', 'fields', …]`, with `$select`'s message; - `query.select: ['name']` / `['name', 'email']` parses and folds onto `fields`. - `DataEngineQueryOptionsSchema.select` refuses the same three values at `['select', …]`, with the bare `select` sibling's message, and keeps a non-empty list unchanged. - `DataEngineVectorFindRequestSchema` and the `DataEngineRequestSchema` union (`method: 'vectorFind'`): `fields` refuses the three values at `['fields', …]`, with the bare `select` sibling's message, and keeps a non-empty list unchanged. Ablation, after committing the fix, through `scripts/ablation-replace.mjs` (anchor hit, blob moved, restored to the HEAD blob with `git diff HEAD` empty): - `fields` back to `z.array(FieldNodeSchema)`: 12 failed / 17 passed in the block. - `select` back to `z.array(z.string())`: 9 failed / 20 passed. - On `ba848653` (the 44-case block): `DataEngineQueryOptionsSchema.select` reverted gives 3 failed / 41 passed; `DataEngineVectorFindRequestSchema.fields` reverted gives 6 failed / 38 passed. Both restored to the HEAD blob, with `git diff HEAD` empty. Runs on head `ba848653`: - `@objectstack/spec` full suite under the lock: 819 files, 23498 tests passed (1 todo). Then `typecheck` (tsc, scripts and test typecheck): `VERDICT command-exit 0`. - `pnpm --filter @objectstack/spec build && check:generated`: `VERDICT command-exit 0`, and all 14 generated artifacts are up to date, so nothing was regenerated. - Full dogfood, owed by the `(narrowing)` arm: built the `@objectstack/dogfood^...` closure, then ran `pnpm --filter @objectstack/dogfood test` under the lock with no shard and no path. Result: 344 files passed, 3 skipped (347); 2784 tests passed, 20 skipped (2804); `VERDICT command-exit 0`. - `dispatch-gates --commands`: 80 derived families. All 80 exit 0. Three of them (`check:doc-formula-expressions`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`) first exited 3 because their prerequisite was not met. They were run again after the dogfood closure build and exited 0. `--ran` reconciliation: 80 run, 0 NOT-MEASURED. - The first head, `17ce2dfb`, had the same readings: 23483 spec tests, 80/80 gates, and the same dogfood counts. ## Acceptance notes - **The declaration is narrower than the in-process engine, by design.** `engine.find(obj, { fields: [] })` still reads every column, because the engine does not parse its options. Everything the new declaration admits, the engine accepts. Nothing the engine does contradicts a sentence in this diff. No `.describe()` claims "is refused". - **The two sibling members first reported here as left loose** (`DataEngineQueryOptionsSchema.select`, `DataEngineVectorFindRequestSchema.fields`) are aligned in this PR, at the seat's request. - The in-process engine reading `fields: []` as every column is runtime behaviour outside this card. The seat filed it as #27008. ## Human Minimum Each item is one rule from [CONTRIBUTING.md › Human Minimum](https://github.com/objectstack-ai/objectstack/blob/main/CONTRIBUTING.md#human-minimum), which links the AGENTS.md text that states it. - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [ ] 3. No formatter ran; `pnpm lint` passes. (No formatter ran. `pnpm lint` is CI's repo-wide run.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed (`check:generated`: none were stale). - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzfjH9js8WRa8Xi2dmx --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6845035 commit 49d652e

3 files changed

Lines changed: 163 additions & 35 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec)!: the engine-side projection declarations (`EngineQueryOptionsSchema.fields`, the RPC request's `select`, the legacy `DataEngineQueryOptionsSchema.select` and the vector-find request's `fields`) declare the projection their transport siblings declare, instead of any list (#26954)
6+
7+
Clause-②: no (narrowing) — the published engine-side declarations of a read's projection (`EngineQueryOptionsSchema.fields`; the deprecated `select` of the RPC query options reached through `DataEngineFindRequestSchema`, `DataEngineFindOneRequestSchema` and `DataEngineRequestSchema`; the deprecated `DataEngineQueryOptionsSchema.select`; and `DataEngineVectorFindRequestSchema.fields`, also an arm of `DataEngineRequestSchema`) refuse a list naming no field, which they admitted before. 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 engine and RPC request declarations, never stored, and no metadata type embeds them. A caller names at least one field or leaves the projection out; there is nothing for a migration to rewrite and no tombstone. -->
11+
12+
**BREAKING** accept-set narrowing of published declarations (`@objectstack/spec/data`). No runtime in this repository parses any of these members, so no request served here changes; what can break is code outside it that parses with these exported schemas, or a client generated from their JSON Schema, and sent an empty projection.
13+
14+
- **Before.** `EngineQueryOptionsSchema.fields`, the RPC query's `select`, `DataEngineQueryOptionsSchema.select` and `DataEngineVectorFindRequestSchema.fields` admitted any list of strings: `[]`, and a list with a blank or whitespace-only name.
15+
- **Now.** A list must hold at least one name, and no name may be blank (JSON Schema `minItems: 1`, and a non-blank pattern on each name). All four read this rule from the same declaration as `QueryTransportParamsSchema.select` / `$select` and `GetDataRequestSchema.select`, so the refusal messages are the same.
16+
- **Unchanged.** The inferred types (`string[]`), a named projection, and leaving the projection out. The in-process ObjectQL engine does not parse its options with this schema: `engine.find(object, { fields: [] })` still reads every column, as before.
17+
18+
**The remedy.** Name at least one field, or leave `fields` / `select` out to read every column.

‎packages/spec/src/data/data-engine.test.ts‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
DataEngineVectorFindRequestSchema,
2626
DataEngineRequestSchema,
2727
DroppedFieldsEventSchema,
28+
QueryTransportParamsSchema,
2829
} from './data-engine.zod';
2930
import { QuerySchema } from './query.zod';
3031

@@ -1242,3 +1243,104 @@ describe('DroppedFieldsEventSchema.reason — why a write dropped submitted fiel
12421243
expect(issue?.path).toEqual(['reason']);
12431244
});
12441245
});
1246+
1247+
describe('the engine-side projection declarations admit exactly what their transport siblings admit', () => {
1248+
/**
1249+
* [#26954] `EngineQueryOptionsSchema.fields` and the RPC request's
1250+
* deprecated `select` (`DataEngineFindRequestSchema.query`, and so
1251+
* `DataEngineFindOneRequestSchema` and the `DataEngineRequestSchema` union)
1252+
* take the projection's array arm from the one declaration their transport
1253+
* siblings use: at least one name, none blank. `fields` shares
1254+
* `FieldNodeSchema` with `$select`; `select` shares `z.string()` with the
1255+
* bare `select`. So each refusal reads exactly as its sibling's does.
1256+
*/
1257+
const REFUSED: ReadonlyArray<readonly [string, string[], ReadonlyArray<string | number>]> = [
1258+
['an empty list', [], []],
1259+
['a list with a blank name', ['name', ''], [1]],
1260+
['a list with a whitespace name', ['name', ' '], [1]],
1261+
];
1262+
const KEPT: ReadonlyArray<readonly [string, string[]]> = [
1263+
['one name', ['name']],
1264+
['two names', ['name', 'email']],
1265+
];
1266+
1267+
/** The first issue's message and path when `value` sits under `key` of a transport bag. */
1268+
const sibling = (key: 'select' | '$select', value: unknown) => {
1269+
const parsed = QueryTransportParamsSchema.safeParse({ [key]: value });
1270+
expect(parsed.success, `${key} sibling refuses ${JSON.stringify(value)}`).toBe(false);
1271+
return parsed.success ? undefined : parsed.error.issues[0];
1272+
};
1273+
1274+
it.each(REFUSED)('EngineQueryOptionsSchema.fields refuses %s, naming the path, as `$select` does', (_name, value, tail) => {
1275+
const parsed = EngineQueryOptionsSchema.safeParse({ fields: value });
1276+
expect(parsed.success).toBe(false);
1277+
const issue = parsed.success ? undefined : parsed.error.issues[0];
1278+
expect(issue?.path).toEqual(['fields', ...tail]);
1279+
const twin = sibling('$select', value);
1280+
expect(issue?.message).toBe(twin?.message);
1281+
expect(twin?.path).toEqual(['$select', ...tail]);
1282+
});
1283+
1284+
it.each(KEPT)('EngineQueryOptionsSchema.fields keeps %s, unchanged', (_name, value) => {
1285+
expect(EngineQueryOptionsSchema.parse({ fields: value }).fields).toEqual(value);
1286+
});
1287+
1288+
for (const [label, schema, method] of [
1289+
['DataEngineFindRequestSchema', DataEngineFindRequestSchema, 'find'],
1290+
['DataEngineFindOneRequestSchema', DataEngineFindOneRequestSchema, 'findOne'],
1291+
['DataEngineRequestSchema', DataEngineRequestSchema, 'find'],
1292+
] as const) {
1293+
it.each(REFUSED)(`${label}: query.select refuses %s, naming the path, as the bare \`select\` does`, (_name, value, tail) => {
1294+
const parsed = schema.safeParse({ method, object: 'account', query: { select: value } });
1295+
expect(parsed.success).toBe(false);
1296+
const issue = parsed.success ? undefined : parsed.error.issues[0];
1297+
expect(issue?.path).toEqual(['query', 'select', ...tail]);
1298+
expect(issue?.message).toBe(sibling('select', value)?.message);
1299+
});
1300+
1301+
it.each(REFUSED)(`${label}: query.fields refuses %s, naming the path`, (_name, value, tail) => {
1302+
const parsed = schema.safeParse({ method, object: 'account', query: { fields: value } });
1303+
expect(parsed.success).toBe(false);
1304+
const issue = parsed.success ? undefined : parsed.error.issues[0];
1305+
expect(issue?.path).toEqual(['query', 'fields', ...tail]);
1306+
expect(issue?.message).toBe(sibling('$select', value)?.message);
1307+
});
1308+
1309+
it.each(KEPT)(`${label}: query.select keeps %s, folded onto \`fields\``, (_name, value) => {
1310+
const parsed = schema.parse({ method, object: 'account', query: { select: value } }) as { query?: Record<string, unknown> };
1311+
expect(parsed.query).toEqual({ fields: value });
1312+
});
1313+
}
1314+
1315+
// The legacy options bag's `select` and the vector-find request's `fields`
1316+
// are `z.string()` lists, so their sibling is the bare `select`.
1317+
it.each(REFUSED)('DataEngineQueryOptionsSchema.select refuses %s, naming the path, as the bare `select` does', (_name, value, tail) => {
1318+
const parsed = DataEngineQueryOptionsSchema.safeParse({ select: value });
1319+
expect(parsed.success).toBe(false);
1320+
const issue = parsed.success ? undefined : parsed.error.issues[0];
1321+
expect(issue?.path).toEqual(['select', ...tail]);
1322+
expect(issue?.message).toBe(sibling('select', value)?.message);
1323+
});
1324+
1325+
it.each(KEPT)('DataEngineQueryOptionsSchema.select keeps %s, unchanged', (_name, value) => {
1326+
expect(DataEngineQueryOptionsSchema.parse({ select: value }).select).toEqual(value);
1327+
});
1328+
1329+
for (const [label, schema] of [
1330+
['DataEngineVectorFindRequestSchema', DataEngineVectorFindRequestSchema],
1331+
['DataEngineRequestSchema', DataEngineRequestSchema],
1332+
] as const) {
1333+
it.each(REFUSED)(`${label}: vectorFind fields refuses %s, naming the path, as the bare \`select\` does`, (_name, value, tail) => {
1334+
const parsed = schema.safeParse({ method: 'vectorFind', object: 'doc', vector: [0.1, 0.2], fields: value });
1335+
expect(parsed.success).toBe(false);
1336+
const issue = parsed.success ? undefined : parsed.error.issues[0];
1337+
expect(issue?.path).toEqual(['fields', ...tail]);
1338+
expect(issue?.message).toBe(sibling('select', value)?.message);
1339+
});
1340+
1341+
it.each(KEPT)(`${label}: vectorFind fields keeps %s, unchanged`, (_name, value) => {
1342+
const parsed = schema.parse({ method: 'vectorFind', object: 'doc', vector: [0.1, 0.2], fields: value }) as { fields?: string[] };
1343+
expect(parsed.fields).toEqual(value);
1344+
});
1345+
}
1346+
});

‎packages/spec/src/data/data-engine.zod.ts‎

Lines changed: 43 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,41 @@ export const BaseEngineOptionsSchema = lazySchema(() => z.object({
7575
context: ExecutionContextSchema.partial().optional(),
7676
}));
7777

78+
/**
79+
* [#26909] The projection's accept set — the `select` / `$select` spellings
80+
* of `fields` — as the doors that read it judge it (`projectionFieldsValue` in
81+
* `@objectstack/metadata-protocol`, shared by the list's `findData` and the
82+
* by-id `getData`). A comma string is split, trimmed and emptied of blank
83+
* members there, and a value naming NO field is refused with a 400: absent
84+
* means "every column", so an empty projection read as absent is a widening.
85+
*
86+
* The string arm is that rule as a pattern: `[^\s,]` holds iff some comma
87+
* segment has a non-whitespace character, and `\s` is exactly the set
88+
* `trim()` strips. A pattern, not a refinement, so the published JSON Schema
89+
* states it too. The array arm is not split: the door keeps every member,
90+
* refuses `[]`, and checks every member against the object's fields, where a
91+
* blank name is never one.
92+
*
93+
* `HttpFindQueryParamsSchema.select` and `GetDataRequestSchema.select`
94+
* (`../api/protocol.zod.ts`) read these arms off the `QueryTransportParamsSchema`
95+
* members rather than restating them, so the three declarations of one door
96+
* rule cannot drift apart.
97+
*
98+
* [#26954] The engine-side declarations of the same projection —
99+
* `EngineQueryOptionsSchema.fields`, `RpcQueryOptionsSchema.select`, the
100+
* legacy `DataEngineQueryOptionsSchema.select` and
101+
* `DataEngineVectorFindRequestSchema.fields` — take the array arm from here
102+
* too. It is declared ahead of both because
103+
* `RpcQueryOptionsSchema` extends `EngineQueryOptionsSchema` at module load,
104+
* which runs the lazy factory before any later `const` exists.
105+
*/
106+
const PROJECTION_NAMES_NO_FIELD = 'Name at least one field';
107+
const TransportProjectionStringSchema = z.string().regex(/[^\s,]/, PROJECTION_NAMES_NO_FIELD);
108+
const transportProjectionArray = <T extends z.ZodString>(member: T) =>
109+
z.array(member.regex(/\S/, 'A field name cannot be blank')).min(1, PROJECTION_NAMES_NO_FIELD);
110+
const PROJECTION_ACCEPT_SET = 'at least one field name; a value naming no field (blank, only commas and spaces, '
111+
+ 'an empty list, or a list with a blank name) is refused';
112+
78113
// ==========================================================================
79114
// 2. method: FIND (QueryAST-aligned)
80115
// ==========================================================================
@@ -101,8 +136,8 @@ export const EngineQueryOptionsSchema = lazySchema(() => BaseEngineOptionsSchema
101136
/** Filter conditions (WHERE) — standard QueryAST `where` */
102137
where: z.union([z.record(z.string(), z.unknown()), FilterConditionSchema]).optional(),
103138

104-
/** Fields to retrieve (SELECT) — standard QueryAST `fields` */
105-
fields: z.array(FieldNodeSchema).optional(),
139+
/** Fields to retrieve (SELECT) — standard QueryAST `fields`; at least one, none blank (#26954) */
140+
fields: transportProjectionArray(FieldNodeSchema).optional(),
106141

107142
/** Sorting instructions (ORDER BY) — standard QueryAST `orderBy` */
108143
orderBy: z.array(SortNodeSchema).optional(),
@@ -179,8 +214,8 @@ export const EngineQueryOptionsSchema = lazySchema(() => BaseEngineOptionsSchema
179214
export const DataEngineQueryOptionsSchema = lazySchema(() => BaseEngineOptionsSchema.extend({
180215
/** @deprecated Use `where` (EngineQueryOptionsSchema) */
181216
filter: DataEngineFilterSchema.optional(),
182-
/** @deprecated Use `fields` (EngineQueryOptionsSchema) */
183-
select: z.array(z.string()).optional(),
217+
/** @deprecated Use `fields` (EngineQueryOptionsSchema) — the same projection rule (#26954) */
218+
select: transportProjectionArray(z.string()).optional(),
184219
/** @deprecated Use `orderBy` (EngineQueryOptionsSchema) */
185220
sort: DataEngineSortSchema.optional(),
186221
limit: z.number().int().min(1).optional(),
@@ -774,8 +809,8 @@ function foldRpcQueryOptions(input: object, ctx: z.core.$RefinementCtx): EngineQ
774809
*/
775810
const RpcQueryOptionsSchema = EngineQueryOptionsSchema.extend({
776811
...RpcLegacyFilterMixin,
777-
/** @deprecated Use `fields` */
778-
select: z.array(z.string()).optional(),
812+
/** @deprecated Use `fields` — the same projection rule (#26954) */
813+
select: transportProjectionArray(z.string()).optional(),
779814
/** @deprecated Use `orderBy` */
780815
sort: DataEngineSortSchema.optional(),
781816
/** @deprecated Use `offset` */
@@ -907,33 +942,6 @@ const TransportFilterValueSchema = lazySchema(() => z.union([FilterArraySchema,
907942
*/
908943
const TransportCountValueSchema = lazySchema(() => z.union([z.boolean(), z.enum(['true', 'false'])]));
909944

910-
/**
911-
* [#26909] The projection's accept set — the `select` / `$select` spellings
912-
* of `fields` — as the doors that read it judge it (`projectionFieldsValue` in
913-
* `@objectstack/metadata-protocol`, shared by the list's `findData` and the
914-
* by-id `getData`). A comma string is split, trimmed and emptied of blank
915-
* members there, and a value naming NO field is refused with a 400: absent
916-
* means "every column", so an empty projection read as absent is a widening.
917-
*
918-
* The string arm is that rule as a pattern: `[^\s,]` holds iff some comma
919-
* segment has a non-whitespace character, and `\s` is exactly the set
920-
* `trim()` strips. A pattern, not a refinement, so the published JSON Schema
921-
* states it too. The array arm is not split: the door keeps every member,
922-
* refuses `[]`, and checks every member against the object's fields, where a
923-
* blank name is never one.
924-
*
925-
* `HttpFindQueryParamsSchema.select` and `GetDataRequestSchema.select`
926-
* (`../api/protocol.zod.ts`) read these arms off the members below rather
927-
* than restating them, so the three declarations of one door rule cannot
928-
* drift apart.
929-
*/
930-
const PROJECTION_NAMES_NO_FIELD = 'Name at least one field';
931-
const TransportProjectionStringSchema = z.string().regex(/[^\s,]/, PROJECTION_NAMES_NO_FIELD);
932-
const transportProjectionArray = <T extends z.ZodString>(member: T) =>
933-
z.array(member.regex(/\S/, 'A field name cannot be blank')).min(1, PROJECTION_NAMES_NO_FIELD);
934-
const PROJECTION_ACCEPT_SET = 'at least one field name; a value naming no field (blank, only commas and spaces, '
935-
+ 'an empty list, or a list with a blank name) is refused';
936-
937945
/**
938946
* [#25269] The page window — `limit` / `offset` and every spelling that folds
939947
* onto them (`top`, `$top`, `skip`, `$skip`) — as the Data API list doors
@@ -1513,8 +1521,8 @@ export const DataEngineVectorFindRequestSchema = lazySchema(() => z.object({
15131521
vector: z.array(z.number()),
15141522
/** Optional pre-filter (Metadata filtering) — standard QueryAST `where` */
15151523
where: z.union([z.record(z.string(), z.unknown()), FilterConditionSchema]).optional(),
1516-
/** Fields to retrieve — standard QueryAST `fields` */
1517-
fields: z.array(z.string()).optional(),
1524+
/** Fields to retrieve — standard QueryAST `fields`; the same projection rule (#26954) */
1525+
fields: transportProjectionArray(z.string()).optional(),
15181526
/** Number of results */
15191527
limit: z.number().int().default(5).optional(),
15201528
/** Minimum similarity score (0-1) or distance threshold */

0 commit comments

Comments
 (0)