Skip to content

Commit b56e81e

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-5271-api-metadata-type-registry
# Conflicts: # packages/spec/src/kernel/metadata-type-schemas.test.ts
2 parents 6aba08e + 28ad90e commit b56e81e

199 files changed

Lines changed: 21917 additions & 5053 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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
docs(adr-0076): 把状态行从 `Proposed` 校准为 `Accepted`,并逐条记录 D1–D12 的真实落地状态(#5063)。参照 #4919 对 ADR-0078 的做法:不整体翻牌,每条决定给出在 `origin/main` 上核对到的证据 —— D3 完全未建、D10/D11 只落一半、D9 已越过 step-1 把联合类型彻底解散、D7 已有 CI 触发指标。同时给 D9 的 back-compat 条目补一条状态注记(组合别名已不存在)。纯文档改动,不发布任何包。
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
---
2+
"@objectstack/types": minor
3+
"@objectstack/cloud-connection": minor
4+
"@objectstack/lint": minor
5+
"@objectstack/cli": minor
6+
"@objectstack/spec": patch
7+
"@objectstack/driver-sql": patch
8+
---
9+
10+
feat(types,cloud-connection,lint,cli): ADR-0120 17.x 收尾 —— `isolated` 安装期姿态硬门(D5e)、D5c 重拼写 advisory、成文契约扫荡与三姿态 conformance (#5081)
11+
12+
ADR-0120 17.x 波的第三块,也是最后一块。前两块已在 main 上:#5212(driver 侧
13+
D3+D4 —— `COALESCE(organization_id, '__global__')` 物化、drift 两侧同步、重复预检)
14+
#5208(spec 词汇 `'organization'` + D5a/D5b lint)。本次补齐三件事:安装期的
15+
姿态决策点、剩余的成文契约、以及把「一个 app 包跑遍三种姿态」从假设变成测试。
16+
17+
**D5e —— 装进 `isolated` 环境时的硬门。** 词汇本身是姿态无关的:作者说的是业务
18+
边界(`'organization'` 一个组织一份 / `'global'` 整个安装一份),没有任何索引形状
19+
读姿态。唯一的残留在一个方向上:`isolated` 下组织就是**不同客户**,此时 app 业务
20+
对象上的 `'global'` 唯一既跨客户过度约束,又变成跨客户的存在性预言机(S10/S14)。
21+
维护者裁定这是**硬门而非 advisory**:把带 `'global'` 唯一(非 `sys` 对象)的 app
22+
装进 `isolated` 环境会**停下来并逐索引列出**,安装者(通常是 AI agent)要么确认它
23+
确实是平台级的,要么改写为 `'organization'`;确认按 ADR-0104 attestation 风格
24+
留痕在安装清单里(`InstalledManifestEntry.globalUniqueAttestation` —— 确认了什么、
25+
谁确认的、何时、在哪个姿态下问的),**之后不复问**
26+
27+
- 停下的安装**什么都不留**:先于 hot-register 和任何 ledger 写入,所以作者改完
28+
元数据可以直接重试,不需要先卸载。
29+
- 逐索引确认是有牙齿的:`confirmGlobalUniques``true` 或明确的 id 数组,只确认
30+
其中一条仍会在剩下的那条上停住。
31+
- 升级引入的****约束会被问,老的答案继续算数。
32+
- 另一个姿态下给出的确认**不算同意** —— `isolated` 那个问题在 `single` 下从未被
33+
问过,所以按「未确认」处理(唯一不会静默放行跨客户约束的方向)。
34+
-**永不做成启动期告警**(#4884 纪律)。boot 时的 rehydrate 不评估此门;门够不到
35+
的两类存量 —— 门禁上线前的安装、装后姿态变更的环境 —— 由 `os doctor`
36+
`os migrate plan` 的 advisory 形态覆盖。
37+
38+
判定里有三条是承重的,别「简化」掉:声明索引上的裸 `unique: true` ****(D1 说它
39+
就是 `'global'` 的位置式拼写,排除它等于让整个 17.x 可以靠拼写绕过);字段级
40+
`true` **不算**(它是 `'organization'`,永久合法);`sys_`/`base_` 对象**不算**
41+
(S5 那批引擎幂等键天然就是平台级的,每次安装都问一遍就是 #4884 的误报类)。
42+
43+
CLI: `os package install` 新增 `--confirm-global-uniques`,并把 409 渲染成可读的
44+
逐条清单而不是一句 "Install failed (409)"。
45+
46+
**D5c —— 遗留手写组织复合索引的 advisory。** 新规则
47+
`unique/legacy-organization-composite`:声明的唯一索引自己列出了组织列
48+
(`{ fields: ['name','organization_id'], unique: true }`)—— 这是词汇出现之前手写
49+
per-organization 的写法。它读起来像「每组织唯一」,物化出来却是普通复合索引,而
50+
SQL UNIQUE 是 NULL-distinct 的:组织列为 NULL 的行上它**什么都不约束**(#5030),
51+
在单组织部署上那就是每一行。改写成 `unique: 'organization'`(`fields` 原样保留,
52+
driver 会把已列出的组织列**就地**变成 NULL-safe 形式)正是补上这个洞的动作。
53+
**永远只是 advisory,永远不自动修**:老拼写永久合法、零强制 drift,而 opt-in 是
54+
真实的物理收紧,要走 D4 的 `recreate_index` + 重复预检。
55+
56+
**D6 —— 成文契约扫荡。** `content/docs/data-modeling/indexing.mdx`
57+
§Two ways to say "unique" 全节按新词汇重写(含 `os:check` 代码块);
58+
`content/docs/protocol/objectql/schema.mdx` 的 §Uniqueness and tenancy 重写为
59+
§Uniqueness and scope —— 其中那句「单租户部署不受影响,租户列是常量,复合索引
60+
退化为单列索引」是 #5030 **证伪过的原话**,现已替换为 D3 的 NULL-safe 事实;
61+
`content/docs/deployment/cli.mdx``replace_unique_index` / `recreate_index`
62+
条目补上 NULL-safe 形状与重复预检;`content/docs/references/**`
63+
`gen:schema && gen:docs` 再生成,未手改。
64+
65+
按 ADR-0120 Resolved #2 的非规范性引导(官方示例/脚手架/生成器在新代码中输出
66+
显式拼写),`skills/objectstack-data/**` 的索引与校验规则整体扫过:声明索引一律
67+
说清 scope,并新增一节完整讲 `'organization'` 的 NULL-safe 语义与「永远不写姿态」。
68+
顺带修掉那里长期使用的 `tenant_id` —— 平台的列叫 `organization_id`
69+
`examples/**``create-objectstack` 模板与 `os generate` 经核查**根本没有声明任何
70+
唯一约束**,故无可扫;这是核查结论,不是遗漏。
71+
72+
**三姿态 conformance(ADR §Acceptance tests)。** 同一个 fixture app 在
73+
`single | group | isolated` 三姿态下启动,逐 S 行用**真实的违规插入**断言 enforcement
74+
(S1/S2/S3/S4/S5/S6/S7/S8/S9/S11/S12),并逐姿态捕获物化出的索引键,断言三者
75+
**逐字节相同** —— 「没有任何索引形状读姿态」这句话一旦有两者不同就是假的。相同性
76+
断言配了一条正向断言(对着期望的键形状),这样「三次都什么都没建」不会读成「一致」。
77+
外加 ADR 只要的那一条 transition smoke:在 `single` 下建库、`isolated` 下重新打开,
78+
drift op 为零。
79+
80+
对既有部署的影响:除新增的安装期确认外,本次不改变任何已有物化行为。字段级
81+
`unique: true` 一如既往合法。
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@objectstack/service-analytics": minor
3+
---
4+
5+
fix(service-analytics)!: 分析查询的 `where` —— `$not` 变 NULL-safe、`{$not:{}}` 变零行、`$or``{}` 析取项不再被丢 (#5325)
6+
7+
`filter-normalizer.ts``buildNode` 是这个包里**第二份**同缺陷拷贝:第一份
8+
(`read-scope-sql.ts``compileNode`,RLS 读作用域)已由 #5297 修好,而这一份编译的是
9+
**作者自己写的 `where`** —— dashboard widget / dataset 的筛选器。两者是各自独立的函数,
10+
所以那一单合入后这三条仍然在。以 `driver-sql` 同一份 fixture 实测(4 行,行 3、4 的
11+
`stage` 为 NULL,行 3 的 `amount` 为 NULL,行 4 的 `owner` 为 NULL):
12+
13+
| widget 的 `where` | 改前取到的行 | 改后(= driver-memory / formula / #5296 后的 driver-sql) |
14+
|---|---|---|
15+
| `{ $not: { stage: 'won' } }` | `2` | `2,3,4` |
16+
| `{ $not: { stage: { $in: ['won'] } } }` | `2` | `2,3,4` |
17+
| `{ $not: {} }` | **全表** | **零行** |
18+
| `{ $or: [{ stage: 'won' }, {}] }` | `1` | 全表 |
19+
| `{ $not: { $or: [{stage:'won'},{owner:'u1'}] } }` | `2` | `2,4` |
20+
21+
**这是可观察的行为变更,不是内部重构 —— 已有的图表数值会变:**
22+
23+
- **`{$not: {}}` 的 widget 此前画的是整个数据集,现在是零行。** `buildNode({})` 返回
24+
`null`(= 无约束 = TRUE),`$not` 分支的 `if (inner)` 因此为假,整条 `$not` 消失,
25+
WHERE 一个字都不发 —— 一条意思是「什么都不显示」的筛选器显示了全部。`NOT TRUE ≡ FALSE`,
26+
现在它编译成 `1 = 0`
27+
- **`$not` 下 NULL 行的去留变了,所以图上的数字会变。** SQL 是三值逻辑而 `WHERE` 只保留
28+
TRUE,裸 `NOT (stage = ?)``stage` 为 NULL 的行全部丢掉;`driver-memory``formula`
29+
和(#5296 之后的)`driver-sql` 都把它们算进来。同一条 widget filter,在分析查询和普通
30+
`find()` 上给出不同的行集,取决于哪个后端接住它。#5146 已拍板 JS 家族的答案为准,本次
31+
按同一口径把守卫**下推到叶子**(`{col: {$null: false}}` / `{$or: [{col:{$null:true}}, …]}`,
32+
极性逐算子决定)。**受影响的图表数值会上升**(负向筛选现在包含空值行)。
33+
- **`$or` 里的 `{}` 析取项不再被丢。** TRUE 是 AND 的单位元但**吸收** OR,所以
34+
`{$or: [{stage:'won'}, {}]}` 整条为 TRUE;此前它被 `.filter(n => n !== null)` 丢掉,
35+
查询被静默**收紧**成剩余分支。
36+
- **空集合是布尔常量,不再是「没有谓词」。** `{stage: {$in: []}}` 此前编译成空子句
37+
→ 无约束 → 画全表,现在是零行(`1 = 0`);`{$nin: []}` 不排除任何行。
38+
- **两处新的响亮拒收(此前静默放宽):** `$not` / `$or` / `$and`**非对象**操作数
39+
(`{$not: null}` 曾整条消失 → 等于不筛),以及**零个操作符的字段约束** `{a: {}}`
40+
—— 后者按 #5240 的拍板拒收,与 driver-sql / driver-memory / formula 一致;不这么做的话,
41+
「TRUE 吸收 OR」会把 `{$or: [{a: {}}, {b: 2}]}``b = 2` 放宽成全表。
42+
43+
实现落在 normalizer 而不是某个 strategy:守卫在这一层是**结构**(多一个 `$null` 合取项),
44+
`filterNodeToCondition` 交给 ObjectQL 引擎后在**任何驱动上都成立**,包括本身不 NULL-safe
45+
的那些;只加在 raw-SQL 那条路径,等于说「分析查询的 `$not` 是什么意思取决于哪个驱动接住它」。
46+
代价是引擎路径会**双重加守卫**,已实测幂等(`NOT (c IS NOT NULL AND (c IS NOT NULL AND c = v))`
47+
与单层等价),只是 SQL 多一层冗余谓词。
48+
49+
`NormalizedFilterNode` 因此新增布尔常量 kind —— 该联合此前只有 `leaf | and | or | not`,
50+
没有 FALSE 的表示法,这正是 `{$not:{}}` 只能编译成「什么都不发」的根本原因。三个编译器
51+
(`native-sql-strategy.compileFilterNode``objectql-strategy.filterNodeToCondition`
52+
回显给浏览器的 `renderFilterNodeSql`)各自实现它;引擎路径用的是 `{$not: {}}`,即
53+
driver-sql / formula / driver-memory 参考匹配器早已钉住的零行写法(#5134),没有另造第二种。
54+
55+
`$and: []` / `$or: []` 的空组合子**不在本次范围**,仍然 fail-closed 抛错(独立裁定见 #5322),
56+
并已加用例钉在抛错这一侧。
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(service-analytics): a `where` written as a `FilterArray` is lowered instead of silently dropped (#5334)
6+
7+
**Observable behaviour change.** An analytics query whose `where` arrived as an
8+
ARRAY had its filter **deleted**: `normalizeAnalyticsFilterTree` answered every
9+
array with `return null`, so no predicate was compiled, no error was raised, and
10+
the widget charted the **entire dataset**. The compiled SQL stayed perfectly
11+
valid — just broader than the author asked for — which is why it was invisible
12+
to every test that asserts a SQL string. The issue's own measurement:
13+
`generateSql({cube:'deals', measures:['total'], dimensions:['id'], where:
14+
[['stage','=','won']]})` emitted `SELECT id AS "id", COUNT(*) AS "total" FROM
15+
"deal" GROUP BY id` with an empty `params`. It now emits the bound `WHERE` and
16+
returns the two won deals.
17+
18+
`FilterArray` (`['stage','=','won']`, `['and', […], […]]`, `[[…], […]]`) is
19+
INPUT-ONLY authoring sugar (#5285), and #5158's ruling C says every door into
20+
the runtime lowers it through the single `parseFilterAST` sink before anything
21+
downstream sees a filter. #5329 closed ObjectQL's six entry points that way and
22+
deleted the four drivers' private array dialects. Analytics is the **fifth
23+
door**: it compiles `where` itself — to SQL (`NativeSQLStrategy`) or to a
24+
`FilterCondition` for the engine (`ObjectQLStrategy`) — so nothing upstream
25+
lowers for it. It now gives the same three answers the engine door gives:
26+
27+
- `[]` — "no filter", not a failed filter: no predicate, no error (unchanged).
28+
- A well-formed `FilterArray`**lowered** through `parseFilterAST`, so both
29+
spellings of one filter select the same rows on both strategies.
30+
- Any other non-empty array — **refused** with `INVALID_FILTER` / 400
31+
(ADR-0112), the envelope the drivers' `filterArrayReachedDriverError` uses.
32+
This is where the undeclared INFIX form (`[condA, 'or', condB]`) lands, and
33+
where a list of `FilterCondition` objects (`[{stage:'won'}]`) lands — neither
34+
is a `FilterArray`, `parseFilterAST` has no lowering for either, and dropping
35+
them is what returned the unfiltered dataset.
36+
37+
Lowering rather than refusing keeps one dashboard's metadata meaning one thing:
38+
the same `where` on a plain `find()` already lowers at the engine door, so
39+
refusing it here would have forked the product by which face read the metadata.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/platform-objects": patch
4+
---
5+
6+
fix(spec): stop offering retired `app` keys in the metadata form, and make the reconciliation gate see tombstones (#5280)
7+
8+
The `app` authoring form rendered **eight** controls for keys `AppSchema` had
9+
already retired to `retiredKey()` tombstones in 17.0.0 — `version`, `homePageId`,
10+
`objects`, `apis`, `sharing`, `embed`, `mobileNavigation` and `aria`. A tombstone
11+
is `z.never().optional()`, so filling one of those controls did not lose the
12+
value quietly: it failed the **entire save** with the key's removal
13+
prescription. The controls are gone, each with a comment in place naming where
14+
the capability went (`manifest.version`; the first `navigation` item by `order`
15+
plus `isDefault`; `defineStack({ objects })`; `defineStack({ apis })`;
16+
`FormView.sharing` for both public access and embedding; the component that
17+
renders the DOM node for `aria`).
18+
19+
Nothing about the contract changes — every one of these keys was already
20+
rejected at parse. What changes is that an author is no longer shown a control
21+
that can only produce a 422.
22+
23+
**The reconciliation gate now judges the right thing.** #3786's
24+
`metadata-form-zod-reconciliation.test.ts` asked whether an offered key was
25+
`∈ shape`. That was the same question as "may the author write this" until
26+
`retiredKey()` existed: a tombstone **deliberately stays in the shape** so the
27+
removal can carry its own upgrade prescription, so every one of those eight keys
28+
read as "the Zod accepts it" and the gate stayed green over all of them. It now
29+
asserts `∈ shape` **and not a tombstone**, in both directions — a retired key
30+
may not be offered, and its absence needs no ledger entry to excuse it. The
31+
detector reads the schema node (`z.never()` under the optional wrapper), never a
32+
list of key names, mirroring `isRetired()` on the JSON-Schema side of
33+
`build-schemas.ts`. The next `retiredKey()` retirement that forgets a form now
34+
fails this test instead of reaching an author.
35+
36+
Retiring an authorable key already required pruning its form input; that step is
37+
now enforced rather than remembered.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): `areas[].requiredPermissions` 的退役处方改口径 —— 项级闸门在**两棵树**都由服务端剥离(#4749)
6+
7+
`AREA_REQUIRED_PERMISSIONS_RETIRED` 是作者写错 area 级键时唯一能读到的文字(strict
8+
schema 的 unknown-key 报错正文)。它此前写着:
9+
10+
> Items nested under `areas[]` are gated in the shell only — the server does not
11+
> walk `areas` — so anything that must never reach the browser belongs in the
12+
> top-level tree, or in its own app.
13+
14+
这句话在 #4722 之后已经过时。`filterAppForUser` 现在对每一棵 `areas[].navigation`
15+
**同一个** `filterNav`,所以导航****上的 `requiredPermissions` / `requiresService`
16+
在顶层树和 area 内部被同等强制,被闸住的条目(连同它的 `objectName` / `pageName` /
17+
`componentRef` 目标)根本不会进入 `/meta` 响应体。
18+
19+
方向上这是「过度保守」而非不安全 —— 它劝作者把敏感项挪到顶层树,那仍然可行 —— 但它错了,
20+
而且错在**说给作者听的**那一份上:一个本可以就地把 `requiredPermissions` 写在 area 内部项
21+
上的作者,会被这段话劝去重构导航树。
22+
23+
处方正文改后陈述当前事实,并保住 #4722 **没有**改变的那一半非对称性:`visible`(CEL)与
24+
`requiresObject` 在任何层级**依然只在客户端求值**(服务端跑 CEL 需要读层没有的 `user`
25+
绑定上下文)。所以「必须永不到达浏览器」的东西写 `requiredPermissions`,不要写 `visible`
26+
—— 这一条在 #4722 之后反而更容易被误读,因此写进了正文并单独钉了 pin。
27+
28+
同一次改口径也覆盖姊妹处方 `AREA_VISIBLE_RETIRED`(作者写错 `areas[].visible` 时读到的那
29+
份)。它此前把服务端强制的落点枚举成「on the app itself, or on items of the app's top-level
30+
`navigation` tree」—— 不是假话,但 #4722 之后这份**枚举**漏了 `areas[].navigation` 的项,
31+
效果与上面那句相同:一个已经站在 area 内部、本可以就地把闸门写在该 area 项上的作者,被劝去
32+
重构导航树。现在两处落点都列全。
33+
34+
⚠️ 这条**没有**改变 `visible` 自身的口径:项级 `visible` 依然是 CEL、依然只在浏览器里求值,
35+
#4722 没有碰这一半。正文里把两者的分工写死 —— `visible` 隐藏的是**已经发出去**的条目,
36+
`requiredPermissions` 才让条目根本不被发出 —— 因为作者是攥着一个 CEL 表达式走到这条报错前
37+
的,「就近改写成项级 `visible`」正是此刻最顺手也最危险的那个落点。
38+
39+
退役裁决本身不动:被强制的是 area **内部的项**,area ****键(`areas[].visible` /
40+
`areas[].requiredPermissions`,#4651)保持退役,没有复活。
41+
42+
行为零变化 —— 改的是两条报错正文与它们的 pin 断言,以及 `app.zod.ts` 里同一段已随 #4722
43+
过期的说明性注释。
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
---
3+
4+
CI-only: a failed `filter` job no longer skips all seven core gates while branch
5+
protection reports green. Releases nothing.
6+
7+
`ci.yml`'s downstream jobs were guarded by `if: needs.filter.outputs.core ==
8+
'true'`, which names no status function — so GitHub wrapped it in an implicit
9+
`success()`. Any `filter` failure (checkout flake, a `dorny/paths-filter` fault,
10+
the 10-minute timeout) skipped `test` / `temporal-conformance` / `dogfood` /
11+
`dogfood-verify` / `build-core` / `build-docs` / `console-pin` at once, and a
12+
skipped required check counts as a pass: zero tests ran, nothing went red, the
13+
PR was mergeable. All seven now read `if: ${{ !cancelled() &&
14+
needs.filter.outputs.<name> != 'false' }}` — skip only when the filter
15+
EXPLICITLY said false — which is the same "when in doubt, run everything"
16+
trade-off the `|| 'true'` on the filter's own outputs already made.
17+
18+
`test-gate` and `dogfood-gate` additionally take `filter` into `needs` and
19+
refuse to accept a `skipped` leg when `filter` did not succeed, so the invariant
20+
is asserted rather than merely arranged. `cancelled` keeps passing both gates
21+
(#3668's run-lifecycle reasoning) and no other state changes behavior.
22+
23+
Third sighting of this GitHub semantic; the other two were `release.yml`'s
24+
publish-integrity guard and its `docker` job (#4900).

0 commit comments

Comments
 (0)