From 285a39f6e0ac13010acaad5e8c0bab16248dd5f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 02:05:57 +0000 Subject: [PATCH 1/2] test(service-analytics): pin the four drill sidecars on one answer, byte for byte One dataset answer carries all four drill-through sidecars at once: a stage grouping beside a month-bucketed date dimension, with a per-stage subtotal and the grand total. The answer is read as the declared AnalyticsResult with no cast, and the serialised sidecars are pinned byte for byte, so the retirement of the local augmentation that follows can be shown to move nothing. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude --- .../__tests__/drill-sidecars-emission.test.ts | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 packages/services/service-analytics/src/__tests__/drill-sidecars-emission.test.ts diff --git a/packages/services/service-analytics/src/__tests__/drill-sidecars-emission.test.ts b/packages/services/service-analytics/src/__tests__/drill-sidecars-emission.test.ts new file mode 100644 index 00000000000..81e70ba8a6f --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/drill-sidecars-emission.test.ts @@ -0,0 +1,101 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #20727 — the four drill-through sidecars are set on the declared + * `AnalyticsResult`, with nothing in between. + * + * `AnalyticsResult` (`@objectstack/spec/contracts`) declares `dimensionFields`, + * `drillRawRows`, `drillRawTotals` and `drillRanges` (#20700). The service once + * set them through a module-private augmentation of that type; it now sets the + * declared members directly. That retirement moved no byte of any answer, and + * this file holds that line. + * + * The fixture is the one answer that carries all four at once: a grouping by + * an equality-drillable dimension (`stage`) beside a month-bucketed date + * dimension (`close_date`), with a `totals` selection (a per-stage subtotal + * and the grand total). The answer is read as `AnalyticsResult` with no cast, + * so each member read below compiles only because the contract declares it. + */ + +import { describe, it, expect } from 'vitest'; +import { DatasetSchema } from '@objectstack/spec/ui'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import type { AnalyticsResult } from '@objectstack/spec/contracts'; +import { AnalyticsService } from '../analytics-service.js'; + +const CTX = { tenantId: 'org_A' } as ExecutionContext; + +const DATASET = DatasetSchema.parse({ + name: 'pipe_drill', + label: 'Pipe', + object: 'opportunity', + include: [], + dimensions: [ + { name: 'stage', field: 'stage', type: 'string' }, + { name: 'close_date', field: 'close_date', type: 'date', dateGranularity: 'month' }, + ], + measures: [{ name: 'cnt', aggregate: 'count' }], +}); + +/** The grouped field names of one `executeAggregate` call (a date bucket arrives as `{ field, dateGranularity }`). */ +function groupedFields(groupBy: unknown[] | undefined): string[] { + return (groupBy ?? []).map((g) => (typeof g === 'string' ? g : (g as { field: string }).field)); +} + +/** + * Granularity bucketing runs through the ObjectQL aggregate path, so the driver + * answers already-bucketed rows: the main grid (stage × month), the per-stage + * subtotal, and the grand total, told apart by what each call groups by. + */ +function drillService(): AnalyticsService { + return new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + executeAggregate: async (_object, { groupBy }) => { + const grouped = groupedFields(groupBy); + if (grouped.includes('close_date')) { + return [ + { stage: 'qualification', close_date: '2026-06', cnt: 3 }, + { stage: 'won', close_date: '2026-07', cnt: 2 }, + ]; + } + if (grouped.includes('stage')) { + return [ + { stage: 'qualification', cnt: 3 }, + { stage: 'won', cnt: 2 }, + ]; + } + return [{ cnt: 5 }]; + }, + getReadScope: (_o, ctx?: ExecutionContext) => (ctx?.tenantId ? { organization_id: ctx.tenantId } : undefined), + }); +} + +const DRILL_SIDECARS = ['dimensionFields', 'drillRawRows', 'drillRawTotals', 'drillRanges']; + +describe('the drill-through sidecars are emitted on the declared AnalyticsResult (#20727)', () => { + it('a grouped, date-bucketed answer with totals carries all four, byte for byte', async () => { + const answer: AnalyticsResult = await drillService().queryDataset( + DATASET, + { dimensions: ['stage', 'close_date'], measures: ['cnt'], totals: { groupings: [['stage'], []] } }, + CTX, + ); + + // All four are present, in the order the service sets them. + expect(Object.keys(answer).filter((k) => DRILL_SIDECARS.includes(k))).toEqual(DRILL_SIDECARS); + + const sidecars = { + dimensionFields: answer.dimensionFields, + drillRawRows: answer.drillRawRows, + drillRawTotals: answer.drillRawTotals, + drillRanges: answer.drillRanges, + }; + expect(JSON.stringify(sidecars)).toBe( + '{"dimensionFields":{"stage":"stage"},' + + '"drillRawRows":[{"stage":"qualification"},{"stage":"won"}],' + + '"drillRawTotals":[[{"stage":"qualification"},{"stage":"won"}],[{}]],' + + '"drillRanges":[' + + '{"close_date":{"field":"close_date","gte":"2026-06-01","lt":"2026-07-01"}},' + + '{"close_date":{"field":"close_date","gte":"2026-07-01","lt":"2026-08-01"}}]}', + ); + }); +}); From 72581948563071a709b815a6b1a0524f64c89b7e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 02:06:26 +0000 Subject: [PATCH 2/2] refactor(service-analytics): set the drill sidecars on the declared AnalyticsResult AnalyticsResult declares dimensionFields, drillRawRows, drillRawTotals and drillRanges with the shapes the service emits. The module-private AnalyticsResultWithDrill augmentation that shadowed them is deleted, and the four sets in answerDataset write the declared members with no cast. Two of the deleted doc comments disagreed with the emission (drillRanges bounds and omission rule; dimensionFields values can be relationship paths); the contract's own descriptions follow the emission and now stand alone. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude --- .../src/analytics-service.ts | 55 ++----------------- 1 file changed, 4 insertions(+), 51 deletions(-) diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index a2921debb46..d22f648ef52 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -88,53 +88,6 @@ import { invalidMemberError } from './dataset-refusal.js'; // it rather than restating it. import { ACCEPTED_SQL_DIALECTS, isUnrecognisedSqlDialectAnswer, type AcceptedSqlDialect } from './text-match-sql.js'; -/** - * Analytics result augmented with drill-through metadata (ADR-0021 D2; see - * queryDataset). Carried alongside `rows` so the host can drill a clicked bucket - * back to the underlying records without the renderer knowing field mappings. - * - * The object those records belong to is not part of this augmentation: it is - * the contract's own `AnalyticsResult.object`, which every dataset answer - * carries, drillable or not (#20644). - */ -type AnalyticsResultWithDrill = AnalyticsResult & { - /** Selected drillable dimension NAME → underlying object FIELD name. */ - dimensionFields?: Record; - /** - * RAW grouped values per row, aligned to `rows` by index — each a map of - * drillable dimension NAME → stored value (BEFORE label resolution rewrote - * `rows[i][dim]` to the display label). The exact-match drill filter is built - * from these, never from the display labels. - */ - drillRawRows?: Array>; - /** - * RAW grouped values for the totals/subtotal rows (#3214), the totals-side - * companion to `drillRawRows`: `drillRawTotals[i]` aligns to `result.totals[i]` - * and `drillRawTotals[i][j]` to `result.totals[i].rows[j]`. Each map holds that - * grouping's DRILLABLE dimension NAME → stored value, snapshotted in the SAME - * pre-label-resolution pass (the totals loop below overwrites a subtotal row's - * dimension value with its display label just like the data rows). Restricted - * to the drillable dims present in the grouping, so the grand-total grouping - * (`[]`) contributes an empty map per row — which keeps the index alignment - * intact and correctly drills the whole (unfiltered) object. - */ - drillRawTotals?: Array>>; - /** - * #1752 — half-open date-range drill scope per row, the RANGE companion to - * `drillRawRows` (which handles equality dims). A time-bucketed date - * dimension (`dateGranularity`) groups a SPAN of records into one bucket - * ("2026-Q2"), so its drill needs `[gte, lt)`, not equality — the humanized - * bucket can't be exact-matched (which is why date dims are excluded from - * `dimensionFields`/`drillRawRows`). Aligned to `rows` by index; each entry - * maps a drillable date-dimension NAME → `{ field, gte, lt }` with `gte` - * inclusive and `lt` exclusive (bounds as `YYYY-MM-DD`). Present only for - * buckets whose boundaries are unambiguous — a `datetime` field under a - * non-UTC reference timezone is omitted (host drills an unscoped superset) - * until instant-boundary support lands. - */ - drillRanges?: Array>; -}; - /** * [#5717] Does this error carry an ADR-0112 envelope — i.e. did its PRODUCER * already classify it? @@ -1967,10 +1920,10 @@ export class AnalyticsService implements IAnalyticsService { // exact-matched against the stored timestamp, so they are not drillable. const drillDims = selectedDims.filter((d) => !!d.field && d.type !== 'date'); if (drillDims.length && result.rows.length) { - (result as AnalyticsResultWithDrill).dimensionFields = Object.fromEntries( + result.dimensionFields = Object.fromEntries( drillDims.map((d) => [d.name, d.field as string]), ); - (result as AnalyticsResultWithDrill).drillRawRows = result.rows.map((row) => { + result.drillRawRows = result.rows.map((row) => { const raw: Record = {}; for (const d of drillDims) raw[d.name] = row[d.name]; return raw; @@ -1982,7 +1935,7 @@ export class AnalyticsService implements IAnalyticsService { // groups by (the grand-total grouping `[]` keeps empty maps, so a subtotal // drill filters by the stored value while the grand total drills unfiltered). if (result.totals?.length) { - (result as AnalyticsResultWithDrill).drillRawTotals = result.totals.map((total) => { + result.drillRawTotals = result.totals.map((total) => { const groupingDims = drillDims.filter((d) => total.dimensions.includes(d.name)); return total.rows.map((row) => { const raw: Record = {}; @@ -2031,7 +1984,7 @@ export class AnalyticsService implements IAnalyticsService { if (rangeDims.length && result.rows.length) { const bound = (ymd: string, instant: boolean): string => instant ? new Date(zonedDateStartToUtcMs(ymd, rangeTz)).toISOString() : ymd; - (result as AnalyticsResultWithDrill).drillRanges = result.rows.map((row) => { + result.drillRanges = result.rows.map((row) => { const ranges: Record = {}; for (const { d, granularity, instant } of rangeDims) { // A row in the empty bucket carries `null` here (#3839) and yields no