Skip to content

Commit d1663ba

Browse files
feat(spec,analytics,lint,platform-objects,app-shell): a dataset measure can aggregate the time between two datetime fields of each record (#25903)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSi9FC1KtovKr42Nv6HsVMzxW)_ Fixes #25778 Clause-②: yes (widening) Metadata-protocol: added ## Summary A dataset measure can now aggregate the time between two `datetime` fields of each record. This is item 1 of the card. Item 2 (comparing against "now minus N days" where N lives on a related record) needs its own key, so it is split to #25808 as the order directed. ```ts { name: 'avg_hours_to_resolve', aggregate: 'avg', duration: { from: 'created_at', to: 'resolved_at', unit: 'hours' } } ``` - **Spec** (`packages/spec/src/ui/dataset.zod.ts`): `DatasetMeasureSchema` gains an optional `duration: { from, to, unit }` in place of `field`. - `from` and `to` are two bare column names of the dataset's own object. - `unit` is `seconds | minutes | hours | days`. - `aggregate` is `avg | min | max | sum`. - Refused at parse: a path, an expression or `*` in either column, the same column twice, any other unit, a counting or missing aggregate, and `field` / `derived` / `currency` beside it. - No expression language and no SQL enter the metadata (ADR-0021). - **Analytics**: - The compile refuses a column not declared `datetime` with `400 DATASET_INVALID`. - `NativeSQLStrategy` computes `to - from` per row in double precision, per dialect: SQLite `julianday`, PostgreSQL `EXTRACT(EPOCH …)`, MySQL `TIMESTAMPDIFF(MICROSECOND …) / 1.0E6`. It divides by the unit and wraps the result in the aggregate, or in the conditional aggregate when the measure has a scoped filter. - The draft preview computes the same value from the rows. - Nulls in either column are excluded. A `to` before its `from` counts as negative. Over a group with no record carrying both, `sum` answers `0` and the others `null`. The `0` is answered in the statement (`withEmptyGroupIdentity`, as for every other measure since #25953), so it sorts and pages as `0`. - **Fail-closed**: the measure is deliberately absent from `cube.measures`. Every other path refuses it with `400 INVALID_FIELD`, naming the measure: a `granularity` bucket, a field-to-field filter, an object served through middleware or an external datasource, an unknown dialect, or a non-SQL driver. The field-level read gate judges both columns. `getMeta` lists the measure, and the `CUBE.MEASURE` spelling it lists is served too. - **`os validate`**: 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 covers both columns. - **Console** (`ui/packages/app-shell`, in this repo since the objectui merge): the dataset inspector's base-object change now clears a duration measure with the other measures that name the old object's fields. Before this it was kept as if it were a plain row count, because it has no `field`. - **Docs**: `data-modeling/analytics.mdx` gets a "Duration measures" section, and `deployment/validating-metadata.mdx` explains the rule. The liveness ledger drills `duration.{from,to,unit}`. ## Tests - `packages/services/service-analytics/src/__tests__/dataset-duration-measure.test.ts`: live on **SQLite and PostgreSQL**, with 7 cases on each driver (14/14 in total). - Covers values per unit and aggregate, nulls, a negative duration, an empty group, a measure filter, a derived measure, ordering with limit and offset (the all-NULL group pages as `0` on both drivers), and the cube door. - Rejections assert `code` + `status`: granularity, objectql-only, and date / number / text columns. - `native-sql-duration-measure.test.ts` (23): - exact SQL per dialect, joins, temporal repair, the unknown-dialect decline, presentation, and the empty-group identity in the statement; - the preview evaluator; - service gates: no mint, the read gate (including a dimension sharing the measure's name), dry-run, preview, the unparsed-dataset door, compile guards, `getMeta`, the `CUBE.MEASURE` spelling, and the `$field` refusal. - `packages/spec/src/ui/dataset-measure-duration.test.ts` (18), `packages/lint/src/validate-dataset-measure-duration.test.ts` (12), and the ADR-0122 isomorphism pins. - `ui/packages/app-shell/.../DatasetDefaultInspector.firstPickKeepsCount-12848.test.tsx`: the move fixture gains a duration measure, and the move clears it. The test fails with the predicate reverted. - Full suites: service-analytics 4842 pass at `8821a8d4`, with 1 pre-existing failure ("the server is not on UTC", because the local PG runs on UTC). Lint 7310 pass. `tsc --noEmit` is clean for service-analytics, and the typecheck of lint, platform-objects and spec exits 0. - Not measured locally: the `@object-ui/app-shell` type-check. `@object-ui/components` fails its own declaration build in this container at the main tip, in files this diff does not touch, so CI's `Type Check UI` is the reading for it. It is green on this head. ## Review An independent full review ran on Fable. Round 1 was CHANGES REQUESTED, with 1 must-fix, 2 should-fix and 4 nits: - the read-gate escape when a dimension shares the name; - MySQL DECIMAL arithmetic; - `getMeta` not listing the measure; - the `CUBE.MEASURE` spelling; - docs and comment nits. All of them are applied except F6 (refusing a dimension/measure name collision at parse), which is a narrowing and so is out of scope. Three delta reviews followed, the last one after the ui/ merge, and each answered ACCEPT. The verdict record is in a comment on this PR. ## Acceptance notes - **Item 2 → #25808.** It needs a now-anchored comparand with a `$field` offset read across a relationship, and the #5222 rulings keep SQL `$field` to the same table. - **Not served off the native-SQL strategy.** The engine/ObjectQL aggregate has no node for a two-column operand, so a duration with a `granularity` bucket (a monthly trend) is refused loudly rather than answered. Follow-up candidate: an engine aggregation operand for durations. - **The console inspector has no editor for `duration`.** A duration measure keeps its key through edits (`patchMeasure` spreads the row), and picking a field beside it is refused by the spec at save. Follow-up candidate: a from / to / unit editor in `DatasetDefaultInspector`. - **No wire `unit` on result fields.** The unit lives on the dataset definition, and adding it to the result is a further protocol addition. Deliberately not done here. - **MySQL is pinned as text only.** This package runs no live MySQL, so the docs claim live parity for SQLite and PostgreSQL only. - **Pre-existing, measured: a filtered ordinary measure asked as `CUBE.MEASURE` drops its filter.** `query({ cube: 'sales', measures: ['sales.won_amount'] })` runs `SUM(amount)` while `['won_amount']` runs `SUM(CASE WHEN stage = $1 THEN amount END)`. That spelling is the one `getMeta` lists. The duration measure resolves its filter by the bare key, but ordinary measures do not. Filed as #25906. - **An all-NULL sum group now sorts and pages as its `0` (#25907, fixed on main by #25953).** It used to fold to `0` only after the statement had ordered and paged it as NULL, at opposite ends on SQLite and PostgreSQL. Since the merge of main at `38050ced`, the duration measure takes the same in-statement identity, and the live ordering test includes that group: ascending, it pages b (-3599.25), c (0), a (91800) on both drivers. - **Unmeasured: a dimension and a measure may share a name** (`DatasetSchema` checks uniqueness per list). Refusing that is a narrowing (review F6). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01AKPixnkg91qCkQCVFDJuqW --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0479cd6 commit d1663ba

40 files changed

Lines changed: 2079 additions & 73 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-analytics': minor
4+
'@objectstack/lint': minor
5+
'@objectstack/platform-objects': minor
6+
'@object-ui/app-shell': minor
7+
---
8+
9+
feat(spec,analytics,lint,platform-objects,app-shell): a dataset measure can aggregate the time between two datetime fields of each record
10+
11+
Clause-②: yes (widening)
12+
Metadata-protocol: added
13+
14+
- **`@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.
15+
- **`@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.
16+
- **`@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.
17+
- **`@objectstack/platform-objects`**: the dataset form's new **Duration** row is translated in every shipped locale.
18+
- **`@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.
19+
20+
What to do: nothing. A dataset without `duration` parses, compiles and answers exactly as before.

‎content/docs/data-modeling/analytics.mdx‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ export const SalesDataset = defineDataset({
6464
{ name: 'won_amount', aggregate: 'sum', field: 'amount', filter: { stage: 'closed_won' } },
6565
// Derived measure — references OTHER measures by name only (no raw fields/SQL).
6666
{ name: 'win_rate', derived: { op: 'ratio', of: ['won_amount', 'revenue'] }, format: '0.0%' },
67+
// Duration measure — the time between two datetime fields of each record.
68+
{ name: 'avg_days_to_close', aggregate: 'avg', duration: { from: 'created_at', to: 'closed_at', unit: 'days' } },
6769
],
6870
});
6971
```
@@ -94,6 +96,38 @@ export default defineStack({
9496
- **RLS / tenant scoping is enforced by the runtime**, per joined object — never
9597
declared in the dataset. There is one place to reason about access.
9698

99+
### Duration measures
100+
101+
A measure can aggregate the time between two `datetime` fields of the same
102+
record (time to resolve, time to first response, cycle time) with `duration`
103+
in place of `field`:
104+
105+
```ts
106+
{ name: 'avg_hours_to_resolve', aggregate: 'avg', duration: { from: 'created_at', to: 'resolved_at', unit: 'hours' } }
107+
```
108+
109+
- `from` and `to` name two `datetime` fields of the dataset's own object, by
110+
their bare names. A relationship path or an expression is refused when the
111+
dataset is parsed. A `date`, `time`, number or text field is refused too:
112+
`os validate` reports `measure-duration-field-type-refused`, and the dataset
113+
compile answers `400 DATASET_INVALID`.
114+
- `unit` is `seconds`, `minutes`, `hours` or `days`, and the measure answers a
115+
number of them (`1.5` hours). `aggregate` is `avg`, `min`, `max` or `sum`.
116+
- A record missing either instant is left out, as a null is left out of an
117+
`avg`. A `to` earlier than its `from` counts as a negative duration. Over a
118+
group with no record carrying both, `sum` answers `0` and the others answer
119+
nothing.
120+
- A measure `filter`, and a `derived` measure naming the duration measure in
121+
`of`, work as they do for any measure.
122+
- It runs as one SQL statement on SQLite, PostgreSQL and MySQL, computed in
123+
double precision on each. A query the service cannot compile to one such
124+
statement is refused with `400 INVALID_FIELD` naming the measure, rather than
125+
answered another way: a time dimension bucketed by `granularity` (a monthly
126+
trend), a field-to-field comparison in a filter, an object served through
127+
engine middleware or an external datasource, or a datasource that is not SQL.
128+
- Comparing each record's duration against a value held on another object (an
129+
SLA target per ticket type, say) is not part of it.
130+
97131
## Binding a dashboard widget
98132

99133
A widget selects dimensions/measures by name. Its presentation-scope `filter`

‎content/docs/deployment/validating-metadata.mdx‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,16 @@ on the object the last hop reaches — the join the cube declares for that hop,
249249
else the lookup's `reference`. A measure of an expression type (`number`,
250250
`string`, `boolean`) and the row wildcard `'*'` are not judged by this check.
251251

252+
A **duration measure** (`duration: { from, to, unit }`, the time between two
253+
fields of each record) is judged by the same check under a third id. Each of
254+
`from` and `to` must name a field declared `datetime`; one declared anything
255+
else (a `date`, a `time`, a number, text) is refused
256+
(`measure-duration-field-type-refused`) at
257+
`datasets[N].measures[M].duration.from` or `.to`, because it holds no instant
258+
to measure from. The analytics service refuses the same column with
259+
`400 DATASET_INVALID` when a query is built. A name the object does not declare
260+
is reported as `dataset-field-unknown` instead.
261+
252262
### 7. Navigation exposing objects nobody can read
253263

254264
Navigation and permissions are separate metadata, each valid on its own — so an

‎content/docs/references/index.mdx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: Protocol reference — every schema by module
33
navTitle: Protocol Reference
4-
description: Every schema published by @objectstack/spec — 1542 schemas across 14 protocol modules
4+
description: Every schema published by @objectstack/spec — 1544 schemas across 14 protocol modules
55
---
66

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

3939
---
4040

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

367367
## UI Protocol
368368

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

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

@@ -379,7 +379,7 @@ Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI lay
379379
| [`chart.zod.ts`](/docs/references/ui/chart) | `ChartAggregate`, `ChartAggregateFunction`, `ChartAnnotation`, `ChartAxis`, `ChartConfig`, `ChartDrillDown`, `ChartGroupBy`, `ChartInteraction`, `ChartSeries`, `ChartType` |
380380
| [`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` |
381381
| [`dashboard.zod.ts`](/docs/references/ui/dashboard) | `Dashboard`, `DashboardHeader`, `DashboardHeaderAction`, `DashboardWidget`, `DashboardWidgetChartConfig`, `DashboardWidgetOptions`, `GlobalFilter`, `GlobalFilterOptionsFrom`, `WidgetActionType`, `WidgetColorVariant` |
382-
| [`dataset.zod.ts`](/docs/references/ui/dataset) | `Dataset`, `DatasetDimension`, `DatasetMeasure`, `DerivedMeasureOp` |
382+
| [`dataset.zod.ts`](/docs/references/ui/dataset) | `Dataset`, `DatasetDimension`, `DatasetDurationUnit`, `DatasetMeasure`, `DatasetMeasureDuration`, `DerivedMeasureOp` |
383383
| [`expression-bindable-text-keys.zod.ts`](/docs/references/ui/expression-bindable-text-keys) | `ExpressionBindableTextKey` |
384384
| [`i18n.zod.ts`](/docs/references/ui/i18n) | `AriaProps`, `I18nLabel`, `InlineLocaleMap` |
385385
| [`notification.zod.ts`](/docs/references/ui/notification) | `NotificationPosition`, `NotificationSeverity`, `NotificationType` |

‎content/docs/references/ui/dataset.mdx‎

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: Dataset schema — UI Protocol reference
33
navTitle: Dataset
4-
description: "Analytics Dataset — the one semantic layer. Reference for Dataset, DatasetDimension, DatasetMeasure, DerivedMeasureOp: every property with its type and default."
4+
description: "Analytics Dataset — the one semantic layer. Reference for Dataset, DatasetDimension, DatasetDurationUnit and 3 more: every property with its type and default."
55
---
66

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

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

4040
// Validate data
4141
const result = DatasetSchema.parse(data);
@@ -89,6 +89,7 @@ const result = DatasetSchema.parse(data);
8989
| **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. |
9090
| **currency** | `string` | optional | Display currency code (ISO 4217) |
9191
| **derived** | `{ op: Enum<'ratio' \| 'sum' \| 'difference' \| 'product'>; of: string[] }` | optional | |
92+
| **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`. |
9293

