Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .changeset/bulk-write-before-hook-per-row-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
"@objectstack/spec": minor
---

feat(spec): 定形 `multi: true` 批量写在 `before*` 阶段的按行 hook 契约(#6462)

#5574 的维护者裁决(2026-08-06,方案 B)把「批量写按行语义为平台契约」(#4800 /
#4862,after 侧已由 #5038 交付)延伸到 `before` 型 hook,并指定 **contract-first
拆分:spec 契约子单先行,engine 实现随后**。本次变更是该拆分的 spec 半边 —— 只落
契约、pin 测试与 ADR 附录,**engine 一行未动**。

**为什么需要这条契约。** 谓词写路径上 `ctx.previous` 在 before 阶段从未被绑定,于是
每一个按守卫写法写出来的 hook —— `if (ctx.previous?.locked) throw` —— 在批量写上
静默放行。hotcrm 实测:一次批量编辑绕过全部 15 个守卫 hook,把单行路径会拒绝的
`readonly: true` 字段写成 `null`。失效方向是 fail-open,而让它静默的可选链正是 AI
会写出的形状。

**新增契约面** `@objectstack/spec/data` → `bulk-write-hook-conformance.ts`:

- `BULK_WRITE_HOOK_DISPATCH_CONTRACT` —— 四个写事件的按行派发表(before/after ×
update/delete),逐条声明 per-row 上下文携带哪些键、载荷作用域,以及 **`delivered`
标记**:after 半边由 #5038 交付(`true`),before 半边为已裁未交(`false`,
engine 半边 = #5574 engine 卡)。契约先行必须能被读出「尚未交付」,否则它就是一次
declared ≠ enforced。
- `MAX_BULK_PER_ROW_HOOK_ROWS` / `BULK_PER_ROW_HOOK_LIMIT_ERROR_CODE` /
`resolveBulkPerRowHookBudget()` —— `assertBulkPerRowHookBudget` 先例的契约级表述:
**两个阶段共用一个上限**,超限在**首次按行派发之前**整单拒绝(什么也不写、一个
handler 也不跑),永不降级成「整批一次派发」。

**载荷可改写语义(裁决必答项 1)的答案是:载荷仍然只有一份,作用域是整批。** 每个
per-row 上下文拿到的是**同一个** payload,而不是逐行副本 —— 于是 N 份载荷不可能分叉,
没有合并步骤,谓词写永远不会被拆成 N 次单行写(仍是一次 `updateMany`、一个受影响行数
#4639)。逐行副本 + 「一致则合并、分叉则拒绝」这条更显然的路线被实测证据否掉:objectql
自带的 `sys_stamp_audit_update` 注册在 `'*'` 上,且在**每行**的 stamp 内部读
`new Date()`,跨毫秒的两行 `updated_at` 天然不同 —— 该规则会非确定性地拒绝正常批量写。

**行为不变。** 本次不改任何 Zod schema 的接受面,不新增可授权键,不动 engine:今天能
通过校验的元数据,改动后逐字节仍然通过。`hook.zod.ts` 的 `input` 形状表继续描述引擎
**当前**的构造(由 objectql 对真实派发钉住),只是补了一条指向新契约的前瞻说明 ——
表与引擎不允许抢跑,这正是 #5273 的教训。

ADR-0058 新增 **Addendum II**,同时收纳裁决指定的 #5748 半边(`data.id` 与
`where.id` 统一走标量测试,已由 PR #5919 交付),并把 Addendum I 中「`before*` hooks
are NOT per row」一段标记为 **SUPERSEDED**(原文保留 —— 被推翻的决策本身是记录)。
155 changes: 154 additions & 1 deletion docs/adr/0058-expression-and-predicate-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,13 @@
> Falling back to one dispatch for the batch would skip the hook for N-1 rows
> silently — the failure shape this whole family exists to abolish.
>
> **`before*` hooks are NOT per row, and that is not a version gap.** A
> **`before*` hooks are NOT per row, and that is not a version gap.**
> ⚠️ **SUPERSEDED by Addendum II below (#5574, ruling B).** The maintainer
> reversed the "not a version gap" half on measured evidence: `before*` hooks
> ARE dispatched per row on a predicate write. What survives verbatim is the
> reason given here for the payload — one `updateMany` carries one payload —
> which Addendum II keeps as its D3 rather than overturning. Kept in place, not
> rewritten, because a reversed decision is a record (Prime Directive #13). A
> `beforeUpdate` / `beforeDelete` fires once for the whole batch because it may
> still rewrite the payload, and one `updateMany` carries one payload — there is
> nothing per-row to hand it. So #5037's `HookConditionError` and its
Expand All @@ -142,6 +148,153 @@

---

> **Addendum II (2026-08, #5748 / #5574 / #6462) — BULK-WRITE, part two: which
> writes ARE bulk writes, and what a bulk write does to the `before*` phase.**
> _Contract recorded here. The `data.id` half is **implemented** (#5748 / PR
> #5919). The `before*` half is the **spec** side of a deliberate contract-first
> split (#6462); the engine side is #5574's engine card, `Blocked-by` it._
>
> Addendum I answered "what is a record-scoped declaration evaluated over when
> one write touches N rows" for the `after*` phase. Two questions it left are
> answered here, because a maintainer ruled on both on 2026-08-06 and directed
> them into one appendix.
>
> ---
>
> ### Part A — which writes are bulk writes (#5748, delivered)
>
> `ObjectQL.update(object, data, options)` took its id from two sources with two
> different rules: `where.id` went through a scalar test (an operator object, an
> array or `null` is a predicate, not an id), while `data.id` went through none
> at all and outranked both `where` and an explicit `options.multi`. So
> `update(o, { id: { $in: ['a','b'] }, title: 'x' }, { multi: true })` bound a
> serialized operator object as a **primary key** and silently discarded the
> declared bulk intent.
>
> **The decision (ruling A, 2026-08-06).** One scalar test, defined once and
> reused on both sides: a non-scalar `data.id` is not an id, so the same call
> now dispatches to `updateMany` and honours the declared `multi: true`. Route B
> (reject non-scalar `data.id` outright) was considered and refused — it would
> have made "the author put the predicate in the wrong slot" fatal for a call
> whose intent is unambiguous once the two halves agree. The concern behind B
> was converted into a required test instead, and #5919 landed 16 assertions of
> it: a non-scalar `data.id` **without** `multi: true` rejects with zero driver
> calls, so a typo is never silently promoted into a real batch write.
>
> Two consequences landed after it, same rule one layer on: a value already
> ruled not-a-primary-key does not get to sit in the primary-key column of the
> SET clause either — stripped with a warning on the predicate branch (#6262)
> and on the by-id branch (#6435).
>
> Why this belongs in the bulk-write appendix and not in a dispatch ADR of its
> own: everything Addendum I says is conditioned on a write BEING a predicate
> write. A rule that decides that question silently, and differently depending
> on which slot the caller used, is the entry to this whole surface.
>
> ---
>
> ### Part B — the `before*` phase is per row too (#5574 ruling B; spec half #6462)
>
> **What Addendum I got right and what it got wrong.** It reasoned: one
> `updateMany` carries one payload, a `before*` hook may rewrite the payload,
> therefore there is nothing per-row to hand it, therefore the dispatch is
> batch-scoped. The premise is true; the conclusion does not follow. The payload
> is not the only thing a `before*` handler reads — `previous` is — and binding
> a per-row pre-image needs no per-row payload at all.
>
> **The measured harm (#5574).** On the predicate path `ctx.previous` was never
> assigned in the before phase, so every guard hook written the way guards are
> written — `if (ctx.previous?.locked) throw` — passed silently. A hotcrm
> deployment measured all **15** of its guard hooks bypassed by one batch edit,
> including writing `null` into a `readonly: true` field that the single-id path
> refuses. The failure direction is fail-OPEN, and the optional chaining that
> makes it silent is exactly what an AI writes. The alternative on the table
> (option A: document the limitation) was refused for that reason — it puts the
> hole in the manual and leaves it in the product.
>
> **The decision.** A predicate write dispatches `beforeUpdate` / `beforeDelete`
> **once per matched row**, on a single-record-shaped context, replacing the
> single batch dispatch — the same move #5038 made for `after*`, held to the
> same yardstick. The full clause set, with the reasoning that does not fit
> here, is `packages/spec/src/data/bulk-write-hook-conformance.ts` (D1–D7), and
> `BULK_WRITE_HOOK_DISPATCH_CONTRACT` is that table machine-readable, carrying a
> `delivered` flag per event so the contract-first gap cannot read as delivered.
> The load-bearing clauses:
>
> - **Per-row context (D1/D2).** `input.id` names the row, `previous` is its
> pre-image, `input.options` is still the caller's bag (the PHASE rule is
> unchanged), `result` stays absent — the before phase has no post-state.
> Zero matched rows is zero dispatches.
> - **The payload stays BATCH-scoped, and that IS the merge rule (D3).** Every
> per-row context carries the one payload, not a copy. A rewrite therefore
> applies to the whole batch whoever made it, rewrites accumulate in dispatch
> order, N post-hook payloads cannot diverge, nothing is reconciled, and no
> predicate write is ever split into N single-row writes. One `updateMany`,
> one affected count (#4639), one aggregate `data.records.updated`. A rewrite
> *conditioned* on the row is out of contract: it widens to every matched row
> rather than scoping itself. Per-row `previous` is supplied so a guard can
> REFUSE, not so a rewrite can be aimed.
> - **`input.id` stops being a reroute lever, on this path only (D4).** A
> per-row context arrives with `id` already bound and the dispatch decided, so
> rebinding it retargets nothing; it is refused rather than ignored, because a
> silent no-op is the failure this family exists to abolish.
> - **One ceiling, both phases (D6).** `MAX_BULK_PER_ROW_HOOK_ROWS` (10 000)
> governs `before*` exactly as it governs `after*`, and the check runs before
> the FIRST per-row dispatch — so an over-ceiling batch runs zero handlers and
> writes nothing, rather than running 10 001 and then throwing. Still a
> refusal, never a downgrade. `resolveBulkPerRowHookBudget` is the rule,
> executable, and the engine half replaces its open-coded copy with it.
> - **One read, reused (D7).** The row set is read once, with the composed AST
> the write binds, and serves validation (#3106), the `readonlyWhen` strip
> (#3042) and both per-row dispatches. The ruling forbids a second fetch in as
> many words.
>
> **Why per-row payload COPIES plus a reconciliation rule was rejected.** It is
> the obvious alternative and it is defeated by a measured fact, so the evidence
> is recorded rather than left to be rediscovered: objectql's own
> `sys_stamp_audit_update` builtin is registered on `'*'` and reads
> `new Date().toISOString()` **inside** the per-record stamp. Under per-row
> dispatch that is one clock read per row, so rows either side of a millisecond
> boundary carry different `updated_at` values — a converge-or-refuse rule would
> refuse honest batches non-deterministically, and a converge-or-split rule
> would shatter one `updateMany` into N writes for the same reason. Beside that,
> nothing measured needs divergent payloads: every `beforeUpdate` payload
> rewrite in the repo (the audit stamp, plugin-pinyin-search's companion
> projection, service-storage's copy-on-claim) is row-invariant. Refusing
> divergence also stays reversible in the safe direction — a later ADR can relax
> it, while a write that has learned to split itself cannot be un-split.
>
> **The consequences, priced as this appendix's predecessor requires.**
>
> - **`bulk_write_previous_unbound` becomes unreachable.** #5038 retired
> `HookConditionError` for after-type hooks and Addendum I kept it alive
> "rescoped to the before dispatch". Once the engine half lands there is no
> dispatch left without a bound `previous`, and `isPredicateBulkWrite`
> (`hook-wrappers.ts`) — whose whole test is "no `input.id` and `multi`" —
> answers `false` for the before phase too. Both `HookConditionLimitation`
> members then have neither producer nor reachable consumer, which is an
> ADR-0049 enforce-or-remove item for the engine card, not a gap.
> - **The demand becomes effectively universal, and that is #5846's bill.** The
> engine gates the row-set read on `hasHooksFor('beforeUpdate', object)`, and
> objectql registers `sys_stamp_audit_update` and `sys_fetch_previous_update`
> on `'*'` — so the gate is true for every object wherever the plugin is
> loaded. Making those builtins express in their registration what their
> handlers already decide at run time is #5846's, already scoped there.
> - **The dispatch ladder must be resolved BEFORE the before phase**, since the
> row set has to be read to build the per-row contexts. That reorders a seam
> #5846 also owns (its (a) direction moves the prior read ahead of
> `beforeUpdate` and binds it), and it interacts with one existing capability
> — a `beforeUpdate` handler that CLEARS `input.id` on a by-id call currently
> converts the write into a predicate write. Deliberately **not** settled
> here: it is a live-behaviour question on a seam another card owns, and this
> appendix will not presume the answer. The engine half and #5846 settle it
> together, in one edit to one ordering, and record it as an amendment.
> - **`scripts/adr-anchors.json`'s `hook-wrappers.ts` invariant still describes
> the batch dispatch.** It is TRUE today and must move with the engine half,
> not before it.

---

## TL;DR

ObjectStack exposes **~50 authorable declarations** that hold an expression — formulas, visibility/required/readonly predicates, validation rules, hook conditions, flow/edge conditions, sharing-rule conditions, RLS `using`/`check`, action/view/app visibility, notification/ETL/export/sync/connector conditions — and they all funnel through **one authoring primitive** (`ExpressionInputSchema` → `{ dialect: 'cel', source }`, helpers `cel`/`F`/`P`). The authoring surface is already unified and clean.
Expand Down
8 changes: 8 additions & 0 deletions packages/spec/api-surface/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,16 @@
"AutonumberToken (type)",
"BOOLEAN_VALUE_TYPES (const)",
"BUILTIN_DRIVER_IDS (const)",
"BULK_PER_ROW_HOOK_LIMIT_ERROR_CODE (const)",
"BULK_WRITE_HOOK_DISPATCH_CONTRACT (const)",
"BaseEngineOptions (type)",
"BaseEngineOptionsSchema (const)",
"BaseValidationRuleShape (interface)",
"BuiltinDriverId (type)",
"BulkPerRowHookBudgetVerdict (type)",
"BulkWriteHookDispatchContractEntry (interface)",
"BulkWriteHookPhase (type)",
"BulkWritePayloadScope (type)",
"CALENDAR_DATE_TYPES (const)",
"CLOCK_TIME_TYPES (const)",
"COMPUTED_VALUE_TYPES (const)",
Expand Down Expand Up @@ -299,6 +305,7 @@
"LocationCoordinatesSchema (const)",
"LocationValueSchema (const)",
"LogicalOperatorKey (type)",
"MAX_BULK_PER_ROW_HOOK_ROWS (const)",
"MEASURE_FIELD_TYPES (const)",
"MULTI_CAPABLE_TYPES (const)",
"MULTI_OPTION_TYPES (const)",
Expand Down Expand Up @@ -606,6 +613,7 @@
"referenceTargetOf (function)",
"referencedFields (function)",
"renderAutonumber (function)",
"resolveBulkPerRowHookBudget (function)",
"resolveCrudAffordances (function)",
"resolveDisplayField (function)",
"resolveDriverId (function)",
Expand Down
Loading
Loading