Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0dec20d
wip(spec): dataset duration measure schema
claude Oct 25, 2026
f183745
wip(analytics): compile dataset duration measures into the scope channel
claude Oct 25, 2026
c06b33c
wip(analytics): serve dataset duration measures on the native SQL face
claude Oct 25, 2026
4edb2de
feat(lint,spec): os validate refuses a non-datetime duration column; …
claude Oct 25, 2026
c62621f
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 25, 2026
eda01f1
chore(spec): regenerate api-surface, export-origins and reference doc…
claude Oct 25, 2026
b2ddd35
fix(analytics): review round 1 on the duration measure
claude Oct 25, 2026
78be554
chore(platform-objects): translate the dataset form's Duration row
claude Oct 25, 2026
634fe6b
test(spec): pin the duration schema and unit enum as isomorphic (ADR-…
claude Oct 25, 2026
1322dd3
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 26, 2026
7caa566
chore(spec): regenerate the four artifacts the main merge deferred
claude Oct 26, 2026
24e5933
fix(app-shell): the dataset inspector clears a duration measure when …
claude Oct 26, 2026
3f3f095
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 26, 2026
7686671
test(platform-objects): the two form-catalog count pins move by the d…
claude Oct 26, 2026
b05a82c
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 26, 2026
a5c3e1f
chore(spec): regenerate the references index the main merge deferred
claude Oct 26, 2026
97934f0
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 26, 2026
7eb5576
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 26, 2026
f4f3e41
chore(spec): regenerate the references index the main merge deferred
claude Oct 26, 2026
b0bf2f6
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 26, 2026
8821a8d
test(service-analytics): a duration sum's empty-group 0 is answered i…
claude Oct 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/25778-dataset-duration-measure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@objectstack/spec': minor
'@objectstack/service-analytics': minor
'@objectstack/lint': minor
'@objectstack/platform-objects': minor
'@object-ui/app-shell': minor
---

feat(spec,analytics,lint,platform-objects,app-shell): a dataset measure can aggregate the time between two datetime fields of each record

Clause-②: yes (widening)
Metadata-protocol: added

- **`@objectstack/spec`**: `DatasetMeasureSchema` gains an optional `duration: { from, to, unit }`, in place of `field`. `from` and `to` name two `datetime` fields of the dataset's own object by their bare names, `unit` is `seconds`, `minutes`, `hours` or `days`, and the measure's `aggregate` is `avg`, `min`, `max` or `sum` of `to` minus `from`. A relationship path, an expression or the row wildcard in either column, the same column twice, another unit, a counting aggregate, or a `field`, `derived` or `currency` beside it is refused at parse. New exports: `DatasetMeasureDurationSchema`, `DatasetDurationUnit`, `DATASET_DURATION_UNIT_SECONDS`, `DATASET_DURATION_AGGREGATES`, `DATASET_DURATION_FIELD_TYPE` and the `DatasetMeasureDuration` / `DatasetDurationUnit` types.
- **`@objectstack/service-analytics`**: the dataset compile refuses a duration column declared anything but `datetime` (`400 DATASET_INVALID`, naming the column and its type). The native-SQL strategy computes the duration as one statement on SQLite, PostgreSQL and MySQL, in double precision on each; SQLite and PostgreSQL are tested live to answer the same number: a record missing either instant is left out, a `to` before its `from` counts as negative, and over a group with no such record `sum` answers `0` and the others `null`. Measure filters, derived measures, ordering and the draft preview serve it. A query the native strategy does not serve (a time dimension bucketed by `granularity`, a field-to-field filter, an object served through engine middleware or an external datasource, a non-SQL datasource) is refused with `400 INVALID_FIELD` naming the measure. Its two columns pass the field-level read gate like any measure's field. The temporal half of the aggregate × field-type refusal now names the duration measure instead of a stored "days open" number.
- **`@objectstack/lint`**: `os validate` refuses a duration column not declared `datetime` with the new gating rule `measure-duration-field-type-refused` (at `datasets[N].measures[M].duration.from` / `.to`), with an `os explain` entry. `dataset-field-unknown` now also reports a duration column the object does not declare.
- **`@objectstack/platform-objects`**: the dataset form's new **Duration** row is translated in every shipped locale.
- **`@object-ui/app-shell`**: changing a dataset's base object in the console's dataset inspector now clears a duration measure along with the other measures that name the old object's fields, instead of keeping it as if it were a plain row count.

What to do: nothing. A dataset without `duration` parses, compiles and answers exactly as before.
34 changes: 34 additions & 0 deletions content/docs/data-modeling/analytics.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ export const SalesDataset = defineDataset({
{ name: 'won_amount', aggregate: 'sum', field: 'amount', filter: { stage: 'closed_won' } },
// Derived measure — references OTHER measures by name only (no raw fields/SQL).
{ name: 'win_rate', derived: { op: 'ratio', of: ['won_amount', 'revenue'] }, format: '0.0%' },
// Duration measure — the time between two datetime fields of each record.
{ name: 'avg_days_to_close', aggregate: 'avg', duration: { from: 'created_at', to: 'closed_at', unit: 'days' } },
],
});
```
Expand Down Expand Up @@ -94,6 +96,38 @@ export default defineStack({
- **RLS / tenant scoping is enforced by the runtime**, per joined object — never
declared in the dataset. There is one place to reason about access.

### Duration measures

A measure can aggregate the time between two `datetime` fields of the same
record (time to resolve, time to first response, cycle time) with `duration`
in place of `field`:

```ts
{ name: 'avg_hours_to_resolve', aggregate: 'avg', duration: { from: 'created_at', to: 'resolved_at', unit: 'hours' } }
```

- `from` and `to` name two `datetime` fields of the dataset's own object, by
their bare names. A relationship path or an expression is refused when the
dataset is parsed. A `date`, `time`, number or text field is refused too:
`os validate` reports `measure-duration-field-type-refused`, and the dataset
compile answers `400 DATASET_INVALID`.
- `unit` is `seconds`, `minutes`, `hours` or `days`, and the measure answers a
number of them (`1.5` hours). `aggregate` is `avg`, `min`, `max` or `sum`.
- A record missing either instant is left out, as a null is left out of an
`avg`. A `to` earlier than its `from` counts as a negative duration. Over a
group with no record carrying both, `sum` answers `0` and the others answer
nothing.
- A measure `filter`, and a `derived` measure naming the duration measure in
`of`, work as they do for any measure.
- It runs as one SQL statement on SQLite, PostgreSQL and MySQL, computed in
double precision on each. A query the service cannot compile to one such
statement is refused with `400 INVALID_FIELD` naming the measure, rather than
answered another way: a time dimension bucketed by `granularity` (a monthly
trend), a field-to-field comparison in a filter, an object served through
engine middleware or an external datasource, or a datasource that is not SQL.
- Comparing each record's duration against a value held on another object (an
SLA target per ticket type, say) is not part of it.

## Binding a dashboard widget

A widget selects dimensions/measures by name. Its presentation-scope `filter`
Expand Down
10 changes: 10 additions & 0 deletions content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,16 @@ on the object the last hop reaches — the join the cube declares for that hop,
else the lookup's `reference`. A measure of an expression type (`number`,
`string`, `boolean`) and the row wildcard `'*'` are not judged by this check.

A **duration measure** (`duration: { from, to, unit }`, the time between two
fields of each record) is judged by the same check under a third id. Each of
`from` and `to` must name a field declared `datetime`; one declared anything
else (a `date`, a `time`, a number, text) is refused
(`measure-duration-field-type-refused`) at
`datasets[N].measures[M].duration.from` or `.to`, because it holds no instant
to measure from. The analytics service refuses the same column with
`400 DATASET_INVALID` when a query is built. A name the object does not declare
is reported as `dataset-field-unknown` instead.

### 7. Navigation exposing objects nobody can read

Navigation and permissions are separate metadata, each valid on its own — so an
Expand Down
10 changes: 5 additions & 5 deletions content/docs/references/index.mdx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: Protocol reference — every schema by module
navTitle: Protocol Reference
description: Every schema published by @objectstack/spec — 1542 schemas across 14 protocol modules
description: Every schema published by @objectstack/spec — 1544 schemas across 14 protocol modules
---

{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
Expand Down Expand Up @@ -33,8 +33,8 @@ counts are sums of the rows they head. Regenerate with
| [Shared Protocol](/docs/references/shared) | 10 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. |
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
| [System Protocol](/docs/references/system) | 37 | 286 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
| [UI Protocol](/docs/references/ui) | 16 | 169 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
| **Total** | **199** | **1542** | 14 protocol modules |
| [UI Protocol](/docs/references/ui) | 16 | 171 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
| **Total** | **199** | **1544** | 14 protocol modules |

---

Expand Down Expand Up @@ -366,7 +366,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a

## UI Protocol

**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 169 schemas**
**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 171 schemas**

Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer.

Expand All @@ -379,7 +379,7 @@ Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI lay
| [`chart.zod.ts`](/docs/references/ui/chart) | `ChartAggregate`, `ChartAggregateFunction`, `ChartAnnotation`, `ChartAxis`, `ChartConfig`, `ChartDrillDown`, `ChartGroupBy`, `ChartInteraction`, `ChartSeries`, `ChartType` |
| [`component.zod.ts`](/docs/references/ui/component) | `ActionButtonProps`, `ActionGroupProps`, `ActionIconProps`, `ActionMenuProps`, `ElementButtonProps`, `ElementDefinitionListProps`, `ElementFilterProps`, `ElementFormProps`, `ElementImageProps`, `ElementMetadataViewerProps`, `ElementNumberProps`, `ElementRecordPickerProps`, `ElementRepeaterProps`, `ElementTextInputProps`, `ElementTextProps`, `ObjectCalendarProps`, `ObjectFormProps`, `ObjectGanttProps`, `ObjectGridProps`, `ObjectKanbanProps`, `ObjectMapProps`, `ObjectMasterDetailFormProps`, `ObjectMetricProps`, `ObjectTimelineProps`, `ObjectTreeProps`, `PageAccordionProps`, `PageCardProps`, `PageContainerProps`, `PageHeaderProps`, `PageTabsProps`, `RecordActivityProps`, `RecordAlertAction`, `RecordAlertProps`, `RecordChatterProps`, `RecordDetailsProps`, `RecordHighlightsField`, `RecordHighlightsProps`, `RecordHistoryProps`, `RecordLineItemsProps`, `RecordPathProps`, `RecordQuickActionsProps`, `RecordReferenceRailProps`, `RecordRelatedListProps`, `ReferenceRailEntry` |
| [`dashboard.zod.ts`](/docs/references/ui/dashboard) | `Dashboard`, `DashboardHeader`, `DashboardHeaderAction`, `DashboardWidget`, `DashboardWidgetChartConfig`, `DashboardWidgetOptions`, `GlobalFilter`, `GlobalFilterOptionsFrom`, `WidgetActionType`, `WidgetColorVariant` |
| [`dataset.zod.ts`](/docs/references/ui/dataset) | `Dataset`, `DatasetDimension`, `DatasetMeasure`, `DerivedMeasureOp` |
| [`dataset.zod.ts`](/docs/references/ui/dataset) | `Dataset`, `DatasetDimension`, `DatasetDurationUnit`, `DatasetMeasure`, `DatasetMeasureDuration`, `DerivedMeasureOp` |
| [`expression-bindable-text-keys.zod.ts`](/docs/references/ui/expression-bindable-text-keys) | `ExpressionBindableTextKey` |
| [`i18n.zod.ts`](/docs/references/ui/i18n) | `AriaProps`, `I18nLabel`, `InlineLocaleMap` |
| [`notification.zod.ts`](/docs/references/ui/notification) | `NotificationPosition`, `NotificationSeverity`, `NotificationType` |
Expand Down
41 changes: 38 additions & 3 deletions content/docs/references/ui/dataset.mdx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: Dataset schema — UI Protocol reference
navTitle: Dataset
description: "Analytics Dataset — the one semantic layer. Reference for Dataset, DatasetDimension, DatasetMeasure, DerivedMeasureOp: every property with its type and default."
description: "Analytics Dataset — the one semantic layer. Reference for Dataset, DatasetDimension, DatasetDurationUnit and 3 more: every property with its type and default."
---

{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
Expand Down Expand Up @@ -34,8 +34,8 @@ layer is absorbed/retired in a later phase (D-A).
## TypeScript Usage

```typescript
import { DatasetSchema, DatasetDimensionSchema, DatasetMeasureSchema, DerivedMeasureOp } from '@objectstack/spec/ui';
import type { Dataset, DatasetDimension, DatasetMeasure } from '@objectstack/spec/ui';
import { DatasetSchema, DatasetDimensionSchema, DatasetDurationUnit, DatasetMeasureSchema, DatasetMeasureDurationSchema, DerivedMeasureOp } from '@objectstack/spec/ui';
import type { Dataset, DatasetDimension, DatasetDurationUnit, DatasetMeasure, DatasetMeasureDuration } from '@objectstack/spec/ui';

