Skip to content

feat(email): detach provider index from legacy graph - #1167

Merged
kentcdodds merged 3 commits into
mainfrom
cursor/mailbox-step5-user-writeoff
Aug 3, 2026
Merged

kentcdodds merged 3 commits into
mainfrom
cursor/mailbox-step5-user-writeoff

Conversation

@kentcdodds

@kentcdodds kentcdodds commented Aug 3, 2026 •

Copy link
Copy Markdown
Owner

Summary

Step 5a prerequisite: rebuilds email_outbound_provider_index without its FK to the USER D1 message graph while preserving all rows, keys, and indexes.

  • USER message IDs become opaque owner-scoped pointers into Mailbox
  • system provider links are prohibited (production verified zero)
  • explicit index cleanup replaces FK cascade during the transition
  • admin status exposes foreignKeyDetached for production gating
  • rollback-compatible email_messages DELETE trigger preserves the previous worker’s cascade semantics; no live authority flips here

No data/table drop; no USER behavior change.

Verification

  • 1,979 unit tests and 48 focused migration/retention/provider tests pass
  • Independent reviews confirm row preservation, trigger-atomic rollback cleanup, and no remaining high/medium findings

Conductor report

  • STATUS: done
  • Sequence step: 5a provider-index prerequisite
  • Risk: medium, non-destructive table rebuild preserving data
  • Backup gate: recorded sha256 7787f8c9…; fresh pre-drop backup deferred until destructive 5b
  • Merged/deployed: pending self-merge
  • Next: self-merge/deploy, verify detached parity/trigger, then fresh Mailbox-only write branch

Cursor ManagePullRequest is pinned to the original run branch; this fresh-branch PR uses the authenticated Kent helper.


Note

Medium Risk
Non-destructive D1 table rebuild on a global webhook lookup table with documented rollback constraints; incorrect deploy ordering or skipping the foreignKeyDetached gate could leave stale index rows or unsafe rollback assumptions.

Overview
Step 5a prerequisite for the Mailbox USER graph cutover: migration 0132-email-outbound-provider-index-detach.sql rebuilds email_outbound_provider_index without the message_id foreign key to legacy email_messages, copies all rows, and adds a user_id <> 'system:email' CHECK so operator mail cannot enter the global provider reverse index.

A compatibility trigger on email_messages DELETE keeps the old FK cascade behavior for rollback-era code paths; explicit message deletes in repo.ts and retention now also remove matching index rows in the same db.batch. Admin mailbox maintenance status adds outboundProviderIndex.foreignKeyDetached (via PRAGMA foreign_key_list) as the production gate before later 5a authority flips.

Architecture docs spell out the ordered 5a/5b cutover and rollback rules (frozen D1 graph after Mailbox-only writes; trigger removed in 5b). No live USER authority change in this deploy.

Reviewed by Cursor Bugbot for commit dd8e7e5. Bugbot is set up for automated code reviews on this repo. Configure here.

Summary by CodeRabbit

  • New Features

    • Added staged support for managing user email data through Mailbox while preserving compatibility during migration.
    • Provider indexes now exclude system emails and support owner-scoped lookups and cleanup.
    • Added maintenance status reporting to confirm provider-index schema readiness.
  • Bug Fixes

    • Email deletion now reliably removes related provider-index entries.
    • Prevented invalid system-email provider-index entries.
  • Documentation

    • Expanded migration, rollback, backup, and production verification guidance.

Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
@coderabbitai

coderabbitai Bot commented Aug 3, 2026 •

Copy link
Copy Markdown

Review Change Stack

πŸ“ Walkthrough

Walkthrough

The PR detaches the outbound provider-index foreign key, adds compatibility and explicit deletion cleanup, rejects system-email rows, exposes detachment status, updates tests, records the migration, and documents the staged USER graph transition.

Changes

Outbound provider index migration

Layer / File(s) Summary
Detach the provider-index foreign key
packages/worker/migrations/..., packages/worker/src/email/test-schema.ts, packages/worker/src/email/outbound-provider-index-detach-migration.node.test.ts, tools/migration-ledger.json
The migration rebuilds the index without the legacy foreign key, preserves valid rows and indexes, excludes system:email, and adds a delete trigger. Tests cover failure, success, lookup, rollback cleanup, and integrity.
Enforce ownership and explicit deletion cleanup
packages/worker/src/email/repo.ts, packages/worker/src/email/outbound-provider-index.workers.test.ts, packages/worker/src/email/system-email-authority.workers.test.ts, packages/worker/src/app/retention.*
Deletion paths explicitly remove provider-index rows. Schemas and tests reject system-email provider-index entries.
Report detachment status
packages/worker/src/email/outbound-provider-index.ts, packages/worker/src/admin/mailbox-maintenance.*, packages/worker/src/mcp/capabilities/admin/admin-mailbox-maintenance.*
Maintenance status checks for the detached foreign key and exposes foreignKeyDetached in the administrative result.
Document the staged graph transition
docs/contributing/architecture/data-storage.md
The architecture documentation describes migration stages, rollback constraints, provider-index rules, and production status checks.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • kentcdodds/kody#1153: Introduces the outbound provider reverse index that this PR migrates.
  • kentcdodds/kody#1160: Also changes email deletion and provider-index cleanup in packages/worker/src/email/repo.ts.
  • kentcdodds/kody#1163: Also changes system:email provider-index handling and ownership boundaries.
