术语澄清:本 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).”
masterDetailTx → batchTransaction → /api/v1/batch 的接线已经存在且工作正常。本 issue 只跟踪清理那条非原子的客户端回退路径。
现状:回退清理指的这三块代码
- 能力探测门 —
packages/plugin-form/src/MasterDetailForm.tsx:589
const canBatch = typeof (dataSource as any)?.batchTransaction === 'function';
true → submitViaBatch → ds.batchTransaction(ops)(原子,走 /batch)。
false → handleParentSaved → persistDetails → applyDetail(逐子集合 createMany/update/delete + 客户端 rollup)。
- best-effort 补偿删除 —
handleParentSaved 的 catch(约 MasterDetailForm.tsx:570-576):父落库后若子写入失败,Promise.allSettled([...created.map(delete), delete(parent)]) 试图删掉孤儿父 + 已建子。
- 为补偿而存在的簿记 —
masterDetailTx.ts 的 applyDetail 返回 ApplyDetailResult.created(专门记已建子 id 供失败回删)。
为什么是 smell
客户端补偿式“回滚”是原子性反模式:
- 有竞态:父落库到补偿删除之间有窗口,别的读取/hook/订阅会看到孤儿父。
- best-effort ≠ 保证:
allSettled 吞掉删除失败;父建成后网络断开则补偿根本没机会跑 → 永久孤儿。
- 副作用不可回滚:父 create 已触发 hooks/flow/rollup/审计/webhook/邮件,删行并不能撤销这些。真事务是根本不 commit。
- 逻辑重复:服务端已做对(一个事务),再留一份客户端实现 = 两条路径两种行为。
为什么不能现在直接删(兼容性约束)
batchTransaction 不在 DataSource 契约里(packages/types/src/data.ts:170 的 DataSource 只有 find/create/update/delete + 可选 bulk?/bulkUpdate?/bulkDelete?),只实现在 ObjectStackAdapter(packages/data-objectstack/src/index.ts:935),且以 (dataSource as any).batchTransaction 访问。因此 canBatch 门保护了两种真实场景:
- 后端版本 skew:连到还没有
/api/v1/batch 的旧后端,直接删回退 = 把“能保存(不够安全)”变成“没有保存路径”。
- 非 ObjectStack 适配器:实现了
DataSource 但没有 batchTransaction 的适配器,原子路径不存在。
这正是 ADR 把它限定为 “once the endpoint is universally available” 的原因。
推荐方案 A(契约先行,首选)
把回退下沉到适配器,让表单永远走原子形态:
好处:表单不再“知道”非原子这回事(对应 framework Prime Directive #12 契约先行:一个契约、N 个实现,消费端不带方言);smell 被隔离到唯一“确实做不到原子”的适配器里并显式标注。
方案 B(更保守,可作为过渡)
两个必须一起考虑的连带点
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,否则会静默丢掉合计。
验收
优先级 / 备注
低优先级技术债清理,非正确性问题——当前代码有端点时原子、无端点时降级但仍可用。承接自 framework#1604 的浏览器端到端验证(报告见该 PR/issue)。相关:已修的 #2582(batchTransaction 补发 MutationEvent)。
背景
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)写道:
masterDetailTx→batchTransaction→/api/v1/batch的接线已经存在且工作正常。本 issue 只跟踪清理那条非原子的客户端回退路径。现状:回退清理指的这三块代码
packages/plugin-form/src/MasterDetailForm.tsx:589true→submitViaBatch→ds.batchTransaction(ops)(原子,走/batch)。false→handleParentSaved→persistDetails→applyDetail(逐子集合 createMany/update/delete + 客户端 rollup)。handleParentSaved的 catch(约MasterDetailForm.tsx:570-576):父落库后若子写入失败,Promise.allSettled([...created.map(delete), delete(parent)])试图删掉孤儿父 + 已建子。masterDetailTx.ts的applyDetail返回ApplyDetailResult.created(专门记已建子 id 供失败回删)。为什么是 smell
客户端补偿式“回滚”是原子性反模式:
allSettled吞掉删除失败;父建成后网络断开则补偿根本没机会跑 → 永久孤儿。为什么不能现在直接删(兼容性约束)
batchTransaction不在DataSource契约里(packages/types/src/data.ts:170的DataSource只有find/create/update/delete+ 可选bulk?/bulkUpdate?/bulkDelete?),只实现在ObjectStackAdapter(packages/data-objectstack/src/index.ts:935),且以(dataSource as any).batchTransaction访问。因此canBatch门保护了两种真实场景:/api/v1/batch的旧后端,直接删回退 = 把“能保存(不够安全)”变成“没有保存路径”。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:144的discoveryCache)——由服务端声明支持batch。/batch时,再删客户端编排分支。两个必须一起考虑的连带点
LineItemsPanel(packages/plugin-form/src/LineItemsPanel.tsx:120)始终用applyDetail编辑已存在父的明细(parentId 已知 → 无孤儿父风险,本就没补偿)。它是独立面、当前完全不走/batch;可顺带迁到batchTransaction(编辑态 diff 已能用buildMasterDetailEditBatch表达)让它也原子化,但属独立加分项。applyDetail还做客户端合计(totalField = sum(amountField)→ 更新父)。原子路径是把 rollup 并进父 payload(submitViaBatch已这么做)。删applyDetail时须确认每个剩余调用方都把 rollup 折进 batch,否则会静默丢掉合计。验收
MasterDetailForm创建/编辑主子表一律经dataSource.batchTransaction,无客户端补偿删除分支。/batch”这个前提上。优先级 / 备注
低优先级技术债清理,非正确性问题——当前代码有端点时原子、无端点时降级但仍可用。承接自 framework#1604 的浏览器端到端验证(报告见该 PR/issue)。相关:已修的 #2582(batchTransaction 补发 MutationEvent)。