// Validate data
const result = DatasetSchema.parse(data);
Expand Down Expand Up @@ -89,6 +89,7 @@ const result = DatasetSchema.parse(data);
| **format** | `string` | optional | Numeral pattern for a NUMERIC measure — grouping, decimals, percent; e.g. "0,0.00", "0.0%". An amount takes its symbol from `currency`, not from a "$" in the pattern. A DATE-valued measure never reads a date pattern: `"YYYY-MM-DD"` renders that arm's default face. A date or datetime value reads `format` as a display style — `short` or `relative`, honoured on both. |
| **currency** | `string` | optional | Display currency code (ISO 4217) |
| **derived** | `{ op: Enum<'ratio' \| 'sum' \| 'difference' \| 'product'>; of: string[] }` | optional | |
| **duration** | `{ from: string; to: string; unit: Enum<'seconds' \| 'minutes' \| 'hours' \| 'days'> }` | optional | Time between two datetime columns of each record (`{ from, to, unit }`), reduced by `aggregate` (avg / min / max / sum). Records with either instant null are excluded. Replaces `field`. |

### Nested Shape: `Dataset.protection`

Expand All @@ -115,6 +116,18 @@ const result = DatasetSchema.parse(data);
| **nullLabel** | `string \| Record<string, string>` | optional | Label for the bucket of rows whose value for this dimension is null (e.g. "System" on a user axis). Display copy only: grouping is unchanged. When absent, consumers fall back to their own default label for that bucket. |


