fix(plugin-email)!: EmailPersistence.insert 必须回传 row.id,改 id 响亮拒绝而非双发 (#5523) - #5709
Merged
Merged
Conversation
… id (#5523) `EmailService.sendInternal()` used to adopt whatever id `persistence.insert` answered with, reserving it as service-managed after the fact. The `sys_email` `afterInsert` outbox drain hook runs INSIDE that insert and decides whether a freshly-inserted row is the service's to deliver by asking `isServiceManaged()` about the inserted row's own id — so a persistence that re-keyed the row handed the hook an id `send()` had not yet reserved. The hook read the row as an application-inserted outbox entry and delivered it; `send()` then delivered it again down its own path. One message sent twice, two terminal updates racing on one row, prevented only by the hook's `setTimeout(0)` losing a race to inline delivery — a race real network I/O normally wins. Contract tightened instead of the consumer accommodating both shapes: the id is minted by the service before the insert and is already load-bearing (out-of-row attachment content is stored under `sys_email/attachments/<id>/…`). `insert` now must confirm that id; a different one throws, naming the contract and the value returned, before any delivery. - Documents the contract on `EmailPersistence` / `insert` (TSDoc). - Drops the `persistedId !== id` branch and the `extraManagedId` release it required: one id, reserved once, released once. - `persistedId` becomes a `rowPersisted` boolean — the insert's answer only ever told us whether the row landed. - The check sits outside the insert's non-fatal catch: a failing insert stays an operational condition the service rides out, a renaming one is fatal. - Judges the confirmation's value, not its presence: an insert that returns no id leaves nothing to disagree with and still sends. Co-Authored-By: Claude <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
marked this pull request as ready for review
August 6, 2026 01:48
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5523
一、前提重验(基线 origin/main
782fc2994)立单核验基于
308c7095,其后 #5169 / PR #5524 已合入同一文件,行号确实漂了。逐条重验:前提成立。sendInternal()只在insert返回之后才把persistedId加进managedRowIds(重构前 604-608 行),而email-plugin.ts的 afterInsert drain 钩子在engine.insert内部同步跑、读的是hookCtx.result.id。时序错位,isServiceManaged(新 id)为false,钩子据此认定「这是应用自己插的 outbox 行」并另投一次。row.id」——实际不是。email-plugin.ts:485的实现回传的是引擎自己的 id:return created?.id ? { id: String(created.id) } : { id: String(row.id) };只因 ObjectQL 尊重传入的 id,结果上才等于
row.id。这个区别对本 PR 有利:适配器转发而非洗白引擎的回答,所以「某个 driver 改 PK」这一情形对契约校验是真实可达的——本 PR 新增的插件级用例正是走这条真实路径复现双发的。拆单前置条件核实(决策卡预记):
EmailPersistence声明在packages/plugins/plugin-email/src/email-service.ts(原 119 行,现 146 行),不在packages/spec;全仓grep确认没有任何packages/spec文件引用它。故按预期单 PR 完成,未触碰 spec。二、实现(A 案:收紧契约)
EmailPersistence与其insert补 TSDoc —— 必须回传row.id,返回值仅作确认;并写明为什么是这个方向(id 由 service 铸造且在 insert 之前就已承重:附件内容已上传到sys_email/attachments/{row.id}/…),以及「存储要用自己主键」的正解(service id 留在id列,存储主键另开一列)。persistedId !== id分支、extraManagedId变量、以及 plugin-email: EmailService.managedRowIds 泄漏 persistedId —— insert 返回不同 id 时永不清理 #5169 在finally里对它的 release 一并删除。一个 id,登记一次,释放一次。persistedId降级为rowPersisted布尔。insert 的回答本来就只告诉我们「行有没有落地」(原rowId = persistedId ?? id恒等于id)。留一个只可能等于id的字符串变量,等于邀请下一位读者把分歧分支加回来。persistence是注入的对象(options.persistence/setPersistence()),insert的返回值无法静态推断,wire 期最多只能查typeof insert === 'function'——那不是本缺陷。按 PM 圈定的「这也可接受」执行,且错误指名契约、指名实际返回值、在投递之前抛出。一处如实说明:钩子那一次投递无法被抢先
钩子在
engine.insert内部同步注册的setTimeout(…, 0)里排程,即insert尚未返回给 service 时就已排好队。契约校验最早只能在insert返回后跑,因此无法取消它。A 案实际消掉的是send()自己那第二次投递:落地的那一行仍由钩子投递一次,同时调用方拿到指名契约的响亮报错。这是「不再双发」,而不是「一次都不发」——插件级用例把这个数字(1,而非 2)钉住了。另有一点刻意保留的行为:校验抛出时不回收已上传的附件字节。行确实落了地(只是主键被改),其
attachments_json仍引用那些 key,删掉才是数据丢失。三、#5169 / PR #5524 测试资产逐条处置
email-service.test.ts—releases an insert-ASSIGNED row id from the managed set too (#5169)rejects an insert that returns a DIFFERENT id, naming the contract, before deliveringtransport.send/update均未被调用(不是先双发再报错),并断言finally仍释放了登记。managedDuringDelivery(「managed 窗口必须覆盖sent终态更新,不得为了释放而收窄」)marks its own row managed during send()),行为覆盖不缩水。email-service.queue-delivery.test.ts—releases an insert-assigned id once the job is published (#5169)rejects an insert that returns a different id — and publishes NO job (#5523)queue.published长度为 0。still marks its row managed during the insert已断言队列态 send 之后isServiceManaged(res.id) === false。.changeset/email-managed-row-ids-release-persisted-id.md(#5169 的 changeset)plugin-email/CHANGELOG.md零处提及 5169),而描述的是本 PR 删掉的那条分支。两者同发会编出自相矛盾的 release note(「insert-assigned id 现已正确释放」+「insert 返回不同 id 直接拒绝」)。本 PR 的 changeset 已把 FROM 态完整叙述进去。此项如维护者不认同,单独 revert 这一个文件即可。email-service.ts中 #5169 的解释性注释四、新增覆盖
email-service.test.ts:改 id 被拒(且未投递、未终态更新、未泄漏登记);裸字符串确认形式合规时放行、不同时同样被拒(字符串形式不是绕过契约的口子);回传无 id 视为确认;insert 抛错仍按 non-fatal 兜住——最后这条与拒绝用例并排放,是为了钉住「校验不在 catch 里」:若有人把它移进去,拒绝用例会绿得毫无道理,而这条会同时暴露。email-plugin.outbox-sweep.test.ts:新增引擎选项reKeyInsert,让sys_emailinsert 存进并回传另一个 id(自增 PK 的忠实模型),经真实插件适配器端到端复现 issue 的机理。transport 刻意做得比setTimeout(0)慢——「唯一的挡箭牌是竞态」那条 race 在这里被故意打输,正如真实网络 I/O。五、Changeset 级别
写
patch,并在此说明取舍:该表面目前无任何外部实现(仓内实现转发引擎回答,ObjectQL 尊重传入 id),且声明的 TypeScript 签名未变(Promise的{ id: string } | string联合保持原样;「必须等于 row.id」无法用类型表达,故落在 TSDoc + 运行时)。若维护者认为「收紧公开接口契约」应记minor/major,改标签即可,正文的 FROM→TO 与一行修法已按 AGENTS.md 第 719 行要求写全。Generated by Claude Code