Skip to content

Enable disaster-recovery backups: multi-store exporter, Access-gated DR UI, one-button restore - #904

Merged
kody-bot merged 15 commits into
mainfrom
cursor/dr-backup-enable-ui-d815
Jul 23, 2026
Merged

kody-bot merged 15 commits into
mainfrom
cursor/dr-backup-enable-ui-d815

Conversation

@kentcdodds

@kentcdodds kentcdodds commented Jul 23, 2026 •

Copy link
Copy Markdown
Owner

Enables the previously inert disaster-recovery system end to end and gives it an operator UI. The DR control plane is already deployed, enabled, and verified live (see "Live verification" below).

What this does

Production side (packages/worker)

  • New dr_export scheduled lane (00:30–02:10 UTC window, resumable ~20s ticks) stages the canonical non-D1 stores into the DR-account R2 bucket over the S3 API: StorageRunner durable-object dumps (NDJSON per storage id), EMAIL_BLOBS + COMMUNITY_ASSETS objects deduplicated into a content-addressed blobs/sha256/{hash} store, and published package/job source snapshots from BUNDLE_ARTIFACTS_KV. Gated by DR_EXPORT_ENABLED + credentials; progress writes are etag-conditional to prevent overlapping-cron races.
  • StorageRunner.importStorage paged replace RPC and a fail-closed POST /__maintenance/dr-restore bearer-secret endpoint (constant-time compare) that restores DO/R2/KV state from a sealed day in bounded chunks; missing dumps/blobs or digest mismatches hard-fail.
  • .github/workflows/dr-escrow.yml + tools/disaster-recovery/seal-escrow.ts: one-click workflow that seals SECRET_STORE_KEY under an operator passphrase (PBKDF2 600k + AES-256-GCM) and uploads the sealed blob to the DR bucket. Plaintext never leaves the job.

DR control plane (packages/backup-control-plane)

  • New fetch handler protected by Cloudflare Access plus in-worker JWT verification (Cf-Access-Jwt-Assertion: RS256 against the team JWKS with refresh cooldown, iss/aud pins, email allowlist me@kentcdodds.com), Sec-Fetch-Site: same-origin on all POSTs.
  • Server-rendered dashboard: enable gates, live source D1 size, per-day D1 manifest / staging / sealed-full-manifest status with Ed25519 signature verification, escrow presence.
  • Actions: run backup now · seal day (verifies staged checksums AND that every referenced content-addressed blob exists) · isolated restore drill (fresh D1 in the DR account, never production) · graduated production restore (prepare → typed database-name confirmation + 10-minute HMAC token whose contents derive the workflow instance id, making replays collide → durable restore Workflow that first captures a fresh pre-restore safety export, then imports via the D1 API, then drives chunked dr-restore calls; any restore warnings error the workflow).
  • Hourly cron auto-seals complete staged days into immutable daily/full/{day}/ with a new signed full-backup manifest (packages/shared/src/backup-full-manifest.ts).

Live-API fixes the first real runs surfaced (the pre-existing export client had never run against production):

  • The D1 export API returns status: "active" while processing — the old parser treated that as malformed and would have failed every night.
  • Completed export results expire from the polling API within seconds — the old 15s initial sleep guaranteed a miss. Polling is now immediate with short early intervals and bounded restart-on-expiry, and the object key follows the restarted bookmark.
  • DigestStream is crypto.DigestStream in the Workers runtime, not a bare global; the test harness had masked this by patching globalThis.

Live verification (all against production data)

  • Deployed to the KCD account as kody-production-d1-backups at kody-dr.kentcdodds.com (workers.dev disabled), behind a new Cloudflare Access app pinned to me@kentcdodds.com; unauthenticated requests 302 to the Access login and the worker independently verifies the JWT.
  • Export benchmark: a clean production D1 export (117 MB DB) completes in ~6 s producing ~101 MB SQL — far under the 30-minute budget and 4.5 GB ceiling; both enable gates are now true.
  • First real backup completed end-to-end: SQL object + Ed25519-signed manifest in locked R2 (sha256 cadc1498…), signature verified against the pinned public key.
  • Isolated restore drill passed: the backup SQL (digest-verified against the manifest) restores cleanly — PRAGMA quick_check ok, foreign_key_check clean, 63 tables, 30 users / 96 jobs / 74 packages / 176 emails present.
  • Trust registries pin the reviewed identities and the live verifying key (kody-dr-2026-07); guardrail tests pin the exact registry contents.

Remaining manual steps (documented in the runbook)

  • Add GitHub Actions secrets SECRET_ESCROW_PASSPHRASE + DR_BACKUP_* (R2 token) and run the dr-escrow workflow once.
  • First multi-store sealed day appears automatically after tonight's exporter + 02:15 UTC backup runs.

Testing

  • npm run validate green locally (format, lint, typecheck, 1270+ unit tests, Playwright E2E, MCP E2E, structure checks).
  • Live evidence in the agent walkthrough: deployed dashboard, Access gate, signed manifest verification, benchmark, restore drill.
System recap — extends the backup control plane (medium risk)

Mode: recap · Base: main @ d073014 · Head: 26309ab

Classification: extends — no new primitive ids; backup-control-plane is materially extended (multi-store capture, operator UI, restore flows) and its ownership roots now include the production-side exporter and shared contracts.

Primitives touched

Primitive Group Impact
backup-control-plane storage extends — fetch UI + Access JWT auth, sealing, drill, restore Workflow, staging/full-manifest contracts, populated trust registries, live-API export polling fixes
durable-storage assistant extends — importStorage paged replace RPC (restore counterpart of exportStorage)
scheduled-cron platform extends — new isolated dr_export lane; control-plane hourly cron also auto-seals
secrets platform composes — SECRET_STORE_KEY sealed-escrow workflow; store code itself unchanged

