@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 +4574 -1
- package/dist/index.cjs +239 -12
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +124 -0
- package/dist/index.d.ts +124 -0
- package/dist/index.js +234 -7
- package/dist/index.js.map +1 -1
- package/package.json +7 -5
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
|
-
|
|
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
|
|