From a70b67c38108e33f5be8068328ca69eebff718ab Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 09:34:53 +0000 Subject: [PATCH 1/5] fix(spec,objectql,app-shell): a count rollup saves without summaryOperations.field `summaryOperations.field` is optional when `function` is `count` (which counts child rows and reads no field) and still required for sum, min, max and avg, refused at `summaryOperations.field` with a message naming the function and the fix. The predicate `summaryFunctionReadsField` lives once in the spec; Studio's roll-up editor reads it and offers no child-field control for a count. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzh4Bxv1fXaUEeGaKDn --- content/docs/data-modeling/field-types.mdx | 3 +- content/docs/references/data/field.mdx | 4 +- content/docs/references/data/object.mdx | 4 +- content/docs/references/system/migration.mdx | 4 +- content/docs/references/ui/component.mdx | 2 +- packages/objectql/src/summary-aggregate.ts | 8 +++- .../src/summary-batch-accumulation.ts | 5 ++- packages/spec/api-surface/data.json | 1 + .../spec/dropped-refinements.baseline.json | 22 +++++++++-- packages/spec/export-origins/data.json | 1 + packages/spec/src/data/field.form.ts | 5 ++- packages/spec/src/data/field.zod.ts | 37 ++++++++++++++++++- packages/spec/src/data/object.form.ts | 3 +- .../approval-decision/snapshotSummaryPlan.ts | 3 +- .../inspectors/ObjectFieldInspector.tsx | 36 ++++++++++-------- ui/packages/i18n/src/namespaces/studio/en.ts | 2 +- ui/packages/i18n/src/namespaces/studio/zh.ts | 2 +- 17 files changed, 103 insertions(+), 39 deletions(-) diff --git a/content/docs/data-modeling/field-types.mdx b/content/docs/data-modeling/field-types.mdx index f9aa290fe02..854293eb85b 100644 --- a/content/docs/data-modeling/field-types.mdx +++ b/content/docs/data-modeling/field-types.mdx @@ -699,7 +699,7 @@ Roll-up summary from child records. | Property | Type | Default | Description | |:---|:---|:---|:---| | `summaryOperations.object` | `string` | **required** | Child object to aggregate | -| `summaryOperations.field` | `string` | **required** | Child field to aggregate; ignored for `count` | +| `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 | | `summaryOperations.function` | `enum` | **required** | `count`, `sum`, `avg`, `min`, or `max` | | `summaryOperations.relationshipField` | `string` | auto | Child FK back to the parent when it cannot be inferred | | `summaryOperations.filter` | `FilterCondition` | — | Only child rows matching this `where` predicate are aggregated | @@ -711,7 +711,6 @@ Roll-up summary from child records. type: 'summary', summaryOperations: { object: 'task', - field: 'id', function: 'count', relationshipField: 'project', }, diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 1d530f90727..ce9a820e217 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -86,7 +86,7 @@ const result = CurrencyConfigSchema.parse(data); | **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. | | **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). | | **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). | -| **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. | +| **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. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | | **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | @@ -227,7 +227,7 @@ const result = CurrencyConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Source child object name for roll-up | -| **field** | `string` | ✅ | Field on child object to aggregate (ignored for count) | +| **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). | | **function** | `Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>` | ✅ | Aggregation function to apply | | **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. | | **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. | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 86c559d0301..98ec2acbd3a 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -265,7 +265,7 @@ const result = ApiMethod.parse(data); | **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. | | **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). | | **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). | -| **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. | +| **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. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | | **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: 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); | **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. | | **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). | | **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). | -| **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. | +| **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. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | | **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 485c6eaae0f..de18286429a 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -99,7 +99,7 @@ Add a new field to an existing object | **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. | | **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). | | **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). | -| **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. | +| **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. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | | **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: 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 | **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. | | **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). | | **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). | -| **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. | +| **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. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | | **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 2e10ddf2b16..ed97a510597 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -618,7 +618,7 @@ Sort field and direction pair | **maxLength** | `integer` | optional | Maximum character count — the control's ceiling (a textarea also draws its counter) | | **pattern** | `string` | optional | Regular expression the value must match, as a string — the native control's `pattern`, enforced by the browser at submit | | **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | The value type a formula field displays (number / text / boolean / date / currency) | -| **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, … }` | +| **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, … }` | | **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 | | **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 | | **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 | diff --git a/packages/objectql/src/summary-aggregate.ts b/packages/objectql/src/summary-aggregate.ts index 560d42ec30d..b226bb8eeac 100644 --- a/packages/objectql/src/summary-aggregate.ts +++ b/packages/objectql/src/summary-aggregate.ts @@ -39,8 +39,12 @@ export interface SummaryDescriptor { /** FK field on the child pointing back to the parent. */ fkField: string; fn: 'count' | 'sum' | 'min' | 'max' | 'avg'; - /** Child field aggregated (unused for count). */ - sourceField: string; + /** + * Child field aggregated — `summaryOperations.field`. Absent on a `count`, + * which counts the child rows and reads no field (the spec's + * `summaryFunctionReadsField`); every other function carries one. + */ + sourceField?: string; /** * Optional predicate (a query `where` FilterCondition) restricting which child * rows are aggregated. ANDed with the parent-FK match when the aggregate runs. diff --git a/packages/objectql/src/summary-batch-accumulation.ts b/packages/objectql/src/summary-batch-accumulation.ts index 311c5d654d0..e6bce6af527 100644 --- a/packages/objectql/src/summary-batch-accumulation.ts +++ b/packages/objectql/src/summary-batch-accumulation.ts @@ -133,7 +133,10 @@ export function createBatchSummaryAccumulator( } let value: number | undefined; if (desc.fn !== 'count') { - const raw = row[desc.sourceField]; + // A non-count roll-up always names its field (the spec refuses one + // without); a definition that reached the registry without it reads + // no value, as an absent column would. + const raw = desc.sourceField === undefined ? undefined : row[desc.sourceField]; if (raw == null) continue; // an aggregate skips a null, as SQL does if (typeof raw !== 'number' || !Number.isFinite(raw)) { dropped.add(desc); continue; } value = raw; diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 96b7e6023bd..0aae431dbf2 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -1002,6 +1002,7 @@ "stripLegacyApiMethods (function)", "suggestDefaultValueToken (function)", "suggestFieldTypeForSqlType (function)", + "summaryFunctionReadsField (function)", "textComparandRefusalReason (function)", "textOperatorDoorVerdict (function)", "undeclaredHookRefusalAtRun (function)", diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 60d3bd77748..9a3aeb656ab 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -3,7 +3,7 @@ "measured": { "zod": "4.4.3", "publishedSchemasWithDroppedRefinements": 244, - "droppedRefinementSites": 892, + "droppedRefinementSites": 906, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -107,6 +107,7 @@ "manifest.objects.element.fields.out.valueType", "manifest.objects.element.fields.out.valueType.currencyConfig", "manifest.objects.element.fields.out.valueType.inlineColumns.element", + "manifest.objects.element.fields.out.valueType.summaryOperations", "manifest.objects.element.lifecycle", "manifest.objects.element.membershipKeys", "manifest.objects.element.membershipKeys.valueType", @@ -237,6 +238,7 @@ "data.options[1].manifest.objects.element.fields.out.valueType", "data.options[1].manifest.objects.element.fields.out.valueType.currencyConfig", "data.options[1].manifest.objects.element.fields.out.valueType.inlineColumns.element", + "data.options[1].manifest.objects.element.fields.out.valueType.summaryOperations", "data.options[1].manifest.objects.element.lifecycle", "data.options[1].manifest.objects.element.membershipKeys", "data.options[1].manifest.objects.element.membershipKeys.valueType", @@ -366,6 +368,7 @@ "options[1].manifest.objects.element.fields.out.valueType", "options[1].manifest.objects.element.fields.out.valueType.currencyConfig", "options[1].manifest.objects.element.fields.out.valueType.inlineColumns.element", + "options[1].manifest.objects.element.fields.out.valueType.summaryOperations", "options[1].manifest.objects.element.lifecycle", "options[1].manifest.objects.element.membershipKeys", "options[1].manifest.objects.element.membershipKeys.valueType", @@ -438,6 +441,7 @@ "data.packages.element.options[1].manifest.objects.element.fields.out.valueType", "data.packages.element.options[1].manifest.objects.element.fields.out.valueType.currencyConfig", "data.packages.element.options[1].manifest.objects.element.fields.out.valueType.inlineColumns.element", + "data.packages.element.options[1].manifest.objects.element.fields.out.valueType.summaryOperations", "data.packages.element.options[1].manifest.objects.element.lifecycle", "data.packages.element.options[1].manifest.objects.element.membershipKeys", "data.packages.element.options[1].manifest.objects.element.membershipKeys.valueType", @@ -492,6 +496,7 @@ "data.fields.out.valueType.currencyConfig", "data.fields.out.valueType.inlineColumns.element", "data.fields.out.valueType.relatedListFilter.lazy", + "data.fields.out.valueType.summaryOperations", "data.lifecycle", "data.listViews.valueType", "data.listViews.valueType.bulkActionDefs.element", @@ -847,7 +852,8 @@ "", "currencyConfig", "inlineColumns.element", - "relatedListFilter.lazy" + "relatedListFilter.lazy", + "summaryOperations" ] }, "data/FieldOperators": { @@ -951,6 +957,7 @@ "fields.out.valueType.currencyConfig", "fields.out.valueType.inlineColumns.element", "fields.out.valueType.relatedListFilter.lazy", + "fields.out.valueType.summaryOperations", "lifecycle", "listViews.valueType", "listViews.valueType.bulkActionDefs.element", @@ -970,7 +977,8 @@ "fields.valueType", "fields.valueType.currencyConfig", "fields.valueType.inlineColumns.element", - "fields.valueType.relatedListFilter.lazy" + "fields.valueType.relatedListFilter.lazy", + "fields.valueType.summaryOperations" ] }, "data/PostgresConfig": { @@ -1251,7 +1259,8 @@ "field", "field.currencyConfig", "field.inlineColumns.element", - "field.relatedListFilter.lazy" + "field.relatedListFilter.lazy", + "field.summaryOperations" ] }, "system/AudienceConfig": { @@ -1275,6 +1284,7 @@ "operations.element.options[0].field.currencyConfig", "operations.element.options[0].field.inlineColumns.element", "operations.element.options[0].field.relatedListFilter.lazy", + "operations.element.options[0].field.summaryOperations", "operations.element.options[3].object", "operations.element.options[3].object.actions.element.in", "operations.element.options[3].object.actions.element.in.ai.outputSchema", @@ -1309,6 +1319,7 @@ "object.fields.out.valueType.currencyConfig", "object.fields.out.valueType.inlineColumns.element", "object.fields.out.valueType.relatedListFilter.lazy", + "object.fields.out.valueType.summaryOperations", "object.lifecycle", "object.listViews.valueType", "object.listViews.valueType.bulkActionDefs.element", @@ -1349,6 +1360,7 @@ "options[0].field.currencyConfig", "options[0].field.inlineColumns.element", "options[0].field.relatedListFilter.lazy", + "options[0].field.summaryOperations", "options[3].object", "options[3].object.actions.element.in", "options[3].object.actions.element.in.ai.outputSchema", @@ -1675,6 +1687,7 @@ "ui/ObjectFormProps": { "sites": [ "customFields.element.columns.element", + "customFields.element.summaryOperations", "customFields.element.summaryOperations.filter.lazy", "sections.element", "submitBehavior.options[1].url" @@ -1721,6 +1734,7 @@ "sites": [ "details.element.columns.element", "sections.element", + "sections.element.fields.element.options[2].summaryOperations", "sections.element.fields.element.options[2].summaryOperations.filter.lazy" ] }, diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 71e26d894d9..24f5bcca6fa 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -988,6 +988,7 @@ "stripLegacyApiMethods": "src/data/object.zod.ts#stripLegacyApiMethods (function)", "suggestDefaultValueToken": "src/data/default-value-shape.ts#suggestDefaultValueToken (function)", "suggestFieldTypeForSqlType": "src/data/type-compat.ts#suggestFieldTypeForSqlType (function)", + "summaryFunctionReadsField": "src/data/field.zod.ts#summaryFunctionReadsField (function)", "textComparandRefusalReason": "src/data/filter-text-comparand.ts#textComparandRefusalReason (function)", "textOperatorDoorVerdict": "src/data/filter-text-operator-declared-type.ts#textOperatorDoorVerdict (function)", "undeclaredHookRefusalAtRun": "src/data/hook-refusal-codes.ts#undeclaredHookRefusalAtRun (function)", diff --git a/packages/spec/src/data/field.form.ts b/packages/spec/src/data/field.form.ts index b1fdf31228b..daa530443d8 100644 --- a/packages/spec/src/data/field.form.ts +++ b/packages/spec/src/data/field.form.ts @@ -374,7 +374,10 @@ export const fieldForm = defineForm({ ], helpText: 'Aggregation function', }, - { field: 'field', required: true, helpText: 'Child field to aggregate (ignored for count)' }, + // Not `required`: a `count` reads no child field and saves without one + // (`summaryFunctionReadsField` in field.zod.ts); the schema refuses a + // missing `field` on every other function, naming it. + { field: 'field', helpText: 'Child field to aggregate (ignored for count)' }, { field: 'relationshipField', helpText: 'Child FK back to this parent (auto-detected when omitted)' }, { field: 'filter', widget: 'filter-condition', helpText: 'Only child rows matching this predicate are aggregated (e.g. status == received)' }, ], diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 85e3316efb8..6c4c2043827 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -555,6 +555,36 @@ function refuseFixedCurrencyWithoutCode( } } +/** + * Does a roll-up with this `summaryOperations.function` read a child field? + * + * `count` counts the matching child rows and reads no field, so it needs no + * `summaryOperations.field`; `sum`, `min`, `max` and `avg` aggregate one, so + * they do. This is the ONE place that rule is written: the schema's refusal + * below and Studio's roll-up editor (which hides the child-field control for + * `count`) both read it. An unset function answers `true`, so an editor still + * offers the control before a function is picked. + */ +export function summaryFunctionReadsField(fn: string | undefined): boolean { + return fn !== 'count'; +} + +/** The refusal a non-`count` roll-up without `summaryOperations.field` meets, naming its function. */ +const summaryWithoutFieldMessage = (fn: string): string => + `\`summaryOperations.function: '${fn}'\` needs \`summaryOperations.field\`, the child field the ${fn} ` + + 'aggregates: name it (e.g. `field: \'amount\'`), or use `function: \'count\'`, which counts the ' + + 'child rows and reads no field.'; + +/** `sum`/`min`/`max`/`avg` ⇒ an authored `field`; `count` reads none, so it needs none. */ +function refuseSummaryWithoutField( + ops: { function: string; field?: string }, + ctx: z.RefinementCtx, +): void { + if (summaryFunctionReadsField(ops.function) && ops.field === undefined) { + ctx.addIssue({ code: 'custom', path: ['field'], message: summaryWithoutFieldMessage(ops.function) }); + } +} + /** * Currency Configuration Schema * Configuration for currency field type supporting multi-currency — and, in the @@ -1918,7 +1948,10 @@ export const FieldSchema = lazySchema(() => { }, }, { object: z.string().describe('Source child object name for roll-up'), - field: z.string().describe('Field on child object to aggregate (ignored for count)'), + // Required for every function but `count` (`summaryFunctionReadsField`): + // the `.superRefine` at the end of this object refuses its absence there. + // A `count` may still carry one, and it stays ignored, as it always was. + field: z.string().optional().describe('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).'), function: z.enum(['count', 'sum', 'min', 'max', 'avg']).describe('Aggregation function to apply'), relationshipField: z.string().optional().describe('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.'), /** @@ -1934,7 +1967,7 @@ export const FieldSchema = lazySchema(() => { * parent on its next write like any other child update. */ filter: FilterConditionSchema.optional().describe("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."), - }).optional().describe('Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted.'), + }).superRefine(refuseSummaryWithoutField).optional().describe('Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted.'), /** Enhanced Field Type Configurations */ // Pruned 2026-06 — per-type *display* knobs that were dead in both layers (no diff --git a/packages/spec/src/data/object.form.ts b/packages/spec/src/data/object.form.ts index 8efd979b9b1..bb2682c6196 100644 --- a/packages/spec/src/data/object.form.ts +++ b/packages/spec/src/data/object.form.ts @@ -396,7 +396,8 @@ export const objectForm = defineForm({ visibleWhen: "data.type == 'summary'", fields: [ { field: 'object', type: 'text', required: true, helpText: 'Source child object name' }, - { field: 'field', type: 'text', required: true, helpText: 'Field on the child object to aggregate (ignored for count)' }, + // Not `required`: a `count` saves without it (see field.form.ts). + { field: 'field', type: 'text', helpText: 'Field on the child object to aggregate (ignored for count)' }, { field: 'function', type: 'select', required: true, helpText: 'Aggregation function', options: [ { label: 'Count', value: 'count' }, { label: 'Sum', value: 'sum' }, { label: 'Avg', value: 'avg' }, { label: 'Min', value: 'min' }, { label: 'Max', value: 'max' }, diff --git a/ui/packages/app-shell/src/views/approval-decision/snapshotSummaryPlan.ts b/ui/packages/app-shell/src/views/approval-decision/snapshotSummaryPlan.ts index daaf6079f53..492934bd3f0 100644 --- a/ui/packages/app-shell/src/views/approval-decision/snapshotSummaryPlan.ts +++ b/ui/packages/app-shell/src/views/approval-decision/snapshotSummaryPlan.ts @@ -95,7 +95,8 @@ export function planSnapshotSummary( // a sum over any other object stays an ordinary row. summed ??= sum.object; if (sum.object !== summed || summarized?.name !== summed) continue; - if (summarized.fields[sum.field]?.type !== 'currency') continue; + // A `sum` always names its field (only a `count` may omit it). + if (sum.field === undefined || summarized.fields[sum.field]?.type !== 'currency') continue; } if (n !== 0) { lead = key; diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx index 01ff01d2217..7a6927bf062 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx @@ -54,6 +54,7 @@ import { NUMERIC_VALUE_TYPES, SelectOptionSchema, SINGLE_OPTION_TYPES, + summaryFunctionReadsField, VALUE_DOMAIN_FIELD_TYPES, type SelectOptionKind, } from '@objectstack/spec/data'; @@ -2451,22 +2452,25 @@ function SummaryConfigFields({ onCommit={(v) => patchOps({ function: v || undefined })} disabled={readOnly} /> - {/* The spec requires `field` even for `count` (documented as ignored), - so it stays visible — hiding it would trap the author with an - unfixable "required" issue in the draft banner. */} - patchOps({ field: v || undefined })} - options={aggregateOptions} - loading={loading} - placeholder={fieldPlaceholder} - searchPlaceholder={tr('designer.field.lookup.searchFields')} - disabled={readOnly} - mono - /> - {fn === 'count' && ( -

