From be82842c135ea8cdcbca50d4ef893b39abd53890 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:40:48 +0000 Subject: [PATCH 1/8] feat(analytics): an authored cube's measures.format and dimensions.granularities reach the query doors (wip) Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude --- .../src/analytics-service.ts | 139 ++++++++++++++++-- .../service-analytics/src/dataset-executor.ts | 38 ++++- 2 files changed, 166 insertions(+), 11 deletions(-) diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 298b36a1acb..9d77adcd798 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -64,7 +64,12 @@ import { } from './strategies/filter-normalizer.js'; import { findCrossFieldComparand } from './comparand-shape.js'; import { compileDataset, type CompiledDataset, type RelationshipResolver } from './dataset-compiler.js'; -import { DatasetExecutor, resolveDimensionGranularity, type DateGranularityValue } from './dataset-executor.js'; +import { + DatasetExecutor, + declaredDefaultGranularity, + resolveDimensionGranularity, + type DateGranularityValue, +} from './dataset-executor.js'; import { resolveDimensionLabels, createOrderLabelResolver, @@ -354,6 +359,9 @@ const BARE_IDENTIFIER = /^[a-z_][a-z0-9_]*$/i; * `measures.revenue = {sql: 'annual_revenue'}` answers * `where: {revenue: {$gt: 100}}` as `annual_revenue > ?`, and a * dimensions-only lookup would have called `revenue` a missing column. + * - `'measure'` — {@link withDeclaredMeasureFormats}, matching + * `lookupMember(…, 'measure')`, which looks in `cube.measures` only: a + * measure column must never borrow a same-named dimension's entry. * * Extracted from #5520's gate so #5669's second caller reads the tree the same * way — two open-coded copies of `lookupMember` in one file is exactly how @@ -362,15 +370,17 @@ const BARE_IDENTIFIER = /^[a-z_][a-z0-9_]*$/i; function declaredMemberEntry( cube: Cube, member: string, - kind: 'dimension' | 'any', + kind: 'dimension' | 'measure' | 'any', ): { key: string; sql?: unknown } | undefined { const bags: Array> = kind === 'dimension' ? [cube.dimensions as Record] - : [ - cube.dimensions as Record, - cube.measures as Record, - ]; + : kind === 'measure' + ? [cube.measures as Record] + : [ + cube.dimensions as Record, + cube.measures as Record, + ]; // `key` is spread LAST in every arm: it is the bag key this member RESOLVED // to, and a `key` property on the cube entry itself must not shadow it. for (const bag of bags) { @@ -413,6 +423,101 @@ function resolveMemberSource( return { key: member, source: BARE_IDENTIFIER.test(member) ? member : null }; } +/** + * `analytics_cube.dimensions.granularities` on the query doors: bucket every + * GROUPED time dimension that names no granularity at the default its cube + * dimension declares ({@link declaredDefaultGranularity} — a single-entry + * list). + * + * The compiled-dataset path has always done this, one layer up: + * `DatasetExecutor.buildQuery` fills the same default into the query it hands + * `query()`, and until this ran on the query doors that was the ONLY place the + * key was read. A cube registered through `AnalyticsServiceConfig.cubes` — + * the authoring door — never becomes a `CompiledDataset`, so its declared + * `granularities` reached no reader: a query grouping by the dimension got one + * group per distinct timestamp, the #3588 shape. Run here, the one rule covers + * every cube that answers the name, whoever produced it; on the dataset path + * it finds the default already filled and changes nothing. + * + * The same scoping `buildQuery` applies, and for the same reason (#5688): + * + * - a `timeDimensions` entry that names no granularity is filled only when its + * dimension is also GROUPED (listed in `dimensions`) — an entry carrying + * only a `dateRange` is a window, a filter, and must stay one; + * - a grouped time dimension with no `timeDimensions` entry at all gets one, + * spelled as the `dimensions` entry spells it, because the strategies key a + * bucket by that spelling; + * - a stated `granularity` is never overridden, and one outside the declared + * list is not refused (the dataset path compares against no list either). + * + * Returns `query` itself when nothing applies, so a query with nothing to + * default reaches the strategies byte-identical to before. + */ +function withDeclaredGranularityDefaults(query: AnalyticsQuery, cube: Cube | undefined): AnalyticsQuery { + if (!cube || !query.dimensions?.length) return query; + const defaultOf = (member: string): DateGranularityValue | undefined => { + const entry = declaredMemberEntry(cube, member, 'dimension'); + return entry ? declaredDefaultGranularity(cube.dimensions[entry.key]) : undefined; + }; + const grouped = new Set(query.dimensions); + let filled = false; + const timeDimensions = (query.timeDimensions ?? []).map((t) => { + if (t.granularity || !grouped.has(t.dimension)) return t; + const granularity = defaultOf(t.dimension); + if (!granularity) return t; + filled = true; + return { ...t, granularity }; + }); + const named = new Set(timeDimensions.map((t) => t.dimension)); + for (const member of query.dimensions) { + if (named.has(member)) continue; + const granularity = defaultOf(member); + if (!granularity) continue; + filled = true; + named.add(member); + timeDimensions.push({ dimension: member, granularity }); + } + return filled ? { ...query, timeDimensions } : query; +} + +/** + * `analytics_cube.measures.format` on the query doors: describe each MEASURE + * column of a result with the display `format` its cube measure declares. + * + * `fields[].format` is the presentation surface a client formats amounts from + * (`AnalyticsResult`); `GET /analytics/meta` deliberately does not carry it. + * The compiled-dataset path fills it from the dataset's own measure + * (`enrichResultColumns`), and the dataset compiler copies that same value onto + * the cube it mints — so for a compiled dataset the value read here is the one + * already there. A cube registered through `AnalyticsServiceConfig.cubes` has + * no dataset: until this ran, its authored `format` reached no reader and + * `POST /analytics/query` described the column with `name` and `type` only. + * + * Only columns the request named under `measures` are described (the + * strategies name a measure column by the request's own spelling), resolved + * through the measures bag alone. A value a column already carries is never + * replaced — this describes, it does not correct. Copy-on-write: the strategy, + * or the delegated fallback service, owns the object it returned. + */ +function withDeclaredMeasureFormats( + result: AnalyticsResult, + query: AnalyticsQuery, + cube: Cube | undefined, +): AnalyticsResult { + if (!cube || !result?.fields?.length || !query.measures?.length) return result; + const requested = new Set(query.measures); + let fields: AnalyticsResult['fields'] | undefined; + result.fields.forEach((f, i) => { + if (f.format != null || !requested.has(f.name)) return; + const entry = declaredMemberEntry(cube, f.name, 'measure'); + const format = entry ? cube.measures[entry.key]?.format : undefined; + if (typeof format !== 'string' || format === '') return; + fields ??= [...result.fields]; + fields[i] = { ...f, format }; + }); + return fields ? { ...result, fields } : result; +} + /** * Configuration for AnalyticsService. */ @@ -1492,7 +1597,13 @@ export class AnalyticsService implements IAnalyticsService { // period boundary. An unresolvable placeholder throws the resolver's // `FILTER_TOKEN_*` 400 instead of charting zero. const tokenCtx = filterTokenContextFrom(context, new Date()); - const query = this.resolveQueryTokens(queryInput, tokenCtx); + // `analytics_cube.dimensions.granularities` — the cube's declared default + // bucket, filled in before the gates and strategy selection run, so both + // see the query that will actually be bucketed. + const query = withDeclaredGranularityDefaults( + this.resolveQueryTokens(queryInput, tokenCtx), + scope.getCube(queryInput.cube), + ); this.ensureCube(query, scope); const ctx = await this.callCtx(query, context, tokenCtx, scope); @@ -1508,7 +1619,12 @@ export class AnalyticsService implements IAnalyticsService { // service minted (e.g. `MemoryAnalyticsService`, which always echoes). // Gating any one of those would leave the others serving, which is the // shape the defect already had. - return this.applySqlEchoPolicy(await strategy.execute(query, ctx)); + // + // `analytics_cube.measures.format` is described at the same seam, for + // the same reason: whichever strategy answered, the measure columns + // leave with the format the cube declares. + const result = await strategy.execute(query, ctx); + return this.applySqlEchoPolicy(withDeclaredMeasureFormats(result, query, scope.getCube(query.cube!))); } catch (e) { if ((e as { code?: string })?.code === 'RAW_SQL_UNSUPPORTED') { this.logger.warn( @@ -2208,11 +2324,16 @@ export class AnalyticsService implements IAnalyticsService { // the same `FILTER_TOKEN_*` refusal), never a literal `{current_user_id}` // the real execution would not bind. const tokenCtx = filterTokenContextFrom(context, new Date()); - const query = this.resolveQueryTokens(queryInput, tokenCtx); // [#20381] Same request scope as `query()`: nothing minted here reaches the // shared registry, admitted or refused. const scope = this.requestScope(); + // …and the same declared default bucket, so the dry run shows the + // statement `query()` would run rather than an unbucketed one. + const query = withDeclaredGranularityDefaults( + this.resolveQueryTokens(queryInput, tokenCtx), + scope.getCube(queryInput.cube), + ); this.ensureCube(query, scope); const ctx = await this.callCtx(query, context, tokenCtx, scope); const strategy = this.resolveStrategy(query, ctx); diff --git a/packages/services/service-analytics/src/dataset-executor.ts b/packages/services/service-analytics/src/dataset-executor.ts index d07fa7f5c7f..f6e0d7e93de 100644 --- a/packages/services/service-analytics/src/dataset-executor.ts +++ b/packages/services/service-analytics/src/dataset-executor.ts @@ -336,6 +336,41 @@ export function resolveDimensionGranularity( return selection.dateGranularity ?? (datasetDefault as DateGranularityValue | undefined); } +/** + * The bucket size a cube dimension DECLARES as its default — the one reading of + * `analytics_cube.dimensions.granularities` there is, whoever produced the cube. + * + * A `time` dimension whose `granularities` lists exactly ONE interval defaults + * to it; any other shape states no default. That is the reading the dataset + * compiler's output was built for: it lowers an explicit + * `dataset.dimensions[].dateGranularity` to a single-entry list and writes the + * five-entry "all intervals" list when the dataset stated none, and + * `CubeRegistry.inferFromObject` / the ad-hoc inference mint the same five. A + * multi-entry list therefore offers several and chooses none. + * + * The default is the LOWEST rung of the precedence + * {@link resolveDimensionGranularity} states: a granularity the request states + * is never overridden, and nothing here refuses one the list does not name — + * the compiled-dataset path has never compared a requested granularity against + * the list, so this reading does not either. + * + * Two callers, one rule: {@link DatasetExecutor}'s `granularityOf` for a + * compiled dataset, and `AnalyticsService`'s query doors for every other cube — + * an authored one (`AnalyticsServiceConfig.cubes`) included, which is the + * producer this key is authored on and which never becomes a + * `CompiledDataset`. + */ +export function declaredDefaultGranularity( + dimension: { type?: string; granularities?: readonly unknown[] } | undefined, +): DateGranularityValue | undefined { + if (dimension?.type !== 'time') return undefined; + // Typed as bare strings by the cube layer (Cube.js heritage); the values are + // `TimeUpdateInterval`, which is `DateGranularity`'s own member list. + return dimension.granularities?.length === 1 + ? (String(dimension.granularities[0]) as DateGranularityValue) + : undefined; +} + // ── ordering + windowing (#3588) ───────────────────────────────────────────── /** @@ -1326,8 +1361,7 @@ export class DatasetExecutor { ): DateGranularityValue | undefined { const cd = compiled.cube.dimensions[name]; if (cd?.type !== 'time') return undefined; - const datasetDefault = cd.granularities?.length === 1 ? String(cd.granularities[0]) : undefined; - return resolveDimensionGranularity(selection, name, datasetDefault); + return resolveDimensionGranularity(selection, name, declaredDefaultGranularity(cd)); } private buildQuery( From e8538d9c1b093bfe8f319692f3e472c35302cbc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:45:29 +0000 Subject: [PATCH 2/8] test(analytics): pin an authored cube's format and declared granularity at the query doors, with dataset controls Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude --- .../cube-authored-format-granularity.test.ts | 268 ++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts diff --git a/packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts b/packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts new file mode 100644 index 00000000000..ff2028f09ba --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts @@ -0,0 +1,268 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `analytics_cube.measures.format` and `analytics_cube.dimensions.granularities` + * — an AUTHORED cube reaches the readers a compiled dataset already reaches. + * + * One Cube shape has three producers (`cube-registry.ts` names them): authored + * cubes (`defineCube()` / `defineStack({ analyticsCubes })`, threaded into + * `AnalyticsServiceConfig.cubes`), compiled datasets, and ad-hoc inference. + * Both keys were read on the compiled-dataset path only: + * + * - a dataset measure's `format` reached `fields[].format` through + * `enrichResultColumns`, which reads the DATASET — an authored cube has none, + * so `POST /analytics/query` described its measure columns with `name` and + * `type` alone and the authored `format` reached nobody; + * - a single-entry `granularities` list was the default bucket + * `DatasetExecutor.buildQuery` filled into the query it hands `query()` — + * an authored cube never becomes a `CompiledDataset`, so grouping by its time + * dimension grouped raw timestamps, one group per distinct instant. + * + * What this file pins, each against a dataset CONTROL that shows the authored + * cube now behaves as the compiled one always did: + * + * - `format` lands on the measure column on both strategies, under both member + * spellings, and on nothing else; + * - the declared single granularity is the default bucket on `query()` and on + * the `generateSql()` dry run; a stated granularity wins, one outside the + * list is not refused, a multi-entry list states no default, and a + * window-only `timeDimensions` entry stays a filter — the same five answers + * the dataset path gives. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { CubeSchema, type Cube } from '@objectstack/spec/data'; +import { DatasetSchema } from '@objectstack/spec/ui'; +import { AnalyticsService } from '../analytics-service.js'; + +const silentLogger = { + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: vi.fn().mockReturnThis(), +} as any; + +/** Parsed the way `defineCube()` and `defineStack({ analyticsCubes })` parse an authored cube. */ +const authored: Cube = CubeSchema.parse({ + name: 'orders', + sql: 'shop_order', + measures: { + count: { label: 'Orders', type: 'count', sql: '*' }, + revenue: { label: 'Revenue', type: 'sum', sql: 'amount', format: '$0,0.00' }, + margin: { label: 'Margin', type: 'avg', sql: 'margin', format: '0.0%' }, + }, + dimensions: { + status: { label: 'Status', type: 'string', sql: 'status' }, + placed_at: { label: 'Placed', type: 'time', sql: 'placed_at', granularities: ['month'] }, + shipped_at: { label: 'Shipped', type: 'time', sql: 'shipped_at', granularities: ['month', 'year'] }, + created_at: { label: 'Created', type: 'time', sql: 'created_at' }, + }, +}); + +/** The dataset twin of `authored`: the same object, measures and default bucket. */ +const dataset = DatasetSchema.parse({ + name: 'order_metrics', + label: 'Orders', + object: 'shop_order', + include: [], + dimensions: [ + { name: 'status', field: 'status', type: 'string' }, + { name: 'placed_at', field: 'placed_at', type: 'date', dateGranularity: 'month' }, + ], + measures: [ + { name: 'count', aggregate: 'count' }, + { name: 'revenue', aggregate: 'sum', field: 'amount', format: '$0,0.00' }, + ], +}); + +type GroupByItem = string | { field: string; dateGranularity?: string }; + +/** An ObjectQL-only host that records the `groupBy` every aggregate ran with. */ +function objectqlService(cubes: Cube[] = [authored]) { + const groupBys: GroupByItem[][] = []; + const service = new AnalyticsService({ + logger: silentLogger, + cubes, + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + executeAggregate: async (_object, options) => { + groupBys.push((options.groupBy ?? []) as GroupByItem[]); + return [{ status: 'open', count: 2, revenue: 10, margin: 0.25, placed_at: '2026-07' }]; + }, + }); + return { service, groupBys }; +} + +/** A raw-SQL-only host that records the statements it ran. */ +function nativeService(cubes: Cube[] = [authored]) { + const sqls: string[] = []; + const service = new AnalyticsService({ + logger: silentLogger, + cubes, + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }), + executeRawSql: async (_object, sql) => { + sqls.push(sql); + return [{ status: 'open', count: 2, revenue: 10, margin: 0.25 }]; + }, + }); + return { service, sqls }; +} + +const formatOf = (fields: Array<{ name: string; format?: string }>, name: string) => + fields.find((f) => f.name === name)?.format; + +describe('analytics_cube.measures.format — reaches `fields[].format` on `query()`', () => { + for (const [profile, make] of [ + ['ObjectQL', () => objectqlService().service], + ['native SQL', () => nativeService().service], + ] as const) { + it(`${profile}: each measure column carries the format its cube measure declares`, async () => { + const result = await make().query({ + cube: 'orders', + measures: ['orders.revenue', 'margin', 'orders.count'], + dimensions: ['orders.status'], + }); + + expect(formatOf(result.fields, 'orders.revenue')).toBe('$0,0.00'); + expect(formatOf(result.fields, 'margin')).toBe('0.0%'); + // A measure that declares no format gets no key at all — this describes, + // it never invents a format. + expect(result.fields.find((f) => f.name === 'orders.count')).not.toHaveProperty('format'); + // A dimension column is not a measure column. + expect(result.fields.find((f) => f.name === 'orders.status')).not.toHaveProperty('format'); + }); + } + + it('dataset CONTROL: the compiled path answers the same measure with the same format', async () => { + const { service } = objectqlService(); + + const viaDataset = await service.queryDataset(dataset as any, { measures: ['revenue'], dimensions: ['status'] }); + const viaCube = await service.query({ cube: 'orders', measures: ['revenue'], dimensions: ['status'] }); + + expect(formatOf(viaDataset.fields, 'revenue')).toBe('$0,0.00'); + expect(formatOf(viaCube.fields, 'revenue')).toBe(formatOf(viaDataset.fields, 'revenue')); + }); + + it('a suffix-inferred measure on an authored cube declares nothing, so it carries no format', async () => { + const { service } = objectqlService(); + + const result = await service.query({ cube: 'orders', measures: ['amount_sum', 'revenue'] }); + + expect(result.fields.find((f) => f.name === 'amount_sum')).not.toHaveProperty('format'); + expect(formatOf(result.fields, 'revenue')).toBe('$0,0.00'); + }); +}); + +describe('analytics_cube.dimensions.granularities — the declared single granularity is the default bucket', () => { + it('`query()` buckets a grouped time dimension at its declared granularity', async () => { + const { service, groupBys } = objectqlService(); + + const result = await service.query({ cube: 'orders', measures: ['count'], dimensions: ['orders.placed_at'] }); + + expect(groupBys).toEqual([[{ field: 'placed_at', dateGranularity: 'month' }]]); + // One column for the bucket, not a second one for the filled entry. + expect(result.fields.filter((f) => f.name === 'orders.placed_at')).toHaveLength(1); + }); + + it('dataset CONTROL: the compiled dataset groups the same dimension with the same bucket', async () => { + const { service, groupBys } = objectqlService(); + + await service.queryDataset(dataset as any, { measures: ['count'], dimensions: ['placed_at'] }); + await service.query({ cube: 'orders', measures: ['count'], dimensions: ['placed_at'] }); + + expect(groupBys).toHaveLength(2); + expect(groupBys[1]).toEqual(groupBys[0]); + expect(groupBys[0]).toEqual([{ field: 'placed_at', dateGranularity: 'month' }]); + }); + + it('fills an unstated `timeDimensions` entry for a GROUPED dimension', async () => { + const { service, groupBys } = objectqlService(); + + await service.query({ + cube: 'orders', + measures: ['count'], + dimensions: ['placed_at'], + timeDimensions: [{ dimension: 'placed_at', dateRange: ['2026-01-01', '2026-12-31'] }], + }); + + expect(groupBys).toEqual([[{ field: 'placed_at', dateGranularity: 'month' }]]); + }); + + it('a stated granularity wins, and one outside the declared list is not refused (dataset parity)', async () => { + const { service, groupBys } = objectqlService(); + + await service.query({ + cube: 'orders', + measures: ['count'], + dimensions: ['placed_at'], + timeDimensions: [{ dimension: 'placed_at', granularity: 'year' }], + }); + await service.queryDataset(dataset as any, { + measures: ['count'], + dimensions: ['placed_at'], + timeDimensions: [{ dimension: 'placed_at', granularity: 'year' }], + }); + + expect(groupBys).toEqual([ + [{ field: 'placed_at', dateGranularity: 'year' }], + [{ field: 'placed_at', dateGranularity: 'year' }], + ]); + }); + + it('a multi-entry list states no default, and an undeclared list none either — the raw column groups', async () => { + const { service, groupBys } = objectqlService(); + + await service.query({ cube: 'orders', measures: ['count'], dimensions: ['shipped_at'] }); + await service.query({ cube: 'orders', measures: ['count'], dimensions: ['created_at'] }); + + expect(groupBys).toEqual([['shipped_at'], ['created_at']]); + }); + + it('a window-only `timeDimensions` entry stays a filter — not grouped, not bucketed', async () => { + const { service, groupBys } = objectqlService(); + + await service.query({ + cube: 'orders', + measures: ['count'], + timeDimensions: [{ dimension: 'placed_at', dateRange: ['2026-01-01', '2026-12-31'] }], + }); + + expect(groupBys).toEqual([[]]); + }); + + it('`generateSql()` dry-runs the bucketed statement `query()` runs', async () => { + const { service } = objectqlService(); + + const declared = await service.generateSql({ cube: 'orders', measures: ['count'], dimensions: ['placed_at'] }); + const undeclared = await service.generateSql({ cube: 'orders', measures: ['count'], dimensions: ['created_at'] }); + + expect(declared.sql).toMatch(/date_trunc\('month'/i); + expect(undeclared.sql).not.toMatch(/date_trunc/i); + }); + + it('native SQL declines a bucketed query, so a declared default routes to the engine path as a dataset does', async () => { + const both = { nativeSql: true, objectqlAggregate: true, inMemory: false }; + const sqls: string[] = []; + const groupBys: GroupByItem[][] = []; + const service = new AnalyticsService({ + logger: silentLogger, + cubes: [authored], + queryCapabilities: () => both, + executeRawSql: async (_o, sql) => { + sqls.push(sql); + return []; + }, + executeAggregate: async (_o, options) => { + groupBys.push((options.groupBy ?? []) as GroupByItem[]); + return []; + }, + }); + + await service.query({ cube: 'orders', measures: ['count'], dimensions: ['placed_at'] }); + await service.query({ cube: 'orders', measures: ['count'], dimensions: ['created_at'] }); + + expect(groupBys).toEqual([[{ field: 'placed_at', dateGranularity: 'month' }]]); + expect(sqls).toHaveLength(1); + expect(sqls[0]).toMatch(/created_at/); + }); +}); From 9bf3b3b0b43dff81f8dcdbdc23844fbda752ecb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:50:39 +0000 Subject: [PATCH 3/8] test(runtime): pin an authored cube's format and declared granularity over POST /analytics/query and /analytics/sql Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude --- ...s-authored-cube-format-granularity.test.ts | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 packages/runtime/src/analytics-authored-cube-format-granularity.test.ts diff --git a/packages/runtime/src/analytics-authored-cube-format-granularity.test.ts b/packages/runtime/src/analytics-authored-cube-format-granularity.test.ts new file mode 100644 index 00000000000..f29670165f5 --- /dev/null +++ b/packages/runtime/src/analytics-authored-cube-format-granularity.test.ts @@ -0,0 +1,154 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `analytics_cube.measures.format` and `analytics_cube.dimensions.granularities` + * over the wire: an AUTHORED cube's two keys reach `POST /api/v1/analytics/query` + * and `POST /api/v1/analytics/sql` through the real dispatcher route. + * + * The service door is pinned beside the implementation + * (`service-analytics` `cube-authored-format-granularity.test.ts`, each case + * against a compiled-dataset control). This file asks the question that pin + * cannot: does what the service answers survive the route — `fields[].format` + * is a member `deps.success()` relays verbatim, and the dry-run door serves + * the bucketed statement `query()` runs. + * + * The cube is handed to the service as `AnalyticsServiceConfig.cubes`, the + * config key the CLI threads an app's `analyticsCubes` into — the authoring + * door, not a registered dataset. + */ + +import { describe, it, expect } from 'vitest'; +import { CubeSchema } from '@objectstack/spec/data'; +import { AnalyticsService } from '@objectstack/service-analytics'; + +import { createDispatcherPlugin } from './dispatcher-plugin.js'; + +// ── harness (the shape `analytics-query-read-scope-withhold.test.ts` uses) ──── + +type Handler = (req: unknown, res: unknown) => unknown; + +function makeFakeServer() { + const handlers: Record = {}; + const rec = (verb: string) => (path: string, handler: Handler) => { + handlers[`${verb} ${path}`] = handler; + }; + return { + handlers, + server: { get: rec('GET'), post: rec('POST'), put: rec('PUT'), delete: rec('DELETE'), patch: rec('PATCH') }, + }; +} + +function makeCtx(fakeServer: unknown, analytics: unknown) { + const kernel = { + getService: (name: string) => (name === 'analytics' ? analytics : undefined), + getServiceAsync: async (name: string) => (name === 'analytics' ? analytics : undefined), + }; + return { + getKernel: () => kernel, + getService: (name: string) => (name === 'http.server' ? fakeServer : undefined), + environmentId: undefined, + logger: { info() {}, warn() {}, error() {}, debug() {} }, + hook: () => {}, + on: () => {}, + } as any; +} + +function makeRes() { + const res: any = { + statusCode: undefined as number | undefined, + body: undefined as any, + status(c: number) { res.statusCode = c; return res; }, + header() { return res; }, + json(b: unknown) { res.body = b; return res; }, + }; + return res; +} + +/** Drive the REAL `POST /api/v1/analytics/` route against `analytics`. */ +async function post(analytics: unknown, sub: 'query' | 'sql', body: unknown) { + const { server, handlers } = makeFakeServer(); + const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false }); + await plugin.start?.(makeCtx(server, analytics)); + const handler = handlers[`POST /api/v1/analytics/${sub}`]; + expect(handler, `POST /api/v1/analytics/${sub} must be mounted`).toBeTypeOf('function'); + const res = makeRes(); + await handler({ body, query: {} }, res); + return res; +} + +const silent = { debug() {}, info() {}, warn() {}, error() {} }; + +/** Parsed the way `defineCube()` and `defineStack({ analyticsCubes })` parse an authored cube. */ +const orders = CubeSchema.parse({ + name: 'orders', + sql: 'shop_order', + measures: { + count: { label: 'Orders', type: 'count', sql: '*' }, + revenue: { label: 'Revenue', type: 'sum', sql: 'amount', format: '$0,0.00' }, + }, + dimensions: { + status: { label: 'Status', type: 'string', sql: 'status' }, + placed_at: { label: 'Placed', type: 'time', sql: 'placed_at', granularities: ['month'] }, + }, +}); + +type GroupByItem = string | { field: string; dateGranularity?: string }; + +/** The composition `AnalyticsServicePlugin` wires by default: both strategies. */ +function analytics() { + const groupBys: GroupByItem[][] = []; + const service = new AnalyticsService({ + logger: silent, + cubes: [orders], + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: true, inMemory: false }), + executeRawSql: async () => [{ status: 'open', count: 2, revenue: 10 }], + executeAggregate: async (_object, options) => { + groupBys.push((options.groupBy ?? []) as GroupByItem[]); + return [{ placed_at: '2026-07', count: 2 }]; + }, + }); + return { service, groupBys }; +} + +describe('POST /analytics/query — an authored cube measure\'s `format` reaches `fields[]`', () => { + it('the measure column carries the declared format; an undeclared one and a dimension carry none', async () => { + const res = await post(analytics().service, 'query', { + cube: 'orders', + measures: ['orders.revenue', 'orders.count'], + dimensions: ['orders.status'], + }); + + expect(res.statusCode).toBe(200); + const fields = res.body.data.fields as Array<{ name: string; format?: string }>; + expect(fields.find((f) => f.name === 'orders.revenue')?.format).toBe('$0,0.00'); + expect(fields.find((f) => f.name === 'orders.count')).not.toHaveProperty('format'); + expect(fields.find((f) => f.name === 'orders.status')).not.toHaveProperty('format'); + }); +}); + +describe('an authored time dimension\'s single declared granularity is its default bucket over the wire', () => { + it('POST /analytics/query groups the dimension at the declared granularity', async () => { + const { service, groupBys } = analytics(); + + const res = await post(service, 'query', { cube: 'orders', measures: ['count'], dimensions: ['placed_at'] }); + + expect(res.statusCode).toBe(200); + expect(groupBys).toEqual([[{ field: 'placed_at', dateGranularity: 'month' }]]); + expect(res.body.data.rows).toEqual([{ placed_at: '2026-07', count: 2 }]); + }); + + it('POST /analytics/sql dry-runs the bucketed statement, and a stated granularity still wins', async () => { + const declared = await post(analytics().service, 'sql', { cube: 'orders', measures: ['count'], dimensions: ['placed_at'] }); + const stated = await post(analytics().service, 'sql', { + cube: 'orders', + measures: ['count'], + dimensions: ['placed_at'], + timeDimensions: [{ dimension: 'placed_at', granularity: 'year' }], + }); + + expect(declared.statusCode).toBe(200); + expect(declared.body.data.sql).toMatch(/date_trunc\('month'/i); + expect(stated.statusCode).toBe(200); + expect(stated.body.data.sql).toMatch(/date_trunc\('year'/i); + }); +}); From 958251b6ace43fb4a1d500d5f6fcf1ab55326765 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 09:02:50 +0000 Subject: [PATCH 4/8] feat(spec): analytics_cube measures.format and dimensions.granularities are live; pin the declared narrowing; changeset Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude --- ...tics-cube-format-granularities-enforced.md | 26 +++++++++ .../cube-authored-format-granularity.test.ts | 58 ++++++++++++++++++- packages/spec/liveness/README.md | 2 +- packages/spec/liveness/analytics_cube.json | 18 +++--- .../liveness/state-counts/analytics_cube.md | 2 +- 5 files changed, 96 insertions(+), 10 deletions(-) create mode 100644 .changeset/20282-analytics-cube-format-granularities-enforced.md diff --git a/.changeset/20282-analytics-cube-format-granularities-enforced.md b/.changeset/20282-analytics-cube-format-granularities-enforced.md new file mode 100644 index 00000000000..a028a4fae16 --- /dev/null +++ b/.changeset/20282-analytics-cube-format-granularities-enforced.md @@ -0,0 +1,26 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-analytics': minor +--- + +An authored analytics cube's measure `format` and time-dimension `granularities` now take effect on the analytics query doors, the way a compiled dataset's always have (#20282). + +Clause-②: yes (narrowing) + + + +**BREAKING**: this narrows what `POST /api/v1/analytics/query` and `POST /api/v1/analytics/sql` answer for one class of request. When an authored cube's time dimension declares exactly one granularity, a query that groups by that dimension without stating a granularity is now bucketed at the declared one — and a bucketed query is served by the engine aggregate path, which refuses a custom-SQL measure (a measure of type `number`, `string` or `boolean` whose `sql` is an expression) with `400 INVALID_FIELD`. Such a query was answered before this change, with one group per distinct timestamp; it is now refused, exactly as it already was when the caller stated that granularity by hand. A host that overrides `queryCapabilities` to offer raw SQL with no engine aggregate bridge (the plugin's default wires both) now answers such a query "No strategy can handle query" instead of grouping raw timestamps. The remedy: query the custom-SQL measure without grouping by that dimension, or, if the dimension is not meant to have one default bucket, declare the granularities it offers as a list of two or more (or omit the key). It ships as `minor` under the launch-window convention; the widening half is two authored keys taking effect. + +Until this change both keys were read on the compiled-dataset path only. One cube shape has three producers — cubes authored with `defineCube()` / `defineStack({ analyticsCubes })`, cubes the dataset compiler mints, and cubes inferred for an ad-hoc query — and only a compiled dataset's cube reached the two readers: + +- **`measures..format`** reached a caller as `fields[].format` only because the dataset door copies it from the DATASET measure. An authored cube has no dataset, so `POST /api/v1/analytics/query` described its measure columns with `name` and `type` alone. Now every measure column a query names carries the `format` its cube measure declares, whichever strategy answered, and a column that declares none carries no `format` key at all. `GET /api/v1/analytics/meta` is unchanged: its projection stays `name`, `type` and `title`, and a client reads `format` off the query result's `fields[]`, as the Data API page already says. The value is relayed verbatim; the vocabulary `fields[].format` documents is a numeral pattern such as `"$0,0.00"` or `"0.0%"`. +- **`dimensions..granularities`** was the default bucket only for a compiled dataset, which the dataset executor filled in before querying. An authored cube's time dimension grouped raw timestamps whatever it declared. Now `query()` and the `generateSql()` dry run read it the same way, through the one rule both paths share: a single-entry list is the dimension's default bucket for a query that groups by it; a granularity the query states always wins, and one the list does not name is not refused (the dataset path compares against no list either); a list of two or more states no default; and a `timeDimensions` entry that carries only a `dateRange` for a dimension the query does not group stays a filter. + +What to expect after upgrading: + +- **A cube measure that declares `format`** now carries it on `POST /api/v1/analytics/query` results. A client that formats amounts from `fields[].format` starts formatting that column. +- **A cube time dimension that declares one granularity** (`granularities: ['month']`) is now bucketed by it when a query groups by it without stating one: one row per month where there was one row per timestamp. Name another granularity in the query's `timeDimensions` to bucket differently. +- **A cube time dimension that declares several, or none**, behaves exactly as before. +- **Compiled datasets** (`POST /api/v1/analytics/dataset/query`) answer exactly as before: the value read off their cube is the one the dataset door already used. + +In `@objectstack/spec`, the liveness ledger rows `analytics_cube.measures.format` and `analytics_cube.dimensions.granularities` move from `dead` to `live`, citing the new readers. diff --git a/packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts b/packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts index ff2028f09ba..f365ba98a37 100644 --- a/packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts +++ b/packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts @@ -27,7 +27,11 @@ * the `generateSql()` dry run; a stated granularity wins, one outside the * list is not refused, a multi-entry list states no default, and a * window-only `timeDimensions` entry stays a filter — the same five answers - * the dataset path gives. + * the dataset path gives; + * - the declared narrowing: a bucketed query is served by the engine path, + * which refuses a custom-SQL measure, so grouping such a measure by a + * declared-default dimension is now refused — with the envelope, byte for + * byte, that stating the same granularity by hand already got. */ import { describe, it, expect, vi } from 'vitest'; @@ -265,4 +269,56 @@ describe('analytics_cube.dimensions.granularities — the declared single granul expect(sqls).toHaveLength(1); expect(sqls[0]).toMatch(/created_at/); }); + + it('DECLARED NARROWING: a custom-SQL measure grouped by a declared-default dimension gets the refusal a stated granularity gets', async () => { + const withExpression: Cube = CubeSchema.parse({ + ...authored, + measures: { + ...authored.measures, + done_rate: { label: 'Done', type: 'number', sql: "SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) * 1.0 / COUNT(*)" }, + }, + }); + const aggregated: string[] = []; + const sqls: string[] = []; + const service = new AnalyticsService({ + logger: silentLogger, + cubes: [withExpression], + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: true, inMemory: false }), + executeRawSql: async (_o, sql) => { + sqls.push(sql); + return []; + }, + executeAggregate: async (object) => { + aggregated.push(object); + return []; + }, + }); + const envelope = (e: any) => ({ code: e?.code, status: e?.status }); + + const byDefault = await service + .query({ cube: 'orders', measures: ['done_rate'], dimensions: ['placed_at'] }) + .catch((e: unknown) => e); + const byHand = await service + .query({ + cube: 'orders', + measures: ['done_rate'], + dimensions: ['placed_at'], + timeDimensions: [{ dimension: 'placed_at', granularity: 'month' }], + }) + .catch((e: unknown) => e); + + expect(envelope(byDefault)).toEqual({ code: 'INVALID_FIELD', status: 400 }); + // Not a new refusal: the one stating the granularity by hand already got. + expect({ ...envelope(byDefault), message: (byDefault as Error).message }).toEqual({ + ...envelope(byHand), + message: (byHand as Error).message, + }); + expect(aggregated).toEqual([]); + expect(sqls).toEqual([]); + + // Control: the same measure grouped by a dimension that declares no single + // default is still answered, on the raw-SQL path, as it was before. + await service.query({ cube: 'orders', measures: ['done_rate'], dimensions: ['shipped_at'] }); + expect(sqls).toHaveLength(1); + }); }); diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 60174dc6c3b..cb124d3ccc4 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -945,7 +945,7 @@ marker where the Notes cell goes, never a guess at what belongs there. | realtime_subscription | seeded 2026-09-04 (#14446) — a TRANSPORT-PROTOCOL surface, the fifth category the `SPEC_ONLY_SCHEMAS` override has had to reach. `SubscriptionSchema` (`packages/spec/src/api/realtime.zod.ts`) is what a client declares to open a realtime subscription: the item type of `RealtimeConfigSchema.subscriptions` and the `Subscription` the generated API reference publishes. Like `query` it is a request surface rather than stored metadata, and like `query` that is exactly why it went unasked — no registry holds it, `RealtimeConfigSchema` is `.passthrough()` so nothing downstream even refuses an unknown key, and the whole vocabulary sat outside the denominator while the reference kept publishing it. Rooted on `SubscriptionSchema` rather than on `RealtimeConfigSchema` for the reason the four `RestServerConfig` sub-objects document one row up: the walk drilled exactly ONE level when this was rooted (it recurses as of #17424; the rooting stands), so with the config as the root `events[].type` and `events[].filters` would inherit a container verdict instead of carrying rows of their own — #4956's shape. **Dead 6 = every key it has, and the CONTAINER is the finding**: nothing outside `packages/spec` imports `SubscriptionSchema`, `SubscriptionEventSchema` or `RealtimeConfigSchema` at all, so no key beneath them can be read (the `manifest.contributes` reasoning). The two keys the card measured are the sharp ones. `events[].type` accepts `RealtimeEventType`, whose four members (`record.created` / `record.updated` / `record.deleted` / `field.changed`), as measured at `5f5511f0` before #20288 repointed the enum at the emitted `DataEventType` + `BulkDataEventType` names, are DISJOINT from what the engine publishes (`DataEventType`'s `data.record.*`, live emitter in `service-knowledge`), so an author who writes the enum's own `record.created` gets a subscription that silently never fires — and the enum is what the API reference shows them. Its direction is settled by the 2026-09-02 triage and quoted verbatim in the row: enforce means REPOINTING THE ENUM, never changing what the runtime publishes. `field.changed` is the same spelling the sibling `DataEventType` REMOVED in 17.0.0 (#4673, PR #4685) for having no producer; it survives here only because this enum was never in a ratchet's denominator. `events[].filters` is `z.unknown().optional()` — the textbook ADR-0049 fourth state, no shape and no reader, failing in the permissive direction (a subscriber who filters receives every event). ⚠️ Three spellings of a realtime subscription exist and only the third is executed: this one, `websocket.zod.ts#EventSubscriptionSchema`, and the plain interface `contracts/realtime-service.ts#RealtimeSubscriptionOptions` that `in-memory-realtime-adapter.ts#matchesSubscription` actually reads. The file note names the same-name-different-shape traps so the next census does not mistake one for a consumer. Zero live | | sharing_rule | seeded 2026-09-17 (#18582) — the second of the three `PENDING_GOVERNANCE` debts #18133 declared, and the first one PAID (`connector` and `analytics_cube` are still owed on that card). Not a registered kind: it is bound in `UNREGISTERED_KIND_SCHEMAS` (#6245) and reaches the walk through `getMetadataTypeSchema`'s unregistered-kind fallback, so this ledger governs a type `listMetadataTypeSchemaTypes()` still does not enumerate. One shape fact decides every row: the AUTHORING shape is not the ENFORCED shape. ADR-0057 D6 makes the `sys_sharing_rule` row canonical (`object_name` + `criteria_json` + `recipient_type`/`recipient_id` + `access_level`) and `bootstrapDeclaredSharingRules` translates each authored key into it at boot — nothing re-parses `SharingRuleSchema` at enforcement time — so every consumer cited reads a COLUMN and every row carries the `producer` (#4837) that populates it, which is the `seed.env` lesson applied to a whole type rather than to one key. Preview read points ENUMERATED per the #7131 rule and the answer recorded rather than skipped: `registerBuiltinPreviews()` (objectui @dda8f381) registers twenty types and `sharing_rule` is not one of them; what objectui does consume is the whole shape, on the CREATE door only (`AUTHOR_SHAPE_ONLY_TYPES` — the EDIT door is deliberately ungated because a served body carries the `_diagnostics` decoration this `.strict()` schema rejects). The single non-`live` row is `type`, the `SharingRuleType` discriminator: one member, `criteria`, whose only reader is a defensive `=== 'owner'` comparison that is unreachable for every value the schema admits. `planned` on the `action.operation` precedent (a one-member discriminator held `planned` until a runtime half dispatched on it, #15080), and deliberately NOT an enforce-or-remove candidate: the key is required, so removing it would break every authored rule to delete nothing. | | connector | seeded 2026-09-17 (#18582) — the second of the three `PENDING_GOVERNANCE` debts #18133 declared, paid in the same diff as `analytics_cube`, which empties that map. Not a registered kind: bound in `UNREGISTERED_KIND_SCHEMAS` (#6245) and reached through `getMetadataTypeSchema`'s unregistered-kind fallback. **What the walk actually resolves, measured:** the binding names `DeclarativeConnectorEntrySchema`. ⚠️ The MECHANISM changed with the `connectionTimeoutMs` retirement and the prior sentence here is corrected rather than carried: that schema USED TO BE `ConnectorSchema.superRefine(...)`, a Zod 4 check attached to the same object def, and the key-set conclusion used to rest on that attachment. It is now a `z.preprocess` PIPE — both published carriers wrap one shared private `ConnectorBaseSchema` in the ADR-0049 retired-default residue stage, the entry schema adding the ADR-0097 cross-field rules on the base before wrapping, so the two are SIBLINGS rather than parent and child, and what preserves the walked shape is the pipe's read-through `shape`, NOT a `superRefine` attachment. The CONCLUSION is unchanged and re-measured on the built entry rather than inherited: both carriers expose 30 keys and the key sets are byte-identical, with no entry-only and no base-only key. The gate cannot tell the two schemas apart; what the entry schema buys is REFUSALS, invisible to the walk and visible only in the three rows where they are the whole verdict. **ONE SCHEMA, TWO DOORS** is the shape fact behind the 29/1/30 split (live/planned/dead; counts read from the generated `state-counts/connector.md` shard, never hand-kept here): the ledger's denominator entry exists for the AUTHORING doors (`defineStack({ connectors })`, `PUT /meta/connector/:name`), while the same `ConnectorSchema` is what `AutomationEngine.registerConnector` parses for a def a PLUGIN or an ADR-0097 provider factory builds in code — so a key can have a real consumer and still do nothing when a metadata author writes it. The keys an authored entry can reach are exactly the author-supplied `ConnectorProviderContext` fields plus `provider` and `enabled` — `name` is itself one of those fields (the former "plus `name`" tail double-counted it), `loadPackageFile` is host-injected rather than authored, and `provider` selects the factory without ever reaching the context; `type` and `icon` reach that context and are dropped by all three shipped factories, and each says so on its own row. `authentication` is the ledger's `planned`, and ⛔ NOT "refused outright" — the former tail here said exactly that and all three instruments contradict it, including the one it cites: the KEY is ACCEPTED (`connector.zod.ts` declares `authentication: ConnectorAuthConfigSchema.optional().default({ type: 'none' })`, and the accepted value does nothing); what #7990 refuses is a non-`none` VALUE (`if (entry.authentication && entry.authentication.type !== 'none')`, whose own message prescribes "drop `authentication` (or set `{ type: 'none' }`)"); and ADR-0097 §3, titled "Credentials are references", rejects **inline secrets** in stack metadata, not the key. Accepted-and-ignored, plus a loud refusal of every value but `{ type: 'none' }`, is exactly the basis of the `planned` verdict — which the row itself already stated ("the accepted value does nothing"), so the summary, not the row, was the wrong half. The 30 `dead`, re-measured at this head and partitioned so every row is counted exactly once: two declared subsystems with no engine — `syncConfig` (8), `fieldMappings` (7) — plus `triggers` (6, and the schema's own docblock says so: #3197), `metadata`, `actions.description`/`.outputSchema`, and the six top-level `retiredKey` tombstones `rateLimitConfig`, `errorMapping`, `connectionTimeoutMs`, `health`, `status` and `webhooks`. That sums to 30, the dead count the generated `state-counts/connector.md` shard carries. ⚠️ It was 44 until the connector resilience family was retired (ADR-0049): `health` counted 15 drilled rows (both sub-blocks plus the `monitoringWindow` tombstone) and is now ONE leaf tombstone row — the gate refuses `children` under a property that is no longer a container — and `webhooks` left the undrilled baseline for the same reason; `status` and `webhooks` stayed one row each and changed only from dead-awaiting-a-decision to dead-and-tombstoned. ⚠️ `retryConfig` IS NO LONGER IN THIS LIST: all eight of its sub-keys went `live` when #18975 made the declared policy execute at the one platform fetch site, which is the same measurement the falsification note at the end of this row records — so a reader who still finds "`retryConfig` (8)" among the dead is reading a stale copy. ⚠️ Nor is it "the two timeouts" any more: `requestTimeoutMs` is `live` (it becomes `resilientFetch`'s per-attempt deadline) and `connectionTimeoutMs` is the retired tombstone named above. ⭐ EIGHT rows in this ledger are `retiredKey` tombstones that keep their rows because the key stays in the walked shape (the `rls.priority` precedent) — `rateLimitConfig`, `errorMapping`, `connectionTimeoutMs`, `health`, `status`, `webhooks`, `fieldMappings.transform` and `triggers.interval` — but ⛔ that eight is NOT a separate addend: the first six ARE the top-level tombstones counted above and the last two are already inside the `fieldMappings` and `triggers` counts, which is exactly the double-count that made the previous "and four `retiredKey` tombstones" tail drift. (`health.circuitBreaker.monitoringWindow` was the ninth until its block left whole with `health`.) Count them by name, never by adding the tail. **A prior in-repo claim is recorded here with its DIRECTION measured rather than remembered, because this row's job is the history of how the type got here**: the conversion registry's note inside `connector-rate-limit-config-removed`'s fixture reads "`retryConfig` and the timeouts beside it are untouched by THIS conversion — a statement about its scope, not a liveness verdict. They are not live: declared, defaulted and documented, and read by nothing." ⚠️ It asserts they are NOT live, and it scopes "untouched" to that one conversion. The former tail here quoted it as asserting the OPPOSITE ("they are live") and called it false when seeded — an inversion that turned this whole passage upside down, and it is corrected rather than carried. Measured direction: the note was TRUE when this ledger was seeded (2026-09-17) and is STALE now, #18975 having made the declared policy execute at the one platform fetch site (`connectorFetchOptions` → `resilientFetch`), so `retryConfig`'s eight sub-keys are `live` on their own rows and `requestTimeoutMs` is `live` beside them; only `connectionTimeoutMs` still answers to it, as the retired tombstone. ⛔ The stale comment is not rewritten from here — it is #19729's, as a dated note beside it — and it is not a line this PR's diff touches. ⚠️ The seeding note's supporting census — "the word does not occur outside `packages/spec` at all" — is FALSE at this head and is corrected rather than carried: `git grep -n retryConfig 14fdebd766 -- . ':!packages/spec'` returns 67 **matching lines** over 15 files — `git grep -o` on the same tree and pathspec returns 77 **occurrences**, and a line is not an occurrence, which is the trap a re-measurer falls into next (26 matching lines in the materializer `packages/services/service-automation/src/plugin.ts` and its materialization test, 22 across `connector-rest` and `connector-openapi` — providers, connectors and their tests — 13 in five `.changeset` fragments, and 6 on two `content/docs` pages). ⛔ Re-read that as the standing lesson of this row: a census is a count plus the tree it was taken against, and a bare "does not occur" with no commit behind it is the shape that rots first. The timeouts half is settled on its own rows: `requestTimeoutMs` is `live`, `connectionTimeoutMs` is retired | -| analytics_cube | seeded 2026-09-17 (#18582) — the third debt, paid in the same diff as `connector`. Not a registered kind either: bound in `UNREGISTERED_KIND_SCHEMAS` by #10194 and reached through the same unregistered-kind fallback. **ONE Cube shape, THREE producers, one registry** is what decides every row: `cube-registry.ts` names them itself — authored cubes (`analyticsCubes[]` / `defineCube()`, threaded by the CLI into `AnalyticsServiceConfig.cubes`), COMPILED DATASETS (ADR-0021, where `dataset-compiler` mints a Cube), and ad-hoc query inference. Only the first is the authoring door governed here, so a key whose only reader sits on the compiled-dataset path is not live for an authored cube however busy that reader is — the #4837 producer rule on a shape with three producers. That is `dimensions.granularities` (read by `dataset-executor#granularityOf`, whose argument is a `CompiledDataset` an authored cube never becomes) and `measures.format` (written by the compiler, threaded to the wire from the DATASET measure instead). The query path is genuinely live: `sql` is the FROM table AND the object whose RLS read scope is injected, `measures.type` picks the aggregate, `measures.sql`/`dimensions.sql` the column, `joins[].name` the joined table. The 9 `dead` are the caching block (`refreshKey.every`/`.sql` — no refresh scheduler exists anywhere), the three `description`s, `measures.format`, `dimensions.granularities`, and the inner `name` on each of `measures`/`dimensions`, where the record KEY is the identity — RETIRED by #20300 (ADR-0049 enforce-or-remove) as `retiredKey()` tombstones on the member `strictObject`s, so those two rows STAY `dead` (the tombstone keeps the key in the walked shape) and the count does not move. **#20282** flips the tenth, the visibility flag `public`, `dead` → `live` 2026-09-27: seeded as a knob that was never wired (three internal mints wrote `false`, nothing read it), it is now read by `service-analytics`' `cube-visibility.ts#isCubePublic` — `getMeta` omits a hidden cube and `query()` / `generateSql()` refuse it — in the same change that moved its default from `false` to the Cube.dev `true`, since enforcing the old default would have hidden every authored cube. It was 12 until #18612 RETIRED `joins[].relationship` and the REQUIRED `joins[].sql` (ADR-0049 enforce-or-remove, maintainer-ruled batch #154): the ON clause is SYNTHESISED as an FK equality and the authored one was never consulted, so a declared join condition came back REPLACED under a 200. `CubeJoinSchema` is a `strictObject`, so the route was strict deletion plus a `guidance` prescription and the two rows left this ledger with the keys — not the `retiredKey()` route, which keeps the row. **#10238 is not prejudged**: whether cube authoring is live end to end is still its own measurement — this ledger answers the per-key question only | +| analytics_cube | seeded 2026-09-17 (#18582) — the third debt, paid in the same diff as `connector`. Not a registered kind either: bound in `UNREGISTERED_KIND_SCHEMAS` by #10194 and reached through the same unregistered-kind fallback. **ONE Cube shape, THREE producers, one registry** is what decides every row: `cube-registry.ts` names them itself — authored cubes (`analyticsCubes[]` / `defineCube()`, threaded by the CLI into `AnalyticsServiceConfig.cubes`), COMPILED DATASETS (ADR-0021, where `dataset-compiler` mints a Cube), and ad-hoc query inference. Only the first is the authoring door governed here, so a key whose only reader sits on the compiled-dataset path is not live for an authored cube however busy that reader is — the #4837 producer rule on a shape with three producers. That kept `dimensions.granularities` (read only by `dataset-executor#granularityOf`, whose argument is a `CompiledDataset` an authored cube never becomes) and `measures.format` (written by the compiler, threaded to the wire from the DATASET measure instead) `dead` until **#20282**'s second stage (2026-09-29) read both on the query doors off whichever cube answers the name: `analytics-service#withDeclaredMeasureFormats` describes each measure column's `fields[].format`, and `#withDeclaredGranularityDefaults` buckets a grouped time dimension at the default `dataset-executor#declaredDefaultGranularity` reads — the one reading (a single-entry list) the dataset path's `granularityOf` now shares. The query path is genuinely live: `sql` is the FROM table AND the object whose RLS read scope is injected, `measures.type` picks the aggregate, `measures.sql`/`dimensions.sql` the column, `joins[].name` the joined table. The 7 `dead` are the caching block (`refreshKey.every`/`.sql` — no refresh scheduler, pre-aggregation or analytics result cache exists anywhere; re-measured 2026-09-29), the three `description`s, and the inner `name` on each of `measures`/`dimensions`, where the record KEY is the identity — RETIRED by #20300 (ADR-0049 enforce-or-remove) as `retiredKey()` tombstones on the member `strictObject`s, so those two rows STAY `dead` (the tombstone keeps the key in the walked shape) and the count does not move. **#20282** flips the tenth, the visibility flag `public`, `dead` → `live` 2026-09-27: seeded as a knob that was never wired (three internal mints wrote `false`, nothing read it), it is now read by `service-analytics`' `cube-visibility.ts#isCubePublic` — `getMeta` omits a hidden cube and `query()` / `generateSql()` refuse it — in the same change that moved its default from `false` to the Cube.dev `true`, since enforcing the old default would have hidden every authored cube. It was 12 until #18612 RETIRED `joins[].relationship` and the REQUIRED `joins[].sql` (ADR-0049 enforce-or-remove, maintainer-ruled batch #154): the ON clause is SYNTHESISED as an FK equality and the authored one was never consulted, so a declared join condition came back REPLACED under a 200. `CubeJoinSchema` is a `strictObject`, so the route was strict deletion plus a `guidance` prescription and the two rows left this ledger with the keys — not the `retiredKey()` route, which keeps the row. **#10238 is not prejudged**: whether cube authoring is live end to end is still its own measurement — this ledger answers the per-key question only | The `dead` set across types is the enforce-or-remove worklist (ADR-0049); every misleading entry carries `authorWarn` so authors hear about it at compile time diff --git a/packages/spec/liveness/analytics_cube.json b/packages/spec/liveness/analytics_cube.json index e914671142a..467a7a845ec 100644 --- a/packages/spec/liveness/analytics_cube.json +++ b/packages/spec/liveness/analytics_cube.json @@ -1,6 +1,6 @@ { "type": "analytics_cube", - "_note": "CubeSchema (packages/spec/src/data/analytics.zod.ts). Seeded 2026-09-17 (#18582): the LAST of the three PENDING_GOVERNANCE debts #18133 declared when PR #18581 widened the governance denominator from the registered kinds to `authorableTypes()`; `sharing_rule` was paid first (PR #18587) and `connector` is paid in the same diff as this file, which empties the map. NOT a registered metadata KIND — it is bound in `UNREGISTERED_KIND_SCHEMAS` (#10194) and reaches this walk through `getMetadataTypeSchema`'s unregistered-kind fallback, so the ledger governs it while `listMetadataTypeSchemaTypes()` still does not enumerate it. THE SHAPE FACT THAT DECIDES EVERY ROW BELOW: one Cube shape, THREE producers, one registry. `packages/services/service-analytics/src/cube-registry.ts` names them itself — (1) authored cubes (`defineStack({ analyticsCubes })` / `defineCube()`), threaded by the CLI into `AnalyticsServiceConfig.cubes` and registered by `registerAll`; (2) COMPILED DATASETS (ADR-0021), where `dataset-compiler.ts` MINTS a Cube from a `dataset` document; (3) ad-hoc query inference (`inferCubeFromQuery`). Only (1) is the authoring door this ledger governs, so a key whose only reader sits on path (2) is NOT live here however busy that reader is — that is the #4837 producer rule applied to a shape with three producers, and it is what decides `dimensions.granularities` and `measures.format` below. Every `live` row therefore carries a `producer` naming the CLI threading site: a consumer citation alone would be the `seed.env` shape, where the mechanism was right and nobody supplied the input. #10238 IS NOT PREJUDGED: the PENDING_GOVERNANCE row this file discharges said whether cube authoring is live end-to-end is its own measurement and 'this row does not prejudge it'. This ledger does not answer that question either — it answers the per-key one (who reads this key?), and the answers below are mixed: the query path (`sql`, `measures.sql`/`.type`, `dimensions.sql`/`.type`, `joins.name`) is genuinely consumed, and so is the visibility key `public` (enforced at discovery and at every query door since 2026-09-27 — its row), while the caching and display-annotation keys are not. PREVIEW READ POINTS ENUMERATED (the #7131 mechanical rule, objectui @dda8f3815): `registerBuiltinPreviews()` in packages/app-shell/src/views/metadata-admin/previews/index.ts registers nineteen types and `analytics_cube` is NOT one of them — this type has no registered metadata-admin preview. Recorded rather than skipped, because 'the type has no registered preview' is the sentence a later sweep needs. What objectui DOES consume is the whole SHAPE: `clientValidation.ts` maps `analytics_cube` to `CubeSchema` itself, and unlike `sharing_rule` it is absent from `AUTHOR_SHAPE_ONLY_TYPES`, so both the CREATE and the EDIT door in metadata-admin refuse a cube this schema rejects. ADR-0054: no row here carries a `proof`, and none is owed — the `analytics` high-risk class binds `dataset/dimensions.dateGranularity` (the dataset door), not this type.", + "_note": "CubeSchema (packages/spec/src/data/analytics.zod.ts). Seeded 2026-09-17 (#18582): the LAST of the three PENDING_GOVERNANCE debts #18133 declared when PR #18581 widened the governance denominator from the registered kinds to `authorableTypes()`; `sharing_rule` was paid first (PR #18587) and `connector` is paid in the same diff as this file, which empties the map. NOT a registered metadata KIND — it is bound in `UNREGISTERED_KIND_SCHEMAS` (#10194) and reaches this walk through `getMetadataTypeSchema`'s unregistered-kind fallback, so the ledger governs it while `listMetadataTypeSchemaTypes()` still does not enumerate it. THE SHAPE FACT THAT DECIDES EVERY ROW BELOW: one Cube shape, THREE producers, one registry. `packages/services/service-analytics/src/cube-registry.ts` names them itself — (1) authored cubes (`defineStack({ analyticsCubes })` / `defineCube()`), threaded by the CLI into `AnalyticsServiceConfig.cubes` and registered by `registerAll`; (2) COMPILED DATASETS (ADR-0021), where `dataset-compiler.ts` MINTS a Cube from a `dataset` document; (3) ad-hoc query inference (`inferCubeFromQuery`). Only (1) is the authoring door this ledger governs, so a key whose only reader sits on path (2) is NOT live here however busy that reader is — that is the #4837 producer rule applied to a shape with three producers, and it is what kept `dimensions.granularities` and `measures.format` dead until 2026-09-29, when the query doors began reading both off whichever cube answers the name (their rows). Every `live` row therefore carries a `producer` naming the CLI threading site: a consumer citation alone would be the `seed.env` shape, where the mechanism was right and nobody supplied the input. #10238 IS NOT PREJUDGED: the PENDING_GOVERNANCE row this file discharges said whether cube authoring is live end-to-end is its own measurement and 'this row does not prejudge it'. This ledger does not answer that question either — it answers the per-key one (who reads this key?), and the answers below are mixed: the query path (`sql`, `measures.sql`/`.type`, `dimensions.sql`/`.type`, `joins.name`) is genuinely consumed, and so is the visibility key `public` (enforced at discovery and at every query door since 2026-09-27 — its row), and so are the measure display `format` and the dimension default bucket `granularities` (read on the query doors since 2026-09-29 — their rows), while the caching block and the three `description` annotations are not. PREVIEW READ POINTS ENUMERATED (the #7131 mechanical rule, objectui @dda8f3815): `registerBuiltinPreviews()` in packages/app-shell/src/views/metadata-admin/previews/index.ts registers nineteen types and `analytics_cube` is NOT one of them — this type has no registered metadata-admin preview. Recorded rather than skipped, because 'the type has no registered preview' is the sentence a later sweep needs. What objectui DOES consume is the whole SHAPE: `clientValidation.ts` maps `analytics_cube` to `CubeSchema` itself, and unlike `sharing_rule` it is absent from `AUTHOR_SHAPE_ONLY_TYPES`, so both the CREATE and the EDIT door in metadata-admin refuse a cube this schema rejects. ADR-0054: no row here carries a `proof`, and none is owed — the `analytics` high-risk class binds `dataset/dimensions.dateGranularity` (the dataset door), not this type.", "props": { "name": { "status": "live", @@ -62,9 +62,11 @@ "note": "REQUIRED, and the one place a cube author writes physical SQL. `'*'` is special-cased to a bare `*` (the COUNT(*) form); anything with a dot is either a relationship path or a SQL expression, and `IDENTIFIER_PATH` is what tells them apart (#4157)." }, "format": { - "status": "dead", - "verifiedAt": "2026-09-17", - "note": "Written by the DATASET compiler, read by nobody — the three-producer fact in the file note decides this row. `dataset-compiler.ts` copies a dataset measure's own `format` onto the minted cube metric (`if (typeof m.format === 'string') metric.format = m.format`), and the value a caller actually receives is threaded from the DATASET measure, not from the cube: `analytics-service.ts` enriches the result's `fields[]` with `if (f.format == null && m.format) f.format = m.format`, where `m` is `dataset.measures[…]`. So the live key is `dataset.measures[].format` (governed in dataset.json), and the cube metric's own `format` is a carbon copy nothing reads back. On the AUTHORING door governed here there is no dataset to copy from, so a hand-written `format:` is inert. ⛔ Retirement is NOT the remedy: the key is the dataset compiler's own output slot on a shared shape, so deleting it from `MetricSchema` would break a live internal write; what is dead is authoring it by hand." + "status": "live", + "verifiedAt": "2026-09-29", + "evidence": "packages/services/service-analytics/src/analytics-service.ts#withDeclaredMeasureFormats — every measure column a query names leaves `query()` (`POST /api/v1/analytics/query`) carrying the `format` its cube measure declares, as `fields[].format`, the presentation slot `AnalyticsResult` documents. It runs at the one seam every strategy's result leaves through (`queryIn`, beside the SQL-echo gate), so the column is described whichever of NativeSQL, ObjectQL or a delegated fallback answered, and on the dataset door's queries too. Pinned per strategy, each case against a compiled-dataset control, in packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts, and over the route in packages/runtime/src/analytics-authored-cube-format-granularity.test.ts.", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#registerAll (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "note": "Dead until 2026-09-29: the DATASET compiler wrote it and nothing read it back, because the value a caller received was threaded from the DATASET measure (`enrichResultColumns`), and an authored cube has no dataset — so a hand-written `format:` reached no reader and its column went out with `name` and `type` only. It is now read off the cube, for every cube that answers the name; on the dataset path the value read is the compiler's copy of the dataset measure's own `format`, the one `enrichResultColumns` writes anyway, so that path is unchanged. NOT on discovery: `GET /api/v1/analytics/meta` keeps the `CubeMeta` projection `{ name, type, title }` (the narrowing recorded on `AnalyticsMetadataResponseSchema`, #6442), and content/docs/api/data-api.mdx sends a client to `fields[]` for `format`. The slot's vocabulary is the one `fields[].format` and the dataset measure's `format` both document, a numeral pattern (\"$0,0\", \"0.0%\"); a named style is relayed verbatim and is not a pattern. ⛔ Retirement was never the remedy: the key is also the dataset compiler's output slot on the shared shape." } } }, @@ -102,9 +104,11 @@ "note": "REQUIRED. Same dotted-path / expression split as `measures.sql`." }, "granularities": { - "status": "dead", - "verifiedAt": "2026-09-17", - "note": "There IS a reader and an AUTHORED cube cannot reach it — the distinction the file note's three-producer fact exists to make. The one read is `packages/services/service-analytics/src/dataset-executor.ts#granularityOf` (`const cd = compiled.cube.dimensions[name]; … cd.granularities?.length === 1 ? String(cd.granularities[0]) : undefined`), whose argument is a `CompiledDataset` — a shape only `compileDataset()` produces, from a `dataset` document. The writers are the same two non-authoring producers: `dataset-compiler.ts` (`dim.granularities = d.dateGranularity ? [d.dateGranularity] : ['day','week','month','quarter','year']`), `cube-registry.ts#inferFromObject` and `analytics-service.ts#inferCubeFromQuery` (the 5-entry 'all granularities' list). A cube registered through `AnalyticsServiceConfig.cubes` never becomes a `CompiledDataset`, so a hand-authored `granularities:` is read by nothing. The AUTHORABLE key that really drives bucketing is `dataset.dimensions[].dateGranularity` — governed in dataset.json and the ADR-0054 `analytics` high-risk class's bound property. ⛔ ADR-0049 RETIREMENT IS NOT THE REMEDY, and this row says so explicitly so the enforce-or-remove channel does not act on the word `dead`: the key is the dataset compiler's own output channel on a shared shape, so deleting it from `DimensionSchema` would break a live internal path. What is dead is AUTHORING it on a hand-written cube." + "status": "live", + "verifiedAt": "2026-09-29", + "evidence": "packages/services/service-analytics/src/analytics-service.ts#withDeclaredGranularityDefaults — on `query()` (`POST /api/v1/analytics/query`) and `generateSql()` (`POST /api/v1/analytics/sql`), before the source-field gates and strategy selection run, a time dimension the query GROUPS BY with no stated granularity is bucketed at the default its cube dimension declares; packages/services/service-analytics/src/dataset-executor.ts#declaredDefaultGranularity is that default — the one reading of this key, a single-entry list — shared with the compiled-dataset path's `granularityOf`, so both producers' cubes are read by one rule. Pinned against a compiled-dataset control in packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts, and over both routes in packages/runtime/src/analytics-authored-cube-format-granularity.test.ts.", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#registerAll (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "note": "Dead until 2026-09-29: the one read (`granularityOf`) took a `CompiledDataset`, which an authored cube never becomes, so grouping by an authored time dimension grouped raw timestamps whatever it declared. What the key does now is exactly the compiled-dataset path's reading: a SINGLE-entry list is the dimension's default bucket; a stated `timeDimensions[].granularity` always wins; a multi-entry list (the compiler's five-entry 'all intervals' shape included) states no default; an entry carrying only a `dateRange` for a dimension the query does not group stays a filter. ⚠️ What it does NOT do: refuse a requested granularity the list does not name — the dataset path compares against no list, so neither does this — and whether a multi-entry list should narrow the accept set (the 'Supported Granularities' reading of the declaration) is an open question on #20282, not decided by this row. A declared default routes a grouped query to the engine path, because NativeSQL declines any bucketed query, and the engine path refuses a custom-SQL measure — the same answer a caller who states that granularity by hand gets. The dataset-side authoring key is still `dataset.dimensions[].dateGranularity` (dataset.json), which the compiler lowers into this slot. ⛔ Retirement was never the remedy." } } }, diff --git a/packages/spec/liveness/state-counts/analytics_cube.md b/packages/spec/liveness/state-counts/analytics_cube.md index 7825385730f..a1ab5d5ffcb 100644 --- a/packages/spec/liveness/state-counts/analytics_cube.md +++ b/packages/spec/liveness/state-counts/analytics_cube.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `analytics_cube` | 18 | 0 | 0 | 9 | 0 | 27 | +| `analytics_cube` | 20 | 0 | 0 | 7 | 0 | 27 | From d665865d5b78056cef128f29136d658834856c3f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 10:27:04 +0000 Subject: [PATCH 5/8] feat(spec): MetricSchema.format and DimensionSchema.granularities describe what the analytics service does with them Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude --- ...tics-cube-format-granularities-enforced.md | 2 +- content/docs/references/data/analytics.mdx | 8 +++--- packages/spec/src/data/analytics.zod.ts | 28 ++++++++++++++++--- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/.changeset/20282-analytics-cube-format-granularities-enforced.md b/.changeset/20282-analytics-cube-format-granularities-enforced.md index a028a4fae16..245c66a60df 100644 --- a/.changeset/20282-analytics-cube-format-granularities-enforced.md +++ b/.changeset/20282-analytics-cube-format-granularities-enforced.md @@ -23,4 +23,4 @@ What to expect after upgrading: - **A cube time dimension that declares several, or none**, behaves exactly as before. - **Compiled datasets** (`POST /api/v1/analytics/dataset/query`) answer exactly as before: the value read off their cube is the one the dataset door already used. -In `@objectstack/spec`, the liveness ledger rows `analytics_cube.measures.format` and `analytics_cube.dimensions.granularities` move from `dead` to `live`, citing the new readers. +In `@objectstack/spec`, `MetricSchema.format` and `DimensionSchema.granularities` now carry descriptions that state what the analytics service does with them (the metric's example values move from the names "currency" / "percent" to numeral patterns, the vocabulary the `fields[].format` slot documents), and the liveness ledger rows `analytics_cube.measures.format` and `analytics_cube.dimensions.granularities` move from `dead` to `live`, citing the new readers. diff --git a/content/docs/references/data/analytics.mdx b/content/docs/references/data/analytics.mdx index 1efc5e7e1f7..356e41dfbe0 100644 --- a/content/docs/references/data/analytics.mdx +++ b/content/docs/references/data/analytics.mdx @@ -148,7 +148,7 @@ Type: `[string, string]` | **description** | `string` | optional | | | **type** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'number' \| 'string' \| 'boolean'>` | ✅ | | | **sql** | `string` | ✅ | SQL expression or field reference | -| **format** | `string` | optional | | +| **format** | `string` | optional | Display format for this measure's result column: a numeral pattern such as "$0,0.00" or "0.0%". Relayed verbatim as fields[].format on POST /analytics/query results; not published by GET /analytics/meta. | ### Nested Shape: `Cube.dimensions[string]` @@ -159,7 +159,7 @@ Type: `[string, string]` | **description** | `string` | optional | | | **type** | `Enum<'string' \| 'number' \| 'boolean' \| 'time' \| 'geo'>` | ✅ | | | **sql** | `string` | ✅ | SQL expression or column reference | -| **granularities** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>[]` | optional | | +| **granularities** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>[]` | optional | For a time dimension. A single interval is its default bucket: a query that groups by this dimension without stating a granularity is bucketed at it. Two or more intervals state no default. A granularity the query states always wins, listed or not. | ### Nested Shape: `Cube.joins[string]` @@ -199,7 +199,7 @@ Type: `[string, string]` | **description** | `string` | optional | | | **type** | `Enum<'string' \| 'number' \| 'boolean' \| 'time' \| 'geo'>` | ✅ | | | **sql** | `string` | ✅ | SQL expression or column reference | -| **granularities** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>[]` | optional | | +| **granularities** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>[]` | optional | For a time dimension. A single interval is its default bucket: a query that groups by this dimension without stating a granularity is bucketed at it. Two or more intervals state no default. A granularity the query states always wins, listed or not. | --- @@ -228,7 +228,7 @@ Type: `[string, string]` | **description** | `string` | optional | | | **type** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'number' \| 'string' \| 'boolean'>` | ✅ | | | **sql** | `string` | ✅ | SQL expression or field reference | -| **format** | `string` | optional | | +| **format** | `string` | optional | Display format for this measure's result column: a numeral pattern such as "$0,0.00" or "0.0%". Relayed verbatim as fields[].format on POST /analytics/query results; not published by GET /analytics/meta. | --- diff --git a/packages/spec/src/data/analytics.zod.ts b/packages/spec/src/data/analytics.zod.ts index f644024f0ef..1bb5a4d8349 100644 --- a/packages/spec/src/data/analytics.zod.ts +++ b/packages/spec/src/data/analytics.zod.ts @@ -256,8 +256,17 @@ export const MetricSchema = lazySchema(() => strictObject( // re-targeted per driver dialect, or walked by the lint rules // (`packages/lint/src/filter-walk.ts` deliberately never enumerated it). - /** Format for display (e.g. "currency", "percent") */ - format: z.string().optional(), + /** + * Display format for this measure's result column. The analytics service + * relays it as `fields[].format` on `POST /analytics/query` results, the + * slot the dataset door fills from a dataset measure's own `format`, so its + * vocabulary is that slot's: a numeral pattern. It is not part of the + * `GET /analytics/meta` projection. + */ + format: z.string().optional().describe( + 'Display format for this measure\'s result column: a numeral pattern such as "$0,0.00" or "0.0%". ' + + 'Relayed verbatim as fields[].format on POST /analytics/query results; not published by GET /analytics/meta.', + ), }, )); @@ -295,8 +304,19 @@ export const DimensionSchema = lazySchema(() => strictObject( /** Source Column */ sql: z.string().describe('SQL expression or column reference'), - /** For Time Dimensions: Supported Granularities */ - granularities: z.array(TimeUpdateInterval).optional(), + /** + * For a time dimension: the intervals it is bucketed at. A SINGLE interval + * is the dimension's default bucket — a query that groups by it without + * stating a granularity is bucketed at that interval, on + * `POST /analytics/query` and `POST /analytics/sql` alike, the reading a + * compiled dataset's `dateGranularity` gets. Two or more state no default. + * A granularity the query states always wins, listed or not. + */ + granularities: z.array(TimeUpdateInterval).optional().describe( + 'For a time dimension. A single interval is its default bucket: a query that groups by this dimension ' + + 'without stating a granularity is bucketed at it. Two or more intervals state no default. A granularity ' + + 'the query states always wins, listed or not.', + ), }, )); From 257ab1bc9287fb746279ea387a631eb389fb1a67 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 11:58:14 +0000 Subject: [PATCH 6/8] feat(spec): register the ADR-0087 semantic entry analytics-cube-single-granularity-default-enforced Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude --- ...tics-cube-format-granularities-enforced.md | 2 +- ...ube-single-granularity-default-enforced.ts | 51 +++++++++++++++++++ packages/spec/src/migrations/registry.ts | 47 +++++++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 packages/spec/src/migrations/entries/semantic/18.analytics-cube-single-granularity-default-enforced.ts diff --git a/.changeset/20282-analytics-cube-format-granularities-enforced.md b/.changeset/20282-analytics-cube-format-granularities-enforced.md index 245c66a60df..8bc7b520ba5 100644 --- a/.changeset/20282-analytics-cube-format-granularities-enforced.md +++ b/.changeset/20282-analytics-cube-format-granularities-enforced.md @@ -7,7 +7,7 @@ An authored analytics cube's measure `format` and time-dimension `granularities` Clause-②: yes (narrowing) - + **BREAKING**: this narrows what `POST /api/v1/analytics/query` and `POST /api/v1/analytics/sql` answer for one class of request. When an authored cube's time dimension declares exactly one granularity, a query that groups by that dimension without stating a granularity is now bucketed at the declared one — and a bucketed query is served by the engine aggregate path, which refuses a custom-SQL measure (a measure of type `number`, `string` or `boolean` whose `sql` is an expression) with `400 INVALID_FIELD`. Such a query was answered before this change, with one group per distinct timestamp; it is now refused, exactly as it already was when the caller stated that granularity by hand. A host that overrides `queryCapabilities` to offer raw SQL with no engine aggregate bridge (the plugin's default wires both) now answers such a query "No strategy can handle query" instead of grouping raw timestamps. The remedy: query the custom-SQL measure without grouping by that dimension, or, if the dimension is not meant to have one default bucket, declare the granularities it offers as a list of two or more (or omit the key). It ships as `minor` under the launch-window convention; the widening half is two authored keys taking effect. diff --git a/packages/spec/src/migrations/entries/semantic/18.analytics-cube-single-granularity-default-enforced.ts b/packages/spec/src/migrations/entries/semantic/18.analytics-cube-single-granularity-default-enforced.ts new file mode 100644 index 00000000000..828d7b20a9d --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.analytics-cube-single-granularity-default-enforced.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// An inert key made real, and the one request class that goes from answered to +// refused because of it — the shape of +// `analytics-cube-public-default-visible-enforced`. There is no D2 conversion: +// no spelling moves, and whether a one-interval list means "bucket by this by +// default" or "the one interval I happened to list" is the author's intent, +// which the chain cannot read. The protocol-18 conversion +// `cube-sub-day-granularities-removed` is why the entry is owed even to an +// author who never wrote a one-interval list. No backticks in `surface`: the +// upgrade guide renders it inside a code span and a table cell. +export const entry: SemanticMigration = { + id: 'analytics-cube-single-granularity-default-enforced', + surface: + 'data.Cube.dimensions.granularities — an analytics cube time dimension whose granularities ' + + 'list holds exactly one interval, whether an author wrote it that way or the protocol 18 ' + + 'conversion cube-sub-day-granularities-removed reduced a longer list to it', + replacement: + 'nothing, when that one interval is the bucket the dimension should be grouped at by default. ' + + 'When it is not, list every interval the dimension serves (two or more state no default) or ' + + 'omit the key. A dashboard or report that charts a custom-SQL measure over such a dimension ' + + 'either stops grouping by it or groups by a dimension that declares no single interval', + reason: + 'An inert key made real. A cube time dimension\'s `granularities` was read only for a cube the ' + + 'dataset compiler minted, where a one-interval list is the dataset\'s default bucket. A cube ' + + 'authored with `defineCube()` or `defineStack({ analyticsCubes })` never reached that reader, ' + + 'so grouping by its time dimension grouped raw timestamps, one group per distinct instant, ' + + 'whatever the list said. The analytics service now reads every cube by the compiled-dataset ' + + 'rule: on `/analytics/query` and on the `/analytics/sql` dry run, a time dimension the query ' + + 'groups by without stating a granularity is bucketed at the one interval its list declares. ' + + 'A granularity the query states still wins, one the list does not name is not refused, and a ' + + 'list of two or more states no default. Two holdings change on upgrade. A query grouping by ' + + 'such a dimension answers one row per bucket where it answered one row per timestamp. And a ' + + 'bucketed query is served by the engine aggregate path, which refuses a custom-SQL measure — a ' + + '`number`, `string` or `boolean` measure whose `sql` is an expression — with 400 ' + + '`INVALID_FIELD`, the same refusal, byte for byte, that the same query already got with that ' + + 'granularity stated by hand; so such a measure grouped by such a dimension goes from answered ' + + 'to refused. The protocol-18 conversion `cube-sub-day-granularities-removed` strips the retired ' + + 'sub-day intervals from every authored and stored cube, so a dimension that offered one ' + + 'sub-day interval and one coarser interval now holds a one-interval list: a default bucket its ' + + 'author never wrote.', + acceptanceCriteria: + 'Every cube time dimension whose `granularities` lists exactly one interval is one you mean to ' + + 'bucket at that interval by default: a query that groups by it on `/analytics/query` answers ' + + 'one row per bucket, and `/analytics/sql` shows the bucketed statement. Every time dimension ' + + 'that should have no default lists two or more intervals or omits the key. No dashboard or ' + + 'report groups a custom-SQL measure by a one-interval dimension — or each one that did now ' + + 'groups by a dimension without a single interval.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 4f6018b0e3c..75bd6e653a4 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6416,6 +6416,53 @@ const step18: MigrationStep = { + 'naming it answers 404 `CUBE_NOT_FOUND`. Every compiled artifact in use was built by ' + '`os compile` from this release or later.', }, + // An inert key made real, and the one request class that goes from answered to + // refused because of it — the shape of + // `analytics-cube-public-default-visible-enforced`. There is no D2 conversion: + // no spelling moves, and whether a one-interval list means "bucket by this by + // default" or "the one interval I happened to list" is the author's intent, + // which the chain cannot read. The protocol-18 conversion + // `cube-sub-day-granularities-removed` is why the entry is owed even to an + // author who never wrote a one-interval list. No backticks in `surface`: the + // upgrade guide renders it inside a code span and a table cell. + { + id: 'analytics-cube-single-granularity-default-enforced', + surface: + 'data.Cube.dimensions.granularities — an analytics cube time dimension whose granularities ' + + 'list holds exactly one interval, whether an author wrote it that way or the protocol 18 ' + + 'conversion cube-sub-day-granularities-removed reduced a longer list to it', + replacement: + 'nothing, when that one interval is the bucket the dimension should be grouped at by default. ' + + 'When it is not, list every interval the dimension serves (two or more state no default) or ' + + 'omit the key. A dashboard or report that charts a custom-SQL measure over such a dimension ' + + 'either stops grouping by it or groups by a dimension that declares no single interval', + reason: + 'An inert key made real. A cube time dimension\'s `granularities` was read only for a cube the ' + + 'dataset compiler minted, where a one-interval list is the dataset\'s default bucket. A cube ' + + 'authored with `defineCube()` or `defineStack({ analyticsCubes })` never reached that reader, ' + + 'so grouping by its time dimension grouped raw timestamps, one group per distinct instant, ' + + 'whatever the list said. The analytics service now reads every cube by the compiled-dataset ' + + 'rule: on `/analytics/query` and on the `/analytics/sql` dry run, a time dimension the query ' + + 'groups by without stating a granularity is bucketed at the one interval its list declares. ' + + 'A granularity the query states still wins, one the list does not name is not refused, and a ' + + 'list of two or more states no default. Two holdings change on upgrade. A query grouping by ' + + 'such a dimension answers one row per bucket where it answered one row per timestamp. And a ' + + 'bucketed query is served by the engine aggregate path, which refuses a custom-SQL measure — a ' + + '`number`, `string` or `boolean` measure whose `sql` is an expression — with 400 ' + + '`INVALID_FIELD`, the same refusal, byte for byte, that the same query already got with that ' + + 'granularity stated by hand; so such a measure grouped by such a dimension goes from answered ' + + 'to refused. The protocol-18 conversion `cube-sub-day-granularities-removed` strips the retired ' + + 'sub-day intervals from every authored and stored cube, so a dimension that offered one ' + + 'sub-day interval and one coarser interval now holds a one-interval list: a default bucket its ' + + 'author never wrote.', + acceptanceCriteria: + 'Every cube time dimension whose `granularities` lists exactly one interval is one you mean to ' + + 'bucket at that interval by default: a query that groups by it on `/analytics/query` answers ' + + 'one row per bucket, and `/analytics/sql` shows the bucketed statement. Every time dimension ' + + 'that should have no default lists two or more intervals or omits the key. No dashboard or ' + + 'report groups a custom-SQL measure by a one-interval dimension — or each one that did now ' + + 'groups by a dimension without a single interval.', + }, { id: 'analytics-date-range-array-two-bounds-required', // No backticks in `surface` — build-upgrade-guide.ts renders it inside a From e1383be04d13833005e851770157374501dd8ad8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 15:12:17 +0000 Subject: [PATCH 7/8] fix(analytics): declare the whole engine-path refusal set a declared default bucket reaches; pin the joined-cube case Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude --- ...tics-cube-format-granularities-enforced.md | 2 +- .../cube-authored-format-granularity.test.ts | 66 +++++++++++++++++++ packages/spec/liveness/analytics_cube.json | 2 +- ...ube-single-granularity-default-enforced.ts | 37 +++++++---- packages/spec/src/migrations/registry.ts | 37 +++++++---- 5 files changed, 120 insertions(+), 24 deletions(-) diff --git a/.changeset/20282-analytics-cube-format-granularities-enforced.md b/.changeset/20282-analytics-cube-format-granularities-enforced.md index 8bc7b520ba5..2881db884c9 100644 --- a/.changeset/20282-analytics-cube-format-granularities-enforced.md +++ b/.changeset/20282-analytics-cube-format-granularities-enforced.md @@ -9,7 +9,7 @@ Clause-②: yes (narrowing) -**BREAKING**: this narrows what `POST /api/v1/analytics/query` and `POST /api/v1/analytics/sql` answer for one class of request. When an authored cube's time dimension declares exactly one granularity, a query that groups by that dimension without stating a granularity is now bucketed at the declared one — and a bucketed query is served by the engine aggregate path, which refuses a custom-SQL measure (a measure of type `number`, `string` or `boolean` whose `sql` is an expression) with `400 INVALID_FIELD`. Such a query was answered before this change, with one group per distinct timestamp; it is now refused, exactly as it already was when the caller stated that granularity by hand. A host that overrides `queryCapabilities` to offer raw SQL with no engine aggregate bridge (the plugin's default wires both) now answers such a query "No strategy can handle query" instead of grouping raw timestamps. The remedy: query the custom-SQL measure without grouping by that dimension, or, if the dimension is not meant to have one default bucket, declare the granularities it offers as a list of two or more (or omit the key). It ships as `minor` under the launch-window convention; the widening half is two authored keys taking effect. +**BREAKING**: this narrows what `POST /api/v1/analytics/query` and `POST /api/v1/analytics/sql` answer for one class of request. When an authored cube's time dimension declares exactly one granularity, a query that groups by that dimension without stating a granularity is now bucketed at the declared one. The raw-SQL path declines every bucketed query, so such a query now runs on the engine aggregate path, which answers `400 INVALID_FIELD` for every member it cannot evaluate: a custom-SQL measure (a measure of type `number`, `string` or `boolean` whose `sql` is an expression); and, on a cube whose members resolve through its `joins`, a measure or a `where` field over a joined object, a `timeDimensions` entry over a joined object (bucketed or a `dateRange` window, so grouping by a one-granularity time dimension over a joined object is refused too), a dimension that traverses more than one relationship, and an `avg` or `count_distinct` measure beside any dimension over a joined object. The raw-SQL path answers every one of these, with one group per distinct timestamp; each is now refused, exactly as it already was when the caller stated that granularity by hand. On a host that overrides `queryCapabilities` to offer raw SQL with no engine aggregate bridge (the plugin's default wires both), no strategy remains for a bucketed query, so every newly bucketed query, a plain `count` included, now answers "No strategy can handle query" instead of grouping raw timestamps. The remedy: run such a query without grouping by that dimension, or, if the dimension is not meant to have one default bucket, declare the granularities it offers as a list of two or more (or omit the key); on a raw-SQL-only host, add the engine aggregate bridge. It ships as `minor` under the launch-window convention; the widening half is two authored keys taking effect. Until this change both keys were read on the compiled-dataset path only. One cube shape has three producers — cubes authored with `defineCube()` / `defineStack({ analyticsCubes })`, cubes the dataset compiler mints, and cubes inferred for an ad-hoc query — and only a compiled dataset's cube reached the two readers: diff --git a/packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts b/packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts index f365ba98a37..8744471354b 100644 --- a/packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts +++ b/packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts @@ -321,4 +321,70 @@ describe('analytics_cube.dimensions.granularities — the declared single granul await service.query({ cube: 'orders', measures: ['done_rate'], dimensions: ['shipped_at'] }); expect(sqls).toHaveLength(1); }); + + it('DECLARED NARROWING, joined cube: a cross-object measure grouped by a declared-default dimension gets the refusal a stated granularity gets', async () => { + // The engine path's other refusals are reached by the same re-route: on a + // cube that declares `joins`, a member resolving through one (here a + // measure over `account.balance`) is refused by + // `ObjectQLStrategy.planCrossObject`, where the raw-SQL path joins it. + const joined: Cube = CubeSchema.parse({ + ...authored, + measures: { + ...authored.measures, + account_balance: { label: 'Account balance', type: 'sum', sql: 'account.balance' }, + }, + joins: { account: { name: 'crm_account' } }, + }); + const aggregated: string[] = []; + const sqls: string[] = []; + const service = new AnalyticsService({ + logger: silentLogger, + cubes: [joined], + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: true, inMemory: false }), + executeRawSql: async (_o, sql) => { + sqls.push(sql); + return []; + }, + executeAggregate: async (object) => { + aggregated.push(object); + return []; + }, + }); + const envelope = (e: any) => ({ + code: e?.code, + status: e?.status, + message: e?.message, + member: e?.member, + param: e?.param, + cube: e?.cube, + keys: e instanceof Error ? Object.keys(e).sort() : undefined, + }); + + const byDefault = await service + .query({ cube: 'orders', measures: ['account_balance'], dimensions: ['placed_at'] }) + .catch((e: unknown) => e); + const byHand = await service + .query({ + cube: 'orders', + measures: ['account_balance'], + dimensions: ['placed_at'], + timeDimensions: [{ dimension: 'placed_at', granularity: 'month' }], + }) + .catch((e: unknown) => e); + + expect({ code: envelope(byDefault).code, status: envelope(byDefault).status }).toEqual({ + code: 'INVALID_FIELD', + status: 400, + }); + // Not a new refusal: byte-equal to the one stating the granularity by hand already got. + expect(envelope(byDefault)).toEqual(envelope(byHand)); + expect(aggregated).toEqual([]); + expect(sqls).toEqual([]); + + // Control: the same cross-object measure grouped by a dimension that + // declares no single default is still answered, joined, on the raw-SQL path. + await service.query({ cube: 'orders', measures: ['account_balance'], dimensions: ['shipped_at'] }); + expect(sqls).toHaveLength(1); + expect(sqls[0]).toMatch(/JOIN/); + }); }); diff --git a/packages/spec/liveness/analytics_cube.json b/packages/spec/liveness/analytics_cube.json index 467a7a845ec..8acd30ae061 100644 --- a/packages/spec/liveness/analytics_cube.json +++ b/packages/spec/liveness/analytics_cube.json @@ -108,7 +108,7 @@ "verifiedAt": "2026-09-29", "evidence": "packages/services/service-analytics/src/analytics-service.ts#withDeclaredGranularityDefaults — on `query()` (`POST /api/v1/analytics/query`) and `generateSql()` (`POST /api/v1/analytics/sql`), before the source-field gates and strategy selection run, a time dimension the query GROUPS BY with no stated granularity is bucketed at the default its cube dimension declares; packages/services/service-analytics/src/dataset-executor.ts#declaredDefaultGranularity is that default — the one reading of this key, a single-entry list — shared with the compiled-dataset path's `granularityOf`, so both producers' cubes are read by one rule. Pinned against a compiled-dataset control in packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts, and over both routes in packages/runtime/src/analytics-authored-cube-format-granularity.test.ts.", "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#registerAll (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", - "note": "Dead until 2026-09-29: the one read (`granularityOf`) took a `CompiledDataset`, which an authored cube never becomes, so grouping by an authored time dimension grouped raw timestamps whatever it declared. What the key does now is exactly the compiled-dataset path's reading: a SINGLE-entry list is the dimension's default bucket; a stated `timeDimensions[].granularity` always wins; a multi-entry list (the compiler's five-entry 'all intervals' shape included) states no default; an entry carrying only a `dateRange` for a dimension the query does not group stays a filter. ⚠️ What it does NOT do: refuse a requested granularity the list does not name — the dataset path compares against no list, so neither does this — and whether a multi-entry list should narrow the accept set (the 'Supported Granularities' reading of the declaration) is an open question on #20282, not decided by this row. A declared default routes a grouped query to the engine path, because NativeSQL declines any bucketed query, and the engine path refuses a custom-SQL measure — the same answer a caller who states that granularity by hand gets. The dataset-side authoring key is still `dataset.dimensions[].dateGranularity` (dataset.json), which the compiler lowers into this slot. ⛔ Retirement was never the remedy." + "note": "Dead until 2026-09-29: the one read (`granularityOf`) took a `CompiledDataset`, which an authored cube never becomes, so grouping by an authored time dimension grouped raw timestamps whatever it declared. What the key does now is exactly the compiled-dataset path's reading: a SINGLE-entry list is the dimension's default bucket; a stated `timeDimensions[].granularity` always wins; a multi-entry list (the compiler's five-entry 'all intervals' shape included) states no default; an entry carrying only a `dateRange` for a dimension the query does not group stays a filter. ⚠️ What it does NOT do: refuse a requested granularity the list does not name — the dataset path compares against no list, so neither does this — and whether a multi-entry list should narrow the accept set (the 'Supported Granularities' reading of the declaration) is an open question on #20282, not decided by this row. A declared default routes a grouped query to the engine path, because NativeSQL declines any bucketed query, and the engine path answers 400 `INVALID_FIELD` — the same answer a caller who states that granularity by hand gets — for every member it cannot evaluate: a custom-SQL measure (`ObjectQLStrategy.resolveMeasureAggregation`), and, from `ObjectQLStrategy.planCrossObject`, on a cube whose members resolve through its `joins`: a measure or `where` field over a joined object, a `timeDimensions` entry over a joined object (bucketed or a window), a dimension that traverses more than one relationship, and an `avg` / `count_distinct` measure beside a dimension over a joined object (planCrossObject's two dataset-definition arms cannot fire on an authored cube). NativeSQL serves all of them, so each goes from answered to refused when grouped by a one-interval dimension. On a host whose `queryCapabilities` offers raw SQL with no engine aggregate bridge (a hand override), no strategy remains, so every newly bucketed query answers \"No strategy can handle query\". The declared narrowing and its D3 entry `analytics-cube-single-granularity-default-enforced` name this class. The dataset-side authoring key is still `dataset.dimensions[].dateGranularity` (dataset.json), which the compiler lowers into this slot. ⛔ Retirement was never the remedy." } } }, diff --git a/packages/spec/src/migrations/entries/semantic/18.analytics-cube-single-granularity-default-enforced.ts b/packages/spec/src/migrations/entries/semantic/18.analytics-cube-single-granularity-default-enforced.ts index 828d7b20a9d..462ff56c689 100644 --- a/packages/spec/src/migrations/entries/semantic/18.analytics-cube-single-granularity-default-enforced.ts +++ b/packages/spec/src/migrations/entries/semantic/18.analytics-cube-single-granularity-default-enforced.ts @@ -2,8 +2,9 @@ import type { SemanticMigration } from '../../types.js'; -// An inert key made real, and the one request class that goes from answered to -// refused because of it — the shape of +// An inert key made real, and the request class that goes from answered to +// refused because of it: the engine aggregate path's whole refusal set, which a +// newly bucketed query reaches by leaving the raw-SQL path. The shape of // `analytics-cube-public-default-visible-enforced`. There is no D2 conversion: // no spelling moves, and whether a one-interval list means "bucket by this by // default" or "the one interval I happened to list" is the author's intent, @@ -20,8 +21,9 @@ export const entry: SemanticMigration = { replacement: 'nothing, when that one interval is the bucket the dimension should be grouped at by default. ' + 'When it is not, list every interval the dimension serves (two or more state no default) or ' - + 'omit the key. A dashboard or report that charts a custom-SQL measure over such a dimension ' - + 'either stops grouping by it or groups by a dimension that declares no single interval', + + 'omit the key. A dashboard or report whose query the engine aggregate path cannot evaluate ' + + '(a custom-SQL measure, or a member of a cube with `joins` that resolves through one) either ' + + 'stops grouping by such a dimension or groups by one that declares no single interval', reason: 'An inert key made real. A cube time dimension\'s `granularities` was read only for a cube the ' + 'dataset compiler minted, where a one-interval list is the dataset\'s default bucket. A cube ' @@ -33,11 +35,20 @@ export const entry: SemanticMigration = { + 'A granularity the query states still wins, one the list does not name is not refused, and a ' + 'list of two or more states no default. Two holdings change on upgrade. A query grouping by ' + 'such a dimension answers one row per bucket where it answered one row per timestamp. And a ' - + 'bucketed query is served by the engine aggregate path, which refuses a custom-SQL measure — a ' - + '`number`, `string` or `boolean` measure whose `sql` is an expression — with 400 ' - + '`INVALID_FIELD`, the same refusal, byte for byte, that the same query already got with that ' - + 'granularity stated by hand; so such a measure grouped by such a dimension goes from answered ' - + 'to refused. The protocol-18 conversion `cube-sub-day-granularities-removed` strips the retired ' + + 'bucketed query leaves the raw-SQL path, which declines every bucketed query, for the engine ' + + 'aggregate path, which answers 400 `INVALID_FIELD` for every member it cannot evaluate — the ' + + 'same refusal, byte for byte, that the same query already got with that granularity stated by ' + + 'hand. Those members are: a custom-SQL measure (a `number`, `string` or `boolean` measure ' + + 'whose `sql` is an expression); and, on a cube whose members resolve through its `joins`, a ' + + 'measure or a `where` field over a joined object, a `timeDimensions` entry over a joined object ' + + '(bucketed or a window, so grouping by a one-interval time dimension over a joined object is ' + + 'refused too), a dimension that traverses more than one relationship, and an `avg` or ' + + '`count_distinct` measure beside any dimension over a joined object. The raw-SQL path serves ' + + 'every one of these, so each such query grouped by such a dimension goes from answered to ' + + 'refused. On a host whose `queryCapabilities` offers raw SQL with no engine aggregate bridge ' + + '(a hand override: the analytics plugin wires both), no strategy remains for a bucketed ' + + 'query, so every newly bucketed query, a plain count included, goes from answered to "No ' + + 'strategy can handle query". The protocol-18 conversion `cube-sub-day-granularities-removed` strips the retired ' + 'sub-day intervals from every authored and stored cube, so a dimension that offered one ' + 'sub-day interval and one coarser interval now holds a one-interval list: a default bucket its ' + 'author never wrote.', @@ -46,6 +57,10 @@ export const entry: SemanticMigration = { + 'bucket at that interval by default: a query that groups by it on `/analytics/query` answers ' + 'one row per bucket, and `/analytics/sql` shows the bucketed statement. Every time dimension ' + 'that should have no default lists two or more intervals or omits the key. No dashboard or ' - + 'report groups a custom-SQL measure by a one-interval dimension — or each one that did now ' - + 'groups by a dimension without a single interval.', + + 'report groups by a one-interval dimension a query the engine aggregate path refuses — a ' + + 'custom-SQL measure; a measure, `where` field or `timeDimensions` entry over a joined object; ' + + 'a dimension that traverses more than one relationship; an `avg` or `count_distinct` measure ' + + 'beside a dimension over a joined object — or each one that did now groups by a dimension ' + + 'without a single interval. A host that overrides `queryCapabilities` to raw SQL only either ' + + 'adds an engine aggregate bridge or groups by no one-interval dimension.', }; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 2f9fee11237..fdf79bc4b7a 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6416,8 +6416,9 @@ const step18: MigrationStep = { + 'naming it answers 404 `CUBE_NOT_FOUND`. Every compiled artifact in use was built by ' + '`os compile` from this release or later.', }, - // An inert key made real, and the one request class that goes from answered to - // refused because of it — the shape of + // An inert key made real, and the request class that goes from answered to + // refused because of it: the engine aggregate path's whole refusal set, which a + // newly bucketed query reaches by leaving the raw-SQL path. The shape of // `analytics-cube-public-default-visible-enforced`. There is no D2 conversion: // no spelling moves, and whether a one-interval list means "bucket by this by // default" or "the one interval I happened to list" is the author's intent, @@ -6434,8 +6435,9 @@ const step18: MigrationStep = { replacement: 'nothing, when that one interval is the bucket the dimension should be grouped at by default. ' + 'When it is not, list every interval the dimension serves (two or more state no default) or ' - + 'omit the key. A dashboard or report that charts a custom-SQL measure over such a dimension ' - + 'either stops grouping by it or groups by a dimension that declares no single interval', + + 'omit the key. A dashboard or report whose query the engine aggregate path cannot evaluate ' + + '(a custom-SQL measure, or a member of a cube with `joins` that resolves through one) either ' + + 'stops grouping by such a dimension or groups by one that declares no single interval', reason: 'An inert key made real. A cube time dimension\'s `granularities` was read only for a cube the ' + 'dataset compiler minted, where a one-interval list is the dataset\'s default bucket. A cube ' @@ -6447,11 +6449,20 @@ const step18: MigrationStep = { + 'A granularity the query states still wins, one the list does not name is not refused, and a ' + 'list of two or more states no default. Two holdings change on upgrade. A query grouping by ' + 'such a dimension answers one row per bucket where it answered one row per timestamp. And a ' - + 'bucketed query is served by the engine aggregate path, which refuses a custom-SQL measure — a ' - + '`number`, `string` or `boolean` measure whose `sql` is an expression — with 400 ' - + '`INVALID_FIELD`, the same refusal, byte for byte, that the same query already got with that ' - + 'granularity stated by hand; so such a measure grouped by such a dimension goes from answered ' - + 'to refused. The protocol-18 conversion `cube-sub-day-granularities-removed` strips the retired ' + + 'bucketed query leaves the raw-SQL path, which declines every bucketed query, for the engine ' + + 'aggregate path, which answers 400 `INVALID_FIELD` for every member it cannot evaluate — the ' + + 'same refusal, byte for byte, that the same query already got with that granularity stated by ' + + 'hand. Those members are: a custom-SQL measure (a `number`, `string` or `boolean` measure ' + + 'whose `sql` is an expression); and, on a cube whose members resolve through its `joins`, a ' + + 'measure or a `where` field over a joined object, a `timeDimensions` entry over a joined object ' + + '(bucketed or a window, so grouping by a one-interval time dimension over a joined object is ' + + 'refused too), a dimension that traverses more than one relationship, and an `avg` or ' + + '`count_distinct` measure beside any dimension over a joined object. The raw-SQL path serves ' + + 'every one of these, so each such query grouped by such a dimension goes from answered to ' + + 'refused. On a host whose `queryCapabilities` offers raw SQL with no engine aggregate bridge ' + + '(a hand override: the analytics plugin wires both), no strategy remains for a bucketed ' + + 'query, so every newly bucketed query, a plain count included, goes from answered to "No ' + + 'strategy can handle query". The protocol-18 conversion `cube-sub-day-granularities-removed` strips the retired ' + 'sub-day intervals from every authored and stored cube, so a dimension that offered one ' + 'sub-day interval and one coarser interval now holds a one-interval list: a default bucket its ' + 'author never wrote.', @@ -6460,8 +6471,12 @@ const step18: MigrationStep = { + 'bucket at that interval by default: a query that groups by it on `/analytics/query` answers ' + 'one row per bucket, and `/analytics/sql` shows the bucketed statement. Every time dimension ' + 'that should have no default lists two or more intervals or omits the key. No dashboard or ' - + 'report groups a custom-SQL measure by a one-interval dimension — or each one that did now ' - + 'groups by a dimension without a single interval.', + + 'report groups by a one-interval dimension a query the engine aggregate path refuses — a ' + + 'custom-SQL measure; a measure, `where` field or `timeDimensions` entry over a joined object; ' + + 'a dimension that traverses more than one relationship; an `avg` or `count_distinct` measure ' + + 'beside a dimension over a joined object — or each one that did now groups by a dimension ' + + 'without a single interval. A host that overrides `queryCapabilities` to raw SQL only either ' + + 'adds an engine aggregate bridge or groups by no one-interval dimension.', }, { id: 'analytics-date-range-array-two-bounds-required', From 162e6c0f01ff5aa17223905ca292e7c32d0f1431 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 15:40:20 +0000 Subject: [PATCH 8/8] test(analytics): the pin file's docblock names both refusal sources of the declared narrowing Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude --- .../__tests__/cube-authored-format-granularity.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts b/packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts index 8744471354b..d32f0c8a77a 100644 --- a/packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts +++ b/packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts @@ -29,9 +29,12 @@ * window-only `timeDimensions` entry stays a filter — the same five answers * the dataset path gives; * - the declared narrowing: a bucketed query is served by the engine path, - * which refuses a custom-SQL measure, so grouping such a measure by a - * declared-default dimension is now refused — with the envelope, byte for - * byte, that stating the same granularity by hand already got. + * which refuses every member it cannot evaluate — a custom-SQL measure, and, + * on a cube with `joins`, a cross-object member (`planCrossObject`) — so + * grouping such a query by a declared-default dimension is now refused, with + * the envelope, byte for byte, that stating the same granularity by hand + * already got. One pin per refusal source: the custom-SQL measure, and a + * cross-object measure on a joined cube. */ import { describe, it, expect, vi } from 'vitest';