Skip to content

feat(spec,analytics): AnalyticsResult.fields[].builtinAggregate discriminator for built-in default measures (#14492) - #15017

Merged
hotlong merged 1 commit into
mainfrom
feat/analytics-builtin-aggregate-14492
Sep 3, 2026
Merged

hotlong merged 1 commit into
mainfrom
feat/analytics-builtin-aggregate-14492

Conversation

@hotlong

@hotlong hotlong commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

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[] — the queryDataset() result, relayed verbatim by POST /api/v1/analytics/dataset/query — now carries an optional builtinAggregate on a measure column, valued from the closed AggregationFunction vocabulary: count | sum | avg | min | max | count_distinct (reused from data/query.zod.ts; no second spelling). It is present exactly when the dataset measure behind the column declares an aggregate and NO label — 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's buildChartSeries() / labelOf() should prefer a locale lookup keyed by builtinAggregate — mirroring the existing report.aggregate.* family (report.aggregate.count = 计数, report.aggregate.countDistinct = 去重计数; note the camelCase key for count_distinct) — and fall back to label, then name. 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 where fields[].label is actually written:

  • fields[].label has exactly ONE producer: the measure enrichment in queryDataset (packages/services/service-analytics/src/analytics-service.ts, the block that also sets format / currency / percentScale), which copies the DATASET MEASURE's own label. Every other fields[] assembler emits name + type only — strategies/objectql-strategy.ts buildFieldMeta, strategies/native-sql-strategy.ts, dataset-executor.ts (compare / derived columns), and driver-memory's memory-analytics.ts — and the REST route ends in res.json(result) with no reshaping.
  • Cube-metric labels never reach fields[]. They surface only through getMeta().measures[].title, where CubeMeta.measures[].type already carries the aggregate beside the title.

Given that, the diff is:

  1. packages/spec/src/contracts/analytics-service.ts — AnalyticsResult.fields[].builtinAggregate?: AggregationFunction (TSDoc states the presence rule above).
  2. packages/spec/src/api/analytics.zod.ts — AnalyticsResultResponseSchema mirrors 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.ts binds AnalyticsResultResponse['data'] to AnalyticsResult at compile time) makes the mirror mandatory — the contract change does not compile without it.
  3. packages/services/service-analytics/src/analytics-service.ts — the queryDataset enrichment sets f.builtinAggregate = m.aggregate when m.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.
  4. packages/spec/src/kernel/metadata-create-seeds.ts — the dataset seed drops label: 'Count' from its count measure. 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 metric count rather than Count (its type already said count).
  5. inferMeasure ×2 and cube-registry.ts inferFromObject — left untouched, deliberately. Their Count never reaches fields[] (measured above). Populating them would require widening MetricSchema — a strict, authorable cube-metric schema (defineCube, StackSchema) — with a key no wire path reads: a dead authorable key under check:liveness, and an AI-authoring foot-gun (a metadata author could stamp builtinAggregate beside a custom label). The ruling's item 1 names AnalyticsResult.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).
  6. packages/qa/downstream-contract/src/additional-domains.fixtures.ts — no change needed: DcCube is a Cube fixture parsed against the strict CubeSchema, 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.
  7. 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 relays fields[].builtinAggregate unchanged.

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 carries label: '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 that label so the derived count measure is a built-in default and the wire carries builtinAggregate: 'count'. The framework seed (item 4) is the same fix on this side.