---

## DatasetDurationUnit

### Allowed Values

* `seconds`
* `minutes`
* `hours`
* `days`


---

## DatasetMeasure
Expand All @@ -131,6 +144,28 @@ const result = DatasetSchema.parse(data);
| **format** | `string` | optional | Numeral pattern for a NUMERIC measure — grouping, decimals, percent; e.g. "0,0.00", "0.0%". An amount takes its symbol from `currency`, not from a "$" in the pattern. A DATE-valued measure never reads a date pattern: `"YYYY-MM-DD"` renders that arm's default face. A date or datetime value reads `format` as a display style — `short` or `relative`, honoured on both. |
| **currency** | `string` | optional | Display currency code (ISO 4217) |
| **derived** | `{ op: Enum<'ratio' \| 'sum' \| 'difference' \| 'product'>; of: string[] }` | optional | |
| **duration** | `{ from: string; to: string; unit: Enum<'seconds' \| 'minutes' \| 'hours' \| 'days'> }` | optional | Time between two datetime columns of each record (`{ from, to, unit }`), reduced by `aggregate` (avg / min / max / sum). Records with either instant null are excluded. Replaces `field`. |

### Nested Shape: `DatasetMeasure.duration`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **from** | `string` | ✅ | The earlier instant: a datetime field of the dataset's own object, by its bare name. |
| **to** | `string` | ✅ | The later instant: a datetime field of the dataset's own object, by its bare name. |
| **unit** | `Enum<'seconds' \| 'minutes' \| 'hours' \| 'days'>` | ✅ | Unit of the result: the measure answers a number of these (1.5 with "days" is a day and a half). |


