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.

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