Skip to content

fix(service-analytics): every dataset answer names its base object - #20712

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-20644-dataset-answer-object
Sep 29, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-20644-dataset-answer-object

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20644

Clause-②: no

This unblocks the consumer card objectstack-ai/objectui#11095. Its DatasetWidget keys record-change refresh on the answer's object, and it can stop waiting once this change is published.

What changed

Premises measured (dispatch A1 to A5)

  • A1, the exits. At defc7f7b there are three: preview :1815, degraded :1922 and main :2069, with no fourth among the method's return statements. object was set only at :1942 (equality drill) and :2022 (date-range drill). So before this change a grouped preview answer lacked object too.

  • A2, the member. The package had exactly two writes of AnalyticsResultWithDrill.object, and the type is module-private. Tests read result.object, and the new pin file reads answer.object against plain AnalyticsResult with no cast. tsc --noEmit is green, and --listFiles holds all 135 src/__tests__ files.

  • A3, the cube side. query() runs queryIn, then the strategy, then applySqlEchoPolicy, and nothing on that path stamps object. 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 -- packages finds one implementation, AnalyticsService.queryDataset, and no other producer.

    • MemoryAnalyticsService (driver-memory) implements IAnalyticsService without the optional queryDataset.
    • @objectstack/client's analytics.queryDataset relays the route body, and @objectstack/rest relays 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 ends res.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):

    • dimension-less answer: keys fields, object, rows, with object = crm_account;
    • grouped answer: keys dimensionFields, drillRawRows, fields, object, rows;
    • zero-row, dimension-less answer: keys fields, object, rows.

    The committed pins are service-level.

Pins: red first, then the fix

New file src/__tests__/dataset-answer-object.test.ts holds 11 tests:

  • Main exit, on both NativeSQLStrategy and ObjectQLStrategy:
    • dimensions: [] answers object;
    • a zero-row answer answers object, grouped or not;
    • a grouped answer is unchanged: object sits beside dimensionFields and drillRawRows.
  • Draft-preview exit: a dimension-less preview and a grouped preview both answer object.
  • Degraded exit: the answer equals { rows: [], fields: [], totals: [], object: 'opportunity' }, and the unavailable-object warn fired.
  • Negative, on both strategies: a cube query answer carries no object.

Fixture triage: five existing files pinned the answer shape the contract now forbids, and they now expect the base object.

  • The degraded answer without object: three shared EMPTY constants and three inline literals, 11 assertion sites.
  • object absent when no dimension is drillable: one site, in query-dataset.test.ts.

Readings:

  • Red at 4e08d0730 (pins only): 23 failed and 68 passed over the six touched test files.
  • Green at f3fb94dfe: 91 passed.

Ablations

The fix was committed first. Each leg ran through scripts/ablation-replace.mjs in WRAP mode, inside an outer trap that restores on EXIT, INT and TERM. The subject is imported relatively (../analytics-service.js), so it resolves to src/ and no dist leg applies.

  • Leg A: delete the stamp. return { ...answer, object: dataset.object }; becomes return { ...answer }; (anchor 1 to 0, blob f5a69cba to c3d60928).
    • Result: 30 failed and 61 passed.
    • Red: every dataset-side pin (the main exit on both strategies, including grouped-unchanged; both previews; the degraded exit), every triaged fixture, and five pre-existing grouped-drill object assertions in query-dataset.test.ts.
    • Green: the two cube negatives.
    • Restore: blob equals HEAD (f5a69cba) and git diff HEAD is empty.
  • Leg B: stamp a registered dataset's object on the shared queryIn seam. Anchor 1 to 0, blob f5a69cba to 59ed0642.
    • Result: 2 failed and 89 passed. The two failures are exactly the two cube negatives, where 'object' in cube read true.
    • Restore proven the same way.

