Skip to content

Commit c8dd8dd

Browse files
feat(analytics): an authored cube's measures.format and dimensions.granularities take effect on the query doors (#20282, stage 2) (#20635)
Part of #20282 Clause-②: yes (narrowing) Stage 2 of #20282, under claim `5886559774` (`session_014EJ1ED8X4MMrT18BhVx4tx`, `domain:spec` seat 2). An AUTHORED analytics cube's `measures.format` and `dimensions.granularities` now reach the readers a compiled dataset already reaches. `refreshKey` is measured only, and nothing is built for it. The card stays open for stage 3 (descriptions) and for the `refreshKey` decision. ## What changes One Cube shape has three producers: authored cubes (`AnalyticsServiceConfig.cubes`, which the CLI threads from `analyticsCubes`), compiled datasets, and ad-hoc inference. Until now, both keys were read only on the compiled-dataset path. - **`measures.format`** - `analytics-service.ts#withDeclaredMeasureFormats` runs in `queryIn`, beside the SQL-echo gate. - Every measure column a query names now carries its cube measure's declared `format` as `fields[].format`. This holds for every strategy (NativeSQL, ObjectQL, the delegated fallback) and for both member spellings. - A measure that declares no format gets no key. A value already on the column is never replaced. - On the dataset door, the value read is the compiler's copy of the dataset measure's `format`, the same value `enrichResultColumns` writes anyway. - `GET /analytics/meta` is unchanged. See the premise checks below. - **`dimensions.granularities`** - `analytics-service.ts#withDeclaredGranularityDefaults` runs on `query()` and on the `generateSql()` dry run, before the source-field gates and strategy selection. - It reads `dataset-executor.ts#declaredDefaultGranularity`, a new function extracted from `granularityOf`, which now calls it too. Both producers are therefore read by one rule. - The rule, exactly the compiled-dataset path's: - a single-entry list is the default bucket for a time dimension the query groups by without stating a granularity; - a stated granularity always wins; - a granularity outside the list is not refused; - a list of two or more states no default; - a `timeDimensions` entry that carries only a `dateRange`, for a dimension the query does not group by, stays a filter. - **Spec** (`packages/spec/src/data/analytics.zod.ts`, after PR #20616 merged; `origin/main` merged first through `scripts/pm/os-regen-merge.sh` as `d963f30e33`) - `MetricSchema.format` and `DimensionSchema.granularities` gain describes that state the enforcement. - The metric's example values move from the names "currency" and "percent" to numeral patterns, the vocabulary the `fields[].format` slot documents. - `content/docs/references/data/analytics.mdx` is regenerated with `gen:docs`. - **Ledger** - Both rows in `packages/spec/liveness/analytics_cube.json` go `dead` → `live`. Each cites its readers as `file#symbol` and the CLI threading producer. - The file note's two sentences that named both keys as dead are rewritten. - `state-counts/analytics_cube.md` is regenerated with `gen:liveness-counts`: live/dead goes from 18/9 to 20/7. - The `analytics_cube` Notes cell in `liveness/README.md`, which listed both keys among the dead, is rewritten. - **Changeset**: `@objectstack/spec` minor and `@objectstack/service-analytics` minor, with a `**BREAKING**` sentence. ## Clause-② (measured arm): yes (narrowing) - **Widening:** two authored keys take effect, and `fields[].format` is populated for authored cubes. The contract already declares that member. - **Narrowing, measured:** - Method: a throwaway service-seam probe, run once on head and once with the fill ablated through `scripts/ablation-replace.mjs` (round 1 at `958251b6ac`; round 3 at `e1383be04d`, blob `9d77adcd798f` → `94b1bc63165a`, restored to HEAD). The probe is not committed. - Cube: an authored cube whose `placed_at` declares `granularities: ['month']`, whose `shipped_at` declares two intervals, and (round 3) which declares `joins: { account: { name: 'crm_account' } }`. | request (authored cube; `placed_at` declares `['month']`, `shipped_at` two intervals; `joins: { account }` where named) | fill ablated (= base behaviour) | head | |:--|:--|:--| | custom-SQL measure grouped by `placed_at` | 200 (raw SQL) | **400 `INVALID_FIELD`**, custom-SQL measure (`resolveMeasureAggregation`) | | cross-object measure (`sum` of `account.balance`) grouped by `placed_at` | 200 | **400 `INVALID_FIELD`**, cross-object measure (`planCrossObject`) | | `count` by `placed_at` with a `where` field over `account` | 200 | **400 `INVALID_FIELD`**, cross-object filter, `param: where` | | `count` grouped by a one-interval time dimension over `account` (`account.created_at`) | 200 | **400 `INVALID_FIELD`**, cannot bucket a cross-object time dimension | | `count` by `placed_at` beside a multi-hop dimension (`account.owner.region`) | 200 | **400 `INVALID_FIELD`**, single-hop only | | `avg` by `placed_at` beside a cross-object dimension (`account.industry`) | 200 | **400 `INVALID_FIELD`**, non-recombinable measure | | `count_distinct` by `placed_at` beside a cross-object dimension | 200 | **400 `INVALID_FIELD`**, non-recombinable measure | | host whose `queryCapabilities` offers raw SQL only (a hand override; `AnalyticsServicePlugin` wires both): plain `count` grouped by `placed_at` | 200 | **"No strategy can handle query"** (every newly bucketed query) | | control: `count` by `placed_at` beside a cross-object dimension (recombinable) | 200 | 200 | | control: `avg` by `placed_at`, no cross-object member | 200 | 200 | | control: custom-SQL measure grouped by `shipped_at` (two intervals) | 200 | 200 | - Every 400 is byte-identical (code, status, message, member, param, cube) to what the same request with `granularity: 'month'` stated by hand already got: the fill hands the engine path the very query a hand-stated granularity does. The class is the engine aggregate path's whole refusal set as the new bucketing reaches it, read from `ObjectQLStrategy.planCrossObject` and `resolveMeasureAggregation`: a custom-SQL measure; and, on a cube whose members resolve through `joins`, a measure or `where` field over a joined object, a `timeDimensions` entry over a joined object, a multi-hop dimension, and an `avg` / `count_distinct` measure beside a dimension over a joined object. `planCrossObject`'s two dataset-definition arms read only compiled-dataset scope, so they cannot fire on an authored cube. Two pins: `DECLARED NARROWING` (custom-SQL) and `DECLARED NARROWING, joined cube` (cross-object measure). - The changeset carries the `**BREAKING**` sentence (the class and its remedy) and the disposition `registered analytics-cube-single-granularity-default-enforced`, a new ADR-0087 D3 semantic entry (round 2, seat note `5889752648` Q4 = B). - `check-adr-0087-registration`: `✓ 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition`. - Round 2 registered it: `18.analytics-cube-single-granularity-default-enforced.ts` tells authors that a one-interval list is now a default bucket, including one the protocol-18 conversion `cube-sub-day-granularities-removed` minted, and (round 3) which queries grouped by it the engine path refuses: the whole class above, plus every newly bucketed query on a raw-SQL-only host. `registry.ts` is regenerated; `spec-changes.json` and the upgrade guide were regenerated with no change, because neither projects major-18 entries yet. ## Premise checks, against `origin/main` `7510663c87` - **`format` on `CubeMeta`: not done, because the premise does not hold.** - The dataset path surfaces `format` only through `fields[]`. A compiled dataset's `getMeta` projection is `{ name, type, title }` too. - `AnalyticsMetadataResponseSchema` records the narrowing for this (`#6442`). - `content/docs/api/data-api.mdx` already sends clients to `fields[]` for `format`. - The spec contract files (`contracts/analytics-service.ts`, `api/analytics.zod.ts`) are outside this claim's surface. - **`granularities` refusal: none invented.** - The dataset path never compares a requested granularity against the list, so there is no refusal to mirror. - What a multi-entry list should mean ("Supported Granularities") is an open fork in the report. - **`refreshKey` census** (tree `958251b6ac`; `packages/services`, `packages/drivers`, `packages/rest`, non-test): - `refreshKey`: 0 hits. The repo-wide control finds 9 files. - Pre-aggregation, materialized-view and rollup terms: 7 hits, all unrelated (automation subflow rollups, and a driver-sql built-in column flag). - `ICacheService` consumers: plugin-auth rate-limit and secondary storage, runtime inbound rate limit, dispatcher counter store, sms. None is in analytics. - service-analytics reads no job, cache or scheduler service. Its one cache is the request-scoped label map in `dimension-labels.ts#withLabelFetchCache` (the lit control, 1 hit). - A scheduler exists (`service-job`'s `IJobService`: cron, interval and db adapters), and nothing in analytics uses it. - Nothing exists that could key on `refreshKey`, so building a cache is a separate card. ## Verification Final head `162e6c0f01` (round 3; merge base `f4ce10c89d`), unless a line says otherwise. Builds and test runs went through `os-verify-lock.sh`. The `check:*` gates and eslint ran outside it, as the lock's scope prescribes. So did `gen:docs`, after two lock acquisitions for `check:generated --fix` timed out in the queue (exit 99). - **Round 3 (`162e6c0f01`):** service-analytics 136/3185 (includes the new joined-cube pin), typecheck clean; runtime REST pin 1/3; spec `src/migrations` 3/161; `migrate-meta-engine-guidance.test.ts` 3/3; spec `--project repo` 43/761. Lit/dark: with the fill ablated, the pin file goes 7 red (the 6 prior bucketing and narrowing cases plus the joined pin), and the probe's E1 to E7 and R1 each answer 200; on head each is refused. `dispatch-gates --ran`: 115 derived / 115 run / 0 NOT MEASURED, 114 exit 0, 1 exit 1 (`check:platform-checklist`, not a PR gate). Regenerated artefacts vs `origin/main`: `registry.ts` +62/-0 (the entry block only), `spec-changes.json` and the upgrade guide identical. Driver-free merge-tree probe against `2473e26875`: clean, and the migration and liveness checks are green on the merge tree. - **Round 2 (`257ab1bc92`):** `migrate-meta-engine-guidance.test.ts` 3/3; spec `--project repo` 43/761; spec `src/migrations` 3/161; `check:migration-registry`, `check:spec-changes`, `check:upgrade-guide`, `check-adr-0087-registration` green; `dispatch-gates --ran` 115 derived / 115 run / 0 NOT MEASURED, 114 exit 0, 1 exit 1 (`check:platform-checklist`, inputs equal to merge base `3f45b6cc13`). The lines below are round 1's, at `d665865d5b`. - **Builds (①):** - `pnpm --filter '@objectstack/service-analytics...' build`: exit 0. - `turbo run build --filter='@objectstack/runtime^...'`: 29/29. - `pnpm --filter @objectstack/spec build`, after the describe edit: exit 0. - **Tests (②):** | suite | files | tests | result | |:--|--:|--:|:--| | service-analytics, whole package | 135 | 3178 | pass | | new service-door file | — | 13 | included above | | spec `--project local` | 575 | 16917 (+1 todo) | pass | | spec `--project repo` | 42 | 745 | pass | | runtime: the new REST pin plus the 2 sibling harness files that consume service-analytics | 3 | 24 | pass | | rest: the 7 files that import service-analytics | 7 | 86 | pass (at `958251b6ac`; service-analytics src is unchanged since) | - Typecheck is clean for spec, service-analytics and runtime. Runtime's includes `check:test-typecheck`, and the new file adds no debt. - `tsc --listFiles` puts the new service test in service-analytics' program. - Two consumers were not run, and both are unaffected by construction because their cubes declare no `format` and no time dimension: `packages/client` `analytics-automation-json-erasure.test.ts`, and the dogfood analytics files, which declare neither key. - **Ablations (predicted before each run; every leg restored to the HEAD blob with `git diff HEAD` empty):** | mutation | suite | predicted | observed | at | |:--|:--|:--|:--|:--| | format early-return (`9d77adcd798f` → `0d48cfde9469`) | service door | 4 red | 4 red, 8 green | `9bf3b3b0b4` | | format early-return | REST, through `dist/` | 1 red | 1 red, 2 green | `9bf3b3b0b4` | | granularity early-return (`9d77adcd798f` → `94b1bc63165a`) | service door | 6 red | 6 red, 7 green | `958251b6ac` | | granularity early-return | REST, through `dist/` | 2 red | 2 red, 1 green | `9bf3b3b0b4` | - Both REST legs were rebuilt, then checked with `ablation-dist-preflight` (marker present in 2 built files). Each restore leg was rebuilt again and checked `--absent`, with the tree clean. - **Gates:** - `dispatch-gates --commands`: 111 derived. `--ran` with exit codes: 111 run, 0 NOT MEASURED. 109 exit 0. - Two exit 1, both pre-existing. Their inputs are byte-identical to the merge base `1322cc72c`: - `check:platform-checklist`: `areas/identity-auth.json` cites `auth-plugin.ts#twoFactor`, which is absent; - `check:docs-transcript-drift`: 4 CLI transcripts print "author-time rules (47)", and the count derives to 46. - `check:liveness`: `analytics_cube 27 classified (live 20, dead 7)`, with the state-counts current. - `check:generated`: all 15 artifacts up to date. - **Lint, narrowed:** - `eslint --no-inline-config --format json` on the 4 changed `.ts` files: 4 files, 0 errors, 0 warnings. - The other 4 changed paths (`.md` / `.json`) are reported by eslint itself as "File ignored because no matching configuration". - Invariance: `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`), so this diff cannot move a verdict on an untouched file. ## Acceptance notes - **File-surface amendments to claim `5886559774`,** each forced by the claim's own items: - the `analytics_cube` Notes cell in `packages/spec/liveness/README.md`. It is the prose half of the state table whose shard the claim names, and it named both keys as dead. - `packages/runtime/src/analytics-authored-cube-format-granularity.test.ts`, the REST pin the claim asks for "where the analytics harness reaches". It is a new file, and the runtime harness drives the real dispatcher route. - the generated `content/docs/references/data/analytics.mdx`, which the describes regenerate. - the D3 entry, the regenerated `migrations/registry.ts` and the changeset marker, per seat note `5889752648`. - `packages/services/service-analytics/src/preview-evaluator.ts` still open-codes the single-entry rule (`dim.granularities?.length === 1`) on the draft-preview path. That makes it a third spelling beside `declaredDefaultGranularity`. It is outside this surface; `carrier:` 承接者:无. - `examples/app-showcase/src/data/analytics/showcase.cube.ts` authors `done_rate: { format: 'percent' }`. That named style now reaches `fields[].format` verbatim, and a numeral-pattern renderer does not read it as a percentage. The spec describe now teaches the pattern vocabulary. The example's value is outside this surface and is reported to the seat. - Carried from stage 1 and unchanged here: the open-core `os serve` artifact-fallback boot threads no `analyticsCubes`. --- _Generated by [Claude Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1a75e39 commit c8dd8dd

