@@ -10,10 +10,11 @@ import {
1010 virtualFieldAggregateRemedy ,
1111} from '@objectstack/spec/data' ;
1212import type { Dataset , DatasetMeasure , DatasetDimension } from '@objectstack/spec/ui' ;
13- import { resolveI18nLabel } from '@objectstack/spec/ui' ;
13+ import { DATASET_DURATION_AGGREGATES , resolveI18nLabel } from '@objectstack/spec/ui' ;
1414import type { FilterCondition } from '@objectstack/spec/data' ;
1515import { datasetInvalidError } from './dataset-refusal.js' ;
1616import { TEMPORAL_SOURCE_FIELD_TYPES } from './measure-result-type.js' ;
17+ import type { DurationMeasureSpec } from './strategies/types.js' ;
1718
1819/**
1920 * Dataset → Cube compiler (ADR-0021 D-A=(c), WS2).
@@ -86,6 +87,12 @@ export interface CompiledDataset {
8687 filter ?: FilterCondition ;
8788 /** Per-measure scoped filters, keyed by measure name (applied by executor). */
8889 measureFilters : Record < string , FilterCondition > ;
90+ /**
91+ * [#25778] Duration measures, keyed by measure name — NOT in `cube.measures`
92+ * (a cube metric reads one column, a duration two). Carried to the
93+ * strategies on the dataset-scope channel; see `DatasetScope.measureDurations`.
94+ */
95+ durations : Record < string , DurationMeasureSpec > ;
8996}
9097
9198/**
@@ -260,8 +267,10 @@ const DIVERGENCE_BY_AGGREGATE = (aggregate: string, fieldType: string, column =
260267 * prescribes; it never re-states which pairs are accepted (that is the table's
261268 * line above it).
262269 *
263- * The temporal sentence is the one #16737 measured and shipped, kept verbatim
264- * for the class it was written about. The deriving sentence is #16099's
270+ * The temporal sentence is the one #16737 measured and shipped; [#25778] its
271+ * second half named a stored "days open" number as the only way to a duration,
272+ * and now names the `duration` measure that answers one without a stored copy.
273+ * The deriving sentence is #16099's
265274 * generalisation: counting is the aggregate that reads no arithmetic off a
266275 * value, so it is what an author who wanted "how much text is there" actually
267276 * wants. [#17560] The selecting sentence is the third: an author who wrote
@@ -281,8 +290,9 @@ const REMEDY_BY_SOURCE_CLASS = (fieldType: string, aggregate: string): string =>
281290 const virtual = virtualFieldAggregateRemedy ( aggregate , fieldType ) ;
282291 if ( virtual !== undefined ) return virtual ;
283292 return TEMPORAL_SOURCE_FIELD_TYPES . has ( fieldType )
284- ? 'For a temporal field, `min`/`max` return a real instant; a DURATION has to be '
285- + 'stored as a number (a computed "days open" field) and aggregated as one.'
293+ ? 'For a temporal field, `min`/`max` return a real instant; the time BETWEEN two `datetime` '
294+ + 'fields of a record is a `duration: { from, to, unit }` measure, aggregated by '
295+ + `${ DATASET_DURATION_AGGREGATES . join ( ' / ' ) } .`
286296 : aggregate === 'count_distinct'
287297 ? 'For a JSON-stored field, `count` counts the rows; a distinct count has to be taken '
288298 + 'over a field that stores one scalar value, so store the part you count in a field '
@@ -441,6 +451,62 @@ function assertAggregateFieldTypeCompatible(
441451 ) ;
442452}
443453
454+ /**
455+ * The one field type a duration measure's `from` / `to` may be declared as.
456+ * A `datetime` is an instant (ADR-0053), and the time between two instants is
457+ * a duration on every backend. A `date` is a calendar day with no instant and
458+ * a `time` a wall clock with no day, so neither denotes a point a duration can
459+ * be measured from.
460+ */
461+ const DURATION_COLUMN_TYPE = 'datetime' ;
462+
463+ /**
464+ * [#25778] Compile one dataset measure's `duration` — the column pair, the
465+ * unit, and the measure's aggregate — after judging what the metadata can
466+ * settle before any query runs.
467+ *
468+ * - The AGGREGATE is one of `DATASET_DURATION_AGGREGATES`. The spec refuses
469+ * any other at parse; this is the guard for a dataset that reaches the
470+ * compiler without meeting that parse (the same posture as every other
471+ * `DATASET_INVALID` here).
472+ * - Each COLUMN is a field the base object declares as `datetime`, judged
473+ * through the same `declaredValueShape` hook the aggregate × field-type gate
474+ * reads, with the same tiering: no hook, or a field the hook cannot resolve,
475+ * is not judged. A column of another declared type is refused, naming the
476+ * column, its type and the one type accepted — so a `text` column holding
477+ * ISO strings, or a `date`, never reaches the SQL as an instant it is not.
478+ * An undeclared column is the field gate's and the database's to refuse.
479+ */
480+ function compileDurationMeasure (
481+ datasetName : string ,
482+ objectName : string ,
483+ measure : DatasetMeasure & { duration : NonNullable < DatasetMeasure [ 'duration' ] > } ,
484+ declaredValueShape ?: ( objectName : string , field : string ) => ValueShapeFieldDef | undefined ,
485+ ) : DurationMeasureSpec {
486+ const { from, to, unit } = measure . duration ;
487+ const aggregate = measure . aggregate ;
488+ if ( ! aggregate || ! ( DATASET_DURATION_AGGREGATES as readonly string [ ] ) . includes ( aggregate ) ) {
489+ throw datasetInvalidError (
490+ `[dataset-compiler] dataset "${ datasetName } " duration measure "${ measure . name } " takes \`aggregate\` ` +
491+ `${ DATASET_DURATION_AGGREGATES . join ( ' / ' ) } ; it declares ${ aggregate ? `"${ aggregate } "` : 'none' } .` ,
492+ ) ;
493+ }
494+ if ( declaredValueShape ) {
495+ for ( const [ slot , column ] of [ [ 'from' , from ] , [ 'to' , to ] ] as const ) {
496+ const fieldType = declaredValueShape ( objectName , column ) ?. type ;
497+ if ( ! fieldType || fieldType === DURATION_COLUMN_TYPE ) continue ;
498+ throw datasetInvalidError (
499+ `[dataset-compiler] dataset "${ datasetName } " duration measure "${ measure . name } " reads ` +
500+ `\`duration.${ slot } \` "${ column } ", which object "${ objectName } " declares as \`${ fieldType } \`. ` +
501+ `A duration spans two \`${ DURATION_COLUMN_TYPE } \` fields of the same record, because an instant is ` +
502+ `the one value every backend measures the time between alike; a \`${ fieldType } \` field holds no ` +
503+ `instant. Point \`duration.${ slot } \` at a \`${ DURATION_COLUMN_TYPE } \` field of "${ objectName } ".` ,
504+ ) ;
505+ }
506+ }
507+ return { aggregate : aggregate as DurationMeasureSpec [ 'aggregate' ] , from, to, unit } ;
508+ }
509+
444510/** Map a dataset dimension type to the Cube dimension `type`. */
445511function dimensionType ( d : DatasetDimension ) : CubeDimension [ 'type' ] {
446512 switch ( d . type ) {
@@ -707,12 +773,28 @@ export function compileDataset(
707773 const measures : Record < string , Metric > = { } ;
708774 const derived : DerivedMeasureSpec [ ] = [ ] ;
709775 const measureFilters : Record < string , FilterCondition > = { } ;
776+ const durations : Record < string , DurationMeasureSpec > = { } ;
710777
711778 for ( const m of dataset . measures ) {
712779 if ( m . derived ) {
713780 derived . push ( { name : m . name , op : m . derived . op , of : m . derived . of } ) ;
714781 continue ;
715782 }
783+ // [#25778] A duration measure reads two columns, which no cube metric can
784+ // carry: it goes to the dataset-scope sidecar and NOT into
785+ // `cube.measures`, so any reader that does not know it refuses it rather
786+ // than aggregating one of its columns (see `DatasetScope.measureDurations`).
787+ // Its scoped `filter` rides the ordinary channel.
788+ if ( m . duration ) {
789+ durations [ m . name ] = compileDurationMeasure (
790+ dataset . name ,
791+ dataset . object ,
792+ m as DatasetMeasure & { duration : NonNullable < DatasetMeasure [ 'duration' ] > } ,
793+ options ?. declaredValueShape ,
794+ ) ;
795+ if ( m . filter ) measureFilters [ m . name ] = m . filter ;
796+ continue ;
797+ }
716798 if ( m . field ) assertDeclared ( m . field , 'measure' , m . name ) ;
717799 // [#16737 / #16099] …and the aggregate ITSELF must be one the field's
718800 // declared type can carry. Placed after the join-declaration check so a
@@ -755,5 +837,6 @@ export function compileDataset(
755837 derived,
756838 filter : dataset . filter ,
757839 measureFilters,
840+ durations,
758841 } ;
759842}
0 commit comments