Skip to content

perf(objectql): update() 单 id 前置行门按对象判定需求(#5284),并校准 #4743 事实一的三处注释 - #5850

Merged
baozhoutao merged 1 commit into
mainfrom
claude/issue-5284-per-object-prior-gate
Aug 6, 2026
Merged

perf(objectql): update() 单 id 前置行门按对象判定需求(#5284),并校准 #4743 事实一的三处注释#5850
baozhoutao merged 1 commit into
mainfrom
claude/issue-5284-per-object-prior-gate

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Fixes #5284
Closes #4743

改了什么

packages/objectql/src/engine.ts,update() 单 id 分支的前置行门,由

needsPriorRecord(updateSchema) || (this.hooks.get('afterUpdate')?.length ?? 0) > 0

改为按对象问:

const wantsPriorRecord =
  needsPriorRecord(updateSchema as any) ||
  this.hasHooksFor('afterUpdate', object) ||
  this.getSummaryDescriptors(object).length > 0;

前提已核实有效:origin/main 上全局门原样保留(#5754 的 update 派发重构没有动它),而同文件的批量路径早就用 hasHooksFor('afterUpdate', object) 问同一个问题 —— 一个文件里两种精度,这次统一到窄的那一侧。

新门的三项需求 = priorRecord 在这条分支上的全部消费方,逐个核过:

  1. needsPriorRecord(schema) —— 对象校验规则,以及它涵盖的 readonlyWhen / requiredWhen / 选项可见性字段谓词;
  2. 本对象afterUpdate hook —— handler 与声明式 condition 都读 hookContext.previous(hasHooksFortriggerHooks 的过滤逻辑一致:不带 object 或写 '*' 的注册照样命中每个对象);
  3. 本对象被 roll-up summary 聚合 —— recomputeSummaries(object, result, priorRecord, ...)previous 里的父 id 重算子记录离开的那个父。

第 3 项是收窄必须带上的:没有它,「子记录改挂父记录时重算旧父」这件今天只是偶然成立(靠别的对象存在 afterUpdate hook 顺带捞到前置行)的事情,会在按对象化之后彻底失效,把一次多余的读换成一个静默过期的汇总值。带上它之后,这条路径第一次自己声明需求 —— 也顺手修好了「部署里一个 afterUpdate hook 都没有时旧父不重算」的既有缺口。

⚠️ #4784 预告的陷阱:实测方向与 issue 描述相反,beforeUpdate 计入

issue 正文(与派发要点)要求把「本对象任一 update 侧 hook,before/after 皆算」计入,理由是「只有 beforeUpdate 且 condition 读 previous 的对象会失去前置读,按 #4775 fail-loud 打回」。实测把这条推理证伪了,证据是 engine.ts 自身的时序:

  • triggerHooks('beforeUpdate') 在 5480 行(收窄前行号),前置行读在 5533 行,hookContext.previous 的赋值在 5726 行(写入之后);
  • 也就是说 update() 的这次读永远到不了 before 阶段。探针在未改动的 origin/main 上跑过(bare engine,对象 B 有 afterUpdate hook 让全局门为真):beforeUpdatectx.previous === undefined;一个 previous.done != true && record.done == truebeforeUpdate condition 今天就已经hook 的 condition 求不出值时:全局 fail loud —— 抛错并中断该次操作(方案 B 已拍板;Blocked-by #4770) #4775 打回(Unknown variable: previous ... not bound for this operation),与门是否为真无关。

所以把 beforeUpdate 计入这道门,买到的是一次没有读者的读;而且它会把 hook-condition-previous-scope.test.ts 里那条现存的 pin(「只有 record-only before-hook condition 的对象不读任何前置行」= 0 次 findOne)直接压红 —— 那条 pin 不能删断言换绿,它现在反而更承重了:它钉的正是「beforeUpdate 不计入」。

delete 侧(#5272)之所以计入 beforeDelete,是因为那条路径先读、后派发、当场绑定,before 阶段是真读者。两侧不对称是时序不同,不是遗漏,已写进代码注释。

那么 kernel 里的 beforeUpdate hook 为什么能读到 previous? 来自另一个生产者:plugin.ts 的内建 sys_fetch_previous_update(object: '*',priority 5)自己做了一次 ql.findOne。它不受本门约束,所以收窄拿不走任何人的绑定 —— 新测试直接驱动了这个形状来证明两者不打架。顺带发现同一行前置状态在装了 plugin-audit 的部署里被读 3 次(内建 + audit 的 captureBefore + 引擎本门),已按 PD #10 平铺记录为 #5846,未在本 PR 内修

省下多少 —— 诚实的口径

取决于 hook 怎么注册,不取决于有多少个:

也就是说:issue 里「plugin-audit 一旦启用,平台范围每次单记录 update 都吃到这次读」这句话在现象上成立,但归因需要修正 —— 它不是「在多对象上注册」,而是全局注册;因此本 PR 单独并不能让那类部署省下这次读,它把门修正确,#5846 负责让收益兑现。

反向验证(方向是先预测、后运行)

预测:把门改回全局形态,只有两条新 pin 变红,其余全绿(尤其两条 beforeUpdate 用例必须保持绿,因为它们钉的是时序而不是门)。实测完全一致:

× object A pays NO prior read while only object B has an afterUpdate hook
  AssertionError: expected 1 to be +0
× a roll-up summary keeps its OLD-parent recompute with no hooks registered anywhere
  AssertionError: expected +0 to be 1
Tests  2 failed | 28 passed (30)

测试

新增 packages/objectql/src/engine-update-prior-read-scope.test.ts(8 例,全部通过真实引擎 + 计数 stub driver;findOne 按对象计数,因为改挂父记录时 #4441 的悬空检查会读对象一次,总数会把两件事混在一起):

  • 对象 B 有 afterUpdate 时,对象 A 的单 id update 不再多付 findOne(0);
  • 有 hook 的那个对象照付(1),且 previous 就是库里那一行;
  • object: '*' 的注册仍然让每个对象都读(1)—— 松了只是多查一次,紧了会漏掉本要触发的 hook;
  • 没有任何 hook 时 needsPriorRecord 仍强制读,且 readonlyWhen 真的按存储态把改名丢掉(次数与效果一起断言:读了却没用会过第一条、挂第二条);
  • 没有任何 hook 时 roll-up 的旧父重算仍然成立(两个父都对);
  • beforeUpdate 在前置行已在手时依旧观察到 previous === undefined,而同一次写的 afterUpdate 拿到完整行;
  • previous.*beforeUpdate condition 在「读了」与「没读」两种配置下同样被拒 —— 判决权在时序不在门;
  • 由更早的 before-hook(内建 sys_fetch_previous_update 的形状,经 ctx.ql 取行)供给的 previous 不被收窄干扰:condition 正常求值,引擎不再追加自己的读(总计 1 次,就是 hook 自己那次)。

hook-condition-previous-scope.test.ts:一条断言未删,只把描述旧门的那段文档注释校准到新门,并写明它的第一条 pin 现在更承重(它驱动的对象带一个 beforeUpdate hook,正是新门刻意不计入的那类)。

命令与结果(容器级验证锁串行,--maxWorkers=2):

pnpm --filter @objectstack/objectql test    → Test Files 126 passed (126) / Tests 2073 passed (2073)
pnpm --filter @objectstack/objectql typecheck → tsc --noEmit(无输出)

消费半径(引擎 hook 面的下游)逐个跑过,全绿:

plugin-audit           7 files / 112 tests
plugin-sharing        13 files / 347 tests
service-automation    64 files / 762 tests
trigger-record-change  5 files /  55 tests
runtime              101 files / 1458 tests

门禁:node scripts/check-nul-bytes.mjs OK;node scripts/check-engine-double-contract.mjs OK(新测试用的是真引擎 + 假 driver,不构成 engine double)。

Rider:#4743 事实一的三处注释校准(不改任何行为)

同文件纯注释,按 2026-08-03 裁决与其后的追记执行:

  1. assertReferencesResolvereadonly 收窄的紧邻注释:改写为陈述独立成立的原则(这个检查只回答「调用方点名的引用」;非系统调用方写进 readonly 字段的值在写入前已被 stripReadonlyFields / stripReadonlyForInsert 剥掉,留下的必然是平台自己写的,本就在检查的自述范围之外;删掉这个 continue 会开始拒绝平台自身的写入),并按 PD [WIP] Add Chinese version of the documentation #13 把决定 id 留在代码里。actor ?? 'system' 哨兵降为历史引用:说明它是这条收窄被发现的方式(dogfood gate),以及 sys_metadata_history.recorded_bylookup('sys_user') 却存哨兵字符串 'system'——声明的类型与实际存的值不是一回事 #4556 拆掉它之后为什么隔壁巡检的整体跳过要重新定范围 —— 不再把收窄描述成绕 bug 的临时补丁。

本 PR 以 Closes #4743 收尾该单;事实二已由 PR #5719 交付

changeset

@objectstack/objectql patch —— 写明性能语义(何时不再多付一次读、省下多少取决于注册面)与不变的正确性保证(previous 语义、#4775 fail-loud 形态、after-hook 分发)。


Generated by Claude Code

…5284)

The gate on `update()`'s single-id prior read asked the pooled `afterUpdate`
registration list of every object, so one observed object taxed every other
object's single-id update with an extra `driver.findOne`. The bulk paths in the
same file already asked it per object (`hasHooksFor`, #5038).

The narrowed gate counts every real consumer of `priorRecord` on that branch,
and only those:

  * `needsPriorRecord(schema)` — validation rules plus the readonlyWhen /
    requiredWhen / option-visibility predicates it subsumes;
  * an `afterUpdate` hook on THIS object (global / `'*'` registrations still
    reach every object, per `hasHooksFor`'s mirror of `triggerHooks`);
  * a roll-up `summary` aggregating this object — `previous` carries the OLD
    parent id, so a repointed child recomputes both parents. That one used to
    work only incidentally, via some other object's hook.

`beforeUpdate` is deliberately NOT counted: `update()` dispatches it before the
read and binds `hookContext.previous` only after the write, so no beforeUpdate
hook can read this row however the gate is written. The binding a kernel-hosted
before-hook does see comes from the `sys_fetch_previous_update` builtin, which
makes its own read and is untouched here (the duplication is filed as #5846).

Also calibrates three stale comments in the same file (#4743 fact 1): the
`readonly` narrowing in `assertReferencesResolve` now states the principle that
holds on its own with the `'system'` sentinel as history, and
`inspectDanglingReferences` no longer advertises a "readonly skip" that #5719
replaced with the `provenance` split or a bounded-scan story that #5718 completed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
@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 8:02am

Request Review

@github-actions github-actions Bot added the size/l label Aug 6, 2026
@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.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 6, 2026
@baozhoutao
baozhoutao marked this pull request as ready for review August 6, 2026 08:14
@baozhoutao
baozhoutao added this pull request to the merge queue Aug 6, 2026
Merged via the queue into main with commit 909895d Aug 6, 2026
24 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-5284-per-object-prior-gate branch August 6, 2026 08:23
baozhoutao pushed a commit that referenced this pull request Aug 6, 2026
队列把本 PR 踢出:@objectstack/objectql 记 333,队列基实测 334。333 是在 07:41
的 main 上冻结的,而 #5802(registry.test.ts +116 行)与 #5850 在 07:52 之后才
落地。合并当前 main 后实测 335,两条增量都能逐一归因:

- +1 TS2339 在 src/registry.test.ts —— #5802 新增的 registry 测试;
- +1 TS2554 在 src/engine-update-prior-read-scope.test.ts —— #5850(#5284)
  新建的文件。

tests 125 -> 126。其余 33 条纹丝不动(总计 2018 raw errors,无一超出记录值)。

先证伪了另一种解释:同一棵树连跑两次 --re-measure,输出逐字节相同,所以不是 tsc
计数不确定,校准就是正确处置(不需要谈容差)。

顺带把队列这一面写进 MEASURED 的文档块:队列是按「合并到队首」构建的,队首会随
前面的条目落地而移动,所以重跑失败的 job 无法自愈(重跑复用同一个 merge ref,
量的还是那个旧基),唯一修法是推新提交;以及排在后面的 PR 会被连坐,红了要先撤出
队列再修。双跑证伪法也一并写下,免得下一个人重新推导。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01559M8FVm6W6vDLABL3jvdW
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/l tests tooling

Projects

None yet

2 participants