System map

The nightly exporter stages DO/R2/KV state into the DR bucket where the control plane seals and signs it; the restore path runs the same boundary in reverse under Access-gated confirmation.

Legend: green = composes (wiring only) · amber = extended by this PR · red = new primitive · gray = context (unchanged, included only when an edge crosses it).

flowchart LR
	scheduledCron["scheduled-cron<br/>Scheduled handler"]:::extended
	durableStorage["durable-storage<br/>Durable storage buckets"]:::extended
	r2Stores["r2-object-stores<br/>EMAIL_BLOBS / COMMUNITY_ASSETS"]:::untouched
	backupCP["backup-control-plane<br/>Production backup control plane"]:::extended
	secretsPrim["secrets<br/>Secret store"]:::touched
	scheduledCron -->|"dr_export lane: exportStorage pages → staging NDJSON"| durableStorage
	scheduledCron -->|"list + sha256 → blobs/sha256/{hash} (S3 SigV4)"| r2Stores
	scheduledCron -->|"staging/{day}/… + summary.json"| backupCP
	backupCP -->|"seal + Ed25519 full manifest; POST /__maintenance/dr-restore (bearer, chunked)"| durableStorage
	secretsPrim -->|"dr-escrow.yml seals SECRET_STORE_KEY → escrow/ (PBKDF2+AES-GCM)"| backupCP
	classDef touched fill:#1a7f37,color:#fff
	classDef extended fill:#9a6700,color:#fff
	classDef added fill:#cf222e,color:#fff
	classDef untouched fill:#57606a,color:#fff
Loading

Change flow

sequenceDiagram
	participant Op as Operator (Access-authenticated)
	participant CP as DR control plane worker
	participant CF as Cloudflare API
	participant PW as kody-production
	Op->>CP: POST /actions/restore/prepare (day)
	CP-->>Op: confirm page (typed DB name + HMAC token, 10 min)
	Op->>CP: POST /actions/restore/execute
	CP->>CP: start RESTORE_WORKFLOW (id derived from token → replay collides)
	CP->>CF: pre-restore safety export → pre-restore/{day}/…
	CP->>CF: D1 import (sealed SQL, sha256-verified)
	loop until done
		CP->>PW: POST /__maintenance/dr-restore {day, cursor}
		PW->>PW: importStorage / R2 put / KV put (sha256-verified blobs)
	end
	CP-->>Op: /restore-status report (warnings ⇒ errored)
Loading

Invariants

Per-user isolation: the exporter iterates all users by design (operator-level whole-platform backup); this is a documented, code-commented exception. Restore writes back the same per-user-scoped keys/ids it captured. Fail-closed posture preserved: enable gates, empty-secret 503s, identity allowlists, signature + digest verification before any restore write.

Open in Web Open in Cursor 

Summary by CodeRabbit

  • New Features
    • Added production disaster-recovery control plane with authenticated operator dashboard, backup actions (enqueue/seal/drill), and graduated production restore.
    • Implemented resilient DR export/restore worker maintenance processing, including time-windowed export and chunked restore ticks.
    • Added DR secret escrow support with a manual/semi-automated sealing workflow for uploading versioned sealed secrets.
  • Security
    • Strengthened operator access using validated access tokens, same-origin CSRF enforcement, restore confirmation HMAC tokens, and fail-closed behavior.
    • Added constant-time secret/token comparisons for maintenance authorization.
  • Documentation / Tests
    • Rewrote DR runbooks and expanded environment-variable guidance.
    • Added/updated extensive automated tests across auth, sealing, export/restore, and verification flows.

@coderabbitai

coderabbitai Bot commented Jul 23, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@cursor[bot], you've reached your PR review limit, so we couldn't start this review.

Next review available in: 40 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: bb9ff412-5bba-40a1-a9af-4a6c8c892146

📥 Commits

Reviewing files that changed from the base of the PR and between 1065d07 and 5fd03b4.

📒 Files selected for processing (11)
  • .github/workflows/dr-escrow.yml
  • packages/backup-control-plane/access-auth.node.test.ts
  • packages/backup-control-plane/access-auth.ts
  • packages/backup-control-plane/d1-import-api.node.test.ts
  • packages/shared/src/backup-staging.ts
  • packages/worker/src/dr/dr-restore.node.test.ts
  • packages/worker/src/dr/dr-restore.ts
  • packages/worker/src/dr/exporter.node.test.ts
  • packages/worker/src/dr/exporter.ts
  • tools/ci/backup-resources.node.test.ts
  • tools/ci/backup-resources.ts
📝 Walkthrough

Walkthrough

This PR adds production disaster-recovery export and restore infrastructure, including staged backups, signed immutable manifests, authenticated control-plane workflows, isolated restore drills, chunked production restores, secret escrow, trust registries, deployment configuration, and operator documentation.

Changes

Production disaster recovery

