发现于 #769(PR #776)的实测过程。#769 是 view label 与 filter 一致性问题,这一条是跨对象的 hook 语义缺陷,越界,单独记录。
事实(pinned 17.0.0-rc.2,真 object schema + 真 hook + sys_fetch_previous_update 内建的忠实复刻)
引擎内建 sys_fetch_previous_update(priority: 5,beforeUpdate,object: '*',由 kernel 服务的 registerAuditHooks() 装载)是 ctx.previous 在 beforeUpdate 上的唯一来源 —— 引擎主体只在 afterUpdate 分支里赋 hookContext.previous。而这个内建的第一行是:
if (hookCtx.input?.id && !hookCtx.previous) { … }
multi: true 的批量更新没有 input.id(目标由 where 圈定),所以该内建取不到任何东西,ctx.previous 保持 undefined。用一个 priority 299 的探针 hook 观测(它排在 knowledge_article_publish_timestamps 的 300 之前):
=== single-row update (category only) ===
[prev-fetch] input.id="crm_knowledge_article-…-1" previous=undefined
[knowledge-hook sees] previous.status="published" input={"id":"…","category":"api"}
=> last_reviewed_at refreshed: true
=== MULTI update (category only, every published row) ===
[prev-fetch] input.id=undefined previous=undefined
[knowledge-hook sees] previous.status=undefined input={"category":"troubleshooting"}
=> B.category=troubleshooting last_reviewed_at refreshed: false
=== MULTI update writing last_reviewed_at: null ===
[prev-fetch] input.id=undefined previous=undefined
[knowledge-hook sees] previous.status=undefined input={"last_reviewed_at":null}
=> A: last_reviewed_at=null
=> B: last_reviewed_at=null
单行路径一切正常;批量路径上 hook 拿到的 previous 是空的。
影响面
src/objects/*.hook.ts 里有 15 个文件读 ctx.previous。它们几乎都用 previous?.x 的可选链写法,所以在批量路径上不会抛错 —— 而是静默地什么都不做,或者走进「当作插入/当作首次转换」的分支。已确认的两种具体后果(均在 crm_knowledge_article 上实测):
- 批量编辑不会把文章标记为已复核。
knowledge_article_publish_timestamps 的注释写着「On any subsequent edit while published, refreshes last_reviewed_at so admin "stale article" reports work」—— 在批量路径上这句不成立。
- 批量写能把已发布文章的
last_reviewed_at 写成 null,readonly: true 不拦(单行路径上 hook 会补戳盖掉,批量路径上不会)。于是数据里出现「已发布 + 从未复核」的行 —— 而批量导入正是一家公司把既有知识库搬进 CRM 的常规方式,这批行恰恰是最需要复核的。
第 2 点是 #769 最终没有给 stale_articles 加 180 天窗口的直接依据(见 PR #776 的实测第 2 条):$lt 不命中空值,而 view filter 语法写不出「早于窗口或为空」的析取,加窗会把这批导入行一条不剩地藏掉。
其余 14 个 hook 尚未逐个核对,但凡是靠 previous 判断状态迁移的(campaign.hook 的 previous?.status === 'completed'、contract.hook 的 previous?.status === 'activated' 守卫、case.hook 的 becameClosed、opportunity 的赢单/丢单捕获等)都在同一条船上:批量路径上迁移判定退化为「没有迁移」或「首次迁移」。contract.hook 那条尤其值得先看 —— 它是守卫语义(if (event === 'beforeUpdate' && previous?.status === 'activated')),守卫在批量路径上失效的方向是放行。
归属
引擎侧的行为(input.id 缺失时不取 previous)大概率是平台的设计取舍而非 bug —— 批量路径要为 N 行取 N 份 previous,代价与语义都需要平台定夺;引擎自己在 needsPriorRecord(updateSchema) 为真时确实会取 priorRows(用于校验规则),只是没有把它喂给 hook。所以这一条可能需要 upstream 配合,本仓能先做的是:
- 逐个核对 15 个 hook 在
previous === undefined 下的行为,把「静默不做」改成显式、可预期的语义;
- 或者在本仓约定「批量写不经过业务 hook 语义」,并把依赖 previous 的不变量挪到别处(校验规则 / flow)。
两条路都是行为决策,需要先定夺再动手。
复现
在 worktree 里跑(需要 @objectstack/objectql + driver-memory):绑定一个 sys_fetch_previous_update 的复刻(priority 5 / beforeUpdate / object *)与真实 knowledge_article.hook,插入一条 status: 'published' 的文章,然后
api.object('crm_knowledge_article').update({ last_reviewed_at: null }, { where: { status: 'published' }, multi: true }),读回即为 null。PR #776 的探针脚本可照抄(未入库,按越界规则删除)。
发现于 #769(PR #776)的实测过程。#769 是 view label 与 filter 一致性问题,这一条是跨对象的 hook 语义缺陷,越界,单独记录。
事实(pinned 17.0.0-rc.2,真 object schema + 真 hook +
sys_fetch_previous_update内建的忠实复刻)引擎内建
sys_fetch_previous_update(priority: 5,beforeUpdate,object: '*',由 kernel 服务的registerAuditHooks()装载)是ctx.previous在beforeUpdate上的唯一来源 —— 引擎主体只在afterUpdate分支里赋hookContext.previous。而这个内建的第一行是:multi: true的批量更新没有input.id(目标由where圈定),所以该内建取不到任何东西,ctx.previous保持 undefined。用一个 priority 299 的探针 hook 观测(它排在knowledge_article_publish_timestamps的 300 之前):单行路径一切正常;批量路径上 hook 拿到的
previous是空的。影响面
src/objects/*.hook.ts里有 15 个文件读ctx.previous。它们几乎都用previous?.x的可选链写法,所以在批量路径上不会抛错 —— 而是静默地什么都不做,或者走进「当作插入/当作首次转换」的分支。已确认的两种具体后果(均在crm_knowledge_article上实测):knowledge_article_publish_timestamps的注释写着「On any subsequent edit while published, refresheslast_reviewed_atso admin "stale article" reports work」—— 在批量路径上这句不成立。last_reviewed_at写成 null,readonly: true不拦(单行路径上 hook 会补戳盖掉,批量路径上不会)。于是数据里出现「已发布 + 从未复核」的行 —— 而批量导入正是一家公司把既有知识库搬进 CRM 的常规方式,这批行恰恰是最需要复核的。第 2 点是 #769 最终没有给
stale_articles加 180 天窗口的直接依据(见 PR #776 的实测第 2 条):$lt不命中空值,而 view filter 语法写不出「早于窗口或为空」的析取,加窗会把这批导入行一条不剩地藏掉。其余 14 个 hook 尚未逐个核对,但凡是靠
previous判断状态迁移的(campaign.hook的previous?.status === 'completed'、contract.hook的previous?.status === 'activated'守卫、case.hook的becameClosed、opportunity的赢单/丢单捕获等)都在同一条船上:批量路径上迁移判定退化为「没有迁移」或「首次迁移」。contract.hook那条尤其值得先看 —— 它是守卫语义(if (event === 'beforeUpdate' && previous?.status === 'activated')),守卫在批量路径上失效的方向是放行。归属
引擎侧的行为(
input.id缺失时不取 previous)大概率是平台的设计取舍而非 bug —— 批量路径要为 N 行取 N 份 previous,代价与语义都需要平台定夺;引擎自己在needsPriorRecord(updateSchema)为真时确实会取priorRows(用于校验规则),只是没有把它喂给 hook。所以这一条可能需要 upstream 配合,本仓能先做的是:previous === undefined下的行为,把「静默不做」改成显式、可预期的语义;两条路都是行为决策,需要先定夺再动手。
复现
在 worktree 里跑(需要
@objectstack/objectql+driver-memory):绑定一个sys_fetch_previous_update的复刻(priority 5 / beforeUpdate / object*)与真实knowledge_article.hook,插入一条status: 'published'的文章,然后api.object('crm_knowledge_article').update({ last_reviewed_at: null }, { where: { status: 'published' }, multi: true }),读回即为 null。PR #776 的探针脚本可照抄(未入库,按越界规则删除)。