Uh oh!
There was an error while loading. Please reload this page.
feat(spec,automation): flow 的 update_record / delete_record 可以声明批量意图 multi - #5485
Conversation
…record (#5393) `UpdateRecordConfigSchema` / `DeleteRecordConfigSchema` are strictObjects and neither declared any spelling of bulk intent, while the CRUD executors never passed `options.multi`. The data engine accepts a write only when `where.id` is a scalar or `options.multi` is truthy and throws otherwise, so a predicate update/delete was unreachable from any flow in any app — while the node descriptors advertised "Delete records matching a filter." Declared != enforced (PD #10); #5225's showcase sweep flow was the site it surfaced at. Adds `multi` (boolean, default false) to both config contracts and wires the executors to forward it as `options.multi`. One name for one concept (PD #12): `multi` is the engine's own word for it, so the concept is greppable from node config to driver call. The engine's rejection path is untouched — refusing an undeclared predicate write IS the contract. - spec: `multi` on both schemas, with `bulk`/`all`/`multiple` prescriptions and a wrong-layer answer for `options: { multi: true }` (edit distance reaches `multi` from none of them); the same curation is copied to the registration door in service-automation's engine, per #4001's finding that detection generalizes for free while prose does not. - descriptors: `multi` declared on both designer forms, so the form<->Zod ledger reconciles and the key is authorable in Studio, not only by hand. - tests: `crud-bulk-intent.test.ts`, whose delete double opens with `assertEngineDeleteDispatch` from @objectstack/objectql — the producer's own predicate — so it cannot accept a call the real engine refuses. That is the #5197 blind spot closed for this file: `run-summary.test.ts`'s zero-parameter `async delete() { return false }` accepts predicate deletes AND is invisible to check-engine-double-contract's arity test. Needed a devDependency on @objectstack/objectql (acyclic — objectql's closure does not contain service-automation); the eight service-automation baseline entries citing that missing dep as their blocker are updated to say so. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 109 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 31015413774 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
os-zhuang
commented
Aug 5, 2026
Uh oh!
There was an error while loading. Please reload this page.
…te node (objectstack-ai#5225) (objectstack-ai#5534) `InquiryPurgeFlow`'s `purge` node deletes by the predicate `{ status: 'closed' }` but declared no bulk intent. The data engine accepts a write without `options.multi` only when `filter` names ONE row by a scalar `id`, so every run of this flow failed on that node: Node 'purge' failed: delete_record(showcase_inquiry) failed: Delete requires an ID or options.multi=true with `acted: 0` — identically on both paths, the declarative endpoint `POST /api/v1/apps/showcase/inquiries/purge` and the built-in trigger route `POST /api/v1/automation/showcase_inquiry_purge/trigger`. The delete half of the CRUD quartet `src/coverage.ts` claims this flow demonstrates had therefore never executed once (declared != enforced, PD objectstack-ai#10); objectstack-ai#5112's boot probes are what finally reached it. The fix is a DECLARATION, not a rewrite. Until objectstack-ai#5393 (PR objectstack-ai#5485) no spelling of bulk intent existed on the node config at all, which is why the third triage round correctly refused a get-then-loop-then-delete-by-id rewrite as a PD objectstack-ai#5 workaround and escalated instead. `filter` stays: `multi: true` with an absent or empty filter is a declared whole-object delete, and this node is meant to be objectstack-ai#5482's zero-warning sample for exactly that distinction. Verified on a real `--fresh` boot, both probes, with row counts: endpoint selected 2 -> purge acted 2, success true, 4 rows -> 2 (both `closed` rows gone, both non-closed survived) trigger selected 3 -> purge acted 3, success true, 5 rows -> 2 Reverse-verified by stripping the declaration and rebooting: both probes returned to the byte-identical original failure above with `acted: 0` and the row count unchanged. The new example test states the rule as a two-sided invariant over EVERY `delete_record` / `update_record` node rather than asserting one node, and walks ADR-0031 structured containers — `showcase_task_crm_sync`'s `catch` region holds an `update_record` a flat scan of `flow.nodes` misses. Claude-Session: https://claude.ai/code/session_016FNvXhtSdnEGEfLEsMmvxh Co-authored-by: Claude <noreply@anthropic.com>
Fixes#5393
前提复核(rule 6,对 origin/main @ ed0d2aa)
issue 的三条事实全部成立,逐条实测:
DeleteRecordConfigSchema/UpdateRecordConfigSchema(packages/spec/src/automation/builtin-node-config.zod.ts)是strictObject,前者只声明objectName/filter,后者只多一个fields—— 任何批量意图拼法都被unrecognized_keys拒。packages/services/service-automation/src/builtin/crud-nodes.ts的两个执行器调用data.update/data.delete时不传options.multi。resolveEngineDeleteDispatch(标量where.id→ 按 id;否则options.multi→deleteMany;否则抛Delete requires an ID or options.multi=true),update 侧在engine.ts有同款内联 throw。所以谓词批量写从任何 app 的任何 flow 都不可达,而节点描述符写着
Delete Records/Delete records matching a filter.—— declared ≠ enforced(PD #10)。按在册裁定 A 实现。改了什么
spec —— 两个 config 各声明一个同名键
multi(z.boolean().optional(),默认 false):false:执行器传multi: false,写入必须以标量id点名一行,谓词(含id: { $in: [...] })由引擎拒绝。拒绝即契约,不是要绕过的缺陷。true:执行器传options.multi: true,落到driver.updateMany/deleteMany,步骤的acted指标报出命中行数。EngineUpdateOptions.multi/EngineDeleteOptions.multi)—— 一概念一名(PD Add comprehensive test suite for Zod schema validation #12),从节点 config 到 driver 调用全程可 grep,不发明第二个词。guidance(#4001 纪律,两道门都写) ——
bulk/all/multiple拿到点名处方,options: { multi: true }被当作写错层回答(那是引擎的 options 包,不是节点 config)。这四个拼法是 #5225 诊断期真喂过safeParse的,编辑距离从它们中的任何一个都够不到multi,没有这些条目拒绝就只会报个键名。同一份处方复制到 service-automation 引擎的注册门(FLOW_NODE_UNKNOWN_KEY_GUIDANCE),因为注册是作者在 boot 时撞到的第一道门,而 #4001 的发现正是:检测面会随默认值翻转自动泛化,处方不会。描述符表单 —— 两个节点的
configSchema各加multi,所以 form ↔ Zod 台账对得上,键在 Studio 里可授权,而不是只能手写元数据。执行器 ——
multi: cfg.multi === true在每次调用上都写出来,而不是 true 时才 spread:multi: false是让引擎拒绝谓词写的那一半契约,调用点的读者应该直接看见问的是哪一半,而不是从一个缺席的键推断。packages/objectql零改动。#3810 的抹除守卫零改动,并新增用例钉住它:声明multi不会解除它。测试
新增
packages/services/service-automation/src/builtin/crud-bulk-intent.test.ts(10 例)。它的 delete 替身以assertEngineDeleteDispatch(从@objectstack/objectqlimport 的生产者自己的判定)开头,所以在任何输入上都不可能比真引擎松 —— 包括手抄守卫必漏的id: { $in: [...] }(看着像 id,其实是谓词)。check-engine-double-contract已把该文件认作pinned。这正是 issue 点名的 #5197 盲点:
run-summary.test.ts的内联async delete() { return false; }既接受真引擎拒绝的谓词删除,又因为零参而躲过门禁的 arity 判定(isEngineDeleteShape要求引擎的(object, options)形状),连被发现都做不到。为此给
@objectstack/service-automation加了@objectstack/objectqldevDependency —— 无环:objectql 的传递依赖闭包(12 包)不含 service-automation,turbo run build --filter=@objectstack/service-automation --dry正常解析。scripts/engine-double-contract.baseline.json里 8 条 service-automation 条目的why原本写着「不依赖 objectql,加依赖是另一次可评审动作」—— 那句话被本 PR 变成假的,所以按 plugin-auth 的 #3585 先例把它们改成 MEASURED 记述,并给crud-filter-guard.test.ts多留一句:它有几条 #3810 fixture 断言谓词删除成功,真引擎会拒;#5393 之后那是可表达的,所以将来 pin 它时那些 fixture 应该补multi: true,而不是删掉。update 侧刻意不 pin,并在文件头写明原因:objectql 没有导出 update 的判定函数(
engine.ts里是内联 throw),门禁自己的文件头也把 "update's twin dispatch" 列为待抽取后再覆盖的切片。所以 update 用例只断言执行器交给引擎的 options 包——执行器的全部义务——并明确不对引擎会不会接受它发表第二份意见;在 fake 里手抄一份 update 判定,恰恰是那道门禁存在的理由。已另开 issue 记录。反向验证(方向是先判定后跑的:红)
预判:把执行器的
multi接线撤掉,multi: true的用例应当变红,因为替身钉在生产者判定上,谓词删除会拿到引擎真实的拒绝。实测撤线后10 例中 7 例红:三例仍绿,各有诚实的理由,其中一条据此改强了:「
multi: false写出来与不写是同一个拒绝」原本只断言拒绝本身,而拒绝在接线前后都成立 —— 它会穿过本文件存在的意义所在的那次 revert 保持绿。已补上对 options 包的断言。另两条(#3810 守卫、描述符表单)本就与执行器接线无关,绿在两边是设计使然,不是空断言。命令与真实输出
消费半径按规则的调用方扫过,不是按被改的包:CRUD node config 被 cli(validate/lint/migrate)、lint、metadata-protocol(canonicalization)、studio flow-builder 一并消费,全部跑过。cli 首轮 30 红,原因是新 worktree 里
@objectstack/setup等依赖没构建(Failed to resolve entry for package),pnpm --filter '@objectstack/cli^...' build后 79/79 全绿 —— 与本改动无关。门禁:
生成物随行:
authorable-surface.json(+2 键)、content/docs/references/automation/builtin-node-config.mdx(+2 行),两者都由gen:schema/gen:docs整体重生成,未手改。strictness 台账重生成后无变化(往已 strict 的 shape 上加键不动计数)。两处刻意留下的行为,写在这里而不是留给下一个读者
multi: true且没有filter= 按声明清空整个对象。 引擎的派发表本就把「multi 且完全没有谓词」列为合法,Flow node filters silently blank date macros: the template engine consumes{…}before the query engine sees it #3810 的守卫按「作者写过的条件被抹掉」判定而不是按「filter 为空」判定(那是它注释里的原话,也有专门用例钉住)。flow 的delete_record/update_record无法表达批量意图 —— 节点 schema 无键、执行器不传options.multi,谓词批量写对所有 flow 平台级不可达,而节点描述符宣称支持 #5393 之前这条路径不可达,现在可达了,但它是显式、可 grep 的声明。是否要在 authoring 期加告警,已另开 issue 交 PM 定级,不在本 PR 内发明。update_record的破坏性并不比delete_record低多少(谓词 update 覆盖整表字段),但它的测试替身在结构上不能被绑到生产者契约。同上,另开 issue。范围外发现(PD #10,均已建单、未指派)
resolveEngineDeleteDispatch,update 的同款三分支只是 engine.ts 里的一个内联 throw #5480 —— objectql 的 UPDATE dispatch 没有共享判定函数;delete 有engine-delete-dispatch.ts,update 只是engine.ts里的内联 throw,门禁的 update 切片因此无从建立。multi: true且filter为空的 delete_record / update_record 是「按声明清空整个对象」,authoring 期零诊断 —— #3810 的守卫按「条件被抹掉」判定,不按「条件为空」判定 #5482 ——multi: true+ 空filter在 authoring 期零诊断(上面第 1 点)。后续
#5225(showcase 清扫流)挂 Blocked-by 本单,本单落地后由它以声明批量意图收尾。示例目录本 PR 未动。
Generated by Claude Code