Skip to content

Commit 0ede2fc

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-6354-date-bucket-parity-as-never
2 parents 6afcb8e + 466bd92 commit 0ede2fc

112 files changed

Lines changed: 10218 additions & 324 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): action-param rejection names the built-in a "differs by one underscore" key meant
6+
7+
`validateActionParams` (ADR-0104 D2) rejected every undeclared key with the
8+
same sentence — `Unknown action param "selectedIds" — not declared on this
9+
action` — including keys one leading underscore away from a built-in
10+
(`ACTION_PARAM_BUILTIN_KEYS`: `recordId` / `objectName` / `_selectedIds`).
11+
That sentence is true, and its only actionable reading is false: the reader's
12+
next step is to declare the key on the action, and a built-in is precisely the
13+
key that **cannot** be declared. #5568's reporter walked that road to its end
14+
on `params.selectedIds`, concluded that REST carried no legal shape for a bulk
15+
selection at all, and opened a platform issue — while `params._selectedIds`
16+
was live the whole time.
17+
18+
The `unknown_field` message now appends a near-miss hint when `'_' + key` or
19+
`key` minus its leading underscore is in the allowed built-in set:
20+
21+
```
22+
Unknown action param "selectedIds" — not declared on this action. Did you mean
23+
the built-in "_selectedIds"? Built-in params are never declared on an action —
24+
an aggregate bulk dispatch (`execution: 'aggregate'`) injects every selected
25+
record id under it, and a handler reads `ctx.params._selectedIds`.
26+
```
27+
28+
The origin sentence is per built-in, because the three have three different
29+
producers: `recordId` / `objectName` are merged into the bag server-side by the
30+
dispatcher, `_selectedIds` arrives from the renderer's aggregate bulk dispatch.
31+
A key reached through a custom `builtinKeys` override gets the generic
32+
"the dispatcher supplies it".
33+
34+
**Message copy only — the verdict does not move.** The key is rejected before
35+
and after, the accepted set is unchanged, and an unknown key that is *not* a
36+
near-miss keeps today's message byte for byte (the match is one leading
37+
underscore, not a similarity score). This is not a second acceptance channel
38+
for `selectedIds`: the contract still has exactly one spelling,
39+
`params._selectedIds`.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(service-analytics): postgres 的「缺列」措辞不再被判为「缺源」(#6035)
6+
7+
数据集查询的降级路径靠驱动措辞判断「后端表没挂载」,从而把控件渲染成空网格而不是 500。
8+
它的判据 `isMissingSourceError` 自己的文档写明范围**只含缺表/缺对象,不含列/语法错误——
9+
后者要保持硬失败,好让真正的查询 bug 浮上来**。有一条 postgres 措辞按构造违反了这条承诺:
10+
11+
```
12+
column "label" of relation "acct" does not exist (SQLSTATE 42703)
13+
```
14+
15+
它内部**逐字包含**一整段合法的缺表措辞 `relation "acct" does not exist`#5717 把 postgres
16+
那一支从「同时含两个词的任意句子」收紧为锚定真实缺表措辞后,这条依然命中——它必然命中,因为它
17+
字面上**就是**那段措辞。所以任何对「这句话是不是在说某个 relation 不存在」的收紧都排除不掉它,
18+
只有**先问更具体的问题**才可以:修法是一个**判定顺序**(先摘掉缺列措辞,再做缺源判定),而不是
19+
一个更好的正则。
20+
21+
两种后果都是错的,而具体触发哪一种只取决于措辞里那个关系名是否恰好是数据集自己的对象:
22+
23+
- 名字是**被 JOIN 的表** → 报出一条响亮但**虚假**的跨数据源拓扑错误,把一个拼写错误说成数据源
24+
布局问题;
25+
- 名字是**数据集自己的对象** → 控件降级成空网格,只留一条 warn,拼错的列名不会告诉任何人。
26+
27+
两半现在都作为回归钉住。判定顺序抄 `rest-server.ts``mapDataError`#5352 起就在用的先例
28+
(它同样先摘出这条措辞,于是 REST 面回答 `400 INVALID_FIELD` 而不是 `404`),用的是同一条正则
29+
而不是它的第二种方言——两个面不该对「postgres 什么时候在说 column」给出不同答案。兄弟函数
30+
`missingSourceRelation` 做同样的前置摘除:实测在修改前它对这条措辞回答 `sys_team`,只修其一会让
31+
「是不是缺了什么」与「缺的是什么」相互矛盾,而那正是 #5717 在这一支上刚消除的分歧。
32+
33+
**这不修线上事故,而是让判据与它自己的文档一致。** analytics 是只读面,而 postgres 在 SELECT
34+
下的未知列措辞是 `column "bogus" does not exist`(不含 `relation`,本来就不命中);
35+
`column … of relation …` 是 INSERT/UPDATE/ALTER 措辞。价值在于:这条分歧不再依赖「读路径不产生该
36+
措辞」这个假设活着——哪天有任何写形状语句、驱动改措辞、或多包一层 `cause` 把它送到这个 catch
37+
面前,它会被正确分类,而不是被静默吞掉。
38+
39+
#5717 量过的 13 条仓内真实措辞全部重新钉住,并且是**按调用方可观测的结果**(空网格 / 拓扑拒收 /
40+
原样上抛)钉的,而不是按私有判据的布尔值——实测 **13 条里只有 1 条改判**,就是缺列那条,其余 12
41+
条(三个驱动家族的措辞、框架的 not-registered 信号、本包自己的拒收)逐条不变。
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
"@objectstack/driver-memory": major
3+
"@objectstack/driver-mongodb": major
4+
---
5+
6+
refactor(drivers)!: memory / mongodb 的 `aggregate` / `distinct` 也收进 `DriverQuery`,契约没覆盖的方法不再要求把对象名写两遍 (#6212 批 C)
7+
8+
#6210 的 changeset 结尾专门留了一句:`aggregate` / `distinct` **不在**那次范围内,因为它们不是 `IDataDriver` 收窄的那六个方法。#6212 记下了这笔账,本次结清 memory 与 mongodb 这两个包的部分。
9+
10+
这批方法的第一个实参**已经是对象名**,query 里却仍旧要求再写一遍:
11+
12+
| 位置 | 收窄前 | 收窄后 |
13+
|:--|:--|:--|
14+
| `MongoDBDriver.aggregate` | `query: QueryAST` | `query: DriverQuery` |
15+
| `InMemoryDriver.distinct` | `query?: QueryInput` | `query?: DriverQuery` |
16+
| `InMemoryDriver.aggregate` | `Record<string, any>[] \| QueryAST` | `Record<string, any>[] \| DriverQuery` |
17+
| `InMemoryDriver.performAggregation`(私有) | `Omit<QueryInput, 'object'>` | `DriverQuery` |
18+
19+
因为 `QueryAST` / `QueryInput` 都把 `object` 声明成**必填**,一个手上只有 `where` 的调用方根本叫不出这个类型的名字,于是伸手去拿 `as any` —— 连 `where` / `orderBy` / `limit` 的检查一起关掉。这正是 #5181 记过账的那笔代价(cloud#1053 实测 20 处,cloud#1030 的 `$like` 就是从这个口子活到运行时的)。收窄之后调用方可以直接写字面量:
20+
21+
```ts
22+
// 收窄前:object 是必填,这句编译不过,于是 ... as any
23+
// 收窄后:直接过,且 where / orderBy / aggregations 逐个受检
24+
await driver.aggregate('order', {
25+
groupBy: ['region'],
26+
aggregations: [{ function: 'sum', field: 'amount', alias: 'total' }],
27+
});
28+
```
29+
30+
同一次改动收回了 4 处已经多余的 `as any`(memory 2、mongodb 2),`check:query-options-erasure` 的测试面因此从 267 降到 263,baseline 已按门禁要求同 PR `--update`
31+
32+
**`InMemoryDriver.aggregate` 的联合刻意保留。** 两条分支都有活体生产者:mongo 管线数组那支由 `memory-analytics.ts` 喂,AST 那支由 objectql 引擎与 `@objectstack/verify` 的日期分桶探针喂。退役任何一支都会打断其中一条。
33+
34+
**顺带把 `#6212` 正文的一处归因证伪了**:正文说 `performAggregation` 当初选 `Omit<QueryInput, 'object'>` 是被 `groupBy` 的元素类型差异逼的。实测 `QueryInput``QueryAST``groupBy`**逐字相同**,差异只在 `search` / `orderBy` / `expand`;直接换 `DriverQuery` 零报错。所以那不是被迫的选择,契约优先取 `DriverQuery`,不再引入第二个查询类型家族。
35+
36+
**零运行时改动。** 非测试改动 100% 是类型注解,无逻辑、无行为、无 emit 差异(`as` 断言在编译期即被抹除)。测试全绿:memory 532、mongodb 206(另 137 条需真实 mongod,按既有 opt-in 规则跳过)。这也是 #5499 冻结面上被允许的处置口径 —— 与 #6210 在同一批驱动上走的是同一条。
37+
38+
**迁移面:删掉调用字面量里的 `object:`**,与 #5181 / #6210 同一句话,现在覆盖到 `aggregate` / `distinct`。编译器会逐处指出来:
39+
40+
```
41+
error TS2353: Object literal may only specify known properties,
42+
and 'object' does not exist in type 'DriverQuery'.
43+
```
44+
45+
本仓实测只有一处需要改(`memory-driver.test.ts``distinct` 用例),且它写的值与第一实参逐字相等,纯冗余。
46+
47+
标 major 的依据与 #5181 / #6210 一致:**源码级破坏性**(调用点内联字面量),运行时行为零变化。`check:api-surface` 只记录导出的存在与否、不记录签名,因此这条说明同样是该变更唯一的下游载体。

.changeset/eighty-jars-tickle.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): the update-path `readonly` strip now drops the value the CALLER submitted, not whatever value the key holds when it runs
6+
7+
The static-`readonly` write strip runs after `beforeUpdate`, but decided what to
8+
delete from a snapshot of the caller's KEY NAMES. Those are different facts the
9+
moment a hook writes to a read-only column: `delete data[name]` took the hook's
10+
value with it whenever the caller's payload happened to carry the same key.
11+
12+
Behaviour change — a whole-record write-back no longer erases hook writes. The
13+
reported shape: a REST caller reads a record, flips `status` to `published`, and
14+
PUTs the whole record back — `published_at: null` included, because that is what
15+
it read. The publish hook stamped `published_at` on the transition; the strip
16+
then deleted the stamp, and the row committed as `status = "published"` with
17+
`published_at = null`, which every view sorting or filtering by `published_at` is
18+
undefined on. The same hook's `last_reviewed_at` — equally read-only, but not
19+
echoed by the caller — landed in that same write. Two hook-derived writes, one
20+
alive and one dead, decided by nothing but a key name collision.
21+
22+
The entry snapshot now carries the caller's values, and a read-only key is
23+
stripped only while it still holds the caller's own value. A key a hook
24+
overwrote is a platform write and survives — the same verdict the runtime
25+
already gave a read-only key a hook ADDS.
26+
27+
Not a relaxation of the read-only write rule: a caller-supplied read-only value
28+
that no hook overwrote is dropped exactly as before, on both the single-id and
29+
predicate update paths, and `isSystem` / `preserveAudit` are untouched. The
30+
insert path is unchanged.
31+
32+
Known limit, by design: the snapshot is shallow, so a hook that mutates a
33+
caller-supplied object or array IN PLACE is indistinguishable from a hook that
34+
did nothing, and the field is still stripped. A hook that means to write a
35+
read-only column should assign to it.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): the `fallbackNodeId` tombstone names the key that actually routes faults (#6094)
6+
7+
`flow.errorHandling.fallbackNodeId` was retired in 17.0.0 (#3896), and its
8+
migration message tells the author what to draw instead. It named the wrong key:
9+
10+
- FROM: "the engine routes unrecoverable node errors via per-node fault edges
11+
(an edge with **condition `'fault'`**)"
12+
- TO: "… (an edge with **`type: 'fault'`**)"
13+
14+
`condition` on `FlowEdgeSchema` is a **CEL predicate** returning boolean
15+
(`flow.zod.ts``ExpressionInputSchema`), while the fault/default/conditional/back
16+
routing lives on `type` (`z.enum([...])`). An author following the old wording
17+
verbatim would write `{ source, target, condition: 'fault' }`, which **parses
18+
clean**`condition` accepts any expression string — and produces an ordinary
19+
edge that is not a fault path. So the tombstone handed them a second silently
20+
inert key in exchange for the one it took away: they delete a fallback that never
21+
existed, then draw a fault edge that isn't one.
22+
23+
The repo already states the correct rule elsewhere (`flows.mdx`: "`type: 'fault'`
24+
is what routes — a label is not"), and every other mention in the tree spells it
25+
`type: 'fault'`; this was the only site out of step. The closing sentence
26+
("draw a fault edge from the failing node to the handler node instead") was
27+
already correct and is unchanged.
28+
29+
Message text only — no schema, validation, or runtime behaviour changes.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
docs(spec): field- and section-level `visibleWhen` stop advertising `current_user` (#6146)
6+
7+
`FormField.visibleWhen` and `FormSection.visibleWhen` documented their runtime
8+
binding root as "`record` + `current_user`". The second half was never true at
9+
that level, and the failure mode is silent:
10+
11+
- FROM: "Root: `record`+`current_user` (runtime forms) or `data` (metadata forms)"
12+
- TO: "Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in
13+
metadata forms" — plus an explicit note that `current_user` is **unbound** here.
14+
15+
Field- and section-level rules are evaluated by `evalFieldPredicate` /
16+
`resolveFieldRuleState` in `@object-ui/core`, which binds `record`, `previous`,
17+
and an `extra` scope (`parent`, for master-detail line items) — nothing else.
18+
Every production call site passes no user scope, and objectui#1582 pins the same
19+
set for the authoring autocomplete (`FIELD_RULE_ROOTS = ['record','previous','parent']`,
20+
commented "nothing else (no `current_user`)").
21+
22+
Why this mattered more than a wording slip: an unbound identifier makes the
23+
evaluation fault, and every fault resolves to the caller's fallback — which for
24+
visibility is `true`. So a predicate written exactly as the spec described it
25+
(`'admin' in current_user.positions`) does not hide the field, it makes the
26+
field **permanently visible**. Authors following the documentation got the
27+
inverse of what they wrote, on the surface where the mistake is least visible.
28+
29+
`current_user` remains documented, and remains correct, for **per-option**
30+
`visibleWhen` (`SelectOption`): options resolve through a different evaluator,
31+
`resolveCascadingOptions` against the host's predicate scope, which does bind it
32+
(ADR-0068 / objectui#2284). That JSDoc previously claimed the per-option
33+
environment was "the SAME binding environment as field-level `visibleWhen`" —
34+
the very equality that is false — so it now states the asymmetry instead of
35+
asserting it away.
36+
37+
Documentation only: no schema, validation, or runtime behaviour change. Authors
38+
whose field-level predicates reference `current_user` should know those
39+
predicates are already faulting open today; this change does not alter that, it
40+
stops the spec from recommending it.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
docs(spec): `session.positions` stops teaching a hook call that cannot run (#6001)
6+
7+
The `positions` key on the hook session carries a deliberate boundary note: it is
8+
**descriptive, never an authorization input**. That conclusion is unchanged. What
9+
changed is the example standing next to it, which was itself defective:
10+
11+
- FROM: "A hook may READ this … forwarding it as the sharing service's evaluation
12+
context (`services.sharing.canEdit(..., { positions })`, the shape both
13+
`content/docs/kernel/runtime-services/` pages teach)"
14+
- TO: an example a hook can actually reach — tailoring a message, branching a
15+
*business* rule through the hook's own `ctx.api` channel, logging — plus an
16+
explicit note that a hook context carries **no `services` key**.
17+
18+
#5720 pinned this by measurement: hook contexts are assembled key by key, and
19+
neither ObjectQL's `buildSession()` nor `buildSandboxContext()`
20+
(`packages/runtime/src/sandbox/body-runner.ts``input` / `previous` / `user` /
21+
`session` / `event` / `object` / `result` / `api` / `log` / `crypto`) ever sets
22+
`services`. So `services.sharing.canEdit(…)` is `undefined()` inside a hook, and
23+
the customary `if (!ok) throw` wrapped around it rejects **every** write. The
24+
one shape the doc held up as correct practice was the exact shape that fails
25+
closed on all traffic.
26+
27+
The two cross-referenced doc pages had already reversed under PR #5938
28+
`sharing-service.mdx` now carries "Enforcement is automatic — do not re-check it
29+
in a hook", and `examples.mdx` teaches `ctx.api` — leaving this JSDoc as the last
30+
site still teaching the withdrawn shape. The reference now points at that
31+
section instead of at the pages generically.
32+
33+
Both the JSDoc **and** the `.describe()` are updated; the `describe` is the copy
34+
served through `/api/v1/meta/types/hook` and rendered in Studio's form, so
35+
leaving it would have kept the defective example on the surface authors actually
36+
read. Text only — no schema, validation, or runtime behaviour change.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
fix(rest): `GET /meta/books/:name` no longer bypasses the ADR-0046 §6.7 audience gate (#6241)
6+
7+
The single-item metadata read has a cached branch and an uncached one, and the
8+
ADR-0046 §6.7 audience gate lives in the uncached one. The comment above the
9+
cached branch's entry condition has always stated why `doc` and `book` must skip
10+
it:
11+
12+
> `doc` and `book` bypass the shared cache: their §6.7 audience gate is
13+
> per-caller, and a shared ETag would leak gated content across viewers.
14+
15+
The condition beneath that sentence compared the **raw** `:type` path segment
16+
against the literals `'doc'` / `'book'`. The route serves both spellings, and
17+
Prime Directive #3 makes the **plural** one canonical — so
18+
`GET /api/v1/meta/books/:name` did not match the exclusion, took the cached
19+
branch, and the audience gate never ran. `enableCache` defaults to `true`, which
20+
made the failing path the default one.
21+
22+
Measured against a real `RestServer` — one book declaring
23+
`audience: { permissionSet: … }`, one signed-in caller holding no permission
24+
set:
25+
26+
```
27+
singular "book" :: cachedCalls=0 status=[403] PERMISSION_DENIED
28+
plural "books" :: cachedCalls=1 status=[] full gated body served
29+
```
30+
31+
Same book, same caller, two spellings of one route. `GET /meta/docs/:name` took
32+
the same path. This was **fail-open**: the wrong outcome is disclosure of gated
33+
documentation, not an availability error.
34+
35+
**The fix is structural, not two corrected literals.** This is #3984 recurring
36+
in the same file eight days later, so the handler now normalizes the type
37+
**once** at the top (`RestServer.metaTypeSingular`) and every gate below reads
38+
that local — a per-type gate added later has no raw param in scope to compare
39+
against by accident. The cache exclusion and the §6.7 gate now read one shared
40+
predicate, so "which types bypass the cache" and "which types are audience
41+
gated" can no longer drift apart. A repository guard
42+
(`pnpm check:meta-type-normalized`, AST-based, zero exemptions) refuses the next
43+
raw comparison in `packages/rest/src`.
44+
45+
**Behaviour change worth knowing:** `GET /meta/docs/:name` and
46+
`GET /meta/books/:name` now take the uncached branch, as their singular
47+
spellings always did, so those two responses no longer carry an `ETag` /
48+
`Cache-Control` validator and a conditional request no longer answers `304`. No
49+
other metadata type is affected. The cost is only the 304's saved bytes —
50+
`getMetaItemCached` delegates to `getMetaItem`, so the server does identical
51+
work either way — and the ETag it gave up was a hash of the **unfiltered**
52+
document, which is the cross-viewer leak the exclusion exists to prevent.

0 commit comments

Comments
 (0)