Skip to content

Commit fbb8bec

Browse files
fix(spec): a count roll-up saves without summaryOperations.field (#27533)
domain:spec#3 · os-support-ai · session_01WGufzgbJhFoXYsLSe5WSPP <!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB6Ss4nCDVbpzX9zFKKXkNyU)_ Fixes #27462 Clause-②: yes (widening) Metadata-protocol: added Skills-docs: updated here. `skills/objectstack-data/rules/field-types.md:125` spelled the `summary` config row `({ object, field, function, relationshipField?, filter? })`, teaching `field` as always required; it now reads `` ({ object, field? (omit for `count`), function, relationshipField?, filter? }) ``. The words are paid for in the same file: its `sum` example (:259) drops the comment "(ignored for count)", which the row now states. `skills/objectstack-data/rules/relationships.md:201` is a `sum` example whose comment stays true; not edited. **Seat ruling.** The spec follows its own describe. `summaryOperations.field` becomes optional when `function` is `count` and stays required for every other function, with the refusal for a missing `field` on a non-count function naming the function and the fix. Not "Studio fills an arbitrary field": that would store a value the engine ignores. ## 维护者速读 - 改了什么:汇总字段(roll-up)用 `count` 时不再要求 `summaryOperations.field`;`sum`/`min`/`max`/`avg` 缺 `field` 仍被拒,报错指明函数和改法。Studio 汇总编辑器在 `count` 时不再显示「Child field to aggregate」。 - 为什么改:规范自己的说明写着 count 忽略该字段,但校验却要求它,Studio 里存 count 汇总会 422,作者只能随便选一个子字段。 - 风险与回滚:放宽接受集,原来能存的定义照旧能存(count 带 field 仍接受、仍忽略);引擎本来就不为 count 读字段。回滚即还原本 PR。 - 先看:`skills/objectstack-data/rules/field-types.md`(:125 与 :259 两行),`packages/spec/src/data/field.zod.ts`(`summaryFunctionReadsField` 与 `refuseSummaryWithoutField`),`ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx`(`SummaryConfigFields` 的条件渲染)。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run src/data/field.test.ts -t "summaryOperations"`,应见 count 无 field 通过、sum/min/max/avg 无 field 各被拒共 5 个用例全绿。 - 需要你做的:席位 ACCEPT 后批准本 PR:它改了受管的 `skills/objectstack-data/rules/field-types.md`(一处表格行写明 count 可省略 field)。 ## What changes **Measured on `origin/main` `273137d2` (before).** Spec parse, `FieldSchema.safeParse` of a `summary` with `summaryOperations: { object: 'child', function: 'count' }`: ``` count false [{"expected":"string","code":"invalid_type","path":["summaryOperations","field"],"message":"Invalid input: expected string, received undefined"}] sum false [{"expected":"string","code":"invalid_type","path":["summaryOperations","field"],"message":"Invalid input: expected string, received undefined"}] count+field true {"object":"child","field":"amount","function":"count"} ``` The metadata write door, `saveMetaItem({ type: 'object', … })` on the metadata-protocol stub engine (the #8783 harness), an object whose `child_count` is that count roll-up: ``` count-no-field: 422 INVALID_METADATA [{"path":"fields.child_count.summaryOperations.field","message":"Invalid input: expected string, received undefined","code":"invalid_type"}] rows=0 sum-no-field: 422 INVALID_METADATA [{"path":"fields.child_count.summaryOperations.field","message":"Invalid input: expected string, received undefined","code":"invalid_type"}] rows=0 count-with-field: saved rows=1 ``` That is the card's 422 ("fields.child_count.summaryOperations.field expected string"). **After (head `023da3b2`), same probes:** ``` count-no-field: saved rows=1 sum-no-field: 422 INVALID_METADATA [{"path":"fields.child_count.summaryOperations.field","message":"`summaryOperations.function: 'sum'` needs `summaryOperations.field`, the child field the sum aggregates: name it (e.g. `field: 'amount'`), or use `function: 'count'`, which counts the child rows and reads no field.","code":"custom"}] rows=0 count-with-field: saved rows=1 ``` ### The spec (`packages/spec/src/data/field.zod.ts`) - `summaryOperations.field` is `z.string().optional()`; its describe now reads "Field on the child object to aggregate. Required for sum, min, max and avg; count counts the child rows and reads no field, so a count may omit it (ignored when given)." - The rule lives once: `export function summaryFunctionReadsField(fn)` answers `fn !== 'count'` (an unset function answers `true`). The `summaryOperations` object carries `.superRefine(refuseSummaryWithoutField)`, which reads that predicate and adds a `custom` issue at `['field']`, so the error stays located at `summaryOperations.field` and travels with the shape wherever it is embedded (objectui's `form.zod.ts` takes `SpecFieldSchema.shape.summaryOperations`). This follows the `CurrencyConfigSchema` precedent (`refuseFixedCurrencyWithoutCode`, #27157): a value-conditional requirement on an inner object. - The rule is value-conditional ("required unless `function` is `count`"), which no arm of the closed projectable list states (`dependent-required` is presence-only and its docblock names this `if`/`then` shape as staying dropped). So `summaryOperations` joins `dropped-refinements.baseline.json` at each of the 14 schemas that embed a field (`data/Field`, `data/Object`, `data/ObjectExtension`, `system/AddFieldOperation`, …); `measured.droppedRefinementSites` 892 → 906. `publishedSchemasWithDroppedRefinements` stays 244: all 14 were already in the ledger. - The two metadata-form sub-rows (`field.form.ts` roll-up composite, `object.form.ts` quick-add grid) drop `required: true`. Their help text ("ignored for count") is unchanged, so no translation bundle moves. ### Readers of `summaryOperations.field`, measured | Reader | On an absent `field` for `count`, before | Change | |:---|:---|:---| | objectql `buildSummaryIndex` (`engine.ts`) | copies `so.field` into `sourceField` (undefined) | none; `SummaryDescriptor.sourceField` becomes optional to say so | | objectql `aggregateSummaryValue` | already sends no `field` for `count` | none | | objectql `createBatchSummaryAccumulator` | reads `row[sourceField]` only when `fn !== 'count'` | guard added for a non-count descriptor without a field (reads no value, as an absent column) | | lint `ROLLUP_NON_NUMERIC_AGGREGAND` (`data-model-rules.ts`) | scoped to `min`/`max`, checks `typeof … === 'string'` | none | | spec `checkFieldCompleteness` / lint functional completeness | reads `summaryOperations` presence only | none | | metadata-core FLS references | keeps `summaryOperations` as is | none | | Studio `SummaryConfigFields` (`ObjectFieldInspector.tsx`) | showed the control for count with "still required — pick any child field" | reads `summaryFunctionReadsField`: no control for count, the note "Count counts the child rows, so it needs no child field." (en/zh) | | ui `snapshotSummaryPlan.ts` (`sum` only) | indexes `summarized.fields[sum.field]` | type guard for the now-optional key | The hand-written `content/docs/data-modeling/field-types.mdx` table and its count example move with the rule; `content/docs/references/**` is regenerated (`gen:docs`), with `api-surface/data.json` and `export-origins/data.json` (`gen:api-surface`, `gen:export-origins`). `check:authorable-surface` is green and the base file does not move. ## Tests - spec `src/data/field.test.ts`: a count with no `field` parses unchanged; `sum`/`min`/`max`/`avg` with no `field` are each refused with one `custom` issue at `['summaryOperations','field']` whose message names the function and the `function: 'count'` alternative; `summaryFunctionReadsField` answers. - objectql `src/summary-rollup.test.ts`: the parent and child definitions go through `ObjectSchema.parse` first; a count with no `field` seeds 0, counts single and batch inserts (4), holds through an update, and drops on delete (3). - objectql `src/summary-batch-accumulation.test.ts`: a count descriptor with no `sourceField` still adds one per admitted row. - app-shell `ObjectFieldInspector.test.tsx`: no aggregate-field control for count; picking "Count — child records" saves `{ object, function: 'count' }` and `FieldSchema` accepts the saved field; control: a sum still offers the control and the spec still refuses it without a field. <details><summary>Ablations (each restored, `git diff HEAD` empty)</summary> Run through `scripts/ablation-replace.mjs`. - Refusal off (`if (false /* ablated-27462 */ && …)` in `refuseSummaryWithoutField`): spec `field.test.ts` 4 failed | 281 passed (the four refusal cases). - Widening off (`field: z.string()` again): 5 failed | 280 passed (the count case, plus the four refusal cases, whose issue becomes `invalid_type`). - Studio predicate off (`{true /* ablated-27462 */ ? (` in `SummaryConfigFields`): `ObjectFieldInspector.test.tsx` 1 failed | 44 passed (the count control case). </details> ## Acceptance notes - A `count` that carries a `field` stays accepted and ignored, as before. Studio does not clear it when the function switches to `count`, so switching back to a `sum` keeps it. - Stored rows: the change only widens the accept set, so every row that loaded before loads the same. A non-count row saved without `field` could only have reached `sys_metadata` around the parse; the engine reads no value for it (the batch guard) instead of indexing `row[undefined]`. ## Verification **r2 (head `7533c782`).** Contract review 6496620533 (FAIL on `023da3b2`): ②-1 was discharged by r1. ③-1: `skills/objectstack-data/rules/field-types.md` spells `field?` with "(omit for `count`)" in the `summary` row and drops the duplicate "(ignored for count)" comment from its `sum` example; the changeset adds `Skills-docs: updated here` and `'@objectstack/skills': minor`. No code change. ③-2 (the QA checklist item) is a follow-up card; `docs/qa` is untouched. <details><summary>Skills readings (the token ratchet)</summary> - `skills/objectstack-data/rules/field-types.md`: 12623 bytes / 322 lines before and after; 3156 tokens against a ceiling of 3158 both times (`check-skills-token-ratchet.mjs`, exit 0). The ceiling does not move. - Whole bundle: ratcheted (authored) 207935 / 217474 tokens, bundle total 219187, unchanged; all `skills/*/SKILL.md` together 4614 lines, unchanged (no SKILL.md moves). </details> <details><summary>Derived gates on head `7533c782`: 116 of 116 run, every one exit 0</summary> `dispatch-gates --commands` now derives 116 families (the 109 below plus 7 for the `skills/**` path: `check-skills-token-ratchet.mjs` and its `--self-test`, `check:agent-test-spelling`, `check:pm-governed-merges`, `check:pm-skill-id-lint`, `check:skill-compatibility`, `check:skill-frame-sync`). All 116 were run serially on `7533c782`, with the full build from r0 still on disk; `dispatch-gates --ran`: "116 derived famil(ies) accounted for — 116 run, 0 NOT-MEASURED". `check-changeset-no-major` with this body as the event: exit 0. ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-adr-links.mjs --self-test :: exit 0 node scripts/check-adr-links.mjs :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dev-prereqs.mjs --self-test :: exit 0 node scripts/check-doc-route-spelling.mjs --advisory :: exit 0 node scripts/check-doc-route-spelling.mjs --self-test :: exit 0 node scripts/check-docs-section-name.mjs --self-test :: exit 0 node scripts/check-docs-section-name.mjs :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-engine-split-ratio.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-section-landing-index.mjs --self-test :: exit 0 node scripts/check-section-landing-index.mjs :: exit 0 node scripts/check-skills-token-ratchet.mjs --self-test :: exit 0 node scripts/check-skills-token-ratchet.mjs :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0 pnpm --filter @objectstack/lint run check:doc-security-posture :: exit 0 pnpm --filter @objectstack/spec run check:api-surface :: exit 0 pnpm --filter @objectstack/spec run check:authorable-surface :: exit 0 pnpm --filter @objectstack/spec run check:browser-reachable-entries :: exit 0 pnpm --filter @objectstack/spec run check:docs :: exit 0 pnpm --filter @objectstack/spec run check:dual-source-exports :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm --filter @objectstack/spec run check:empty-state :: exit 0 pnpm --filter @objectstack/spec run check:entry-nameability :: exit 0 pnpm --filter @objectstack/spec run check:export-origins :: exit 0 pnpm --filter @objectstack/spec run check:exported-any :: exit 0 pnpm --filter @objectstack/spec run check:generated :: exit 0 pnpm --filter @objectstack/spec run check:liveness :: exit 0 pnpm --filter @objectstack/spec run check:llms-txt :: exit 0 pnpm --filter @objectstack/spec run check:skill-examples :: exit 0 pnpm --filter @objectstack/spec run check:skill-refs :: exit 0 pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0 pnpm --filter @objectstack/spec run check:variant-docs :: exit 0 pnpm --filter @objectstack/spec run check:yaml-examples :: exit 0 pnpm check:agent-test-spelling :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:corpus-claim-drift :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:doc-anchors :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:doc-frontmatter :: exit 0 pnpm check:docs-audit-scope :: exit 0 pnpm check:docs-redirects :: exit 0 pnpm check:docs-single-h1 :: exit 0 pnpm check:docs-spec-enumerations :: exit 0 pnpm check:docs-transcript-drift :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 0 pnpm check:durability-log-level :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:i18n :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:merge-driver :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:pm-governed-merges :: exit 0 pnpm check:pm-prior-rulings :: exit 0 pnpm check:pm-skill-id-lint :: exit 0 pnpm check:published-files :: exit 0 pnpm check:published-readme-links :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:react-page-adapter-contract :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:role-word :: exit 0 pnpm check:skill-compatibility :: exit 0 pnpm check:skill-frame-sync :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:spec-docblock-symbol-anchors :: exit 0 pnpm check:spec-parsed-alias :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:vendor-version-stamps :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` </details> **r1 (head `39742179`).** The declaration is `Metadata-protocol: added`: a count roll-up with no `summaryOperations.field` is a newly accepted value. The changeset declares the same and grades every package it names `minor`; the commit moves only `.changeset/27462-count-rollup-without-field.md`. `check-changeset-no-major` with this body as the event and `check-empty-changeset` were rerun on that head; the readings below are from `023da3b2`, whose tree differs from it only in that changeset. All on head `023da3b2`, under `os-verify-lock` (shared-box seconds; one hold of 2390 s for the chain). <details><summary>Package suites</summary> - closure build `'@objectstack/objectql^...' '@objectstack/lint^...' '@objectstack/platform-objects^...'`: exit 0 - `pnpm --filter @objectstack/spec test`: exit 0 · Test Files 750 passed · Tests 22319 passed | 1 todo - `pnpm --filter @objectstack/spec typecheck`: exit 0 - `pnpm --filter @objectstack/spec test:repo`: exit 0 · Test Files 86 passed · Tests 1419 passed - `pnpm --filter @objectstack/objectql test`: exit 0 · Test Files 483 passed · Tests 9090 passed - `pnpm --filter @objectstack/objectql typecheck`: exit 0 - `pnpm --filter @objectstack/metadata-protocol test`: exit 0 · Test Files 279 passed | 4 skipped · Tests 21774 passed | 26 skipped - `pnpm --filter @objectstack/lint test`: exit 0 · Test Files 177 passed · Tests 7589 passed - `pnpm --filter @objectstack/platform-objects test`: exit 0 · Test Files 97 passed · Tests 1369 passed - `'@object-ui/app-shell^...'` build, then `pnpm --filter @object-ui/app-shell type-check`: exit 0 - `cd ui && vitest run packages/i18n/ packages/app-shell/src/views/approval-decision/ …/ObjectFieldInspector`: exit 0 · Test Files 118 passed · Tests 1699 passed | 13 skipped (narrowed to the files the diff moves and their importers; CI's Test UI runs the rest) </details> <details><summary>Derived gates: 109 of 109 run, every one exit 0</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 109 families against merge base `273137d22`; each was run serially on head `023da3b2` and recorded as `CMD :: exit N`. Three (`spec check:skill-examples`, `check:dual-build-cjs-loads`, `check:i18n`) exited 3 (prerequisite: built output) on the first pass and exit 0 after a full `turbo run build --concurrency=2` (exit 0, 677 s held). `dispatch-gates --ran`: "109 derived famil(ies) accounted for — 109 run, 0 NOT-MEASURED". ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-adr-links.mjs --self-test :: exit 0 node scripts/check-adr-links.mjs :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dev-prereqs.mjs --self-test :: exit 0 node scripts/check-doc-route-spelling.mjs --advisory :: exit 0 node scripts/check-doc-route-spelling.mjs --self-test :: exit 0 node scripts/check-docs-section-name.mjs --self-test :: exit 0 node scripts/check-docs-section-name.mjs :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-engine-split-ratio.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-section-landing-index.mjs --self-test :: exit 0 node scripts/check-section-landing-index.mjs :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0 pnpm --filter @objectstack/lint run check:doc-security-posture :: exit 0 pnpm --filter @objectstack/spec run check:api-surface :: exit 0 pnpm --filter @objectstack/spec run check:authorable-surface :: exit 0 pnpm --filter @objectstack/spec run check:browser-reachable-entries :: exit 0 pnpm --filter @objectstack/spec run check:docs :: exit 0 pnpm --filter @objectstack/spec run check:dual-source-exports :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm --filter @objectstack/spec run check:empty-state :: exit 0 pnpm --filter @objectstack/spec run check:entry-nameability :: exit 0 pnpm --filter @objectstack/spec run check:export-origins :: exit 0 pnpm --filter @objectstack/spec run check:exported-any :: exit 0 pnpm --filter @objectstack/spec run check:generated :: exit 0 pnpm --filter @objectstack/spec run check:liveness :: exit 0 pnpm --filter @objectstack/spec run check:llms-txt :: exit 0 pnpm --filter @objectstack/spec run check:skill-examples :: exit 0 pnpm --filter @objectstack/spec run check:skill-refs :: exit 0 pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0 pnpm --filter @objectstack/spec run check:variant-docs :: exit 0 pnpm --filter @objectstack/spec run check:yaml-examples :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:corpus-claim-drift :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:doc-anchors :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:doc-frontmatter :: exit 0 pnpm check:docs-audit-scope :: exit 0 pnpm check:docs-redirects :: exit 0 pnpm check:docs-single-h1 :: exit 0 pnpm check:docs-spec-enumerations :: exit 0 pnpm check:docs-transcript-drift :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 0 pnpm check:durability-log-level :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:i18n :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:merge-driver :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:pm-prior-rulings :: exit 0 pnpm check:published-files :: exit 0 pnpm check:published-readme-links :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:react-page-adapter-contract :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:role-word :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:spec-docblock-symbol-anchors :: exit 0 pnpm check:spec-parsed-alias :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:vendor-version-stamps :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` </details> Dogfood: not run — no `!` in the title and the `Clause-②:` arm is `(widening)`, not `(narrowing)`. --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzh4Bxv1fXaUEeGaKDn)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7538f27 commit fbb8bec

23 files changed

Lines changed: 253 additions & 45 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/objectql': minor
4+
'@objectstack/skills': minor
5+
'@object-ui/app-shell': minor
6+
'@object-ui/i18n': minor
7+
---
8+
9+
fix(spec): a count roll-up saves without `summaryOperations.field`
10+
11+
Clause-②: yes (widening)
12+
Metadata-protocol: added
13+
Skills-docs: updated here — `skills/objectstack-data/rules/field-types.md` spells `field?` in the `summary` config row, with "(omit for `count`)"; the same file's `sum` example drops its now-duplicate "ignored for count" comment, so the file's size is unchanged.
14+
15+
The description of `summaryOperations.field` always said it is ignored for `count`, yet the schema required it for every function, so a count roll-up with no child field was refused (`422 INVALID_METADATA` at `fields.<name>.summaryOperations.field`, "expected string") and Studio's author had to pick an arbitrary child field to save one. The schema now follows its own description:
16+
17+
- **`count` may omit `field`.** A count counts the matching child rows and reads no field, so `{ object, function: 'count' }` parses and saves as written. A count that carries a `field` is still accepted, and the field is still ignored.
18+
- **Every other function still needs it.** `sum`, `min`, `max` and `avg` without `field` are refused at `summaryOperations.field`, and the message names the function and the fix: name the child field to aggregate, or use `function: 'count'`.
19+
- **One rule, read twice.** `summaryFunctionReadsField(fn)` (exported from `@objectstack/spec/data`) is where "does this function read a child field" is written. The schema's refusal reads it, and Studio's roll-up editor reads it: for a count the editor offers no "Child field to aggregate" control, only a note that a count needs none, and saves the roll-up without `field`.
20+
- **The engine counts it.** The roll-up index carries no source field for such a count (`SummaryDescriptor.sourceField` is now optional), and the recompute and bulk-insert paths count child rows without reading one, as they already did for every count.
21+
- **Published JSON Schema.** `summaryOperations.field` is no longer in `required`; the "required unless `count`" rule is a refinement the JSON Schema does not state, so `summaryOperations` joins the dropped-refinements ledger at each schema that embeds a field.
22+
- **Metadata forms.** The `field` sub-row of the roll-up composite (field editor and object quick-add grid) is no longer marked required; its help text, "ignored for count", is unchanged.

‎content/docs/data-modeling/field-types.mdx‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -712,7 +712,7 @@ Roll-up summary from child records.
712712
| Property | Type | Default | Description |
713713
|:---|:---|:---|:---|
714714
| `summaryOperations.object` | `string` | **required** | Child object to aggregate |
715-
| `summaryOperations.field` | `string` | **required** | Child field to aggregate; ignored for `count` |
715+
| `summaryOperations.field` | `string` | **required** for `sum`, `avg`, `min`, `max` | Child field to aggregate; a `count` counts the child rows and reads no field, so it may omit it |
716716
| `summaryOperations.function` | `enum` | **required** | `count`, `sum`, `avg`, `min`, or `max` |
717717
| `summaryOperations.relationshipField` | `string` | auto | Child FK back to the parent when it cannot be inferred |
718718
| `summaryOperations.filter` | `FilterCondition` | — | Only child rows matching this `where` predicate are aggregated |
@@ -729,7 +729,6 @@ currencies would be added into one number. A `count` is served
729729
type: 'summary',
730730
summaryOperations: {
731731
object: 'task',
732-
field: 'id',
733732
function: 'count',
734733
relationshipField: 'project',
735734
},

‎content/docs/references/data/field.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ const result = CurrencyConfigSchema.parse(data);
8787
| **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. |
8888
| **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). |
8989
| **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). |
90-
| **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. |
90+
| **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. |
9191
| **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) |
9292
| **step** | `number` | optional | Step increment for slider (default: 1) |
9393
| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. |
@@ -228,7 +228,7 @@ const result = CurrencyConfigSchema.parse(data);
228228
| Property | Type | Required | Description |
229229
| :--- | :--- | :--- | :--- |
230230
| **object** | `string` | ✅ | Source child object name for roll-up |
231-
| **field** | `string` | ✅ | Field on child object to aggregate (ignored for count) |
231+
| **field** | `string` | optional | Field on the child object to aggregate. Required for sum, min, max and avg; count counts the child rows and reads no field, so a count may omit it (ignored when given). |
232232
| **function** | `Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>` | ✅ | Aggregation function to apply |
233233
| **relationshipField** | `string` | optional | FK field on the child pointing back to this parent. Auto-detected from the child's lookup/master_detail field referencing this object when omitted; set explicitly only when the child has more than one such reference. |
234234
| **filter** | `any` | optional | Predicate restricting which child rows are aggregated (a query `where` FilterCondition, e.g. `{ status: 'received' }` or `{ type: { $in: ['signup','trial'] } }`). Omit to aggregate all children. Lets one child object feed multiple filtered roll-ups. |

