Skip to content

Commit dacea17

Browse files
committed
fix(analytics): a dataset measure's result type stops contradicting its own value
Every producer of `AnalyticsResult.fields` minted `{ name, type: 'number' }` for a measure — both strategies' `buildFieldMeta`, the draft-preview evaluator, and `DatasetExecutor.runMeasurePass`'s supplementary / compare / derived appends. That is right for most of the closed `AggregationFunction` vocabulary and wrong for `min`/`max` over a `date`/`datetime`/`time` field, which return a value OF THE AGGREGATED FIELD'S OWN TYPE: the response then carried an ISO instant and a `type: "number"` describing it, in one line. The measure column's type is now resolved in `queryDataset`'s ADR-0021 result-column enrichment — the block that already resolves `label` / `format` / `currency` / `percentScale` from the authored measure plus `sourceFieldMeta`, and the one seam every producer passes through on the way to a route that relays the return verbatim. The rule itself lives in `measure-result-type.ts` so the per-aggregate verdict has one home rather than four copies. The corrected spelling is `time`, the `DimensionType` word a temporal DIMENSION column in the same response has always carried; a second temporal word in one wire position would leave every existing consumer branch unreached. Only `min`/`max` move. `count`/`count_distinct` are numeric however temporal the column they read is; `sum`/`avg` over a temporal column are refused by no layer and answered by the backend, so no type is invented for them; a derived measure is numeric by construction. Tiered "cannot answer, do not block": a host with no source-field metadata, and a measure over a relationship path, leave the column exactly as produced. Row values are untouched on every path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
1 parent c252041 commit dacea17

6 files changed

Lines changed: 546 additions & 1 deletion

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@objectstack/service-analytics": minor
3+
"@objectstack/spec": patch
4+
---
5+
6+
A dataset measure's `fields[].type` stops contradicting the value beside it: a `min`/`max` over a temporal field is described as `time`, not `number` (#15768)
7+
8+
`POST /api/v1/analytics/dataset/query` described **every** measure column as `type: "number"`, including a `min`/`max` over a `date` / `datetime` / `time` field whose value in the same response is an ISO instant. Measured on a real boot (`@objectstack/cli` 17.3.0, SQLite dev datasource):
9+
10+
```json
11+
{"rows":[{"oldest_last_update_at":"2026-07-04T07:00:00.000Z"}],
12+
"fields":[{"name":"oldest_last_update_at","type":"number","label":"Oldest touch","format":"relative"}]}
13+
```
14+
15+
`min` and `max` return a value **of the aggregated field's own type**, so that column carries an instant and the metadata denied it — which is enough on its own to keep a formatter that branches on the declared type from ever reaching a temporal branch.
16+
17+
What changed:
18+
19+
- **The measure column's type is resolved from the authored measure plus the source field's declared type**, in `AnalyticsService.queryDataset`'s ADR-0021 result-column enrichment — the same block that already resolves `label` / `format` / `currency` / `percentScale`, and the one seam every producer of the shape passes through on the way to the route, which relays that method's return verbatim. The rule itself is `measureResultType` in the new `measure-result-type.ts`, so the per-aggregate verdict has one home instead of four copies.
20+
- **The corrected spelling is `time`**, the `DimensionType` word a temporal DIMENSION column in the same response has always carried. A second temporal word in one wire position would have left every existing consumer branch unreached.
21+
- **Only `min` and `max` move.** `count` and `count_distinct` are numeric however temporal the column they read is; `sum` / `avg` over a temporal column are refused by no layer and answered by the backend (an epoch mean on SQLite, an error on Postgres), so there is no single value for a type to describe and none is invented; a derived measure is numeric by construction, because `computeDerived` coerces its operands with `Number()`. Row values are untouched on every path.
22+
- **Tiered "cannot answer, do not block".** A host with no source-field metadata wired, and a measure over a relationship PATH (which the source-field lookup resolves against the base object and therefore cannot answer), both leave the column exactly as the query layer produced it.
23+
24+
`AnalyticsResult.fields[].type` and the `AnalyticsResultResponse` schema now state the vocabulary this position speaks and what each aggregate answers; neither declaration widens — the wire type was, and remains, a string.

0 commit comments

Comments
 (0)