diff --git a/.changeset/analytics-measure-result-type-string-family.md b/.changeset/analytics-measure-result-type-string-family.md new file mode 100644 index 0000000000..95c9311bd3 --- /dev/null +++ b/.changeset/analytics-measure-result-type-string-family.md @@ -0,0 +1,25 @@ +--- +"@objectstack/service-analytics": minor +--- + +A `min`/`max` over a string-valued field is described as `string`, not `number` (#16098) + +The sibling population of the temporal fix. `min` and `max` return a value **of the aggregated field's own type**, so a `min` over a `text` / `select` / `lookup` / `autonumber` column carries a string — and `POST /api/v1/analytics/dataset/query` described every one of those columns as `type: "number"`, exactly as it did for the temporal family before the temporal half landed. + +What changed: + +- **`measureResultType` now answers `string` for the string-valued field types too**, in the same one table it already answered `time` from. No second mechanism and no new call site: the rule still answers `undefined` for "no correction", and `queryDataset`'s ADR-0021 result-column enrichment still applies it once, downstream of all four producers of the shape. +- **The corrected spelling is `string`**, the `DimensionType` word a `lookup` or `string` DIMENSION column in the same response already carries (`dataset-compiler.dimensionType`). A textual measure spelled `text` would have been a sixth word in a five-word wire vocabulary, leaving every existing consumer branch unreached — the same argument that chose `time` over `datetime`. +- **Membership is composed from `@objectstack/spec`'s own value classes** (`STRING_VALUE_TYPES`, `SINGLE_OPTION_TYPES`, `REFERENCE_VALUE_TYPES`) rather than re-listed, so what the platform says a field type STORES and what this rule says a `min` over it RETURNS cannot drift. + +Corrected: `text`, `textarea`, `email`, `url`, `phone`, `password`, `secret`, `markdown`, `html`, `richtext`, `code`, `color`, `signature`, `qrcode`, `select`, `radio`, `lookup`, `master_detail`, `tree`, `user`, `autonumber` — twenty-one members, each verdict read off the two shipped statements of what the type stores (the spec value contract and `driver-sql`'s DDL column switch). + +Deliberately NOT corrected, with the measurement recorded rather than a guess shipped as a declaration: + +- **`boolean` / `toggle`** — Postgres has no `min(boolean)` at all, SQLite answers `0`/`1` as numbers, and the driver seam has been recorded answering `false`/`true`. Three readings that disagree about whether a value exists and what kind it is. `DimensionType` does carry a `boolean` word, so the correction is spellable; it is not made. +- **The JSON-column classes** (`multiselect` / `checkboxes` / `tags`, `composite` / `repeater` / `record` / `location` / `address` / `vector`, `json`) — no `min` over `jsonb` on Postgres, serialized TEXT on SQLite. +- **The file types** (`image` / `file` / `avatar` / `video` / `audio`) — their stored form is mid-migration under ADR-0104 D3: the value contract already says an opaque `sys_file` id while the DDL still gives them a JSON column. +- **`formula`** — its result type IS declared, on `FieldSchema.returnType`, but that key is not on `AnalyticsServiceConfig.sourceFieldMeta`'s return shape and is itself optional. +- **`summary`** — measured NUMERIC on both shipped statements (the spec's `NUMERIC_VALUE_TYPES`, and `driver-sql`'s `table.float` column), so the `number` it already carried is correct rather than merely unexamined. + +Every member of `FieldType` now carries an explicit verdict, pinned by a test that walks the enum: a field type added to the spec fails that pin instead of silently inheriting the flat `number`. diff --git a/packages/services/service-analytics/src/__tests__/measure-result-type.test.ts b/packages/services/service-analytics/src/__tests__/measure-result-type.test.ts index b4f65a2ee9..20d6b965bb 100644 --- a/packages/services/service-analytics/src/__tests__/measure-result-type.test.ts +++ b/packages/services/service-analytics/src/__tests__/measure-result-type.test.ts @@ -1,8 +1,8 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * #15768 — a dataset measure's `fields[].type` must describe the value sitting - * beside it in the same response. + * A dataset measure's `fields[].type` must describe the value sitting beside it + * in the same response. * * Measured on a real boot (`@objectstack/cli` 17.3.0, SQLite dev datasource), * `POST /api/v1/analytics/dataset/query` answered a `min` over a @@ -17,7 +17,9 @@ * The value is an ISO instant; the metadata beside it says `number`. Every * producer of this shape minted a flat `'number'` for every measure, so a * renderer that branches on the declared type could never reach a temporal - * branch for the column. + * branch for the column. The STRING half is the same defect over a different + * population: a `min` over a `text` / `select` / `lookup` / `autonumber` column + * returns a string and was described as `number` too. * * ## Where the assembly point is, and how this file proves it is the real one * @@ -46,74 +48,209 @@ * the supplementary-sub-query producer (every base measure filter-scoped, the * card's own shape) and the `__compare` producer for the same reason. * + * ## Section A walks BOTH closed vocabularies + * + * `AggregationFunction` and `FieldType` are both closed enums, so "which pairs + * does this rule speak about" has a finite answer and every member of each axis + * carries an explicit verdict here. A member ADDED to either enum lands as a + * failure of the exhaustiveness guard rather than silently inheriting the flat + * `number` — which is the whole point of walking the enum instead of sampling + * it. The `why` column on each row is the MEASUREMENT behind the verdict, not + * a preference; `measure-result-type.ts`'s header carries the long form. + * * ## Reverse verification, direction predicted BEFORE running * * Reverting ONLY the two-line call site in `analytics-service.ts` (leaving * `measure-result-type.ts` in place) must turn RED every assertion that expects - * `'time'` — sections B and C — and leave section A (the rule in isolation) and - * section D (the columns the rule deliberately does not touch) GREEN. Ordinary - * direction: the change CORRECTS a value on existing entries, mints no column - * and removes no limb, so nothing downstream can gain or lose a finding. - * Predicted red: the four `'time'` cases. Measured: recorded in the PR body. + * `'time'` or `'string'` — sections B, C and E — and leave section A (the rule + * in isolation) and section D (the columns the rule deliberately does not + * touch) GREEN. Ordinary direction: the change CORRECTS a value on existing + * entries, mints no column and removes no limb, so nothing downstream can gain + * or lose a finding. Measured: recorded in the PR body. */ import { describe, it, expect } from 'vitest'; -import { AggregationFunction } from '@objectstack/spec/data'; +import { AggregationFunction, FieldType } from '@objectstack/spec/data'; import { DatasetSchema } from '@objectstack/spec/ui'; import type { ExecutionContext } from '@objectstack/spec/kernel'; import { AnalyticsService } from '../analytics-service.js'; -import { measureResultType, MEASURE_RESULT_TYPE_TEMPORAL } from '../measure-result-type.js'; +import { + measureResultType, + MEASURE_RESULT_TYPE_STRING, + MEASURE_RESULT_TYPE_TEMPORAL, +} from '../measure-result-type.js'; const CTX = { tenantId: 'org_A' } as ExecutionContext; // ───────────────────────────────────────────────────────────────────────────── -// A) the CLOSED aggregate vocabulary, enumerated rather than sampled +// A) both CLOSED vocabularies, enumerated rather than sampled // ───────────────────────────────────────────────────────────────────────────── /** * One row per member of `AggregationFunction`, with what the member answers and - * why. `overTemporal` is the verdict for a `date`/`datetime`/`time` source - * field; `undefined` means "this rule says nothing — the producer's `'number'` - * stands", which for four of the six members is the CORRECT answer rather than - * an omission. + * why. `corrects` says whether the member is one this rule speaks about at all; + * `undefined` for the other four means "this rule says nothing — the producer's + * `'number'` stands", which is the CORRECT answer rather than an omission. */ -const VOCABULARY: ReadonlyArray<{ +const AGGREGATE_VOCABULARY: ReadonlyArray<{ fn: (typeof AggregationFunction.options)[number]; - overTemporal: string | undefined; + corrects: boolean; + why: string; +}> = [ + { fn: 'count', corrects: false, why: 'a row count is a number however the counted column is typed' }, + { fn: 'count_distinct', corrects: false, why: 'a cardinality is a number, same reason as count' }, + { fn: 'sum', corrects: false, why: 'unrefused and backend-decided over a non-numeric column — no single value to type' }, + { fn: 'avg', corrects: false, why: 'same as sum: an epoch mean on SQLite, a refusal on Postgres' }, + { fn: 'min', corrects: true, why: 'returns a value of the aggregated field own type' }, + { fn: 'max', corrects: true, why: 'returns a value of the aggregated field own type' }, +]; + +/** + * The bucket each `FieldType` member lands in. The three that matter are the + * card's own split, made honest by measuring rather than guessing: + * + * - `string` / `temporal` — the stored value's kind is established by BOTH + * shipped statements of it (the spec value contract and driver-sql's DDL), + * so the wire word follows from a measurement. + * - `numeric-correct` — the producer's `number` is right; there is nothing to + * correct. Not the same thing as "unexamined". + * - `backend-dependent` — measured, and the readings do not converge on one + * value (or on whether a value exists at all). Left uncorrected on purpose; + * the missing refusal is owned by the `needs-user-decision` card for "no + * layer refuses an incoherent aggregate / field-type pair". + * - `not-on-this-input` — an answer exists in the metadata but not on this + * rule's input (`formula.returnType`, which `sourceFieldMeta` does not + * carry). Filed rather than guessed. + */ +type FieldTypeBucket = + | 'string' + | 'temporal' + | 'numeric-correct' + | 'backend-dependent' + | 'not-on-this-input'; + +const EXPECTED_BY_BUCKET: Record = { + string: MEASURE_RESULT_TYPE_STRING, + temporal: MEASURE_RESULT_TYPE_TEMPORAL, + 'numeric-correct': undefined, + 'backend-dependent': undefined, + 'not-on-this-input': undefined, +}; + +/** + * One row per member of `FieldType` — the enumerated verdict the card asks for. + * The three `undefined` buckets are deliberately kept APART even though they + * answer the same value: "the producer is already right", "no backend- + * independent answer exists" and "the answer is not on this input" are + * different findings, and collapsing them into one default branch is exactly + * how the uncertain members would get silently swallowed. + */ +const FIELD_TYPE_VERDICTS: ReadonlyArray<{ + type: (typeof FieldType.options)[number]; + bucket: FieldTypeBucket; why: string; }> = [ - { fn: 'count', overTemporal: undefined, why: 'a row count is a number however temporal the counted column is' }, - { fn: 'count_distinct', overTemporal: undefined, why: 'a cardinality is a number, same reason as count' }, - { fn: 'sum', overTemporal: undefined, why: 'unrefused and backend-decided over a temporal column — no single value to type' }, - { fn: 'avg', overTemporal: undefined, why: 'same as sum: an epoch mean on SQLite, a refusal on Postgres' }, - { fn: 'min', overTemporal: MEASURE_RESULT_TYPE_TEMPORAL, why: 'returns a value of the aggregated field own type' }, - { fn: 'max', overTemporal: MEASURE_RESULT_TYPE_TEMPORAL, why: 'returns a value of the aggregated field own type' }, + // ── the plain-string class: spec `STRING_VALUE_TYPES`, a string/TEXT column ── + { type: 'text', bucket: 'string', why: 'valueSchemaFor answers z.string(); TEXT column' }, + { type: 'textarea', bucket: 'string', why: 'valueSchemaFor answers z.string(); TEXT column' }, + { type: 'email', bucket: 'string', why: 'valueSchemaFor answers z.string(); TEXT column' }, + { type: 'url', bucket: 'string', why: 'valueSchemaFor answers z.string(); TEXT column' }, + { type: 'phone', bucket: 'string', why: 'valueSchemaFor answers z.string(); TEXT column' }, + { type: 'password', bucket: 'string', why: 'stored plaintext-or-hashed, masked on read — a string either way' }, + { type: 'secret', bucket: 'string', why: 'stores an opaque sys_secret ref, masked on read — a string either way' }, + { type: 'markdown', bucket: 'string', why: 'a multi-line body in a TEXT column' }, + { type: 'html', bucket: 'string', why: 'a multi-line body in a TEXT column' }, + { type: 'richtext', bucket: 'string', why: 'a multi-line body in a TEXT column' }, + { type: 'code', bucket: 'string', why: 'stores the editor contents verbatim (measured in field-zoo)' }, + { type: 'color', bucket: 'string', why: 'a color code string' }, + { type: 'signature', bucket: 'string', why: 'a data-URI string; the write seam enforces its declared bound' }, + { type: 'qrcode', bucket: 'string', why: 'a data-URI string; the write seam enforces its declared bound' }, + // ── one declared option code: spec `SINGLE_OPTION_TYPES` ── + { type: 'select', bucket: 'string', why: 'one option code; optionCodes stringifies a numerically-spelled code' }, + { type: 'radio', bucket: 'string', why: 'one option code, same branch as select' }, + // ── the referenced row id: spec `REFERENCE_VALUE_TYPES` ── + { type: 'lookup', bucket: 'string', why: 'ReferenceIdValueSchema is z.string() — the id, never the expanded record' }, + { type: 'master_detail', bucket: 'string', why: 'same ReferenceIdValueSchema as lookup' }, + { type: 'tree', bucket: 'string', why: 'same ReferenceIdValueSchema as lookup' }, + { type: 'user', bucket: 'string', why: 'a lookup fixed to sys_user; identical storage' }, + // ── measured, not assumed ── + { type: 'autonumber', bucket: 'string', why: 'renderAutonumber returns a zero-padded string; DDL is table.string; SQLite min/max over padded numbers returns text' }, + // ── the temporal family, landed earlier ── + { type: 'date', bucket: 'temporal', why: 'a calendar day, stored YYYY-MM-DD' }, + { type: 'datetime', bucket: 'temporal', why: 'an ISO instant with explicit zone' }, + { type: 'time', bucket: 'temporal', why: 'a wall-clock time of day' }, + // ── genuinely numeric: the producer is already right ── + { type: 'number', bucket: 'numeric-correct', why: 'spec NUMERIC_VALUE_TYPES; a numeric column' }, + { type: 'currency', bucket: 'numeric-correct', why: 'a bare number on the wire (ADR-0104 header)' }, + { type: 'percent', bucket: 'numeric-correct', why: 'spec NUMERIC_VALUE_TYPES; a numeric column' }, + { type: 'rating', bucket: 'numeric-correct', why: 'spec NUMERIC_VALUE_TYPES; a numeric column' }, + { type: 'slider', bucket: 'numeric-correct', why: 'spec NUMERIC_VALUE_TYPES; a numeric column' }, + { type: 'progress', bucket: 'numeric-correct', why: 'spec NUMERIC_VALUE_TYPES; a numeric column' }, + { type: 'summary', bucket: 'numeric-correct', why: 'spec NUMERIC_VALUE_TYPES and DDL table.float — both shipped statements say numeric' }, + // ── measured, and the readings do not converge ── + { type: 'boolean', bucket: 'backend-dependent', why: 'Postgres has no min(boolean) at all; SQLite answers 0/1 as numbers; the driver seam has been recorded answering false/true' }, + { type: 'toggle', bucket: 'backend-dependent', why: 'a boolean rendered as a switch — same column, same three readings' }, + { type: 'multiselect', bucket: 'backend-dependent', why: 'an array in a JSON column: no min over jsonb on Postgres, serialized TEXT on SQLite' }, + { type: 'checkboxes', bucket: 'backend-dependent', why: 'an array in a JSON column, same as multiselect' }, + { type: 'tags', bucket: 'backend-dependent', why: 'a free-form array in a JSON column, same as multiselect' }, + { type: 'image', bucket: 'backend-dependent', why: 'stored form mid-migration (ADR-0104 D3): contract says opaque id, DDL still a JSON column' }, + { type: 'file', bucket: 'backend-dependent', why: 'stored form mid-migration, same as image' }, + { type: 'avatar', bucket: 'backend-dependent', why: 'stored form mid-migration, same as image' }, + { type: 'video', bucket: 'backend-dependent', why: 'stored form mid-migration, same as image' }, + { type: 'audio', bucket: 'backend-dependent', why: 'stored form mid-migration, same as image' }, + { type: 'composite', bucket: 'backend-dependent', why: 'an object in a JSON column' }, + { type: 'repeater', bucket: 'backend-dependent', why: 'an array of objects in a JSON column' }, + { type: 'record', bucket: 'backend-dependent', why: 'a name-keyed map in a JSON column' }, + { type: 'location', bucket: 'backend-dependent', why: 'a {lat,lng} object in a JSON column' }, + { type: 'address', bucket: 'backend-dependent', why: 'a structured object in a JSON column' }, + { type: 'vector', bucket: 'backend-dependent', why: 'a number array in a JSON column' }, + { type: 'json', bucket: 'backend-dependent', why: 'the untyped escape hatch — the value contract is explicitly open (z.unknown())' }, + // ── answerable, but not from this rule's input ── + { type: 'formula', bucket: 'not-on-this-input', why: 'FieldSchema.returnType declares it, but sourceFieldMeta returns only { type, defaultCurrency, max } — and returnType is itself optional' }, ]; -describe('A) measureResultType covers the whole closed AggregationFunction vocabulary', () => { - it('the table enumerates every declared member, and only declared members', () => { - // The exhaustiveness guard. A member ADDED to the spec enum lands here as a - // failure rather than silently falling through `measureResultType` as - // "nothing to say" — which is exactly how a new aggregate would inherit the - // flat `number` this card is about. - expect([...VOCABULARY.map((v) => v.fn)].sort()).toEqual([...AggregationFunction.options].sort()); +describe('A) measureResultType covers both closed vocabularies, member by member', () => { + it('the aggregate table enumerates every declared member, and only declared members', () => { + // A member ADDED to the spec enum lands here as a failure rather than + // silently falling through `measureResultType` as "nothing to say" — which + // is exactly how a new aggregate would inherit the flat `number`. + expect([...AGGREGATE_VOCABULARY.map((v) => v.fn)].sort()) + .toEqual([...AggregationFunction.options].sort()); + }); + + it('the field-type table enumerates every declared member, and only declared members', () => { + // The same guard on the other axis, and the one this card is about: a new + // FieldType cannot fall through unconsidered. It must be given a bucket + // here — including the honest buckets, which answer `undefined`. + expect([...FIELD_TYPE_VERDICTS.map((v) => v.type)].sort()) + .toEqual([...FieldType.options].sort()); }); - for (const { fn, overTemporal, why } of VOCABULARY) { - it(`${fn} over a temporal field → ${overTemporal ?? 'no correction'} (${why})`, () => { - expect(measureResultType(fn, 'datetime')).toBe(overTemporal); - expect(measureResultType(fn, 'date')).toBe(overTemporal); - expect(measureResultType(fn, 'time')).toBe(overTemporal); + it('every bucket is populated — the split is real, not three names for one branch', () => { + const buckets = new Set(FIELD_TYPE_VERDICTS.map((v) => v.bucket)); + expect([...buckets].sort()).toEqual([ + 'backend-dependent', 'not-on-this-input', 'numeric-correct', 'string', 'temporal', + ]); + }); + + for (const { type, bucket, why } of FIELD_TYPE_VERDICTS) { + const expected = EXPECTED_BY_BUCKET[bucket]; + it(`min/max over ${type} → ${expected ?? 'no correction'} [${bucket}] (${why})`, () => { + expect(measureResultType('min', type)).toBe(expected); + expect(measureResultType('max', type)).toBe(expected); }); - it(`${fn} over a NUMBER field is never corrected`, () => { - expect(measureResultType(fn, 'number')).toBeUndefined(); - expect(measureResultType(fn, 'currency')).toBeUndefined(); + it(`the non-min/max aggregates over ${type} are never corrected`, () => { + for (const { fn, corrects } of AGGREGATE_VOCABULARY) { + if (corrects) continue; + expect(measureResultType(fn, type)).toBeUndefined(); + } }); } it('a derived measure (no aggregate) is never corrected — computeDerived coerces with Number()', () => { expect(measureResultType(undefined, 'datetime')).toBeUndefined(); + expect(measureResultType(undefined, 'text')).toBeUndefined(); }); it('an unanswerable source field is left alone ("cannot answer, do not block")', () => { @@ -123,16 +260,24 @@ describe('A) measureResultType covers the whole closed AggregationFunction vocab expect(measureResultType('max', undefined)).toBeUndefined(); }); - it('min/max over a NON-temporal field is deliberately out of this rule population', () => { - // Still described as `number`, and still wrong for a text column — reported - // as its own finding rather than absorbed here. - expect(measureResultType('min', 'text')).toBeUndefined(); - expect(measureResultType('max', 'select')).toBeUndefined(); + it('a field type outside the enum entirely is left alone, not defaulted', () => { + // A driver-internal alias or an unrecognised string reaching this input is + // the same "cannot answer" tier — never a guess at `string`. + expect(measureResultType('min', 'integer')).toBeUndefined(); + expect(measureResultType('min', 'not_a_field_type')).toBeUndefined(); + }); + + it('the two minted words are the DimensionType spellings, not FieldType ones', () => { + // The wire vocabulary here is `string` / `number` / `boolean` / `time` / + // `geo`. A sixth word would leave every existing consumer branch unreached. + expect(MEASURE_RESULT_TYPE_STRING).toBe('string'); + expect(MEASURE_RESULT_TYPE_TEMPORAL).toBe('time'); }); }); // ───────────────────────────────────────────────────────────────────────────── -// the fixture — the card shape: a `min` over a `Field.datetime` +// the fixture — the card shape: a `min` over a `Field.datetime`, plus the +// string-family population and the controls that must not move // ───────────────────────────────────────────────────────────────────────────── const dataset = DatasetSchema.parse({ @@ -144,6 +289,9 @@ const dataset = DatasetSchema.parse({ // The dated axis `compareTo` shifts. Its own descriptor is the control in // section D: a temporal DIMENSION column has always said `time`. { name: 'touched_on', field: 'last_update_at', type: 'date', label: 'Touched' }, + // The OTHER control in section D: a lookup DIMENSION column has always said + // `string` — the reason the corrected measure spelling is `string` too. + { name: 'by_owner', field: 'owner_id', type: 'lookup', label: 'Owner' }, ], measures: [ // The card's measure, verbatim in shape: `min` over a datetime, carrying a @@ -152,29 +300,78 @@ const dataset = DatasetSchema.parse({ { name: 'oldest_last_update_at', aggregate: 'min', field: 'last_update_at', label: 'Oldest touch', format: 'relative', filter: { status: 'open' } }, // `max` over the same column, unfiltered → the primary `buildFieldMeta` producer. { name: 'newest_last_update_at', aggregate: 'max', field: 'last_update_at', label: 'Newest touch' }, + // The STRING population this card lands — one member per value class. + { name: 'first_subject', aggregate: 'min', field: 'subject', label: 'First subject' }, + // …with a measure-scoped filter, so the string family covers the + // supplementary-sub-query producer too, not only the primary one. + { name: 'last_subject', aggregate: 'max', field: 'subject', label: 'Last subject', filter: { status: 'open' } }, + { name: 'first_status_code', aggregate: 'min', field: 'status', label: 'First status' }, + { name: 'first_owner_id', aggregate: 'min', field: 'owner_id', label: 'First owner' }, + { name: 'first_case_no', aggregate: 'min', field: 'case_no', label: 'First case number' }, // The controls that must NOT move. { name: 'task_count', aggregate: 'count', label: 'Tasks' }, { name: 'counted_touches', aggregate: 'count', field: 'last_update_at', label: 'Touched' }, + { name: 'counted_subjects', aggregate: 'count', field: 'subject', label: 'Subjects' }, { name: 'summed_touches', aggregate: 'sum', field: 'last_update_at', label: 'Summed touches' }, { name: 'avg_touch', aggregate: 'avg', field: 'last_update_at', label: 'Average touch' }, { name: 'min_estimate', aggregate: 'min', field: 'estimate_hours', label: 'Smallest estimate' }, + { name: 'min_flag', aggregate: 'min', field: 'is_urgent', label: 'Min urgency flag' }, + { name: 'min_payload', aggregate: 'min', field: 'payload', label: 'Min payload' }, + { name: 'min_margin', aggregate: 'min', field: 'margin', label: 'Min margin' }, + { name: 'min_child_total', aggregate: 'min', field: 'child_total', label: 'Min child total' }, { name: 'touch_ratio', derived: { op: 'ratio', of: ['counted_touches', 'task_count'] }, label: 'Touch ratio' }, ], }); -/** `duly_task.last_update_at` is `Field.datetime`; `estimate_hours` is a number. */ +/** + * The declared `FieldType` of each aggregated column — one member of every + * bucket section A enumerates, so the end-to-end sections exercise the real + * split rather than one representative. + */ +const FIELD_TYPES: Record = { + last_update_at: 'datetime', // temporal + subject: 'text', // string — plain + status: 'select', // string — one option code + owner_id: 'lookup', // string — a referenced row id + case_no: 'autonumber', // string — the rendered record number + estimate_hours: 'number', // numeric-correct + is_urgent: 'boolean', // backend-dependent + payload: 'json', // backend-dependent + margin: 'formula', // not-on-this-input + child_total: 'summary', // numeric-correct +}; + const sourceFieldMeta = (_object: string, field: string) => - field === 'last_update_at' - ? { type: 'datetime' } - : field === 'estimate_hours' - ? { type: 'number' } - : undefined; + FIELD_TYPES[field] ? { type: FIELD_TYPES[field] } : undefined; const OLDEST = '2026-07-04T07:00:00.000Z'; const NEWEST = '2026-08-30T09:15:00.000Z'; -/** The grid every fake producer below answers with. */ -const GRID = [{ status: 'open', oldest_last_update_at: OLDEST, newest_last_update_at: NEWEST, task_count: 3, counted_touches: 3, summed_touches: 12, avg_touch: 4, min_estimate: 2 }]; +/** + * The grid every fake producer below answers with. The string columns carry the + * values SQLite really answers for `min`/`max` over TEXT — measured directly: + * lexicographic, and a zero-padded record number stays padded text. + */ +const GRID = [{ + status: 'open', + oldest_last_update_at: OLDEST, + newest_last_update_at: NEWEST, + first_subject: 'Archive the backlog', + last_subject: 'Zip the release notes', + first_status_code: 'closed', + first_owner_id: 'usr_01H8XK', + first_case_no: '0003', + task_count: 3, + counted_touches: 3, + counted_subjects: 3, + summed_touches: 12, + avg_touch: 4, + min_estimate: 2, + min_flag: 0, + min_payload: '{"a":1}', + min_margin: 0.25, + min_child_total: 7, +}]; /** The ObjectQL-aggregate path — one `buildFieldMeta` producer. */ function objectqlService() { @@ -200,7 +397,7 @@ function typeOf(fields: Awaited>['f } // ───────────────────────────────────────────────────────────────────────────── -// B) the card's own shape, end to end through queryDataset +// B) the temporal shape, end to end through queryDataset // ───────────────────────────────────────────────────────────────────────────── describe('B) a min/max over a datetime is described as temporal, not number', () => { @@ -267,7 +464,10 @@ describe('B) a min/max over a datetime is described as temporal, not number', () describe('C) both strategy producers move together — the correction is downstream of both', () => { it('ObjectQL-aggregate and native-SQL answer the same column metadata', async () => { - const selection = { dimensions: ['status'], measures: ['newest_last_update_at', 'task_count'] }; + const selection = { + dimensions: ['status'], + measures: ['newest_last_update_at', 'first_subject', 'task_count'], + }; const viaObjectql = await objectqlService().queryDataset(dataset, selection, CTX); const viaNativeSql = await nativeSqlService().queryDataset(dataset, selection, CTX); @@ -277,6 +477,8 @@ describe('C) both strategy producers move together — the correction is downstr expect(shape(viaObjectql)).toEqual(shape(viaNativeSql)); expect(typeOf(viaObjectql.fields, 'newest_last_update_at')).toBe('time'); expect(typeOf(viaNativeSql.fields, 'newest_last_update_at')).toBe('time'); + expect(typeOf(viaObjectql.fields, 'first_subject')).toBe('string'); + expect(typeOf(viaNativeSql.fields, 'first_subject')).toBe('string'); }); }); @@ -306,6 +508,15 @@ describe('D) the columns that are genuinely numeric keep saying number', () => { expect(typeOf(result.fields, 'touch_ratio')).toBe('number'); }); + it('count over a TEXT column is still a number — counting strings is counting', async () => { + const result = await objectqlService().queryDataset( + dataset, + { dimensions: ['status'], measures: ['counted_subjects'] }, + CTX, + ); + expect(typeOf(result.fields, 'counted_subjects')).toBe('number'); + }); + it('min over a NUMBER field is untouched', async () => { const result = await objectqlService().queryDataset( dataset, @@ -328,6 +539,25 @@ describe('D) the columns that are genuinely numeric keep saying number', () => { expect(typeOf(result.fields, 'touched_on')).toBe('time'); }); + it('a lookup DIMENSION column already said `string` — the measure now uses the SAME word', async () => { + // The `time` argument's other half, and the reason a textual measure is not + // spelled `text`: `dataset-compiler.dimensionType` maps a lookup dimension + // to `string`, so the corrected measure column reuses a word this position + // already speaks rather than adding a sixth to `DimensionType`. + const svc = new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + sourceFieldMeta, + executeAggregate: async () => [{ by_owner: 'usr_01H8XK', first_owner_id: 'usr_01H8XK' }], + }); + const result = await svc.queryDataset( + dataset, + { dimensions: ['by_owner'], measures: ['first_owner_id'] }, + CTX, + ); + expect(typeOf(result.fields, 'by_owner')).toBe('string'); + expect(typeOf(result.fields, 'first_owner_id')).toBe('string'); + }); + it('a host that cannot answer for the field leaves the column exactly as produced', async () => { const blind = new AnalyticsService({ queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), @@ -336,9 +566,105 @@ describe('D) the columns that are genuinely numeric keep saying number', () => { }); const result = await blind.queryDataset( dataset, - { dimensions: ['status'], measures: ['newest_last_update_at'] }, + { dimensions: ['status'], measures: ['newest_last_update_at', 'first_subject'] }, CTX, ); expect(typeOf(result.fields, 'newest_last_update_at')).toBe('number'); + expect(typeOf(result.fields, 'first_subject')).toBe('number'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// E) the string population this card lands, and the members it leaves alone +// ───────────────────────────────────────────────────────────────────────────── + +describe('E) a min/max over a string-valued column is described as string, not number', () => { + it('the card shape: a text column whose value and metadata no longer contradict', async () => { + const result = await objectqlService().queryDataset( + dataset, + { dimensions: ['status'], measures: ['first_subject'] }, + CTX, + ); + // The value beside the metadata is a string, exactly as SQLite answers. + expect(result.rows[0]?.first_subject).toBe('Archive the backlog'); + expect(result.fields.find((f) => f.name === 'first_subject')).toMatchObject({ + name: 'first_subject', + type: 'string', + label: 'First subject', + }); + }); + + it('the SUPPLEMENTARY-sub-query producer is covered for the string family too', async () => { + // `last_subject` is the only measure and carries a filter, so + // `runMeasurePass` appends the descriptor itself instead of issuing a + // primary query — the producer the card's own measured response came from. + const result = await objectqlService().queryDataset( + dataset, + { dimensions: [], measures: ['last_subject'] }, + CTX, + ); + expect(typeOf(result.fields, 'last_subject')).toBe('string'); + }); + + it('one member of every string value class moves: option code, reference id, record number', async () => { + const result = await objectqlService().queryDataset( + dataset, + { + dimensions: ['status'], + measures: ['first_status_code', 'first_owner_id', 'first_case_no'], + }, + CTX, + ); + // `select` — the stored option code. + expect(result.rows[0]?.first_status_code).toBe('closed'); + expect(typeOf(result.fields, 'first_status_code')).toBe('string'); + // `lookup` — the referenced row's id, never the expanded record. + expect(result.rows[0]?.first_owner_id).toBe('usr_01H8XK'); + expect(typeOf(result.fields, 'first_owner_id')).toBe('string'); + // `autonumber` — the rendered, zero-padded record number. Measured on + // SQLite: min over padded record numbers answers padded TEXT, not an int. + expect(result.rows[0]?.first_case_no).toBe('0003'); + expect(typeOf(result.fields, 'first_case_no')).toBe('string'); + }); + + it('the __compare producer carries the corrected string type too', async () => { + const result = await objectqlService().queryDataset( + dataset, + { + dimensions: ['status'], + measures: ['first_subject'], + timeDimensions: [{ dimension: 'touched_on', dateRange: ['2026-08-01', '2026-08-31'] }], + compareTo: { kind: 'previousPeriod' as const, dimension: 'touched_on' }, + }, + CTX, + ); + expect(typeOf(result.fields, 'first_subject__compare')).toBe('string'); + expect(typeOf(result.fields, 'first_subject')).toBe('string'); + }); + + it('the UNCORRECTED members keep the number they had, and each for its own recorded reason', async () => { + const result = await objectqlService().queryDataset( + dataset, + { + dimensions: ['status'], + measures: ['min_flag', 'min_payload', 'min_margin', 'min_child_total'], + }, + CTX, + ); + // `boolean` — Postgres has no min(boolean); SQLite answers 0/1 as numbers; + // the driver seam has been recorded answering false/true. Three readings, + // no single answer, so no word is invented. `DimensionType` HAS a `boolean` + // spelling, which is precisely why this assertion is load-bearing: the + // correction is spellable and is deliberately not made. + expect(typeOf(result.fields, 'min_flag')).toBe('number'); + // `json` — an object in a JSON column; the value contract is explicitly open. + expect(typeOf(result.fields, 'min_payload')).toBe('number'); + // `formula` — the answer is declared on `FieldSchema.returnType`, which is + // not on `sourceFieldMeta`'s return shape (and is itself optional). + expect(typeOf(result.fields, 'min_margin')).toBe('number'); + // `summary` — genuinely numeric on both shipped statements (spec + // NUMERIC_VALUE_TYPES, DDL `table.float`), so `number` is CORRECT here + // rather than merely unexamined. + expect(typeOf(result.fields, 'min_child_total')).toBe('number'); }); }); diff --git a/packages/services/service-analytics/src/measure-result-type.ts b/packages/services/service-analytics/src/measure-result-type.ts index dbb5053da1..806b5a3614 100644 --- a/packages/services/service-analytics/src/measure-result-type.ts +++ b/packages/services/service-analytics/src/measure-result-type.ts @@ -1,9 +1,14 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import type { AggregationFunction } from '@objectstack/spec/data'; +import { + REFERENCE_VALUE_TYPES, + SINGLE_OPTION_TYPES, + STRING_VALUE_TYPES, +} from '@objectstack/spec/data'; /** - * #15768 — what a dataset MEASURE column's `fields[].type` should say. + * What a dataset MEASURE column's `fields[].type` should say. * * Every producer of `AnalyticsResult.fields` mints `{ name, type: 'number' }` * for a measure — `ObjectQLStrategy.buildFieldMeta`, `NativeSQLStrategy. @@ -19,11 +24,12 @@ import type { AggregationFunction } from '@objectstack/spec/data'; * * `min`/`max` return a value OF THE AGGREGATED FIELD'S OWN TYPE. Over a * `date` / `datetime` / `time` field that value is an instant, a calendar day - * or a clock time — never a number — so a renderer that branches on the - * declared type never reaches its temporal branch and falls through to a - * numeric default. + * or a clock time; over a `text` / `select` / `lookup` field it is a string — + * never a number — so a renderer that branches on the declared type never + * reaches its temporal or textual branch and falls through to a numeric + * default. * - * ## The population, enumerated rather than sampled + * ## The aggregate axis, enumerated rather than sampled * * `AggregationFunction` (`spec/data/query.zod.ts`) is a CLOSED vocabulary, so * "which aggregates does this rule speak about" has a finite answer, and every @@ -35,8 +41,8 @@ import type { AggregationFunction } from '@objectstack/spec/data'; * | `count_distinct` | a cardinality | `number` — unchanged, same reason. | * | `sum` | see below | `number` — unchanged. | * | `avg` | see below | `number` — unchanged. | - * | `min` | a value of the aggregated field's type | temporal source ⇒ {@link MEASURE_RESULT_TYPE_TEMPORAL}. | - * | `max` | a value of the aggregated field's type | temporal source ⇒ {@link MEASURE_RESULT_TYPE_TEMPORAL}. | + * | `min` | a value of the aggregated field's type | {@link MEASURE_RESULT_TYPE_TEMPORAL} / {@link MEASURE_RESULT_TYPE_STRING} per the field-type table. | + * | `max` | a value of the aggregated field's type | {@link MEASURE_RESULT_TYPE_TEMPORAL} / {@link MEASURE_RESULT_TYPE_STRING} per the field-type table. | * * A measure with NO aggregate is a `derived` one (the two are mutually * exclusive in `DatasetMeasureSchema`). `computeDerived` coerces every operand @@ -55,25 +61,132 @@ import type { AggregationFunction } from '@objectstack/spec/data'; * and the missing refusal is reported as its own finding rather than papered * over with a type that would be wrong on at least one backend. * - * ## The field-type axis, and what it deliberately excludes - * - * Only the TEMPORAL family is corrected: `date`, `datetime`, `time`. That is - * the population the card measured and the one the triage ruled on. `min`/`max` - * over a `text` / `select` / `lookup` field returns a string and is still - * described as `number` after this change — the same defect, a different - * population, reported separately rather than absorbed here. - * - * ## Why `'time'` and not `'date'` / `'datetime'` - * - * `fields[].type` is not a `FieldType` position. A temporal DIMENSION column in - * the very same response already carries `'time'` — `DatasetDimensionSchema`'s - * `type: 'date'` compiles to a cube dimension of `type: 'time'` - * (`dataset-compiler.dimensionType`), and both `buildFieldMeta`s copy that - * through. `'time'` is the `DimensionType` vocabulary this position already - * speaks (`string` / `number` / `boolean` / `time` / `geo`), so a consumer that - * can draw a date axis at all already has the branch. Spelling a temporal - * measure `'datetime'` would introduce a SECOND temporal word into one wire - * position and leave every existing consumer's `'time'` branch unreached. + * ## The field-type axis: every `FieldType` member, by MEASURED value class + * + * The verdict per member is not a judgement call about what a type "feels + * like". It is read off the two shipped statements of what the type STORES — + * `@objectstack/spec`'s runtime value contract (`data/field-value.zod.ts`, + * ADR-0104 D1) and `driver-sql`'s DDL column switch — and the sets below are + * COMPOSED FROM the spec's classes rather than re-listing their members, the + * way `driver-sql`'s own `JSON_COLUMN_TYPES` composes from them. A type moved + * between classes upstream moves here with it; a type ADDED to the enum lands + * as a failure of the enum-walking pin in + * `__tests__/measure-result-type.test.ts` rather than silently inheriting the + * flat `number` this rule exists to correct. + * + * | value class (spec) | members | `min`/`max` verdict | + * |:---|:---|:---| + * | `STRING_VALUE_TYPES` | text, textarea, email, url, phone, password, secret, markdown, html, richtext, code, color, signature, qrcode | {@link MEASURE_RESULT_TYPE_STRING} — `valueSchemaFor` answers `z.string()`; the DDL gives every one a string/TEXT column. | + * | `SINGLE_OPTION_TYPES` | select, radio | {@link MEASURE_RESULT_TYPE_STRING} — the stored value is ONE option code, and `optionCodes` stringifies a declared code before the value schema is built (`String(o.value)`), so a numerically-spelled option still stores text. | + * | `REFERENCE_VALUE_TYPES` | lookup, master_detail, tree, user | {@link MEASURE_RESULT_TYPE_STRING} — all four share `ReferenceIdValueSchema` (`z.string()`); the stored value is the referenced row's id, never the expanded record. | + * | `autonumber` | autonumber | {@link MEASURE_RESULT_TYPE_STRING} — measured, not assumed (see below). | + * | temporal | date, datetime, time | {@link MEASURE_RESULT_TYPE_TEMPORAL}. | + * | `NUMERIC_VALUE_TYPES` | number, currency, percent, rating, slider, progress, summary | no correction — the producer's `number` is CORRECT, not merely unexamined. | + * | `BOOLEAN_VALUE_TYPES` | boolean, toggle | no correction — no single answer across backends (see below). | + * | `MULTI_OPTION_TYPES` | multiselect, checkboxes, tags | no correction — an array in a JSON column (see below). | + * | `FILE_REFERENCE_TYPES` | image, file, avatar, video, audio | no correction — the stored form is mid-migration (see below). | + * | `STRUCTURED_JSON_TYPES` | json, composite, repeater, record, location, address, vector | no correction — an object in a JSON column (see below). | + * | `formula` | formula | no correction — the answer exists but is not on this rule's input (see below). | + * + * ### `autonumber` is a STRING, measured on three independent readings + * + * The open question was whether the stored value is an integer counter or the + * rendered record number. All three shipped statements say the latter: + * `renderAutonumber` returns `value: prefix + String(seq).padStart(width,'0') + * + suffix` — a string by construction, and zero-padded under the contract + * default format `{0000}`; `driver-sql`'s DDL switch answers + * `col = table.string(name)` for it; and `RUNTIME_OWNED_FIELD_TYPES` makes the + * runtime, not a caller, the producer of that string. Measured directly on + * SQLite, `min`/`max` over a column of padded record numbers returns the + * padded text (`'0003'` / `'0012'`), typeof `string`. + * + * The spec's value contract answers `z.unknown()` here — "producer-owned, + * explicitly open" — which is a statement about what a CALLER may write, not + * about what the producer emits. Reading that openness as "unknowable" is what + * would have made this member a guess; reading the producer is what makes it a + * measurement. + * + * ### `boolean` / `toggle`: three readings, and they do not agree + * + * - **Postgres** has no `min`/`max` over `boolean` at all — `function + * min(boolean) does not exist`, SQLSTATE 42883. There is no value. + * - **SQLite**, measured directly here, answers `0` / `1` as JS **numbers**: + * a `boolean` column has NUMERIC affinity and the aggregate alias is not a + * declared column, so `formatOutput`'s `booleanFields` pass — keyed to + * declared `Field.boolean` COLUMNS — does not reach it. + * - The same SQLite pair recorded at `SqlDriver.aggregate()` in the earlier + * driver-level measurement reads `false` / `true`, i.e. JS **booleans**. + * + * So the three readings disagree about whether there is a value at all, and + * about whether the one backend that answers reports a number or a boolean. + * `DimensionType` does carry a `boolean` word, so a correction is SPELLABLE + * here — which is exactly why it is not made: spelling it would ship one of + * three disagreeing readings as a published declaration. The column keeps the + * `number` it has (the accurate word for the raw SQLite value), and the + * missing refusal is owned by the `needs-user-decision` card for "no layer + * refuses an incoherent aggregate / field-type pair". + * + * ### The JSON-column classes: array- and object-valued types + * + * `MULTI_OPTION_TYPES` and `STRUCTURED_JSON_TYPES` are `JSON_COLUMN_TYPES` in + * `driver-sql`, so the aggregate is taken over a JSON column: `jsonb` has no + * `min` on Postgres, while SQLite compares the serialized TEXT. Same shape as + * the boolean case — backend-decided, no single value to describe — and the + * same owner. + * + * `FILE_REFERENCE_TYPES` is left alone for a second, additive reason: its + * stored form is mid-migration under ADR-0104 D3. The value contract's stored + * schema is already the opaque `sys_file` id (`FileReferenceIdValueSchema`, a + * string) while the DDL still gives these types a JSON column for the pre-D3 + * inline metadata object. Two shipped statements, two different stored forms; + * correcting to either would describe half the deployments. + * + * ### `formula`: answerable, but not from this rule's input + * + * A formula field's result type IS declared — `FieldSchema.returnType` + * (`number` / `text` / `boolean` / `date`), whose own JSDoc names "dataset + * measures" as its intended consumer. This rule cannot read it: its input is + * the declared `FieldType` alone, because that is all + * `AnalyticsServiceConfig.sourceFieldMeta` returns (`{ type?, + * defaultCurrency?, max? }`). `returnType` is also OPTIONAL — "absent when the + * type can't be proven (an ambiguous/`dyn` expression)" — so even with the + * plumbing the rule would answer for some formula fields and not others. + * Carrying it is a change to the host callback contract and its call site, not + * a row in this table; filed separately rather than guessed at here. + * + * ### `summary` is NUMERIC — the correction is not needed, not merely skipped + * + * Both shipped statements agree: `summary` is a member of the spec's + * `NUMERIC_VALUE_TYPES` (so `valueSchemaFor` answers `z.number().finite()`) + * and `driver-sql`'s DDL answers `col = table.float(name)`. The producer's + * `number` is therefore the CORRECT word and no correction applies. That a + * roll-up may declare `summaryOperations.function: 'min'` over a non-numeric + * child field — which `aggregateSummaryValue` returns verbatim, into that + * float column — is a defect one layer down in the same family; it is filed, + * and it is a statement about `summary`'s own storage, not about what this + * rule should say for the declared type. + * + * ## What this rule deliberately cannot see: `multiple` + * + * `sourceFieldMeta` returns no `multiple` flag, so a `select` / `radio` / + * `lookup` / `user` field declared `multiple: true` — stored as a JSON array — + * is indistinguishable here from its single-valued form and is corrected to + * `string` with the rest of its class. That is the safe direction rather than + * an oversight: where the backend answers at all it is SQLite comparing the + * serialized TEXT, which is a string; where it does not answer (Postgres over + * `jsonb`) there is no response for any word to mis-describe. + * + * ## Why `'string'` and `'time'`, and not `FieldType` spellings + * + * `fields[].type` is not a `FieldType` position. It speaks `DimensionType` + * (`string` / `number` / `boolean` / `time` / `geo`), and both words this rule + * mints are ALREADY carried by dimension columns in the very same response: + * `dataset-compiler.dimensionType` maps a `date` dimension to `'time'` and a + * `lookup` dimension to `'string'`, and both `buildFieldMeta`s copy that + * through. A consumer that can draw a date axis or render a text column + * already has the branch. Spelling a textual measure `'text'`, or a temporal + * one `'datetime'`, would introduce a SIXTH word into a five-word wire + * vocabulary and leave every existing branch unreached. */ /** @@ -82,6 +195,13 @@ import type { AggregationFunction } from '@objectstack/spec/data'; */ export const MEASURE_RESULT_TYPE_TEMPORAL = 'time'; +/** + * The `DimensionType` word this position uses for a string-valued column — the + * same one a `lookup` or `string` dimension column already carries in the same + * response (`dataset-compiler.dimensionType`). + */ +export const MEASURE_RESULT_TYPE_STRING = 'string'; + /** * Source-field types whose stored value is temporal (`FieldType`, `spec/data/ * field.zod.ts` → "Date & Time"). `min`/`max` over one of these returns that @@ -93,6 +213,29 @@ export const TEMPORAL_SOURCE_FIELD_TYPES: ReadonlySet = new Set([ 'time', ]); +/** + * Source-field types whose stored value is a STRING. `min`/`max` over one of + * these returns a string. + * + * Composed from `@objectstack/spec`'s value classes rather than re-listing + * their members, so membership is owned where the stored shape is declared + * (ADR-0104 D1) and cannot drift from it — the same construction `driver-sql` + * uses for `JSON_COLUMN_TYPES`. `autonumber` is the one local extra: the spec + * classes it as producer-owned/open, and the string is established from the + * producer instead (see the module header). + */ +export const STRING_SOURCE_FIELD_TYPES: ReadonlySet = new Set([ + // Plain strings: text/textarea/email/url/phone/password/secret, the rich + // bodies (markdown/html/richtext/code), and color/signature/qrcode. + ...STRING_VALUE_TYPES, + // One declared option code — select/radio. + ...SINGLE_OPTION_TYPES, + // The referenced row's id — lookup/master_detail/tree/user. + ...REFERENCE_VALUE_TYPES, + // The rendered record number, zero-padded under the default `{0000}`. + 'autonumber', +]); + /** * The corrected `fields[].type` for a measure column, or `undefined` for "this * rule has nothing to say — keep whatever the producer minted". @@ -103,6 +246,12 @@ export const TEMPORAL_SOURCE_FIELD_TYPES: ReadonlySet = new Set([ * `sourceFieldMeta` cannot resolve because it looks a column up on the BASE * object) all answer `undefined` and leave the column exactly as it was. * + * `undefined` is also the answer for every field type whose `min`/`max` has no + * single backend-independent value — booleans, the JSON-column classes, the + * mid-migration file types — and for `formula`, whose declared result type is + * not on this function's input. Those are VERDICTS, not gaps; the module + * header records the measurement behind each one. + * * @param aggregate - the measure's declared `aggregate`; absent on a `derived` * measure. * @param sourceFieldType - the DECLARED `FieldType` of the aggregated field, @@ -116,7 +265,7 @@ export function measureResultType( // aggregate — all keep the `number` their producer minted. See the table above. if (aggregate !== 'min' && aggregate !== 'max') return undefined; if (sourceFieldType === undefined) return undefined; - return TEMPORAL_SOURCE_FIELD_TYPES.has(sourceFieldType) - ? MEASURE_RESULT_TYPE_TEMPORAL - : undefined; + if (TEMPORAL_SOURCE_FIELD_TYPES.has(sourceFieldType)) return MEASURE_RESULT_TYPE_TEMPORAL; + if (STRING_SOURCE_FIELD_TYPES.has(sourceFieldType)) return MEASURE_RESULT_TYPE_STRING; + return undefined; }