Layer / File(s) Summary
Shared backup contracts and transport
packages/shared/src/backup-*, packages/worker/src/dr/*, packages/worker/src/storage-runner.ts
Defines versioned staging/full-manifest contracts, S3/R2 access, environment settings, and paged storage import operations.
DR exporter
packages/worker/src/dr/exporter.ts, packages/worker/src/index.ts
Exports storage, R2 objects, and artifacts into resumable, deduplicated staging data and final summaries.
Control plane and sealing
packages/backup-control-plane/*
Adds Access JWT and CSRF checks, operator pages, full-backup sealing, manifest signing, scheduled sealing, and authenticated action routing.
Export reliability
packages/backup-control-plane/d1-export-api.ts, packages/backup-control-plane/durable-export.ts
Handles active and expired export states with bounded polling, restart behavior, and early polling intervals.
Restore workflows
packages/backup-control-plane/*restore*, packages/backup-control-plane/d1-import-api.ts
Adds restore confirmation tokens, D1 import APIs, isolated restore drills, production restore orchestration, and workflow execution.
Chunked worker restore
packages/worker/src/dr/dr-restore.ts, packages/worker/src/storage-runner.ts
Restores storage, R2 objects, and artifact snapshots through cursor-based, time-bounded maintenance ticks.
Escrow and operator configuration
.github/workflows/*, tools/disaster-recovery/*, docs/contributing/*, */wrangler.jsonc
Adds passphrase-sealed secret escrow, pinned trust registries, DR deployment settings, and updated disaster-recovery runbooks.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ProductionWorker
  participant DrBackupS3
  participant BackupControlPlane
  participant RestoreWorker
  ProductionWorker->>DrBackupS3: Write resumable staged exports
  BackupControlPlane->>DrBackupS3: Read and seal staged artifacts
  BackupControlPlane->>RestoreWorker: Start restore maintenance workflow
  RestoreWorker->>DrBackupS3: Read sealed indexes and verified blobs
  RestoreWorker-->>BackupControlPlane: Return cursor-based restore progress
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.32% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: DR backups, Access-gated control plane UI, and restore workflow.
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
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cursor/dr-backup-enable-ui-d815

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.

@kody-bot
kody-bot marked this pull request as ready for review July 23, 2026 22:38
@github-actions

github-actions Bot commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor

🔎 Preview deployed: https://kody-pr-904.kody-a99.workers.dev

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

Mocks:

@kentcdodds

Copy link
Copy Markdown
Owner Author

Bugbot review

Comment thread packages/backup-control-plane/seal-full-backup.ts Outdated
Comment thread docs/contributing/disaster-recovery.md Outdated
Comment thread packages/worker/src/dr/exporter.ts
Comment thread packages/backup-control-plane/d1-import-api.ts

export const drExportRunTimeBudgetMs = 20_000
/** Skip individual R2 objects larger than this (full-buffer ceiling). */
export const drExportMaxObjectBytes = 25 * 1024 * 1024

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

R2 skip limit contradicts runbook

Medium Severity

The nightly R2 exporter skips objects larger than drExportMaxObjectBytes (25 MiB), but the disaster-recovery runbook states objects above 100 MiB are skipped. Large MIME or assets between 25 MiB and 100 MiB are omitted from backup with only a warning, while operators may believe they are still in scope.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1c0d565. Configure here.

@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: 8

🧹 Nitpick comments (2)
packages/worker/src/storage-runner.import.node.test.ts (1)

17-56: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for empty-entries "first" page (used by DR restore to clear empty dumps).

dr-restore.ts's restoreStoragePhase calls applyImportStoragePage({mode: 'replace', replacePage: 'first', entries: []}) when a sealed dump is empty, but no test here exercises that combination.

+test('importStorage first page with empty entries still clears', async () => {
+	const storage = createMemoryStorage({ stale: true })
+	const result = await applyImportStoragePage(storage, {
+		mode: 'replace',
+		replacePage: 'first',
+		entries: [],
+	})
+	expect(result).toEqual({ ok: true, written: 0, cleared: true })
+	expect(storage.map.size).toBe(0)
+})
🤖 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/storage-runner.import.node.test.ts` around lines 17 - 56,
Add a test covering applyImportStoragePage with mode 'replace', replacePage
'first', and an empty entries array, verifying the existing storage contents are
cleared and the result reports ok, written: 0, and cleared: true. Use the same
createMemoryStorage fixture and assertions as the neighboring replace-page
tests.
packages/shared/src/backup-full-manifest.ts (1)

42-42: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Derive knownR2Labels from the shared backupR2BucketLabels.

This hardcoded set duplicates backupR2BucketLabels from backup-staging.ts (already the source of BackupR2BucketLabel imported on Line 1). If a new bucket label is added there, this validator will silently reject it, diverging the two contracts.

♻️ Proposed change
-import { type BackupR2BucketLabel } from './backup-staging.ts'
+import {
+	type BackupR2BucketLabel,
+	backupR2BucketLabels,
+} from './backup-staging.ts'
-const knownR2Labels = new Set<string>(['email-blobs', 'community-assets'])
+const knownR2Labels = new Set<string>(backupR2BucketLabels)
🤖 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/shared/src/backup-full-manifest.ts` at line 42, Update knownR2Labels
to derive its values from the shared backupR2BucketLabels symbol imported from
backup-staging.ts, rather than maintaining a hardcoded label list. Preserve
Set<string> behavior so validation automatically accepts any labels added to the
shared source.
🤖 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 @.github/workflows/dr-escrow.yml:
- Around line 12-13: Update the Checkout step using actions/checkout@v4 to
disable credential persistence by setting persist-credentials to false, while
leaving the rest of the workflow unchanged.

In `@packages/backup-control-plane/access-auth.ts`:
- Around line 48-85: Update loadJwks to enforce an explicit timeout on the
Access JWKS fetch by creating an AbortController, scheduling cancellation after
the configured timeout, and passing its signal to fetcher. Ensure the timer is
cleared after the fetch completes or fails, and preserve existing response
validation and BackupError handling.