πŸš₯ Pre-merge checks | βœ… 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
βœ… Passed checks (4 passed)
Check name Status Explanation
Title check βœ… Passed The title clearly summarizes the main change: detaching the provider index from the legacy email graph.
Description check βœ… Passed The description explains the intent, changes, risks, testing, deployment state, and next steps, although it uses Verification instead of the template's Testing heading.
Linked Issues check βœ… Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check βœ… Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches πŸ’‘ 1
πŸ“ Generate docstrings πŸ’‘
  • Create stacked PR
  • Commit on current branch
πŸ§ͺ Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cursor/mailbox-step5-user-writeoff

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❀️ Share

Comment @coderabbitai help to get the list of available commands.

cursoragent and others added 2 commits August 3, 2026 05:09
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
@kentcdodds
kentcdodds marked this pull request as ready for review August 3, 2026 05:11
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

πŸ”Ž Preview deployed: https://kody-pr-1167.kody-a99.workers.dev

Worker: kody-pr-1167
D1: kody-pr-1167-db
KV: kody-pr-1167-oauth-kv

Mocks:

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/worker/src/app/retention.ts (1)

704-721: πŸ—„οΈ Data Integrity & Integration | πŸ”΅ Trivial | ⚑ Quick win

Add explicit provider-index cleanup here to match the pattern used in repo.ts.

This function relies solely on the compatibility trigger to clean up email_outbound_provider_index rows when messages are deleted. The migration comment states this trigger is temporary: "Step 5b removes this compatibility trigger with the legacy table." Once that trigger is removed, this retention path will stop cleaning up provider-index rows for pruned messages, leaving orphaned rows.

repo.ts's deleteEmailMessageById and deleteEmailMessageProjectionById already add an explicit DELETE FROM email_outbound_provider_index WHERE message_id = ? in the same atomic batch, specifically so cleanup does not depend on the trigger. Apply the same explicit-deletion pattern here, batched with the email_attachments/email_messages deletes, so this path does not silently regress when the trigger is removed.

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/worker/src/app/retention.ts` around lines 704 - 721, Add explicit
batched deletion of email_outbound_provider_index rows for messageIds in the
retention deletion flow, alongside the existing email_attachments and
email_messages operations. Follow the atomic cleanup pattern used by
deleteEmailMessageById and deleteEmailMessageProjectionById, and do not rely on
the compatibility trigger.
πŸ€– Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/worker/src/email/outbound-provider-index.ts`:
- Around line 29-41: Update isOutboundProviderIndexForeignKeyDetached to default
the optional result.results array to an empty array before calling .some(),
preserving the existing foreign-key matching logic and boolean result.

---

Nitpick comments:
In `@packages/worker/src/app/retention.ts`:
- Around line 704-721: Add explicit batched deletion of
email_outbound_provider_index rows for messageIds in the retention deletion
flow, alongside the existing email_attachments and email_messages operations.
Follow the atomic cleanup pattern used by deleteEmailMessageById and
deleteEmailMessageProjectionById, and do not rely on the compatibility trigger.
πŸͺ„ Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
βš™οΈ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ece5ac85-2082-43d7-a68e-d64d0bacc764

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between 97479d8 and dd8e7e5.

πŸ“’ Files selected for processing (15)
  • docs/contributing/architecture/data-storage.md
  • packages/worker/migrations/0132-email-outbound-provider-index-detach.sql
  • packages/worker/src/admin/mailbox-maintenance.node.test.ts
  • packages/worker/src/admin/mailbox-maintenance.ts
  • packages/worker/src/app/retention.node.test.ts
  • packages/worker/src/app/retention.ts
  • packages/worker/src/email/outbound-provider-index-detach-migration.node.test.ts
  • packages/worker/src/email/outbound-provider-index.ts
  • packages/worker/src/email/outbound-provider-index.workers.test.ts
  • packages/worker/src/email/repo.ts
  • packages/worker/src/email/system-email-authority.workers.test.ts
  • packages/worker/src/email/test-schema.ts
  • packages/worker/src/mcp/capabilities/admin/admin-mailbox-maintenance.node.test.ts
  • packages/worker/src/mcp/capabilities/admin/admin-mailbox-maintenance.ts
  • tools/migration-ledger.json

