Repository navigation
feat(spec,analytics): AnalyticsResult.fields[].builtinAggregate discriminator for built-in default measures (#14492) - #15017
Conversation
…iminator for built-in default measures (#14492) `AnalyticsResult.fields[]` gains an optional `builtinAggregate` carrying the closed `AggregationFunction` vocabulary (count | sum | avg | min | max | count_distinct); `AnalyticsResultResponseSchema` mirrors it. The producer (`queryDataset`'s measure enrichment) sets it from the dataset measure's own `aggregate` exactly when the measure has NO authored `label` — the header a renderer draws for it is then the server's built-in default, not an author's text, so an i18n-aware consumer may substitute its own localized name. The `dataset` create seed drops its hardcoded `label: 'Count'` so a Studio-created dataset is a built-in default on the wire rather than an authored English literal. Authored labels (string or locale map) never carry the field. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
📓 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:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 128 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 9a0c6ec94b3b56e3517f808c52e6aafca2950cb2 && git checkout 9a0c6ec94b3b56e3517f808c52e6aafca2950cb2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5d4d55aecfb9eabfa7ffd3835a415229a1f50f78 105ec6c1bc234c47734ab73b7f0736f4160191c6 && git checkout -B drift-repro 5d4d55aecfb9eabfa7ffd3835a415229a1f50f78 && git merge --no-ff 105ec6c1bc234c47734ab73b7f0736f4160191c6
node scripts/docs-audit/affected-docs.mjs --json 5d4d55aecfb9eabfa7ffd3835a415229a1f50f78
|
|
Clause-② contract review — PASS (in-seat, fable tier, epic cloud#1841 PM; reading of PR head 105ec6c):
Clearing Generated by Claude Code |
Closes #14492
Producer half of objectui#7258's ruling B (maintainer, 2026-09-02); epic objectstack-ai/cloud#1841. Clause-②: yes — the public spec surface grows by one optional response member.
Consumer-side contract (for objectui#7258)
AnalyticsResult.fields[]— thequeryDataset()result, relayed verbatim byPOST /api/v1/analytics/dataset/query— now carries an optionalbuiltinAggregateon a measure column, valued from the closedAggregationFunctionvocabulary:count | sum | avg | min | max | count_distinct(reused fromdata/query.zod.ts; no second spelling). It is present exactly when the dataset measure behind the column declares anaggregateand NOlabel— the header a renderer draws for it is then the server's built-in default, not an author's text. It is absent whenever the author declared a label (a plain string or an inline locale map, even one with no entry for the request locale), and absent on dimension columns and derived measures. objectui'sbuildChartSeries()/labelOf()should prefer a locale lookup keyed bybuiltinAggregate— mirroring the existingreport.aggregate.*family (report.aggregate.count= 计数,report.aggregate.countDistinct= 去重计数; note the camelCase key forcount_distinct) — and fall back tolabel, thenname. A consumer that ignores the member sees exactly the response it saw before.What changed, site by site
The ruling named five hardcoded
label: 'Count'sites. Before editing I measured how each one reaches the wire, because the discriminator only helps wherefields[].labelis actually written:fields[].labelhas exactly ONE producer: the measure enrichment inqueryDataset(packages/services/service-analytics/src/analytics-service.ts, the block that also setsformat/currency/percentScale), which copies the DATASET MEASURE's ownlabel. Every otherfields[]assembler emitsname+typeonly —strategies/objectql-strategy.tsbuildFieldMeta,strategies/native-sql-strategy.ts,dataset-executor.ts(compare / derived columns), and driver-memory'smemory-analytics.ts— and the REST route ends inres.json(result)with no reshaping.fields[]. They surface only throughgetMeta().measures[].title, whereCubeMeta.measures[].typealready carries the aggregate beside the title.Given that, the diff is:
packages/spec/src/contracts/analytics-service.ts—AnalyticsResult.fields[].builtinAggregate?: AggregationFunction(TSDoc states the presence rule above).packages/spec/src/api/analytics.zod.ts—AnalyticsResultResponseSchemamirrors the member (AggregationFunction.optional()), refusing a spelling outside the enum. Not on the Claim's file list, but the spec's own drift guard (api/analytics.test.tsbindsAnalyticsResultResponse['data']toAnalyticsResultat compile time) makes the mirror mandatory — the contract change does not compile without it.packages/services/service-analytics/src/analytics-service.ts— thequeryDatasetenrichment setsf.builtinAggregate = m.aggregatewhenm.label == null(the AUTHORED key, never the resolved string, so an unresolved locale map stays an author's label). This is the one site that populates the wire.packages/spec/src/kernel/metadata-create-seeds.ts— thedatasetseed dropslabel: 'Count'from itscountmeasure. A Studio-created dataset is now a built-in default on the wire (builtinAggregate: 'count') instead of an authored English literal. Side effect:getMeta()titles that metriccountrather thanCount(itstypealready saidcount).inferMeasure×2 andcube-registry.tsinferFromObject— left untouched, deliberately. TheirCountnever reachesfields[](measured above). Populating them would require wideningMetricSchema— a strict, authorable cube-metric schema (defineCube,StackSchema) — with a key no wire path reads: a dead authorable key undercheck:liveness, and an AI-authoring foot-gun (a metadata author could stampbuiltinAggregatebeside a custom label). The ruling's item 1 namesAnalyticsResult.fields[]as the contract change; I kept it there. If the maintainer wants the Cube side carried too, that is a separate decision (open question in the report).packages/qa/downstream-contract/src/additional-domains.fixtures.ts— no change needed:DcCubeis a Cube fixture parsed against the strictCubeSchema, and no Cube-side key was added. The suite was run as instructed (green, below) to prove the response-side widening breaks nothing a frozen consumer authors.packages/rest/src/analytics-routes.test.ts— test only, outside the Claim surface per the dispatch's "verify passthrough, add a test if there is a response-shape test": the route relaysfields[].builtinAggregateunchanged.The AI dashboard's actual producer is in cloud, not here
The chart in objectui#7258 was AI-built. Its dataset comes from cloud's
packages/service-ai-studio/src/tools/dataset-derive.ts(line ~206), which writes{ name: 'count', label: 'Count', aggregate: 'count' }— an AUTHORED label from this contract's point of view, so with this PR alone that widget still carrieslabel: 'Count'and no discriminator. The epic needs a one-line cloud follow-up — filed as objectstack-ai/cloud#1933 (sub-issue of cloud#1841, blocked by this PR's pin bump): drop thatlabelso the derivedcountmeasure is a built-in default and the wire carriesbuiltinAggregate: 'count'. The framework seed (item 4) is the same fix on this side.Tests
packages/spec/src/contracts/analytics-service.test.ts— a built-incountcolumn carriesbuiltinAggregate: 'count'; an authored-label column does not; an off-enum spelling is refused at compile time (@ts-expect-error, compiled bycheck:test-typecheck).packages/spec/src/api/analytics.test.ts— the response schema preservesbuiltinAggregateand refuses'total'at pathdata.fields.0.builtinAggregate.packages/services/service-analytics/src/__tests__/query-dataset.test.ts(+5): label-lesscount⇒'count'and no label; authored"Tasks"⇒ label kept, no discriminator; locale-map label with no entry for the request locale ⇒ still no discriminator;sum/avg/count_distinctcarried verbatim when label-less; derived measure ⇒ none while its label-less inputs keep theirs.packages/rest/src/analytics-routes.test.ts(+1): passthrough.builtinAggregate: 'total'went red against the REBUILTdist/*.d.ts—TS2322: Type '"total"' is not assignable to type '"min" | "max" | "count" | "sum" | "avg" | "count_distinct" | undefined'— then was removed;git diff HEADempty.Local verification (head
105ec6c1b)Built the
service-analyticsandrestdependency closures first (pnpm --filter 'PKG^...' build, exit 0 both). Exit codes captured before any pipe.pnpm --filter @objectstack/spec check:generated✓ All 15 generated artifacts are up to date(api-surface, authorable-surface, docs, export-origins, declaration-map, liveness, strictness-ledger, test-typecheck, …)tsc --noEmit·check:test-typecheck54 file(s) / 261 error(s)ledger held)api/analytics·contracts/analytics-service·kernel/metadata-create-seedstypecheck· vitestquery-dataset.test.tstypecheck(incl. test layer) · vitestanalytics-routes.test.tstypecheck·testcheck:doc-formula-expressionscheck:type-check-debt·check:dual-build-cjs-loads·check-dev-prereqs·check-test-completenessPREREQUISITE NOT MET/ needs a whole-treepnpm buildor a turbo test log; exit 3 / 3 / 1 / 3 by their own definition "not a red") — CI builds the whole tree before themcheck:pm-half-states·scripts/pm/check-half-states.mjsGate list derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(60 commands; every one accounted for above). The shared verify lock (scripts/pm/os-verify-lock.sh) reports "no exclusion on this host" (noflockon macOS), so heavy runs went direct.Changeset
.changeset/analytics-result-builtin-aggregate.md—@objectstack/spec: minor,@objectstack/service-analytics: minor(new optional response member; nothing removed or renamed; no ADR-0087 disposition needed since it is not breaking).Out of scope, noted
inferFromObject's composite defaultsLABEL (Sum)/LABEL (Avg)embed the field label, so an aggregate-only discriminator cannot reconstruct them; they stay English ongetMeta()titles. Not in the ruling's five sites. Measured onorigin/main,inferFromObjecthas no runtime caller at all (its only call is a unit test), so ruling site 3 is dead code — filed as #15019 (finding), with delete-vs-wire left to the maintainer.Generated by Claude Code
🤖 Generated with Claude Code