Skip to content

Commit 39ab294

Browse files
fix(service-analytics)!: the cube door asks the aggregate × field-type table for every measure (#21044) (#21128)
Fixes #21044 Clause-②: no (narrowing) ## What changed A configured cube's `max` over a `text` column was served at the cube door (`POST /api/v1/analytics/query`) by the native-SQL strategy, with the column's text, while the same response's `fields[]` declared the measure `number`. The dataset door refuses that pair at compile by the spec table `AGGREGATE_FIELD_TYPE_COMPATIBILITY`, and the cube door consulted nothing. Triage's direction (`5924500811`) is carried out as ruled: the cube door asks the one table, and no second table exists. - **The judgment** (`packages/services/service-analytics/src/cube-measure-field-type-door.ts`, new, beside `measure-result-type.ts`): `assertCubeMeasureFieldTypesAccepted` refuses the first `measures` entry whose aggregate the table refuses for its column's declared type, `INVALID_FIELD` / 400 through `invalidMemberError`, with `member`, `param: 'measures'`, `cube`, `field` and `object` on the error. The verdict is `isAggregateCompatibleWithFieldType`'s; the accepted set the words name is read off the exported table. It judges every row of the table, as the dataset door does since decision batch #127, with no scope condition on top of it. `count_distinct` keeps its own door (#20912, `structured-json-dimension-door.ts`), which asks the same row plus the `multiple: true` declaration, so one pair has one verdict and one wording. - **Where it runs** (`analytics-service.ts`): `assertMeasureFieldTypes`, called in `ensureCube` on all three paths (inferred cube, augmented cube, declared cube), right after the #20807 / #20912 door and before the `where` gate. That is ahead of `callCtx` and strategy selection, so both strategies see it once and nothing is read before it answers. The same placement covers the dry run (`POST /api/v1/analytics/sql`) and every query `DatasetExecutor` runs through `queryIn`. - **The column description** (`analytics-service.ts`): `withMeasureResultTypes`, applied at the result seam in `queryIn` beside `withDeclaredMeasureFormats`, asks the dataset door's one rule, `measureResultType`, with the cube measure's aggregate and the declared type of the column it reads, and writes only what the rule answers. A `min` / `max` over `date` / `datetime` / `time` is now described `time`. ⛔ No copy of the rule in `buildFieldMeta`: both strategies are untouched. - **`measure-result-type.ts`**: TSDoc only, one paragraph naming the cube door as the rule's second reader. - **`.changeset/21044-cube-measure-field-type-table.md`**: `@objectstack/service-analytics` minor, BREAKING banner, `Clause-②: no (narrowing)`, ADR-0087 `not-required (already-registered dataset-measure-selecting-aggregate-field-type-refused, dataset-measure-aggregate-field-type-refused)`. `check:adr-0087-registration` accepts it. ### The four measured questions of the dispatch - **H1, the card's reading on `main`.** Confirmed through the real dispatcher route at base `2821e9f15b`, SQLite and PostgreSQL 16.13, both strategies (table below). The native face served every refused `min` / `max` pair with the column's text under `fields[]` `number`. Since PR #21037 the ObjectQL face already answered `400 INVALID_FIELD`, from the engine's aggregate door, after the strategy had begun: the words name the engine's position (`aggregate('…'): aggregations[0].field takes the max of 'note', a declared text field…`), and the error carries no `member`. `sum` over the same text column answered `0` on SQLite on both faces and `500 DATABASE_ERROR` on PostgreSQL; `avg` answered `0` / `500` on the native face and `400` on the ObjectQL face. - **H2, where the door learns the source field type.** From the resolver this file already uses for measure columns: `declaredMemberEntry(cube, member, 'measure')` (the one `withDeclaredMeasureFormats` reads) gives the cube measure, its `sql` is the column when it is a bare identifier, and the type is `sourceFieldMeta(object, column).type`, the base-object declaration the #20807 / #20912 door and `compile()` already read. ⛔ No second resolution of a member to a field. A relationship-path column is not judged (the declaration read is the base object's), which is the dataset door's tier. - **H3, where the refusal goes and its code.** In `ensureCube`, as above. The code is `INVALID_FIELD` / 400, not the dataset door's `DATASET_INVALID` / 400, for the reason `dataset-refusal.ts`'s header gives: `DATASET_INVALID` is a verdict about a dataset document, and `/analytics/query` carries none; a verdict about one member the request named is the `INVALID_FIELD` family. It is also the code the cube door's three source-field gates and its #20912 `count_distinct` door answer, and the code the engine's door already answered for this very pair on the ObjectQL face, so that face's wire code does not move. The dataset door keeps `DATASET_INVALID` at compile and never reaches this door for a pair it refuses. - **H4, `fields[]` for an accepted non-numeric pair.** By `measureResultType`, read at the cube door's result seam without widening the claimed surface: `min` / `max` over a temporal column is `time`; over a `boolean` column the rule declines (three readings disagree), so the producer's `number` stands, which is what SQLite (`1`) and the ObjectQL face on PostgreSQL (`1`) answer. Triage's second sentence, "`buildFieldMeta` stops minting `number` for a non-numeric `min` / `max`", is delivered at the seam both strategies' results leave through rather than inside `buildFieldMeta`, which has no field types to read: a refused pair never reaches a descriptor, a temporal one is re-described `time` by the one rule, and a boolean one keeps `number` by that rule's own verdict. ## Per-row readings, before and after | driver | strategy | measure | pair | before (`2821e9f15b`): status, value, `fields[]` type | after (`d85b700030`) | |:--|:--|:--|:--|:--|:--| | SQLite | native SQL | config `max_note` | `max` over note (text) | 200, `"y"`, `number` | 400 `INVALID_FIELD` | | SQLite | native SQL | config `min_note` | `min` over note (text) | 200, `"x"`, `number` | 400 `INVALID_FIELD` | | SQLite | native SQL | config `max_status` | `max` over status (select) | 200, `"won"`, `number` | 400 `INVALID_FIELD` | | SQLite | native SQL | config `max_opened` | `max` over opened_at (datetime) | 200, `"2026-03-04T05:06:07.000Z"`, `number` | 200, `"2026-03-04T05:06:07.000Z"`, `time` | | SQLite | native SQL | config `min_due` | `min` over due_on (date) | 200, `"2026-01-15"`, `number` | 200, `"2026-01-15"`, `time` | | SQLite | native SQL | config `max_flag` | `max` over flag (boolean) | 200, `1`, `number` | 200, `1`, `number` | | SQLite | native SQL | config `max_amount` | `max` over amount (number) control | 200, `32`, `number` | 200, `32`, `number` | | SQLite | native SQL | config `sum_note` | `sum` over note (text) | 200, `0`, `number` | 400 `INVALID_FIELD` | | SQLite | native SQL | config `avg_note` | `avg` over note (text) | 200, `0`, `number` | 400 `INVALID_FIELD` | | SQLite | native SQL | config `cd_note` | `count_distinct` over note (text) control | 200, `2`, `number` | 200, `2`, `number` | | SQLite | native SQL | inferred `note_max` | `max` over note (text) | 200, `"y"`, `number` | 400 `INVALID_FIELD` | | SQLite | native SQL | inferred `amount_max` | `max` over amount (number) control | 200, `32`, `number` | 200, `32`, `number` | | SQLite | native SQL | inferred `opened_at_max` | `max` over opened_at (datetime) | 200, `"2026-03-04T05:06:07.000Z"`, `number` | 200, `"2026-03-04T05:06:07.000Z"`, `time` | | SQLite | native SQL | augmented `note_max` | `max` over note (text) | 200, `"y"`, `number` | 400 `INVALID_FIELD` | | SQLite | ObjectQL | config `max_note` | `max` over note (text) | 400 `INVALID_FIELD` | 400 `INVALID_FIELD` | | SQLite | ObjectQL | config `min_note` | `min` over note (text) | 400 `INVALID_FIELD` | 400 `INVALID_FIELD` | | SQLite | ObjectQL | config `max_status` | `max` over status (select) | 400 `INVALID_FIELD` | 400 `INVALID_FIELD` | | SQLite | ObjectQL | config `max_opened` | `max` over opened_at (datetime) | 200, `"2026-03-04T05:06:07.000Z"`, `number` | 200, `"2026-03-04T05:06:07.000Z"`, `time` | | SQLite | ObjectQL | config `min_due` | `min` over due_on (date) | 200, `"2026-01-15"`, `number` | 200, `"2026-01-15"`, `time` | | SQLite | ObjectQL | config `max_flag` | `max` over flag (boolean) | 200, `1`, `number` | 200, `1`, `number` | | SQLite | ObjectQL | config `max_amount` | `max` over amount (number) control | 200, `32`, `number` | 200, `32`, `number` | | SQLite | ObjectQL | config `sum_note` | `sum` over note (text) | 200, `0`, `number` | 400 `INVALID_FIELD` | | SQLite | ObjectQL | config `avg_note` | `avg` over note (text) | 400 `INVALID_FIELD` | 400 `INVALID_FIELD` | | SQLite | ObjectQL | config `cd_note` | `count_distinct` over note (text) control | 200, `2`, `number` | 200, `2`, `number` | | SQLite | ObjectQL | inferred `note_max` | `max` over note (text) | 400 `INVALID_FIELD` | 400 `INVALID_FIELD` | | SQLite | ObjectQL | inferred `amount_max` | `max` over amount (number) control | 200, `32`, `number` | 200, `32`, `number` | | SQLite | ObjectQL | inferred `opened_at_max` | `max` over opened_at (datetime) | 200, `"2026-03-04T05:06:07.000Z"`, `number` | 200, `"2026-03-04T05:06:07.000Z"`, `time` | | SQLite | ObjectQL | augmented `note_max` | `max` over note (text) | 400 `INVALID_FIELD` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | native SQL | config `max_note` | `max` over note (text) | 200, `"y"`, `number` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | native SQL | config `min_note` | `min` over note (text) | 200, `"x"`, `number` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | native SQL | config `max_status` | `max` over status (select) | 200, `"won"`, `number` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | native SQL | config `max_opened` | `max` over opened_at (datetime) | 200, `"2026-03-04T05:06:07.000Z"`, `number` | 200, `"2026-03-04T05:06:07.000Z"`, `time` | | PostgreSQL 16.13 | native SQL | config `min_due` | `min` over due_on (date) | 200, `"2026-01-15"`, `number` | 200, `"2026-01-15"`, `time` | | PostgreSQL 16.13 | native SQL | config `max_flag` | `max` over flag (boolean) | 500 `DATABASE_ERROR` | 500 `DATABASE_ERROR` | | PostgreSQL 16.13 | native SQL | config `max_amount` | `max` over amount (number) control | 200, `32`, `number` | 200, `32`, `number` | | PostgreSQL 16.13 | native SQL | config `sum_note` | `sum` over note (text) | 500 `DATABASE_ERROR` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | native SQL | config `avg_note` | `avg` over note (text) | 500 `DATABASE_ERROR` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | native SQL | config `cd_note` | `count_distinct` over note (text) control | 200, `2`, `number` | 200, `2`, `number` | | PostgreSQL 16.13 | native SQL | inferred `note_max` | `max` over note (text) | 200, `"y"`, `number` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | native SQL | inferred `amount_max` | `max` over amount (number) control | 200, `32`, `number` | 200, `32`, `number` | | PostgreSQL 16.13 | native SQL | inferred `opened_at_max` | `max` over opened_at (datetime) | 200, `"2026-03-04T05:06:07.000Z"`, `number` | 200, `"2026-03-04T05:06:07.000Z"`, `time` | | PostgreSQL 16.13 | native SQL | augmented `note_max` | `max` over note (text) | 200, `"y"`, `number` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | ObjectQL | config `max_note` | `max` over note (text) | 400 `INVALID_FIELD` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | ObjectQL | config `min_note` | `min` over note (text) | 400 `INVALID_FIELD` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | ObjectQL | config `max_status` | `max` over status (select) | 400 `INVALID_FIELD` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | ObjectQL | config `max_opened` | `max` over opened_at (datetime) | 200, `"2026-03-04T05:06:07.000Z"`, `number` | 200, `"2026-03-04T05:06:07.000Z"`, `time` | | PostgreSQL 16.13 | ObjectQL | config `min_due` | `min` over due_on (date) | 200, `"2026-01-15"`, `number` | 200, `"2026-01-15"`, `time` | | PostgreSQL 16.13 | ObjectQL | config `max_flag` | `max` over flag (boolean) | 200, `1`, `number` | 200, `1`, `number` | | PostgreSQL 16.13 | ObjectQL | config `max_amount` | `max` over amount (number) control | 200, `32`, `number` | 200, `32`, `number` | | PostgreSQL 16.13 | ObjectQL | config `sum_note` | `sum` over note (text) | 500 `DATABASE_ERROR` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | ObjectQL | config `avg_note` | `avg` over note (text) | 400 `INVALID_FIELD` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | ObjectQL | config `cd_note` | `count_distinct` over note (text) control | 200, `2`, `number` | 200, `2`, `number` | | PostgreSQL 16.13 | ObjectQL | inferred `note_max` | `max` over note (text) | 400 `INVALID_FIELD` | 400 `INVALID_FIELD` | | PostgreSQL 16.13 | ObjectQL | inferred `amount_max` | `max` over amount (number) control | 200, `32`, `number` | 200, `32`, `number` | | PostgreSQL 16.13 | ObjectQL | inferred `opened_at_max` | `max` over opened_at (datetime) | 200, `"2026-03-04T05:06:07.000Z"`, `number` | 200, `"2026-03-04T05:06:07.000Z"`, `time` | | PostgreSQL 16.13 | ObjectQL | augmented `note_max` | `max` over note (text) | 400 `INVALID_FIELD` | 400 `INVALID_FIELD` | "Before" is base `2821e9f15b`; "after" is this branch's head `d85b700030` (two merges of `main` in, the second carrying #21098's 401 for an anonymous analytics caller, so the probe signs its caller in). Both were read through the real `dispatcher-plugin` mount of `POST /api/v1/analytics/query`, over `AnalyticsServicePlugin` composed on a real `ObjectQL` engine and `SqlDriver`, by a scratch probe that was deleted after each run. Two rows of a ledger: `note` `x` / `y` (text), `status` `open` / `won` (select), two instants and two days, `flag` `true` / `false`, `amount` `10` / `32`. "Inferred" is an unregistered cube name (the object's), "augmented" a suffix-inferred measure on the configured cube. The same 56 readings were taken again after the first merge of `main` (`0abe2120fb`): no row differs from the head's. Since #21103 landed (in the second merge) the engine's door also refuses `sum` over a refused type, so on the ObjectQL face the `sum` rows would answer `400` without this change too; this door answers first. ## Pins (red first), the ablation - **Red, on the tree committed as `be5c1a69b2`** (pins only, no fix; base `2821e9f15b`), SQLite and a private PostgreSQL 16.13: - `service-analytics` `src/__tests__/cube-measure-field-type-door.test.ts` (new) and the flipped `native-sql-measure-number-presentation.test.ts`: 16 failed, 16 passed, 1 skipped. Failures: `max_note must not be served: expected { rows: [ { max_note: 'y' } ], …(1) } to be undefined` (native), `max_note: expected undefined to be 'max_note'` (ObjectQL: the engine's refusal carries no `member`), `max_opened is described time: expected 'number' to be 'time'`, `expected the query to be refused, but it resolved` (dry run). - `runtime` `src/analytics-cube-measure-field-type-door.test.ts` (new): 6 failed, 6 passed. Native `max_note` answered `200`; both faces described `max_opened` `number`. The ObjectQL face's `400 INVALID_FIELD` and both faces' `number` controls were green already. - **Fixture triage.** `native-sql-measure-number-presentation.test.ts` (#20889) read a configured cube's `max` over its `code` text column back as text, as the second half of its "keyed on the declared function, never on the value" control. That pin held exactly the served pair this card refuses, so it is flipped, not deleted: the case now asserts `INVALID_FIELD` / 400 with no statement run, keeps its text-dimension half, and the cube read above it no longer asks for `max_code`. - **Green, at `d85b700030`:** the same files 32 passed, 1 skipped (the PostgreSQL native `max` over `boolean` cell, a named skip: an accepted pair that is a 500 there, see Acceptance notes) and 12 passed. - **Ablation** (the cube door's table check removed), from committed code at `5d69394a60`. Predicted before running, in `progress.log`: service-analytics 12 red (per dialect: the native refusal, the ObjectQL refusal, both inferred-measure cases, the dry run and the flipped #20889 case), 20 green, 1 skipped; runtime 2 red (the native refusal on both dialects), 10 green, because the engine's door still answers the ObjectQL face's `400 INVALID_FIELD` on the wire. - The mutation went through `scripts/ablation-replace.mjs` (WRAP mode, trap-restored, absolute path): `if (isAggregateCompatibleWithFieldType(aggregate, declared)) continue;` gained `|| String(aggregate) !== 'ABLATION-21044'`, so every pair passes. Anchor 1 to 0, marker 0 to 1, blob `6c7bdce48e43` to `f49be3058c84`. A second `trap` in the outer script restored by absolute path too. - `service-analytics` was rebuilt, and `ablation-dist-preflight.mjs` found the marker in 2 built files (`dist/index.cjs`, `dist/index.js`). - Observed: service-analytics 12 failed, 20 passed, 1 skipped; runtime 2 failed, 10 passed. Exactly as predicted. - Restore: blob after restore `6c7bdce48e43` equals HEAD, `git diff HEAD` empty, whole-tree porcelain 0 lines. Then rebuilt, and `ablation-dist-preflight.mjs --absent`: marker absent from all 6 built files and the tree clean. The pins are green again at both later heads. ## Tests (at `d85b700030`, after `pnpm install --frozen-lockfile` and a full `turbo run build`, 73 tasks) - `@objectstack/service-analytics`, full suite with `OS_TEST_POSTGRES_URL` set (no PostgreSQL cell skipped): 156 files, 3558 passed, 1 skipped (the named cell above). `typecheck` exit 0; `tsc --noEmit --listFiles` lists both touched test files. - `@objectstack/rest`, `src/analytics-*` and `rest-hook-refusal-message-parity`, with PostgreSQL: 20 files, 279 passed. - `@objectstack/runtime`, `src/analytics-*`, `src/dispatcher*`, `src/http-dispatcher*`, `src/domains/analytics*` and the other analytics-route suites: 51 files, 843 passed. `typecheck` exit 0, `check:test-typecheck` holds its ledger (27 files, 190 errors, 68 signatures; the new file adds none). - Not run locally, declared to CI: the whole `rest` and `runtime` suites, and `packages/qa/dogfood` (`Dogfood Regression Gate`). ## Gates (at `d85b700030`, as ONE sequential script under the shared verify lock, each exit code captured before any pipe) - **Derived families:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` names 62. `--ran` reconciles: `62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED (a DERIVED zero — all 62 recorded an exit code and none of them is 3)`. All 62 exit 0. `check:dual-build-cjs-loads` and `check:type-check-debt` answered `PREREQUISITE NOT MET` (exit 3) on the first sweep at `0abe2120fb`, which had built only the dependency closure; after a full `turbo run build` (73 tasks) both exit 0, and both are 0 in the sweep at this head. - **The five roster families the derivation marks ⛔ (a roster in a directory this diff touches):** `node scripts/check-changeset-fixed.mjs`, `pnpm check:authz-resolver`, `pnpm check:error-code-casing`, `pnpm check:filter-alias-parity`, `pnpm check:route-ledger-census`. All exit 0. - `check:adr-0087-registration` reads the changeset as `[BREAKING+bang+clause-②-narrowing] not-required (already-registered)`. `check-changeset-no-major`: no `major` bump. `check:nul-bytes`: 9734 text files, no raw control bytes. ## ESLint, a declared narrowing (the repo-wide `pnpm lint` is CI's) - Touched files: `eslint --no-inline-config --format json` over the 6 touched `.ts` files at `0abe2120fb` (the two later commits are a merge of `main` and a test-only harness change of 7 lines). From the JSON: 6 files, 0 errors, 0 warnings, 0 ignored. - Population: `eslint.config.mjs`'s `files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']` minus `NEVER_LINTED`, which contains all 6 (none came back ignored). - Invariance: the config enables no type-aware linting (no `parserOptions.project` or `projectService` in any block), and this diff does not touch the config, so no untouched file's verdict can move. ## Beyond the claimed file surface - **The route pin lives in `packages/runtime/src/`, not beside `packages/rest/src/analytics-*.test.ts`.** The cube door, `POST /api/v1/analytics/query`, is mounted by `@objectstack/runtime`'s `dispatcher-plugin`; `RestServer` mounts only `/analytics/dataset/query`, so a pin in `rest` cannot reach this route. The new file is test-only and sits beside the sibling analytics route pins there. `rest`'s analytics suites were run as well (above). - **`sum` / `avg` are judged by the same door.** The claim priced the narrowing as `min` / `max`; the door asks the table for every row, as the dataset door has since decision batch #127, because a `min` / `max`-only condition would be a second scope on top of the one table, the shape `dataset-compiler.ts` records retiring. Measured, those rows answered `0` on SQLite and `500` on PostgreSQL before (table above), and the changeset prices them. No shipped cube authors either. ## Acceptance notes - **NOT MEASURED: MySQL** (no server here). The door runs before any driver, so its verdict cannot depend on the dialect; the `time` description reads metadata only. - **NOT MEASURED locally: `packages/qa/dogfood`** (`Dogfood Regression Gate` is CI's). The one shipped cube, `examples/app-showcase`'s `showcase_delivery`, declares `count` over `*` and `sum` / `avg` over `estimate_hours` (`Field.number`): every pair accepted. Every other `min` / `max` / `sum` / `avg` in `examples/**` is a dataset measure, which the dataset door already judged. - **NOT MEASURED: the console.** A console widget that sends a suffix-inferred `FIELD_max` / `FIELD_sum` to `/analytics/query` over a refused field now gets `400 INVALID_FIELD`; the sibling repository was not read (dispatch order). - **Relationship-path measures are not judged here, measured at the head:** a configured measure `{ type: 'max', sql: 'account.name' }` over a related `text` field is still served on the native face (`200`, `"zeta"`, `fields[]` `number`, SQLite and PostgreSQL), and `{ type: 'max', sql: 'account.revenue' }` over a related `number` field answers the string `"250.000000000000000000000000000000"` on PostgreSQL's native face under `fields[]` `number`. The ObjectQL face refuses both as a cross-object measure (`400 INVALID_FIELD`). Judging them needs the declaration on the hop's object, which is the hop-object resolution this dispatch fences off (#20986's sites); the door stands down on a dotted column rather than guess, the dataset door's tier. This is the second position the seat's comment `5924234751` names; reported to the seat, not fixed here. - **PostgreSQL's native face answers `500 DATABASE_ERROR` for `max` over a `boolean` column**, a pair the table ACCEPTS (`function max(boolean) does not exist`); SQLite and the ObjectQL face on PostgreSQL answer `1`. Unchanged by this PR (measured before and after); the boolean pin skips that one cell by name. Reported to the seat. - `main` advanced three commits after the second merge (`a11faeecb3`, `99398542b3`, `53ed3d1093`: objectql's aggregation-filter door, driver-mongodb and the showcase's security set). None touches `service-analytics`, the dispatcher or the spec table; CI and the merge queue read the merged generation. - The private PostgreSQL 16.13 cluster used for every live cell runs on `127.0.0.1` from `/tmp`; it is stopped and its directory removed with this delivery. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b616c0a commit 39ab294

7 files changed

Lines changed: 877 additions & 13 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/service-analytics": minor
3+
---
4+
5+
fix(service-analytics)!: the cube door asks the aggregate × field-type table for every measure, so a configured or suffix-inferred cube measure whose aggregate the table refuses for its column's declared type answers `INVALID_FIELD` / 400 on every driver and both strategies, and a `min` / `max` over a temporal column is described `time` in `fields[]`
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (already-registered dataset-measure-selecting-aggregate-field-type-refused, dataset-measure-aggregate-field-type-refused) the pairs this change refuses are exactly the pairs AGGREGATE_FIELD_TYPE_COMPATIBILITY already refuses, and the table is not edited: every refused min / max pair is registered under protocol major 18 by the first id and every refused sum / avg pair by the second, each with its routes (count, a sort for a first or last record, or a numeric / temporal field for a quantity stored as text). This change adds a query-time reader of the same table at the analytics cube door; it refuses a query shape, not a stored one, and no authorable key, export or stored row moves: CubeSchema and the analytics query body keep parsing every member. -->
10+
11+
**BREAKING**: this narrows what `POST /api/v1/analytics/query` and its dry run `POST /api/v1/analytics/sql` accept, on every driver and on both strategies. It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes.
12+
13+
FROM → TO, for a `measures` entry that resolves to a cube measure over a column of the cube's own object (an authored cube measure, or a suffix-inferred one such as `note_max`):
14+
15+
- `min` / `max` over a type outside the numeric, temporal and boolean classes (the string family such as `text`, `email` and `url`; `select`, `radio`, `lookup`, `user`; `autonumber`; the JSON-stored, file and `formula` types): FROM, on the native-SQL strategy, `200` with the column's own value (a string such as `"y"`) under `fields[] { type: 'number' }`, on SQLite and PostgreSQL alike; on the ObjectQL strategy the engine's door already answered `400 INVALID_FIELD` after the strategy began. TO `400 INVALID_FIELD` before either strategy reads anything.
16+
- `sum` / `avg` over a type outside the numeric and boolean classes (`sum` also refuses `percent`): FROM `200` with a plausible `0` on SQLite and `500 DATABASE_ERROR` on PostgreSQL (the ObjectQL strategy refused `avg` at the engine and passed `sum` to the driver, which answered the same `0` / `500`). TO `400 INVALID_FIELD`.
17+
- `min` / `max` over a `date`, `datetime` or `time` column: FROM `fields[] { type: 'number' }` beside the instant. TO `fields[] { type: 'time' }`, the `DimensionType` word a temporal dimension column already carries, by the same rule the dataset door applies (`measureResultType`).
18+
19+
**What an author sees now.** `400 INVALID_FIELD`, naming the measure as the request wrote it, the cube, the column, the object and its declared type, saying the query was not run, and naming the types the aggregate accepts, read off `AGGREGATE_FIELD_TYPE_COMPATIBILITY`. The thrown error carries `member`, `param` (`measures`), `cube`, `field` and `object`.
20+
21+
**Why a refusal.** The dataset door (`POST /api/v1/analytics/dataset/query`) refuses every one of these pairs at compile by the same table (`DATASET_INVALID`), and the engine's aggregate door refuses most of them on the ObjectQL strategy; the native-SQL strategy compiled its own statement and asked nothing. Measured through the real dispatcher route on SQLite and PostgreSQL 16: a configured cube's `max` over a `text` column answered `"y"` under a column described `number` on the native strategy and `400` on the ObjectQL strategy, and `sum` over the same column answered `0` on SQLite and `500` on PostgreSQL. One cube, one query, an answer chosen by the driver.
22+
23+
**What to write instead.** Aggregate a field of a type the aggregate accepts. A question that was counting in disguise is `count` (or `count_distinct` over a scalar-stored field). A first or last record by a text value is a sort on a list, not an aggregate. A quantity stored as text belongs in a numeric field of its own, aggregated there.
24+
25+
**Who is affected.** A dashboard, report or caller that asked `min` / `max` / `sum` / `avg` of such a column through `/analytics/query` on the native-SQL strategy and read the answer as a real one. No example app and no shipped cube authors such a pair. A reader that branched on `fields[].type === 'number'` for a temporal `min` / `max` column now sees `time`.
26+
27+
**Unchanged.** Every pair the table accepts, a `max` over a `boolean` column included (its column keeps `number`: the rule declines the boolean class); `count` over any column; `count_distinct`, which keeps its own door and words; a measure over a relationship path (`account.name`), which this door does not judge; a column the host's field metadata cannot resolve, or a type outside `FieldType`; a measure whose `sql` is `*`; an expression metric type (`number` / `string` / `boolean`); and a host that wires no `sourceFieldMeta`, where the declaration cannot be read. The dataset door keeps its own `DATASET_INVALID` answer at compile.
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21044] Over the wire: `POST /api/v1/analytics/query`, served by the real
5+
* dispatcher route, refuses a configured cube's `max` over a `text` column with
6+
* the cube door's `INVALID_FIELD` / 400 — on the native-SQL face, which served
7+
* the column's text under `fields[] { type: 'number' }`, and on the ObjectQL
8+
* face, which the engine's aggregate door refused only after the strategy had
9+
* begun — and serves the controls.
10+
*
11+
* The service door is pinned beside the implementation (`service-analytics`
12+
* `cube-measure-field-type-door.test.ts`, both faces, with read counters).
13+
* This file asks the question that pin cannot: does the route relay the
14+
* refusal's envelope as a 400 with its code, and does `fields[]` reach the wire
15+
* as the service describes it.
16+
*
17+
* The analytics service is the one `AnalyticsServicePlugin` composes over a
18+
* real `ObjectQL` engine and `SqlDriver`: `sourceFieldMeta` wired from the
19+
* engine's registry, both auto-bridges live, the cube handed in as
20+
* `AnalyticsServiceConfig.cubes` (the config key the CLI threads an app's
21+
* `analyticsCubes` into). The SQLite cell always runs; the PostgreSQL cell runs
22+
* where `OS_TEST_POSTGRES_URL` is set and is a named skip otherwise. No CI step
23+
* provisions that variable for this package, so the live cell is red-capable
24+
* and un-run in CI; the PR that landed this file carries its local PostgreSQL
25+
* 16 run. The live cell owns its table, dropped before and after.
26+
*/
27+
28+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
29+
import { ObjectQL } from '@objectstack/objectql';
30+
import { SqlDriver } from '@objectstack/driver-sql';
31+
import { AnalyticsServicePlugin } from '@objectstack/service-analytics';
32+
33+
import { createDispatcherPlugin } from './dispatcher-plugin.js';
34+
35+
const OBJECT = 'os21044_route_ledger';
36+
37+
const LEDGER = {
38+
name: OBJECT,
39+
label: 'Cube measure route ledger',
40+
fields: {
41+
note: { name: 'note', type: 'text' as const },
42+
opened_at: { name: 'opened_at', type: 'datetime' as const },
43+
amount: { name: 'amount', type: 'number' as const },
44+
},
45+
};
46+
47+
const ROWS = [
48+
{ id: 'r1', note: 'x', opened_at: '2026-01-02T03:04:05.000Z', amount: 10 },
49+
{ id: 'r2', note: 'y', opened_at: '2026-03-04T05:06:07.000Z', amount: 32 },
50+
] as const;
51+
52+
const CUBE = {
53+
name: 'os21044_route_cube',
54+
title: 'Cube measure route cube',
55+
sql: OBJECT,
56+
public: true,
57+
measures: {
58+
max_note: { type: 'max', sql: 'note', label: 'Largest note (text)' },
59+
max_opened: { type: 'max', sql: 'opened_at', label: 'Latest opening (datetime)' },
60+
max_amount: { type: 'max', sql: 'amount', label: 'Largest amount (number)' },
61+
},
62+
dimensions: {},
63+
};
64+
65+
interface Cell {
66+
id: 'sqlite' | 'pg';
67+
label: string;
68+
env: string | null;
69+
config: () => Record<string, unknown> | null;
70+
}
71+
72+
const CELLS: readonly Cell[] = [
73+
{ id: 'sqlite', label: 'sqlite', env: null, config: () => ({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) },
74+
{
75+
id: 'pg',
76+
label: 'live postgres',
77+
env: 'OS_TEST_POSTGRES_URL',
78+
config: () => (process.env.OS_TEST_POSTGRES_URL ? { client: 'pg', connection: process.env.OS_TEST_POSTGRES_URL } : null),
79+
},
80+
];
81+
82+
const FACES = ['native', 'objectql'] as const;
83+
type Face = (typeof FACES)[number];
84+
85+
const quiet = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } };
86+
87+
// ── harness (the shape `analytics-authored-cube-format-granularity.test.ts` uses) ──
88+
89+
// The analytics domain refuses an anonymous caller first (ADR-0056 D2), so this
90+
// harness signs its caller in the way that file does: an `auth` slot in the
91+
// shape `resolveExecutionContext` reads answers a session for every request.
92+
// Only identity is stubbed; the route, the service and the engine are real.
93+
const SIGNED_IN_AUTH = { api: { getSession: async () => ({ user: { id: 'usr_analytics_caller' } }) } };
94+
95+
type Handler = (req: unknown, res: unknown) => unknown;
96+
97+
function makeFakeServer() {
98+
const handlers: Record<string, Handler> = {};
99+
const rec = (verb: string) => (path: string, handler: Handler) => {
100+
handlers[`${verb} ${path}`] = handler;
101+
};
102+
return {
103+
handlers,
104+
server: { get: rec('GET'), post: rec('POST'), put: rec('PUT'), delete: rec('DELETE'), patch: rec('PATCH') },
105+
};
106+
}
107+
108+
function makeCtx(fakeServer: unknown, analytics: unknown) {
109+
const kernel = {
110+
getService: (name: string) => (name === 'analytics' ? analytics : name === 'auth' ? SIGNED_IN_AUTH : undefined),
111+
getServiceAsync: async (name: string) => (name === 'analytics' ? analytics : name === 'auth' ? SIGNED_IN_AUTH : undefined),
112+
};
113+
return {
114+
getKernel: () => kernel,
115+
getService: (name: string) => (name === 'http.server' ? fakeServer : undefined),
116+
environmentId: undefined,
117+
logger: quiet,
118+
hook: () => {},
119+
on: () => {},
120+
} as any;
121+
}
122+
123+
function makeRes() {
124+
const res: any = {
125+
statusCode: undefined as number | undefined,
126+
body: undefined as any,
127+
status(c: number) { res.statusCode = c; return res; },
128+
header() { return res; },
129+
json(b: unknown) { res.body = b; return res; },
130+
};
131+
return res;
132+
}
133+
134+
for (const cell of CELLS) {
135+
const config = cell.config();
136+
describe.skipIf(!config)(
137+
`[#21044] POST /api/v1/analytics/query — a cube measure the aggregate × field-type table refuses is a 400 — ${cell.label}${config ? '' : ` (skipped: set ${cell.env} to run this cell)`}`,
138+
() => {
139+
let driver: any;
140+
let engine: ObjectQL;
141+
const post: Partial<Record<Face, (body: unknown) => Promise<{ status: number; body: any }>>> = {};
142+
143+
const dropTable = async () => {
144+
if (cell.id === 'pg') await driver?.execute(`drop table if exists ${OBJECT}`).catch(() => {});
145+
};
146+
147+
beforeAll(async () => {
148+
driver = new SqlDriver(config as any);
149+
await dropTable();
150+
engine = new ObjectQL({ logger: quiet } as any);
151+
engine.registerDriver(driver, true);
152+
await engine.init();
153+
engine.registry.registerObject(LEDGER as any);
154+
await engine.syncSchemas();
155+
for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any);
156+
157+
for (const [face, caps] of [
158+
['native', undefined],
159+
['objectql', () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false })],
160+
] as const) {
161+
const registered: Record<string, unknown> = {};
162+
await new AnalyticsServicePlugin({ cubes: [CUBE], ...(caps ? { queryCapabilities: caps } : {}) } as any).init({
163+
getService: (name: string) => (name === 'data' ? engine : registered[name]),
164+
registerService: (name: string, svc: unknown) => { registered[name] = svc; },
165+
replaceService: (name: string, svc: unknown) => { registered[name] = svc; },
166+
hook: () => {},
167+
logger: quiet,
168+
} as never);
169+
const { server, handlers } = makeFakeServer();
170+
const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false });
171+
await plugin.start?.(makeCtx(server, registered.analytics));
172+
const handler = handlers['POST /api/v1/analytics/query'];
173+
expect(handler, 'POST /api/v1/analytics/query must be mounted').toBeTypeOf('function');
174+
post[face] = async (body) => {
175+
const res = makeRes();
176+
// What the wire carries: JSON, both ways.
177+
await handler({ body: JSON.parse(JSON.stringify(body)), query: {} }, res);
178+
return { status: res.statusCode ?? 200, body: JSON.parse(JSON.stringify(res.body ?? null)) };
179+
};
180+
}
181+
});
182+
183+
afterAll(async () => {
184+
await dropTable();
185+
try { await engine?.destroy(); } catch { /* noop */ }
186+
});
187+
188+
for (const face of FACES) {
189+
it(`${face}: max over a text column answers 400 INVALID_FIELD, and nothing is served`, async () => {
190+
const res = await post[face]!({ cube: CUBE.name, measures: ['max_note'] });
191+
expect(res.status, JSON.stringify(res.body)).toBe(400);
192+
expect(res.body.success).toBe(false);
193+
expect(res.body.error.code).toBe('INVALID_FIELD');
194+
expect(res.body.error.httpStatus).toBe(400);
195+
expect(res.body.data).toBeUndefined();
196+
});
197+
198+
it(`${face}: the control — max over a number column is served, a number typed number`, async () => {
199+
const res = await post[face]!({ cube: CUBE.name, measures: ['max_amount'] });
200+
expect(res.status, JSON.stringify(res.body)).toBe(200);
201+
const fields = res.body.data.fields as Array<{ name: string; type: string }>;
202+
expect(res.body.data.rows[0].max_amount).toBe(32);
203+
expect(fields.find((f) => f.name === 'max_amount')?.type).toBe('number');
204+
});
205+
206+
it(`${face}: an accepted temporal pair is served and reaches the wire typed time`, async () => {
207+
const res = await post[face]!({ cube: CUBE.name, measures: ['max_opened'] });
208+
expect(res.status, JSON.stringify(res.body)).toBe(200);
209+
const fields = res.body.data.fields as Array<{ name: string; type: string }>;
210+
expect(res.body.data.rows[0].max_opened).not.toBeNull();
211+
expect(fields.find((f) => f.name === 'max_opened')?.type).toBe('time');
212+
});
213+
}
214+
},
215+
);
216+
}

0 commit comments

Comments
 (0)