Skip to content

analytics 契约:AnalyticsResult.fields[] 增加可选 builtinAggregate 鉴别字段,服务端内置聚合默认 label("Count" 等 5 处硬编码)不再以英文字面量出线(objectui#7258 裁 B 的生产者半边) #14492

Description

@hotlong

背景(来自 objectstack-ai/objectui#7258 的诊断,维护者 2026-09-02 裁 B)

AI 搭建的客户仪表盘「按状态统计客户数量」柱状图,X 轴标题在中文界面显示英文 "Count"。objectui 侧 dev 证实:渲染端(packages/core/src/utils/chart-series.ts 的 labelOf)把 queryDataset() 返回的 fields[].label 当作已解析的作者内容原样透传(objectui#4106 为真实自定义 measure label 钉了这一行为);英文字面量来自 objectstack 服务端内置聚合的硬编码默认 label,无 i18n 钩子,共 5 处:

  • packages/services/service-analytics/src/analytics-service.ts:2045 与 :2300(inferMeasure)
  • packages/services/service-analytics/src/cube-registry.ts:80(inferFromObject)
  • packages/spec/src/kernel/metadata-create-seeds.ts:98
  • 镜像:packages/qa/downstream-contract/src/additional-domains.fixtures.ts:116

裁决(维护者 2026-09-02,在场裁定):B — 契约加结构化鉴别字段

  1. packages/spec/src/contracts/analytics-service.ts 的 AnalyticsResult.fields[] 增加可选 builtinAggregate?: 'count' | 'sum' | 'avg' | 'min' | 'max' | 'count_distinct'(闭合枚举,与 spec 里既有的聚合枚举对齐,⛔ 不另造第二套拼写)。
  2. 只在上述服务端内置默认处填充它;作者显式声明了 label 的自定义 measure 一律不填("Tasks" 类 label 保持原样)。queryDataset 的 REST 响应透传该字段。
  3. objectui 的 buildChartSeries()/labelOf() 在字段带 builtinAggregate 时优先查 locale(复用/镜像现有 report.aggregate.* 键,zh 已有 计数 等),否则回落 label —— objectui 半边由 objectui#7258 的原认领分支跟进,本卡是生产者半边,PR 正文写明消费侧契约。
  4. packages/qa/downstream-contract 的 fixture 同步。

选项 A(objectui 按名字/文本猜)被否决:会误伤真的叫 count 的作者字段、且文本匹配一换语言就失效——正是 AGENTS.md 契约优先禁止的消费端宽容。选项 C(服务端按 userLanguage 出译文)作为回退项未采用:把前端的语言判断复制到服务端,且没有鉴别字段供 per-widget 覆盖。

契约面

触及 packages/spec/src/**(扩大公开面)⇒ Clause-②: yes,按 spec 席队列与契约复审档处理;本卡是 epic objectstack-ai/cloud#1841 的 sub-issue,落地后 objectui#7258 解除 Blocked-by。

验收

  1. spec 类型与 zod(如有)增字段、测试;service-analytics 三处 + seeds 一处填充;fixture 更新;
  2. 一条契约测试:内置 count 度量的响应带 builtinAggregate: 'count',自定义 label 度量不带;
  3. changeset(minor:新增可选字段)。

Activity

  1. hotlong commented on Sep 3, 2026

    @hotlong
    ContributorAuthor

    Claim: epic cloud#1841 lane — spec seat taken over cross-lane by the epic PM per the owner's 2026-09-04 authorization (「你可以跨车道处理 epic 所有任务」)
    Session: wonderful-driscoll-ac0895 (cloud PM session, Fable 5.1)
    Branch: feat/analytics-builtin-aggregate-14492
    Worktree: ~/Documents/GitHub/objectstack-issue-14492 (linked worktree off origin/main; primary checkout untouched)
    Domain: spec (contracts/analytics-service) + service-analytics + kernel seeds + qa/downstream-contract fixtures
    File surface: packages/spec/src/contracts/analytics-service.ts (+ zod/tests), packages/services/service-analytics/src/analytics-service.ts (inferMeasure ×2), packages/services/service-analytics/src/cube-registry.ts (inferFromObject), packages/spec/src/kernel/metadata-create-seeds.ts, packages/qa/downstream-contract/src/additional-domains.fixtures.ts, .changeset/*
    Container & model: general-purpose subagent reading .claude/agents/os-dev.md, inherit tier
    Clause-②: yes
    Clause-② rationale: public spec surface grows by one optional field; contract review in-seat at fable tier (cloud scripts/pm/check-clause2-carriers.mjs --pair) before ACCEPT; consumer half = objectui#7258
    Serial constraints: none on shared pin files; downstream cloud pin bump follows after merge (cloud#1841 lane)

    Generated by Claude Code

  2. self-assigned this
    on Sep 3, 2026
  3. hotlong commented on Sep 3, 2026

    @hotlong
    ContributorAuthor

    ACCEPT — PR #15017 verified in-seat: 9 files, all inside the Claim's surface (spec contract + zod mirror + seeds + service-analytics queryDataset + tests + changeset); all check runs terminal with 0 failures at 105ec6c; clause-② review PASS posted on the PR and the needs:contract-review gate cleared on both carriers (predicate: legible, carriers agree). Marked ready and enqueued (--auto --squash). Downstream: cloud .objectstack-sha bump (third in epic cloud#1841) → cloud#1933 label drop → objectui#7535 ready → cloud .objectui-sha bump.

    _Generated by Claude Code

  4. hotlong commented on Sep 3, 2026

    @hotlong
    ContributorAuthor

    os-dev-report

    {
      "issue": 14492,
      "status": "done",
      "branch": "feat/analytics-builtin-aggregate-14492",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/15017",
      "premise_still_valid": true,
      "summary": "Ruling B implemented: AnalyticsResult.fields[] gains optional builtinAggregate (reusing the closed AggregationFunction enum from data/query.zod.ts), mirrored in AnalyticsResultResponseSchema (api/analytics.zod.ts — required by the spec's own compile-time parity guard), populated by queryDataset's measure enrichment exactly when the dataset measure declares an aggregate and NO authored label (judged on the authored key, so an unresolved locale-map label is still an author's label), and the dataset create seed drops its hardcoded label: 'Count' so a Studio-created dataset is a built-in default on the wire. Premise PARTIALLY refuted and stated in the PR body: fields[].label has exactly one producer (that enrichment, from the dataset measure), so the three Cube-metric sites (inferMeasure x2, cube-registry inferFromObject) never reach fields[] — they surface only via getMeta().title where CubeMeta.measures[].type already carries the aggregate — and populating them would mean widening the strict authorable MetricSchema with a key nothing reads; left untouched. The downstream-contract fixture is a Cube fixture with nothing to mirror (suite run green). The AI dashboard's actual 'Count' producer is cloud's dataset-derive.ts:206 (authored label), filed as cloud#1933 under epic #1841. Footer conflict: dispatch asked for the claude.com line, the role file for a session-URL footer — PR body carries both (bare form; session id not known to this seat). Verify lock reports no flock on this macOS host, so heavy runs went direct.",
      "tests": "Built closures first: pnpm --filter '@objectstack/service-analytics^...' build (exit 0) and '@objectstack/rest^...' build (exit 0). spec: check:generated exit 0 ('All 15 generated artifacts are up to date'); tsc --noEmit exit 0; check:test-typecheck exit 0 (54 files / 261 errors ledger held — the @ts-expect-error reverse pin compiled); vitest api/analytics + contracts/analytics-service + kernel/metadata-create-seeds: 3 files, 50 passed. service-analytics: typecheck exit 0; vitest query-dataset.test.ts 32 passed (+5 new: label-less count => 'count' and no label; authored 'Tasks' => no discriminator; locale-map label unresolved for locale 'fr' => no discriminator; sum/avg/count_distinct verbatim; derived => none). rest: typecheck (incl. test layer) exit 0; vitest analytics-routes.test.ts 14 passed (+1 passthrough). downstream-contract: typecheck exit 0, test 2 files / 19 passed. Reverse verification AFTER commit 105ec6c1b: scratch file in service-analytics assigning builtinAggregate: 'total' against the REBUILT dist d.ts => tsc exit 1, TS2322 naming the closed union; file removed, git diff HEAD empty. Gates derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (60 commands, all accounted for): spec check:api-surface/authorable-surface/docs/export-origins/liveness/strictness-ledger/skill-refs via check:generated exit 0; spec 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. NOT MEASURED by their own verdict text (PREREQUISITE NOT MET, need whole-tree build / turbo test log): check:type-check-debt (3), check:dual-build-cjs-loads (3), check-dev-prereqs (1), check-test-completeness (3). Not run locally (network board sweeps): check:pm-half-states, scripts/pm/check-half-states.mjs. Head for every run: 105ec6c1b. CI: ALL TERMINAL on head 105ec6c1b — pass=30, skipping=5; 0 fail/cancel (skipped = Console Pin Gate, Build Docs, Packed-tarball smoke and the two other path-filtered jobs). Watched via a single until-condition wait (harness Monitor; foreground sleep is blocked here and macOS has no timeout binary); the monitor exited on ALL TERMINAL. Draft NOT flipped, nothing merged. Worktree cleaned up after the report (node_modules removed, git worktree remove without force).",
      "mcp_calls": "0 — REST (gh api) and git only for the whole run",
      "open_questions": [
        {
          "question": "Should the Cube metric (MetricSchema, strict + authorable via defineCube/StackSchema) ALSO carry builtinAggregate so inferMeasure x2 and cube-registry inferFromObject populate it literally as the ruling's site list reads?",
          "options": [
            "A — No (implemented): the discriminator lives only on the response, derived from 'aggregate present and label absent' on the dataset measure; the Cube-metric sites never reach fields[] and getMeta already exposes CubeMeta.measures[].type.",
            "B — Yes: widen MetricSchema with an optional builtinAggregate, populate the three Cube sites, and add a query()-path enrichment that copies cube metric label + builtinAggregate onto fields[] (new wire behaviour on /analytics/query, plus gen:schema/docs/authorable-surface regen)."
          ],
          "recommendation": "A. Long-term soundness: item 1 of the ruling names AnalyticsResult.fields[] as the contract change and a response-only member cannot be authored wrong; B declares an authorable key that no wire path reads today (check:liveness territory) and lets a metadata author or an AI stamp builtinAggregate beside a custom label, exactly the consumer-side tolerance the ruling refused. Real need: the measured producer of the reported chart is a dataset measure (cloud dataset-derive.ts), served by A. Scope: A is the smaller change. If the maintainer wants B, it is a separate card on top of this one, not a rework of it."
        }
      ],
      "out_of_scope_findings": [
        "filed as objectstack-ai/cloud#1933 (sub-issue of cloud#1841, Blocked-by objectstack#14492 + pin bump): dataset-derive.ts:206 authors label: 'Count' on the AI-derived count measure, so the reported chart still carries an authored English label until that one line is dropped.",
        "filed as #15019 (finding): CubeRegistry.inferFromObject has no runtime caller on origin/main (only a unit test), so ruling site 3 (cube-registry.ts:80) is dead code and its composite LABEL (Sum) / LABEL (Avg) English defaults are unreachable; delete-vs-wire left to the maintainer."
      ]
    }

    Generated by Claude Code

  5. hotlong commented on Sep 3, 2026

    @hotlong
    ContributorAuthor

    PM ruling on the seat's open question: A (as implemented). The discriminator is a response-only member derived from "aggregate declared, no authored label" on the dataset measure; the three Cube-metric sites never reach fields[] and CubeMeta.measures[].type already exposes the aggregate on the meta path. Widening the authorable MetricSchema (B) would declare a key no wire path reads and let an author stamp it beside a custom label — the consumer-side tolerance ruling B refused. If the maintainer later wants the cube query() path to carry it, that is a separate card on top of this one. objectstack#15019 (dead inferFromObject) stays with the maintainer.

    Generated by Claude Code

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions