Repository navigation
fix(spec,service-automation)!: QuerySchema.fields and the get_record node's fields refuse an empty projection - #27244
Conversation
… an empty projection Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzfMG4xAptDoRoz9kyT
…node's fields refuse an empty projection The QueryAST's own fields (and so every nested expand entry) and the flow get_record node's fields take the array arm every other published projection declaration takes: at least one name, none blank. The arm and its sentence move to a non-barrel module both sides of the query.zod import edge can read. Adds the D3 semantic entry, the designer form's minItems, and the changeset; regenerates the reference pages. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzfMG4xAptDoRoz9kyT
…pty projection Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzfMG4xAptDoRoz9kyT
…or prints it Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzfMG4xAptDoRoz9kyT
📓 Docs Drift CheckThis PR changes 2 package(s): 12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 148 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6a4a9fb460d50aab6a245a27a3810874adc631e9 && git checkout 6a4a9fb460d50aab6a245a27a3810874adc631e9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9ff8154cf694c616de5d467b35af21e037de1509 c483a00ecc316adc468f61952a558554e84c8efb && git checkout -B drift-repro 9ff8154cf694c616de5d467b35af21e037de1509 && git merge --no-ff c483a00ecc316adc468f61952a558554e84c8efb
node scripts/docs-audit/affected-docs.mjs --json 9ff8154cf694c616de5d467b35af21e037de1509
|
…s refused Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzfMG4xAptDoRoz9kyT
Contract reviewServed-tier: ① Derived judgmentsEach accept-set or public-surface change the diff implies, judged against the live tree.
② Semver level
③ Boundary flags
④ Design judgmentDoes the narrowing take a declared capability away at the consumer? No. On Does the REST body refusal match the Is "no D2" honest for stored flows whose Tried to break it:
Findings:
No must-fix. The narrowing holds against the engine door, the transport doors, the flow doors and the stored-flow seam, every in-repo producer, and the published spec on Implemented-by: VERDICT: PASS |
…me the stored-flow consequence and the superseded 26954 sentence Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzfMG4xAptDoRoz9kyT
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzfMG4xAptDoRoz9kyT
…the schema The canonical `fields` of a POST /data/:object/query body, top level and on an `expand` entry, is now the QueryAST's own projection, which takes the shared array arm, so the dispatcher answers `400 VALIDATION_FAILED` at `query.fields` (as its `$select` alias does) instead of the normalizer's `VALIDATION_ERROR`. The row moves to the schema table, with the blank member and the `expand` entry beside it, as the REST door's pin does. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzfMG4xAptDoRoz9kyT
席位核对 · ACCEPT ·
|
Requested by Yibo Wang · project thread
Fixes #27166
Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: none —
skills/**teachesfieldsand nestedexpandfieldsonly as non-empty lists (skills/objectstack-query/SKILL.md: the nestedexpandvalue row and the nested-expand example), and teaches theget_recordnode withoutfields(skills/objectstack-automation/SKILL.md); no file teaches an empty projection, and every taught use stays true.维护者速读
QuerySchema.fields(因而也是每一层expand条目的值类型)和流程get_record节点的fields——改为与其余投影声明共用同一条规则:至少一个字段名,且不能为空白。报错文字同一处定义,不复制。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
main(9dacccf)QuerySchema.parse({ object, fields: [] })fields: "Name at least one field"QuerySchemawithexpand: { rel: { object, fields: [] } }, any depthexpand.rel.fields(or deeper), same wordsEngineQueryOptionsSchemawith anexpandentry'sfields: []expand.rel.fields, same wordsfields: ['name', '']/['name', ' ']on any of the abovefields.1: "A field name cannot be blank"QueryWithTransportSchemacanonicalfields: []$select: []alias was already refused)fields, the same message$select: []getsPOST /data/:object/querybody{ "fields": [] }400 VALIDATION_ERRORfrom the normalizer (on 18.2.0:200, every column)400 VALIDATION_FAILEDatquery.fieldsfrom the body schema, as$select/selectPOST /data/:object/querybody{ "expand": { "owner": { "object": "sys_user", "fields": [] } } }400 VALIDATION_ERRORfrom the engine door (on 18.2.0:200, every column of the related record)400 VALIDATION_FAILEDatquery.expand.owner.fieldsfrom the body schemaGetRecordConfigSchemafields: []fields, same wordsget_recordnode withfields: []defineStackrefuses it with422 STACK_SCHEMA_INVALIDatflows.0.nodes.1.config.fields; the save door's node judge (flowNodeConfigRefusals) refuses it atfields; the executor refuses the config before any readfieldsabsent, or a non-empty listPROJECTION_NAMES_NO_FIELDandtransportProjectionArraymove fromdata-engine.zod.tsinto the newpackages/spec/src/data/projection-names-a-field.ts. They had to move:data-engine.zod.tsimportsquery.zod.ts, soquery.zod.tscould not read them where they were. The module stays outside thedatabarrel, so no public export is added (as withfilter-save-door-refusals.ts).data-engine.zod.tsimports 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.fieldsbecomestransportProjectionArray(FieldNodeSchema), the constructionEngineQueryOptionsSchema.fieldsandQueryTransportParamsSchema.$selectalready use.QuerySchemais one declaration, so this also covers its top level (the driver tuple inputs, the API contract input, the canonicalfieldsofQueryWithTransportSchema) and every nestedexpandvalue. Seat 1 ruled for narrowing the base declaration rather than an expand-only copy of the recursive schema.GetRecordConfigSchema.fieldsbecomestransportProjectionArray(z.string())..describe()texts now say that an empty list or a blank name is refused and that omittingfieldsreads every field. The five reference pages that render them are regenerated (gen:docs).@objectstack/service-automation: theget_recorddesigner form declaresfieldswithminItems: 1, the way itslimitmirrors the contract's floor, so the form no longer offers an empty list the save refuses.builtin-node-form-zod-ledger.test.tspins both sides at that boundary, beside thelimitpin.registered query-and-get-record-empty-projection-refused, a new step-18 D3 semantic entry. There is no D2 conversion: the only mechanical rewrite offields: []is to drop the key, which reads every column, and that is the widening the rule refuses. Stored flows are not rewritten..changeset/27166-query-get-record-empty-projection.md:@objectstack/specminor and@objectstack/service-automationminor (Metadata-protocol: narrowedtakesminoron each package whosesrc/**the diff moves), with both declaration lines, theSkills-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_FAILEDatquery.fields); the querystring forms (?fields=,$select=) keepVALIDATION_ERROR;engine.findin-process refuses it; aget_recordnode is refused when the flow is saved.@objectstack/spec@18.2.0. Its Before now describes the last release: there, aget_recordnode withfields: []saved, registered and ran, reading every column, and a REST POST withfields: []answered200. data list door: a blank $select answers every column and an unrecognised $count value counts — malformed values are read as the absent default #26644 and objectql: the in-process engine readsfields: []as every column while every published projection declaration refuses an empty projection (found on #26954) #27008 are in the same unreleased batch. The changeset also names the stored-flow consequence: such a flow is refused byregisterFlowon every pull, and it stays unregistered with its triggers unbound until the row is edited.os migrate meta --storedhas no conversion to apply to it. It also carries a Supersedes, in the same release: paragraph for the26954-engine-projection-empty.mdsentence that saysengine.find(object, { fields: [] })still reads every column, which objectql: the in-process engine readsfields: []as every column while every published projection declaration refuses an empty projection (found on #26954) #27008 made false; the precedent is25923-calendar-gantt-flat-fallback-retired.md. The D3 entry'sreasonandacceptanceCriteriasay the same.data-empty-projection-count-door.test.tsgains theexpandrow: the test checks for400 VALIDATION_FAILEDandfields[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 thefind/findOneentry, top level and on everyexpandentry. Its acceptance notes reported two declarations still admitting it: the QueryAST'sfields, which is the value type ofEngineQueryOptionsSchema.expand, andGetRecordConfigSchema.fields, which theget_recordexecutor hands todata.find/findOneas written. So the runtime refused shapes these declarations admitted, and aget_recordnode withfields: []was caught at run instead of at save. Triage 6466002857 set the lane todomain:spec.Readers and producers (measured on
origin/main9dacccf)get_recordexecutor parses its config withGetRecordConfigSchema(crud-nodes.ts,parseNodeConfig).defineStack/os validateandregisterFlowjudge every builtin node config throughflowNodeConfigRefusals.FindDataRequestSchema.query=QueryWithTransportSchema) extendsQuerySchema's shape, so its canonicalfieldsnarrows too.packages/specparsesQuerySchemaitself, nor the driver tuple inputs.git grepoverpackages,ui,examples,appsandskills.get-record-id-projection.integration.test.ts:167, the fix(objectql)!: the in-process engine refuses an empty projection, as every published declaration does #27161 test that pinned the run-time refusal. It now pins the config refusal: the engine is never asked, and the node fails withconfig.fields: Name at least one field.query.test.ts("should handle empty arrays") dropsfields: []and points at the new file.data-empty-projection-count-door.test.tsmoves itsfields: []row from the normalizer table to the body-schema table and adds a blank-member row.examples/**or non-test source authors either shape empty.ui/offers nofieldsinput forget_record(flow-node-config.tshasobjectName,filter,limit,outputVariable), so noui/change is needed.z.input/z.inferstaystring[].typecheckexits 0 for spec, service-automation and rest.fieldsgainsminItems: 1and a non-blankpatternon each item.check:generatedreported only the reference pages stale, and they are regenerated.Verification
All at head
939accc9unless a line says otherwise.Round 2, on the merged tree (
a1b4b045, merge295699d5), all exit 0:check:generated; specsrc/data+src/automation+src/migrations(189 files, 6392 passed); spec typecheck; service-automationsrc/builtin(82 files, 1154 passed); the three rest files (120 passed);check-adr-0087-registration,check-changeset-no-major,check-empty-changeset.New pins and ablation
packages/spec/src/data/projection-names-a-field.test.tshas 28 cases:QuerySchemarefuses[],['name', '']and['name', ' ']at the top level, on anexpandentry and on a second-levelexpandentry. Each refusal asserts the path and the messageEngineQueryOptionsSchema.fieldsgives for the same value.EngineQueryOptionsSchema.expandrefuses the same values on an entry.QueryWithTransportSchema's canonicalfieldsrefuses them with$select's message.GetRecordConfigSchemarefuses them. The save door's node judge refuses them atfields/fields[1].defineStackrefuses a flow whoseget_recordnode carriesfields: []with422 STACK_SCHEMA_INVALIDatflows.0.nodes.1.config.fields.fieldsparses unchanged, on every shape.Ablation after commit, through
scripts/ablation-replace.mjs(anchor hit 1 → 0; restored to the HEAD blob withgit diff HEADempty):query.zod.tsfieldsreverted toz.array(FieldNodeSchema): 15 failed / 13 passed. These are the fiveQuerySchema/expand/ transport groups.builtin-node-config.zod.tsfieldsreverted toz.array(z.string()): 7 failed / 21 passed. These are the three contract refusals, the three node-judge refusals and thedefineStackcase.Local runs
pnpm --filter @objectstack/spec build && check:generated: onlycontent/docs/references/**was stale.gen:docswas run and the result committed.@objectstack/specfull suite, atbd35a3faunder 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-automationbuild, suite and typecheck, atbd35a3fa: 216 files, 2756 passed, 1 failed (the rewritten get_record pin, whose expected path readfields:where the executor printsconfig.fields:; fixed in the next commit, and that file plus the form ledger are then 17/17).typecheck: exit 0.@objectstack/restsuite: 338 files passed, 6835 tests passed, 344 skipped.@objectstack/metadata-protocolsuite: 277 files passed, 4 skipped; 21750 tests passed, 26 skipped.(narrowing)arm, after the@objectstack/dogfood^...closure build:pnpm --filter @objectstack/dogfood testunder the lock, no shard, no path: Test Files 348 passed | 3 skipped (351); Tests 2804 passed | 20 skipped (2824); exit 0.Derived gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 109 commands, all run serially at this head. 107 exited 0 on the first pass, includingcheck-adr-0087-registration --base origin/main(registered query-and-get-record-empty-projection-refused (new here)),check-changeset-no-major --base origin/mainandspec check:generated(14/14 up to date). Two exited 3 because their prerequisite was not met:spec check:skill-examplesandcheck:dual-build-cjs-loads. They read built output. After I built the seven packages whosedistthey named, both exited 0.--ranreconciliation: "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-testran after fetching its pinned fixture commit. Alsoeslint --no-inline-configon the 11 changed.tsfiles: exit 0.@objectstack/resttypecheck: exit 0.Acceptance notes
POST /data/:object/querybody withfields: []was refused before and is refused now. The code moves fromVALIDATION_ERROR(normalizer) toVALIDATION_FAILED(body schema), and the refusal now namesquery.fields, as it already did forselect/$select. The querystring spellings (?fields=,?$select=) are unchanged: they go through the normalizer as before.minItems.mainwas merged (merge commit, no rebase) at295699d5. Main had movedcontent/docs/references/api/protocol.mdxandpackages/spec/src/api/protocol.zod.ts, in hunks that do not overlap this diff.protocol.mdxis 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 againstmainis 19 files: the 18 above, pluspackages/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 expectsVALIDATION_FAILEDatquery.fields, next to the blank-member andexpandrows, mirroring the rest pin. That row had turned Test Core 3/8 red on4dbe7883.get_recordnode carriesfields: []already failed at run onmain. 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,
which links the AGENTS.md text that states it.
packages/specholds no runtime logic.pnpm lintpasses. (No formatter ran.eslint --no-inline-configover every changed.tsfile exits 0; the repo-widepnpm lintis CI's.)main, one pull request.packages/spectouched: its generated artifacts are regenerated and committed.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/(the reference pages aregen:docsoutput).Generated by Claude Code