Tests

  • packages/spec/src/contracts/analytics-service.test.ts — a built-in count column carries builtinAggregate: 'count'; an authored-label column does not; an off-enum spelling is refused at compile time (@ts-expect-error, compiled by check:test-typecheck).
  • packages/spec/src/api/analytics.test.ts — the response schema preserves builtinAggregate and refuses 'total' at path data.fields.0.builtinAggregate.
  • packages/services/service-analytics/src/__tests__/query-dataset.test.ts (+5): label-less count ⇒ '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_distinct carried verbatim when label-less; derived measure ⇒ none while its label-less inputs keep theirs.
  • packages/rest/src/analytics-routes.test.ts (+1): passthrough.
  • Reverse verification on the consumer side, run after the commit: a scratch file in service-analytics assigning builtinAggregate: 'total' went red against the REBUILT dist/*.d.ts — TS2322: Type '"total"' is not assignable to type '"min" | "max" | "count" | "sum" | "avg" | "count_distinct" | undefined' — then was removed; git diff HEAD empty.

Local verification (head 105ec6c1b)

Built the service-analytics and rest dependency closures first (pnpm --filter 'PKG^...' build, exit 0 both). Exit codes captured before any pipe.

run verdict
pnpm --filter @objectstack/spec check:generated exit 0 — ✓ All 15 generated artifacts are up to date (api-surface, authorable-surface, docs, export-origins, declaration-map, liveness, strictness-ledger, test-typecheck, …)
spec tsc --noEmit · check:test-typecheck exit 0 · exit 0 (54 file(s) / 261 error(s) ledger held)
spec vitest api/analytics · contracts/analytics-service · kernel/metadata-create-seeds exit 0 — 3 files, 50 passed
service-analytics typecheck · vitest query-dataset.test.ts exit 0 · exit 0 — 32 passed
rest typecheck (incl. test layer) · vitest analytics-routes.test.ts exit 0 · exit 0 — 14 passed
downstream-contract typecheck · test exit 0 · exit 0 — 2 files, 19 passed
spec source audits: exported-any, dual-source-exports, empty-state, variant-docs, yaml-examples, llms-txt, entry-nameability, browser-reachable-entries, liveness all exit 0
root: nul-bytes, cross-package-test-inputs, test-source-alias, type-check-coverage, spec-parsed-alias, changeset-gate-self-tests, objectui-changeset, published-files, doc-authoring, query-options-erasure, dispatcher-error-vocabulary, engine-double-contract, merge-driver, page-declaration-shape, slot-lookup, where-matcher, watch-hint-literal, refd-timer-probe, logger-receiver-detach, objectql-double-limit, type-source-resolution all exit 0
node scripts: adr-0087-registration, changeset-no-major, ci-filter-parity, closing-keyword-parity, comment-mask-adoption, comment-mask-corpus, empty-changeset, keyed-text-bounds, plugin-teardown-shape, shard-attestation, system-context-census, tenant-audit-census, undeclared-dep-imports, docs-audit/check-affected-docs, docs-audit/check-drift-comment, pm/release-rehearsal-clone --self-test; lint check:doc-formula-expressions all exit 0
check:type-check-debt · check:dual-build-cjs-loads · check-dev-prereqs · check-test-completeness NOT MEASURED (each prints PREREQUISITE NOT MET / needs a whole-tree pnpm build or a turbo test log; exit 3 / 3 / 1 / 3 by their own definition "not a red") — CI builds the whole tree before them
check:pm-half-states · scripts/pm/check-half-states.mjs not run locally (network-bound board sweeps)

Gate 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" (no flock on 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 defaults LABEL (Sum) / LABEL (Avg) embed the field label, so an aggregate-only discriminator cannot reconstruct them; they stay English on getMeta() titles. Not in the ruling's five sites. Measured on origin/main, inferFromObject has 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

…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>
@github-actions github-actions Bot added the size/m label Sep 3, 2026
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/service-analytics, @objectstack/spec, touching 4 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/data-api.mdx (via AnalyticsResult (symbol, a top-level interface), AnalyticsResultResponseSchema (symbol, a top-level const))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via queryDataset (symbol, a method of class AnalyticsService))
  • content/docs/releases/v9.mdx (via AnalyticsResult (symbol, a top-level interface), queryDataset (symbol, a method of class AnalyticsService))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 128 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5d4d55aecfb9eabfa7ffd3835a415229a1f50f78 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9a0c6ec94b3b56e3517f808c52e6aafca2950cb2 — the merge of head 105ec6c1bc234c47734ab73b7f0736f4160191c6 into base 5d4d55aecfb9eabfa7ffd3835a415229a1f50f78, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 5d4d55aecfb9eabfa7ffd3835a415229a1f50f78 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@hotlong

hotlong commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Clause-② contract review — PASS (in-seat, fable tier, epic cloud#1841 PM; reading of PR head 105ec6c):

  • AnalyticsResult.fields[].builtinAggregate?: AggregationFunction (packages/spec/src/contracts/analytics-service.ts) — additive, optional, typed by the EXISTING AggregationFunction from data/query.zod (the six values count | sum | avg | min | max | count_distinct); no second spelling introduced, satisfying ruling B §1.
  • Zod mirror in api/analytics.zod.ts uses the same AggregationFunction.optional() — TS and wire schema agree; spec check:generated current per the seat.
  • Population rule (service-analytics queryDataset): set iff the dataset measure declares aggregate and label == null — judged on the authored key, never on the resolved string, so an author's label (plain or inline locale map) is never re-labelled; derived measures and dimensions never carry it. This is ruling B §2 exactly.
  • Seeds: the built-in dataset seed drops its authored label: 'Count' so the seeded measure is a server default again (§2 for the seed site).
  • Scope narrowing vs the five named sites (inferMeasure ×2, cube-registry.inferFromObject) accepted on the seat's measured evidence in the PR body: those defaults live on the cube path and do not reach fields[]; objectstack#15019 records the dead-code finding. The downstream-contract fixture is a Cube fixture with nothing to mirror; its suite ran green.
  • Consumer contract stated in the PR body and already implemented at objectui#7535 (locale lookup keyed on the discriminator, fallback to label, then name).

Clearing needs:contract-review from both carriers. Enqueue after the seat's final CI report.

Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

1 participant