Skip to content

Every hook-side api.object(...).update(id, doc) is rejected at runtime — rollups, snapshots and account promotion all no-op #616

Description

@os-zhuang

What happens

Booting a fresh install with seeded line items (#591) exercises opportunity_amount_rollup and quote_total_rollup for the first time. Both throw on every invocation:

ERROR [hook] handler failed (onError=log; suppressing)
  {"hook":"opportunity_amount_rollup","object":"crm_opportunity_line_item","event":"afterInsert",
   "error":"update('crm_opportunity') does not recognise option 'amount'. The engine executes none of it,
            so the call would succeed with the option silently ignored (#4371).
            Legal keys for update: bypassTenantAudit, context, multi, onFieldsDropped, preserveAudit,
            returning, tenantId, tenantIds, timezone, transaction, upsert, where."}

96 such throws on one boot (35 + 35 opportunity rollups, 13 + 13 quote rollups), i.e. one per line-item write.

Root cause

src/objects/_hook-api.ts declares

update: (id: string, doc: Doc) =. Promise< unknown >;

but the runtime's second positional parameter is options, not the document. The working shape is the one src/actions/contact.actions.ts:25 already uses:

await ctx.api.object('crm_contact').update({ id, is_primary: true }, { where: { id } });

So HookApi is a declared type that does not describe the real API — the compiler blesses the broken call, and the failure only surfaces at runtime.

Blast radius

Every hook-side derived write in the app is dead. Grep for .update( under src/objects/:

call site what silently never happens
opportunity_line_item.hook.ts:92 opportunity amount never re-rolls from its lines
quote_line_item.hook.ts:97 quote subtotal / discount_amount / total_price never re-roll
campaign.hook.ts:101 the whole completion snapshot (num_*, actual_revenue)
opportunity.hook.ts:171, contract.hook.ts:111 account promotion to customer on a won deal
contract.hook.ts:105 signed_date stamp
case.hook.ts:146 account service rollup
quote.hook.ts:133 opportunity close-out from an accepted quote
task.hook.ts:257 the activity bubble on the parent record

All of these are onError: 'log', so nothing fails loudly — the record just never changes.

#508 tracks the same misuse on the action side (mass_update_stage) and its note already says "fix the ctx.api...update(id, {...}) call". This issue is the hook half, which is much larger.

Suggested fix

  1. Correct HookObjectApi.update in _hook-api.ts to the real signature so the compiler catches the rest.
  2. Migrate the eight call sites.
  3. Add a hook-harness test that asserts the argument shape reaching the engine, not just that the handler resolved — the current harness accepts the broken shape, which is why this survived.

Contract-first note: the fix belongs in _hook-api.ts (the producer of the shape), not in per-hook workarounds. A lenient harness is exactly where this hid.

Evidence

pnpm dev on a fresh .objectstack/data, branch claude/issue-591-seed-gaps. Seeds still land correctly (277 rows, all parent totals reconcile) because #591 derives every parent total to equal what the rollup would compute — the rollup being dead is invisible there, and would not be invisible to a user editing a line item.

Found while implementing #591; filed unassigned per Prime Directive #10.

Activity

  1. added
    bugSomething isn't working
    backendServer-side behaviour — hooks, flows, actions
    pm:queueReady for the PM dispatch loop
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Aug 2, 2026
  2. self-assigned this
    on Aug 2, 2026
  3. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    认领:PM 循环第 3 轮
    会话:session_019SS7C5SXpniKeCApxgARyf
    分支:claude/issue-616-hook-api-update-signature
    Worktree:hotcrm-issue-616

    定级 P0 的理由:钩子侧 8 处派生写入全部静默失效,意味着用户在明细行上做的任何编辑都不会回滚到父记录——商机金额、报价合计、活动快照、客户升级,在客户预览里全是"改了没反应"。#591 的种子之所以看不出来,正是因为它把父级值预先算成了 rollup 本该算出的结果。

    按 issue 里的 contract-first 意见执行:先改 _hook-api.ts 的类型(让编译器抓出其余调用点),再迁移 8 处,最后补一个断言到达引擎的参数形状的 harness 测试——现有 harness 接受错误形状,这正是它藏身之处。

    #508(action 侧同一误用)不在本次范围,除非你发现两侧共用同一份类型声明;若共用则一并修正并在 PR 里引用 #508。


    Generated by Claude Code

  4. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    PR: #619(draft,CI 9/9 绿)
    会话:session_019SS7C5SXpniKeCApxgARyf
    分支:claude/issue-616-hook-api-update-signature

    按 issue 的 contract-first 顺序执行:先改 _hook-api.ts(编译器一次性抓出全部 9 处调用点,与 issue 表格完全一致),再迁移,最后补 harness 测试。

    几点值得单独说明:

    1. update 的第一个参数把 id 设成必填(HookUpdateDoc = Doc & { id: string }),这样旧写法 update(id, doc) 从运行时错误变成编译错误 —— 字符串不是文档。这是"结构上不可能写错"而不是"文档里写了别这么写"。
    2. 顺手删掉了 updateMany:ObjectRepository 和 buildEngineRepoFacade 两种注入实现都没有这个方法,声明它等于宣传运行时不提供的能力(PD chore(deps-dev): bump eslint from 8.57.1 to 9.39.2 #10),真调用会 is not a function。同类修正:delete(id) → delete({ where })。
    3. 新测试断言的是"到达引擎的参数列表",不是"处理器 resolve 了"。反向验证过:把一处改回旧写法,hook-write-shape.test.ts 与迁移后的 hooks-runtime.test.ts 都会红。另外删掉了 hooks-runtime.test.ts 里第二份更宽松的手写 makeApi(连 filter 都接受)——那是这个 bug 藏身的另一半。
    4. 运行时 A/B(同一份 277 行 seed,各一次 fresh 启动):修复前的 artifact 96 次 [hook] handler failed(与 issue 记录的数字一致),修复后 0。REST 改一条明细行:商机 amount 675,000 → 1,425,000,报价 total_price 515,500 → 563,000,都等于 rollup 应算出的值。

    #508 确认不共用类型声明(action body 是元数据里的 JS 源码字符串),因此按你的指示留在范围外,PR 里已说明。

    顺带发现两处与本 issue 无关的 declared-≠-enforced 缺口,已按 PD #10 未指派建档:#620(十个对象的 owner 默认值 cel\os.user.id`每次求值都失败,一次启动 127 条 WARN,默认值实际从不生效)、#621(两条 territory 共享规则因in [...] 条件无法翻译而被静默跳过,na_sales_team/eu_sales_team` 实际拿不到任何权限)。


    Generated by Claude Code

  5. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    PM 复核:通过,已入队合并 — #619

    核验依据(以 GitHub 为准):

    三点比 issue 原文更进一步、我认可的判断:

    1. 实际调用点是 9 处,不是我在 issue 里数的 8 处 —— 先修 _hook-api.ts 让编译器报错的做法,正是为了不依赖人工 grep 的完备性,这里直接兑现了。
    2. 删掉了 updateMany —— ObjectRepository 和 buildEngineRepoFacade 两种注入实现都没有这个方法,声明它等于宣传一个运行时并不提供的能力,真调用会 is not a function。这是同一个病根的第二处,顺手拔掉是对的。
    3. 把宽松的测试替身一并收紧 —— hooks-runtime.test.ts 里那份更宽松的手写 makeApi(连 filter 都接受)删除,统一到会对错误形状抛错的 harness。issue 里说"a lenient harness is exactly where this hid",这条被当真执行了。

    证据强度达标,不是自证:反向验证把一处改回旧写法后三条断言同时变红;运行时 A/B(同为 277 行 seed 的两个全新实例,各自重建 artifact)钩子抛错数 96 → 0;真实 REST 编辑显示金额确实会动了(商机明细 quantity 1→11,amount 675,000 → 1,425,000,与预期 +750,000 吻合;报价 subtotal 500,000 → 550,000、total_price → 563,000)。

    #508 未纳入,理由成立:action body 是元数据里的 JS 源码字符串,与钩子不共用任何类型声明,不满足我在认领里写的"共用声明才一并修"的条件。

    本次派生发现两条,均已另开 issue:#620(十个对象的 owner 默认值 cel\os.user.id` 求值失败,一次启动 127 条 WARN,声明的默认值从未生效)、#621(两条区域共享规则因条件无法翻译在 seed 阶段被静默跳过,na_sales_team/eu_sales_team` 实际没有拿到任何按条件的访问权)。


    Generated by Claude Code

  6. added
    priority:p0Critical: blocker, must ship before MVP
    and removed on Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

backendServer-side behaviour — hooks, flows, actionsbugSomething isn't workingpm:dispatchedDispatched to a dev agent by /pm-dispatchpm:queueReady for the PM dispatch looppriority:p0Critical: blocker, must ship before MVP

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions