@objectstack/service-analytics 17.0.0-rc.6 → 17.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,4578 @@
1
1
  # Changelog — @objectstack/service-analytics
2
2
 
3
+ ## 17.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - d17df80: **BREAKING — `dashboard.widgets[].compareTo` converges on the analytics executor's contract (#5011).**
8
+
9
+ The widget declared three period-over-period arms with confident TSDoc. The analytics
10
+ executor implements one shape, and it was never the same one — so on the ADR-0021 dataset
11
+ path (the spec's own "single author-facing analytics shape") **all three arms were
12
+ broken**, in two different ways:
13
+
14
+ - `compareTo: 'previousPeriod'` / `'previousYear'` were **silently DROPPED** by the dataset
15
+ renderer. The widget rendered its base numbers and the comparison the author asked for
16
+ simply was not there.
17
+ - `compareTo: { offset: '7d' }` was forwarded into `DatasetSelection.compareTo`, whose
18
+ contract is `{ kind, dimension }` and has no `offset` in it — so the executor threw
19
+ `compareTo requires a timeDimension "undefined"` and the whole widget errored out.
20
+
21
+ All three worked on the legacy inline chart path. Same key, two fates, and the failing one
22
+ was the path the spec calls canonical.
23
+
24
+ `compareTo` is now a thin projection of the contract that is actually implemented:
25
+
26
+ ```ts
27
+ compareTo?: { kind: 'previousPeriod' | 'previousYear'; dimension?: string }
28
+ ```
29
+
30
+ There is no widget-side vocabulary left to drift from the executor's, so `declared =
31
+ enforced` holds by construction rather than by review.
32
+
33
+ ## FROM → TO
34
+
35
+ | v16 | v17 | Fix |
36
+ | :----------------------------------------- | :-------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------ |
37
+ | `compareTo: 'previousPeriod'` | `compareTo: { kind: 'previousPeriod' }` | `os migrate meta --from 16` rewrites it |
38
+ | `compareTo: 'previousYear'` | `compareTo: { kind: 'previousYear' }` | `os migrate meta --from 16` rewrites it |
39
+ | `compareTo: { offset: '1y' }` | `compareTo: { kind: 'previousYear' }` | `os migrate meta --from 16` rewrites it — `1y` **is** `previousYear` |
40
+ | `compareTo: { offset: '7d' \| '1M' \| … }` | **no faithful target** | State the window on the widget's own `filter` and compare with `{ kind: 'previousPeriod' }`, which shifts by that window's own length |
41
+
42
+ The last row is deliberately _not_ rewritten. `previousPeriod` shifts by the length of
43
+ whatever window the filter resolves to, which equals `7d` only when that window happens to
44
+ be seven days — a mechanical rewrite would silently change which rows the comparison
45
+ column counts, turning a loud failure into a wrong number. It is registered as the
46
+ `dashboard-widget-compareto-offset` semantic migration; the schema rejects the key with the
47
+ prescription in hand.
48
+
49
+ Retired at the schema, so every old spelling is a parse error carrying its own upgrade —
50
+ including the bare strings, which are dispatched by value so a _typo_ is still told it is a
51
+ typo rather than told it "was removed".
52
+
53
+ ## `dimension` is optional — resolved by the executor, not by a renderer
54
+
55
+ Omit it and `dataset-executor.ts` resolves it, by its own long-standing criterion (a
56
+ `timeDimensions` entry carrying a `dateRange`):
57
+
58
+ - exactly one candidate → that one is shifted;
59
+ - **zero** → a loud error: a comparison is only defined against a bounded window;
60
+ - **two or more** → a loud error **listing the candidates by name**, never a silent
61
+ first-wins. Picking `created_at` when the author meant `close_date` produces a comparison
62
+ that is _wrong_ rather than _missing_, which is the failure nobody audits.
63
+
64
+ This is a producer-side resolution rule, not consumer-side tolerance (Prime Directive
65
+ #12): every caller — dashboard widget, report, raw `queryDataset` — gets the same dimension
66
+ or the same error, and no renderer is ever in a position to guess one.
67
+
68
+ ## Notes
69
+
70
+ - `DatasetCompareTo.dimension` is now optional. Callers that always passed it are
71
+ unaffected; callers that relied on the old "must be present" typing get a wider type.
72
+ - The converged slot is **union-free**. That is not cosmetic: zod collapses a failed union
73
+ into one bare `Invalid input`, so curated guidance written inside a union arm never
74
+ reaches the author (#5014). This slot's prescriptions are top-level and do.
75
+ - objectui's legacy inline chart path adapts separately (objectui#3337), which also deletes
76
+ the `DatasetWidget` string-drop workaround this change makes unnecessary.
77
+
78
+ - 3c7bcc0: feat(spec)!: converge the 11 contracts-vs-domain dual-source type names (#4538)
79
+
80
+ `packages/spec/src/contracts/` hand-wrote parameter/result interfaces whose
81
+ names collided with same-named zod-derived types in the domains — the #4411
82
+ trap, tracked as 11 rows of `dual-source-exports.baseline.json`. Each name was
83
+ judged individually against a three-repo import-level scan (framework, cloud,
84
+ objectui): which declaration actually flows at runtime decides the direction.
85
+ All 11 rows are deleted from the baseline; no name below is exported twice
86
+ anymore.
87
+
88
+ **Converged — `./contracts` now re-exports the domain zod type (same
89
+ declaration on both entries, imports keep compiling from either):**
90
+
91
+ - `NotificationChannel` → `system/notification.zod`'s
92
+ `z.infer<NotificationChannelSchema>` (member sets were identical).
93
+ - `ValidationResult` → `kernel/plugin-validator.zod` (shapes were identical).
94
+ - `HealthStatus` → `kernel/startup-orchestrator.zod` (`details` narrows
95
+ `Record<string, any>` → `Record<string, unknown>`).
96
+ - `PluginStartupResult` → `kernel/startup-orchestrator.zod`. FROM `plugin:
97
+ Plugin` (live object) and `error?: Error` TO the serializable projection
98
+ (`plugin: { name, version? }`-passthrough, `error?: { name, message,
99
+ stack?, code? }`). Neither side had any consumer outside spec; the
100
+ zod-validatable shape wins.
101
+ - `StartupOptions` → `kernel/startup-orchestrator.zod` — the PARSED tier
102
+ (defaults applied). `IStartupOrchestrator.orchestrateStartup` now takes
103
+ `StartupOptionsInput` (the caller-authored all-optional tier, also
104
+ re-exported from `./contracts`). Fix for callers typed to the old
105
+ all-optional `StartupOptions`: rename to `StartupOptionsInput`.
106
+ - `JobExecution` → `system/job.zod`. The system schema's `duration` field is
107
+ RENAMED `durationMs` — that is what every job adapter produces and what the
108
+ `sys_job_run.duration_ms` column round-trips; the schema described records
109
+ nothing ever wrote. Fix: `duration` → `durationMs` when parsing
110
+ `JobExecutionSchema` payloads.
111
+ - `AnalyticsQuery` → `data/analytics.zod`. The domain schema aligned to the
112
+ contract's semantics first: `timezone` LOST its `.default('UTC')` — absence
113
+ is meaningful (the engine resolves org timezone, #1982/#2018; the
114
+ `/analytics` entry always refused to apply that default). The schema is now
115
+ transform-free, so `AnalyticsQuery` ≡ `AnalyticsQueryInput` (both kept
116
+ exported). Fix for code that relied on `.parse()` injecting `timezone:
117
+ 'UTC'`: pass the timezone explicitly or resolve it via the engine chain
118
+ (`selection.timezone ?? context.timezone ?? 'UTC'`).
119
+
120
+ **Renamed — two genuinely different concepts were sharing one name (both
121
+ flow at runtime):**
122
+
123
+ - `./contracts` `DriverCapabilities` → **`AnalyticsDriverCapabilities`**
124
+ (`{ nativeSql, objectqlAggregate, inMemory }`, the analytics strategy-chain
125
+ execution-path probe). The `DriverCapabilities` name now belongs solely to
126
+ the data domain's driver feature-flag record (`DriverCapabilitiesSchema`,
127
+ what `IDataDriver.supports` declares). Fix: importers of the trio from
128
+ `@objectstack/spec/contracts` (or `@objectstack/service-analytics`, whose
129
+ re-export is renamed in lockstep) rename the import; importers who meant
130
+ the driver flags import `DriverCapabilities` from `@objectstack/spec/data`.
131
+
132
+ **Removed — the domain-side declaration was dead (zero import-level consumers
133
+ in framework/cloud/objectui; the #4411 family's last survivors):**
134
+
135
+ - `system` `MetadataExportOptionsSchema` / `MetadataExportOptions` and
136
+ `MetadataImportOptionsSchema` / `MetadataImportOptions` (the
137
+ `output`/`source`-directory bags). The names now have ONE declaration each:
138
+ the `IMetadataService.exportMetadata` / `importMetadata` parameter
139
+ interfaces on `./contracts` (`types`/`namespaces`/`format` and
140
+ `conflictResolution`/`validate`/`dryRun`), which `MetadataManager`
141
+ implements. No tombstone/D2 conversion, deliberately — these are runtime
142
+ option-bag types, not authorable metadata (same reasoning as #4458).
143
+ `@objectstack/metadata` re-exports the two names from `./contracts` now
144
+ (it previously re-exported the dead system-side shapes its own manager
145
+ did not accept).
146
+ - `system` `JobSchedule` (the `= Schedule` back-compat alias). The name's one
147
+ declaration is the `IJobService.schedule` boundary shape on `./contracts`
148
+ (plain-string cron `expression`); the authored metadata type keeps its real
149
+ name `Schedule`. Fix: `import type { JobSchedule } from
150
+ '@objectstack/spec/system'` → `Schedule` (authoring tier) or the
151
+ `./contracts` `JobSchedule` (service boundary), whichever you meant.
152
+
153
+ ### Minor Changes
154
+
155
+ - 9a75790: feat(service-analytics): a field-to-field (`$field`) RLS rule is served on the analytics path — native SQL declines and routes to the engine (#7598)
156
+
157
+ A CEL permission / RLS rule that compares two columns of the same record —
158
+ `compileCelToFilter` lowers it to `{ amount: { $gt: { $field: 'budget' } } }` —
159
+ now **works** on `/analytics/query`, whether it arrives in the caller's `where`
160
+ or in the read scope the platform compiles from an admin-authored sharing rule.
161
+
162
+ Before #7694 the two analytics SQL compilers **bound the reference object as the
163
+ comparison's value**: the statement compiled perfectly and compared a column
164
+ against the text `{"$field":"budget"}`, which no row can hold — an empty chart,
165
+ or an RLS predicate quietly answering the wrong row set, with nothing to read.
166
+ #7694 stopped that by refusing the shape. This change replaces the refusal with
167
+ the answer.
168
+
169
+ **How.** `NativeSQLStrategy.canHandle` declines a query whose `where` or read
170
+ scope carries a reference in a scalar comparand position, so the query falls
171
+ through to the lower-priority ObjectQL/engine path — the same decline-and-route
172
+ mechanism this strategy already uses for federated objects (ADR-0062 D6) and for
173
+ date-bucketed queries. `driver-sql` then compiles the comparison and enforces the
174
+ four #5222 security rulings — same-table columns only, declared-only enumeration,
175
+ the tenant-isolation column forbidden on both sides, and a matching comparison
176
+ class — using the `initObjects` metadata it owns. Those rules stay in exactly one
177
+ place; the alternative considered was a `StrategyContext` enumeration hook plus a
178
+ second implementation of them inside this package, and a guard that exists twice
179
+ is a guard that will eventually disagree with itself.
180
+
181
+ ⚠️ **Query routing now depends on filter CONTENT, not only on query shape.** That
182
+ is new behaviour for `canHandle`, and it is deliberate: a query carrying a
183
+ cross-field comparison takes the engine path rather than raw SQL, so it is served
184
+ by `engine.aggregate` and is slower than a pushed-down statement. Every other
185
+ query is unaffected — a literal comparand, a literal read scope and a filterless
186
+ query all keep the native-SQL path exactly as before.
187
+
188
+ **Two positions deliberately still refuse**, and both converge with what
189
+ `driver-sql` itself refuses rather than diverging from it:
190
+
191
+ - `/analytics/sql` — the display echo declines a cross-field comparison instead
192
+ of half-rendering one. It describes an execution it does not perform, and the
193
+ predicate the engine path actually runs is written total across NULLs; what
194
+ this renderer can emit is a comparison against the reference as a bound value,
195
+ which reproduces none of the rows the query returns. `/analytics/query` still
196
+ serves those queries and returns rows — the response simply carries no `sql`
197
+ string.
198
+ - a `$field` in a `$between` **endpoint**. No backend serves it (`@objectstack/spec`
199
+ removed the position in #7596), and this compiler splits `$between` into its two
200
+ bounds — so routing it would hand the driver a `$gte` / `$lte` the author never
201
+ wrote, and the range would quietly succeed here while the identical filter is
202
+ refused everywhere else. The refusal message now names that, and points at the
203
+ scalar spelling which _is_ served.
204
+
205
+ The LIKE family and `$in` / `$nin` members keep their existing refusals and
206
+ wordings, unchanged.
207
+
208
+ **Read-scope error envelope: unchanged.** An unsupported rule on the read-scope
209
+ lowering still answers `READ_SCOPE_COMPILE_FAILED` / 500 with the message
210
+ withheld, exactly as the #5367 ruling set it — no new error code, no move to a
211
+ 4xx. A read scope is not the caller's document, so it is not the caller's 4xx.
212
+
213
+ One further fix this needed, in the same class as #7597: `ObjectQLStrategy`
214
+ lowered an equality comparand **bare** (`{ amount: 5 }` — correct for a literal),
215
+ which for a reference produced `{ amount: { $field: 'budget' } }`, a field spec no
216
+ backend reads as an equality. It now emits an explicit `$eq` when the comparand is
217
+ a reference, branching on the comparand rather than on the operator. And a
218
+ reference comparand no longer takes this door's NULL-safe `$ne` guard (#5298),
219
+ which is right for a literal and wrong for a reference — measured, it admitted the
220
+ both-NULL row that the shared corpus, both SQL drivers and the in-memory evaluator
221
+ all exclude.
222
+
223
+ - 840ee4b: fix(analytics,runtime,types): gate cube auto-inference on object existence; stop the dispatcher boundary returning raw SQL (#3867)
224
+
225
+ Two independent defects on the `/analytics` surface, found while verifying #3770
226
+ against a real server. On an authenticated CRM dev server, before this change:
227
+
228
+ ```
229
+ POST /api/v1/analytics/query {"cube":"sqlite_master","measures":["count"],"dimensions":["type"]}
230
+ → 200 {"rows":[{"type":"index","count":262},{"type":"table","count":71},{"type":"view","count":1}],
231
+ "sql":"SELECT type AS \"type\", COUNT(*) AS \"count\" FROM \"sqlite_master\" GROUP BY type"}
232
+ ```
233
+
234
+ That is SQLite's internal schema table — never a registered object — read
235
+ successfully through the analytics endpoint. Not merely "the name reaches the
236
+ driver and errors": **any table the connection can see was readable.**
237
+
238
+ **① The cube name reached the driver as a table name.** `AnalyticsService.ensureCube`
239
+ auto-infers a minimal Cube when none is registered, with `cube.sql = <the queried
240
+ name>`. That is the intended "metric over an object" path — an `object-metric` KPI
241
+ widget queries `crm_account` with no authored Cube — but it accepted _any_ string,
242
+ so the endpoint could aggregate over an arbitrary physical table. The
243
+ analytics-side twin of the data-path gap #3770 closed, and it was not covered by
244
+ that fix: #3770 gated the protocol's `analyticsQuery`, which is the _degraded
245
+ fallback_; a deployment with `@objectstack/service-analytics` installed runs the
246
+ real engine instead (`ctx.replaceService`).
247
+
248
+ Inference is now gated on the same schema registry the data path consults, via a
249
+ new optional `AnalyticsServiceConfig.isRegisteredObject` that `plugin.ts` wires
250
+ from the `data` engine's `getObject`. Three-way rule: a registered Cube runs
251
+ untouched (its `sql` is whatever it declares); an unregistered name that IS an
252
+ object still auto-infers exactly as before; neither → `CUBE_NOT_FOUND` / 404
253
+ raised before any SQL exists, naming both ways to make the request valid. With no
254
+ probe configured the gate stands down and warns once — the same tiering #3770
255
+ took for a missing registry. `generateSql` (`/analytics/sql`) is gated too.
256
+
257
+ **② The dispatcher boundary returned `err.message` verbatim.** `errorResponseBase`
258
+ is the single error exit for _every_ route the dispatcher plugin mounts —
259
+ `/analytics`, `/packages`, `/i18n`, `/storage`, `/automation`, `/auth`,
260
+ `/notifications`, `/mcp`. `@objectstack/rest` has guarded its data routes against
261
+ driver dumps forever (`mapDataError`); this boundary guarded nothing, so any
262
+ driver error on any of those routes shipped its SQL to the client. Unlike ①, this
263
+ half is unconditional — it does not depend on the cube being invalid.
264
+
265
+ The leak heuristic moved out of `rest-server.ts` into `@objectstack/types` as
266
+ `looksLikeInternalErrorLeak` (both packages already depend on it) and is now
267
+ applied at both boundaries — one predicate, one place to widen when a new
268
+ dialect's phrasing shows up. `mapDataError`'s behaviour is unchanged. At the
269
+ dispatcher it applies **only to 5xx**: a 4xx message is a deliberate
270
+ business/validation answer and must reach the caller intact. Sanitising costs no
271
+ diagnostics — the untouched error still reaches `errorReporter` through the
272
+ existing `__obsRecordedError` side-channel.
273
+
274
+ **Also fixed in the same function:** `errorResponseBase` read only
275
+ `err.statusCode`, while domain errors across this codebase carry `status` (and
276
+ `HttpDispatcher.errorFromThrown` already reads `status` first). Every deliberate
277
+ 4xx thrown through a dispatcher route — including #3770's `OBJECT_NOT_FOUND` on
278
+ the analytics fallback path — was rendered as a **500**. It now reads `status`
279
+ then `statusCode`.
280
+
281
+ **Behaviour change.** `/analytics/query` and `/analytics/sql` return 404
282
+ `CUBE_NOT_FOUND` for a cube that is neither registered nor a registered object;
283
+ previously the name was passed to the driver. Dashboards and KPI widgets pointed
284
+ at real objects or authored cubes are unaffected. A 5xx on a dispatcher route
285
+ whose message looks like a driver dump now reads `Internal server error` — check
286
+ server logs or your error reporter for the original.
287
+
288
+ - fa94b2c: fix(service-analytics): a measure a query never reported reads 0 for a count/sum on every merge seam (#4708)
289
+
290
+ A dataset measure carrying its own `filter` runs as a separate grouped
291
+ sub-query and is merged back onto the selected dimensions. A `GROUP BY` over a
292
+ filtered row set emits **no group at all** for a dimension value the filter
293
+ excludes entirely, so the measure comes back **absent**, not `0` — and
294
+ `computeDerived` treats an absent operand as unknowable, so every ratio over it
295
+ goes null too. The cell then renders blank, which is visually identical to "no
296
+ data for this row" and means the opposite.
297
+
298
+ The bias runs the worst possible way: the rows that blank are the ones whose
299
+ numerator matched nothing — the **worst-performing rows**. A `lead_source` that
300
+ won nothing rendered as "no data" while one that won everything rendered fine.
301
+
302
+ The empty-group value is now filled **by aggregate kind** into every measure
303
+ column the assembled grid lists but no query reported:
304
+
305
+ | aggregate | over an excluded group | why |
306
+ | :------------------------ | :--------------------- | :------------------------------------------------------------------ |
307
+ | `count`, `count_distinct` | `0` | "how many rows matched" has an exact answer when the answer is none |
308
+ | `sum` | `0` | the identity element of the empty set |
309
+ | `avg`, `min`, `max` | stays `null` | genuinely undefined — there is nothing to average |
310
+
311
+ Filling all five with `0` would trade this lie for its mirror image, reporting a
312
+ measurement nobody made, so the kinds are judged separately (via
313
+ `emptyGroupValueFor`, shared with the authoring-side coherence checks).
314
+
315
+ **Only cells are filled, never rows.** A dimension value no query reported at
316
+ all has genuinely no data and stays out of the grid.
317
+
318
+ **What changes beyond the measure-scoped seam.** The fill previously ran before
319
+ the `compareTo` merge, and that merge _appends_ a row for every bucket the
320
+ PREVIOUS window had and this one does not. Every base measure on those rows —
321
+ including unfiltered ones — was absent, so a lead source that sold last month
322
+ and nothing this month rendered as "no data" instead of `0`: the same worst-row
323
+ bias, one merge later. The fill now runs after every merge and covers all base
324
+ measures plus their `<measure>__compare` columns.
325
+
326
+ Widgets that worked around this with `?? 0` in the consumer or a `coalesce` in
327
+ the measure can drop it; the coercion belongs in the executor, which is the only
328
+ layer that knows which aggregate produced the gap.
329
+
330
+ **New export.** `fillEmptyGroups(rows, columnAggregates)` is exported from the
331
+ package root beside `mergeByDimensions`, so a host assembling a grid outside
332
+ `DatasetExecutor` can apply the same aggregate-kind rule rather than
333
+ reimplementing it — which is what makes this a `minor` rather than a `patch`.
334
+
335
+ - 587fc91: feat(analytics): the executeAggregate bridge carries ExecutionContext — ADR-0021 D-C second belt
336
+
337
+ The analytics→engine bridge now forwards the request's `ExecutionContext` to
338
+ `engine.aggregate`, so the engine's own middleware chain scopes analytics reads
339
+ independently of the analytics layer's `getReadScope`.
340
+
341
+ **Why.** `BaseEngineOptions.context` has always been `.optional()`, so nothing
342
+ forced the bridge to pass it — and it did not. An authenticated aggregate
343
+ reached the engine with no principal, plugin-security's principal-less fall-open
344
+ skipped its RLS injection, and the only thing left scoping the query was the
345
+ strategy remembering to call `getReadScope`. #3597 was a strategy that did not,
346
+ and both belts were off at once.
347
+
348
+ `getReadScope` stays: the two resolve scope through different paths (engine
349
+ middleware vs `security.getReadFilter`), and a deployment without
350
+ plugin-security has only the analytics layer. This is depth, not a replacement.
351
+
352
+ - `StrategyContext` gains `context?: ExecutionContext`, bound per call by
353
+ `AnalyticsService` from `query()` / `generateSql()` / `queryDataset()`.
354
+ - `StrategyContext.executeAggregate` and the `AnalyticsServicePlugin` /
355
+ `AnalyticsService` `executeAggregate` config options gain `context?:
356
+ ExecutionContext`. **Custom bridges should forward it** to their engine; the
357
+ built-in auto-bridge does. Purely additive — an existing bridge that ignores
358
+ it keeps working exactly as before.
359
+ - `DimensionLabelDeps.fetchRecordLabels` and `resolveDimensionLabels` each gain
360
+ an optional trailing `context`, beside the `scope` / `resolveScope` that
361
+ #3639 added — the same two-belt split as the aggregate path.
362
+ - `BootOptions.analytics` (`@objectstack/verify`) overrides the
363
+ AnalyticsServicePlugin instance, so a gate can boot with the analytics belt
364
+ off and assert the engine-side belt alone still scopes.
365
+
366
+ **Also fixed on the same seam:**
367
+
368
+ - `fetchRecordLabels` — the dimension display-label lookup — is row-granular
369
+ (one row per record, real display names). #3639 gave it the analytics-layer
370
+ belt (the referenced object's own read scope); it now also carries the
371
+ context, so the engine scopes the same read independently.
372
+ - `ObjectQLStrategy.generateSql` emitted no `WHERE` at all, so the
373
+ `/analytics/sql` preview read as an unscoped table scan while the real
374
+ aggregate was scoped. It now renders the caller's filters and the read scope.
375
+ The preview never executed, so this was misleading output rather than a leak.
376
+
377
+ - 79c3145: fix(analytics)!: a `{ $field }` comparand is refused on both SQL-lowering doors instead of being BOUND as the comparison's value (#7598)
378
+
379
+ <!-- adr-0087: not-required (no-migration-prescription) This change retires NO key and adds none. `FieldReferenceSchema` stays declared in `packages/spec` exactly as it is, stays implemented by `@objectstack/formula`'s in-memory evaluator, and stays COMPILED by `driver-sql` / `driver-sqlite-wasm` under #5222 — `packages/spec` is untouched by this PR, no metadata schema gains or loses a key, and no authored or stored shape becomes unparseable. What moves is one COMPILER's posture at two doors of `@objectstack/service-analytics`: a shape that used to compile into a predicate binding the reference OBJECT as a value now refuses. There is therefore nothing for `objectstack migrate meta` to rewrite — the FROM shape is still valid metadata everywhere it was valid before, and rewriting it would be wrong, since the identical filter continues to execute on the ObjectQL engine path and on both SQL drivers. Nor is there a FROM/TO rule a ledger entry could state: the correct repair depends on which face the author's query routes to, which is a deployment fact rather than a metadata one. The channels that do reach an affected reader are this changeset's CHANGELOG text and the refusal message itself, which names the operator, the field, the referenced column, the faces that DO execute the shape, and why this compiler cannot — all shipped with this change. -->
380
+
381
+ **⚠️ Behaviour change.** A filter whose comparand is a field reference —
382
+ `{ amount: { $gt: { $field: 'budget' } } }`, the shape
383
+ `FieldReferenceSchema` declares and `compileCelToFilter` emits for a
384
+ field-to-field comparison in a CEL permission / RLS rule — used to COMPILE on
385
+ both of this package's doors. It now refuses: `INVALID_FILTER` / 400 on the
386
+ analytics `where` door, `READ_SCOPE_COMPILE_FAILED` / 500 on the read-scope
387
+ lowering (each door's existing envelope, unchanged).
388
+
389
+ #7598 was filed reading "these compilers still REFUSE `$field`". Measured on
390
+ `origin/main` (`5823d593d`), nothing refused. For the six scalar comparison
391
+ operators — exactly the ones #5222 taught `driver-sql` to compile into a
392
+ same-table column-to-column comparison — the reference OBJECT went into the
393
+ bind list:
394
+
395
+ | face | `{ amount: { $gt: { $field: 'budget' } } }` |
396
+ | ------------------------------- | ------------------------------------------------------------------ |
397
+ | `read-scope-sql` | `"person"."amount" > ?` · bound to `{"$field":"budget"}` |
398
+ | `where` → `NativeSQLStrategy` | `WHERE amount > $1` · bound to the JSON TEXT `{"$field":"budget"}` |
399
+ | `where` → `/analytics/sql` echo | `WHERE amount > $1` · bound to the reference OBJECT |
400
+ | `where` → ObjectQL engine | reached `driver-sql`, which compiles it CORRECTLY since #5222 |
401
+
402
+ So the defect was a silent wrong answer, not a refusal: a syntactically perfect
403
+ predicate comparing a column against a value no row can hold. Three of the four
404
+ faces answered differently, and on the read-scope door the one answering wrongly
405
+ is an administrator's RLS predicate. The gates assumed to be catching this
406
+ (`isBindableComparand` / `isRenderableTextComparand`) had not drifted from
407
+ `driver-sql` — they are simply never ASKED about that position, only about the
408
+ LIKE family and `$in` / `$nin` / `$between` MEMBERS.
409
+
410
+ **What this does not do:** it does not bring the capability to these compilers.
411
+ The four maintainer rulings that make a referenced column name safe in a SQL
412
+ identifier position (same-table only, declared-only enumeration, tenant-isolation
413
+ column forbidden on both sides, same comparison class) all turn on metadata
414
+ `StrategyContext` does not expose — neither an object's declared field set nor its
415
+ tenant-isolation column — so these compilers cannot enforce them, and shipping a
416
+ port without them would open a comparison surface onto the tenant boundary.
417
+ Implementing it here is a `packages/spec` contract question, left open on #7598.
418
+
419
+ Field-to-field RLS rules continue to work on the ObjectQL engine path, where the
420
+ driver compiles them with the metadata it owns; they are now loudly refused,
421
+ rather than silently mis-answered, on the raw-SQL analytics path.
422
+
423
+ Positions already refused before this change keep their exact wording — the LIKE
424
+ family, `$in` / `$nin` members, and a bare `{ field: { $field: … } }` — because
425
+ each of those refusals already CONVERGES with `driver-sql`'s own #5222 refusal
426
+ arm. `minor` rather than `patch` follows #5234, the same class of change on the
427
+ same two doors.
428
+
429
+ - 1792384: fix(service-analytics)!: 分析查询的 `where` —— `$not` 变 NULL-safe、`{$not:{}}` 变零行、`$or` 的 `{}` 析取项不再被丢 (#5325)
430
+
431
+ `filter-normalizer.ts` 的 `buildNode` 是这个包里**第二份**同缺陷拷贝:第一份
432
+ (`read-scope-sql.ts` 的 `compileNode`,RLS 读作用域)已由 #5297 修好,而这一份编译的是
433
+ **作者自己写的 `where`** —— dashboard widget / dataset 的筛选器。两者是各自独立的函数,
434
+ 所以那一单合入后这三条仍然在。以 `driver-sql` 同一份 fixture 实测(4 行,行 3、4 的
435
+ `stage` 为 NULL,行 3 的 `amount` 为 NULL,行 4 的 `owner` 为 NULL):
436
+
437
+ | widget 的 `where` | 改前取到的行 | 改后(= driver-memory / formula / #5296 后的 driver-sql) |
438
+ | ------------------------------------------------- | ------------ | ------------------------------------------------------- |
439
+ | `{ $not: { stage: 'won' } }` | `2` | `2,3,4` |
440
+ | `{ $not: { stage: { $in: ['won'] } } }` | `2` | `2,3,4` |
441
+ | `{ $not: {} }` | **全表** | **零行** |
442
+ | `{ $or: [{ stage: 'won' }, {}] }` | `1` | 全表 |
443
+ | `{ $not: { $or: [{stage:'won'},{owner:'u1'}] } }` | `2` | `2,4` |
444
+
445
+ **这是可观察的行为变更,不是内部重构 —— 已有的图表数值会变:**
446
+
447
+ - **`{$not: {}}` 的 widget 此前画的是整个数据集,现在是零行。** `buildNode({})` 返回
448
+ `null`(= 无约束 = TRUE),`$not` 分支的 `if (inner)` 因此为假,整条 `$not` 消失,
449
+ WHERE 一个字都不发 —— 一条意思是「什么都不显示」的筛选器显示了全部。`NOT TRUE ≡ FALSE`,
450
+ 现在它编译成 `1 = 0`。
451
+ - **`$not` 下 NULL 行的去留变了,所以图上的数字会变。** SQL 是三值逻辑而 `WHERE` 只保留
452
+ TRUE,裸 `NOT (stage = ?)` 把 `stage` 为 NULL 的行全部丢掉;`driver-memory`、`formula`
453
+ 和(#5296 之后的)`driver-sql` 都把它们算进来。同一条 widget filter,在分析查询和普通
454
+ `find()` 上给出不同的行集,取决于哪个后端接住它。#5146 已拍板 JS 家族的答案为准,本次
455
+ 按同一口径把守卫**下推到叶子**(`{col: {$null: false}}` / `{$or: [{col:{$null:true}}, …]}`,
456
+ 极性逐算子决定)。**受影响的图表数值会上升**(负向筛选现在包含空值行)。
457
+ - **`$or` 里的 `{}` 析取项不再被丢。** TRUE 是 AND 的单位元但**吸收** OR,所以
458
+ `{$or: [{stage:'won'}, {}]}` 整条为 TRUE;此前它被 `.filter(n => n !== null)` 丢掉,
459
+ 查询被静默**收紧**成剩余分支。
460
+ - **空集合是布尔常量,不再是「没有谓词」。** `{stage: {$in: []}}` 此前编译成空子句
461
+ → 无约束 → 画全表,现在是零行(`1 = 0`);`{$nin: []}` 不排除任何行。
462
+ - **两处新的响亮拒收(此前静默放宽):** `$not` / `$or` / `$and` 的**非对象**操作数
463
+ (`{$not: null}` 曾整条消失 → 等于不筛),以及**零个操作符的字段约束** `{a: {}}`
464
+ —— 后者按 #5240 的拍板拒收,与 driver-sql / driver-memory / formula 一致;不这么做的话,
465
+ 「TRUE 吸收 OR」会把 `{$or: [{a: {}}, {b: 2}]}` 从 `b = 2` 放宽成全表。
466
+
467
+ 实现落在 normalizer 而不是某个 strategy:守卫在这一层是**结构**(多一个 `$null` 合取项),
468
+ 经 `filterNodeToCondition` 交给 ObjectQL 引擎后在**任何驱动上都成立**,包括本身不 NULL-safe
469
+ 的那些;只加在 raw-SQL 那条路径,等于说「分析查询的 `$not` 是什么意思取决于哪个驱动接住它」。
470
+ 代价是引擎路径会**双重加守卫**,已实测幂等(`NOT (c IS NOT NULL AND (c IS NOT NULL AND c = v))`
471
+ 与单层等价),只是 SQL 多一层冗余谓词。
472
+
473
+ `NormalizedFilterNode` 因此新增布尔常量 kind —— 该联合此前只有 `leaf | and | or | not`,
474
+ 没有 FALSE 的表示法,这正是 `{$not:{}}` 只能编译成「什么都不发」的根本原因。三个编译器
475
+ (`native-sql-strategy.compileFilterNode`、`objectql-strategy.filterNodeToCondition`、
476
+ 回显给浏览器的 `renderFilterNodeSql`)各自实现它;引擎路径用的是 `{$not: {}}`,即
477
+ driver-sql / formula / driver-memory 参考匹配器早已钉住的零行写法(#5134),没有另造第二种。
478
+
479
+ `$and: []` / `$or: []` 的空组合子**不在本次范围**,仍然 fail-closed 抛错(独立裁定见 #5322),
480
+ 并已加用例钉在抛错这一侧。
481
+
482
+ - 328ccc5: fix(security,analytics): scope /analytics/query to the caller's readable records, and refuse a measure over a missing field (#4467, #4437)
483
+
484
+ Two defects on the analytics query path, both found by the v17 verification run
485
+ (#3909 / #4482), both reproduced against a live showcase server before the fix
486
+ and re-verified with the same requests after.
487
+
488
+ ## #4467 — `/analytics/query` applied no record-level scoping
489
+
490
+ `ISecurityService.getReadFilter` documents itself as "the same filter the engine
491
+ middleware AND-s into every find", and exists precisely for paths that bypass
492
+ that middleware — its own doc comment names the analytics raw-SQL path. But the
493
+ chain it mirrors is TWO sibling middlewares: plugin-security's RLS injection and
494
+ plugin-sharing's owner/share visibility filter (`buildSharingMiddleware` AND-s
495
+ `buildReadFilter` into `ast.where` for `find`/`findOne`/`count`/`aggregate`).
496
+ Only the RLS half was ever computed here, and analytics has no other source of
497
+ scope, so the OWD/share predicate simply never existed on that path.
498
+
499
+ Live repro: `showcase_private_note` is `sharingModel: 'private'`; an admin owns
500
+ 5 notes, a member holds read shares on exactly 2 and no `viewAllRecords`.
501
+ `GET /data/showcase_private_note` correctly returned 2 for the member, while
502
+ `POST /analytics/query {measures:['count']}` returned 5 — and adding
503
+ `dimensions:['title']` returned all five titles, i.e. the VALUES of a column
504
+ that caller may not read, not merely a bad count. Any authenticated caller who
505
+ could reach `/analytics` could enumerate the field values of every row of any
506
+ object exposed as a cube, regardless of OWD, sharing rules, or RLS.
507
+
508
+ `getReadFilter` now resolves plugin-sharing's `buildReadFilter` through the
509
+ late-bound `sharing` service and AND-composes it with the RLS filter — the same
510
+ composition the two middlewares reach by both writing into `ast.where`. It also
511
+ computes the ADR-0057 D1 `__readScope` depth that the security middleware
512
+ normally stashes on the context for plugin-sharing to widen its owner-match
513
+ with, using the same `getEffectiveScope` call the middleware makes: no
514
+ middleware runs on this path, and without it a caller granted `unit`/`org` read
515
+ depth would be silently narrowed to `own`. The sharing predicate is resolved for
516
+ every non-system caller AHEAD of the RLS stand-down branches, because those are
517
+ the RLS middleware's own early exits and none of them is a reason to drop a
518
+ sibling middleware's predicate; a sharing-resolution failure denies outright
519
+ rather than falling through to half a scope.
520
+
521
+ **Why `minor` rather than `patch`.** This is an observable behaviour change on a
522
+ public read surface, in the narrowing direction: analytics results that a
523
+ principal could previously read they now cannot. Counts drop, `dimensions`
524
+ groupings lose rows, and any dashboard, report, or export built on
525
+ `/analytics/query` over an owner-private object will show smaller numbers for
526
+ non-superuser principals — correctly, but visibly. Deployments that had (however
527
+ unknowingly) come to depend on the unscoped totals will see them change on
528
+ upgrade, so this warrants more than a patch-level note even though it is a
529
+ security fix. No API signature changed: `ISecurityService.getReadFilter`'s
530
+ declaration is untouched — the implementation merely started honouring the
531
+ contract it already documented.
532
+
533
+ ## #4437 — a measure naming a missing field 500'd with SQLITE_ERROR
534
+
535
+ `inferMeasure('ghost_sum')` maps a suffix convention onto a field name and has
536
+ no way to know the field exists, so it built `SUM(ghost)`, the driver threw
537
+ `no such column`, and the caller got
538
+ `500 {"code":"SQLITE_ERROR","message":"Internal server error"}` — a driver error
539
+ class as the `error.code` for what is a plain typo, which ADR-0112 forbids. A
540
+ dotted spelling took the same path (`measures:['total.sum']` prefix-strips to
541
+ `sum` → `SUM(sum)` → 500). The DATA route has refused the identical mistake with
542
+ a `400 INVALID_FIELD` naming the field since #4315/#4254.
543
+
544
+ `AnalyticsService.ensureCube` now validates each measure's resolved source field
545
+ against the backing object's field names before any SQL is built, and rejects
546
+ with the same envelope the data route produces (`400 INVALID_FIELD` carrying
547
+ `field`, `object`, `param`, `measure`) so one mistake has one shape across
548
+ `/data` and `/analytics`. The new `getObjectFieldNames` config hook reads the
549
+ same schema registry `isRegisteredObject` already consults and the data path's
550
+ own gate reads, so "which fields exist" has a single answer across both routes.
551
+
552
+ The gate is tiered exactly like the #3867 cube-inference gate, deliberately
553
+ narrow: it applies only when the cube's `sql` is a bare object name (an authored
554
+ cube whose `sql` is a real SQL expression has no field list to check against),
555
+ only when the probe answers (no data engine, or an external datasource whose
556
+ columns are not mirrored locally, stands down), and only to measures whose
557
+ source is a bare column — `count(*)` has no source field, and a dotted
558
+ cross-object reference resolves through a join this layer cannot see, so both
559
+ pass through untouched. `id`/`created_at`/`updated_at` are admitted
560
+ unconditionally, matching the data path's `resolveQueryFields`: a gate stricter
561
+ than the engine it guards would reject queries that used to work. Validation
562
+ runs before the cube is registered, so a rejected query leaves no trace in the
563
+ registry — otherwise a retry would find a "registered" cube carrying the bogus
564
+ measure and sail straight into SQL.
565
+
566
+ This half is `minor` for the same envelope reason: a request that used to return
567
+ 500 now returns 400 with a different `code`, which is a visible contract change
568
+ for any caller branching on the response.
569
+
570
+ - 1f0e7cb: fix(service-analytics): reject a dataset's cross-datasource JOIN when it is compiled, not when it is queried (#5115)
571
+
572
+ #5033 routed a dataset's raw SQL to its base object's own datasource, which
573
+ turned a JOIN whose target lives in another database into a **loud query-time
574
+ failure** — correct, but late: the dataset can still be saved, published and
575
+ put on a dashboard, and the failure lands in front of whoever opens that
576
+ dashboard, usually in another environment on another day. It is a pure metadata
577
+ error, decidable the moment the dataset is compiled: the whole dataset is
578
+ lowered into ONE statement on the base object's datasource, so a join target
579
+ bound elsewhere is simply not there.
580
+
581
+ `compileDataset` now decides it. `AnalyticsService.registerDataset` — the single
582
+ door every dataset passes through, whether pre-registered at boot, saved, or
583
+ previewed as a Studio draft — hands the compiler the datasource and federation
584
+ probes that already existed on `AnalyticsServiceConfig`, and a proven conflict
585
+ is rejected before any SQL is built. The message names both objects, both
586
+ datasources, the offending `include` path, and the two ways out (bind both
587
+ objects to the same datasource, or drop the relationship), in the same wording
588
+ family as the #5033 query-time diagnostic so the two never read as two bugs.
589
+
590
+ **Who is affected.** This is a tightening: a dataset that used to compile and
591
+ then fail (or, before #5033, silently read the wrong database) now fails at
592
+ registration. It fires only where the metadata _proves_ the conflict — the base
593
+ object and a join target each declare an explicit `object.datasource` and the
594
+ two names differ. A dataset registered at boot is skipped with a WARN naming the
595
+ conflict, as before; the rest of the host's datasets still register.
596
+
597
+ **What is deliberately not rejected** ("cannot answer, do not block", the same
598
+ tiering as `isRegisteredObject` / `getObjectFieldNames`):
599
+
600
+ - a host that wires no datasource probe at all (no data engine) — compiles
601
+ exactly as it did before;
602
+ - either side leaving `datasource` at its default. `'default'` is the schema's
603
+ default _value_, not a routing decision: `ObjectQL.getDriver` short-circuits
604
+ only on an explicit non-`'default'` name, then falls through to
605
+ `datasourceMapping` rules, the ADR-0057 §3.6 lifecycle split
606
+ (audit/telemetry/event) and the owning package's `defaultDatasource` — none of
607
+ which are visible to the compiler. Treating `'default'` as "the primary DB"
608
+ would reject datasets whose objects a mapping rule in fact lands on the _same_
609
+ database;
610
+ - a federated (external) participant on either side. `NativeSQLStrategy` already
611
+ declines such a cube (ADR-0062 D6), so the query is served by the ObjectQL
612
+ FK-expand path, which crosses datasources by construction.
613
+
614
+ Everything not proven here keeps failing loudly at query time via #5033.
615
+ Making cross-datasource dashboards actually _work_ (declining in
616
+ `NativeSQLStrategy` and serving the join with two reads) is separate and not
617
+ part of this change.
618
+
619
+ - 6117f7b: fix(spec,service-analytics): a percentage measure carries its SCALE, so a ratio of 1 is 100% (objectui#3136)
620
+
621
+ A `%` format string says how to PRINT a number, not what scale that number is
622
+ on — and the two readings collide at exactly `1`, which is both "100%" (a 0–1
623
+ ratio at full compliance) and "1%" (a single percentage point). With nothing on
624
+ the wire to tell them apart, renderers guessed from the value's magnitude and
625
+ resolved the collision the wrong way: an SLA / pass-rate dashboard reporting
626
+ `sla_rate = 1` displayed **"1.0%"** — "everything met the SLA" read as "1% met
627
+ the SLA" — on both the KPI card and the dataset table.
628
+
629
+ The scale was never actually unknowable; it just never left the server. A
630
+ measure declaring `derived: { op: 'ratio' }` is a 0–1 fraction _by definition_,
631
+ and a measure aggregating a `percent` field has whatever scale that field
632
+ stores. Both facts sit in metadata the enrichment pass already reads for the
633
+ ADR-0053 currency chain — which walks back to the source field, checks
634
+ `type === 'currency'`, and rides the resolved code onto the result column.
635
+ Percentages got no such treatment. They do now, through the same seam.
636
+
637
+ **`percentScaleOf(field)` (`@objectstack/spec/data`)** is the one place the
638
+ question is answered. A `percent` field stores a FRACTION unless it declares
639
+ `max > 1` (e.g. `min: 0, max: 100`), which marks whole-percent storage — the
640
+ same rule the percent edit widget already writes by, so a value round-trips.
641
+ Non-`percent` fields get no opinion: a plain `number` an author formatted with
642
+ a `%` keeps meaning exactly what their format string says.
643
+
644
+ **`AnalyticsResult.fields[].percentScale`** carries the answer: `'fraction'`
645
+ (`1` ⇒ "100%") or `'whole'` (`1` ⇒ "1%"), absent when the column is not a
646
+ percentage. `queryDataset` sets it from the measure's `derived.op === 'ratio'`
647
+ first, then the source field's scale. `currency` — emitted since ADR-0053 but
648
+ only ever written through a cast — is now declared on the same interface.
649
+
650
+ The config seam `measureCurrency` is renamed **`sourceFieldMeta`** and returns
651
+ `max` alongside `type`/`defaultCurrency`. The old name had already outgrown
652
+ itself: the date-bucketing path reads `type` through it to tell a `date`
653
+ dimension from a `datetime` one, and the percent chain is its third consumer.
654
+
655
+ Renderers that receive `percentScale` must scale by it rather than inferring
656
+ from the value; one that does not receive it (an older server) keeps whatever
657
+ fallback it has, so this is additive on the wire.
658
+
659
+ **Same widget family, second fix: an empty filtered group is a measured zero.**
660
+ A measure-scoped filter can exclude every row of a group the grid still lists,
661
+ and the database reports that by omitting the group from the supplementary
662
+ result — after the merge, indistinguishable from "not measured". For a COUNT or
663
+ a SUM it _is_ measured: the answer is 0. `emptyGroupValueFor(aggregate)`
664
+ (`spec/data/aggregation-policy`) states which aggregates have an identity over
665
+ the empty set, and `queryDataset` fills it in once all supplementary merges are
666
+ done (a later measure's merge can append rows no earlier query saw). So
667
+ "0 of 12 paid" now reports `0` instead of blank, and a ratio built on it
668
+ computes to `0` instead of going null — the difference between a dashboard
669
+ saying "0% met the SLA" and saying nothing at all. `avg`/`min`/`max` keep their
670
+ null: there is nothing to average over an empty group, and flattening that to
671
+ zero would invent a measurement.
672
+
673
+ - 763931e: feat(filters): evaluate `{filter-token}` placeholders server-side (#3582)
674
+
675
+ Filter values travel as JSON, so a time- or user-scoped slice writes a
676
+ placeholder instead of code:
677
+
678
+ ```ts
679
+ filter: { close_date: { $gte: '{current_year_start}' }, owner: '{current_user_id}' }
680
+ ```
681
+
682
+ The vocabulary has been in `@objectstack/spec` for a while (`date-macros.zod.ts`,
683
+ `context-tokens.zod.ts`) and `objectstack build` rejects tokens outside it
684
+ (#3574). What was missing is the half that _substitutes a value_: **nothing on
685
+ the server ever did**. A placeholder reached the driver as the literal string
686
+ `'{current_year_start}'`, compared as text, and matched nothing.
687
+
688
+ That failure is invisible — an empty widget looks exactly like a metric that is
689
+ legitimately zero — so apps worked around it by computing dates at module load,
690
+ which freezes "this year" into the built artifact and quietly goes stale.
691
+
692
+ **New: `resolveFilterTokens()` in `@objectstack/core`**, wired into the two
693
+ server-side seams every filter passes through:
694
+
695
+ - **ObjectQL read path** — `find` / `findOne` / `count` / `aggregate`, so REST
696
+ queries, related lists, saved-view filters and flow `find_records` all resolve.
697
+ It runs before the middleware chain, so only author-supplied filters are
698
+ inspected; RLS/sharing filters are injected downstream from concrete values.
699
+ - **Analytics dataset executor** — a dataset's intrinsic `filter`, a widget's
700
+ `runtimeFilter`, measure-scoped filters, and time-dimension `dateRange`s.
701
+ This path needs its own call: `NativeSQLStrategy` compiles raw SQL and binds
702
+ comparands directly, so a dashboard widget never passes through `engine.find()`.
703
+
704
+ Behavioural notes:
705
+
706
+ - Date tokens resolve to ISO strings (`YYYY-MM-DD`, or a full timestamp for
707
+ `{now}` / `{N_hours_ago}` / `{N_minutes_ago}`). Turning that into a column's
708
+ on-disk form stays the driver's job (`SqlDriver.temporalFilterValue`), so
709
+ there is still exactly one source of truth for the storage convention.
710
+ - Calendar boundaries follow `ExecutionContext.timezone`; one instant is pinned
711
+ per filter tree, so a `>= {current_month_start}` / `< {next_month_start}` pair
712
+ can never straddle a boundary.
713
+ - `{current_org_id}` reads `ExecutionContext.tenantId`; `{current_user_id}` reads
714
+ `userId`. A request carrying neither now **throws** instead of resolving to
715
+ `null` — a null comparand degrades to `IS NULL` on most drivers and would hand
716
+ back the rows the filter was written to exclude.
717
+ - An unrecognised placeholder **throws**, carrying the near-miss fix
718
+ (`{current_user}` → `{current_user_id}`, `{this_quarter_start}` →
719
+ `{current_quarter_start}`). This matches what `objectstack build` already
720
+ enforces. Consequence, previously implicit and now load-bearing: a filter value
721
+ that is _entirely_ `{...}` is always read as a placeholder, so a literal value
722
+ of that shape is not expressible — rename the value.
723
+
724
+ Also in this change: `notify` no longer sends the six-character string
725
+ `"undefined"` as an audience member. `to: ['{record.owner.manager}']` walks
726
+ `.manager` on a scalar foreign-key id, resolves to nothing, and `String(undefined)`
727
+ turned that into a phantom recipient — the emit "succeeded", addressed nobody,
728
+ and said nothing. Unresolved recipients are now dropped, and a node with no
729
+ recipient left fails naming the offending template and pointing at the start
730
+ node's `config.expand` (#3475), which does hydrate the relation.
731
+
732
+ - 3f8817a: feat(spec,drivers,objectql,analytics,formula): `$icontains` reaches every JS evaluation face (#6520)
733
+
734
+ The other half of #5702. That change implemented `$icontains` on the SQL family
735
+ and correctly left the spec's `FILTER_OPERATORS` alone; this one adds the
736
+ operator to that array and gives every remaining evaluation face an arm, in ONE
737
+ change, because those two steps cannot be separated.
738
+
739
+ **Why one PR.** `FILTER_OPERATORS` is not a word list, it is a runtime allowlist:
740
+ `driver-memory`'s shape gate derives from it, and its matcher's `default:` arm
741
+ assumes the gate already refused anything unimplemented. Measured on a branch
742
+ that added the name early (#5701): the gate stopped refusing, the matcher fell
743
+ through, and `match({ name: 'zzz' }, { name: { $icontains: 'acme' } })` returned
744
+ `true` — the predicate silently dropped, every row matched. A dropped predicate
745
+ does not narrow a query, it WIDENS it, and on an RLS read scope that is a
746
+ permission bypass rather than a degraded feature (#3948). So the word list
747
+ travels with the evaluators or not at all.
748
+
749
+ **What now answers it**, all folding the same domain: `driver-memory` (query
750
+ path, reference matcher, and the analytics/cube face), `driver-mongodb`,
751
+ `objectql`'s `having`, `@objectstack/formula`'s `matchesFilterCondition` (the RLS
752
+ write-side `check`), and `service-analytics`' three SQL compilers (the RLS
753
+ lowering, the native-SQL strategy, and the `/analytics/sql` echo).
754
+
755
+ **The fold is ASCII-only, and that is the contract, not an implementation
756
+ detail** (#4706 Q1 = A). `$icontains: 'café'` does not match `CAFÉ`. Every face
757
+ reads one shared definition — `foldAsciiCase` /
758
+ `asciiCaseInsensitiveContains` / `asciiCaseInsensitiveRegexSource`, new exports
759
+ on `@objectstack/spec/data` — because the two obvious per-package spellings are
760
+ both wrong in the same direction: `toLowerCase()` folds the whole Unicode range,
761
+ and so does a `RegExp` built with the `i` flag. SQLite folds ASCII only and three
762
+ of the five drivers are SQLite underneath, so a Unicode fold on a JS face would
763
+ re-open exactly the divergence the ruling closed. The pattern-binding faces
764
+ (mingo, mongo) therefore emit one `[Aa]` character class per ASCII letter and
765
+ pass NO flags; mongo's `$icontains` is the one arm in its family that does not
766
+ set `$options: 'i'`.
767
+
768
+ The comparand keeps the rules its SQL twin has: matched LITERALLY (`%`, `_` and
769
+ regex metacharacters are ordinary characters), and refused when empty or
770
+ non-string — an empty comparand matches every row, which is a predicate that
771
+ constrains nothing.
772
+
773
+ **User-visible effect.** A filter using `$icontains` now behaves the same on the
774
+ in-memory double and on SQL, so an app whose tests run on one and whose
775
+ production runs the other stops getting two answers from one filter. Downstream,
776
+ #5814 (better-auth `Where.mode: 'insensitive'`) no longer hits a 400 on the
777
+ memory double.
778
+
779
+ Not changed, and still tracked: the `$contains` family still folds Unicode on
780
+ `driver-memory`'s query path and `driver-mongodb` (#6682) — both remain DEBT rows
781
+ in `scripts/check-driver-conformance.mjs`, now naming one open requirement each
782
+ instead of two. `formula`'s unknown-operator posture stays a silent, fail-closed
783
+ `false` (it governs a write-side check, where an unevaluable condition denies
784
+ rather than widens); the decision and its limits are documented on
785
+ `matches-filter.ts`, and no operator the spec DECLARES is answered that way any
786
+ more.
787
+
788
+ - 99ffc04: fix(analytics)!: a measure emits what it declares, instead of `COUNT(*)` (#4157)
789
+
790
+ `NativeSQLStrategy.resolveMeasureSql` answered `COUNT(*)` to three different
791
+ questions it could not otherwise answer — each time aliased under the name the
792
+ caller asked for, so the result looked like an answer:
793
+
794
+ 1. **A measure the cube does not declare.** `lookupMember`'s synthetic
795
+ relation fallback is dimension-only, so any undeclared or mistyped measure
796
+ name landed here. `measures: ['revenue']` against a cube without it returned
797
+ `COUNT(*) AS "revenue"` — a row count presented as revenue.
798
+ 2. **A `number`/`string`/`boolean` metric.** `AggregationMetricType` documents
799
+ these as _"Custom SQL expression returning a number / string / boolean"_: the
800
+ measure's `sql` **is** the computation — a ratio, a `CASE`, a window
801
+ function. The expression was discarded and replaced by a row count.
802
+ 3. **An unrecognised `type`.** Same silent substitution.
803
+
804
+ Now: an undeclared measure and an unrecognised type **throw**, naming the
805
+ declared measures and both accepted vocabularies respectively; a custom-
806
+ expression type emits its expression unwrapped. The six aggregates are
807
+ unchanged.
808
+
809
+ **A dot no longer implies a relationship hop.** `qualifyAndRegisterJoin` split
810
+ any dotted string into a join chain, so the expression `SUM(account.amount)`
811
+ became `"SUM(account"."amount)"` _plus_ a `LEFT JOIN "SUM(account"` — invalid
812
+ SQL naming a table that does not exist. Harmless only while the result was
813
+ being thrown away for `COUNT(*)`; emitting the expression makes it matter. A
814
+ dotted string is now treated as a path only when every segment is a bare
815
+ identifier, so `account.amount` still lowers to a qualified column and a join,
816
+ and an expression is emitted as written. That also fixes the same mangling for
817
+ an _aggregate_ measure whose `sql` is an expression — `type: 'sum'` with
818
+ `sql: 'SUM(account.amount)'` was producing the same garbage.
819
+
820
+ **Breaking, narrowly.** Two inputs that used to produce SQL now raise: a query
821
+ naming an undeclared measure, and a cube measure with a type outside
822
+ `AggregationMetricType`. Both were returning a wrong number rather than data,
823
+ so nothing correct can depend on them — but a caller that was silently getting
824
+ row counts will now see an error, which is the point. This is the trade #3948
825
+ settled for the drivers.
826
+
827
+ Datasets are unaffected: `aggregateToMetricType` only ever emits an
828
+ `AggregationFunction` member, so a compiled dataset never had a
829
+ custom-expression measure or an unknown type. The reachable path is a
830
+ hand-authored Cube.
831
+
832
+ `metric-type-coverage.test.ts` asserts the aggregate and expression sets
833
+ _partition_ `AggregationMetricType`, so a tenth metric type fails a test rather
834
+ than reaching the throw. Both sets are named, not derived as each other's
835
+ complement — deriving would classify a new _aggregate_ as an expression and emit
836
+ a bare column, a different silent wrong answer.
837
+
838
+ Verified: **460 tests across 35 files** green, including the four suites that
839
+ assert `COUNT(*)` — all of them use a _declared_ `type: 'count'` metric, so none
840
+ relied on a fallback. The 14 new tests were confirmed to fail against the old
841
+ behaviour (6 of 10 in the behaviour suite) before the fix.
842
+
843
+ - fc5f126: feat(analytics): serve in-envelope cross-object grouping on the ObjectQL path by FK-expand (#3654)
844
+
845
+ `engine.aggregate()` cannot join, so the ObjectQL fallback path (date-granularity
846
+ bucketing, in-memory driver, federated objects) previously REJECTED any
847
+ cross-object grouping like `revenue by account.region` (#3664 stopgap — a loud
848
+ error instead of the earlier silent `(null)` mis-bucket). It now SERVES the
849
+ common case directly.
850
+
851
+ For a single-hop cross-object DIMENSION with recombinable measures, the strategy:
852
+
853
+ 1. groups the base aggregate on the lookup FK column (`account`) — which the
854
+ engine can do — scoped to the base object;
855
+ 2. resolves each FK id to the related attribute (`region`) with a read of the
856
+ referenced object **scoped to that object's own RLS**; then
857
+ 3. re-buckets by the resolved attribute in memory, recombining the measures
858
+ (sum/count add; min/max take the extremum).
859
+
860
+ A base row whose referenced record the caller cannot read buckets under an
861
+ explicit `(restricted)` group: its measure still counts (grand totals are
862
+ preserved) but the hidden record's attribute never appears — no leak (ADR-0021
863
+ D-C, the #3602 class). `/analytics/sql` renders the equivalent `LEFT JOIN`.
864
+
865
+ Deliberately bounded — still REJECTED (loud, never silently wrong): cross-object
866
+ references in a MEASURE or FILTER (need a real join to evaluate), multi-hop
867
+ dimensions (`a.b.c`), and non-recombinable measures (`avg`, `count_distinct`)
868
+ with a cross-object dimension. Cross-object queries on `NativeSQLStrategy` (the
869
+ normal SQL path) are unchanged — it hand-compiles the joins.
870
+
871
+ - 3264516: fix(driver-sql,service-analytics)!: 两类无意义比较对象不再编译成「静默空谓词」——`$in`/`$nin` 的对象成员与 LIKE 族的对象比较值一律拒收 (#5234)
872
+
873
+ 两个形状此前都**编译通过、执行、并给出一个作者没写过的答案**,而且没有任何东西记录这件事:
874
+
875
+ | filter | 改前 | 改后 |
876
+ | ---------------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------- |
877
+ | `{status: {$in: ['a', {foo: 1}]}}` | 该成员绑不上任何行,查询答得**就像第二个成员从没被写过** | `INVALID_FILTER` / 400,点名 `index 1` |
878
+ | `{status: {$nin: [{foo: 1}]}}` | `NOT IN ('[object Object]')` —— **一行都没排除**,作者写下的排除悄悄没发生 | 同上 |
879
+ | `{name: {$contains: {}}}` | `LIKE '%[object Object]%'` —— 对一行文本恰好是 `[object Object]` 的记录,**真的命中了** | `INVALID_FILTER` / 400,点名 `StringOperatorSchema` |
880
+ | `{name: {$notContains: {}}}` | 反过来:为一个没人记录的理由**排除了一条真实记录** | 同上 |
881
+
882
+ #5041(PR #5223)在 `assertCompilableComparand` 的头注释里把这两个形状写为 "Deliberately NOT
883
+ extended",理由是它们 fail-closed(只收窄结果集)、比 #5041 实测的裸 `TypeError` 低一级。**实测下来这
884
+ 两条理由都不成立**:`$nin` / `$notContains` 方向是**放宽**(该排除的没排除,在 read-scope 下即 #5347 /
885
+ #5324 判过的 over-reach);而 `$contains: {}` 给的从来不是「零行」,是**错行**。
886
+
887
+ ## 三份实现一起动,否则修完仍是方言
888
+
889
+ 同一个 `String()` 宽容在本仓有多份;只收紧 `driver-sql` 会变成「哪个面接的就是哪个答案」——
890
+ #5146 / #5332 / #5567 各花一轮消掉的那类分叉。守卫因此落在**每个包自己的收口点**,而不是三个发射器:
891
+
892
+ - **`driver-sql`** —— `assertCompilableComparand`,#5041 已有的那一个门。
893
+ - **`service-analytics` 的 `where` 门** —— `filter-normalizer.ts` 的 `fieldLeaves`。它是本包**唯一**的
894
+ leaf 生产者,所以一处拒收同时覆盖三个消费方:`NativeSQLStrategy`(真正执行的语句)、
895
+ `ObjectQLStrategy.generateSql`(`/analytics/sql` 回显)与 `ObjectQLStrategy.convertFilter`(引擎路径)。
896
+ 这个顺序是关键而非顺手:`convertFilter` 是**生产者**,在那里 `String()` 会把对象洗成一个类型完全正确
897
+ 的 `'[object Object]'` 字符串交给驱动,下游再严格的驱动也永远看不到它该严格的那个形状。
898
+ - **`service-analytics` 的 read-scope 门** —— `read-scope-sql.ts` 的 `compileOperator`,它编译的
899
+ `FilterCondition` 不经过上面那个门。
900
+
901
+ `like-pattern.ts` 与 `applyLike` 里的 `String(value)` **原样保留**:它们不再是缺陷所在,因为门前已经没有
902
+ 渲染不出来的值能到达。两包的谓词由 `like-metacharacter-escape.test.ts` 逐值互锁——正是该文件已经用来锁
903
+ 转义表达式的同一套办法。
904
+
905
+ ## 围栏是 allow-list,而且每一条都是实测后决定的
906
+
907
+ 抄 `driver-turso` `RemoteTransport` 的形状(cloud#1004 / #1058):deny-list 会把下一个被发明出来的值形状
908
+ 悄悄放进来,这正是那个 bug 熬过第一次修复的原因。顺带说明,**turso 自 #1058 起就已经拒收这两个形状**,
909
+ 所以本地 SQLite 与远程 SQLite 此前对同一条查询给的是不同答案;本次改动把它们收敛到一起。
910
+
911
+ 留在围栏内的(逐条实测,不是假设):
912
+
913
+ - **数字 / 布尔 / `null`**:`{$contains: 5}` → `%5%`、`{$contains: null}` → `%null%` 在 `driver-sql`、
914
+ `driver-memory` 与 analytics 两个面上**今天答案一致**,#5526 还专门把 `null` 这条钉住了。拒收它们是在
915
+ **破坏**一致,不是建立一致——所以只拒**对象**。
916
+ - **`Date`**:turso 的 allow-list 把它作为唯一的对象转换保留,拒收会重新叉开本地与远程。
917
+ - **binary**:`$in` 成员照收(`isBindableComparand` 与写路径 `formatInput` 同一套分类),LIKE 拒收——它
918
+ 绑得上但渲染不出作者想要的东西。这就是两个谓词而不是一个带 flag 的原因。
919
+ - **`undefined`**:不可授权(JSON 没有 `undefined`),analytics 门按 #5526 / #5332 归一为 `null` 而非拒收;
920
+ 在 `driver-sql` 拒收它会**造出**一个分歧而不是消除一个,故照旧。
921
+
922
+ 被拒的**数组**是本次唯一一个「拒收即消分叉」的形状:`{name: {$contains: ['al','be']}}` 在 `read-scope-sql`
923
+ (与 `driver-sql`)绑 `%al,be%`,在 analytics 的 `where` 门却绑 `%al%`(它读 `values[0]`,后面的成员被
924
+ 静默丢弃)。同一个包对同一条 filter 有两个答案,两个门现在都拒。
925
+
926
+ ## 作者需要知道的迁移
927
+
928
+ 这两个形状本来就没有能用的读法——`filter.zod.ts` 的 `StringOperatorSchema` 早就把 LIKE 族比较数声明为
929
+ `z.string()`,本次只是让声明变成强制(Prime Directive #12,declared = enforced)。改后它们答 400 而不是
930
+ 一个错答案;把比较数换成字面值即可。`{$eq: {…}}` **不在本次范围**,仍按 `toSqlBindValue` 绑 JSON(#5526
931
+ 钉住的行为)。
932
+
933
+ ### Patch Changes
934
+
935
+ - c7f4417: fix(driver-sql,analytics): stop `aggregate()` / `distinct()` leaking SQLite's raw epoch storage (#3797)
936
+
937
+ Both returned `await builder` directly, without the `formatOutput` pass every
938
+ `find()` row gets. On SQLite — the one dialect where a `Field.datetime` is
939
+ stored as INTEGER epoch milliseconds rather than a native timestamp — that raw
940
+ storage form went straight to the caller:
941
+
942
+ | call | before | after |
943
+ | -------------------------------------- | ---------------------------- | -------------------------------- |
944
+ | `find()` | `"2026-01-10T09:00:00.000Z"` | unchanged |
945
+ | `distinct('closed_at')` | `[1768035600000]` | `["2026-01-10T09:00:00.000Z"]` |
946
+ | `aggregate()` `max(closed_at)` | `1768035600000` | `"2026-01-10T09:00:00.000Z"` |
947
+ | `aggregate()` `groupBy: ['closed_at']` | key `1768035600000` | key `"2026-01-10T09:00:00.000Z"` |
948
+
949
+ Same root cause as #3773, different exit. `Field.date` was never affected — it
950
+ is ISO TEXT on every dialect, so its storage form already equals its
951
+ presentation.
952
+
953
+ The visible surfaces were a `_max`/`_min` measure over a datetime (a "last
954
+ closed" KPI tile rendered `1768035600000`) and a `groupBy` on a raw datetime
955
+ dimension, which also disagreed with the in-memory `applyInMemoryAggregation`
956
+ fallback — that one consumes already-formatted `find()` rows, so the same
957
+ dataset changed key type depending on which path served it.
958
+
959
+ Which columns hold an instant is now recorded while the statement is built,
960
+ because that is the only point where a column name and its meaning are both
961
+ known: a `min()` lands under its alias and never under the field name, while a
962
+ date-BUCKETED column lands under the field name but holds a label (`'2026-01'`)
963
+ rather than an instant. Matching on names afterwards gets both backwards.
964
+
965
+ `distinct()` additionally re-deduplicates after presenting: SQL `DISTINCT`
966
+ compares STORED values, and one SQLite datetime column holds both INTEGER and
967
+ TEXT forms, so two rows recording the same instant survived as two and then
968
+ presented identically. It has no in-repo callers today; this keeps it honest
969
+ rather than leaving a second convention in the driver.
970
+
971
+ **`cross-object-rebucket` was fixed alongside it, because presenting min/max
972
+ correctly is what exposed it.** `recombine()` coerced every operand with
973
+ `Number()`, which silently depended on receiving an epoch: handed the ISO string
974
+ the driver now returns it produced `NaN`, and on Postgres/MySQL (where knex
975
+ returns a `Date`) it had always flattened the value back to an epoch integer one
976
+ layer above the driver. `min`/`max` now order by the instant and return the
977
+ winning value in the shape it arrived in; `sum`/`count` stay numeric.
978
+
979
+ - 259459d: refactor(spec)!: retire `array_agg` / `string_agg` from `AggregationFunction` — `count_distinct` deliberately kept (#6188, ADR-0049)
980
+
981
+ `AggregationFunction` declared eight functions; the SQL family compiles five.
982
+ `SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` each lower
983
+ `count`/`sum`/`avg`/`min`/`max` and route everything else to one refusal, so
984
+ three of the eight were declared-but-unenforced against the backends this
985
+ platform targets — and, worse, the _set_ each backend implemented was different,
986
+ so "which aggregations can I use" had no answer an author could read off the
987
+ schema.
988
+
989
+ What makes these two sharper than an ordinary inert declaration is that another
990
+ package had to carry a denylist for them. `service-analytics` subtracted
991
+ `array_agg` and `string_agg` by name in `UNSUPPORTED_AGGREGATES`, because
992
+ without that subtraction they reached the Cube strategy's `default` and came
993
+ back as `COUNT(*)` — **a row count in place of the value the author asked for**,
994
+ with no error and no log (objectui#2945).
995
+
996
+ **The three unlowered functions were SPLIT, not retired as a block** (maintainer
997
+ ruling, 2026-08-07):
998
+
999
+ - **`count_distinct` STAYS** and takes ADR-0049's _enforce_ leg. It is a
1000
+ dashboard staple with one portable lowering (`COUNT(DISTINCT x)`), and
1001
+ `service-analytics` lowers it already; the SQL-driver implementation follows
1002
+ on its own card. Its declaration leads its implementation here by decision,
1003
+ not by drift.
1004
+ - **`array_agg` / `string_agg` take the _remove_ leg.** Display conveniences
1005
+ with no measured pull, and `string_agg` never had one shape to lower to at
1006
+ all: the delimiter is a second argument in PostgreSQL, a `SEPARATOR` clause in
1007
+ MySQL and a differently named function in SQL Server.
1008
+
1009
+ FROM → TO, both authoring surfaces:
1010
+
1011
+ | Was | Now |
1012
+ | :-------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- |
1013
+ | `aggregations: [{ function: 'array_agg', field: 'tag', alias: 'tags' }]` | no replacement — read the rows with an ordinary `fields` query and shape them in the caller, or materialise the roll-up as a stored field |
1014
+ | `aggregations: [{ function: 'string_agg', field: 'name', alias: 'names' }]` | as above |
1015
+ | `measures: [{ name: 'tags', aggregate: 'array_agg', field: 'tag' }]` | delete the measure — `compileDataset` already refused it by name, so it never produced a number |
1016
+
1017
+ The retirement kit:
1018
+
1019
+ - This is an enum **VALUE** retirement, so there is no `retiredKey()` tombstone:
1020
+ the enum's own error map carries the prescription, keyed on the received value
1021
+ so that only the two spellings which used to be legal are told they "were
1022
+ removed" (the `crypto.hash` / `HookBodyCapability` precedent, #4391). A
1023
+ mis-spelling still gets zod's list of the legal functions. For the same reason
1024
+ nothing lands in `RETIRED_KEYS_BY_MAJOR` and the four surface ratchets are
1025
+ byte-identical — no def and no authorable key changed.
1026
+ - **ADR-0087 D2 conversion + D3 chain step**
1027
+ (`dataset-measure-array-string-agg-removed`): `os migrate meta --from 16`
1028
+ drops any `dataset.measures[]` declaring a retired aggregate, plus any derived
1029
+ measure the drop strands, with a notice each. The measure is dropped rather
1030
+ than stripped down because one with neither `aggregate` nor `derived` fails
1031
+ the dataset's own refinement — a conversion whose output cannot parse is worse
1032
+ than none.
1033
+ - **D3 semantic entry** (`query-array-string-agg-retired`) for
1034
+ `QueryAST.aggregations[].function`: a request surface, never stored, so there
1035
+ is no source for the chain to rewrite and callers move their own queries.
1036
+ - The engine's in-memory fallback (`@objectstack/objectql`) drops its arms for
1037
+ both functions — a `switch` case on a value the enum no longer has does not
1038
+ type-check, and a dead arm is how a retired vocabulary returns by accident.
1039
+ - `service-analytics`' `UNSUPPORTED_AGGREGATES` is now **empty and kept**: it is
1040
+ half of an arithmetic the lockstep suite enforces (`SUPPORTED = spec
1041
+ vocabulary − this`), which is what stops the next aggregate added to the spec
1042
+ from silently reaching that `COUNT(*)` default.
1043
+
1044
+ **Behaviour that actually changes** — this is the rare narrowing that removes
1045
+ reachable behaviour, and it is worth stating plainly: on `driver-mongodb` and on
1046
+ the engine's in-memory fallback these two DID compute. A raw QueryAST
1047
+ aggregation against those backends returned an array or a joined string and will
1048
+ now be refused at parse. That unpredictability is precisely what the ruling
1049
+ ended — an aggregation that worked on one backend and failed on another is not a
1050
+ capability — and both of those backends are inside the #5499 freeze. Their code
1051
+ is untouched; it is simply no longer reachable through a spec-valid request. On
1052
+ the dataset path nothing changes: `compileDataset` refused both by name already.
1053
+
1054
+ <!-- adr-0087: registered query-array-string-agg-retired, dataset-measure-array-string-agg-removed -->
1055
+
1056
+ - b4be309: fix(analytics): a new spec aggregate can no longer silently return a row count
1057
+
1058
+ Track C item 4 of objectstack-ai/objectui#2945 — _"`AggregationFunction`: three
1059
+ places in lockstep"_. They agreed only by coincidence, and the failure mode when
1060
+ they stopped agreeing was silent wrong numbers.
1061
+
1062
+ The three:
1063
+
1064
+ 1. `AggregationFunction` (`@objectstack/spec/data`) — eight members, what an
1065
+ author may declare as a dataset measure's `aggregate`.
1066
+ 2. `UNSUPPORTED_AGGREGATES` (`dataset-compiler.ts`) — `array_agg`/`string_agg`,
1067
+ rejected at compile time with a clear error.
1068
+ 3. The aggregate `switch` in `native-sql-strategy.ts` — six cases, then
1069
+ `default: return 'COUNT(*)'`.
1070
+
1071
+ 8 − 2 = 6 = the six cases, today. Add a ninth member to the spec — `median`,
1072
+ `percentile`, anything — and it would:
1073
+
1074
+ - pass the compiler's gate, since it is not in `UNSUPPORTED_AGGREGATES`;
1075
+ - be **advertised as supported** by that gate's error message, which listed
1076
+ `count, sum, avg, min, max, count_distinct` as hand-written prose — a third
1077
+ copy of the vocabulary;
1078
+ - reach the strategy's `switch`, match no case, and fall to
1079
+ `default: COUNT(*)`.
1080
+
1081
+ The author asks for a median and gets a row count. No error, no log, wrong
1082
+ figures on a dashboard — the same silent-wrong-answer shape as the filter
1083
+ operators in #3948, in the analytics SQL builder.
1084
+
1085
+ **The fix is derivation plus a guard, with no behaviour change.** The `switch`
1086
+ becomes `AGGREGATE_SQL`, a table whose coverage is assertable; the error
1087
+ message's prose list becomes `SUPPORTED_AGGREGATES`, derived as
1088
+ `AggregationFunction.options` minus `UNSUPPORTED_AGGREGATES`; and
1089
+ `aggregation-lockstep.test.ts` asserts the arithmetic — the lowered set equals
1090
+ the admitted set, every spec member is either lowered or explicitly rejected,
1091
+ nothing is both, and the rejection list names only aggregates the spec has.
1092
+
1093
+ Verified by adding a hypothetical `median` to the spec, which now fails three
1094
+ assertions naming it, including _"these would fall through to the COUNT(_)
1095
+ fallback and return a row count"\*. Before this change the same edit was green.
1096
+
1097
+ Nothing is narrowed and no SQL changes: the same six aggregates lower to the
1098
+ same six expressions, and the `COUNT(*)` fallback still catches everything else.
1099
+
1100
+ **Reported, not fixed:** that fallback is also reached by a measure whose `type`
1101
+ is `number`/`string`/`boolean` — a custom SQL _expression_, per
1102
+ `AggregationMetricType` — whose expression is then replaced by a row count.
1103
+ Datasets cannot produce one (`aggregateToMetricType` only ever returns an
1104
+ `AggregationFunction` member), so it is reachable only from a hand-authored
1105
+ Cube. Emitting `col` instead is a behavioural change in an analytics SQL path
1106
+ and deserves its own change with its own tests; the strategy's doc comment now
1107
+ records it.
1108
+
1109
+ - 7a55913: fix(service-analytics): a `$between` analytics filter no longer vanishes from the query (ADR-0053 D-A3.1)
1110
+
1111
+ A dashboard widget or dataset whose filter used `$between` was querying **every
1112
+ row**. `normalizeAnalyticsFilters` maps Mongo-style operators onto the internal
1113
+ pipeline form, `$between` was missing from that map, and an unmapped operator is
1114
+ skipped — so the predicate was silently dropped from the compiled WHERE clause.
1115
+ Both strategies read that normalizer, so both the raw-SQL and the ObjectQL
1116
+ aggregate paths were affected. The symptom is #3650's: a chart that draws the
1117
+ whole dataset instead of the requested window, with nothing in the SQL to
1118
+ suggest a filter was ever asked for.
1119
+
1120
+ `$between [min, max]` now lowers to its two bounds (`gte` + `lte`) instead of
1121
+ gaining an operator of its own, so a range's max inherits the calendar-day
1122
+ whole-day rule (#3777) from each strategy's existing upper-bound handling —
1123
+ `NativeSQLStrategy` compiles a bare-day upper bound half-open itself, and the
1124
+ ObjectQL path gets the same rule from the driver — rather than needing a second
1125
+ implementation to keep in step. A malformed `$between` (not a two-element
1126
+ array) now throws instead of being dropped, matching the stance driver-memory
1127
+ took for the same shape in #3948: an unbounded read is exactly the failure this
1128
+ prevents, and it is indistinguishable from a legitimately wide query.
1129
+
1130
+ Found by giving the temporal conformance matrix its missing sixth consumer
1131
+ (`native-sql-temporal-conformance.test.ts`), which executes the shared cases
1132
+ against a real SQLite engine and asserts row ids — a dropped predicate is
1133
+ invisible to the SQL-string assertions the strategy's other suites use.
1134
+
1135
+ - c637387: fix(service-analytics): only a canonical numeric spelling is recovered as a number, so `'007'` / `'1.50'` stay strings (#5528)
1136
+
1137
+ An analytics `where` round-trips every comparand through the internal
1138
+ `values: string[]` form — `stringifyForCube` on the way out, and
1139
+ `coerceFilterValueForSql` / `coerceFilterValueForObjectQL` on the way back. The
1140
+ decoder decided "this is a number" from the string's **shape** alone
1141
+ (`/^-?\d+(\.\d+)?$/`), which cannot distinguish a number that was stringified on
1142
+ the way out from a string the author actually wrote.
1143
+
1144
+ Measured before the fix, on cube `orders` / TEXT column `code`:
1145
+
1146
+ | author's `where` | leaf `values` | SQL bind | engine comparand |
1147
+ | ----------------------- | ------------- | -------- | ---------------- |
1148
+ | `{code: {$eq: '007'}}` | `["007"]` | `7` | `7` |
1149
+ | `{code: {$eq: '0912'}}` | `["0912"]` | `912` | `912` |
1150
+ | `{code: {$eq: '1.50'}}` | `["1.50"]` | `1.5` | `1.5` |
1151
+
1152
+ Both consumers were affected: the raw-SQL bind in `NativeSQLStrategy` and the
1153
+ comparand handed to the ObjectQL aggregate engine.
1154
+
1155
+ The failure was **silent and mis-targeted, not empty**. Against a text column
1156
+ SQLite applies the column's affinity to the integer bind, so a widget filtered on
1157
+ order number `'007'` returned the row storing `'7'` — a different row, with no
1158
+ error to read; on Postgres the same query is a `text = integer` type error, and on
1159
+ the engine path the strict comparison simply matched nothing (measured: 0 rows).
1160
+ Zero-padded and trailing-zero strings are ordinary business shapes — order
1161
+ numbers, work orders, SKUs, dialling codes, postcodes, `'1.50'` prices.
1162
+
1163
+ Recovery is now limited to a number's **own canonical spelling**
1164
+ (`String(Number(s)) === s`):
1165
+
1166
+ - a comparand that really was a number is `String(n)` by construction, so it
1167
+ still round-trips — `7` → `'7'` → `7`, `1.5` → `'1.5'` → `1.5`, `-3` → `-3`;
1168
+ - a string `Number()` would rewrite — `'007'`, `'0912'`, `'1.50'`, `'1.0'`,
1169
+ `'-0'`, or more digits than a double holds — cannot have come from a number, so
1170
+ it stays the string the author wrote.
1171
+
1172
+ The narrowing can only ever **remove** recoveries: the shape regex still runs
1173
+ first, so `'1e3'`, `'1e+21'`, `'+7'`, `' 7'`, `'0x10'`, `'Infinity'` and `'NaN'`
1174
+ were strings before this change and are strings after it. This also aligns with
1175
+ ADR-0053 D-A2, which demoted this textual type re-derivation to a last resort
1176
+ behind the driver-backed `coerceTemporalFilterValue` hook.
1177
+
1178
+ **Stopgap, and named as one.** `values: string[]` still has no escape, so the
1179
+ author strings `'null'` / `'true'` / `'false'` still collide with the tokens the
1180
+ encoder writes for the real `null` and booleans. Making the round trip lossless —
1181
+ tagged values, or an `unknown[]` internal representation — is #5526; the
1182
+ collision is pinned as unchanged in
1183
+ `src/__tests__/filter-value-canonical-number.test.ts` so it is not mistaken for
1184
+ fixed.
1185
+
1186
+ - 2f05139: fix(service-analytics): `compareTo` applies measure-scoped filters, so `<measure>__compare` is the same measure as the column beside it (#4820)
1187
+
1188
+ A dataset measure declared with its own `filter` is scoped by running a
1189
+ supplementary grouped sub-query — `combineFilters(baseFilter, measureFilters[m])`
1190
+ — and merging it back by dimension key. The `compareTo` pass did not: it issued
1191
+ **one** shifted query over every base measure with only the base filter as its
1192
+ `where`, and never consulted `compiled.measureFilters` at all.
1193
+
1194
+ For a dataset like
1195
+
1196
+ ```ts
1197
+ measures: [
1198
+ { name: "revenue", aggregate: "sum", field: "amount" },
1199
+ { name: "won_count", aggregate: "count", filter: { stage: "closed_won" } },
1200
+ ];
1201
+ ```
1202
+
1203
+ the current-period column was scoped and the comparison column was not — two
1204
+ different measures rendered side by side under one label:
1205
+
1206
+ | # | measures | where | |
1207
+ | :-- | :--------------------- | :----------------------- | :------ |
1208
+ | 1 | `revenue` | — | current |
1209
+ | 2 | `won_count` | `{"stage":"closed_won"}` | current |
1210
+ | 3 | `revenue`, `won_count` | **absent** | shifted |
1211
+
1212
+ `won_count__compare` was therefore a count of **every** opportunity in the
1213
+ previous window, inflated by exactly the rows the measure exists to exclude.
1214
+ The error runs one way: the comparison period always looks better, so a "won
1215
+ deals vs. last month" tile reads as a collapse when nothing went wrong. Only
1216
+ filter-scoped measures were affected — the unfiltered ones next to them compared
1217
+ correctly, which is what made it survive.
1218
+
1219
+ The comparison window now runs the **same pass** as the current period —
1220
+ unfiltered measures in one shifted query plus one shifted sub-query per
1221
+ filter-scoped measure, merged by dimension key — through a single shared
1222
+ implementation, so the two paths cannot re-diverge at the next change. The
1223
+ dataset filter, the presentation's `runtimeFilter` and the measure's own filter
1224
+ compose identically in both windows; the only difference between them is the
1225
+ shifted `dateRange`.
1226
+
1227
+ Numbers reported by existing dashboards change where a filtered measure was
1228
+ compared: with 3 won deals this month against 1 won of 5 opportunities last
1229
+ month, `won_count__compare` was `5` and is now `1`.
1230
+
1231
+ Cost: one extra query per filter-scoped measure when `compareTo` is set.
1232
+ Selections whose measures carry no filter are untouched and still compare in a
1233
+ single shifted query.
1234
+
1235
+ The empty-group fill (#4708) covers the new seam: a group the measure's filter
1236
+ empties in the _previous_ window now reports `0` for a `count`/`sum` compare
1237
+ column rather than blanking it, exactly as it already did for the current period.
1238
+
1239
+ - c113690: fix(service-analytics): `contains` 以规范算子 `$contains` 送进引擎,比较值不再落进正则位置(#5557)
1240
+
1241
+ `ObjectQLStrategy.convertFilter` 在同一个 `switch` 里处理 LIKE 家族的四个算子。
1242
+ 其中三个(`notContains` / `startsWith` / `endsWith`)自 #4128 起就是规范 spec 算子,
1243
+ 只有 `contains` 是 `{ $regex: values[0] }` —— 比较值**原样**放进一个正则位置,不转义。
1244
+
1245
+ 实测(修复前 → 修复后,引擎收到的 filter):
1246
+
1247
+ | `where` | 修复前 | 修复后 |
1248
+ | -------------------------------- | -------------------------------- | ----------------------------- |
1249
+ | `{stage: {$contains: 'a.b'}}` | `{stage: {$regex: 'a.b'}}` | `{stage: {$contains: 'a.b'}}` |
1250
+ | `{stage: {$notContains: 'a.b'}}` | `{stage: {$notContains: 'a.b'}}` | 不变 |
1251
+ | `{stage: {$startsWith: 'a.b'}}` | `{stage: {$startsWith: 'a.b'}}` | 不变 |
1252
+ | `{stage: {$endsWith: 'a.b'}}` | `{stage: {$endsWith: 'a.b'}}` | 不变 |
1253
+
1254
+ 三条后果,都是作者没有要求过的行为,且都不依赖 #4706 对 `$regex` 语义的裁决:
1255
+
1256
+ 1. **`$regex` 不在契约里。** `filter.zod.ts` 的 `FILTER_OPERATORS` 声明 15 个算子,
1257
+ 没有 `$regex` —— 这是**生产方**在发送 schema 未声明的算子。按 Prime Directive #12
1258
+ 修生产方(一个 `case` 标签),而不是给消费方加宽容。
1259
+ 2. **同一棵过滤树在同包两个消费方之间不通。** `read-scope-sql.ts` 的
1260
+ `compileScopedFilterToSql` 也是一个 `FilterCondition` 消费方,`compileOperator`
1261
+ 的 `default` 是 fail-closed,于是它对本策略产出的 filter 直接抛
1262
+ `unsupported operator "$regex" … (fail-closed)`。
1263
+ 3. **行结果取决于哪个驱动来答。** 把 `$regex` 当真正则求值的后端(driver-memory 的
1264
+ `memory-matcher.ts` 就是,而且是有意为之 —— 服务 plugin-auth 的 ObjectQL adapter)
1265
+ 把 `a.b` 读成「a、任意一个字符、b」,于是 `axb` 也被匹配上;而 `50% (+)` 作为正则
1266
+ 根本编译不过(`Nothing to repeat`),`catch` 之后 `return false` —— 一个**有匹配行**
1267
+ 的筛选器静默返回零行,作者那边只看到「无数据」。同一个 `$contains` widget 在
1268
+ `driver-sql` 上则被编译成子串 LIKE:同一张 dashboard,不同驱动,不同行集。
1269
+
1270
+ `filter-normalizer.ts` 的 `MONGO_TO_CUBE_OP` 只把 `$contains` 映到 `contains`,
1271
+ 别无来源,所以这里回送 `$contains` 就是作者自己那个 key 的往返。
1272
+
1273
+ **测试**(`objectql-contains-canonical-operator.test.ts`,新增):引擎 filter 的算子键
1274
+ 逐个对 `filter.zod.ts` 的 `ALL_OPERATORS` 校验(取自 spec 而非手抄一份);行结果跑在一个
1275
+ 复刻 `memory-matcher.ts` 各 arm 的求值面上 —— `a.b` 只命中字面行、`50% (+)` 命中它该
1276
+ 命中的那一行且**恰好**只有那一行(修复前分别是多一行和空集);同一个 filter 再送进
1277
+ `compileScopedFilterToSql` 确认它现在编译得过。只断言 filter/SQL 字符串会漏掉「不转义」
1278
+ 这一半,所以两半都断言。
1279
+
1280
+ 顺带删掉 #5558(PR for #5333)在 `objectql-echo-operator-coverage.test.ts` 的替身引擎里
1281
+ 留下的那处 `$regex` → `$contains` 翻译:它存在的理由就是本单,现在没有了。那也是本修复
1282
+ 最直接的反向证据 —— 把 `case 'contains'` 退回 `$regex`,该文件的 `$contains` 行会以
1283
+ 上面第 2 条的 fail-closed 报错红掉。
1284
+
1285
+ - 705efeb: fix(analytics): a dataset refusal that declares an ADR-0112 envelope is never degraded to an empty result (#5717)
1286
+
1287
+ `queryDataset` wraps execution in a catch that exists for one deliberate reason
1288
+ (#5033): a widget whose backing object is not mounted in this kernel renders
1289
+ "no data" instead of failing with a 500. The criterion for "not mounted" was
1290
+ `isMissingSourceError` — a substring match over the error MESSAGE. So the
1291
+ leniency was available to any error that happened to phrase itself like a
1292
+ driver, and #5352 / #5367's finding on the REST face — "the wire shape of an
1293
+ error family must not be a property of its wording" — applied here one level
1294
+ worse: the outcome was not a wrong status code but a **silent empty result**.
1295
+ No exception, no 4xx, no 5xx; one `warn` line and a confident empty chart, which
1296
+ is the "populated table, Total Spend: 0" symptom #5033 was filed about.
1297
+
1298
+ One refusal already matched. `dataset-compiler.ts` refuses an `include` naming a
1299
+ relationship the object graph does not have with
1300
+
1301
+ > `[dataset-compiler] dataset "X" includes relationship "R" which does not exist on object "O".`
1302
+
1303
+ which carries both `relation` (inside "relationship") and `does not exist` — and
1304
+ that conjunction was the postgres limb. It has never gone off for one reason:
1305
+ `queryDataset` compiles **before** the try, so that throw has never been inside
1306
+ the catch's reach. A mine, wired and unarmed.
1307
+
1308
+ **Two independent defences, so the disarming does not depend on either one.**
1309
+
1310
+ - **The criterion (main change).** An error carrying an ADR-0112 envelope —
1311
+ numeric `status` + non-empty `code`, the same structural fact
1312
+ `rest-server.ts`'s `/analytics/dataset/query` catch reads — is re-thrown
1313
+ untouched, ahead of any message inspection. Its producer already answered the
1314
+ classification question. The status RANGE is deliberately not part of the
1315
+ test: a `DATASET_INVALID` / 400 rendered as an empty grid is the loud case,
1316
+ but a declared 5xx (`READ_SCOPE_COMPILE_FAILED` — an RLS lowering that failed
1317
+ closed) is if anything worse to swallow, since nobody is told at all.
1318
+ - **The sniffer.** Its postgres limb is now anchored to postgres's actual
1319
+ wording (`relation "x" does not exist`) instead of "any sentence containing
1320
+ both words" — the same pattern the sibling `missingSourceRelation` already
1321
+ used, so "is something missing" and "what is missing" can no longer disagree.
1322
+
1323
+ **Observable behaviour change — read this if you alert on empty widgets.** The
1324
+ guarantee is new, not the status of any shipped message: measured over the 13
1325
+ real wordings this repo carries (three driver families including sql-prefixed
1326
+ and schema-qualified forms, the framework's not-registered signals, and this
1327
+ package's own refusals), exactly one verdict moves — the compiler refusal above,
1328
+ which reaches callers as `400 DATASET_INVALID` either way because its throw site
1329
+ sits outside the try. What changes is that a caller-shaped refusal raised
1330
+ **during execution** can no longer become `{rows: [], fields: [], totals: []}`
1331
+ by phrasing alone: it now propagates and the route answers its declared code
1332
+ (4xx as itself, declared 5xx through `ANALYTICS_QUERY_FAILED`). A dashboard that
1333
+ silently rendered an empty chart for such a refusal will now surface the error.
1334
+
1335
+ **#5033's leniency is untouched, and that is asserted rather than claimed.** A
1336
+ bare driver error is still classified by its words and still degrades: `no such
1337
+ table` (sqlite/libsql), postgres's real `relation "x" does not exist`, mysql's
1338
+ `doesn't exist`, the framework's not-registered signals — and a bare error
1339
+ naming a JOINED table still fails loudly as a cross-datasource dataset. Those
1340
+ cases are green in all four states of the reverse verification
1341
+ (`dataset-degradation-envelope.test.ts`), including with both defences reverted.
1342
+
1343
+ The compile point deliberately stays outside the try. Moving it in would newly
1344
+ expose the compiler's own bare invariants and the host-supplied relationship
1345
+ resolver to this degradation path — widening leniency in the opposite direction
1346
+ from the fix.
1347
+
1348
+ - 978fed2: fix(analytics,rest): five dataset refusals declare `DATASET_INVALID` / 400 themselves, and the route's message-sniffing list shrinks to one entry (#5367)
1349
+
1350
+ `POST /analytics/dataset/query` answered `400 DATASET_INVALID` for six error
1351
+ families because the route recognised their **prose**, not because the errors
1352
+ said anything about themselves. #5352 gave the catch an ADR-0112 envelope branch
1353
+ (`error.code` + a 4xx `error.status`, read first) and had to leave a hardcoded
1354
+ list of message substrings behind it, since all six producers were still bare
1355
+ `throw new Error(…)`:
1356
+
1357
+ ```
1358
+ /not declared in the dataset|not backed by a declared relationship|
1359
+ not supported by the v1 dataset runtime|read-scope-sql|
1360
+ not a selected dimension or measure|is not a subset of the selected dimensions/
1361
+ ```
1362
+
1363
+ That made the HTTP status of six families a property of their wording.
1364
+ Rephrasing `dataset-compiler`'s "is not declared in the dataset's `include`" —
1365
+ no logic change — moved that refusal from 400 to 500, i.e. re-opened #5352 for a
1366
+ different family, and no test and no gate would have gone red. Prime Directive
1367
+ #12 permits an accommodation like that only while it is declared, loud, tested
1368
+ **and removable on a schedule**; #5366 delivered the first three and nothing
1369
+ carried the fourth.
1370
+
1371
+ **Five producers now declare their own verdict.** A new
1372
+ `dataset-refusal.ts` in `@objectstack/service-analytics` exports
1373
+ `datasetInvalidError` — the same shape as that package's existing
1374
+ `invalidFilterError` (`INVALID_FILTER` / 400) and `assertDimensionFields`
1375
+ (`INVALID_FIELD` / 400) — and five sites throw through it:
1376
+
1377
+ - `dataset-compiler.ts` — a measure whose aggregate the v1 runtime cannot lower;
1378
+ a dimension/measure traversing a relationship path the dataset never declared
1379
+ in `include`;
1380
+ - `dataset-executor.ts` — an `order` key that is not a selected dimension or
1381
+ measure; a `totals` grouping that is not a subset of the selected dimensions;
1382
+ - `native-sql-strategy.ts` — a join outside the dataset's declared allowlist.
1383
+
1384
+ Their five entries are gone from the route's list, which is now a single
1385
+ `read-scope-sql` test.
1386
+
1387
+ **`read-scope-sql` deliberately stays.** Its ten fail-closed refusals are RLS
1388
+ read-scope lowering failures whose inputs are an admin-authored policy and a
1389
+ compiler-generated join alias — not caller input — so `DATASET_INVALID` ("your
1390
+ request is invalid") may well be the wrong verdict and choosing the right one is
1391
+ a separate judgement, still tracked by #5367. Deleting the entry before that
1392
+ judgement lands would regress those ten from `400 DATASET_INVALID` to 500.
1393
+
1394
+ **No outward behaviour change for the five.** They answered
1395
+ `400 DATASET_INVALID` before and answer `400 DATASET_INVALID` now, with the same
1396
+ message; what changed is the mechanism, from message-matching to the producer's
1397
+ own declaration. The one visible difference is for a bare `Error` that merely
1398
+ _resembles_ one of those messages: it is no longer promoted to a 400. That is the
1399
+ point — a phrase is no longer a classification.
1400
+
1401
+ `DATASET_INVALID` is registered in `ERROR_CODE_LEDGER` under
1402
+ `@objectstack/service-analytics` as well as `@objectstack/rest` (provenance, per
1403
+ ADR-0112 D3; the code itself is unchanged and the union does not grow), and the
1404
+ constructor types it as `RegisteredErrorCode` so an unregistered code is a
1405
+ compile error rather than a body some route rejects at runtime.
1406
+
1407
+ Coverage: `dataset-refusal-envelope.test.ts` (service-analytics) pins each of the
1408
+ five refusals against its real producer — the refusal SET first, green before and
1409
+ after, then the envelope; `analytics-dataset-refusal-envelope.test.ts` (rest)
1410
+ drives all five end-to-end through a real `AnalyticsService` with positive
1411
+ controls on both the aggregate and raw-SQL paths; and
1412
+ `analytics-filter-refusal-envelope.test.ts` pins the deletion in both directions
1413
+ — the five messages answer 400 when enveloped and 500 when bare, so re-adding a
1414
+ regex entry turns it red.
1415
+
1416
+ - c36abfe: fix(service-analytics,rest): an analytics dimension over a missing field answers 400 INVALID_FIELD, not a driver 500 (#5520)
1417
+
1418
+ #4437 gave a **measure** over a non-existent field a `400 INVALID_FIELD` naming
1419
+ the field, because a driver error class must never be the caller's `error.code`
1420
+ for a caller-shaped mistake (ADR-0112). It covered the measure half only, so the
1421
+ identical typo one request key over still reached the driver as a `GROUP BY`
1422
+ column:
1423
+
1424
+ ```
1425
+ POST /analytics/query {"cube":"account_metrics","measures":["account_count"],"dimensions":["bogus_dim"]}
1426
+ → 500 {"code":"SQLITE_ERROR","message":"Internal server error"}
1427
+
1428
+ # the control group on the same route, already fixed by #4437
1429
+ POST /analytics/query {"cube":"account_metrics","measures":["bogus_measure"]}
1430
+ → 400 {"code":"INVALID_FIELD","message":"Measure 'bogus_measure' … Valid measures: …"}
1431
+ ```
1432
+
1433
+ **The gate.** `ensureCube` now runs `assertDimensionFields` alongside
1434
+ `assertMeasureFields` on every path, so a dimension whose source column the
1435
+ backing object does not have is refused **before** any SQL is built, with the
1436
+ same envelope the measure gate uses: `INVALID_FIELD` / 400 plus
1437
+ `field` / `object` / `param`, a message naming the field, the valid dimensions,
1438
+ and the object's known field list. `query`, `generateSql` and `queryDataset` are
1439
+ all covered, and a rejected query leaves nothing behind in the cube registry.
1440
+ `timeDimensions` are covered too — they resolve through the same
1441
+ `cube.dimensions` bag and produced the same 500 — with `param` reporting which
1442
+ request key carried the bad name.
1443
+
1444
+ **What deliberately did not change:** grouping by a REAL field the cube never
1445
+ declared as a dimension (`dimensions: ["phone"]`) still works. The gate asks
1446
+ "does the _object_ have this field", never "did the cube declare this
1447
+ dimension". A cube whose `sql` is an expression, a dotted relation dimension,
1448
+ and a host that wires no field-name probe are all stood down on, exactly as the
1449
+ measure gate stands down.
1450
+
1451
+ **The SQL echo, same request.** `POST /analytics/dataset/query` composed its own
1452
+ 5xx body and echoed the error message verbatim. Knex prefixes the offending
1453
+ statement to its message, so the caller received the generated SQL — physical
1454
+ table and column names included:
1455
+
1456
+ ```
1457
+ 500 {"code":"ANALYTICS_QUERY_FAILED",
1458
+ "error":"SELECT bogus_dim AS \"bogus_dim\", COUNT(*) AS \"account_count\"
1459
+ FROM \"crm_account\" GROUP BY bogus_dim - no such column: bogus_dim"}
1460
+ ```
1461
+
1462
+ The sibling face never leaked it: `/analytics/query` exits through the
1463
+ dispatcher, which has applied the shared `looksLikeInternalErrorLeak` predicate
1464
+ to every >= 500 message since #3867. That same predicate now guards this route's
1465
+ 500 body. Classification is untouched — the status stays 500, the code stays
1466
+ `ANALYTICS_QUERY_FAILED`, the ADR-0112 envelope branch and the transitional
1467
+ message list are unchanged — and the full text still reaches server logs. A 500
1468
+ whose message does not look like driver output keeps its prose.
1469
+
1470
+ - 2bc1876: fix(service-analytics): refuse a dotted `measures` entry loudly instead of aggregating the base column (#5918)
1471
+
1472
+ **Observable behaviour change.** An analytics query whose `measures` entry
1473
+ carries a dot that is not the cube-name qualifier — `owner.region_count_distinct`,
1474
+ `total.sum` — now answers `400 INVALID_FIELD` naming the entry **as the request
1475
+ spelled it**. Some of these queries used to succeed.
1476
+
1477
+ That is the point: succeeding is what was wrong with them. The auto-inference
1478
+ path minted a measure by dropping the first segment of any dotted entry, so on
1479
+ an object that happened to carry a same-named column the query ran
1480
+
1481
+ ```
1482
+ SELECT COUNT(DISTINCT region) AS "owner.region_count_distinct" FROM "crm_account"
1483
+ ```
1484
+
1485
+ — no JOIN, no error, a response column labelled with a relation attribute and a
1486
+ number that came from the base table. The caller could not tell from the result
1487
+ that it was wrong. Where the object had no same-named column it degraded to the
1488
+ #4437 gate's `400 INVALID_FIELD`, which was honest about what reached SQL
1489
+ (`aggregates field 'score'`) but named a string nobody had written; the caller
1490
+ had sent `owner.score_sum`.
1491
+
1492
+ `measures` was the fourth and last mint site of the punctuation #5739 sorted
1493
+ out on `dimensions` / `where` / `timeDimensions`. It is ruled the other way, and
1494
+ deliberately so: `lookupMember`'s relation-traversal tier is dimension-only, so a
1495
+ dotted measure has no correct traversal answer to converge on. A refusal is the
1496
+ honest answer, and it costs nothing that was working. Maintainer ruling,
1497
+ 2026-08-07.
1498
+
1499
+ Both a genuine traversal intent (`owner.amount_sum`) and a plain typo
1500
+ (`total.sum`) get this refusal. They are lexically indistinguishable on this
1501
+ path, and separating them would need field metadata the ad-hoc path does not
1502
+ have. A real relation-traversal measure (`SUM("owner"."amount")` + LEFT JOIN)
1503
+ would be a capability with its own justification, not a side effect of a strip.
1504
+
1505
+ The refusal is applied at both places a Metric is minted from a request
1506
+ spelling — the ad-hoc mint and the suffix-augmentation mint for a cube that is
1507
+ already registered — because the ad-hoc path registers what it infers, so the
1508
+ very same query reaches the second one from the second request onwards.
1509
+
1510
+ Unchanged: the `<cube>.` qualifier (`crm_account.region_count_distinct`) is
1511
+ still stripped and still runs; bare measures (`region_count_distinct`, `count`,
1512
+ `created_at_max`) are untouched; a cube's own declared measure is authored, not
1513
+ minted, so a Cube whose measure names a related column in its `sql` still
1514
+ compiles the JOIN — which is the supported way to aggregate across a
1515
+ relationship; and dotted **dimensions** still traverse, per #5739.
1516
+
1517
+ **Migration.** Aggregate one of the object's own fields
1518
+ (`<field>_sum` / `_avg` / `_min` / `_max` / `_count_distinct`), or declare a Cube
1519
+ whose measure names the related column. The refusal message says both, and names
1520
+ the entry you sent.
1521
+
1522
+ - 9ecdca9: fix(service-analytics): `/analytics/sql` 回显补上 `$startsWith` / `$endsWith` 谓词(#5333)
1523
+
1524
+ `ObjectQLStrategy.generateSql` 是同一棵过滤树的**第三个**编译器 —— 输出给浏览器的
1525
+ 展示 SQL。它的 `buildFilterClauseSql` 显式处理 `set`/`notSet`/`in`/`notIn`/
1526
+ `contains`/`notContains`,其余落到只有六个条目的 `SCALAR_SQL_OPS` 查表;
1527
+ `startsWith` / `endsWith` 两处都不在,于是走到 `return null`,而**这棵树的每个编译器
1528
+ 都把 `null` 读成「本节点没有约束」**。结果:
1529
+
1530
+ | `where` | 实际执行(`NativeSQLStrategy`) | 修复前的回显 | 修复后的回显 |
1531
+ | ----------------------------- | -------------------------------- | ------------------------------- | -------------------------------- |
1532
+ | `{stage: {$startsWith: 'w'}}` | `WHERE stage LIKE $1` / `['w%']` | **没有 WHERE**,`params` 为空 | `WHERE stage LIKE $1` / `['w%']` |
1533
+ | `{stage: {$endsWith: 'n'}}` | `WHERE stage LIKE $1` / `['%n']` | **没有 WHERE**,`params` 为空 | `WHERE stage LIKE $1` / `['%n']` |
1534
+ | `{stage: {$contains: 'w'}}` | `WHERE stage LIKE $1` | `WHERE stage LIKE $1`(本来就对) | 不变 |
1535
+
1536
+ 回显比实际执行的查询**更宽**。这个字符串存在的唯一理由就是复现执行 —— 文件自己在渲染
1537
+ 块顶上写着 “a rendering that contradicts execution is worse than no rendering” ——
1538
+ 所以一个带着「为什么这张图少了几行」来看回显的作者,拿到的是一条**没有该筛选条件**的
1539
+ 语句:跑一遍返回更多行,于是结论是「筛选器没生效」,而实际执行是生效的。与
1540
+ #3601 / #3602 / #3650 同一类「回显与执行不一致」,只是这次是从**算子表**这一侧到达的。
1541
+
1542
+ 不涉及越权或错行:该字符串从不执行(`execute()` 的 echo 会丢弃 `params`),损害限于
1543
+ 可调试性。
1544
+
1545
+ **两处修改:**
1546
+
1547
+ 1. **LIKE 家族收进一张表。** 新增 `LIKE_SQL_OPS`,四个算子(`contains` /
1548
+ `notContains` / `startsWith` / `endsWith`)的 SQL 拼写与 pattern 并排放在一起,
1549
+ 与 `NativeSQLStrategy.buildFilterClause` 的 `opMap` / `likePattern` 逐条对应 ——
1550
+ 回显描述的正是那个编译器产出的语句,两张表并列摆着,漂移才看得见。
1551
+ `contains` / `notContains` 的产物一字未变。
1552
+
1553
+ 2. **「渲染不了就静默丢」的出口改为 THROW。** `return null` 在这里与「无约束」同形,
1554
+ 所以下一个新增算子会以同样的方式再丢一次。之所以**可以**抛错:上游算子词汇表是
1555
+ **封闭**的 —— `filter-normalizer.ts` 的 `fieldLeaves` 是叶节点的唯一生产者,它对
1556
+ `MONGO_TO_CUBE_OP` 之外的算子在建叶之前就以 `INVALID_FILTER` / 400 拒绝。因此任何
1557
+ 调用方写出的过滤器都到不了这个出口;真到了,只能意味着 normalizer 的表新增了这里
1558
+ 没有分支的算子,那是我们自己两张表漂移,而对此**唯一不能给的答案就是悄悄放宽作者的
1559
+ 查询**。与 `convertFilter` 的 `default:` 分支在 #4128 做出的是同一个选择;刻意**不**用
1560
+ `invalidFilterError` 的 400 信封 —— 这不是调用方形状的错误。
1561
+
1562
+ **该 throw 出口今天从公共入口不可达,这一点是测过的、也是刻意报告的**:把它改回
1563
+ `return null`(保留第 1 项修改)只会让它自己那一条断言变红,枚举断言和回显对照表
1564
+ 全部保持绿色。它是一个漂移探针,不是行为修复 —— 行为修复是第 1 项。
1565
+
1566
+ 新增 `objectql-echo-operator-coverage.test.ts`:issue 那张对照表按**行结果**钉住
1567
+ (回显语句在同一份 fixture 上真的被执行,行 id 与查询实际返回的行 id 比对 —— 丢掉的
1568
+ 谓词藏不住,它返回的正是筛选器排除掉的行),再按 `filter.zod.ts` 的
1569
+ `FILTER_OPERATORS` 枚举全部 15 个可编写算子,逐个断言回显渲染出谓词、且
1570
+ placeholder 与 `params` 对齐。只断言 SQL 字符串会放过下一个未映射的算子 —— #4128 里
1571
+ `$between` 就藏在 `$startsWith` 后面。
1572
+
1573
+ - 7101ca2: fix(analytics): apply the EFFECTIVE date granularity to bucket labels and drill ranges (#3588 follow-up)
1574
+
1575
+ `selection.dateGranularity` (shipped in #3652) reached the `GROUP BY` but not the
1576
+ post-processing: the bucket-label formatter and the drill-range inverter both
1577
+ kept reading the DATASET dimension's default. A query was grouped one way and
1578
+ described another. Found by driving a real dashboard query in a browser against
1579
+ a dataset whose dimension declares `dateGranularity: 'month'`:
1580
+
1581
+ - selection `year` → the row came back labelled **`1970-01`** — a year bucket
1582
+ re-formatted with the dataset's month granularity, its `"2026"` key re-read as
1583
+ 2026 _milliseconds_ past the epoch;
1584
+ - selection `day` → day buckets were re-labelled as months, so ten distinct days
1585
+ collapsed into two duplicated keys;
1586
+ - selection `quarter` / `year` / `day` / `week` → `drillRanges` came back empty,
1587
+ silently removing drill-through from every bucketed chart.
1588
+
1589
+ Granularity precedence now lives in one exported function,
1590
+ `resolveDimensionGranularity`, called from all three sites that must agree — the
1591
+ query's `GROUP BY`, the label formatter, and the range inverter. The drift was
1592
+ possible only because each site resolved it independently.
1593
+
1594
+ Two consequences beyond the override case:
1595
+
1596
+ - A dataset dimension that declares **no** granularity but is bucketed by the
1597
+ widget now gets drill ranges too. Previously the range sidecar keyed off the
1598
+ dataset's own `dateGranularity`, so this case — the one #3588 is actually
1599
+ about — could never drill.
1600
+ - `formatDateBucket` no longer mistakes a bare year key for an epoch timestamp.
1601
+ A year bucket's canonical key IS `"2026"`, which is the only bucket key that
1602
+ collides with the pure-digit epoch heuristic (`"2026-Q2"`, `"2026-07"` and
1603
+ `"2026-07-15"` all fail it). Being idempotent over already-formatted keys is
1604
+ that function's stated contract; the year case just never held.
1605
+
1606
+ - cfc293f: fix(service-analytics): 空 `$and` / `$or` 按布尔单位元归约,两个编译器与五后端对齐 (#5322)
1607
+
1608
+ 同一个仓库对空组合子曾有两个对立答案:五个 `FILTER_LOGIC_CASES` 后端
1609
+ (`driver-sql` #5134/PR #5243、`driver-memory`、`formula`、`driver-sqlite-wasm`、
1610
+ `driver-mongodb` #5239)把 `{ $and: [] }` / `{ $or: [] }` 归约成布尔单位元,而
1611
+ service-analytics 的两个编译器 —— `read-scope-sql.ts` 的 `compileNode` 与
1612
+ `filter-normalizer.ts` 的 `buildNode` —— 成文地 fail-closed 抛错("An empty
1613
+ combinator has no defensible reading…"),并有 pin 测试钉住。2026-08-04 维护者拍板
1614
+ (#5322)取单位元,本次把两处对齐:
1615
+
1616
+ - `{ $and: [] }` = TRUE(全部行,AND 单位元);`{ $or: [] }` = FALSE(零行,OR
1617
+ 单位元)。嵌套可归约:空组合子作 `$or` 分支时按 TRUE 吸收/FALSE 退出析取,作
1618
+ `$not` 操作数时取反(`{$not: {$and: []}}` = 零行、`{$not: {$or: []}}` = 全部
1619
+ 行)。`{}` = TRUE 与 `{ $not: {} }` = 零行两格已由 #5297(read-scope)/#5325
1620
+ (normalizer)先行落地,本次连同这四格由同一张一致性表钉住。
1621
+ - **迁移含义**:过去发出空组合子的调用方收到的是抛错(REST 面上是一次失败的请
1622
+ 求);现在按上表求值。`{ $or: [] }` 在 RLS/图表场景是 fail-closed 的 —— 析取列
1623
+ 表循环出零项时隐藏全部行,而不是放行全表。写作期对字面量空组合子的响亮拒收另立
1624
+ #5330(publish/lint),不在运行期。
1625
+ - **没有放宽的部分**:非数组的 `$and`/`$or`、非对象的分支、非对象的 `$not` 操作数
1626
+ 仍然抛错(#5325 的形状拒收原样保留)。归约让「无约束」成为有意义的裁决,静默把
1627
+ 畸形分支读成 TRUE 会让垃圾析取项吸收 `$or` 而放宽查询,所以畸形形状保持响亮。
1628
+ - 归约与 #5146/#5325 的 NULL-safe `$not` 重写的组合语义是「先归约、后 NULL-safe」
1629
+ —— 常量归约出的单位元不受重写影响,幸存的叶子照常加守卫,有测试钉住。
1630
+ - `packages/spec`:`FILTER_LOGIC_CASES` 补四条布尔单位元行(空 `$and`、空 `$or`、
1631
+ `{}` 析取项吸收、`{$not: {}}`),两个 analytics conformance suite 与五后端从此
1632
+ 被同一张表钉住这四格。
1633
+
1634
+ - de70b42: analytics: `$ne` / `$nin` / `$notContains` in a dashboard `where` keep the rows that have no value
1635
+
1636
+ Second batch of the #5298 ruling, after PR #5962 landed it on `driver-sql`,
1637
+ `read-scope-sql` and `formula`. An analytics filter meaning "not this" now
1638
+ returns the rows whose column is empty, the same answer every other backend
1639
+ gives — a `stage != 'won'` widget shows the deals with no stage set.
1640
+
1641
+ The Cube face was the last surface still splitting on it, and it split three
1642
+ ways for one filter. Measured on the package's own fixture before the change,
1643
+ for `{stage: {$ne: 'won'}}` with rows 3-4 carrying a NULL `stage`:
1644
+
1645
+ | compiler | was | now |
1646
+ | ----------------------------------- | ------- | ------- |
1647
+ | `NativeSQLStrategy` raw SQL | `2` | `2,3,4` |
1648
+ | `ObjectQLStrategy` display-SQL echo | `2` | `2,3,4` |
1649
+ | `ObjectQLStrategy` engine condition | `2,3,4` | `2,3,4` |
1650
+
1651
+ The engine column was already right — because `driver-sql` guards for itself
1652
+ since #5962, not because the analytics layer did — so which rows a widget drew
1653
+ depended on which compiler downstream caught the leaf, and the `/analytics/sql`
1654
+ echo described a narrower query than the one that ran.
1655
+
1656
+ `filter-normalizer` now emits the guard as tree STRUCTURE (an `or` of the null
1657
+ predicate with the comparison) rather than as a SQL trick in one strategy, so
1658
+ all three compilers of that tree produce one predicate and none of them needs
1659
+ to know the rule. Which operators are guarded is decided by the polarity table
1660
+ the `$not` rewrite already consults, not by a second list of operator names:
1661
+ positive comparisons (`$eq`, `$in`, `$contains`, the ordering family) compile
1662
+ byte-identically to before, `$ne: null` stays `IS NOT NULL`, an empty `$nin`
1663
+ stays the TRUE constant, and `{$not: {stage: {$ne: 'won'}}}` still means
1664
+ "stage is won" rather than widening.
1665
+
1666
+ `FILTER_LOGIC_CASES` is unchanged: the `$ne` and `$not` null rows enrol in
1667
+ #5903's PR, which clears the last backend (`driver-turso` remote). The spec
1668
+ table's measured blocker matrix drops the Cube row it no longer describes.
1669
+
1670
+ - 7a55913: fix(service-analytics): every authorable filter operator now reaches the query (#4128)
1671
+
1672
+ Closes the cause behind the `$between` defect rather than just that instance.
1673
+ `normalizeAnalyticsFilters` skipped any operator missing from its map, and a
1674
+ skipped predicate does not narrow a query — it **widens** it: the compiled SQL
1675
+ stays valid and returns rows the author excluded. Four operators from the
1676
+ spec's authorable vocabulary sat in that state, plus one that was mapped
1677
+ incorrectly.
1678
+
1679
+ - **`$startsWith` / `$endsWith`** were dropped entirely. Both strategies now
1680
+ compile them — anchored `LIKE 'x%'` / `LIKE '%x'` on the raw-SQL path, and
1681
+ the canonical `$startsWith` / `$endsWith` operators (which every driver
1682
+ implements directly) on the ObjectQL path, so an anchored match does not
1683
+ depend on regex dialect.
1684
+ - **`$null`** was dropped. It is the shape the console emits for an "is empty"
1685
+ / "is not empty" filter, so such a widget was showing every row. Now compiles
1686
+ to `IS NULL` / `IS NOT NULL` per its boolean.
1687
+ - **`$exists`** was mapped value-_independently_ to `set`, so `{$exists: false}`
1688
+ compiled to `IS NOT NULL` — the exact inverse of what it asks for. It and
1689
+ `$null` are now resolved explicitly, because a key→name map cannot express an
1690
+ operator whose meaning flips with its value.
1691
+ - **`$notContains`** reached the ObjectQL strategy, which had no arm for it and
1692
+ fell through to a `default` returning a bare value — compiling "does not
1693
+ contain x" as "**equals** x".
1694
+ - **Unknown operators now throw** on both surfaces instead of being silently
1695
+ dropped (normalizer) or reinterpreted as an equality (ObjectQL strategy). An
1696
+ operator outside the vocabulary is a caller error, and a loud one beats a
1697
+ silently widened read — the call driver-memory made for the same shape in
1698
+ #3948.
1699
+
1700
+ Still declared as a gap, but no longer a silent one: `$or` / `$not` are skipped,
1701
+ since expressing them needs a recursive WHERE builder rather than the flat
1702
+ array the strategies consume.
1703
+
1704
+ Cover is `filter-operator-coverage.test.ts`, which runs the whole vocabulary
1705
+ against a real SQLite engine and asserts **row ids** — six of its cases fail
1706
+ without this change. A dropped predicate is invisible to the SQL-string
1707
+ assertions the strategies' other suites use, which is how these survived.
1708
+
1709
+ - 2f6516e: fix(analytics,rest): an analytics filter refusal reaches the caller as `400 INVALID_FILTER`, not `500 ANALYTICS_QUERY_FAILED` (#5352)
1710
+
1711
+ Misspell an operator in a dashboard widget's filter and analytics refuses it —
1712
+ correctly, and loudly, which is the posture #3948 / #5240 / #5325 / #5334 each
1713
+ argued for one refusal at a time: dropping a predicate the compiler cannot
1714
+ express does not narrow the query, it **widens** it to rows the author excluded,
1715
+ and a chart drawn over the whole dataset looks like a working chart.
1716
+
1717
+ The refusal never reached the author. It landed as `500 ANALYTICS_QUERY_FAILED`
1718
+ — read as "the platform is broken" rather than "your filter has a typo", and
1719
+ counted by ops alerting as a 5xx. The identical mistake on `find()` has answered
1720
+ `400 INVALID_FILTER` since #3948, so one authoring error had two wire shapes,
1721
+ chosen by which face happened to catch it.
1722
+
1723
+ **One defect, two halves — either alone leaves it unfixed.**
1724
+
1725
+ - **Producer** (`filter-normalizer.ts`): seven of its nine refusals were bare
1726
+ `throw new Error(…)` carrying no `code`/`status`. All nine now go through the
1727
+ `invalidFilterError` helper #5334 introduced (`INVALID_FILTER` / 400), which
1728
+ becomes the module's only way to refuse.
1729
+ - **Consumer** (`rest-server.ts`, `POST /analytics/dataset/query`): the catch
1730
+ discarded `error.code` / `error.status` and re-derived the classification from
1731
+ a hardcoded list of message substrings — so a producer that took ADR-0112
1732
+ seriously was punished for it. It now reads the envelope **first**; the
1733
+ substring list is demoted to a fallback for the families that still carry no
1734
+ envelope.
1735
+
1736
+ **Observable behaviour change — read this if you alert or retry on status.**
1737
+ The same request that returned `500 ANALYTICS_QUERY_FAILED` now returns
1738
+ `400 INVALID_FILTER` (and, for two neighbouring conditions whose producers
1739
+ already declared an envelope this route was discarding, `400 INVALID_FIELD` for
1740
+ a measure over a field the object does not have, `404 CUBE_NOT_FOUND` for an
1741
+ unregistered cube). Monitoring that counted these as server faults will see the
1742
+ 5xx rate drop and a 4xx rate appear; a client that retries on 5xx will stop
1743
+ retrying a request that could only ever fail the same way. Both are the intended
1744
+ correction — the condition was always the caller's mistake — but they are
1745
+ visible, so they are stated rather than buried.
1746
+
1747
+ **Which inputs are refused did not change.** This changes the SHAPE of the
1748
+ error and nothing about the judgement that produced it: no refusal condition
1749
+ was touched, no input that used to compile now refuses, and no input that used
1750
+ to refuse now compiles. That claim is pinned input-by-input (refusals _and_
1751
+ accepted inputs with their compiled trees) in
1752
+ `filter-refusal-envelope.test.ts`, which is green both before and after the
1753
+ change — only the envelope assertions move.
1754
+
1755
+ The message-substring list survives on purpose. All six of its entries were
1756
+ re-verified as bare `Error`s (`dataset-compiler.ts`, `native-sql-strategy.ts`,
1757
+ `dataset-executor.ts`, `read-scope-sql.ts`), so deleting it would regress those
1758
+ families from `400 DATASET_INVALID` to 500. It is a placeholder for their
1759
+ enveloping, not a second classification mechanism, and it is now documented as
1760
+ such: a new refusal should carry a `code`/`status` and be served by the
1761
+ envelope branch for free. The passthrough is deliberately **4xx-only** and
1762
+ requires **both** `code` and `status`, so an internal fault can never be
1763
+ re-labelled as the caller's fault, and this route never invents a code a
1764
+ producer failed to supply.
1765
+
1766
+ - e6b1bb0: fix(service-analytics): 过滤值不再被降级成字符串 —— `{code: {$eq: '007'}}` / `'null'` / `'true'` 按作者写的字面值绑定 (#5526)
1767
+
1768
+ analytics 的 `filter-normalizer` 内部把每个比较数(comparand)压成 `values: string[]`
1769
+ 再由消费方**猜**回类型:出口是 `stringifyForCube`,入口是 `recoverNumber` 与
1770
+ `coerceFilterValueForSql` / `coerceFilterValueForObjectQL`。字母表是"全体字符串"、
1771
+ 解码规则是"这串看起来像不像数字/布尔/null"的编码没有任何转义机制,于是作者写的字符串
1772
+ 和编码器为其他类型写下的 token 撞车。`{code: {$eq: v}}` 在 `main` 上实测:
1773
+
1774
+ | 作者的 `v` | SQL 绑定 | 引擎绑定 |
1775
+ | ---------- | ----------------- | ----------------- |
1776
+ | `'007'` | `7`(#5528 已修) | `7`(#5528 已修) |
1777
+ | `'1.50'` | `1.5`(#5528 已修) | `1.5`(#5528 已修) |
1778
+ | `'null'` | 真 NULL | 真 `null` |
1779
+ | `'true'` | `1` | `true` |
1780
+
1781
+ 每一行都是一个缺陷:存着作者那种写法的 TEXT 列不再匹配。`'007'` 在 SQLite 上是
1782
+ 整数与 TEXT 列的跨类型比较、恒不相等,在 Postgres 上 `text = integer` 直接报类型错;
1783
+ `'null'` 那一行比"空"更糟 —— 与真 NULL 的比较对任何行都是 UNKNOWN,图表永远画不出东西。
1784
+ 零填充串、当枚举码用的 `'true'`/`'false'`、当字面标签用的 `'null'` 都是真实业务形状
1785
+ (订单号、SKU、邮编、国际长途区号)。
1786
+
1787
+ **修法**:`NormalizedFilterNode` 的 leaf `values` 由 `string[]` 改为 `unknown[]`,
1788
+ 作者写的值原样穿过整棵树,不再有任何东西去解码它。仅在边界真正要求时才转换:
1789
+
1790
+ - `toSqlBindValue`(唯一留下的转换,且是**单向**的:值 → 它的 SQL 绑定形态,不是解码器)
1791
+ ——只处理驱动绑不了的 JS 类型:`boolean` → `1`/`0`(better-sqlite3 拒绝 JS 布尔)、
1792
+ `Date` → ISO 文本、其他对象 → JSON 文本。它不检查任何字符串。
1793
+ - LIKE 族的比较数被 `filter.zod.ts` 声明为 `z.string()`,所以在发射点字符串化 ——
1794
+ 与 `driver-sql` 的 `applyLike` 同一个 `String(value)`,两个面上 `$contains` 仍是一件事。
1795
+
1796
+ ObjectQL 引擎路径现在不需要任何转换:引擎按**存储**的运行时类型比较,而它拿到的就是
1797
+ 作者写的值。`stringifyForCube` / `recoverNumber` / `coerceFilterValueForSql` /
1798
+ `coerceFilterValueForObjectQL` 一并删除。
1799
+
1800
+ 两处读法作为直接后果改变了,方向都是 fail-closed:
1801
+
1802
+ - `{name: {$contains: null}}` 原先编译成 `LIKE '%%'` —— 匹配**每一个**非 NULL 行,
1803
+ 因为 `stringifyForCube(null)` 是 `''`;现在是 `LIKE '%null%'`,与 `driver-sql`
1804
+ 一直以来的编译结果一致。
1805
+ - `{amount: {$gt: null}}` 原先编译成 `amount > ''`(一次针对空字符串的真实比较);
1806
+ 现在绑定 NULL,谓词为 UNKNOWN、图表画不出行 —— 无序比较数的诚实答案,也是
1807
+ `driver-memory` / `formula` 给出的答案。(#5332 明确指出这个比较数位置没有任何裁决
1808
+ 覆盖、`''` 只是占位符;删掉编码器就按构造把它定了。)
1809
+
1810
+ `timeDimensions[].dateRange` 的两个边界现在按 spec 声明的类型(`string[]`)原样传递:
1811
+ 原先它们也过 `coerceFilterValueForObjectQL`,其文档宣称"epoch-ms 边界会还原成数字"——
1812
+ 那是消费方在宽容地兜一个契约并未声明的形状,和把 `'007'` 读成 `7` 是同一个猜测
1813
+ (Prime Directive #12:epoch-ms 窗口要么在生产者、要么在 spec 里声明,不在这里猜)。
1814
+
1815
+ `{stage: null}` / `{$eq: null}` / `{$ne: null}` / `{$null:}` / `{$exists:}` 的空值
1816
+ 谓词语义(#5332 / #5525)不变:真 `null` 比较数编译成 `notSet` / `set`,从不进入
1817
+ `values`。#5567 的 LIKE 转义契约不变。
1818
+
1819
+ - 415254c: fix(analytics): scope the dimension-label lookup to the referenced object's RLS (#3602)
1820
+
1821
+ When a dataset groups by a `lookup`/`master_detail` dimension, analytics resolves
1822
+ the grouped FK ids to the related record's display name via a per-record read
1823
+ (`group by id`) dressed as an aggregate. That read carried **no read scope**, so
1824
+ it revealed related-record display names whenever the referenced object's RLS is
1825
+ stricter than the base object whose rows carry the id — a user could see a name
1826
+ the referenced object's own RLS would hide. (Same-object and looser-referenced
1827
+ cases were already safe because the ids come from the post-#3597 scoped
1828
+ aggregate; this closes the stricter-referenced case.)
1829
+
1830
+ The label lookup now applies the **referenced object's own** read scope — bound
1831
+ to the request via the same `getReadScope` provider the aggregate path uses,
1832
+ composed with `$and` (never key-merge) so it can't be displaced by the id
1833
+ predicate. Fail-closed: if that object's scope can't be resolved, the dimension's
1834
+ labels are skipped (the raw id renders) rather than fetched unscoped. No behaviour
1835
+ change when no read-scope provider is configured.
1836
+
1837
+ Internal `DimensionLabelDeps.fetchRecordLabels` gains an optional `scope` argument
1838
+ and `resolveDimensionLabels` an optional `resolveScope` resolver; both are
1839
+ service-analytics-internal (no spec/contract change).
1840
+
1841
+ - a7b854f: fix(service-analytics): the three SQL compilers compare LIKE values literally (#5567)
1842
+
1843
+ `$contains` / `$notContains` / `$startsWith` / `$endsWith` build a `LIKE` pattern
1844
+ around the comparand the author wrote. All three of this package's SQL compilers
1845
+ concatenated that comparand straight into a wildcard position — no escaping, no
1846
+ `ESCAPE` clause — so `_` (LIKE's single-character wildcard) and `%` (its
1847
+ multi-character one) stopped being literals. Measured on real SQLite, over the
1848
+ rows `x_admin` / `xyadmin` / `off 50% now` / `off 5012 now`:
1849
+
1850
+ | `where` | returned | correct |
1851
+ | ------------------------------- | ----------- | ------- |
1852
+ | `{name: {$contains: '_admin'}}` | `['1','2']` | `['1']` |
1853
+ | `{name: {$contains: '50%'}}` | `['3','4']` | `['3']` |
1854
+ | `{name: {$startsWith: 'x_'}}` | `['1','2']` | `['1']` |
1855
+ | `{name: {$endsWith: '0% now'}}` | `['3','4']` | `['3']` |
1856
+
1857
+ Every row is a **widening** — rows the author excluded came back — and
1858
+ `$notContains` is the mirror image, excluding rows the author kept. One of the
1859
+ three call sites is the ADR-0021 D-C read-scope (tenant + RLS) lowering, where a
1860
+ wider predicate is over-reach rather than a loose filter (the #5347 / #5324
1861
+ ruling on that same file). Prime Directive #3 forces machine names to
1862
+ `snake_case`, so essentially every machine-name comparand carries a `_` and hit
1863
+ this silently.
1864
+
1865
+ All three compilers now escape the comparand and bind an explicit
1866
+ `ESCAPE` argument, matching what `driver-sql`'s `applyLike` has always done — so
1867
+ the same filter selects the same rows whichever strategy answers, and the
1868
+ `/analytics/sql` echo describes the statement that ran instead of a wider one.
1869
+
1870
+ **No authoring change.** A comparand with no `_`, `%` or `\` binds exactly the
1871
+ pattern it bound before; only its meaning when it _does_ carry one changes, from
1872
+ wildcard to literal. If you were relying on a comparand acting as a wildcard,
1873
+ that was never a declared capability of these operators — the spec describes them
1874
+ as substring / prefix / suffix matches — and `driver-sql` already read it
1875
+ literally, so the reading you got depended on which strategy served the query.
1876
+
1877
+ - 1d0faa7: fix(service-analytics): postgres 的「缺列」措辞不再被判为「缺源」(#6035)
1878
+
1879
+ 数据集查询的降级路径靠驱动措辞判断「后端表没挂载」,从而把控件渲染成空网格而不是 500。
1880
+ 它的判据 `isMissingSourceError` 自己的文档写明范围**只含缺表/缺对象,不含列/语法错误——
1881
+ 后者要保持硬失败,好让真正的查询 bug 浮上来**。有一条 postgres 措辞按构造违反了这条承诺:
1882
+
1883
+ ```
1884
+ column "label" of relation "acct" does not exist (SQLSTATE 42703)
1885
+ ```
1886
+
1887
+ 它内部**逐字包含**一整段合法的缺表措辞 `relation "acct" does not exist`。#5717 把 postgres
1888
+ 那一支从「同时含两个词的任意句子」收紧为锚定真实缺表措辞后,这条依然命中——它必然命中,因为它
1889
+ 字面上**就是**那段措辞。所以任何对「这句话是不是在说某个 relation 不存在」的收紧都排除不掉它,
1890
+ 只有**先问更具体的问题**才可以:修法是一个**判定顺序**(先摘掉缺列措辞,再做缺源判定),而不是
1891
+ 一个更好的正则。
1892
+
1893
+ 两种后果都是错的,而具体触发哪一种只取决于措辞里那个关系名是否恰好是数据集自己的对象:
1894
+
1895
+ - 名字是**被 JOIN 的表** → 报出一条响亮但**虚假**的跨数据源拓扑错误,把一个拼写错误说成数据源
1896
+ 布局问题;
1897
+ - 名字是**数据集自己的对象** → 控件降级成空网格,只留一条 warn,拼错的列名不会告诉任何人。
1898
+
1899
+ 两半现在都作为回归钉住。判定顺序抄 `rest-server.ts` 的 `mapDataError` 自 #5352 起就在用的先例
1900
+ (它同样先摘出这条措辞,于是 REST 面回答 `400 INVALID_FIELD` 而不是 `404`),用的是同一条正则
1901
+ 而不是它的第二种方言——两个面不该对「postgres 什么时候在说 column」给出不同答案。兄弟函数
1902
+ `missingSourceRelation` 做同样的前置摘除:实测在修改前它对这条措辞回答 `sys_team`,只修其一会让
1903
+ 「是不是缺了什么」与「缺的是什么」相互矛盾,而那正是 #5717 在这一支上刚消除的分歧。
1904
+
1905
+ **这不修线上事故,而是让判据与它自己的文档一致。** analytics 是只读面,而 postgres 在 SELECT
1906
+ 下的未知列措辞是 `column "bogus" does not exist`(不含 `relation`,本来就不命中);
1907
+ `column … of relation …` 是 INSERT/UPDATE/ALTER 措辞。价值在于:这条分歧不再依赖「读路径不产生该
1908
+ 措辞」这个假设活着——哪天有任何写形状语句、驱动改措辞、或多包一层 `cause` 把它送到这个 catch
1909
+ 面前,它会被正确分类,而不是被静默吞掉。
1910
+
1911
+ #5717 量过的 13 条仓内真实措辞全部重新钉住,并且是**按调用方可观测的结果**(空网格 / 拓扑拒收 /
1912
+ 原样上抛)钉的,而不是按私有判据的布尔值——实测 **13 条里只有 1 条改判**,就是缺列那条,其余 12
1913
+ 条(三个驱动家族的措辞、框架的 not-registered 信号、本包自己的拒收)逐条不变。
1914
+
1915
+ - f56ebea: fix(service-analytics): a `null` comparand in an analytics `where` is a null predicate, not `= ''` (#5332)
1916
+
1917
+ `{stage: null}` compiled to `stage IS NULL`, while `{stage: {$eq: null}}` — the
1918
+ same predicate — compiled to `stage = $1` binding the empty **string**. One
1919
+ meaning had two answers inside one file: the bare-`null` spelling took
1920
+ `fieldLeaves`' `raw === null` branch, the operator spelling fell through to the
1921
+ `MONGO_TO_CUBE_OP` map, and `stringifyForCube(null)` handed it `''`.
1922
+
1923
+ Measured before the fix, on cube `deals` / column `stage`:
1924
+
1925
+ | `where` | WHERE | bindings |
1926
+ | ------------------------ | --------------- | -------- |
1927
+ | `{stage: null}` | `stage IS NULL` | `[]` |
1928
+ | `{stage: {$eq: null}}` | `stage = $1` | `['']` |
1929
+ | `{stage: {$ne: null}}` | `stage != $1` | `['']` |
1930
+ | `{stage: {$null: true}}` | `stage IS NULL` | `[]` |
1931
+
1932
+ The failure was **silent, not loud**: an "is empty" dashboard widget drew zero
1933
+ rows — never an error — because a real value can never equal a NULL column, and
1934
+ the author saw "no data" rather than anything to debug. On a text column the
1935
+ `$ne` direction was worse than empty: in SQLite / MySQL `''` is a value rows
1936
+ genuinely store, so "stage is not empty" compiled to `stage != ''` and excluded
1937
+ exactly the rows it was asked to keep, while "stage is empty" returned the one
1938
+ row that is emphatically not null.
1939
+
1940
+ `$eq: null` and `$null: true` are not near-synonyms to be reconciled by taste —
1941
+ `driver-mongodb`'s translator **rewrites** the latter into the former, so they
1942
+ are one predicate in the contract, and `read-scope-sql.ts` (this package's other
1943
+ SQL compiler), `driver-sql`, `driver-memory` and `formula` all compile them
1944
+ alike. This module was the one dissenting half of one package; `fieldLeaves` now
1945
+ emits the same `notSet` / `set` leaves for all three spellings, so both
1946
+ strategies, the ObjectQL engine filter and the `/analytics/sql` display echo
1947
+ follow with no new cases.
1948
+
1949
+ The #5146 NULL-safe `$not` guard table moved in the **same** commit, because it
1950
+ describes this file's emitter rather than a sibling's: while `$eq: null` was a
1951
+ value comparison the guard correctly classified it as one, and left alone it
1952
+ would have wrapped `stage IS NOT NULL AND stage IS NULL` — an always-false
1953
+ conjunction — and negated it to **every** row for a filter meaning "stage is not
1954
+ empty". `nullValueSatisfiesOperator` and `operatorIsNullTotal` now carry the
1955
+ `value === null` arms their `read-scope-sql` counterparts have, and
1956
+ `{$not: {stage: {$eq: null}}}` returns the rows the other three backends already
1957
+ return for it.
1958
+
1959
+ Scoped deliberately to the two spellings `filter.zod.ts` gives a null _meaning_.
1960
+ `stringifyForCube`'s `v == null` arm is untouched: it still serves comparand
1961
+ positions no ruling covers (`$gt: null`, `$in: [null]`), where `''` is a
1962
+ placeholder rather than an answer. An empty-string comparand also stays a value
1963
+ comparison — `{stage: {$eq: ''}}` still binds `''` — since reading `''` as null
1964
+ would be the same defect with its sign flipped.
1965
+
1966
+ Authoring is unchanged; only the compiled predicate is. A widget that worked
1967
+ around the old behaviour by filtering on the literal empty string (`{$eq: ''}`)
1968
+ keeps working and still means the empty string; one that wrote `{$eq: null}` and
1969
+ saw nothing now gets its rows.
1970
+
1971
+ - 1f8390b: fix(analytics): ObjectQLStrategy now enforces the read scope (RLS + tenant) (#3597)
1972
+
1973
+ `ObjectQLStrategy` never consumed `getReadScope`, so any analytics query served by
1974
+ that path ran with **no RLS or tenant predicate** — an authenticated caller
1975
+ received aggregates computed over every tenant's rows.
1976
+
1977
+ Both belts were off at once. The strategy dropped the pre-resolved read scope, and
1978
+ the engine could not compensate: the `executeAggregate` bridge passes no
1979
+ `ExecutionContext`, so plugin-security's principal-less fall-open skipped its own
1980
+ RLS injection. Only `NativeSQLStrategy` was ever wired for ADR-0021 D-C.
1981
+
1982
+ The exposure was **not** limited to exotic drivers. `NativeSQLStrategy` declines —
1983
+ handing the query to this path — on any date-bucketed query
1984
+ (`timeDimensions[].granularity`, the most common dashboard shape, on Postgres and
1985
+ SQLite too), on `RAW_SQL_UNSUPPORTED` (in-memory driver), and on federated objects.
1986
+
1987
+ The scope is composed with `$and`, never by key merge, so a caller filter naming
1988
+ the same field (e.g. `organization_id`) cannot displace the security predicate.
1989
+
1990
+ **Behaviour change to be aware of:** a query that references a **joined** object
1991
+ carrying its own read scope is now REJECTED on this path rather than run
1992
+ partially-scoped. `engine.aggregate`'s `where` addresses the base object, so a
1993
+ per-join predicate cannot be expressed there; failing closed matches the posture
1994
+ already taken by `resolveReadScopes` and `compileScopedFilterToSql`. Such a query
1995
+ previously returned results that omitted the joined object's tenant predicate.
1996
+ Run it on a native-SQL driver (`NativeSQLStrategy` scopes each join), or drop the
1997
+ cross-object dimension/measure.
1998
+
1999
+ Deployments with no read-scope provider configured are unaffected — that path
2000
+ stays unscoped by documented contract.
2001
+
2002
+ - f5ab1c7: fix(service-analytics): a `$or` / `$not` filter no longer vanishes from an analytics query (#4128 follow-up)
2003
+
2004
+ The last of the silently-dropped filter family. `normalizeAnalyticsFilters`
2005
+ produced a flat **array**, which cannot carry a disjunction, so both strategies
2006
+ skipped `$or` and `$not` outright — a widget or dataset whose filter used
2007
+ either compiled a WHERE clause that simply did not contain it, and drew every
2008
+ row. That is #3650's symptom, and unlike a rejected query it looks like a
2009
+ working chart.
2010
+
2011
+ The normalizer now produces a **tree** (`normalizeAnalyticsFilterTree`), and
2012
+ each strategy compiles it the way its own backend expresses a disjunction:
2013
+
2014
+ - **`NativeSQLStrategy`** builds the WHERE recursively, routing every leaf
2015
+ through its existing clause emitter — so the storage-form coercion and the
2016
+ calendar-day upper-bound rule (#3777) apply at every depth, including inside
2017
+ an `$or`. Parentheses are explicit rather than relying on SQL precedence.
2018
+ - **`ObjectQLStrategy`** hands `$or` / `$not` to the engine, which speaks them
2019
+ natively. AND-ed leaves still merge per field exactly as before, so a query
2020
+ without combinators produces byte-identical engine input.
2021
+ - **`/analytics/sql`** renders the same tree, so the echoed statement keeps
2022
+ reproducing what executes rather than showing a conjunction where the engine
2023
+ runs a disjunction.
2024
+ - The **cross-object envelope check** now sees members nested inside an `$or`.
2025
+ It rejects cross-object filters, so a member it could not see was a filter it
2026
+ could not reject.
2027
+
2028
+ Empty `$and` / `$or` arrays now throw instead of being ignored, matching the
2029
+ fail-closed stance of `read-scope-sql.ts` — the compiler in this same package
2030
+ that has always handled the full tree, and whose semantics the tree walker now
2031
+ mirrors deliberately.
2032
+
2033
+ Cover is `native-sql-filter-logic-conformance.test.ts`, which runs the shared
2034
+ combinator table (`FILTER_LOGIC_CASES`, #3774) against a real SQLite engine and
2035
+ asserts row ids. The analytics raw-SQL path now stands beside `driver-sql`,
2036
+ `driver-memory`, `formula` and `read-scope-sql` under that one standard; 14 of
2037
+ its 17 cases fail without this change.
2038
+
2039
+ - 3167e29: fix(analytics): sort dataset selections by the display label for select/lookup dimensions (#3680)
2040
+
2041
+ `DatasetSelection.order` (what a widget's `options.sortBy` lowers to) sorted a
2042
+ `select` or `lookup`/`master_detail` dimension by its STORED value — the option
2043
+ value or the foreign-key id — while the response rows carry the resolved display
2044
+ label. A "sort by Account" therefore ordered by opaque ids and read as arbitrary;
2045
+ a localized select sorted by its ASCII value while showing a non-ASCII label.
2046
+
2047
+ Order keys naming a label-bearing dimension now sort by the display label the
2048
+ user reads. The executor receives an injected sort-key hook (`OrderLabelResolver`,
2049
+ built by `queryDataset` over the same label-resolution capabilities and #3602
2050
+ read scoping as the display pass); only the COMPARISON substitutes the label —
2051
+ rows keep their raw values until the display pass, so drill metadata still
2052
+ snapshots stored values, and ordering + windowing stay one adjacent step (a
2053
+ "top 10 by account name" truncates the right ten).
2054
+
2055
+ Cost model: sorting by a measure or a plain/date dimension is unchanged (SQL
2056
+ pushdown included). A label-ordered `select` resolves from field metadata (no
2057
+ query). A label-ordered `lookup` costs one batched id→name read over the
2058
+ pre-window grouped ids (chunked, and reused by the display pass via a
2059
+ per-request cache), and its window can no longer be pushed into SQL — the
2060
+ inherent price of ordering by a value the database doesn't store.
2061
+
2062
+ - f522e95: fix(service-analytics): the dataset raw-SQL bridge routes by object, so datasets over non-default datasources stop reading `0` (#5033)
2063
+
2064
+ `AnalyticsServicePlugin`'s `executeRawSql` auto-bridge received the object name
2065
+ and threw it away: `engine.execute(knexSql, { args: params })`. `ObjectQL.execute()`
2066
+ picks its driver in the order `options.object` → `getDriver(object)`, then
2067
+ `options.datasource`, then the default driver — so rule 1 could never fire and
2068
+ **every dataset raw-SQL read landed on the default datasource**. Any object routed
2069
+ elsewhere (the ADR-0057 §3.6 telemetry split for `lifecycle.class ∈ {audit,
2070
+ telemetry, event}`, an explicit `object.datasource`, a `datasourceMapping` rule)
2071
+ raised `no such table`, which the widget-level graceful degradation then turned
2072
+ into an empty result — a confident `0` over live rows, on a green dashboard.
2073
+ Measured: `sys_audit_log` returned 49 records through the object-routed read and
2074
+ `{"rows":[]}` through the dataset raw-SQL read, on the same running kernel.
2075
+
2076
+ The bridge now passes `{ args: params, object: objectName }`, matching the
2077
+ `executeAggregate` bridge beside it (`engine.aggregate(objectName, …)`), so both
2078
+ dataset execution paths give **one** answer to "which datasource is this object in".
2079
+ No configuration change is needed; misrouted dashboards start reading real data.
2080
+
2081
+ **Behaviour change worth knowing about.** A dataset whose SQL `LEFT JOIN`s (what
2082
+ `NativeSQLStrategy` emits for a dotted dimension such as `account.industry`) across
2083
+ two datasources previously ran against the default datasource and silently read the
2084
+ wrong database. It now runs on the base object's own datasource, where the joined
2085
+ table genuinely is not — and **fails loudly** instead of degrading, because the base
2086
+ table resolved fine and reporting it as "unavailable" would keep the confident `0`
2087
+ alive under a new cause. The error names the actual cause and the remedy:
2088
+
2089
+ ```
2090
+ [Analytics] dataset "audit_by_actor" cannot be executed as one statement:
2091
+ table "account" is not on datasource "telemetry", which is where its base object
2092
+ "sys_audit_log" lives — "account" is registered on the default datasource.
2093
+ A dataset JOIN cannot cross datasources. Fix it by binding both objects to the
2094
+ same datasource, or by dropping the cross-datasource relationship from the
2095
+ dataset's `include`/dimensions.
2096
+ ```
2097
+
2098
+ Graceful degradation is unchanged for genuine absence: a dataset whose own backing
2099
+ object (or a joined object that this kernel never registered) has no table still
2100
+ renders as "no data" with the existing server-side `warn`, rather than failing the
2101
+ widget. `AnalyticsServiceConfig` gains one optional, diagnostics-only hook —
2102
+ `getObjectDatasource(objectName)` — used solely to name the datasources in that
2103
+ message; it never selects a driver.
2104
+
2105
+ - 0a6fb1e: fix(analytics): the read-scope auto-bridge no longer depends on plugin order (#3618)
2106
+
2107
+ `getReadScope` was only wired when the `security` service already existed at this
2108
+ plugin's `init()`. The closure itself resolved lazily, but the ASSIGNMENT was
2109
+ gated on an init-time probe — so a kernel that registers `AnalyticsServicePlugin`
2110
+ before the security plugin got **no read-scope provider at all**, and every
2111
+ analytics strategy ran unscoped with only a WARN to show for it.
2112
+
2113
+ Both sibling bridges (`executeAggregate`, `executeRawSql`) are wired
2114
+ unconditionally and resolve at call time, and this one's own comment claimed the
2115
+ same. Now it actually does: the probe only decides the log wording.
2116
+
2117
+ The CLI (`os serve`) registers security before analytics, so that path was
2118
+ already correct. The exposure was for embedders composing their own kernel — and
2119
+ for this repo's own `bootStack` harness, which registers analytics first, meaning
2120
+ the entire dogfood/verify suite had analytics RLS silently disabled and any RLS
2121
+ assertion written there passed vacuously.
2122
+
2123
+ Also corrects the WARN text: with no provider, scoping is absent on ALL paths and
2124
+ ALL objects, not just "the raw-SQL path" and "joined objects" as it claimed.
2125
+
2126
+ Adds `analytics-rls.dogfood.test.ts`: an owner-scoped RLS fixture driven over real
2127
+ HTTP as a real non-admin, asserting the rows a member's aggregate actually
2128
+ returns. Reverting either this fix or the #3597 strategy fix turns it red.
2129
+
2130
+ - fb3d99b: fix(analytics,rest)!: an RLS read-scope lowering failure is a `500`, not the caller's `400` — and its policy detail no longer reaches the response (#5367)
2131
+
2132
+ **Observable behaviour change — read this if you alert, retry, or assert on status.**
2133
+ A request whose dataset carries an RLS read scope that `read-scope-sql.ts` cannot
2134
+ lower used to answer `400 DATASET_INVALID` with the refusal message echoed
2135
+ verbatim. It now answers `500 ANALYTICS_QUERY_FAILED` with the message withheld
2136
+ (`"Internal server error"`); the full text goes to the server log. Monitoring that
2137
+ counted these as client errors will see a 4xx disappear and a 5xx appear, and a
2138
+ client retrying on 5xx will now retry a request that cannot succeed until an
2139
+ administrator fixes the policy. Both follow from the correction below and are
2140
+ stated rather than buried.
2141
+
2142
+ ## What was wrong
2143
+
2144
+ These ten fail-closed refusals were the last family `/analytics/dataset/query`
2145
+ classified by **prose** — the final entry of the hardcoded message-substring list
2146
+ #5352 introduced, which #5367's first PR had already shrunk from six entries to
2147
+ one. Two defects in one verdict:
2148
+
2149
+ - **Misattribution.** `compileScopedFilterToSql(filter, alias)` receives an RLS
2150
+ `FilterCondition` the security service compiled from an **administrator's**
2151
+ sharing rule / permission set, and a join alias the **dataset compiler**
2152
+ generated. Neither is caller input — the caller's own predicate goes through
2153
+ `filter-normalizer.ts` and has answered `INVALID_FILTER` / 400 since #5352. So
2154
+ what can arrive here is a broken policy, or drift between two of our own
2155
+ components (#5557's `$regex` was literally the second case). For this request's
2156
+ caller both are a **server** fault; `400` told them to fix a request that was
2157
+ never wrong and kept the real fault out of 5xx alerting.
2158
+ - **Disclosure.** A 400 echoed the message, so
2159
+ `unsafe field identifier "secret_policy_field"` and
2160
+ `unsupported operator "$regex" on "owner_email"` handed a tenant the field names
2161
+ and comparands of the RLS policy governing them.
2162
+
2163
+ The maintainer ruled on 2026-08-06 (option B on #5367's decision card; option A
2164
+ was `READ_SCOPE_INVALID` / 422, rejected because no consumer reads a code on this
2165
+ path, a 4xx misreports a condition the client cannot fix, and 422 would have left
2166
+ the disclosure question to be re-decided message by message).
2167
+
2168
+ ## What changed
2169
+
2170
+ - `read-scope-sql.ts` gains a module-local `readScopeCompileError` — the twin of
2171
+ `filter-normalizer.ts`'s `invalidFilterError`, and likewise **the only way the
2172
+ module refuses**. All ten sites carry `READ_SCOPE_COMPILE_FAILED` / **500**.
2173
+ `:104`'s alias-vs-field split (option C on the card) collapses under B: both
2174
+ branches answer the same verdict, pinned so the collapse is a recorded decision.
2175
+ - `rest-server.ts` loses branch ② entirely. **The message-sniffing mechanism is
2176
+ fully retired** — nothing in this catch reads prose any more, and #5367's
2177
+ Prime-Directive-#12 retirement schedule ("declared, loud, tested AND removable
2178
+ on a schedule") is paid off.
2179
+ - The route's 5xx branch now withholds the message of any producer that
2180
+ **declares** a server fault (`status >= 500` with a `code`). This was needed
2181
+ rather than inherited: `looksLikeInternalErrorLeak` (#3867/#5520) is a heuristic
2182
+ over SQL/driver _phrasing_, and measured, every read-scope message returns
2183
+ `false` from it — so retiring the list alone would have moved the policy content
2184
+ from a 400 body into a 500 body instead of out of the response. Teaching that
2185
+ heuristic to recognise `[read-scope-sql]` would have been _more_ message
2186
+ sniffing, so the rule keys on the ADR-0112 envelope instead. **Undeclared** 5xx
2187
+ errors keep #5667's tiering, so a self-authored fault ("no strategy can handle
2188
+ query …") stays readable.
2189
+ - `READ_SCOPE_COMPILE_FAILED` is registered in `ERROR_CODE_LEDGER` under
2190
+ `@objectstack/service-analytics` (ADR-0112 D3) and typed as
2191
+ `RegisteredErrorCode` at the constructor, so an unregistered code is a compile
2192
+ error. It is legible on the wire through the sibling `/analytics/query` exit,
2193
+ which puts a thrown `err.code` at **`error.code`** (#3842) — read it there.
2194
+ `errorResponseBase` only stages the code inside a `details` object;
2195
+ `buildApiError` then runs `splitSemanticCode`, which promotes it into the
2196
+ declared `error.code` field and drops the now-empty `details`, so the key is
2197
+ omitted from the body and `error.details.code` is never present:
2198
+ `{"success":false,"error":{"code":"READ_SCOPE_COMPILE_FAILED","message":"Internal server error","httpStatus":500}}`.
2199
+
2200
+ **Which inputs are refused did not change.** No refusal condition moved: nothing
2201
+ that used to lower now throws, and nothing that used to throw now lowers. That is
2202
+ pinned input-by-input — refusals _and_ accepted read scopes with their compiled
2203
+ SQL and bind params — in `read-scope-refusal-envelope.test.ts`, which is green both
2204
+ before and after; only the envelope assertions move.
2205
+
2206
+ Coverage: `read-scope-refusal-envelope.test.ts` (service-analytics) drives all ten
2207
+ sites through the real compiler; `analytics-read-scope-refusal-envelope.test.ts`
2208
+ (rest) drives five policy shapes end-to-end through a real `AnalyticsService`,
2209
+ asserting the 500, that the body contains no policy detail, and that the withheld
2210
+ text is present in the log — plus a positive control and both sides of the
2211
+ declared-vs-undeclared withhold.
2212
+
2213
+ - 1eaea20: fix(service-analytics): gate the `/analytics/query` SQL echo on debug, as the contract has always declared (#8286)
2214
+
2215
+ `POST /api/v1/analytics/query` returned the executed statement to the caller in
2216
+ `data.sql` on every deployment, `NODE_ENV=production` included, with no debug
2217
+ flag requested and none available to request. The contract had declared the
2218
+ field debug-only since it was introduced — `AnalyticsResultResponseSchema`
2219
+ (`spec/api/analytics.zod.ts`) types it `optional()` and describes it as
2220
+ "Executed SQL (if debug enabled)" — but no implementation ever read a debug
2221
+ switch. This restores declared = enforced. **The contract is unchanged; the
2222
+ response now matches it.**
2223
+
2224
+ **What was disclosed.** More than table and column names. The echoed statement
2225
+ carries the compiled read scope, so it describes the SHAPE of the tenant
2226
+ isolation predicate: on the reported deployment it showed that `sys_user` is
2227
+ walled by an enumerated `"sys_user"."id" IN ($2, $3, …)` member list rather than
2228
+ by an `organization_id` comparison — that is, which column the wall is built on
2229
+ and how — plus the bound-parameter arity, which counts the caller's own
2230
+ organization's membership and hands a prober the exact query surface to work
2231
+ against.
2232
+
2233
+ **No wall was breached.** This is information disclosure and nothing more. The
2234
+ reporter ran the isolation probes on the same deployment and every one held:
2235
+ cross-tenant read answered 404, cross-tenant update and delete answered 403 at
2236
+ row-level security, a `filter`/`where` naming another organization came back
2237
+ empty, a batch write by foreign id answered per-row `PERMISSION_DENIED`, and the
2238
+ audit log and activity stream were partitioned cleanly. The wall works; it
2239
+ simply should not have been describing itself to callers.
2240
+
2241
+ **The gate is one gate.** It lives at the response-assembly seam —
2242
+ `AnalyticsService.query`, the single point every strategy's result leaves
2243
+ through — not on any one strategy. `NativeSQLStrategy` returns the statement it
2244
+ ran, `ObjectQLStrategy` renders a representative one, and the fallback delegate
2245
+ passes through whatever the service it delegates to minted (the in-memory
2246
+ analytics service always echoes); gating one of the three would have left the
2247
+ others serving. `queryDataset` reaches the same seam through `DatasetExecutor`,
2248
+ so dataset-backed dashboard and report responses inherit the verdict without a
2249
+ second gate to keep in step.
2250
+
2251
+ **The switch, and its default.** New `debugSql` option on
2252
+ `AnalyticsServicePlugin` (forwarded to `AnalyticsServiceConfig`). Unset means no
2253
+ host choice, which resolves to `NODE_ENV === 'development'` — and only that: an
2254
+ **unset** `NODE_ENV` counts as production and the echo stays off, matching how
2255
+ `os start`, `os serve` and `os doctor` already read that absence. Of the two ways
2256
+ to be wrong, disclosing on a production deployment whose operator forgot the
2257
+ variable is the dangerous one.
2258
+
2259
+ It is deliberately a HOST switch with no request field behind it: a
2260
+ caller-settable debug flag would let any tenant reopen the disclosure on demand,
2261
+ which is the shape of the defect rather than a fix for it. It is also
2262
+ deliberately separate from the plugin's existing `debug` option, which stays
2263
+ server-side log verbosity only — raising log level on a live deployment must not
2264
+ widen what travels to a tenant.
2265
+
2266
+ **Unaffected.** `POST /api/v1/analytics/sql` — the dedicated dry-run route that
2267
+ exists to hand back a statement — is not gated and behaves exactly as before; it
2268
+ is where an author debugging a widget should look. Rows, `fields`, `totals`,
2269
+ drill-through metadata, error envelopes and every gate on the query path are
2270
+ untouched, and no shipped consumer read the echo (the Studio console does not
2271
+ render it).
2272
+
2273
+ - 3abd233: fix(analytics): project a `timeDimensions` bucket into the result rows and fields (#4033)
2274
+
2275
+ An analytics query that buckets by `timeDimensions` alone grouped correctly —
2276
+ the echoed SQL read `date_trunc('month', due_date) AS "due_date"` — but the row
2277
+ mapper and `buildFieldMeta` both enumerated `query.dimensions` only, so the
2278
+ bucket never reached the caller: rows carried just the measures and `fields`
2279
+ never mentioned the dimension. A trend chart got N values and no x-axis. The
2280
+ same query written with `dimensions: ['due_date']` was unaffected, which is why
2281
+ it went unnoticed.
2282
+
2283
+ Grouping, row mapping and field metadata now derive the projected set from one
2284
+ `projectedDimensions()` helper — `dimensions` plus every _granular_
2285
+ `timeDimensions` entry not already among them. A `timeDimensions` entry without
2286
+ a granularity contributes only its `dateRange` predicate and stays out of the
2287
+ projection, so no phantom column is declared.
2288
+
2289
+ - 628b028: fix(service-analytics): thirteen caller-shaped analytics refusals answer 4xx from their own envelope instead of `500` (#5716)
2290
+
2291
+ **Observable behaviour change — read this if you alert, retry, or assert on status.**
2292
+ Thirteen refusal conditions in `service-analytics` (twelve `throw` sites — the
2293
+ cross-object measure and filter share one) used to reach the caller as
2294
+ `500 {"code":"ANALYTICS_QUERY_FAILED"}` on `POST /analytics/dataset/query`, and as
2295
+ `500 {"code":"INTERNAL_ERROR"}` on `POST /analytics/query`. They now answer **400** —
2296
+ `DATASET_INVALID` for the seven that are a verdict about the dataset or the whole
2297
+ selection, `INVALID_FIELD` for the six that name one member of the request:
2298
+
2299
+ | refusal | now |
2300
+ | ---------------------------------------------------------------- | ----------------------- |
2301
+ | dataset JOIN crosses datasources (#5115) | `DATASET_INVALID` / 400 |
2302
+ | `include` names a relationship the object does not have | `DATASET_INVALID` / 400 |
2303
+ | `include` path past the 3-hop limit | `DATASET_INVALID` / 400 |
2304
+ | a `dateRange` bound that is not a date | `DATASET_INVALID` / 400 |
2305
+ | `compareTo` names a timeDimension with no `dateRange` | `DATASET_INVALID` / 400 |
2306
+ | `compareTo` with no dated window to shift | `DATASET_INVALID` / 400 |
2307
+ | `compareTo` ambiguous between two dated windows | `DATASET_INVALID` / 400 |
2308
+ | cube declares no such measure (#4157) | `INVALID_FIELD` / 400 |
2309
+ | ObjectQL: cross-object time-dimension bucket | `INVALID_FIELD` / 400 |
2310
+ | ObjectQL: cross-object measure | `INVALID_FIELD` / 400 |
2311
+ | ObjectQL: cross-object filter | `INVALID_FIELD` / 400 |
2312
+ | ObjectQL: multi-hop cross-object dimension | `INVALID_FIELD` / 400 |
2313
+ | ObjectQL: non-recombinable measure over a cross-object dimension | `INVALID_FIELD` / 400 |
2314
+
2315
+ Monitoring that counted these as server errors will see a 5xx disappear and a 4xx
2316
+ appear, and a client retrying on 5xx will stop retrying a request that cannot
2317
+ succeed until the request or the dataset changes. **No refusal condition moved and
2318
+ no message was reworded** — the same inputs are refused, in the same words; only
2319
+ the envelope is new. (The messages are load-bearing beyond readability: #5923's
2320
+ tests assert the `planCrossObject` wording, and #5717 tracks one compiler message
2321
+ for colliding with a downstream sniffer.)
2322
+
2323
+ ## What was wrong
2324
+
2325
+ #5352 gave the dataset route a list of message SUBSTRINGS so six refusal families
2326
+ could answer 400, and #5367 retired five of those entries by giving their
2327
+ producers an ADR-0112 envelope. Both rounds worked from that list — and the list
2328
+ was only ever the refusals someone had already hit. Reading every `throw` in the
2329
+ package afterwards found thirteen more of exactly the same kind, which had never
2330
+ been on it: a typo in `compareTo`, a `dateRange` the dashboard sent, a dataset
2331
+ whose `include` names a relationship that does not exist. Each answered "the
2332
+ platform is broken" for a mistake the caller or the author could fix, on both
2333
+ analytics faces.
2334
+
2335
+ **Both faces move, measured.** `/analytics/dataset/query` reads the envelope in
2336
+ its catch (#5352); `/analytics/query` exits through
2337
+ `dispatcher-plugin.errorResponseBase`, which already adopts a thrown `status` and
2338
+ carries the `code` (#3867/#3842) — so the cross-object refusals go from
2339
+ `500 INTERNAL_ERROR` to `400 INVALID_FIELD` there as well, without touching that
2340
+ route. The open question #5811 tracks on that face is about _withholding the
2341
+ message of a declared 5xx_, which none of these are.
2342
+
2343
+ ## Why two codes
2344
+
2345
+ `dataset-refusal.ts` gains a second constructor, `invalidMemberError`
2346
+ (`INVALID_FIELD` / 400 + `member`/`param`/`cube`), beside `datasetInvalidError`.
2347
+ The split is by what the refusal is a verdict ABOUT: the dataset/selection as a
2348
+ whole, or one member the request named. The member family is `INVALID_FIELD`
2349
+ because the three shipped analytics gates already answer exactly that for the
2350
+ NEIGHBOURING member-level mistakes on the same request keys — `measures` (#4437),
2351
+ `dimensions`/`timeDimensions` (#5520), `where` (#5669) — so one class of mistake
2352
+ keeps one wire shape; and because these six fire on `/analytics/query` too, where
2353
+ there is no dataset for `DATASET_INVALID` to be about. No new code is registered:
2354
+ both are already in the ADR-0112 vocabulary.
2355
+
2356
+ ## What deliberately did NOT change
2357
+
2358
+ `native-sql-strategy`'s "measure … has unrecognised type" stays a bare `Error`
2359
+ (an undeclared 500) although #5716 listed it as author-shaped. Measured:
2360
+ `Metric.type` is the closed `AggregationMetricType` enum, `metric-type-coverage.test.ts`
2361
+ pins that the strategy handles every member of it, the dataset compiler writes
2362
+ only `SUPPORTED_AGGREGATES` into a cube, and `inferMeasure` mints six known types
2363
+ — so no spec-valid cube can reach it. An arrival is our own drift or a host
2364
+ registering an unparsed cube, and blaming the caller would hide a platform fault
2365
+ from 5xx alerting. The two "Cube not found" guards and the two operator-drift
2366
+ throws stay bare for the same reason.
2367
+
2368
+ Coverage: `unlisted-refusal-envelope.test.ts` (service-analytics) drives all
2369
+ thirteen refusals through the real producers — one block pinning that the refusal
2370
+ SET and its wording are unchanged, one pinning the envelope, one pinning the
2371
+ verdicts that stay 500; `analytics-dataset-unlisted-refusal-envelope.test.ts`
2372
+ (rest) drives eleven of them end-to-end through the route with a real
2373
+ `AnalyticsService`, plus three positive controls and the two sites that route
2374
+ cannot reach (with the measurement that explains why).
2375
+
2376
+ - b857356: fix(service-analytics): a `where` written as a `FilterArray` is lowered instead of silently dropped (#5334)
2377
+
2378
+ **Observable behaviour change.** An analytics query whose `where` arrived as an
2379
+ ARRAY had its filter **deleted**: `normalizeAnalyticsFilterTree` answered every
2380
+ array with `return null`, so no predicate was compiled, no error was raised, and
2381
+ the widget charted the **entire dataset**. The compiled SQL stayed perfectly
2382
+ valid — just broader than the author asked for — which is why it was invisible
2383
+ to every test that asserts a SQL string. The issue's own measurement:
2384
+ `generateSql({cube:'deals', measures:['total'], dimensions:['id'], where:
2385
+ [['stage','=','won']]})` emitted `SELECT id AS "id", COUNT(*) AS "total" FROM
2386
+ "deal" GROUP BY id` with an empty `params`. It now emits the bound `WHERE` and
2387
+ returns the two won deals.
2388
+
2389
+ `FilterArray` (`['stage','=','won']`, `['and', […], […]]`, `[[…], […]]`) is
2390
+ INPUT-ONLY authoring sugar (#5285), and #5158's ruling C says every door into
2391
+ the runtime lowers it through the single `parseFilterAST` sink before anything
2392
+ downstream sees a filter. #5329 closed ObjectQL's six entry points that way and
2393
+ deleted the four drivers' private array dialects. Analytics is the **fifth
2394
+ door**: it compiles `where` itself — to SQL (`NativeSQLStrategy`) or to a
2395
+ `FilterCondition` for the engine (`ObjectQLStrategy`) — so nothing upstream
2396
+ lowers for it. It now gives the same three answers the engine door gives:
2397
+
2398
+ - `[]` — "no filter", not a failed filter: no predicate, no error (unchanged).
2399
+ - A well-formed `FilterArray` — **lowered** through `parseFilterAST`, so both
2400
+ spellings of one filter select the same rows on both strategies.
2401
+ - Any other non-empty array — **refused** with `INVALID_FILTER` / 400
2402
+ (ADR-0112), the envelope the drivers' `filterArrayReachedDriverError` uses.
2403
+ This is where the undeclared INFIX form (`[condA, 'or', condB]`) lands, and
2404
+ where a list of `FilterCondition` objects (`[{stage:'won'}]`) lands — neither
2405
+ is a `FilterArray`, `parseFilterAST` has no lowering for either, and dropping
2406
+ them is what returned the unfiltered dataset.
2407
+
2408
+ Lowering rather than refusing keeps one dashboard's metadata meaning one thing:
2409
+ the same `where` on a plain `find()` already lowers at the engine door, so
2410
+ refusing it here would have forked the product by which face read the metadata.
2411
+
2412
+ - fce4c73: fix(service-analytics): an analytics `where` over a missing field answers 400 INVALID_FIELD, not a driver 500 (#5669)
2413
+
2414
+ `ensureCube` carried two source-field gates — `assertMeasureFields` (#4437,
2415
+ `param: 'measures'`) and `assertDimensionFields` (#5520,
2416
+ `param: 'dimensions' | 'timeDimensions'`) — and none for the filter face, the
2417
+ request key most likely to carry a hand-typed field name. A `where` naming a
2418
+ field the object does not have compiled straight into the statement and came
2419
+ back as a driver error with no envelope:
2420
+
2421
+ ```
2422
+ POST /analytics/query {"cube":"crm_account","measures":["count"],"where":{"bogus_col":"x"}}
2423
+ → SELECT COUNT(*) AS "count" FROM "crm_account" WHERE bogus_col = $1
2424
+ → 500 {"code":"SQLITE_ERROR","message":"Internal server error"}
2425
+
2426
+ # the control group on the same route, already fixed by #4437 / #5520
2427
+ POST /analytics/query {"cube":"crm_account","measures":["count"],"dimensions":["bogus_dim"]}
2428
+ → 400 {"code":"INVALID_FIELD","message":"Dimension 'bogus_dim' … "}
2429
+ ```
2430
+
2431
+ A driver error class as the caller's `error.code` for a caller-shaped mistake is
2432
+ the ADR-0112 fault #4437 was filed about; the `/data` route has answered the same
2433
+ typo with a field-naming 400 since #4315/#4254.
2434
+
2435
+ **The gate.** `ensureCube` now runs `assertWhereFields` after the other two on
2436
+ every path, so a filter whose source column the backing object does not have is
2437
+ refused **before** any SQL is built, with the same envelope its two siblings
2438
+ use: `INVALID_FIELD` / 400 plus `field` / `object` / `param: 'where'`, and a
2439
+ message naming the field, the valid filter members and the object's known field
2440
+ list. `query`, `generateSql` and `queryDataset` (both `runtimeFilter` and a
2441
+ dataset's own declared `filter`) are covered, and a rejected query leaves
2442
+ nothing behind in the cube registry. `/analytics/dataset/query` needed no
2443
+ change: #5352's envelope branch already carries a coded 4xx through, which the
2444
+ new REST-face test pins end to end.
2445
+
2446
+ **Field names come from the SQL producer's own reader.** The members are
2447
+ collected through `normalizeAnalyticsFilterTree` + `collectFilterLeaves` — the
2448
+ same pair both strategies call to build the predicate — rather than by walking
2449
+ the raw `where` object. So `$and`/`$or`/`$not` nesting, `$`-prefixed operator
2450
+ keys, `$between` lowering, the `{owner: {region: 'NA'}}` → `owner.region`
2451
+ flattening and the #5334 array spelling are all read exactly as they will be
2452
+ compiled, in one place, instead of in a second walker that could drift from it.
2453
+
2454
+ **What deliberately did not change:**
2455
+
2456
+ - Filtering on a REAL field the cube never declared (`where: {phone: '555'}`)
2457
+ still works — the gate asks "does the _object_ have this field", never "did the
2458
+ cube declare it".
2459
+ - A filter member resolves through `cube.dimensions` **and** `cube.measures`,
2460
+ which is what the strategies do: a cube declaring
2461
+ `measures.revenue = {sql: 'annual_revenue'}` still answers
2462
+ `where: {revenue: {$gt: 100}}` as `annual_revenue > ?`.
2463
+ - A declared member is followed to its real column, so a dimension `assessed`
2464
+ over column `assessed_at` is not judged by its own name.
2465
+ - `id` / `created_at` / `updated_at` stay admitted unconditionally, matching the
2466
+ data path's `resolveQueryFields`.
2467
+ - An expression `sql` (on the cube or on a member), a dotted relation traversal,
2468
+ and a host that wires no field-name probe are all stood down on, exactly as the
2469
+ measure and dimension gates stand down.
2470
+ - The `INVALID_FILTER` family is untouched. A `where` the normalizer refuses
2471
+ outright — an unknown operator, a zero-operator field constraint, an
2472
+ unlowerable filter array — is _not_ judged here: the gate stands down and the
2473
+ refusal stays where it already happens (#5352 / #5367's geography). A field
2474
+ gate that cannot read the tree has nothing to say about it, and pulling those
2475
+ refusals forward would also have newly refused them on the draft-preview path,
2476
+ whose matcher never consults the normalizer.
2477
+
2478
+ - 1986594: feat(analytics): honour widget `dateGranularity`, `sortBy`/`sortOrder`, and `limit` in the dataset query (#3588)
2479
+
2480
+ Three presentation options were accepted by the metadata layer and then dropped
2481
+ by the analytics query builder. They reached no SQL, produced no error, and the
2482
+ only way to notice was to read the `sql` a dataset response echoes — so a
2483
+ dashboard could declare `dateGranularity: 'month'` and quietly render one bar
2484
+ per record.
2485
+
2486
+ - **`dateGranularity` now buckets.** `DatasetSelection` gained an optional
2487
+ `dateGranularity`, applied to every selected `date` dimension. Precedence per
2488
+ dimension: an explicit `timeDimensions` granularity, then the selection's,
2489
+ then the dataset dimension's own default. A widget can bucket a trend by month
2490
+ without the dataset committing every other consumer to that granularity.
2491
+ - **`order` / `limit` / `offset` now apply on every path.** They are applied to
2492
+ the ASSEMBLED grid — after measure-scoped sub-queries merge, after `compareTo`
2493
+ columns attach, and after derived measures are computed — so a derived measure
2494
+ is a valid sort key and the ObjectQL aggregate path (which has no ordering
2495
+ grammar, and which native SQL hands every date-bucketed query to) orders
2496
+ identically to native SQL. A single-query selection still pushes the window
2497
+ down into the statement. An `order` key that names nothing the selection
2498
+ projects is now rejected (400) rather than silently ignored.
2499
+ - **`limit` is deterministic.** Without an `order`, a limit orders by the
2500
+ selected dimensions first, so it truncates a reproducible window instead of an
2501
+ arbitrary subset.
2502
+ - **Widget `options` is a contract again.** The four query-affecting keys
2503
+ (`dateGranularity`, `sortBy`, `sortOrder`, `limit`) plus `stageOrder` are
2504
+ declared on `DashboardWidgetOptionsSchema`, so a typo like `sortDirection` is
2505
+ an author-time error. The bag stays open — renderer extras (`icon`, `columns`,
2506
+ `striped`, …) pass through untouched.
2507
+
2508
+ Two latent bugs surfaced while fixing the above and are fixed here too:
2509
+
2510
+ - `order`/`limit` were forwarded to EVERY sub-query. A measure-scoped
2511
+ supplementary query selects one measure, so an inherited `ORDER BY` named a
2512
+ column it never selected, and an inherited `LIMIT` truncated it before the
2513
+ merge — dropping rows from the assembled grid. Nothing hit this only because
2514
+ nothing passed `order`.
2515
+ - The `compareTo` pass built its query by hand and skipped granularity
2516
+ resolution, so a month-bucketed primary grid was merged against raw-timestamp
2517
+ comparison rows. No dimension key matched and every `<measure>__compare`
2518
+ column came back empty.
2519
+
2520
+ `ObjectQLStrategy` now also echoes a representative `sql` (with `date_trunc`,
2521
+ `WHERE`, `ORDER BY`, and `LIMIT`; filter values parameterized, never inlined).
2522
+ Previously the `sql` field simply vanished from the response whenever a query
2523
+ was date-bucketed, leaving an author unable to tell "not implemented" from "this
2524
+ strategy doesn't report".
2525
+
2526
+ - f6385c7: fix(service-analytics): a `timeDimensions` entry used only as a date WINDOW no longer buckets the grid (#5688)
2527
+
2528
+ **Observable behaviour change — read this if you render, page, or assert on
2529
+ dataset responses.** A selection that used a date dimension only as a window —
2530
+ `timeDimensions: [{ dimension, dateRange }]` with no `granularity`, and the
2531
+ dimension NOT listed in `selection.dimensions` — used to have the dataset
2532
+ dimension's declared `dateGranularity` filled in anyway. That made the entry a
2533
+ `GROUP BY` item, so the response grew a time column nobody selected and every
2534
+ row split per bucket. "Count by Owner" plus a dashboard date-range filter came
2535
+ back as "by Owner × month":
2536
+
2537
+ ```
2538
+ before fields [owner, close_date, opp_count]
2539
+ rows [{owner:'u1', close_date:'2026-01', opp_count:1},
2540
+ {owner:'u1', close_date:'2026-02', opp_count:1},
2541
+ {owner:'u2', close_date:'2026-01', opp_count:1}]
2542
+
2543
+ after fields [owner, opp_count]
2544
+ rows [{owner:'u1', opp_count:2},
2545
+ {owner:'u2', opp_count:1}]
2546
+ ```
2547
+
2548
+ Both the **row count and the column set** change for such a selection: the extra
2549
+ month column disappears and rows that were split per bucket collapse back into
2550
+ one row per selected dimension tuple. A KPI single-value card that was reading
2551
+ the first of several month rows now reads the only row. Consumers that pinned
2552
+ the previous shape (a snapshot of `fields`, a row count, a hard-coded column
2553
+ index) need updating; consumers that render the response's own `fields` do not.
2554
+
2555
+ Three conditions had to hold together to be affected, so a selection outside
2556
+ them is byte-identical: the dataset dimension declares an explicit
2557
+ `dateGranularity`, the `timeDimensions` entry states no `granularity`, and
2558
+ `selection.dateGranularity` is unset.
2559
+
2560
+ **What still buckets, unchanged.** An entry is bucketed when the request says
2561
+ that date is being bucketed: the dimension is one of the selection's own
2562
+ `dimensions`, the entry carries its own `granularity` (#4033 — still projected
2563
+ as a column even when not selected), or `selection.dateGranularity` is set. The
2564
+ granularity _precedence_ chain is untouched. A dataset dimension's
2565
+ `dateGranularity` says how that date renders **when** grouped — it is no longer
2566
+ read as a request to group by it.
2567
+
2568
+ **`compareTo` alignment (#3588/#4870) holds by construction.** The comparison
2569
+ pass re-enters the same query builder with the same grid dimensions, differing
2570
+ only in the shifted `dateRange`, so both passes bucket an entry alike or not at
2571
+ all — never one of each, which was the state that left every `__compare` column
2572
+ empty. For a window-only anchor this **repairs** the comparison rather than
2573
+ preserving it: the merge has always keyed on `selection.dimensions` alone, so
2574
+ the backfilled bucket column sat outside the merge key, and with several
2575
+ month-split rows per group the comparison value landed on whichever row the
2576
+ index held last while the others read a confident `0`.
2577
+
2578
+ Also fixed, same root cause: a time column that IS projected via
2579
+ `timeDimensions` (an entry carrying its own `granularity`, never listed under
2580
+ `dimensions`) now carries its dataset `label` in `fields` instead of a bare
2581
+ `type` — the label enrichment walked `selection.dimensions` only.
2582
+
2583
+ - 344a22a: refactor(plugin-audit)!: retire `export` and `permission_change` from the `sys_audit_log` action enum — two declared actions nothing has ever written (#8147, #7675, ADR-0049/ADR-0087)
2584
+
2585
+ <!-- adr-0087: registered audit-log-action-enum-retired -->
2586
+
2587
+ **BREAKING** (shipped as `minor` under the launch-window lockstep convention).
2588
+
2589
+ `sys_audit_log.action` declared ten actions. Two of them named events this
2590
+ platform does not record, and has never recorded. Enumerating every
2591
+ `sys_audit_log` writer in the repo finds exactly two:
2592
+
2593
+ - `plugin-audit/src/audit-writers.ts` — the generic hook writer, whose
2594
+ `actionFor()` maps `afterInsert`/`afterUpdate`/`afterDelete` to
2595
+ `create`/`update`/`delete` and **nothing else**;
2596
+ - `plugin-auth/src/admin-import-users.ts` — the admin user-import run-level row.
2597
+
2598
+ Neither has ever emitted `export` or `permission_change`. The cost was not a
2599
+ dormant string: `sys_audit_log` ships **list views** filtered on those values and
2600
+ the platform dashboard ships **metric widgets** counting them, so an operator got
2601
+ a permanently empty "Permission Changes" tile and an Auth view whose filter could
2602
+ never match, while an auditor reading the enum believed the platform captured
2603
+ permission changes and data exports. That is false compliance on a compliance
2604
+ surface — the sharpest form of ADR-0049 declared-≠-enforced.
2605
+
2606
+ Maintainer ruling 2026-08-12 (#7675) split the finding in two: build the cheap
2607
+ writers (`login`/`logout` in #8144, `config_change` in #8145) and retire the enum
2608
+ values with no feature behind them. 原则记录:空 widget + 永远查不到东西的过滤器
2609
+ 是可见产品缺陷;审计面宁窄勿谎。
2610
+
2611
+ ### Migration: FROM → TO
2612
+
2613
+ | Wrote | Write instead |
2614
+ | :------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2615
+ | a filter, saved query or dashboard on `action = 'permission_change'` | filter the permission objects' own `create` / `update` rows by `object_name` — a grant or binding write is an ordinary record write and the generic writer already ledgers it |
2616
+ | a filter, saved query or dashboard on `action = 'export'` | delete it — no export feature ever wrote an audit row, so it returned nothing on every deployment |
2617
+ | a `switch` / badge map with arms for either value | delete those arms; an exhaustive `switch` over the action type now fails to compile if they stay |
2618
+
2619
+ Every such query returned an empty result set before this change and returns the
2620
+ same empty result set after it. What changed is that the contract stops promising
2621
+ otherwise.
2622
+
2623
+ ⚠️ **Existing rows are untouched and must stay untouched.** The enum is not
2624
+ enforced on this object — `validateRecord` skips `readonly` fields and every
2625
+ `sys_audit_log` field is `readonly: true` — so stored history parses and reads
2626
+ back exactly as written. Audit history is append-only; do not migrate or delete
2627
+ rows to satisfy a schema narrowing.
2628
+
2629
+ ### Also in this change
2630
+
2631
+ - `auth_events` list view: filter narrowed to `['login', 'logout']`.
2632
+ - `config_changes` list view: `export` dropped from the filter.
2633
+ - `plugin-audit`'s generated translation bundles regenerated for all four locales.
2634
+ - ADR-0087 registration as the semantic migration `audit-log-action-enum-retired`
2635
+ (D3 step 17). An enum-VALUE retirement, so nothing lands in
2636
+ `RETIRED_KEYS_BY_MAJOR` and the four surface ratchets are byte-identical by
2637
+ construction — no authorable key and no def changed.
2638
+
2639
+ ### `import` is deliberately NOT retired
2640
+
2641
+ The 2026-08-12 ruling named `import` alongside the other two on the stated
2642
+ premise 无此 feature. That premise is measurably false and the value stays:
2643
+ `plugin-auth`'s admin user-import writes a real run-level row on every run
2644
+ (`action: 'import'`, `record_id: null`), pinned by case W4 of
2645
+ `packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts`. Retiring
2646
+ it would make the enum deny a value the platform writes — and silently, since
2647
+ the enum is unenforced here. Referred back for a maintainer ruling on #8147.
2648
+
2649
+ - 0af50a3: fix(driver-sql,service-analytics): a bare-day upper bound covers the whole day on `Field.datetime` (#3777)
2650
+
2651
+ A bare `YYYY-MM-DD` comparand anchors to midnight UTC. That is right for a
2652
+ lower bound and was silently wrong for an upper one: the dashboard date-range
2653
+ filter compiles `{ $gte: from, $lte: to }` with bare-day bounds, so on a
2654
+ `datetime` column every row created after 00:00 of the `to` day vanished from
2655
+ the result — no error, the chart renders, the numbers are just smaller. The
2656
+ default configuration hit it: the filter's default field is `created_at`
2657
+ (a system-injected `Field.datetime`) and 7 of the 13 presets end "today".
2658
+
2659
+ The translation is operator-sensitive and half-open, applied at every
2660
+ comparison emitter:
2661
+
2662
+ - `SqlDriver` (and `SqliteWasmDriver` by inheritance): `$lte`/`<=` with a
2663
+ bare-day comparand on a `datetime` column compiles to `< next-day-midnight`
2664
+ in the column's storage form; `$between [min, max]` with a bare-day max
2665
+ decomposes to `>= min AND < next-day(max)`. Both the plain and the
2666
+ legacy-repair (mixed-storage) column paths, both `where` spellings.
2667
+ - `NativeSQLStrategy`: `dateRange` windows and `lte` filters bind `< next-day`
2668
+ instead of an inclusive `BETWEEN`/`<=` when the bound is a bare day.
2669
+ - The `/analytics/sql` rendering and the dataset preview evaluator apply the
2670
+ same rule, so the echoed SQL and drafted numbers reproduce execution.
2671
+
2672
+ `@objectstack/core` gains the shared primitive `nextUtcCalendarDay(value)`:
2673
+ the next calendar day of a valid bare `YYYY-MM-DD` (else `null` — instants,
2674
+ `Date`s and impossible days are never widened).
2675
+
2676
+ Unchanged on purpose, per the semantics table on #3777: `date`/`time` columns
2677
+ (`<= day` is already whole-day-correct there), full-ISO/`Date` comparands
2678
+ (instant semantics), and `$gte`/`$gt`/`$lt` (midnight anchoring is correct for
2679
+ those). No authored metadata changes: a dashboard's existing
2680
+ `{ $gte, $lte }` window now simply includes its final day.
2681
+
2682
+ - 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261)
2683
+
2684
+ The AGENTS.md post-task checklist requires breaking changesets to carry their
2685
+ FROM → TO migration because "this text ships to consumers as `CHANGELOG.md`
2686
+ inside the npm package and is what an upgrading agent greps after the tombstone
2687
+ error." That delivery path was severed for 68 of the 69 publishable packages:
2688
+ npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike
2689
+ older npm versions — not `CHANGELOG.md`, and the canonical
2690
+ `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm
2691
+ 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its
2692
+ 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it
2693
+ explicitly.
2694
+
2695
+ The tombstone-error scenario is precisely the one where the repo is out of
2696
+ reach — the upgrading agent has `node_modules` and nothing else — so the
2697
+ migration text has to ride in the tarball. Every publishable package now
2698
+ declares `CHANGELOG.md` in `files`, and the canonical whitelist is
2699
+ `["dist", "README.md", "CHANGELOG.md"]`.
2700
+
2701
+ The other half is the gate: `check:published-files` gains a fifth invariant,
2702
+ COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the
2703
+ always-required lint job, so the next package cannot silently sever the path
2704
+ again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves
2705
+ into the canonical set.
2706
+
2707
+ Consumer-visible change: one more file per install (the package's changelog,
2708
+ e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key"
2709
+ node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was
2710
+ promised.
2711
+
2712
+ - 8e2bbba: fix(service-analytics): `compareTo` 在「日期维度本身就是网格维度」时把比较桶键平移回当期 (#6007)
2713
+
2714
+ 趋势图 + 同比是 `compareTo` 最常见的形状:日期维度既写进 `selection.dimensions`
2715
+ (它就是图表的时间轴),又被 `compareTo` 用作锚点。这个形状下比较趟从来没有对齐过。
2716
+
2717
+ 比较趟查询的是**平移后**的窗口,所以它的行按平移后的桶键落地;而
2718
+ `mergeByDimensions` 按 `selection.dimensions` 元组建键 —— `2025-01` 不等于
2719
+ `2026-01`,于是**没有一条**比较行合并得进去,全部作为新行追加。两趟各自只报告了自己
2720
+ 那一半,`fillEmptyGroups` 把另一半填成自信的 `0`,再加上平移后的桶键坐在网格里,而它们
2721
+ 落在调用方筛选窗口之外。一个 2 桶窗口的「今年 vs 去年同期」回来是这样的:
2722
+
2723
+ ```
2724
+ [{"close_date":"2025-01","opp_count__compare":5,"opp_count":0},
2725
+ {"close_date":"2025-02","opp_count__compare":7,"opp_count":0},
2726
+ {"close_date":"2026-01","opp_count":1,"opp_count__compare":0},
2727
+ {"close_date":"2026-02","opp_count":2,"opp_count__compare":0}]
2728
+ ```
2729
+
2730
+ 四行、每行一个 0、两行在窗口外;期望是 2 行 × 2 列。
2731
+
2732
+ **修法(维护者裁决 2026-08-07,方向 1):合并之前,把每个比较桶键用当期的说法重述一遍。**
2733
+ 上例现在返回 `[{close_date:'2026-01',opp_count:1,opp_count__compare:5},
2734
+ {close_date:'2026-02',opp_count:2,opp_count__compare:7}]`。
2735
+
2736
+ - `previousYear` —— 窗口是按日历年平移的,所以逆运算就是按日历年往前推一年:对桶自己的
2737
+ 首日做平移再重新分桶。`2025-01` → `2026-01`、`2025-Q1` → `2026-Q1`、
2738
+ `2025-W03` → `2026-W03`。它刻意是 `shiftRange` 那套年运算的精确逆运算(含
2739
+ `setUTCFullYear` 的溢出行为),窗口与桶键因此不可能对「一年」有两种理解。
2740
+ - `previousPeriod` —— 任意天数窗口没有日历对应物,所以按**桶序(bucket ordinal)**对齐:
2741
+ 上一窗口的第 n 个桶对上本窗口的第 n 个桶,n 各自从自己窗口的起点数起。序号由**日历**算出
2742
+ 而不是数组下标,所以本期网格里某个桶没有数据(存在空档)不会让其后每个桶都错位一格。
2743
+
2744
+ **响应形状不变** —— 仍然是 `<measure>__compare` 列,行仍然是网格维度元组,所以消费端
2745
+ (objectui#3337 正在收敛的那条契约)不受影响。
2746
+
2747
+ 不确定时一律**保持原样**(即改动前的行为),而不是猜:空桶(两条聚合路径上键都是 `null`,
2748
+ 两趟本来就互相合并)、未分桶的日期维度(分组的是原始时间戳,不是桶键)、以及平移回来落在
2749
+ 当期窗口之外的桶(两个等长的天数窗口可以切出不同的桶数)。
2750
+
2751
+ 范围严格限定在坏掉的那个形状:锚点必须是**网格维度**(仅作窗口的锚点两趟都不是列,#5688
2752
+ 之后本来就对齐)且必须**被分桶**。两趟通过同一个 `granularityOf` 读取桶大小,所以这里重述
2753
+ 的桶大小按构造就是查询分组用的桶大小。
2754
+
2755
+ - 8dbd2a8: fix(service-analytics): dataset 响应的 `fields` 在「度量全部自带 filter」的路径上也描述维度列 (#5537)
2756
+
2757
+ 一个 dataset 查询,只要它的**基础度量全部带有自身的 `filter`**(或它选中的 derived
2758
+ 度量的依赖全部如此),响应里的 `fields` 就只剩度量列,被选中的维度**完全没有描述符**。
2759
+ 维度值一直都在 `rows` 里(它就是合并键),但读取列元数据的消费者拿不到维度列的
2760
+ `label` 与 `type`,只能退回去 humanize 原始行键。
2761
+
2762
+ HotCRM「Sales Performance」上肉眼可见:同一个声明了 `label: 'Owner'` 的 `owner` 维度,
2763
+ "Open Pipeline by Owner"(度量无 filter)表头是 `Owner`,而 "Win / Loss by Rep"
2764
+ (`won_count`/`lost_count` 各带 filter、`win_rate` 是 ratio)表头是小写 `owner`。
2765
+ 换成字符串维度 `lead_source` 看起来正常纯属巧合 —— humanize 后恰好等于真 label;
2766
+ 两种维度的描述符其实都丢了。
2767
+
2768
+ 根因在网格装配处,不在渲染端:`DatasetExecutor.runMeasurePass` 只有在存在**无 filter**
2769
+ 度量时才发那条主查询;当每个基础度量都自带 filter 时,它从 `{ rows: [], fields: [] }`
2770
+ 起步,而随后每个补充子查询只追加一个**度量**描述符。现在这种情况下,维度描述符取自
2771
+ **第一个补充子查询自己的结果** —— 它 group by 的维度与整个网格完全一致 —— 因此两条路径
2772
+ 的 `fields` 形状(维度在前、顺序、`type`)按构造收敛,而不是靠 executor 再抄一份
2773
+ 「哪些维度被投影」的规则(该规则的单一事实源在各 strategy 的 `buildFieldMeta`,#4033)。
2774
+
2775
+ `compareTo`、`totals` 与 derived 度量都经由同一条 pass,所以一并修好。
2776
+
2777
+ 已知的相邻缺口**不在**本次修复范围,单独立了 #5688:一个只带 `dateRange` 的
2778
+ `timeDimensions` 条目会被补上 dataset 的默认粒度,于是「窗口」变成第二层 GROUP BY,
2779
+ 网格被按月拆分、并多出一个没人选过的时间列(该列在 `fields` 里也拿不到 `label`)。
2780
+ 它在两条路径上表现一致(本次修复前后皆然),且修它会改变响应形状,故不搭车。
2781
+
2782
+ - ab54608: fix(service-analytics): a dataset `label` written as an inline locale map reaches the wire resolved, instead of being dropped (#6761)
2783
+
2784
+ `I18nLabelSchema` has authorized two forms of a display label since #5728: a
2785
+ plain string, and an inline locale map `{ en: 'Owner', 'zh-CN': '负责人' }`. The
2786
+ analytics producer only understood the first one, so a dataset written the way
2787
+ the schema documents came back with **no label at all**:
2788
+
2789
+ | dataset declares | `fields[]` carried, before |
2790
+ | ------------------------------------------- | -------------------------- |
2791
+ | `label: 'Owner'` | `label: 'Owner'` |
2792
+ | `label: { en: 'Owner', 'zh-CN': '负责人' }` | _(no `label` key)_ |
2793
+ | _(no label)_ | _(no `label` key)_ |
2794
+
2795
+ Measured identically on both strategies. All three renderers that read
2796
+ `fields[].label` first — `DatasetWidget`, `DatasetPreview`,
2797
+ `DatasetReportRenderer` — then fell back to humanizing the raw key, so a Chinese
2798
+ deployment authoring exactly what the spec documents got English-ish machine
2799
+ names for its column headers.
2800
+
2801
+ One layer earlier, `dataset-compiler` substituted the machine **name** for the
2802
+ same map (`typeof d.label === 'string' ? d.label : d.name`), which additionally
2803
+ made `/analytics/meta` publish `title: 'owner'` as a _display title_ — a face
2804
+ that lied rather than one that was merely bare.
2805
+
2806
+ Both are fixed by calling the shared `I18nLabel → string` resolver
2807
+ (`resolveI18nLabel`, `@objectstack/spec`, #6765), which is pinned in its own
2808
+ package to rule parity with objectui's `pickLocalized`. Nothing is
2809
+ re-implemented here: the maintainer's ruling on #6761 chose one shared resolver
2810
+ precisely so the two ends cannot answer the same authored map differently.
2811
+
2812
+ **The wire is unchanged.** `AnalyticsResult.fields[].label` is still
2813
+ `string | undefined` on both ends — this resolves _to_ a string rather than
2814
+ widening the contract, so no consumer changes and no map can reach a renderer
2815
+ that would print `[object Object]`.
2816
+
2817
+ **Which locale each site uses:**
2818
+
2819
+ - `queryDataset`'s two field-enrichment sites resolve at
2820
+ `ExecutionContext.locale` — the per-request BCP-47 tag derived from the
2821
+ caller's `Accept-Language`, falling back to the workspace `localization`
2822
+ setting. Both sites read one hoisted value, so a single response cannot mix
2823
+ two audiences.
2824
+ - `dataset-compiler` resolves with **no** locale, i.e. the resolver's documented
2825
+ nullish answer `en`. A compiled Cube is a registry artifact shared by every
2826
+ later reader, and `getMeta()` — the `/analytics/meta` face — takes no
2827
+ execution context at all; baking a request locale there would make
2828
+ `/analytics/meta` answer whoever queried last.
2829
+
2830
+ **Nothing is invented on a miss.** A label the resolver cannot resolve (an
2831
+ absent label, or an empty map) writes no `label` key on the wire at all — a
2832
+ placeholder would permanently pre-empt the real label under the downstream
2833
+ `if (field.label == null)` guard. In the compiler, where `Metric.label` /
2834
+ `Dimension.label` are required strings, the machine-name fallback is unchanged
2835
+ from before; it never reaches `fields[]`, so it cannot pre-empt anything either.
2836
+
2837
+ - c8124e5: fix(driver-sql): give `Field.datetime` one UTC storage form per dialect (#3912, #3942)
2838
+
2839
+ Any window filter on a `Field.datetime` column returned an empty set on SQLite —
2840
+ a dashboard `dateRange: last_30_days` on `created_date` read 0 while 29 matching
2841
+ rows existed.
2842
+
2843
+ There was never a storage _convention_, only a description of what better-sqlite3
2844
+ happened to do with a bound JS `Date`. Nothing enforced it — `formatInput`
2845
+ deliberately left `datetime` untouched — so the form was decided by whichever
2846
+ writer got there first: a JS `Date` landed as INTEGER epoch ms, while a REST/JSON
2847
+ write (JSON has no `Date` type), a `defaultValue: 'NOW()'` slot, and the
2848
+ platform's own `created_at` / `updated_at` all landed as ISO **TEXT**. One column
2849
+ held both forms while the read path coerced comparands to epoch ms purely from
2850
+ the _declared_ type. On SQLite's type ordering (`INTEGER < TEXT`) a two-sided
2851
+ window collapsed to zero rows, and a one-sided `>=` matched every TEXT row
2852
+ regardless of the bound.
2853
+
2854
+ `Field.datetime` now has one canonical instant per dialect, produced by one
2855
+ function applied on write **and** to every filter comparand, so the two sides of
2856
+ a comparison cannot disagree about shape:
2857
+
2858
+ - **SQLite** — `YYYY-MM-DDTHH:MM:SS.sssZ` text. Lexicographic order _is_
2859
+ chronological order, so range filters and `ORDER BY` read the column directly
2860
+ and can use an index; `strftime` parses it, so the date-bucket expression needs
2861
+ no CASE.
2862
+ - **Postgres** — `timestamptz`, unchanged. The fix here is on the write and
2863
+ comparand side: a zone-naive write was previously resolved against the
2864
+ _server's_ timezone (measured 8 hours off on `Asia/Shanghai`), and an
2865
+ un-anchored `YYYY-MM-DD` comparand meant the server's local midnight, so the
2866
+ identical query over the identical instant landed a row on a different calendar
2867
+ day than SQLite did.
2868
+ - **MySQL** — `DATETIME(3)` instead of `TIMESTAMP`, a connection pinned to UTC on
2869
+ both the mysql2 and the server layer, and a MySQL-spelled bind carrying the
2870
+ same UTC wall clock. MySQL accepts neither the `T` separator nor the `Z` suffix
2871
+ in a datetime literal, so datetime writes over REST had always failed outright;
2872
+ `TIMESTAMP` additionally truncated milliseconds and could not store an instant
2873
+ outside 1970..2038.
2874
+
2875
+ Existing rows converge at schema sync. Both migrations are allowed to fail: they
2876
+ log, mark nothing, and the read paths keep a repair expression, so an un-migrated
2877
+ column still compares and buckets **correctly** — just unindexed. Neither can
2878
+ repair instants the old timezone-ambiguous write path recorded wrongly; they
2879
+ preserve what is on disk.
2880
+
2881
+ Also closes #3928 (datetime `ORDER BY` mis-sorted on mixed storage) by
2882
+ construction. Rationale is recorded as ADR-0053 addendum D-B1..D-B4.
2883
+
2884
+ The analytics change is additive: a `coerceTemporalFilterColumn` companion to the
2885
+ existing `coerceTemporalFilterValue` hook, so a raw-SQL strategy can normalise the
2886
+ column side too. Absent hook → byte-identical SQL.
2887
+
2888
+ - 6fde910: fix(objectql,service-analytics): report the datasource an object is actually on, not the one it declares (#5288)
2889
+
2890
+ Analytics' `getObjectDatasource` probe read `getObject(name).datasource` — the
2891
+ object's **declared** value, which is step 1 of the five `ObjectQL.getDriver`
2892
+ resolves by. `ObjectSchema.datasource` carries `.default('default')`, and
2893
+ `'default'` means "no explicit binding, keep looking" inside the engine, so
2894
+ every object placed by a `datasourceMapping` rule, by the ADR-0057 §3.6
2895
+ lifecycle split, or by its package's `defaultDatasource` answered `'default'`
2896
+ and was read out here as "the primary DB".
2897
+
2898
+ `sys_audit_log` is the live specimen: `lifecycle.class: 'audit'` puts it on the
2899
+ `telemetry` datasource with nothing declared to read. So #5033's query-time
2900
+ diagnostic — whose entire job is to NAME the database a table is missing from —
2901
+ named the wrong one:
2902
+
2903
+ ```
2904
+ before: table "account" is not on datasource "default", which is where its base object "sys_audit_log" lives
2905
+ after: table "account" is not on datasource "telemetry", which is where its base object "sys_audit_log" lives
2906
+ ```
2907
+
2908
+ **New engine accessor — `ObjectQL.resolveEffectiveDatasource(objectName)`.** The
2909
+ public, name-only face of the resolution order `getDriver` already routes by,
2910
+ extracted so the order exists exactly once (the same argument that produced
2911
+ `resolveMappedDatasource` in #4462: a second, shorter copy of a routing order
2912
+ drifts by one step, silently). `getDriver` now consumes the same resolver and
2913
+ keeps every existing behaviour — precedence, the refusal to fall through to the
2914
+ default store when a declared or mapped datasource has no live driver, and both
2915
+ of its diagnostics.
2916
+
2917
+ It answers `undefined` when nothing binds the object anywhere and it simply
2918
+ rides the deployment's default driver. That is deliberate and unchanged from
2919
+ what consumers already documented: the default driver keeps its natural name
2920
+ (#3826), so that name identifies a driver rather than a datasource anyone bound
2921
+ the object to. `getDefaultDriverName()` is still there for callers that want it.
2922
+
2923
+ Analytics' probe now asks the engine instead of the declaration; the routing
2924
+ rules are **not** re-implemented on the analytics side. #5115's compile-time
2925
+ cross-datasource join gate keeps its predicate exactly as written — what changed
2926
+ is that its input can now answer for objects bound by a mapping rule, by the
2927
+ lifecycle split, or by a package default, so a join between two bound
2928
+ datasources is refused at registration instead of exploding at query time. A
2929
+ join from a bound object to one that merely rides the deployment default is
2930
+ still not decidable at compile time and remains the query-time diagnostic's
2931
+ business.
2932
+
2933
+ - a227ed7: fix(objectql)!: one key for the empty group bucket — real `null`, on both aggregation paths (#3839)
2934
+
2935
+ A grouped row whose dimension value is empty now carries `null` for that
2936
+ dimension no matter which way the aggregate ran. Downstream code can test the
2937
+ empty bucket with a plain `value == null` again: charts render their own empty
2938
+ label, drill-through on that bucket builds `field = null` and returns the rows
2939
+ it should, and a dashboard no longer changes shape when the driver, the
2940
+ granularity or the reference timezone changes.
2941
+
2942
+ ### What was wrong
2943
+
2944
+ `engine.aggregate` has two implementations of one feature. It pushes the
2945
+ aggregate down as SQL when the driver advertises every requested granularity and
2946
+ the reference timezone is UTC; otherwise it fetches rows and buckets them in JS.
2947
+ The two disagreed about how to spell "empty":
2948
+
2949
+ ```
2950
+ --- same dataset, same query, one row with a NULL value ---
2951
+ pushed-down SQL : [{ "key": null, "type": "null", "total": 2 }, …]
2952
+ in-memory : [{ "key": "(null)", "type": "string", "total": 2 }, …]
2953
+ ```
2954
+
2955
+ The measures were always right — only the key's type and literal differed —
2956
+ which is why this went unnoticed for so long: every total reconciled. But the
2957
+ engine picks a path per query, so the same data produced a different bucket key
2958
+ on SQLite-plus-UTC-plus-`month` than on `week` (which SQLite does not advertise),
2959
+ a non-UTC timezone, or `driver-rest` / `driver-memory` / a remote Turso, all of
2960
+ which bucket in memory unconditionally.
2961
+
2962
+ It was never date-specific either. A plain `groupBy: ['stage']` over a NULL
2963
+ column diverged the same way.
2964
+
2965
+ Consumers are written against `null` — they check `== null` and supply their own
2966
+ empty label ('—', '(empty)', a localized "Uncategorized"). The sentinel defeated
2967
+ every one of them: it rendered a raw English debug string in the UI, and a drill
2968
+ on the empty bucket compiled to `field = '(null)'` and matched nothing.
2969
+
2970
+ The in-memory path's comment justified the string as staying "consistent with
2971
+ the client `useReportData` hook". That hook was removed with ADR-0021, and the
2972
+ literal never appeared in it.
2973
+
2974
+ ### What changed
2975
+
2976
+ - `applyInMemoryAggregation` and `bucketDateValue` (`@objectstack/objectql`) key
2977
+ the empty bucket as `null`. `bucketDateValue` now returns `string | null`. A
2978
+ null instant and an unparseable one still share one bucket, because SQL cannot
2979
+ tell them apart either (`strftime('%Y-%m', 'not-a-date')` is NULL).
2980
+ - The internal composite bucket id is JSON-encoded, so the empty bucket stays
2981
+ distinct from a row whose value is the literal string `"null"`.
2982
+ - `bucketKeyToCalendarRange` (`@objectstack/core`) accepts `string | null`. The
2983
+ empty bucket has no calendar span, so a drill on it opens the unscoped
2984
+ superset instead of an invented bound — unchanged behavior, honest signature.
2985
+ - The driver output contract in `@objectstack/spec` now states the rule: a row
2986
+ with no value keys as `null`, never a sentinel. Propagating NULL through the
2987
+ bucket expression is the whole of it; a driver only breaks it by adding a
2988
+ `COALESCE`.
2989
+
2990
+ ### Gates
2991
+
2992
+ `checkDateBucketParity` (`@objectstack/verify`) deliberately carried no null
2993
+ instant, because the divergence would have failed it for a reason it was not
2994
+ about. Its fixture now has one, so the convergence is held in place — including
2995
+ for out-of-tree drivers that run the check against themselves.
2996
+
2997
+ Two fixes were needed to make that fixture meaningful:
2998
+
2999
+ - The check folded bucket labels through `String(value)`, which turns SQL NULL
3000
+ into `'null'` — a label a TEXT column can genuinely hold. A driver spelling
3001
+ "empty" as a string could compare equal to one returning real NULL. The empty
3002
+ bucket is now keyed out of band.
3003
+ - Label sets were compared with `JSON.stringify`, which is sensitive to key
3004
+ insertion order. Row order is not part of this contract and the two paths
3005
+ naturally differ (SQL sorts its groups; the in-memory path emits first-seen
3006
+ order), so a driver with entirely correct buckets could be reported as
3007
+ disagreeing — with an empty diff message, since nothing actually differed.
3008
+ The comparison is now order-insensitive.
3009
+
3010
+ A new dogfood check covers the non-date half against real drivers: same dataset,
3011
+ plain and date-bucketed `groupBy`, both paths, one key.
3012
+
3013
+ - 1a19e9d: fix(service-analytics): fence `$icontains` comparands on the analytics `where` door (#7693)
3014
+
3015
+ `$icontains` was the one text-pattern operator the #5234 comparand fence never
3016
+ covered on the analytics `where` door. It arrived after the fence: #6520 added
3017
+ it to `filter-normalizer.ts`'s `MONGO_TO_CUBE_OP` and gave `read-scope-sql.ts`'s
3018
+ arm its `assertRenderableText` call, but not the entry in `comparand-shape.ts`'s
3019
+ `TEXT_PATTERN_OPERATORS` — the set the `where` door's shape gate reads. So one
3020
+ operator had **two answers inside one package**. Measured on `origin/main` @
3021
+ `b54aaab`:
3022
+
3023
+ | filter | analytics `where` door | `read-scope-sql` |
3024
+ | -------------------------------- | -------------------------------------------------------------- | ------------------------------------------- |
3025
+ | `{name: {$contains: {foo: 1}}}` | REFUSED (`INVALID_FILTER` / 400) | REFUSED (`READ_SCOPE_COMPILE_FAILED` / 500) |
3026
+ | `{name: {$icontains: {foo: 1}}}` | **compiled** — `NativeSQLStrategy` bound `'%[object Object]%'` | REFUSED |
3027
+
3028
+ The compiled statement was the #5234 defect verbatim: a parameterised,
3029
+ syntactically perfect `LIKE` pattern nobody wrote, which a row whose text really
3030
+ is `[object Object]` matches. `driver-sql`'s own `TEXT_PATTERN_OPERATORS` has
3031
+ listed the operator since #6520, and #7158 closed the same gap at objectql
3032
+ `having`; this closes the third and last face.
3033
+
3034
+ **What changes for a caller.** A malformed `$icontains` comparand — an object, a
3035
+ `{$field}` reference, or an array — on the `/analytics` `where` door is now
3036
+ refused with `INVALID_FILTER` / 400 and the same sentence `$contains` gets,
3037
+ instead of compiling into a pattern that matches the wrong rows. A well-formed
3038
+ comparand is untouched: strings, numbers, `null`, booleans and `Date`s compile
3039
+ exactly as before, ASCII fold and metacharacter escaping included. The
3040
+ read-scope door is unchanged — it already refused these shapes.
3041
+
3042
+ Held by `__tests__/cross-field-reference-refusal.test.ts`, where #7598's
3043
+ RECORDED GAP pin is flipped to assert the refusal and the shared `#5222` corpus
3044
+ is now driven whole (its `$icontains` case no longer has to be filtered out),
3045
+ and by the fifth member added to the LIKE-family loops in
3046
+ `__tests__/comparand-shape-refusal.test.ts`. Reverse-verified against the whole
3047
+ package: deleting the entry turns exactly those five `where`-door cells red and
3048
+ leaves every read-scope and narrowness control green.
3049
+
3050
+ - 88a6bed: fix(service-analytics): an ad-hoc cube's dimensions no longer depend on how the `where` was spelled (#5353)
3051
+
3052
+ `inferCubeFromQuery` mints a Cube for a free-form analytics query that names no
3053
+ registered cube, seeding `dimensions` from the fields the query mentions — its
3054
+ `measures`, `dimensions`, `timeDimensions`, and its `where`. The `where` arm was
3055
+ guarded by `!Array.isArray(query.where)`, written when an array `where` was not a
3056
+ filter. #5334 made it one, so from then on one filter minted two different cubes
3057
+ depending on its spelling:
3058
+
3059
+ ```
3060
+ where: {stage: 'won'} → dimensions: {stage} ← seeded
3061
+ where: [['stage','=','won']] → dimensions: {} ← skipped
3062
+ ```
3063
+
3064
+ The `where` is now LOWERED to its canonical `FilterCondition` before its keys are
3065
+ read, so the spelling stops mattering. The lowering is the same one the
3066
+ strategies already use (#5334's `parseFilterAST` call, extracted from
3067
+ `normalizeAnalyticsFilterTree` as `lowerAnalyticsWhere` so there is still exactly
3068
+ one of it), and the keys are read through `conjunctFieldKeys`, which descends
3069
+ `$and` — necessarily, because the lowering itself introduces `$and` where the
3070
+ object spelling has none: `[[a,…],[b,…]]` lowers to `{$and: [{a…},{b…}]}`. As a
3071
+ result an explicit `{$and: […]}` object `where` now also seeds its conjuncts'
3072
+ keys, which it never did.
3073
+
3074
+ `$or` / `$not` are not descended, and contribute no key on either spelling, as
3075
+ before.
3076
+
3077
+ **No compiled statement, bound value or gate verdict changes.** Both spellings
3078
+ already compiled a byte-identical predicate (which is why this shipped as an
3079
+ observation rather than a defect): `resolveFieldSql` falls back to the bare
3080
+ column name for an undeclared member, and `qualifyAndRegisterJoin` leaves bare
3081
+ columns bare on a cube with no `joins` — which an inferred cube never has. So the
3082
+ newly-declared dimensions move those members from the undeclared branch to the
3083
+ declared one and both yield the same column. What does change is the suggestion
3084
+ list in a rejection: `Valid filter members:` / `Valid dimensions:` now read the
3085
+ same for both spellings of one filter, and `getMeta` reports the same dimension
3086
+ vocabulary for both.
3087
+
3088
+ **Still spelling-dependent: a DOTTED `where` key.** `{'owner.region': 'NA'}`
3089
+ seeds the stripped tail `region` as a base-table dimension; the array spelling
3090
+ `[['owner.region','=','NA']]` seeds nothing and compiles the relation traversal.
3091
+ Unifying them is #5739's call, not this change's — propagating the mint to the
3092
+ array spelling turns a working traversal into a base-column filter over different
3093
+ rows (and a `400 INVALID_FIELD` where the base table has no such column), while
3094
+ withdrawing it from the object spelling would split a verdict #5740 deliberately
3095
+ shares with the `dimensions` request key. Dotted keys therefore keep today's
3096
+ per-spelling answer, pinned by tests, until #5739 rules.
3097
+
3098
+ - a6b3ee7: fix(service-analytics): 即席推断的 Cube 把 `owner.region` 当成关系穿越,不再铸成基表列 `region` (#5739)
3099
+
3100
+ `inferCubeFromQuery` 为「没有注册 Cube 的自由查询」即席合成一个 Cube,并从查询提
3101
+ 到的字段里播种 `dimensions`。每个铸造点都先把成员过一遍 `stripPrefix` —— 一个把
3102
+ **任何**点号名的首段剥掉的判定。对 `<cube>.` 限定符(`crm_account.industry` →
3103
+ `industry`)这是对的;对**关系穿越**则不是:`owner.region` 被铸成
3104
+ `dimensions.region = { sql: 'region' }`,一个**基表列**。下游 `lookupMember` 的
3105
+ 「plain second-segment」那一档随即命中它,**赶在**「synthetic relation traversal」
3106
+ 那一档把点号路径交给 JOIN 机制之前就返回了 —— 关系穿越被基表列遮蔽。
3107
+
3108
+ 危害分两档,而更糟的是安静的那一档。当基表**恰好有同名列**时(`crm_account` 自己
3109
+ 就有 `region`),四个组合全部静默通过、无任何拒收:
3110
+
3111
+ ```
3112
+ ① ObjectQL, where: {'owner.region':'NA'} → executeAggregate 收到 {"region":"NA"}
3113
+ ② NativeSQL, where: {'owner.region':'NA'} → … FROM "crm_account" WHERE region = $1
3114
+ ③ ObjectQL, dimensions: ['owner.region'] → groupBy: ["region"]
3115
+ ④ NativeSQL, dimensions: ['owner.region'] → SELECT region AS "owner.region" … GROUP BY region
3116
+ ```
3117
+
3118
+ 行数与图表都是错的,而没有任何错误可读 —— ④ 尤甚:响应列名标着 `owner.region`,值
3119
+ 却来自基表,读者无法从结果里看出来。基表**没有**同名列时则落到 `400 INVALID_FIELD`
3120
+ 且点名 `region`,而调用方写的是 `owner.region`。
3121
+
3122
+ 维护者 2026-08-06 裁定(issue #5739):即席路径**支持**关系穿越。铸造改为**原样**
3123
+ (`dimensions['owner.region'] = { sql: 'owner.region' }`),真正的 `<cube>.` 限定
3124
+ 前缀(首段 == cube 名)仍然剥。这同时收敛了一处早有的分叉:同一个过滤器写成数组
3125
+ (`[['owner.region','=','NA']]`)时铸不出 dimension,于是一直走 synthetic 档、一直
3126
+ 编出正确的 JOIN —— 两种写法现在逐字生成同一条语句。
3127
+
3128
+ **Observable behaviour change —— 若你按状态码告警/重试,或消费即席 cube 的元数据,
3129
+ 请读这一段。**
3130
+
3131
+ - **对象写法的点号 member 从「静默错列」/「`INVALID_FIELD` 指错名」变为 JOIN 穿越。**
3132
+ NativeSQL 上 `where: {'owner.region': 'NA'}` 与
3133
+ `dimensions: ['owner.region']` 现在编出
3134
+ `LEFT JOIN "owner" ON "crm_account"."owner" = "owner"."id"` 并按 `"owner"."region"`
3135
+ 筛选/分组;此前它们筛/分组的是基表 `region`(有同名列时),或以
3136
+ `400 INVALID_FIELD "constrains field 'region'"` 被拒(无同名列时)。**同一个请求
3137
+ 现在返回的行可能与此前不同 —— 此前那些行是错的。**
3138
+ - **ObjectQL 上同一个 member 改为响亮拒收或正确穿越,不再有第三种更安静的答案。**
3139
+ `where` 得到 `cannot evaluate a cross-object filter ("owner.region")` —— 与**已
3140
+ 注册 cube** 上的既有答案逐字一致;`dimensions` 走 FK-expand 正确穿越,返回关联对象
3141
+ 的值。带 `granularity` 的跨对象 `timeDimensions` 得到
3142
+ `cannot bucket a cross-object time dimension`。
3143
+ - **即席 cube 的 `dimensions` 词汇表里现在出现点号键**(`getMeta` 上是
3144
+ `crm_account.owner.region`)。此前该穿越要么以剥掉的尾段出现(`crm_account.region`),
3145
+ 要么(数组写法)完全不出现。
3146
+ - **不变的部分**:真正的 `<cube>.` 限定符照旧剥除;裸列名照旧是基表列(基表自己的
3147
+ `region` 仍可作为 `region` 分组);#4437 / #5520 / #5669 三道源字段闸门的代码一行未
3148
+ 动,它们对裸名拼错的 `400 INVALID_FIELD` 拒收原样保留;点号 **measure**(如
3149
+ `total.sum`)仍按 #4437 的 `400 INVALID_FIELD` 拒收 —— `lookupMember` 的 synthetic
3150
+ 穿越档是 dimension-only,dotted measure 没有可收敛的穿越答案。
3151
+
3152
+ - 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.
3153
+ - 49f208b: fix(analytics): an `undefined` comparand in an analytics `where` is refused (400 `INVALID_FILTER`), not read seven different ways
3154
+
3155
+ **Observable behaviour change.** A `where` key whose value is `undefined` used to
3156
+ compile — in seven different ways, depending on where it sat. It is now refused
3157
+ with `INVALID_FILTER` / 400, the envelope every other refusal at this door
3158
+ already carries.
3159
+
3160
+ The three that mattered WIDENED the query, which is the failure mode
3161
+ `filter-normalizer.ts` forbids in its own body ("NEVER drop: a missing predicate
3162
+ does not narrow the query, it WIDENS it"), while its entry line did exactly that:
3163
+
3164
+ | `where` | used to normalize to | reading |
3165
+ | ------------------------------ | -------------------------------- | -------------------------------------------------------- |
3166
+ | `{d: undefined}` | `null` | the WHOLE filter dropped — the query ran **unfiltered** |
3167
+ | `{stage: 'won', d: undefined}` | `stage equals 'won'` | the `d` conjunct vanished in silence |
3168
+ | `{$not: {d: undefined}}` | `NOT (d set)` | `d IS NULL` — a predicate the author never wrote |
3169
+ | `{d: {$eq: undefined}}` | `d equals [null]` | a value comparison, **not** `$eq: null`'s null predicate |
3170
+ | `{d: {$gt: undefined}}` | `d gt [null]` | ditto |
3171
+ | `{d: {$in: [undefined]}}` | `d in [null]` | ditto |
3172
+ | `{d: {$ne: undefined}}` | `d notSet OR d notEquals [null]` | ditto |
3173
+
3174
+ The direction is silently **wrong results** — an analytics figure, a report
3175
+ total, an aggregate, wrong with nothing to read — **not** a permission bypass:
3176
+ read scope is compiled by a different door (`read-scope-sql.ts`) and never passed
3177
+ through here, so a caller still saw only rows it was entitled to, just more of
3178
+ them than it asked for.
3179
+
3180
+ **What to change if this refuses your filter.** `undefined` cannot cross JSON, so
3181
+ neither REST door can carry it — this only reaches in-process callers of
3182
+ `AnalyticsService.query({ where })` that spread a possibly-absent value into the
3183
+ filter object (`{ owner_id: ctx.user?.id }`). Two repairs, both stated by the
3184
+ error message:
3185
+
3186
+ - meant the null predicate → write `{ field: null }` or `{ field: { $null: true } }`;
3187
+ - the value is genuinely absent → **omit the key**, which is the same "no
3188
+ constraint" without the ambiguity.
3189
+
3190
+ Inside stored metadata, the platform's own answer to "scope this to the current
3191
+ user" is unaffected and was already fail-closed: a `{current_user_id}`
3192
+ placeholder resolves through `resolveFilterTokens`, which raises
3193
+ `FILTER_TOKEN_UNRESOLVED` / 400 rather than emitting `undefined`.
3194
+
3195
+ ⛔ **`null` does not move.** `{d: null}`, `{$eq: null}`, `{$ne: null}`,
3196
+ `{$null: …}`, `{$exists: …}` and `$contains: null` keep their exact lowering —
3197
+ `null` is a declared comparand and is the null predicate. `$null` / `$exists`
3198
+ carry a declared boolean flag rather than a comparand and are likewise untouched.
3199
+
3200
+ - ff39e63: fix(service-analytics): 维度合并键不再把「未分配」并进「空白」,并改为长度前缀消歧 (#4821)
3201
+
3202
+ `mergeByDimensions` 是每一份多查询 dataset 结果的装配缝:主查询与每个带 `filter`
3203
+ 的 measure 的补充子查询在这里对齐,`compareTo` 窗口自 #4870 起也按 measure 扇出后
3204
+ 经由同一个缝合并回来。这里一次键碰撞不会报错 —— 一个分组静默吸走另一个分组的数字,
3205
+ 网格仍然保持看起来合理的行数和列数。
3206
+
3207
+ **#4821 报告的机制与实际的缺陷不完全一致,先把这一点说清楚。** 原键是
3208
+ `String(row[d] ?? '')` 以一个**直接写进源码的裸 U+0001 字节**相连。裸控制字符渲染
3209
+ 为空,所以 issue 正文读到的是 `join('')`,其头号复现(`['ab','c']` 与 `['a','bc']`
3210
+ 同键为 `"abc"`)其实并不成立 —— 分隔符一直在,只是看不见。真正咬人的是另外两条:
3211
+
3212
+ - `?? ''` 让**真正为 null** 的维度与**空字符串**维度键成同一个值。于是「未分配」被
3213
+ 并进「空白」:一行吞掉另一行的 measure,另一行的列则整个缺失 —— 而 #4708 的空组
3214
+ 填充随后会给它填上一个理直气壮的 `0`。一个真实计数为 3 的分组因此显示为 0。
3215
+ - 单字符分隔符只在「没有任何维度**值**包含该字符」时才无歧义。维度值是用户数据
3216
+ (文本字段、导入记录),所以那是一个假设而非保证,且一旦不成立同样静默。
3217
+
3218
+ **改法:长度前缀 + 显式空值哨兵。** 每段编码为 `<长度>:<值>`,`2:ab1:c` 与
3219
+ `1:a2:bc` 对任意输入都不同,不再保留任何字符、也不再有看不见的字节留给下一个读者
3220
+ 误读(本 issue 正是这样被误读出来的)。null/undefined 单独走一个哨兵段,与消歧这件
3221
+ 事解耦。
3222
+
3223
+ **逐段的 `String()` 强制被刻意保留**,这与一文件之隔的 `cross-object-rebucket.ts`
3224
+ 的 JSON 键不是同一笔交易:后者重新分桶的是**同一个查询**的行,一列只有一种类型,
3225
+ JSON 在那里免费且能换来真实的区分(空桶 `null` vs 字面量字符串 `"null"`)。本函数
3226
+ 做的是相反的事 —— 跨**不同查询**对齐行,而驱动确实会对同一个分组返回不同的 JS 类型
3227
+ (本文件 `compareValues` 的注释即记着 "numeric strings, which is how some drivers
3228
+ return SUM results")。改用 `JSON.stringify` 会把 `1` 与 `"1"` 渲染成两个键,让今天
3229
+ 能正确合并的行不再合并 —— 用一个新的静默缺陷换掉旧的,不算修好。该行为已有回归钉
3230
+ 测试锁住。
3231
+
3232
+ 仅影响内部合并键,响应中的任何值都不改变。
3233
+
3234
+ - 2604d34: fix(analytics): a field constraint mixing `$` operators with non-`$` sibling keys is refused (400 `INVALID_FILTER`), not silently narrowed to its operators
3235
+
3236
+ **Observable behaviour change.** A `where` field wrapper that carries `$`-operator
3237
+ keys and non-`$` keys at once used to compile its operators and silently DROP
3238
+ every non-`$` sibling. It is now refused with `INVALID_FILTER` / 400, the
3239
+ envelope every other refusal at this door already carries. Ruled Option A
3240
+ (refuse) on #6444, 2026-08-08; Option B (flattening the siblings as nested
3241
+ paths) was rejected because it would compile the likely-real cause — a dropped
3242
+ `$` — into a predicate on a non-existent member such as `amount.gte`.
3243
+
3244
+ | `where` | used to normalize to | reading |
3245
+ | ----------------------------------------- | ------------------------- | --------------------------------------------------- |
3246
+ | `{d: {$eq: 1, nested: 'x'}}` | `d equals [1]` | the `nested` conjunct vanished in silence |
3247
+ | `{amount: {gte: 10, $lte: 20}}` | `amount lte 20` | the missing-`$` typo: the lower bound silently gone |
3248
+ | `{$not: {d: {$null: true, nested: 'x'}}}` | `NOT(d set AND d notSet)` | a contradiction that negates to TRUE — every row |
3249
+
3250
+ Every row WIDENED the query — a dropped conjunct returns rows the author
3251
+ excluded, with nothing to read (the #3650 family this module refuses everywhere
3252
+ else). Unlike #6386's `undefined` comparand, this shape survives JSON, so it can
3253
+ sit in stored dashboard / report / dataset metadata as well as in-process
3254
+ callers of `AnalyticsService.query({ where })`.
3255
+
3256
+ **What to change if this refuses your filter.** The message names the offending
3257
+ key(s) and both repairs, because the shape has two readings this door cannot
3258
+ tell apart:
3259
+
3260
+ - an operator missing its `$` was meant → spell it with the prefix
3261
+ (`gte` → `$gte`: `{ "amount": { "$gte": 10, "$lte": 20 } }`);
3262
+ - a nested-relation member was meant → give it a wrapper of its own with no `$`
3263
+ siblings (`{ "d": { "nested": "x" } }` compiles to the member `d.nested`) and
3264
+ AND it with the operator constraint explicitly via `$and`.
3265
+
3266
+ ⛔ **The two pure shapes do not move.** A wrapper that is all `$`-operators
3267
+ compiles exactly as before (`{amount: {$gte: 10, $lte: 20}}` stays the AND of
3268
+ its bounds), and a wrapper that is all non-`$` keys keeps flattening to the
3269
+ dotted member (`{d: {nested: 'x'}}` → `d.nested`). `$null` / `$exists` flag
3270
+ semantics, the `null` comparand rulings (#5332 / #5526) and the sibling door
3271
+ `read-scope-sql.ts` — which has always failed closed on this shape — are
3272
+ untouched.
3273
+
3274
+ - adabaa8: fix(analytics): fail closed on cross-object aggregation the ObjectQL path cannot join (#3654)
3275
+
3276
+ `engine.aggregate()` has no join — it never expands a lookup and the SQL driver's
3277
+ aggregate emits no `JOIN`. So a dotted dimension/measure like `account.region`
3278
+ reaching `ObjectQLStrategy` (the fallback NativeSQL declines: date-granularity
3279
+ bucketing, in-memory driver, federated objects) failed SILENTLY: the in-memory
3280
+ path bucketed every row under one `(null)` group and summed the whole table into
3281
+ it (a plausible number that is actually a mislabelled full-table total), and the
3282
+ native path errored on the unresolved column.
3283
+
3284
+ `ObjectQLStrategy` now rejects any cross-object reference outright, with a clear
3285
+ message, before the query reaches the engine. This generalizes the #3597 guard
3286
+ (which only rejected when the joined object carried a read scope, and skipped the
3287
+ check entirely when no read-scope provider was configured — so the silent
3288
+ `(null)` bucket still shipped on unsecured/in-memory setups) into an
3289
+ unconditional one, and subsumes it: a rejected query never loads the joined
3290
+ object, so there is nothing left unscoped.
3291
+
3292
+ Cross-object datasets are unaffected on `NativeSQLStrategy`, which hand-compiles
3293
+ the LEFT JOINs (and scopes each). This only changes the fallback path, turning a
3294
+ silent wrong answer into a loud, actionable error. Full lookup-traversal support
3295
+ in the aggregate path is left as follow-up (see #3654).
3296
+
3297
+ - 605c23f: fix(analytics): ObjectQLStrategy applies `timeDimensions[].dateRange` — the predicate every date-bucketed chart was missing (#3650)
3298
+
3299
+ `ObjectQLStrategy.execute()` built its engine filter purely from
3300
+ `normalizeAnalyticsFilters(query)`, which reads only `query.where`. But
3301
+ `dateRange` is a **sibling** of `where`, never folded into it — so the window
3302
+ was dropped on the floor. No error, no warning: the chart rendered, and the
3303
+ numbers were for all of history.
3304
+
3305
+ This was not a "some drivers only" corner. `NativeSQLStrategy.canHandle`
3306
+ declines any query carrying a `granularity`, so a **date-bucketed trend lands on
3307
+ the ObjectQL path on every driver**, Postgres and SQLite included — and a
3308
+ bucketed trend is precisely the shape that also carries a range ("last 12
3309
+ months", "this quarter"). The other two paths always applied it
3310
+ (`NativeSQLStrategy` as `BETWEEN`, `preview-evaluator` row-wise); only this one
3311
+ did not.
3312
+
3313
+ **Two visible symptoms:**
3314
+
3315
+ - A trend chart with a time filter plotted **every row ever recorded** instead
3316
+ of the selected window.
3317
+ - `compareTo` (period-over-period) was **structurally dead**. `runCompare`
3318
+ builds the comparison pass by shifting `dateRange` and changing nothing else,
3319
+ so with the window ignored both passes issued a byte-identical aggregate:
3320
+ every `<measure>__compare` column equalled its primary and the delta was a
3321
+ flat 0%. And since `compareTo` requires a time dimension, it always took this
3322
+ path.
3323
+
3324
+ The window now lowers to an inclusive `{$gte, $lte}` on the resolved field — the
3325
+ same shape `NativeSQLStrategy` binds as `BETWEEN` and the memory driver builds
3326
+ as a `$match` — so one dashboard reads the same on every driver. No storage
3327
+ coercion is applied here on purpose: unlike the raw-SQL path (which had to learn
3328
+ about SQLite's INTEGER epoch in #2034), this path goes through
3329
+ `engine.aggregate()`, where the driver's own CRUD filter coercion already
3330
+ handles a `where` bound on that same column.
3331
+
3332
+ **Same-field composition was fixed alongside it**, because the window makes it
3333
+ routine. Operands merged into one field entry by spreading, which silently kept
3334
+ whichever came last: a `where` bound and a window bound on `close_date` would
3335
+ have had one erase the other, and a `where` that names one field twice through
3336
+ `$and` (`{$and: [{stage: 'won'}, {stage: {$ne: 'lost'}}]}`) already lost its
3337
+ first operand today. Operands that name **different** operators still share one
3338
+ entry; colliding ones become their own `$and` conjunct, so the engine
3339
+ intersects them instead of the strategy picking a winner.
3340
+
3341
+ `generateSql()` renders the window as a parameterised `BETWEEN` to match — its
3342
+ comment previously explained why a `BETWEEN` was deliberately absent, which was
3343
+ correct only while `execute()` dropped the window. Bounds bind as `$n`
3344
+ placeholders, never inlined: the echoed statement travels to the browser.
3345
+
3346
+ A window on a **cross-object** time dimension is still rejected, and is now
3347
+ reported as the bucketing error it is rather than as the "cross-object filter"
3348
+ its lowered predicate would otherwise resemble. `execute()` and
3349
+ `/analytics/sql` continue to accept and reject the same set.
3350
+
3351
+ Relative-phrase ranges ("Last 7 days") are still not resolved on this path, and
3352
+ a bare-string `dateRange` degenerates to a single point — both matching
3353
+ `NativeSQLStrategy` exactly, rather than inventing a second interpretation for
3354
+ the driver-independent path.
3355
+
3356
+ - be7360c: chore(plugins,services): declare `providesServices` on the 20 remaining init-time service providers (ADR-0116 follow-up, #4131)
3357
+
3358
+ ADR-0116 gave the kernel a declared ordering contract, but only
3359
+ `ObjectQLPlugin` and `MetadataPlugin` had declared what their `init()`
3360
+ registers. The pre-Phase-1 ordering check can only _name a provider_ for
3361
+ services someone declared, so its coverage was two plugins wide.
3362
+
3363
+ An audit of every plugin's `init()` body (brace-matched, comments stripped,
3364
+ each call classified by whether it sits inside a `try`/`if`) found 20 plugins
3365
+ that register a service on every path without declaring it. All 20 now
3366
+ declare `providesServices`. Purely additive: no ordering changes, no new
3367
+ failure modes — a `providesServices` entry only lets the kernel say _who_
3368
+ provides a service when it reports a misordering, and enriches the Phase-1
3369
+ `getService` miss diagnostic.
3370
+
3371
+ Three needed a closer read before declaring, because they register the same
3372
+ service from several branches (`cache`, `queue`, `job`): each early-return
3373
+ branch plus the fallback registers it, so every path does — the declaration
3374
+ is honest. ADR-0116's rule that a _conditionally_ registered service must
3375
+ never be declared is unchanged and was applied throughout.
3376
+
3377
+ The same audit found 12 plugins that hard-resolve a service during `init()`
3378
+ (11 of them `manifest`) without declaring `requiresServices`. None is a live
3379
+ exposure — every one already declares a hard `dependencies` entry on the
3380
+ provider, so the kernel orders them correctly today. Those are tracked
3381
+ separately: with a hard dependency in place, `requiresServices` mostly
3382
+ restates what the kernel already enforces, and its real value is on
3383
+ _soft_-dependency consumers, of which `AppPlugin` is currently the only one.
3384
+
3385
+ - 3cc8676: fix(analytics): read scope 里非布尔的 `$null` / `$exists` 比较数改为拒收,不再按真值性编成相反的谓词 (#6387)
3386
+
3387
+ **⚠️ 行为变更。** `compileScopedFilterToSql` 遇到 `$null` / `$exists` 上的非布尔比较数,从「按 JS 真值性归入两个声明答案之一、静默编出合法 SQL」改为 `READ_SCOPE_COMPILE_FAILED` / **500** 拒收。今天靠这个静默翻转在跑的 read scope,从此会响亮地失败。
3388
+
3389
+ ## 实测到的毛病
3390
+
3391
+ 发射器读的是 `val ? … : …` —— **真值性**,不是 `@objectstack/spec` `FieldOperatorsSchema` 声明的 `z.boolean()`。在 `5faa23ca3` 上直接调 `compileScopedFilterToSql`,alias `t`:
3392
+
3393
+ | read scope | 编译结果 | |
3394
+ | ------------------------------------ | ---------------------------- | ------------------------- |
3395
+ | `{ owner_id: { $null: "false" } }` | `"t"."owner_id" IS NULL` | ⛔ 与作者写的意思**相反** |
3396
+ | `{ owner_id: { $null: "true" } }` | `"t"."owner_id" IS NULL` | |
3397
+ | `{ owner_id: { $null: 0 } }` | `"t"."owner_id" IS NOT NULL` | |
3398
+ | `{ owner_id: { $null: null } }` | `"t"."owner_id" IS NOT NULL` | |
3399
+ | `{ owner_id: { $null: undefined } }` | `"t"."owner_id" IS NOT NULL` | |
3400
+ | `{ owner_id: { $exists: "false" } }` | `"t"."owner_id" IS NOT NULL` | ⛔ 与作者写的意思**相反** |
3401
+ | `{ owner_id: { $exists: 0 } }` | `"t"."owner_id" IS NULL` | |
3402
+ | `{ owner_id: { $exists: "no" } }` | `"t"."owner_id" IS NOT NULL` | |
3403
+
3404
+ 两行 ⛔ 是要害:字符串 `"false"` 是**真值**,于是它落在它被写下来所要表达的 `false` 的**对面** —— `{ $exists: "false" }` 写来表示「没有 owner 的行」,编出来是「**有** owner 的行」。这与 #6125 那一格方向相反:那边是 fail-**closed**(匹配零行、只是安静),这边是**加宽** —— admit 了策略要排除的行,出现在一个自述「A read-scope predicate must never be silently dropped、fail-closed」的模块里。
3405
+
3406
+ ## 修法
3407
+
3408
+ 按 #5347(`$null`)/ #5369(`$exists`)在 `driver-sql` 面确立的先例,理由逐字适用:非布尔比较数**按声明拒收**,不做强转。闸落在 `compileField`,紧挨 #6125 的 `undefined` 闸 —— 两道闸的作用域互不相交(那一道按名字跳过这两个算子),所以谁也盖不住谁的措辞。
3409
+
3410
+ 两个算子**共用一条措辞**(#5240「一个条件一种措辞」),只有算子名与 `path` 不同:`driver-sql` 给孪生实现两条措辞,是因为各自要指名**自己**发射器默认倒向哪边;本模块只有一条规则(真值性)同时管着两个算子,两者失败方式完全一样,所以一条措辞才是诚实的写法。测试里有一条断言把「只有这两处不同」钉死。
3411
+
3412
+ 信封沿用本模块自述的那一个(`READ_SCOPE_COMPILE_FAILED` / 500),不是 #5347 的 `INVALID_FILTER` / 400:read scope 由平台自己从 CEL 与库存 metadata 编出来,报 400 等于让调用方去修一个他既没写、也改不动的东西。继承的是**处置**(拒收),不是信封。
3413
+
3414
+ 极性表**同 PR 一起改**:`nullValueSatisfiesOperator` 的 `$null` / `$exists` 两臂从真值性(`Boolean(value)` / `!value`)改为恒等(`value === true` / `value === false`)。每张极性表钉的是它**自己**发射器的拼写(#5146 / #5298),只改发射器不改表,不变量会安静地断在定义处。这条差异消失后,本编译器与 `driver-sql` 的同名表第一次逐臂一致。
3415
+
3416
+ ## ⚠️ 触达性:实测结论是**库存 metadata 走不通**
3417
+
3418
+ 定级依据是测量,不是立单时的措辞。`{ $null: <非布尔> }` **无法**从库存 metadata 走到本编译器,三道闸各自独立关死:`RowLevelSecurityPolicySchema` 把 `using` / `check` 声明为 `z.string()`(CEL 谓词,不是 FilterCondition),存对象直接被拒;CEL 下降只在两处发射 `$null` 且比较数是**硬编码布尔**(`== null` → `{$null: true}`,`!= null` → `{$null: false}`),`$exists` 一次都不发射;绕开 schema 塞裸对象会在 `sqlPredicateToCel` 里抛错,被 `getReadFilter` 的 catch 变成 `RLS_DENY_FILTER`。其余 read scope 生产者(Layer 0 租户过滤、`plugin-sharing` 的 `buildReadFilter`、controlled-by-parent、deny 哨兵)压根不含这两个算子。
3419
+
3420
+ **仍然开着的那条**:`getReadScope` 是 `AnalyticsPluginOptions` 上有文档的公开扩展点,宿主自带的 read scope(来自 JSON 配置或没走类型检查的 JS)与本编译器之间没有任何闸 —— 本单也确认了 `plugin-security` 全路径无 `FilterConditionSchema` / `safeParse`。所以:今天不从库存 metadata 触达,但没有任何结构性的东西挡住下一个生产者。在编译器处拒收,才让「声明为布尔」等于「强制为布尔」,与谁写这条 scope 无关。
3421
+
3422
+ ## ⛔ 一字未动的邻居
3423
+
3424
+ - **合法布尔**:`$null: true/false`、`$exists: true/false` 的 SQL 逐字节不变(`IS NULL` 下降正是 RLS 用来圈无主行的写法,也是 CEL 唯一能产出的四种形状)。有自己的对照组回归 pin。
3425
+ - **比较数位置上的 `null`**:`{ d: null }`、`{ $eq: null }`、`{ $ne: null }`、`$in: [null]` 等 #6125 的 `NULL_CONTROL` 全部保持绿。
3426
+ - `driver-sql` / `driver-turso`(#5347 / #5369 已落地)、`packages/spec`(声明已是 `z.boolean()`)、以及本包的 `where` 门 `strategies/filter-normalizer.ts` 均未触碰。
3427
+
3428
+ - 2cca98b: fix(service-analytics): 分析查询的 RLS read scope 不再被 `{ $not: {} }` 整表放行,`$not` 改为 NULL-safe
3429
+
3430
+ **这是一次安全相关的行为变更,涉及分析查询的可见行集合。请读完再升级。**
3431
+
3432
+ ### 变更一(要害):`{ $not: {} }` 的 read scope 以前**完全不加 WHERE**,整表可见;现在是零行
3433
+
3434
+ `read-scope-sql.ts` 是 RLS / 租户 read scope 降解成 SQL 的**唯一**通道(ADR-0021 D-C),
3435
+ 被 `NativeSQLStrategy.applyReadScope` 与 `ObjectQLStrategy` 用来给分析查询加可见性约束。
3436
+ 它以空字符串表示「无约束」(布尔常量 TRUE)。`compileNode({})` 返回空串,于是:
3437
+
3438
+ ```
3439
+ compileNode({}) → '' → if (inner) 为假 → $not 不产出任何子句
3440
+ → compileScopedFilterToSql 返回 ''
3441
+ → applyReadScope 的 `if (!sql) return;` 接手
3442
+ → 生成的 SQL 里没有 WHERE
3443
+ ```
3444
+
3445
+ 一条语义为 `NOT TRUE ≡ FALSE`(**什么都不给看**)的 read scope,实际效果是**整张表都给看**。
3446
+ 同一段循环里 `$and` / `$or` 的空数组一直是 fail-closed 抛错的,只漏了 `$not` 这一格。
3447
+
3448
+ 修复后 `{ $not: {} }` 编译为恒假子句 `1 = 0`,`applyReadScope` 照常拼进 WHERE,返回零行 ——
3449
+ 与 driver-sql 在 #5134 / PR #5243 上的口径一致。
3450
+
3451
+ **升级影响:** 如果你的 RLS 策略(或 `cel-to-filter.ts` 降解出的 CEL 规则)在某条路径上
3452
+ 产出过 `{ $not: {} }`,该对象的分析查询此前是**无边界**的,现在会返回零行。行数从「全部」
3453
+ 掉到「零」不是本次引入的收紧,而是那条策略本来就该有的答案 —— 请核对策略本身。
3454
+
3455
+ 同源、方向相反的一处一并修正:`$or` 的空析取项 `{}` 以前被 `.filter(s => s.length > 0)`
3456
+ 丢掉,`{ $or: [{}, { a: 1 }] }` 收紧成 `a = 1`。`{}` 是 TRUE 析取项,TRUE 吸收整个析取,
3457
+ 所以现在整条 `$or` 为 TRUE(无约束)。被丢弃分支的绑定值同时被丢弃 —— 否则 `params` 里
3458
+ 会留下没有 `?` 消费的值,把后面每一个占位符都错位到别人的值上。
3459
+
3460
+ ### 变更二:`$not` 改为 NULL-safe
3461
+
3462
+ SQL 是三值逻辑,`WHERE` 只保留 TRUE,所以裸 `NOT ("t"."stage" = ?)` 会把 `stage IS NULL`
3463
+ 的行整批丢掉;`driver-memory`、`formula` 以及 #5296 之后的 `driver-sql` 都**返回**这些行。
3464
+ 同一条 read scope,普通查询与分析查询给出不同的可见集合。#5146 已由维护者判定以 JS 家族的
3465
+ 答案为准,本次把这个编译器对齐过去 —— 它是仓内最后一个按三值逻辑回答 `$not` 的 SQL 家族实现。
3466
+
3467
+ `$not` 的操作数在取反前先被改写成**全域(total)谓词**:
3468
+
3469
+ ```sql
3470
+ -- 之前
3471
+ NOT ("t"."stage" = ?)
3472
+ -- 现在
3473
+ NOT (("t"."stage" IS NOT NULL AND "t"."stage" = ?))
3474
+ ```
3475
+
3476
+ 守卫**下推到每个叶子**而不是挂在 `NOT` 旁边:操作数一旦嵌套(`$not` 里套 `$or`),顶层的
3477
+ `OR col IS NULL` 会把 JS 家族排除的行重新放进来。守卫方向**逐算子**判定,不是一刀切 ——
3478
+ `{ $not: { a: { $ne: 5 } } }` 语义是「a 就是 5」,无条件加 `OR a IS NULL` 会把 scope 排除的
3479
+ 行交回去,正是本次要避免的静默放松。所以 `$ne` / `$nin` / `$notContains` 用
3480
+ `col IS NULL OR (…)`,`$eq` / `$in` / `$gt` / `$between` / `$contains` 一族用
3481
+ `col IS NOT NULL AND (…)`,而 `$null` / `$exists` / `$eq: null` / `$ne: null` 本就是全域谓词,
3482
+ 一个字节都不加。
3483
+
3484
+ **升级影响:** 形如 `{ $not: { stage: 'won' } }` 的 read scope,以前**不返回** `stage` 为
3485
+ NULL 的行,现在**返回**它们 —— 分析查询的行数与图表数值会随之变化。这是把分析侧对齐到其余
3486
+ 后端,不是新增的放宽。
3487
+
3488
+ ### 不变的部分
3489
+
3490
+ `$not` 路径以外一个字符都没动:普通比较仍然编译成原样的 SQL。fail-closed 的全部保证原封不动
3491
+ ——未知算子、嵌套关系值、裸数组、不安全标识符、非 filter 节点的 `$not` 操作数,以及
3492
+ `$and: []` / `$or: []` 的空组合子(那一格是 #5322 的独立裁定)统统照旧抛错。
3493
+
3494
+ - 07f1822: fix(service-analytics): read scope 的 `$ne` / `$nin` / `$notContains` 改为 NULL-safe,与写侧 `check` 对齐
3495
+
3496
+ **这是一次安全相关的行为变更,涉及分析查询的可见行集合。**
3497
+ read scope 里的 `{ stage: { $ne: 'won' } }` 以前**不返回** `stage IS NULL` 的行,
3498
+ 现在**返回**它们。`$nin` / `$notContains` 同理。
3499
+
3500
+ `read-scope-sql.ts` 是 RLS / 租户 read scope 降解成 SQL 的唯一通道(ADR-0021 D-C)。
3501
+ 它此前把这三个算子编译成裸的 `col <> ?` / `col NOT IN (…)` / `col NOT LIKE ?`,
3502
+ 而 SQL 是三值逻辑:被比较列为 NULL 时谓词是 UNKNOWN,`WHERE` 只保留 TRUE,于是
3503
+ 「该列没有值」的行被整批丢掉。
3504
+
3505
+ **为什么必须与 `driver-sql` 同一个 PR 落地,而不是排到下一批。** 同一条 RLS 规则被
3506
+ 写一次、在**两侧**求值:读路径由本文件降解成 SQL,写路径由 `formula` 的
3507
+ `matchesFilterCondition` 逐记录求值。`formula` 一直用两值 JS(`undefined !== 'won'`
3508
+ 为真)返回这些行。只对齐其中一侧,得到的不是「更小的修复」,而正是那个缺陷本身 ——
3509
+ 一条权限规则准入两个不同的行集,写侧允许的记录读侧看不见。
3510
+
3511
+ ```sql
3512
+ -- 之前
3513
+ "t"."stage" <> ?
3514
+ "t"."stage" NOT IN (?)
3515
+ "t"."stage" NOT LIKE ? ESCAPE ?
3516
+ -- 现在
3517
+ ("t"."stage" IS NULL OR "t"."stage" <> ?)
3518
+ ("t"."stage" IS NULL OR "t"."stage" NOT IN (?))
3519
+ ("t"."stage" IS NULL OR "t"."stage" NOT LIKE ? ESCAPE ?)
3520
+ ```
3521
+
3522
+ 括号不是排版:`compileField` 用裸 `AND` 连接同一字段的多个算子,不加括号的
3523
+ `col IS NULL OR …` 会比那个 AND 结合得更松,从而**静默放宽整条 scope**。
3524
+
3525
+ 与 `driver-sql` 一样统一用 OR 展开而非方言等价物(`NOT LIKE` 没有对应形式;SQLite
3526
+ 写法依赖本仓不锁定的引擎版本;实测执行计划相同)。正向比较逐字符不变,
3527
+ `$ne: null` 仍是 `IS NOT NULL`(空值谓词,不是比较)。
3528
+
3529
+ `$not` 路径的逐叶守卫(#5146 / #5326)按原样保留,两条路径读同一张极性表。
3530
+ `filter-normalizer`(Cube 面)不在本次范围内,归本裁决第二批。
3531
+
3532
+ - 76bcb83: feat(spec): filter-subtree provenance — the cross-field refusal names an author's own columns again, without re-disclosing policy (#8220, A of the #7929 ruling)
3533
+
3534
+ #8198 (B of the 2026-08-12 #7929 ruling) made the SQL family's cross-field
3535
+ `{ $field }` refusal withhold its operands from **every** caller, because the
3536
+ predicate reached the driver as a bare `FilterCondition`: an administrator's
3537
+ CEL sharing/permission rule and the author's own filter were indistinguishable
3538
+ there. The accepted, named cost was the author's diagnostic. This change is A
3539
+ — the sanctioned follow-up that pays it back behind a real mark instead of a
3540
+ guess.
3541
+
3542
+ **The mark** (`@objectstack/spec/data`, `filter-subtree-provenance.ts`) is a
3543
+ spec-declared symbol on a filter subtree: `markFilterSubtreeProvenance(subtree,
3544
+ 'author' | 'policy')`, read positionally by
3545
+ `resolveFilterSubtreeProvenance(root, node)` (innermost mark on the ancestor
3546
+ chain wins; located by object identity, never structural equality). It rides
3547
+ the `where` tree by reference across the `DriverQuery` boundary — no new slot,
3548
+ documented on `DriverQuery` itself — and is dropped by exactly the operations
3549
+ (serialize, copy, rewrite) after which no attestation could be trusted.
3550
+
3551
+ **Set at both read-scope merge boundaries**: `plugin-security`'s CRUD RLS
3552
+ injection marks every injected scope `'policy'` and the caller's verbatim
3553
+ predicate `'author'` — the latter only under the identity vouch
3554
+ `ast.where === options.where`, so a tree a sibling middleware already rewrote
3555
+ is vouched for nobody. `service-analytics`' `ObjectQLStrategy.withReadScope`
3556
+ marks its scope `'policy'` and the strategy-built user filter `'author'` (and
3557
+ `resolveFkAttr`'s scope arm `'policy'`).
3558
+
3559
+ **Consumed by the SQL family** (`driver-sql`, `driver-turso`'s
3560
+ `RemoteTransport`; `driver-sqlite-wasm` inherits): a refusal raised from a
3561
+ subtree positively marked `'author'` carries its full diagnostic on the wire
3562
+ again — both columns, the operator, the list index, the boundary reason —
3563
+ same identity (`INVALID_FILTER` / 400).
3564
+
3565
+ **⚠️ The fail direction is closed, and it is the design**: unmarked or
3566
+ ambiguous — no mark anywhere, a mark lost to serialization, a node
3567
+ unreachable from the query's own `where`, conflicting aliased marks —
3568
+ withholds exactly like `'policy'`. The mark is permission to reveal, never a
3569
+ requirement to prove secrecy; a driver-side guess at provenance is the shape
3570
+ the #7929 triage rejected.
3571
+
3572
+ **Two B-era pins were REWRITTEN deliberately, not weakened.** First,
3573
+ `service-analytics`' `cross-field-engine-fallback.test.ts` pinned B's blanket
3574
+ redaction on refusals of the caller's OWN `where` (no scope in play) — under A
3575
+ that caller is the vouched author, so those cases now assert the corpus's
3576
+ `diagnosticIncludes` fragments are back on the wire, while the
3577
+ policy-injected-scope case gains the explicit non-disclosure assertions as its
3578
+ fail-closed pair. Second, the sharper one:
3579
+ `packages/runtime/src/cross-field-refusal-operand-withhold.test.ts` pinned
3580
+ author-written and policy-injected refusals **byte-identical** — the strongest
3581
+ available statement of "the driver cannot tell them apart", and explicitly the
3582
+ assertion A was chartered to supersede. Its successor pins the three-way split
3583
+ #8220's "Done means" names: policy-injected withholds (unchanged), the vouched
3584
+ author's filter names its columns again (the messages now differ, by design),
3585
+ and an unmarked predicate still withholds **byte-identical to the policy
3586
+ case** — B's surviving half. Reading that diff as a regression is exactly what
3587
+ the old pin's comment warned against; the file header carries the full
3588
+ account.
3589
+
3590
+ Unaffected: the REST boundary's 5xx-only withhold (#5367/#5667) and every
3591
+ refusal outside the cross-field family.
3592
+
3593
+ - e15bf7e: fix(analytics): read scope 里的 `undefined` 比较数改为拒收,不再编成绑了 `undefined` 的合法 SQL (#6125)
3594
+
3595
+ **⚠️ 行为变更。** `compileScopedFilterToSql` 遇到比较数位置上的 `undefined`,从「编出合法 SQL、绑一个 `undefined`、匹配零行、零日志」改为 `READ_SCOPE_COMPILE_FAILED` / **500** 拒收。
3596
+
3597
+ ## 实测到的毛病
3598
+
3599
+ #6050 于 2026-08-07 裁定(B 案):比较数位置的 `undefined` 一律拒收,并落在了**已证实可触达**的 `driver-sql` / `driver-turso` 两面。#6125 在同一轮把仓内其余求值面逐格实测,同一个形状拿到五种读法;本条改的是其中一格 —— `service-analytics` 的 `read-scope-sql.ts`。在 `d8e8d9cbc` 上把本次拒收关掉复测,alias `t`、字段 `d`,四格与 #6125 正文表一致:
3600
+
3601
+ | read scope | 编译结果 | 绑定表 |
3602
+ | ----------------------------- | --------------------------------------------- | ------------- |
3603
+ | `{ d: undefined }` | `"t"."d" = ?` | `[undefined]` |
3604
+ | `{ d: { $gt: undefined } }` | `"t"."d" > ?` | `[undefined]` |
3605
+ | `{ d: { $in: [undefined] } }` | `"t"."d" IN (?)` | `[undefined]` |
3606
+ | `{ $not: { d: undefined } }` | `NOT (("t"."d" IS NOT NULL AND "t"."d" = ?))` | `[undefined]` |
3607
+
3608
+ 绑定表里是 JS 的 `undefined` 本身,不是 `null`:`applyReadScope`(`native-sql-strategy.ts`)在把 `?` 改写成 `$N` 时原样 `push(scopeParams[i])`。所以 NULL 是**驱动**对一个 JS `undefined` 的读法 —— 同一格在不肯猜的驱动上则是一句裸 `Undefined binding(s)` 崩溃。一次绑定、两种败法,取决于数据源恰好挂的是哪个驱动,这正是它该在编译器处拒收、而不是在某一个消费者处修补的理由。
3609
+
3610
+ 方向与 #6050 不同,如实记:那边是**越权**(`{ owner_id: ctx.user?.id }` 在 Turso remote 上编成 `IS NULL`,匹配全环境行);这边是 fail-**closed** —— 匹配零行,永远不会多给行。所以它不是潜伏的权限绕过,#6125 也没有按那个级别定级。之所以照样拒收:一个「答了没人问的问题、且一条日志都不报」的 read scope,与一个真的生效了的 read scope 在外部完全无法区分。本次改动的价值就是把沉默变成响亮。
3611
+
3612
+ ## 修法
3613
+
3614
+ 一道闸落在 `compileField` 的开头 —— 在 `quoteIdent` 之后(不安全标识符是注入向量,保留它自己的措辞与优先级),在任何 `bind()` 之前。
3615
+
3616
+ 拒收的**位置**逐个清点,因为「比较数」是位置而不是类型:直接比较数(`{ d: undefined }`)、单值算子的比较数(`$eq`/`$ne`/`$gt`/`$gte`/`$lt`/`$lte` 与 LIKE 族)、列表算子数组的**成员**(`$in`/`$nin`/`$between`)。四格共用**一条**措辞,只有 `path` 不同(#5240「一个条件,一种措辞」)。
3617
+
3618
+ 信封沿用本模块自述的那一个(`READ_SCOPE_COMPILE_FAILED` / 500),不是 #6050 的 `INVALID_FILTER` / 400:read scope 的 filter 由平台自己从 CEL 与库存 metadata 编译而来,不是调用方输入 —— 报 400 等于让调用方去修一个他既没写、也改不动的东西。消息里指名要修的是**生产者**(管理员写的共享规则 / 权限集、它的 CEL 下降、或进程内拼这条 FilterCondition 的代码),并按 #5367 只进日志、不进响应体。
3619
+
3620
+ 三个位置**故意不扫**,各自因为本模块已经用更贴切的诊断拒了它:`$null` / `$exists`(比较数是声明的布尔量,不是比较数位置)、直接位置上的裸数组(`compileField` 整体拒「用 `{ $in: [...] }`」)、以及约束对象里的非 `$` 键(那是嵌套关系,改写成 `null` 一样编不过 —— 这一条是与 `driver-sql` 孪生实现的唯一有意分歧,来自本模块拒收嵌套关系,而不是对 #6050 的另一种读法)。
3621
+
3622
+ ## ⛔ `null` 一字未动
3623
+
3624
+ `{ d: null }` / `{ $eq: null }` → `IS NULL`;`{ $ne: null }` → `IS NOT NULL`;`$null` / `$exists`、`$in: [null]`、`$nin: [null]`、`$between: [null, 5]`、`$contains: null`(`%null%`,#5526)、以及 `$not` 下的各式 —— SQL 与绑定表逐字节不变。这是本次改动唯一可能造成伤害的方向(模块里每张极性表都只用一个 `===` 把 `null` 与 `undefined` 分开),所以它有自己的对照组回归 pin。
3625
+
3626
+ ## 刻意不动的邻居
3627
+
3628
+ - ⛔ `@objectstack/formula` 把同一个 `undefined` 读作「这个键在记录里不存在」—— 那是**第三种语义**,不是第三个 bug 拼写,也正是 #5299 在争的问题。在这里顺手改掉等于替 #5299 拍板。
3629
+ - ⛔ `driver-memory` / `driver-mongodb` 维持 #5499 投入冻结,只 pin 不改。后果是本编译器与 `driver-memory` 在这一格上从此不一致 —— 这是裁决接受的代价,解冻时一并还,账记在 #6125。
3630
+ - ⛔ `driver-sql` / `driver-turso` 已由 #6050 落地,未触碰。
3631
+
3632
+ - 91cefb8: refactor(types,rest,metadata,analytics): Postgres 的 `"x" of relation "y"` 短语收归一处,三个包不再各修一遍同一个超串洞(#6615)
3633
+
3634
+ Postgres 把「关系内部某个子对象」的失败写成 `column "label" of relation "sys_team" does not exist`——里面**逐字包含**一句合法的「表不存在」短语 `relation "sys_team" does not exist`,含义却相反:关系正因为存在才被点名。任何对「这句话是不是在说表没了」的正则收紧都消不掉这个匹配,短语确实在里面;唯一的修法是**先问更具体的问题**。所以修的是**顺序**,不是模式。
3635
+
3636
+ 正因为如此,这个短语被分三次教给了这个仓库,分属三个包、三个 PR,其中两次是在别处已经踩过同一个洞之后:`@objectstack/rest` 的 `mapDataError`(#5352)、`@objectstack/service-analytics` 的缺列扣除(#6035 / PR #6346)、`@objectstack/metadata` 的 `MISSING_TABLE.excludes`(#6347 / PR #6613)。本次把它收进 `@objectstack/types`,与 `isUniqueViolationError`(#6250)和 `isModuleNotFoundError`(framework#3265)同一个理由与同一个位置。
3637
+
3638
+ **两种宽度,故意保留成两个导出。** 三个消费者要的并不是同一条正则,差别也不是随手写的,而是**每个站点哪个方向的误差是安全的**:
3639
+
3640
+ - `matchMissingColumnOfRelation(message)` —— 严格提取器,锚定 Postgres 的 errmsg 模板 `column "%s" of relation "%s" does not exist`,返回列名。`rest` 用它把 42703 答成 `400 INVALID_FIELD` 而不是 `404`;`service-analytics` 用它在分类前扣除缺列。这两处**过宽**会把真正缺失的表变成硬失败、回退 #5033 刻意保留的宽容,**漏匹配**只是让消息含糊一点——所以必须严格。
3641
+ - `isRelationSubObjectPhrase(message)` —— 宽检测器,丢掉 `column` / `[a-z0-9_]+` / `does not exist` 三个锚点:任意子对象、任意带引号标识符、任意判词。`metadata` 用它做排除。这一处**过宽**只会把良性判定变成响亮判定,**漏匹配**却会让 `event_seq` 从 1 重新开始、撞进一张已有行的历史表——方向正好相反。
3642
+
3643
+ 把两者合并成一条正则,无论哪种宽度胜出都会对其中一个调用方是错的;这是卡片记录在案的风险,两个导出即为此而设,理由是承重的而非风格的。仓库里第四份拷贝(`service-analytics` 测试内用于守护 fixture 的那条正则)同时收编:它本是为「两张面孔别对不上」而写,却把断言打在其中一面的私有复述上,因而正是它要防的漂移。
3644
+
3645
+ 行为逐字保持不变:搬进来的两条模式与原站点逐字节相同。`@objectstack/service-analytics` 因此新增一条对 `@objectstack/types` 的依赖边——这是本次唯一的依赖变化,构造上无环(`@objectstack/types` 只依赖 `@objectstack/spec`,后者无仓内依赖),且仓库 73 个包中已有 25 个、16 个 service 中已有 5 个携带同一条边。
3646
+
3647
+ - f752ee3: feat(analytics): order the time axis by default, and give reports a sort declaration (#3916)
3648
+
3649
+ A matrix report with a date dimension across rendered its columns in arbitrary
3650
+ order — `2026-07-01, 2026-07-05, …, 2026-07-02`. Declaring `dateGranularity` on
3651
+ the dataset dimension made the bucket keys _sortable_ (`2026-07`, `2026-Q3`)
3652
+ without making anything _sort_ them, and the report author had no way to ask:
3653
+ `DatasetSelection.order` existed on the wire, but `ReportSchema` had no ordering
3654
+ field at all (dashboard widgets had their own `options.sortBy` channel; reports
3655
+ did not). Nothing in the chain supplied an order either — `resolveOrdering`
3656
+ returned `undefined` unless the selection carried one explicitly, the ObjectQL
3657
+ aggregate path has no ordering grammar so its buckets came back in Map-insertion
3658
+ order, and the pivot builds its column headers in row-arrival order.
3659
+
3660
+ - **A selected time dimension is now chronological by default.** When a
3661
+ selection states no `order` (and no `limit`, whose own fallback already
3662
+ ordered by every dimension), each selected dimension the cube types as `time`
3663
+ defaults to ASCENDING, in selection order. Bucket keys are minted sort-stable
3664
+ precisely so this works — `2026-07` sorts after `2026-06`, `2026-Q3` after
3665
+ `2026-Q1`. This lands on both strategy paths: a real `ORDER BY` where native
3666
+ SQL serves the query, and the executor's post-pass where a date-bucketed query
3667
+ is handed to the ObjectQL path. Null / empty buckets stay last, as everywhere
3668
+ else. Deliberately narrow: only time dimensions get a default, so grids with
3669
+ nothing wrong with them are not reordered.
3670
+ - **Reports can declare an ordering.** `ReportSchema.order` (and
3671
+ `blocks[].order` for a `joined` report) is a list of `{ by, direction }` sort
3672
+ keys, most significant first — an array, not a `Record`, because key order is
3673
+ the contract and JSON object key order should not have to be. `by` must name a
3674
+ dimension the report groups by (`rows` / `columns`) or a measure it displays
3675
+ (`values`); anything else fails at authoring time rather than becoming an
3676
+ ordering that silently does nothing. Duplicate keys are rejected. A `joined`
3677
+ report orders per block — declaring `order` on the container is an error.
3678
+ `reportSelectionOrder()` lowers the list into the `DatasetSelection.order` a
3679
+ renderer posts, and returns `undefined` for an empty list so the runtime's own
3680
+ defaults still apply.
3681
+
3682
+ An explicit `order` still wins outright — the chronological default is a
3683
+ default, not a policy, so "newest month first" is one declaration away.
3684
+
3685
+ `report.order` ships as `planned` + `authorWarn` in the liveness ledger: the
3686
+ framework half is complete and live (schema, lowering helper, executor), but
3687
+ objectui's `DatasetReportRenderer` does not yet carry `report.order` into the
3688
+ selection it posts. The default time-axis ordering needs no renderer change and
3689
+ is live now.
3690
+
3691
+ - b3a3d83: feat(spec): a shared temporal conformance matrix, and the `$between` gap it found (ADR-0053 D-A3, #4081)
3692
+
3693
+ `@objectstack/spec/data` gains `TEMPORAL_ROWS` and `TEMPORAL_CASES` — the
3694
+ single set of temporal filter cases every backend is checked against, the twin
3695
+ of the existing `FILTER_LOGIC_CASES`. Five backends consume it and assert **row
3696
+ results**: `driver-sql` (and, through the live-dialect CI job, real Postgres and
3697
+ MySQL), `driver-memory`, `driver-mongodb` (real MongoDB), the analytics preview
3698
+ evaluator, and `formula`'s RLS write-side `check`.
3699
+
3700
+ This is the regression backstop ADR-0053 D-A3 has asked for since 2026-06 and
3701
+ the last of its decisions to be actioned. Four separate incidents — #3650,
3702
+ #3773, #3777, #4047 — were each found by a human by accident, and each left a
3703
+ suite proving only its own issue against its own fixture. Nothing held the
3704
+ backends to one standard, so the fifth divergence had nowhere to fail.
3705
+
3706
+ **`service-analytics` — a real fix the matrix found on its first run.** The
3707
+ draft-preview evaluator had no `$between` case, so it fell through to its
3708
+ permissive `default` and matched **every** row: a drafted dashboard carrying a
3709
+ range filter charted the entire dataset, then changed its numbers at publish —
3710
+ the exact continuity the preview exists to provide. It now evaluates
3711
+ `$between`, sharing the upper-bound helper with `$lte` so the whole-day
3712
+ calendar-day rule (#3777) applies to a range's max as well.
3713
+
3714
+ Also recorded (ADR-0053 D-A3.1): `$gt` with a bare-day comparand on a
3715
+ `datetime` column cannot agree between typed and type-blind backends, and the
3716
+ gap is irreducible without field types. It is asserted in the shared matrix on
3717
+ `date` only, with the `datetime` cell left to the typed drivers' own suites,
3718
+ rather than papered over.
3719
+
3720
+ - 35accbf: feat(spec): promote the temporal storage hooks onto the IDataDriver contract (ADR-0053 D-A2)
3721
+
3722
+ `temporalFilterValue` and `temporalFilterColumnSql` — the pair that closed
3723
+ #3912's storage-form drift — were duck-typed: analytics probed
3724
+ `typeof driver.x === 'function'` against a locally-invented interface, and
3725
+ nothing at the type level said a driver must implement both or neither. The
3726
+ lesson of #3912 is precisely that coercing the comparand without normalising
3727
+ the column reintroduces half the bug, so a driver implementing one hook alone
3728
+ would silently regress.
3729
+
3730
+ Both are now optional members of `IDataDriver`
3731
+ (`@objectstack/spec/contracts`), documented as a pair with "absent = identity"
3732
+ semantics for drivers whose storage form is the wire form (memory, mongo).
3733
+ `SqlDriver implements IDataDriver`, so its signatures are compile-checked from
3734
+ here on; analytics derives its driver seam by `Pick`-ing the contract instead
3735
+ of a local duck type. Runtime `typeof` guards remain — that is the correct way
3736
+ to consume an optional contract member — but the shape they guard now has one
3737
+ authoritative definition.
3738
+
3739
+ No runtime behaviour change. ADR-0053 D-A2 is recorded as resolved.
3740
+
3741
+ - e4c2dc8: Order temporal operands correctly when one side is a JS `Date` on the two
3742
+ type-blind filter backends (ADR-0053 D-A3 / #4191).
3743
+
3744
+ `utcInstantMs` joins `nextUtcCalendarDay` in `@objectstack/spec/data`
3745
+ (re-exported from `@objectstack/core`): it reads the UTC instant a temporal
3746
+ operand denotes, accepting only unambiguous spellings — a `Date`, epoch ms, a
3747
+ bare `YYYY-MM-DD`, and an ISO timestamp with or without an explicit zone (a
3748
+ zone-naive one being UTC, per D-B2) — and returning `null` for everything
3749
+ else, notably a bare wall clock, which denotes no instant.
3750
+
3751
+ Both type-blind evaluators now use it to compare a `Date` against wire text,
3752
+ which JS relational operators cannot do: `<` and friends coerce with hint
3753
+ `number`, so the `Date` becomes its epoch and the string becomes `NaN`.
3754
+
3755
+ - `formula`'s `matchesFilterCondition` (the RLS write-side `check`) dropped
3756
+ every `Date`-valued row in 10 of the 16 shared conformance cases. The
3757
+ post-image is the caller's raw write payload, so an SDK write of
3758
+ `new Date()` hit this directly, and fail-closed turned it into a **denied
3759
+ write**.
3760
+ - `service-analytics`' preview evaluator diverged on the same 10 cases in
3761
+ BOTH directions, because `String(new Date())` sorts after every `'2026-…'`
3762
+ comparand — a drafted chart both lost rows and gained ones, then changed
3763
+ its numbers at publish. Rows from a mongo-backed dataset arrive as BSON
3764
+ `Date`s, so this was reachable in normal use.
3765
+
3766
+ Comparisons that did not involve a `Date` are unchanged.
3767
+
3768
+ - Updated dependencies [50616d9]
3769
+ - Updated dependencies [430dcc2]
3770
+ - Updated dependencies [690ccf2]
3771
+ - Updated dependencies [6a67d7a]
3772
+ - Updated dependencies [333a374]
3773
+ - Updated dependencies [9fe9c1d]
3774
+ - Updated dependencies [3d5c090]
3775
+ - Updated dependencies [e5bd768]
3776
+ - Updated dependencies [08b5a3d]
3777
+ - Updated dependencies [e027b3e]
3778
+ - Updated dependencies [e6ac4bd]
3779
+ - Updated dependencies [c2429b0]
3780
+ - Updated dependencies [445a0c2]
3781
+ - Updated dependencies [d99aeb3]
3782
+ - Updated dependencies [f6609e6]
3783
+ - Updated dependencies [4727eb8]
3784
+ - Updated dependencies [a70358a]
3785
+ - Updated dependencies [0ecc656]
3786
+ - Updated dependencies [06772eb]
3787
+ - Updated dependencies [d4e0809]
3788
+ - Updated dependencies [80334c7]
3789
+ - Updated dependencies [f63cd09]
3790
+ - Updated dependencies [97e7e3c]
3791
+ - Updated dependencies [ce5242c]
3792
+ - Updated dependencies [a7163ea]
3793
+ - Updated dependencies [e6e9379]
3794
+ - Updated dependencies [5823d59]
3795
+ - Updated dependencies [3140f9c]
3796
+ - Updated dependencies [9500ba4]
3797
+ - Updated dependencies [fa3d0cf]
3798
+ - Updated dependencies [af5a224]
3799
+ - Updated dependencies [71f76e1]
3800
+ - Updated dependencies [37b1346]
3801
+ - Updated dependencies [99736a0]
3802
+ - Updated dependencies [fe67e34]
3803
+ - Updated dependencies [fdb4f50]
3804
+ - Updated dependencies [270650f]
3805
+ - Updated dependencies [3aef718]
3806
+ - Updated dependencies [1bd5652]
3807
+ - Updated dependencies [14252d3]
3808
+ - Updated dependencies [7fb436c]
3809
+ - Updated dependencies [879ea13]
3810
+ - Updated dependencies [8828b9e]
3811
+ - Updated dependencies [1ea6bce]
3812
+ - Updated dependencies [c1dcacd]
3813
+ - Updated dependencies [ad303ed]
3814
+ - Updated dependencies [32ccb23]
3815
+ - Updated dependencies [f5a4ef0]
3816
+ - Updated dependencies [2d3e255]
3817
+ - Updated dependencies [a8940e4]
3818
+ - Updated dependencies [7d7521f]
3819
+ - Updated dependencies [5dc4d02]
3820
+ - Updated dependencies [f724f69]
3821
+ - Updated dependencies [98877c9]
3822
+ - Updated dependencies [98877c9]
3823
+ - Updated dependencies [53068c1]
3824
+ - Updated dependencies [ee58392]
3825
+ - Updated dependencies [f16e54e]
3826
+ - Updated dependencies [06be54e]
3827
+ - Updated dependencies [28ad90e]
3828
+ - Updated dependencies [76d74ec]
3829
+ - Updated dependencies [201b31f]
3830
+ - Updated dependencies [e6b1b69]
3831
+ - Updated dependencies [259459d]
3832
+ - Updated dependencies [3f7f14e]
3833
+ - Updated dependencies [e2616e0]
3834
+ - Updated dependencies [6fdc5c6]
3835
+ - Updated dependencies [8b9d71e]
3836
+ - Updated dependencies [05154a1]
3837
+ - Updated dependencies [33f5e23]
3838
+ - Updated dependencies [259af21]
3839
+ - Updated dependencies [f8644c7]
3840
+ - Updated dependencies [306ca50]
3841
+ - Updated dependencies [840ee4b]
3842
+ - Updated dependencies [978fed2]
3843
+ - Updated dependencies [cfc293f]
3844
+ - Updated dependencies [587fc91]
3845
+ - Updated dependencies [de70b42]
3846
+ - Updated dependencies [9b6fe7c]
3847
+ - Updated dependencies [64cd010]
3848
+ - Updated dependencies [fb3d99b]
3849
+ - Updated dependencies [1986594]
3850
+ - Updated dependencies [6968885]
3851
+ - Updated dependencies [eaed61f]
3852
+ - Updated dependencies [cdfbee2]
3853
+ - Updated dependencies [ad4af62]
3854
+ - Updated dependencies [debe2f6]
3855
+ - Updated dependencies [d44dbfa]
3856
+ - Updated dependencies [29c6c9d]
3857
+ - Updated dependencies [d21c001]
3858
+ - Updated dependencies [ad047d2]
3859
+ - Updated dependencies [8c711fb]
3860
+ - Updated dependencies [f1cc3a3]
3861
+ - Updated dependencies [09e4547]
3862
+ - Updated dependencies [97b0798]
3863
+ - Updated dependencies [474fe39]
3864
+ - Updated dependencies [0bc685a]
3865
+ - Updated dependencies [b949059]
3866
+ - Updated dependencies [2826d1e]
3867
+ - Updated dependencies [be1c52c]
3868
+ - Updated dependencies [c5ff96d]
3869
+ - Updated dependencies [5a84d41]
3870
+ - Updated dependencies [84e7be9]
3871
+ - Updated dependencies [91f4c78]
3872
+ - Updated dependencies [ddc2527]
3873
+ - Updated dependencies [820eff9]
3874
+ - Updated dependencies [a6c3f38]
3875
+ - Updated dependencies [debc23a]
3876
+ - Updated dependencies [0f8ad09]
3877
+ - Updated dependencies [553a47f]
3878
+ - Updated dependencies [43a7a8d]
3879
+ - Updated dependencies [a98085f]
3880
+ - Updated dependencies [20b1a9e]
3881
+ - Updated dependencies [344a22a]
3882
+ - Updated dependencies [4827e91]
3883
+ - Updated dependencies [8d895ff]
3884
+ - Updated dependencies [86f7a20]
3885
+ - Updated dependencies [a3a884d]
3886
+ - Updated dependencies [cfed092]
3887
+ - Updated dependencies [203a449]
3888
+ - Updated dependencies [8f9689f]
3889
+ - Updated dependencies [73f69dc]
3890
+ - Updated dependencies [04c56aa]
3891
+ - Updated dependencies [f6472d7]
3892
+ - Updated dependencies [57a3bb3]
3893
+ - Updated dependencies [b3efeb7]
3894
+ - Updated dependencies [ddd075a]
3895
+ - Updated dependencies [88154be]
3896
+ - Updated dependencies [e8dc61e]
3897
+ - Updated dependencies [9c82146]
3898
+ - Updated dependencies [5f9a987]
3899
+ - Updated dependencies [744b8f5]
3900
+ - Updated dependencies [9f5cc79]
3901
+ - Updated dependencies [ac37fc6]
3902
+ - Updated dependencies [2f3e793]
3903
+ - Updated dependencies [4820f55]
3904
+ - Updated dependencies [462d9c4]
3905
+ - Updated dependencies [78caf51]
3906
+ - Updated dependencies [7d21581]
3907
+ - Updated dependencies [37785ed]
3908
+ - Updated dependencies [62a789b]
3909
+ - Updated dependencies [2e284b2]
3910
+ - Updated dependencies [d8e8d9c]
3911
+ - Updated dependencies [789ad63]
3912
+ - Updated dependencies [f2445c9]
3913
+ - Updated dependencies [94e749b]
3914
+ - Updated dependencies [ea1d916]
3915
+ - Updated dependencies [2af1988]
3916
+ - Updated dependencies [0af50a3]
3917
+ - Updated dependencies [1b49eaf]
3918
+ - Updated dependencies [ae31a19]
3919
+ - Updated dependencies [2e836de]
3920
+ - Updated dependencies [e0f300b]
3921
+ - Updated dependencies [0161c7f]
3922
+ - Updated dependencies [e900015]
3923
+ - Updated dependencies [db02d47]
3924
+ - Updated dependencies [b5bdf48]
3925
+ - Updated dependencies [23338c3]
3926
+ - Updated dependencies [12a19a8]
3927
+ - Updated dependencies [5b843fb]
3928
+ - Updated dependencies [62b6a2f]
3929
+ - Updated dependencies [7e5af5c]
3930
+ - Updated dependencies [5b4780b]
3931
+ - Updated dependencies [a933452]
3932
+ - Updated dependencies [9d1d9c7]
3933
+ - Updated dependencies [8140915]
3934
+ - Updated dependencies [a019e52]
3935
+ - Updated dependencies [e8f8f6c]
3936
+ - Updated dependencies [41dcda3]
3937
+ - Updated dependencies [7b48cf9]
3938
+ - Updated dependencies [b5404f4]
3939
+ - Updated dependencies [64fc6d5]
3940
+ - Updated dependencies [b746aa0]
3941
+ - Updated dependencies [b4487aa]
3942
+ - Updated dependencies [1007379]
3943
+ - Updated dependencies [65ca83a]
3944
+ - Updated dependencies [0bfdf46]
3945
+ - Updated dependencies [947d4f9]
3946
+ - Updated dependencies [f764691]
3947
+ - Updated dependencies [e120a5a]
3948
+ - Updated dependencies [e5bd2f6]
3949
+ - Updated dependencies [e650d67]
3950
+ - Updated dependencies [04476e7]
3951
+ - Updated dependencies [67bf2e2]
3952
+ - Updated dependencies [eaaf03c]
3953
+ - Updated dependencies [d17df80]
3954
+ - Updated dependencies [7d0e7b5]
3955
+ - Updated dependencies [c6d1cb4]
3956
+ - Updated dependencies [6513c17]
3957
+ - Updated dependencies [36030ff]
3958
+ - Updated dependencies [79228cd]
3959
+ - Updated dependencies [6117f7b]
3960
+ - Updated dependencies [87aca93]
3961
+ - Updated dependencies [e533b0b]
3962
+ - Updated dependencies [cdf4d9a]
3963
+ - Updated dependencies [aee1806]
3964
+ - Updated dependencies [c13350b]
3965
+ - Updated dependencies [c13350b]
3966
+ - Updated dependencies [2c1988c]
3967
+ - Updated dependencies [9ca2d85]
3968
+ - Updated dependencies [c13350b]
3969
+ - Updated dependencies [891d345]
3970
+ - Updated dependencies [c8124e5]
3971
+ - Updated dependencies [a52e2ef]
3972
+ - Updated dependencies [5293114]
3973
+ - Updated dependencies [376a061]
3974
+ - Updated dependencies [c142ced]
3975
+ - Updated dependencies [211abdb]
3976
+ - Updated dependencies [b3363e9]
3977
+ - Updated dependencies [eda599e]
3978
+ - Updated dependencies [a1a4140]
3979
+ - Updated dependencies [c20b875]
3980
+ - Updated dependencies [7c7e246]
3981
+ - Updated dependencies [2ef1807]
3982
+ - Updated dependencies [f35cdc5]
3983
+ - Updated dependencies [d03fe25]
3984
+ - Updated dependencies [2a37694]
3985
+ - Updated dependencies [217e2e6]
3986
+ - Updated dependencies [2672f85]
3987
+ - Updated dependencies [20bc357]
3988
+ - Updated dependencies [11066f6]
3989
+ - Updated dependencies [916af17]
3990
+ - Updated dependencies [84c86fb]
3991
+ - Updated dependencies [2a2a9fb]
3992
+ - Updated dependencies [86a71d1]
3993
+ - Updated dependencies [c001422]
3994
+ - Updated dependencies [77022a9]
3995
+ - Updated dependencies [d5c75e2]
3996
+ - Updated dependencies [03d26f7]
3997
+ - Updated dependencies [5966c2a]
3998
+ - Updated dependencies [2382580]
3999
+ - Updated dependencies [9ea2bc5]
4000
+ - Updated dependencies [a2e157c]
4001
+ - Updated dependencies [95c4227]
4002
+ - Updated dependencies [2a61116]
4003
+ - Updated dependencies [52760bf]
4004
+ - Updated dependencies [5543020]
4005
+ - Updated dependencies [880d343]
4006
+ - Updated dependencies [6e82972]
4007
+ - Updated dependencies [d4df105]
4008
+ - Updated dependencies [4615a18]
4009
+ - Updated dependencies [f505689]
4010
+ - Updated dependencies [d9fa683]
4011
+ - Updated dependencies [32d3800]
4012
+ - Updated dependencies [606d577]
4013
+ - Updated dependencies [4384921]
4014
+ - Updated dependencies [e2798fa]
4015
+ - Updated dependencies [3c628ce]
4016
+ - Updated dependencies [c2d9098]
4017
+ - Updated dependencies [0fd8556]
4018
+ - Updated dependencies [3c7bcc0]
4019
+ - Updated dependencies [4b6cac7]
4020
+ - Updated dependencies [7631964]
4021
+ - Updated dependencies [ac471a0]
4022
+ - Updated dependencies [60ae58e]
4023
+ - Updated dependencies [7f62706]
4024
+ - Updated dependencies [667fa44]
4025
+ - Updated dependencies [37e38d1]
4026
+ - Updated dependencies [e906126]
4027
+ - Updated dependencies [ce92674]
4028
+ - Updated dependencies [08363a0]
4029
+ - Updated dependencies [444de5b]
4030
+ - Updated dependencies [a227ed7]
4031
+ - Updated dependencies [7cb922e]
4032
+ - Updated dependencies [1d22114]
4033
+ - Updated dependencies [1eb13a0]
4034
+ - Updated dependencies [c52e608]
4035
+ - Updated dependencies [9613396]
4036
+ - Updated dependencies [3f7b4ff]
4037
+ - Updated dependencies [74155c7]
4038
+ - Updated dependencies [b5f9397]
4039
+ - Updated dependencies [ed77493]
4040
+ - Updated dependencies [6908830]
4041
+ - Updated dependencies [8b06bba]
4042
+ - Updated dependencies [58a03d2]
4043
+ - Updated dependencies [2bacd1a]
4044
+ - Updated dependencies [e47b342]
4045
+ - Updated dependencies [4c54037]
4046
+ - Updated dependencies [dc530b4]
4047
+ - Updated dependencies [9f601e8]
4048
+ - Updated dependencies [6a9dec6]
4049
+ - Updated dependencies [0f7157b]
4050
+ - Updated dependencies [4dc1c7d]
4051
+ - Updated dependencies [d9bef45]
4052
+ - Updated dependencies [f598aa8]
4053
+ - Updated dependencies [4dfd002]
4054
+ - Updated dependencies [f549a0d]
4055
+ - Updated dependencies [51c5227]
4056
+ - Updated dependencies [82da264]
4057
+ - Updated dependencies [f586f1a]
4058
+ - Updated dependencies [77be690]
4059
+ - Updated dependencies [4ed7ed4]
4060
+ - Updated dependencies [9b9b70f]
4061
+ - Updated dependencies [f5a9bc2]
4062
+ - Updated dependencies [e59786e]
4063
+ - Updated dependencies [2fa4ca1]
4064
+ - Updated dependencies [bcf1112]
4065
+ - Updated dependencies [baeb4f0]
4066
+ - Updated dependencies [29488cc]
4067
+ - Updated dependencies [881a3cc]
4068
+ - Updated dependencies [f5a2320]
4069
+ - Updated dependencies [ad6317b]
4070
+ - Updated dependencies [811c30c]
4071
+ - Updated dependencies [a4a85c8]
4072
+ - Updated dependencies [859cb83]
4073
+ - Updated dependencies [07a4e26]
4074
+ - Updated dependencies [9774b78]
4075
+ - Updated dependencies [8a88885]
4076
+ - Updated dependencies [deb538f]
4077
+ - Updated dependencies [b49ccfd]
4078
+ - Updated dependencies [5b89711]
4079
+ - Updated dependencies [85d95e7]
4080
+ - Updated dependencies [08cd163]
4081
+ - Updated dependencies [0c8a22f]
4082
+ - Updated dependencies [5f7669e]
4083
+ - Updated dependencies [becbe53]
4084
+ - Updated dependencies [b127c8b]
4085
+ - Updated dependencies [763931e]
4086
+ - Updated dependencies [ec975f1]
4087
+ - Updated dependencies [168f60f]
4088
+ - Updated dependencies [b07d829]
4089
+ - Updated dependencies [de9af8a]
4090
+ - Updated dependencies [eb4204b]
4091
+ - Updated dependencies [a80302a]
4092
+ - Updated dependencies [a648e96]
4093
+ - Updated dependencies [a47ac06]
4094
+ - Updated dependencies [e4c61a7]
4095
+ - Updated dependencies [cc60165]
4096
+ - Updated dependencies [474f131]
4097
+ - Updated dependencies [081aa6f]
4098
+ - Updated dependencies [91f4c78]
4099
+ - Updated dependencies [050cd82]
4100
+ - Updated dependencies [4d552af]
4101
+ - Updated dependencies [44d677c]
4102
+ - Updated dependencies [c32944d]
4103
+ - Updated dependencies [1dd780f]
4104
+ - Updated dependencies [e8d0c21]
4105
+ - Updated dependencies [244ca86]
4106
+ - Updated dependencies [546ab3c]
4107
+ - Updated dependencies [c4df271]
4108
+ - Updated dependencies [c8d6f6e]
4109
+ - Updated dependencies [0b51bb6]
4110
+ - Updated dependencies [08f93bc]
4111
+ - Updated dependencies [d9971d3]
4112
+ - Updated dependencies [7dc1067]
4113
+ - Updated dependencies [4f13be2]
4114
+ - Updated dependencies [a41ba5c]
4115
+ - Updated dependencies [189854c]
4116
+ - Updated dependencies [0e3a226]
4117
+ - Updated dependencies [92a67f2]
4118
+ - Updated dependencies [9136327]
4119
+ - Updated dependencies [bf0ae99]
4120
+ - Updated dependencies [eb3e650]
4121
+ - Updated dependencies [abeb375]
4122
+ - Updated dependencies [cb3b6cd]
4123
+ - Updated dependencies [73b7234]
4124
+ - Updated dependencies [d2b97c3]
4125
+ - Updated dependencies [61cc079]
4126
+ - Updated dependencies [45dc446]
4127
+ - Updated dependencies [0e96e46]
4128
+ - Updated dependencies [c1d44f7]
4129
+ - Updated dependencies [59b794f]
4130
+ - Updated dependencies [ef4efa8]
4131
+ - Updated dependencies [cbb6a5c]
4132
+ - Updated dependencies [fc3a36a]
4133
+ - Updated dependencies [ab9fb5c]
4134
+ - Updated dependencies [69787f0]
4135
+ - Updated dependencies [5d022a1]
4136
+ - Updated dependencies [042b9ee]
4137
+ - Updated dependencies [b25a116]
4138
+ - Updated dependencies [02dc076]
4139
+ - Updated dependencies [f985b3f]
4140
+ - Updated dependencies [795b6e1]
4141
+ - Updated dependencies [d52d4fe]
4142
+ - Updated dependencies [742cebb]
4143
+ - Updated dependencies [175d789]
4144
+ - Updated dependencies [f549a0d]
4145
+ - Updated dependencies [427344c]
4146
+ - Updated dependencies [8af76ae]
4147
+ - Updated dependencies [1d4756e]
4148
+ - Updated dependencies [720c5ad]
4149
+ - Updated dependencies [a8d1e24]
4150
+ - Updated dependencies [b85cc54]
4151
+ - Updated dependencies [a36db28]
4152
+ - Updated dependencies [7a8476f]
4153
+ - Updated dependencies [518ca7a]
4154
+ - Updated dependencies [41642b0]
4155
+ - Updated dependencies [4cca74c]
4156
+ - Updated dependencies [88ef03e]
4157
+ - Updated dependencies [9a4932a]
4158
+ - Updated dependencies [3f8817a]
4159
+ - Updated dependencies [a2443e3]
4160
+ - Updated dependencies [e1554b1]
4161
+ - Updated dependencies [9e2caf3]
4162
+ - Updated dependencies [4856789]
4163
+ - Updated dependencies [81ce41a]
4164
+ - Updated dependencies [85e1e4e]
4165
+ - Updated dependencies [c3f4916]
4166
+ - Updated dependencies [55dbbba]
4167
+ - Updated dependencies [33e0385]
4168
+ - Updated dependencies [dac6a08]
4169
+ - Updated dependencies [72c3c86]
4170
+ - Updated dependencies [2d8dba3]
4171
+ - Updated dependencies [7f1a635]
4172
+ - Updated dependencies [2205363]
4173
+ - Updated dependencies [09fe58d]
4174
+ - Updated dependencies [f9fc874]
4175
+ - Updated dependencies [d62f8eb]
4176
+ - Updated dependencies [d0a5ceb]
4177
+ - Updated dependencies [a7586cd]
4178
+ - Updated dependencies [4c5e80e]
4179
+ - Updated dependencies [4b5702a]
4180
+ - Updated dependencies [011b386]
4181
+ - Updated dependencies [e18a162]
4182
+ - Updated dependencies [394b7a1]
4183
+ - Updated dependencies [ce92674]
4184
+ - Updated dependencies [0f2fdcd]
4185
+ - Updated dependencies [d6d1a50]
4186
+ - Updated dependencies [cf2c9b7]
4187
+ - Updated dependencies [8ffa8b9]
4188
+ - Updated dependencies [d127ff0]
4189
+ - Updated dependencies [674ac99]
4190
+ - Updated dependencies [833b512]
4191
+ - Updated dependencies [9881074]
4192
+ - Updated dependencies [36d90fc]
4193
+ - Updated dependencies [7777e8f]
4194
+ - Updated dependencies [9b86cf6]
4195
+ - Updated dependencies [d063a96]
4196
+ - Updated dependencies [8825a06]
4197
+ - Updated dependencies [5087ac6]
4198
+ - Updated dependencies [677b591]
4199
+ - Updated dependencies [cf7c694]
4200
+ - Updated dependencies [ddd0f06]
4201
+ - Updated dependencies [d77d1b7]
4202
+ - Updated dependencies [0f9faa2]
4203
+ - Updated dependencies [2d1ddf0]
4204
+ - Updated dependencies [354b00f]
4205
+ - Updated dependencies [3de535b]
4206
+ - Updated dependencies [fe2e15a]
4207
+ - Updated dependencies [5b79a34]
4208
+ - Updated dependencies [502564d]
4209
+ - Updated dependencies [603cab8]
4210
+ - Updated dependencies [c757854]
4211
+ - Updated dependencies [471839d]
4212
+ - Updated dependencies [507b92a]
4213
+ - Updated dependencies [46365ab]
4214
+ - Updated dependencies [b508244]
4215
+ - Updated dependencies [df95346]
4216
+ - Updated dependencies [3dede58]
4217
+ - Updated dependencies [c6b6bb4]
4218
+ - Updated dependencies [594508e]
4219
+ - Updated dependencies [7cf42fe]
4220
+ - Updated dependencies [5966c2a]
4221
+ - Updated dependencies [0045682]
4222
+ - Updated dependencies [7309c81]
4223
+ - Updated dependencies [2f59da0]
4224
+ - Updated dependencies [d56012f]
4225
+ - Updated dependencies [f78dd83]
4226
+ - Updated dependencies [a2cd18a]
4227
+ - Updated dependencies [9051802]
4228
+ - Updated dependencies [20bc1ec]
4229
+ - Updated dependencies [1c625ca]
4230
+ - Updated dependencies [2f8328c]
4231
+ - Updated dependencies [2a6c279]
4232
+ - Updated dependencies [9319586]
4233
+ - Updated dependencies [8c8f0df]
4234
+ - Updated dependencies [8ad609c]
4235
+ - Updated dependencies [bbee302]
4236
+ - Updated dependencies [90c2b15]
4237
+ - Updated dependencies [4638aaa]
4238
+ - Updated dependencies [0222d3c]
4239
+ - Updated dependencies [08863dd]
4240
+ - Updated dependencies [39eb01b]
4241
+ - Updated dependencies [071d0dc]
4242
+ - Updated dependencies [f293d45]
4243
+ - Updated dependencies [56664f5]
4244
+ - Updated dependencies [71f205d]
4245
+ - Updated dependencies [f067930]
4246
+ - Updated dependencies [414395b]
4247
+ - Updated dependencies [42eeb7d]
4248
+ - Updated dependencies [31cbe90]
4249
+ - Updated dependencies [6b7129a]
4250
+ - Updated dependencies [c5adfe1]
4251
+ - Updated dependencies [97ace2a]
4252
+ - Updated dependencies [26e1029]
4253
+ - Updated dependencies [0a936ea]
4254
+ - Updated dependencies [90bbf25]
4255
+ - Updated dependencies [023c00b]
4256
+ - Updated dependencies [eb91eba]
4257
+ - Updated dependencies [42da73d]
4258
+ - Updated dependencies [01e124d]
4259
+ - Updated dependencies [ef7b5ef]
4260
+ - Updated dependencies [9514767]
4261
+ - Updated dependencies [8f20201]
4262
+ - Updated dependencies [155507e]
4263
+ - Updated dependencies [643b7c7]
4264
+ - Updated dependencies [7bba90b]
4265
+ - Updated dependencies [8813b90]
4266
+ - Updated dependencies [108ba8d]
4267
+ - Updated dependencies [2a5f04a]
4268
+ - Updated dependencies [4f740b0]
4269
+ - Updated dependencies [030125b]
4270
+ - Updated dependencies [7ce02eb]
4271
+ - Updated dependencies [b4ad984]
4272
+ - Updated dependencies [a9f32df]
4273
+ - Updated dependencies [aeb9b27]
4274
+ - Updated dependencies [7d27da0]
4275
+ - Updated dependencies [d0d5205]
4276
+ - Updated dependencies [1a15893]
4277
+ - Updated dependencies [b70e534]
4278
+ - Updated dependencies [7e05d8e]
4279
+ - Updated dependencies [8f1851e]
4280
+ - Updated dependencies [b4b2c7d]
4281
+ - Updated dependencies [61ea810]
4282
+ - Updated dependencies [2233a85]
4283
+ - Updated dependencies [67452d1]
4284
+ - Updated dependencies [089767f]
4285
+ - Updated dependencies [a13827e]
4286
+ - Updated dependencies [66d99ec]
4287
+ - Updated dependencies [cb43296]
4288
+ - Updated dependencies [b61afc1]
4289
+ - Updated dependencies [79021fc]
4290
+ - Updated dependencies [7733604]
4291
+ - Updated dependencies [40e420f]
4292
+ - Updated dependencies [62dd69a]
4293
+ - Updated dependencies [d13004a]
4294
+ - Updated dependencies [be7360c]
4295
+ - Updated dependencies [e15e679]
4296
+ - Updated dependencies [2ab1257]
4297
+ - Updated dependencies [0fc6219]
4298
+ - Updated dependencies [061406d]
4299
+ - Updated dependencies [e4c8b6c]
4300
+ - Updated dependencies [acb10f6]
4301
+ - Updated dependencies [605e190]
4302
+ - Updated dependencies [c6c59f1]
4303
+ - Updated dependencies [b0e78a8]
4304
+ - Updated dependencies [f31cc8d]
4305
+ - Updated dependencies [f343dc4]
4306
+ - Updated dependencies [8269e32]
4307
+ - Updated dependencies [74f7339]
4308
+ - Updated dependencies [a6c35a2]
4309
+ - Updated dependencies [c2f1002]
4310
+ - Updated dependencies [4cc4fb7]
4311
+ - Updated dependencies [97b6658]
4312
+ - Updated dependencies [28d1eb7]
4313
+ - Updated dependencies [06770c0]
4314
+ - Updated dependencies [2c26040]
4315
+ - Updated dependencies [f758cec]
4316
+ - Updated dependencies [5b47ab5]
4317
+ - Updated dependencies [b09d8d9]
4318
+ - Updated dependencies [b09d8d9]
4319
+ - Updated dependencies [8675db6]
4320
+ - Updated dependencies [b09d8d9]
4321
+ - Updated dependencies [27358d5]
4322
+ - Updated dependencies [1c3da1f]
4323
+ - Updated dependencies [c1f344b]
4324
+ - Updated dependencies [3eb1b2b]
4325
+ - Updated dependencies [9c93465]
4326
+ - Updated dependencies [a34fd2e]
4327
+ - Updated dependencies [ebb209c]
4328
+ - Updated dependencies [76bcb83]
4329
+ - Updated dependencies [59b85c0]
4330
+ - Updated dependencies [889ae47]
4331
+ - Updated dependencies [4f4c3fb]
4332
+ - Updated dependencies [78f0be8]
4333
+ - Updated dependencies [6e357ed]
4334
+ - Updated dependencies [d6938bf]
4335
+ - Updated dependencies [35f7fb4]
4336
+ - Updated dependencies [0410522]
4337
+ - Updated dependencies [63b33e6]
4338
+ - Updated dependencies [f163028]
4339
+ - Updated dependencies [814db6d]
4340
+ - Updated dependencies [a5302c7]
4341
+ - Updated dependencies [31e0be9]
4342
+ - Updated dependencies [4bfd455]
4343
+ - Updated dependencies [ffd2ce2]
4344
+ - Updated dependencies [2a44c1d]
4345
+ - Updated dependencies [7084313]
4346
+ - Updated dependencies [f07808c]
4347
+ - Updated dependencies [91cefb8]
4348
+ - Updated dependencies [7ffc3d3]
4349
+ - Updated dependencies [88346ba]
4350
+ - Updated dependencies [4631592]
4351
+ - Updated dependencies [62f8017]
4352
+ - Updated dependencies [32ff033]
4353
+ - Updated dependencies [a831df1]
4354
+ - Updated dependencies [f752ee3]
4355
+ - Updated dependencies [a1b61e0]
4356
+ - Updated dependencies [cd6b9f2]
4357
+ - Updated dependencies [2cb6d3c]
4358
+ - Updated dependencies [af2a095]
4359
+ - Updated dependencies [5ac93d4]
4360
+ - Updated dependencies [695cfbd]
4361
+ - Updated dependencies [0e043d8]
4362
+ - Updated dependencies [93f267f]
4363
+ - Updated dependencies [7445149]
4364
+ - Updated dependencies [ec796d5]
4365
+ - Updated dependencies [071d0dc]
4366
+ - Updated dependencies [0024abf]
4367
+ - Updated dependencies [8dd98bf]
4368
+ - Updated dependencies [e87fea1]
4369
+ - Updated dependencies [c65e529]
4370
+ - Updated dependencies [0848bea]
4371
+ - Updated dependencies [d51bed2]
4372
+ - Updated dependencies [dadd1ad]
4373
+ - Updated dependencies [acbf364]
4374
+ - Updated dependencies [3ca34c1]
4375
+ - Updated dependencies [7adc841]
4376
+ - Updated dependencies [239c3a3]
4377
+ - Updated dependencies [b8b3c64]
4378
+ - Updated dependencies [2f2e63c]
4379
+ - Updated dependencies [4845f85]
4380
+ - Updated dependencies [486d526]
4381
+ - Updated dependencies [94a0bbc]
4382
+ - Updated dependencies [d6bfb3d]
4383
+ - Updated dependencies [8a9c079]
4384
+ - Updated dependencies [7b005b4]
4385
+ - Updated dependencies [cc3555e]
4386
+ - Updated dependencies [a2266a6]
4387
+ - Updated dependencies [d25a0ec]
4388
+ - Updated dependencies [89d7b35]
4389
+ - Updated dependencies [94f7b6a]
4390
+ - Updated dependencies [5c94f83]
4391
+ - Updated dependencies [ea936f3]
4392
+ - Updated dependencies [0c0fbd9]
4393
+ - Updated dependencies [667b83e]
4394
+ - Updated dependencies [f3141d8]
4395
+ - Updated dependencies [7687f7b]
4396
+ - Updated dependencies [5a84d41]
4397
+ - Updated dependencies [fd3013a]
4398
+ - Updated dependencies [85ec26d]
4399
+ - Updated dependencies [73e576f]
4400
+ - Updated dependencies [f6476fc]
4401
+ - Updated dependencies [69ac82c]
4402
+ - Updated dependencies [4ac12ef]
4403
+ - Updated dependencies [833ed84]
4404
+ - Updated dependencies [a18abf3]
4405
+ - Updated dependencies [c6a4eeb]
4406
+ - Updated dependencies [1659072]
4407
+ - Updated dependencies [f450ae7]
4408
+ - Updated dependencies [abceb0d]
4409
+ - Updated dependencies [627b188]
4410
+ - Updated dependencies [8d4eae7]
4411
+ - Updated dependencies [c5a5996]
4412
+ - Updated dependencies [0c302a7]
4413
+ - Updated dependencies [b88f5e8]
4414
+ - Updated dependencies [857a6cf]
4415
+ - Updated dependencies [65a3a84]
4416
+ - Updated dependencies [6633337]
4417
+ - Updated dependencies [21676eb]
4418
+ - Updated dependencies [e9cb9ab]
4419
+ - Updated dependencies [42cc219]
4420
+ - Updated dependencies [d7e0b42]
4421
+ - Updated dependencies [3510e4a]
4422
+ - Updated dependencies [d5749d7]
4423
+ - Updated dependencies [f00d8d4]
4424
+ - Updated dependencies [5326b36]
4425
+ - Updated dependencies [aa4b90d]
4426
+ - Updated dependencies [ccd9397]
4427
+ - Updated dependencies [503be86]
4428
+ - Updated dependencies [54299ca]
4429
+ - Updated dependencies [ae490ef]
4430
+ - Updated dependencies [e124711]
4431
+ - Updated dependencies [dc61def]
4432
+ - Updated dependencies [bca935b]
4433
+ - Updated dependencies [d92c72d]
4434
+ - Updated dependencies [c54c822]
4435
+ - Updated dependencies [8dcc0f5]
4436
+ - Updated dependencies [75b9e51]
4437
+ - Updated dependencies [f61c8cf]
4438
+ - Updated dependencies [e3ef52b]
4439
+ - Updated dependencies [0a2f233]
4440
+ - Updated dependencies [8621cdd]
4441
+ - Updated dependencies [251e888]
4442
+ - Updated dependencies [07f1822]
4443
+ - Updated dependencies [e336549]
4444
+ - Updated dependencies [3bb9340]
4445
+ - Updated dependencies [1e604c4]
4446
+ - Updated dependencies [04fab5e]
4447
+ - Updated dependencies [183b4c4]
4448
+ - Updated dependencies [7f713b6]
4449
+ - Updated dependencies [d40f43a]
4450
+ - Updated dependencies [2fdb36e]
4451
+ - Updated dependencies [6f23667]
4452
+ - Updated dependencies [cde1975]
4453
+ - Updated dependencies [0bc685a]
4454
+ - Updated dependencies [20526f5]
4455
+ - Updated dependencies [efedd28]
4456
+ - Updated dependencies [5d21a48]
4457
+ - Updated dependencies [5278e11]
4458
+ - Updated dependencies [c5eef1d]
4459
+ - Updated dependencies [e5e7ee0]
4460
+ - Updated dependencies [23dba62]
4461
+ - Updated dependencies [e0f300b]
4462
+ - Updated dependencies [761a0ba]
4463
+ - Updated dependencies [c960170]
4464
+ - Updated dependencies [19365b7]
4465
+ - Updated dependencies [ba98e26]
4466
+ - Updated dependencies [b7ed26d]
4467
+ - Updated dependencies [a2ebea2]
4468
+ - Updated dependencies [800bdb0]
4469
+ - Updated dependencies [9d4dfc4]
4470
+ - Updated dependencies [1059965]
4471
+ - Updated dependencies [def5919]
4472
+ - Updated dependencies [ee264b2]
4473
+ - Updated dependencies [60b672e]
4474
+ - Updated dependencies [6b441a8]
4475
+ - Updated dependencies [ce0cfe9]
4476
+ - Updated dependencies [04f1182]
4477
+ - Updated dependencies [be87153]
4478
+ - Updated dependencies [dd0f681]
4479
+ - Updated dependencies [60f0dd8]
4480
+ - Updated dependencies [a87c5cd]
4481
+ - Updated dependencies [a47f338]
4482
+ - Updated dependencies [b3a3d83]
4483
+ - Updated dependencies [7a55913]
4484
+ - Updated dependencies [35accbf]
4485
+ - Updated dependencies [6038de7]
4486
+ - Updated dependencies [fc5f536]
4487
+ - Updated dependencies [5647006]
4488
+ - Updated dependencies [e654bfd]
4489
+ - Updated dependencies [01a7337]
4490
+ - Updated dependencies [b45c71e]
4491
+ - Updated dependencies [f8cfbb4]
4492
+ - Updated dependencies [6e6c872]
4493
+ - Updated dependencies [2598216]
4494
+ - Updated dependencies [11949fc]
4495
+ - Updated dependencies [2c7e62d]
4496
+ - Updated dependencies [eb95d97]
4497
+ - Updated dependencies [b098b0e]
4498
+ - Updated dependencies [4d00b13]
4499
+ - Updated dependencies [1363084]
4500
+ - Updated dependencies [fa5758e]
4501
+ - Updated dependencies [38f7e4f]
4502
+ - Updated dependencies [eb7613c]
4503
+ - Updated dependencies [c57f3cf]
4504
+ - Updated dependencies [ecc9110]
4505
+ - Updated dependencies [e4c2dc8]
4506
+ - Updated dependencies [97faca3]
4507
+ - Updated dependencies [57bab76]
4508
+ - Updated dependencies [c89d18c]
4509
+ - Updated dependencies [1bd2795]
4510
+ - Updated dependencies [f7bd4e2]
4511
+ - Updated dependencies [694c350]
4512
+ - Updated dependencies [361bd5b]
4513
+ - Updated dependencies [aac90a5]
4514
+ - Updated dependencies [3da3da5]
4515
+ - Updated dependencies [1e6ab15]
4516
+ - Updated dependencies [b90086a]
4517
+ - Updated dependencies [129b378]
4518
+ - Updated dependencies [88f9d94]
4519
+ - Updated dependencies [8186a70]
4520
+ - Updated dependencies [a329cca]
4521
+ - Updated dependencies [c87ef70]
4522
+ - Updated dependencies [3cb0618]
4523
+ - Updated dependencies [32a0874]
4524
+ - Updated dependencies [6eec18c]
4525
+ - Updated dependencies [4d7bebf]
4526
+ - Updated dependencies [821ac7a]
4527
+ - Updated dependencies [8f81731]
4528
+ - Updated dependencies [7055c22]
4529
+ - Updated dependencies [785a748]
4530
+ - Updated dependencies [3af0354]
4531
+ - Updated dependencies [866ff16]
4532
+ - Updated dependencies [5a85e67]
4533
+ - Updated dependencies [8b50cb3]
4534
+ - Updated dependencies [a0fdc56]
4535
+ - Updated dependencies [b95577a]
4536
+ - Updated dependencies [0dcbc11]
4537
+ - Updated dependencies [d88f3e9]
4538
+ - Updated dependencies [ad5fe25]
4539
+ - Updated dependencies [c183a12]
4540
+ - Updated dependencies [83c161f]
4541
+ - Updated dependencies [d8c4957]
4542
+ - Updated dependencies [b9f930b]
4543
+ - Updated dependencies [f24cb83]
4544
+ - Updated dependencies [5dbbb92]
4545
+ - Updated dependencies [ea90179]
4546
+ - Updated dependencies [1818998]
4547
+ - Updated dependencies [ce92674]
4548
+ - Updated dependencies [5ef0b5b]
4549
+ - Updated dependencies [8c2db68]
4550
+ - Updated dependencies [22b5e54]
4551
+ - Updated dependencies [0166bd5]
4552
+ - Updated dependencies [8064b07]
4553
+ - Updated dependencies [09ee21c]
4554
+ - Updated dependencies [4a56dbd]
4555
+ - Updated dependencies [289d04a]
4556
+ - Updated dependencies [f549a0d]
4557
+ - Updated dependencies [48fbacb]
4558
+ - Updated dependencies [06df4fa]
4559
+ - Updated dependencies [3fc2e48]
4560
+ - Updated dependencies [c9b809f]
4561
+ - Updated dependencies [e8f435c]
4562
+ - Updated dependencies [32386f8]
4563
+ - Updated dependencies [9b702dc]
4564
+ - Updated dependencies [ab16331]
4565
+ - Updated dependencies [41610f6]
4566
+ - Updated dependencies [69f1dfd]
4567
+ - Updated dependencies [bbe05de]
4568
+ - Updated dependencies [355e951]
4569
+ - Updated dependencies [a1dd1e4]
4570
+ - Updated dependencies [dadb43f]
4571
+ - Updated dependencies [3556b67]
4572
+ - @objectstack/spec@17.0.0
4573
+ - @objectstack/core@17.0.0
4574
+ - @objectstack/types@17.0.0
4575
+
3
4576
  ## 17.0.0-rc.6
4
4577
 
5
4578
  ### Minor Changes
@@ -199,7 +4772,7 @@ vocabulary − this`), which is what stops the next aggregate added to the spec
199
4772
  is untouched; it is simply no longer reachable through a spec-valid request. On
200
4773
  the dataset path nothing changes: `compileDataset` refused both by name already.
201
4774
 
202
- <!-- adr-0087: registered query-array-string-agg-retired, dataset-measure-array-string-agg-removed -->
4775
+ <!-- adr-0087: registered query-array-string-agg-retired, dataset-measure-array-string-agg-removed -->
203
4776
 
204
4777
  - 2bc1876: fix(service-analytics): refuse a dotted `measures` entry loudly instead of aggregating the base column (#5918)
205
4778