@objectstack/service-analytics 17.0.0-rc.5 → 17.0.0-rc.6
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 +803 -0
- package/dist/index.cjs +371 -27
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +57 -11
- package/dist/index.d.ts +57 -11
- package/dist/index.js +374 -30
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,808 @@
|
|
|
1
1
|
# Changelog — @objectstack/service-analytics
|
|
2
2
|
|
|
3
|
+
## 17.0.0-rc.6
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 3f8817a: feat(spec,drivers,objectql,analytics,formula): `$icontains` reaches every JS evaluation face (#6520)
|
|
8
|
+
|
|
9
|
+
The other half of #5702. That change implemented `$icontains` on the SQL family
|
|
10
|
+
and correctly left the spec's `FILTER_OPERATORS` alone; this one adds the
|
|
11
|
+
operator to that array and gives every remaining evaluation face an arm, in ONE
|
|
12
|
+
change, because those two steps cannot be separated.
|
|
13
|
+
|
|
14
|
+
**Why one PR.** `FILTER_OPERATORS` is not a word list, it is a runtime allowlist:
|
|
15
|
+
`driver-memory`'s shape gate derives from it, and its matcher's `default:` arm
|
|
16
|
+
assumes the gate already refused anything unimplemented. Measured on a branch
|
|
17
|
+
that added the name early (#5701): the gate stopped refusing, the matcher fell
|
|
18
|
+
through, and `match({ name: 'zzz' }, { name: { $icontains: 'acme' } })` returned
|
|
19
|
+
`true` — the predicate silently dropped, every row matched. A dropped predicate
|
|
20
|
+
does not narrow a query, it WIDENS it, and on an RLS read scope that is a
|
|
21
|
+
permission bypass rather than a degraded feature (#3948). So the word list
|
|
22
|
+
travels with the evaluators or not at all.
|
|
23
|
+
|
|
24
|
+
**What now answers it**, all folding the same domain: `driver-memory` (query
|
|
25
|
+
path, reference matcher, and the analytics/cube face), `driver-mongodb`,
|
|
26
|
+
`objectql`'s `having`, `@objectstack/formula`'s `matchesFilterCondition` (the RLS
|
|
27
|
+
write-side `check`), and `service-analytics`' three SQL compilers (the RLS
|
|
28
|
+
lowering, the native-SQL strategy, and the `/analytics/sql` echo).
|
|
29
|
+
|
|
30
|
+
**The fold is ASCII-only, and that is the contract, not an implementation
|
|
31
|
+
detail** (#4706 Q1 = A). `$icontains: 'café'` does not match `CAFÉ`. Every face
|
|
32
|
+
reads one shared definition — `foldAsciiCase` /
|
|
33
|
+
`asciiCaseInsensitiveContains` / `asciiCaseInsensitiveRegexSource`, new exports
|
|
34
|
+
on `@objectstack/spec/data` — because the two obvious per-package spellings are
|
|
35
|
+
both wrong in the same direction: `toLowerCase()` folds the whole Unicode range,
|
|
36
|
+
and so does a `RegExp` built with the `i` flag. SQLite folds ASCII only and three
|
|
37
|
+
of the five drivers are SQLite underneath, so a Unicode fold on a JS face would
|
|
38
|
+
re-open exactly the divergence the ruling closed. The pattern-binding faces
|
|
39
|
+
(mingo, mongo) therefore emit one `[Aa]` character class per ASCII letter and
|
|
40
|
+
pass NO flags; mongo's `$icontains` is the one arm in its family that does not
|
|
41
|
+
set `$options: 'i'`.
|
|
42
|
+
|
|
43
|
+
The comparand keeps the rules its SQL twin has: matched LITERALLY (`%`, `_` and
|
|
44
|
+
regex metacharacters are ordinary characters), and refused when empty or
|
|
45
|
+
non-string — an empty comparand matches every row, which is a predicate that
|
|
46
|
+
constrains nothing.
|
|
47
|
+
|
|
48
|
+
**User-visible effect.** A filter using `$icontains` now behaves the same on the
|
|
49
|
+
in-memory double and on SQL, so an app whose tests run on one and whose
|
|
50
|
+
production runs the other stops getting two answers from one filter. Downstream,
|
|
51
|
+
#5814 (better-auth `Where.mode: 'insensitive'`) no longer hits a 400 on the
|
|
52
|
+
memory double.
|
|
53
|
+
|
|
54
|
+
Not changed, and still tracked: the `$contains` family still folds Unicode on
|
|
55
|
+
`driver-memory`'s query path and `driver-mongodb` (#6682) — both remain DEBT rows
|
|
56
|
+
in `scripts/check-driver-conformance.mjs`, now naming one open requirement each
|
|
57
|
+
instead of two. `formula`'s unknown-operator posture stays a silent, fail-closed
|
|
58
|
+
`false` (it governs a write-side check, where an unevaluable condition denies
|
|
59
|
+
rather than widens); the decision and its limits are documented on
|
|
60
|
+
`matches-filter.ts`, and no operator the spec DECLARES is answered that way any
|
|
61
|
+
more.
|
|
62
|
+
|
|
63
|
+
- 3264516: fix(driver-sql,service-analytics)!: 两类无意义比较对象不再编译成「静默空谓词」——`$in`/`$nin` 的对象成员与 LIKE 族的对象比较值一律拒收 (#5234)
|
|
64
|
+
|
|
65
|
+
两个形状此前都**编译通过、执行、并给出一个作者没写过的答案**,而且没有任何东西记录这件事:
|
|
66
|
+
|
|
67
|
+
| filter | 改前 | 改后 |
|
|
68
|
+
| ---------------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------- |
|
|
69
|
+
| `{status: {$in: ['a', {foo: 1}]}}` | 该成员绑不上任何行,查询答得**就像第二个成员从没被写过** | `INVALID_FILTER` / 400,点名 `index 1` |
|
|
70
|
+
| `{status: {$nin: [{foo: 1}]}}` | `NOT IN ('[object Object]')` —— **一行都没排除**,作者写下的排除悄悄没发生 | 同上 |
|
|
71
|
+
| `{name: {$contains: {}}}` | `LIKE '%[object Object]%'` —— 对一行文本恰好是 `[object Object]` 的记录,**真的命中了** | `INVALID_FILTER` / 400,点名 `StringOperatorSchema` |
|
|
72
|
+
| `{name: {$notContains: {}}}` | 反过来:为一个没人记录的理由**排除了一条真实记录** | 同上 |
|
|
73
|
+
|
|
74
|
+
#5041(PR #5223)在 `assertCompilableComparand` 的头注释里把这两个形状写为 "Deliberately NOT
|
|
75
|
+
extended",理由是它们 fail-closed(只收窄结果集)、比 #5041 实测的裸 `TypeError` 低一级。**实测下来这
|
|
76
|
+
两条理由都不成立**:`$nin` / `$notContains` 方向是**放宽**(该排除的没排除,在 read-scope 下即 #5347 /
|
|
77
|
+
#5324 判过的 over-reach);而 `$contains: {}` 给的从来不是「零行」,是**错行**。
|
|
78
|
+
|
|
79
|
+
## 三份实现一起动,否则修完仍是方言
|
|
80
|
+
|
|
81
|
+
同一个 `String()` 宽容在本仓有多份;只收紧 `driver-sql` 会变成「哪个面接的就是哪个答案」——
|
|
82
|
+
#5146 / #5332 / #5567 各花一轮消掉的那类分叉。守卫因此落在**每个包自己的收口点**,而不是三个发射器:
|
|
83
|
+
|
|
84
|
+
- **`driver-sql`** —— `assertCompilableComparand`,#5041 已有的那一个门。
|
|
85
|
+
- **`service-analytics` 的 `where` 门** —— `filter-normalizer.ts` 的 `fieldLeaves`。它是本包**唯一**的
|
|
86
|
+
leaf 生产者,所以一处拒收同时覆盖三个消费方:`NativeSQLStrategy`(真正执行的语句)、
|
|
87
|
+
`ObjectQLStrategy.generateSql`(`/analytics/sql` 回显)与 `ObjectQLStrategy.convertFilter`(引擎路径)。
|
|
88
|
+
这个顺序是关键而非顺手:`convertFilter` 是**生产者**,在那里 `String()` 会把对象洗成一个类型完全正确
|
|
89
|
+
的 `'[object Object]'` 字符串交给驱动,下游再严格的驱动也永远看不到它该严格的那个形状。
|
|
90
|
+
- **`service-analytics` 的 read-scope 门** —— `read-scope-sql.ts` 的 `compileOperator`,它编译的
|
|
91
|
+
`FilterCondition` 不经过上面那个门。
|
|
92
|
+
|
|
93
|
+
`like-pattern.ts` 与 `applyLike` 里的 `String(value)` **原样保留**:它们不再是缺陷所在,因为门前已经没有
|
|
94
|
+
渲染不出来的值能到达。两包的谓词由 `like-metacharacter-escape.test.ts` 逐值互锁——正是该文件已经用来锁
|
|
95
|
+
转义表达式的同一套办法。
|
|
96
|
+
|
|
97
|
+
## 围栏是 allow-list,而且每一条都是实测后决定的
|
|
98
|
+
|
|
99
|
+
抄 `driver-turso` `RemoteTransport` 的形状(cloud#1004 / #1058):deny-list 会把下一个被发明出来的值形状
|
|
100
|
+
悄悄放进来,这正是那个 bug 熬过第一次修复的原因。顺带说明,**turso 自 #1058 起就已经拒收这两个形状**,
|
|
101
|
+
所以本地 SQLite 与远程 SQLite 此前对同一条查询给的是不同答案;本次改动把它们收敛到一起。
|
|
102
|
+
|
|
103
|
+
留在围栏内的(逐条实测,不是假设):
|
|
104
|
+
|
|
105
|
+
- **数字 / 布尔 / `null`**:`{$contains: 5}` → `%5%`、`{$contains: null}` → `%null%` 在 `driver-sql`、
|
|
106
|
+
`driver-memory` 与 analytics 两个面上**今天答案一致**,#5526 还专门把 `null` 这条钉住了。拒收它们是在
|
|
107
|
+
**破坏**一致,不是建立一致——所以只拒**对象**。
|
|
108
|
+
- **`Date`**:turso 的 allow-list 把它作为唯一的对象转换保留,拒收会重新叉开本地与远程。
|
|
109
|
+
- **binary**:`$in` 成员照收(`isBindableComparand` 与写路径 `formatInput` 同一套分类),LIKE 拒收——它
|
|
110
|
+
绑得上但渲染不出作者想要的东西。这就是两个谓词而不是一个带 flag 的原因。
|
|
111
|
+
- **`undefined`**:不可授权(JSON 没有 `undefined`),analytics 门按 #5526 / #5332 归一为 `null` 而非拒收;
|
|
112
|
+
在 `driver-sql` 拒收它会**造出**一个分歧而不是消除一个,故照旧。
|
|
113
|
+
|
|
114
|
+
被拒的**数组**是本次唯一一个「拒收即消分叉」的形状:`{name: {$contains: ['al','be']}}` 在 `read-scope-sql`
|
|
115
|
+
(与 `driver-sql`)绑 `%al,be%`,在 analytics 的 `where` 门却绑 `%al%`(它读 `values[0]`,后面的成员被
|
|
116
|
+
静默丢弃)。同一个包对同一条 filter 有两个答案,两个门现在都拒。
|
|
117
|
+
|
|
118
|
+
## 作者需要知道的迁移
|
|
119
|
+
|
|
120
|
+
这两个形状本来就没有能用的读法——`filter.zod.ts` 的 `StringOperatorSchema` 早就把 LIKE 族比较数声明为
|
|
121
|
+
`z.string()`,本次只是让声明变成强制(Prime Directive #12,declared = enforced)。改后它们答 400 而不是
|
|
122
|
+
一个错答案;把比较数换成字面值即可。`{$eq: {…}}` **不在本次范围**,仍按 `toSqlBindValue` 绑 JSON(#5526
|
|
123
|
+
钉住的行为)。
|
|
124
|
+
|
|
125
|
+
### Patch Changes
|
|
126
|
+
|
|
127
|
+
- 259459d: refactor(spec)!: retire `array_agg` / `string_agg` from `AggregationFunction` — `count_distinct` deliberately kept (#6188, ADR-0049)
|
|
128
|
+
|
|
129
|
+
`AggregationFunction` declared eight functions; the SQL family compiles five.
|
|
130
|
+
`SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` each lower
|
|
131
|
+
`count`/`sum`/`avg`/`min`/`max` and route everything else to one refusal, so
|
|
132
|
+
three of the eight were declared-but-unenforced against the backends this
|
|
133
|
+
platform targets — and, worse, the _set_ each backend implemented was different,
|
|
134
|
+
so "which aggregations can I use" had no answer an author could read off the
|
|
135
|
+
schema.
|
|
136
|
+
|
|
137
|
+
What makes these two sharper than an ordinary inert declaration is that another
|
|
138
|
+
package had to carry a denylist for them. `service-analytics` subtracted
|
|
139
|
+
`array_agg` and `string_agg` by name in `UNSUPPORTED_AGGREGATES`, because
|
|
140
|
+
without that subtraction they reached the Cube strategy's `default` and came
|
|
141
|
+
back as `COUNT(*)` — **a row count in place of the value the author asked for**,
|
|
142
|
+
with no error and no log (objectui#2945).
|
|
143
|
+
|
|
144
|
+
**The three unlowered functions were SPLIT, not retired as a block** (maintainer
|
|
145
|
+
ruling, 2026-08-07):
|
|
146
|
+
|
|
147
|
+
- **`count_distinct` STAYS** and takes ADR-0049's _enforce_ leg. It is a
|
|
148
|
+
dashboard staple with one portable lowering (`COUNT(DISTINCT x)`), and
|
|
149
|
+
`service-analytics` lowers it already; the SQL-driver implementation follows
|
|
150
|
+
on its own card. Its declaration leads its implementation here by decision,
|
|
151
|
+
not by drift.
|
|
152
|
+
- **`array_agg` / `string_agg` take the _remove_ leg.** Display conveniences
|
|
153
|
+
with no measured pull, and `string_agg` never had one shape to lower to at
|
|
154
|
+
all: the delimiter is a second argument in PostgreSQL, a `SEPARATOR` clause in
|
|
155
|
+
MySQL and a differently named function in SQL Server.
|
|
156
|
+
|
|
157
|
+
FROM → TO, both authoring surfaces:
|
|
158
|
+
|
|
159
|
+
| Was | Now |
|
|
160
|
+
| :-------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- |
|
|
161
|
+
| `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 |
|
|
162
|
+
| `aggregations: [{ function: 'string_agg', field: 'name', alias: 'names' }]` | as above |
|
|
163
|
+
| `measures: [{ name: 'tags', aggregate: 'array_agg', field: 'tag' }]` | delete the measure — `compileDataset` already refused it by name, so it never produced a number |
|
|
164
|
+
|
|
165
|
+
The retirement kit:
|
|
166
|
+
|
|
167
|
+
- This is an enum **VALUE** retirement, so there is no `retiredKey()` tombstone:
|
|
168
|
+
the enum's own error map carries the prescription, keyed on the received value
|
|
169
|
+
so that only the two spellings which used to be legal are told they "were
|
|
170
|
+
removed" (the `crypto.hash` / `HookBodyCapability` precedent, #4391). A
|
|
171
|
+
mis-spelling still gets zod's list of the legal functions. For the same reason
|
|
172
|
+
nothing lands in `RETIRED_KEYS_BY_MAJOR` and the four surface ratchets are
|
|
173
|
+
byte-identical — no def and no authorable key changed.
|
|
174
|
+
- **ADR-0087 D2 conversion + D3 chain step**
|
|
175
|
+
(`dataset-measure-array-string-agg-removed`): `os migrate meta --from 16`
|
|
176
|
+
drops any `dataset.measures[]` declaring a retired aggregate, plus any derived
|
|
177
|
+
measure the drop strands, with a notice each. The measure is dropped rather
|
|
178
|
+
than stripped down because one with neither `aggregate` nor `derived` fails
|
|
179
|
+
the dataset's own refinement — a conversion whose output cannot parse is worse
|
|
180
|
+
than none.
|
|
181
|
+
- **D3 semantic entry** (`query-array-string-agg-retired`) for
|
|
182
|
+
`QueryAST.aggregations[].function`: a request surface, never stored, so there
|
|
183
|
+
is no source for the chain to rewrite and callers move their own queries.
|
|
184
|
+
- The engine's in-memory fallback (`@objectstack/objectql`) drops its arms for
|
|
185
|
+
both functions — a `switch` case on a value the enum no longer has does not
|
|
186
|
+
type-check, and a dead arm is how a retired vocabulary returns by accident.
|
|
187
|
+
- `service-analytics`' `UNSUPPORTED_AGGREGATES` is now **empty and kept**: it is
|
|
188
|
+
half of an arithmetic the lockstep suite enforces (`SUPPORTED = spec
|
|
189
|
+
vocabulary − this`), which is what stops the next aggregate added to the spec
|
|
190
|
+
from silently reaching that `COUNT(*)` default.
|
|
191
|
+
|
|
192
|
+
**Behaviour that actually changes** — this is the rare narrowing that removes
|
|
193
|
+
reachable behaviour, and it is worth stating plainly: on `driver-mongodb` and on
|
|
194
|
+
the engine's in-memory fallback these two DID compute. A raw QueryAST
|
|
195
|
+
aggregation against those backends returned an array or a joined string and will
|
|
196
|
+
now be refused at parse. That unpredictability is precisely what the ruling
|
|
197
|
+
ended — an aggregation that worked on one backend and failed on another is not a
|
|
198
|
+
capability — and both of those backends are inside the #5499 freeze. Their code
|
|
199
|
+
is untouched; it is simply no longer reachable through a spec-valid request. On
|
|
200
|
+
the dataset path nothing changes: `compileDataset` refused both by name already.
|
|
201
|
+
|
|
202
|
+
<!-- adr-0087: registered query-array-string-agg-retired, dataset-measure-array-string-agg-removed -->
|
|
203
|
+
|
|
204
|
+
- 2bc1876: fix(service-analytics): refuse a dotted `measures` entry loudly instead of aggregating the base column (#5918)
|
|
205
|
+
|
|
206
|
+
**Observable behaviour change.** An analytics query whose `measures` entry
|
|
207
|
+
carries a dot that is not the cube-name qualifier — `owner.region_count_distinct`,
|
|
208
|
+
`total.sum` — now answers `400 INVALID_FIELD` naming the entry **as the request
|
|
209
|
+
spelled it**. Some of these queries used to succeed.
|
|
210
|
+
|
|
211
|
+
That is the point: succeeding is what was wrong with them. The auto-inference
|
|
212
|
+
path minted a measure by dropping the first segment of any dotted entry, so on
|
|
213
|
+
an object that happened to carry a same-named column the query ran
|
|
214
|
+
|
|
215
|
+
```
|
|
216
|
+
SELECT COUNT(DISTINCT region) AS "owner.region_count_distinct" FROM "crm_account"
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
— no JOIN, no error, a response column labelled with a relation attribute and a
|
|
220
|
+
number that came from the base table. The caller could not tell from the result
|
|
221
|
+
that it was wrong. Where the object had no same-named column it degraded to the
|
|
222
|
+
#4437 gate's `400 INVALID_FIELD`, which was honest about what reached SQL
|
|
223
|
+
(`aggregates field 'score'`) but named a string nobody had written; the caller
|
|
224
|
+
had sent `owner.score_sum`.
|
|
225
|
+
|
|
226
|
+
`measures` was the fourth and last mint site of the punctuation #5739 sorted
|
|
227
|
+
out on `dimensions` / `where` / `timeDimensions`. It is ruled the other way, and
|
|
228
|
+
deliberately so: `lookupMember`'s relation-traversal tier is dimension-only, so a
|
|
229
|
+
dotted measure has no correct traversal answer to converge on. A refusal is the
|
|
230
|
+
honest answer, and it costs nothing that was working. Maintainer ruling,
|
|
231
|
+
2026-08-07.
|
|
232
|
+
|
|
233
|
+
Both a genuine traversal intent (`owner.amount_sum`) and a plain typo
|
|
234
|
+
(`total.sum`) get this refusal. They are lexically indistinguishable on this
|
|
235
|
+
path, and separating them would need field metadata the ad-hoc path does not
|
|
236
|
+
have. A real relation-traversal measure (`SUM("owner"."amount")` + LEFT JOIN)
|
|
237
|
+
would be a capability with its own justification, not a side effect of a strip.
|
|
238
|
+
|
|
239
|
+
The refusal is applied at both places a Metric is minted from a request
|
|
240
|
+
spelling — the ad-hoc mint and the suffix-augmentation mint for a cube that is
|
|
241
|
+
already registered — because the ad-hoc path registers what it infers, so the
|
|
242
|
+
very same query reaches the second one from the second request onwards.
|
|
243
|
+
|
|
244
|
+
Unchanged: the `<cube>.` qualifier (`crm_account.region_count_distinct`) is
|
|
245
|
+
still stripped and still runs; bare measures (`region_count_distinct`, `count`,
|
|
246
|
+
`created_at_max`) are untouched; a cube's own declared measure is authored, not
|
|
247
|
+
minted, so a Cube whose measure names a related column in its `sql` still
|
|
248
|
+
compiles the JOIN — which is the supported way to aggregate across a
|
|
249
|
+
relationship; and dotted **dimensions** still traverse, per #5739.
|
|
250
|
+
|
|
251
|
+
**Migration.** Aggregate one of the object's own fields
|
|
252
|
+
(`<field>_sum` / `_avg` / `_min` / `_max` / `_count_distinct`), or declare a Cube
|
|
253
|
+
whose measure names the related column. The refusal message says both, and names
|
|
254
|
+
the entry you sent.
|
|
255
|
+
|
|
256
|
+
- 1d0faa7: fix(service-analytics): postgres 的「缺列」措辞不再被判为「缺源」(#6035)
|
|
257
|
+
|
|
258
|
+
数据集查询的降级路径靠驱动措辞判断「后端表没挂载」,从而把控件渲染成空网格而不是 500。
|
|
259
|
+
它的判据 `isMissingSourceError` 自己的文档写明范围**只含缺表/缺对象,不含列/语法错误——
|
|
260
|
+
后者要保持硬失败,好让真正的查询 bug 浮上来**。有一条 postgres 措辞按构造违反了这条承诺:
|
|
261
|
+
|
|
262
|
+
```
|
|
263
|
+
column "label" of relation "acct" does not exist (SQLSTATE 42703)
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
它内部**逐字包含**一整段合法的缺表措辞 `relation "acct" does not exist`。#5717 把 postgres
|
|
267
|
+
那一支从「同时含两个词的任意句子」收紧为锚定真实缺表措辞后,这条依然命中——它必然命中,因为它
|
|
268
|
+
字面上**就是**那段措辞。所以任何对「这句话是不是在说某个 relation 不存在」的收紧都排除不掉它,
|
|
269
|
+
只有**先问更具体的问题**才可以:修法是一个**判定顺序**(先摘掉缺列措辞,再做缺源判定),而不是
|
|
270
|
+
一个更好的正则。
|
|
271
|
+
|
|
272
|
+
两种后果都是错的,而具体触发哪一种只取决于措辞里那个关系名是否恰好是数据集自己的对象:
|
|
273
|
+
|
|
274
|
+
- 名字是**被 JOIN 的表** → 报出一条响亮但**虚假**的跨数据源拓扑错误,把一个拼写错误说成数据源
|
|
275
|
+
布局问题;
|
|
276
|
+
- 名字是**数据集自己的对象** → 控件降级成空网格,只留一条 warn,拼错的列名不会告诉任何人。
|
|
277
|
+
|
|
278
|
+
两半现在都作为回归钉住。判定顺序抄 `rest-server.ts` 的 `mapDataError` 自 #5352 起就在用的先例
|
|
279
|
+
(它同样先摘出这条措辞,于是 REST 面回答 `400 INVALID_FIELD` 而不是 `404`),用的是同一条正则
|
|
280
|
+
而不是它的第二种方言——两个面不该对「postgres 什么时候在说 column」给出不同答案。兄弟函数
|
|
281
|
+
`missingSourceRelation` 做同样的前置摘除:实测在修改前它对这条措辞回答 `sys_team`,只修其一会让
|
|
282
|
+
「是不是缺了什么」与「缺的是什么」相互矛盾,而那正是 #5717 在这一支上刚消除的分歧。
|
|
283
|
+
|
|
284
|
+
**这不修线上事故,而是让判据与它自己的文档一致。** analytics 是只读面,而 postgres 在 SELECT
|
|
285
|
+
下的未知列措辞是 `column "bogus" does not exist`(不含 `relation`,本来就不命中);
|
|
286
|
+
`column … of relation …` 是 INSERT/UPDATE/ALTER 措辞。价值在于:这条分歧不再依赖「读路径不产生该
|
|
287
|
+
措辞」这个假设活着——哪天有任何写形状语句、驱动改措辞、或多包一层 `cause` 把它送到这个 catch
|
|
288
|
+
面前,它会被正确分类,而不是被静默吞掉。
|
|
289
|
+
|
|
290
|
+
#5717 量过的 13 条仓内真实措辞全部重新钉住,并且是**按调用方可观测的结果**(空网格 / 拓扑拒收 /
|
|
291
|
+
原样上抛)钉的,而不是按私有判据的布尔值——实测 **13 条里只有 1 条改判**,就是缺列那条,其余 12
|
|
292
|
+
条(三个驱动家族的措辞、框架的 not-registered 信号、本包自己的拒收)逐条不变。
|
|
293
|
+
|
|
294
|
+
- 8e2bbba: fix(service-analytics): `compareTo` 在「日期维度本身就是网格维度」时把比较桶键平移回当期 (#6007)
|
|
295
|
+
|
|
296
|
+
趋势图 + 同比是 `compareTo` 最常见的形状:日期维度既写进 `selection.dimensions`
|
|
297
|
+
(它就是图表的时间轴),又被 `compareTo` 用作锚点。这个形状下比较趟从来没有对齐过。
|
|
298
|
+
|
|
299
|
+
比较趟查询的是**平移后**的窗口,所以它的行按平移后的桶键落地;而
|
|
300
|
+
`mergeByDimensions` 按 `selection.dimensions` 元组建键 —— `2025-01` 不等于
|
|
301
|
+
`2026-01`,于是**没有一条**比较行合并得进去,全部作为新行追加。两趟各自只报告了自己
|
|
302
|
+
那一半,`fillEmptyGroups` 把另一半填成自信的 `0`,再加上平移后的桶键坐在网格里,而它们
|
|
303
|
+
落在调用方筛选窗口之外。一个 2 桶窗口的「今年 vs 去年同期」回来是这样的:
|
|
304
|
+
|
|
305
|
+
```
|
|
306
|
+
[{"close_date":"2025-01","opp_count__compare":5,"opp_count":0},
|
|
307
|
+
{"close_date":"2025-02","opp_count__compare":7,"opp_count":0},
|
|
308
|
+
{"close_date":"2026-01","opp_count":1,"opp_count__compare":0},
|
|
309
|
+
{"close_date":"2026-02","opp_count":2,"opp_count__compare":0}]
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
四行、每行一个 0、两行在窗口外;期望是 2 行 × 2 列。
|
|
313
|
+
|
|
314
|
+
**修法(维护者裁决 2026-08-07,方向 1):合并之前,把每个比较桶键用当期的说法重述一遍。**
|
|
315
|
+
上例现在返回 `[{close_date:'2026-01',opp_count:1,opp_count__compare:5},
|
|
316
|
+
{close_date:'2026-02',opp_count:2,opp_count__compare:7}]`。
|
|
317
|
+
|
|
318
|
+
- `previousYear` —— 窗口是按日历年平移的,所以逆运算就是按日历年往前推一年:对桶自己的
|
|
319
|
+
首日做平移再重新分桶。`2025-01` → `2026-01`、`2025-Q1` → `2026-Q1`、
|
|
320
|
+
`2025-W03` → `2026-W03`。它刻意是 `shiftRange` 那套年运算的精确逆运算(含
|
|
321
|
+
`setUTCFullYear` 的溢出行为),窗口与桶键因此不可能对「一年」有两种理解。
|
|
322
|
+
- `previousPeriod` —— 任意天数窗口没有日历对应物,所以按**桶序(bucket ordinal)**对齐:
|
|
323
|
+
上一窗口的第 n 个桶对上本窗口的第 n 个桶,n 各自从自己窗口的起点数起。序号由**日历**算出
|
|
324
|
+
而不是数组下标,所以本期网格里某个桶没有数据(存在空档)不会让其后每个桶都错位一格。
|
|
325
|
+
|
|
326
|
+
**响应形状不变** —— 仍然是 `<measure>__compare` 列,行仍然是网格维度元组,所以消费端
|
|
327
|
+
(objectui#3337 正在收敛的那条契约)不受影响。
|
|
328
|
+
|
|
329
|
+
不确定时一律**保持原样**(即改动前的行为),而不是猜:空桶(两条聚合路径上键都是 `null`,
|
|
330
|
+
两趟本来就互相合并)、未分桶的日期维度(分组的是原始时间戳,不是桶键)、以及平移回来落在
|
|
331
|
+
当期窗口之外的桶(两个等长的天数窗口可以切出不同的桶数)。
|
|
332
|
+
|
|
333
|
+
范围严格限定在坏掉的那个形状:锚点必须是**网格维度**(仅作窗口的锚点两趟都不是列,#5688
|
|
334
|
+
之后本来就对齐)且必须**被分桶**。两趟通过同一个 `granularityOf` 读取桶大小,所以这里重述
|
|
335
|
+
的桶大小按构造就是查询分组用的桶大小。
|
|
336
|
+
|
|
337
|
+
- ab54608: fix(service-analytics): a dataset `label` written as an inline locale map reaches the wire resolved, instead of being dropped (#6761)
|
|
338
|
+
|
|
339
|
+
`I18nLabelSchema` has authorized two forms of a display label since #5728: a
|
|
340
|
+
plain string, and an inline locale map `{ en: 'Owner', 'zh-CN': '负责人' }`. The
|
|
341
|
+
analytics producer only understood the first one, so a dataset written the way
|
|
342
|
+
the schema documents came back with **no label at all**:
|
|
343
|
+
|
|
344
|
+
| dataset declares | `fields[]` carried, before |
|
|
345
|
+
| ------------------------------------------- | -------------------------- |
|
|
346
|
+
| `label: 'Owner'` | `label: 'Owner'` |
|
|
347
|
+
| `label: { en: 'Owner', 'zh-CN': '负责人' }` | _(no `label` key)_ |
|
|
348
|
+
| _(no label)_ | _(no `label` key)_ |
|
|
349
|
+
|
|
350
|
+
Measured identically on both strategies. All three renderers that read
|
|
351
|
+
`fields[].label` first — `DatasetWidget`, `DatasetPreview`,
|
|
352
|
+
`DatasetReportRenderer` — then fell back to humanizing the raw key, so a Chinese
|
|
353
|
+
deployment authoring exactly what the spec documents got English-ish machine
|
|
354
|
+
names for its column headers.
|
|
355
|
+
|
|
356
|
+
One layer earlier, `dataset-compiler` substituted the machine **name** for the
|
|
357
|
+
same map (`typeof d.label === 'string' ? d.label : d.name`), which additionally
|
|
358
|
+
made `/analytics/meta` publish `title: 'owner'` as a _display title_ — a face
|
|
359
|
+
that lied rather than one that was merely bare.
|
|
360
|
+
|
|
361
|
+
Both are fixed by calling the shared `I18nLabel → string` resolver
|
|
362
|
+
(`resolveI18nLabel`, `@objectstack/spec`, #6765), which is pinned in its own
|
|
363
|
+
package to rule parity with objectui's `pickLocalized`. Nothing is
|
|
364
|
+
re-implemented here: the maintainer's ruling on #6761 chose one shared resolver
|
|
365
|
+
precisely so the two ends cannot answer the same authored map differently.
|
|
366
|
+
|
|
367
|
+
**The wire is unchanged.** `AnalyticsResult.fields[].label` is still
|
|
368
|
+
`string | undefined` on both ends — this resolves _to_ a string rather than
|
|
369
|
+
widening the contract, so no consumer changes and no map can reach a renderer
|
|
370
|
+
that would print `[object Object]`.
|
|
371
|
+
|
|
372
|
+
**Which locale each site uses:**
|
|
373
|
+
|
|
374
|
+
- `queryDataset`'s two field-enrichment sites resolve at
|
|
375
|
+
`ExecutionContext.locale` — the per-request BCP-47 tag derived from the
|
|
376
|
+
caller's `Accept-Language`, falling back to the workspace `localization`
|
|
377
|
+
setting. Both sites read one hoisted value, so a single response cannot mix
|
|
378
|
+
two audiences.
|
|
379
|
+
- `dataset-compiler` resolves with **no** locale, i.e. the resolver's documented
|
|
380
|
+
nullish answer `en`. A compiled Cube is a registry artifact shared by every
|
|
381
|
+
later reader, and `getMeta()` — the `/analytics/meta` face — takes no
|
|
382
|
+
execution context at all; baking a request locale there would make
|
|
383
|
+
`/analytics/meta` answer whoever queried last.
|
|
384
|
+
|
|
385
|
+
**Nothing is invented on a miss.** A label the resolver cannot resolve (an
|
|
386
|
+
absent label, or an empty map) writes no `label` key on the wire at all — a
|
|
387
|
+
placeholder would permanently pre-empt the real label under the downstream
|
|
388
|
+
`if (field.label == null)` guard. In the compiler, where `Metric.label` /
|
|
389
|
+
`Dimension.label` are required strings, the machine-name fallback is unchanged
|
|
390
|
+
from before; it never reaches `fields[]`, so it cannot pre-empt anything either.
|
|
391
|
+
|
|
392
|
+
- 6fde910: fix(objectql,service-analytics): report the datasource an object is actually on, not the one it declares (#5288)
|
|
393
|
+
|
|
394
|
+
Analytics' `getObjectDatasource` probe read `getObject(name).datasource` — the
|
|
395
|
+
object's **declared** value, which is step 1 of the five `ObjectQL.getDriver`
|
|
396
|
+
resolves by. `ObjectSchema.datasource` carries `.default('default')`, and
|
|
397
|
+
`'default'` means "no explicit binding, keep looking" inside the engine, so
|
|
398
|
+
every object placed by a `datasourceMapping` rule, by the ADR-0057 §3.6
|
|
399
|
+
lifecycle split, or by its package's `defaultDatasource` answered `'default'`
|
|
400
|
+
and was read out here as "the primary DB".
|
|
401
|
+
|
|
402
|
+
`sys_audit_log` is the live specimen: `lifecycle.class: 'audit'` puts it on the
|
|
403
|
+
`telemetry` datasource with nothing declared to read. So #5033's query-time
|
|
404
|
+
diagnostic — whose entire job is to NAME the database a table is missing from —
|
|
405
|
+
named the wrong one:
|
|
406
|
+
|
|
407
|
+
```
|
|
408
|
+
before: table "account" is not on datasource "default", which is where its base object "sys_audit_log" lives
|
|
409
|
+
after: table "account" is not on datasource "telemetry", which is where its base object "sys_audit_log" lives
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
**New engine accessor — `ObjectQL.resolveEffectiveDatasource(objectName)`.** The
|
|
413
|
+
public, name-only face of the resolution order `getDriver` already routes by,
|
|
414
|
+
extracted so the order exists exactly once (the same argument that produced
|
|
415
|
+
`resolveMappedDatasource` in #4462: a second, shorter copy of a routing order
|
|
416
|
+
drifts by one step, silently). `getDriver` now consumes the same resolver and
|
|
417
|
+
keeps every existing behaviour — precedence, the refusal to fall through to the
|
|
418
|
+
default store when a declared or mapped datasource has no live driver, and both
|
|
419
|
+
of its diagnostics.
|
|
420
|
+
|
|
421
|
+
It answers `undefined` when nothing binds the object anywhere and it simply
|
|
422
|
+
rides the deployment's default driver. That is deliberate and unchanged from
|
|
423
|
+
what consumers already documented: the default driver keeps its natural name
|
|
424
|
+
(#3826), so that name identifies a driver rather than a datasource anyone bound
|
|
425
|
+
the object to. `getDefaultDriverName()` is still there for callers that want it.
|
|
426
|
+
|
|
427
|
+
Analytics' probe now asks the engine instead of the declaration; the routing
|
|
428
|
+
rules are **not** re-implemented on the analytics side. #5115's compile-time
|
|
429
|
+
cross-datasource join gate keeps its predicate exactly as written — what changed
|
|
430
|
+
is that its input can now answer for objects bound by a mapping rule, by the
|
|
431
|
+
lifecycle split, or by a package default, so a join between two bound
|
|
432
|
+
datasources is refused at registration instead of exploding at query time. A
|
|
433
|
+
join from a bound object to one that merely rides the deployment default is
|
|
434
|
+
still not decidable at compile time and remains the query-time diagnostic's
|
|
435
|
+
business.
|
|
436
|
+
|
|
437
|
+
- 49f208b: fix(analytics): an `undefined` comparand in an analytics `where` is refused (400 `INVALID_FILTER`), not read seven different ways
|
|
438
|
+
|
|
439
|
+
**Observable behaviour change.** A `where` key whose value is `undefined` used to
|
|
440
|
+
compile — in seven different ways, depending on where it sat. It is now refused
|
|
441
|
+
with `INVALID_FILTER` / 400, the envelope every other refusal at this door
|
|
442
|
+
already carries.
|
|
443
|
+
|
|
444
|
+
The three that mattered WIDENED the query, which is the failure mode
|
|
445
|
+
`filter-normalizer.ts` forbids in its own body ("NEVER drop: a missing predicate
|
|
446
|
+
does not narrow the query, it WIDENS it"), while its entry line did exactly that:
|
|
447
|
+
|
|
448
|
+
| `where` | used to normalize to | reading |
|
|
449
|
+
| ------------------------------ | -------------------------------- | -------------------------------------------------------- |
|
|
450
|
+
| `{d: undefined}` | `null` | the WHOLE filter dropped — the query ran **unfiltered** |
|
|
451
|
+
| `{stage: 'won', d: undefined}` | `stage equals 'won'` | the `d` conjunct vanished in silence |
|
|
452
|
+
| `{$not: {d: undefined}}` | `NOT (d set)` | `d IS NULL` — a predicate the author never wrote |
|
|
453
|
+
| `{d: {$eq: undefined}}` | `d equals [null]` | a value comparison, **not** `$eq: null`'s null predicate |
|
|
454
|
+
| `{d: {$gt: undefined}}` | `d gt [null]` | ditto |
|
|
455
|
+
| `{d: {$in: [undefined]}}` | `d in [null]` | ditto |
|
|
456
|
+
| `{d: {$ne: undefined}}` | `d notSet OR d notEquals [null]` | ditto |
|
|
457
|
+
|
|
458
|
+
The direction is silently **wrong results** — an analytics figure, a report
|
|
459
|
+
total, an aggregate, wrong with nothing to read — **not** a permission bypass:
|
|
460
|
+
read scope is compiled by a different door (`read-scope-sql.ts`) and never passed
|
|
461
|
+
through here, so a caller still saw only rows it was entitled to, just more of
|
|
462
|
+
them than it asked for.
|
|
463
|
+
|
|
464
|
+
**What to change if this refuses your filter.** `undefined` cannot cross JSON, so
|
|
465
|
+
neither REST door can carry it — this only reaches in-process callers of
|
|
466
|
+
`AnalyticsService.query({ where })` that spread a possibly-absent value into the
|
|
467
|
+
filter object (`{ owner_id: ctx.user?.id }`). Two repairs, both stated by the
|
|
468
|
+
error message:
|
|
469
|
+
|
|
470
|
+
- meant the null predicate → write `{ field: null }` or `{ field: { $null: true } }`;
|
|
471
|
+
- the value is genuinely absent → **omit the key**, which is the same "no
|
|
472
|
+
constraint" without the ambiguity.
|
|
473
|
+
|
|
474
|
+
Inside stored metadata, the platform's own answer to "scope this to the current
|
|
475
|
+
user" is unaffected and was already fail-closed: a `{current_user_id}`
|
|
476
|
+
placeholder resolves through `resolveFilterTokens`, which raises
|
|
477
|
+
`FILTER_TOKEN_UNRESOLVED` / 400 rather than emitting `undefined`.
|
|
478
|
+
|
|
479
|
+
⛔ **`null` does not move.** `{d: null}`, `{$eq: null}`, `{$ne: null}`,
|
|
480
|
+
`{$null: …}`, `{$exists: …}` and `$contains: null` keep their exact lowering —
|
|
481
|
+
`null` is a declared comparand and is the null predicate. `$null` / `$exists`
|
|
482
|
+
carry a declared boolean flag rather than a comparand and are likewise untouched.
|
|
483
|
+
|
|
484
|
+
- 2604d34: fix(analytics): a field constraint mixing `$` operators with non-`$` sibling keys is refused (400 `INVALID_FILTER`), not silently narrowed to its operators
|
|
485
|
+
|
|
486
|
+
**Observable behaviour change.** A `where` field wrapper that carries `$`-operator
|
|
487
|
+
keys and non-`$` keys at once used to compile its operators and silently DROP
|
|
488
|
+
every non-`$` sibling. It is now refused with `INVALID_FILTER` / 400, the
|
|
489
|
+
envelope every other refusal at this door already carries. Ruled Option A
|
|
490
|
+
(refuse) on #6444, 2026-08-08; Option B (flattening the siblings as nested
|
|
491
|
+
paths) was rejected because it would compile the likely-real cause — a dropped
|
|
492
|
+
`$` — into a predicate on a non-existent member such as `amount.gte`.
|
|
493
|
+
|
|
494
|
+
| `where` | used to normalize to | reading |
|
|
495
|
+
| ----------------------------------------- | ------------------------- | --------------------------------------------------- |
|
|
496
|
+
| `{d: {$eq: 1, nested: 'x'}}` | `d equals [1]` | the `nested` conjunct vanished in silence |
|
|
497
|
+
| `{amount: {gte: 10, $lte: 20}}` | `amount lte 20` | the missing-`$` typo: the lower bound silently gone |
|
|
498
|
+
| `{$not: {d: {$null: true, nested: 'x'}}}` | `NOT(d set AND d notSet)` | a contradiction that negates to TRUE — every row |
|
|
499
|
+
|
|
500
|
+
Every row WIDENED the query — a dropped conjunct returns rows the author
|
|
501
|
+
excluded, with nothing to read (the #3650 family this module refuses everywhere
|
|
502
|
+
else). Unlike #6386's `undefined` comparand, this shape survives JSON, so it can
|
|
503
|
+
sit in stored dashboard / report / dataset metadata as well as in-process
|
|
504
|
+
callers of `AnalyticsService.query({ where })`.
|
|
505
|
+
|
|
506
|
+
**What to change if this refuses your filter.** The message names the offending
|
|
507
|
+
key(s) and both repairs, because the shape has two readings this door cannot
|
|
508
|
+
tell apart:
|
|
509
|
+
|
|
510
|
+
- an operator missing its `$` was meant → spell it with the prefix
|
|
511
|
+
(`gte` → `$gte`: `{ "amount": { "$gte": 10, "$lte": 20 } }`);
|
|
512
|
+
- a nested-relation member was meant → give it a wrapper of its own with no `$`
|
|
513
|
+
siblings (`{ "d": { "nested": "x" } }` compiles to the member `d.nested`) and
|
|
514
|
+
AND it with the operator constraint explicitly via `$and`.
|
|
515
|
+
|
|
516
|
+
⛔ **The two pure shapes do not move.** A wrapper that is all `$`-operators
|
|
517
|
+
compiles exactly as before (`{amount: {$gte: 10, $lte: 20}}` stays the AND of
|
|
518
|
+
its bounds), and a wrapper that is all non-`$` keys keeps flattening to the
|
|
519
|
+
dotted member (`{d: {nested: 'x'}}` → `d.nested`). `$null` / `$exists` flag
|
|
520
|
+
semantics, the `null` comparand rulings (#5332 / #5526) and the sibling door
|
|
521
|
+
`read-scope-sql.ts` — which has always failed closed on this shape — are
|
|
522
|
+
untouched.
|
|
523
|
+
|
|
524
|
+
- 3cc8676: fix(analytics): read scope 里非布尔的 `$null` / `$exists` 比较数改为拒收,不再按真值性编成相反的谓词 (#6387)
|
|
525
|
+
|
|
526
|
+
**⚠️ 行为变更。** `compileScopedFilterToSql` 遇到 `$null` / `$exists` 上的非布尔比较数,从「按 JS 真值性归入两个声明答案之一、静默编出合法 SQL」改为 `READ_SCOPE_COMPILE_FAILED` / **500** 拒收。今天靠这个静默翻转在跑的 read scope,从此会响亮地失败。
|
|
527
|
+
|
|
528
|
+
## 实测到的毛病
|
|
529
|
+
|
|
530
|
+
发射器读的是 `val ? … : …` —— **真值性**,不是 `@objectstack/spec` `FieldOperatorsSchema` 声明的 `z.boolean()`。在 `5faa23ca3` 上直接调 `compileScopedFilterToSql`,alias `t`:
|
|
531
|
+
|
|
532
|
+
| read scope | 编译结果 | |
|
|
533
|
+
| ------------------------------------ | ---------------------------- | ------------------------- |
|
|
534
|
+
| `{ owner_id: { $null: "false" } }` | `"t"."owner_id" IS NULL` | ⛔ 与作者写的意思**相反** |
|
|
535
|
+
| `{ owner_id: { $null: "true" } }` | `"t"."owner_id" IS NULL` | |
|
|
536
|
+
| `{ owner_id: { $null: 0 } }` | `"t"."owner_id" IS NOT NULL` | |
|
|
537
|
+
| `{ owner_id: { $null: null } }` | `"t"."owner_id" IS NOT NULL` | |
|
|
538
|
+
| `{ owner_id: { $null: undefined } }` | `"t"."owner_id" IS NOT NULL` | |
|
|
539
|
+
| `{ owner_id: { $exists: "false" } }` | `"t"."owner_id" IS NOT NULL` | ⛔ 与作者写的意思**相反** |
|
|
540
|
+
| `{ owner_id: { $exists: 0 } }` | `"t"."owner_id" IS NULL` | |
|
|
541
|
+
| `{ owner_id: { $exists: "no" } }` | `"t"."owner_id" IS NOT NULL` | |
|
|
542
|
+
|
|
543
|
+
两行 ⛔ 是要害:字符串 `"false"` 是**真值**,于是它落在它被写下来所要表达的 `false` 的**对面** —— `{ $exists: "false" }` 写来表示「没有 owner 的行」,编出来是「**有** owner 的行」。这与 #6125 那一格方向相反:那边是 fail-**closed**(匹配零行、只是安静),这边是**加宽** —— admit 了策略要排除的行,出现在一个自述「A read-scope predicate must never be silently dropped、fail-closed」的模块里。
|
|
544
|
+
|
|
545
|
+
## 修法
|
|
546
|
+
|
|
547
|
+
按 #5347(`$null`)/ #5369(`$exists`)在 `driver-sql` 面确立的先例,理由逐字适用:非布尔比较数**按声明拒收**,不做强转。闸落在 `compileField`,紧挨 #6125 的 `undefined` 闸 —— 两道闸的作用域互不相交(那一道按名字跳过这两个算子),所以谁也盖不住谁的措辞。
|
|
548
|
+
|
|
549
|
+
两个算子**共用一条措辞**(#5240「一个条件一种措辞」),只有算子名与 `path` 不同:`driver-sql` 给孪生实现两条措辞,是因为各自要指名**自己**发射器默认倒向哪边;本模块只有一条规则(真值性)同时管着两个算子,两者失败方式完全一样,所以一条措辞才是诚实的写法。测试里有一条断言把「只有这两处不同」钉死。
|
|
550
|
+
|
|
551
|
+
信封沿用本模块自述的那一个(`READ_SCOPE_COMPILE_FAILED` / 500),不是 #5347 的 `INVALID_FILTER` / 400:read scope 由平台自己从 CEL 与库存 metadata 编出来,报 400 等于让调用方去修一个他既没写、也改不动的东西。继承的是**处置**(拒收),不是信封。
|
|
552
|
+
|
|
553
|
+
极性表**同 PR 一起改**:`nullValueSatisfiesOperator` 的 `$null` / `$exists` 两臂从真值性(`Boolean(value)` / `!value`)改为恒等(`value === true` / `value === false`)。每张极性表钉的是它**自己**发射器的拼写(#5146 / #5298),只改发射器不改表,不变量会安静地断在定义处。这条差异消失后,本编译器与 `driver-sql` 的同名表第一次逐臂一致。
|
|
554
|
+
|
|
555
|
+
## ⚠️ 触达性:实测结论是**库存 metadata 走不通**
|
|
556
|
+
|
|
557
|
+
定级依据是测量,不是立单时的措辞。`{ $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 哨兵)压根不含这两个算子。
|
|
558
|
+
|
|
559
|
+
**仍然开着的那条**:`getReadScope` 是 `AnalyticsPluginOptions` 上有文档的公开扩展点,宿主自带的 read scope(来自 JSON 配置或没走类型检查的 JS)与本编译器之间没有任何闸 —— 本单也确认了 `plugin-security` 全路径无 `FilterConditionSchema` / `safeParse`。所以:今天不从库存 metadata 触达,但没有任何结构性的东西挡住下一个生产者。在编译器处拒收,才让「声明为布尔」等于「强制为布尔」,与谁写这条 scope 无关。
|
|
560
|
+
|
|
561
|
+
## ⛔ 一字未动的邻居
|
|
562
|
+
|
|
563
|
+
- **合法布尔**:`$null: true/false`、`$exists: true/false` 的 SQL 逐字节不变(`IS NULL` 下降正是 RLS 用来圈无主行的写法,也是 CEL 唯一能产出的四种形状)。有自己的对照组回归 pin。
|
|
564
|
+
- **比较数位置上的 `null`**:`{ d: null }`、`{ $eq: null }`、`{ $ne: null }`、`$in: [null]` 等 #6125 的 `NULL_CONTROL` 全部保持绿。
|
|
565
|
+
- `driver-sql` / `driver-turso`(#5347 / #5369 已落地)、`packages/spec`(声明已是 `z.boolean()`)、以及本包的 `where` 门 `strategies/filter-normalizer.ts` 均未触碰。
|
|
566
|
+
|
|
567
|
+
- e15bf7e: fix(analytics): read scope 里的 `undefined` 比较数改为拒收,不再编成绑了 `undefined` 的合法 SQL (#6125)
|
|
568
|
+
|
|
569
|
+
**⚠️ 行为变更。** `compileScopedFilterToSql` 遇到比较数位置上的 `undefined`,从「编出合法 SQL、绑一个 `undefined`、匹配零行、零日志」改为 `READ_SCOPE_COMPILE_FAILED` / **500** 拒收。
|
|
570
|
+
|
|
571
|
+
## 实测到的毛病
|
|
572
|
+
|
|
573
|
+
#6050 于 2026-08-07 裁定(B 案):比较数位置的 `undefined` 一律拒收,并落在了**已证实可触达**的 `driver-sql` / `driver-turso` 两面。#6125 在同一轮把仓内其余求值面逐格实测,同一个形状拿到五种读法;本条改的是其中一格 —— `service-analytics` 的 `read-scope-sql.ts`。在 `d8e8d9cbc` 上把本次拒收关掉复测,alias `t`、字段 `d`,四格与 #6125 正文表一致:
|
|
574
|
+
|
|
575
|
+
| read scope | 编译结果 | 绑定表 |
|
|
576
|
+
| ----------------------------- | --------------------------------------------- | ------------- |
|
|
577
|
+
| `{ d: undefined }` | `"t"."d" = ?` | `[undefined]` |
|
|
578
|
+
| `{ d: { $gt: undefined } }` | `"t"."d" > ?` | `[undefined]` |
|
|
579
|
+
| `{ d: { $in: [undefined] } }` | `"t"."d" IN (?)` | `[undefined]` |
|
|
580
|
+
| `{ $not: { d: undefined } }` | `NOT (("t"."d" IS NOT NULL AND "t"."d" = ?))` | `[undefined]` |
|
|
581
|
+
|
|
582
|
+
绑定表里是 JS 的 `undefined` 本身,不是 `null`:`applyReadScope`(`native-sql-strategy.ts`)在把 `?` 改写成 `$N` 时原样 `push(scopeParams[i])`。所以 NULL 是**驱动**对一个 JS `undefined` 的读法 —— 同一格在不肯猜的驱动上则是一句裸 `Undefined binding(s)` 崩溃。一次绑定、两种败法,取决于数据源恰好挂的是哪个驱动,这正是它该在编译器处拒收、而不是在某一个消费者处修补的理由。
|
|
583
|
+
|
|
584
|
+
方向与 #6050 不同,如实记:那边是**越权**(`{ owner_id: ctx.user?.id }` 在 Turso remote 上编成 `IS NULL`,匹配全环境行);这边是 fail-**closed** —— 匹配零行,永远不会多给行。所以它不是潜伏的权限绕过,#6125 也没有按那个级别定级。之所以照样拒收:一个「答了没人问的问题、且一条日志都不报」的 read scope,与一个真的生效了的 read scope 在外部完全无法区分。本次改动的价值就是把沉默变成响亮。
|
|
585
|
+
|
|
586
|
+
## 修法
|
|
587
|
+
|
|
588
|
+
一道闸落在 `compileField` 的开头 —— 在 `quoteIdent` 之后(不安全标识符是注入向量,保留它自己的措辞与优先级),在任何 `bind()` 之前。
|
|
589
|
+
|
|
590
|
+
拒收的**位置**逐个清点,因为「比较数」是位置而不是类型:直接比较数(`{ d: undefined }`)、单值算子的比较数(`$eq`/`$ne`/`$gt`/`$gte`/`$lt`/`$lte` 与 LIKE 族)、列表算子数组的**成员**(`$in`/`$nin`/`$between`)。四格共用**一条**措辞,只有 `path` 不同(#5240「一个条件,一种措辞」)。
|
|
591
|
+
|
|
592
|
+
信封沿用本模块自述的那一个(`READ_SCOPE_COMPILE_FAILED` / 500),不是 #6050 的 `INVALID_FILTER` / 400:read scope 的 filter 由平台自己从 CEL 与库存 metadata 编译而来,不是调用方输入 —— 报 400 等于让调用方去修一个他既没写、也改不动的东西。消息里指名要修的是**生产者**(管理员写的共享规则 / 权限集、它的 CEL 下降、或进程内拼这条 FilterCondition 的代码),并按 #5367 只进日志、不进响应体。
|
|
593
|
+
|
|
594
|
+
三个位置**故意不扫**,各自因为本模块已经用更贴切的诊断拒了它:`$null` / `$exists`(比较数是声明的布尔量,不是比较数位置)、直接位置上的裸数组(`compileField` 整体拒「用 `{ $in: [...] }`」)、以及约束对象里的非 `$` 键(那是嵌套关系,改写成 `null` 一样编不过 —— 这一条是与 `driver-sql` 孪生实现的唯一有意分歧,来自本模块拒收嵌套关系,而不是对 #6050 的另一种读法)。
|
|
595
|
+
|
|
596
|
+
## ⛔ `null` 一字未动
|
|
597
|
+
|
|
598
|
+
`{ 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。
|
|
599
|
+
|
|
600
|
+
## 刻意不动的邻居
|
|
601
|
+
|
|
602
|
+
- ⛔ `@objectstack/formula` 把同一个 `undefined` 读作「这个键在记录里不存在」—— 那是**第三种语义**,不是第三个 bug 拼写,也正是 #5299 在争的问题。在这里顺手改掉等于替 #5299 拍板。
|
|
603
|
+
- ⛔ `driver-memory` / `driver-mongodb` 维持 #5499 投入冻结,只 pin 不改。后果是本编译器与 `driver-memory` 在这一格上从此不一致 —— 这是裁决接受的代价,解冻时一并还,账记在 #6125。
|
|
604
|
+
- ⛔ `driver-sql` / `driver-turso` 已由 #6050 落地,未触碰。
|
|
605
|
+
|
|
606
|
+
- 91cefb8: refactor(types,rest,metadata,analytics): Postgres 的 `"x" of relation "y"` 短语收归一处,三个包不再各修一遍同一个超串洞(#6615)
|
|
607
|
+
|
|
608
|
+
Postgres 把「关系内部某个子对象」的失败写成 `column "label" of relation "sys_team" does not exist`——里面**逐字包含**一句合法的「表不存在」短语 `relation "sys_team" does not exist`,含义却相反:关系正因为存在才被点名。任何对「这句话是不是在说表没了」的正则收紧都消不掉这个匹配,短语确实在里面;唯一的修法是**先问更具体的问题**。所以修的是**顺序**,不是模式。
|
|
609
|
+
|
|
610
|
+
正因为如此,这个短语被分三次教给了这个仓库,分属三个包、三个 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)同一个理由与同一个位置。
|
|
611
|
+
|
|
612
|
+
**两种宽度,故意保留成两个导出。** 三个消费者要的并不是同一条正则,差别也不是随手写的,而是**每个站点哪个方向的误差是安全的**:
|
|
613
|
+
|
|
614
|
+
- `matchMissingColumnOfRelation(message)` —— 严格提取器,锚定 Postgres 的 errmsg 模板 `column "%s" of relation "%s" does not exist`,返回列名。`rest` 用它把 42703 答成 `400 INVALID_FIELD` 而不是 `404`;`service-analytics` 用它在分类前扣除缺列。这两处**过宽**会把真正缺失的表变成硬失败、回退 #5033 刻意保留的宽容,**漏匹配**只是让消息含糊一点——所以必须严格。
|
|
615
|
+
- `isRelationSubObjectPhrase(message)` —— 宽检测器,丢掉 `column` / `[a-z0-9_]+` / `does not exist` 三个锚点:任意子对象、任意带引号标识符、任意判词。`metadata` 用它做排除。这一处**过宽**只会把良性判定变成响亮判定,**漏匹配**却会让 `event_seq` 从 1 重新开始、撞进一张已有行的历史表——方向正好相反。
|
|
616
|
+
|
|
617
|
+
把两者合并成一条正则,无论哪种宽度胜出都会对其中一个调用方是错的;这是卡片记录在案的风险,两个导出即为此而设,理由是承重的而非风格的。仓库里第四份拷贝(`service-analytics` 测试内用于守护 fixture 的那条正则)同时收编:它本是为「两张面孔别对不上」而写,却把断言打在其中一面的私有复述上,因而正是它要防的漂移。
|
|
618
|
+
|
|
619
|
+
行为逐字保持不变:搬进来的两条模式与原站点逐字节相同。`@objectstack/service-analytics` 因此新增一条对 `@objectstack/types` 的依赖边——这是本次唯一的依赖变化,构造上无环(`@objectstack/types` 只依赖 `@objectstack/spec`,后者无仓内依赖),且仓库 73 个包中已有 25 个、16 个 service 中已有 5 个携带同一条边。
|
|
620
|
+
|
|
621
|
+
- Updated dependencies [3d5c090]
|
|
622
|
+
- Updated dependencies [e5bd768]
|
|
623
|
+
- Updated dependencies [e027b3e]
|
|
624
|
+
- Updated dependencies [c2429b0]
|
|
625
|
+
- Updated dependencies [445a0c2]
|
|
626
|
+
- Updated dependencies [f6609e6]
|
|
627
|
+
- Updated dependencies [a70358a]
|
|
628
|
+
- Updated dependencies [97e7e3c]
|
|
629
|
+
- Updated dependencies [8828b9e]
|
|
630
|
+
- Updated dependencies [53068c1]
|
|
631
|
+
- Updated dependencies [ee58392]
|
|
632
|
+
- Updated dependencies [f16e54e]
|
|
633
|
+
- Updated dependencies [06be54e]
|
|
634
|
+
- Updated dependencies [259459d]
|
|
635
|
+
- Updated dependencies [3f7f14e]
|
|
636
|
+
- Updated dependencies [6968885]
|
|
637
|
+
- Updated dependencies [eaed61f]
|
|
638
|
+
- Updated dependencies [debe2f6]
|
|
639
|
+
- Updated dependencies [97b0798]
|
|
640
|
+
- Updated dependencies [43a7a8d]
|
|
641
|
+
- Updated dependencies [73f69dc]
|
|
642
|
+
- Updated dependencies [04c56aa]
|
|
643
|
+
- Updated dependencies [b3efeb7]
|
|
644
|
+
- Updated dependencies [ddd075a]
|
|
645
|
+
- Updated dependencies [88154be]
|
|
646
|
+
- Updated dependencies [e8dc61e]
|
|
647
|
+
- Updated dependencies [2f3e793]
|
|
648
|
+
- Updated dependencies [d8e8d9c]
|
|
649
|
+
- Updated dependencies [94e749b]
|
|
650
|
+
- Updated dependencies [ea1d916]
|
|
651
|
+
- Updated dependencies [ae31a19]
|
|
652
|
+
- Updated dependencies [e0f300b]
|
|
653
|
+
- Updated dependencies [62b6a2f]
|
|
654
|
+
- Updated dependencies [5b4780b]
|
|
655
|
+
- Updated dependencies [a933452]
|
|
656
|
+
- Updated dependencies [8140915]
|
|
657
|
+
- Updated dependencies [7b48cf9]
|
|
658
|
+
- Updated dependencies [b5404f4]
|
|
659
|
+
- Updated dependencies [f764691]
|
|
660
|
+
- Updated dependencies [e120a5a]
|
|
661
|
+
- Updated dependencies [e650d67]
|
|
662
|
+
- Updated dependencies [04476e7]
|
|
663
|
+
- Updated dependencies [79228cd]
|
|
664
|
+
- Updated dependencies [b3363e9]
|
|
665
|
+
- Updated dependencies [2ef1807]
|
|
666
|
+
- Updated dependencies [d03fe25]
|
|
667
|
+
- Updated dependencies [2672f85]
|
|
668
|
+
- Updated dependencies [11066f6]
|
|
669
|
+
- Updated dependencies [916af17]
|
|
670
|
+
- Updated dependencies [84c86fb]
|
|
671
|
+
- Updated dependencies [2a2a9fb]
|
|
672
|
+
- Updated dependencies [a2e157c]
|
|
673
|
+
- Updated dependencies [95c4227]
|
|
674
|
+
- Updated dependencies [2a61116]
|
|
675
|
+
- Updated dependencies [d4df105]
|
|
676
|
+
- Updated dependencies [e2798fa]
|
|
677
|
+
- Updated dependencies [0fd8556]
|
|
678
|
+
- Updated dependencies [74155c7]
|
|
679
|
+
- Updated dependencies [6908830]
|
|
680
|
+
- Updated dependencies [8b06bba]
|
|
681
|
+
- Updated dependencies [4c54037]
|
|
682
|
+
- Updated dependencies [0f7157b]
|
|
683
|
+
- Updated dependencies [d9bef45]
|
|
684
|
+
- Updated dependencies [f549a0d]
|
|
685
|
+
- Updated dependencies [82da264]
|
|
686
|
+
- Updated dependencies [f586f1a]
|
|
687
|
+
- Updated dependencies [9b9b70f]
|
|
688
|
+
- Updated dependencies [f5a9bc2]
|
|
689
|
+
- Updated dependencies [881a3cc]
|
|
690
|
+
- Updated dependencies [ad6317b]
|
|
691
|
+
- Updated dependencies [8a88885]
|
|
692
|
+
- Updated dependencies [5f7669e]
|
|
693
|
+
- Updated dependencies [becbe53]
|
|
694
|
+
- Updated dependencies [b127c8b]
|
|
695
|
+
- Updated dependencies [a80302a]
|
|
696
|
+
- Updated dependencies [474f131]
|
|
697
|
+
- Updated dependencies [050cd82]
|
|
698
|
+
- Updated dependencies [4d552af]
|
|
699
|
+
- Updated dependencies [44d677c]
|
|
700
|
+
- Updated dependencies [c32944d]
|
|
701
|
+
- Updated dependencies [1dd780f]
|
|
702
|
+
- Updated dependencies [c8d6f6e]
|
|
703
|
+
- Updated dependencies [92a67f2]
|
|
704
|
+
- Updated dependencies [9136327]
|
|
705
|
+
- Updated dependencies [bf0ae99]
|
|
706
|
+
- Updated dependencies [cb3b6cd]
|
|
707
|
+
- Updated dependencies [73b7234]
|
|
708
|
+
- Updated dependencies [d2b97c3]
|
|
709
|
+
- Updated dependencies [59b794f]
|
|
710
|
+
- Updated dependencies [fc3a36a]
|
|
711
|
+
- Updated dependencies [69787f0]
|
|
712
|
+
- Updated dependencies [5d022a1]
|
|
713
|
+
- Updated dependencies [042b9ee]
|
|
714
|
+
- Updated dependencies [f549a0d]
|
|
715
|
+
- Updated dependencies [a36db28]
|
|
716
|
+
- Updated dependencies [3f8817a]
|
|
717
|
+
- Updated dependencies [a2443e3]
|
|
718
|
+
- Updated dependencies [e1554b1]
|
|
719
|
+
- Updated dependencies [4856789]
|
|
720
|
+
- Updated dependencies [c3f4916]
|
|
721
|
+
- Updated dependencies [33e0385]
|
|
722
|
+
- Updated dependencies [2205363]
|
|
723
|
+
- Updated dependencies [09fe58d]
|
|
724
|
+
- Updated dependencies [d0a5ceb]
|
|
725
|
+
- Updated dependencies [e18a162]
|
|
726
|
+
- Updated dependencies [d6d1a50]
|
|
727
|
+
- Updated dependencies [d127ff0]
|
|
728
|
+
- Updated dependencies [9b86cf6]
|
|
729
|
+
- Updated dependencies [8825a06]
|
|
730
|
+
- Updated dependencies [5087ac6]
|
|
731
|
+
- Updated dependencies [2d1ddf0]
|
|
732
|
+
- Updated dependencies [354b00f]
|
|
733
|
+
- Updated dependencies [3de535b]
|
|
734
|
+
- Updated dependencies [fe2e15a]
|
|
735
|
+
- Updated dependencies [c6b6bb4]
|
|
736
|
+
- Updated dependencies [2f59da0]
|
|
737
|
+
- Updated dependencies [8ad609c]
|
|
738
|
+
- Updated dependencies [bbee302]
|
|
739
|
+
- Updated dependencies [08863dd]
|
|
740
|
+
- Updated dependencies [56664f5]
|
|
741
|
+
- Updated dependencies [31cbe90]
|
|
742
|
+
- Updated dependencies [90bbf25]
|
|
743
|
+
- Updated dependencies [eb91eba]
|
|
744
|
+
- Updated dependencies [42da73d]
|
|
745
|
+
- Updated dependencies [643b7c7]
|
|
746
|
+
- Updated dependencies [d0d5205]
|
|
747
|
+
- Updated dependencies [1a15893]
|
|
748
|
+
- Updated dependencies [b70e534]
|
|
749
|
+
- Updated dependencies [2233a85]
|
|
750
|
+
- Updated dependencies [62dd69a]
|
|
751
|
+
- Updated dependencies [e15e679]
|
|
752
|
+
- Updated dependencies [2ab1257]
|
|
753
|
+
- Updated dependencies [4cc4fb7]
|
|
754
|
+
- Updated dependencies [28d1eb7]
|
|
755
|
+
- Updated dependencies [2c26040]
|
|
756
|
+
- Updated dependencies [f758cec]
|
|
757
|
+
- Updated dependencies [78f0be8]
|
|
758
|
+
- Updated dependencies [35f7fb4]
|
|
759
|
+
- Updated dependencies [a5302c7]
|
|
760
|
+
- Updated dependencies [7084313]
|
|
761
|
+
- Updated dependencies [91cefb8]
|
|
762
|
+
- Updated dependencies [0e043d8]
|
|
763
|
+
- Updated dependencies [dadd1ad]
|
|
764
|
+
- Updated dependencies [2f2e63c]
|
|
765
|
+
- Updated dependencies [486d526]
|
|
766
|
+
- Updated dependencies [89d7b35]
|
|
767
|
+
- Updated dependencies [85ec26d]
|
|
768
|
+
- Updated dependencies [f6476fc]
|
|
769
|
+
- Updated dependencies [4ac12ef]
|
|
770
|
+
- Updated dependencies [b88f5e8]
|
|
771
|
+
- Updated dependencies [42cc219]
|
|
772
|
+
- Updated dependencies [d7e0b42]
|
|
773
|
+
- Updated dependencies [3510e4a]
|
|
774
|
+
- Updated dependencies [aa4b90d]
|
|
775
|
+
- Updated dependencies [54299ca]
|
|
776
|
+
- Updated dependencies [dc61def]
|
|
777
|
+
- Updated dependencies [251e888]
|
|
778
|
+
- Updated dependencies [183b4c4]
|
|
779
|
+
- Updated dependencies [2fdb36e]
|
|
780
|
+
- Updated dependencies [20526f5]
|
|
781
|
+
- Updated dependencies [c5eef1d]
|
|
782
|
+
- Updated dependencies [e0f300b]
|
|
783
|
+
- Updated dependencies [761a0ba]
|
|
784
|
+
- Updated dependencies [be87153]
|
|
785
|
+
- Updated dependencies [60f0dd8]
|
|
786
|
+
- Updated dependencies [a87c5cd]
|
|
787
|
+
- Updated dependencies [a47f338]
|
|
788
|
+
- Updated dependencies [2598216]
|
|
789
|
+
- Updated dependencies [2c7e62d]
|
|
790
|
+
- Updated dependencies [eb7613c]
|
|
791
|
+
- Updated dependencies [ecc9110]
|
|
792
|
+
- Updated dependencies [f7bd4e2]
|
|
793
|
+
- Updated dependencies [361bd5b]
|
|
794
|
+
- Updated dependencies [129b378]
|
|
795
|
+
- Updated dependencies [88f9d94]
|
|
796
|
+
- Updated dependencies [1818998]
|
|
797
|
+
- Updated dependencies [09ee21c]
|
|
798
|
+
- Updated dependencies [f549a0d]
|
|
799
|
+
- Updated dependencies [3fc2e48]
|
|
800
|
+
- Updated dependencies [e8f435c]
|
|
801
|
+
- Updated dependencies [41610f6]
|
|
802
|
+
- @objectstack/spec@17.0.0-rc.6
|
|
803
|
+
- @objectstack/core@17.0.0-rc.6
|
|
804
|
+
- @objectstack/types@17.0.0-rc.6
|
|
805
|
+
|
|
3
806
|
## 17.0.0-rc.5
|
|
4
807
|
|
|
5
808
|
### Patch Changes
|