Skip to content

重构:master-detail 保存统一走原子 batchTransaction,把非原子回退隔离进适配器(承接 framework#1604 / framework ADR-0034 item 4) #2679

Description

@os-zhuang

术语澄清:本 issue 里的 ADR-0034 指 framework 仓库的 ADR-0034(“Robust multi-write transactions / ambient transaction”),与 objectui 自己的 ADR-0034(运行时元数据持久化)无关。

背景

framework 侧的跨对象原子批量端点 POST /api/v1/batch 已随 framework#1604 落地并加固(按对象 API 权限门 + Zod 校验 + $ref 解析 + 原子提交/回滚),并已在真实浏览器里端到端验证:console 主子表保存 → 一次 POST /api/v1/batch(父 + 子 {"$ref":0})→ HTTP 200 原子提交;不选 product 时整批回滚、不产生孤儿发票。

framework ADR-0034 的收尾项(item 4)写道:

“Point ObjectUI masterDetailTx at it and delete the client-side best-effort cleanup (a smell that exists only because server atomicity was missing).”

masterDetailTxbatchTransaction/api/v1/batch 的接线已经存在且工作正常。本 issue 只跟踪清理那条非原子的客户端回退路径

现状:回退清理指的这三块代码

  1. 能力探测门packages/plugin-form/src/MasterDetailForm.tsx:589
    const canBatch = typeof (dataSource as any)?.batchTransaction === 'function';
    • truesubmitViaBatchds.batchTransaction(ops)(原子,走 /batch)。
    • falsehandleParentSavedpersistDetailsapplyDetail(逐子集合 createMany/update/delete + 客户端 rollup)。
  2. best-effort 补偿删除handleParentSaved 的 catch(约 MasterDetailForm.tsx:570-576):父落库后若子写入失败,Promise.allSettled([...created.map(delete), delete(parent)]) 试图删掉孤儿父 + 已建子。
  3. 为补偿而存在的簿记masterDetailTx.tsapplyDetail 返回 ApplyDetailResult.created(专门记已建子 id 供失败回删)。

为什么是 smell

客户端补偿式“回滚”是原子性反模式:

  • 有竞态:父落库到补偿删除之间有窗口,别的读取/hook/订阅会看到孤儿父。
  • best-effort ≠ 保证:allSettled 吞掉删除失败;父建成后网络断开则补偿根本没机会跑 → 永久孤儿。
  • 副作用不可回滚:父 create 已触发 hooks/flow/rollup/审计/webhook/邮件,删行并不能撤销这些。真事务是根本不 commit
  • 逻辑重复:服务端已做对(一个事务),再留一份客户端实现 = 两条路径两种行为。

为什么不能现在直接删(兼容性约束)

batchTransaction 不在 DataSource 契约里(packages/types/src/data.ts:170DataSource 只有 find/create/update/delete + 可选 bulk?/bulkUpdate?/bulkDelete?),只实现在 ObjectStackAdapter(packages/data-objectstack/src/index.ts:935),且以 (dataSource as any).batchTransaction 访问。因此 canBatch 门保护了两种真实场景:

  1. 后端版本 skew:连到还没有 /api/v1/batch 的旧后端,直接删回退 = 把“能保存(不够安全)”变成“没有保存路径”。
  2. 非 ObjectStack 适配器:实现了 DataSource 但没有 batchTransaction 的适配器,原子路径不存在。

这正是 ADR 把它限定为 “once the endpoint is universally available” 的原因。

推荐方案 A(契约先行,首选)

把回退下沉到适配器,让表单永远走原子形态:

  • batchTransaction 提升为 DataSource 契约的一等方法(类型化,去掉 as any),语义:“把一组跨对象有序操作原子地持久化”。
  • 每个适配器实现它:ObjectStackAdapter/batch(已实现);无服务端原子性的适配器在适配器内部用现有客户端编排 + best-effort 补偿实现(把 applyDetail + 补偿搬进去,集中到一个有测试的地方)。
  • 表单侧删 canBatch / handleParentSaved / persistDetails / 补偿删除,一律调 dataSource.batchTransaction(ops)

好处:表单不再“知道”非原子这回事(对应 framework Prime Directive #12 契约先行:一个契约、N 个实现,消费端不带方言);smell 被隔离到唯一“确实做不到原子”的适配器里并显式标注。

方案 B(更保守,可作为过渡)

  • discovery 文档里的能力信号替代 (as any) 方法嗅探(适配器已缓存 discovery,data-objectstack/src/index.ts:144discoveryCache)——由服务端声明支持 batch
  • 最低支持后端版本保证有 /batch 时,再删客户端编排分支。

两个必须一起考虑的连带点

  • LineItemsPanel(packages/plugin-form/src/LineItemsPanel.tsx:120)始终用 applyDetail 编辑已存在父的明细(parentId 已知 → 无孤儿父风险,本就没补偿)。它是独立面、当前完全不走 /batch;可顺带迁到 batchTransaction(编辑态 diff 已能用 buildMasterDetailEditBatch 表达)让它也原子化,但属独立加分项。
  • rollup 不能丢:applyDetail 还做客户端合计(totalField = sum(amountField) → 更新父)。原子路径是把 rollup 并进父 payload(submitViaBatch 已这么做)。删 applyDetail 时须确认每个剩余调用方都把 rollup 折进 batch,否则会静默丢掉合计

验收

  • MasterDetailForm 创建/编辑主子表一律经 dataSource.batchTransaction,无客户端补偿删除分支。
  • 无服务端原子性的适配器仍能保存(回退实现移入适配器,单点、有测试)。
  • rollup 在原子路径下不丢。
  • 任何硬删除客户端编排都卡在“最低后端版本经 discovery 声明支持 /batch”这个前提上。

优先级 / 备注

低优先级技术债清理,非正确性问题——当前代码有端点时原子、无端点时降级但仍可用。承接自 framework#1604 的浏览器端到端验证(报告见该 PR/issue)。相关:已修的 #2582(batchTransaction 补发 MutationEvent)。

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions