Skip to content

Commit dcf62f6

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-5696-transaction-contract
Conflict: packages/objectql/src/engine.ts import block — both sides added an import next to the same line (#5696's transaction-errors, main's summary-aggregate). Both kept; no logic overlap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
2 parents 4843e58 + ede5a8e commit dcf62f6

225 files changed

Lines changed: 8236 additions & 292 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: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
fix(service-automation): runAs:'system' 的 create_record 按 ADR-0118 染全三列——组织、属主、创建者禁 NULL (#5494)
6+
7+
修的是缺陷,不是新语义——契约是 ADR-0118(#4608)既有的:显式 `isSystem`、fail-closed、
8+
禁 NULL 歧义;`runAs` 声明的是授权姿态而非身份(ADR-0073 D2),提权不等于匿名。
9+
10+
根因:`resolveRunDataContext` 的 system 分支把触发上下文的 `userId` / `tenantId` 整个丢弃,
11+
而三列的平台盖章恰好全部键在被丢弃的信息上——`created_by` 键在写上下文的 `userId`
12+
(ObjectQL 审计钩子)、`owner_id` 键在安全中间件的 acting user(而整条中间件含盖章步骤在
13+
`isSystem` 上短路)、`organization_id` 键在上下文 `tenantId`(驱动层租户机制)。于是用户
14+
触发的 system 清扫流程建出的每一行三列全 NULL:落在组织分区之外(唯一索引跨 NULL 不生效、
15+
org 作用域查询看不见),也落在所有 owner/creator 作用域授权之外——issue 里"admin 都
16+
403"的由来。
17+
18+
修复(writer 侧,`packages/services/service-automation`):
19+
20+
- system 分支把触发身份原样带过去(`userId` + `tenantId`),与 action-body 缝的
21+
`{ ...caller, isSystem: true }` 信封(hotcrm#548 同族修复)同形:`isSystem` 独自决定
22+
授权(中间件在读到 `userId` 之前就短路),身份只驱动归因盖章(`created_by`/`updated_by`
23+
审计 actor)、驱动层的 `organization_id` 填充,以及下游 record-change 级联的触发身份;
24+
- `create_record` 对 system 运行补 `owner_id` 填充(fill-only、schema 存在才染):所有权锚
25+
的平台盖章在 `isSystem` 上被短路,payload 是唯一通道;染的是 acting user——与同一触发在
26+
`runAs:'user'` 下会得到的默认一致,不是把系统身份塞进 owner(ADR-0118 D6 / ADR-0073 D3);
27+
- 流程 `fields` 显式给值一律优先;真正无用户的运行(schedule)保持三列不染——没有 acting
28+
user 时按 ADR-0118 D1,哨兵串与伪用户都是被禁的替代品,`svc:flow:*` actor 标签 +
29+
`flowRunId` 继续承担溯源。
30+
31+
行为变化:`runAs:'system'` 且触发上下文带 org 的运行,其数据操作在驱动层按
32+
`(org = 触发 org OR org IS NULL)` 作用域——与 action-body 缝一致的姿态;schedule 触发的
33+
运行不带 org,行为不变。
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): name the parsed state `XParsed` on every schema that has one (ADR-0122, #5551)
6+
7+
A Zod schema denotes two types — `z.input` (what an author writes: defaulted keys
8+
optional, pre-transform) and `z.infer` (what `.parse()` returns) — and `packages/spec`
9+
has been naming them two different ways with nothing written down about which is which.
10+
Measurement on `origin/main`: **1384** bare aliases mean the parsed state, **86** mean
11+
the author state, and three separate first-hand sources each described the 8-file
12+
minority as "the house convention". No ADR recorded either spelling.
13+
14+
**[ADR-0122](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0122-schema-type-alias-naming-convention.md)
15+
settles it: the bare name `X` is the AUTHOR state, `XParsed` is the PARSED state.** The
16+
deciding argument is the keystroke every author writes first — `const c: Connector = { … }`
17+
— which should be correct by default in every domain, without knowing which file you are in.
18+
19+
**This release is phase 1, and it is purely additive. Nothing is renamed or removed;
20+
no existing annotation stops compiling.** It declares `XParsed` for the **657** aliases
21+
whose schema genuinely has two distinct shapes, so that every consumer whose meaning
22+
phase 2 will change already has a name to move to:
23+
24+
```ts
25+
// before — one name, meaning the parsed state
26+
export type Connector = z.infer< typeof ConnectorSchema >;
27+
export type ConnectorInput = z.input< typeof ConnectorSchema >;
28+
29+
// after — the parsed state also has a name that will keep meaning it
30+
export type Connector = z.infer< typeof ConnectorSchema >;
31+
export type ConnectorParsed = z.infer< typeof ConnectorSchema >; // new
32+
export type ConnectorInput = z.input< typeof ConnectorSchema >; // unchanged
33+
```
34+
35+
Schemas whose `z.input` and `z.infer` are the *same* type (enums, plain unions, objects
36+
with no defaults or transforms anywhere in their tree) deliberately get **no** `XParsed`
37+
— a permanent synonym is a name you can only pick wrongly. All 718 of them are pinned
38+
with compile-time assertions so the exemption cannot rot silently when one later gains
39+
a `.default()`.
40+
41+
One name to note if you are upgrading across protocol 17: `FieldMapping` does **not**
42+
gain a `FieldMappingParsed`. #5552 retired `FieldMapping.transform` and the whole
43+
`FieldMappingTransform` union in the same release, and that key was the only reason the
44+
schema had two shapes — so it is now isomorphic, and under this convention it correctly
45+
keeps exactly one name.
46+
47+
**What to do now (optional, and cheap).** If you hold the result of a `.parse()` — or of
48+
a `defineX()` factory, which returns it — move that annotation to `XParsed`:
49+
50+
```ts
51+
-const c: Connector = ConnectorSchema.parse(raw);
52+
+const c: ConnectorParsed = ConnectorSchema.parse(raw);
53+
```
54+
55+
Annotations on values you *write* need no change now and will be correct after phase 2.
56+
Doing nothing is also fine until then.
57+
58+
**What comes next.** Phase 2 flips the bare names to `z.input` and ships in a major, with
59+
its own changeset and migration notes. `XInput` aliases are untouched by this release and
60+
their fate is decided then.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
tooling: strictness 台账新增第九个判定词 `covered` —— 给「无门、无 parse,但每个消费者都已把守」的形状片段一个诚实的格子 (#5249)
6+
7+
`docs/audits/2026-07-unknown-key-strictness-ledger.md``Class` 列是**机读**的(按类小计是对它做算术),枚举值此前只有八个。`ui/app.zod.ts``BaseNavItemSchema` 八个都不合适,而这不是标签精度问题 —— **是词汇表返回了错误的动作**:
8+
9+
- 两轴表(carrier / parse)把「carrier 缺席 + parse 缺席」解析成 `no door`,其规定的后续动作是 ADR-0049 退役。**在这里是破坏性的**:这个基底的键被九个导航分支共享,九个分支各自 `.strict()` 并带 `navItemUnknownKeyError`,退役等于删掉九个活着的分支的共享键。
10+
- `no gate` 反向错(门是存在的,就在成员上)。`authorable``view.zod.ts``FormFieldBaseSchema` 的先例,但**那个基底真的被 `.extend()`**,姿态会继承,所以它是一扇真门;把这个也记成 `authorable`,等于邀请下一轮 sweep 去「把活干完」—— 收紧一个没有任何 parse 的形状。
11+
- `verify` 的语义是「待检查」,而检查在批 19 就做完了。
12+
13+
维护者 2026-08-06 裁定取 A:**加词,而不是四舍五入到最近的错误答案** —— 与批 15 加 `no gate` 同一条理由。同一个测量第二次返回相反方向的后续动作,说明缺的是一格判定,不是这个站点特殊。这一格的读者主要是后续 agent,一个指向错误**动作**的分类,会被执行它的人放大,正是「消费者侧宽容」在分类层的镜像。
14+
15+
**`covered` 的定义**:carrier 缺席、parse 缺席,但词汇在**每个消费者处都被完整把守**;后续动作是****。它拿到自己的桶而不是并入 `no door`:两者测量相同,但小计是一张工作清单,而两行规定的工作相反 —— 合并会把刚刚消除的歧义原样搬到上一层。
16+
17+
改动面:
18+
19+
- `packages/spec/scripts/lib/strictness-ledger-doc.ts` —— `VERDICTS` / `BUCKETS` / `BUCKET_OF` / `emptyBuckets()` / 渲染标签。`verify`**语义与归桶完全不动**(仍计入 authorable),它继续为下一个需要挂起的站点保留。
20+
- 台账 `ui/app.zod.ts``verify``covered`;头部散文补上词表变更的出处一行(批 13 `no door` / 批 15 `no gate` / #5249 `covered`),分类表与两轴表各补一行。
21+
- `.counts.md``gen:strictness-ledger` 整体重算:全局 authorable 43 → 42、新增 `covered` 1;`ui/` authorable 34 → 33、`covered` 1。总数仍是 197,分桶仍恰好划分。
22+
23+
**改判范围是测量出来的,不是走过场。** 判据是机械的:`covered` 要求键通过 `...X.shape` **展开**到达消费者 —— 展开把逐键 schema 复制进一个全新的 `z.object`,姿态是新对象自己的,所以基底是惰性的;而 `.extend()` / `.merge()` / `.omit()` **继承**姿态,基底就还是一扇真门。对五个已分诊目录的全部 **197** 个 strip 站点跑了这条判据,**只有一个**站点是展开的,就是本行。另外三个模块私有的 strip 基底各有归宿且**维持原判**:`view.zod.ts` 的 `FormFieldBaseSchema` 在 `:1475` 被 `.extend()`(姿态继承 → 真门 → 仍 `authorable`);`query.zod.ts` 的 `BaseQuerySchema` 在 `:485` 被 `.extend()` 成 `QuerySchema`(同理 → 仍 `open`);`component.zod.ts` 的 `EmptyProps` 作为**值**挂在 `ComponentPropsMap` 的十一个 carrier 键下(carrier 存在 → 根本不满足「carrier 缺席」)。其余约 50 个站点是属性下的内联嵌套字面量,天然自带 carrier,不可能是 `covered`。
24+
25+
不改任何 schema 姿态 —— 批 19 已测定关掉这个基底是保证的 no-op,而 #4583 明确 no-op 收紧并非中性(*"a precisely-validated dead slot is the more convincing lie"*)。
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/client": major
4+
"@objectstack/client-react": major
5+
---
6+
7+
refactor(client)!: `subscribeMetadata``type` 收窄为 `MetadataEventSubject`,订阅一个合同上永远不会来的事件改为编译报错 (#4627)
8+
9+
`MetadataEventType` 是一个封闭枚举:13 个 metadata 类型 × 3 个动作。#4602 已经把生产端钉成 declared = enforced —— 枚举外的类型(`translation``datasource``page``hook``trigger``validation` 等,全都是 `DEFAULT_METADATA_TYPE_REGISTRY` 里可注册的真实类型)**不发布**任何 realtime 事件,因为不存在能合法交付给 `(event: MetadataEvent) => void` 回调的事件形状。
10+
11+
消费端却一直是宽的 `string`。于是 `client.events.subscribeMetadata('translation', cb)` 编译全绿、运行永盲:回调永远不会被调用,而类型系统一个字都没说。这正是 AI 写订阅代码最容易踩的形状 —— 它看起来订阅上了。
12+
13+
本次把消费端也钉上,两端对齐后这种代码写不出来。
14+
15+
**新增导出**`@objectstack/spec/api``MetadataEventSubject` —— `metadata.{type}.{action}``{type}` 半边,`'object' | 'field' | 'view' | …`。它是从 `MetadataEventType` **派生**的(模板字面量 + 分发式条件类型),不是在旁边重抄一份,所以两者不可能各说各话:枚举加一个成员,这个联合自动跟着长。`check:api-surface` 记录为 0 breaking / 1 added。
16+
17+
**签名收窄**(三处,全部只是把 `string` 换成这个联合):
18+
19+
- `@objectstack/client``RealtimeAPI.subscribeMetadata(type, …)`
20+
- `@objectstack/client-react``useMetadataSubscription(type, …)`
21+
- `@objectstack/client-react``useMetadataSubscriptionCallback(type, …)`
22+
23+
**FROM → TO —— 原来传 `string` 的代码怎么改**
24+
25+
枚举内的字面量一个字都不用动,本仓 6 处调用点(`'object'`)零迁移:
26+
27+
```ts
28+
// 照常编译,没有变化
29+
client.events.subscribeMetadata('object', onEvent);
30+
useMetadataSubscription('view');
31+
```
32+
33+
真正被拒绝的只有两种写法,各有各的一行修复:
34+
35+
```ts
36+
// FROM —— 变量声明成了宽的 string
37+
const type: string = route.params.metaType;
38+
client.events.subscribeMetadata(type, onEvent); // TS2345
39+
40+
// TO —— 把变量(或 state、或路由参数)的类型改成这个联合
41+
import type { MetadataEventSubject } from '@objectstack/spec/api';
42+
const type: MetadataEventSubject = 'object';
43+
client.events.subscribeMetadata(type, onEvent);
44+
```
45+
46+
```ts
47+
// FROM —— 订阅一个没有 realtime 合同的类型
48+
client.events.subscribeMetadata('translation', onEvent); // TS2345
49+
50+
// TO —— 删掉它。这段代码从 #4602 起就收不到任何事件,
51+
// 编译器现在说的是它一直以来的运行时事实,不是新增的限制。
52+
```
53+
54+
编译器会把每一处指出来,错误码都是 **TS2345**`Argument of type '"translation"' is not assignable to parameter of type 'MetadataEventSubject'`)。**运行时行为零变化** —— 被拒绝的调用本来就收不到事件,标 major 是因为这是源码级破坏性变更(#5181 的同一条先例:源码级破坏、运行时不变,仍走 major)。
55+
56+
**本次不做、也不预答的**:哪些可注册类型「应该」有 realtime 事件,是 #4627 的轴 2 —— 一个由真实需求驱动的产品覆盖面问题(例如 #4426 的 flow/workflow i18n 若落地会把 `translation` 推上来)。枚举没有动一个成员。派生关系保证了这件事将来只需要改一处:枚举加三个名字,两端同时跟上。
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/objectql": patch
3+
"@objectstack/cli": patch
4+
---
5+
6+
feat(objectql,cli): `os migrate summary-nulls` backfills roll-up count/sum columns left NULL by pre-seed inserts (#6063)
7+
8+
#5749 / PR #6013 fixed the **producer**: a parent row created from that release
9+
on has its `count` / `sum` roll-up columns seeded to the empty-set value at
10+
insert, so `filter ["task_count", "=", 0]`, sorting, `GROUP BY` and formulas
11+
over the column stop silently dropping parents that never had a child.
12+
13+
Being a create-time fix, it reaches **new rows only**. A database upgraded **in
14+
place** still holds parents stored before the upgrade, and the recompute that
15+
would otherwise correct them runs only when one of their **children** is
16+
written — so those rows keep their `NULL` indefinitely and keep disappearing
17+
from the same queries. A freshly seeded deployment is correct; an upgraded one
18+
is not. This release ships the other half: a one-off, explicit data migration.
19+
20+
```bash
21+
os migrate summary-nulls # dry run: full report, writes nothing
22+
os migrate summary-nulls --apply # recompute and write (prompts)
23+
os migrate summary-nulls --apply --yes --json # CI / scripts
24+
os migrate summary-nulls --object project # restrict to one object (repeatable)
25+
```
26+
27+
**Every NULL row is recomputed, never blanket-set to 0.** A pre-upgrade parent
28+
that *does* have children is `NULL` too — nothing ever recomputed it — and its
29+
correct value is the real aggregate. `UPDATE ... SET col = 0 WHERE col IS NULL`
30+
would replace a visibly-missing value with a confidently-wrong one, which the
31+
next child write would then silently change back. The run computes each value
32+
through the same code path the engine's own child-write recompute uses
33+
(`aggregateSummaryValue`), over the descriptors the engine itself maintains, so
34+
a backfilled column and a recomputed one can never mean different things.
35+
36+
**`min` / `max` / `avg` are never touched.** They are undefined on an empty set
37+
— which is why the insert-time seed leaves them `null` — so a stored `null`
38+
there is the correct reading of "no child rows", not a defect. The report names
39+
them as deliberately skipped rather than omitting them silently.
40+
41+
Other properties: dry run by default and a dry run writes nothing at all;
42+
idempotent, so re-running is safe and a clean report is the operator's own
43+
verification; driver-agnostic (it reads values and tests them in JS rather than
44+
pushing a null predicate down, since null-predicate compilation is precisely
45+
where drivers diverge); one row's failure is recorded and the run carries on.
46+
It records no deployment flag — unlike its `os migrate` siblings, nothing is
47+
gated on it having run.
48+
49+
Never running it is safe in the sense that nothing breaks *further*: the
50+
affected rows simply stay missing from `= 0` filters until a child of theirs is
51+
written.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
feat(spec)!: `system-data` 的桶默认不再包含 CSV `import`,改为按对象显式 opt-in (#4671)
6+
7+
**FROM → TO:`managedBy: 'system-data'` 的默认 affordance 从
8+
`create/import/edit/delete/exportCsv: true` 收窄为
9+
`create/edit/delete/exportCsv: true`,`import: false`** 需要 CSV 导入向导的对象
10+
显式写一行:
11+
12+
```ts
13+
export const SysHolidayCalendar = ObjectSchema.create({
14+
name: 'sys_holiday_calendar',
15+
managedBy: 'system-data',
16+
userActions: { import: true }, // 明确要这个入口
17+
});
18+
```
19+
20+
`platform` 现在是唯一默认授予 `import` 的桶。其余五个桶(`config``system-data`
21+
`engine-owned``append-only``better-auth`)一致地把它留给对象自己声明。
22+
23+
## 具体消失的是哪几个 UI 入口
24+
25+
仓内 8 个 `system-data` 对象都不再从桶默认继承导入向导,其中要紧的是三张 RBAC 关联表 ——
26+
它们是整个权限模型的**授予面**:
27+
28+
| 对象 | v17-rc.3 之前的管理台入口 | 本次之后 |
29+
| :--- | :--- | :--- |
30+
| `sys_user_position` | 「CSV 批量绑定用户 ↔ 岗位」 | 不再出现(需显式 opt-in) |
31+
| `sys_user_permission_set` | 「CSV 批量绑定用户 ↔ 权限集」 | 不再出现(需显式 opt-in) |
32+
| `sys_position_permission_set` | 「CSV 批量绑定岗位 ↔ 权限集」 | 不再出现(需显式 opt-in) |
33+
34+
另外 5 个成员(`sys_user_preference``sys_approval_delegation`
35+
`sys_notification_template``sys_notification_subscription`
36+
`sys_notification_preference`)同样从「有导入入口」回到「无导入入口」。
37+
38+
**要恢复其中任意一个,在该对象上加 `userActions: { import: true }` 即可** —— 只动
39+
`import` 这一个动词,create/edit/delete/exportCsv 仍走桶默认,不需要像 v16 那样把整块
40+
`userActions` 抄回来。
41+
42+
## 为什么
43+
44+
授权边界一点没动。`import`**affordance**,只决定 UI 入口是否渲染;CSV 导入写下的每一行
45+
仍然逐条经过 `DelegatedAdminGate`、RLS 与权限集裁决 —— 一个无权手工授予某权限集的 admin,
46+
通过 CSV 同样授不出去(ADR-0103 D5 关于 enforcement 的结论完全不变)。
47+
48+
变的是**杠杆**:逐行点选时一次误操作影响一个人;一份错误 CSV 就是一次批量授权,且没有天然的
49+
复核节奏 —— 而这三张表恰好决定「谁能做什么」。所以批量授予入口应当是一次显式声明,而不是
50+
「被归进了正确的桶」就自动继承的东西。对成批继承桶默认的 AI 生成对象元数据尤其如此:
51+
「没想过 import」的默认结果落在安全侧,打开它则是 reviewer 能看见的一行。
52+
53+
原先「默认含 import」出自 #3355 上更早的 agent 会话(评论带 Claude Code 脚注),不是维护者
54+
拍板;当时的实现 agent 自己标注了这条 security-adjacent 并指出裁决可能未考虑批量绑定权限集
55+
这一具体场景。维护者 2026-08-03 正式裁决收窄,2026-08-06 最终确认。记录见 ADR-0103 的
56+
#4671 addendum。
57+
58+
## 升级影响
59+
60+
**从 v16 升上来的用户:零影响。** v16 的 `managedBy: 'system'` 默认 LOCKED,8 个成员各自用
61+
`userActions: { create, edit, delete }` 重开写入,没有一个重开 `import` —— 所以 CSV 导入在
62+
v16 就解析为 `false`,改名后仍是 `false`#3355 的 4 个包逐对象 before/after 等价 pin 因此
63+
从「四动词等价 + 一条 import 差异」变成**五动词全等价**,并新增一条 opt-in 可达性 pin。
64+
65+
**已在 v17 rc.1–rc.3 上依赖 `system-data` 默认导入入口的用户:**
66+
`userActions: { import: true }`

0 commit comments

Comments
 (0)