Comment on lines +29 to +41
export async function isOutboundProviderIndexForeignKeyDetached(
db: D1Database,
): Promise<boolean> {
const result = await db
.prepare(`PRAGMA foreign_key_list(email_outbound_provider_index)`)
.all<{ table: string; from: string; to: string }>()
return !result.results.some(
(foreignKey) =>
foreignKey.table === 'email_messages' &&
foreignKey.from === 'message_id' &&
foreignKey.to === 'id',
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟑 Minor | ⚑ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate relevant files =="
fd -a 'outbound-provider-index|repo\.ts|retention\.ts|package\.json|pnpm-lock|package-lock|yarn\.lock' . | sed 's#^\./##' | head -200

echo
echo "== find D1Result definitions/usages in installed deps/types if present =="
fd -a 'index.d.ts|cloudflare' . | rg '(`@cloudflare/workers-types`|workers-types)' || true

echo
echo "== inspect target file =="
target='packages/worker/src/email/outbound-provider-index.ts'
if [ -f "$target" ]; then
  wc -l "$target"
  cat -n "$target" | sed -n '1,120p'
fi

echo
echo "== inspect .all call sites in reviewed-ish files =="
for f in $(fd 'repo\.ts|retention\.ts' packages); do
  echo "--- $f"
  rg -n '\.all\(|result\.results|results\?' "$f" || true
done

Repository: kentcdodds/kody

Length of output: 9973


🌐 Web query:

@cloudflare/workers-types D1Result results optional results?: T[]

πŸ’‘ Result:

In the @cloudflare/workers-types definitions, the results property within the D1Result type is defined as optional (results?: T[]) [1][2]. This design reflects the nature of D1 operations: 1. Consistency with Runtime: The D1Result object is returned by methods like run and batch [3][4]. While a successful query often populates the results array with an array of objects [3][4], write operations (such as INSERT, UPDATE, or DELETE) may not return rows, in which case the results property may be empty or not present in the same way depending on the specific execution context [4]. 2. TypeScript Usage: Because the property is optional (marked with?), TypeScript users must account for the possibility that results might be undefined [1][2]. When processing a D1Result, it is standard practice to check for the existence of the property or handle it as an array (e.g., results || []) before iterating over it [4]. 3. Documentation vs. Types: While official Cloudflare documentation often illustrates D1Result with a results array [3][5][4], the TypeScript definition provided in @cloudflare/workers-types explicitly includes the optional modifier to ensure type safety, acknowledging that not every D1 operation yields a result set [1][2].

Citations:


🌐 Web query:

WorkerD1Types@cloudflare/workers-types D1Result

πŸ’‘ Result:

The D1Result interface is a standard return type for specific Cloudflare D1 operations in the @cloudflare/workers-types package [1][2]. It represents the outcome of database queries executed via D1PreparedStatement.run or D1Database.batch [3][4]. Key Characteristics: - The D1Result type is generic, where T represents the expected shape of the database rows [1][2]. - It is returned by D1PreparedStatement.run and D1Database.batch [3][4]. Note that D1Database.exec returns a different type, D1ExecResult [4][5]. Typical Structure: While implementation details in type definition files may vary slightly based on versioning, the official D1 documentation defines the D1Result object as follows [4][5]: { success: boolean, // true if the operation was successful meta: { served_by: string, // Cloudflare backend version served_by_region: string, // Region of the execution served_by_primary: boolean, // Whether the primary instance was used timings: { sql_duration_ms: number }, // SQL execution duration duration: number, // Total duration in ms changes: number, // Number of changes made last_row_id: number, // Last inserted row ID changed_db: boolean, // Whether the database was changed size_after: number, // Database size after the query rows_read: number, // Rows scanned rows_written: number, // Rows written total_attempts: number // Number of attempts including retries }, results: array | null // Array of rows, or null if not applicable } Usage Recommendation: Cloudflare now recommends using the wrangler types command to generate types based on your Worker's compatibility date and configuration flags, rather than relying solely on the @cloudflare/workers-types package [6][7]. This ensures that the type definitions accurately match the runtime APIs available to your specific Worker [6].

Citations:


Guard the results array before calling .some().

D1Result<T>.results is optional, and this function calls result.results.some(...) directly. Use result.results ?? [] so isOutboundProviderIndexForeignKeyDetached does not throw when the D1 result lacks results.

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/worker/src/email/outbound-provider-index.ts` around lines 29 - 41,
Update isOutboundProviderIndexForeignKeyDetached to default the optional
result.results array to an empty array before calling .some(), preserving the
existing foreign-key matching logic and boolean result.

@kentcdodds
kentcdodds merged commit 3045642 into main Aug 3, 2026
18 checks passed
@kentcdodds
kentcdodds deleted the cursor/mailbox-step5-user-writeoff branch August 3, 2026 05:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants