Repository navigation
feat(spec,analytics): a dataset answer's measure column states its aggregate, labelled or not (fields[].aggregate) - #22021
Conversation
…column's aggregate A dataset answer's measure column now states the aggregate its measure declares (`fields[].aggregate`, the closed AggregationFunction vocabulary), whether or not the author labelled it. `builtinAggregate` keeps its label-only meaning. Written in enrichResultColumns, so the live query and the draft-data preview agree by construction; absent on dimension columns, derived measures and cube query answers. Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7 Co-authored-by: Claude <noreply@anthropic.com>
…angeset The data-api page listed the optional fields[] members the response schema declares; it now names `aggregate` too and says how it differs from `builtinAggregate`. Changeset: @objectstack/spec and service-analytics minor. Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 5 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 139 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 69cf9c10c4be5556f604df22b55d89f56a7a1fa5 && git checkout 69cf9c10c4be5556f604df22b55d89f56a7a1fa5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8caa131e52717ef35fe71742d758f10b8f2b77ee cce091f511ea76031cced6e01798acdcae163feb && git checkout -B drift-repro 8caa131e52717ef35fe71742d758f10b8f2b77ee && git merge --no-ff cce091f511ea76031cced6e01798acdcae163feb
node scripts/docs-audit/affected-docs.mjs --json 8caa131e52717ef35fe71742d758f10b8f2b77ee
|
Contract reviewServed-tier: Inputs: card #21995 (body and all four comments, the ① Derived judgments
② Semver level
Clause-②: yes (widening) ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
|
CI note from the owning
Generated by Claude Code |
…asure-column-aggregate
|
CI note from the owning On the merged head
Generated by Claude Code |
…asure-column-aggregate
…tack/spec/ui; metadata-core re-exports the same bindings (objectstack-ai#22056) Fixes objectstack-ai#22047 Clause-②: yes (widening: new exports on the published `@objectstack/spec/ui` entry; `@objectstack/spec` changeset at least `minor`) ## What changes - **New on `@objectstack/spec/ui`:** `publicFormSlug`, `anonymousFormIntakeSlug`, `anonymousFormIntakeCandidates`, `anonymousFormIntakeSlugs` and the `AnonymousFormIntakeCandidate` type. This is the candidates half of the anonymous-form-intake rule, which the triage ruling on objectstack-ai/objectui#11545 (`5967405932`) asks a console to read instead of re-deriving "published" from the sharing keys. - **Moved, not copied.** The bodies are the ones that were in `packages/metadata-core/src/anonymous-form-intake.ts`, unchanged except for indentation (2 spaces, as in the rest of `packages/spec`). `diff -w` between the BASE lines 52-105 and the new module is empty. The scan still covers the three shapes in the same order: nested `form`, then `formViews` entries, then a `viewKind: 'form'` item's `config`. - **`@objectstack/metadata-core` re-exports the same bindings** (`export { … } from '@objectstack/spec/ui'`, plus `export type` for the interface), so one copy remains. Its remaining code imports `publicFormSlug` and the type from the spec. Its posture, withdrawal-layer and object-name parts stay where they were. `@objectstack/rest` and `@objectstack/metadata-protocol` are not edited and keep importing from `metadata-core`. `packages/metadata-core/src/index.ts` is unchanged. - Changeset `.changeset/22047-spec-ui-anonymous-form-intake.md`: `@objectstack/spec` `minor`, `@objectstack/metadata-core` `patch` (its built `dist/index.{js,cjs,d.ts,d.cts}` now import these functions from `@objectstack/spec/ui` instead of defining them). ## Where it lives, and why (H3) It goes in a new module, `packages/spec/src/ui/anonymous-form-intake.ts`, next to `sharing.zod.ts`. It is not added to `sharing.zod.ts`, for three reasons: - **The module imports nothing.** It has no zod import and no schema import, and does no work at load time. It stays as cheap as a browser can import, whichever entry reaches it. `sharing.zod.ts` imports `zod` and two schema helpers. - **This is the existing pattern for runtime helpers in `spec/ui` that do not live inside a schema module.** `chart-aggregate.ts`, `i18n-label-resolver.ts` and `view-grouping-query.ts` are sibling non-`.zod.ts` modules. `expandViewContainer` sits in `view.zod.ts` only because it reads that module's member constants. These four functions read no schema. - **The file name matches the one in `metadata-core`,** so the move is easy to follow in history. Trade-off: `files[]` ships `src/**/*.zod.ts` as source, so this module's source is not in the tarball. Its JS and declarations are, in `dist/ui/index.{mjs,js,d.mts,d.ts}`. Prime Directive 2 (no business logic in `packages/spec`: schemas, types and constants only) holds here the way ADR-0053 D-D2 reads it. A pure helper that states what the contract's own vocabulary denotes is protocol, not business logic. It lives beside that vocabulary, and a server package re-exports it: D-D2 moved `nextUtcCalendarDay` into `@objectstack/spec/data` and has `@objectstack/core` re-export it. These four functions only say which `sharing` declarations open a form. The two checks that read server state (another layer's withdrawal, the tenancy posture) stay in `metadata-core`. So does `anonymousFormObjectName`, a pure read of the form and the view, because that is where this export's surface was drawn. `expandViewContainer` (`view.zod.ts`) is the placement precedent: a pure helper beside the schema it serves. The reason two codebases must agree on this rule byte for byte is the triage ruling on objectui#11545 (`5967405932`), as the module's header now says (patch round 1, `6c5741c6`). ## Pins and measurements **Identity pin (3).** This is in `packages/metadata-core/src/anonymous-form-intake.test.ts`; the existing cases are unchanged, and the pin adds 3 import lines and 1 `describe`. For each of the four names it asserts that `./anonymous-form-intake.js[name]` and `./index.js[name]` are `toBe` (Object.is) `@objectstack/spec/ui[name]`. - **Ablation:** run once and not kept, through `scripts/ablation-replace.mjs` with the fix committed first. The re-export of `anonymousFormIntakeCandidates` was replaced by a wrapper that returns the spec function's answer. - Result: `Tests 1 failed | 45 passed (46)`. Only the identity case for `anonymousFormIntakeCandidates` failed (`expected [Function] to be [Function] // Object.is equality`). Every behaviour test passed against the wrapper, so only the identity pin can catch a copy. - Restore was verified by the tool: the blob equals HEAD (`c08671875`) and `git diff HEAD` is empty. metadata-core's test reads its own `src` directly, so the mutation needed no rebuild to take effect. **H5: identity in the built dual output.** A one-time node probe, run from `packages/rest`, compared the four functions in metadata-core's built output with `@objectstack/spec/ui`'s: | condition | metadata-core export === spec/ui export | |---|---| | ESM (`dist/index.js` vs `dist/ui/index.mjs`) | true for all four | | CJS (`dist/index.cjs` vs `dist/ui/index.js`) | true for all four | | ESM vs CJS (cross-condition) | false: the dual-package split every spec export already has | **Parity pin (1).** `packages/spec/src/ui/anonymous-form-intake.test.ts` (26 cases) pins each of the three shapes on its own (open; withdrawn by either switch; no link), all three in one body in scan order, a `config` without `viewKind: 'form'`, slug normalisation, and raw input against `SharingConfigSchema.parse` input. Its expectations are the same as metadata-core's existing ones. A one-time parity probe compared the BASE metadata-core functions (`git show 8caa131`, lines 52-105) with the built spec/ui and metadata-core functions now. It compared candidate keys, key presence, slugs, whether each candidate is a form object from the input, and the slug set: | shape | bodies | with an open slug | BASE = spec/ui = metadata-core | |---|---|---|---| | nested `form` | 13 | 4 | 13 | | `formViews` entry (plus an open sibling) | 13 | 13 | 13 | | `viewKind: 'form'` + `config` | 13 | 4 | 13 | | all three in one body | 13 | 4 | 13 | | `config` without `viewKind: 'form'` | 13 | 0 | 13 | | non-view input | 4 | 0 | 4 | | real producers: showcase `inquiry.view.ts`, crm `lead.view.ts`, as containers and as `expandViewContainer` items | 9 | 4 | 9 | That is 78 bodies with 0 mismatches, and 21 leaf inputs (`anonymousFormIntakeSlug`, `publicFormSlug`) with 0 mismatches. **H1.** Lines 52-105 call nothing from `@objectstack/spec/security`, `applyInjectedSystemColumns` or `resolveRecordWallOrganizationField`. The new module has no imports, and the four bodies compile unchanged in `packages/spec`. **H2.** `./ui` is in `browser-reachable-entries.json`'s `unjudged` list, so `check:browser-reachable-entries` asserts nothing about it (it passed). The module is plain `function` declarations with no top-level statements, and the package declares `"sideEffects": false`. **Declaration surface downstream.** `metadata-core`'s `dist/index.d.ts` and `.d.cts` now reference `@objectstack/spec/ui`. Measured with `tsc --noEmit --extendedDiagnostics --listFiles`, building metadata-core from BASE source and then from HEAD source. These are absolute numbers from a shared box: | program | files BASE → HEAD | memory BASE → HEAD | |---|---|---| | `packages/rest` | 579 → 580 | 1,246,583K → 1,268,472K | | `packages/objectql` | 574 → 574 | 983,063K → 994,921K | | `packages/plugins/plugin-security` | 495 → 495 | 1,079,234K → 1,084,561K | | `packages/metadata-protocol` | 808 → 808 | 1,319,331K → 1,318,969K | | `packages/qa/http-conformance` | 345 → 345 | 357,199K → 357,044K | The one new file in `rest` is `spec/dist/ui/index.d.ts`, the CJS barrel, reached through `metadata-core/dist/index.d.cts`. The chunks it re-exports were already in that program in both flavours. tsc exited 0 in every program on both builds. ## Prose that named the old home (H4) - `packages/spec/src/ui/sharing.zod.ts:19-23` said the rule lives in `anonymousFormIntakeCandidates` "in `@objectstack/metadata-core`". It now names `anonymous-form-intake.ts` beside that module, which metadata-core re-exports to the server's doors. - `content/docs/references/ui/sharing.mdx:22-26` was regenerated from that docblock by `gen:docs`, not edited by hand. - `packages/metadata-core/src/anonymous-form-intake.ts:13-20`: the module docblock says the candidates half is declared in `@objectstack/spec/ui`, whose docblock is now the authority on it. The scan-shape paragraph moved with the code. - Judged still true and left alone: - `packages/rest/src/rest-server.ts:10698` reads "(`anonymousFormIntakeCandidates`, `@objectstack/metadata-core`)". That is where rest imports the function from, and metadata-core still exports it. The file is also outside this card's surface. - `packages/metadata-core/src/index.ts:141-145` ("both read this one rule"). - The `docs/qa/platform-checklist` mechanism references name `anonymousFormIntakeWithdrawnIn` and `anonymousFormExplicitWithdrawals`, which stay in metadata-core. ## Verification (HEAD `cfdc8804f0`; the source tree is the same as `3e9a9e48b1` plus the changeset) - `pnpm --filter @objectstack/spec build` (JS + DTS): exit 0. `check-dts-emitted` reported 38/38. - `pnpm --filter @objectstack/spec check:generated`: all 15 artifacts up to date. Before regeneration, 3 were stale: `api-surface/` (+5 rows in `ui.json`), `export-origins/` (+5) and `content/docs/references/**` (the H4 sentence). They were regenerated with `gen:api-surface`, `gen:export-origins` and `gen:docs`. - `check:api-surface`, `check:export-origins`, `check:docs`, `check:exported-any`, `check:dual-source-exports` (0 accepted dual-source), `check:entry-nameability`, `check:browser-reachable-entries`, `check:liveness`, `check:llms-txt`, `check:skill-examples`: exit 0. - `pnpm --filter @objectstack/spec test`: 620 files, 18511 passed, 1 todo. The new file alone: 26 passed. - `pnpm --filter @objectstack/metadata-core test`: 18 files, 415 passed. `src/anonymous-form-intake.test.ts` alone: 46 passed. - `pnpm --filter @objectstack/metadata-protocol test`: 219 files passed, 3 skipped; 28135 tests passed. The focused run of `runtime-authoring-gate.public-form-intake`, `protocol.runtime-authoring-gate` and `protocol.org-scoped-write-refused` passed 257. - `pnpm --filter @objectstack/rest test`: 260 files, 4914 passed, 326 skipped. The focused run of `public-form-routes`, `public-form-routes.stored-row`, `public-form-withdrawal` and `public-form-intake-availability` passed 76. - `pnpm --filter @objectstack/spec typecheck` and `pnpm --filter @objectstack/metadata-core typecheck`: exit 0. `--listFiles` shows both new test files are in their packages' test programs. - Gates derived by `node scripts/pm/dispatch-gates.mjs --commands` at `cfdc8804f0`, and checked with `--ran`: 110 derived, 109 run, 0 unrun, 1 NOT MEASURED. The NOT MEASURED one is `pnpm check:dual-build-cjs-loads`, exit 3, PREREQUISITE NOT MET: it needs every package built. The CJS half of the H5 probe above loaded `metadata-core/dist/index.cjs` with `require`. The 5 roster gates the lead list flagged under a touched directory also pass (`check:meta-url-spelling` and `check:spec-changes` through `check:generated`; `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`). - Lint is a narrowed run, and it measures something: `eslint --no-inline-config --format json` over the 6 changed TS files reported 6 files, 0 errors and 0 warnings. All 6 are in the population of `eslint.config.mjs` (`files: ['**/*.{ts,…}']` minus `NEVER_LINTED`, and `--print-config` resolves each one). That config never turns on type-aware linting (no `parserOptions.project`, no typed rules; see its own note near line 327), so this diff cannot change the result for any file it does not touch. The repo-wide `pnpm lint` is left to CI. ## Acceptance notes - **Not covered by this card** (the card's "Not this card" section): whether another layer withdraws a form, whether the posture makes a form unavailable, any server-side "published" answer, and the objectui page change. objectui#11545 restarts once objectui uses a release that carries these exports. - `main` gained objectstack-ai#22021 (a spec analytics change) after this branch was cut. It touches none of these files and none of the generated artifacts, so `main` is not merged here, and the merge queue rebuilds on the current `main`. --- _Generated by [Claude Code](https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21995
Clause-②: yes (widening: a new member on a published response schema; @objectstack/spec changeset at least minor)
Dev report for this PR: session
session_01GV6oYwgc1kWiUCb1YaprQ7(PM loop round 2,domain:specseat 2), branchclaude/issue-21995-measure-column-aggregate, base1bc6ca1d.What this changes
A dataset answer's measure column now states its aggregate, whether or not the author labelled the measure:
packages/spec/src/api/analytics.zod.ts): one new closed member onAnalyticsResultResponseSchema.data.fields[],aggregate: AggregationFunction.optional(). TheAnalyticsResultcontract (packages/spec/src/contracts/analytics-service.ts) gains the same member, so the compile-time binding between the two (AnalyticsResultMatchesContract) holds.packages/services/service-analytics/src/analytics-service.ts,enrichResultColumns): one line beside thebuiltinAggregateline, read off the same dataset measure.enrichResultColumnsruns on both the live query and the draft-data preview, so the two answers agree by construction.!m.derived, because the compiler ignores a strayaggregatewritten besidederived); and the cube query answer (POST /analytics/query), which never passes throughenrichResultColumns. The.describe()says exactly this.builtinAggregatekeeps its label-only meaning, and every existing pin on it stays green. No authoring key is added.content/docs/api/data-api.mdxlisted "the optional membersAnalyticsResultResponseSchemadeclares". That list would be false after this change, so it now namesaggregatetoo and says how it differs frombuiltinAggregate. See Acceptance notes: this file is outside the claim's declared surface..changeset/21995-measure-column-aggregate.md, with@objectstack/specand@objectstack/service-analyticsbothminor. It says what a renderer can now read.What a renderer reads: on
POST /analytics/dataset/query, a measure column such as{ name: 'task_count', type: 'number', label: 'Tasks', aggregate: 'count' }. objectstack-ai/objectui#11681 is the consumer half: once a release carries this, it derives integer ticks from it.Mechanism readings (PM hypotheses H1 to H4, measured at
1bc6ca1d)enrichResultColumnsis the only writer of descriptor keys on a dataset answer'sfields[]. It has two call sites: the draft preview (analytics-service.ts:2504) and the live return (:2761). The column ENTRIES themselves are minted upstream as{ name, type: 'number' }by the strategies and byDatasetExecutor(dataset-executor.ts:1166for__compare,:1205for derived,:1333). The degraded "backing object unavailable" exit returnsfields: [], so it has no column to describe.queryDatasethas a single implementation repo-wide. The measure'saggregateis in hand at thebuiltinAggregateline (:2866).AnalyticsResultResponseSchemais the response schema ofPOST /analytics/query(plugin-rest-api.zod.ts:1324), and through theAnalyticsResultbinding it is also the dataset answer's shape. The cube door writesfields[]through the strategies,withDeclaredMeasureFormatsandwithMeasureResultTypes, and none of them writesbuiltinAggregateor the new member. So the describe states that the member is absent on a cube query answer. A pin callsAnalyticsService.query()and asserts that absence.dataset-executor.ts:1205and has no aggregate. The edge:DatasetSchemaacceptsaggregatebesidederived, and the compiler ignores it (dataset-compiler.ts:704). So the new member checks!m.derivedrather than relying onaggregatebeing absent, and a pin covers that case.res.json(result)(rest-server.ts:11319). No REST source change is needed.packages/rest/src/analytics-routes.test.tspasses (14 tests).Tests
All on HEAD
908f4f02, or on91e05bd9where noted. The commit between them touches only the.mdxand the changeset.packages/services/service-analytics/src/__tests__/measure-column-aggregate.test.ts(new, 7 tests, each run on BOTH the live and preview paths):countmeasure statescount, and an unlabelled one still carriesbuiltinAggregate;sumover a currency field statessum;aggregate;__comparecolumn states its measure's aggregate;query()answer.preview-column-enrichment.test.ts:aggregatewas added to its key-for-key live/preview parity descriptor.packages/spec/src/api/analytics.test.ts: the member parses beside alabel. Off-enumtotalis refused atdata.fields.0.aggregatewith issue codeinvalid_value.packages/spec/src/contracts/analytics-service.test.ts: the member types on a labelled column. Off-enum is a compile error (@ts-expect-error, compiled bycheck:test-typecheck).91e05bd9);analytics-routes.test.ts: 14 passed;typecheckfor@objectstack/spec(tsc, scripts-typecheck, test-typecheck) and for@objectstack/service-analytics: exit 0. service-analytics resolves@objectstack/spectypes fromdist/, so its compilingf.aggregateshows it read the rebuilt.d.ts.Reverse verification. The direction was predicted before running. The fix was committed first. The mutation went through
scripts/ablation-replace.mjs: the anchor hit 1 time and then 0, the blob changed, and the restore was proven by blob equal to HEAD and an emptygit diff HEAD. The subject is imported fromsrc/(relative import), so no dist leg applies.aggregateassertions go red, absences and everybuiltinAggregateassertion stay green. Observed: 4 failed and 47 passed across the 3 files. The red ones were labelled-count, sum, stray-aggregate (its control leg) and__compare.!m.derivedguard. Predicted: only the stray-aggregate case goes red. Observed: 1 failed and 50 passed (live: expected 'sum' to be undefined).Gates
These were derived by
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon HEAD908f4f02, from a 9-path change set measured against merge base1bc6ca1d.--ranreconciliation exits 0.check:api-surface: "public API surface + factory signatures unchanged".check:docs: "226 generated files in sync".check:authorable-surface: green.check-adr-0087-registration: no declared-breaking changeset.check-changeset-no-major: no major.check:nul-bytes,check:docs-spec-enumerations,check:doc-authoring,check:cross-package-test-inputs,check:test-source-alias: OK.pnpm --filter @objectstack/spec run check:skill-examplesfirst exited 3 (PREREQUISITE NOT MET, noclient-reactdist). After a turbo build of@objectstack/client-reactit was re-run and exited 0 ("262 prose examples type-check").pnpm check:dual-build-cjs-loads(exit 3). Reason: it reads every package's built output, and 44 packages had nodist/here. A whole-repo build is CI's.check:authz-resolver,check:error-code-casing,check:filter-alias-parity, and speccheck:error-code-provenance. All exited 0.pnpm --filter @objectstack/spec check:generated: all 15 artifacts up to date. Thefieldsrow incontent/docs/references/api/analytics.mdxis truncated, so no generated artifact moves.eslint --no-inline-config --format jsonover the 7 touched.tsfiles.eslint.config.mjsenables no type-aware linting (noparserOptions.project, noprojectService), so this diff cannot move a verdict on any untouched file..mdand.mdxfiles are outside ESLint'sfilesglobs.pnpm lintis CI's.Acceptance notes
content/docs/api/data-api.mdxis not on the claim's declared surface. It was edited because its sentence naming "the optional membersAnalyticsResultResponseSchemadeclares" becomes false with this change. The claim's file list wants that path added.builtinAggregateon a derived measure with a strayaggregate(noted, not filed).builtinAggregatewith the stray value on such a column when it is unlabelled.builtinAggregate's own describe says it is absent on derived columns.DatasetSchemaacceptsaggregatebesidederivedat the REST door's parse, and the compiler then ignores it.examples/**and in non-testpackages/**sources.aggregatebesidederivedat the schema would close both this and the!m.derivedguard's reason to exist. That is a contract tightening, outside this card.aggregate. It is declared absent there. A cube measure'stypeis its aggregate, so stating it there would be one write besidewithMeasureResultTypes. No measured consumer pulls it; the dashboards on this card read dataset answers.origin/main. It is 2 commits behind (1fb274e6, touching rest/runtime, metadata-protocol,.claude). Neither touches a path here, somainwas not merged.Clause-②contract review is owed, as the claim records.Generated by Claude Code