Skip to content

Commit 688382d

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-5748-update-data-id-scalar-test
# Conflicts: # scripts/engine-double-contract.baseline.json
2 parents 53d5a8c + 72bd873 commit 688382d

78 files changed

Lines changed: 5012 additions & 534 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.

.changeset/great-buttons-tap.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
"@objectstack/rest": major
3+
"@objectstack/runtime": major
4+
"@objectstack/client-react": major
5+
---
6+
7+
**BREAKING**`GET /meta/:type/:name` now answers exactly one body shape: the
8+
`GetMetaItemResponseSchema` envelope `{ type, name, item, … }` that
9+
`packages/spec` has always declared for it. On the default configuration this
10+
endpoint used to answer the **bare metadata document** instead (#5563).
11+
12+
### What changed, and why it is breaking
13+
14+
The route had two mutually exclusive branches with different response
15+
structures. The cached branch — reached whenever `metadata.enableCache` is on,
16+
which is the **default** (`enableCache: z.boolean().default(true)`) — served
17+
`getMetaItemCached`'s `result.data`, and that value has the envelope already
18+
stripped. The uncached branch served `getMetaItem`'s envelope. So the one shape
19+
the spec declared was the one a default deployment could not obtain, and the
20+
envelope surfaced only when the cache was off or when the read structurally
21+
bypassed it (`app`, `doc`, `book`, `?state=draft`, `?preview=draft`,
22+
`?package=`). Consumers had no correct static type — they sniffed at runtime or
23+
reached for `as any` (#5545 was blocked on exactly this).
24+
25+
The dispatcher's `/meta` domain had the same split one layer down: the protocol
26+
resolver answered the envelope while the ObjectQL-registry and MetadataService
27+
fallbacks answered bare documents. Both fallbacks now wrap what they found,
28+
taking `type`/`name` from the request.
29+
30+
### Migration
31+
32+
`GET /api/v1/meta/object/customer`, default configuration:
33+
34+
```jsonc
35+
// before — the bare document
36+
{ "name": "customer", "label": "Customer", "fields": { /**/ } }
37+
38+
// after — the declared envelope; the document is verbatim under `item`
39+
{
40+
"type": "object",
41+
"name": "customer",
42+
"item": { "name": "customer", "label": "Customer", "fields": { /**/ } }
43+
}
44+
```
45+
46+
- **Reading the body directly** (`fetch`, `client.meta.getItem`,
47+
`client.meta.getCached().data`): read the document at `.item`. Nothing inside
48+
it changed. `type` is the canonical singular metadata type name, so
49+
`/meta/objects/customer` and `/meta/object/customer` answer the same `type`.
50+
- **`useObject` / `useFields` (`@objectstack/client-react`)**: `useObject().data`
51+
is now the envelope — `data.item.label`, `data.item.fields`, where it used to
52+
be `data.label` / `data.fields`. `useFields()` is unchanged (it already
53+
returns the flattened field list) and is the shorter path when fields are all
54+
you need.
55+
- **`isMetaEnvelope`, exported from `@objectstack/rest`, is REMOVED.** It
56+
existed only to tell the two shapes apart. There is one shape now, so the
57+
replacement for `isMetaEnvelope(r) ? r.item : r` is `r.item`.
58+
- **Not converged, deliberately**: `?layers=true` still answers the layered
59+
diagnostic projection `{ type, name, code, overlay, overlayScope, effective,
60+
validation }`. Collapsing three layers into one `item` would delete the
61+
diagnostic. Unaffected unless you pass that flag.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(service-analytics): 即席推断的 Cube 把 `owner.region` 当成关系穿越,不再铸成基表列 `region` (#5739)
6+
7+
`inferCubeFromQuery` 为「没有注册 Cube 的自由查询」即席合成一个 Cube,并从查询提
8+
到的字段里播种 `dimensions`。每个铸造点都先把成员过一遍 `stripPrefix` —— 一个把
9+
**任何**点号名的首段剥掉的判定。对 `<cube>.` 限定符(`crm_account.industry`
10+
`industry`)这是对的;对**关系穿越**则不是:`owner.region` 被铸成
11+
`dimensions.region = { sql: 'region' }`,一个**基表列**。下游 `lookupMember`
12+
「plain second-segment」那一档随即命中它,**赶在**「synthetic relation traversal」
13+
那一档把点号路径交给 JOIN 机制之前就返回了 —— 关系穿越被基表列遮蔽。
14+
15+
危害分两档,而更糟的是安静的那一档。当基表**恰好有同名列**时(`crm_account` 自己
16+
就有 `region`),四个组合全部静默通过、无任何拒收:
17+
18+
```
19+
① ObjectQL, where: {'owner.region':'NA'} → executeAggregate 收到 {"region":"NA"}
20+
② NativeSQL, where: {'owner.region':'NA'} → … FROM "crm_account" WHERE region = $1
21+
③ ObjectQL, dimensions: ['owner.region'] → groupBy: ["region"]
22+
④ NativeSQL, dimensions: ['owner.region'] → SELECT region AS "owner.region" … GROUP BY region
23+
```
24+
25+
行数与图表都是错的,而没有任何错误可读 —— ④ 尤甚:响应列名标着 `owner.region`,值
26+
却来自基表,读者无法从结果里看出来。基表**没有**同名列时则落到 `400 INVALID_FIELD`
27+
且点名 `region`,而调用方写的是 `owner.region`
28+
29+
维护者 2026-08-06 裁定(issue #5739):即席路径**支持**关系穿越。铸造改为**原样**
30+
(`dimensions['owner.region'] = { sql: 'owner.region' }`),真正的 `<cube>.` 限定
31+
前缀(首段 == cube 名)仍然剥。这同时收敛了一处早有的分叉:同一个过滤器写成数组
32+
(`[['owner.region','=','NA']]`)时铸不出 dimension,于是一直走 synthetic 档、一直
33+
编出正确的 JOIN —— 两种写法现在逐字生成同一条语句。
34+
35+
**Observable behaviour change —— 若你按状态码告警/重试,或消费即席 cube 的元数据,
36+
请读这一段。**
37+
38+
- **对象写法的点号 member 从「静默错列」/「`INVALID_FIELD` 指错名」变为 JOIN 穿越。**
39+
NativeSQL 上 `where: {'owner.region': 'NA'}`
40+
`dimensions: ['owner.region']` 现在编出
41+
`LEFT JOIN "owner" ON "crm_account"."owner" = "owner"."id"` 并按 `"owner"."region"`
42+
筛选/分组;此前它们筛/分组的是基表 `region`(有同名列时),或以
43+
`400 INVALID_FIELD "constrains field 'region'"` 被拒(无同名列时)。**同一个请求
44+
现在返回的行可能与此前不同 —— 此前那些行是错的。**
45+
- **ObjectQL 上同一个 member 改为响亮拒收或正确穿越,不再有第三种更安静的答案。**
46+
`where` 得到 `cannot evaluate a cross-object filter ("owner.region")` —— 与**
47+
注册 cube** 上的既有答案逐字一致;`dimensions` 走 FK-expand 正确穿越,返回关联对象
48+
的值。带 `granularity` 的跨对象 `timeDimensions` 得到
49+
`cannot bucket a cross-object time dimension`
50+
- **即席 cube 的 `dimensions` 词汇表里现在出现点号键**(`getMeta` 上是
51+
`crm_account.owner.region`)。此前该穿越要么以剥掉的尾段出现(`crm_account.region`),
52+
要么(数组写法)完全不出现。
53+
- **不变的部分**:真正的 `<cube>.` 限定符照旧剥除;裸列名照旧是基表列(基表自己的
54+
`region` 仍可作为 `region` 分组);#4437 / #5520 / #5669 三道源字段闸门的代码一行未
55+
动,它们对裸名拼错的 `400 INVALID_FIELD` 拒收原样保留;点号 **measure**(如
56+
`total.sum`)仍按 #4437`400 INVALID_FIELD` 拒收 —— `lookupMember` 的 synthetic
57+
穿越档是 dimension-only,dotted measure 没有可收敛的穿越答案。
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/objectql": patch
4+
"@objectstack/lint": patch
5+
---
6+
7+
fix(lint,spec,objectql): 编写期表达式与 `highlightFields` 校验识别注册表注入的系统列 (#5378)
8+
9+
平台在每个业务对象上注入 `owner_id` / `created_at` / `organization_id` 等系统列,
10+
文档也把 `ownership: 'user'` 写作 "injects reassignable owner_id"。但编写期的两处
11+
校验只读**作者声明的** `fields`,于是注入列一律当作不存在:
12+
13+
- `validate-expressions.ts``buildFieldIndex``has(record.owner_id)` 直接
14+
报错 `unknown field owner_id`;
15+
- `highlightFields` 存在性检查对 `['owner_id']` 发出 "is not a field on this
16+
object — it is silently skipped by every consumer"。
17+
18+
也就是平台自己的 linter 否认平台自己的契约。结果是应用被迫**重声明系统列**才能通过
19+
编写期校验:hotcrm#548 为此在全部 12 个业务对象上显式声明了 `owner_id`(6 个对象曾
20+
`highlightFields` 警告,`contact_welcome` 触发器的 `has(record.owner_id)` 被硬
21+
拒)。这正是本项目视为缺陷的形状:能力已声明(列确实注入且有文档),但执行层不认。
22+
23+
**权威来源只有一份。** 新增纯派生 `resolveInjectedSystemColumns()`
24+
(`@objectstack/spec/data`)回答"这个对象带哪些系统列",并由 registry 的
25+
`applySystemFields()` **消费**它——沿用 #3786 为审计字段族确立的分工:spec 声明
26+
**有哪些**列,registry 拥有**每列长什么样**。lint 通过同一派生取答案,因此编写期
27+
判断与运行时注入不可能不一致(`@objectstack/lint` 的包契约是"只依赖 spec,绝不依赖
28+
运行时",此前它根本无法读到权威)。两个消费面共用同一判定,不各写一份。
29+
30+
**并入是按对象有条件的**,不是无条件放行整张系统列名单:`ownership: 'org' | 'none'`
31+
的对象没有 `owner_id`,那里的 `record.owner_id` 仍然是真错误并继续报;
32+
`tenancy.enabled: false``organization_id`;`systemFields: { audit: false }`
33+
审计四列;`systemFields: false` / `managedBy: 'better-auth'` 什么都不注入(只剩驱动
34+
提供的主键 `id`)。真正拼错的字段照旧被拒,并且注入列现在也进入 "did you mean?" 候选
35+
(`record.ownerid` → 提示 `owner_id`)。
36+
37+
被解析的注入列在诊断与补全语义上与授权字段等同;类型健全性与 null-guard 两个索引
38+
**刻意**仍只读声明字段,原因写在各自注释里:列的 `type` 与可空性属于 registry 的列
39+
定义,在 lint 侧另立一份就是本次要消灭的第二份副本,而 null-guard 喂的是会中断构建的
40+
判定,擅自并入会让今天能构建的 stack 变红。
41+
42+
注入行为本身零改动:`applySystemFields` 的输出在全条件矩阵上逐列不变(新增 parity
43+
pin 用实跑注入代码比对)。已显式重声明系统列的应用不受影响——重声明仍然合法,
44+
examples 三个 app 的 `os validate` 输出改动前后完全一致。
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
chore(objectql): retire `applyFormulaPlan`'s zero-caller `nowSnapshot` parameter and narrow its docstring to what actually holds (#5699)
6+
7+
`applyFormulaPlan` declared a fourth optional parameter `nowSnapshot?: Date`
8+
whose only effect was `nowSnapshot ?? new Date()`. Not one of its three call
9+
sites ever passed it — `find`, `findOne`, and the write-response hydration
10+
`hydrateWriteFormulas` added by #5504 — so the parameter went down the
11+
`new Date()` branch from birth. Dormant code, removed rather than archived: a
12+
parameter that looks live is worse than no parameter, because everyone
13+
reasoning from it concludes the caller can pin the instant, and one caller
14+
plainly should have.
15+
16+
No behaviour change (the removed branch was unreachable), no public API change
17+
(`applyFormulaPlan` is module-private and never exported).
18+
19+
The docstring claimed the eval context "mirrors `applyFieldDefaults`". Half of
20+
that was true — the same keys, so `formula` and `defaultValue` expressions share
21+
one vocabulary — and half was not: the two pin their own `now`.
22+
`applyFieldDefaults` is handed the insert's pre-write snapshot, while
23+
`applyFormulaPlan` reads the clock once per call, because a formula is evaluated
24+
when a record is materialized. So inside one `insert` a `NOW()` default and a
25+
`now()` formula observe two instants a driver round-trip apart (sub-millisecond
26+
in practice; across a second/day boundary they can land on different calendar
27+
days). The docstring now says so, and names #5699 as where making them share one
28+
instant would have to be argued — it would hand the write path a determinism
29+
guarantee the read path cannot have, which is a semantic decision, not a cleanup.
30+
31+
Adds the pins that the retired parameter's *appearance* was standing in for
32+
(`engine-write-formula-hydration.test.ts`): one snapshot per call shared by every
33+
row × every formula field, asserted by object identity on the eval context so a
34+
per-evaluation `new Date()` fails even when the milliseconds agree, on the write
35+
path and the read path alike; plus a tripwire that the default's instant and the
36+
formula's instant stay independently sourced.

.changeset/olive-pugs-repeat.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/trigger-record-change': patch
3+
---
4+
5+
record-change trigger: drop the unreachable `input.doc` alias read, seed the flow record from `input.data` only
6+
7+
Behaviour is unchanged: no engine path has ever built `input.doc`, so the alias
8+
limb could not be reached. Every ObjectQL write context spells the payload
9+
`data` — measured and pinned by `hook-input-shape-contract.test.ts` in
10+
`@objectstack/objectql` ("insert carries `data` — never `doc`", #5273). The
11+
branch survived only because the old `HookContext.input` contract table
12+
documented insert as `{ doc, options }`; that table was corrected in #5668, so
13+
the fallback no longer had even a documented producer to defend against, and it
14+
is removed here rather than left as a second de-facto contract (PD #12).

.changeset/quick-melons-swim.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): 保存成功的回执不再一律自称 "customization overlay"
6+
7+
`saveMetaItem` 的成功 `message` 原本只有两种句式,都写死了 "customization
8+
overlay"。但 `DEFAULT_METADATA_TYPE_REGISTRY` 里有一批类型声明
9+
`supportsOverlay: false` 而按设计可以运行时写入(`object` / `field` / `hook` /
10+
`seed` / `mapping` / `flow` / `action`),对它们的一次全新创建并没有覆盖任何
11+
artifact,却也被回执成 "saved a customization overlay"。
12+
13+
判据不是 `supportsOverlay`,也不是 `allowOrgOverride`(spec 的 TSDoc 把这两件事
14+
分得很清楚:前者是 loader 的合并能力,后者是运行时写入的许可),而是写路径**早已
15+
算出**`isArtifactBacked` —— 也就是 `intent: 'override-artifact' |
16+
'runtime-only'` 的来源。回执现在只说这条已知事实,不新增任何读路径查询。
17+
18+
| | FROM | TO |
19+
|:---|:---|:---|
20+
| 覆盖了 code package 的 artifact | `Saved customization overlay (org=…, state=…) — type=…, name=… [seq=N]` | 逐字不变 |
21+
| 无 artifact 的运行时写入 | `Saved customization overlay (env-wide, state=…) — type=…, name=… [seq=N]` | `Saved <type> '<name>' (env-wide, state=…) [seq=N]` |
22+
23+
org 维度照旧在括号里(`org=<id>` / `env-wide`),`state=``[seq=N]` 两个分支都
24+
保留,所以读取 `seq`(HMR 游标)或 `state` 的消费方不受影响;`message` 本身没有
25+
任何消费方解析,仅作 toast 展示。
26+
27+
回执不区分「新建」与「更新既有 DB-only 行」:唯一可用的事实 `parentVersion ===
28+
null` 的作用域是 `(state, packageId)`,一个已有 active 行的首个 draft 也会读成
29+
"没有父版本",据此写 `Created …` 只是把一句假话换成另一句假话。中性动词
30+
"Saved" 如实,且不为一句文案发明新的查询。
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): 退休登记按**确切 key** 判定 —— 无关簇的同名叶子不再让 tombstone 冒充「已登记」(#4659)
6+
7+
`scripts/build-schemas.ts` 的检查 (b)(`check:authorable-surface`)保证一件事:
8+
一个 authorable key 从 live 翻成 retired(`retiredKey()` 墓碑)时,这次退休必须
9+
被登记下来,否则 `spec-changes.json`(ADR-0087 D4)、生成的升级指南、`spec_changes`
10+
MCP 工具全是空的,消费者只能靠失败才知道。
11+
12+
它此前是这样判定「已登记」的:取 key 的**叶名**,拿去和**全部 major** 的所有
13+
conversion / migration `surface` 子句做 `endsWith('.' + name)` —— 完全不看 key
14+
属于哪个 def。于是任何一条无关登记,只要 surface 以同名叶子结尾,就把这个墓碑判为
15+
「已登记」:
16+
17+
- #4658 实测:`automation/Event:type` 被 tombstone、零 conversion,门禁全绿 ——
18+
命中的是 protocol 11 的 `flow-node-http-callout-rename`(`flow.node.type`),
19+
一个 flow 节点的 `type`,和状态机事件毫无关系。
20+
- #5509(ADR-0087 D2 `page-header-subtitle-alias`)登记
21+
`page.component.page-header.description` 之后,任何叶名为 `description` 的 key
22+
也进了同一个免检名单。`type` / `name` / `config` / `filter` / `schema` /
23+
`description` 都是 authorable 形状上最常见的叶子,这条保证对它们整体失效。
24+
25+
现在登记有了自己的表:
26+
27+
- **新增导出 `RETIRED_KEYS_BY_MAJOR`**(`@objectstack/spec`,
28+
`src/migrations/registry.ts`),值就是确切的 `` `${defKey}:${name}` `` 字符串 ——
29+
`authorable-surface.json` 怎么写它就怎么写,去掉 `[RETIRED]` 标记。
30+
- 检查 (b) 改为对这张表做**精确集合判定**:不再有 `endsWith`,不再取叶名,不再从
31+
相邻的 key 辐射过来。门禁失败时直接打印要粘贴的那一行和它该进哪个 major。
32+
- 新增检查 (b2):表里登记了一个**当前仍然 live** 的 key —— 一次没有任何东西消费的
33+
登记 —— 直接失败;它会替一次尚未发生的退休提前放行。登记了一个本次构建**已不再
34+
产出**的 key 则不是错误:墓碑满 ~2 个 major 之后由检查 (c) 放行其基线行,登记条目
35+
留下,这是预期稳态。
36+
37+
conversion 的 `surface` 保持散文形态、一个字没动:它面向作者、按作者书写元数据的
38+
形状表达(`flow.nodes[].outputSchema`),本来就无法可判定地映射回 def key。所以
39+
搬走的是机器事实,不是散文。一次退休仍然两样都要写:登记条目是**声明的凭据**,
40+
conversion 是**消费者照着做的处方**
41+
42+
不回填历史:检查 (b) 只在相对已提交基线的 live → retired ****跃迁上触发,而更早的
43+
墓碑在基线里已经是 `[RETIRED]`,不会再触发它。所以这张表读作「在确切-key 门禁下登记的
44+
退休」,不是「历史上的全部退休」—— 空表在 `main` 上实测全绿。
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
fix(service-automation): `wait` 节点的五条日志不再把外来 cause 拼进 message,改走 meta (#5737)
6+
7+
`builtin/wait-node.ts` 里有五处记录把**我们不控制文本**的失败原因(数据源驱动、
8+
job 服务、`engine.resume()` 的错误信封)直接插进日志 message。`ObjectLogger.write()`
9+
一次调用只加一个「时间戳 + 级别」记录头,所以 message 里的换行会把**一条**记录变成
10+
多个物理行,后面几行既无级别也无时间戳。在 `pretty` / `text` 格式(`os dev` / `os serve`
11+
的默认)下,文件 sink 会把它们当成独立记录存,日志采集器读成无主碎片,而
12+
`grep ERROR` 只捞得到不含任何事实的那一行 —— 恰恰是运维正在找的那条。
13+
14+
五处现在都改成:**message 单行自足**,外来 cause 交给 logger 的结构化参数位 ——
15+
`Logger` 契约(`packages/spec/src/contracts/logger.ts`)选位置,`warn(message, meta?)`
16+
用第二参,`error(message, error?, meta?)`**第三**参(第二参留空,否则每条记录都
17+
会带上整个栈)。与 #5048 / #5575 / #5636 / #5661 完全同一套修法,零新词汇。
18+
19+
对运维可见的变化(日志形状,非行为):
20+
21+
- 这五条记录各自恒为**一个**物理行,不论日志格式;
22+
- 原因文本从 `msg` 末尾的 `Cause: …` 移到记录的 `error` 字段(`meta`),多行驱动错误
23+
`JSON.stringify` 转义换行后完整保留 —— 一个字节都不丢;
24+
- 消息里原本指向拼接文本的「the cause below」措辞改为指向记录的 meta;
25+
- 级别一律不变。其中三处是 #4632 明确定为 `error` 的耐久性诊断
26+
(`rearmSuspendedWaitTimers` 的 store 不可列、overdue 运行叫不醒、唤醒 job 没排上),
27+
仍是 `error`,`pnpm check:durability-log-level` 照旧覆盖;「无 job 服务」那条声明式
28+
缺失仍是 `warn`
29+
30+
`Cause:` 字面量 grep 这五条记录的日志查询需要改成读记录的 `error` 字段。

0 commit comments

Comments
 (0)