Skip to content

Commit 5eff983

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-6212-batch-a-e-sql-query-signatures
2 parents e27a788 + 85ec26d commit 5eff983

110 files changed

Lines changed: 7751 additions & 485 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: 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: 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).

.changeset/lazy-buttons-invite.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/rest': minor
3+
---
4+
5+
REST 的 9 条 direct-mount 路由现在对 `RestServer` 可枚举,并随之进入 `GET {apiPath}/openapi.json`
6+
7+
`package-routes.ts`(4 条 `packages.*`)与 `external-datasource-routes.ts`(5 条
8+
`datasources/:name/external/*`)一直绕过 `RouteManager`、直接挂在宿主 `IHttpServer` 上,
9+
`RestServer` 因此不持有「这 9 条本次 boot 是否挂载」的事实。#5588(PR #5821)把
10+
`/openapi.json` 的 built-in 段改成服务器自身路由表的投影之后,这 9 条(其中 8 条在
11+
`rest-route-ledger.ts` 里是 `disposition: 'sdk'` 的真实能力)就不在生成的文档里 ——
12+
`/openapi.json` 生成客户端的 consumer 拿不到它们,任何基于 `getRoutes()` 的自省也看不见。
13+
14+
现在两个 registrar 各自把「实际挂载的那一个数组」原样返回,由组合步骤
15+
(`mountAndRecordDirectRoutes`,`rest-api-plugin.ts` 调用)登记到 `RestServer` 上:
16+
17+
- `RestServer.getRoutes()` 返回本次 boot 的**全部**已挂载路由,每条带 `source`
18+
(`'route-manager' | 'direct-mount'`),类型为新导出的 `MountedRoute`;
19+
- `/openapi.json` 的 built-in 段随之覆盖这 9 条,带各自的 summary / tags / 路径参数;
20+
- 描述与挂载**同源**:返回的数组就是用来挂载的那个数组,不存在第二份手工清单。
21+
22+
诚实性两个方向都保持不变:某次 boot 没有 `package` 服务 ⇒ `packages.*` 既没挂载、
23+
也不出现在 `getRoutes()` 与文档里;federation 那 5 条无条件挂载(服务缺席时按请求答 503),
24+
所以它们始终出现 —— 文档说的仍然只是「什么被挂载了」。
25+
26+
对使用者的影响:`getRoutes()` 的返回值多了 9 条(服务在场时)以及每条上的 `source`
27+
字段;既有的 `method` / `path` / `handler` / `metadata` 读法不变。

.changeset/light-berries-tickle.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
sandbox: `ScriptContext.user``unknown` 收窄为命名联合 `ScriptUser`(#5521)
6+
7+
沙箱接缝 `ScriptContext`(`packages/runtime/src/sandbox/script-runner.ts`)把交给 hook /
8+
action body 的调用者声明为 `user?: unknown`,类型系统对这个字段一无所知 —— 第四个
9+
dispatch 面明天再手搓一个 user 字面量,编译器不会说一句话。而"三个 dispatcher 手搓出三种
10+
形状"正是 #5372 的成因:它能存在几个版本,部分原因就是没有任何声明可以违背。
11+
12+
现在它是 `user?: ScriptUser`,`ScriptUser = ActorUser | HookContext['user']` —— 两个**实测
13+
的真实生产者形状**的联合,与 33 行外的姊妹字段 `ScriptSession`(#5613 / #5991)同构:
14+
15+
- action body 收 `ActorUser`(`security/actor-user.ts`,#5372 起的唯一生产者,#6011
16+
`positions` 为唯一拼法);
17+
- hook body 收 `HookContext['user']`(ObjectQL `buildUser()``session.userId` 快捷方式:
18+
`id` / `name` / `email` / `organizationId`,全部可选)。
19+
20+
刻意****收成单一类型:hook 快捷方式不带 `positions` / `permissions` / `systemPermissions`,
21+
收成 `ActorUser` 会在 hook 面断言一套它从未生产过的授权词汇;也****收成 spec 的
22+
`EvalUser`(issue 选项 1)—— 实测 `buildUser()` 根本不产 `positions`,而 `EvalUser` 要求它,
23+
那是套着 spec 外衣的同一种过度声明。
24+
25+
行为零变化:两个写入方从 `any` 引擎上下文赋值,唯一的 VM 侧读取方收 `unknown`。TS 消费者
26+
可见,故走 patch。`ActorUser` 同时作为**类型**从包入口导出,使联合的两支都可被消费者命名。

0 commit comments

Comments
 (0)