9394
### Nested Shape: `Dataset.protection`
9495

@@ -115,6 +116,18 @@ const result = DatasetSchema.parse(data);
115116
| **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. |
116117

117118

119+
---
120+
121+
## DatasetDurationUnit
122+
123+
### Allowed Values
124+
125+
* `seconds`
126+
* `minutes`
127+
* `hours`
128+
* `days`
129+
130+
118131
---
119132

120133
## DatasetMeasure
@@ -131,6 +144,28 @@ const result = DatasetSchema.parse(data);
131144
| **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. |
132145
| **currency** | `string` | optional | Display currency code (ISO 4217) |
133146
| **derived** | `{ op: Enum<'ratio' \| 'sum' \| 'difference' \| 'product'>; of: string[] }` | optional | |
147+
| **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`. |
148+
149+
### Nested Shape: `DatasetMeasure.duration`
150+
151+
| Property | Type | Required | Description |
152+
| :--- | :--- | :--- | :--- |
153+
| **from** | `string` | ✅ | The earlier instant: a datetime field of the dataset's own object, by its bare name. |
154+
| **to** | `string` | ✅ | The later instant: a datetime field of the dataset's own object, by its bare name. |
155+
| **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). |
156+
157+
158+
---
159+
160+
## DatasetMeasureDuration
161+
162+
### Properties
163+
164+
| Property | Type | Required | Description |
165+
| :--- | :--- | :--- | :--- |
166+
| **from** | `string` | ✅ | The earlier instant: a datetime field of the dataset's own object, by its bare name. |
167+
| **to** | `string` | ✅ | The later instant: a datetime field of the dataset's own object, by its bare name. |
168+
| **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). |
134169

135170

136171
---

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th
2121

2222
| Dir | Sites | strict | passthrough | catchall | strip |
2323
|---|---|---|---|---|---|
24-
| `ui/` | 215 | 204 | 4 | 0 | 7 |
24+
| `ui/` | 216 | 205 | 4 | 0 | 7 |
2525

2626
## `ui/` — sites
2727

@@ -38,31 +38,31 @@ classify and is not listed (it becomes reportable the day it grows its first sit
3838
| `chart.zod.ts` | 8 |
3939
| `component.zod.ts` | 81 |
4040
| `dashboard.zod.ts` | 11 |
41-
| `dataset.zod.ts` | 4 |
41+
| `dataset.zod.ts` | 5 |
4242
| `i18n.zod.ts` | 1 |
4343
| `page.zod.ts` | 12 |
4444
| `report.zod.ts` | 3 |
4545
| `responsive.zod.ts` | 1 |
4646
| `sharing.zod.ts` | 1 |
4747
| `view.zod.ts` | 59 |
4848
| `widget.zod.ts` | 1 |
49-
| **total** | **215** |
49+
| **total** | **216** |
5050

5151
## `ui/` — open
5252

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

57-
**7 strip of 215**, in 4 file(s).
57+
**7 strip of 216**, in 4 file(s).
5858

5959
| File | Strip | Sites |
6060
|---|---|---|
6161
| `action-params.zod.ts` | 1 | 1 |
6262
| `app.zod.ts` | 1 | 19 |
6363
| `view.zod.ts` | 4 | 59 |
6464
| `widget.zod.ts` | 1 | 1 |
65-
| **total** | **7** | **215** |
65+
| **total** | **7** | **216** |
6666

6767
| Bucket | Sites |
6868
|---|---|

‎packages/lint/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export {
6060
validateDatasetMeasureAggregates,
6161
MEASURE_AGGREGATE_FIELD_TYPE_REFUSED,
6262
DIMENSION_JSON_STORED_FIELD_REFUSED,
63+
MEASURE_DURATION_FIELD_TYPE_REFUSED,
6364
// [#23235] The dimension leg's group-key verdict on its own, so a consumer
6465
// deriving dataset dimensions (cloud's AI build) asks the door's two
6566
// predicates in the door's order here rather than composing them itself.

0 commit comments

Comments
 (0)