fix(analytics): a dataset measure's result type stops contradicting its own value — min/max over a temporal field is time, not number - #16101
Conversation
…ts 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
📓 Docs Drift CheckThis PR changes 2 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 130 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 464ab7a88b5a70bf82f4f8e434764809d7c7d45b && git checkout 464ab7a88b5a70bf82f4f8e434764809d7c7d45b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 33e939ff318ffecbaf9fe4dd4401dee679e08698 dacea17828c14d802f7d1752a30185b188409f03 && git checkout -B drift-repro 33e939ff318ffecbaf9fe4dd4401dee679e08698 && git merge --no-ff dacea17828c14d802f7d1752a30185b188409f03
node scripts/docs-audit/affected-docs.mjs --json 33e939ff318ffecbaf9fe4dd4401dee679e08698
|
PM 验收 · 独立复核了一处,结论:成立。⛔ 其余未重测。本席位只重测了一件事 —— 正文里那个唯一的设计选择(为什么是 我担心的是什么dev 记了一条未立卡的观察:
而正文用「同一响应里的时间维度列本来就是 实测:两个函数,两条路径,不是同一件事正文引的是后者,而 dev 的观察说的是前者。⇒ 两者服务不同的构造(自动推断的 cube vs 已授权的 dataset),⛔ 不构成同一响应内的矛盾。 关键读数 —— switch (d.type) {
case 'date': return 'time';
case 'number': return 'number';
case 'boolean': return 'boolean';
case 'lookup': return 'string';
case 'string': return 'string';
default: return 'string';
}⭐ ⇒ dev 那条观察谈的是自动推断 cube 的响应,另一种响应形状。⛔ 不与本 PR 冲突,保留为未立卡观察是对的。 ⛔ 本席位没有重测的部分
派单口径核对
⛔ 本 PR 仍为 draft、未 arm —— 按维护者裁决,等总监契约复审,CI 绿本身不是门槛。
Generated by Claude Code |
…s columns like the live one (#16097) `queryDataset`'s ADR-0037 P3 preview branch returned ~250 lines before the ADR-0021 result-column enrichment, so a response over drafted seed rows carried no `label`, `format`, `currency`, `percentScale`, `builtinAggregate` and no `type` correction — on measure and dimension columns alike. The same dataset in the same widget described its columns differently depending only on whether a pending seed draft existed. Every key that block writes is read off the authored dataset and `sourceFieldMeta`, never off `result.rows`, so it is extracted into one `enrichResultColumns` seam that both paths call — one rule, not a per-path copy free to drift, the same argument #15768/#16101's `type` correction already makes for living there. Dimension VALUE label resolution stays skipped on the preview path on purpose; the standing comment is narrowed to say that it is a statement about row values and never covered the column descriptors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Refs #15768— the services half only. The renderer half of that card is untouched here; see "Deliberately left alone" below.What was wrong
POST /api/v1/analytics/dataset/querydescribed every measure column astype: "number", including amin/maxover adate/datetime/timefield whose value in the same response is an ISO instant:{"rows":[{"oldest_last_update_at":"2026-07-04T07:00:00.000Z","untouched_over_30d":3}], "fields":[{"name":"oldest_last_update_at","type":"number","label":"Oldest touch","format":"relative"}, {"name":"untouched_over_30d","type":"number","label":"Untouched > 30 days"}]}minandmaxreturn a value of the aggregated field's own type. The column carried an instant and the metadata beside it denied it — enough on its own to keep a formatter that branches on the declared type from ever reaching a temporal branch.What changed
packages/services/service-analytics/src/measure-result-type.ts—measureResultType(aggregate, sourceFieldType), the enumerated per-aggregate verdict, answeringundefinedfor "nothing to say, keep what the producer minted".analytics-service.ts— two lines inqueryDataset's ADR-0021 result-column enrichment, plus the import and the note explaining why the correction is made there.packages/spec/src/contracts/analytics-service.ts+packages/spec/src/api/analytics.zod.ts—AnalyticsResult.fields[].typeand its wire schema now state the vocabulary this position speaks and what each aggregate answers. Neither declaration widens: the wire type was, and remains, a string.minorfor@objectstack/service-analytics(a published wire surface changes what it produces),patchfor@objectstack/spec(declaration text only).Row values are untouched on every path.
computeDerivedstill coerces its operands withNumber()and still sees exactly the values it saw before — this change moves column METADATA only.Where the assembly point is, and how it was proved
The triage seat recorded that it had not located the production code behind
fields[].type— its grep hit only test constants undersrc/__tests__/**. Treating that as not found rather than found, the search turned up four producers of the measure descriptor, each spelling{ name: m, type: 'number' }and none of them knowing the aggregated field's type:ObjectQLStrategy.buildFieldMetastrategies/objectql-strategy.tsNativeSQLStrategy.buildFieldMetastrategies/native-sql-strategy.tsevaluateAnalyticsQueryOverRowspreview-evaluator.tsDatasetExecutor.runMeasurePass(+ compare / derived appends)dataset-executor.tsWhat all of them pass through is
AnalyticsService.queryDataset's ADR-0021 enrichment block — the one that already resolveslabel/format/currency/percentScale/builtinAggregatefrom the authored measure plussourceFieldMeta. The REST face relays that method's return verbatim: thePOST {basePath}/analytics/dataset/queryroute inpackages/rest/src/rest-server.tsendsres.json(result). So the enrichment block is the wire's last word on this key, and it is the only place holding both halves of the question — the authored measure (aggregate+field) and the source field's declared type. A per-producer copy would be four implementations of one rule, free to drift.Proved by control, not by reading. Reverting only the two-line call site (leaving the rule module in place) and re-measuring:
1 → 0, injected marker0 → 1, file blob978d82ea → d23ca934.Tests 5 failed | 20 passed (25), and the five are exactly the cases that asserttime— the ObjectQL producer, the supplementary-sub-query producer, the primarybuildFieldMetaproducer, the__compareproducer, and the two-strategy agreement case. The assertion diff reads- "type": "time"/+ "type": "number".978d82ea, byte-identical to theHEADblob,git diff HEADempty.The subject is reached by a relative SOURCE import inside its own package, not through a dependency's
exportsmap, so no build sits between the edit and the run — the implementation itself measured green with noservice-analyticsbuild after it was written, which is the same fact from the other side.Two of the four producers are the two
buildFieldMetas, and the test drives one selection down both of them and asserts identical column metadata. That is only possible if the value the wire carries is decided downstream of both, which is the claim.The population, enumerated
AggregationFunction(packages/spec/src/data/query.zod.ts) is a closed vocabulary, so the population is finite and every member is answered. The test asserts the table's membership againstAggregationFunction.options, so a member added to the spec fails here instead of silently inheriting the flatnumber.countnumber— unchangeddatetimes is still counting; typing it otherwise would be a new bugcount_distinctnumber— unchangedsumnumber— unchangedavgnumber— unchangedmintimewhen that field is temporalmaxtimewhen that field is temporalderived)number— unchangedcomputeDerivedcoerces operands withNumber(), so it is numeric by constructionsum/avgover a temporal field was established, not assumed. Nothing in the shipped stack refuses the pair:DatasetMeasureSchemadeclaresaggregateandfieldindependently,dataset-compiler.aggregateToMetricTypechecks only vocabulary membership, the three source-field gates (assertMeasureFields/assertDimensionFields/assertWhereFields) check only that the column exists, andpackages/lintcarries no rule pairing the two. It therefore reaches the driver, where the answer is backend-decided — a mean of epoch integers on SQLite, an error on Postgres, which has noavg(timestamptz). There is no one value for a type to describe, so none is invented and the gap is reported as its own card rather than papered over.The field-type axis is deliberately narrower than the aggregate axis. Only the temporal family —
date,datetime,time— is corrected: the population the card measured and the triage ruled on. Amin/maxover atext/select/lookupfield returns a string and is still described asnumber; that is the same defect over a different population, reported separately rather than absorbed here, because several members of the wider set (autonumber,boolean,formula) have genuinely uncertain answers that would ship as declarations if guessed.Tiered "cannot answer, do not block". A host with no
sourceFieldMetawired, and a measure over a relationship PATH (sourceFieldMetaresolves a column on the BASE object, so a dotted field answersundefined), both leave the column exactly as the query layer produced it. Both are pinned.Why
timeand notdate/datetimefields[].typeis not aFieldTypeposition. A temporal DIMENSION column in the very same response already carriestime—DatasetDimensionSchema'stype: 'date'compiles to a cube dimension oftype: 'time'(dataset-compiler.dimensionType), and bothbuildFieldMetas copy it through.timeis theDimensionTypevocabulary this position already speaks (string/number/boolean/time/geo), so a consumer that can draw a date axis at all already has the branch. A second temporal word in one wire position would leave every existingtimebranch unreached. A test pins the dimension column'stimebeside the measure column's, so the two cannot drift into two spellings.Deliberately left alone
numberin the analytics response, and a metric tile ignores theformatstyle it is handed — measured on 17.3.0 #15768 — the metric tile ignoring theformatstyle it is handed. That is anobjectuiconcern, the card itself flags it as possibly version skew rather than a defect, and the triage seat kept confirming that as a precondition for whoever takes it. Noobjectuiworktree was opened and nothing here asserts whether it is or is not skew. This work produced no evidence either way: the tile was never rendered.text-match-sql.ts,like-pattern.ts,read-scope-sql.tsand everything understrategies/. The fix did not need them; the correction is made downstream of bothbuildFieldMetas precisely because that is the one seam all four producers share.queryDatasetreturns the preview result early, before the ADR-0021 enrichment, so a preview response already carries nolabel,format,currency,percentScaleorbuiltinAggregate— and therefore no type correction either. Adding only the type there would make that path inconsistent in a new way. Filed as A draft-preview dataset response skips the whole ADR-0021 result-column enrichment — no label, no format, no currency, no percentScale #16097.content/docs/releases/— untouched. This PR's input to release notes is its changeset.Out-of-scope findings, filed unassigned
Duplicate-checked with one targeted search each; the search was verified live in this session by a control term that returned #15768 itself.
min/maxover a text / select / lookup field is still described astype: "number"in the analytics response #16098 — amin/maxover a text / select / lookup field is still described asnumber.avgover a datetime works on SQLite and errors on Postgres #16099 — no layer refuses an incoherent aggregate / field-type pair, so anavgover a datetime works on SQLite and errors on Postgres.One further observation, not filed because it may well be deliberate:
CubeRegistry.fieldTypeToDimensionTypemapsdateanddatetimetotimebut leavesField.timeon thedefaultarm, so an auto-inferred cube types a clock-time dimension asstring. A clock time has no calendar buckets, so that may be the intended answer.Verification — measured, with exit codes
Every exit code was captured immediately after a single redirected command, never through a pipe. Exit
3isPREREQUISITE NOT MET, not a pass: each one below was satisfied and the gate re-run.pnpm --workspace-concurrency=2 --filter '@objectstack/service-analytics^...' buildpnpm --filter @objectstack/service-analytics exec vitest run --maxWorkers=2 src/__tests__/measure-result-type.test.tsTest Files 1 passed (1),Tests 25 passed (25)pnpm --filter @objectstack/service-analytics testTest Files 93 passed (93),Tests 2027 passed (2027)pnpm --filter @objectstack/service-analytics typechecktsc --noEmit --listFiles, grepmeasure-result-type.tsand__tests__/measure-result-type.test.ts— so the green above is not a skippnpm --filter @objectstack/spec buildthencheck:generatedTests 5 failed/20 passed (25)— exactly the predicted five; restore verified byte-identical to theHEADblobThe gate family was derived mechanically from the actual changed files, not hand-built:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack. Its Reconciliation line reads 69 families (57 by path + 7 by change kind + 7 declared whole-tree, 2 reached both ways). All 69 were run at the final commitdacea1782, and all 69 exited 0. Three answered3on the first pass and were re-run green after their prerequisite:check:doc-formula-expressions— neededturbo run build --filter=@objectstack/lint.check:dual-build-cjs-loadsandcheck:type-check-debt— needed the full closure,turbo run build --filter='./packages/*' --filter='./packages/*/*';check:type-check-debtthen needed@objectstack/service-analyticsrebuilt on top, because this PR edits its sources.Final read of the ledger gate:
check-type-check-coverage --re-measure: OK — 12 ledger entries re-measured, 140 raw tsc errors total, none above its recorded number. surplus: none.Six further families this PR's paths reach take a value from the workflow (
$RUNNER_TEMP,${{ matrix.shard }}) and have no local invocation. They are UNMEASURED here — the derivation names them and refuses to invent a command, and so does this note.Heavy runs went through
scripts/pm/os-verify-lock.sh; the verdict line, not a bare$?, is what each exit above was read from.🤖 Generated with Claude Code
https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Generated by Claude Code