In `@packages/backup-control-plane/restore-workflow.ts`:
- Around line 24-36: The restore workflow currently wraps all phases in the
single runProductionRestore step, causing retries to repeat
capturePreRestoreSafetyExport. Split the flow into separate step.do calls for
capture-pre-restore-safety-export, import-d1, and restore-stores, wiring each
phase’s outputs into the next while preserving the existing warning and failure
handling. Ensure retries of import or store restoration do not re-execute the
safety export.

In `@packages/backup-control-plane/wrangler.jsonc`:
- Around line 53-58: Remove the plaintext ACCESS_ALLOWED_EMAIL entry from the
tracked vars configuration in wrangler.jsonc. Configure ACCESS_ALLOWED_EMAIL as
a Worker secret via the deployment process, preserving the existing
env.ACCESS_ALLOWED_EMAIL lookup and BackupEnvironment typing without other code
changes.

In `@packages/shared/src/backup-staging.ts`:
- Around line 181-182: Update the r2Indexes validation in the relevant
staging-manifest guard to require every key to be included in
backupR2BucketLabels, while retaining the existing isFileSummary validation for
values. Match the key-validation behavior of isR2Indexes in
backup-full-manifest.ts so unexpected bucket labels are rejected.

In `@packages/worker/src/dr/dr-restore.ts`:
- Around line 236-321: Update restoreStoragePhase to verify each loaded storage
dump’s SHA-256 checksum against entry.sha256 before parsing or calling
runner.importStorage. Reuse the existing blob-verification helper used by
restoreR2Phase and restoreArtifactsPhase where applicable, and fail with the
established integrity error behavior before any destructive replace import
occurs.

In `@packages/worker/src/dr/exporter.ts`:
- Around line 466-518: In the R2 export loop, snapshot progress.r2PartialNdjson
before processing each listed page. When the inner time-budget check exits
mid-page, restore that snapshot before persistProgress so the page is
reprocessed without duplicating index entries; keep blob deduplication through
putBlobIfAbsent and preserve normal page-boundary cursor advancement.

In `@tools/disaster-recovery/seal-escrow.ts`:
- Around line 126-212: Update the write-once error created by
isWriteOnceRejection inside putSealedEscrowBlob so it no longer claims the
rejection is enforced by an escrow/ bucket lock rule. Use wording that
accurately describes the remote object as write-once and retain guidance to
rotate ESCROW_KEY_VERSION for a new key.

---

Nitpick comments:
In `@packages/shared/src/backup-full-manifest.ts`:
- Line 42: Update knownR2Labels to derive its values from the shared
backupR2BucketLabels symbol imported from backup-staging.ts, rather than
maintaining a hardcoded label list. Preserve Set<string> behavior so validation
automatically accepts any labels added to the shared source.

In `@packages/worker/src/storage-runner.import.node.test.ts`:
- Around line 17-56: Add a test covering applyImportStoragePage with mode
'replace', replacePage 'first', and an empty entries array, verifying the
existing storage contents are cleared and the result reports ok, written: 0, and
cleared: true. Use the same createMemoryStorage fixture and assertions as the
neighboring replace-page tests.
🪄 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: 076f85de-7ca7-4e6d-acb3-b8f8a4f2958d

📥 Commits

Reviewing files that changed from the base of the PR and between da376e2 and 26309ab.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (60)
  • .github/workflows/dr-escrow.yml
  • docs/contributing/architecture/primitives.yaml
  • docs/contributing/disaster-recovery.md
  • docs/contributing/environment-variables.md
  • docs/contributing/secret-rotation.md
  • docs/contributing/security.md
  • docs/contributing/setup-manifest.md
  • packages/backup-control-plane/access-auth.node.test.ts
  • packages/backup-control-plane/access-auth.ts
  • packages/backup-control-plane/backup-control-plane-test-support.ts
  • packages/backup-control-plane/backup-runtime.ts
  • packages/backup-control-plane/backup-types.ts
  • packages/backup-control-plane/control-plane-fetch.node.test.ts
  • packages/backup-control-plane/control-plane-fetch.ts
  • packages/backup-control-plane/control-plane-ui.ts
  • packages/backup-control-plane/d1-export-api.node.test.ts
  • packages/backup-control-plane/d1-export-api.ts
  • packages/backup-control-plane/d1-import-api.ts
  • packages/backup-control-plane/durable-export.node.test.ts
  • packages/backup-control-plane/durable-export.ts
  • packages/backup-control-plane/full-manifest-signing.node.test.ts
  • packages/backup-control-plane/full-manifest-signing.ts
  • packages/backup-control-plane/immutable-storage.ts
  • packages/backup-control-plane/production-restore.node.test.ts
  • packages/backup-control-plane/production-restore.ts
  • packages/backup-control-plane/restore-confirm-token.node.test.ts
  • packages/backup-control-plane/restore-confirm-token.ts
  • packages/backup-control-plane/restore-drill.node.test.ts
  • packages/backup-control-plane/restore-drill.ts
  • packages/backup-control-plane/restore-workflow.ts
  • packages/backup-control-plane/seal-full-backup.node.test.ts
  • packages/backup-control-plane/seal-full-backup.ts
  • packages/backup-control-plane/worker.ts
  • packages/backup-control-plane/wrangler.jsonc
  • packages/shared/src/backup-full-manifest.node.test.ts
  • packages/shared/src/backup-full-manifest.ts
  • packages/shared/src/backup-staging.ts
  • packages/worker/package.json
  • packages/worker/src/dr/backup-s3.ts
  • packages/worker/src/dr/dr-restore.node.test.ts
  • packages/worker/src/dr/dr-restore.ts
  • packages/worker/src/dr/exporter.node.test.ts
  • packages/worker/src/dr/exporter.ts
  • packages/worker/src/dr/sha256.ts
  • packages/worker/src/dr/storage-identity.ts
  • packages/worker/src/env-schema.ts
  • packages/worker/src/index.ts
  • packages/worker/src/index.workers.test.ts
  • packages/worker/src/maintenance-handler.node.test.ts
  • packages/worker/src/maintenance-handler.ts
  • packages/worker/src/security/public-route-hardening.workers.test.ts
  • packages/worker/src/storage-runner.import.node.test.ts
  • packages/worker/src/storage-runner.ts
  • packages/worker/wrangler.jsonc
  • tools/disaster-recovery/readme.md
  • tools/disaster-recovery/restore-trust-and-verification.node.test.ts
  • tools/disaster-recovery/seal-escrow.node.test.ts
  • tools/disaster-recovery/seal-escrow.ts
  • tools/disaster-recovery/trusted-backup-manifest-public-keys.json
  • tools/disaster-recovery/trusted-d1-restore-identities.json

