fix(service-analytics): every dataset answer names its base object - #20712
Conversation
… object (red) Pins, committed ahead of the fix and red against it: - the main exit, on NativeSQLStrategy and ObjectQLStrategy: a `dimensions: []` answer and a zero-row answer (grouped or not) carry `object`; a grouped answer keeps `object` beside its drill-through keys; - the draft-preview exit: a dimension-less and a grouped preview answer carry `object`; - the degraded "backing object unavailable" exit answers no rows and still `object`; - the negative: a cube `query` answer over a registered dataset's cube carries no `object`, even after a dataset answer on the same service. Five existing files pinned the answer shape the contract now forbids: the degraded answer without `object` (three shared EMPTY constants and three inline literals, eleven assertion sites) and `object` absent when no dimension is drillable (one site). They now expect the base object. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
`queryDataset` set `object` only inside the two drill-through blocks, which need a drillable dimension and at least one row. A dimension-less answer, a zero-row answer, the draft-preview answer and the degraded "backing object unavailable" answer went without it, although `AnalyticsResult.object` declares it on every dataset answer. `queryDataset` now wraps its former body (`answerDataset`) and sets `object: dataset.object` once, on the path all three exits leave through, as a copy rather than a write onto the answer. The two drill-block writes and the local `AnalyticsResultWithDrill.object` member are retired; the four drill sidecars are unchanged. `query()` does not pass through the wrapper, so a cube answer carries no `object`. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): ⛔ 3 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 10 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 e8557ef7a616b67f409e4f276297c09e329ed56b && git checkout e8557ef7a616b67f409e4f276297c09e329ed56b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 57574637129bebb4a6868c465be7e1b80eed1954 f3fb94dfe145aa6501ad8b62062b2e5e43fb06d3 && git checkout -B drift-repro 57574637129bebb4a6868c465be7e1b80eed1954 && git merge --no-ff f3fb94dfe145aa6501ad8b62062b2e5e43fb06d3
node scripts/docs-audit/affected-docs.mjs --json 57574637129bebb4a6868c465be7e1b80eed1954
|
Contract reviewServed-tier: Inputs read: card #20644 (body and all six comments: the block/unblock transitions, the claim ① Derived judgments
② Semver level
③ Boundary flagsDev report
Check-runs on Implemented-by: VERDICT: PASS Rendered 2026-09-29T20:39Z. Generated by Claude Code |
Fixes #20644
Clause-②: no
This unblocks the consumer card objectstack-ai/objectui#11095. Its
DatasetWidgetkeys record-change refresh on the answer'sobject, and it can stop waiting once this change is published.What changed
queryDatasetnow wraps its former body, a privateanswerDataset, and returns{ ...answer, object: dataset.object }. The stamp is set once, on the path all three exits leave through: the draft-preview return, the degraded "backing object unavailable" return, and the main return. An exit added later inherits it. The stamp is a copy rather than a write onto the answer, following the same ownership ruleapplySqlEchoPolicykeeps.objectand the localAnalyticsResultWithDrill.objectmember.objectis now typed by the declaredAnalyticsResult.object(@objectstack/spec/contracts, landed by spec(contracts):AnalyticsResultdeclaresobject, the dataset answer's base object, present on every dataset answer (the spec half of #20644) #20647). The four drill sidecars (dimensionFields,drillRawRows,drillRawTotals,drillRanges) are untouched; they belong to spec(contracts): the dataset answer's drill sidecars (dimensionFields,drillRawRows,drillRawTotals,drillRanges) are emitted by service-analytics and read by objectui, butAnalyticsResultdeclares none of them #20700.query()(cube) does not pass through the wrapper, so a cube answer carries noobject, as the contract states..changeset/20644-dataset-answer-object.md:@objectstack/service-analyticspatch.Premises measured (dispatch A1 to A5)
A1, the exits. At
defc7f7bthere are three: preview:1815, degraded:1922and main:2069, with no fourth among the method's return statements.objectwas set only at:1942(equality drill) and:2022(date-range drill). So before this change a grouped preview answer lackedobjecttoo.A2, the member. The package had exactly two writes of
AnalyticsResultWithDrill.object, and the type is module-private. Tests readresult.object, and the new pin file readsanswer.objectagainst plainAnalyticsResultwith no cast.tsc --noEmitis green, and--listFilesholds all 135src/__tests__files.A3, the cube side.
query()runsqueryIn, then the strategy, thenapplySqlEchoPolicy, and nothing on that path stampsobject. The pin: a cube answer over a registered dataset's cube carries none, even after a dataset answer on the same service.A4, other producers.
git grep -n queryDataset -- packagesfinds one implementation,AnalyticsService.queryDataset, and no other producer.MemoryAnalyticsService(driver-memory) implementsIAnalyticsServicewithout the optionalqueryDataset.@objectstack/client'sanalytics.queryDatasetrelays the route body, and@objectstack/restrelays the service answer.metadata-protocol's build probes consume the answer.A5, the wire. The route is
packages/rest/src/rest-server.ts:11093-11099, and it endsres.json(result). No existing REST-level test covers a dimension-less answer: the route tests that drive the real service all select a dimension. I measured once through the real route handler over the real service (not committed):fields, object, rows, withobject=crm_account;dimensionFields, drillRawRows, fields, object, rows;fields, object, rows.The committed pins are service-level.
Pins: red first, then the fix
New file
src/__tests__/dataset-answer-object.test.tsholds 11 tests:NativeSQLStrategyandObjectQLStrategy:dimensions: []answersobject;object, grouped or not;objectsits besidedimensionFieldsanddrillRawRows.object.{ rows: [], fields: [], totals: [], object: 'opportunity' }, and the unavailable-object warn fired.queryanswer carries noobject.Fixture triage: five existing files pinned the answer shape the contract now forbids, and they now expect the base object.
object: three sharedEMPTYconstants and three inline literals, 11 assertion sites.objectabsent when no dimension is drillable: one site, inquery-dataset.test.ts.Readings:
4e08d0730(pins only): 23 failed and 68 passed over the six touched test files.f3fb94dfe: 91 passed.Ablations
The fix was committed first. Each leg ran through
scripts/ablation-replace.mjsin WRAP mode, inside an outer trap that restores on EXIT, INT and TERM. The subject is imported relatively (../analytics-service.js), so it resolves tosrc/and nodistleg applies.return { ...answer, object: dataset.object };becomesreturn { ...answer };(anchor 1 to 0, blobf5a69cbatoc3d60928).objectassertions inquery-dataset.test.ts.f5a69cba) andgit diff HEADis empty.queryInseam. Anchor 1 to 0, blobf5a69cbato59ed0642.'object' in cuberead true.Verification, at
f3fb94dfepnpm --filter @objectstack/service-analytics test: 137 files and 3216 tests passed.pnpm --filter @objectstack/service-analytics run typecheck: exit 0.@objectstack/rest: 17 files and 308 tests, every test file that names the dataset route.service-analyticsresolved through adistrebuilt from this head.@objectstack/client: 5 files and 251 tests.dispatch-gates --commandsderived 62 commands, identical to the dispatch-time list. All 62 ran with exit 0, and--ranreads "62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN".check-changeset-fixed,check:authz-resolver,check:error-code-casingandcheck:filter-alias-parity.check:dual-build-cjs-loadsfirst answered exit 3 (PREREQUISITE NOT MET: 44 unbuilt packages). After a full build it was green: 104 require entry points across 66 packages load.pnpm lintis CI's to run..tsfiles in scope, andisPathIgnoredis true for the changeset.eslint --no-inline-config --format jsonon those files reads 7 files, 0 errors, 0 warnings.eslint.config.mjs:327-328, andcalculateConfigForFileagrees for all 7). It reads only two baseline JSONs, which this diff does not touch, so no untouched file's verdict can move.Acceptance notes
dimensionFields,drillRawRows,drillRawTotals,drillRanges) are emitted by service-analytics and read by objectui, butAnalyticsResultdeclares none of them #20700 · noted, not filed. A draft-preview answer carries none of the four drill sidecars, grouped or not, because the preview branch returns before the drill blocks run. With this PR it does carryobject. Whether a preview answer should drill at all is a question for the sidecars' own card: its rows are drafted seed rows, not stored records. spec(contracts): the dataset answer's drill sidecars (dimensionFields,drillRawRows,drillRawTotals,drillRanges) are emitted by service-analytics and read by objectui, butAnalyticsResultdeclares none of them #20700 remains open and is not addressed here.AnalyticsResultdeclaresobject, the dataset answer's base object, present on every dataset answer (the spec half of #20644) #20647's dev report, objectui readsobjectfor drill as well as for refresh. A consumer that gates drill on the presence ofobjectalone will now see it on dimension-less and zero-row answers too. This is not measured here, because objectui is outside this card's read scope. That card is where it is judged.Generated by Claude Code