From 8b45e44cdc565791d8fb76d37482b70f64f378a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 02:33:48 +0000 Subject: [PATCH] fix(analytics): pin the matrix reports' date-bucket intent and drop the empty column (#523) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v9 single-form migration dropped `groupingsAcross[].dateGranularity` and never carried it to the dataset dimensions, so every matrix report's date axis groups by the raw timestamp — one column per distinct value instead of one per month/quarter/day. Re-declaring it is blocked on the platform, not on us, and the block was measured rather than assumed. On the pinned @objectstack 16.1 a bucketed dimension does not bucket the axis, it empties the surface: 1. A granular dimension is refused by NativeSQLStrategy (`canHandle` bails on any timeDimension carrying a granularity), so the query falls through to ObjectQLStrategy → the auto-bridged `executeAggregate`, which calls `engine.aggregate()` with no ExecutionContext. The sharing middleware then composes `{ id: '__deny_all__' }` for every `sharingModel: 'private'` object — all of ours. Measured on lead_metrics: 21 rows → 0. 2. A `Field.datetime()` column is INTEGER epoch millis in SQLite and 16.1 buckets it with a bare `strftime()`, so every row lands in one NULL bucket (`crm_lead.last_contacted_date`, `crm_case.created_date`). A `Field.date()` column is TEXT and buckets correctly, but still hits (1). Both are fixed in 17.0.0-rc.0 — the bridge threads `context` and the strategy applies the read scope itself (#3602/#3597), and driver-sql normalises epoch storage before formatting. That is why #500 could not honour `dateGranularity` either, and why these declarations belong with the 17.0 upgrade rather than ahead of it. So this ships what 16.1 can honour and pins the rest: · `test/dataset-granularity.test.ts` records the intended bucket for every date dimension and for each matrix report's axis — including the dedicated quarter dimension `pipeline_coverage_by_quarter` needs, since `close_date` is shared with the Sales/CRM/Executive revenue trends, which want month. The guard fails if anyone re-declares a bucket on 16.x, and flips to demanding every declaration once the platform pin crosses 17, so the upgrade cannot go green with the migration unfinished. · `lead_inflow_by_month_source` and `cases_opened_by_day_priority` exclude records with no date — that empty group is what rendered as a headerless `—` column (lead report: 21 groups → 20, no null bucket). · Three report comments described a `dateGranularity` no dataset declares; they now say what the code actually does, and the dataset notes carry the verified mechanism instead of a one-line guess. Verified: pnpm validate (no new warnings) + typecheck + build + test (12 files, 146 passed), plus a booted kernel with the sharing middleware live to measure each claim above and to confirm the `—` column is gone. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FeXgPwo5jJyuZa8XKbBqXo --- .changeset/matrix-report-date-granularity.md | 31 +++ src/datasets/account.dataset.ts | 4 +- src/datasets/case.dataset.ts | 4 + src/datasets/lead.dataset.ts | 4 + src/datasets/opportunity.dataset.ts | 36 ++- src/reports/case.report.ts | 18 +- src/reports/lead.report.ts | 20 +- src/reports/opportunity.report.ts | 12 +- test/dataset-granularity.test.ts | 219 +++++++++++++++++++ 9 files changed, 330 insertions(+), 18 deletions(-) create mode 100644 .changeset/matrix-report-date-granularity.md create mode 100644 test/dataset-granularity.test.ts diff --git a/.changeset/matrix-report-date-granularity.md b/.changeset/matrix-report-date-granularity.md new file mode 100644 index 00000000..9770c3ce --- /dev/null +++ b/.changeset/matrix-report-date-granularity.md @@ -0,0 +1,31 @@ +--- +'hotcrm': patch +--- + +Record the matrix reports' date-bucket intent and drop the empty `—` column +(#523). The v9 single-form migration dropped `groupingsAcross[].dateGranularity` +and never carried it to the dataset dimensions, so every date axis groups by the +raw timestamp — one column per distinct value. Re-declaring it turns out to be +blocked on the platform, not on us, and the blockage was measured rather than +assumed: on the pinned @objectstack 16.1 a bucketed dimension does not bucket the +axis, it EMPTIES the surface. A granular dimension is refused by +NativeSQLStrategy, so the query falls to the auto-bridged `executeAggregate`, +which calls `engine.aggregate()` with no ExecutionContext — sharing then +composes `id = '__deny_all__'` for every private object (all of ours), and +`lead_metrics` goes 21 rows → 0. A `Field.datetime()` column additionally buckets +to a single NULL, because SQLite stores it as epoch millis and 16.1 formats it +with a bare `strftime()`. Both are fixed in 17.0.0-rc.0 (#3602/#3597 and +driver-sql's epoch normalisation), so the declarations belong with that upgrade, +not ahead of it — which is also why #500 could not honour `dateGranularity`. + +So this ships what 16.1 can honour and pins the rest: the intended bucket for +every date dimension (and for each matrix report's axis, including the dedicated +quarter dimension `pipeline_coverage_by_quarter` needs — `close_date` is shared +with the revenue trends, which want month) now lives in a new guard, +`test/dataset-granularity.test.ts`. It fails today if anyone re-declares a bucket +on 16.x, and flips to demanding every declaration the moment the platform pin +crosses 17, so the upgrade cannot go green with the migration unfinished. +`lead_inflow_by_month_source` and `cases_opened_by_day_priority` now exclude +records with no date, which is what produced the headerless `—` column (the lead +report loses it: 21 groups → 20, no null bucket). Three report comments that +described a `dateGranularity` no dataset declares are corrected. diff --git a/src/datasets/account.dataset.ts b/src/datasets/account.dataset.ts index 7f66cc3e..f1d6947e 100644 --- a/src/datasets/account.dataset.ts +++ b/src/datasets/account.dataset.ts @@ -11,8 +11,8 @@ export const AccountDataset = defineDataset({ dimensions: [ { name: 'industry', label: 'Industry', field: 'industry', type: 'string' }, { name: 'type', label: 'Type', field: 'type', type: 'string' }, - // No `dateGranularity`: bucketed date dims hit a fail-closed read scope in - // the platform's aggregate bridge (see opportunity_metrics.close_date). + // No `dateGranularity` (intended: month) — see the note on + // opportunity_metrics.close_date for why it cannot be declared on 16.x. { name: 'created_at', label: 'Created', field: 'created_at', type: 'date' }, ], measures: [ diff --git a/src/datasets/case.dataset.ts b/src/datasets/case.dataset.ts index a7d4e29f..0448b177 100644 --- a/src/datasets/case.dataset.ts +++ b/src/datasets/case.dataset.ts @@ -17,6 +17,10 @@ export const CaseDataset = defineDataset({ { name: 'priority', label: 'Priority', field: 'priority', type: 'string' }, { name: 'origin', label: 'Origin', field: 'origin', type: 'string' }, { name: 'type', label: 'Type', field: 'type', type: 'string' }, + // No `dateGranularity` (intended: day, for cases_opened_by_day_priority and + // the Service dashboard's inflow trend) — see the note on + // opportunity_metrics.close_date. `crm_case.created_date` is a + // `Field.datetime()`, so it is blocked by BOTH gaps described there. { name: 'created_date', label: 'Created', field: 'created_date', type: 'date' }, ], diff --git a/src/datasets/lead.dataset.ts b/src/datasets/lead.dataset.ts index 06eb365c..549a256a 100644 --- a/src/datasets/lead.dataset.ts +++ b/src/datasets/lead.dataset.ts @@ -12,6 +12,10 @@ export const LeadDataset = defineDataset({ { name: 'status', label: 'Status', field: 'status', type: 'string' }, { name: 'lead_source', label: 'Source', field: 'lead_source', type: 'string' }, { name: 'created_at', label: 'Created', field: 'created_at', type: 'date' }, + // No `dateGranularity` (intended: month, for lead_inflow_by_month_source) + // — see the note on opportunity_metrics.close_date for why it cannot be + // declared on 16.x. `crm_lead.last_contacted_date` is a `Field.datetime()`, + // so it is blocked by BOTH gaps described there. { name: 'last_contacted_date', label: 'Last Contacted', field: 'last_contacted_date', type: 'date' }, ], measures: [{ name: 'lead_count', label: 'Leads', aggregate: 'count' }], diff --git a/src/datasets/opportunity.dataset.ts b/src/datasets/opportunity.dataset.ts index 199daea6..a862e9cf 100644 --- a/src/datasets/opportunity.dataset.ts +++ b/src/datasets/opportunity.dataset.ts @@ -26,11 +26,37 @@ export const OpportunityDataset = defineDataset({ { name: 'forecast_category', label: 'Forecast Category', field: 'forecast_category', type: 'string' }, { name: 'type', label: 'Deal Type', field: 'type', type: 'string' }, { name: 'owner', label: 'Owner', field: 'owner', type: 'lookup' }, - // NOTE: no `dateGranularity` here — a bucketed date dimension routes the - // query through the platform's ObjectQL aggregate bridge, which drops the - // caller's ExecutionContext and fail-closes the read scope (id = - // '__deny_all__') on @objectstack 16.1, returning zero rows. Trend widgets - // group by the raw date until that is fixed upstream. + // ─── Why no `dateGranularity` on any date dimension (hotcrm#523) ──── + // This is the canonical note; the other datasets point here. + // + // Declaring a bucket on a dataset dimension is the supported mechanism and + // IS what these dimensions want (month for the trend widgets, quarter for + // pipeline_coverage_by_quarter, day for cases_opened_by_day_priority). It + // cannot be declared while the app is pinned to @objectstack 16.x: doing so + // makes every affected surface render EMPTY, which is worse than the + // un-bucketed columns it would fix. Two independent 16.x gaps, both + // verified against the pinned packages, both fixed in 17.0.0-rc.0: + // + // 1. Read scope. A granular dimension is refused by NativeSQLStrategy + // (service-analytics `canHandle` bails on any timeDimension carrying a + // granularity), so the query falls through to ObjectQLStrategy → the + // auto-bridged `executeAggregate`, which calls `engine.aggregate()` + // WITHOUT the caller's ExecutionContext. The sharing middleware then + // sees an empty principal and composes `{ id: '__deny_all__' }` for + // every `sharingModel: 'private'` object — which is all of ours. Fixed + // upstream by #3602/#3597: the bridge threads `context`, and the + // strategy applies the read scope itself. + // 2. Bucketing. A `Field.datetime()` column lands in SQLite as INTEGER + // epoch millis, and 16.x buckets with a bare `strftime('%Y-%m', col)` + // — NULL for every row, i.e. a single '—' column. 17.0's driver-sql + // normalises epoch storage before formatting. `Field.date()` columns + // (close_date) are TEXT and bucket correctly even on 16.x, but they + // still hit gap 1. + // + // The intended bucket per dimension is not lost — it lives in + // `test/dataset-granularity.test.ts`, whose guard flips from "must not + // declare" to "must declare" the moment this app moves to @objectstack 17. + // Until then the trend widgets and matrix reports group by the raw date. { name: 'close_date', label: 'Close Date', field: 'close_date', type: 'date' }, // Cross-object dimension: account industry via the crm_account relationship. { name: 'account_industry', label: 'Account Industry', field: 'crm_account.industry', type: 'string' }, diff --git a/src/reports/case.report.ts b/src/reports/case.report.ts index 54270f27..f96104da 100644 --- a/src/reports/case.report.ts +++ b/src/reports/case.report.ts @@ -29,10 +29,16 @@ export const SlaPerformanceReport: ReportInput = { }; /** - * Daily case inflow by priority — matrix with day-level bucketing. Support - * managers use this to spot priority spikes (e.g. a P1 burst on Tuesday) and - * staff accordingly. Exercises the finest `dateGranularity: 'day'` bucket and - * its interaction with a small categorical axis. + * Daily case inflow by priority — matrix over `created_date`. Support managers + * use this to spot priority spikes (e.g. a P1 burst on Tuesday) and staff + * accordingly. + * + * The across axis is NOT bucketed by day yet: `case_metrics` cannot declare + * `dateGranularity` while the app is pinned to @objectstack 16.x (see the note + * on `opportunity_metrics.close_date`; hotcrm#523). Seeded cases carry a + * midnight timestamp, so the raw columns happen to READ as days — but they are + * raw timestamps, and a case created mid-afternoon gets its own column. The + * day bucket is pinned as intent in `test/dataset-granularity.test.ts`. */ export const CasesOpenedByDayPriorityReport: ReportInput = { name: 'cases_opened_by_day_priority', @@ -40,4 +46,8 @@ export const CasesOpenedByDayPriorityReport: ReportInput = { description: 'Daily case inflow split by priority', dataset: 'case_metrics', rows: ['priority'], columns: ['created_date'], values: ['case_count'], type: 'matrix', + // `created_date` is stamped by the platform, not required by the schema — a + // case that reaches the table without one would group into a headerless '—' + // column. "Cases opened" needs an open date; exclude the empty bucket. + runtimeFilter: { created_date: { $ne: null } }, }; diff --git a/src/reports/lead.report.ts b/src/reports/lead.report.ts index 3bad7ca9..4c1b6fc2 100644 --- a/src/reports/lead.report.ts +++ b/src/reports/lead.report.ts @@ -3,11 +3,16 @@ import type { ReportInput } from '@objectstack/spec/ui'; /** - * Lead engagement trend — matrix with monthly bucketing on - * `last_contacted_date` (a user-managed datetime, so it can be set in seed - * data and varied across months for a realistic demo). Lets a marketing-ops - * lead spot which channels we're actively working month-over-month. - * Exercises the `dateGranularity: 'month'` server-side aggregation path. + * Lead engagement trend — matrix over `last_contacted_date` (a user-managed + * datetime, so it can be set in seed data and varied across months for a + * realistic demo). Lets a marketing-ops lead spot which channels we're + * actively working month-over-month. + * + * The across axis is NOT bucketed by month yet: `lead_metrics` cannot declare + * `dateGranularity` while the app is pinned to @objectstack 16.x (both gaps + * are spelled out on `opportunity_metrics.close_date`; hotcrm#523), so the + * columns are one-per-raw-date until the 17.0 upgrade lands. The month bucket + * is pinned as intent in `test/dataset-granularity.test.ts`. */ export const LeadInflowByMonthSourceReport: ReportInput = { name: 'lead_inflow_by_month_source', @@ -15,4 +20,9 @@ export const LeadInflowByMonthSourceReport: ReportInput = { description: 'Contacted-lead volume per month, broken down by acquisition channel', dataset: 'lead_metrics', rows: ['lead_source'], columns: ['last_contacted_date'], values: ['lead_count'], type: 'matrix', + // A never-contacted lead has no engagement month, and grouping it produced a + // headerless '—' column next to the real ones. This report counts CONTACTED + // leads (see the label/description), so the empty bucket is excluded at the + // source rather than rendered as a mystery column. + runtimeFilter: { last_contacted_date: { $ne: null } }, }; diff --git a/src/reports/opportunity.report.ts b/src/reports/opportunity.report.ts index d0a2b95c..147e7496 100644 --- a/src/reports/opportunity.report.ts +++ b/src/reports/opportunity.report.ts @@ -29,8 +29,16 @@ export const WonOpportunitiesByOwnerReport: ReportInput = { * Quarterly pipeline coverage — the matrix view that powers the classic * sales-ops "pipeline coverage" conversation: each forecast category against * the quarter the deal is expected to close in. `close_date` is the across - * dimension (`columns`); the dataset's `dateGranularity` buckets it into - * quarters in a single server-side aggregate query. + * dimension (`columns`). + * + * It is NOT bucketed into quarters yet — the comment here used to claim "the + * dataset's dateGranularity buckets it into quarters", but no such declaration + * exists (it was dropped in the v9 single-form migration and cannot be + * restored on @objectstack 16.x; see the note on + * `opportunity_metrics.close_date`, hotcrm#523). Restoring it needs a + * DEDICATED quarter dimension rather than a bucket on `close_date` itself: + * that dimension is shared with the Sales/CRM/Executive revenue trends, which + * want month. Both intents are pinned in `test/dataset-granularity.test.ts`. */ export const PipelineCoverageByQuarterReport: ReportInput = { name: 'pipeline_coverage_by_quarter', diff --git a/test/dataset-granularity.test.ts b/test/dataset-granularity.test.ts new file mode 100644 index 00000000..70c5201c --- /dev/null +++ b/test/dataset-granularity.test.ts @@ -0,0 +1,219 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import stack from '../objectstack.config'; + +/** + * Date-bucket guard for the analytics semantic layer (#523). + * + * Matrix reports over a date axis need the dataset dimension to declare a + * `dateGranularity` — that is the only supported place to say "bucket this by + * month". The v9 single-form migration dropped the old + * `groupingsAcross[].dateGranularity` declarations and nothing carried them to + * the new location, so every date axis has been grouping by the raw timestamp + * (one column per distinct value) ever since. + * + * Re-declaring them is blocked on the platform, not on us. On @objectstack + * 16.x a bucketed dimension makes the surface render EMPTY — strictly worse + * than the un-bucketed columns — for two independent reasons, both verified + * against the pinned packages and both fixed in 17.0.0-rc.0: + * + * 1. A granular dimension is refused by NativeSQLStrategy, so the query + * falls to ObjectQLStrategy → the auto-bridged `executeAggregate`, which + * calls `engine.aggregate()` with no ExecutionContext; the sharing + * middleware composes `{ id: '__deny_all__' }` for every private object + * (upstream #3602/#3597). + * 2. A `Field.datetime()` column is INTEGER epoch millis in SQLite and 16.x + * buckets it with a bare `strftime()`, which returns NULL for every row. + * + * So this guard has two faces, and the platform major flips between them: + * + * · on 16.x — assert NOBODY re-declares a bucket (the #500 regression), and + * keep the intended buckets recorded below so they are not lost again; + * · on 17+ — assert every intended bucket IS declared, and that each matrix + * report's date axis carries the bucket that report is named after. The + * upgrade cannot go green without finishing the migration. + * + * The tables below are the migration's source of truth. Keep them in sync with + * the surfaces, not with the current platform version. + */ + +type AnyRec = Record; +type Granularity = 'day' | 'week' | 'month' | 'quarter' | 'year'; + +/** + * `dataset.dimension` → the bucket it must declare on @objectstack 17+. + * + * A dimension is SHARED: `opportunity_metrics.close_date` feeds the Sales / + * CRM / Executive revenue trends (month) and `pipeline_coverage_by_quarter` + * (quarter). Two intents over one field means two dimensions — hence + * `close_quarter`, which does not exist yet and must be added over the same + * `close_date` field when the buckets go in. + */ +const INTENDED_BUCKET: Record = { + 'lead_metrics.last_contacted_date': 'month', + 'case_metrics.created_date': 'day', + 'account_metrics.created_at': 'month', + 'opportunity_metrics.close_date': 'month', + 'opportunity_metrics.close_quarter': 'quarter', +}; + +/** Matrix report → the bucket its date axis must carry on @objectstack 17+. */ +const REPORT_DATE_AXIS: Record = { + lead_inflow_by_month_source: 'month', + cases_opened_by_day_priority: 'day', + pipeline_coverage_by_quarter: 'quarter', +}; + +/** + * The packages that own the two gaps: service-analytics (the aggregate bridge) + * and driver-sqlite-wasm (which carries driver-sql's bucket SQL). `spec` pins + * the dataset schema itself. The guard flips only once the LOWEST of them is + * on 17+, so a partial bump cannot half-open the door. + */ +const GAP_OWNERS = [ + '@objectstack/service-analytics', + '@objectstack/driver-sqlite-wasm', + '@objectstack/spec', +]; + +const pkg = JSON.parse(readFileSync('package.json', 'utf8')) as AnyRec; + +const majorOf = (name: string): number => { + const range = pkg.dependencies?.[name] ?? pkg.devDependencies?.[name]; + if (!range) throw new Error(`granularity guard out of date: "${name}" is not a dependency`); + const m = String(range).match(/(\d+)\./); + if (!m) throw new Error(`granularity guard cannot read a major out of "${name}": "${range}"`); + return Number(m[1]); +}; + +const platformMajor = Math.min(...GAP_OWNERS.map(majorOf)); +/** True once the platform honours a bucketed dimension (see the header). */ +const bucketsAreHonoured = platformMajor >= 17; + +const datasets: AnyRec[] = (stack as any).datasets ?? []; +const reports: AnyRec[] = (stack as any).reports ?? []; +const datasetByName = new Map(datasets.map((d) => [d.name, d])); + +const dimensionOf = (ref: string): AnyRec | undefined => { + const [dataset, dimension] = ref.split('.'); + return (datasetByName.get(dataset)?.dimensions ?? []).find((d: AnyRec) => d.name === dimension); +}; + +/** Every declared dimension, keyed as `dataset.dimension`. */ +const allDimensions: Array<[string, AnyRec]> = datasets.flatMap((ds) => + (ds.dimensions ?? []).map((d: AnyRec) => [`${ds.name}.${d.name}`, d] as [string, AnyRec]), +); + +/** The date dimensions a report groups across / down, as `dataset.dimension`. */ +const dateAxesOf = (report: AnyRec): string[] => { + const ds = datasetByName.get(report.dataset); + if (!ds) return []; + return [...(report.columns ?? []), ...(report.rows ?? [])] + .map((name: string) => [name, (ds.dimensions ?? []).find((d: AnyRec) => d.name === name)] as const) + .filter(([, dim]) => dim?.type === 'date') + .map(([name]) => `${ds.name}.${name}`); +}; + +describe('the date-bucket migration table stays wired to real metadata', () => { + it('every intended bucket names a dataset that exists', () => { + const bad = Object.keys(INTENDED_BUCKET) + .filter((ref) => !datasetByName.has(ref.split('.')[0])) + .map((ref) => `${ref}: dataset "${ref.split('.')[0]}" is not defined`); + expect(bad, bad.join('\n ')).toEqual([]); + }); + + it('every intended bucket that exists today is a date dimension', () => { + const bad = Object.keys(INTENDED_BUCKET) + .map((ref) => [ref, dimensionOf(ref)] as const) + .filter(([, dim]) => dim && dim.type !== 'date') + .map(([ref, dim]) => `${ref}: type is "${dim!.type}", expected "date"`); + expect(bad, bad.join('\n ')).toEqual([]); + }); + + it('every listed report exists and has exactly one date axis', () => { + const bad: string[] = []; + for (const name of Object.keys(REPORT_DATE_AXIS)) { + const report = reports.find((r) => r.name === name); + if (!report) { + bad.push(`${name}: no such report — did it get renamed?`); + continue; + } + const axes = dateAxesOf(report); + if (axes.length !== 1) bad.push(`${name}: expected 1 date axis, found ${axes.length} [${axes.join(', ')}]`); + } + expect(bad, bad.join('\n ')).toEqual([]); + }); + + it('no dimension declares a bucket outside the table', () => { + const bad = allDimensions + .filter(([ref, dim]) => dim.dateGranularity && !(ref in INTENDED_BUCKET)) + .map(([ref, dim]) => `${ref}: declares dateGranularity "${dim.dateGranularity}" but is not in INTENDED_BUCKET`); + expect(bad, `undeclared bucket intent:\n ${bad.join('\n ')}`).toEqual([]); + }); + + it('only date dimensions declare a bucket', () => { + const bad = allDimensions + .filter(([, dim]) => dim.dateGranularity && dim.type !== 'date') + .map(([ref, dim]) => `${ref}: type "${dim.type}" cannot carry dateGranularity "${dim.dateGranularity}"`); + expect(bad, bad.join('\n ')).toEqual([]); + }); +}); + +describe.runIf(!bucketsAreHonoured)('on @objectstack 16.x — buckets must stay undeclared', () => { + it('no date dimension declares a dateGranularity', () => { + const declared = allDimensions + .filter(([, dim]) => dim.type === 'date' && dim.dateGranularity) + .map(([ref, dim]) => `${ref} → "${dim.dateGranularity}"`); + expect( + declared, + 'A dataset dimension declares a dateGranularity while the app is pinned to ' + + `@objectstack ${platformMajor}.x:\n ${declared.join('\n ')}\n\n` + + 'On 16.x that does not bucket the axis — it empties the surface: the granular ' + + 'query leaves NativeSQLStrategy for the aggregate bridge, which drops the ' + + "caller's ExecutionContext, so sharing composes id = '__deny_all__' for every " + + 'private object (upstream #3602/#3597). A Field.datetime() column additionally ' + + 'buckets to NULL, because SQLite stores it as epoch millis and 16.x formats it ' + + 'with a bare strftime(). Both are fixed in 17.0.0-rc.0. Record the intent in ' + + 'INTENDED_BUCKET instead and declare it with the upgrade (#523).', + ).toEqual([]); + }); +}); + +describe.runIf(bucketsAreHonoured)('on @objectstack 17+ — buckets must be declared', () => { + it('every intended bucket is declared on its dimension', () => { + const bad: string[] = []; + for (const [ref, granularity] of Object.entries(INTENDED_BUCKET)) { + const dim = dimensionOf(ref); + if (!dim) { + bad.push(`${ref}: dimension is missing — add it (see INTENDED_BUCKET for why it exists)`); + continue; + } + if (dim.dateGranularity !== granularity) { + bad.push(`${ref}: dateGranularity is ${JSON.stringify(dim.dateGranularity)}, expected "${granularity}"`); + } + } + expect( + bad, + `the platform now honours bucketed dimensions — finish the #523 migration:\n ${bad.join('\n ')}`, + ).toEqual([]); + }); + + it("every matrix report's date axis carries that report's bucket", () => { + const bad: string[] = []; + for (const [name, granularity] of Object.entries(REPORT_DATE_AXIS)) { + const report = reports.find((r) => r.name === name); + const [axis] = dateAxesOf(report ?? {}); + if (!axis) continue; // reported by the wiring suite above + const dim = dimensionOf(axis); + if (dim?.dateGranularity !== granularity) { + bad.push( + `${name}: axis "${axis}" buckets by ${JSON.stringify(dim?.dateGranularity)}, expected "${granularity}"` + + ' — a dimension is shared, so a second intent needs its own dimension over the same field', + ); + } + } + expect(bad, bad.join('\n ')).toEqual([]); + }); +});