‎content/docs/references/data/object.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ const result = ApiMethod.parse(data);
265265
| **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. |
266266
| **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). |
267267
| **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). |
268-
| **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. |
268+
| **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. |
269269
| **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) |
270270
| **step** | `number` | optional | Step increment for slider (default: 1) |
271271
| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. |
@@ -628,7 +628,7 @@ const result = ApiMethod.parse(data);
628628
| **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. |
629629
| **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). |
630630
| **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). |
631-
| **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. |
631+
| **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. |
632632
| **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) |
633633
| **step** | `number` | optional | Step increment for slider (default: 1) |
634634
| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. |

‎content/docs/references/system/migration.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ Add a new field to an existing object
9999
| **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. |
100100
| **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). |
101101
| **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). |
102-
| **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. |
102+
| **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. |
103103
| **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) |
104104
| **step** | `number` | optional | Step increment for slider (default: 1) |
105105
| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. |
@@ -530,7 +530,7 @@ Add a new field to an existing object
530530
| **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. |
531531
| **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). |
532532
| **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). |
533-
| **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. |
533+
| **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. |
534534
| **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) |
535535
| **step** | `number` | optional | Step increment for slider (default: 1) |
536536
| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. |

‎content/docs/references/ui/component.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -618,7 +618,7 @@ Sort field and direction pair
618618
| **maxLength** | `integer` | optional | Maximum character count — the control's ceiling (a textarea also draws its counter) |
619619
| **pattern** | `string` | optional | Regular expression the value must match, as a string — the native control's `pattern`, enforced by the browser at submit |
620620
| **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | The value type a formula field displays (number / text / boolean / date / currency) |
621-
| **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | The roll-up a summary field displays — the object field's own `{ object, field, function, … }` |
621+
| **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | The roll-up a summary field displays — the object field's own `{ object, field, function, … }` |
622622
| **columns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | The columns of a `grid` field — the strict, name-keyed inline grid column a relationship field's `inlineColumns` takes |
623623
| **minRows** | `number` | optional | A `grid` field's minimum row count: its Remove action is disabled at this many rows (no minimum when unset). Read only by the `grid` widget |
624624
| **maxRows** | `number` | optional | A `grid` field's maximum row count: its Add and Duplicate actions are disabled, and no blank entry row is drawn, at this many rows (no maximum when unset). Read only by the `grid` widget |