---

## DatasetMeasureDuration

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **from** | `string` | ✅ | The earlier instant: a datetime field of the dataset's own object, by its bare name. |
| **to** | `string` | ✅ | The later instant: a datetime field of the dataset's own object, by its bare name. |
| **unit** | `Enum<'seconds' \| 'minutes' \| 'hours' \| 'days'>` | ✅ | Unit of the result: the measure answers a number of these (1.5 with "days" is a day and a half). |


---
Expand Down
10 changes: 5 additions & 5 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th

| Dir | Sites | strict | passthrough | catchall | strip |
|---|---|---|---|---|---|
| `ui/` | 215 | 204 | 4 | 0 | 7 |
| `ui/` | 216 | 205 | 4 | 0 | 7 |

## `ui/` — sites

Expand All @@ -38,31 +38,31 @@ classify and is not listed (it becomes reportable the day it grows its first sit
| `chart.zod.ts` | 8 |
| `component.zod.ts` | 81 |
| `dashboard.zod.ts` | 11 |
| `dataset.zod.ts` | 4 |
| `dataset.zod.ts` | 5 |
| `i18n.zod.ts` | 1 |
| `page.zod.ts` | 12 |
| `report.zod.ts` | 3 |
| `responsive.zod.ts` | 1 |
| `sharing.zod.ts` | 1 |
| `view.zod.ts` | 59 |
| `widget.zod.ts` | 1 |
| **total** | **215** |
| **total** | **216** |

## `ui/` — open

Per file, how many of its sites still silently discard unknown keys. The `Class`
column that decides the bucket split is hand-written in the ledger; the arithmetic
over it is here.

**7 strip of 215**, in 4 file(s).
**7 strip of 216**, in 4 file(s).

| File | Strip | Sites |
|---|---|---|
| `action-params.zod.ts` | 1 | 1 |
| `app.zod.ts` | 1 | 19 |
| `view.zod.ts` | 4 | 59 |
| `widget.zod.ts` | 1 | 1 |
| **total** | **7** | **215** |
| **total** | **7** | **216** |

| Bucket | Sites |
|---|---|
Expand Down
1 change: 1 addition & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export {
validateDatasetMeasureAggregates,
MEASURE_AGGREGATE_FIELD_TYPE_REFUSED,
DIMENSION_JSON_STORED_FIELD_REFUSED,
MEASURE_DURATION_FIELD_TYPE_REFUSED,
// [#23235] The dimension leg's group-key verdict on its own, so a consumer
// deriving dataset dimensions (cloud's AI build) asks the door's two
// predicates in the door's order here rather than composing them itself.
Expand Down
Loading
Loading