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

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