12 files changed

Lines changed: 908 additions & 28 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-analytics': minor
4+
---
5+
6+
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).
7+
8+
Clause-②: yes (narrowing)
9+
10+
<!-- adr-0087: registered analytics-cube-single-granularity-default-enforced -->
11+
12+
**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.
13+
14+
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:
15+
16+
- **`measures.<metric>.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%"`.
17+
- **`dimensions.<dimension>.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.
18+
19+
What to expect after upgrading:
20+
21+
- **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.
22+
- **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.
23+
- **A cube time dimension that declares several, or none**, behaves exactly as before.
24+
- **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.
25+
26+
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.

‎content/docs/references/data/analytics.mdx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ Type: `[string, string]`
148148
| **description** | `string` | optional | |
149149
| **type** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'number' \| 'string' \| 'boolean'>` | ✅ | |
150150
| **sql** | `string` | ✅ | SQL expression or field reference |
151-
| **format** | `string` | optional | |
151+
| **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. |
152152

153153
### Nested Shape: `Cube.dimensions[string]`
154154

@@ -159,7 +159,7 @@ Type: `[string, string]`
159159
| **description** | `string` | optional | |
160160
| **type** | `Enum<'string' \| 'number' \| 'boolean' \| 'time' \| 'geo'>` | ✅ | |
161161
| **sql** | `string` | ✅ | SQL expression or column reference |
162-
| **granularities** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>[]` | optional | |
162+
| **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. |
163163

164164
### Nested Shape: `Cube.joins[string]`
165165

@@ -199,7 +199,7 @@ Type: `[string, string]`
199199
| **description** | `string` | optional | |
200200
| **type** | `Enum<'string' \| 'number' \| 'boolean' \| 'time' \| 'geo'>` | ✅ | |
201201
| **sql** | `string` | ✅ | SQL expression or column reference |
202-
| **granularities** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>[]` | optional | |
202+
| **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. |
203203

