Skip to content

Commit 7053333

Browse files
refactor(service-analytics): set the drill sidecars on the declared AnalyticsResult (#20762)
Fixes #20727 Clause-②: no `AnalyticsResult` (`@objectstack/spec/contracts`) has declared `dimensionFields`, `drillRawRows`, `drillRawTotals` and `drillRanges` since #20700 (PR #20720). This retires the service's module-private `AnalyticsResultWithDrill` augmentation and sets the four members on the declared type, with no cast. No answer changes, and nothing published changes. ## What changed - `packages/services/service-analytics/src/analytics-service.ts`: deleted `type AnalyticsResultWithDrill` (at `:100` on the base) and its doc comments. The four sets in `answerDataset` now write `result.dimensionFields`, `result.drillRawRows`, `result.drillRawTotals` and `result.drillRanges` on `result: AnalyticsResult`. Each was `(result as AnalyticsResultWithDrill).…` (`:1970`, `:1973`, `:1985`, `:2034` on the base). No new local type, no other cast, no contract change. - Two of the deleted doc comments disagreed with the emission: the `drillRanges` bounds and omission rule, and `dimensionFields` values, which can be relationship paths. The contract's descriptions follow the emission and are now the only text. - New pin: `src/__tests__/drill-sidecars-emission.test.ts` (1 test). - Not touched: the `previewDrafts` early return (triage ruled it out of this card), and `packages/spec`. ## Premises measured (dispatch D1 to D4) - **D1: the sets type-check on the declared type.** `pnpm --filter @objectstack/service-analytics run typecheck` exits 0 with the casts gone. `tsc --listFiles` holds 138 of 138 `src/__tests__` files, the new pin included. Two reverse checks ran through `scripts/ablation-replace.mjs`, each with the restore proven (blob equals HEAD, `git diff HEAD` empty): - A wrong value shape at the `dimensionFields` set gives `TS2322` at `(1923,7)`: the type `{ [k: string]: number; }` is not assignable to the declared `Record` of string to string. (The message's angle-bracket spelling is written out here in words.) - An undeclared member name at the `drillRanges` set gives `TS2551: Property 'drillRangez' does not exist on type 'AnalyticsResult'. Did you mean 'drillRanges'?` at `(1987,14)`. - So each set is checked against the member `AnalyticsResult` declares. The emission and the contract agree. - **D2: every reference.** `git grep AnalyticsResultWithDrill` over the whole tracked tree at the base finds 5 hits, all in `analytics-service.ts`: the type and the four casts. After the edit it finds 0 (exit 1). After a full build, none of the 68 `packages/**/dist` directories names it, against a positive control (`drillRawTotals` hits the spec and service-analytics dists). - **D3: what publishes. Nothing, so `skip-changeset`.** `@objectstack/service-analytics` was built at the base and after the edit. A determinism control (the base rebuilt unchanged) gave the same six hashes. - `index.js`, `index.cjs`, `index.d.ts`, `index.d.cts`: byte-identical (`cmp` exit 0 for each). - `index.js.map`, `index.cjs.map`: differ in position data only. Decoded, `version`, `sources` and `names` are identical, and neither map embeds source text. Every segment matches once the original lines of `analytics-service.ts` after the deleted block move up by 47, except on the four set lines, whose columns moved with the removed cast. A comment-only edit makes the same kind of delta. - The retired name has 0 hits in the built `dist`, before and after. The positive control `drillRawTotals` hits `index.js` and `index.cjs`. - **D4: the byte-identity proof.** No existing fixture emits all four sidecars at once: the two `totals` fixtures in `query-dataset.test.ts` group by no date dimension. So the pin's fixture joins two existing ones: the matrix "X by time" dataset (`stage` beside a month-bucketed `close_date`), and the per-grouping driver of the subtotal test, with `totals: { groupings: [['stage'], []] }`. - Its full answer (8 keys, 744 bytes) was serialised at the base and after the edit. The sha256 was `06130c86…3ec980` both times, and `cmp` exits 0. The same bytes came back on the final head. - The pin reads the answer as `AnalyticsResult` with no cast, and holds the four keys, their order and their serialised bytes. ## Pin and ablation - The pin was committed first (`285a39f6e`) and was green on the unedited source. The retirement follows in `725819485`. - Ablation on the committed state, in WRAP mode inside an outer restore trap: `result.drillRawTotals = …` becomes `void …` (anchor 1 to 0, blob `d22f648e` to `9be86803`). - The pin goes red: `expected [ 'dimensionFields', …(2) ] to deeply equal [ 'dimensionFields', …(3) ]`. - Restore: the blob equals HEAD (`d22f648e`) and `git diff HEAD` is empty. - The subject is imported relatively, so it resolves to `src/` and no `dist` leg applies. ## Verification, at `39d49081b` This is the head after merging `origin/main` at `a6866da0c`, which touched two other `service-analytics` files. - `pnpm --filter @objectstack/service-analytics run typecheck`: exit 0. - `pnpm --filter @objectstack/service-analytics test`: 141 files and 3267 tests passed. - The contract pins from #20700, `packages/spec` `src/api/analytics.test.ts` and `src/contracts/analytics-service.test.ts`: 2 files and 40 tests passed. - **Gates.** - `dispatch-gates --commands` derived 55. All 55 ran with exit 0, and `--ran` reads "55 derived, 55 run, 0 NOT-MEASURED, 0 UNRUN", a derived zero with every line carrying its exit code. - Also run, all exit 0: the dispatch's 7 changeset-keyed families, and the four roster families outside the list (`check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`). - `check:dual-build-cjs-loads` and `check:type-check-debt` first answered exit 3 (unbuilt packages). Both were green after a full build. - **Lint, a declared narrowing.** The repo-wide `pnpm lint` is CI's to run. - Population: eslint's config puts both changed `.ts` files in scope (`isPathIgnored` is false). - Count: `eslint --no-inline-config --format json` reads 2 files, 0 errors, 0 warnings. - Invariance: the config enables no type-aware linting (`eslint.config.mjs:327-328`, and `calculateConfigForFile` shows no `parserOptions.project` or `projectService` for either file). It reads only baseline JSONs this diff does not touch, so no untouched file's verdict can move. ## Acceptance notes - No out-of-scope finding to file. - carrier: none · noted, not filed. The `previewDrafts` question the card carried is answered by triage for this card: a preview answer carries no sidecars, and the early return and the contract sentence stay. A consumer that needs preview drill is a card of its own. - The D3 reading, for the seat: the published `.map` files move by position only (above). By the package's policy that is the same class as a comment edit, so this PR carries `skip-changeset` and no changeset. If the seat reads a position-only map delta as published, a `patch` changeset on `@objectstack/service-analytics` is the whole remedy. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0d9349f commit 7053333

2 files changed

Lines changed: 105 additions & 51 deletions

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #20727 — the four drill-through sidecars are set on the declared
5+
* `AnalyticsResult`, with nothing in between.
6+
*
7+
* `AnalyticsResult` (`@objectstack/spec/contracts`) declares `dimensionFields`,
8+
* `drillRawRows`, `drillRawTotals` and `drillRanges` (#20700). The service once
9+
* set them through a module-private augmentation of that type; it now sets the
10+
* declared members directly. That retirement moved no byte of any answer, and
11+
* this file holds that line.
12+
*
13+
* The fixture is the one answer that carries all four at once: a grouping by
14+
* an equality-drillable dimension (`stage`) beside a month-bucketed date
15+
* dimension (`close_date`), with a `totals` selection (a per-stage subtotal
16+
* and the grand total). The answer is read as `AnalyticsResult` with no cast,
17+
* so each member read below compiles only because the contract declares it.
18+
*/
19+
20+
import { describe, it, expect } from 'vitest';
21+
import { DatasetSchema } from '@objectstack/spec/ui';
22+
import type { ExecutionContext } from '@objectstack/spec/kernel';
23+
import type { AnalyticsResult } from '@objectstack/spec/contracts';
24+
import { AnalyticsService } from '../analytics-service.js';
25+
26+
const CTX = { tenantId: 'org_A' } as ExecutionContext;
27+
28+
const DATASET = DatasetSchema.parse({
29+
name: 'pipe_drill',
30+
label: 'Pipe',
31+
object: 'opportunity',
32+
include: [],
33+
dimensions: [
34+
{ name: 'stage', field: 'stage', type: 'string' },
35+
{ name: 'close_date', field: 'close_date', type: 'date', dateGranularity: 'month' },
36+
],
37+
measures: [{ name: 'cnt', aggregate: 'count' }],
38+
});
39+
40+
/** The grouped field names of one `executeAggregate` call (a date bucket arrives as `{ field, dateGranularity }`). */
41+
function groupedFields(groupBy: unknown[] | undefined): string[] {
42+
return (groupBy ?? []).map((g) => (typeof g === 'string' ? g : (g as { field: string }).field));
43+
}
44+
45+
/**
46+
* Granularity bucketing runs through the ObjectQL aggregate path, so the driver
47+
* answers already-bucketed rows: the main grid (stage × month), the per-stage
48+
* subtotal, and the grand total, told apart by what each call groups by.
49+
*/
50+
function drillService(): AnalyticsService {
51+
return new AnalyticsService({
52+
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
53+
executeAggregate: async (_object, { groupBy }) => {
54+
const grouped = groupedFields(groupBy);
55+
if (grouped.includes('close_date')) {
56+
return [
57+
{ stage: 'qualification', close_date: '2026-06', cnt: 3 },
58+
{ stage: 'won', close_date: '2026-07', cnt: 2 },
59+
];
60+
}
61+
if (grouped.includes('stage')) {
62+
return [
63+
{ stage: 'qualification', cnt: 3 },
64+
{ stage: 'won', cnt: 2 },
65+
];
66+
}
67+
return [{ cnt: 5 }];
68+
},
69+
getReadScope: (_o, ctx?: ExecutionContext) => (ctx?.tenantId ? { organization_id: ctx.tenantId } : undefined),
70+
});
71+
}
72+
73+
const DRILL_SIDECARS = ['dimensionFields', 'drillRawRows', 'drillRawTotals', 'drillRanges'];
74+
75+
describe('the drill-through sidecars are emitted on the declared AnalyticsResult (#20727)', () => {
76+
it('a grouped, date-bucketed answer with totals carries all four, byte for byte', async () => {
77+
const answer: AnalyticsResult = await drillService().queryDataset(
78+
DATASET,
79+
{ dimensions: ['stage', 'close_date'], measures: ['cnt'], totals: { groupings: [['stage'], []] } },
80+
CTX,
81+
);
82+
83+
// All four are present, in the order the service sets them.
84+
expect(Object.keys(answer).filter((k) => DRILL_SIDECARS.includes(k))).toEqual(DRILL_SIDECARS);
85+
86+
const sidecars = {
87+
dimensionFields: answer.dimensionFields,
88+
drillRawRows: answer.drillRawRows,
89+
drillRawTotals: answer.drillRawTotals,
90+
drillRanges: answer.drillRanges,
91+
};
92+
expect(JSON.stringify(sidecars)).toBe(
93+
'{"dimensionFields":{"stage":"stage"},' +
94+
'"drillRawRows":[{"stage":"qualification"},{"stage":"won"}],' +
95+
'"drillRawTotals":[[{"stage":"qualification"},{"stage":"won"}],[{}]],' +
96+
'"drillRanges":[' +
97+
'{"close_date":{"field":"close_date","gte":"2026-06-01","lt":"2026-07-01"}},' +
98+
'{"close_date":{"field":"close_date","gte":"2026-07-01","lt":"2026-08-01"}}]}',
99+
);
100+
});
101+
});

‎packages/services/service-analytics/src/analytics-service.ts‎

Lines changed: 4 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -88,53 +88,6 @@ import { invalidMemberError } from './dataset-refusal.js';
8888
// it rather than restating it.
8989
import { ACCEPTED_SQL_DIALECTS, isUnrecognisedSqlDialectAnswer, type AcceptedSqlDialect } from './text-match-sql.js';
9090

91-
/**
92-
* Analytics result augmented with drill-through metadata (ADR-0021 D2; see
93-
* queryDataset). Carried alongside `rows` so the host can drill a clicked bucket
94-
* back to the underlying records without the renderer knowing field mappings.
95-
*
96-
* The object those records belong to is not part of this augmentation: it is
97-
* the contract's own `AnalyticsResult.object`, which every dataset answer
98-
* carries, drillable or not (#20644).
99-
*/
100-
type AnalyticsResultWithDrill = AnalyticsResult & {
101-
/** Selected drillable dimension NAME → underlying object FIELD name. */
102-
dimensionFields?: Record<string, string>;
103-
/**
104-
* RAW grouped values per row, aligned to `rows` by index — each a map of
105-
* drillable dimension NAME → stored value (BEFORE label resolution rewrote
106-
* `rows[i][dim]` to the display label). The exact-match drill filter is built
107-
* from these, never from the display labels.
108-
*/
109-
drillRawRows?: Array<Record<string, unknown>>;
110-
/**
111-
* RAW grouped values for the totals/subtotal rows (#3214), the totals-side
112-
* companion to `drillRawRows`: `drillRawTotals[i]` aligns to `result.totals[i]`
113-
* and `drillRawTotals[i][j]` to `result.totals[i].rows[j]`. Each map holds that
114-
* grouping's DRILLABLE dimension NAME → stored value, snapshotted in the SAME
115-
* pre-label-resolution pass (the totals loop below overwrites a subtotal row's
116-
* dimension value with its display label just like the data rows). Restricted
117-
* to the drillable dims present in the grouping, so the grand-total grouping
118-
* (`[]`) contributes an empty map per row — which keeps the index alignment
119-
* intact and correctly drills the whole (unfiltered) object.
120-
*/
121-
drillRawTotals?: Array<Array<Record<string, unknown>>>;
122-
/**
123-
* #1752 — half-open date-range drill scope per row, the RANGE companion to
124-
* `drillRawRows` (which handles equality dims). A time-bucketed date
125-
* dimension (`dateGranularity`) groups a SPAN of records into one bucket
126-
* ("2026-Q2"), so its drill needs `[gte, lt)`, not equality — the humanized
127-
* bucket can't be exact-matched (which is why date dims are excluded from
128-
* `dimensionFields`/`drillRawRows`). Aligned to `rows` by index; each entry
129-
* maps a drillable date-dimension NAME → `{ field, gte, lt }` with `gte`
130-
* inclusive and `lt` exclusive (bounds as `YYYY-MM-DD`). Present only for
131-
* buckets whose boundaries are unambiguous — a `datetime` field under a
132-
* non-UTC reference timezone is omitted (host drills an unscoped superset)
133-
* until instant-boundary support lands.
134-
*/
135-
drillRanges?: Array<Record<string, { field: string; gte: string; lt: string }>>;
136-
};
137-
13891
/**
13992
* [#5717] Does this error carry an ADR-0112 envelope — i.e. did its PRODUCER
14093
* already classify it?
@@ -1967,10 +1920,10 @@ export class AnalyticsService implements IAnalyticsService {
19671920
// exact-matched against the stored timestamp, so they are not drillable.
19681921
const drillDims = selectedDims.filter((d) => !!d.field && d.type !== 'date');
19691922
if (drillDims.length && result.rows.length) {
1970-
(result as AnalyticsResultWithDrill).dimensionFields = Object.fromEntries(
1923+
result.dimensionFields = Object.fromEntries(
19711924
drillDims.map((d) => [d.name, d.field as string]),
19721925
);
1973-
(result as AnalyticsResultWithDrill).drillRawRows = result.rows.map((row) => {
1926+
result.drillRawRows = result.rows.map((row) => {
19741927
const raw: Record<string, unknown> = {};
19751928
for (const d of drillDims) raw[d.name] = row[d.name];
19761929
return raw;
@@ -1982,7 +1935,7 @@ export class AnalyticsService implements IAnalyticsService {
19821935
// groups by (the grand-total grouping `[]` keeps empty maps, so a subtotal
19831936
// drill filters by the stored value while the grand total drills unfiltered).
19841937
if (result.totals?.length) {
1985-
(result as AnalyticsResultWithDrill).drillRawTotals = result.totals.map((total) => {
1938+
result.drillRawTotals = result.totals.map((total) => {
19861939
const groupingDims = drillDims.filter((d) => total.dimensions.includes(d.name));
19871940
return total.rows.map((row) => {
19881941
const raw: Record<string, unknown> = {};
@@ -2031,7 +1984,7 @@ export class AnalyticsService implements IAnalyticsService {
20311984
if (rangeDims.length && result.rows.length) {
20321985
const bound = (ymd: string, instant: boolean): string =>
20331986
instant ? new Date(zonedDateStartToUtcMs(ymd, rangeTz)).toISOString() : ymd;
2034-
(result as AnalyticsResultWithDrill).drillRanges = result.rows.map((row) => {
1987+
result.drillRanges = result.rows.map((row) => {
20351988
const ranges: Record<string, { field: string; gte: string; lt: string }> = {};
20361989
for (const { d, granularity, instant } of rangeDims) {
20371990
// A row in the empty bucket carries `null` here (#3839) and yields no

0 commit comments

Comments
 (0)