Skip to content

refactor(objectql): update 的三分支派发抽成生产者侧唯一判定 + 门禁 update 切片 (#5480) - #5754

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-5480-update-dispatch-predicate
Aug 6, 2026
Merged

refactor(objectql): update 的三分支派发抽成生产者侧唯一判定 + 门禁 update 切片 (#5480)#5754
os-zhuang merged 3 commits into
mainfrom
claude/issue-5480-update-dispatch-predicate

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Fixes #5480

范围:issue 的第 1、2 步。第 3 步(service-automation 测试升级)不在本单 —— packages/services/** 属 services 车道,本单落地后依赖已就位、零阻塞,该车道按 #5393 的既有线索自行升级即可(crud-bulk-intent.test.ts 的文件头那段「update 没有共享判定,所以只断言 options 包」的说明,届时连同一起改写)。

前提复核(先证再做)

issue 的两条前提在 origin/main @ 488b66c 上都还成立,实测:

  • packages/objectql/src/engine.ts:5682 仍是内联字面量 throw new Error('Update requires an ID or options.multi=true'),全仓没有对应的导出常量或可复用函数;
  • scripts/check-engine-double-contract.mjs 的文件头仍把 "update's twin dispatch" 列在「刻意不覆盖」里,理由写明是「等生产者侧判定先被抽出」。

全仓 grep 该消息文本:只有 1 处 throw + 5 处注释/文档散文 + packages/lint 里的一条 dispatchNote 字符串,没有任何测试断言它。所以把文本搬进导出常量是零风险的,措辞也一字未改。

1. 生产者侧判定

新增 packages/objectql/src/engine-update-dispatch.ts,形状照抄 engine-delete-dispatch.ts:resolveEngineUpdateDispatch / assertEngineUpdateDispatch / scalarUpdateId / ENGINE_UPDATE_REJECT_MESSAGE / ENGINE_UPDATE_DISPATCH_CASES,全部从 @objectstack/objectql 导出。ObjectQL.update 自身改用它 —— 生产者与判定是同一份,否则只是第二份副本。

行为保持:三分支语义、$in 谓词判定、拒绝消息一字未改。有两处 update 与 delete 不同,判定里**照抄而非「改良」**了生产者,并在模块头和测试里写死:

  1. data.id 不做标量测试,为真即当 id,且优先于 wheremulti;
  2. 分支按真值(if (hookContext.input.id))而非 !== undefined,所以 where: { id: 0 } 不走按 id 路径。

比生产者更「聪明」的判定就是第二份意见 —— 正是 #4550 要消除的东西。第 1 点本身是缺陷,已单独归档为 #5748;修的时候两个文件一起翻面,现在那是一次编辑。

测试不是对照表,是对照真实引擎

engine-update-dispatch.test.ts 用记录型 driver 驱动真实引擎跑完 17 条用例,逐例断言引擎实际行为 == 判定裁决。

反向验证(方向先定后跑):抽取之后 observeEngine 走的是同一个判定,所以「引擎同意判定」有变成同义反复的风险。真正独立的一半是分支本身(if (hookContext.input.id) 与那条 options?.multidriver.updateMany 的判断),它不经过判定。所以预定方向是:扰动引擎分支,判定那一半应保持绿、real ObjectQL.update 那一半应变红。实测把该分支条件里的 options?.multi 一项去掉,只留 driver.updateMany:

× real engine agrees with the predicate: predicate on a non-id column, no multi → reject
× ... $in over ids, no multi ...  × array id ...  × null id ...
× falsy scalar where.id (0) ...   × empty where ...  × no options ...  × multi explicitly false ...
AssertionError: real ObjectQL.update: expected 'multi' to be 'reject'
Tests  8 failed | 15 passed (23)

8 条 reject 用例全红,且红的都是 real ObjectQL.update 断言,predicate 断言一条没红 —— 与预测一致。已还原。

2. 门禁 update 切片

SLICES 化:一个切片 = 「看替身的哪个成员」+「该成员必须够到哪个生产者判定」,其余(引擎/驱动归属、一层 helper 的间接、账本、双向对账)一份实现服务两个动词。

基线键从 file 变成 (file, verb),verb 是必填、不默认 —— 继承来的动词等于一条谁也读不出范围的豁免,而两个动词确有实质差别(data.id)。为此加了 DECLARED 不变量:条目的 verb 必须是本脚本真的会扫的切片,否则它永远对不上任何东西、变成退不掉的豁免。已负向验证(把某条改成 "updat" → 门禁 exit 1 并指名该文件)。

一处判据修正,不是为 update 特设:driver-only 成员的否决改为在任意 arity 生效。原先只在形参少于 2 个时才查,delete 侧侥幸没事(驱动的 delete 第二参数这仓一贯写作 id),update 侧则不然 —— plugin.integration.test.ts 的假驱动把 update 写成三参数箭头函数 (_o: string, _i: any, d: any),_i 就是主键只是没拼成 id,于是一个文件里 19 个驱动替身被当成引擎替身。账本里混进误报比账本窄危害更大。实测:delete 切片一条不变(108 doubles / 91 files / 27 pinned,零 RECONCILED),因为 delete 的替身本就没有 driver-only 成员。

自测扩面并已证明能红:切片隔离、假驱动的 update、零参 update 的两半证据、update 不把自己算作 sibling、objectql 自身的相对路径 import。其中最关键的一条是「在 update 体内调用 delete 的判定不算 pin」—— 第一版没有这条,我把 pinnedImportsOf 扰动成跨切片记账,自测没有红;补上这条 fixture 后同样的扰动立刻红:

x self-test: delete's predicate inside update() does not pin the update slice
check-engine-double-contract --self-test: 1 failure(s).

基线按实测填,没有 --fix 式生成:每条的 unguarded 与行号都是门禁自己打印出来的那一行解析来的;每个包的 devDependency 路线是本分支上真跑 turbo run build --filter=... --dry 量的(platform-objects 得到 WARNING Circular package dependency detected: @objectstack/metadata, @objectstack/metadata-protocol, @objectstack/objectql, @objectstack/platform-objects,service-job / plugin-reports / plugin-security / plugin-webhooks / service-datasource 无环警告),量完即还原;delete 侧账本已量过的包直接引用不重量。每条 why 都写明它不主张什么:与 #5629 的 delete 批次不同,这批没有逐文件的休眠探针,所以没有一条说「这处宽松未被走到」。

顺带把 objectql 自己的 10 个假引擎 update 钉上了(相对路径 import,本包内)—— 其中 4 个文件本就钉了 delete,「一个动词绑得上、另一个绑不上」正是本单的由来。全部套件绿,没有暴露出第二个 #4434

追加一条:合流后的 base 穿越(commit 2)

首轮 CI 在 merge-ref 上红了一条,原因不在两侧任何一方:packages/services/service-automation/src/plugin-startup-log-cause.test.ts#5738(来自 #5661)在本分支测量之后落进 main,而它落地时 main 还没有 update 切片 —— 所以它自己的 CI 不可能标记它,本账本也不可能记录它;新切片与新文件第一次相遇只发生在 merge-ref 上。

处理:git merge origin/main(未 rebase,零冲突),在合流后的树上重新实测并补一条 MEASURED DEBT 条目(line 170,1 个未钉替身)。该包已有 @objectstack/objectql devDependency,所以 pin 只是一行 —— 但文件在 packages/services/**,不在本单范围,归 services 车道与第 3 步同批做。条目同样不带休眠探针主张。

验证(合流后重跑的实测输出)

pnpm check:engine-double-contract   exit=0   (--self-test + 正式,零 x 行)
  delete doubles: 108 in 91 test file(s) — 27 pinned, 81 in the shrink-only baseline.
  update doubles: 127 in 111 test file(s) — 10 pinned, 117 in the shrink-only baseline.
  OK — 37 pinned, 165 in the DEBT ledger, 2 exempt.
pnpm check:query-options-erasure    exit=0  (ratchet holds:84 unswept sites in 19 files, none new)
node scripts/check-nul-bytes.mjs    exit=0  (5652 files, no raw ASCII control bytes)
pnpm --filter @objectstack/objectql typecheck   exit=0
pnpm --filter @objectstack/objectql test        exit=0  →  Test Files 124 passed / Tests 2044 passed

delete 侧在合流前后都是 108 / 91 / 27 且零 RECONCILED —— 本 PR 的判据修正没有让 delete 门禁的覆盖面缩水一格。另加自查(门禁扫不到的控制字符):grep -naP[\x00-\x08\x0b\x0c\x0e-\x1f] 覆盖本 PR 全部改动文件,无命中。

范围外发现(PD #10,已归档,本 PR 不修)

🤖 Generated with Claude Code

https://claude.ai/code/session_01V7WetGmnfoXNn8cLieKKmx

…producer-side predicate (#5480)

`delete` has had one since #4550 (`resolveEngineDeleteDispatch`), so any test
double standing in for the engine can import the producer's own decision and
cannot be looser than it. `update`'s identical three-way dispatch was an inline
literal throw in `engine.ts` with no exported constant and no reusable function
— so #5393 could pin its fake's `delete` to the producer and structurally could
not do the same for `update`, on a verb whose predicate branch rewrites every
matching row's fields.

- new `packages/objectql/src/engine-update-dispatch.ts`:
  `resolveEngineUpdateDispatch` / `assertEngineUpdateDispatch` / `scalarUpdateId`
  / `ENGINE_UPDATE_REJECT_MESSAGE` / `ENGINE_UPDATE_DISPATCH_CASES`, all exported
  from `@objectstack/objectql`.
- `ObjectQL.update` now uses it — producer and predicate are one copy.

Behaviour-preserving: the three branches, the `$in` predicate test and the
reject message text are unchanged. Two places where update is not delete are
copied verbatim rather than "improved", and documented as such: `data.id` is
taken without a scalar test and outranks both `where` and `multi`
(objectstack#5748), and the branch reads truthiness, so `where: { id: 0 }` is
not an identifying call.

`engine-update-dispatch.test.ts` drives the REAL engine with a recording driver
over the case set and asserts observed behaviour equals the predicate's verdict
case by case — verified non-vacuous by perturbing the engine's own branch, which
turns the `real ObjectQL.update` half red while the predicate half stays green.

check-engine-double-contract grows an `update` slice on the same criterion:
ledger entries are now keyed on (file, verb) with a required `verb` and a new
DECLARED invariant, the driver-only-member veto applies at every arity (a fake
driver spelling its key `_i` had 19 of its doubles read as engine doubles), and
the self-test covers the update slice on both sides of every decision including
cross-slice credit. Measured: delete unchanged at 108 doubles / 91 files / 27
pinned; update 126 doubles / 110 files, 10 pinned (objectql's own fakes), 116 in
the shrink-only ledger.

Fixes #5480

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V7WetGmnfoXNn8cLieKKmx
@vercel

vercel Bot commented Aug 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 6, 2026 4:21am

Request Review

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql.

13 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql)
  • content/docs/kernel/services.mdx (via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/plugins/index.mdx (via @objectstack/objectql)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql)
  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

… was measured (#5480)

Base crossing, not a regression on either side.
`packages/services/service-automation/src/plugin-startup-log-cause.test.ts`
arrived on main in #5738 (from #5661) after this branch measured the update
slice and while main still had no update slice at all — so its own CI could not
have flagged its fake engine's `update`, and this ledger could not have recorded
it. It becomes a violation only on the merge ref, where the new slice and the
new file meet for the first time.

Re-measured on the merge with origin/main rather than transcribed: 1 unguarded
engine double at line 170. The package already carries @objectstack/objectql in
devDependencies, so the pin is one line, but the file is under
`packages/services/**` — outside this PR's scope — so it goes to the services
lane's batch alongside the `crud-bulk-intent.test.ts` upgrade step 3 leaves it.
Like every other update entry, it claims no dormancy probe.

Verified on the merged tree: engine-double-contract OK (self-test + run),
delete unchanged at 108 doubles / 91 files / 27 pinned with zero RECONCILED,
update 127 / 111 with 10 pinned and 117 ledgered; query-options-erasure ratchet
holds; check-nul-bytes clean; @objectstack/objectql typecheck clean and 124
files / 2044 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V7WetGmnfoXNn8cLieKKmx
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants