feat(objectql)!: hook 的 condition 求不出值时 fail loud —— 抛错并中断该次操作 (#4775) - #4861
Conversation
…4775) A declarative hook whose `condition` could not be evaluated emitted a `logger.warn` and returned `false`, collapsing "the condition said no" and "the platform could not work out what the condition says" into one outcome — which carries opposite risks per hook kind: a `before*` guard swallowed into `false` lets through a write it was declared to stop, and an `after*` audit drops a row nobody will go looking for. Resolve it the way #4649 already resolved it for validation predicates: reject loudly, naming the hook and the key that would not resolve. `before*` and `after*` take the same direction, knowingly. A condition that never compiled aborts too, reported at invocation so one broken hook cannot wedge boot. The fault wording is shared with `rule-validator.ts` through a new `cel-fault.ts`, so two evaluators that reject a write for the same reason cannot describe it in two dialects. Predicate (`multi: true`) bulk writes get a dedicated diagnosis (#4800/B1) rather than a bare `No such key: previous`: the hook is named, the batch is explained, and the route that works is given. It deliberately does not name a record-change flow trigger as a way out — that trigger binds these same lifecycle hooks and receives the same unbound `previous` on a bulk write (verified against the engine and `trigger-record-change`). `onError`, `retryPolicy` and `async` are untouched and stay outside this gate: condition evaluation happens before the handler exists to fail, and routing it through `onError` would let `onError: 'log'` resurrect the silent skip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny
…k-condition-fail-loud
…tes (#4775) `hook-binder.test.ts` pinned the OLD uncompilable behaviour and explicitly accepted either outcome ("ignored at compile time (handler runs) or evaluated false (skipped) … just assert we didn't crash"). That latitude was the defect: "condition ignored" deleted the gate, so a hook declared to run conditionally ran on every write. It now asserts the rejection, the `uncompilable` reason and that the handler never ran. `hook-metrics.test.ts` used a BARE `name == "skipme"` condition. Hook conditions are `record`-scoped, so the bare identifier resolved to nothing and the expression faulted on every call — the "skip" the test asserted came from the old swallow (fault → warn → false), not from a condition that answered NO. This is precisely the never-actually-enforced condition #4775 was expected to flush out. Rewritten as `record.name == "skipme"`, which expresses the same intent and is actually evaluated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny
…k-condition-fail-loud
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 13 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
范围外发现已立单:#4862正文里「flow trigger 的批量语义已在报告中交回 PM 另立单」那一条,已按 PD #10 直接立单为 #4862(未指派),以免只存在于报告里。 核实 B1 出路时顺带测出的、比预期更重的一格:predicate 批量写上 record-change flow trigger 不仅 要紧的是这一格正是官方文档与 showcase 在教的写法落点:showcase 有 10 条 flow 的起始条件是 对照本 PR 值得记一笔:hook 本 PR 不处理它,也不因它扩大范围。 Generated by Claude Code |
复核通过 —— ACCEPT,已标 ready 并送合并队列(发版关键项)本单同样是接手一个被宿主上下文压缩机械中断的 agent 的未提交成果。接手方没有照单全收,核出并修掉三处缺陷 —— 其中一处的性质值得单独讲。 那条修在自家测试里的缺陷,正是本 issue 存在的理由
condition: 'name == "skipme"' // 裸标识符,每次调用都在 fault它断言的「skip」不是条件回答了「否」,而是旧的静默吞咽。也就是说:一条一直在通过的测试,证明的是一个从来没真正求值过的条件。改成 这与 #4649 用同一招揪出两条我们自己的示例规则、与今早 #4837 / #4839 的形态完全同源。本 PR 上线前,这一招在本仓已经四次找到"声明了但从未生效"的东西 —— 而这次是在测试自己身上。 派发时我说「预期它会揪出若干条从没真正生效过的条件,那是该被发现的东西,不是要绕过的噪音」,这就是那个东西。 另两处也修得对: 硬防线守住了 —— 而且答案比我要求的更强我的派发文里写了一条红线:
dev 核实了,结论是不通,于是错误文案里写的是:
没有把一条自己没验证过的出路写进错误信息 —— 反而主动把它标成「此路不通」。 这正是这条红线要保护的东西。 并且把不通的原因查清了,立为 #4862:predicate 批量写上 flow trigger 不但 #4862 里最要命的一条:showcase 有 10 条 flow 起始条件是 于是本 PR 之后出现一个需要收口的不对称:同一格上,hook condition 从此响亮失败,flow trigger 依然静默。建议 #4862 与 #4800 一起定方向。 关键假设是实测的,不是推断的B1 的分支靠区分
一个「修 declared ≠ enforced」的 PR,自己不能靠假设别人的输出形状办事。 这一步做对了。 一处扩展我认可「编译不过的 condition 同样阻断」—— issue 没明写,但 dev 的理由站得住:旧的 约束核对
我自己补做的一项核验:
|
Fixes #4775
按维护者拍板的**方案 B(全局 fail loud)**实现,并同批实现 #4800 的 B1(predicate 批量写的专门诊断)。
做了什么
hook 的
condition求不出值时,原先是logger.warn+return false—— 把「表达式说不」和「平台算不出表达式说什么」压成同一个结果。这两件事对不同 hook 是相反方向的风险:before*守卫被吞成false会放行一次本该被拦的写入,after*审计被吞成false会漏记一条没人会去找的记录。现在:求不出值 → 抛
HookConditionError,中断该次操作,错误点名 hook 与出问题的 key。before*与after*同一个方向,这是明知并接受的代价 —— 一条规则一个答案,平台不多长一条「要看 event 类别才知道失败方向」的隐性规则。编译不过的 condition 也一并阻断。 它原先的处置(
condition ignored)是这次吞咽里更糟的一半:整个门被删掉了,于是声明来拦截的守卫放行了每一次写入、审计 hook 在每一次写入上都触发。改为在调用时报错而非 bind 时,这样一条坏 hook 不会把没人写入的 app 的启动卡死。没有改变的部分
onError未被接入,这是刻意的。 它管辖的是 handler 抛错;condition 求值发生在 handler 之前。接进去会让onError: 'log'把本次要消灭的静默跳过原地复活,并长出第三套语义。retryPolicy/async同理留在门外。错误文案与 #4649 同源
新增
cel-fault.ts,由 hookcondition与 validation 谓词(rule-validator.ts)共用。两个因同一原因拒写的求值器不该用两种方言描述它 —— 与当初把materializeDeclaredFields抽出来共用是同一个理由。rule-validator.ts这边是纯重构,对外错误形状不变。B1:predicate 批量写的专门诊断 (#4800)
批量写匹配 N 行、hook 只触发一次,所以
previous不绑定、record只是裸 payload。fail loud 不开例外,但文案是诊断而不是谜语:点名 hook、说明「这是 predicate 批量更新,没有单一前置记录」、给出可行的路(改写掉previous,或按 id 单记录写入)。批量写上的未声明 key 仍然走普通的拼写错误文案 —— 那个确实是拼错,说成批量问题会把作者引去修一个拼写正确的字段。
🔴 硬防线:record-change flow trigger 这条出路已核实,并被否决
文案里没有写「改用 record-change flow trigger」。核实结论(实测,非推断):
packages/triggers/trigger-record-change/src/record-change-trigger.ts:252通过engine.registerHook订阅的正是这同一批生命周期 hook;ctx.previous ?? ctx.__previous(第 291-293 行);hookContext.previous(engine.ts:4888),批量分支priorRecord始终为 null;__previous兜底也在批量上直接返回(audit-writers.ts:425,if (!id) return;)。所以 flow trigger 在批量写上拿到的是同样未绑定的
previous—— 它不是出路。在一个修 declared ≠ enforced 的 PR 里,于错误信息中指一条自己没验证过的路,就是当场再造一个同类缺陷。flow trigger 的批量语义已在报告中交回 PM 另立单。落地前的全仓扫描(含
examples/)仓内真正的 hook
condition只有两条,都在 showcase,且都是对的:showcase_audit_task_completionprevious.done != true && record.done == trueshowcase_warn_over_budgetrecord.spent != null && record.budget != null && record.spent > record.budget!= null而非has()守卫showcase 三个
update_record节点全部按id过滤,roll-up(recomputeSummaries)也是按 id 单记录更新,仓内没有任何一条已编写的批量写路径会踩到 B1 那一格。examples/app-showcase/src/automation/flows/index.ts里那些status == "done" && previous.status != "done"是 flow / automation 的扁平作用域,与 hookcondition的record作用域是两个面,不受本改动影响。扫描当场揪出一条从没真正生效过的条件
hook-metrics.test.ts里的condition: 'name == "skipme"'是裸标识符。hook condition 是record作用域的,裸标识符解析不到任何东西,该表达式每次调用都在 fault —— 那条测试断言的「skip」来自旧的吞咽(fault → warn → false),而不是来自一个回答了「否」的条件。这正是本 issue 预期会揪出来的东西。已改为record.name == "skipme",同样的意图,但真的被求值了。hook-binder.test.ts里那条 pin 则显式接受两种结果(「要么编译期忽略(handler 跑)、要么求值 false(跳过)…只断言没崩」),那份宽容本身就是缺陷;已改为断言拒绝、uncompilable原因与 handler 未运行。破坏性 & changeset
已加
.changeset/hook-condition-fail-loud.md,标 major,写清「存量靠静默跳过苟着的 hook 会开始让写入失败」,并附迁移清单(拼错/退役字段、未加保护的 null 比较、批量上的previous、裸标识符)。验证
pnpm --filter @objectstack/objectql testpnpm --filter @objectstack/objectql typechecktsc --noEmit无输出eslint(全部改动文件)@objectstack/rest@objectstack/runtime@objectstack/plugin-approvalsrecord-lock-multi-update)@objectstack/plugin-sharing@objectstack/trigger-record-changeturbo run build(全仓,排除 docs)example-showcase@objectstack/spec check:generated新增
hook-condition-fail-loud.test.ts(504 行)覆盖:求不出值即拒绝且点名 hook 与 key、与 #4649 共用同一句文案、before*/after*同向且无 event 分档、FALSE 仍只是跳过(改动的爆炸半径,最容易误伤的一条)、onError/retryPolicy不参与、B1 四格诊断、以及编译不过的 condition。归口约束
未改动
skills/**、packages/lint、content/docs/**、packages/spec/**。改动仅落在.changeset/与packages/objectql/**。skills/objectstack-formula/SKILL.md§5 该补的那一行已写进给 PM 的报告,由 #4814 在对侧车道补上。🤖 Generated with Claude Code
Generated by Claude Code