Skip to content

Commit f183745

Browse files
committed
wip(analytics): compile dataset duration measures into the scope channel
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AKPixnkg91qCkQCVFDJuqW
1 parent 0dec20d commit f183745

5 files changed

Lines changed: 135 additions & 15 deletions

File tree

‎packages/services/service-analytics/src/dataset-compiler.ts‎

Lines changed: 88 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ import {
1010
virtualFieldAggregateRemedy,
1111
} from '@objectstack/spec/data';
1212
import 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';
1414
import type { FilterCondition } from '@objectstack/spec/data';
1515
import { datasetInvalidError } from './dataset-refusal.js';
1616
import { 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`. */
445511
function 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
}

‎packages/services/service-analytics/src/strategies/types.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export type {
1717

1818
import type { FilterCondition, ValueShapeFieldDef } from '@objectstack/spec/data';
1919
import type { IObjectQLEngine, StrategyContext } from '@objectstack/spec/contracts';
20+
import type { DATASET_DURATION_AGGREGATES, DatasetDurationUnitValue } from '@objectstack/spec/ui';
2021

2122
/**
2223
* [#19995] The engine's judge-only `where` admission, as the contract declares
@@ -65,6 +66,35 @@ export interface DatasetScope {
6566
filter?: FilterCondition;
6667
/** Per-measure scoped filters, keyed by measure name. */
6768
measureFilters?: Record<string, FilterCondition>;
69+
/**
70+
* [#25778] The dataset's duration measures, keyed by measure name — the
71+
* second half of a compiled dataset the Cube cannot carry. A cube metric
72+
* names ONE column; a duration reads two, so a duration measure is
73+
* deliberately ABSENT from `cube.measures` and present here only.
74+
*
75+
* Absent rather than approximated, so it fails closed: a reader that does
76+
* not know this channel finds no such measure and refuses it (`INVALID_FIELD`
77+
* / 400), where a cube metric standing in for it (an aggregate over one of
78+
* its columns) would have answered an instant, or the average year (#16737),
79+
* under the duration's name. The readers that DO serve it: the native-SQL
80+
* strategy (`duration-measure-sql.ts`), the draft-preview evaluator, and the
81+
* service's own gates (the field-level read gate, the measure mint, the
82+
* strategy-route refusal).
83+
*/
84+
measureDurations?: Record<string, DurationMeasureSpec>;
85+
}
86+
87+
/**
88+
* [#25778] One compiled duration measure: the time from `from` to `to` — two
89+
* `datetime` columns of the dataset's base object — on each record, in `unit`,
90+
* reduced by `aggregate`. The dataset's `duration` key (`@objectstack/spec/ui`,
91+
* `DatasetMeasureDurationSchema`) with the measure's aggregate beside it.
92+
*/
93+
export interface DurationMeasureSpec {
94+
aggregate: (typeof DATASET_DURATION_AGGREGATES)[number];
95+
from: string;
96+
to: string;
97+
unit: DatasetDurationUnitValue;
6898
}
6999

70100
/** A {@link StrategyContext} that can answer for a compiled dataset (#10298). */

‎packages/spec/authorable-surface/ui.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,11 +416,15 @@
416416
"ui/DatasetMeasure:aggregate",
417417
"ui/DatasetMeasure:currency",
418418
"ui/DatasetMeasure:derived",
419+
"ui/DatasetMeasure:duration",
419420
"ui/DatasetMeasure:field",
420421
"ui/DatasetMeasure:filter",
421422
"ui/DatasetMeasure:format",
422423
"ui/DatasetMeasure:label",
423424
"ui/DatasetMeasure:name",
425+
"ui/DatasetMeasureDuration:from",
426+
"ui/DatasetMeasureDuration:to",
427+
"ui/DatasetMeasureDuration:unit",
424428
"ui/DocNavItem:badge",
425429
"ui/DocNavItem:badgeVariant",
426430
"ui/DocNavItem:book",

‎packages/spec/json-schema.manifest/ui.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@
4747
"ui/DashboardWidgetOptions",
4848
"ui/Dataset",
4949
"ui/DatasetDimension",
50+
"ui/DatasetDurationUnit",
5051
"ui/DatasetMeasure",
52+
"ui/DatasetMeasureDuration",
5153
"ui/DerivedMeasureOp",
5254
"ui/DocNavItem",
5355
"ui/ElementApiDataSource",

‎packages/spec/src/ui/dataset.zod.ts‎

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -298,16 +298,6 @@ export const DatasetMeasureDurationSchema = lazySchema(() => strictObject({
298298
unit: DatasetDurationUnit
299299
.describe('Unit of the result: the measure answers a number of these (1.5 with "days" is a day and a half).')
300300
.meta({ title: 'Unit' }),
301-
}).superRefine((duration, ctx) => {
302-
if (duration.from === duration.to) {
303-
ctx.addIssue({
304-
code: 'custom',
305-
path: ['to'],
306-
message:
307-
`\`from\` and \`to\` both name "${duration.from}", so every record's duration is zero. `
308-
+ 'Name the two instants the duration spans — e.g. `from: \'submitted_at\'`, `to: \'activated_at\'`.',
309-
});
310-
}
311301
}));
312302

313303
/**
@@ -522,6 +512,17 @@ export const DatasetMeasureSchema = lazySchema(() => strictObject({
522512
+ 'two instants are set).',
523513
});
524514
}
515+
// Judged here rather than on the duration spec itself, so the published
516+
// JSON Schema keeps ONE dropped-refinement site for the measure.
517+
if (measure.duration.from === measure.duration.to) {
518+
ctx.addIssue({
519+
code: 'custom',
520+
path: ['duration', 'to'],
521+
message:
522+
`\`duration.from\` and \`duration.to\` both name "${measure.duration.from}", so every record's duration `
523+
+ 'is zero. Name the two instants the duration spans — e.g. `from: \'submitted_at\'`, `to: \'activated_at\'`.',
524+
});
525+
}
525526
if (measure.currency !== undefined) {
526527
ctx.addIssue({
527528
code: 'custom',

0 commit comments

Comments
 (0)