Repository navigation
Commit 385aa12
fix(spec,service-automation)!: QuerySchema.fields and the get_record node's fields refuse an empty projection (#27244)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} -->
_Requested by **Yibo Wang** · [project
thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB8Kh2HdiJzzy5dQUrvvTRJa)_
Fixes #27166
Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: none — `skills/**` teaches `fields` and nested `expand`
`fields` only as non-empty lists (`skills/objectstack-query/SKILL.md`:
the nested `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.
## 维护者速读
- **改了什么**:最后两份还接受空投影的公开声明——QueryAST 自身的 `QuerySchema.fields`(因而也是每一层
`expand` 条目的值类型)和流程 `get_record` 节点的
`fields`——改为与其余投影声明共用同一条规则:至少一个字段名,且不能为空白。报错文字同一处定义,不复制。
- **为什么改**:#27161 之后引擎在 `find` / `findOne` 入口(顶层与任意层
`expand`)拒绝空投影,而这两份声明仍写着「空也收」:`get_record` 节点写 `fields: []`
能保存,运行时才失败。现在保存时就被拒。
- **风险与回滚**:低。仓内没有任何非测试的作者写这两种空投影;不写 `fields`
仍读全部字段,非空列表不变,类型不变(`string[]`)。附带一处可见变化:`POST /data/:object/query` 请求体里
`fields: []` 由请求体 schema 拒绝(`400 VALIDATION_FAILED`,`query.fields`),与其别名
`$select: []` 的回答一致;此前由 normalizer 拒绝(`400
VALIDATION_ERROR`)。两者都是拒绝,只是错误码变了。回滚即 revert。
-
**先看**:`packages/spec/src/data/projection-names-a-field.ts`(共享规则移入的新模块)、`query.zod.ts`
与 `builtin-node-config.zod.ts`
各一行、`packages/spec/src/data/projection-names-a-field.test.ts`。
- **两分钟验证**:`pnpm --filter @objectstack/spec exec vitest run
src/data/projection-names-a-field.test.ts`,预期 28/28 通过;把 `query.zod.ts`
的 `fields` 改回 `z.array(FieldNodeSchema)` 时 15 条失败。
- **需要你做的**:无,席位落地。
## What changes
| Shape | `main` (9dacccf) | This head |
|---|---|---|
| `QuerySchema.parse({ object, fields: [] })` | parses | refused at
`fields`: "Name at least one field" |
| `QuerySchema` with `expand: { rel: { object, fields: [] } }`, any
depth | parses | refused at `expand.rel.fields` (or deeper), same words
|
| `EngineQueryOptionsSchema` with an `expand` entry's `fields: []` |
parses | refused at `expand.rel.fields`, same words |
| `fields: ['name', '']` / `['name', ' ']` on any of the above | parses
| refused at `fields.1`: "A field name cannot be blank" |
| `QueryWithTransportSchema` canonical `fields: []` | parses (its
`$select: []` alias was already refused) | refused at `fields`, the same
message `$select: []` gets |
| `POST /data/:object/query` body `{ "fields": [] }` | `400
VALIDATION_ERROR` from the normalizer (on 18.2.0: `200`, every column) |
`400 VALIDATION_FAILED` at `query.fields` from the body schema, as
`$select` / `select` |
| `POST /data/:object/query` body `{ "expand": { "owner": { "object":
"sys_user", "fields": [] } } }` | `400 VALIDATION_ERROR` from the engine
door (on 18.2.0: `200`, every column of the related record) | `400
VALIDATION_FAILED` at `query.expand.owner.fields` from the body schema |
| `GetRecordConfigSchema` `fields: []` | parses | refused at `fields`,
same words |
| a flow `get_record` node with `fields: []` | saves; run fails with the
engine's refusal | `defineStack` refuses it with `422
STACK_SCHEMA_INVALID` at `flows.0.nodes.1.config.fields`; the save
door's node judge (`flowNodeConfigRefusals`) refuses it at `fields`; the
executor refuses the config before any read |
| `fields` absent, or a non-empty list | parses | unchanged |
- **One declaration, not a copy.** `PROJECTION_NAMES_NO_FIELD` and
`transportProjectionArray` move from `data-engine.zod.ts` into the new
`packages/spec/src/data/projection-names-a-field.ts`. They had to move:
`data-engine.zod.ts` imports `query.zod.ts`, so `query.zod.ts` could not
read them where they were. The module stays outside the `data` barrel,
so no public export is added (as with `filter-save-door-refusals.ts`).
`data-engine.zod.ts` imports both names and keeps its string arm and its
docblock; the docblock gains one paragraph that says where the array arm
now lives and why.
- `BaseQuerySchema.fields` becomes
`transportProjectionArray(FieldNodeSchema)`, the construction
`EngineQueryOptionsSchema.fields` and
`QueryTransportParamsSchema.$select` already use. `QuerySchema` is one
declaration, so this also covers its top level (the driver tuple inputs,
the API contract input, the canonical `fields` of
`QueryWithTransportSchema`) and every nested `expand` value. Seat 1
ruled for narrowing the base declaration rather than an expand-only copy
of the recursive schema.
- `GetRecordConfigSchema.fields` becomes
`transportProjectionArray(z.string())`.
- Both `.describe()` texts now say that an empty list or a blank name is
refused and that omitting `fields` reads every field. The five reference
pages that render them are regenerated (`gen:docs`).
- `@objectstack/service-automation`: the `get_record` designer form
declares `fields` with `minItems: 1`, the way its `limit` mirrors the
contract's floor, so the form no longer offers an empty list the save
refuses. `builtin-node-form-zod-ledger.test.ts` pins both sides at that
boundary, beside the `limit` pin.
- ADR-0087: `registered query-and-get-record-empty-projection-refused`,
a new step-18 D3 semantic entry. There is no D2 conversion: the only
mechanical rewrite of `fields: []` is to drop the key, which reads every
column, and that is the widening the rule refuses. Stored flows are not
rewritten.
- Changeset `.changeset/27166-query-get-record-empty-projection.md`:
`@objectstack/spec` minor and `@objectstack/service-automation` minor
(`Metadata-protocol: narrowed` takes `minor` on each package whose
`src/**` the diff moves), with both declaration lines, the
`Skills-docs:` line, the ADR-0087 marker and a BREAKING banner with
Before / Now / Unchanged and the remedy.
- `content/docs/data-modeling/queries.mdx`: the empty-projection
sentence names each refusal point (`4dbe7883`). A POST body is refused
by the schema (`VALIDATION_FAILED` at `query.fields`); the querystring
forms (`?fields=`, `$select=`) keep `VALIDATION_ERROR`; `engine.find`
in-process refuses it; a `get_record` node is refused when the flow is
saved.
- **The changeset is anchored to `@objectstack/spec@18.2.0`.** Its
**Before** now describes the last release: there, a `get_record` node
with `fields: []` saved, registered and ran, reading every column, and a
REST POST with `fields: []` answered `200`. #26644 and #27008 are in the
same unreleased batch. The changeset also names the stored-flow
consequence: such a flow is refused by `registerFlow` on every pull, and
it stays unregistered with its triggers unbound until the row is edited.
`os migrate meta --stored` has no conversion to apply to it. It also
carries a **Supersedes, in the same release:** paragraph for the
`26954-engine-projection-empty.md` sentence that says
`engine.find(object, { fields: [] })` still reads every column, which
#27008 made false; the precedent is
`25923-calendar-gantt-flat-fallback-retired.md`. The D3 entry's `reason`
and `acceptanceCriteria` say the same.
- `data-empty-projection-count-door.test.ts` gains the `expand` row: the
test checks for `400 VALIDATION_FAILED` and `fields[0].field =
query.expand.owner.fields`, and that the object was never read.
## Why
#26954 (PR #27007) made the engine-side projection declarations refuse
`[]`. #27008 (PR #27161) made the in-process engine refuse it at the
`find` / `findOne` entry, top level and on every `expand` entry. Its
acceptance notes reported two declarations still admitting it: the
QueryAST's `fields`, which is the value type of
`EngineQueryOptionsSchema.expand`, and `GetRecordConfigSchema.fields`,
which the `get_record` executor hands to `data.find` / `findOne` as
written. So the runtime refused shapes these declarations admitted, and
a `get_record` node with `fields: []` was caught at run instead of at
save. Triage 6466002857 set the lane to `domain:spec`.
## Readers and producers (measured on `origin/main` 9dacccf)
- **Who parses these shapes at run time.**
- The `get_record` executor parses its config with
`GetRecordConfigSchema` (`crud-nodes.ts`, `parseNodeConfig`).
- `defineStack` / `os validate` and `registerFlow` judge every builtin
node config through `flowNodeConfigRefusals`.
- The REST body schema (`FindDataRequestSchema.query` =
`QueryWithTransportSchema`) extends `QuerySchema`'s shape, so its
canonical `fields` narrows too.
- No code outside `packages/spec` parses `QuerySchema` itself, nor the
driver tuple inputs.
- **Census of authored empty projections on the two shapes.** I ran `git
grep` over `packages`, `ui`, `examples`, `apps` and `skills`.
- One hit: `get-record-id-projection.integration.test.ts:167`, the
#27161 test that pinned the run-time refusal. It now pins the config
refusal: the engine is never asked, and the node fails with
`config.fields: Name at least one field.`
- Two other tests pinned the old acceptance and are rewritten.
`query.test.ts` ("should handle empty arrays") drops `fields: []` and
points at the new file. `data-empty-projection-count-door.test.ts` moves
its `fields: []` row from the normalizer table to the body-schema table
and adds a blank-member row.
- No `examples/**` or non-test source authors either shape empty.
- **The designer UI.** `ui/` offers no `fields` input for `get_record`
(`flow-node-config.ts` has `objectName`, `filter`, `limit`,
`outputVariable`), so no `ui/` change is needed.
- **Types.** `z.input` / `z.infer` stay `string[]`. `typecheck` exits 0
for spec, service-automation and rest.
- **JSON Schema.** `fields` gains `minItems: 1` and a non-blank
`pattern` on each item. `check:generated` reported only the reference
pages stale, and they are regenerated.
## Verification
All at head `939accc9` unless a line says otherwise.
**Round 2, on the merged tree (`a1b4b045`, merge `295699d5`)**, all exit
0: `check:generated`; spec `src/data` + `src/automation` +
`src/migrations` (189 files, 6392 passed); spec typecheck;
service-automation `src/builtin` (82 files, 1154 passed); the three rest
files (120 passed); `check-adr-0087-registration`,
`check-changeset-no-major`, `check-empty-changeset`.
<details><summary>New pins and ablation</summary>
`packages/spec/src/data/projection-names-a-field.test.ts` has 28 cases:
- `QuerySchema` refuses `[]`, `['name', '']` and `['name', ' ']` at the
top level, on an `expand` entry and on a second-level `expand` entry.
Each refusal asserts the path and the message
`EngineQueryOptionsSchema.fields` gives for the same value.
- `EngineQueryOptionsSchema.expand` refuses the same values on an entry.
- `QueryWithTransportSchema`'s canonical `fields` refuses them with
`$select`'s message.
- `GetRecordConfigSchema` refuses them. The save door's node judge
refuses them at `fields` / `fields[1]`. `defineStack` refuses a flow
whose `get_record` node carries `fields: []` with `422
STACK_SCHEMA_INVALID` at `flows.0.nodes.1.config.fields`.
- Controls: one or two names parse unchanged, and absent `fields` parses
unchanged, on every shape.
Ablation after commit, through `scripts/ablation-replace.mjs` (anchor
hit 1 → 0; restored to the HEAD blob with `git diff HEAD` empty):
- `query.zod.ts` `fields` reverted to `z.array(FieldNodeSchema)`: 15
failed / 13 passed. These are the five `QuerySchema` / `expand` /
transport groups.
- `builtin-node-config.zod.ts` `fields` reverted to
`z.array(z.string())`: 7 failed / 21 passed. These are the three
contract refusals, the three node-judge refusals and the `defineStack`
case.
</details>
<details><summary>Local runs</summary>
- `pnpm --filter @objectstack/spec build && check:generated`: only
`content/docs/references/**` was stale. `gen:docs` was run and the
result committed.
- `@objectstack/spec` full suite, at `bd35a3fa` under the lock: 823
files, 23598 passed, 1 failed (`query.test.ts` "should handle empty
arrays", the old acceptance pin, rewritten in the next commit; that file
is then 91/91). `typecheck`: exit 0.
- `@objectstack/service-automation` build, suite and typecheck, at
`bd35a3fa`: 216 files, 2756 passed, 1 failed (the rewritten get_record
pin, whose expected path read `fields:` where the executor prints
`config.fields:`; fixed in the next commit, and that file plus the form
ledger are then 17/17). `typecheck`: exit 0.
- `@objectstack/rest` suite: 338 files passed, 6835 tests passed, 344
skipped.
- `@objectstack/metadata-protocol` suite: 277 files passed, 4 skipped;
21750 tests passed, 26 skipped.
- Full dogfood, owed by the `(narrowing)` arm, after the
`@objectstack/dogfood^...` closure build: `pnpm --filter
@objectstack/dogfood test` under the lock, no shard, no path: Test Files
348 passed | 3 skipped (351); Tests 2804 passed | 20 skipped (2824);
exit 0.
</details>
<details><summary>Derived gates</summary>
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 109 commands, all run serially at
this head. 107 exited 0 on the first pass, including
`check-adr-0087-registration --base origin/main` (`registered
query-and-get-record-empty-projection-refused (new here)`),
`check-changeset-no-major --base origin/main` and `spec check:generated`
(14/14 up to date). Two exited 3 because their prerequisite was not met:
`spec check:skill-examples` and `check:dual-build-cjs-loads`. They read
built output. After I built the seven packages whose `dist` they named,
both exited 0. `--ran` reconciliation: "109 derived famil(ies) accounted
for — 107 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)", and
those two then ran green. `check-plugin-teardown-shape --self-test` ran
after fetching its pinned fixture commit. Also `eslint
--no-inline-config` on the 11 changed `.ts` files: exit 0.
`@objectstack/rest` `typecheck`: exit 0.
</details>
## Acceptance notes
- **The REST code change is deliberate and is the ruling's
consequence.** A `POST /data/:object/query` body with `fields: []` was
refused before and is refused now. The code moves from
`VALIDATION_ERROR` (normalizer) to `VALIDATION_FAILED` (body schema),
and the refusal now names `query.fields`, as it already did for `select`
/ `$select`. The querystring spellings (`?fields=`, `?$select=`) are
unchanged: they go through the normalizer as before.
- **The in-process engine already refused these shapes** (#27161). This
diff changes no runtime source except the designer form's `minItems`.
- **`main` was merged (merge commit, no rebase) at `295699d5`.** Main
had moved `content/docs/references/api/protocol.mdx` and
`packages/spec/src/api/protocol.zod.ts`, in hunks that do not overlap
this diff. `protocol.mdx` is a generated artifact, so it was regenerated
on the merged tree (`gen:schema && gen:docs`) in its own commit, not
text-merged. After the merge, the PR's delta against `main` is 19 files:
the 18 above, plus
`packages/runtime/src/domains/data-empty-projection-count.test.ts`
(`c483a00e`). The runtime dispatcher's door moves the same way as REST.
Its POST body `{ "fields": [] }` row moves from the normalizer table to
the file's schema table and expects `VALIDATION_FAILED` at
`query.fields`, next to the blank-member and `expand` rows, mirroring
the rest pin. That row had turned Test Core 3/8 red on `4dbe7883`.
- **Stored flows are not rewritten.** A stored flow whose `get_record`
node carries `fields: []` already failed at run on `main`. It now also
meets the refusal where a flow is registered or saved. The D3 entry
tells authors to sweep for it.
## 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.
`eslint --no-inline-config` over every changed `.ts` file exits 0; the
repo-wide `pnpm lint` is CI's.)
- [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.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/` (the reference
pages are `gen:docs` output).
- [x] 8. Title, body and comments are in English.
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01WGufzfMG4xAptDoRoz9kyT)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 3beb224 commit 385aa12
19 files changed
Lines changed: 335 additions & 51 deletions
File tree
- .changeset
- content/docs
- data-modeling
- references
- api
- automation
- data
- packages
- rest/src
- runtime/src/domains
- services/service-automation/src/builtin
- spec/src
- automation
- data
- migrations/entries/semantic
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
296 | 296 | | |
297 | 297 | | |
298 | 298 | | |
299 | | - | |
300 | | - | |
301 | | - | |
302 | | - | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
303 | 306 | | |
304 | 307 | | |
305 | 308 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
580 | 580 | | |
581 | 581 | | |
582 | 582 | | |
583 | | - | |
| 583 | + | |
584 | 584 | | |
585 | 585 | | |
586 | 586 | | |
| |||
633 | 633 | | |
634 | 634 | | |
635 | 635 | | |
636 | | - | |
| 636 | + | |
637 | 637 | | |
638 | 638 | | |
639 | 639 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
899 | 899 | | |
900 | 900 | | |
901 | 901 | | |
902 | | - | |
| 902 | + | |
903 | 903 | | |
904 | 904 | | |
905 | 905 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
232 | 232 | | |
233 | 233 | | |
234 | 234 | | |
235 | | - | |
| 235 | + | |
236 | 236 | | |
237 | 237 | | |
238 | 238 | | |
| |||
0 commit comments