Comment thread .github/workflows/dr-escrow.yml
Comment thread packages/backup-control-plane/access-auth.ts
Comment on lines +24 to +36
try {
const progress = await step.do('run-production-restore', async () =>
runProductionRestore(this.env, event.payload),
)
// Persist progress (including warnings) as step output, then fail the
// workflow instance when restore finished with any dr-restore warnings.
if (progress.phase === 'failed' || progress.warnings.length > 0) {
throw new NonRetryableError(
progress.errorMessage ?? 'production restore completed with warnings',
progress.errorCode ?? 'dr-restore-warnings',
)
}
return progress

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🌐 Web query:

Cloudflare Workflows step.do default retry behavior when callback throws

💡 Result:

In Cloudflare Workflows, when the callback provided to step.do throws an error, the default behavior depends on the type of error thrown [1][2]. If a standard Error (or any error other than a NonRetryableError) is thrown, the step will automatically retry according to the configuration defined in the step's retries policy [2][3]. The step will continue to retry until it either succeeds or reaches the configured retry limit [2][3]. If the retry limit is exceeded, the error propagates to the top level, potentially failing the Workflow instance [1][2]. If you wish to stop retries immediately and prevent the Workflow from attempting further retries for that specific step, you must throw a NonRetryableError [1][2]. Throwing a NonRetryableError causes the step to fail immediately, bypassing any remaining retry attempts and propagating the error to the top level [1][2]. You can configure the retry behavior (such as limit, delay, and backoff strategy) by passing a WorkflowStepConfig object to the step.do method [1][2]. Top results: [1], [2], [3]

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== candidate file =="
fd -a 'restore-workflow\.ts$' . | sed 's#^\./##'

echo "== outline =="
ast-grep outline packages/backup-control-plane/restore-workflow.ts || true

echo "== relevant lines =="
cat -n packages/backup-control-plane/restore-workflow.ts | sed -n '1,220p'

echo "== BackupError definitions/usages =="
rg -n "class BackupError|interface BackupError|enum BackupError|BackupError|step\.do|capturePreRestoreSafetyExport|runProductionRestore|NonRetryableError" packages/backup-control-plane packages --glob '*.ts' --glob '*.tsx' | head -n 300

Repository: kentcdodds/kody

Length of output: 30091


🌐 Web query:

site:developers.cloudflare.com/workflows build Workers API step.do retries error type throws documentation

💡 Result:

In Cloudflare Workflows, when using the step.do API, you can control retry behavior and error propagation using the following mechanisms: Retry Configuration You can configure retry behavior for a step by passing an optional WorkflowStepConfig object as the second argument to step.do [1][2][3]. This configuration allows you to define: - retries.limit: The maximum number of retry attempts (up to 10,000) [4][5]. - retries.delay: A fixed duration (as a number in milliseconds or a string) or a dynamic delay function [4][1][5]. - retries.backoff: The algorithm to use (constant, linear, or exponential) [4][5]. - timeout: A duration after which a step attempt is considered failed [4][5][2]. Non-Retryable Errors To force a Workflow step to stop retrying immediately and propagate an error to the top level (the run function), you should throw an instance of NonRetryableError [4][1][3]. Throwing this specific error prevents further retry attempts for that step [1][2]. Error Handling and Propagation - Standard Errors: If an uncaught exception (other than NonRetryableError) is thrown within a step, the Workflow runtime will follow the retry policy defined in the step's configuration [4][5]. If the retry limit is reached, the Workflow instance will transition to an Errored state [4][5]. - Catching Errors: You can wrap step.do calls in a try...catch block to handle errors manually, perform cleanup tasks, or trigger conditional logic [4][5]. - Python Specifics: In the Python SDK, while you can catch standard built-in errors, some custom or specific errors may not be re-instantiated correctly through the RPC layer. It is recommended to catch using the general Exception class when working within Workflow steps [6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== BackupError definition =="
cat -n packages/backup-control-plane/backup-policy.ts | sed -n '1,160p'

echo "== production-restore relevant definitions == "
cat -n packages/backup-control-plane/production-restore.ts | sed -n '230,415p'

echo "== production restore retryable examples =="
cat -n packages/backup-control-plane/production-restore.ts | sed -n '415,485p'

echo "== durable export step retry pattern =="
cat -n packages/backup-control-plane/durable-export.ts | sed -n '80,150p'
cat -n packages/backup-control-plane/backup-runtime.ts | sed -n '70,135p'

Repository: kentcdodds/kody

Length of output: 19285


Split restore phases into separate steps before making any phase retryable.

step.do retries non-NonRetryableError failures, and the current workflow rethrows retryable BackupErrors. A failure during the D1 import or store restore retry loop then re-executes the whole runProductionRestore callback on the next attempt, including capturePreRestoreSafetyExport; this can record the already-restored database as the pre-restore rollback snapshot instead of capturing just what failed. Separate step.do('capture-pre-restore-safety-export'), step.do('import-d1'), and step.do('restore-stores') steps would make the already-completed safety export memoized and prevent retrying it.

🤖 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/backup-control-plane/restore-workflow.ts` around lines 24 - 36, The
restore workflow currently wraps all phases in the single runProductionRestore
step, causing retries to repeat capturePreRestoreSafetyExport. Split the flow
into separate step.do calls for capture-pre-restore-safety-export, import-d1,
and restore-stores, wiring each phase’s outputs into the next while preserving
the existing warning and failure handling. Ensure retries of import or store
restoration do not re-execute the safety export.

Comment on lines +53 to +58
"ACCESS_TEAM_DOMAIN": "kentcdodds.cloudflareaccess.com",
"ACCESS_APP_AUD": "769c70e393e2652f5878af99333322184a35cc3aef53376344f8753805e42a76",
"ACCESS_ALLOWED_EMAIL": "me@kentcdodds.com",
// DR (KCD) account: isolated restore drills only, never production.
"DRILL_ACCOUNT_ID": "a41d50ecaf0ae0f86dd1824ef6729cb2",
"PRIMARY_WORKER_ORIGIN": "https://heykody.dev",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Personal email committed in plaintext config.

ACCESS_ALLOWED_EMAIL embeds a real individual's email address directly in a git-tracked vars block, where it persists in history indefinitely. Since this value is only compared against the JWT email claim at runtime, it doesn't need to be build-time visible — moving it to a Worker secret avoids the PII exposure with no behavior change.

🔒 Suggested approach
 	"vars": {
 		...
-		"ACCESS_ALLOWED_EMAIL": "me@kentcdodds.com",
 		"DRILL_ACCOUNT_ID": "a41d50ecaf0ae0f86dd1824ef6729cb2",
 		"PRIMARY_WORKER_ORIGIN": "https://heykody.dev",
 	},

Set ACCESS_ALLOWED_EMAIL via wrangler secret put ACCESS_ALLOWED_EMAIL instead, and read it from env.ACCESS_ALLOWED_EMAIL as before (it's already typed as a string on BackupEnvironment, so no code changes are needed beyond the deploy step).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"ACCESS_TEAM_DOMAIN": "kentcdodds.cloudflareaccess.com",
"ACCESS_APP_AUD": "769c70e393e2652f5878af99333322184a35cc3aef53376344f8753805e42a76",
"ACCESS_ALLOWED_EMAIL": "me@kentcdodds.com",
// DR (KCD) account: isolated restore drills only, never production.
"DRILL_ACCOUNT_ID": "a41d50ecaf0ae0f86dd1824ef6729cb2",
"PRIMARY_WORKER_ORIGIN": "https://heykody.dev",
"ACCESS_TEAM_DOMAIN": "kentcdodds.cloudflareaccess.com",
"ACCESS_APP_AUD": "769c70e393e2652f5878af99333322184a35cc3aef53376344f8753805e42a76",
// DR (KCD) account: isolated restore drills only, never production.
"DRILL_ACCOUNT_ID": "a41d50ecaf0ae0f86dd1824ef6729cb2",
"PRIMARY_WORKER_ORIGIN": "https://heykody.dev",
🧰 Tools
🪛 Betterleaks (1.6.1)

[high] 54-54: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)

🤖 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/backup-control-plane/wrangler.jsonc` around lines 53 - 58, Remove
the plaintext ACCESS_ALLOWED_EMAIL entry from the tracked vars configuration in
wrangler.jsonc. Configure ACCESS_ALLOWED_EMAIL as a Worker secret via the
deployment process, preserving the existing env.ACCESS_ALLOWED_EMAIL lookup and
BackupEnvironment typing without other code changes.

Comment thread packages/shared/src/backup-staging.ts
Comment thread packages/worker/src/dr/dr-restore.ts
Comment on lines +466 to +518
while (progress.r2LabelIndex < backupR2BucketLabels.length) {
if (Date.now() - input.startedAtMs >= input.timeBudgetMs) return true
const label = backupR2BucketLabels[progress.r2LabelIndex]!
const bucket = r2BindingForLabel(env, label)
const listed = await bucket.list({
cursor: progress.r2ListCursor ?? undefined,
limit: 100,
})
for (const object of listed.objects) {
if (Date.now() - input.startedAtMs >= input.timeBudgetMs) {
await persistProgress(s3, session)
return true
}
if (object.size > drExportMaxObjectBytes) {
progress.warnings.push(
`Skipped ${label} object ${object.key}: size ${object.size} exceeds ${drExportMaxObjectBytes} bytes`,
)
input.counts.r2ObjectsProcessed += 1
continue
}
const body = await bucket.get(object.key)
if (!body) {
progress.warnings.push(
`Missing ${label} object during export: ${object.key}`,
)
input.counts.r2ObjectsProcessed += 1
continue
}
const bytes = new Uint8Array(await body.arrayBuffer())
const digest = await sha256Hex(bytes)
await putBlobIfAbsent({ s3, sha256: digest, bytes, progress })
const indexEntry = {
key: object.key,
size: bytes.byteLength,
sha256: digest,
} satisfies R2IndexEntry
progress.r2PartialNdjson += ndjsonLine(indexEntry)
input.counts.r2ObjectsProcessed += 1
}
if (listed.truncated) {
progress.r2ListCursor = listed.cursor
await persistProgress(s3, session)
continue
}
const objectKey = stagingR2IndexKey(progress.day, label)
const body = progress.r2PartialNdjson
await s3.put(objectKey, body, { contentType: 'application/x-ndjson' })
progress.r2Completed[label] = await fileSummary(objectKey, body)
progress.r2LabelIndex += 1
progress.r2ListCursor = null
progress.r2PartialNdjson = ''
await persistProgress(s3, session)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Mid-page budget exhaustion duplicates R2 index entries on resume.

The inner budget check persists progress and returns without advancing r2ListCursor. Because R2's list cursor only advances at page boundaries, the next tick re-lists the same page and re-appends the already-processed objects into progress.r2PartialNdjson, producing duplicate rows in the sealed R2 index. In the worst case (one page never fits the budget) the phase never makes progress. Snapshot the partial NDJSON at page start and restore it on mid-page exit so resume re-processes the page cleanly (already-written blobs are deduped by the HEAD in putBlobIfAbsent).

Note the test harness's createR2 mock never returns truncated: true and no test exhausts the budget mid-R2-page, so this path is currently uncovered.

🐛 Proposed fix
 		const listed = await bucket.list({
 			cursor: progress.r2ListCursor ?? undefined,
 			limit: 100,
 		})
+		// R2 list cursors only advance at page boundaries, so a mid-page
+		// stop cannot resume where it left off. Remember the buffer at page
+		// start and roll back on exit so the next tick re-lists this page
+		// without duplicating index rows (blobs are deduped via HEAD).
+		const r2PartialAtPageStart = progress.r2PartialNdjson
 		for (const object of listed.objects) {
 			if (Date.now() - input.startedAtMs >= input.timeBudgetMs) {
+				progress.r2PartialNdjson = r2PartialAtPageStart
 				await persistProgress(s3, session)
 				return true
 			}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
while (progress.r2LabelIndex < backupR2BucketLabels.length) {
if (Date.now() - input.startedAtMs >= input.timeBudgetMs) return true
const label = backupR2BucketLabels[progress.r2LabelIndex]!
const bucket = r2BindingForLabel(env, label)
const listed = await bucket.list({
cursor: progress.r2ListCursor ?? undefined,
limit: 100,
})
for (const object of listed.objects) {
if (Date.now() - input.startedAtMs >= input.timeBudgetMs) {
await persistProgress(s3, session)
return true
}
if (object.size > drExportMaxObjectBytes) {
progress.warnings.push(
`Skipped ${label} object ${object.key}: size ${object.size} exceeds ${drExportMaxObjectBytes} bytes`,
)
input.counts.r2ObjectsProcessed += 1
continue
}
const body = await bucket.get(object.key)
if (!body) {
progress.warnings.push(
`Missing ${label} object during export: ${object.key}`,
)
input.counts.r2ObjectsProcessed += 1
continue
}
const bytes = new Uint8Array(await body.arrayBuffer())
const digest = await sha256Hex(bytes)
await putBlobIfAbsent({ s3, sha256: digest, bytes, progress })
const indexEntry = {
key: object.key,
size: bytes.byteLength,
sha256: digest,
} satisfies R2IndexEntry
progress.r2PartialNdjson += ndjsonLine(indexEntry)
input.counts.r2ObjectsProcessed += 1
}
if (listed.truncated) {
progress.r2ListCursor = listed.cursor
await persistProgress(s3, session)
continue
}
const objectKey = stagingR2IndexKey(progress.day, label)
const body = progress.r2PartialNdjson
await s3.put(objectKey, body, { contentType: 'application/x-ndjson' })
progress.r2Completed[label] = await fileSummary(objectKey, body)
progress.r2LabelIndex += 1
progress.r2ListCursor = null
progress.r2PartialNdjson = ''
await persistProgress(s3, session)
}
while (progress.r2LabelIndex < backupR2BucketLabels.length) {
if (Date.now() - input.startedAtMs >= input.timeBudgetMs) return true
const label = backupR2BucketLabels[progress.r2LabelIndex]!
const bucket = r2BindingForLabel(env, label)
const listed = await bucket.list({
cursor: progress.r2ListCursor ?? undefined,
limit: 100,
})
// R2 list cursors only advance at page boundaries, so a mid-page
// stop cannot resume where it left off. Remember the buffer at page
// start and roll back on exit so the next tick re-lists this page
// without duplicating index rows (blobs are deduped via HEAD).
const r2PartialAtPageStart = progress.r2PartialNdjson
for (const object of listed.objects) {
if (Date.now() - input.startedAtMs >= input.timeBudgetMs) {
progress.r2PartialNdjson = r2PartialAtPageStart
await persistProgress(s3, session)
return true
}
if (object.size > drExportMaxObjectBytes) {
progress.warnings.push(
`Skipped ${label} object ${object.key}: size ${object.size} exceeds ${drExportMaxObjectBytes} bytes`,
)
input.counts.r2ObjectsProcessed += 1
continue
}
const body = await bucket.get(object.key)
if (!body) {
progress.warnings.push(
`Missing ${label} object during export: ${object.key}`,
)
input.counts.r2ObjectsProcessed += 1
continue
}
const bytes = new Uint8Array(await body.arrayBuffer())
const digest = await sha256Hex(bytes)
await putBlobIfAbsent({ s3, sha256: digest, bytes, progress })
const indexEntry = {
key: object.key,
size: bytes.byteLength,
sha256: digest,
} satisfies R2IndexEntry
progress.r2PartialNdjson += ndjsonLine(indexEntry)
input.counts.r2ObjectsProcessed += 1
}
if (listed.truncated) {
progress.r2ListCursor = listed.cursor
await persistProgress(s3, session)
continue
}
const objectKey = stagingR2IndexKey(progress.day, label)
const body = progress.r2PartialNdjson
await s3.put(objectKey, body, { contentType: 'application/x-ndjson' })
progress.r2Completed[label] = await fileSummary(objectKey, body)
progress.r2LabelIndex += 1
progress.r2ListCursor = null
progress.r2PartialNdjson = ''
await persistProgress(s3, session)
}
🤖 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/dr/exporter.ts` around lines 466 - 518, In the R2 export
loop, snapshot progress.r2PartialNdjson before processing each listed page. When
the inner time-budget check exits mid-page, restore that snapshot before
persistProgress so the page is reprocessed without duplicating index entries;
keep blob deduplication through putBlobIfAbsent and preserve normal
page-boundary cursor advancement.

Comment thread tools/disaster-recovery/seal-escrow.ts
if (error instanceof BackupError && !error.retryable) {
throw new NonRetryableError(error.message, error.code)
}
throw error

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Restore workflow retries whole restore

Medium Severity

The production restore Workflow runs all of runProductionRestore inside a single step.do with default step retries. When a retryable BackupError escapes (for example transient dr-restore HTTP or network failures after D1 import), the platform can re-execute the entire step, triggering another safety export, another production D1 import, and a fresh chunked store restore with no persisted cursor.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1065d07. Configure here.

@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

🤖 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/backup-control-plane/d1-import-api.node.test.ts`:
- Around line 12-15: Update importPollSequence and both terminal-success tests
to track poll consumption and assert that exactly one expected poll response was
used. Expose the poll count or assertion from importPollSequence, then have each
test verify it after awaiting the import operation, ensuring the implementation
reaches the polling phase and observes the terminal response.
🪄 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: 974b4092-6562-4598-abda-8fef6748b61e

📥 Commits

Reviewing files that changed from the base of the PR and between 1c0d565 and 1065d07.

📒 Files selected for processing (6)
  • docs/contributing/disaster-recovery.md
  • packages/backup-control-plane/backup-types.ts
  • packages/backup-control-plane/d1-import-api.node.test.ts
  • packages/backup-control-plane/d1-import-api.ts
  • packages/backup-control-plane/seal-full-backup.node.test.ts
  • packages/backup-control-plane/seal-full-backup.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • packages/backup-control-plane/seal-full-backup.node.test.ts
  • packages/backup-control-plane/backup-types.ts
  • docs/contributing/disaster-recovery.md
  • packages/backup-control-plane/d1-import-api.ts
  • packages/backup-control-plane/seal-full-backup.ts

Comment thread packages/backup-control-plane/d1-import-api.node.test.ts Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 5fd03b4. Configure here.

const existingSummary = await s3.getText(stagingSummaryKey(day))
if (existingSummary) {
return empty('already-complete')
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Prior staging days never resume

Medium Severity

Each runDrExportTick always targets formatUtcDay(now) and returns immediately outside the 00:30–02:10 UTC window. If staging/{day}/exporter/progress.json for an earlier UTC day never reaches summary.json before the window ends, later cron ticks work on the new day and never resume the stuck day’s exporter.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5fd03b4. Configure here.

@kody-bot
kody-bot merged commit a73035b into main Jul 23, 2026
5 checks passed
@kody-bot
kody-bot deleted the cursor/dr-backup-enable-ui-d815 branch July 23, 2026 23:28
kody-bot pushed a commit that referenced this pull request Jul 28, 2026
)

* fix(dr): bound D1 row sizes so exports stay importable

D1's import path rejects statements above its ~100 KB limit
(SQLITE_TOOBIG) and exports write one INSERT per row, so a single
oversized row made every production backup un-importable (verified with
live restore drills of the 2026-07-26 export).

- add shared restore-safety limits (packages/shared/src/backup-restore-safety.ts)
- drop oversized package-invocation replay caches instead of storing them
  (duplicates get the existing idempotency_response_unavailable outcome)
- truncate stored email body copies at 64 KiB (raw MIME in R2 stays canonical)
- reject oversized value_set writes with a storage-bucket hint
- migration 0102 bounds existing rows (81 oversized invocation rows in
  production, 2 oversized email bodies)

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

* fix(dr): stop re-downloading from D1 at backup finalization

The finalize step re-polled D1 with the cached bookmark and required the
fresh download to byte-match the stored object. When the short-lived
poll result had expired, the refresh started a new export of a newer
database state, so the comparison was doomed whenever production wrote
anything in between — roughly every other nightly backup errored
terminally with existing-object-source-mismatch and left orphaned
~107 MB immutable objects behind.

Finalization now verifies the stored object against the durable
upload-step digest (size, R2 ETag, full SHA-256 re-read) and never
polls D1 again. It also measures statement lengths while streaming
(quote-aware) and persists <objectKey>.stats.json beside the SQL;
oversized statements log backup-unrestorable-statements with failure
status because such a backup cannot be re-imported through the D1 API.

Also refreshes TRUSTED_RESTORE_BASELINE_SHA256 (stale since #904) for
the current migration set including 0102.

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

* feat(dr): finish nightly staging and alert when backups go missing

The staging exporter's 00:30-02:10 UTC window was never enough at
production scale: every night ended mid-artifacts-phase, so
exporter/summary.json was never written and no day could ever be
sealed. Extend the window to 00:30-06:10 (completed days exit via the
cheap already-complete check) and add a 06:15 watchdog lane that fails
loudly to Sentry when the summary is still missing — window exhaustion
was previously silent.

Documents the new schedule, the restore-safe row size contract, and the
TRUSTED_RESTORE_BASELINE_SHA256 recipe in the disaster-recovery runbook.

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

* fix(email): resolve import conflict from main merge

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
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.

3 participants