‎packages/objectql/src/summary-aggregate.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,12 @@ export interface SummaryDescriptor {
3939
/** FK field on the child pointing back to the parent. */
4040
fkField: string;
4141
fn: 'count' | 'sum' | 'min' | 'max' | 'avg';
42-
/** Child field aggregated (unused for count). */
43-
sourceField: string;
42+
/**
43+
* Child field aggregated — `summaryOperations.field`. Absent on a `count`,
44+
* which counts the child rows and reads no field (the spec's
45+
* `summaryFunctionReadsField`); every other function carries one.
46+
*/
47+
sourceField?: string;
4448
/**
4549
* Optional predicate (a query `where` FilterCondition) restricting which child
4650
* rows are aggregated. ANDed with the parent-FK match when the aggregate runs.

‎packages/objectql/src/summary-batch-accumulation.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,13 @@ describe('[#25579] createBatchSummaryAccumulator — per roll-up kind', () => {
6262
expect(acc.overlay(bound({ id: 'o1', lowest: null, highest: null }))!.order!.row).toEqual({ id: 'o1', lowest: 3, highest: 7 });
6363
});
6464

65+
it('[#27462] count with no sourceField (a count reads no child field): each admitted row still adds one', () => {
66+
const acc = createBatchSummaryAccumulator([desc({ summaryField: 'lines', fn: 'count', sourceField: undefined })], CHILD_FIELDS)!;
67+
acc.accept({ order: 'o1', amount: 5 });
68+
acc.accept({ order: 'o1' });
69+
expect(acc.overlay(bound({ id: 'o1', lines: 1 }))!.order!.row).toEqual({ id: 'o1', lines: 3 });
70+
});
71+
6572
it('a row never accepted moves nothing: the caller accepts only the rows its rules admitted', () => {
6673
const acc = createBatchSummaryAccumulator([desc({ summaryField: 'primaries', fn: 'count' })], CHILD_FIELDS)!;
6774
const stored = bound({ id: 'o1', primaries: 0 });

0 commit comments

Comments
 (0)