feat(forms): add durable delivery receipts and retries#2222
feat(forms): add durable delivery receipts and retries#2222AaronBakerDev wants to merge 3 commits into
Conversation
🦋 Changeset detectedLatest commit: 6ec9b7e The changes in this PR will be included in the next version bump. This PR includes changesets to release 5 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
All contributors have signed the CLA ✍️ ✅ |
Scope checkThis PR changes 1,350 lines across 14 files. Large PRs are harder to review and more likely to be closed without review. If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs. See CONTRIBUTING.md for contribution guidelines. |
There was a problem hiding this comment.
This is the right change for the stated problem. Moving form delivery from synchronous fire-and-forget sends to a versioned outbox with durable receipts, bounded leases, backoff, and provider-side idempotency headers fits EmDash’s plugin model and the documented storage constraints. The implementation is careful about sanitizing the public receipt/health responses and the test coverage is broad.
I did not run the test suite, linter, or build; the reported test/typecheck/lint results are unverified.
There are several AGENTS.md convention violations and one contract issue that should be addressed before merge:
packages/plugins/forms/src/outbox.tsuses a module-scopeletas a singleton (deliveryRun), which AGENTS.md forbids because Vite can duplicate modules across SSR chunks. It should live onglobalThiskeyed bySymbol.for.- The CAS comment in
outbox.tsand the self-heal comment insubmit.tsare justification/narrative comments; AGENTS.md says comments should not explain rejected alternatives or justify a decision. - The submit handler now calls
ctx.cron.list()on every public submission, adding a query to the logged-out hot path. AGENTS.md treats this as presumed wrong; the self-heal goal is valid but the per-submit cost needs to be avoided (e.g. via a KV flag, an upgrade hook, or scheduling idempotently without a read). - Honey-pot submissions return
submissionId/receiptIdbut never persist a submission, so the returned receipt ID is unresolvable. This contradicts the PR’s stated contract that IDs are returned only after the delivery plan is durably stored.
None of these are security/data-loss blockers, so I’m leaving this as comment rather than request_changes, but the AGENTS.md items are real findings under the repo’s own rubric.
Findings
-
[needs fixing]
packages/plugins/forms/src/outbox.ts:369Module-scope mutable singletons are prohibited by AGENTS.md because Vite can duplicate modules across SSR chunks, so a plain
letmay become more than one variable.deliveryRunis used to serializehandleDeliverycalls and should live onglobalThisbehind aSymbol.forkey instead.const deliveryRunKey = Symbol.for("emdash:forms:deliveryRun"); function getDeliveryRun(): Promise<void> { return ((globalThis as Record<symbol, unknown>)[deliveryRunKey] as Promise<void> | undefined) ?? Promise.resolve(); } function setDeliveryRun(run: Promise<void>): void { (globalThis as Record<symbol, Promise<void>>)[deliveryRunKey] = run; } -
[needs fixing]
packages/plugins/forms/src/outbox.ts:236-241This comment explains the rejected approach (compare-and-set), narrates the duplicate-window trade-off, and justifies the design with "therefore required". AGENTS.md says comments should not justify decisions, narrate rejected alternatives, or address reviewers. Trim it to a single invariant if the residual concurrency risk is non-obvious, or delete it.
-
[needs fixing]
packages/plugins/forms/src/handlers/submit.ts:112-115This comment justifies why the cron scheduler is checked inside the submit handler. AGENTS.md prohibits comments that explain a decision rather than a non-obvious invariant. The code already states what it does; the surrounding block could be extracted to a named helper like
ensureDeliveryCron()so the code explains itself, and the comment can be removed. -
[needs fixing]
packages/plugins/forms/src/handlers/submit.ts:119Calling
ctx.cron.list()on every public form submission adds a new query to the logged-out hot path. AGENTS.md is explicit that any increase in logged-out query counts is presumed wrong and needs a really good reason. The self-heal goal is legitimate, but this should not be paid on every submit: consider using a cached/process-level guard, a KV flag set once after activation, or an EmDash upgrade hook so the list call is not on the request path. -
[needs fixing]
packages/plugins/forms/src/handlers/submit.ts:93-100Honey-pot submissions return
submissionIdandreceiptIdbut never persist a submission, so the receipt ID cannot be resolved later. This contradicts the PR’s stated contract that IDs are returned only after the delivery plan is durably stored. Either omit the IDs from the decoy response or generate the IDs after the honey-pot check.if (settings.spamProtection === "honeypot") { if (input.data._hp) { // Honeypot triggered — return success silently return { success: true, message: settings.confirmationMessage }; } }
|
I have read the CLA Document and I hereby sign the CLA |
|
recheck |
|
Closing at the author's request. This upstream submission was opened without the repository owner's explicit approval. |
What does this PR do?
Adds a durable Forms delivery outbox so persisted submissions have stable submission and receipt IDs, bounded delivery leases, retry/backoff state, terminal outcomes, and sanitized receipt/health readback. Email and webhook delivery use the same stored plan, while webhook requests carry a stable idempotency key. The plugin self-heals its delivery task for already-active installations before accepting a new submission.
EmDash plugin storage does not expose compare-and-set. Claims therefore use a durable lease, unique token, and read-back verification. The implementation documents the residual cross-isolate duplicate window; strict exactly-once effects still require provider-side idempotency.
Closes #
Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runmessages.pochanges except in translation PRs — a workflow extracts catalogs on merge tomain.AI-generated code disclosure
Screenshots / test output
@emdash-cms/plugin-forms.