+ {/* Whether the function reads a child field is the spec's rule + (`summaryFunctionReadsField`), not a copy of it: `count` counts the + child rows and reads none, so its control is not offered and the + roll-up saves without `field`. A `field` already stored on a count + is left as it is (ignored), so switching back keeps it. */} + {summaryFunctionReadsField(fn || undefined) ? ( + patchOps({ field: v || undefined })} + options={aggregateOptions} + loading={loading} + placeholder={fieldPlaceholder} + searchPlaceholder={tr('designer.field.lookup.searchFields')} + disabled={readOnly} + mono + /> + ) : ( +

{tr('designer.field.summary.countFieldHint')}

)} diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index 459dc3ada24..3ef06f51eb0 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -422,7 +422,7 @@ const STUDIO_EN: Record = { 'designer.field.summary.field': 'Child field to aggregate', 'designer.field.summary.relationshipField': 'Child relationship field (optional)', 'designer.field.summary.setObjectFirst': 'Set the child object first', - 'designer.field.summary.countFieldHint': 'Ignored for count, but still required — pick any child field.', + 'designer.field.summary.countFieldHint': 'Count counts the child rows, so it needs no child field.', 'designer.field.summary.filterSection': 'Filter (rows to aggregate)', 'designer.field.summary.addFilter': 'Add condition', 'designer.field.summary.noFilter': 'No filter — every child row is aggregated.', diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index 1970b893645..d6b2bd1fea8 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -357,7 +357,7 @@ const STUDIO_ZH: Record = { 'designer.field.summary.field': '汇总的子字段', 'designer.field.summary.relationshipField': '子关系字段(可选)', 'designer.field.summary.setObjectFirst': '请先选择子对象', - 'designer.field.summary.countFieldHint': '计数时不参与计算,但规范要求必填——任选一个子字段即可。', + 'designer.field.summary.countFieldHint': '计数统计子记录条数,无需选择子字段。', 'designer.field.summary.filterSection': '筛选(参与汇总的子记录)', 'designer.field.summary.addFilter': '添加条件', 'designer.field.summary.noFilter': '未设筛选 — 所有子记录都参与汇总。', From 7f0991fa688d2967f0893b5d74536b127248373e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 09:38:21 +0000 Subject: [PATCH 2/5] test(spec,objectql,app-shell): a count rollup without field parses, counts, and saves from Studio Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzh4Bxv1fXaUEeGaKDn --- .../src/summary-batch-accumulation.test.ts | 7 +++ packages/objectql/src/summary-rollup.test.ts | 48 +++++++++++++++++++ packages/spec/src/data/field.test.ts | 35 ++++++++++++++ .../inspectors/ObjectFieldInspector.test.tsx | 40 ++++++++++++++-- 4 files changed, 126 insertions(+), 4 deletions(-) diff --git a/packages/objectql/src/summary-batch-accumulation.test.ts b/packages/objectql/src/summary-batch-accumulation.test.ts index 8bcc8ea35e3..7824fed499f 100644 --- a/packages/objectql/src/summary-batch-accumulation.test.ts +++ b/packages/objectql/src/summary-batch-accumulation.test.ts @@ -62,6 +62,13 @@ describe('[#25579] createBatchSummaryAccumulator — per roll-up kind', () => { expect(acc.overlay(bound({ id: 'o1', lowest: null, highest: null }))!.order!.row).toEqual({ id: 'o1', lowest: 3, highest: 7 }); }); + it('[#27462] count with no sourceField (a count reads no child field): each admitted row still adds one', () => { + const acc = createBatchSummaryAccumulator([desc({ summaryField: 'lines', fn: 'count', sourceField: undefined })], CHILD_FIELDS)!; + acc.accept({ order: 'o1', amount: 5 }); + acc.accept({ order: 'o1' }); + expect(acc.overlay(bound({ id: 'o1', lines: 1 }))!.order!.row).toEqual({ id: 'o1', lines: 3 }); + }); + it('a row never accepted moves nothing: the caller accepts only the rows its rules admitted', () => { const acc = createBatchSummaryAccumulator([desc({ summaryField: 'primaries', fn: 'count' })], CHILD_FIELDS)!; const stored = bound({ id: 'o1', primaries: 0 }); diff --git a/packages/objectql/src/summary-rollup.test.ts b/packages/objectql/src/summary-rollup.test.ts index e5328044f16..c4f8002abcc 100644 --- a/packages/objectql/src/summary-rollup.test.ts +++ b/packages/objectql/src/summary-rollup.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { ObjectQL } from './engine.js'; +import { ObjectSchema } from '@objectstack/spec/data'; function makeDriver() { const stores = new Map>(); @@ -171,6 +172,53 @@ describe('roll-up summary fields', () => { }); }); +// [#27462] `summaryOperations.field` is optional on a `count` (it reads no +// child field). The definitions here go through the spec's own parse first, so +// the shape the engine indexes is the shape the metadata door now accepts. +describe('[#27462] a count roll-up with no summaryOperations.field', () => { + let engine: ObjectQL; + let storeFor: ReturnType['storeFor']; + + beforeEach(async () => { + engine = new ObjectQL(); + const d = makeDriver(); + storeFor = d.storeFor; + engine.registerDriver(d.driver, true); + await engine.init(); + const parentObject = ObjectSchema.parse({ + name: 'inv', + fields: { + name: { type: 'text' }, + line_count: { type: 'summary', summaryOperations: { object: 'inv_line', function: 'count' } }, + }, + }); + expect(parentObject.fields.line_count.summaryOperations).toEqual({ object: 'inv_line', function: 'count' }); + engine.registry.registerObject(parentObject as any); + engine.registry.registerObject(ObjectSchema.parse({ + name: 'inv_line', + fields: { + amount: { type: 'number' }, + inv: { type: 'master_detail', reference: 'inv' }, + }, + }) as any); + }); + + const parent = (id: string) => storeFor('inv').get(id); + + it('counts the children through insert, update and delete', async () => { + const p = await engine.insert('inv', { name: 'INV-1' }); + expect(parent(p.id).line_count).toBe(0); + const a = await engine.insert('inv_line', { inv: p.id, amount: 10 }); + await engine.insert('inv_line', { inv: p.id, amount: 32 }); + await engine.insert('inv_line', [{ inv: p.id }, { inv: p.id, amount: 1 }]); + expect(parent(p.id).line_count).toBe(4); + await engine.update('inv_line', { id: a.id, amount: 11 }); + expect(parent(p.id).line_count).toBe(4); + await engine.delete('inv_line', { where: { id: a.id } }); + expect(parent(p.id).line_count).toBe(3); + }); +}); + describe('roll-up summary fields with a filter predicate', () => { let engine: ObjectQL; let storeFor: ReturnType['storeFor']; diff --git a/packages/spec/src/data/field.test.ts b/packages/spec/src/data/field.test.ts index ebabb6904ac..09e80a73ae0 100644 --- a/packages/spec/src/data/field.test.ts +++ b/packages/spec/src/data/field.test.ts @@ -8,6 +8,7 @@ import { Field, BOUNDED_STRING_FIELD_TYPES, VALUE_DOMAIN_FIELD_TYPES, + summaryFunctionReadsField, type SelectOption, type CurrencyConfig, } from './field.zod'; @@ -1312,6 +1313,40 @@ describe('FieldSchema', () => { expect(() => FieldSchema.parse(field)).not.toThrow(); }); + + // [#27462] The describe always said `field` is ignored for count; the + // schema now agrees: a count may omit it, every other function needs it. + it('accepts a count with no summaryOperations.field, and parses it unchanged', () => { + const parsed = FieldSchema.parse({ + name: 'child_count', + type: 'summary', + summaryOperations: { object: 'child_object', function: 'count' }, + }); + expect(parsed.summaryOperations).toEqual({ object: 'child_object', function: 'count' }); + }); + + it.each(['sum', 'min', 'max', 'avg'] as const)( + 'refuses %s with no summaryOperations.field, at that path, naming the function and the fix', + (fn) => { + const result = FieldSchema.safeParse({ + name: 'child_total', + type: 'summary', + summaryOperations: { object: 'child_object', function: fn }, + }); + expect(result.success).toBe(false); + const issues = result.error!.issues; + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ code: 'custom', path: ['summaryOperations', 'field'] }); + expect(issues[0].message).toContain(`\`summaryOperations.function: '${fn}'\` needs \`summaryOperations.field\``); + expect(issues[0].message).toContain("`function: 'count'`"); + }, + ); + + it('summaryFunctionReadsField: only count reads no child field; an unset function still does', () => { + expect(summaryFunctionReadsField('count')).toBe(false); + for (const fn of ['sum', 'min', 'max', 'avg']) expect(summaryFunctionReadsField(fn)).toBe(true); + expect(summaryFunctionReadsField(undefined)).toBe(true); + }); }); describe('Security and Visibility', () => { diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.test.tsx index 5ad5b07d078..d4a58977ef9 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.test.tsx @@ -2,7 +2,8 @@ import * as React from 'react'; import { describe, it, expect, vi, afterEach } from 'vitest'; -import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; +import { render, screen, fireEvent, cleanup, waitFor, within } from '@testing-library/react'; +import { FieldSchema } from '@objectstack/spec/data'; // The inspector loads the object list (published + drafts) for the lookup // picker; stub both surfaces. @@ -567,16 +568,47 @@ describe('ObjectFieldInspector — summary roll-up editor', () => { }); }); - it('keeps the aggregate-field picker for count (the spec requires the key) with the ignored note', () => { + // #27462 — a count reads no child field, so the spec no longer requires + // `summaryOperations.field` for it (`summaryFunctionReadsField`). The editor + // reads that same predicate: no child-field control for a count. + it('offers no aggregate-field picker for count, only the note that a count needs none', () => { renderField( { total: { type: 'summary', summaryOperations: { object: 'crm_order', function: 'count' } } }, 'total', ); - expect(screen.getByText('Child field to aggregate')).toBeInTheDocument(); - expect(screen.getByText(/ignored for count/i)).toBeInTheDocument(); + expect(screen.queryByText('Child field to aggregate')).not.toBeInTheDocument(); + expect(screen.getByText('Count counts the child rows, so it needs no child field.')).toBeInTheDocument(); expect(screen.getByText('Child relationship field (optional)')).toBeInTheDocument(); }); + it('saves a count roll-up with no child field, and the spec accepts what it saves', async () => { + const { onPatch } = renderField( + { total: { type: 'summary', summaryOperations: { object: 'crm_order' } } }, + 'total', + ); + // Before a function is picked the control is still offered (an unset + // function answers "reads a field"). + expect(screen.getByText('Child field to aggregate')).toBeInTheDocument(); + fireEvent.keyDown(controlFor('Aggregation'), { key: 'ArrowDown' }); + fireEvent.click(within(await screen.findByRole('listbox')).getByRole('option', { name: 'Count — child records' })); + const total = onPatch.mock.calls.at(-1)![0].fields.total; + expect(total.summaryOperations).toEqual({ object: 'crm_order', function: 'count' }); + expect(FieldSchema.safeParse({ name: 'total', ...total }).success).toBe(true); + }); + + it('control: a sum still offers the picker, and the spec still refuses it without a field', () => { + renderField( + { total: { type: 'summary', summaryOperations: { object: 'crm_order', function: 'sum' } } }, + 'total', + ); + expect(screen.getByText('Child field to aggregate')).toBeInTheDocument(); + const parsed = FieldSchema.safeParse({ + name: 'total', type: 'summary', summaryOperations: { object: 'crm_order', function: 'sum' }, + }); + expect(parsed.success).toBe(false); + expect(parsed.error!.issues[0].path).toEqual(['summaryOperations', 'field']); + }); + /* ── filter editor (framework#1868): summaryOperations.filter ── */ it('renders the filter section for a summary field', () => { From 023da3b2d48ccb56351bbbbf103aec7e5140e5cb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 09:38:51 +0000 Subject: [PATCH 3/5] chore(changeset): a count rollup saves without summaryOperations.field Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzh4Bxv1fXaUEeGaKDn --- .../27462-count-rollup-without-field.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .changeset/27462-count-rollup-without-field.md diff --git a/.changeset/27462-count-rollup-without-field.md b/.changeset/27462-count-rollup-without-field.md new file mode 100644 index 00000000000..2f04c13689e --- /dev/null +++ b/.changeset/27462-count-rollup-without-field.md @@ -0,0 +1,20 @@ +--- +'@objectstack/spec': patch +'@objectstack/objectql': patch +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +fix(spec): a count roll-up saves without `summaryOperations.field` + +Clause-②: yes (widening) +Metadata-protocol: none + +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..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: + +- **`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. +- **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'`. +- **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`. +- **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. +- **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. +- **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. From 3974217946302492aed78fc4fb09da3e4e26cf85 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 14:21:56 +0000 Subject: [PATCH 4/5] chore(changeset): declare Metadata-protocol added, minor on every package Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzh4Bxv1fXaUEeGaKDn --- .changeset/27462-count-rollup-without-field.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.changeset/27462-count-rollup-without-field.md b/.changeset/27462-count-rollup-without-field.md index 2f04c13689e..978c00d3759 100644 --- a/.changeset/27462-count-rollup-without-field.md +++ b/.changeset/27462-count-rollup-without-field.md @@ -1,14 +1,14 @@ --- -'@objectstack/spec': patch -'@objectstack/objectql': patch -'@object-ui/app-shell': patch -'@object-ui/i18n': patch +'@objectstack/spec': minor +'@objectstack/objectql': minor +'@object-ui/app-shell': minor +'@object-ui/i18n': minor --- fix(spec): a count roll-up saves without `summaryOperations.field` Clause-②: yes (widening) -Metadata-protocol: none +Metadata-protocol: added 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..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: From 7533c782584d6c8f9bda75d66ad1a3ed02d319c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 14:42:32 +0000 Subject: [PATCH 5/5] docs(skills): a count roll-up omits summaryOperations.field Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzh4Bxv1fXaUEeGaKDn --- .changeset/27462-count-rollup-without-field.md | 2 ++ skills/objectstack-data/rules/field-types.md | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.changeset/27462-count-rollup-without-field.md b/.changeset/27462-count-rollup-without-field.md index 978c00d3759..6d3b9ec452c 100644 --- a/.changeset/27462-count-rollup-without-field.md +++ b/.changeset/27462-count-rollup-without-field.md @@ -1,6 +1,7 @@ --- '@objectstack/spec': minor '@objectstack/objectql': minor +'@objectstack/skills': minor '@object-ui/app-shell': minor '@object-ui/i18n': minor --- @@ -9,6 +10,7 @@ fix(spec): a count roll-up saves without `summaryOperations.field` Clause-②: yes (widening) Metadata-protocol: added +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. 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..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: diff --git a/skills/objectstack-data/rules/field-types.md b/skills/objectstack-data/rules/field-types.md index 4f929d6c349..590ea2cc5fd 100644 --- a/skills/objectstack-data/rules/field-types.md +++ b/skills/objectstack-data/rules/field-types.md @@ -122,7 +122,7 @@ Stored as JSON on the parent row — no separate table / FK: | Type | When to Use | Config | |:-----|:------------|:-------| | `formula` | Computed from an expression referencing other fields — virtual: not aggregatable, a dataset measure needs a stored field (objectstack-ui → Dashboards → Dataset-Bound Widgets) | `expression` (CEL, `record.` prefixes), `returnType` (`'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'`); `'currency'` carries its own `currencyConfig` | -| `summary` | Roll-up aggregation from child records | `summaryOperations` ({ object, field, function, relationshipField?, filter? }) | +| `summary` | Roll-up aggregation from child records | `summaryOperations` ({ object, field? (omit for `count`), function, relationshipField?, filter? }) | | `autonumber` | Auto-incrementing display format ({0000} counter + optional date / {field} tokens, resets per scope) | `format` (shorthand) / `autonumberFormat` (canonical) — e.g., `"CASE-{0000}"`, `"AD{YYYYMMDD}{0000}"` | ## Enhanced Types @@ -256,7 +256,7 @@ const total = { type: 'summary', summaryOperations: { object: 'invoice_line_item', // child object to aggregate - field: 'amount', // child field (ignored for count) + field: 'amount', // child field function: 'sum', // 'count' | 'sum' | 'min' | 'max' | 'avg' // relationshipField / filter — optional (see rules/relationships.md) },