Skip to content

Commit 3d7cb1c

Browse files
committed
Merge origin/main into claude/issue-6225-reference-cell-width
main 上有 8 个参考页在本分支开出后被重生成(#6243#6280#6281#6333 等), 其中 `ui/bulk-action.mdx` 与 `automation/state-machine.mdx` 与本分支相交。 两棵独立重生成的树会零冲突合并却落地陈旧组合,故按 os-regen 钩子的要求从合并后的 树重新 `gen:schema && gen:docs`,并在合并结果上重跑全部验证: - 参考语料 216 页 / 8548 个单元格,check:docs 232 个生成文件 in sync - MDX 编译 216/216 - @objectstack/spec: 337 个测试文件 / 8614 个测试通过,typecheck 干净 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wsZeReNTqiceBfLb5Pyf5
2 parents 5b1d846 + 881a3cc commit 3d7cb1c

113 files changed

Lines changed: 7619 additions & 572 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: 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: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): `BulkActionDefSchema` accepts `requiredPermissions` — the capability gate the selection bar already enforces (#6257)
6+
7+
The renderer has filtered selection-bar buttons on `def.requiredPermissions`
8+
since objectui#3492 (`BulkActionBar` runs the same `useCapabilityGate` as the
9+
row kebab and record header), but the `.strict()` `BulkActionDefSchema` did not
10+
declare the key, so no legal metadata could ever reach that filter —
11+
`enforced ≠ declarable`, the mirror image of the "declared ≠ enforced" gap.
12+
The forms with no workaround were the INLINE data-plane defs
13+
(`operation: 'update' | 'delete'`): they dispatch no action, so unlike a def
14+
promoted from `bulkActions: ['<name>']` (or an aggregate def naming a declared
15+
action) they have nothing to inherit a gate from. In practice that meant a
16+
declarative bulk delete — the button that most needs a gate — was visible to
17+
every caller who could open the list, and rejected only per record, server-side,
18+
after the click.
19+
20+
`BulkActionDefSchema` now declares an optional `requiredPermissions: string[]`
21+
with `action.requiredPermissions` semantics verbatim: absent or empty always
22+
passes, several entries AND, a client that cannot resolve the caller's
23+
capabilities fails OPEN (the server stays the authority), and the platform-admin
24+
bit grants no exemption — the gate reads grants. On a data-plane def the key
25+
governs visibility only; the write is still authorized by the data API's object
26+
permissions and server hooks. The `ActionSchema` near-miss aliases
27+
(`permissions`, `capabilities`, `requiresPermissions`, `requiredCapabilities`,
28+
`acl`) rename onto the new key here too. No renderer change: objectui's
29+
`BulkActionDef` type and `BulkActionBar` filter shipped in objectui 11
30+
(objectui#3548).
31+
32+
Specimens: `examples/app-showcase` `showcase_project.default` gains the two
33+
inline gated defs the #6157 action-gating matrix could not pin — `relabel_ops`
34+
(`update` + `patch`, gated on the Ops-held `showcase.export_data`) and
35+
`purge_restricted` (`delete`, gated on the granted-to-nobody
36+
`showcase.restricted_ops`).

.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: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-automation': patch
4+
---
5+
6+
feat(spec)!: `FlowNodeSchema` parses its own ADR-0031 regions — the post-parse pass retires (#4415)
7+
8+
`FlowSchema.parse` normalized a flow's own `nodes[]` / `edges[]` but could not reach a
9+
**region**, because a region lives inside `FlowNodeSchema.config` — a deliberately open
10+
`z.record` (ADR-0018). #4381 closed the resulting gap with a **post-parse pass**,
11+
`normalizeControlFlowRegions`, that every caller had to remember to run:
12+
13+
```ts
14+
const flowShell = FlowSchema.parse(converted);
15+
validateControlFlow(flowShell);
16+
const parsed = normalizeControlFlowRegions(flowShell); // ← had to remember
17+
```
18+
19+
That is an unwritten rule on top of a parse, and it is exactly the condition the #4347
20+
family of defects grows in: a new consumer — a Studio publish path, an MCP tool, a bulk
21+
validation script — takes a `FlowParsed` and uses it, holding a **half-parsed flow that
22+
looks finished**. Nested edge predicates were still bare strings, nested nodes had not been
23+
through `.strict()`, and nothing said so.
24+
25+
Now the schema does it. `FlowNodeSchema` carries a `.transform()` that parses each declared
26+
region slot — `loop.config.body`, `parallel.config.branches[]`, `try_catch.config.try` /
27+
`.catch` — through the schema that slot's value *is*. Nesting needs no manual recursion: a
28+
region's `nodes` are `z.array(FlowNodeSchema)`, so Zod re-enters the transform on the way
29+
down. **"Parsed" now means parsed at every depth** (Prime Directive #1), from any entry
30+
point — including `FlowNodeSchema.parse(node)` on a single node, which the old whole-flow
31+
pass could not serve at all.
32+
33+
## Migration
34+
35+
**`normalizeControlFlowRegions` is removed from `@objectstack/spec/automation`.** Delete the
36+
call; the parse above it already did the work:
37+
38+
```diff
39+
const parsed = FlowSchema.parse(converted);
40+
validateControlFlow(parsed);
41+
- const normalized = normalizeControlFlowRegions(parsed);
42+
```
43+
44+
Its replacement, `parseFlowNodeRegions(node)`, is exported for the same purpose one node at
45+
a time, but you should not normally need it — it is the transform's own body.
46+
47+
**`FlowNodeSchema` is now a `ZodPipe`, not a `ZodObject`,** so it no longer has `.shape` /
48+
`.extend()` / `.pick()`. `z.infer` / `z.input` / `.parse` / `.safeParse` and
49+
`z.toJSONSchema` are unaffected, and the authorable key set is byte-identical (verified by
50+
`check:authorable-surface`). If you were reaching for the object half, read it from the
51+
pipe's input side — `FlowNodeSchema.def.in` — which is also what the repo's own generators
52+
do (`pipeAuthorableSide` in `scripts/lib/zod-graph.ts`).
53+
54+
One visible consequence in the generated reference: `content/docs/references/automation/flow.mdx`
55+
now renders FlowNode's **input** shape, so keys carrying a `.default()` (`boundaryConfig.interrupting`,
56+
`inputSchema[].required`) show as optional. That is what an author actually writes, which is
57+
what an authoring reference should say.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
feat(spec)!: `HookContext.api``z.unknown()` 收窄为 `IScopedContext`,文档教的第一个 hook 终于编译得过 (#5945)
6+
7+
`HookContext.api` 是文档教的**主数据通道**,而它的类型是 `unknown`。于是所有文档、技能、示例里那个标准写法:
8+
9+
```ts
10+
handler: async (ctx: HookContext) => {
11+
const users = ctx.api.object('user'); // error TS18046: 'ctx.api' is of type 'unknown'.
12+
}
13+
```
14+
15+
一行都编译不过 —— 包括 `hook.zod.ts``api` 这个键**自己 JSDoc 上的示例**。语料库全在这么教(`skills/objectstack-data/references/data-hooks.md``content/docs/automation/hooks.mdx``content/docs/api/error-handling-server.mdx``content/docs/kernel/runtime-services/*`),这些块都没进 `os:check`,所以从来没有一道门看见过。唯一进了 `os:check` 的那块(`runtime-services/examples.mdx`)也只能靠在示例里自建一个 `type CrossObjectApi = …``ctx.api as CrossObjectApi` 才编得过 —— 每个消费方各 cast 一遍、cast 的形状无人校验,正是 contract-first 要终结的方向。
16+
17+
**本次落地维护者裁决 C**`packages/spec/src/contracts/` 新增 `IScopedContext` / `IScopedObjectRepository`(与 `IDataEngine` / `IObjectQLEngine` 同层同风格),`HookContext.api` 的 TS 类型指向它。
18+
19+
**声明面 = 语料库实测的调用点**,不多也不少(证据表在 PR 正文,逐条 file:line):
20+
21+
- `IScopedContext``object(name)` + `transaction(cb, opts?)`
22+
- `IScopedObjectRepository``find` / `findOne` / `count` / `insert` / `update` / `updateById`
23+
24+
`upsert` / `delete` / `aggregate` / `create` 只出现在文档的**方法表与能力表**里、从没有一处调用点(表格不过编译器),`sudo()` 的三个调用方全部把值持成 `any` 且它是提权动作 —— 一律不声明,等到有调用点再按同一条规则加。这与 `IDataEngine` 当年(#4251)确立的「有证据才声明」是同一条纪律。
25+
26+
**运行时零变化**:Zod 侧仍是 `z.unknown()``z.custom` 会让 `HookContext` 在 JSON Schema 里不可表达,`gen:schema` 直接不再产出 `json-schema/data/HookContext.json`,进而在下次 `gen:docs` 抹掉它的参考页 —— 实测过,不是推测)。收窄是纯静态的:接受的值、JSON Schema、生成的参考页行全部逐字节不变,只有 `.describe()` 文案改了。
27+
28+
**漂移由编译器盯着**`packages/objectql``ScopedContext` / `ObjectRepository` 声明了 `implements`,契约与引擎实际绑定的那个对象再也不能各说各话(把 `updateById` 改个名,objectql 的 `tsc` 会在 `implements` 处和五个 hook 派发点同时报错 —— 实测过)。
29+
30+
**FROM → TO —— 什么代码需要改**
31+
32+
读取端只会变宽,原来编译得过的读法一行都不用动(原来根本没有能编译过的读法)。两类**写入端**可能要改:
33+
34+
```ts
35+
// 1. 自建 cast 的消费方 —— 删掉 cast 即可,`ctx.api` 现在自带类型
36+
-const api = ctx.api as CrossObjectApi;
37+
-const account = await api.object('crm_account').findOne({ where: { id } });
38+
+const account = await ctx.api?.object('crm_account').findOne({ where: { id } });
39+
40+
// 2. 构造 HookContext 字面量的测试替身 —— `api` 现在必须是 IScopedContext 形状(或省略)
41+
const ctx: HookContext = {
42+
object: 'account', event: 'beforeInsert', input: {}, ql: {},
43+
- api: whateverStub,
44+
+ api: undefined, // 或一个带 object(name) / transaction(cb) 的替身
45+
};
46+
```
47+
48+
`api` **仍是可选的**`buildHookApi` 在全部五个派发点都会设置它,但改成必填会开始拒绝今天能过的部分上下文(没有活引擎时构造的 context),所以读法是 `ctx.api?.object(…)`
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
fix(lint): `object/missing-name-field``nameField`、不再把已退役的 `titleFormat` 当作 name 面(#6108)
6+
7+
`object/missing-name-field` 的谓词从来不读 `obj.nameField`,却仍然采信 `obj.titleFormat`:
8+
9+
```
10+
hasNameField = !!obj.primaryField || !!obj.titleFormat || fields.some(name-like)
11+
```
12+
13+
净效果是同一个包里两条规则互相矛盾。`validate-record-title.ts` 把每一处 `titleFormat`
14+
声明都报成 `title-format-retired`,并按 **ADR-0079** 指示作者迁移到 `nameField`
15+
(`titleFormat` 是 render-only 模板,服务端既不能返回也不能查询);而共享的
16+
`objectTitleCompleteness`(`@objectstack/spec/data`)判定标题面时也从不读它。于是:
17+
**照平台自己的迁移建议把 `titleFormat` 换成 `nameField` 的对象,反而多得一条
18+
"records will display as raw IDs" suggestion;守着已退役的键不动的对象反而干净。**
19+
20+
下游实测(hotcrm main,`@objectstack/* 17.0.0-rc.3`):6 处命中里 4 处是误报,
21+
四个对象——`crm_campaign_member` / `crm_event_attendee` / `crm_contract` /
22+
`crm_forecast`——都显式声明了 `nameField`;只有两个 line-item 对象是真命中。
23+
24+
本次修正:
25+
26+
- 谓词补读 `nameField`(ADR-0079 的规范主标题指针),显式声明它的对象不再被告警;
27+
- 摘掉 `titleFormat` 这一支。**只声明 `titleFormat`、没有 `nameField` 的对象因此会
28+
新得一条本规则的 suggestion** —— 这是刻意的翻转,不是回归:这类对象正是 ADR-0079
29+
要求迁移的那一批,`validate-record-title` 今天已经对它同时报
30+
`title-format-retired``title-unresolvable`。两条规则从此对同一个对象给出一致判断;
31+
- `primaryField` 与 name-like 字段两支行为不变;
32+
- 提示文案改为只点名作者真正能声明的面(`nameField` 与 name-like 字段),并新增 `fix` 提示
33+
说明 `titleFormat` 不算标题面 —— 读到旧文案的作者很容易顺手再写一个 `titleFormat`,
34+
又掉回同一个矛盾里。旧文案里的 `primaryField` 同时不再出现:该键在 `packages/spec`
35+
没有任何声明,`ObjectSchema.create()` 会以 `unrecognized_keys` 拒收它(实测,已立 #6326),
36+
提示不该向作者广告一个会被 schema 硬拒的键。谓词里的这一支保持不动。

.changeset/olive-hounds-repeat.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): 删除回执不再对 runtime-only 项谎称"已重置为 artifact 默认值"
6+
7+
`deleteMetaItem` 的四句成功回执(repository 路径两句 + legacy raw-engine 路径两
8+
句)原本无条件把每一次删除都叙述成"摘掉一层 overlay、回落到 artifact 默认值"。
9+
但对一个 **runtime-only** 项 —— 管理员在 Studio 里新建的 `object` / `flow` /
10+
`hook`,没有任何 code package 提供同名 artifact —— 底下根本没有默认值可回落:那
11+
一行就是这个项的全部,删掉之后它在任何层都不复存在。回执却把管理员指向一个从未
12+
存在过的基线。
13+
14+
判据与 #5265 / PR #5926 在 save 侧用的是同一个:`isArtifactBacked` —— 也就是
15+
`intent: 'override-artifact' | 'runtime-only'` 的来源,本方法内早已算出。新增的
16+
方法级绑定**替换**`intent` 原来的那次 inline 调用,所以分句后 registry 读取次
17+
数不增反减。
18+
19+
| | FROM | TO |
20+
|:---|:---|:---|
21+
| 覆盖了 artifact,删除即回落 | `Customization overlay deleted — <t>/<n> reset to artifact default. [seq=N]` | 逐字不变 |
22+
| runtime-only,删除即消失 | 同上 | `Deleted <type> '<name>' — it no longer exists. [seq=N]` |
23+
| 覆盖了 artifact,本就没有 overlay 行 | `No customization overlay found for <t>/<n> — already at artifact default.` | 逐字不变 |
24+
| runtime-only,本就不存在 | 同上 | `No <type> '<name>' found — nothing to delete.` |
25+
26+
`success` / `reset` / `seq` 三个字段一字未动 —— `message` 没有任何消费方解析,仅
27+
作展示。草稿两句(`Draft discarded — …` / `No pending draft for …`)本来就没有声
28+
称过 overlay 或 reset,对两类项都为真,故逐字保留。legacy raw-engine 路径不写
29+
history、不发 watch 事件,两句因此本就不带 `[seq=…]`,该差异为既有设计,分句未
30+
触碰。

0 commit comments

Comments
 (0)