204204

205205
---
@@ -228,7 +228,7 @@ Type: `[string, string]`
228228
| **description** | `string` | optional | |
229229
| **type** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'number' \| 'string' \| 'boolean'>` | ✅ | |
230230
| **sql** | `string` | ✅ | SQL expression or field reference |
231-
| **format** | `string` | optional | |
231+
| **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. |
232232

233233

234234
---
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `analytics_cube.measures.format` and `analytics_cube.dimensions.granularities`
5+
* over the wire: an AUTHORED cube's two keys reach `POST /api/v1/analytics/query`
6+
* and `POST /api/v1/analytics/sql` through the real dispatcher route.
7+
*
8+
* The service door is pinned beside the implementation
9+
* (`service-analytics` `cube-authored-format-granularity.test.ts`, each case
10+
* against a compiled-dataset control). This file asks the question that pin
11+
* cannot: does what the service answers survive the route — `fields[].format`
12+
* is a member `deps.success()` relays verbatim, and the dry-run door serves
13+
* the bucketed statement `query()` runs.
14+
*
15+
* The cube is handed to the service as `AnalyticsServiceConfig.cubes`, the
16+
* config key the CLI threads an app's `analyticsCubes` into — the authoring
17+
* door, not a registered dataset.
18+
*/
19+
20+
import { describe, it, expect } from 'vitest';
21+
import { CubeSchema } from '@objectstack/spec/data';
22+
import { AnalyticsService } from '@objectstack/service-analytics';
23+
24+
import { createDispatcherPlugin } from './dispatcher-plugin.js';
25+
26+
// ── harness (the shape `analytics-query-read-scope-withhold.test.ts` uses) ────
27+
28+
type Handler = (req: unknown, res: unknown) => unknown;
29+
30+
function makeFakeServer() {
31+
const handlers: Record<string, Handler> = {};
32+
const rec = (verb: string) => (path: string, handler: Handler) => {
33+
handlers[`${verb} ${path}`] = handler;
34+
};
35+
return {
36+
handlers,
37+
server: { get: rec('GET'), post: rec('POST'), put: rec('PUT'), delete: rec('DELETE'), patch: rec('PATCH') },
38+
};
39+
}
40+
41+
function makeCtx(fakeServer: unknown, analytics: unknown) {
42+
const kernel = {
43+
getService: (name: string) => (name === 'analytics' ? analytics : undefined),
44+
getServiceAsync: async (name: string) => (name === 'analytics' ? analytics : undefined),
45+
};
46+
return {
47+
getKernel: () => kernel,
48+
getService: (name: string) => (name === 'http.server' ? fakeServer : undefined),
49+
environmentId: undefined,
50+
logger: { info() {}, warn() {}, error() {}, debug() {} },
51+
hook: () => {},
52+
on: () => {},
53+
} as any;
54+
}
55+
56+
function makeRes() {
57+
const res: any = {
58+
statusCode: undefined as number | undefined,
59+
body: undefined as any,
60+
status(c: number) { res.statusCode = c; return res; },
61+
header() { return res; },
62+
json(b: unknown) { res.body = b; return res; },
63+
};
64+
return res;
65+
}
66+
67+
/** Drive the REAL `POST /api/v1/analytics/<sub>` route against `analytics`. */
68+
async function post(analytics: unknown, sub: 'query' | 'sql', body: unknown) {
69+
const { server, handlers } = makeFakeServer();
70+
const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false });
71+
await plugin.start?.(makeCtx(server, analytics));
72+
const handler = handlers[`POST /api/v1/analytics/${sub}`];
73+
expect(handler, `POST /api/v1/analytics/${sub} must be mounted`).toBeTypeOf('function');
74+
const res = makeRes();
75+
await handler({ body, query: {} }, res);
76+
return res;
77+
}
78+
79+
const silent = { debug() {}, info() {}, warn() {}, error() {} };
80+
81+
/** Parsed the way `defineCube()` and `defineStack({ analyticsCubes })` parse an authored cube. */
82+
const orders = CubeSchema.parse({
83+
name: 'orders',
84+
sql: 'shop_order',
85+
measures: {
86+
count: { label: 'Orders', type: 'count', sql: '*' },
87+
revenue: { label: 'Revenue', type: 'sum', sql: 'amount', format: '$0,0.00' },
88+
},
89+
dimensions: {
90+
status: { label: 'Status', type: 'string', sql: 'status' },
91+
placed_at: { label: 'Placed', type: 'time', sql: 'placed_at', granularities: ['month'] },
92+
},
93+
});
94+
95+
type GroupByItem = string | { field: string; dateGranularity?: string };
96+
97+
/** The composition `AnalyticsServicePlugin` wires by default: both strategies. */
98+
function analytics() {
99+
const groupBys: GroupByItem[][] = [];
100+
const service = new AnalyticsService({
101+
logger: silent,
102+
cubes: [orders],
103+
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: true, inMemory: false }),
104+
executeRawSql: async () => [{ status: 'open', count: 2, revenue: 10 }],
105+
executeAggregate: async (_object, options) => {
106+
groupBys.push((options.groupBy ?? []) as GroupByItem[]);
107+
return [{ placed_at: '2026-07', count: 2 }];
108+
},
109+
});
110+
return { service, groupBys };
111+
}
112+
113+
describe('POST /analytics/query — an authored cube measure\'s `format` reaches `fields[]`', () => {
114+
it('the measure column carries the declared format; an undeclared one and a dimension carry none', async () => {
115+
const res = await post(analytics().service, 'query', {
116+
cube: 'orders',
117+
measures: ['orders.revenue', 'orders.count'],
118+
dimensions: ['orders.status'],
119+
});
120+
121+
expect(res.statusCode).toBe(200);
122+
const fields = res.body.data.fields as Array<{ name: string; format?: string }>;
123+
expect(fields.find((f) => f.name === 'orders.revenue')?.format).toBe('$0,0.00');
124+
expect(fields.find((f) => f.name === 'orders.count')).not.toHaveProperty('format');
125+
expect(fields.find((f) => f.name === 'orders.status')).not.toHaveProperty('format');
126+
});
127+
});
128+
129+
describe('an authored time dimension\'s single declared granularity is its default bucket over the wire', () => {
130+
it('POST /analytics/query groups the dimension at the declared granularity', async () => {
131+
const { service, groupBys } = analytics();
132+
133+
const res = await post(service, 'query', { cube: 'orders', measures: ['count'], dimensions: ['placed_at'] });
134+
135+
expect(res.statusCode).toBe(200);
136+
expect(groupBys).toEqual([[{ field: 'placed_at', dateGranularity: 'month' }]]);
137+
expect(res.body.data.rows).toEqual([{ placed_at: '2026-07', count: 2 }]);
138+
});
139+
140+
it('POST /analytics/sql dry-runs the bucketed statement, and a stated granularity still wins', async () => {
141+
const declared = await post(analytics().service, 'sql', { cube: 'orders', measures: ['count'], dimensions: ['placed_at'] });
142+
const stated = await post(analytics().service, 'sql', {
143+
cube: 'orders',
144+
measures: ['count'],
145+
dimensions: ['placed_at'],
146+
timeDimensions: [{ dimension: 'placed_at', granularity: 'year' }],
147+
});
148+
149+
expect(declared.statusCode).toBe(200);
150+
expect(declared.body.data.sql).toMatch(/date_trunc\('month'/i);
151+
expect(stated.statusCode).toBe(200);
152+
expect(stated.body.data.sql).toMatch(/date_trunc\('year'/i);
153+
});
154+
});

0 commit comments

Comments
 (0)