@objectstack/service-analytics 17.2.0 → 17.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,904 @@
1
1
  # Changelog — @objectstack/service-analytics
2
2
 
3
+ ## 17.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 6136293: A `min`/`max` over a string-valued field is described as `string`, not `number` (#16098)
8
+
9
+ The sibling population of the temporal fix. `min` and `max` return a value **of the aggregated field's own type**, so a `min` over a `text` / `select` / `lookup` / `autonumber` column carries a string — and `POST /api/v1/analytics/dataset/query` described every one of those columns as `type: "number"`, exactly as it did for the temporal family before the temporal half landed.
10
+
11
+ What changed:
12
+
13
+ - **`measureResultType` now answers `string` for the string-valued field types too**, in the same one table it already answered `time` from. No second mechanism and no new call site: the rule still answers `undefined` for "no correction", and `queryDataset`'s ADR-0021 result-column enrichment still applies it once, downstream of all four producers of the shape.
14
+ - **The corrected spelling is `string`**, the `DimensionType` word a `lookup` or `string` DIMENSION column in the same response already carries (`dataset-compiler.dimensionType`). A textual measure spelled `text` would have been a sixth word in a five-word wire vocabulary, leaving every existing consumer branch unreached — the same argument that chose `time` over `datetime`.
15
+ - **Membership is composed from `@objectstack/spec`'s own value classes** (`STRING_VALUE_TYPES`, `SINGLE_OPTION_TYPES`, `REFERENCE_VALUE_TYPES`) rather than re-listed, so what the platform says a field type STORES and what this rule says a `min` over it RETURNS cannot drift.
16
+
17
+ Corrected: `text`, `textarea`, `email`, `url`, `phone`, `password`, `secret`, `markdown`, `html`, `richtext`, `code`, `color`, `signature`, `qrcode`, `select`, `radio`, `lookup`, `master_detail`, `tree`, `user`, `autonumber` — twenty-one members, each verdict read off the two shipped statements of what the type stores (the spec value contract and `driver-sql`'s DDL column switch).
18
+
19
+ Deliberately NOT corrected, with the measurement recorded rather than a guess shipped as a declaration:
20
+
21
+ - **`boolean` / `toggle`** — Postgres has no `min(boolean)` at all, SQLite answers `0`/`1` as numbers, and the driver seam has been recorded answering `false`/`true`. Three readings that disagree about whether a value exists and what kind it is. `DimensionType` does carry a `boolean` word, so the correction is spellable; it is not made.
22
+ - **The JSON-column classes** (`multiselect` / `checkboxes` / `tags`, `composite` / `repeater` / `record` / `location` / `address` / `vector`, `json`) — no `min` over `jsonb` on Postgres, serialized TEXT on SQLite.
23
+ - **The file types** (`image` / `file` / `avatar` / `video` / `audio`) — their stored form is mid-migration under ADR-0104 D3: the value contract already says an opaque `sys_file` id while the DDL still gives them a JSON column.
24
+ - **`formula`** — its result type IS declared, on `FieldSchema.returnType`, but that key is not on `AnalyticsServiceConfig.sourceFieldMeta`'s return shape and is itself optional.
25
+ - **`summary`** — measured NUMERIC on both shipped statements (the spec's `NUMERIC_VALUE_TYPES`, and `driver-sql`'s `table.float` column), so the `number` it already carried is correct rather than merely unexamined.
26
+
27
+ Every member of `FieldType` now carries an explicit verdict, pinned by a test that walks the enum: a field type added to the spec fails that pin instead of silently inheriting the flat `number`.
28
+ - 07f40e5: A dataset measure's `fields[].type` stops contradicting the value beside it: a `min`/`max` over a temporal field is described as `time`, not `number` (#15768)
29
+
30
+ `POST /api/v1/analytics/dataset/query` described **every** measure column as `type: "number"`, including a `min`/`max` over a `date` / `datetime` / `time` field whose value in the same response is an ISO instant. Measured on a real boot (`@objectstack/cli` 17.3.0, SQLite dev datasource):
31
+
32
+ ```json
33
+ {"rows":[{"oldest_last_update_at":"2026-07-04T07:00:00.000Z"}],
34
+ "fields":[{"name":"oldest_last_update_at","type":"number","label":"Oldest touch","format":"relative"}]}
35
+ ```
36
+
37
+ `min` and `max` return a value **of the aggregated field's own type**, so that column carries an instant and the metadata denied it — which is enough on its own to keep a formatter that branches on the declared type from ever reaching a temporal branch.
38
+
39
+ What changed:
40
+
41
+ - **The measure column's type is resolved from the authored measure plus the source field's declared type**, in `AnalyticsService.queryDataset`'s ADR-0021 result-column enrichment — the same block that already resolves `label` / `format` / `currency` / `percentScale`, and the one seam every producer of the shape passes through on the way to the route, which relays that method's return verbatim. The rule itself is `measureResultType` in the new `measure-result-type.ts`, so the per-aggregate verdict has one home instead of four copies.
42
+ - **The corrected spelling is `time`**, the `DimensionType` word a temporal DIMENSION column in the same response has always carried. A second temporal word in one wire position would have left every existing consumer branch unreached.
43
+ - **Only `min` and `max` move.** `count` and `count_distinct` are numeric however temporal the column they read is; `sum` / `avg` over a temporal column are refused by no layer and answered by the backend (an epoch mean on SQLite, an error on Postgres), so there is no single value for a type to describe and none is invented; a derived measure is numeric by construction, because `computeDerived` coerces its operands with `Number()`. Row values are untouched on every path.
44
+ - **Tiered "cannot answer, do not block".** A host with no source-field metadata wired, and a measure over a relationship PATH (which the source-field lookup resolves against the base object and therefore cannot answer), both leave the column exactly as the query layer produced it.
45
+
46
+ `AnalyticsResult.fields[].type` and the `AnalyticsResultResponse` schema now state the vocabulary this position speaks and what each aggregate answers; neither declaration widens — the wire type was, and remains, a string.
47
+ - 6573af9: A draft-preview `min`/`max` answers the operand's own type instead of `0`, and a preview dimension column is described by its own type
48
+
49
+ `POST /api/v1/analytics/dataset/query` has two producers of one response: the engine, and — when the request renders the as-if-published world over a pending seed draft (ADR-0037 P3) — `evaluateAnalyticsQueryOverRows`. The second one coerced every aggregate operand with `Number()` and dropped the non-finite ones, so a `min` / `max` over a non-numeric field answered `0`. Measured on one dataset and one row set, with two services differing only in whether a pending seed draft exists:
50
+
51
+ ```
52
+ live {"category":"travel","latest_spend":"2026-05-12"}
53
+ preview {"category":"travel","latest_spend":0}
54
+ ```
55
+
56
+ That is not a mislabelled column: it is a different, wrong answer to the same query, with no refusal and no warning, on the path an author is looking at *while* authoring the dataset.
57
+
58
+ What changed, per member of the closed `AggregationFunction` vocabulary:
59
+
60
+ - **`min` / `max` return the winning operand in its own type.** Ordering goes through this file's shared `compare` — so an ISO date orders as a date, a BSON `Date` orders as its instant against wire text, and text orders the way `MIN(text_col)` does on a SQL face — with a numeric arm so a numeric column written as text (`'800'`) still orders numerically. `cross-object-rebucket.ts` settled the identical question for the recombination path: the value these two pick is a value OF the column, so it must come back in the shape the row carried.
61
+ - **A group whose operand is null throughout answers `null`, not `0`** — `emptyGroupValueFor` (`@objectstack/spec/data`) rules `min` / `max` over nothing unanswerable, and `0` reads as a measurement nobody made.
62
+ - **`count_distinct` answers a cardinality again.** Its arm was spelled `countDistinct`, a word no producer mints (`dataset-compiler` copies the spec's `count_distinct` through), so it was unreachable and the measure fell to the numeric default — answering a row count under the author's `count_distinct` name (measured: `3` where the live path says `2`).
63
+ - **`count` stays a row count and `sum` / `avg` stay arithmetic.** Counting dates is still counting.
64
+ - **`sum` / `avg` over a TEMPORAL operand is deliberately unchanged.** There is no defined answer — the SQL faces do not agree on one either — and refusing an incoherent aggregate/field-type pair is an open decision, not this fix's to invent.
65
+ - **A dimension column is typed from the cube dimension**, the same expression both live producers use (`d?.type || 'string'`), so a `date` dataset dimension is `time` on the preview path as it already was on the live one. A MEASURE column keeps the `number` every producer mints; correcting that is the ADR-0021 descriptor pass's one rule, not a second copy here.
66
+
67
+ Derived measures are untouched: `computeDerived` still coerces with `Number()` and answers `null` for a non-finite operand — but a derived ratio over a temporal `min` / `max` now sees a date instead of the spurious `0`, so it answers `null` on the preview path exactly as it already did on the live one.
68
+ - 54bb2f1: The analytics SQL compilers compile the case-sensitive text family per dialect, so a `$contains` policy on SQLite stops admitting rows it excludes (#15684)
69
+
70
+ `$contains` / `$notContains` / `$startsWith` / `$endsWith` are case-SENSITIVE on every backend (#4706 Q2 = A). All three of `service-analytics`' SQL compilers emitted `col LIKE ? ESCAPE ?` on every dialect, and SQLite's `LIKE` folds ASCII case unconditionally — the fold cannot be turned off per statement, because `PRAGMA case_sensitive_like` is a connection-global switch. Measured on sql.js over the shared `FILTER_TEXT_ROWS` fixture, `{ name: { $contains: 'acme' } }` answered `['1','2']` — `ACME Corp` **and** `acme corp` — where `FILTER_TEXT_CASES` says `['2']`.
71
+
72
+ On two of the three compilers that is a wrong chart. The third is `read-scope-sql.ts`, the ADR-0021 D-C read scope: a scope that **admits** rows the policy's case-sensitive predicate excludes is over-reach, not a loose filter — the same reading that file already applied to its own `LIKE` escaping. The `/analytics/sql` echo was wrong in a third way: it printed `LIKE` while the statement it claims to reproduce ran through a driver that has emitted `GLOB` on the SQLite dialects since #6518.
73
+
74
+ What changed:
75
+
76
+ - **The construct is chosen per dialect** (`text-match-sql.ts`), arm for arm with `driver-sql`'s own table: `GLOB` on SQLite (case-exact by definition, with its own `*` / `?` / `[` escaped class and no `ESCAPE` clause), `LIKE` over `CAST(… AS BINARY)` on MySQL, and `LIKE` **unchanged** on Postgres, where it is already exactly the ruled semantics. There is no single construct that is case-exact and parses on all three, so the dialect had to become an input rather than a guess.
77
+ - **The dialect arrives from the driver that will execute the statement.** New optional `AnalyticsServiceConfig.sqlDialect`, wired by `AnalyticsServicePlugin` from `IDataEngine.getDriverForObject`. `SqlDriver.dialectName` is now public so that answer can be read without a second dialect-resolution table drifting behind the driver's own knex spellings; it is derived and read-only.
78
+ - **A host that answers no dialect keeps the `LIKE` it always got** — "cannot answer, do not block". Postgres deployments see byte-identical SQL.
79
+
80
+ `$icontains` is untouched: it keeps its own ASCII-only fold on both sides, and collapsing the two families onto one path would hand the case-exact family back the fold the ruling took away from it. `LIKE` escaping is unchanged wherever a `LIKE` is still emitted.
81
+ - a646120: The three SQL compilers in this package — the RLS read-scope lowering (`compileScopedFilterToSql`), `NativeSQLStrategy`'s own `where` and the `ObjectQLStrategy` SQL echo — compile a text operator over a column whose declared type stores no text to the contract's declared answer.
82
+
83
+ `compileScopedFilterToSql(filter, alias, options?)` takes a new optional `nonTextColumn(field)` predicate; when it answers `true`, a positive text operator compiles to `1 = 0` and `$notContains` to `1 = 1` instead of a `LIKE` that coerces on SQLite (`5` renders `'5.0'`) and is refused at query time on Postgres (SQLSTATE 42883 — a 500 on a read scope the platform accepted). The service answers the predicate from the field metadata hook it already holds (`sourceFieldMeta`), exposed to strategies as `DatasetScopedStrategyContext.declaredFieldType`, and the two strategies pass it for the read scope and for the query's own text filters, so a query and its RLS scope answer one cell one way and the echo prints the statement that ran (`FILTER_TEXT_CASES`' `score` rows, maintainer ruling 2026-09-05). A host that wires no field metadata keeps the `LIKE` it always got, and every comparand refusal still runs ahead of the constant.
84
+
85
+ ### Patch Changes
86
+
87
+ - dcad825: Analytics `$icontains` no longer compiles a `translate()` call on the `sqlite` and `mysql` dialects. On **SQLite** that function does not exist and the statement failed to parse — measured on the engine, not inferred. On **MySQL** the same construct was emitted and its arm is repaired the same way, but nothing was ever executed there: the MySQL arm is asserted as emitted TEXT only, on this face and on `driver-sql`'s alike, so no MySQL parse failure is claimed as measured.
88
+
89
+ `$icontains` folds ASCII case on both sides of the comparison (#4706 Q1 = A). All three of this package's SQL compilers — the query's own `where` (`NativeSQLStrategy.buildFilterClause`), the ADR-0021 D-C read scope (`compileScopedFilterToSql`) and the `ObjectQLStrategy` echo of that statement — spelled that fold as `translate(col, 'ABC…', 'abc…')` on all four dialect values a compiler can see: `sqlite`, `mysql`, `postgres` and `unknown`, onto which `normalizeSqlDialect` maps everything else, an unset hook and `'oracle'` included. `translate()` is PostgreSQL/Oracle; SQLite has none. Measured on sql.js 1.14.1 (SQLite 3.49.1, the engine `driver-sqlite-wasm` runs), `SELECT translate('ABC','ABC','abc')` answers `no such function: translate` — so this was not a filter that returned the wrong rows, it was a statement the engine refused. On a SQLite datasource, an analytics `where` carrying `$icontains` and an **RLS read scope** carrying it were both unusable.
90
+
91
+ The fold is now chosen per dialect, on the same construct table the case-exact text family already used, reached through one `fold` flag:
92
+
93
+ - **SQLite** — `lower(col) GLOB lower(?)`. SQLite's `lower()` is ASCII-only (measured: `lower('CAFÉ')` is `cafÉ`), so this is the ruled fold rather than an approximation of it, and it runs.
94
+ - **PostgreSQL** and the `unknown` residue — `translate()`, byte-for-byte what those two arms emitted before. Measured set for that word: this package's own suite pins six cells verbatim — `{NativeSQLStrategy, ObjectQLStrategy echo, compileScopedFilterToSql} × {dialect unset, 'postgres'}` for `{name: {$icontains: 'acme'}}`, full emitted SQL and the exact bound params — and the round-1 contract review widened it to **2,721 cells** (2,720 = `{undefined, 'postgres', 'unknown', 'oracle'} × 5 compiler paths × 8 filter shapes × 17 comparands`, plus the bare `{dialect: undefined}` cell), emitted at the merge-base blobs (all five hash-verified) and again at this head: **0 changed cells, 0 error cells**. Outside that set nothing is claimed — no PostgreSQL server was contacted, and on `sqlite` and `mysql` the bytes deliberately changed (340 of 680 cells each, all inside the four `$icontains` shapes).
95
+ - **MySQL** — the nested-`REPLACE` fold over `CAST(… AS BINARY)`, matching what `driver-sql` emits for the same operator; the review measured the two faces byte-equal on 60 of 60 MySQL cells. Asserted as text only — no MySQL server is provisionable in the container that wrote this, so that cell is a declared skip, not a claimed pass.
96
+
97
+ ⚠️ Carve-out, stated because it is the surviving half of the defect and not an aside: an `unknown` dialect that is really SQLite is **not** fixed by this change. The residue is reached by four constructions the round-1 contract review drove rather than reasoned — a `SqlDriver` given a **class** client or an unrecognised spelling (`'libsql'`), a host hook answering knex's own `'sqlite3'`, a directly-constructed public `AnalyticsService` with the optional `sqlDialect` omitted, and a `data` service without `getDriverForObject`. For each of them `translate()` still reaches the engine and still fails to parse, on the `where` path, the read scope and the echo alike. No in-repo SQLite driver lands there — `SqliteWasmDriver` and `TursoDriver` both answer `"sqlite"`, measured — so this is an embedder-composition population, not a shipped-driver one. Tracked as #16028.
98
+
99
+ `$icontains` and the case-sensitive `$contains` family remain two separate constructs on every dialect the compilers accept — collapsing them would give `$contains` back the case fold #4706 Q2 = A took away from it. Measured set for that word: 510 cells (six dialect names — the four values above plus `'oracle'` and an unset hook, which both normalize to `unknown` — × 5 compiler paths × 17 comparands), 0 of them identical between the two families and no `$contains` cell carrying a fold.
100
+
101
+ ⚠️ One deliberate divergence from `driver-sql`, recorded here rather than only in this package's source: `driver-sql`'s own `unknown` arm folds with `LOWER()`, this one keeps `translate()`. Each face keeps the residue it already had, and adopting `LOWER()` here would silently restore on PostgreSQL the Unicode fold #4706 Q1 = A rules out. The pointer exists on this side only; `driver-sql` carries no cross-reference back.
102
+ - fd014b1: Analytics `$icontains` no longer compiles a `translate()` call on the `unknown` dialect arm, so a datasource whose dialect nothing answered — which includes SQLite — gets a statement its engine can parse. **Graded `patch`:** no exported type, signature or option changes; the package's own contract for the operator (#4706 Q1 = A, an ASCII-only fold on both sides) is unchanged, and this repairs an arm that could not run rather than adding or retiring behaviour. What moves is emitted SQL text on one arm, measured and enumerated below.
103
+
104
+ `normalizeSqlDialect` maps **everything it cannot name** onto `unknown`: an unset `sqlDialect` hook, `'oracle'`, `'libsql'`, a `SqlDriver` handed a knex Client **class** rather than a spelling. #15780 left that arm folding with `translate()` and recorded it as "never broken", which was true of the dialects the arm was *pictured* as — mssql and oracle, which have `translate()` — and false of the ones actually routed there. Measured on sql.js 1.14.1 (SQLite 3.49.1, the engine `driver-sqlite-wasm` runs), `SELECT translate('ABC','ABC','abc')` answers `no such function: translate`, so on all three of this package's compilers — the query's own `where` (`NativeSQLStrategy.buildFilterClause`), the ADR-0021 D-C read scope (`compileScopedFilterToSql`) and the `ObjectQLStrategy` echo — the statement failed to **parse**. It reached the client as a 500, not an ADR-0112 refusal. One of the four constructions that land there is a directly-constructed public `AnalyticsService` with its **optional** `sqlDialect` omitted: leaving out an optional field turned a documented operator into a 500.
105
+
106
+ The `unknown` arm now folds with one nested `REPLACE` per ASCII letter — the chain the MySQL arm already used, minus its `CAST(… AS BINARY)`, so there is one builder and the two arms cannot fold different alphabets. `REPLACE` is the one string function every SQL dialect has, and the domain is the same 26-letter constant, so the fold is ASCII-only **by construction**:
107
+
108
+ - **PostgreSQL / Oracle-like** — same result set as `translate()`. The chain equals the simultaneous `A`-`Z` map because no step can feed a later one: every replacement writes a lower-case letter and every later step matches an upper-case one. Measured on the engine over **every ASCII code point** plus accented, Greek, Cyrillic and dotted-I probes, required equal to the ASCII-only map exactly.
109
+ - **SQLite-like** — it runs. Executed over the shared `FILTER_TEXT_CASES` `$icontains` rows through all three compilers on sql.js: the same row sets the `sqlite` arm is required to answer, including the `CAFÉ`/`café` pair that separates an ASCII fold from a Unicode one.
110
+ - ⛔ **Not `LOWER()`**, which is what `driver-sql`'s own `unknown` arm folds with. `LOWER()` follows the collation, so adopting it would trade this parse failure for **silently wrong rows** on PostgreSQL — the Unicode fold #4706 Q1 = A rules out. ⚠️ Measuring `LOWER()` in this container proves nothing about that: SQLite's `lower()` is ASCII-only and passes the same fixture, which is exactly the trap of letting a green SQLite reading stand in for a PostgreSQL one. No PostgreSQL server was contacted.
111
+
112
+ **Which cells moved.** The emitted SQL and bound params of `{NativeSQLStrategy, ObjectQLStrategy echo, compileScopedFilterToSql} × {undefined, 'unknown', 'oracle', 'libsql', 'postgres', 'sqlite', 'mysql'} × 5 text operators × 17 comparands` = **1,785 cells**, generated at this head and again with the emitter reverted to its merge-base blob (both legs hash-verified on disk and rebuilt, the marker's presence and absence checked in `dist/`): **204 moved, 1,581 byte-identical, 0 error cells either side.** Every moved cell is `$icontains` on one of the four dialect inputs that normalize to `unknown` (51 each = 17 comparands × 3 compilers). **0 of the 204 changed their bound params** — only the fold's spelling moved, never the escaping or the `ESCAPE` binding. Nothing moved on `postgres`, `sqlite` or `mysql`, and no case-exact operator moved on any dialect input.
113
+
114
+ ⚠️ **The cost, stated rather than left to be found:** the predicate grows from 168 to 1,014 characters on the read scope (233 → 1,079 on the other two). Both constructs are non-sargable scalar expressions over the column, so the plan class is unchanged — what grows is statement text and per-row work, on the arm where the alternative was a statement that did not run.
115
+
116
+ ⚠️ **The residue that remains**, because this arm is a residue and not a dialect: the fold is exact everywhere, but the comparison is `LIKE`, which on a case- or accent-insensitive collation (MySQL/MariaDB arriving here through the `'mariadb'` spelling #11756 deliberately leaves unrecognised; SQL Server) over-matches beyond ASCII. That is the **same** residue this arm's case-exact neighbour already carries and names — not a new one — and on those engines `translate()` did not run at all, so nothing that answered correctly before stops answering.
117
+
118
+ `SqliteWasmDriver.dialectName` gains a direct pin. It answers `"sqlite"` only through an `isSqlite` override (the base class string-matches `config.client`, and this transport passes a class), that override had **0 direct test hits**, and it is the sole reason no in-repo SQLite driver reaches the arm above. The new pin includes the control: the base class answers `'unknown'` for that very config.
119
+ - d5d8d50: Correct the documented reason for rejecting `CAST(col AS BLOB) LIKE ?` as a portable case-exact construct.
120
+
121
+ Four headers stated, as a universal fact about SQLite, that the construct "was measured to return NOTHING". That is not a property of SQLite: whether `LIKE` is false for a BLOB operand is fixed when SQLite is compiled, by `SQLITE_LIKE_DOESNT_MATCH_BLOBS`, and the two SQLite builds this project ships disagree about it. Measured over the shared `FILTER_TEXT_ROWS` fixture, `{ name: { $contains: 'acme' } }` compiled to that construct returns `[]` on better-sqlite3 13.0.3 (SQLite 3.53.4, flag compiled in) and `['1','2']` on sql.js 1.14.1 (SQLite 3.49.1, flag absent) — the latter being exactly the ASCII case-folding defect the construct was being considered to avoid.
122
+
123
+ No behaviour changes and no conclusion changes: all four sites still reject the construct and still choose `GLOB`. The rejection is now stated in a form that does not depend on any particular return value — a construct whose meaning is decided by an upstream compile flag cannot carry a read scope, because it means two different things on the two builds shipped here. Two supporting readings are recorded alongside it: `typeof CAST(name AS BLOB)` is `'blob'` on both builds, so the CAST is not the part that differs, and `GLOB` answers identically on both.
124
+
125
+ Documentation only. `@objectstack/spec` and `@objectstack/driver-turso` ship the corrected text in their published type declarations (and `spec` also publishes the corrected source file directly, via its `src/**/*.zod.ts` entry); for `@objectstack/driver-sql` and `@objectstack/service-analytics` the change reaches published output only through sourcemaps.
126
+ - 81919a7: `CubeRegistry`'s documentation now describes what the class actually does. Four claims it shipped were measured false against the built package; no behaviour changes, and the corrected text ships in `dist/index.d.ts`, where consumers read it.
127
+
128
+ The class docblock said cubes reach the registry "from two sources: manifest definitions, and object schema inference". Neither half held. Two sources were missing — a compiled dataset's Cube (ADR-0021), registered under the dataset's name by `queryDataset`, and the ad-hoc Cube `ensureCube` / `inferCubeFromQuery` mints from the members a query references. And object schema inference is `inferFromObject`, which no path in this repository calls: its only in-tree caller is a unit test. The list now names the three sources that do write to the registry, and points at the method for the fourth door instead of advertising it as delivered.
129
+
130
+ `inferFromObject`'s own "heuristic rules" list was wrong in three of five bullets. Driving the built package:
131
+
132
+ - `number` / `currency` / `percent` fields mint one `sum` and one `avg` measure each — not the documented `sum`, `avg`, `min`, `max`. No `min` or `max` measure exists.
133
+ - `boolean` fields become a `boolean` dimension and nothing else. The documented "`count` measure (count where true)" is not minted.
134
+ - Every field becomes a dimension. The documented "all non-computed fields" implies an exclusion the code does not have, on a parameter that carries no such flag.
135
+
136
+ The two accurate bullets (a default `count` measure, and `date` / `datetime` fields becoming `time` dimensions granulated day/week/month/quarter/year) are kept and stated in the form the run produced.
137
+
138
+ The method's docblock now also records what it is: a published method with no in-repo caller, still callable by consumers through the package entry (`CubeRegistry`) or `AnalyticsService.cubeRegistry`, whose output does reach the wire because `getMeta()` serves its labels as `CubeMeta` titles.
139
+ - d770b3e: Analytics: a draft-preview dataset response now describes its columns like the live one
140
+
141
+ `AnalyticsService.queryDataset`'s ADR-0037 P3 draft-preview branch returned before the
142
+ ADR-0021 result-column enrichment ever ran, so a dataset queried while the base object had a
143
+ pending seed draft came back with none of its column metadata: `fields[].label`, `format`,
144
+ `currency`, `percentScale`, `builtinAggregate`, and the temporal `type` correction were all
145
+ absent, on measure and dimension columns alike. A renderer then fell back to humanizing the
146
+ raw measure name and guessing a percent scale from magnitude — so the same dataset in the
147
+ same widget described its columns differently depending only on whether a pending seed draft
148
+ existed, which is the surface an author is looking at while authoring the dataset.
149
+
150
+ Every one of those keys is read off the authored dataset and the source object's field
151
+ metadata, never off the rows, so the enrichment is now one method both paths call. Dimension
152
+ VALUE label resolution (resolving a lookup id to a display name) stays skipped on the preview
153
+ path deliberately: drafted seed rows reference lookups by name, so there is no id to resolve.
154
+ - Updated dependencies [fe0d9a4]
155
+ - Updated dependencies [ecd2158]
156
+ - Updated dependencies [f2b5e46]
157
+ - Updated dependencies [2ed6be6]
158
+ - Updated dependencies [ed7243d]
159
+ - Updated dependencies [6ba0db4]
160
+ - Updated dependencies [625b0c3]
161
+ - Updated dependencies [233222e]
162
+ - Updated dependencies [07f40e5]
163
+ - Updated dependencies [ceb4877]
164
+ - Updated dependencies [e9fcd6b]
165
+ - Updated dependencies [90e7e6d]
166
+ - Updated dependencies [2bdabe6]
167
+ - Updated dependencies [ca326b5]
168
+ - Updated dependencies [8f404a5]
169
+ - Updated dependencies [68437d4]
170
+ - Updated dependencies [abb140c]
171
+ - Updated dependencies [8333a6c]
172
+ - Updated dependencies [3e3ecb0]
173
+ - Updated dependencies [3030369]
174
+ - Updated dependencies [d5d8d50]
175
+ - Updated dependencies [e08892d]
176
+ - Updated dependencies [ae05f2e]
177
+ - Updated dependencies [b548e43]
178
+ - Updated dependencies [c463d03]
179
+ - Updated dependencies [64bd6a3]
180
+ - Updated dependencies [13c48c2]
181
+ - Updated dependencies [b0529e1]
182
+ - Updated dependencies [66dc6ab]
183
+ - Updated dependencies [6f94458]
184
+ - Updated dependencies [6e67b86]
185
+ - Updated dependencies [132742f]
186
+ - Updated dependencies [85a2459]
187
+ - Updated dependencies [50dc214]
188
+ - Updated dependencies [e89fa92]
189
+ - Updated dependencies [e9fcd6b]
190
+ - Updated dependencies [8976ea1]
191
+ - Updated dependencies [56fe8c2]
192
+ - Updated dependencies [acabd24]
193
+ - Updated dependencies [ab50c8f]
194
+ - Updated dependencies [6491463]
195
+ - Updated dependencies [89cf4d6]
196
+ - Updated dependencies [21c5dcb]
197
+ - Updated dependencies [6d4d5d3]
198
+ - Updated dependencies [ed5d557]
199
+ - Updated dependencies [bca21f7]
200
+ - Updated dependencies [e9fcd6b]
201
+ - Updated dependencies [2025b1f]
202
+ - Updated dependencies [1a7a7c9]
203
+ - Updated dependencies [e9fcd6b]
204
+ - Updated dependencies [ef3a138]
205
+ - Updated dependencies [68d5dfd]
206
+ - Updated dependencies [3e21cf0]
207
+ - Updated dependencies [4cfc93b]
208
+ - Updated dependencies [efd6b43]
209
+ - Updated dependencies [859ded3]
210
+ - Updated dependencies [fa125f3]
211
+ - Updated dependencies [74628d9]
212
+ - Updated dependencies [a646120]
213
+ - Updated dependencies [6f1ce7d]
214
+ - Updated dependencies [7778115]
215
+ - Updated dependencies [2c753fe]
216
+ - Updated dependencies [52804cd]
217
+ - Updated dependencies [3f89967]
218
+ - Updated dependencies [53cf263]
219
+ - Updated dependencies [21aabbc]
220
+ - Updated dependencies [9c270bb]
221
+ - Updated dependencies [76c8c5a]
222
+ - Updated dependencies [088f761]
223
+ - Updated dependencies [a84e1ce]
224
+ - Updated dependencies [bf1054a]
225
+ - Updated dependencies [d8d2776]
226
+ - Updated dependencies [222dc0f]
227
+ - Updated dependencies [e9fcd6b]
228
+ - Updated dependencies [32c917d]
229
+ - Updated dependencies [f9a3c32]
230
+ - Updated dependencies [f502898]
231
+ - Updated dependencies [51ae731]
232
+ - Updated dependencies [af7edfe]
233
+ - Updated dependencies [b60f48b]
234
+ - Updated dependencies [c78c918]
235
+ - Updated dependencies [cf9bda4]
236
+ - Updated dependencies [784cb92]
237
+ - Updated dependencies [7629f4d]
238
+ - Updated dependencies [51df9fd]
239
+ - Updated dependencies [a7da4de]
240
+ - Updated dependencies [de0bcdd]
241
+ - Updated dependencies [70f7d6d]
242
+ - Updated dependencies [c677cda]
243
+ - Updated dependencies [554a160]
244
+ - Updated dependencies [f7da71e]
245
+ - Updated dependencies [7f745c3]
246
+ - Updated dependencies [5eb24f8]
247
+ - Updated dependencies [2a3decc]
248
+ - Updated dependencies [cc00df2]
249
+ - Updated dependencies [cc00df2]
250
+ - Updated dependencies [f4e6adf]
251
+ - Updated dependencies [ee4a59b]
252
+ - Updated dependencies [4db3c61]
253
+ - Updated dependencies [5ca314a]
254
+ - Updated dependencies [e0af1a8]
255
+ - Updated dependencies [4771bd9]
256
+ - Updated dependencies [414c1fc]
257
+ - Updated dependencies [22c0279]
258
+ - Updated dependencies [0db2947]
259
+ - Updated dependencies [92b5d7f]
260
+ - Updated dependencies [613bfbd]
261
+ - Updated dependencies [abae16a]
262
+ - Updated dependencies [094b8fd]
263
+ - Updated dependencies [c7aca0d]
264
+ - Updated dependencies [c1d8f98]
265
+ - Updated dependencies [8e0b297]
266
+ - Updated dependencies [d4f9b2a]
267
+ - Updated dependencies [5f7fa1d]
268
+ - Updated dependencies [87f0ccc]
269
+ - Updated dependencies [aedbaef]
270
+ - Updated dependencies [a727043]
271
+ - Updated dependencies [c5d6803]
272
+ - Updated dependencies [10d05bb]
273
+ - Updated dependencies [69602e5]
274
+ - Updated dependencies [c3ce76c]
275
+ - Updated dependencies [7936b29]
276
+ - Updated dependencies [46803fa]
277
+ - Updated dependencies [c2a336c]
278
+ - Updated dependencies [9f890d3]
279
+ - Updated dependencies [0bb2318]
280
+ - Updated dependencies [f7db8f4]
281
+ - Updated dependencies [1ecee3e]
282
+ - Updated dependencies [9408b7f]
283
+ - Updated dependencies [e9fcd6b]
284
+ - Updated dependencies [9bcd9be]
285
+ - Updated dependencies [b398ad2]
286
+ - Updated dependencies [99261a7]
287
+ - Updated dependencies [81b426f]
288
+ - Updated dependencies [001af1c]
289
+ - Updated dependencies [fb77aa5]
290
+ - Updated dependencies [3d3f60e]
291
+ - Updated dependencies [581d8f8]
292
+ - Updated dependencies [f81afe3]
293
+ - Updated dependencies [40a44b9]
294
+ - Updated dependencies [f89812e]
295
+ - Updated dependencies [7a7fb03]
296
+ - Updated dependencies [8fd246d]
297
+ - @objectstack/spec@17.4.0
298
+ - @objectstack/core@17.4.0
299
+ - @objectstack/types@17.4.0
300
+
301
+ ## 17.3.0
302
+
303
+ ### Minor Changes
304
+
305
+ - 74cee59: Resolve `{current_user_id}` (and every other filter placeholder) on the direct analytics query path, at parity with the list path and the dashboard dataset path.
306
+
307
+ What changes for an app author: a widget or report whose filter says `owner: '{current_user_id}'` used to render `0` for every viewer whenever the query reached the SQL strategy — the literal text was bound into the `WHERE` and matched no row, silently. Now the same filter expression means the same thing on every surface: `AnalyticsService.query` and `generateSql` expand `where`, `timeDimensions[].dateRange`, and a registered dataset's own filter / measure filters against the requesting user before any strategy compiles, so each viewer gets their own rows. A placeholder that cannot be resolved — an unknown spelling, or `{current_user_id}` on an unauthenticated request — now refuses loudly with `FILTER_TOKEN_UNKNOWN` / `FILTER_TOKEN_UNRESOLVED` (HTTP 400) instead of charting a plausible zero.
308
+
309
+ This also closes a gap on the dashboard dataset door: the dataset-scope channel used to hand strategies the registry's unresolved filter copy, which was ANDed in beside the resolved one (`owner = $viewer AND owner = '{current_user_id}'`) and selected nothing.
310
+ - 399ecad: `ObjectQLStrategy` now refuses a cross-object leaf in a compiled measure's own `filter`, on both of its doors, instead of sending it to an engine that cannot join (#11461). This is the third producer of a predicate on that path — after the caller's `where` and the dataset's definition-level `filter` (#10861) — and the one `filterMemberView` did not fold in: #10413 phase 2 lowers `measureFilters[m]` onto that measure's `aggregations[].filter` entry (#10576), and the envelope check enumerated only two origins while its `query.measures` arm read each measure's resolved *field* and never its filter.
311
+
312
+ Measured on one fixture before the change, both doors in one run: a measure declaring `filter: { 'account.region': 'West' }` on a cube with `include: ['account']` was ACCEPTED, `engine.aggregate` received `{field:"*",method:"count",alias:"west_count",filter:{"account.region":"West"}}`, and an honest evaluator answered `west_count: 0` where the truthful answer was `2` — beside a correct `total_count: 3`, so the wrong number came back wearing the same response shape as the right one. The `/analytics/sql` echo rendered `COUNT(CASE WHEN account.region = $1 THEN 1 END)` over a `FROM` carrying no join at all. Both doors now answer `INVALID_FIELD`/400 before the engine is reached, naming the offending field, the dataset, and — the locator neither sibling refusal has — the measure whose declaration holds the leaf.
313
+
314
+ Ordinary per-measure filters are unaffected and still reach the engine carrying their own `aggregations[].filter`, and a cross-object filter declared on a measure a query does not ask for changes nothing: only the measures in `query.measures` are judged, which is exactly the set both doors lower. The same definition remains valid on a native-SQL driver, which the refusal says.
315
+ - d5b330d: feat(spec,analytics): `AnalyticsResult.fields[].builtinAggregate` — a closed discriminator for a measure column whose display name is the server's built-in default (#14492)
316
+
317
+ **What a consumer sees.** `queryDataset()` (and `POST /api/v1/analytics/dataset/query`,
318
+ which relays the result verbatim) now carries an optional
319
+ `fields[].builtinAggregate?: 'count' | 'sum' | 'avg' | 'min' | 'max' | 'count_distinct'`
320
+ on a measure column. It is present exactly when the dataset measure behind the
321
+ column declares an `aggregate` and **no** `label` — the producer then has nothing
322
+ but the aggregate to name the column by, so it says which aggregate that is. It is
323
+ absent whenever the author declared a label (a plain string or an inline locale
324
+ map, even one with no entry for the request locale: an author's text is never
325
+ re-labelled by a consumer), and absent on dimension columns and derived measures.
326
+ The vocabulary is `AggregationFunction` (`data/query.zod.ts`), the one closed
327
+ aggregate enum — no second spelling. `AnalyticsResultResponseSchema`
328
+ (`api/analytics.zod.ts`) mirrors the member, refusing a spelling outside the enum.
329
+
330
+ **Why.** An AI-built dashboard's "count of customers by status" chart showed the
331
+ English axis title "Count" on a Chinese UI. The renderer (objectui
332
+ `buildChartSeries()` / `labelOf()`) treats `fields[].label` as resolved author
333
+ content and passes it through verbatim — correctly, since a real custom label
334
+ ("Tasks") must survive. What it could not tell apart was an author's text from
335
+ the server's built-in default for a bare `count`. Guessing from the label text
336
+ was refused (it would catch an author who really named a field `Count`, and break
337
+ the moment the default is spelled in another language); translating on the
338
+ server was not taken (it copies the front end's language decision into the
339
+ producer and leaves nothing for a per-widget override). The ruling (2026-09-02,
340
+ option B) is a structured discriminator on the contract: the consumer prefers a
341
+ locale lookup keyed by `builtinAggregate` — mirroring its existing
342
+ `report.aggregate.*` keys — and falls back to `label`, then `name`.
343
+
344
+ **Producer-side changes.**
345
+
346
+ - `@objectstack/service-analytics` — `queryDataset`'s measure enrichment sets
347
+ `builtinAggregate` from the dataset measure's own `aggregate` when the measure
348
+ has no authored `label`. Judged on the authored key, never on the resolved
349
+ string.
350
+ - `@objectstack/spec` — the `dataset` create seed (`metadata-create-seeds.ts`)
351
+ drops its hardcoded `label: 'Count'` from the seeded `count` measure, so a
352
+ dataset created from Studio is a built-in default (wire: `builtinAggregate:
353
+ 'count'`) instead of an authored English literal. `getMeta()` for such a
354
+ dataset now titles the metric by its name (`count`) rather than `Count`;
355
+ `CubeMeta.measures[].type` already carried the aggregate there.
356
+
357
+ Purely additive: no key is removed or renamed, no authorable schema changes shape,
358
+ and a consumer that ignores the member sees exactly the response it saw before.
359
+
360
+ ### Patch Changes
361
+
362
+ - c8be110: refactor(service-analytics): derive the analytics auto-bridge's engine view from the declared contracts (#11833)
363
+
364
+ `plugin.ts` named the data engine through a consumer-local structural
365
+ `DataEngineLike` — the second of the two sites #11833 records, after the
366
+ datasource half that landed as PR #12011. It is now derived from the declared
367
+ contracts: `IDataEngine.aggregate` / `execute?` /
368
+ `resolveEffectiveDatasource?` / `getDriverForObject?` and
369
+ `IObjectQLEngine.getObject`. Optionality is preserved exactly — `aggregate`
370
+ required, everything else `Partial<>` — because these probes are the plugin's
371
+ graceful-degradation seam.
372
+
373
+ **Why this is `patch` and not a type-only no-op.** Four of the five members
374
+ substitute with no behaviour change. The fifth does not: the deleted structural
375
+ type declared `aggregations[].function` as `string`, while the contract
376
+ declares the six-value `AggregationFunction`. The bridge therefore forwarded
377
+ whatever method string reached it. That forward is now parsed with the spec's
378
+ own enum, so a method the engine contract does not declare is refused at the
379
+ bridge — loudly, naming the aggregation and the legal vocabulary — instead of
380
+ reaching the engine, where `driver-sql` blamed a `function` key the author
381
+ never wrote and the in-memory evaluator answered `null` for every bucket under
382
+ the author's own measure name.
383
+
384
+ No authored analytics can trigger the new refusal: the one reachable producer
385
+ of a non-aggregate method — a custom-SQL measure (`AggregationMetricType`
386
+ `number` / `string` / `boolean`) — is already refused earlier, caller-facing,
387
+ by `ObjectQLStrategy.resolveMeasureAggregation` (#12209). What is left is host
388
+ drift (a cube object registered without meeting `CubeSchema`), which is why
389
+ the new refusal is a bare `Error` in the undeclared-500 tier rather than an
390
+ ADR-0112 400 that would blame the caller for something they did not write.
391
+ - aa16721: fix(service-analytics): the consumer-local `executeAggregate` config mirrors narrow `aggregations[].method` to `AggregationFunction` (#12940)
392
+
393
+ #12776 narrowed the contract — `StrategyContext.executeAggregate`'s
394
+ `aggregations[].method` went from `string` to the six-value
395
+ `AggregationFunction` — but this package's two CONSUMER-LOCAL config mirrors
396
+ of that same slot kept declaring `string`, so the compile-time vocabulary the
397
+ narrowing bought for strategy authors stopped at the package boundary and
398
+ never reached the people who write a custom bridge.
399
+
400
+ FROM → TO, at all three sites the tree carries (the card enumerated two):
401
+
402
+ - `AnalyticsServicePluginOptions.executeAggregate` (`plugin.ts`) —
403
+ `aggregations[].method: string` → `AggregationFunction`. This is the
404
+ declaration an app author's own `executeAggregate` bridge is typed against.
405
+ - `AnalyticsServiceConfig.executeAggregate` (`analytics-service.ts`) — the
406
+ same narrowing on the config twin whose own comment says it is kept in
407
+ lockstep with `StrategyContext.executeAggregate`; that claim is true again,
408
+ and now names the member so the next drift is visible.
409
+ - `parseEngineAggregateFunction`'s `method` parameter (`plugin.ts`), the
410
+ auto-bridge's runtime parse — narrowed for the same reason: it was the
411
+ third place a reader was told this vocabulary is open.
412
+
413
+ Who breaks at compile time on upgrade: CALLERS that fill `method` with a
414
+ value typed `string` (or a literal outside the six) when invoking one of
415
+ these bridges — the values the bridge already refused at runtime (#11833).
416
+ IMPLEMENTORS are source-compatible: a handler that accepts `method: string`
417
+ accepts a superset and stays assignable to the narrowed member (parameter
418
+ contravariance), which is why the ~nine test doubles in this package that
419
+ declare their own `{ field, method: string, alias }` mirrors still compile
420
+ untouched.
421
+
422
+ No runtime change. The auto-bridge's runtime parse-and-refuse (#11833) stays
423
+ exactly where it was — with both ends of the `method` → `function` rename now
424
+ declaring the same enum, it is defence in depth behind a compile-time check
425
+ rather than the only check, and the two comments that explained it by
426
+ pointing at the old `method: string` declaration say so instead.
427
+ - e7191ce: fix(build): give each `exports` condition its own `types` target in the 28 dual-build packages (#13112)
428
+
429
+ **Published-surface change, zero runtime change.** No emitted byte moves; what
430
+ moves is which declaration file a resolver READS. Maintainer ruling 2026-08-29
431
+ (decision batch #3, verbatim 「同意」) chose declaring the files over deleting
432
+ them.
433
+
434
+ ## What was wrong
435
+
436
+ These 28 packages are `"type": "module"` and dual-built, and each spelled one
437
+ `types` condition as a **sibling** of `import`/`require`:
438
+
439
+ ```json
440
+ "exports": { ".": {
441
+ "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs"
442
+ } }
443
+ ```
444
+
445
+ A sibling `types` answers for **both** conditions, so a CommonJS consumer was
446
+ handed `dist/index.d.ts` — an ES-module declaration, because the package is
447
+ `"type": "module"` — for an entry point it reaches with `require`. Measured with
448
+ `tsc --traceResolution` on a `"type": "commonjs"` fixture at `moduleResolution:
449
+ node16`:
450
+
451
+ ```
452
+ error TS1479: The current file is a CommonJS module whose imports will produce
453
+ 'require' calls; however, the referenced file is an ECMAScript module and cannot
454
+ be imported with 'require'.
455
+ ```
456
+
457
+ The JavaScript at `dist/index.cjs` loads perfectly (`check:dual-build-cjs-loads`
458
+ has asserted that for months). It is the **types** that told the consumer the
459
+ supported `require` entry point could not be required. The `dist/index.d.cts`
460
+ twin tsup emits beside it — 36 files, 5,517,701 B on this build — was named by
461
+ no condition at all and shipped in every tarball unreachable.
462
+
463
+ ## What changed
464
+
465
+ Each condition now names its own declaration, the shape TypeScript documents:
466
+
467
+ ```json
468
+ "exports": { ".": {
469
+ "import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
470
+ "require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }
471
+ } }
472
+ ```
473
+
474
+ 33 entry points across 27 packages, subpaths included. The root `types` field is
475
+ untouched, so `node10` resolvers are unaffected; the `import` condition resolves
476
+ exactly what it resolved before, measured as an unchanged control in the same
477
+ run.
478
+
479
+ ## `@objectstack/core` is deliberately NOT changed
480
+
481
+ Splitting a declaration in two makes TypeScript compare it nominally, and
482
+ `ObjectKernel` carries a `private plugins` member that reaches every plugin
483
+ through `PluginContext.getKernel()`. With core split, whole-repo `pnpm build`
484
+ fails in `@objectstack/verify` with 5 × TS2345 ("Types have separate
485
+ declarations of a private property 'plugins'"); with core held back and the
486
+ other 27 split, 71/71 tasks pass. So core keeps the sibling-`types` shape and
487
+ its two `.d.cts` files (220,854 B) stay unreachable, declared as such in
488
+ `check:dual-build-cjs-loads`. Splitting it needs a decision about core's public
489
+ types, not about an exports map.
490
+
491
+ ## For consumers
492
+
493
+ - **ESM consumers: nothing changes.** Same declaration file, byte for byte.
494
+ - **CJS consumers under `node16`/`nodenext`: TS1479 goes away** and the
495
+ declarations they get are the ones built for CommonJS.
496
+ - **`node10` / `moduleResolution: node` consumers: nothing changes** — they never
497
+ read `exports`.
498
+ - Nothing is removed: every path that resolved before still resolves.
499
+
500
+ Packages that are CJS-first (`require` → `./dist/index.js`, no `"type": "module"`)
501
+ were already correct and are untouched — their `dist/index.d.ts` really is the
502
+ CommonJS declaration. Their ESM mirror (an unreachable `.d.mts` under the
503
+ `import` condition) is a separate, larger population and is filed separately per
504
+ the ruling, not fixed here.
505
+
506
+ `check:dual-build-cjs-loads` grew a fourth invariant (TYPED) that reds on the old
507
+ shape, so the drift cannot return silently.
508
+ - 017130a: The ObjectQL analytics strategy now refuses a custom-SQL measure (`AggregationMetricType` `number` / `string` / `boolean`) with a loud `400 INVALID_FIELD` naming the measure and its metric type, instead of forwarding the raw SQL expression into `engine.aggregate` — where `driver-sql` rejected it blaming a `function` key the author never wrote, and the in-memory evaluator silently answered `null` for every bucket under the measure's own name.
509
+
510
+ What stops being served, and for whom: on deployments whose driver has no native SQL capability (the ObjectQL aggregate path — e.g. Mongo or in-memory), a query or dataset widget selecting a custom-SQL measure now answers a 400 that says to use an aggregate measure (count/sum/avg/min/max/count_distinct) or run the cube on a native-SQL driver. Those queries previously "succeeded" with a per-bucket `null` (or a mis-attributed driver error), never with a correct number. Native-SQL driver behaviour is unchanged: custom-SQL measures still run there, emitted verbatim.
511
+ - 466b389: **Fix:** `/api/v1/analytics/query` on the ObjectQL door (MongoDB, the memory driver, or any deployment whose driver reports `objectqlAggregate` but not `nativeSql`) now honours a measure's own scoped `filter` — `won_count` and `won_amount`-style conditional measures answer the same numbers the dashboard door and the native-SQL door already did (#10413 phase 2).
512
+
513
+ `ObjectQLStrategy.execute` lowers each measure's `filter` into the ONE aggregation it belongs to, via the per-aggregation `filter` field #10576 added to `engine.aggregate`'s contract (SQL `FILTER (WHERE …)` semantics) — not into the whole-call filter, which would have narrowed every measure (a fix shaped that way would make a conditional measure right while making every unconditional sibling measure in the same query wrong). An aggregation with no measure filter is unchanged and keeps the native-pushdown-eligible shape.
514
+
515
+ `ObjectQLStrategy.generateSql` (the `/analytics/sql` echo) renders the same conditional aggregate — `COUNT(CASE WHEN … THEN … END)`-style — so the preview stays an honest description of what `execute()` now actually runs, matching the native-SQL strategy's existing echo for the same class of measure.
516
+
517
+ Phase 1 (PR #10758) already ANDed a dataset's definition-level `filter` into the whole-call filter on this door; this closes the remaining half of the two-door disagreement #10413 reported. `NativeSQLStrategy` (#10298 / PR #10411) is unaffected by this change.
518
+ - b0d7d54: `ObjectQLStrategy` now refuses a read scope that does not bind, before handing it to the engine (`READ_SCOPE_COMPILE_FAILED` / 500). That strategy merges `StrategyContext.getReadScope` output straight into the `FilterCondition` it gives `engine.aggregate` and never reaches `compileScopedFilterToSql`, so the empty-`$nin` refusal that compiler gained guarded the NativeSQL path and the `/analytics/sql` echo only. Measured against a real engine, a non-RLS scope provider handing `{ f: { $nin: [] } }`, `{ $not: { f: { $in: [] } } }`, `{ $not: { f: [] } }` or `{ $not: { f: { $in: [], $ne: 'x' } } }` received the WHOLE TABLE on any query this strategy served; all four are now refused, at both engine-bound merges (the aggregate filter and the FK→attribute resolution). Deliberately unchanged: `$in: []` keeps its ruled constant-FALSE fold, so the RLS compiler's live composite — an emptied membership `$or`-ed beside an own-rows grant — still admits exactly the own rows; and the NativeSQL path and the SQL echo keep the disposition they already had.
519
+ - 967402a: The analytics read-scope compiler (`read-scope-sql.ts`) now refuses an empty `$nin` (`READ_SCOPE_COMPILE_FAILED` / 500) instead of folding it to constant TRUE. An emptied exclusion ("NOT IN () excludes nothing") vacated the whole read scope — every row admitted — on the ADR-0021 lowering, where a widening is scope over-reach; no in-repo producer can emit the shape (the CEL lowering never emits `$nin`, and the RLS guard drops even-polarity empty-`$nin` policies upstream), so the refusal costs no live traffic. Deliberately asymmetric: `$in: []` keeps its ruled constant-FALSE fold (#5322/#5243), which the RLS compiler's inert positive composite — an emptied membership `$or`-ed beside an own-rows grant — depends on.
520
+ - 5c7cbe3: Refuse a non-binding (vacating) read scope at the two remaining `getReadScope` merge sites: the `/analytics/sql` echo (`ObjectQLStrategy.generateSql`) and `NativeSQLStrategy.applyReadScope`. The `$not`-over-`$in: []` family compiled to a constant-TRUE predicate on those routes, so the echo rendered — and the native strategy actually executed — a whole-table `WHERE` for a scope the ObjectQL execution path already refused (#13640). All three faces now answer one verdict, in the same `READ_SCOPE_COMPILE_FAILED` / 500 envelope; the ruled `$in: []` zero-rows reduction, the live RLS empty-membership composite, and `compileScopedFilterToSql` itself (the ruled #13571 residue included) are unchanged.
521
+ - a3c4215: fix(service-analytics): wire the `typecheck` script so turbo stops silently no-opping the gate, and clear the 10 type errors it was hiding (#12939)
522
+
523
+ `packages/services/service-analytics/package.json` declared only `build` and
524
+ `test`. Root `typecheck` is `turbo run typecheck`, which **no-ops a package
525
+ that has no such script and reports success** — so no tsc read this package's
526
+ `src/` from the typecheck lane at all. `build` is tsup (esbuild; the DTS pass
527
+ processes declarations only) and `test` is vitest (esbuild transform), and
528
+ neither type-checks. The package was reached only by the `check:type-check-debt`
529
+ ratchet, which asserts the error count does not *grow* — never that it is zero.
530
+
531
+ Adding the one-line script (mirroring its sibling `service-settings`, repaired
532
+ the same way in #7925) makes the task real. The tests are already inside the
533
+ program — the package `tsconfig.json` includes `src` and the tests live in
534
+ `src/__tests__/**` — so `tsc --noEmit --listFiles` lists **83 of the 83**
535
+ `*.test.ts` files on disk. The new gate reads the tests, not just the source.
536
+
537
+ All 10 errors were stale tests, not source defects; no non-test source file
538
+ changed. Nothing was silenced: no `any` added, no `@ts-expect-error`, no
539
+ `@ts-nocheck`, `strict` untouched, and the tsconfig `include`/`exclude` are
540
+ byte-identical — excluding the tests would have converted a missing gate into
541
+ a lying one.
542
+
543
+ - `__tests__/measure-source-field-gate.test.ts` (7 x TS2339). `promise.catch(fn)`
544
+ does not drop the resolved branch from the type, so
545
+ `service.query(...).catch((e) => e as Error)` was `AnalyticsResult | Error`
546
+ and every `err.message` / `err.field` / `err.member` / `err.param` read was a
547
+ property access on `AnalyticsResult`. A local `refusalOf()` helper narrows it
548
+ once via `then<never, Refusal>`; as a bonus the resolved branch now fails by
549
+ name instead of surfacing later as `expect(undefined).toMatch(...)`.
550
+ - `__tests__/objectql-timedimension-projection.test.ts` (2 x TS7053). The
551
+ `TABLE` fixture was inferred as `{ id: number; due_date: string; priority:
552
+ string }[]` and the aggregate stand-in indexes it by a computed `string` key.
553
+ Annotated as the `Row` (`Record<string, unknown>`) the file already declares.
554
+ - `__tests__/analytics-service.test.ts` (1 x TS6133). An unused
555
+ `AnalyticsDriverCapabilities` type import. The capability literals in this
556
+ file are inline `ctx` objects checked contextually at each `canHandle` call
557
+ site, so the import added no coverage and is removed.
558
+
559
+ `service-analytics` graduates out of the `check:type-check-coverage` DEBT
560
+ ledger: 65/78 -> 66/78 workspace packages type-checked, 382 -> 372 frozen raw
561
+ errors, 13 -> 12 ledger entries.
562
+ - d028b37: fix(spec): `StrategyContext.executeAggregate` `aggregations[].method` narrows from `string` to `AggregationFunction` (#12776)
563
+
564
+ <!-- adr-0087: registered strategy-context-aggregation-method-narrowed -->
565
+
566
+ **BREAKING** accept-set narrowing on a published contract, landing after the
567
+ v17.0.0 cut (the lockstep launch-window convention ships it as `minor`).
568
+
569
+ Two spec-declared surfaces described the same slot and disagreed about its
570
+ type: `IDataEngine.aggregate`'s `aggregations[].function` is the closed
571
+ six-value `AggregationFunction` enum, while the analytics strategy contract's
572
+ `StrategyContext.executeAggregate` declared the same value as
573
+ `aggregations[].method: string`. The analytics bridge renames one to the
574
+ other, so nothing on the analytics side of that seam was compile-checked
575
+ against the engine's vocabulary — a strategy author (very often an AI) got
576
+ no compile-time help and hit the bridge's runtime refusal instead.
577
+
578
+ FROM → TO:
579
+
580
+ - `aggregations[].method: string` →
581
+ `aggregations[].method: AggregationFunction`
582
+ (`'count' | 'sum' | 'avg' | 'min' | 'max' | 'count_distinct'`, the spec's
583
+ own enum from `@objectstack/spec/data`). One slot, one declaration.
584
+
585
+ Who breaks at compile time on upgrade:
586
+
587
+ - external CALLERS of `StrategyContext.executeAggregate` that fill `method`
588
+ with a value typed `string` (or a literal outside the six) — the values the
589
+ bridge already refused at runtime (#11833) now fail `tsc`.
590
+ - external IMPLEMENTORS of `StrategyContext` stay source-compatible: a
591
+ handler that accepts `method: string` accepts a superset and remains
592
+ assignable to the narrowed member.
593
+
594
+ The bridge's runtime parse-and-refuse (#11833) stays as defence in depth.
595
+ In-repo, `ObjectQLStrategy`'s aggregation locals now carry the enum
596
+ end-to-end (`@objectstack/service-analytics`, runtime behaviour unchanged —
597
+ the census measured every reachable producer already emitting enum-legal
598
+ values only).
599
+ - a40c0f9: Guard the analytics record-label lookup with `assertReadScopeCannotVacate` — the fourth read-scope door
600
+
601
+ `AnalyticsServicePlugin`'s `fetchRecordLabels` hook `$and`s the **referenced** object's read scope with an `id $in [...]` filter and hands the result straight to `executeAggregate`. Unlike the three faces unified previously (the ObjectQL engine merge, the `/analytics/sql` echo merge, and `NativeSQLStrategy.applyReadScope`), it met neither `compileScopedFilterToSql` nor the vacancy guard, so a read scope that lowers to a boolean constant — the `$not`-over-`$in: []` family reachable from any out-of-repo `StrategyContext.getReadScope` producer — let that per-record read run effectively unscoped for the ids in hand, surfacing the display names the referenced object's RLS exists to hide.
602
+
603
+ The hook now calls the already-exported `assertReadScopeCannotVacate` on the referenced object's scope before composing the filter, refusing in the same envelope as its siblings (`READ_SCOPE_COMPILE_FAILED` / 500). No behaviour changes for scopes that bind: an ordinary referenced-object scope still narrows the label lookup, and the `$in: []` zero-rows reduction (including the live RLS composite that pairs it with an own-rows grant) still passes through untouched. The read-scope SQL compiler is unchanged.
604
+ - Updated dependencies [809d417]
605
+ - Updated dependencies [387e231]
606
+ - Updated dependencies [f794e4e]
607
+ - Updated dependencies [cae2169]
608
+ - Updated dependencies [b812a54]
609
+ - Updated dependencies [2d4fa75]
610
+ - Updated dependencies [0e4e51b]
611
+ - Updated dependencies [e84bbf6]
612
+ - Updated dependencies [effae80]
613
+ - Updated dependencies [efb3513]
614
+ - Updated dependencies [d62f990]
615
+ - Updated dependencies [c45d8e6]
616
+ - Updated dependencies [2e3e8c7]
617
+ - Updated dependencies [e621291]
618
+ - Updated dependencies [655b106]
619
+ - Updated dependencies [40a93b5]
620
+ - Updated dependencies [101ad2c]
621
+ - Updated dependencies [d5b330d]
622
+ - Updated dependencies [dda969c]
623
+ - Updated dependencies [1f45690]
624
+ - Updated dependencies [277948f]
625
+ - Updated dependencies [8bdd955]
626
+ - Updated dependencies [f3bbbef]
627
+ - Updated dependencies [4f24e9d]
628
+ - Updated dependencies [e27583e]
629
+ - Updated dependencies [4bd6faa]
630
+ - Updated dependencies [86cbe37]
631
+ - Updated dependencies [6a180e4]
632
+ - Updated dependencies [474242f]
633
+ - Updated dependencies [63cd487]
634
+ - Updated dependencies [bd4aa4e]
635
+ - Updated dependencies [803eaab]
636
+ - Updated dependencies [f8e8f03]
637
+ - Updated dependencies [983edf1]
638
+ - Updated dependencies [eae824e]
639
+ - Updated dependencies [f6fa22c]
640
+ - Updated dependencies [8a483b3]
641
+ - Updated dependencies [97bcd99]
642
+ - Updated dependencies [df59de0]
643
+ - Updated dependencies [96e25a8]
644
+ - Updated dependencies [f75a38a]
645
+ - Updated dependencies [7a25e7d]
646
+ - Updated dependencies [1fa05a6]
647
+ - Updated dependencies [c85a265]
648
+ - Updated dependencies [dcb10a5]
649
+ - Updated dependencies [773a999]
650
+ - Updated dependencies [35dffea]
651
+ - Updated dependencies [d8024f0]
652
+ - Updated dependencies [8120808]
653
+ - Updated dependencies [776a098]
654
+ - Updated dependencies [5060877]
655
+ - Updated dependencies [4f6325d]
656
+ - Updated dependencies [52954c0]
657
+ - Updated dependencies [2aa8456]
658
+ - Updated dependencies [93809a3]
659
+ - Updated dependencies [7c0d0c3]
660
+ - Updated dependencies [daae7aa]
661
+ - Updated dependencies [8dc22d6]
662
+ - Updated dependencies [279431e]
663
+ - Updated dependencies [948dd6b]
664
+ - Updated dependencies [3b4c56c]
665
+ - Updated dependencies [ae8edd2]
666
+ - Updated dependencies [e25403c]
667
+ - Updated dependencies [a81aa9d]
668
+ - Updated dependencies [64baa68]
669
+ - Updated dependencies [9fa70d7]
670
+ - Updated dependencies [09db64a]
671
+ - Updated dependencies [92916e7]
672
+ - Updated dependencies [a84f3ea]
673
+ - Updated dependencies [f2eaae8]
674
+ - Updated dependencies [56c093c]
675
+ - Updated dependencies [c09451b]
676
+ - Updated dependencies [ba64877]
677
+ - Updated dependencies [7345308]
678
+ - Updated dependencies [79b6a22]
679
+ - Updated dependencies [30d96ab]
680
+ - Updated dependencies [f658793]
681
+ - Updated dependencies [c95ad19]
682
+ - Updated dependencies [e58ea8b]
683
+ - Updated dependencies [4a17645]
684
+ - Updated dependencies [3795c5f]
685
+ - Updated dependencies [8ab926b]
686
+ - Updated dependencies [7317cf2]
687
+ - Updated dependencies [e25e839]
688
+ - Updated dependencies [5997207]
689
+ - Updated dependencies [8b13cc8]
690
+ - Updated dependencies [4a4a35d]
691
+ - Updated dependencies [86e765a]
692
+ - Updated dependencies [1d7e76a]
693
+ - Updated dependencies [53dc739]
694
+ - Updated dependencies [fd289be]
695
+ - Updated dependencies [03bf7b1]
696
+ - Updated dependencies [f90e820]
697
+ - Updated dependencies [18d816a]
698
+ - Updated dependencies [e8bd715]
699
+ - Updated dependencies [b91c351]
700
+ - Updated dependencies [a28a3c0]
701
+ - Updated dependencies [daeaaf9]
702
+ - Updated dependencies [c459da6]
703
+ - Updated dependencies [e914733]
704
+ - Updated dependencies [f887e52]
705
+ - Updated dependencies [881f8d8]
706
+ - Updated dependencies [3bfa1e6]
707
+ - Updated dependencies [0a8ebf3]
708
+ - Updated dependencies [901355c]
709
+ - Updated dependencies [34ce8e7]
710
+ - Updated dependencies [33681ea]
711
+ - Updated dependencies [bfe13c8]
712
+ - Updated dependencies [0fb3044]
713
+ - Updated dependencies [4635f3e]
714
+ - Updated dependencies [fd289be]
715
+ - Updated dependencies [ee3595c]
716
+ - Updated dependencies [b2eab95]
717
+ - Updated dependencies [93940d4]
718
+ - Updated dependencies [3a04b01]
719
+ - Updated dependencies [45b9051]
720
+ - Updated dependencies [b9e9227]
721
+ - Updated dependencies [d395692]
722
+ - Updated dependencies [5894d30]
723
+ - Updated dependencies [a3765f6]
724
+ - Updated dependencies [2d5cee3]
725
+ - Updated dependencies [e22158f]
726
+ - Updated dependencies [7404925]
727
+ - Updated dependencies [0c2334f]
728
+ - Updated dependencies [778c59f]
729
+ - Updated dependencies [d2619fd]
730
+ - Updated dependencies [af56546]
731
+ - Updated dependencies [6acb11a]
732
+ - Updated dependencies [33c5fd3]
733
+ - Updated dependencies [20b0fdb]
734
+ - Updated dependencies [905019b]
735
+ - Updated dependencies [a286411]
736
+ - Updated dependencies [98c0d33]
737
+ - Updated dependencies [368a82e]
738
+ - Updated dependencies [a3d5724]
739
+ - Updated dependencies [93ea19b]
740
+ - Updated dependencies [9ee2dcf]
741
+ - Updated dependencies [8cb96ec]
742
+ - Updated dependencies [8f10a79]
743
+ - Updated dependencies [6269a55]
744
+ - Updated dependencies [a17da05]
745
+ - Updated dependencies [a8c00e2]
746
+ - Updated dependencies [22e5236]
747
+ - Updated dependencies [0fb8760]
748
+ - Updated dependencies [e5ce2ed]
749
+ - Updated dependencies [be21955]
750
+ - Updated dependencies [bc56e18]
751
+ - Updated dependencies [be21955]
752
+ - Updated dependencies [a9ee989]
753
+ - Updated dependencies [4d0d944]
754
+ - Updated dependencies [15d58db]
755
+ - Updated dependencies [d63b014]
756
+ - Updated dependencies [9abe4e4]
757
+ - Updated dependencies [2cc7122]
758
+ - Updated dependencies [50d6c92]
759
+ - Updated dependencies [9e0ba21]
760
+ - Updated dependencies [311433f]
761
+ - Updated dependencies [3e5ad08]
762
+ - Updated dependencies [9abe4e4]
763
+ - Updated dependencies [b7131f3]
764
+ - Updated dependencies [e5812fa]
765
+ - Updated dependencies [7085f90]
766
+ - Updated dependencies [dee4dd4]
767
+ - Updated dependencies [ce7e497]
768
+ - Updated dependencies [51ecb2f]
769
+ - Updated dependencies [9086761]
770
+ - Updated dependencies [42a117b]
771
+ - Updated dependencies [1401ae7]
772
+ - Updated dependencies [4297fe7]
773
+ - Updated dependencies [e398863]
774
+ - Updated dependencies [d16df74]
775
+ - Updated dependencies [f11fc61]
776
+ - Updated dependencies [e808890]
777
+ - Updated dependencies [8f79379]
778
+ - Updated dependencies [e6ca40e]
779
+ - Updated dependencies [0c77ea4]
780
+ - Updated dependencies [52954c0]
781
+ - Updated dependencies [89eb997]
782
+ - Updated dependencies [7131f12]
783
+ - Updated dependencies [aa5994e]
784
+ - Updated dependencies [be93457]
785
+ - Updated dependencies [a65db76]
786
+ - Updated dependencies [2cf5a96]
787
+ - Updated dependencies [15eb2c9]
788
+ - Updated dependencies [5691b07]
789
+ - Updated dependencies [2a6122b]
790
+ - Updated dependencies [225e769]
791
+ - Updated dependencies [8af88dd]
792
+ - Updated dependencies [fb5fbb8]
793
+ - Updated dependencies [d7b3963]
794
+ - Updated dependencies [33184fd]
795
+ - Updated dependencies [7c41693]
796
+ - Updated dependencies [b72db01]
797
+ - Updated dependencies [dce5cd4]
798
+ - Updated dependencies [9688f58]
799
+ - Updated dependencies [556ebc1]
800
+ - Updated dependencies [177ebdc]
801
+ - Updated dependencies [8d237b4]
802
+ - Updated dependencies [2d2e6f0]
803
+ - Updated dependencies [2d8dd8d]
804
+ - Updated dependencies [22d573e]
805
+ - Updated dependencies [b5a2398]
806
+ - Updated dependencies [348860c]
807
+ - Updated dependencies [5383fa6]
808
+ - Updated dependencies [5b3ff63]
809
+ - Updated dependencies [1a6a19c]
810
+ - Updated dependencies [527e050]
811
+ - Updated dependencies [dd33bf9]
812
+ - Updated dependencies [4cb2a90]
813
+ - Updated dependencies [74a7804]
814
+ - Updated dependencies [53d3689]
815
+ - Updated dependencies [b3a63d3]
816
+ - Updated dependencies [49f0dcf]
817
+ - Updated dependencies [033a34c]
818
+ - Updated dependencies [4d25d22]
819
+ - Updated dependencies [1ffee51]
820
+ - Updated dependencies [5ae4303]
821
+ - Updated dependencies [ece4dad]
822
+ - Updated dependencies [e9b377e]
823
+ - Updated dependencies [146f448]
824
+ - Updated dependencies [735f5c7]
825
+ - Updated dependencies [a7e18de]
826
+ - Updated dependencies [366f895]
827
+ - Updated dependencies [dc75ba8]
828
+ - Updated dependencies [cce0aa9]
829
+ - Updated dependencies [e764507]
830
+ - Updated dependencies [cff17af]
831
+ - Updated dependencies [39404f3]
832
+ - Updated dependencies [ca1965f]
833
+ - Updated dependencies [8619f95]
834
+ - Updated dependencies [b706af9]
835
+ - Updated dependencies [db8c288]
836
+ - Updated dependencies [0e5fe7f]
837
+ - Updated dependencies [add4360]
838
+ - Updated dependencies [fc9ba76]
839
+ - Updated dependencies [0f94cc7]
840
+ - Updated dependencies [a11c1a5]
841
+ - Updated dependencies [71f9cd1]
842
+ - Updated dependencies [ee17d86]
843
+ - Updated dependencies [cdbd920]
844
+ - Updated dependencies [18c432e]
845
+ - Updated dependencies [3c418c4]
846
+ - Updated dependencies [fa8715a]
847
+ - Updated dependencies [a933ed7]
848
+ - Updated dependencies [b3ca463]
849
+ - Updated dependencies [a933ed7]
850
+ - Updated dependencies [0d4a6a8]
851
+ - Updated dependencies [518d5e5]
852
+ - Updated dependencies [6643ba1]
853
+ - Updated dependencies [eeba2ef]
854
+ - Updated dependencies [ec4c4d2]
855
+ - Updated dependencies [424f73c]
856
+ - Updated dependencies [cccbe51]
857
+ - Updated dependencies [a8d6b1d]
858
+ - Updated dependencies [e4a7695]
859
+ - Updated dependencies [87075b1]
860
+ - Updated dependencies [fc58a99]
861
+ - Updated dependencies [14cfc00]
862
+ - Updated dependencies [1c6f7b4]
863
+ - Updated dependencies [e854a53]
864
+ - Updated dependencies [dfebfc8]
865
+ - Updated dependencies [d028b37]
866
+ - Updated dependencies [f7b25c5]
867
+ - Updated dependencies [122ef38]
868
+ - Updated dependencies [4a37870]
869
+ - Updated dependencies [428f9b2]
870
+ - Updated dependencies [aa7ff56]
871
+ - Updated dependencies [c41b42e]
872
+ - Updated dependencies [c4db311]
873
+ - Updated dependencies [750fff5]
874
+ - Updated dependencies [c19035e]
875
+ - Updated dependencies [ececf7a]
876
+ - Updated dependencies [d173125]
877
+ - Updated dependencies [8eeca27]
878
+ - Updated dependencies [8425c17]
879
+ - Updated dependencies [a5ef1d8]
880
+ - Updated dependencies [87ad30c]
881
+ - Updated dependencies [772d5de]
882
+ - Updated dependencies [ce80ec2]
883
+ - Updated dependencies [b372318]
884
+ - Updated dependencies [97a2263]
885
+ - Updated dependencies [29d0676]
886
+ - Updated dependencies [0169d49]
887
+ - Updated dependencies [6bd3231]
888
+ - Updated dependencies [d2b5ba8]
889
+ - Updated dependencies [b799ac5]
890
+ - Updated dependencies [8f74307]
891
+ - Updated dependencies [d23dc08]
892
+ - Updated dependencies [644ad50]
893
+ - Updated dependencies [9735662]
894
+ - Updated dependencies [4d5b4f8]
895
+ - Updated dependencies [0da7cd2]
896
+ - Updated dependencies [28a5c3e]
897
+ - Updated dependencies [4bc18e5]
898
+ - @objectstack/spec@17.3.0
899
+ - @objectstack/core@17.3.0
900
+ - @objectstack/types@17.3.0
901
+
3
902
  ## 17.2.0
4
903
 
5
904
  ### Minor Changes