From c08b52718a9cd6b18577e06c309def69e06de1f8 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:15:13 +0500 Subject: [PATCH 1/3] chore: bump objectui to e95cc25b2c0d MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(app-shell): NavigationSyncEffect baseline race — readiness gate, sys_ filter, ADR-0010 lock filter (#1668) objectui@e95cc25b2c0d2fa680e232151721e71c19630659 --- .objectui-sha | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.objectui-sha b/.objectui-sha index 4b1609af97..2f1a4d2778 100644 --- a/.objectui-sha +++ b/.objectui-sha @@ -1 +1 @@ -18594c2ffcee72c02da99dfa8588146f78672fd0 +e95cc25b2c0d2fa680e232151721e71c19630659 From 6d830bfcddc436ef34bd8af2857c26b549cf35d9 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:49:08 +0500 Subject: [PATCH 2/3] feat(analytics): server-side totals for matrix reports (#1753) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit queryDataset selections accept totals.groupings — dimension subsets the executor re-runs the full selection against (measure-scoped filters, derived measures, compareTo included), returning marginal aggregates on AnalyticsResult.totals. Each subtotal/grand total is the measure's true aggregate over the underlying rows, never re-derived from bucketed values (ADR-0021 governance line). Dimension display labels resolve on totals rows the same way as the primary grid. Co-Authored-By: Claude Fable 5 --- .../src/__tests__/dataset-executor.test.ts | 72 +++++++++++++++++++ .../src/__tests__/dimension-labels.test.ts | 15 ++++ .../src/analytics-service.ts | 8 +++ .../service-analytics/src/dataset-executor.ts | 46 +++++++++++- .../spec/src/contracts/analytics-service.ts | 26 +++++++ 5 files changed, 166 insertions(+), 1 deletion(-) diff --git a/packages/services/service-analytics/src/__tests__/dataset-executor.test.ts b/packages/services/service-analytics/src/__tests__/dataset-executor.test.ts index 20cfb094b1..d84cc6d114 100644 --- a/packages/services/service-analytics/src/__tests__/dataset-executor.test.ts +++ b/packages/services/service-analytics/src/__tests__/dataset-executor.test.ts @@ -110,6 +110,78 @@ describe('DatasetExecutor', () => { expect(res.rows[0].win_rate).toBe(0.6); }); + it('totals groupings re-run the selection per dimension subset (#1753)', async () => { + const matrix = DatasetSchema.parse({ + name: 'hours', label: 'Hours', object: 'task', + dimensions: [ + { name: 'owner', field: 'owner', type: 'string' }, + { name: 'status', field: 'status', type: 'string' }, + ], + measures: [{ name: 'avg_hours', aggregate: 'avg', field: 'est_hours' }], + }); + const seen: AnalyticsQuery[] = []; + const svc = fakeService((q) => { + seen.push(q); + const dims = (q.dimensions ?? []).join(','); + if (dims === 'owner,status') return { rows: [{ owner: 'alice', status: 'done', avg_hours: 2 }], fields: [] }; + if (dims === 'owner') return { rows: [{ owner: 'alice', avg_hours: 3 }], fields: [] }; + if (dims === 'status') return { rows: [{ status: 'done', avg_hours: 4 }], fields: [] }; + // grand total — the TRUE avg over all rows, not an avg of bucket avgs + return { rows: [{ avg_hours: 3.5 }], fields: [] }; + }); + const compiled = compileDataset(matrix); + const res = await new DatasetExecutor(svc).execute(compiled, { + dimensions: ['owner', 'status'], measures: ['avg_hours'], + order: { avg_hours: 'desc' }, limit: 50, + totals: { groupings: [['owner'], ['status'], []] }, + }); + expect(res.rows).toEqual([{ owner: 'alice', status: 'done', avg_hours: 2 }]); + expect(res.totals).toEqual([ + { dimensions: ['owner'], rows: [{ owner: 'alice', avg_hours: 3 }] }, + { dimensions: ['status'], rows: [{ status: 'done', avg_hours: 4 }] }, + { dimensions: [], rows: [{ avg_hours: 3.5 }] }, + ]); + // primary query keeps order/limit; totals queries drop them (totals cover + // the full selection, and an order key may reference a dropped dimension) + const primary = seen.find((q) => (q.dimensions ?? []).length === 2)!; + expect(primary.order).toEqual({ avg_hours: 'desc' }); + expect(primary.limit).toBe(50); + for (const q of seen.filter((s) => (s.dimensions ?? []).length < 2)) { + expect(q.order).toBeUndefined(); + expect(q.limit).toBeUndefined(); + } + }); + + it('totals carry measure-scoped filters and derived measures', async () => { + const svc = fakeService((q) => { + const grandTotal = (q.dimensions ?? []).length === 0; + if (q.measures.includes('won_amount')) { + // measure-scoped filter must also reach the totals query + expect(JSON.stringify(q.where)).toContain('"stage":"won"'); + return { rows: [grandTotal ? { won_amount: 120 } : { region: 'NA', won_amount: 60 }], fields: [] }; + } + return { rows: [grandTotal ? { revenue: 200 } : { region: 'NA', revenue: 100 }], fields: [] }; + }); + const compiled = compileDataset(dataset); + const res = await new DatasetExecutor(svc).execute(compiled, { + dimensions: ['region'], measures: ['win_rate'], + totals: { groupings: [[]] }, + }); + expect(res.rows[0].win_rate).toBe(0.6); + expect(res.totals).toEqual([{ dimensions: [], rows: [{ revenue: 200, won_amount: 120, win_rate: 0.6 }] }]); + }); + + it('rejects a totals grouping that is not a subset of the selected dimensions', async () => { + const svc = fakeService(() => ({ rows: [], fields: [] })); + const compiled = compileDataset(dataset); + await expect( + new DatasetExecutor(svc).execute(compiled, { + dimensions: ['region'], measures: ['revenue'], + totals: { groupings: [['stage']] }, + }), + ).rejects.toThrow(/not a subset of the selected dimensions/); + }); + it('compareTo runs a shifted query and attaches __compare', async () => { const seen: AnalyticsQuery[] = []; const svc = fakeService((q) => { diff --git a/packages/services/service-analytics/src/__tests__/dimension-labels.test.ts b/packages/services/service-analytics/src/__tests__/dimension-labels.test.ts index a69debf16b..38d0a6ea7b 100644 --- a/packages/services/service-analytics/src/__tests__/dimension-labels.test.ts +++ b/packages/services/service-analytics/src/__tests__/dimension-labels.test.ts @@ -221,6 +221,21 @@ describe('AnalyticsService.queryDataset — label resolution (integration)', () ]); }); + it('resolves labels on server-side totals rows (#1753)', async () => { + const res = await service().queryDataset(dataset, { + dimensions: ['status', 'account'], + measures: ['task_count'], + totals: { groupings: [['account']] }, + }); + expect(res.totals).toEqual([{ + dimensions: ['account'], + rows: [ + { account: 'Acme Corp', task_count: 4 }, + { account: 'Globex', task_count: 2 }, + ], + }]); + }); + it('enriches measure fields with their display label + format', async () => { const labelledDataset = DatasetSchema.parse({ name: 'sales_metrics', diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 02388ff33e..3722ed2031 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -404,6 +404,14 @@ export class AnalyticsService implements IAnalyticsService { if (dims.length) { try { await resolveDimensionLabels(dataset.object, dims, result.rows, this.labelResolver); + // Totals rows (#1753) carry dimension values too (a row subtotal is + // keyed by its row bucket) — resolve each grouping's own subset. + for (const total of result.totals ?? []) { + const subset = dims.filter((d) => total.dimensions.includes(d.name)); + if (subset.length) { + await resolveDimensionLabels(dataset.object, subset, total.rows, this.labelResolver); + } + } } catch (e) { this.logger?.warn?.(`[Analytics] dimension label resolution failed for "${dataset.name}": ${String((e as Error)?.message ?? e)}`); } diff --git a/packages/services/service-analytics/src/dataset-executor.ts b/packages/services/service-analytics/src/dataset-executor.ts index a564ce15a8..0081116a40 100644 --- a/packages/services/service-analytics/src/dataset-executor.ts +++ b/packages/services/service-analytics/src/dataset-executor.ts @@ -26,7 +26,10 @@ export type CompareTo = DatasetCompareTo; * - applies measure-scoped filters via supplementary grouped queries, * - evaluates derived measures (ratio/sum/difference/product) row-by-row (Q1), * - shifts the query for `compareTo` (previousPeriod / previousYear) and - * attaches `__compare` columns. + * attaches `__compare` columns, + * - computes server-side totals (`selection.totals.groupings`, #1753) by + * re-running the selection per dimension subset, so matrix subtotals and + * the grand total use each measure's true aggregate. * * RLS/tenant scoping is NOT handled here — it is enforced inside the strategy * via the StrategyContext read-scope hook (D-C). This layer is pure query @@ -136,6 +139,47 @@ export class DatasetExecutor { compiled: CompiledDataset, selection: DatasetSelection, context?: ExecutionContext, + ): Promise { + const result = await this.executeSelection(compiled, selection, context); + + // Server-side totals (#1753) — re-run the selection grouped by each + // requested dimension subset, so a subtotal/grand total is the measure's + // TRUE aggregate over the underlying rows (an avg total is the average of + // all rows, not of bucket averages). Re-running the full pipeline keeps + // measure-scoped filters, derived measures, and compareTo consistent with + // the primary grid. order/limit/offset are dropped: totals cover the whole + // selection, and an order key may reference a dimension the grouping drops. + const groupings = selection.totals?.groupings; + if (groupings?.length) { + const selected = new Set(selection.dimensions ?? []); + const totals: NonNullable = []; + for (const grouping of groupings) { + const unknown = grouping.filter((d) => !selected.has(d)); + if (unknown.length) { + throw new Error( + `[dataset-executor] totals grouping [${grouping.join(', ')}] is not a subset of the selected dimensions — unknown: ${unknown.join(', ')}.`, + ); + } + const sub = await this.executeSelection(compiled, { + ...selection, + dimensions: grouping, + totals: undefined, + order: undefined, + limit: undefined, + offset: undefined, + }, context); + totals.push({ dimensions: grouping, rows: sub.rows }); + } + result.totals = totals; + } + + return result; + } + + private async executeSelection( + compiled: CompiledDataset, + selection: DatasetSelection, + context?: ExecutionContext, ): Promise { const derivedByName = new Map(compiled.derived.map((d) => [d.name, d])); const selectedDerived = selection.measures diff --git a/packages/spec/src/contracts/analytics-service.ts b/packages/spec/src/contracts/analytics-service.ts index cda693001b..575441c203 100644 --- a/packages/spec/src/contracts/analytics-service.ts +++ b/packages/spec/src/contracts/analytics-service.ts @@ -75,6 +75,19 @@ export interface AnalyticsResult { }>; /** Generated SQL (if available) */ sql?: string; + /** + * Marginal aggregates — one entry per `DatasetSelection.totals` grouping, + * in request order. Each entry's rows carry the grouping's dimension + * columns plus the same measure columns as `rows`, computed with the + * measure's true aggregate over the underlying data (never re-derived + * from bucketed values). The grand-total grouping (`[]`) yields a single + * dimensionless row. + */ + totals?: Array<{ + /** The dimension subset this marginal was grouped by ([] = grand total). */ + dimensions: string[]; + rows: Record[]; + }>; } /** @@ -121,6 +134,19 @@ export interface DatasetSelection { offset?: number; /** Compare-to directive — runs a shifted query and attaches `__compare`. */ compareTo?: DatasetCompareTo; + /** + * Server-side totals (matrix subtotals + grand total). Each grouping is a + * subset of `dimensions` to additionally aggregate by; the selection is + * re-run grouped only by those dimensions, so every total is the measure's + * TRUE aggregate over the underlying rows — an `avg` total is the average + * over all rows, not an average of bucket averages (the ADR-0021 + * governance line that forbids client-side re-aggregation). `[]` requests + * the grand total. A matrix report asks for + * `{ groupings: [rowDims, columnDims, []] }`. Results arrive on + * `AnalyticsResult.totals` in request order. `order`/`limit`/`offset` do + * not apply to totals queries — totals always cover the full selection. + */ + totals?: { groupings: string[][] }; timezone?: string; } From b4765be1c552d9880de63d370dc48ca87912aafc Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:50:59 +0500 Subject: [PATCH 3/3] chore: changeset for matrix server-side totals Co-Authored-By: Claude Fable 5 --- .changeset/matrix-server-side-totals.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/matrix-server-side-totals.md diff --git a/.changeset/matrix-server-side-totals.md b/.changeset/matrix-server-side-totals.md new file mode 100644 index 0000000000..8d82c3f38f --- /dev/null +++ b/.changeset/matrix-server-side-totals.md @@ -0,0 +1,6 @@ +--- +"@objectstack/spec": minor +"@objectstack/service-analytics": minor +--- + +Server-side totals for matrix reports (#1753). `queryDataset` selections accept `totals: { groupings: string[][] }` — each grouping a subset of `selection.dimensions` to additionally aggregate by (`[]` = grand total); the marginal rows come back on `AnalyticsResult.totals` in request order. Each subtotal/grand total re-runs the full executor pipeline (measure-scoped filters, derived measures, compareTo) grouped only by that subset, so totals use each measure's true aggregate over the underlying rows — an `avg` total is the average of all rows, never an average of bucket averages (the ADR-0021 line that forbids client-side re-aggregation). Dimension display labels resolve on totals rows the same as the primary grid. A matrix report renderer asks for `{ groupings: [rowDims, columnDims, []] }` and renders the supplied totals row/column.