@objectstack/service-analytics 17.0.0-rc.0 → 17.0.0-rc.1

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 ADDED
@@ -0,0 +1,2656 @@
1
+ # Changelog — @objectstack/service-analytics
2
+
3
+ ## 17.0.0-rc.1
4
+
5
+ ### Minor Changes
6
+
7
+ - 99ffc04: fix(analytics)!: a measure emits what it declares, instead of `COUNT(*)` (#4157)
8
+
9
+ `NativeSQLStrategy.resolveMeasureSql` answered `COUNT(*)` to three different
10
+ questions it could not otherwise answer — each time aliased under the name the
11
+ caller asked for, so the result looked like an answer:
12
+
13
+ 1. **A measure the cube does not declare.** `lookupMember`'s synthetic
14
+ relation fallback is dimension-only, so any undeclared or mistyped measure
15
+ name landed here. `measures: ['revenue']` against a cube without it returned
16
+ `COUNT(*) AS "revenue"` — a row count presented as revenue.
17
+ 2. **A `number`/`string`/`boolean` metric.** `AggregationMetricType` documents
18
+ these as _"Custom SQL expression returning a number / string / boolean"_: the
19
+ measure's `sql` **is** the computation — a ratio, a `CASE`, a window
20
+ function. The expression was discarded and replaced by a row count.
21
+ 3. **An unrecognised `type`.** Same silent substitution.
22
+
23
+ Now: an undeclared measure and an unrecognised type **throw**, naming the
24
+ declared measures and both accepted vocabularies respectively; a custom-
25
+ expression type emits its expression unwrapped. The six aggregates are
26
+ unchanged.
27
+
28
+ **A dot no longer implies a relationship hop.** `qualifyAndRegisterJoin` split
29
+ any dotted string into a join chain, so the expression `SUM(account.amount)`
30
+ became `"SUM(account"."amount)"` _plus_ a `LEFT JOIN "SUM(account"` — invalid
31
+ SQL naming a table that does not exist. Harmless only while the result was
32
+ being thrown away for `COUNT(*)`; emitting the expression makes it matter. A
33
+ dotted string is now treated as a path only when every segment is a bare
34
+ identifier, so `account.amount` still lowers to a qualified column and a join,
35
+ and an expression is emitted as written. That also fixes the same mangling for
36
+ an _aggregate_ measure whose `sql` is an expression — `type: 'sum'` with
37
+ `sql: 'SUM(account.amount)'` was producing the same garbage.
38
+
39
+ **Breaking, narrowly.** Two inputs that used to produce SQL now raise: a query
40
+ naming an undeclared measure, and a cube measure with a type outside
41
+ `AggregationMetricType`. Both were returning a wrong number rather than data,
42
+ so nothing correct can depend on them — but a caller that was silently getting
43
+ row counts will now see an error, which is the point. This is the trade #3948
44
+ settled for the drivers.
45
+
46
+ Datasets are unaffected: `aggregateToMetricType` only ever emits an
47
+ `AggregationFunction` member, so a compiled dataset never had a
48
+ custom-expression measure or an unknown type. The reachable path is a
49
+ hand-authored Cube.
50
+
51
+ `metric-type-coverage.test.ts` asserts the aggregate and expression sets
52
+ _partition_ `AggregationMetricType`, so a tenth metric type fails a test rather
53
+ than reaching the throw. Both sets are named, not derived as each other's
54
+ complement — deriving would classify a new _aggregate_ as an expression and emit
55
+ a bare column, a different silent wrong answer.
56
+
57
+ Verified: **460 tests across 35 files** green, including the four suites that
58
+ assert `COUNT(*)` — all of them use a _declared_ `type: 'count'` metric, so none
59
+ relied on a fallback. The 14 new tests were confirmed to fail against the old
60
+ behaviour (6 of 10 in the behaviour suite) before the fix.
61
+
62
+ ### Patch Changes
63
+
64
+ - b4be309: fix(analytics): a new spec aggregate can no longer silently return a row count
65
+
66
+ Track C item 4 of objectstack-ai/objectui#2945 — _"`AggregationFunction`: three
67
+ places in lockstep"_. They agreed only by coincidence, and the failure mode when
68
+ they stopped agreeing was silent wrong numbers.
69
+
70
+ The three:
71
+
72
+ 1. `AggregationFunction` (`@objectstack/spec/data`) — eight members, what an
73
+ author may declare as a dataset measure's `aggregate`.
74
+ 2. `UNSUPPORTED_AGGREGATES` (`dataset-compiler.ts`) — `array_agg`/`string_agg`,
75
+ rejected at compile time with a clear error.
76
+ 3. The aggregate `switch` in `native-sql-strategy.ts` — six cases, then
77
+ `default: return 'COUNT(*)'`.
78
+
79
+ 8 − 2 = 6 = the six cases, today. Add a ninth member to the spec — `median`,
80
+ `percentile`, anything — and it would:
81
+
82
+ - pass the compiler's gate, since it is not in `UNSUPPORTED_AGGREGATES`;
83
+ - be **advertised as supported** by that gate's error message, which listed
84
+ `count, sum, avg, min, max, count_distinct` as hand-written prose — a third
85
+ copy of the vocabulary;
86
+ - reach the strategy's `switch`, match no case, and fall to
87
+ `default: COUNT(*)`.
88
+
89
+ The author asks for a median and gets a row count. No error, no log, wrong
90
+ figures on a dashboard — the same silent-wrong-answer shape as the filter
91
+ operators in #3948, in the analytics SQL builder.
92
+
93
+ **The fix is derivation plus a guard, with no behaviour change.** The `switch`
94
+ becomes `AGGREGATE_SQL`, a table whose coverage is assertable; the error
95
+ message's prose list becomes `SUPPORTED_AGGREGATES`, derived as
96
+ `AggregationFunction.options` minus `UNSUPPORTED_AGGREGATES`; and
97
+ `aggregation-lockstep.test.ts` asserts the arithmetic — the lowered set equals
98
+ the admitted set, every spec member is either lowered or explicitly rejected,
99
+ nothing is both, and the rejection list names only aggregates the spec has.
100
+
101
+ Verified by adding a hypothetical `median` to the spec, which now fails three
102
+ assertions naming it, including _"these would fall through to the COUNT(_)
103
+ fallback and return a row count"\*. Before this change the same edit was green.
104
+
105
+ Nothing is narrowed and no SQL changes: the same six aggregates lower to the
106
+ same six expressions, and the `COUNT(*)` fallback still catches everything else.
107
+
108
+ **Reported, not fixed:** that fallback is also reached by a measure whose `type`
109
+ is `number`/`string`/`boolean` — a custom SQL _expression_, per
110
+ `AggregationMetricType` — whose expression is then replaced by a row count.
111
+ Datasets cannot produce one (`aggregateToMetricType` only ever returns an
112
+ `AggregationFunction` member), so it is reachable only from a hand-authored
113
+ Cube. Emitting `col` instead is a behavioural change in an analytics SQL path
114
+ and deserves its own change with its own tests; the strategy's doc comment now
115
+ records it.
116
+
117
+ - 7a55913: fix(service-analytics): a `$between` analytics filter no longer vanishes from the query (ADR-0053 D-A3.1)
118
+
119
+ A dashboard widget or dataset whose filter used `$between` was querying **every
120
+ row**. `normalizeAnalyticsFilters` maps Mongo-style operators onto the internal
121
+ pipeline form, `$between` was missing from that map, and an unmapped operator is
122
+ skipped — so the predicate was silently dropped from the compiled WHERE clause.
123
+ Both strategies read that normalizer, so both the raw-SQL and the ObjectQL
124
+ aggregate paths were affected. The symptom is #3650's: a chart that draws the
125
+ whole dataset instead of the requested window, with nothing in the SQL to
126
+ suggest a filter was ever asked for.
127
+
128
+ `$between [min, max]` now lowers to its two bounds (`gte` + `lte`) instead of
129
+ gaining an operator of its own, so a range's max inherits the calendar-day
130
+ whole-day rule (#3777) from each strategy's existing upper-bound handling —
131
+ `NativeSQLStrategy` compiles a bare-day upper bound half-open itself, and the
132
+ ObjectQL path gets the same rule from the driver — rather than needing a second
133
+ implementation to keep in step. A malformed `$between` (not a two-element
134
+ array) now throws instead of being dropped, matching the stance driver-memory
135
+ took for the same shape in #3948: an unbounded read is exactly the failure this
136
+ prevents, and it is indistinguishable from a legitimately wide query.
137
+
138
+ Found by giving the temporal conformance matrix its missing sixth consumer
139
+ (`native-sql-temporal-conformance.test.ts`), which executes the shared cases
140
+ against a real SQLite engine and asserts row ids — a dropped predicate is
141
+ invisible to the SQL-string assertions the strategy's other suites use.
142
+
143
+ - 7a55913: fix(service-analytics): every authorable filter operator now reaches the query (#4128)
144
+
145
+ Closes the cause behind the `$between` defect rather than just that instance.
146
+ `normalizeAnalyticsFilters` skipped any operator missing from its map, and a
147
+ skipped predicate does not narrow a query — it **widens** it: the compiled SQL
148
+ stays valid and returns rows the author excluded. Four operators from the
149
+ spec's authorable vocabulary sat in that state, plus one that was mapped
150
+ incorrectly.
151
+
152
+ - **`$startsWith` / `$endsWith`** were dropped entirely. Both strategies now
153
+ compile them — anchored `LIKE 'x%'` / `LIKE '%x'` on the raw-SQL path, and
154
+ the canonical `$startsWith` / `$endsWith` operators (which every driver
155
+ implements directly) on the ObjectQL path, so an anchored match does not
156
+ depend on regex dialect.
157
+ - **`$null`** was dropped. It is the shape the console emits for an "is empty"
158
+ / "is not empty" filter, so such a widget was showing every row. Now compiles
159
+ to `IS NULL` / `IS NOT NULL` per its boolean.
160
+ - **`$exists`** was mapped value-_independently_ to `set`, so `{$exists: false}`
161
+ compiled to `IS NOT NULL` — the exact inverse of what it asks for. It and
162
+ `$null` are now resolved explicitly, because a key→name map cannot express an
163
+ operator whose meaning flips with its value.
164
+ - **`$notContains`** reached the ObjectQL strategy, which had no arm for it and
165
+ fell through to a `default` returning a bare value — compiling "does not
166
+ contain x" as "**equals** x".
167
+ - **Unknown operators now throw** on both surfaces instead of being silently
168
+ dropped (normalizer) or reinterpreted as an equality (ObjectQL strategy). An
169
+ operator outside the vocabulary is a caller error, and a loud one beats a
170
+ silently widened read — the call driver-memory made for the same shape in
171
+ #3948.
172
+
173
+ Still declared as a gap, but no longer a silent one: `$or` / `$not` are skipped,
174
+ since expressing them needs a recursive WHERE builder rather than the flat
175
+ array the strategies consume.
176
+
177
+ Cover is `filter-operator-coverage.test.ts`, which runs the whole vocabulary
178
+ against a real SQLite engine and asserts **row ids** — six of its cases fail
179
+ without this change. A dropped predicate is invisible to the SQL-string
180
+ assertions the strategies' other suites use, which is how these survived.
181
+
182
+ - f5ab1c7: fix(service-analytics): a `$or` / `$not` filter no longer vanishes from an analytics query (#4128 follow-up)
183
+
184
+ The last of the silently-dropped filter family. `normalizeAnalyticsFilters`
185
+ produced a flat **array**, which cannot carry a disjunction, so both strategies
186
+ skipped `$or` and `$not` outright — a widget or dataset whose filter used
187
+ either compiled a WHERE clause that simply did not contain it, and drew every
188
+ row. That is #3650's symptom, and unlike a rejected query it looks like a
189
+ working chart.
190
+
191
+ The normalizer now produces a **tree** (`normalizeAnalyticsFilterTree`), and
192
+ each strategy compiles it the way its own backend expresses a disjunction:
193
+
194
+ - **`NativeSQLStrategy`** builds the WHERE recursively, routing every leaf
195
+ through its existing clause emitter — so the storage-form coercion and the
196
+ calendar-day upper-bound rule (#3777) apply at every depth, including inside
197
+ an `$or`. Parentheses are explicit rather than relying on SQL precedence.
198
+ - **`ObjectQLStrategy`** hands `$or` / `$not` to the engine, which speaks them
199
+ natively. AND-ed leaves still merge per field exactly as before, so a query
200
+ without combinators produces byte-identical engine input.
201
+ - **`/analytics/sql`** renders the same tree, so the echoed statement keeps
202
+ reproducing what executes rather than showing a conjunction where the engine
203
+ runs a disjunction.
204
+ - The **cross-object envelope check** now sees members nested inside an `$or`.
205
+ It rejects cross-object filters, so a member it could not see was a filter it
206
+ could not reject.
207
+
208
+ Empty `$and` / `$or` arrays now throw instead of being ignored, matching the
209
+ fail-closed stance of `read-scope-sql.ts` — the compiler in this same package
210
+ that has always handled the full tree, and whose semantics the tree walker now
211
+ mirrors deliberately.
212
+
213
+ Cover is `native-sql-filter-logic-conformance.test.ts`, which runs the shared
214
+ combinator table (`FILTER_LOGIC_CASES`, #3774) against a real SQLite engine and
215
+ asserts row ids. The analytics raw-SQL path now stands beside `driver-sql`,
216
+ `driver-memory`, `formula` and `read-scope-sql` under that one standard; 14 of
217
+ its 17 cases fail without this change.
218
+
219
+ - 3abd233: fix(analytics): project a `timeDimensions` bucket into the result rows and fields (#4033)
220
+
221
+ An analytics query that buckets by `timeDimensions` alone grouped correctly —
222
+ the echoed SQL read `date_trunc('month', due_date) AS "due_date"` — but the row
223
+ mapper and `buildFieldMeta` both enumerated `query.dimensions` only, so the
224
+ bucket never reached the caller: rows carried just the measures and `fields`
225
+ never mentioned the dimension. A trend chart got N values and no x-axis. The
226
+ same query written with `dimensions: ['due_date']` was unaffected, which is why
227
+ it went unnoticed.
228
+
229
+ Grouping, row mapping and field metadata now derive the projected set from one
230
+ `projectedDimensions()` helper — `dimensions` plus every _granular_
231
+ `timeDimensions` entry not already among them. A `timeDimensions` entry without
232
+ a granularity contributes only its `dateRange` predicate and stays out of the
233
+ projection, so no phantom column is declared.
234
+
235
+ - 0af50a3: fix(driver-sql,service-analytics): a bare-day upper bound covers the whole day on `Field.datetime` (#3777)
236
+
237
+ A bare `YYYY-MM-DD` comparand anchors to midnight UTC. That is right for a
238
+ lower bound and was silently wrong for an upper one: the dashboard date-range
239
+ filter compiles `{ $gte: from, $lte: to }` with bare-day bounds, so on a
240
+ `datetime` column every row created after 00:00 of the `to` day vanished from
241
+ the result — no error, the chart renders, the numbers are just smaller. The
242
+ default configuration hit it: the filter's default field is `created_at`
243
+ (a system-injected `Field.datetime`) and 7 of the 13 presets end "today".
244
+
245
+ The translation is operator-sensitive and half-open, applied at every
246
+ comparison emitter:
247
+
248
+ - `SqlDriver` (and `SqliteWasmDriver` by inheritance): `$lte`/`<=` with a
249
+ bare-day comparand on a `datetime` column compiles to `< next-day-midnight`
250
+ in the column's storage form; `$between [min, max]` with a bare-day max
251
+ decomposes to `>= min AND < next-day(max)`. Both the plain and the
252
+ legacy-repair (mixed-storage) column paths, both `where` spellings.
253
+ - `NativeSQLStrategy`: `dateRange` windows and `lte` filters bind `< next-day`
254
+ instead of an inclusive `BETWEEN`/`<=` when the bound is a bare day.
255
+ - The `/analytics/sql` rendering and the dataset preview evaluator apply the
256
+ same rule, so the echoed SQL and drafted numbers reproduce execution.
257
+
258
+ `@objectstack/core` gains the shared primitive `nextUtcCalendarDay(value)`:
259
+ the next calendar day of a valid bare `YYYY-MM-DD` (else `null` — instants,
260
+ `Date`s and impossible days are never widened).
261
+
262
+ Unchanged on purpose, per the semantics table on #3777: `date`/`time` columns
263
+ (`<= day` is already whole-day-correct there), full-ISO/`Date` comparands
264
+ (instant semantics), and `$gte`/`$gt`/`$lt` (midnight anchoring is correct for
265
+ those). No authored metadata changes: a dashboard's existing
266
+ `{ $gte, $lte }` window now simply includes its final day.
267
+
268
+ - 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261)
269
+
270
+ The AGENTS.md post-task checklist requires breaking changesets to carry their
271
+ FROM → TO migration because "this text ships to consumers as `CHANGELOG.md`
272
+ inside the npm package and is what an upgrading agent greps after the tombstone
273
+ error." That delivery path was severed for 68 of the 69 publishable packages:
274
+ npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike
275
+ older npm versions — not `CHANGELOG.md`, and the canonical
276
+ `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm
277
+ 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its
278
+ 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it
279
+ explicitly.
280
+
281
+ The tombstone-error scenario is precisely the one where the repo is out of
282
+ reach — the upgrading agent has `node_modules` and nothing else — so the
283
+ migration text has to ride in the tarball. Every publishable package now
284
+ declares `CHANGELOG.md` in `files`, and the canonical whitelist is
285
+ `["dist", "README.md", "CHANGELOG.md"]`.
286
+
287
+ The other half is the gate: `check:published-files` gains a fifth invariant,
288
+ COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the
289
+ always-required lint job, so the next package cannot silently sever the path
290
+ again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves
291
+ into the canonical set.
292
+
293
+ Consumer-visible change: one more file per install (the package's changelog,
294
+ e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key"
295
+ node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was
296
+ promised.
297
+
298
+ - c8124e5: fix(driver-sql): give `Field.datetime` one UTC storage form per dialect (#3912, #3942)
299
+
300
+ Any window filter on a `Field.datetime` column returned an empty set on SQLite —
301
+ a dashboard `dateRange: last_30_days` on `created_date` read 0 while 29 matching
302
+ rows existed.
303
+
304
+ There was never a storage _convention_, only a description of what better-sqlite3
305
+ happened to do with a bound JS `Date`. Nothing enforced it — `formatInput`
306
+ deliberately left `datetime` untouched — so the form was decided by whichever
307
+ writer got there first: a JS `Date` landed as INTEGER epoch ms, while a REST/JSON
308
+ write (JSON has no `Date` type), a `defaultValue: 'NOW()'` slot, and the
309
+ platform's own `created_at` / `updated_at` all landed as ISO **TEXT**. One column
310
+ held both forms while the read path coerced comparands to epoch ms purely from
311
+ the _declared_ type. On SQLite's type ordering (`INTEGER < TEXT`) a two-sided
312
+ window collapsed to zero rows, and a one-sided `>=` matched every TEXT row
313
+ regardless of the bound.
314
+
315
+ `Field.datetime` now has one canonical instant per dialect, produced by one
316
+ function applied on write **and** to every filter comparand, so the two sides of
317
+ a comparison cannot disagree about shape:
318
+
319
+ - **SQLite** — `YYYY-MM-DDTHH:MM:SS.sssZ` text. Lexicographic order _is_
320
+ chronological order, so range filters and `ORDER BY` read the column directly
321
+ and can use an index; `strftime` parses it, so the date-bucket expression needs
322
+ no CASE.
323
+ - **Postgres** — `timestamptz`, unchanged. The fix here is on the write and
324
+ comparand side: a zone-naive write was previously resolved against the
325
+ _server's_ timezone (measured 8 hours off on `Asia/Shanghai`), and an
326
+ un-anchored `YYYY-MM-DD` comparand meant the server's local midnight, so the
327
+ identical query over the identical instant landed a row on a different calendar
328
+ day than SQLite did.
329
+ - **MySQL** — `DATETIME(3)` instead of `TIMESTAMP`, a connection pinned to UTC on
330
+ both the mysql2 and the server layer, and a MySQL-spelled bind carrying the
331
+ same UTC wall clock. MySQL accepts neither the `T` separator nor the `Z` suffix
332
+ in a datetime literal, so datetime writes over REST had always failed outright;
333
+ `TIMESTAMP` additionally truncated milliseconds and could not store an instant
334
+ outside 1970..2038.
335
+
336
+ Existing rows converge at schema sync. Both migrations are allowed to fail: they
337
+ log, mark nothing, and the read paths keep a repair expression, so an un-migrated
338
+ column still compares and buckets **correctly** — just unindexed. Neither can
339
+ repair instants the old timezone-ambiguous write path recorded wrongly; they
340
+ preserve what is on disk.
341
+
342
+ Also closes #3928 (datetime `ORDER BY` mis-sorted on mixed storage) by
343
+ construction. Rationale is recorded as ADR-0053 addendum D-B1..D-B4.
344
+
345
+ The analytics change is additive: a `coerceTemporalFilterColumn` companion to the
346
+ existing `coerceTemporalFilterValue` hook, so a raw-SQL strategy can normalise the
347
+ column side too. Absent hook → byte-identical SQL.
348
+
349
+ - be7360c: chore(plugins,services): declare `providesServices` on the 20 remaining init-time service providers (ADR-0116 follow-up, #4131)
350
+
351
+ ADR-0116 gave the kernel a declared ordering contract, but only
352
+ `ObjectQLPlugin` and `MetadataPlugin` had declared what their `init()`
353
+ registers. The pre-Phase-1 ordering check can only _name a provider_ for
354
+ services someone declared, so its coverage was two plugins wide.
355
+
356
+ An audit of every plugin's `init()` body (brace-matched, comments stripped,
357
+ each call classified by whether it sits inside a `try`/`if`) found 20 plugins
358
+ that register a service on every path without declaring it. All 20 now
359
+ declare `providesServices`. Purely additive: no ordering changes, no new
360
+ failure modes — a `providesServices` entry only lets the kernel say _who_
361
+ provides a service when it reports a misordering, and enriches the Phase-1
362
+ `getService` miss diagnostic.
363
+
364
+ Three needed a closer read before declaring, because they register the same
365
+ service from several branches (`cache`, `queue`, `job`): each early-return
366
+ branch plus the fallback registers it, so every path does — the declaration
367
+ is honest. ADR-0116's rule that a _conditionally_ registered service must
368
+ never be declared is unchanged and was applied throughout.
369
+
370
+ The same audit found 12 plugins that hard-resolve a service during `init()`
371
+ (11 of them `manifest`) without declaring `requiresServices`. None is a live
372
+ exposure — every one already declares a hard `dependencies` entry on the
373
+ provider, so the kernel orders them correctly today. Those are tracked
374
+ separately: with a hard dependency in place, `requiresServices` mostly
375
+ restates what the kernel already enforces, and its real value is on
376
+ _soft_-dependency consumers, of which `AppPlugin` is currently the only one.
377
+
378
+ - f752ee3: feat(analytics): order the time axis by default, and give reports a sort declaration (#3916)
379
+
380
+ A matrix report with a date dimension across rendered its columns in arbitrary
381
+ order — `2026-07-01, 2026-07-05, …, 2026-07-02`. Declaring `dateGranularity` on
382
+ the dataset dimension made the bucket keys _sortable_ (`2026-07`, `2026-Q3`)
383
+ without making anything _sort_ them, and the report author had no way to ask:
384
+ `DatasetSelection.order` existed on the wire, but `ReportSchema` had no ordering
385
+ field at all (dashboard widgets had their own `options.sortBy` channel; reports
386
+ did not). Nothing in the chain supplied an order either — `resolveOrdering`
387
+ returned `undefined` unless the selection carried one explicitly, the ObjectQL
388
+ aggregate path has no ordering grammar so its buckets came back in Map-insertion
389
+ order, and the pivot builds its column headers in row-arrival order.
390
+
391
+ - **A selected time dimension is now chronological by default.** When a
392
+ selection states no `order` (and no `limit`, whose own fallback already
393
+ ordered by every dimension), each selected dimension the cube types as `time`
394
+ defaults to ASCENDING, in selection order. Bucket keys are minted sort-stable
395
+ precisely so this works — `2026-07` sorts after `2026-06`, `2026-Q3` after
396
+ `2026-Q1`. This lands on both strategy paths: a real `ORDER BY` where native
397
+ SQL serves the query, and the executor's post-pass where a date-bucketed query
398
+ is handed to the ObjectQL path. Null / empty buckets stay last, as everywhere
399
+ else. Deliberately narrow: only time dimensions get a default, so grids with
400
+ nothing wrong with them are not reordered.
401
+ - **Reports can declare an ordering.** `ReportSchema.order` (and
402
+ `blocks[].order` for a `joined` report) is a list of `{ by, direction }` sort
403
+ keys, most significant first — an array, not a `Record`, because key order is
404
+ the contract and JSON object key order should not have to be. `by` must name a
405
+ dimension the report groups by (`rows` / `columns`) or a measure it displays
406
+ (`values`); anything else fails at authoring time rather than becoming an
407
+ ordering that silently does nothing. Duplicate keys are rejected. A `joined`
408
+ report orders per block — declaring `order` on the container is an error.
409
+ `reportSelectionOrder()` lowers the list into the `DatasetSelection.order` a
410
+ renderer posts, and returns `undefined` for an empty list so the runtime's own
411
+ defaults still apply.
412
+
413
+ An explicit `order` still wins outright — the chronological default is a
414
+ default, not a policy, so "newest month first" is one declaration away.
415
+
416
+ `report.order` ships as `planned` + `authorWarn` in the liveness ledger: the
417
+ framework half is complete and live (schema, lowering helper, executor), but
418
+ objectui's `DatasetReportRenderer` does not yet carry `report.order` into the
419
+ selection it posts. The default time-axis ordering needs no renderer change and
420
+ is live now.
421
+
422
+ - b3a3d83: feat(spec): a shared temporal conformance matrix, and the `$between` gap it found (ADR-0053 D-A3, #4081)
423
+
424
+ `@objectstack/spec/data` gains `TEMPORAL_ROWS` and `TEMPORAL_CASES` — the
425
+ single set of temporal filter cases every backend is checked against, the twin
426
+ of the existing `FILTER_LOGIC_CASES`. Five backends consume it and assert **row
427
+ results**: `driver-sql` (and, through the live-dialect CI job, real Postgres and
428
+ MySQL), `driver-memory`, `driver-mongodb` (real MongoDB), the analytics preview
429
+ evaluator, and `formula`'s RLS write-side `check`.
430
+
431
+ This is the regression backstop ADR-0053 D-A3 has asked for since 2026-06 and
432
+ the last of its decisions to be actioned. Four separate incidents — #3650,
433
+ #3773, #3777, #4047 — were each found by a human by accident, and each left a
434
+ suite proving only its own issue against its own fixture. Nothing held the
435
+ backends to one standard, so the fifth divergence had nowhere to fail.
436
+
437
+ **`service-analytics` — a real fix the matrix found on its first run.** The
438
+ draft-preview evaluator had no `$between` case, so it fell through to its
439
+ permissive `default` and matched **every** row: a drafted dashboard carrying a
440
+ range filter charted the entire dataset, then changed its numbers at publish —
441
+ the exact continuity the preview exists to provide. It now evaluates
442
+ `$between`, sharing the upper-bound helper with `$lte` so the whole-day
443
+ calendar-day rule (#3777) applies to a range's max as well.
444
+
445
+ Also recorded (ADR-0053 D-A3.1): `$gt` with a bare-day comparand on a
446
+ `datetime` column cannot agree between typed and type-blind backends, and the
447
+ gap is irreducible without field types. It is asserted in the shared matrix on
448
+ `date` only, with the `datetime` cell left to the typed drivers' own suites,
449
+ rather than papered over.
450
+
451
+ - 35accbf: feat(spec): promote the temporal storage hooks onto the IDataDriver contract (ADR-0053 D-A2)
452
+
453
+ `temporalFilterValue` and `temporalFilterColumnSql` — the pair that closed
454
+ #3912's storage-form drift — were duck-typed: analytics probed
455
+ `typeof driver.x === 'function'` against a locally-invented interface, and
456
+ nothing at the type level said a driver must implement both or neither. The
457
+ lesson of #3912 is precisely that coercing the comparand without normalising
458
+ the column reintroduces half the bug, so a driver implementing one hook alone
459
+ would silently regress.
460
+
461
+ Both are now optional members of `IDataDriver`
462
+ (`@objectstack/spec/contracts`), documented as a pair with "absent = identity"
463
+ semantics for drivers whose storage form is the wire form (memory, mongo).
464
+ `SqlDriver implements IDataDriver`, so its signatures are compile-checked from
465
+ here on; analytics derives its driver seam by `Pick`-ing the contract instead
466
+ of a local duck type. Runtime `typeof` guards remain — that is the correct way
467
+ to consume an optional contract member — but the shape they guard now has one
468
+ authoritative definition.
469
+
470
+ No runtime behaviour change. ADR-0053 D-A2 is recorded as resolved.
471
+
472
+ - e4c2dc8: Order temporal operands correctly when one side is a JS `Date` on the two
473
+ type-blind filter backends (ADR-0053 D-A3 / #4191).
474
+
475
+ `utcInstantMs` joins `nextUtcCalendarDay` in `@objectstack/spec/data`
476
+ (re-exported from `@objectstack/core`): it reads the UTC instant a temporal
477
+ operand denotes, accepting only unambiguous spellings — a `Date`, epoch ms, a
478
+ bare `YYYY-MM-DD`, and an ISO timestamp with or without an explicit zone (a
479
+ zone-naive one being UTC, per D-B2) — and returning `null` for everything
480
+ else, notably a bare wall clock, which denotes no instant.
481
+
482
+ Both type-blind evaluators now use it to compare a `Date` against wire text,
483
+ which JS relational operators cannot do: `<` and friends coerce with hint
484
+ `number`, so the `Date` becomes its epoch and the string becomes `NaN`.
485
+
486
+ - `formula`'s `matchesFilterCondition` (the RLS write-side `check`) dropped
487
+ every `Date`-valued row in 10 of the 16 shared conformance cases. The
488
+ post-image is the caller's raw write payload, so an SDK write of
489
+ `new Date()` hit this directly, and fail-closed turned it into a **denied
490
+ write**.
491
+ - `service-analytics`' preview evaluator diverged on the same 10 cases in
492
+ BOTH directions, because `String(new Date())` sorts after every `'2026-…'`
493
+ comparand — a drafted chart both lost rows and gained ones, then changed
494
+ its numbers at publish. Rows from a mongo-backed dataset arrive as BSON
495
+ `Date`s, so this was reachable in normal use.
496
+
497
+ Comparisons that did not involve a `Date` are unchanged.
498
+
499
+ - Updated dependencies [6a67d7a]
500
+ - Updated dependencies [0ecc656]
501
+ - Updated dependencies [06772eb]
502
+ - Updated dependencies [270650f]
503
+ - Updated dependencies [3aef718]
504
+ - Updated dependencies [1ea6bce]
505
+ - Updated dependencies [c1dcacd]
506
+ - Updated dependencies [ad303ed]
507
+ - Updated dependencies [32ccb23]
508
+ - Updated dependencies [f5a4ef0]
509
+ - Updated dependencies [2d3e255]
510
+ - Updated dependencies [7d7521f]
511
+ - Updated dependencies [5dc4d02]
512
+ - Updated dependencies [05154a1]
513
+ - Updated dependencies [9b6fe7c]
514
+ - Updated dependencies [8c711fb]
515
+ - Updated dependencies [09e4547]
516
+ - Updated dependencies [91f4c78]
517
+ - Updated dependencies [820eff9]
518
+ - Updated dependencies [8d895ff]
519
+ - Updated dependencies [f6472d7]
520
+ - Updated dependencies [78caf51]
521
+ - Updated dependencies [62a789b]
522
+ - Updated dependencies [789ad63]
523
+ - Updated dependencies [2af1988]
524
+ - Updated dependencies [0af50a3]
525
+ - Updated dependencies [2e836de]
526
+ - Updated dependencies [12a19a8]
527
+ - Updated dependencies [41dcda3]
528
+ - Updated dependencies [c8124e5]
529
+ - Updated dependencies [a1a4140]
530
+ - Updated dependencies [217e2e6]
531
+ - Updated dependencies [86a71d1]
532
+ - Updated dependencies [d5c75e2]
533
+ - Updated dependencies [03d26f7]
534
+ - Updated dependencies [4384921]
535
+ - Updated dependencies [3c628ce]
536
+ - Updated dependencies [7cb922e]
537
+ - Updated dependencies [1d22114]
538
+ - Updated dependencies [b5f9397]
539
+ - Updated dependencies [ed77493]
540
+ - Updated dependencies [58a03d2]
541
+ - Updated dependencies [dc530b4]
542
+ - Updated dependencies [e59786e]
543
+ - Updated dependencies [bcf1112]
544
+ - Updated dependencies [9774b78]
545
+ - Updated dependencies [b07d829]
546
+ - Updated dependencies [a648e96]
547
+ - Updated dependencies [a47ac06]
548
+ - Updated dependencies [e4c61a7]
549
+ - Updated dependencies [cc60165]
550
+ - Updated dependencies [081aa6f]
551
+ - Updated dependencies [91f4c78]
552
+ - Updated dependencies [e8d0c21]
553
+ - Updated dependencies [45dc446]
554
+ - Updated dependencies [c1d44f7]
555
+ - Updated dependencies [ab9fb5c]
556
+ - Updated dependencies [f985b3f]
557
+ - Updated dependencies [9a4932a]
558
+ - Updated dependencies [f9fc874]
559
+ - Updated dependencies [011b386]
560
+ - Updated dependencies [7777e8f]
561
+ - Updated dependencies [507b92a]
562
+ - Updated dependencies [7309c81]
563
+ - Updated dependencies [20bc1ec]
564
+ - Updated dependencies [90c2b15]
565
+ - Updated dependencies [42eeb7d]
566
+ - Updated dependencies [01e124d]
567
+ - Updated dependencies [7ce02eb]
568
+ - Updated dependencies [a13827e]
569
+ - Updated dependencies [7733604]
570
+ - Updated dependencies [40e420f]
571
+ - Updated dependencies [d13004a]
572
+ - Updated dependencies [be7360c]
573
+ - Updated dependencies [5b47ab5]
574
+ - Updated dependencies [b09d8d9]
575
+ - Updated dependencies [b09d8d9]
576
+ - Updated dependencies [8675db6]
577
+ - Updated dependencies [b09d8d9]
578
+ - Updated dependencies [3eb1b2b]
579
+ - Updated dependencies [59b85c0]
580
+ - Updated dependencies [6e357ed]
581
+ - Updated dependencies [d6938bf]
582
+ - Updated dependencies [31e0be9]
583
+ - Updated dependencies [4bfd455]
584
+ - Updated dependencies [ffd2ce2]
585
+ - Updated dependencies [62f8017]
586
+ - Updated dependencies [a831df1]
587
+ - Updated dependencies [f752ee3]
588
+ - Updated dependencies [a1b61e0]
589
+ - Updated dependencies [cd6b9f2]
590
+ - Updated dependencies [2cb6d3c]
591
+ - Updated dependencies [af2a095]
592
+ - Updated dependencies [ec796d5]
593
+ - Updated dependencies [e87fea1]
594
+ - Updated dependencies [c65e529]
595
+ - Updated dependencies [3ca34c1]
596
+ - Updated dependencies [239c3a3]
597
+ - Updated dependencies [94a0bbc]
598
+ - Updated dependencies [d6bfb3d]
599
+ - Updated dependencies [a2266a6]
600
+ - Updated dependencies [d25a0ec]
601
+ - Updated dependencies [667b83e]
602
+ - Updated dependencies [627b188]
603
+ - Updated dependencies [8d4eae7]
604
+ - Updated dependencies [857a6cf]
605
+ - Updated dependencies [65a3a84]
606
+ - Updated dependencies [ccd9397]
607
+ - Updated dependencies [bca935b]
608
+ - Updated dependencies [d92c72d]
609
+ - Updated dependencies [c54c822]
610
+ - Updated dependencies [8dcc0f5]
611
+ - Updated dependencies [75b9e51]
612
+ - Updated dependencies [0a2f233]
613
+ - Updated dependencies [8621cdd]
614
+ - Updated dependencies [6f23667]
615
+ - Updated dependencies [5d21a48]
616
+ - Updated dependencies [19365b7]
617
+ - Updated dependencies [b7ed26d]
618
+ - Updated dependencies [b3a3d83]
619
+ - Updated dependencies [7a55913]
620
+ - Updated dependencies [35accbf]
621
+ - Updated dependencies [6038de7]
622
+ - Updated dependencies [eb95d97]
623
+ - Updated dependencies [e4c2dc8]
624
+ - Updated dependencies [1bd2795]
625
+ - Updated dependencies [8186a70]
626
+ - Updated dependencies [a329cca]
627
+ - Updated dependencies [6eec18c]
628
+ - Updated dependencies [4d7bebf]
629
+ - Updated dependencies [821ac7a]
630
+ - Updated dependencies [8f81731]
631
+ - Updated dependencies [8b50cb3]
632
+ - Updated dependencies [8c2db68]
633
+ - Updated dependencies [22b5e54]
634
+ - Updated dependencies [0166bd5]
635
+ - Updated dependencies [9b702dc]
636
+ - Updated dependencies [ab16331]
637
+ - @objectstack/spec@17.0.0-rc.1
638
+ - @objectstack/core@17.0.0-rc.1
639
+
640
+ ## 17.0.0-rc.0
641
+
642
+ ### Minor Changes
643
+
644
+ - 840ee4b: fix(analytics,runtime,types): gate cube auto-inference on object existence; stop the dispatcher boundary returning raw SQL (#3867)
645
+
646
+ Two independent defects on the `/analytics` surface, found while verifying #3770
647
+ against a real server. On an authenticated CRM dev server, before this change:
648
+
649
+ ```
650
+ POST /api/v1/analytics/query {"cube":"sqlite_master","measures":["count"],"dimensions":["type"]}
651
+ → 200 {"rows":[{"type":"index","count":262},{"type":"table","count":71},{"type":"view","count":1}],
652
+ "sql":"SELECT type AS \"type\", COUNT(*) AS \"count\" FROM \"sqlite_master\" GROUP BY type"}
653
+ ```
654
+
655
+ That is SQLite's internal schema table — never a registered object — read
656
+ successfully through the analytics endpoint. Not merely "the name reaches the
657
+ driver and errors": **any table the connection can see was readable.**
658
+
659
+ **① The cube name reached the driver as a table name.** `AnalyticsService.ensureCube`
660
+ auto-infers a minimal Cube when none is registered, with `cube.sql = <the queried
661
+ name>`. That is the intended "metric over an object" path — an `object-metric` KPI
662
+ widget queries `crm_account` with no authored Cube — but it accepted _any_ string,
663
+ so the endpoint could aggregate over an arbitrary physical table. The
664
+ analytics-side twin of the data-path gap #3770 closed, and it was not covered by
665
+ that fix: #3770 gated the protocol's `analyticsQuery`, which is the _degraded
666
+ fallback_; a deployment with `@objectstack/service-analytics` installed runs the
667
+ real engine instead (`ctx.replaceService`).
668
+
669
+ Inference is now gated on the same schema registry the data path consults, via a
670
+ new optional `AnalyticsServiceConfig.isRegisteredObject` that `plugin.ts` wires
671
+ from the `data` engine's `getObject`. Three-way rule: a registered Cube runs
672
+ untouched (its `sql` is whatever it declares); an unregistered name that IS an
673
+ object still auto-infers exactly as before; neither → `CUBE_NOT_FOUND` / 404
674
+ raised before any SQL exists, naming both ways to make the request valid. With no
675
+ probe configured the gate stands down and warns once — the same tiering #3770
676
+ took for a missing registry. `generateSql` (`/analytics/sql`) is gated too.
677
+
678
+ **② The dispatcher boundary returned `err.message` verbatim.** `errorResponseBase`
679
+ is the single error exit for _every_ route the dispatcher plugin mounts —
680
+ `/analytics`, `/packages`, `/i18n`, `/storage`, `/automation`, `/auth`,
681
+ `/notifications`, `/mcp`. `@objectstack/rest` has guarded its data routes against
682
+ driver dumps forever (`mapDataError`); this boundary guarded nothing, so any
683
+ driver error on any of those routes shipped its SQL to the client. Unlike ①, this
684
+ half is unconditional — it does not depend on the cube being invalid.
685
+
686
+ The leak heuristic moved out of `rest-server.ts` into `@objectstack/types` as
687
+ `looksLikeInternalErrorLeak` (both packages already depend on it) and is now
688
+ applied at both boundaries — one predicate, one place to widen when a new
689
+ dialect's phrasing shows up. `mapDataError`'s behaviour is unchanged. At the
690
+ dispatcher it applies **only to 5xx**: a 4xx message is a deliberate
691
+ business/validation answer and must reach the caller intact. Sanitising costs no
692
+ diagnostics — the untouched error still reaches `errorReporter` through the
693
+ existing `__obsRecordedError` side-channel.
694
+
695
+ **Also fixed in the same function:** `errorResponseBase` read only
696
+ `err.statusCode`, while domain errors across this codebase carry `status` (and
697
+ `HttpDispatcher.errorFromThrown` already reads `status` first). Every deliberate
698
+ 4xx thrown through a dispatcher route — including #3770's `OBJECT_NOT_FOUND` on
699
+ the analytics fallback path — was rendered as a **500**. It now reads `status`
700
+ then `statusCode`.
701
+
702
+ **Behaviour change.** `/analytics/query` and `/analytics/sql` return 404
703
+ `CUBE_NOT_FOUND` for a cube that is neither registered nor a registered object;
704
+ previously the name was passed to the driver. Dashboards and KPI widgets pointed
705
+ at real objects or authored cubes are unaffected. A 5xx on a dispatcher route
706
+ whose message looks like a driver dump now reads `Internal server error` — check
707
+ server logs or your error reporter for the original.
708
+
709
+ - 587fc91: feat(analytics): the executeAggregate bridge carries ExecutionContext — ADR-0021 D-C second belt
710
+
711
+ The analytics→engine bridge now forwards the request's `ExecutionContext` to
712
+ `engine.aggregate`, so the engine's own middleware chain scopes analytics reads
713
+ independently of the analytics layer's `getReadScope`.
714
+
715
+ **Why.** `BaseEngineOptions.context` has always been `.optional()`, so nothing
716
+ forced the bridge to pass it — and it did not. An authenticated aggregate
717
+ reached the engine with no principal, plugin-security's principal-less fall-open
718
+ skipped its RLS injection, and the only thing left scoping the query was the
719
+ strategy remembering to call `getReadScope`. #3597 was a strategy that did not,
720
+ and both belts were off at once.
721
+
722
+ `getReadScope` stays: the two resolve scope through different paths (engine
723
+ middleware vs `security.getReadFilter`), and a deployment without
724
+ plugin-security has only the analytics layer. This is depth, not a replacement.
725
+
726
+ - `StrategyContext` gains `context?: ExecutionContext`, bound per call by
727
+ `AnalyticsService` from `query()` / `generateSql()` / `queryDataset()`.
728
+ - `StrategyContext.executeAggregate` and the `AnalyticsServicePlugin` /
729
+ `AnalyticsService` `executeAggregate` config options gain `context?:
730
+ ExecutionContext`. **Custom bridges should forward it** to their engine; the
731
+ built-in auto-bridge does. Purely additive — an existing bridge that ignores
732
+ it keeps working exactly as before.
733
+ - `DimensionLabelDeps.fetchRecordLabels` and `resolveDimensionLabels` each gain
734
+ an optional trailing `context`, beside the `scope` / `resolveScope` that
735
+ #3639 added — the same two-belt split as the aggregate path.
736
+ - `BootOptions.analytics` (`@objectstack/verify`) overrides the
737
+ AnalyticsServicePlugin instance, so a gate can boot with the analytics belt
738
+ off and assert the engine-side belt alone still scopes.
739
+
740
+ **Also fixed on the same seam:**
741
+
742
+ - `fetchRecordLabels` — the dimension display-label lookup — is row-granular
743
+ (one row per record, real display names). #3639 gave it the analytics-layer
744
+ belt (the referenced object's own read scope); it now also carries the
745
+ context, so the engine scopes the same read independently.
746
+ - `ObjectQLStrategy.generateSql` emitted no `WHERE` at all, so the
747
+ `/analytics/sql` preview read as an unscoped table scan while the real
748
+ aggregate was scoped. It now renders the caller's filters and the read scope.
749
+ The preview never executed, so this was misleading output rather than a leak.
750
+
751
+ - 763931e: feat(filters): evaluate `{filter-token}` placeholders server-side (#3582)
752
+
753
+ Filter values travel as JSON, so a time- or user-scoped slice writes a
754
+ placeholder instead of code:
755
+
756
+ ```ts
757
+ filter: { close_date: { $gte: '{current_year_start}' }, owner: '{current_user_id}' }
758
+ ```
759
+
760
+ The vocabulary has been in `@objectstack/spec` for a while (`date-macros.zod.ts`,
761
+ `context-tokens.zod.ts`) and `objectstack build` rejects tokens outside it
762
+ (#3574). What was missing is the half that _substitutes a value_: **nothing on
763
+ the server ever did**. A placeholder reached the driver as the literal string
764
+ `'{current_year_start}'`, compared as text, and matched nothing.
765
+
766
+ That failure is invisible — an empty widget looks exactly like a metric that is
767
+ legitimately zero — so apps worked around it by computing dates at module load,
768
+ which freezes "this year" into the built artifact and quietly goes stale.
769
+
770
+ **New: `resolveFilterTokens()` in `@objectstack/core`**, wired into the two
771
+ server-side seams every filter passes through:
772
+
773
+ - **ObjectQL read path** — `find` / `findOne` / `count` / `aggregate`, so REST
774
+ queries, related lists, saved-view filters and flow `find_records` all resolve.
775
+ It runs before the middleware chain, so only author-supplied filters are
776
+ inspected; RLS/sharing filters are injected downstream from concrete values.
777
+ - **Analytics dataset executor** — a dataset's intrinsic `filter`, a widget's
778
+ `runtimeFilter`, measure-scoped filters, and time-dimension `dateRange`s.
779
+ This path needs its own call: `NativeSQLStrategy` compiles raw SQL and binds
780
+ comparands directly, so a dashboard widget never passes through `engine.find()`.
781
+
782
+ Behavioural notes:
783
+
784
+ - Date tokens resolve to ISO strings (`YYYY-MM-DD`, or a full timestamp for
785
+ `{now}` / `{N_hours_ago}` / `{N_minutes_ago}`). Turning that into a column's
786
+ on-disk form stays the driver's job (`SqlDriver.temporalFilterValue`), so
787
+ there is still exactly one source of truth for the storage convention.
788
+ - Calendar boundaries follow `ExecutionContext.timezone`; one instant is pinned
789
+ per filter tree, so a `>= {current_month_start}` / `< {next_month_start}` pair
790
+ can never straddle a boundary.
791
+ - `{current_org_id}` reads `ExecutionContext.tenantId`; `{current_user_id}` reads
792
+ `userId`. A request carrying neither now **throws** instead of resolving to
793
+ `null` — a null comparand degrades to `IS NULL` on most drivers and would hand
794
+ back the rows the filter was written to exclude.
795
+ - An unrecognised placeholder **throws**, carrying the near-miss fix
796
+ (`{current_user}` → `{current_user_id}`, `{this_quarter_start}` →
797
+ `{current_quarter_start}`). This matches what `objectstack build` already
798
+ enforces. Consequence, previously implicit and now load-bearing: a filter value
799
+ that is _entirely_ `{...}` is always read as a placeholder, so a literal value
800
+ of that shape is not expressible — rename the value.
801
+
802
+ Also in this change: `notify` no longer sends the six-character string
803
+ `"undefined"` as an audience member. `to: ['{record.owner.manager}']` walks
804
+ `.manager` on a scalar foreign-key id, resolves to nothing, and `String(undefined)`
805
+ turned that into a phantom recipient — the emit "succeeded", addressed nobody,
806
+ and said nothing. Unresolved recipients are now dropped, and a node with no
807
+ recipient left fails naming the offending template and pointing at the start
808
+ node's `config.expand` (#3475), which does hydrate the relation.
809
+
810
+ - fc5f126: feat(analytics): serve in-envelope cross-object grouping on the ObjectQL path by FK-expand (#3654)
811
+
812
+ `engine.aggregate()` cannot join, so the ObjectQL fallback path (date-granularity
813
+ bucketing, in-memory driver, federated objects) previously REJECTED any
814
+ cross-object grouping like `revenue by account.region` (#3664 stopgap — a loud
815
+ error instead of the earlier silent `(null)` mis-bucket). It now SERVES the
816
+ common case directly.
817
+
818
+ For a single-hop cross-object DIMENSION with recombinable measures, the strategy:
819
+
820
+ 1. groups the base aggregate on the lookup FK column (`account`) — which the
821
+ engine can do — scoped to the base object;
822
+ 2. resolves each FK id to the related attribute (`region`) with a read of the
823
+ referenced object **scoped to that object's own RLS**; then
824
+ 3. re-buckets by the resolved attribute in memory, recombining the measures
825
+ (sum/count add; min/max take the extremum).
826
+
827
+ A base row whose referenced record the caller cannot read buckets under an
828
+ explicit `(restricted)` group: its measure still counts (grand totals are
829
+ preserved) but the hidden record's attribute never appears — no leak (ADR-0021
830
+ D-C, the #3602 class). `/analytics/sql` renders the equivalent `LEFT JOIN`.
831
+
832
+ Deliberately bounded — still REJECTED (loud, never silently wrong): cross-object
833
+ references in a MEASURE or FILTER (need a real join to evaluate), multi-hop
834
+ dimensions (`a.b.c`), and non-recombinable measures (`avg`, `count_distinct`)
835
+ with a cross-object dimension. Cross-object queries on `NativeSQLStrategy` (the
836
+ normal SQL path) are unchanged — it hand-compiles the joins.
837
+
838
+ ### Patch Changes
839
+
840
+ - c7f4417: fix(driver-sql,analytics): stop `aggregate()` / `distinct()` leaking SQLite's raw epoch storage (#3797)
841
+
842
+ Both returned `await builder` directly, without the `formatOutput` pass every
843
+ `find()` row gets. On SQLite — the one dialect where a `Field.datetime` is
844
+ stored as INTEGER epoch milliseconds rather than a native timestamp — that raw
845
+ storage form went straight to the caller:
846
+
847
+ | call | before | after |
848
+ | -------------------------------------- | ---------------------------- | -------------------------------- |
849
+ | `find()` | `"2026-01-10T09:00:00.000Z"` | unchanged |
850
+ | `distinct('closed_at')` | `[1768035600000]` | `["2026-01-10T09:00:00.000Z"]` |
851
+ | `aggregate()` `max(closed_at)` | `1768035600000` | `"2026-01-10T09:00:00.000Z"` |
852
+ | `aggregate()` `groupBy: ['closed_at']` | key `1768035600000` | key `"2026-01-10T09:00:00.000Z"` |
853
+
854
+ Same root cause as #3773, different exit. `Field.date` was never affected — it
855
+ is ISO TEXT on every dialect, so its storage form already equals its
856
+ presentation.
857
+
858
+ The visible surfaces were a `_max`/`_min` measure over a datetime (a "last
859
+ closed" KPI tile rendered `1768035600000`) and a `groupBy` on a raw datetime
860
+ dimension, which also disagreed with the in-memory `applyInMemoryAggregation`
861
+ fallback — that one consumes already-formatted `find()` rows, so the same
862
+ dataset changed key type depending on which path served it.
863
+
864
+ Which columns hold an instant is now recorded while the statement is built,
865
+ because that is the only point where a column name and its meaning are both
866
+ known: a `min()` lands under its alias and never under the field name, while a
867
+ date-BUCKETED column lands under the field name but holds a label (`'2026-01'`)
868
+ rather than an instant. Matching on names afterwards gets both backwards.
869
+
870
+ `distinct()` additionally re-deduplicates after presenting: SQL `DISTINCT`
871
+ compares STORED values, and one SQLite datetime column holds both INTEGER and
872
+ TEXT forms, so two rows recording the same instant survived as two and then
873
+ presented identically. It has no in-repo callers today; this keeps it honest
874
+ rather than leaving a second convention in the driver.
875
+
876
+ **`cross-object-rebucket` was fixed alongside it, because presenting min/max
877
+ correctly is what exposed it.** `recombine()` coerced every operand with
878
+ `Number()`, which silently depended on receiving an epoch: handed the ISO string
879
+ the driver now returns it produced `NaN`, and on Postgres/MySQL (where knex
880
+ returns a `Date`) it had always flattened the value back to an epoch integer one
881
+ layer above the driver. `min`/`max` now order by the instant and return the
882
+ winning value in the shape it arrived in; `sum`/`count` stay numeric.
883
+
884
+ - 7101ca2: fix(analytics): apply the EFFECTIVE date granularity to bucket labels and drill ranges (#3588 follow-up)
885
+
886
+ `selection.dateGranularity` (shipped in #3652) reached the `GROUP BY` but not the
887
+ post-processing: the bucket-label formatter and the drill-range inverter both
888
+ kept reading the DATASET dimension's default. A query was grouped one way and
889
+ described another. Found by driving a real dashboard query in a browser against
890
+ a dataset whose dimension declares `dateGranularity: 'month'`:
891
+
892
+ - selection `year` → the row came back labelled **`1970-01`** — a year bucket
893
+ re-formatted with the dataset's month granularity, its `"2026"` key re-read as
894
+ 2026 _milliseconds_ past the epoch;
895
+ - selection `day` → day buckets were re-labelled as months, so ten distinct days
896
+ collapsed into two duplicated keys;
897
+ - selection `quarter` / `year` / `day` / `week` → `drillRanges` came back empty,
898
+ silently removing drill-through from every bucketed chart.
899
+
900
+ Granularity precedence now lives in one exported function,
901
+ `resolveDimensionGranularity`, called from all three sites that must agree — the
902
+ query's `GROUP BY`, the label formatter, and the range inverter. The drift was
903
+ possible only because each site resolved it independently.
904
+
905
+ Two consequences beyond the override case:
906
+
907
+ - A dataset dimension that declares **no** granularity but is bucketed by the
908
+ widget now gets drill ranges too. Previously the range sidecar keyed off the
909
+ dataset's own `dateGranularity`, so this case — the one #3588 is actually
910
+ about — could never drill.
911
+ - `formatDateBucket` no longer mistakes a bare year key for an epoch timestamp.
912
+ A year bucket's canonical key IS `"2026"`, which is the only bucket key that
913
+ collides with the pure-digit epoch heuristic (`"2026-Q2"`, `"2026-07"` and
914
+ `"2026-07-15"` all fail it). Being idempotent over already-formatted keys is
915
+ that function's stated contract; the year case just never held.
916
+
917
+ - 415254c: fix(analytics): scope the dimension-label lookup to the referenced object's RLS (#3602)
918
+
919
+ When a dataset groups by a `lookup`/`master_detail` dimension, analytics resolves
920
+ the grouped FK ids to the related record's display name via a per-record read
921
+ (`group by id`) dressed as an aggregate. That read carried **no read scope**, so
922
+ it revealed related-record display names whenever the referenced object's RLS is
923
+ stricter than the base object whose rows carry the id — a user could see a name
924
+ the referenced object's own RLS would hide. (Same-object and looser-referenced
925
+ cases were already safe because the ids come from the post-#3597 scoped
926
+ aggregate; this closes the stricter-referenced case.)
927
+
928
+ The label lookup now applies the **referenced object's own** read scope — bound
929
+ to the request via the same `getReadScope` provider the aggregate path uses,
930
+ composed with `$and` (never key-merge) so it can't be displaced by the id
931
+ predicate. Fail-closed: if that object's scope can't be resolved, the dimension's
932
+ labels are skipped (the raw id renders) rather than fetched unscoped. No behaviour
933
+ change when no read-scope provider is configured.
934
+
935
+ Internal `DimensionLabelDeps.fetchRecordLabels` gains an optional `scope` argument
936
+ and `resolveDimensionLabels` an optional `resolveScope` resolver; both are
937
+ service-analytics-internal (no spec/contract change).
938
+
939
+ - 1f8390b: fix(analytics): ObjectQLStrategy now enforces the read scope (RLS + tenant) (#3597)
940
+
941
+ `ObjectQLStrategy` never consumed `getReadScope`, so any analytics query served by
942
+ that path ran with **no RLS or tenant predicate** — an authenticated caller
943
+ received aggregates computed over every tenant's rows.
944
+
945
+ Both belts were off at once. The strategy dropped the pre-resolved read scope, and
946
+ the engine could not compensate: the `executeAggregate` bridge passes no
947
+ `ExecutionContext`, so plugin-security's principal-less fall-open skipped its own
948
+ RLS injection. Only `NativeSQLStrategy` was ever wired for ADR-0021 D-C.
949
+
950
+ The exposure was **not** limited to exotic drivers. `NativeSQLStrategy` declines —
951
+ handing the query to this path — on any date-bucketed query
952
+ (`timeDimensions[].granularity`, the most common dashboard shape, on Postgres and
953
+ SQLite too), on `RAW_SQL_UNSUPPORTED` (in-memory driver), and on federated objects.
954
+
955
+ The scope is composed with `$and`, never by key merge, so a caller filter naming
956
+ the same field (e.g. `organization_id`) cannot displace the security predicate.
957
+
958
+ **Behaviour change to be aware of:** a query that references a **joined** object
959
+ carrying its own read scope is now REJECTED on this path rather than run
960
+ partially-scoped. `engine.aggregate`'s `where` addresses the base object, so a
961
+ per-join predicate cannot be expressed there; failing closed matches the posture
962
+ already taken by `resolveReadScopes` and `compileScopedFilterToSql`. Such a query
963
+ previously returned results that omitted the joined object's tenant predicate.
964
+ Run it on a native-SQL driver (`NativeSQLStrategy` scopes each join), or drop the
965
+ cross-object dimension/measure.
966
+
967
+ Deployments with no read-scope provider configured are unaffected — that path
968
+ stays unscoped by documented contract.
969
+
970
+ - 3167e29: fix(analytics): sort dataset selections by the display label for select/lookup dimensions (#3680)
971
+
972
+ `DatasetSelection.order` (what a widget's `options.sortBy` lowers to) sorted a
973
+ `select` or `lookup`/`master_detail` dimension by its STORED value — the option
974
+ value or the foreign-key id — while the response rows carry the resolved display
975
+ label. A "sort by Account" therefore ordered by opaque ids and read as arbitrary;
976
+ a localized select sorted by its ASCII value while showing a non-ASCII label.
977
+
978
+ Order keys naming a label-bearing dimension now sort by the display label the
979
+ user reads. The executor receives an injected sort-key hook (`OrderLabelResolver`,
980
+ built by `queryDataset` over the same label-resolution capabilities and #3602
981
+ read scoping as the display pass); only the COMPARISON substitutes the label —
982
+ rows keep their raw values until the display pass, so drill metadata still
983
+ snapshots stored values, and ordering + windowing stay one adjacent step (a
984
+ "top 10 by account name" truncates the right ten).
985
+
986
+ Cost model: sorting by a measure or a plain/date dimension is unchanged (SQL
987
+ pushdown included). A label-ordered `select` resolves from field metadata (no
988
+ query). A label-ordered `lookup` costs one batched id→name read over the
989
+ pre-window grouped ids (chunked, and reused by the display pass via a
990
+ per-request cache), and its window can no longer be pushed into SQL — the
991
+ inherent price of ordering by a value the database doesn't store.
992
+
993
+ - 0a6fb1e: fix(analytics): the read-scope auto-bridge no longer depends on plugin order (#3618)
994
+
995
+ `getReadScope` was only wired when the `security` service already existed at this
996
+ plugin's `init()`. The closure itself resolved lazily, but the ASSIGNMENT was
997
+ gated on an init-time probe — so a kernel that registers `AnalyticsServicePlugin`
998
+ before the security plugin got **no read-scope provider at all**, and every
999
+ analytics strategy ran unscoped with only a WARN to show for it.
1000
+
1001
+ Both sibling bridges (`executeAggregate`, `executeRawSql`) are wired
1002
+ unconditionally and resolve at call time, and this one's own comment claimed the
1003
+ same. Now it actually does: the probe only decides the log wording.
1004
+
1005
+ The CLI (`os serve`) registers security before analytics, so that path was
1006
+ already correct. The exposure was for embedders composing their own kernel — and
1007
+ for this repo's own `bootStack` harness, which registers analytics first, meaning
1008
+ the entire dogfood/verify suite had analytics RLS silently disabled and any RLS
1009
+ assertion written there passed vacuously.
1010
+
1011
+ Also corrects the WARN text: with no provider, scoping is absent on ALL paths and
1012
+ ALL objects, not just "the raw-SQL path" and "joined objects" as it claimed.
1013
+
1014
+ Adds `analytics-rls.dogfood.test.ts`: an owner-scoped RLS fixture driven over real
1015
+ HTTP as a real non-admin, asserting the rows a member's aggregate actually
1016
+ returns. Reverting either this fix or the #3597 strategy fix turns it red.
1017
+
1018
+ - 1986594: feat(analytics): honour widget `dateGranularity`, `sortBy`/`sortOrder`, and `limit` in the dataset query (#3588)
1019
+
1020
+ Three presentation options were accepted by the metadata layer and then dropped
1021
+ by the analytics query builder. They reached no SQL, produced no error, and the
1022
+ only way to notice was to read the `sql` a dataset response echoes — so a
1023
+ dashboard could declare `dateGranularity: 'month'` and quietly render one bar
1024
+ per record.
1025
+
1026
+ - **`dateGranularity` now buckets.** `DatasetSelection` gained an optional
1027
+ `dateGranularity`, applied to every selected `date` dimension. Precedence per
1028
+ dimension: an explicit `timeDimensions` granularity, then the selection's,
1029
+ then the dataset dimension's own default. A widget can bucket a trend by month
1030
+ without the dataset committing every other consumer to that granularity.
1031
+ - **`order` / `limit` / `offset` now apply on every path.** They are applied to
1032
+ the ASSEMBLED grid — after measure-scoped sub-queries merge, after `compareTo`
1033
+ columns attach, and after derived measures are computed — so a derived measure
1034
+ is a valid sort key and the ObjectQL aggregate path (which has no ordering
1035
+ grammar, and which native SQL hands every date-bucketed query to) orders
1036
+ identically to native SQL. A single-query selection still pushes the window
1037
+ down into the statement. An `order` key that names nothing the selection
1038
+ projects is now rejected (400) rather than silently ignored.
1039
+ - **`limit` is deterministic.** Without an `order`, a limit orders by the
1040
+ selected dimensions first, so it truncates a reproducible window instead of an
1041
+ arbitrary subset.
1042
+ - **Widget `options` is a contract again.** The four query-affecting keys
1043
+ (`dateGranularity`, `sortBy`, `sortOrder`, `limit`) plus `stageOrder` are
1044
+ declared on `DashboardWidgetOptionsSchema`, so a typo like `sortDirection` is
1045
+ an author-time error. The bag stays open — renderer extras (`icon`, `columns`,
1046
+ `striped`, …) pass through untouched.
1047
+
1048
+ Two latent bugs surfaced while fixing the above and are fixed here too:
1049
+
1050
+ - `order`/`limit` were forwarded to EVERY sub-query. A measure-scoped
1051
+ supplementary query selects one measure, so an inherited `ORDER BY` named a
1052
+ column it never selected, and an inherited `LIMIT` truncated it before the
1053
+ merge — dropping rows from the assembled grid. Nothing hit this only because
1054
+ nothing passed `order`.
1055
+ - The `compareTo` pass built its query by hand and skipped granularity
1056
+ resolution, so a month-bucketed primary grid was merged against raw-timestamp
1057
+ comparison rows. No dimension key matched and every `<measure>__compare`
1058
+ column came back empty.
1059
+
1060
+ `ObjectQLStrategy` now also echoes a representative `sql` (with `date_trunc`,
1061
+ `WHERE`, `ORDER BY`, and `LIMIT`; filter values parameterized, never inlined).
1062
+ Previously the `sql` field simply vanished from the response whenever a query
1063
+ was date-bucketed, leaving an author unable to tell "not implemented" from "this
1064
+ strategy doesn't report".
1065
+
1066
+ - a227ed7: fix(objectql)!: one key for the empty group bucket — real `null`, on both aggregation paths (#3839)
1067
+
1068
+ A grouped row whose dimension value is empty now carries `null` for that
1069
+ dimension no matter which way the aggregate ran. Downstream code can test the
1070
+ empty bucket with a plain `value == null` again: charts render their own empty
1071
+ label, drill-through on that bucket builds `field = null` and returns the rows
1072
+ it should, and a dashboard no longer changes shape when the driver, the
1073
+ granularity or the reference timezone changes.
1074
+
1075
+ ### What was wrong
1076
+
1077
+ `engine.aggregate` has two implementations of one feature. It pushes the
1078
+ aggregate down as SQL when the driver advertises every requested granularity and
1079
+ the reference timezone is UTC; otherwise it fetches rows and buckets them in JS.
1080
+ The two disagreed about how to spell "empty":
1081
+
1082
+ ```
1083
+ --- same dataset, same query, one row with a NULL value ---
1084
+ pushed-down SQL : [{ "key": null, "type": "null", "total": 2 }, …]
1085
+ in-memory : [{ "key": "(null)", "type": "string", "total": 2 }, …]
1086
+ ```
1087
+
1088
+ The measures were always right — only the key's type and literal differed —
1089
+ which is why this went unnoticed for so long: every total reconciled. But the
1090
+ engine picks a path per query, so the same data produced a different bucket key
1091
+ on SQLite-plus-UTC-plus-`month` than on `week` (which SQLite does not advertise),
1092
+ a non-UTC timezone, or `driver-rest` / `driver-memory` / a remote Turso, all of
1093
+ which bucket in memory unconditionally.
1094
+
1095
+ It was never date-specific either. A plain `groupBy: ['stage']` over a NULL
1096
+ column diverged the same way.
1097
+
1098
+ Consumers are written against `null` — they check `== null` and supply their own
1099
+ empty label ('—', '(empty)', a localized "Uncategorized"). The sentinel defeated
1100
+ every one of them: it rendered a raw English debug string in the UI, and a drill
1101
+ on the empty bucket compiled to `field = '(null)'` and matched nothing.
1102
+
1103
+ The in-memory path's comment justified the string as staying "consistent with
1104
+ the client `useReportData` hook". That hook was removed with ADR-0021, and the
1105
+ literal never appeared in it.
1106
+
1107
+ ### What changed
1108
+
1109
+ - `applyInMemoryAggregation` and `bucketDateValue` (`@objectstack/objectql`) key
1110
+ the empty bucket as `null`. `bucketDateValue` now returns `string | null`. A
1111
+ null instant and an unparseable one still share one bucket, because SQL cannot
1112
+ tell them apart either (`strftime('%Y-%m', 'not-a-date')` is NULL).
1113
+ - The internal composite bucket id is JSON-encoded, so the empty bucket stays
1114
+ distinct from a row whose value is the literal string `"null"`.
1115
+ - `bucketKeyToCalendarRange` (`@objectstack/core`) accepts `string | null`. The
1116
+ empty bucket has no calendar span, so a drill on it opens the unscoped
1117
+ superset instead of an invented bound — unchanged behavior, honest signature.
1118
+ - The driver output contract in `@objectstack/spec` now states the rule: a row
1119
+ with no value keys as `null`, never a sentinel. Propagating NULL through the
1120
+ bucket expression is the whole of it; a driver only breaks it by adding a
1121
+ `COALESCE`.
1122
+
1123
+ ### Gates
1124
+
1125
+ `checkDateBucketParity` (`@objectstack/verify`) deliberately carried no null
1126
+ instant, because the divergence would have failed it for a reason it was not
1127
+ about. Its fixture now has one, so the convergence is held in place — including
1128
+ for out-of-tree drivers that run the check against themselves.
1129
+
1130
+ Two fixes were needed to make that fixture meaningful:
1131
+
1132
+ - The check folded bucket labels through `String(value)`, which turns SQL NULL
1133
+ into `'null'` — a label a TEXT column can genuinely hold. A driver spelling
1134
+ "empty" as a string could compare equal to one returning real NULL. The empty
1135
+ bucket is now keyed out of band.
1136
+ - Label sets were compared with `JSON.stringify`, which is sensitive to key
1137
+ insertion order. Row order is not part of this contract and the two paths
1138
+ naturally differ (SQL sorts its groups; the in-memory path emits first-seen
1139
+ order), so a driver with entirely correct buckets could be reported as
1140
+ disagreeing — with an empty diff message, since nothing actually differed.
1141
+ The comparison is now order-insensitive.
1142
+
1143
+ A new dogfood check covers the non-date half against real drivers: same dataset,
1144
+ plain and date-bucketed `groupBy`, both paths, one key.
1145
+
1146
+ - adabaa8: fix(analytics): fail closed on cross-object aggregation the ObjectQL path cannot join (#3654)
1147
+
1148
+ `engine.aggregate()` has no join — it never expands a lookup and the SQL driver's
1149
+ aggregate emits no `JOIN`. So a dotted dimension/measure like `account.region`
1150
+ reaching `ObjectQLStrategy` (the fallback NativeSQL declines: date-granularity
1151
+ bucketing, in-memory driver, federated objects) failed SILENTLY: the in-memory
1152
+ path bucketed every row under one `(null)` group and summed the whole table into
1153
+ it (a plausible number that is actually a mislabelled full-table total), and the
1154
+ native path errored on the unresolved column.
1155
+
1156
+ `ObjectQLStrategy` now rejects any cross-object reference outright, with a clear
1157
+ message, before the query reaches the engine. This generalizes the #3597 guard
1158
+ (which only rejected when the joined object carried a read scope, and skipped the
1159
+ check entirely when no read-scope provider was configured — so the silent
1160
+ `(null)` bucket still shipped on unsecured/in-memory setups) into an
1161
+ unconditional one, and subsumes it: a rejected query never loads the joined
1162
+ object, so there is nothing left unscoped.
1163
+
1164
+ Cross-object datasets are unaffected on `NativeSQLStrategy`, which hand-compiles
1165
+ the LEFT JOINs (and scopes each). This only changes the fallback path, turning a
1166
+ silent wrong answer into a loud, actionable error. Full lookup-traversal support
1167
+ in the aggregate path is left as follow-up (see #3654).
1168
+
1169
+ - 605c23f: fix(analytics): ObjectQLStrategy applies `timeDimensions[].dateRange` — the predicate every date-bucketed chart was missing (#3650)
1170
+
1171
+ `ObjectQLStrategy.execute()` built its engine filter purely from
1172
+ `normalizeAnalyticsFilters(query)`, which reads only `query.where`. But
1173
+ `dateRange` is a **sibling** of `where`, never folded into it — so the window
1174
+ was dropped on the floor. No error, no warning: the chart rendered, and the
1175
+ numbers were for all of history.
1176
+
1177
+ This was not a "some drivers only" corner. `NativeSQLStrategy.canHandle`
1178
+ declines any query carrying a `granularity`, so a **date-bucketed trend lands on
1179
+ the ObjectQL path on every driver**, Postgres and SQLite included — and a
1180
+ bucketed trend is precisely the shape that also carries a range ("last 12
1181
+ months", "this quarter"). The other two paths always applied it
1182
+ (`NativeSQLStrategy` as `BETWEEN`, `preview-evaluator` row-wise); only this one
1183
+ did not.
1184
+
1185
+ **Two visible symptoms:**
1186
+
1187
+ - A trend chart with a time filter plotted **every row ever recorded** instead
1188
+ of the selected window.
1189
+ - `compareTo` (period-over-period) was **structurally dead**. `runCompare`
1190
+ builds the comparison pass by shifting `dateRange` and changing nothing else,
1191
+ so with the window ignored both passes issued a byte-identical aggregate:
1192
+ every `<measure>__compare` column equalled its primary and the delta was a
1193
+ flat 0%. And since `compareTo` requires a time dimension, it always took this
1194
+ path.
1195
+
1196
+ The window now lowers to an inclusive `{$gte, $lte}` on the resolved field — the
1197
+ same shape `NativeSQLStrategy` binds as `BETWEEN` and the memory driver builds
1198
+ as a `$match` — so one dashboard reads the same on every driver. No storage
1199
+ coercion is applied here on purpose: unlike the raw-SQL path (which had to learn
1200
+ about SQLite's INTEGER epoch in #2034), this path goes through
1201
+ `engine.aggregate()`, where the driver's own CRUD filter coercion already
1202
+ handles a `where` bound on that same column.
1203
+
1204
+ **Same-field composition was fixed alongside it**, because the window makes it
1205
+ routine. Operands merged into one field entry by spreading, which silently kept
1206
+ whichever came last: a `where` bound and a window bound on `close_date` would
1207
+ have had one erase the other, and a `where` that names one field twice through
1208
+ `$and` (`{$and: [{stage: 'won'}, {stage: {$ne: 'lost'}}]}`) already lost its
1209
+ first operand today. Operands that name **different** operators still share one
1210
+ entry; colliding ones become their own `$and` conjunct, so the engine
1211
+ intersects them instead of the strategy picking a winner.
1212
+
1213
+ `generateSql()` renders the window as a parameterised `BETWEEN` to match — its
1214
+ comment previously explained why a `BETWEEN` was deliberately absent, which was
1215
+ correct only while `execute()` dropped the window. Bounds bind as `$n`
1216
+ placeholders, never inlined: the echoed statement travels to the browser.
1217
+
1218
+ A window on a **cross-object** time dimension is still rejected, and is now
1219
+ reported as the bucketing error it is rather than as the "cross-object filter"
1220
+ its lowered predicate would otherwise resemble. `execute()` and
1221
+ `/analytics/sql` continue to accept and reject the same set.
1222
+
1223
+ Relative-phrase ranges ("Last 7 days") are still not resolved on this path, and
1224
+ a bare-string `dateRange` degenerates to a single point — both matching
1225
+ `NativeSQLStrategy` exactly, rather than inventing a second interpretation for
1226
+ the driver-independent path.
1227
+
1228
+ - Updated dependencies [50616d9]
1229
+ - Updated dependencies [08b5a3d]
1230
+ - Updated dependencies [d99aeb3]
1231
+ - Updated dependencies [4727eb8]
1232
+ - Updated dependencies [f63cd09]
1233
+ - Updated dependencies [fa3d0cf]
1234
+ - Updated dependencies [af5a224]
1235
+ - Updated dependencies [71f76e1]
1236
+ - Updated dependencies [37b1346]
1237
+ - Updated dependencies [99736a0]
1238
+ - Updated dependencies [fe67e34]
1239
+ - Updated dependencies [fdb4f50]
1240
+ - Updated dependencies [1bd5652]
1241
+ - Updated dependencies [14252d3]
1242
+ - Updated dependencies [7fb436c]
1243
+ - Updated dependencies [879ea13]
1244
+ - Updated dependencies [201b31f]
1245
+ - Updated dependencies [e2616e0]
1246
+ - Updated dependencies [6fdc5c6]
1247
+ - Updated dependencies [8b9d71e]
1248
+ - Updated dependencies [33f5e23]
1249
+ - Updated dependencies [259af21]
1250
+ - Updated dependencies [587fc91]
1251
+ - Updated dependencies [1986594]
1252
+ - Updated dependencies [ad4af62]
1253
+ - Updated dependencies [d44dbfa]
1254
+ - Updated dependencies [474fe39]
1255
+ - Updated dependencies [0bc685a]
1256
+ - Updated dependencies [b949059]
1257
+ - Updated dependencies [be1c52c]
1258
+ - Updated dependencies [c5ff96d]
1259
+ - Updated dependencies [84e7be9]
1260
+ - Updated dependencies [a6c3f38]
1261
+ - Updated dependencies [debc23a]
1262
+ - Updated dependencies [0f8ad09]
1263
+ - Updated dependencies [8f9689f]
1264
+ - Updated dependencies [57a3bb3]
1265
+ - Updated dependencies [5f9a987]
1266
+ - Updated dependencies [db02d47]
1267
+ - Updated dependencies [0bfdf46]
1268
+ - Updated dependencies [376a061]
1269
+ - Updated dependencies [7c7e246]
1270
+ - Updated dependencies [f35cdc5]
1271
+ - Updated dependencies [9ea2bc5]
1272
+ - Updated dependencies [c2d9098]
1273
+ - Updated dependencies [a227ed7]
1274
+ - Updated dependencies [9613396]
1275
+ - Updated dependencies [e47b342]
1276
+ - Updated dependencies [4ed7ed4]
1277
+ - Updated dependencies [2fa4ca1]
1278
+ - Updated dependencies [f5a2320]
1279
+ - Updated dependencies [deb538f]
1280
+ - Updated dependencies [5b89711]
1281
+ - Updated dependencies [0c8a22f]
1282
+ - Updated dependencies [763931e]
1283
+ - Updated dependencies [de9af8a]
1284
+ - Updated dependencies [c4df271]
1285
+ - Updated dependencies [a41ba5c]
1286
+ - Updated dependencies [189854c]
1287
+ - Updated dependencies [0e3a226]
1288
+ - Updated dependencies [1d4756e]
1289
+ - Updated dependencies [720c5ad]
1290
+ - Updated dependencies [a8d1e24]
1291
+ - Updated dependencies [41642b0]
1292
+ - Updated dependencies [4cca74c]
1293
+ - Updated dependencies [88ef03e]
1294
+ - Updated dependencies [9e2caf3]
1295
+ - Updated dependencies [81ce41a]
1296
+ - Updated dependencies [85e1e4e]
1297
+ - Updated dependencies [dac6a08]
1298
+ - Updated dependencies [394b7a1]
1299
+ - Updated dependencies [677b591]
1300
+ - Updated dependencies [d77d1b7]
1301
+ - Updated dependencies [5b79a34]
1302
+ - Updated dependencies [c757854]
1303
+ - Updated dependencies [0045682]
1304
+ - Updated dependencies [2a5f04a]
1305
+ - Updated dependencies [4f740b0]
1306
+ - Updated dependencies [67452d1]
1307
+ - Updated dependencies [0fc6219]
1308
+ - Updated dependencies [605e190]
1309
+ - Updated dependencies [c6c59f1]
1310
+ - Updated dependencies [b0e78a8]
1311
+ - Updated dependencies [f31cc8d]
1312
+ - Updated dependencies [f343dc4]
1313
+ - Updated dependencies [8269e32]
1314
+ - Updated dependencies [74f7339]
1315
+ - Updated dependencies [a6c35a2]
1316
+ - Updated dependencies [c2f1002]
1317
+ - Updated dependencies [f163028]
1318
+ - Updated dependencies [f07808c]
1319
+ - Updated dependencies [7ffc3d3]
1320
+ - Updated dependencies [88346ba]
1321
+ - Updated dependencies [4631592]
1322
+ - Updated dependencies [32ff033]
1323
+ - Updated dependencies [5ac93d4]
1324
+ - Updated dependencies [93f267f]
1325
+ - Updated dependencies [0024abf]
1326
+ - Updated dependencies [acbf364]
1327
+ - Updated dependencies [7687f7b]
1328
+ - Updated dependencies [1659072]
1329
+ - Updated dependencies [abceb0d]
1330
+ - Updated dependencies [0c302a7]
1331
+ - Updated dependencies [6633337]
1332
+ - Updated dependencies [f00d8d4]
1333
+ - Updated dependencies [503be86]
1334
+ - Updated dependencies [cde1975]
1335
+ - Updated dependencies [0bc685a]
1336
+ - Updated dependencies [11949fc]
1337
+ - Updated dependencies [b098b0e]
1338
+ - Updated dependencies [4d00b13]
1339
+ - Updated dependencies [57bab76]
1340
+ - Updated dependencies [b90086a]
1341
+ - Updated dependencies [b95577a]
1342
+ - Updated dependencies [83c161f]
1343
+ - Updated dependencies [d8c4957]
1344
+ - Updated dependencies [f24cb83]
1345
+ - Updated dependencies [5dbbb92]
1346
+ - Updated dependencies [69f1dfd]
1347
+ - @objectstack/spec@17.0.0-rc.0
1348
+ - @objectstack/core@17.0.0-rc.0
1349
+
1350
+ ## 16.1.0
1351
+
1352
+ ### Patch Changes
1353
+
1354
+ - Updated dependencies [9e45b63]
1355
+ - Updated dependencies [b20201f]
1356
+ - @objectstack/spec@16.1.0
1357
+ - @objectstack/core@16.1.0
1358
+
1359
+ ## 16.0.0
1360
+
1361
+ ### Minor Changes
1362
+
1363
+ - a9459e6: Analytics drill metadata now snapshots raw grouped values for totals/subtotal rows too (#3214). The ADR-0021 D2 drill sidecar (`drillRawRows`, #2080) only covered `result.rows`, but the totals rows added in #1753 carry dimension values and go through the same label resolution — which overwrote their stored value (select option value, lookup/master_detail FK id) with the display label, leaving a subtotal drill nothing to exact-match on.
1364
+
1365
+ `queryDataset` now also emits `drillRawTotals`, aligned to `result.totals` by index (`drillRawTotals[i][j]` ↔ `result.totals[i].rows[j]`), captured in the same pre-label-resolution pass. Each map is restricted to the drillable dimensions the grouping actually groups by, so the grand-total grouping (`[]`) contributes an empty map per row. Purely additive result props (same as #2080) — no spec-contract change.
1366
+
1367
+ - dd9f223: feat(analytics): scope a datetime date-bucket drill to the reference-tz midnight instants (#1752 follow-up)
1368
+
1369
+ Closes the one gap left by the initial #1752 change: a `datetime` date dimension
1370
+ bucketed under a **non-UTC reference timezone** previously fell back to a superset
1371
+ drill (its bucket boundary is that tz's midnight _instant_, which `YYYY-MM-DD`
1372
+ calendar bounds can't express).
1373
+
1374
+ - **`@objectstack/core`** adds `zonedDateStartToUtcMs(ymd, tz)` — the UTC instant
1375
+ at which a calendar day begins in a reference timezone (the inverse of
1376
+ `calendarPartsInTz`). DST-safe: the offset is read from the platform tz
1377
+ database via `Intl`, with a two-pass resolution for the rare offset-boundary
1378
+ case; an unset/`'UTC'`/invalid zone returns plain UTC midnight.
1379
+ - **`@objectstack/service-analytics`** now emits `drillRanges` bounds per the
1380
+ field's temporal type (ADR-0053): a `datetime` field → ISO **instant** bounds
1381
+ at the reference tz's midnight (works under any tz, incl. DST); a `date` field
1382
+ → `YYYY-MM-DD` calendar bounds (tz-naive, exact under any tz). An unknown field
1383
+ type is still emitted only under UTC and omitted (superset) under a non-UTC tz.
1384
+
1385
+ No objectui change is needed — the client already forwards whatever bound values
1386
+ the server sends into the drill filter and the `filter[field][gte|lt]` URL.
1387
+
1388
+ - 290e2f0: feat(analytics): emit a half-open date-range drill scope for granularity-bucketed date dimensions (#1752)
1389
+
1390
+ A report/dashboard cell grouped by a `dateGranularity` date dimension ("2026-Q2")
1391
+ covers a SPAN of records, so drilling it needs a range (`>= start AND < nextStart`),
1392
+ which the equality drill contract (`drillRawRows`) can't express — date dims were
1393
+ therefore excluded from drill metadata and a drill landed on an unscoped superset.
1394
+
1395
+ - **`@objectstack/core`** adds `bucketKeyToCalendarRange(key, granularity)`, the
1396
+ inverse of `bucketDateValue`: it turns a canonical bucket key into its half-open
1397
+ `[start, end)` calendar span (`YYYY-MM-DD`, `end` exclusive). Pure, timezone-naive
1398
+ calendar arithmetic; returns `null` for unbucketable / out-of-range keys so the
1399
+ caller falls back to an unscoped (superset) drill rather than emit a wrong bound.
1400
+ - **`@objectstack/service-analytics`** emits a `drillRanges` sidecar (aligned to
1401
+ `rows` by index — the range companion to `drillRawRows`) for `date` +
1402
+ `dateGranularity` dimensions, computed from the canonical bucket key in the
1403
+ pre-label-resolution snapshot pass. A `datetime` field under a non-UTC reference
1404
+ timezone is omitted (host drills a superset) until instant-boundary support
1405
+ lands; a tz-naive `date` field is exact under any timezone (ADR-0053).
1406
+
1407
+ Consumed by objectui's report drill-through to scope the drilled record list to the
1408
+ clicked time bucket.
1409
+
1410
+ ### Patch Changes
1411
+
1412
+ - Updated dependencies [f972574]
1413
+ - Updated dependencies [6289ec3]
1414
+ - Updated dependencies [22013aa]
1415
+ - Updated dependencies [3ad3dd5]
1416
+ - Updated dependencies [8efa395]
1417
+ - Updated dependencies [3a18b60]
1418
+ - Updated dependencies [a8aa34c]
1419
+ - Updated dependencies [e057f42]
1420
+ - Updated dependencies [a3823b2]
1421
+ - Updated dependencies [43a3efb]
1422
+ - Updated dependencies [524696a]
1423
+ - Updated dependencies [bfa3c3f]
1424
+ - Updated dependencies [5e3301d]
1425
+ - Updated dependencies [dd9f223]
1426
+ - Updated dependencies [46e876c]
1427
+ - Updated dependencies [5f05de2]
1428
+ - Updated dependencies [021ba4c]
1429
+ - Updated dependencies [158aa14]
1430
+ - Updated dependencies [62a2117]
1431
+ - Updated dependencies [d2723e2]
1432
+ - Updated dependencies [fefcd54]
1433
+ - Updated dependencies [beaf2de]
1434
+ - Updated dependencies [369eb6e]
1435
+ - Updated dependencies [06ff734]
1436
+ - Updated dependencies [b659111]
1437
+ - Updated dependencies [5754a23]
1438
+ - Updated dependencies [6c270a6]
1439
+ - Updated dependencies [290e2f0]
1440
+ - Updated dependencies [668dd17]
1441
+ - Updated dependencies [8abf133]
1442
+ - Updated dependencies [e0859b1]
1443
+ - Updated dependencies [04ecd4e]
1444
+ - Updated dependencies [4d5a892]
1445
+ - Updated dependencies [16cebeb]
1446
+ - Updated dependencies [86d30af]
1447
+ - Updated dependencies [8923843]
1448
+ - Updated dependencies [a2795f6]
1449
+ - Updated dependencies [f16b492]
1450
+ - Updated dependencies [4b6fde8]
1451
+ - Updated dependencies [2018df9]
1452
+ - Updated dependencies [fc5a3a2]
1453
+ - Updated dependencies [8ff9210]
1454
+ - @objectstack/spec@16.0.0
1455
+ - @objectstack/core@16.0.0
1456
+
1457
+ ## 16.0.0-rc.1
1458
+
1459
+ ### Patch Changes
1460
+
1461
+ - Updated dependencies [6289ec3]
1462
+ - Updated dependencies [8efa395]
1463
+ - Updated dependencies [bfa3c3f]
1464
+ - Updated dependencies [62a2117]
1465
+ - Updated dependencies [06ff734]
1466
+ - @objectstack/spec@16.0.0-rc.1
1467
+ - @objectstack/core@16.0.0-rc.1
1468
+
1469
+ ## 16.0.0-rc.0
1470
+
1471
+ ### Minor Changes
1472
+
1473
+ - a9459e6: Analytics drill metadata now snapshots raw grouped values for totals/subtotal rows too (#3214). The ADR-0021 D2 drill sidecar (`drillRawRows`, #2080) only covered `result.rows`, but the totals rows added in #1753 carry dimension values and go through the same label resolution — which overwrote their stored value (select option value, lookup/master_detail FK id) with the display label, leaving a subtotal drill nothing to exact-match on.
1474
+
1475
+ `queryDataset` now also emits `drillRawTotals`, aligned to `result.totals` by index (`drillRawTotals[i][j]` ↔ `result.totals[i].rows[j]`), captured in the same pre-label-resolution pass. Each map is restricted to the drillable dimensions the grouping actually groups by, so the grand-total grouping (`[]`) contributes an empty map per row. Purely additive result props (same as #2080) — no spec-contract change.
1476
+
1477
+ - dd9f223: feat(analytics): scope a datetime date-bucket drill to the reference-tz midnight instants (#1752 follow-up)
1478
+
1479
+ Closes the one gap left by the initial #1752 change: a `datetime` date dimension
1480
+ bucketed under a **non-UTC reference timezone** previously fell back to a superset
1481
+ drill (its bucket boundary is that tz's midnight _instant_, which `YYYY-MM-DD`
1482
+ calendar bounds can't express).
1483
+
1484
+ - **`@objectstack/core`** adds `zonedDateStartToUtcMs(ymd, tz)` — the UTC instant
1485
+ at which a calendar day begins in a reference timezone (the inverse of
1486
+ `calendarPartsInTz`). DST-safe: the offset is read from the platform tz
1487
+ database via `Intl`, with a two-pass resolution for the rare offset-boundary
1488
+ case; an unset/`'UTC'`/invalid zone returns plain UTC midnight.
1489
+ - **`@objectstack/service-analytics`** now emits `drillRanges` bounds per the
1490
+ field's temporal type (ADR-0053): a `datetime` field → ISO **instant** bounds
1491
+ at the reference tz's midnight (works under any tz, incl. DST); a `date` field
1492
+ → `YYYY-MM-DD` calendar bounds (tz-naive, exact under any tz). An unknown field
1493
+ type is still emitted only under UTC and omitted (superset) under a non-UTC tz.
1494
+
1495
+ No objectui change is needed — the client already forwards whatever bound values
1496
+ the server sends into the drill filter and the `filter[field][gte|lt]` URL.
1497
+
1498
+ - 290e2f0: feat(analytics): emit a half-open date-range drill scope for granularity-bucketed date dimensions (#1752)
1499
+
1500
+ A report/dashboard cell grouped by a `dateGranularity` date dimension ("2026-Q2")
1501
+ covers a SPAN of records, so drilling it needs a range (`>= start AND < nextStart`),
1502
+ which the equality drill contract (`drillRawRows`) can't express — date dims were
1503
+ therefore excluded from drill metadata and a drill landed on an unscoped superset.
1504
+
1505
+ - **`@objectstack/core`** adds `bucketKeyToCalendarRange(key, granularity)`, the
1506
+ inverse of `bucketDateValue`: it turns a canonical bucket key into its half-open
1507
+ `[start, end)` calendar span (`YYYY-MM-DD`, `end` exclusive). Pure, timezone-naive
1508
+ calendar arithmetic; returns `null` for unbucketable / out-of-range keys so the
1509
+ caller falls back to an unscoped (superset) drill rather than emit a wrong bound.
1510
+ - **`@objectstack/service-analytics`** emits a `drillRanges` sidecar (aligned to
1511
+ `rows` by index — the range companion to `drillRawRows`) for `date` +
1512
+ `dateGranularity` dimensions, computed from the canonical bucket key in the
1513
+ pre-label-resolution snapshot pass. A `datetime` field under a non-UTC reference
1514
+ timezone is omitted (host drills a superset) until instant-boundary support
1515
+ lands; a tz-naive `date` field is exact under any timezone (ADR-0053).
1516
+
1517
+ Consumed by objectui's report drill-through to scope the drilled record list to the
1518
+ clicked time bucket.
1519
+
1520
+ ### Patch Changes
1521
+
1522
+ - Updated dependencies [f972574]
1523
+ - Updated dependencies [22013aa]
1524
+ - Updated dependencies [3ad3dd5]
1525
+ - Updated dependencies [3a18b60]
1526
+ - Updated dependencies [a8aa34c]
1527
+ - Updated dependencies [e057f42]
1528
+ - Updated dependencies [a3823b2]
1529
+ - Updated dependencies [43a3efb]
1530
+ - Updated dependencies [524696a]
1531
+ - Updated dependencies [5e3301d]
1532
+ - Updated dependencies [dd9f223]
1533
+ - Updated dependencies [46e876c]
1534
+ - Updated dependencies [5f05de2]
1535
+ - Updated dependencies [021ba4c]
1536
+ - Updated dependencies [158aa14]
1537
+ - Updated dependencies [d2723e2]
1538
+ - Updated dependencies [fefcd54]
1539
+ - Updated dependencies [beaf2de]
1540
+ - Updated dependencies [369eb6e]
1541
+ - Updated dependencies [b659111]
1542
+ - Updated dependencies [5754a23]
1543
+ - Updated dependencies [6c270a6]
1544
+ - Updated dependencies [290e2f0]
1545
+ - Updated dependencies [668dd17]
1546
+ - Updated dependencies [8abf133]
1547
+ - Updated dependencies [e0859b1]
1548
+ - Updated dependencies [04ecd4e]
1549
+ - Updated dependencies [4d5a892]
1550
+ - Updated dependencies [16cebeb]
1551
+ - Updated dependencies [86d30af]
1552
+ - Updated dependencies [8923843]
1553
+ - Updated dependencies [a2795f6]
1554
+ - Updated dependencies [f16b492]
1555
+ - Updated dependencies [4b6fde8]
1556
+ - Updated dependencies [2018df9]
1557
+ - Updated dependencies [fc5a3a2]
1558
+ - @objectstack/spec@16.0.0-rc.0
1559
+ - @objectstack/core@16.0.0-rc.0
1560
+
1561
+ ## 15.1.1
1562
+
1563
+ ### Patch Changes
1564
+
1565
+ - @objectstack/spec@15.1.1
1566
+ - @objectstack/core@15.1.1
1567
+
1568
+ ## 15.1.0
1569
+
1570
+ ### Patch Changes
1571
+
1572
+ - Updated dependencies [f531a26]
1573
+ - Updated dependencies [f531a26]
1574
+ - Updated dependencies [f531a26]
1575
+ - Updated dependencies [f531a26]
1576
+ - Updated dependencies [f531a26]
1577
+ - Updated dependencies [f531a26]
1578
+ - Updated dependencies [3fe9df1]
1579
+ - Updated dependencies [f531a26]
1580
+ - Updated dependencies [f531a26]
1581
+ - Updated dependencies [f531a26]
1582
+ - Updated dependencies [f531a26]
1583
+ - Updated dependencies [f531a26]
1584
+ - Updated dependencies [f531a26]
1585
+ - Updated dependencies [f531a26]
1586
+ - Updated dependencies [f531a26]
1587
+ - Updated dependencies [f531a26]
1588
+ - Updated dependencies [f531a26]
1589
+ - Updated dependencies [f531a26]
1590
+ - Updated dependencies [f531a26]
1591
+ - Updated dependencies [4109153]
1592
+ - Updated dependencies [f531a26]
1593
+ - Updated dependencies [f531a26]
1594
+ - Updated dependencies [f531a26]
1595
+ - Updated dependencies [f531a26]
1596
+ - Updated dependencies [f531a26]
1597
+ - Updated dependencies [f531a26]
1598
+ - Updated dependencies [627f225]
1599
+ - Updated dependencies [f531a26]
1600
+ - Updated dependencies [f531a26]
1601
+ - Updated dependencies [f531a26]
1602
+ - @objectstack/spec@15.1.0
1603
+ - @objectstack/core@15.1.0
1604
+
1605
+ ## 15.0.0
1606
+
1607
+ ### Patch Changes
1608
+
1609
+ - Updated dependencies [28b7c28]
1610
+ - Updated dependencies [13749ec]
1611
+ - Updated dependencies [e62c233]
1612
+ - Updated dependencies [ed61c9b]
1613
+ - Updated dependencies [31d04d4]
1614
+ - @objectstack/spec@15.0.0
1615
+ - @objectstack/core@15.0.0
1616
+
1617
+ ## 14.8.0
1618
+
1619
+ ### Patch Changes
1620
+
1621
+ - Updated dependencies [16b4bf6]
1622
+ - Updated dependencies [16b4bf6]
1623
+ - Updated dependencies [10e8983]
1624
+ - Updated dependencies [607aaf4]
1625
+ - Updated dependencies [bb71321]
1626
+ - @objectstack/spec@14.8.0
1627
+ - @objectstack/core@14.8.0
1628
+
1629
+ ## 14.7.0
1630
+
1631
+ ### Patch Changes
1632
+
1633
+ - Updated dependencies [d6a72eb]
1634
+ - @objectstack/spec@14.7.0
1635
+ - @objectstack/core@14.7.0
1636
+
1637
+ ## 14.6.0
1638
+
1639
+ ### Patch Changes
1640
+
1641
+ - Updated dependencies [609cb13]
1642
+ - Updated dependencies [ce6d151]
1643
+ - @objectstack/spec@14.6.0
1644
+ - @objectstack/core@14.6.0
1645
+
1646
+ ## 14.5.0
1647
+
1648
+ ### Patch Changes
1649
+
1650
+ - Updated dependencies [526805e]
1651
+ - Updated dependencies [d79ca07]
1652
+ - Updated dependencies [33ebd34]
1653
+ - Updated dependencies [c044f08]
1654
+ - Updated dependencies [01274eb]
1655
+ - @objectstack/spec@14.5.0
1656
+ - @objectstack/core@14.5.0
1657
+
1658
+ ## 14.4.0
1659
+
1660
+ ### Patch Changes
1661
+
1662
+ - Updated dependencies [7953832]
1663
+ - Updated dependencies [82e745e]
1664
+ - Updated dependencies [f3035bd]
1665
+ - Updated dependencies [82c0d94]
1666
+ - Updated dependencies [7449476]
1667
+ - @objectstack/spec@14.4.0
1668
+ - @objectstack/core@14.4.0
1669
+
1670
+ ## 14.3.0
1671
+
1672
+ ### Patch Changes
1673
+
1674
+ - Updated dependencies [2a71f48]
1675
+ - Updated dependencies [02f6af4]
1676
+ - Updated dependencies [c1064f1]
1677
+ - @objectstack/spec@14.3.0
1678
+ - @objectstack/core@14.3.0
1679
+
1680
+ ## 14.2.0
1681
+
1682
+ ### Patch Changes
1683
+
1684
+ - Updated dependencies [ac8f029]
1685
+ - Updated dependencies [4ab9958]
1686
+ - @objectstack/spec@14.2.0
1687
+ - @objectstack/core@14.2.0
1688
+
1689
+ ## 14.1.0
1690
+
1691
+ ### Patch Changes
1692
+
1693
+ - Updated dependencies [5a8465f]
1694
+ - Updated dependencies [7f8620b]
1695
+ - Updated dependencies [82ba3a6]
1696
+ - @objectstack/spec@14.1.0
1697
+ - @objectstack/core@14.1.0
1698
+
1699
+ ## 14.0.0
1700
+
1701
+ ### Patch Changes
1702
+
1703
+ - Updated dependencies [0a8e685]
1704
+ - Updated dependencies [afa8115]
1705
+ - Updated dependencies [80f12ca]
1706
+ - Updated dependencies [e2fa074]
1707
+ - Updated dependencies [23c8668]
1708
+ - Updated dependencies [29f017d]
1709
+ - Updated dependencies [216fa9a]
1710
+ - Updated dependencies [6c22b12]
1711
+ - @objectstack/spec@14.0.0
1712
+ - @objectstack/core@14.0.0
1713
+
1714
+ ## 13.0.0
1715
+
1716
+ ### Patch Changes
1717
+
1718
+ - Updated dependencies [6d83431]
1719
+ - Updated dependencies [01917c2]
1720
+ - Updated dependencies [b271691]
1721
+ - Updated dependencies [a5a1e41]
1722
+ - Updated dependencies [466adf6]
1723
+ - Updated dependencies [5be00c3]
1724
+ - Updated dependencies [466adf6]
1725
+ - Updated dependencies [2bee609]
1726
+ - Updated dependencies [fc7e7f7]
1727
+ - @objectstack/spec@13.0.0
1728
+ - @objectstack/core@13.0.0
1729
+
1730
+ ## 12.6.0
1731
+
1732
+ ### Patch Changes
1733
+
1734
+ - Updated dependencies [6cebf22]
1735
+ - Updated dependencies [21420d9]
1736
+ - @objectstack/spec@12.6.0
1737
+ - @objectstack/core@12.6.0
1738
+
1739
+ ## 12.5.0
1740
+
1741
+ ### Patch Changes
1742
+
1743
+ - Updated dependencies [8b3d363]
1744
+ - @objectstack/spec@12.5.0
1745
+ - @objectstack/core@12.5.0
1746
+
1747
+ ## 12.4.0
1748
+
1749
+ ### Patch Changes
1750
+
1751
+ - Updated dependencies [60dc3ba]
1752
+ - @objectstack/spec@12.4.0
1753
+ - @objectstack/core@12.4.0
1754
+
1755
+ ## 12.3.0
1756
+
1757
+ ### Patch Changes
1758
+
1759
+ - Updated dependencies [e7eceec]
1760
+ - @objectstack/spec@12.3.0
1761
+ - @objectstack/core@12.3.0
1762
+
1763
+ ## 12.2.0
1764
+
1765
+ ### Patch Changes
1766
+
1767
+ - Updated dependencies [fce8ff4]
1768
+ - Updated dependencies [3962023]
1769
+ - Updated dependencies [2bb193d]
1770
+ - Updated dependencies [0426d27]
1771
+ - Updated dependencies [da807f7]
1772
+ - Updated dependencies [4f5b791]
1773
+ - @objectstack/spec@12.2.0
1774
+ - @objectstack/core@12.2.0
1775
+
1776
+ ## 12.1.0
1777
+
1778
+ ### Patch Changes
1779
+
1780
+ - Updated dependencies [93e6d02]
1781
+ - @objectstack/spec@12.1.0
1782
+ - @objectstack/core@12.1.0
1783
+
1784
+ ## 12.0.0
1785
+
1786
+ ### Patch Changes
1787
+
1788
+ - Updated dependencies [a8df396]
1789
+ - Updated dependencies [e695fe0]
1790
+ - Updated dependencies [7c09621]
1791
+ - Updated dependencies [7709db4]
1792
+ - Updated dependencies [2082109]
1793
+ - Updated dependencies [7c09621]
1794
+ - Updated dependencies [9860de4]
1795
+ - Updated dependencies [069c205]
1796
+ - @objectstack/spec@12.0.0
1797
+ - @objectstack/core@12.0.0
1798
+
1799
+ ## 11.10.0
1800
+
1801
+ ### Patch Changes
1802
+
1803
+ - Updated dependencies [6a9397e]
1804
+ - Updated dependencies [c0efe5d]
1805
+ - @objectstack/spec@11.10.0
1806
+ - @objectstack/core@11.10.0
1807
+
1808
+ ## 11.9.0
1809
+
1810
+ ### Patch Changes
1811
+
1812
+ - Updated dependencies [d3595d9]
1813
+ - @objectstack/spec@11.9.0
1814
+ - @objectstack/core@11.9.0
1815
+
1816
+ ## 11.8.0
1817
+
1818
+ ### Patch Changes
1819
+
1820
+ - @objectstack/spec@11.8.0
1821
+ - @objectstack/core@11.8.0
1822
+
1823
+ ## 11.7.0
1824
+
1825
+ ### Patch Changes
1826
+
1827
+ - Updated dependencies [5178906]
1828
+ - @objectstack/spec@11.7.0
1829
+ - @objectstack/core@11.7.0
1830
+
1831
+ ## 11.6.0
1832
+
1833
+ ### Patch Changes
1834
+
1835
+ - @objectstack/spec@11.6.0
1836
+ - @objectstack/core@11.6.0
1837
+
1838
+ ## 11.5.0
1839
+
1840
+ ### Patch Changes
1841
+
1842
+ - Updated dependencies [6ee4f04]
1843
+ - Updated dependencies [c1e3a65]
1844
+ - @objectstack/spec@11.5.0
1845
+ - @objectstack/core@11.5.0
1846
+
1847
+ ## 11.4.0
1848
+
1849
+ ### Patch Changes
1850
+
1851
+ - Updated dependencies [5821c51]
1852
+ - Updated dependencies [a0fce3f]
1853
+ - @objectstack/spec@11.4.0
1854
+ - @objectstack/core@11.4.0
1855
+
1856
+ ## 11.3.0
1857
+
1858
+ ### Patch Changes
1859
+
1860
+ - Updated dependencies [58e8e31]
1861
+ - Updated dependencies [b4a5df0]
1862
+ - @objectstack/spec@11.3.0
1863
+ - @objectstack/core@11.3.0
1864
+
1865
+ ## 11.2.0
1866
+
1867
+ ### Patch Changes
1868
+
1869
+ - Updated dependencies [d0f4b13]
1870
+ - Updated dependencies [302bdab]
1871
+ - @objectstack/spec@11.2.0
1872
+ - @objectstack/core@11.2.0
1873
+
1874
+ ## 11.1.0
1875
+
1876
+ ### Patch Changes
1877
+
1878
+ - Updated dependencies [ce0b4f6]
1879
+ - Updated dependencies [9ccfcd6]
1880
+ - Updated dependencies [ecf193f]
1881
+ - Updated dependencies [51bec81]
1882
+ - Updated dependencies [3e593a7]
1883
+ - Updated dependencies [63d5403]
1884
+ - @objectstack/core@11.1.0
1885
+ - @objectstack/spec@11.1.0
1886
+
1887
+ ## 11.0.0
1888
+
1889
+ ### Minor Changes
1890
+
1891
+ - 5eef4cf: feat(analytics): multi-hop relationship joins for datasets (ADR-0071)
1892
+
1893
+ A dataset's `include` and dimension/measure `field` paths may now traverse up to
1894
+ 3 to-one relationship hops (`account.owner.region`), not just one. The compiler
1895
+ expands each declared path into the ordered join chain (one `cube.join` per path
1896
+ prefix, aliased dot-free as `account__owner` so it stays a single valid SQL
1897
+ identifier), and the NativeSQLStrategy emits the chained `LEFT JOIN`s. Per-hop
1898
+ tenant/RLS read-scope is enforced for EVERY object in the chain — the
1899
+ alias-driven scope loop already generalizes, so no security path is rewritten.
1900
+
1901
+ Restricted to **to-one** (lookup / master_detail) relationships, which never fan
1902
+ out — aggregates stay correct with no symmetric-aggregate machinery; to-many
1903
+ traversal is out of scope. Single-hop datasets are byte-for-byte unchanged (the
1904
+ dot-free alias is a no-op for a single segment). Undeclared paths are still
1905
+ rejected (ADR-0021 D-C); paths beyond 3 hops are rejected at both parse and
1906
+ compile time.
1907
+
1908
+ ### Patch Changes
1909
+
1910
+ - 910a8f0: fix(analytics): compare boolean filters/group-by against the real boolean, not stringified '1'
1911
+
1912
+ The analytics filter normalizer stringified boolean `true` → `'1'`, which the
1913
+ ObjectQL strategy then coerced back to the number `1` before calling
1914
+ `engine.aggregate`. Boolean fields hold a real `true`/`false`, so `1 !== true`
1915
+ never matched: a metric widget filtered on a boolean field (e.g.
1916
+ `{ is_critical: true }`) always returned 0, and pie/donut/bar charts grouped by
1917
+ a boolean dimension failed to bucket. `stringifyForCube` now serializes booleans
1918
+ as the tokens `'true'`/`'false'`, and a new `coerceFilterValueForObjectQL`
1919
+ recovers a real boolean for the ObjectQL engine while the SQL path keeps binding
1920
+ `1`/`0` (better-sqlite3 cannot bind a JS boolean).
1921
+
1922
+ - 715d667: fix(analytics): qualify base-object columns in joined dataset queries
1923
+
1924
+ A dataset that joins a related object (`include` + a `relationship.field`
1925
+ dimension/measure) emitted BARE base-table columns in SELECT/GROUP BY while the
1926
+ joined columns were alias-qualified. When the base and joined tables share a
1927
+ column name (e.g. both have `status`), the query failed at runtime with
1928
+ "ambiguous column name". `NativeSQLStrategy` now qualifies plain base-column
1929
+ identifiers with the base table when the cube has joins; single-object cubes
1930
+ are unchanged (byte-for-byte identical SQL).
1931
+
1932
+ - Updated dependencies [ab5718a]
1933
+ - Updated dependencies [4845c12]
1934
+ - Updated dependencies [c1a754a]
1935
+ - Updated dependencies [6fbe91f]
1936
+ - Updated dependencies [715d667]
1937
+ - Updated dependencies [5eef4cf]
1938
+ - Updated dependencies [72759e1]
1939
+ - Updated dependencies [6c4fbd9]
1940
+ - Updated dependencies [ef3ed67]
1941
+ - Updated dependencies [cd51229]
1942
+ - Updated dependencies [7697a0e]
1943
+ - Updated dependencies [e7e04f1]
1944
+ - Updated dependencies [cfd5ac4]
1945
+ - Updated dependencies [2be5c1f]
1946
+ - Updated dependencies [ad143ce]
1947
+ - Updated dependencies [5c4a8c8]
1948
+ - Updated dependencies [3afaeed]
1949
+ - Updated dependencies [8801c02]
1950
+ - Updated dependencies [3d04e06]
1951
+ - Updated dependencies [4a84c98]
1952
+ - Updated dependencies [c715d25]
1953
+ - Updated dependencies [aa33b02]
1954
+ - Updated dependencies [d980f0d]
1955
+ - Updated dependencies [a658523]
1956
+ - Updated dependencies [82ff91c]
1957
+ - Updated dependencies [638f472]
1958
+ - @objectstack/spec@11.0.0
1959
+ - @objectstack/core@11.0.0
1960
+
1961
+ ## 10.3.0
1962
+
1963
+ ### Patch Changes
1964
+
1965
+ - f73d40a: fix(analytics): log scalar auto-inferred cubes at debug, not warn
1966
+
1967
+ Scalar metric queries (measures only, no `dimensions`/`timeDimensions`) over an
1968
+ unregistered cube — the first-class `object-metric` "metric over an object" path
1969
+ — auto-infer a trivial count/sum cube by design. That auto-infer now logs at
1970
+ `debug` instead of `warn`, so boot/render no longer spams
1971
+ `No cube registered for "..."` for a non-problem. Grouped queries (explicit
1972
+ dimension / time bucket) over an unregistered cube keep the `warn`, where a
1973
+ forgotten cube registration is a real mistake.
1974
+
1975
+ - @objectstack/spec@10.3.0
1976
+ - @objectstack/core@10.3.0
1977
+
1978
+ ## 10.2.0
1979
+
1980
+ ### Patch Changes
1981
+
1982
+ - Updated dependencies [b496498]
1983
+ - @objectstack/spec@10.2.0
1984
+ - @objectstack/core@10.2.0
1985
+
1986
+ ## 10.1.0
1987
+
1988
+ ### Minor Changes
1989
+
1990
+ - 49da36e: feat(analytics): correct analytics over federated objects (ADR-0062 Phase 3, D6)
1991
+
1992
+ Analytics over an external (federated) object now aggregates against the
1993
+ **correct** remote table instead of silently querying the wrong one. The
1994
+ `NativeSQLStrategy` hand-compiles `FROM "<object>"` and bare column references,
1995
+ which bypass the driver's physical-table resolution (`external.remoteName` /
1996
+ `remoteSchema` / `columnMap`). It now **declines** any query whose base or joined
1997
+ object is federated, routing it to the `ObjectQLStrategy` — whose
1998
+ `engine.aggregate()` goes through the driver's `getBuilder` and already honours
1999
+ `remoteName`/`remoteSchema` (#2138/#2149). This "reuses the driver's resolution"
2000
+ (D6) rather than re-implementing it.
2001
+
2002
+ Adds an optional `StrategyContext.isExternalObject(objectName)` hook (reported by
2003
+ the analytics plugin from the object's `external` block). Purely additive — with
2004
+ no hook, behavior is unchanged for managed objects.
2005
+
2006
+ ### Patch Changes
2007
+
2008
+ - Updated dependencies [49da36e]
2009
+ - Updated dependencies [ac79f16]
2010
+ - @objectstack/spec@10.1.0
2011
+ - @objectstack/core@10.1.0
2012
+
2013
+ ## 10.0.0
2014
+
2015
+ ### Minor Changes
2016
+
2017
+ - 70609af: Resolve a monetary measure's display currency via the field→tenant chain.
2018
+
2019
+ A dataset measure-currency now resolves through: explicit measure `currency` →
2020
+ source-field `currencyConfig.defaultCurrency` → tenant default (`ctx.currency`).
2021
+ A measure is monetary iff it declares a currency or aggregates a `currency`-type
2022
+ field, so count/avg-of-number measures never receive a code. Wires a
2023
+ `measureCurrency` field-metadata resolver from the data engine's object schema.
2024
+
2025
+ - 3187952: Dataset analytics enrich **dimension** result fields with their display label (so report/dashboard table headers read "Status" instead of the raw field name) and expose drill-through metadata on the dataset query result: the base `object`, a drillable dimension→field map, and a parallel `drillRawRows` array of each row's raw grouped values (captured before label resolution). This lets a host drill a grouped bucket back to its underlying records with an exact-match filter built from the stored value, not the display label. Date dimensions are excluded (a humanized bucket can't be exact-matched).
2026
+ - a581385: Propagate a dataset measure's declared currency to the analytics result field.
2027
+
2028
+ Adds an optional `DatasetMeasure.currency` (ISO 4217) on the semantic layer and
2029
+ carries it onto each measure result field alongside `label`/`format`, so a
2030
+ currency-aware client (Intl symbol) can render `¥1,234` / `$616,000` from a real
2031
+ currency code instead of a plain number or a `$` baked into `format`. Additive
2032
+ and optional — existing datasets are unaffected.
2033
+
2034
+ ### Patch Changes
2035
+
2036
+ - Updated dependencies [d7ff626]
2037
+ - Updated dependencies [2a1b16b]
2038
+ - Updated dependencies [e16f2a8]
2039
+ - Updated dependencies [e411a82]
2040
+ - Updated dependencies [a581385]
2041
+ - Updated dependencies [d5f6d29]
2042
+ - Updated dependencies [220ce5b]
2043
+ - Updated dependencies [3efe334]
2044
+ - Updated dependencies [feead7e]
2045
+ - Updated dependencies [6ca20b3]
2046
+ - Updated dependencies [5f875fe]
2047
+ - Updated dependencies [b469950]
2048
+ - @objectstack/spec@10.0.0
2049
+ - @objectstack/core@10.0.0
2050
+
2051
+ ## 9.11.0
2052
+
2053
+ ### Patch Changes
2054
+
2055
+ - Updated dependencies [e7f6539]
2056
+ - Updated dependencies [2365d07]
2057
+ - Updated dependencies [6595b53]
2058
+ - Updated dependencies [fa8964d]
2059
+ - Updated dependencies [36138c7]
2060
+ - Updated dependencies [a8e4f3b]
2061
+ - Updated dependencies [4c213c2]
2062
+ - Updated dependencies [2afb612]
2063
+ - @objectstack/spec@9.11.0
2064
+ - @objectstack/core@9.11.0
2065
+
2066
+ ## 9.10.0
2067
+
2068
+ ### Patch Changes
2069
+
2070
+ - db02bd5: Fix dashboard time-series charts / "last N months" KPIs that filter or group by a `Field.datetime` column silently returning "No rows".
2071
+
2072
+ The analytics `NativeSQLStrategy` compiles dashboard relative-date tokens (`{12_months_ago}`, `{today}`, …) to ISO date strings and binds them directly into raw SQL, bypassing the driver's own filter coercion. Under better-sqlite3 a `Field.datetime` column is stored as an INTEGER epoch (ms), so `assessed_at >= '2025-06-18'` became a TEXT-vs-INTEGER affinity compare that is always false — an empty result even though the rows exist. `Field.date` columns store ISO TEXT and were unaffected.
2073
+
2074
+ The strategy now coerces a temporal comparand to the column's on-disk storage form via a new optional `StrategyContext.coerceTemporalFilterValue` hook, wired to the driver's public `SqlDriver.temporalFilterValue` (the single source of truth for the storage convention). Coercion is dialect-correct: SQLite `Field.datetime` → epoch ms; `Field.date` text and native-timestamp dialects (Postgres/MySQL) are left unchanged, so Postgres is never handed an epoch integer. Applied to `gte`/`lte`/`gt`/`lt`/`equals`, `in`/`notIn`, and the `dateRange`/timeDimension `BETWEEN` path.
2075
+
2076
+ - fd07027: fix(analytics): make organization timezone actually drive date-dimension bucketing (ADR-0053 Phase 2, #1982)
2077
+
2078
+ Date-bucketed analytics silently ignored the reference timezone end-to-end. Three independent seams were broken:
2079
+
2080
+ - **service-analytics** — `NativeSQLStrategy` (priority 10) won every cube/dataset query on a SQL driver, but it groups by the raw column (no `date_trunc`) and ignores `timezone`, so a date dimension never bucketed (one row per raw timestamp) and a non-UTC zone was dropped. It now declines queries that carry a `timeDimensions[].granularity`, handing them to `ObjectQLStrategy` → `engine.aggregate` (native bucketing when UTC-safe, uniform in-memory bucketing when non-UTC).
2081
+ - **objectql** — the in-memory `count` aggregation treated the `*` count-all sentinel (the Cube `count` measure / a fieldless dataset `count`, both compiled to `sql: '*'`) as a column name, counting non-null of a non-existent property → `0` for every bucket. The driver's `COUNT(*)` masked it; the in-memory path (non-UTC date buckets, `driver-rest`/`driver-memory`) returned zeros. `*` is now counted as all rows.
2082
+ - **rest** — `resolveExecCtx` never resolved the localization timezone/locale, so `/analytics/dataset/query` always ran with `timezone: 'UTC'`. It now resolves them through the `settings` service (honouring the 4-tier cascade incl. the `OS_LOCALIZATION_TIMEZONE` env override), mirroring the dispatcher path.
2083
+
2084
+ - Updated dependencies [db02bd5]
2085
+ - Updated dependencies [641675d]
2086
+ - Updated dependencies [94e9040]
2087
+ - Updated dependencies [1f88fd9]
2088
+ - Updated dependencies [1f88fd9]
2089
+ - @objectstack/spec@9.10.0
2090
+ - @objectstack/core@9.10.0
2091
+
2092
+ ## 9.9.1
2093
+
2094
+ ### Patch Changes
2095
+
2096
+ - @objectstack/spec@9.9.1
2097
+ - @objectstack/core@9.9.1
2098
+
2099
+ ## 9.9.0
2100
+
2101
+ ### Minor Changes
2102
+
2103
+ - 9afeb2d: feat(settings): `localization` settings — platform default timezone, language & formats (ADR-0053 Phase 2)
2104
+
2105
+ Adds a `localization` SettingsManifest, the missing keystone that makes the Phase 2 reference-timezone actually configurable end-to-end. One declaration gives the full settings stack for free: platform built-in default → `global` → `tenant` cascade, a permission-gated settings page, and i18n.
2106
+
2107
+ **Keys** (organization-level; per-user overrides intentionally out of scope for v1): `timezone` (UTC), `locale` (en-US), `default_country`, `date_format`, `time_format`, `number_format`, `first_day_of_week`, `currency` (USD), `fiscal_year_start`. Benchmarked against Salesforce/Workday "Company Information + Locale".
2108
+
2109
+ **Resolver 收编** — `resolveExecutionContext` now resolves `timezone` **and** `locale` from the `localization` settings via the `settings` service (canonical 4-tier cascade), falling back to a direct tenant-scoped `sys_setting` read, then `UTC` / `en-US`. This replaces the hand-rolled `sys_user_preference` + tenant-only `sys_setting` path from #1978 (which bypassed the settings abstraction and is dropped along with the per-user tier). New `ExecutionContext.locale`.
2110
+
2111
+ **Consumer wiring** — analytics date bucketing now picks up the resolved org timezone: `DatasetExecutor` threads `ExecutionContext.timezone` into the query (precedence: explicit selection tz → request tz → UTC), so #1982's tz-aware buckets fire for a configured org without callers passing a zone. Formula `today()`/`datetime` were already wired (#1979/#1980).
2112
+
2113
+ Email `datetime` rendering (`SendTemplateInput.timezone`, shipped in #1981) is intentionally **not** wired here: the only current `sendTemplate` callers are pre-session auth emails with no org context; business-notification callers can pass the zone when they appear.
2114
+
2115
+ - 601cc11: feat(analytics): timezone-aware date bucketing (ADR-0053 Phase 2)
2116
+
2117
+ Analytics day/week/month/quarter/year buckets now resolve on a **reference timezone's** calendar days, so a row near a tz day-boundary lands in the bucket a user in that zone would expect — identically on SQLite and Postgres.
2118
+
2119
+ Per ADR-0053 decision **D2**, bucketing is done **in-memory, uniformly** for non-UTC zones rather than emitting dialect-specific `date_trunc … AT TIME ZONE` (SQLite has no tz database and MySQL needs tz tables loaded, so splitting by dialect would shift bucket boundaries for the same data). `engine.aggregate({ timezone })` therefore forces the in-memory aggregation path when a non-UTC reference tz is set — the date-range `where` still goes to the driver, so only matching rows are fetched. **UTC / unset keeps the native driver fast path unchanged.**
2120
+
2121
+ - New shared `calendarPartsInTz` / `calendarPartsInTzOrUtc` util in `@objectstack/core` (DST-safe via `Intl.DateTimeFormat`, never hand-rolled offset math; falls back to UTC for an unset/`'UTC'`/invalid zone).
2122
+ - `EngineAggregateOptions` and the analytics `executeAggregate` bridge / `ObjectQLStrategy` thread the reference timezone (sourced from the dataset selection / `ExecutionContext`) through to `applyInMemoryAggregation` → `bucketDateValue`, and the draft-preview evaluator's `bucketDate`.
2123
+ - `formatDateBucket` (dimension labels) stays UTC-only by design: it re-labels values that were _already_ bucketed upstream, so re-applying a timezone there would shift a correct bucket by a day.
2124
+
2125
+ ### Patch Changes
2126
+
2127
+ - Updated dependencies [84249a4]
2128
+ - Updated dependencies [11af299]
2129
+ - Updated dependencies [d5774b5]
2130
+ - Updated dependencies [134043a]
2131
+ - Updated dependencies [90108e0]
2132
+ - Updated dependencies [9afeb2d]
2133
+ - Updated dependencies [6bec07e]
2134
+ - Updated dependencies [601cc11]
2135
+ - Updated dependencies [575448d]
2136
+ - @objectstack/spec@9.9.0
2137
+ - @objectstack/core@9.9.0
2138
+
2139
+ ## 9.8.0
2140
+
2141
+ ### Patch Changes
2142
+
2143
+ - Updated dependencies [97c55b3]
2144
+ - Updated dependencies [1b1f490]
2145
+ - @objectstack/spec@9.8.0
2146
+ - @objectstack/core@9.8.0
2147
+
2148
+ ## 9.7.0
2149
+
2150
+ ### Patch Changes
2151
+
2152
+ - @objectstack/spec@9.7.0
2153
+ - @objectstack/core@9.7.0
2154
+
2155
+ ## 9.6.0
2156
+
2157
+ ### Patch Changes
2158
+
2159
+ - Updated dependencies [d1e930a]
2160
+ - Updated dependencies [71578f2]
2161
+ - Updated dependencies [5e3a301]
2162
+ - Updated dependencies [5db2742]
2163
+ - @objectstack/spec@9.6.0
2164
+ - @objectstack/core@9.6.0
2165
+
2166
+ ## 9.5.1
2167
+
2168
+ ### Patch Changes
2169
+
2170
+ - Updated dependencies [ee72aae]
2171
+ - @objectstack/spec@9.5.1
2172
+ - @objectstack/core@9.5.1
2173
+
2174
+ ## 9.5.0
2175
+
2176
+ ### Patch Changes
2177
+
2178
+ - Updated dependencies [d08551c]
2179
+ - Updated dependencies [707aeed]
2180
+ - Updated dependencies [7a103d4]
2181
+ - Updated dependencies [4b01250]
2182
+ - @objectstack/spec@9.5.0
2183
+ - @objectstack/core@9.5.0
2184
+
2185
+ ## 9.4.0
2186
+
2187
+ ### Patch Changes
2188
+
2189
+ - Updated dependencies [060467a]
2190
+ - Updated dependencies [0856476]
2191
+ - Updated dependencies [b678d8c]
2192
+ - Updated dependencies [b678d8c]
2193
+ - Updated dependencies [b678d8c]
2194
+ - @objectstack/spec@9.4.0
2195
+ - @objectstack/core@9.4.0
2196
+
2197
+ ## 9.3.0
2198
+
2199
+ ### Minor Changes
2200
+
2201
+ - b4765be: Server-side totals for matrix reports (#1753). `queryDataset` selections accept `totals: { groupings: string[][] }` — each grouping a subset of `selection.dimensions` to additionally aggregate by (`[]` = grand total); the marginal rows come back on `AnalyticsResult.totals` in request order. Each subtotal/grand total re-runs the full executor pipeline (measure-scoped filters, derived measures, compareTo) grouped only by that subset, so totals use each measure's true aggregate over the underlying rows — an `avg` total is the average of all rows, never an average of bucket averages (the ADR-0021 line that forbids client-side re-aggregation). Dimension display labels resolve on totals rows the same as the primary grid. A matrix report renderer asks for `{ groupings: [rowDims, columnDims, []] }` and renders the supplied totals row/column.
2202
+
2203
+ ### Patch Changes
2204
+
2205
+ - Updated dependencies [1ada658]
2206
+ - Updated dependencies [3219191]
2207
+ - Updated dependencies [290f631]
2208
+ - Updated dependencies [50b7b47]
2209
+ - Updated dependencies [f15d6f6]
2210
+ - Updated dependencies [f8684ea]
2211
+ - Updated dependencies [b4765be]
2212
+ - @objectstack/spec@9.3.0
2213
+ - @objectstack/core@9.3.0
2214
+
2215
+ ## 9.2.0
2216
+
2217
+ ### Patch Changes
2218
+
2219
+ - Updated dependencies [2f57b75]
2220
+ - Updated dependencies [2f57b75]
2221
+ - @objectstack/spec@9.2.0
2222
+ - @objectstack/core@9.2.0
2223
+
2224
+ ## 9.1.0
2225
+
2226
+ ### Patch Changes
2227
+
2228
+ - Updated dependencies [b9062c9]
2229
+ - @objectstack/spec@9.1.0
2230
+ - @objectstack/core@9.1.0
2231
+
2232
+ ## 9.0.1
2233
+
2234
+ ### Patch Changes
2235
+
2236
+ - Updated dependencies [1817845]
2237
+ - @objectstack/spec@9.0.1
2238
+ - @objectstack/core@9.0.1
2239
+
2240
+ ## 9.0.0
2241
+
2242
+ ### Minor Changes
2243
+
2244
+ - 4a0736b: Analytics now renders date dimensions as human bucket labels instead of raw
2245
+ epoch millis, and buckets them by their declared granularity.
2246
+
2247
+ - A date dimension with an explicit `dateGranularity` is now grouped by that
2248
+ bucket (the executor promotes it to a time dimension), so a "monthly" trend
2249
+ chart shows one point per month rather than one per raw timestamp.
2250
+ - Grouped date values are formatted to a sort-stable label per granularity
2251
+ (`year` → `2026`, `quarter` → `2026-Q2`, `month` → `2026-04`, `day`/`week`
2252
+ → `2026-04-15`), so charts no longer show `1777632968596`.
2253
+
2254
+ Pairs with the dimension display-label resolution (select option labels / lookup
2255
+ names) shipped previously.
2256
+
2257
+ - 2c6864f: Analytics dimensions now render human display labels instead of raw stored
2258
+ values. A `select` dimension shows its option `label` (e.g. `Backlog` rather than
2259
+ `backlog`), and a `lookup`/`master_detail` dimension shows the related record's
2260
+ display name (e.g. an account's name rather than its FK id). `queryDataset`
2261
+ resolves these server-side, so every dashboard/report chart benefits with no
2262
+ frontend change. Date/number/string dimensions are unaffected, and unresolved
2263
+ values are left as-is.
2264
+ - 0bf39f1: `queryDataset` now carries each measure's display `label` and `format` on the
2265
+ result `fields`, so presentations can show "Tasks" / "$616,000" instead of the
2266
+ raw measure name "task_count" / "616000".
2267
+
2268
+ - `AnalyticsResult.fields[]` gains optional `label?` and `format?`.
2269
+ - The dataset executor enriches measure columns from the dataset's measure
2270
+ definitions (matching `<name>` and `<name>__compare`).
2271
+
2272
+ The format can't be baked into the numeric row value (charts need the raw
2273
+ number), so the renderer applies it at display time.
2274
+
2275
+ ### Patch Changes
2276
+
2277
+ - Updated dependencies [4c3f693]
2278
+ - Updated dependencies [0bf39f1]
2279
+ - Updated dependencies [f533f42]
2280
+ - Updated dependencies [1c83ee8]
2281
+ - @objectstack/spec@9.0.0
2282
+ - @objectstack/core@9.0.0
2283
+
2284
+ ## 8.0.1
2285
+
2286
+ ### Patch Changes
2287
+
2288
+ - @objectstack/spec@8.0.1
2289
+ - @objectstack/core@8.0.1
2290
+
2291
+ ## 8.0.0
2292
+
2293
+ ### Patch Changes
2294
+
2295
+ - Updated dependencies [a46c017]
2296
+ - Updated dependencies [b990b89]
2297
+ - Updated dependencies [99111ec]
2298
+ - Updated dependencies [d5a8161]
2299
+ - Updated dependencies [5cf1f1b]
2300
+ - Updated dependencies [9ef89d4]
2301
+ - Updated dependencies [3306d2f]
2302
+ - Updated dependencies [c262301]
2303
+ - Updated dependencies [bc44195]
2304
+ - Updated dependencies [9e2e229]
2305
+ - @objectstack/spec@8.0.0
2306
+ - @objectstack/core@8.0.0
2307
+
2308
+ ## 7.9.0
2309
+
2310
+ ### Patch Changes
2311
+
2312
+ - @objectstack/spec@7.9.0
2313
+ - @objectstack/core@7.9.0
2314
+
2315
+ ## 7.8.0
2316
+
2317
+ ### Patch Changes
2318
+
2319
+ - Updated dependencies [06f2bbb]
2320
+ - Updated dependencies [36719db]
2321
+ - Updated dependencies [424ab26]
2322
+ - @objectstack/spec@7.8.0
2323
+ - @objectstack/core@7.8.0
2324
+
2325
+ ## 7.7.0
2326
+
2327
+ ### Patch Changes
2328
+
2329
+ - Updated dependencies [b391955]
2330
+ - Updated dependencies [f06b64e]
2331
+ - Updated dependencies [023bf93]
2332
+ - @objectstack/spec@7.7.0
2333
+ - @objectstack/core@7.7.0
2334
+
2335
+ ## 7.6.0
2336
+
2337
+ ### Patch Changes
2338
+
2339
+ - Updated dependencies [955d4c8]
2340
+ - Updated dependencies [c4a4cbd]
2341
+ - Updated dependencies [b046ec2]
2342
+ - Updated dependencies [2170ad9]
2343
+ - Updated dependencies [02d6359]
2344
+ - Updated dependencies [7648242]
2345
+ - Updated dependencies [8fa1e7f]
2346
+ - Updated dependencies [55866f5]
2347
+ - Updated dependencies [60f9c45]
2348
+ - @objectstack/spec@7.6.0
2349
+ - @objectstack/core@7.6.0
2350
+
2351
+ ## 7.5.0
2352
+
2353
+ ### Patch Changes
2354
+
2355
+ - @objectstack/spec@7.5.0
2356
+ - @objectstack/core@7.5.0
2357
+
2358
+ ## 7.4.1
2359
+
2360
+ ### Patch Changes
2361
+
2362
+ - @objectstack/spec@7.4.1
2363
+ - @objectstack/core@7.4.1
2364
+
2365
+ ## 7.4.0
2366
+
2367
+ ### Patch Changes
2368
+
2369
+ - Updated dependencies [23c7107]
2370
+ - Updated dependencies [c72daad]
2371
+ - Updated dependencies [f115182]
2372
+ - Updated dependencies [2faf9f2]
2373
+ - Updated dependencies [2faf9f2]
2374
+ - Updated dependencies [2faf9f2]
2375
+ - Updated dependencies [58b450b]
2376
+ - Updated dependencies [82eb6cf]
2377
+ - Updated dependencies [13d8653]
2378
+ - Updated dependencies [ff3d006]
2379
+ - Updated dependencies [5e831de]
2380
+ - @objectstack/spec@7.4.0
2381
+ - @objectstack/core@7.4.0
2382
+
2383
+ ## 7.3.0
2384
+
2385
+ ### Patch Changes
2386
+
2387
+ - Updated dependencies [5e7c554]
2388
+ - @objectstack/spec@7.3.0
2389
+ - @objectstack/core@7.3.0
2390
+
2391
+ ## 7.2.1
2392
+
2393
+ ### Patch Changes
2394
+
2395
+ - @objectstack/spec@7.2.1
2396
+ - @objectstack/core@7.2.1
2397
+
2398
+ ## 7.2.0
2399
+
2400
+ ### Patch Changes
2401
+
2402
+ - @objectstack/spec@7.2.0
2403
+ - @objectstack/core@7.2.0
2404
+
2405
+ ## 7.1.0
2406
+
2407
+ ### Patch Changes
2408
+
2409
+ - Updated dependencies [47a92f4]
2410
+ - @objectstack/spec@7.1.0
2411
+ - @objectstack/core@7.1.0
2412
+
2413
+ ## 7.0.0
2414
+
2415
+ ### Patch Changes
2416
+
2417
+ - Updated dependencies [74470ad]
2418
+ - Updated dependencies [d29617e]
2419
+ - Updated dependencies [dc72172]
2420
+ - @objectstack/spec@7.0.0
2421
+ - @objectstack/core@7.0.0
2422
+
2423
+ ## 6.9.0
2424
+
2425
+ ### Patch Changes
2426
+
2427
+ - @objectstack/spec@6.9.0
2428
+ - @objectstack/core@6.9.0
2429
+
2430
+ ## 6.8.1
2431
+
2432
+ ### Patch Changes
2433
+
2434
+ - @objectstack/spec@6.8.1
2435
+ - @objectstack/core@6.8.1
2436
+
2437
+ ## 6.8.0
2438
+
2439
+ ### Patch Changes
2440
+
2441
+ - Updated dependencies [6e88f77]
2442
+ - Updated dependencies [c8b9f57]
2443
+ - @objectstack/spec@6.8.0
2444
+ - @objectstack/core@6.8.0
2445
+
2446
+ ## 6.7.1
2447
+
2448
+ ### Patch Changes
2449
+
2450
+ - @objectstack/spec@6.7.1
2451
+ - @objectstack/core@6.7.1
2452
+
2453
+ ## 6.7.0
2454
+
2455
+ ### Patch Changes
2456
+
2457
+ - Updated dependencies [430067b]
2458
+ - Updated dependencies [4f9e9d4]
2459
+ - @objectstack/spec@6.7.0
2460
+ - @objectstack/core@6.7.0
2461
+
2462
+ ## 6.6.0
2463
+
2464
+ ### Patch Changes
2465
+
2466
+ - Updated dependencies [a49cfc2]
2467
+ - @objectstack/spec@6.6.0
2468
+ - @objectstack/core@6.6.0
2469
+
2470
+ ## 6.5.1
2471
+
2472
+ ### Patch Changes
2473
+
2474
+ - @objectstack/spec@6.5.1
2475
+ - @objectstack/core@6.5.1
2476
+
2477
+ ## 6.5.0
2478
+
2479
+ ### Patch Changes
2480
+
2481
+ - @objectstack/spec@6.5.0
2482
+ - @objectstack/core@6.5.0
2483
+
2484
+ ## 6.4.0
2485
+
2486
+ ### Patch Changes
2487
+
2488
+ - Updated dependencies [f8651cc]
2489
+ - Updated dependencies [f8651cc]
2490
+ - Updated dependencies [0bf6f9a]
2491
+ - @objectstack/spec@6.4.0
2492
+ - @objectstack/core@6.4.0
2493
+
2494
+ ## 6.3.0
2495
+
2496
+ ### Patch Changes
2497
+
2498
+ - @objectstack/spec@6.3.0
2499
+ - @objectstack/core@6.3.0
2500
+
2501
+ ## 6.2.0
2502
+
2503
+ ### Patch Changes
2504
+
2505
+ - Updated dependencies [b4c74a9]
2506
+ - @objectstack/spec@6.2.0
2507
+ - @objectstack/core@6.2.0
2508
+
2509
+ ## 6.1.1
2510
+
2511
+ ### Patch Changes
2512
+
2513
+ - @objectstack/spec@6.1.1
2514
+ - @objectstack/core@6.1.1
2515
+
2516
+ ## 6.1.0
2517
+
2518
+ ### Patch Changes
2519
+
2520
+ - Updated dependencies [93c0589]
2521
+ - @objectstack/spec@6.1.0
2522
+ - @objectstack/core@6.1.0
2523
+
2524
+ ## 6.0.0
2525
+
2526
+ ### Patch Changes
2527
+
2528
+ - Updated dependencies [629a716]
2529
+ - Updated dependencies [dbc4f7d]
2530
+ - Updated dependencies [944f187]
2531
+ - @objectstack/spec@6.0.0
2532
+ - @objectstack/core@6.0.0
2533
+
2534
+ ## 5.2.0
2535
+
2536
+ ### Patch Changes
2537
+
2538
+ - Updated dependencies [bab2b20]
2539
+ - Updated dependencies [fa011d8]
2540
+ - Updated dependencies [b806f58]
2541
+ - @objectstack/spec@5.2.0
2542
+ - @objectstack/core@5.2.0
2543
+
2544
+ ## 5.1.0
2545
+
2546
+ ### Patch Changes
2547
+
2548
+ - Updated dependencies [75f4ee6]
2549
+ - Updated dependencies [823d559]
2550
+ - @objectstack/spec@5.1.0
2551
+ - @objectstack/core@5.1.0
2552
+
2553
+ ## 5.0.0
2554
+
2555
+ ### Patch Changes
2556
+
2557
+ - Updated dependencies [2f9073a]
2558
+ - @objectstack/spec@5.0.0
2559
+ - @objectstack/core@5.0.0
2560
+
2561
+ ## 4.2.0
2562
+
2563
+ ### Patch Changes
2564
+
2565
+ - Updated dependencies [2869891]
2566
+ - @objectstack/spec@4.2.0
2567
+ - @objectstack/core@4.2.0
2568
+
2569
+ ## 4.1.1
2570
+
2571
+ ### Patch Changes
2572
+
2573
+ - @objectstack/spec@4.1.1
2574
+ - @objectstack/core@4.1.1
2575
+
2576
+ ## 4.1.0
2577
+
2578
+ ### Patch Changes
2579
+
2580
+ - Updated dependencies [2108c30]
2581
+ - Updated dependencies [23db640]
2582
+ - @objectstack/spec@4.1.0
2583
+ - @objectstack/core@4.1.0
2584
+
2585
+ ## 4.0.5
2586
+
2587
+ ### Patch Changes
2588
+
2589
+ - 15e0df6: chore: unify all package versions to a single patch release
2590
+ - Updated dependencies [15e0df6]
2591
+ - @objectstack/spec@4.0.5
2592
+ - @objectstack/core@4.0.5
2593
+
2594
+ ## 4.0.4
2595
+
2596
+ ### Patch Changes
2597
+
2598
+ - Updated dependencies [326b66b]
2599
+ - @objectstack/spec@4.0.4
2600
+ - @objectstack/core@4.0.4
2601
+
2602
+ ## 4.0.3
2603
+
2604
+ ### Patch Changes
2605
+
2606
+ - @objectstack/spec@4.0.3
2607
+ - @objectstack/core@4.0.3
2608
+
2609
+ ## 4.0.2
2610
+
2611
+ ### Patch Changes
2612
+
2613
+ - Updated dependencies [5f659e9]
2614
+ - @objectstack/spec@4.0.2
2615
+ - @objectstack/core@4.0.2
2616
+
2617
+ ## 4.0.0
2618
+
2619
+ ### Patch Changes
2620
+
2621
+ - Updated dependencies [f08ffc3]
2622
+ - Updated dependencies [e0b0a78]
2623
+ - @objectstack/spec@4.0.0
2624
+ - @objectstack/core@4.0.0
2625
+
2626
+ ## 3.3.1
2627
+
2628
+ ### Patch Changes
2629
+
2630
+ - @objectstack/spec@3.3.1
2631
+ - @objectstack/core@3.3.1
2632
+
2633
+ ## 3.2.10
2634
+
2635
+ ### Patch Changes
2636
+
2637
+ - @objectstack/spec@3.3.0
2638
+ - @objectstack/core@3.3.0
2639
+
2640
+ All notable changes to this package will be documented in this file.
2641
+
2642
+ ## [3.2.9] — 2026-03-22
2643
+
2644
+ ### Added
2645
+
2646
+ - Initial implementation of `@objectstack/service-analytics`
2647
+ - `AnalyticsService` orchestrator implementing `IAnalyticsService`
2648
+ - Strategy pattern with priority chain:
2649
+ - **P1 — NativeSQLStrategy**: Pushes queries as native SQL to SQL-capable drivers (Postgres, MySQL, etc.)
2650
+ - **P2 — ObjectQLStrategy**: Translates analytics queries into ObjectQL `engine.aggregate()` calls
2651
+ - **P3 — InMemoryStrategy**: Delegates to any registered `IAnalyticsService` (e.g., `MemoryAnalyticsService`)
2652
+ - `CubeRegistry` for auto-discovery and registration of cubes from manifest definitions and object schema inference
2653
+ - `AnalyticsServicePlugin` for kernel plugin lifecycle integration
2654
+ - `queryCapabilities()` driver capability probing for strategy selection
2655
+ - `generateSql()` dry-run SQL generation across all strategies
2656
+ - Unit tests covering all strategy branches