diff --git a/.changeset/aggregate-field-type-compatibility.md b/.changeset/aggregate-field-type-compatibility.md new file mode 100644 index 0000000000..9603b4f3ee --- /dev/null +++ b/.changeset/aggregate-field-type-compatibility.md @@ -0,0 +1,25 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): declare the aggregate × field-type compatibility matrix a dataset measure is judged against — `AGGREGATE_FIELD_TYPE_COMPATIBILITY` and `isAggregateCompatibleWithFieldType` (#16353, spec half of #16099) + +A dataset measure pairs an `aggregate` with a `field`, and nothing between author and driver correlated the two: `avg` over a `Field.datetime` compiled to `AVG(col)` and reached the backend, where one SQL family averages the column's storage form and another rejects the call — one metadata document, two answers. Which pairs are accepted is a contract, so it is now declared once in `@objectstack/spec/data`: + +| Aggregate | Accepted field types | +|---|---| +| `count`, `count_distinct` | every `FieldType` | +| `sum` | `number`, `currency`, `rating`, `slider`, `progress`, `summary` — the numeric class EXCEPT `percent` (a rate does not add; `isIncoherentAggregate` already says so) | +| `avg` | the numeric class, `percent` included | +| `min`, `max` | the numeric class plus `date`, `datetime`, `time` — both return a value of the field's own type | +| every other pair | refused | + +The ruling (director, decision batch #59, 2026-09-06) named its buckets by category; the table resolves them against the real `FieldType` membership through the `field-value.zod` semantic classes: "numeric" is `NUMERIC_VALUE_TYPES` (`integer` is a driver-internal column alias, not a `FieldType` — the integer-valued authorable members are `rating` / `slider` / `progress`); "temporal" is the three temporal classes, `time` included because its stored form is a dialect question exactly like `date` / `datetime` (native TIME on Postgres and MySQL, canonical `HH:MM:SS[.fff]` TEXT on SQLite), the canonical form orders chronologically on every dialect, and `AnalyticsResult.fields[].type` already describes `min` / `max` over it as temporal (#15768). `formula` is refused for arithmetic aggregates whatever its declared `returnType`: it is virtual in SQL storage, no column exists to aggregate. + +Two refused rows override existing opinions and are recorded as such, not presented as agreement. **Booleans** are refused for `sum` / `avg` / `min` / `max` by the ruling's "every other pair: refused", while maintainer ruling #11152 already has every backend answer them as numbers (`sum(flag)=3`, `avg(flag)=0.5`, `min(flag)=0`, `max(flag)=1`, pinned in the spec's `AGGREGATION_CASES`; `driver-sql` casts the aggregand on Postgres to make it hold). That refusal is therefore not grounded in backend divergence; whether booleans belong in those rows is a collision between two rulings and is referred to the maintainer as its own decision — the row ships exactly as batch #59 stated it. **The string classes** are refused for `min` / `max` here, while `service-analytics` (#15768) already types `min` / `max` over them as a supported `'string'` result; the refusal is defensible (string order is collation-dependent) but it overrides that opinion. + +**The narrowing, stated plainly.** Every pair outside the table — `avg` × `datetime`, `sum` × `boolean`, `min` × `text`, `sum` × `percent`, and so on — is an authoring shape `DatasetMeasureSchema` accepts today and will be REFUSED once the two consumer legs land: the compile-time refusal in the dataset compiler (#16099) and the authoring-time lint rule (its devx sub-card). A measure whose pair is refused is fixed by changing the aggregate to one the field's type supports (`min` / `max` for a temporal field; `avg` for a `percent`; `count` for anything), never by widening the table. + +**Not breaking in this release, `minor` on purpose.** This changeset ships a table and a predicate that nothing yet enforces: `DatasetMeasureSchema` accepts byte-for-byte what it accepted before, no export is removed or narrowed, and no runtime path reads the table yet. It is an additive widening of the published surface — two new exports in `dist/*.d.ts` — which the maintainer ruling of 2026-09-04 (decision batch #35) puts at `minor`. The refusal itself arrives with the consumer legs, whose changesets carry the breaking declaration, its migration prescription and the ADR-0087 disposition; this one names the narrowing so an upgrading author can read the contract before it is executed. + +`isIncoherentAggregate` is unchanged and stays the semantic opinion beside this table. The two diverge on exactly one pair: `count_distinct` × `percent` is flagged there and accepted here (the ruling reads `count_distinct` as "any type"). That divergence is pinned in the table's test and reported on #16353 rather than resolved silently. diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 6d5b872370..ad8388155c 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -4,6 +4,7 @@ "exports": [ "ACCEPTED_FILTER_COMPARAND_TYPES (const)", "ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE (const)", + "AGGREGATE_FIELD_TYPE_COMPATIBILITY (const)", "AGGREGATION_CASES (const)", "AGGREGATION_ROWS (const)", "ALL_OPERATORS (const)", @@ -738,6 +739,7 @@ "hookForm (const)", "injectedSystemColumnDefs (function)", "isAcceptedFilterComparand (function)", + "isAggregateCompatibleWithFieldType (function)", "isAnalyticsDateRangeRefusalIssue (function)", "isApiOperationAllowed (function)", "isApiPrimitive (function)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 48f3e7aa4b..198229b0da 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -4,6 +4,7 @@ "exports": { "ACCEPTED_FILTER_COMPARAND_TYPES": "src/data/filter-comparand-type.ts#ACCEPTED_FILTER_COMPARAND_TYPES (const)", "ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE": "src/data/filter-comparand-type.ts#ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE (const)", + "AGGREGATE_FIELD_TYPE_COMPATIBILITY": "src/data/aggregate-field-type-compatibility.ts#AGGREGATE_FIELD_TYPE_COMPATIBILITY (const)", "AGGREGATION_CASES": "src/data/aggregation-conformance.ts#AGGREGATION_CASES (const)", "AGGREGATION_ROWS": "src/data/aggregation-conformance.ts#AGGREGATION_ROWS (const)", "ALL_OPERATORS": "src/data/filter.zod.ts#ALL_OPERATORS (const)", @@ -725,6 +726,7 @@ "hookForm": "src/data/hook.form.ts#hookForm (const)", "injectedSystemColumnDefs": "src/data/injected-system-column-provenance.ts#injectedSystemColumnDefs (function)", "isAcceptedFilterComparand": "src/data/filter-comparand-type.ts#isAcceptedFilterComparand (function)", + "isAggregateCompatibleWithFieldType": "src/data/aggregate-field-type-compatibility.ts#isAggregateCompatibleWithFieldType (function)", "isAnalyticsDateRangeRefusalIssue": "src/data/analytics.zod.ts#isAnalyticsDateRangeRefusalIssue (function)", "isApiOperationAllowed": "src/data/api-derivation.ts#isApiOperationAllowed (function)", "isApiPrimitive": "src/data/api-derivation.ts#isApiPrimitive (function)", diff --git a/packages/spec/src/data/aggregate-field-type-compatibility.test.ts b/packages/spec/src/data/aggregate-field-type-compatibility.test.ts new file mode 100644 index 0000000000..b724dd1974 --- /dev/null +++ b/packages/spec/src/data/aggregate-field-type-compatibility.test.ts @@ -0,0 +1,177 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Pins for the aggregate × field-type compatibility table (#16353). + * + * Two totality claims are the card's acceptance and are held here literally: + * every `AggregationFunction` member has a row, and every `FieldType` member + * is classified (in or out) on every row. The per-row memberships are pinned + * as the LITERAL sets the ruling resolved to, and tied to the `field-value.zod` + * semantic classes so a field type joining the numeric or temporal class + * elsewhere reds this file until the table records a decision. + */ + +import { describe, it, expect } from 'vitest'; +import { AggregationFunction } from './query.zod'; +import { FieldType } from './field.zod'; +import { + NUMERIC_VALUE_TYPES, + CALENDAR_DATE_TYPES, + INSTANT_TYPES, + CLOCK_TIME_TYPES, +} from './field-value.zod'; +import { isIncoherentAggregate } from './aggregation-policy'; +import { + AGGREGATE_FIELD_TYPE_COMPATIBILITY, + isAggregateCompatibleWithFieldType, +} from './aggregate-field-type-compatibility'; + +const sorted = (xs: Iterable) => [...xs].sort(); + +const NUMERIC = ['currency', 'number', 'percent', 'progress', 'rating', 'slider', 'summary']; +const ADDITIVE = NUMERIC.filter((t) => t !== 'percent'); +const TEMPORAL = ['date', 'datetime', 'time']; + +describe('AGGREGATE_FIELD_TYPE_COMPATIBILITY — totality', () => { + it('every AggregationFunction member has a row, and no row is for a non-member', () => { + expect(sorted(Object.keys(AGGREGATE_FIELD_TYPE_COMPATIBILITY))).toEqual(sorted(AggregationFunction.options)); + }); + + it('every row member is a declared FieldType', () => { + const all = new Set(FieldType.options); + for (const row of Object.values(AGGREGATE_FIELD_TYPE_COMPATIBILITY)) { + for (const t of row) expect(all).toContain(t); + } + }); + + it('every FieldType member is classified on every row — the predicate answers a boolean for all pairs', () => { + for (const fn of AggregationFunction.options) { + const row = AGGREGATE_FIELD_TYPE_COMPATIBILITY[fn]; + for (const t of FieldType.options) { + expect(isAggregateCompatibleWithFieldType(fn, t)).toBe(row.includes(t)); + } + } + }); + + it('the table and its rows are frozen — consumers read, never edit', () => { + expect(Object.isFrozen(AGGREGATE_FIELD_TYPE_COMPATIBILITY)).toBe(true); + for (const row of Object.values(AGGREGATE_FIELD_TYPE_COMPATIBILITY)) expect(Object.isFrozen(row)).toBe(true); + }); +}); + +describe('AGGREGATE_FIELD_TYPE_COMPATIBILITY — the ruled rows, resolved against the membership', () => { + it('`count` / `count_distinct`: every FieldType', () => { + expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.count)).toEqual(sorted(FieldType.options)); + expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.count_distinct)).toEqual(sorted(FieldType.options)); + }); + + it('`sum`: the numeric class EXCEPT `percent`', () => { + expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.sum)).toEqual(ADDITIVE); + expect(isAggregateCompatibleWithFieldType('sum', 'percent')).toBe(false); + }); + + it('`avg`: the numeric class, `percent` included', () => { + expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.avg)).toEqual(NUMERIC); + }); + + it('`min` / `max`: the numeric class plus the temporal class', () => { + expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.min)).toEqual(sorted([...NUMERIC, ...TEMPORAL])); + expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.max)).toEqual(sorted([...NUMERIC, ...TEMPORAL])); + }); + + it('the numeric bucket IS the field-value numeric class — a type joining it elsewhere must be decided here', () => { + // `avg` accepts exactly the numeric class; `sum` is that class minus the rate. + expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.avg)).toEqual(sorted(NUMERIC_VALUE_TYPES)); + expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.sum)).toEqual(sorted([...NUMERIC_VALUE_TYPES].filter((t) => t !== 'percent'))); + }); + + it('the temporal bucket IS the three field-value temporal classes', () => { + const temporal = sorted([...CALENDAR_DATE_TYPES, ...INSTANT_TYPES, ...CLOCK_TIME_TYPES]); + expect(temporal).toEqual(TEMPORAL); + const minOnly = AGGREGATE_FIELD_TYPE_COMPATIBILITY.min.filter((t) => !NUMERIC_VALUE_TYPES.has(t)); + expect(sorted(minOnly)).toEqual(temporal); + }); +}); + +describe('isAggregateCompatibleWithFieldType — the pairs the card is about', () => { + it('refuses the motivating defect: `avg` (and `sum`) over a datetime', () => { + expect(isAggregateCompatibleWithFieldType('avg', 'datetime')).toBe(false); + expect(isAggregateCompatibleWithFieldType('sum', 'datetime')).toBe(false); + expect(isAggregateCompatibleWithFieldType('avg', 'date')).toBe(false); + }); + + it('accepts `min` / `max` over every temporal type — they return a value of the field\'s own type', () => { + for (const t of TEMPORAL) { + expect(isAggregateCompatibleWithFieldType('min', t)).toBe(true); + expect(isAggregateCompatibleWithFieldType('max', t)).toBe(true); + } + }); + + it('refuses arithmetic over booleans (row as ruled; membership referred, see module TSDoc) and over the computed / text / structured types', () => { + for (const fn of ['sum', 'avg', 'min', 'max'] as const) { + for (const t of ['boolean', 'toggle', 'formula', 'autonumber', 'text', 'select', 'lookup', 'json', 'vector', 'file']) { + expect(isAggregateCompatibleWithFieldType(fn, t)).toBe(false); + } + } + }); + + it('accepts `count` / `count_distinct` over anything, `vector` and `formula` included', () => { + for (const t of FieldType.options) { + expect(isAggregateCompatibleWithFieldType('count', t)).toBe(true); + expect(isAggregateCompatibleWithFieldType('count_distinct', t)).toBe(true); + } + }); + + it('agrees with `isIncoherentAggregate` on `sum` × `percent`; the one divergence is `count_distinct` × `percent`', () => { + // Both refuse the sum of a rate. + expect(isIncoherentAggregate('sum', 'percent')).toBe(true); + expect(isAggregateCompatibleWithFieldType('sum', 'percent')).toBe(false); + // The semantic opinion flags count_distinct of a rate; the ruling reads + // `count_distinct` as "any type" and this table follows the ruling. Pinned + // so the divergence is visible, not discovered (reported on #16353). + expect(isIncoherentAggregate('count_distinct', 'percent')).toBe(true); + expect(isAggregateCompatibleWithFieldType('count_distinct', 'percent')).toBe(true); + }); + + it('fails closed on vocabulary it does not know — driver aliases and retired functions', () => { + expect(isAggregateCompatibleWithFieldType('sum', 'integer')).toBe(false); + expect(isAggregateCompatibleWithFieldType('sum', 'float')).toBe(false); + expect(isAggregateCompatibleWithFieldType('array_agg', 'text')).toBe(false); + expect(isAggregateCompatibleWithFieldType('countDistinct', 'text')).toBe(false); + expect(isAggregateCompatibleWithFieldType('toString', 'text')).toBe(false); + expect(isAggregateCompatibleWithFieldType('', '')).toBe(false); + }); + + it('fails closed on SHAPE — a non-string that would coerce to a member spelling is refused, not looked up', () => { + // `hasOwnProperty.call` applies ToPropertyKey, so without the typeof guard + // `['count']` reads as 'count' and an object with a toString reads as + // 'sum'. A refusal gate must not be talked past by coercion. + const loose = isAggregateCompatibleWithFieldType as unknown as (a: unknown, f: unknown) => boolean; + expect(loose(['count'], 'number')).toBe(false); + expect(loose({ toString: () => 'sum' }, 'currency')).toBe(false); + expect(loose('sum', ['currency'])).toBe(false); + expect(loose('min', { toString: () => 'date' })).toBe(false); + expect(loose(undefined, 'number')).toBe(false); + expect(loose(null, 'number')).toBe(false); + expect(loose('count', undefined)).toBe(false); + expect(loose('count', null)).toBe(false); + expect(loose(1, 'number')).toBe(false); + expect(loose('count', 1)).toBe(false); + expect(loose(Symbol('count'), 'number')).toBe(false); + }); + + it('records the two overrides of existing opinions without changing the rows: booleans and the string classes', () => { + // Booleans: refused here by the ruling's default; #11152 / AGGREGATION_CASES + // answer them as numbers on every face. Row kept as ruled, question referred. + for (const fn of ['sum', 'avg', 'min', 'max']) { + expect(isAggregateCompatibleWithFieldType(fn, 'boolean')).toBe(false); + expect(isAggregateCompatibleWithFieldType(fn, 'toggle')).toBe(false); + } + // String classes: min/max refused here; measureResultType (#15768) types + // min/max over them as a supported 'string' result. Override recorded. + for (const t of ['text', 'select', 'lookup', 'autonumber']) { + expect(isAggregateCompatibleWithFieldType('min', t)).toBe(false); + expect(isAggregateCompatibleWithFieldType('max', t)).toBe(false); + } + }); +}); diff --git a/packages/spec/src/data/aggregate-field-type-compatibility.ts b/packages/spec/src/data/aggregate-field-type-compatibility.ts new file mode 100644 index 0000000000..c7b31303da --- /dev/null +++ b/packages/spec/src/data/aggregate-field-type-compatibility.ts @@ -0,0 +1,171 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Aggregate × field-type compatibility — the ONE table saying which + * `AggregationFunction` may be applied to a field of which `FieldType` + * (#16353; director ruling, decision batch #59, 2026-09-06: "both legs, table + * in spec"). A `DatasetMeasure` pairs an `aggregate` with a `field`; this + * table is the contract both consumer legs execute — the compile-time refusal + * in the dataset compiler (#16099) and the authoring-time lint rule — so the + * two cannot drift into two accounts of one pair. + * + * ## Why this exists + * + * Nothing between author and driver correlated a measure's `aggregate` with + * the field's type. `avg` over a `Field.datetime` compiled to `AVG(col)` and + * reached the backend, where the answer is a property of the dialect rather + * than of the data: one SQL family averages whatever the column's storage form + * is, another rejects the call outright. Two backends, one metadata document, + * two answers — the shape Prime Directive #12 exists to remove. Which pairs + * are accepted is therefore a narrowing of a published acceptance set, + * declared ONCE here and refused at authoring / compile time — never + * tolerated in a consumer with a fallback. + * + * ## The rule + * + * | Aggregate | Accepted field types | + * |---|---| + * | `count`, `count_distinct` | every `FieldType` — counting rows or distinct values reads no arithmetic off the value | + * | `sum` | the numeric class EXCEPT `percent` — a rate does not add (see `isIncoherentAggregate`) | + * | `avg` | the numeric class, `percent` included | + * | `min`, `max` | the numeric class plus the temporal class — both return a value of the field's OWN type (#15768) | + * | every other pair | refused | + * + * ## How the ruling's categories resolve against the real membership + * + * The ruling named its buckets by category ("numeric", "`integer`-class", + * "temporal"); this file resolves each against the `FieldType` enum through + * the `field-value.zod` semantic classes — the sets the SQL DDL actually + * stores by: + * + * - **numeric** = `NUMERIC_VALUE_TYPES`: `number`, `currency`, `percent`, + * `rating`, `slider`, `progress`, `summary`. "`integer`-class" is not a + * `FieldType` member — `integer` / `int` are driver-internal column aliases + * (`type-compat.ts`, `sql-driver.ts`) — so it resolves to the integer-valued + * authorable members `rating`, `slider`, `progress`, which the driver stores + * as REAL columns beside `number`. `summary` is a roll-up persisted as a + * numeric column. + * - **temporal** = `CALENDAR_DATE_TYPES` ∪ `INSTANT_TYPES` ∪ `CLOCK_TIME_TYPES`: + * `date`, `datetime`, `time`. The ruling named the first two; `time` is the + * third temporal class and takes the same treatment: its stored form is a + * dialect question exactly like the other two (native TIME on Postgres and + * MySQL `TIME(3)`, canonical `HH:MM:SS[.fff]` TEXT on SQLite — #3994), the + * canonical form orders chronologically on every one of them, and + * `AnalyticsResult.fields[].type` already describes `min` / `max` over it as + * temporal (#15768, `TEMPORAL_SOURCE_FIELD_TYPES`). So it sits beside the + * two members the ruling named. + * - **everything else** — the text family, booleans, option types, references, + * files, structured JSON, `vector`, and the computed `formula` / + * `autonumber` — is refused for `sum` / `avg` / `min` / `max`, the ruling's + * "every other pair: refused". `formula` carries a declared `returnType`, + * but it is VIRTUAL in SQL storage (no column is emitted), so no arithmetic + * aggregate can be lowered to it whatever that type says; `autonumber` is a + * formatted string. + * + * Two rows the ruling's default covers are recorded here as OVERRIDES of + * existing opinions, not as settled ground — the row stands as ruled, the + * text says only what this tree can defend: + * + * - **Booleans** (`boolean`, `toggle`) are refused for the four arithmetic / + * order aggregates by the ruling's default, yet the runtime already ANSWERS + * them: maintainer ruling #11152 pins that booleans aggregate as numbers on + * every face with no per-aggregate exception (`AGGREGATION_CASES` in + * `aggregation-conformance.ts`: `sum(flag)=3`, `avg(flag)=0.5`, + * `min(flag)=0`, `max(flag)=1`, six backends), and `driver-sql` casts a + * boolean aggregand to `int` on Postgres to make that hold (#11635). So the + * refusal is NOT grounded in backend divergence — the backends agree. Whether + * booleans belong in these rows is a collision between two rulings (batch + * #59 and #11152) and is referred to the maintainer as its own decision; the + * row is left exactly as batch #59 stated it until that decision lands. + * - **The string classes** (`STRING_VALUE_TYPES`, `SINGLE_OPTION_TYPES`, + * `REFERENCE_VALUE_TYPES`, `autonumber`) are refused for `min` / `max` here, + * while `service-analytics`' `measureResultType` (#15768, + * `STRING_SOURCE_FIELD_TYPES`) already types `min` / `max` over them as a + * supported `'string'` result. The refusal is defensible — the ORDER of + * strings is collation-dependent, so two backends can return two different + * "smallest" values — but it overrides that existing opinion, and is + * recorded as such rather than presented as agreement. + * + * ## Relation to `isIncoherentAggregate` + * + * That predicate (`aggregation-policy.ts`) is the SEMANTIC opinion — "does + * this number mean anything" — and `sum` × `percent` is refused here on its + * authority. It also flags `count_distinct` × `percent`, which this table + * ACCEPTS: the ruling reads `count_distinct` as "any type", and counting + * distinct rates is backend-consistent even where it is odd. The two stay + * separate on purpose: this table answers "can every backend give one + * answer", the lint warning answers "is that answer meaningful". + * + * ## What this module deliberately does NOT do + * + * It refuses nothing itself. The refusals are the two consumer legs; a + * consumer that cannot resolve a field's type (a relationship PATH it has no + * metadata for) must NOT call the predicate with a guess — "cannot answer, do + * not block" is the consumer's tier, not this table's. + */ + +import type { AggregationFunction } from './query.zod'; +import { FieldType } from './field.zod'; + +/** + * The numeric class — the `NUMERIC_VALUE_TYPES` membership, spelled out here + * rather than imported so that a type joining that class elsewhere is a + * DECISION in this file (the pin test holds the two equal), never a silent + * widening of what a backend is asked to add up. + */ +const NUMERIC_AGGREGATE_FIELD_TYPES = [ + 'number', 'currency', 'percent', 'rating', 'slider', 'progress', 'summary', +] as const satisfies readonly FieldType[]; + +/** The numeric class minus the rate: what `sum` may add. */ +const ADDITIVE_AGGREGATE_FIELD_TYPES = [ + 'number', 'currency', 'rating', 'slider', 'progress', 'summary', +] as const satisfies readonly FieldType[]; + +/** The temporal class: `min` / `max` return a value of the field's own type. */ +const TEMPORAL_AGGREGATE_FIELD_TYPES = [ + 'date', 'datetime', 'time', +] as const satisfies readonly FieldType[]; + +/** Every declared `FieldType` — the `count` / `count_distinct` row. */ +const ANY_FIELD_TYPE: readonly FieldType[] = Object.freeze([...FieldType.options]); + +/** + * Which `FieldType`s each `AggregationFunction` may be applied to. Total over + * `AggregationFunction` (the `Record` key type makes a missing row a `tsc` + * error) and total over `FieldType` (a type is classified by being in, or out + * of, every row). Consumers read it through + * {@link isAggregateCompatibleWithFieldType}; the table is exported so a + * refusal can NAME the accepted set in its message. + */ +export const AGGREGATE_FIELD_TYPE_COMPATIBILITY: Readonly> = + Object.freeze({ + count: ANY_FIELD_TYPE, + count_distinct: ANY_FIELD_TYPE, + sum: Object.freeze([...ADDITIVE_AGGREGATE_FIELD_TYPES]), + avg: Object.freeze([...NUMERIC_AGGREGATE_FIELD_TYPES]), + min: Object.freeze([...NUMERIC_AGGREGATE_FIELD_TYPES, ...TEMPORAL_AGGREGATE_FIELD_TYPES]), + max: Object.freeze([...NUMERIC_AGGREGATE_FIELD_TYPES, ...TEMPORAL_AGGREGATE_FIELD_TYPES]), + }); + +/** + * May `aggregate` be applied to a field of `fieldType`? The single predicate + * both consumer legs call, so one pair cannot be accepted at authoring and + * refused at compile time. + * + * Fail-closed on vocabulary AND on shape: a value outside `AggregationFunction` + * or outside `FieldType` answers `false`, and so does anything that is not a + * string at all. The second half is load-bearing for a refusal gate: the lint + * leg judges metadata BEFORE it is parsed, so the value it hands in may be an + * array or an object, and a property-key lookup alone would coerce + * `['count']` or `{ toString: () => 'sum' }` to a member spelling and let the + * pair through. The `string` parameter types are a convenience of the + * signature, not a tolerance — off-vocabulary or off-shape input is refused, + * never mapped. + */ +export function isAggregateCompatibleWithFieldType(aggregate: string, fieldType: string): boolean { + if (typeof aggregate !== 'string' || typeof fieldType !== 'string') return false; + if (!Object.prototype.hasOwnProperty.call(AGGREGATE_FIELD_TYPE_COMPATIBILITY, aggregate)) return false; + const row: readonly string[] = AGGREGATE_FIELD_TYPE_COMPATIBILITY[aggregate as AggregationFunction]; + return row.includes(fieldType); +} diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index e00c07a3d1..8f83fe5b2e 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -210,6 +210,11 @@ export * from './analytics.zod'; // and build-time coherence validation. export * from './aggregation-policy'; +// Aggregate × field-type compatibility (#16353) — which `AggregationFunction` +// a `DatasetMeasure` may apply to a field of which `FieldType`; the one table +// the dataset compiler and the lint rule both refuse against. +export * from './aggregate-field-type-compatibility'; + // Percent storage scale (0–1 fraction vs whole percentage points) — resolved // from field metadata so renderers never guess it from the value's magnitude. export * from './percent-scale';