Skip to content

Commit 0d291d6

Browse files
committed
Merge origin/main into claude/issue-4616-notification-orphan-schemas
2 parents c240c28 + f160ba4 commit 0d291d6

20 files changed

Lines changed: 2249 additions & 71 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
"@objectstack/objectql": patch
3+
"@objectstack/platform-objects": patch
4+
---
5+
6+
fix(objectql,platform-objects): 一次启动不能证明它自己随即违反的契约 —— ADR-0104 空库自证改为在本次启动写完数据后下结论 (#4769)
7+
8+
一个全新部署第一次 `pnpm dev` 全绿(130 rows,0 ERROR),**第二次启动开始永久 10 条
9+
ERROR**、10 条种子记录写不进去。数据没变、代码没变,只是重启了一次;被拒的正是首启
10+
自己写进去的数据。
11+
12+
根因不是哪个值算错了,是**顺序反了**`sys_migration` 里那两行
13+
(`adr-0104-file-references` / `adr-0104-value-shapes`)带着
14+
`{"attested":"datastore-created-empty"}` 写在 `kernel:ready`,而同一次启动的 seed
15+
还在往里写行。「空库 ⇒ 没有历史值」这个推理成立的前提是**没有数据可写**,而它恰恰
16+
写在即将写入 130 行之前 —— 证明落笔那一刻是真的,一秒之后就不是了。于是首启在
17+
warn-first 下把数据留下,之后每一次启动读到这张证书、进入 strict、拒掉前任写下的
18+
那批行。
19+
20+
## 改了什么
21+
22+
**证书必须覆盖它所声称的那批数据。**
23+
24+
- **写入时机**:新库自证改为在**本次启动自己的数据落定之后**进行 ——
25+
`app:seeded`(inline seed 结算点,含超出 `OS_INLINE_SEED_BUDGET_MS` 后台跑完的
26+
那一半),不 seed 的 kernel 仍由 `kernel:ready` 兜底。两条路径进的是同一个幂等
27+
调用。
28+
- **写入前提**:`attestFreshDatastore` 先问引擎「这次启动放行过违反该契约的值吗」。
29+
引擎在 warn-first 放行每一个不合形状的值时,用**与 strict 模式完全相同的判定**
30+
它记下来 —— 证明干净需要扫全库,证伪只需要一个反例,而这个反例写路径已经算出来
31+
了。任一条被本次启动证伪的迁移 id **不再自证**,部署维持 warn-first(真实且可
32+
恢复),并在日志里指名是哪个 `对象.字段` 让这道闸没关上、该跑哪条 `os migrate`
33+
两行一起改:`adr-0104-file-references``adr-0104-value-shapes` 各自独立判定,
34+
一个 `cover` 不合形状不牵连 `location`,反之亦然。
35+
- **写入之后**:证书若在签发之后被本次启动推翻(操作员显式开了
36+
`OS_ALLOW_LAX_MEDIA_VALUES` / `OS_ALLOW_LAX_VALUE_SHAPES`,或后台 seed 收尾晚于
37+
签发),引擎**撤销**它 —— `verified_at` 清空、`blocking` 记上、`details` 保留原
38+
`attested` 并补一条 `revoked`。只针对**本次启动亲手创建的库**上的自证行:扫过全
39+
库的真实迁移证据不会被一次写入的观察推翻。
40+
41+
**记忆化的第二张脸也一并修了。** 首启之所以「看起来是绿的」,一半靠的是进程内正好
42+
缓存了 `false``sys_migration` 在 kernel init 期间才注册,而第一条写可能赶在它之
43+
前 —— 那次读根本没读到账本,却被当成结论冻结了一整个进程的姿态。现在区分两种否定:
44+
**问过了、账本说不**(结论,照旧缓存)与**根本问不到**(未注册 / 查询抛错 —— 依旧
45+
`false`,闸依旧关着,但不记住,下一次写再问一次)。代价是账本存在之前每次写多一
46+
次 registry 查表(在任何查询之前就短路),账本可读之后即止。
47+
48+
启动横幅那条 ADR-0104 建议行(`kernel:bootstrapped`)也改为直接读账本而非读记忆化
49+
结果 —— 否则一个刚刚自证成功的新部署会被告知去跑一条已经不需要跑的迁移。
50+
51+
## 对既有部署的影响
52+
53+
- 数据本来就合规的新部署:行为不变,照旧 born-migrated,启动即 strict。
54+
- 种子数据不合规的新部署:**不再**发出那张假证书。首启与之后每一次启动一致地停在
55+
warn-first,并且每次都告诉你是哪一个值、跑哪条命令。数据本身该怎么修还是怎么修
56+
(showcase 的 `cover` 种子值在 #4774 单独跟踪)。
57+
- 已经跑过 `os migrate … --apply` 的部署:完全不受影响 —— 扫描得来的证据不经由本
58+
次改动的任何路径改写。
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
'@objectstack/objectql': minor
3+
---
4+
5+
**A declarative hook `condition` can now express a TRANSITION: the CEL scope binds `previous` alongside `record` (#4784).**
6+
7+
The condition gate evaluated against a single root — `{ record }`. Both published skill
8+
docs, however, taught the `previous` form: `objectstack-formula` §5 ("Update hook
9+
condition — `previous` vs `record`") gives
10+
`P\`previous.status != 'escalated' && record.status == 'escalated'\``, and its legacy
11+
migration table maps `OLD.x``previous.x` and `ISCHANGED(x)``previous.x != record.x`.
12+
Written into a hook, any of those aborted the expression with `No such key: previous`,
13+
which the gate swallowed into `false` — the hook simply never ran, leaving one WARN line.
14+
Declared ≠ delivered.
15+
16+
It became load-bearing with #4770. `record` now means the record's **state** (stored ⊕
17+
payload), so `record.done == true` is true on *every* update of an already-done row — not
18+
only the one that completed it. `showcase_audit_task_completion`'s own description says
19+
"after a task transitions to done", and there was no way to write that. Now there is:
20+
21+
```ts
22+
condition: P`previous.done != true && record.done == true`
23+
```
24+
25+
`previous` is built exactly as the validation side builds it (#4649), through the shared
26+
`materializeDeclaredFields` helper, so one CEL expression means one thing on both
27+
surfaces:
28+
29+
- **the stored pre-write row**, made **total over the object's DECLARED fields** — a
30+
column the driver never returned reads as `null` instead of aborting the expression;
31+
- **declared fields only**`previous.dnoe` stays unevaluable, so a typo is still
32+
reported rather than quietly answered;
33+
- **copied, never mutated in place.** `ctx.previous` is the engine's own pre-image object,
34+
observed by every after-hook; the materialised `null`s do not leak into it.
35+
36+
**Where `previous` is NOT bound** — verbatim the rule `validation/rule-validator.ts`
37+
already applies, so referencing it there makes the condition unevaluable:
38+
39+
- **insert events** (`beforeInsert` / `afterInsert`) — there is no prior state. Write
40+
insert conditions over `record` alone.
41+
- **predicate (`multi: true`) bulk updates** — one write matches N rows and the hook fires
42+
once, so there is no single prior record. Binding `{}` or `null` would answer
43+
`previous.x == null` with a fabricated fact about rows nobody read.
44+
45+
**Cost: none.** No new demand-driven fetch was introduced. `previous` rides on the prior
46+
row `engine.update` already reads whenever an afterUpdate hook is registered — the same
47+
one that feeds `ctx.previous` and record-change flow triggers. A condition that never
48+
mentions `previous` reads nothing extra, pinned by test.
49+
50+
**What you may see after upgrading:** hooks whose condition referenced `previous` never
51+
fired before and start firing now. That is the declaration finally being honoured — review
52+
any hook carrying a `previous.*` condition before you upgrade.
53+
54+
**Unchanged, deliberately:** a condition that is *still* unevaluable is logged at WARN and
55+
treated as `false`. Whether that should fail loudly instead is tracked separately.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/metadata-protocol": minor
3+
---
4+
5+
fix(metadata-protocol): `deleteMany` / `updateMany` honour `atomic` for real, or refuse it (#4620)
6+
7+
ADR-0119 D4 made `batchData`'s `atomic` flag a real guarantee. Its two siblings
8+
in the same file were out of that PR's confirmed scope and kept the defect:
9+
10+
- **`deleteManyData` was fake-atomic.** `atomic: true` opened no transaction; it
11+
only `break`-ed the loop, so every row deleted before the failure stayed
12+
**deleted** while the response called itself atomic and reported those rows
13+
`success: true`. Worse than the `batchData` case it was copied from, because a
14+
partial delete has no natural undo — a client cannot reconstruct the rows from
15+
its own request.
16+
- **`updateManyData` ignored `atomic` entirely.** The option was accepted,
17+
declared in `BatchOptionsSchema` with an all-or-nothing contract, and never
18+
read: a caller asking for atomicity silently got best-effort, with no signal.
19+
20+
Both now run the **same** atomic arm as `batchData`, extracted into one shared
21+
runner so a fourth copy of transaction handling cannot drift into a fourth lie:
22+
23+
- `atomic: true` runs the whole batch inside ONE `engine.transaction()`; the
24+
first failure rolls back every prior write.
25+
- A rolled-back batch reports **zero successes**. Rows that had succeeded are
26+
marked `ROLLED_BACK: record <i> failed — <cause>`, rows never reached are
27+
`NOT_ATTEMPTED: atomic batch aborted by record <i>`, and the causal row keeps
28+
its own error — so a client can tell "attempted, undone" from "never ran".
29+
- `atomic` outranks `continueOnError`, whose contract text already scoped it to
30+
`atomic=false`.
31+
32+
**Behaviour change to be aware of:** a runtime that cannot roll back (no
33+
`engine.transaction()`, or a default driver without `beginTransaction`) now
34+
**refuses** an `atomic: true` `deleteMany` / `updateMany` with `501
35+
NOT_IMPLEMENTED` instead of silently running best-effort — the same fail-closed
36+
gate `batchData` uses. That silent downgrade is the defect class this fixes; if
37+
you want best-effort, ask for it (`atomic: false`, or omit the option), or probe
38+
the runtime's transaction support before sending. Non-atomic behaviour of both
39+
endpoints — including the `continueOnError` interaction and their response
40+
shapes — is unchanged.

examples/app-showcase/src/data/hooks/index.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,20 @@ export const NormalizeTaskTitleHook = {
3838
description: 'Trims leading/trailing whitespace from the task title before every write.',
3939
};
4040

41-
/** afterUpdate (gated) — log a line whenever a task flips to done. */
41+
/**
42+
* afterUpdate (gated) — log a line on the update that flips a task to done.
43+
*
44+
* The condition compares against `previous` on purpose (#4784). Since #4770
45+
* `record` means the record's STATE, so `record.done == true` alone would audit
46+
* every later edit of an already-done task — while this hook's own description
47+
* says "transitions to done". The transition is the two-root form.
48+
*/
4249
export const AuditTaskCompletionHook = {
4350
name: 'showcase_audit_task_completion',
4451
label: 'Audit Task Completion',
4552
object: 'showcase_task',
4653
events: ['afterUpdate'] as LifecycleEvent[],
47-
condition: "record.done == true",
54+
condition: "previous.done != true && record.done == true",
4855
body: {
4956
language: 'js' as const,
5057
source: "var r = ctx.result || ctx.input || {}; ctx.log.info('task completed: ' + (r.title || r.id || 'unknown'));",

packages/metadata-protocol/src/protocol.delete-many.test.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -145,15 +145,19 @@ describe('deleteManyData — partial-failure semantics (#3897)', () => {
145145
expect(res).toMatchObject({ success: false, total: 3, succeeded: 2, failed: 1 });
146146
});
147147

148-
it('atomic aborts the remaining ids on the first failure', async () => {
148+
// [#4620] This used to pin the fake-atomic: `atomic: true` merely broke the
149+
// loop, so `a` stayed DELETED and the response reported `succeeded: 1` under
150+
// a flag whose one job is to guarantee it was undone. On this engine — no
151+
// `transaction()` at all — the honest answer is a refusal, not a half-batch.
152+
// Real rollback is pinned in protocol.many-data-atomic.test.ts.
153+
it('atomic REFUSES on an engine that cannot roll back, deleting nothing (#4620)', async () => {
149154
const { p, del } = failOn('b');
150-
const res: any = await p.deleteManyData({
155+
await expect(p.deleteManyData({
151156
object: 'invoice',
152157
ids: ['a', 'b', 'c'],
153158
options: { atomic: true, continueOnError: true },
154-
} as any);
159+
} as any)).rejects.toMatchObject({ status: 501, code: 'NOT_IMPLEMENTED' });
155160

156-
expect(del).toHaveBeenCalledTimes(2);
157-
expect(res.succeeded).toBe(1);
161+
expect(del).not.toHaveBeenCalled();
158162
});
159163
});

0 commit comments

Comments
 (0)