diff --git a/.changeset/27166-query-get-record-empty-projection.md b/.changeset/27166-query-get-record-empty-projection.md new file mode 100644 index 00000000000..b911668f982 --- /dev/null +++ b/.changeset/27166-query-get-record-empty-projection.md @@ -0,0 +1,24 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-automation': minor +--- + +fix(spec)!: `QuerySchema.fields` (and so every nested `expand` entry) and the flow `get_record` node's `fields` refuse an empty projection, as every other published projection declaration and the in-process engine do (#27166) + +Clause-②: no (narrowing) — two published declarations of a read's projection, `QuerySchema.fields` (the QueryAST, which is also the value type of every `expand` entry, `EngineQueryOptionsSchema.expand` included, and the canonical `fields` of the transport-aware query slot) and `GetRecordConfigSchema.fields` (the flow `get_record` node), refuse a list naming no field, which they admitted before. Nothing is widened and no key is added, removed or renamed. +Metadata-protocol: narrowed +Skills-docs: none — `skills/**` teaches `fields` and nested `expand` `fields` only as non-empty lists (`skills/objectstack-query/SKILL.md`, the `expand` value row and the nested-expand example), and teaches the `get_record` node without `fields` (`skills/objectstack-automation/SKILL.md`); no file teaches an empty projection, and every taught use stays true. + + + +**BREAKING** for a flow `get_record` node, or a query you build, that carries an empty projection. In this same release the in-process engine starts refusing `fields: []` at its `find` / `findOne` entry, top level and at any `expand` depth; without this change the two declarations below would still admit it, so such a `get_record` node would save clean and fail at its first run. + +- **Before (`@objectstack/spec@18.2.0`, the last release).** `QuerySchema.fields` and `GetRecordConfigSchema.fields` admitted any list of strings: `[]`, and a list with a blank or whitespace-only name. A `get_record` node with `fields: []` passed `os validate`, `defineStack` and `registerFlow`, and its run read every column of the row. A `POST /data/:object/query` body with `fields: []`, top level or on an `expand` entry, answered `200` with every column. +- **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). Both read the rule from the declaration `EngineQueryOptionsSchema.fields` and `QueryTransportParamsSchema.$select` read, so the refusal says the same thing: "Name at least one field", or "A field name cannot be blank" for a blank member. A `get_record` node with `fields: []` is refused where a flow is saved or registered, at `flows.N.nodes.M.config.fields` (`STACK_SCHEMA_INVALID` from `defineStack`), and its executor refuses the config before any read. An `expand` entry with `fields: []` is refused at `expand..fields`. A `POST /data/:object/query` body with `fields: []` is refused by the body schema, `400 VALIDATION_FAILED` at `query.fields`, which is the answer its `$select: []` spelling gets; an `expand` entry's `fields: []` in that body is refused the same way, at `query.expand..fields`. +- **`@objectstack/service-automation`.** The `get_record` node's designer form (`configSchema`) declares `fields` with `minItems: 1`, as its `limit` already mirrors the contract's floor, so the form no longer offers an empty list the save refuses. +- **Stored flows.** They are not rewritten, and there is no conversion for `os migrate meta --stored` to apply. A stored flow whose `get_record` node carries `fields: []` is refused by `registerFlow` on every pull of the stored definitions, so it stays unregistered, and its triggers unbound, until the row is edited: name the fields, or remove the key. +- **Unchanged.** The inferred types (`string[]`), a named projection, and leaving `fields` out to read every column. + +**Supersedes, in the same release:** `26954-engine-projection-empty.md` says, under **Unchanged**, that the in-process ObjectQL engine does not parse its options with that schema and that `engine.find(object, { fields: [] })` still reads every column, as before. The engine change in this release (`27008-engine-empty-projection-refused.md`) refuses that call at its `find` / `findOne` entry, so that sentence no longer holds: an empty projection is refused by the declarations and by the engine alike, and leaving `fields` out is the one way to read every column. + +**The remedy.** Name the fields to read, or remove `fields` to read every field; on an `expand` entry, the same for the related record. There is no mechanical rewrite: dropping the key reads every column, which is the widening the rule refuses, so the choice is the author's. diff --git a/content/docs/data-modeling/queries.mdx b/content/docs/data-modeling/queries.mdx index 367cbf3b1be..d3988466838 100644 --- a/content/docs/data-modeling/queries.mdx +++ b/content/docs/data-modeling/queries.mdx @@ -296,10 +296,13 @@ retired, read them with the operator-only them before `os migrate apply --allow-destructive` drops the column. No runtime door serves them. -`fields` names at least one column. An empty list (`fields: []`, `$select=`) is refused with -`400 VALIDATION_ERROR` — on the data API, and by `engine.find()` / `findOne()` for an in-process -caller (a plugin, a hook, a flow), the top-level `fields` and an `expand` entry's alike — rather -than read as "every column". Leave `fields` out to read every column. +`fields` names at least one column. An empty list, or a list with a blank name, is refused +rather than read as "every column", the top-level `fields` and an `expand` entry's alike: by the +query schema itself (`QuerySchema`), so a `POST /data/:object/query` body with `fields: []` answers +`400 VALIDATION_FAILED` naming `query.fields`; on the querystring (`?fields=`, `$select=`) with +`400 VALIDATION_ERROR`; and by `engine.find()` / `findOne()` for an in-process caller (a plugin, a +hook). A flow `get_record` node with `fields: []` is refused when the flow is saved. Leave +`fields` out to read every column. ### Nested / Related Fields diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 26074cf317d..a8b16daab90 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -580,7 +580,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object name (e.g. account) | -| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | +| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns, at least one: omit `fields` to read every column, because an empty list or a blank name is refused rather than read as every column. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | | **where** | `any` | optional | Filtering criteria (WHERE) | | **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration | | **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) | @@ -633,7 +633,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object name (e.g. account) | -| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | +| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns, at least one: omit `fields` to read every column, because an empty list or a blank name is refused rather than read as every column. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | | **where** | `any` | optional | Filtering criteria (WHERE) | | **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration | | **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) | diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 59bd354a877..70883fe3ee9 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -899,7 +899,7 @@ Enable package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object name (e.g. account) | -| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | +| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns, at least one: omit `fields` to read every column, because an empty list or a blank name is refused rather than read as every column. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | | **where** | `[string, string, any] \| [string, string] \| [string, object, ...object[]] \| object[] \| Record \| any` | optional | Filtering criteria (WHERE) — a filter condition, or the input-only `FilterArray` sugar (`['status', '=', 'open']`), which is lowered through `parseFilterAST` before the query is produced. | | **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration | | **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) | diff --git a/content/docs/references/automation/builtin-node-config.mdx b/content/docs/references/automation/builtin-node-config.mdx index 09afddca6b3..3baea037e0c 100644 --- a/content/docs/references/automation/builtin-node-config.mdx +++ b/content/docs/references/automation/builtin-node-config.mdx @@ -232,7 +232,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr | :--- | :--- | :--- | :--- | | **objectName** | `string` | ✅ | Object to query | | **filter** | `Record` | optional | Field/value pairs to match: each operand — a field's value, an operator's comparand (`{"$ne": null}`), a list comparand's element — is a CEL value envelope `{ dialect: 'cel', source }` evaluated before the query (one that evaluates to null refuses the node), a query placeholder such as `{current_user_id}` or `{today}` the query engine resolves, or a literal; a flow `{…}` template token is refused | -| **fields** | `string[]` | optional | Field projection — only these fields are read (default: all) | +| **fields** | `string[]` | optional | Field projection — only these fields are read; omit it to read all. An empty list or a blank name is refused | | **limit** | `integer` | optional | Max records to return — a positive integer; >1 switches to a multi-record query | | **outputVariable** | `string` | optional | Flow variable the result is bound to — a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}` | diff --git a/content/docs/references/data/data-engine.mdx b/content/docs/references/data/data-engine.mdx index 3a6c3d7e689..88923ae3886 100644 --- a/content/docs/references/data/data-engine.mdx +++ b/content/docs/references/data/data-engine.mdx @@ -1185,7 +1185,7 @@ QueryAST-aligned query options for IDataEngine.find() operations | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object name (e.g. account) | -| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | +| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns, at least one: omit `fields` to read every column, because an empty list or a blank name is refused rather than read as every column. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | | **where** | `any` | optional | Filtering criteria (WHERE) | | **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration | | **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) | @@ -1324,7 +1324,7 @@ Transport spellings of the QueryAST slots — folded to canonical keys at the bo | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object name (e.g. account) | -| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | +| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns, at least one: omit `fields` to read every column, because an empty list or a blank name is refused rather than read as every column. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | | **where** | `any` | optional | Filtering criteria (WHERE) | | **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration | | **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) | @@ -1364,7 +1364,7 @@ Transport spellings of the QueryAST slots — folded to canonical keys at the bo | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object name (e.g. account) | -| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | +| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns, at least one: omit `fields` to read every column, because an empty list or a blank name is refused rather than read as every column. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | | **where** | `[string, string, any] \| [string, string] \| [string, object, ...object[]] \| object[] \| Record \| any` | optional | Filtering criteria (WHERE) — a filter condition, or the input-only `FilterArray` sugar (`['status', '=', 'open']`), which is lowered through `parseFilterAST` before the query is produced. | | **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration | | **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) | @@ -1433,7 +1433,7 @@ Transport spellings of the QueryAST slots — folded to canonical keys at the bo | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object name (e.g. account) | -| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | +| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns, at least one: omit `fields` to read every column, because an empty list or a blank name is refused rather than read as every column. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | | **where** | `any` | optional | Filtering criteria (WHERE) | | **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration | | **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) | @@ -1455,7 +1455,7 @@ Transport spellings of the QueryAST slots — folded to canonical keys at the bo | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object name (e.g. account) | -| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | +| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns, at least one: omit `fields` to read every column, because an empty list or a blank name is refused rather than read as every column. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | | **where** | `any` | optional | Filtering criteria (WHERE) | | **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration | | **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) | diff --git a/content/docs/references/data/query.mdx b/content/docs/references/data/query.mdx index ce28a4101b3..a7572560848 100644 --- a/content/docs/references/data/query.mdx +++ b/content/docs/references/data/query.mdx @@ -129,7 +129,7 @@ Type: `string` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object name (e.g. account) | -| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | +| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns, at least one: omit `fields` to read every column, because an empty list or a blank name is refused rather than read as every column. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | | **where** | `any` | optional | Filtering criteria (WHERE) | | **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration | | **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) | diff --git a/packages/rest/src/data-empty-projection-count-door.test.ts b/packages/rest/src/data-empty-projection-count-door.test.ts index 07e84a19a5e..c6e1265c615 100644 --- a/packages/rest/src/data-empty-projection-count-door.test.ts +++ b/packages/rest/src/data-empty-projection-count-door.test.ts @@ -13,7 +13,7 @@ * |:--|:--|:--| * | `GET ?$select=`, `?$select=%20`, `?$select=,`, `?select=`, `?fields=` | 200, every column | 400 naming the spelling | * | `GET ?$count=maybe`, `?$count=1`, `?count=` (with `$top`) | 200, `total` reported | 400 naming the spelling | - * | `POST {"select":""}`, `{"fields":[]}` | 200, every column | 400 naming the key (`select` at the body schema, `VALIDATION_FAILED`, since #26909) | + * | `POST {"select":""}`, `{"fields":[]}` | 200, every column | 400 naming the key at the body schema, `VALIDATION_FAILED` (`select` since #26909, `fields` since #27166) | * | `POST {"count":"maybe"}` | 400 `VALIDATION_FAILED` (the body schema) | unchanged | * | `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 | * | `GET ?$select=label`, `?$count=true` / `false`, absent; `GET /:id?select=label` | 200 | unchanged | @@ -57,11 +57,6 @@ const REFUSED_GET: ReadonlyArray, strin ['count= blank', { $top: '2', count: '' }, 'count', 'must be true or false'], ]; -/** name · the JSON body `POST /data/:object/query` receives · the key the refusal names. */ -const REFUSED_POST: ReadonlyArray, string]> = [ - ['fields: []', { fields: [] }, 'fields'], -]; - /** * [#26909] The body's published schema (`FindDataRequestSchema`) now declares * the projection the normalizer enforces, so these are refused where every @@ -74,6 +69,13 @@ const REFUSED_POST_AT_SCHEMA: ReadonlyArray { - const before = reads.n; - for (const [name, body, param] of REFUSED_POST) { - const res = await query(body); - expect(res.status, `${name}: ${JSON.stringify(res.body)}`).toBe(400); - expect(res.body.code, name).toBe('VALIDATION_ERROR'); - expect(res.body.error, name).toContain(`Query parameter '${param}' names no field`); - } - expect(reads.n - before, 'no read of the object — every refusal precedes the driver').toBe(0); - }); - - it('POST query: `select` / `$select` naming no field is the schema door\'s 400 VALIDATION_FAILED — no read', async () => { + it('POST query: `fields` / `select` / `$select` naming no field is the schema door\'s 400 VALIDATION_FAILED — no read', async () => { const before = reads.n; for (const [name, body, field] of REFUSED_POST_AT_SCHEMA) { const res = await query(body); diff --git a/packages/runtime/src/domains/data-empty-projection-count.test.ts b/packages/runtime/src/domains/data-empty-projection-count.test.ts index 330c2577c5b..d4e274341bf 100644 --- a/packages/runtime/src/domains/data-empty-projection-count.test.ts +++ b/packages/runtime/src/domains/data-empty-projection-count.test.ts @@ -112,7 +112,6 @@ describe('[#26644] the dispatcher /data doors — an empty projection and an unr ['GET list ?$select=, (the card)', 'GET', '/data/inquiry', { $select: ',' }, undefined, '$select', 'names no field'], ['GET list ?select= (the card)', 'GET', '/data/inquiry', { select: '' }, undefined, 'select', 'names no field'], ['GET list ?$count=maybe (the card)', 'GET', '/data/inquiry', { $top: '2', $count: 'maybe' }, undefined, '$count', 'must be true or false'], - ['POST query {"fields":[]}', 'POST', '/data/inquiry/query', {}, { fields: [] }, 'fields', 'names no field'], ['GET by id ?select=', 'GET', '/data/inquiry/r1', { select: '' }, undefined, 'select', 'names no field'], ['GET by id ?select=,', 'GET', '/data/inquiry/r1', { select: ',' }, undefined, 'select', 'names no field'], ])('%s: 400 VALIDATION_ERROR naming the spelling — no read', async (_name, method, path, query, body, param, sentence) => { @@ -131,12 +130,18 @@ describe('[#26644] the dispatcher /data doors — an empty projection and an unr // naming no field is refused where every other malformed body is: at the // schema, `400 VALIDATION_FAILED` naming `query.`, and the member for // a blank name (the #25269 precedent for the page window). Still a 400, - // still before any read. + // still before any read. [#27166] The canonical `fields` (top level and + // on an `expand` entry) is the QueryAST's own projection, which now takes + // the same array arm, so a body naming no field there is refused at the + // schema too, as its `$select` alias is. it.each<[string, Record, string]>([ ['{"select":""}', { select: '' }, 'query.select'], ['{"$select":","}', { $select: ',' }, 'query.$select'], ['{"select":[]}', { select: [] }, 'query.select'], ['{"select":["name",""]} (the blank member)', { select: ['name', ''] }, 'query.select.1'], + ['{"fields":[]}', { fields: [] }, 'query.fields'], + ['{"fields":["name",""]} (the blank member)', { fields: ['name', ''] }, 'query.fields.1'], + ['{"expand":{"owner":{"fields":[]}}}', { expand: { owner: { object: 'sys_user', fields: [] } } }, 'query.expand.owner.fields'], ])('POST query %s: 400 VALIDATION_FAILED naming the body key at the schema — no read', async (_name, body, field) => { const before = reads.n; diff --git a/packages/services/service-automation/src/builtin/builtin-node-form-zod-ledger.test.ts b/packages/services/service-automation/src/builtin/builtin-node-form-zod-ledger.test.ts index 903515f7ba4..8921e6d09f0 100644 --- a/packages/services/service-automation/src/builtin/builtin-node-form-zod-ledger.test.ts +++ b/packages/services/service-automation/src/builtin/builtin-node-form-zod-ledger.test.ts @@ -154,6 +154,22 @@ describe('CRUD / screen / map form ↔ Zod reconciliation (#4045)', () => { expect(parse(0), 'the contract refuses 0').toBe(false); }); + it('get_record.fields: the form\'s minItems is the contract\'s refusal boundary', () => { + // [#27166] The contract refuses an empty projection at every save door; a + // form that offers an empty list offers a value the save always refuses. + const fields = formSchema('get_record').properties?.fields as + | { type?: string; minItems?: number } + | undefined; + expect(fields?.type).toBe('array'); + expect(fields?.minItems).toBe(1); + + const parse = (value: string[]) => + (GetRecordConfigSchema as unknown as { safeParse(v: unknown): { success: boolean } }) + .safeParse({ objectName: 'account', fields: value }).success; + expect(parse(['name']), 'the contract accepts one name').toBe(true); + expect(parse([]), 'the contract refuses an empty list').toBe(false); + }); + describe('assignment: pinned as un-reconcilable, with the reason on record', () => { // `assignment` deliberately has NO entry in builtin-node-config.zod.ts: with // no `assignments` wrapper, the TOP-LEVEL config keys ARE the author's diff --git a/packages/services/service-automation/src/builtin/crud-nodes.ts b/packages/services/service-automation/src/builtin/crud-nodes.ts index 64d295900c7..66630adad9c 100644 --- a/packages/services/service-automation/src/builtin/crud-nodes.ts +++ b/packages/services/service-automation/src/builtin/crud-nodes.ts @@ -474,9 +474,12 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext): // Declared in #4045: the executor has always passed this // straight into find/findOne as the projection, but no // form offered it — authorable only by hand until now. + // `minItems: 1` mirrors the contract: an empty + // `GetRecordConfigSchema.fields` is refused at every + // flow save door (#27166), as `limit` below is. fields: { - type: 'array', items: { type: 'string' }, title: 'Fields', - description: 'Field projection — only these fields are read (default: all).', + type: 'array', items: { type: 'string' }, minItems: 1, title: 'Fields', + description: 'Field projection — only these fields are read; leave it out to read all. An empty list is refused.', }, // `minimum: 1` mirrors the contract: `GetRecordConfigSchema.limit` // is a positive integer, refused below 1 at every flow save diff --git a/packages/services/service-automation/src/builtin/get-record-id-projection.integration.test.ts b/packages/services/service-automation/src/builtin/get-record-id-projection.integration.test.ts index 70178ecc88a..410216c2758 100644 --- a/packages/services/service-automation/src/builtin/get-record-id-projection.integration.test.ts +++ b/packages/services/service-automation/src/builtin/get-record-id-projection.integration.test.ts @@ -15,8 +15,9 @@ * - a projection that already names `id` is handed over as written (no * second `id`); * - no projection stays a whole-row read (no projection is invented); - * - an empty projection is handed over as written, and the engine refuses it - * (#27008): the node fails with that refusal carried, never a whole-row read; + * - an empty projection is refused by the node's own config contract + * (`GetRecordConfigSchema.fields`, #27166), so the engine is never asked: + * the node fails before any read, never a whole-row read; * - the `find` branch (`limit > 1`) declares no top-level `id` and is not * touched. * @@ -161,18 +162,17 @@ describe('get_record answers the row id as output.id whatever its fields project expect(result.output).toEqual({ record: whole, id: rowId, object: OBJECT.name }); }); - it("empty fields: handed to the engine as written, and the node fails with the engine's refusal carried (#27008)", async () => { + it("empty fields: refused by the node's config contract before any read (#27166)", async () => { handed.length = 0; const result = await getRecord.execute( { id: 'read', type: 'get_record', config: { objectName: OBJECT.name, filter: { title: 'alpha' }, fields: [] } }, new Map(), { runAs: 'system', flowName: 'pin_get_record_id' }, ); - expect(handed, 'the node hands the empty projection over unchanged, once').toEqual([{ method: 'findOne', fields: [] }]); + expect(handed, 'the engine is never asked').toEqual([]); expect(result.success, 'an empty projection is refused, never read as the whole row').toBe(false); expect(result.output).toBeUndefined(); - expect(result.innerRefusal).toMatchObject({ code: 'VALIDATION_ERROR' }); - expect(result.error).toMatch(/^get_record\(pin_get_record_id\) failed: ObjectQL\.findOne\('pin_get_record_id'\) names no field in 'fields'/); + expect(result.error).toContain("get_record 'read': config does not satisfy the get_record contract — config.fields: Name at least one field."); }); it('a downstream update_record filtering on read.id updates the row the narrowed get_record read', async () => { diff --git a/packages/spec/src/automation/builtin-node-config.zod.ts b/packages/spec/src/automation/builtin-node-config.zod.ts index 4f9b45b9978..f878eaf3ca0 100644 --- a/packages/spec/src/automation/builtin-node-config.zod.ts +++ b/packages/spec/src/automation/builtin-node-config.zod.ts @@ -99,6 +99,7 @@ import { z } from 'zod'; import { EvaluatedExpressionSchema } from '../shared/expression.zod'; import { lazySchema } from '../shared/lazy-schema'; import { strictObject } from '../shared/strict-object'; +import { transportProjectionArray } from '../data/projection-names-a-field'; import { refuseCatchallProtoKey, refuseRecordProtoKey } from '../shared/record-proto-key-guard'; import { flowFilterOperands, isExpressionEnvelopeShaped } from './flow-node-expression-paths'; // [#19939] The retirement of the `{…}` template dialect from value slots — the @@ -622,9 +623,14 @@ export const GetRecordConfigSchema = lazySchema(() => strictObject({ objectName: z.string().describe('Object to query'), /** Field/value pairs to match; each operand an envelope, a query placeholder or a literal ({@link flowFilterSlotSchema}). */ filter: flowFilterSlotSchema(`Field/value pairs to match: ${FILTER_OPERAND_DESCRIPTION}`).optional(), - /** Field projection — only these fields are read (default: all). */ - fields: z.array(z.string()).optional() - .describe('Field projection — only these fields are read (default: all)'), + /** + * Field projection — only these fields are read; omit it to read all. At + * least one name, none blank: the executor hands it to `data.find` / + * `findOne` as written, and the engine refuses an empty projection there + * (#27166), so the list is refused at save instead of at run. + */ + fields: transportProjectionArray(z.string()).optional() + .describe('Field projection — only these fields are read; omit it to read all. An empty list or a blank name is refused'), /** * Max records — a positive integer; >1 returns a `records` list, otherwise a * single `record`. The executor hands it to `data.find` as the query's LIMIT diff --git a/packages/spec/src/data/data-engine.zod.ts b/packages/spec/src/data/data-engine.zod.ts index 028003d2bda..81651f409bf 100644 --- a/packages/spec/src/data/data-engine.zod.ts +++ b/packages/spec/src/data/data-engine.zod.ts @@ -5,6 +5,7 @@ import { FilterConditionSchema, FilterArraySchema, parseFilterAST } from './filt import type { FilterArray, FilterCondition } from './filter.zod'; import { SortNodeSchema, QuerySchema, FullTextSearchSchema, FieldNodeSchema, AggregationNodeSchema, GroupByNodeSchema, QUERY_CURSOR_REMOVED, QUERY_DISTINCT_REMOVED } from './query.zod'; import type { QueryAST, QueryInput } from './query.zod'; +import { PROJECTION_NAMES_NO_FIELD, transportProjectionArray } from './projection-names-a-field'; import { retiredKey } from '../shared/retired-key'; import { ExecutionContextSchema } from '../kernel/execution-context.zod'; @@ -102,11 +103,13 @@ export const BaseEngineOptionsSchema = lazySchema(() => z.object({ * too. It is declared ahead of both because * `RpcQueryOptionsSchema` extends `EngineQueryOptionsSchema` at module load, * which runs the lazy factory before any later `const` exists. + * + * [#27166] The array arm and its sentence live in + * `./projection-names-a-field.ts`, because `QuerySchema.fields` (and so every + * nested `expand` value) and the flow `get_record` node's `fields` take it too, + * and `./query.zod.ts` cannot import this module. */ -const PROJECTION_NAMES_NO_FIELD = 'Name at least one field'; const TransportProjectionStringSchema = z.string().regex(/[^\s,]/, PROJECTION_NAMES_NO_FIELD); -const transportProjectionArray = (member: T) => - z.array(member.regex(/\S/, 'A field name cannot be blank')).min(1, PROJECTION_NAMES_NO_FIELD); const PROJECTION_ACCEPT_SET = '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'; diff --git a/packages/spec/src/data/projection-names-a-field.test.ts b/packages/spec/src/data/projection-names-a-field.test.ts new file mode 100644 index 00000000000..c422bc8cc2b --- /dev/null +++ b/packages/spec/src/data/projection-names-a-field.test.ts @@ -0,0 +1,149 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27166] The QueryAST's own `fields` (and so every nested `expand` value) + * and the flow `get_record` node's `fields` take the projection's array arm + * the other published declarations take: at least one name, none blank. The + * in-process engine refuses an empty projection at its `find` / `findOne` + * entry, top level and at any `expand` depth (#27008), and the `get_record` + * executor hands its authored `fields` to that entry as written, so a + * declaration admitting `[]` admitted a shape the runtime refuses. + * + * Every refusal is pinned against the sentence `EngineQueryOptionsSchema.fields` + * gives for the same value, so the declarations cannot drift apart. + */ + +import { describe, it, expect } from 'vitest'; +import { QuerySchema } from './query.zod'; +import { EngineQueryOptionsSchema, QueryTransportParamsSchema, QueryWithTransportSchema } from './data-engine.zod'; +import { GetRecordConfigSchema } from '../automation/builtin-node-config.zod'; +import { flowNodeConfigRefusals } from '../automation/flow-node-config-refusals'; +import { defineStack } from '../stack.zod'; + +type Path = ReadonlyArray; + +const REFUSED: ReadonlyArray = [ + ['an empty list', [], []], + ['a list with a blank name', ['name', ''], [1]], + ['a list with a whitespace name', ['name', ' '], [1]], +]; +const KEPT: ReadonlyArray = [ + ['one name', ['name']], + ['two names', ['name', 'email']], +]; + +/** The first issue a schema raises for `input`, or a failed expectation when it parses. */ +function firstIssue(schema: { safeParse(v: unknown): { success: boolean; error?: { issues: Array<{ message: string; path: PropertyKey[] }> } } }, input: unknown) { + const parsed = schema.safeParse(input); + expect(parsed.success, `refuses ${JSON.stringify(input)}`).toBe(false); + return parsed.error!.issues[0]!; +} + +/** The sentence the engine-side declaration gives for the same projection value. */ +const engineMessage = (value: string[]) => firstIssue(EngineQueryOptionsSchema, { fields: value }).message; + +describe('QuerySchema.fields admits exactly what EngineQueryOptionsSchema.fields admits', () => { + it.each(REFUSED)('refuses %s at the top level, in the engine declaration\'s words', (_name, value, tail) => { + const issue = firstIssue(QuerySchema, { object: 'task', fields: value }); + expect(issue.path).toEqual(['fields', ...tail]); + expect(issue.message).toBe(engineMessage(value)); + }); + + it.each(REFUSED)('refuses %s on an expand entry, naming the entry', (_name, value, tail) => { + const issue = firstIssue(QuerySchema, { object: 'task', fields: ['title', 'project_id'], expand: { project_id: { object: 'project', fields: value } } }); + expect(issue.path).toEqual(['expand', 'project_id', 'fields', ...tail]); + expect(issue.message).toBe(engineMessage(value)); + }); + + it.each(REFUSED)('refuses %s on a second-level expand entry', (_name, value, tail) => { + const query = { + object: 'task', + expand: { project_id: { object: 'project', expand: { org_id: { object: 'org', fields: value } } } }, + }; + const issue = firstIssue(QuerySchema, query); + expect(issue.path).toEqual(['expand', 'project_id', 'expand', 'org_id', 'fields', ...tail]); + expect(issue.message).toBe(engineMessage(value)); + }); + + it.each(REFUSED)('EngineQueryOptionsSchema.expand refuses %s on an entry, as the engine entry does', (_name, value, tail) => { + const issue = firstIssue(EngineQueryOptionsSchema, { expand: { project_id: { object: 'project', fields: value } } }); + expect(issue.path).toEqual(['expand', 'project_id', 'fields', ...tail]); + expect(issue.message).toBe(engineMessage(value)); + }); + + it.each(REFUSED)('the transport-aware query slot refuses canonical `fields` %s as it refuses `$select`', (_name, value, tail) => { + const issue = firstIssue(QueryWithTransportSchema, { object: 'task', fields: value }); + expect(issue.path).toEqual(['fields', ...tail]); + expect(issue.message).toBe(firstIssue(QueryTransportParamsSchema, { $select: value }).message); + }); + + it.each(KEPT)('keeps %s unchanged, at the top level and on an expand entry', (_name, value) => { + const query = { object: 'task', fields: value, expand: { project_id: { object: 'project', fields: value } } }; + const parsed = QuerySchema.parse(query); + expect(parsed.fields).toEqual(value); + expect(parsed.expand?.project_id?.fields).toEqual(value); + }); + + it('keeps an absent projection: absent still reads every column', () => { + const parsed = QuerySchema.parse({ object: 'task', expand: { project_id: { object: 'project' } } }); + expect(parsed.fields).toBeUndefined(); + expect(parsed.expand?.project_id?.fields).toBeUndefined(); + }); +}); + +describe('GetRecordConfigSchema.fields: an empty projection is refused at save, not at run', () => { + const config = (fields: unknown) => ({ objectName: 'gr_task', filter: { title: 'alpha' }, fields }); + + it.each(REFUSED)('refuses %s, in the engine declaration\'s words', (_name, value, tail) => { + const issue = firstIssue(GetRecordConfigSchema, config(value)); + expect(issue.path).toEqual(['fields', ...tail]); + expect(issue.message).toBe(engineMessage(value)); + }); + + it.each(REFUSED)('the save door\'s node judge refuses %s at `fields`, naming the blank member', (_name, value, tail) => { + const at = `fields${tail.map((i) => `[${i}]`).join('')}`; + expect(flowNodeConfigRefusals('get_record', config(value)).map(({ code, params, path }) => ({ code, params, path }))).toEqual([ + { code: 'node-config-refused-by-contract', params: { nodeType: 'get_record', key: at }, path: at }, + ]); + }); + + it('defineStack refuses a get_record node with `fields: []` at its config path', () => { + const stack = { + manifest: { id: 'com.example.projection', name: 'projection', version: '1.0.0', type: 'app', namespace: 'gr' }, + objects: [{ name: 'gr_task', label: 'Task', fields: { title: { type: 'text', label: 'Title' } } }], + flows: [{ + name: 'gr_read', + label: 'Read', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'read', type: 'get_record', label: 'Read', config: config([]) }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'read' }, + { id: 'e2', source: 'read', target: 'end' }, + ], + }], + }; + let refusal: { code?: unknown; status?: unknown; issues?: Array<{ path: unknown[] }> } | undefined; + try { + defineStack(stack as never); + } catch (e) { + refusal = e as typeof refusal; + } + expect(refusal, 'defineStack must refuse the flow').toBeDefined(); + expect({ code: refusal!.code, status: refusal!.status }).toEqual({ code: 'STACK_SCHEMA_INVALID', status: 422 }); + expect(refusal!.issues!.map((i) => i.path.join('.'))).toEqual(['flows.0.nodes.1.config.fields']); + }); + + it.each(KEPT)('keeps %s unchanged, and the node judge finds nothing', (_name, value) => { + expect(GetRecordConfigSchema.parse(config(value)).fields).toEqual(value); + expect(flowNodeConfigRefusals('get_record', config(value))).toEqual([]); + }); + + it('keeps an absent projection: the node reads every field', () => { + expect(GetRecordConfigSchema.parse({ objectName: 'gr_task' }).fields).toBeUndefined(); + expect(flowNodeConfigRefusals('get_record', { objectName: 'gr_task' })).toEqual([]); + }); +}); diff --git a/packages/spec/src/data/projection-names-a-field.ts b/packages/spec/src/data/projection-names-a-field.ts new file mode 100644 index 00000000000..3b53a17958d --- /dev/null +++ b/packages/spec/src/data/projection-names-a-field.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; + +/** + * The array arm of the projection rule every published declaration of a + * read's projection shares: a list names at least one field, and no member is + * blank. Absent means "every column", so an empty projection read as absent is + * a widening; it is refused instead, in one sentence. + * + * Declared in its own module, outside the `data` barrel, because its readers + * sit on both sides of an import edge: `./query.zod.ts` (the QueryAST's own + * `fields`, and so every nested `expand` value), `./data-engine.zod.ts` (the + * transport and engine-side spellings, which import `./query.zod.ts`) and + * `../automation/builtin-node-config.zod.ts` (the flow `get_record` node's + * `fields`). The accept set and its history are documented beside the + * transport arms in `./data-engine.zod.ts`. + * + * [#27166] The QueryAST and `get_record` declarations joined once the + * in-process engine refused an empty projection at its `find` / `findOne` + * entry, top level and at any `expand` depth. + */ +export const PROJECTION_NAMES_NO_FIELD = 'Name at least one field'; + +export const transportProjectionArray = (member: T) => + z.array(member.regex(/\S/, 'A field name cannot be blank')).min(1, PROJECTION_NAMES_NO_FIELD); diff --git a/packages/spec/src/data/query.test.ts b/packages/spec/src/data/query.test.ts index 2ad02daa627..a87b9328e6a 100644 --- a/packages/spec/src/data/query.test.ts +++ b/packages/spec/src/data/query.test.ts @@ -840,10 +840,10 @@ describe('QuerySchema - Edge Cases and Null Handling', () => { it('should handle empty arrays', () => { // `joins: []` / `windowFunctions: []` moved to the retirement pins — the - // tombstones refuse even an empty array (#4286). + // tombstones refuse even an empty array (#4286). `fields: []` moved to + // `projection-names-a-field.test.ts`: an empty projection is refused (#27166). const query: QueryAST = { object: 'account', - fields: [], aggregations: [], groupBy: [], orderBy: [], diff --git a/packages/spec/src/data/query.zod.ts b/packages/spec/src/data/query.zod.ts index d3f37aa2d2c..fd0c6c81270 100644 --- a/packages/spec/src/data/query.zod.ts +++ b/packages/spec/src/data/query.zod.ts @@ -6,6 +6,7 @@ import { FilterConditionSchema } from './filter.zod'; import { lazySchema } from '../shared/lazy-schema'; import { retiredKey } from '../shared/retired-key'; import { strictObject } from '../shared/strict-object'; +import { transportProjectionArray } from './projection-names-a-field'; /** * QueryAST — Abstract Syntax Tree for data queries. @@ -516,7 +517,7 @@ const BaseQuerySchema = z.object({ object: z.string().describe('Object name (e.g. account)'), /** Select Clause */ - fields: z.array(FieldNodeSchema).optional().describe("Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes."), + fields: transportProjectionArray(FieldNodeSchema).optional().describe("Fields to retrieve — names of the queried object's OWN columns, at least one: omit `fields` to read every column, because an empty list or a blank name is refused rather than read as every column. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes."), /** Where Clause (Filtering) */ where: FilterConditionSchema.optional().describe('Filtering criteria (WHERE)'), diff --git a/packages/spec/src/migrations/entries/semantic/18.query-and-get-record-empty-projection-refused.ts b/packages/spec/src/migrations/entries/semantic/18.query-and-get-record-empty-projection-refused.ts new file mode 100644 index 00000000000..784a8147a42 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.query-and-get-record-empty-projection-refused.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The last two declarations of a read's projection that admitted an empty +// list. No D2 conversion: the only mechanical rewrite of `fields: []` is to +// drop the key, which reads every column, the widening the rule refuses. +export const entry: SemanticMigration = { + id: 'query-and-get-record-empty-projection-refused', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span already, and a nested backtick would close it. + surface: + 'QuerySchema.fields (the QueryAST projection, and so every nested expand entry, including ' + + 'EngineQueryOptionsSchema.expand and the canonical fields of a POST /data/:object/query body) and ' + + 'a flow get_record node\'s config.fields (GetRecordConfigSchema), authored as an EMPTY list or a ' + + 'list with a blank or whitespace-only name', + replacement: + 'name the fields to read (`fields: [\'title\', \'status\']`), or omit `fields` to read every ' + + 'field. On an `expand` entry the same holds for the related record: name its fields, or omit ' + + '`fields` on that entry. ⛔ There is no replacement that can be derived from what was written: ' + + 'an empty list names no field, and reading it as every field is exactly the widening the rule ' + + 'refuses, so the platform must not pick for the author.', + reason: + 'One rule for every published declaration of a read\'s projection: a projection names at least ' + + 'one field, none blank, and an empty one is refused rather than read as every column. Measured ' + + 'against the published @objectstack/spec 18.2.0: a get_record node with fields: [] saved, ' + + 'registered and ran reading every column of the row, and a POST /data/:object/query body with ' + + 'fields: [], top level or on an expand entry, answered 200 with every column. In the same release ' + + 'as this entry, the REST doors, the transport spellings (select, $select), the engine-side ' + + 'declarations (EngineQueryOptionsSchema.fields and its RPC and legacy siblings) and the ' + + 'in-process engine\'s find / findOne entry (top level and at any expand depth) refuse it. Two ' + + 'declarations would otherwise still admit it: the QueryAST\'s own fields, which is the value ' + + 'type of every expand entry, and the get_record node\'s fields, which the executor hands to the ' + + 'engine as written, so a get_record node with fields: [] would save clean and fail at its first ' + + 'run. Both now take the same array arm, with the same sentence ("Name at least one field"; "A ' + + 'field name cannot be blank" for a blank member). At the REST door a POST /data/:object/query ' + + 'body whose fields is empty, top level or on an expand entry, is refused by the body schema (400 ' + + 'VALIDATION_FAILED at query.fields or query.expand..fields), the answer its $select alias ' + + 'gets. Stored flows are not rewritten and there is no D2 conversion: the only mechanical rewrite ' + + 'is dropping the key, which reads every column. A stored flow carrying such a node is therefore ' + + 'refused by registerFlow on every pull of the stored definitions and stays unregistered, its ' + + 'triggers unbound, until the row is edited; os migrate meta --stored has no conversion to apply ' + + 'to it. ADR-0049 / ADR-0087.', + acceptanceCriteria: + 'Search flow definitions (metadata files and stored sys_metadata flow rows) for a get_record node ' + + 'whose config.fields is an empty list or carries a blank name, and every query you build in code ' + + '(engine.find / findOne options, an expand entry at any depth, a POST /data/:object/query body) ' + + 'for the same. Name the fields, or remove the key. For STORED flow rows the search is the ' + + 'catch-all: a stored flow carrying such a node is not registered at boot, and its triggers do ' + + 'not fire, until the row is edited. The sweep is mechanical for authored ' + + 'metadata: os validate and defineStack refuse such a node at flows.N.nodes.M.config.fields ' + + '(STACK_SCHEMA_INVALID), registerFlow refuses it when the flow is registered, and ' + + 'QuerySchema.safeParse / GetRecordConfigSchema.safeParse answer one issue at the fields path ' + + '(or fields.N for a blank member) reading "Name at least one field". An absent fields and a ' + + 'non-empty list parse unchanged. The repository carried no authored empty projection on either ' + + 'shape at the time of the change, outside the one test that pinned the old run-time failure.', +};