Verification, at f3fb94dfe

  • Package tests. pnpm --filter @objectstack/service-analytics test: 137 files and 3216 tests passed.
  • Typecheck. pnpm --filter @objectstack/service-analytics run typecheck: exit 0.
  • Consumers of the wire shape.
    • @objectstack/rest: 17 files and 308 tests, every test file that names the dataset route. service-analytics resolved through a dist rebuilt from this head.
    • @objectstack/client: 5 files and 251 tests.
  • Gates.
    • dispatch-gates --commands derived 62 commands, identical to the dispatch-time list. All 62 ran with exit 0, and --ran reads "62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN".
    • The four roster families outside that list also ran green: check-changeset-fixed, check:authz-resolver, check:error-code-casing and check:filter-alias-parity.
    • check:dual-build-cjs-loads first answered exit 3 (PREREQUISITE NOT MET: 44 unbuilt packages). After a full build it was green: 104 require entry points across 66 packages load.
  • Lint, a proven narrowing. The repo-wide pnpm lint is CI's to run.
    • Population: eslint's own config puts the 7 changed .ts files in scope, and isPathIgnored is true for the changeset.
    • Count: eslint --no-inline-config --format json on those files reads 7 files, 0 errors, 0 warnings.
    • Invariance: the config enables no type-aware linting (eslint.config.mjs:327-328, and calculateConfigForFile agrees 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


Generated by Claude Code

… 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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-analytics, touching 5 documentable anchor(s).

⛔ 3 release-owned page(s) 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/v17/17-5.mdx (via AnalyticsService (symbol, a top-level class))
  • content/docs/releases/v9.mdx (via 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 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 10 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 57574637129bebb4a6868c465be7e1b80eed1954 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from e8557ef7a616b67f409e4f276297c09e329ed56b — the merge of head f3fb94dfe145aa6501ad8b62062b2e5e43fb06d3 into base 57574637129bebb4a6868c465be7e1b80eed1954, 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 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

⚠️ 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 57574637129bebb4a6868c465be7e1b80eed1954 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: f3fb94dfe145aa6501ad8b62062b2e5e43fb06d3
Local-runs: none

Inputs read: card #20644 (body and all six comments: the block/unblock transitions, the claim 5897184311, the dev report 5898112319, the ACCEPT 5898215206); card #20647 and PR #20687 (the contract as landed, 35587f76ce); PR #20712's body, file list (8 files, +235/−16) and its net diff against main (merge-base defc7f7b); the check-runs on the head. Read-only throughout: the branch was fetched into the shared checkout's object store and read with git show / git diff; nothing was built, run or re-run.

① Derived judgments

  • Accept sets: none moved — right. The diff touches no Zod schema, no validator, no query-param allowlist, no route and no conversion entry. Files: analytics-service.ts, one new pin file, five existing test files, one changeset.
  • Public TypeScript surface of @objectstack/service-analytics: unchanged — right. queryDataset keeps its signature and its declared return type AnalyticsResult; answerDataset is private; AnalyticsResultWithDrill is a module-private type (no export), so dropping its object member removes nothing a consumer can import. Its four drill sidecars stay on it, untouched (spec(contracts): the dataset answer's drill sidecars (dimensionFields, drillRawRows, drillRawTotals, drillRanges) are emitted by service-analytics and read by objectui, but AnalyticsResult declares none of them #20700's).
  • Emitted shape of a dataset answer: object on every queryDataset answer — right, and already declared. The contract this producer must honour is AnalyticsResult.object and AnalyticsResultResponseSchema.data.object as PR feat(spec): AnalyticsResult declares object, the dataset answer's base object #20687 landed them: every dataset answer must carry it, whatever dimensions are selected and whether or not rows came back; absent on a cube query answer. Read at the head, answerDataset has exactly three method-level returns (the draft-preview return, the degraded { rows: [], fields: [], totals: [] } return, and the main return result); the only other returns in its range sit inside map callbacks of the drill blocks. The public queryDataset wraps it and returns { ...answer, object: dataset.object } once, so all three exits carry it and none can drift; the two former drill-block writes are deleted, so the stamp is the single source. DatasetSchema.object is a required string, so the value is always present.
  • Cube answers: unchanged — right. query() never enters the wrapper. The negative is pinned on both strategies over a registered dataset's own compiled cube, after a dataset answer on the same service (the hardest shape for a leak through the shared queryIn path), and the dev's ablation leg B showed that pin can fail.
  • The card's three pins, each present in dataset-answer-object.test.ts: dimensions: [] answers object (both strategies); a zero-row answer answers object (grouped and KPI, both strategies); a grouped answer is unchanged (toMatchObject with object beside dimensionFields and drillRawRows). Beyond the card: the preview exit (dimension-less and grouped), the degraded exit (toEqual, with the unavailable-object warn asserted so it is provably that exit), and the cube negative.
  • Fixture triage — right, not a weakening. Five existing files pinned the degraded envelope as { rows, fields, totals }; each now carries the base object (opportunity / sys_audit_log). query-dataset.test.ts:286 flips object from toBeUndefined() to toBe('opportunity') on the no-drillable-dimension case while still asserting all three drill sidecars absent — exactly the contract's split between the answer's subject and drill metadata.
  • Other producers: none — the dev's A4 holds on the head tree. git grep queryDataset over packages/ finds one implementation, AnalyticsService.queryDataset; @objectstack/client relays the route body, @objectstack/rest ends res.json(result) at rest-server.ts:11093, metadata-protocol's build probes consume through a shape-tolerant reader, and MemoryAnalyticsService does not implement the optional method. So POST /api/v1/analytics/dataset/query now carries object on dimension-less, zero-row, degraded and preview answers, with no other change to the wire.
  • Docs and generated artifacts: nothing owed — right. No packages/spec path is touched, so no regeneration is due; the docs-drift bot lists only release-owned pages, which are read-only by rule.
  • One gap, not a defect: no committed REST-level pin covers a dimension-less answer; the dev's wire reading (A5) was a one-shot, uncommitted measurement. The card's pins are service-level and all committed, and the REST relay is a pass-through. Noted, not required.

② Semver level

  • Changeset .changeset/20644-dataset-answer-object.md: @objectstack/service-analytics at patch — right. A released package's producer now honours a member the contract already declares; the package's exported API is unchanged and no accept set moves. That is the bug-fix-in-a-released-package case, and skip-changeset would be wrong because the diff publishes.
  • The declaration line — right. Both the PR body and the changeset carry the standalone line Clause-②: no, with no arm. The widening was declared and reviewed on the spec half (PR feat(spec): AnalyticsResult declares object, the dataset answer's base object #20687, Clause-②: yes (widening), @objectstack/spec minor, at-tier PASS 5896169822); this PR moves neither an accept set nor a public surface, so no is the truthful answer and patch satisfies it. No (narrowing) arm is owed: nothing a consumer could write or import is removed — the retired AnalyticsResultWithDrill.object was never exported.
  • ADR-0087: no marker owed — right. Not a breaking changeset; the Check Changeset run on the head is success, and the changeset body still states the before (drill-only object) and after (object on every dataset answer) for the upgrading reader.

③ Boundary flags

Dev report 5898112319 declares open_questions: []. Its five deviations and two out-of-scope findings, each answered:

Check-runs on f3fb94dfe145aa6501ad8b62062b2e5e43fb06d3 (latest run per check name, read at 2026-09-29T20:38Z): 34 runs — 31 success, 3 skipped (Console Pin Gate, Build Docs, Packed-tarball smoke (opt-in): path and opt-in skips), none in progress, none failed. All seven required contexts are success: Lint & Repo Gates (the last to conclude, at 2026-09-29T20:35Z), TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL) and Governed Surface Queue Guard. Check Changeset, Check PR Size (251 changed lines, well under the human-merge threshold) and the card-claim / single-writer guards are success too. The PR is still a draft; readying and arming are the owning seat's acts, on its own re-read of the head.

Implemented-by: claude/issue-20644-dataset-answer-object
Reviewed-by: session_01XY5uCwTjZj7884yYtyur4H

VERDICT: PASS

Rendered 2026-09-29T20:39Z.


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

2 participants