Skip to content

Commit d67fcf0

Browse files
authored
Merge branch 'main' into claude/issue-6212-batch-c-frozen-face-types
2 parents e14e6f9 + d8e8d9c commit d67fcf0

156 files changed

Lines changed: 10492 additions & 845 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: 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`).
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/service-datasource": patch
3+
---
4+
5+
fix(service-datasource): 未构建的工作区不再被当成「配置写错了」(#5794)
6+
7+
datasource 的 fail-fast 报错原本只有一句收尾建议,不分成因:
8+
9+
```
10+
✗ datasource 'default': connect failed — Cannot find module
11+
'…/@objectstack/driver-sql/dist/index.mjs' imported from …
12+
Fix the datasource configuration, or set OS_ALLOW_DRIVER_CONNECT_FAILURE=1
13+
to boot anyway and serve errors until it is reachable.
14+
```
15+
16+
对「数据库真连不上」——错的 DSN、轮换掉的密码、断掉的网络——这句话是对的。
17+
但对**驱动包没构建**这一个成因,两半都是有害建议:
18+
19+
- **「Fix the datasource configuration」** 把读者支去改一份本来就正确的配置。
20+
在那里写什么都变不出一个 `dist/` 目录。
21+
- **「set OS_ALLOW_DRIVER_CONNECT_FAILURE=1 to boot anyway」** 比没用更糟:
22+
它不是绕过问题,而是**藏起**问题。半个工作区会宣称自己启动成功,然后对每个
23+
请求回 `ERR_DATASOURCE_UNAVAILABLE`——比诚实地拒绝启动难查得多。那个开关是
24+
为「数据库暂时不可达」准备的(一个关于世界的事实,可能自己好起来);缺构建产物
25+
是关于这份 checkout 的事实,不该有任何环境变量能启动越过它。
26+
27+
而唯一有效的修法(`pnpm build`)一个字都没提。
28+
29+
现在 connect 失败会按**成因**选收尾句。底层错误是模块解析失败时(ESM `import()`
30+
`err.code === 'ERR_MODULE_NOT_FOUND'`,CJS `require()``MODULE_NOT_FOUND`;
31+
`code` 被 re-throw 丢掉时退回 `Cannot find module` / `Cannot find package` 文本),
32+
消息改成:
33+
34+
```
35+
The driver package could not be LOADED at all — it is not installed, or its build
36+
output is missing. That is a build precondition, not a datasource fault: the
37+
configuration is fine, and no boot-time override can make a driver that does not
38+
exist answer a query. Run `pnpm install && pnpm build`, then start again.
39+
```
40+
41+
一个正确修法,只说一次,**不提**那个逃生开关——连「别用它」都不提:一个已经卡住的
42+
读者会去找最短的那行看起来能让他继续的话。这与 `datasource-pool-support.ts`
43+
(#5714 / #5931)和 `check:dev-prereqs`(#5795)是同一条消息纪律。
44+
45+
判据复用 `@objectstack/types``isModuleNotFoundError`(framework#3265 起的唯一
46+
所有者),不另起一份;它先看结构化的 `err.code`、再退回文本,而这个结构化信号原本
47+
`handleFailure` 只收 `reason: string` 时被丢弃了,所以抛出值本身现在也一并传入。
48+
49+
**纯诊断分类,零行为变化。** fail-fast 的判定、触发时机、抛出的错误类型、保留的
50+
连接状态,以及设了 `OS_ALLOW_DRIVER_CONNECT_FAILURE` 时的降级启动路径全部不变;
51+
其它成因(真连接失败、驱动不受支持、凭据解析不出)的消息逐字未动。
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
fix(service-automation): 降级版挂起态读取器的「存储读不到」告警不再把驱动错误拼进 message,改走 meta (#6230)
6+
7+
`engine.ts``loadSuspendedRun` —— `loadSuspendedRunStrict`**降级版**读取器 ——
8+
在 catch 里把**我们不控制文本**的数据源驱动失败原因直接插进了 `logger.warn` 的 message。
9+
`ObjectLogger.write()` 一次调用只加一个「时间戳 + 级别」记录头,message 里的换行会把
10+
**一条**记录变成多个物理行,后面几行既无级别也无时间戳。
11+
12+
这条比 #5912(PR #6228)刚治完的那条**多一层危害**:`ObjectLogger``warn` 路由到
13+
**stdout**,而 `serve` 的 boot-quiet 窗口只包了 `process.stdout.write`,其
14+
`BootLogCapture.offer()` 仅在该物理行带级别头时才保留 —— 所以无头续行是被**直接丢弃**,
15+
不只是被误读。而它在 boot 期真实可达:`plugin.ts``start()``rearmSuspendedWaitTimers`
16+
→ 对 overdue 运行 `engine.resume()``resume()` 的授权 gate 走的正是这个降级版读取器。
17+
18+
实测:一个三行的 better-sqlite3 驱动错误把这条告警切成 **3 个物理行**,过 boot 缓冲的
19+
过滤后**只剩 1 行**留下 —— 而留下的那一行恰恰不含任何驱动事实。
20+
21+
改法与 #5048 / #5575 / #5636 / #5661 / #5737 / #5912 完全同一套,零新词汇:**message
22+
单行自足**,外来 cause 交给 `Logger` 契约(`packages/spec/src/contracts/logger.ts`)
23+
`warn(message, meta?)`**第二**参 —— 注意与 `error(message, error?, meta?)` 的第三参
24+
不同,`warn` 没有 `Error` 槽。
25+
26+
对运维可见的变化(日志形状,非行为):
27+
28+
- 这条记录恒为**一个**物理行,不论日志格式,boot-quiet 窗口内不再丢字节;
29+
- 原因文本从 `msg` 末尾的 `: <驱动文本>` 移到记录的 `error` 字段(`meta`),多行驱动
30+
错误由 `JSON.stringify` 转义换行后完整保留 —— 一个字节都不丢;
31+
- message 补上了这条降级的**后果**:读失败被翻译成 `null`,调用方(resume gate、screen
32+
取数)看到的与「本来就没有这个挂起运行」完全一样,而运行本身未被触碰、仍停在原处;
33+
原文本只说了「读失败」,没说读失败被翻译成了什么。
34+
35+
刻意**不变**的一处,已钉上回归测试:**级别仍是 `warn`**。这是一个刻意的**功能性**降级
36+
读取器(注释写明它服务于只需要 best-effort 答案的顺带读取方),真正需要区分「存储挂了」
37+
与「运行没了」的 `resumeInternal` 用的是严格版 —— 按 #4632 的判据这不是耐久性降级,
38+
上调到 `error` 才是该规则的镜像误用(整个故障期间每次 gate 查询都报警)。
39+
40+
按记录末尾驱动文本字面量 grep 这条记录的日志查询,需要改成读记录的 `error` 字段。
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
fix(spec): `functions: { fn: { handler, effect: 'writes' } }` survives `objectstack build` (#4976)
6+
7+
`FlowFunctionEntrySchema` gains a fourth union member — the **lowered
8+
declaration**, a `functions` entry whose `handler` has been replaced by the
9+
string ref `objectstack build` emits:
10+
11+
```
12+
functions: {
13+
sweepProjectHealth: { handler: 'sweepProjectHealth', effect: 'writes' },
14+
}
15+
```
16+
17+
Nothing an author writes changes. This shape is produced by the CLI, not typed
18+
by a person: `lowerCallables` replaces every inline callable with a serialisable
19+
ref before the stack is parsed (it must — `z.function()` wraps callables and
20+
would break the ref mapping), and since #4396 it keeps the declaration beside
21+
the ref so what a function said about itself survives into the artifact. The
22+
union was not extended in that change, so the artifact it started emitting was
23+
rejected by the very schema it had to pass:
24+
25+
```
26+
✗ Validation failed
27+
28+
functions:
29+
✗ functions
30+
invalid_union: Invalid input
31+
```
32+
33+
Loading from source was unaffected — `objectstack dev`, `objectstack validate`
34+
and the test suite all passed — so the failure appeared only at build, on the
35+
one spelling the platform asks writers to use. That is the same asymmetry #4343
36+
fixed for the bare handler ref, one shape over.
37+
38+
**Why this was worse than a failed build.** `effect: 'writes'` exists so a
39+
function that writes is not counted as having written nothing (#4396, #4354): a
40+
`script` step reports no record metrics *because* flow functions are
41+
contractually pure, and a declared writer instead reports `unmeasuredEffect` so
42+
the run's broken-sweep query (`selected > 0 AND acted = 0 AND unmeasured = 0`)
43+
stays off it. The error above names no key, no entry and no reason, so the
44+
practical repair an author reaches for is deleting the declaration — shipping an
45+
undeclared writer, which is exactly the state it exists to prevent, recorded
46+
permanently in `sys_automation_run`.
47+
48+
**One behaviour change worth stating.** `{ handler: 'someName' }` written by
49+
hand now parses where it used to be rejected as "handler is not callable". The
50+
rejection could not survive this member and should not have: a bare string entry
51+
(`functions: { foo: 'foo' }`) has been accepted since #4343 with the caveat that
52+
it registers nothing, so refusing the record spelling of the same mistake while
53+
accepting the string spelling was two dialects for one contract. Both fail the
54+
same way, loudly, at execute: `no function named '…' is registered` (#1870).
55+
Everything else stays strict — the lowered member is *derived* from the authored
56+
declaration rather than re-typed beside it, so `{ handler: 'fn', efect: 'writes' }`
57+
still raises the named surface and the `` `efect` → `effect` `` prescription, an
58+
unknown `effect` value is still refused, and an empty ref is still not a name.
59+
60+
**Runtime is unchanged and was already correct.** `normalizeFlowFunctionEntry`
61+
returns `undefined` for a lowered entry in both its shapes, because neither
62+
carries a callable; `mergeRuntimeModule` re-attaches the sidecar module's
63+
function to the declaration the JSON carried *before* any collector runs, so
64+
`effect` reaches `collectBundleFunctionEntries` intact on the built path.
65+
66+
The two halves are now pinned against each other by a round-trip test that
67+
drives the real pipeline (`defineStack``normalizeStackInput`
68+
`lowerCallables` → parse) instead of a hand-written sample of what the lowering
69+
is believed to emit — the crossing neither side previously made, which is why
70+
both stayed green while the build failed on the join.
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: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/service-storage': patch
3+
'@objectstack/plugin-sharing': patch
4+
'@objectstack/runtime': patch
5+
---
6+
7+
hooks: drop the last three `doc` / `previousDoc` alias reads on a hook context — read the engine's own keys only
8+
9+
Behaviour is unchanged: every one of these limbs guarded against a producer that
10+
has never existed, so none of them could be reached.
11+
12+
- `service-storage` attachment lifecycle read `ctx.result ?? ctx.input.doc ?? ctx.input.data`
13+
- `plugin-sharing` primary-BU projection read `(ctx.input.data ?? ctx.input.doc).user_id`
14+
- `runtime`'s hook sandbox read `engineCtx.input ?? engineCtx.doc` and `engineCtx.previous ?? engineCtx.previousDoc`
15+
16+
Every ObjectQL write context spells the payload `data` — measured and pinned by
17+
`hook-input-shape-contract.test.ts` in `@objectstack/objectql` ("insert carries
18+
`data` — never `doc`", #5273). The top-level pair is the same family one level
19+
up: `HookContextSchema` declares `input` / `result` / `previous` and neither a
20+
`doc` nor a `previousDoc`, and `engine.ts` — the sole producer of a HookContext
21+
— builds neither. The limbs survived only because the old `HookContext.input`
22+
contract table documented insert as `{ doc, options }`; that table was corrected
23+
in #5668, and the same alias was removed from `trigger-record-change` in #5671.
24+
These are the remainder (#5906), removed rather than left as a second de-facto
25+
contract (PD #12).

0 commit comments

Comments
 (0)