Skip to content

feat(exports): add queued user data exports - #5156

Merged
pandemicsyn merged 19 commits into
mainfrom
feat/user-data-export
Aug 9, 2026
Merged

feat(exports): add queued user data exports#5156
pandemicsyn merged 19 commits into
mainfrom
feat/user-data-export

Conversation

@pandemicsyn

@pandemicsyn pandemicsyn commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds the Phase 1 foundation for Kilo-admin user data exports:

  • Adds a /data-exports account page for requesting exports, monitoring status, and downloading completed artifacts.
  • Adds a dedicated Cloudflare Worker using Queues, Hyperdrive, and private R2 multipart uploads.
  • Reads approved source data through a separate read-replica binding and enforces user ownership in every source query.
  • Exports the matched Kilo account's approved identity, profile, usage-total, preference, safety-identifier, attribution, and signup fields with native JSON value types.
  • Uses a fixed data cutoff of 2026-08-03T00:00:00.000Z; records created after that timestamp are excluded.
  • Streams pages through native gzip compression and writes resumable, uniform 5 MiB R2 multipart checkpoints.
  • Adds PostgreSQL job, part, and transactional outbox state with leases, generation fencing, retries, expiry, and email-delivery state.
  • Reuses the existing shared production Hyperdrive for primary state; only the read-replica Hyperdrive is a new rollout resource.
  • Preserves account-deletion cleanup through durable R2 deletion tombstones, including in-flight multipart aborts.
  • Sends export-ready notifications through the existing Mailgun integration.
  • Uses the existing x-internal-api-key / INTERNAL_API_SECRET contract for web-to-Worker and Worker-to-web communication.
  • Implements five-minute, attachment-only R2 download signing with independent ownership/readiness validation.
  • Restricts navigation, the server page, and all export procedures to existing Kilo admins for the initial release.
  • Registers pnpm dev:start data-export with worktree-aware ports, generated local URLs, both Hyperdrive overrides, and the shared internal API key.
  • Matches the usage-meter Worker compatibility date and compact wrangler types --include-runtime=false generation setup.
  • Leaves only Cloudflare resource, database-role, credential-binding, and production-origin setup for rollout.
flowchart TB
    Admin["Kilo admin browser"]

    subgraph Web["Kilo Web trust boundary"]
        Page["/data-exports<br/>server page: adminOnly"]
        TRPC["userExports router<br/>adminProcedure + ctx.user.id"]
        Notify["Ready-email endpoint<br/>timing-safe internal key check"]
    end

    subgraph Worker["Export Worker trust boundary"]
        Internal["user-data-export.kilosessions.ai<br/>timing-safe x-internal-api-key"]
        Consumer["Queue consumer<br/>lease + generation fencing"]
        Signer["Download signer<br/>re-checks exportId + kiloUserId<br/>ready + unexpired"]
    end

    subgraph Data["Private data plane"]
        Primary[("PostgreSQL primary<br/>existing shared Hyperdrive<br/>job state, outbox, object key")]
        Replica[("Read-only replica role<br/>matched Kilo user row<br/>and approved source tables only")]
        Queue["Cloudflare Queue + DLQ<br/>IDs/generation only; no prompts"]
        Bucket[("Private R2 bucket<br/>no public domain")]
        Tombstone[("Deletion tombstones<br/>survive user/export cascade")]
    end

    Mailgun["Mailgun transactional email"]
    Signed["5-minute presigned GET<br/>attachment; private, no-store"]

    Admin -->|"authenticated session"| Page --> TRPC
    TRPC -->|"rows scoped by ctx.user.id"| Primary
    TRPC -->|"x-internal-api-key"| Internal
    Internal --> Queue
    Queue --> Consumer
    Consumer -->|"SELECT only; user predicate;<br/>cutoff <= 2026-08-03 UTC"| Replica
    Consumer -->|"state/checkpoints"| Primary
    Consumer -->|"multipart .jsonl.gz"| Bucket
    Consumer -->|"x-internal-api-key; exportId only"| Notify --> Mailgun
    TRPC -->|"exportId + ctx.user.id"| Signer
    Signer -->|"ownership/readiness lookup"| Primary
    Signer -->|"sign exact stored key"| Signed --> Admin
    Primary -->|"account deletion copies key + upload ID"| Tombstone
    Consumer -->|"abort multipart + delete object"| Tombstone

    classDef secure fill:#103b2d,stroke:#55d68b,color:#fff
    classDef private fill:#17233d,stroke:#73a7ff,color:#fff
    class TRPC,Notify,Internal,Consumer,Signer secure
    class Primary,Replica,Queue,Bucket private
Loading
flowchart LR
    Request["Admin request"] --> Admit["Single DB transaction<br/>create queued job and generation 0 outbox"]
    Admit --> Dispatch["Immediate Queue send<br/>plus scheduled outbox recovery"]
    Dispatch --> Delivery["At-least-once delivery<br/>exportId and generation only"]
    Delivery --> Claim{"Lease claim matches<br/>current generation?"}

    Claim -->|"No: stale, duplicate, or terminal"| Ack["Acknowledge safe no-op"]
    Claim -->|"Yes"| Read["Read bounded keyset pages<br/>for one user and fixed cutoff"]
    Claim --> Attach["Create multipart and CAS-persist upload ID<br/>abort immediately if row deleted or lease lost"]
    Attach --> Read
    Read --> Upload["Stream gzip to R2<br/>uniform multipart parts"]
    Upload --> Checkpoint["One DB transaction<br/>persist all ETags and cursor<br/>advance generation and outbox"]
    Checkpoint --> More{"Sources complete?"}
    More -->|"No"| Dispatch
    More -->|"Yes"| Final["Complete multipart<br/>HEAD exact object"]
    Final --> Ready["Lease-fenced ready transition<br/>store key, size, expiry"]

    Claim -.->|"expired lease"| Recover["Reconciler clears lease<br/>re-arms current outbox"]
    Recover --> Dispatch
    Delivery -.->|"configured retry limit"| Failed["DLQ consumer marks failed<br/>reconciler aborts multipart"]
    Claim -.->|"five consecutive claims expire"| Failed

    classDef durable fill:#17233d,stroke:#73a7ff,color:#fff
    classDef guarded fill:#103b2d,stroke:#55d68b,color:#fff
    class Admit,Dispatch,Checkpoint,Ready,Recover durable
    class Claim,Attach,Upload,Final guarded
Loading

Verification

  • Reset and migrated the local development database through the repository-supported workflow.
  • Signed in as a fake Kilo admin, requested an export, and verified queued status plus duplicate-request disablement.
  • Verified the page at desktop and 375px mobile widths.
  • Signed in as a non-admin and verified /data-exports returns 404 and the navigation item is absent.
  • Additional manual verification details:

Visual Changes

Before After
No account data-export surface Desktop queued export
No mobile export workflow Mobile queued export

Reviewer Notes

  • This PR intentionally contains development-phase code only. PRIMARY_STATE_DB reuses the existing shared production Hyperdrive; it does not provision or deploy Queues, R2, the read-replica Hyperdrive, standard Worker secrets, environment values, or production migrations.
  • Focus review on cross-user ownership predicates, Queue retry/idempotency behavior, multipart gzip checkpoints, database state transitions, and account-deletion cleanup.
  • Poisoned Queue work terminates through both generation-fenced DLQ handling and a five-consecutive-claim database bound for isolate deaths.
  • The primary Queue consumer starts at concurrency five with a five-minute CPU allowance; Cloudflare's fixed 15-minute consumer wall-clock limit still applies. The DLQ consumer remains at concurrency one.
  • Five-minute private R2 signing is implemented; rollout only needs bucket-scoped read credentials, account/bucket values, and Worker bindings.
  • Sensitive Worker values use vanilla wrangler secret put secrets; this Worker intentionally does not use Secrets Store.
  • The initial release is Kilo-admin-only instead of feature-flagged.
  • Exports deliberately stop at 2026-08-03T00:00:00.000Z. No WAL replay gate is needed because the workload is intentionally historical and runs against four large read replicas.
  • The detailed deviations and rollout register is maintained outside the repository at ~/fd-plans/research/data-export.md.

Comment thread services/user-data-export/src/databases.ts
Comment thread services/user-data-export/src/worker.ts
@@ -0,0 +1,326 @@
import { getWorkerDb, pg } from '@kilocode/db/client';

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.

Moderate, Testing: This file contains the most intricate SQL in the PR (claim with lease and generation fencing, the attach CTE with the tombstone fallback, the multi CTE checkpoint, reconcile), and none of it runs against real Postgres in any test. worker.test.ts mocks the state object entirely, and the vitest-pool-workers test only covers HTTP auth and body limits. Meanwhile the web side does have a real database harness (the router and softDeleteUser tests use it).

These queries interact with CHECK constraints, partial unique indexes, and cascade rules defined in this same PR, and mocks cannot validate that interaction; the attach constraint violation flagged above is exactly the kind of failure this gap hides. The state machine here (queued to processing to finalizing to ready, lease takeover, generation fencing) is the correctness core of the feature.

Suggest an integration test that drives claim, attach, checkpoint, complete, and the reconcile sweeps against the real migrated schema, plus the deletion race (soft delete between attach and checkpoint). Reusing the web test database harness for these functions would be enough even if the R2 side stays mocked.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not implemented per project direction: test-only feedback was explicitly excluded from this feedback pass. Existing state-machine unit, runtime, migration bootstrap, and database-backed web tests remain in place.

Comment thread services/user-data-export/src/worker.ts
@pandemicsyn
pandemicsyn force-pushed the feat/user-data-export branch from 75e1a3c to 7bc6950 Compare August 9, 2026 16:57
@pandemicsyn
pandemicsyn marked this pull request as ready for review August 9, 2026 17:01
@pandemicsyn
pandemicsyn requested a review from St0rmz1 August 9, 2026 17:01

export async function POST(request: NextRequest): Promise<NextResponse> {
const secret = request.headers.get('x-internal-api-key');
if (!INTERNAL_API_SECRET || !secret || !timingSafeEqual(secret, INTERNAL_API_SECRET)) {

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.

Low, Testing: This auth guard has no test coverage. The Worker's equivalent x-internal-api-key guard is tested in test/worker.test.ts, but on the web side only delivery-state.ts is covered; nothing asserts this route returns 401 for a missing key, a wrong key, or an unset INTERNAL_API_SECRET. Since this endpoint triggers outbound email to users, it should get rejection and acceptance coverage in the same PR, consistent with how the other authorization guards in this feature are tested.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not implemented per project direction: test-only feedback was explicitly excluded from this feedback pass. The route uses the same timing-safe internal API-key contract as the tested Worker boundary.

Comment thread services/user-data-export/src/source-adapters.ts Outdated
Comment thread apps/web/src/lib/user-data-export-worker-client.ts Outdated
Comment thread packages/db/src/migrations/0209_windy_mindworm.sql
Comment thread packages/db/src/migrations/0209_nasty_paladin.sql Outdated
Comment thread packages/db/src/migrations/0209_windy_mindworm.sql
Comment thread packages/db/src/schema.ts Outdated
Comment thread packages/db/src/schema.ts
Comment thread services/user-data-export/wrangler.jsonc
Comment thread services/user-data-export/wrangler.jsonc Outdated
@kilo-code-bot

kilo-code-bot Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: 1 Issue Found | Recommendation: Address before merge

Executive Summary

Incremental review of c7d3cfa3..b5593b2b: all seven actionable prior findings are fixed at HEAD — microsecond keyset cursors end boundary duplication, ON DELETE RESTRICT closes the tombstone bypass, both drain indexes landed, the unused sha256 column is dropped, the [::1] loopback entry now matches WHATWG URL.hostname, and failed outbox dispatches increment attempt_count with capped exponential backoff; the only remaining item is the previously declined integration-test coverage for the state SQL.

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 0
Issue Details (click to expand)

WARNING

File Line Issue
services/user-data-export/src/databases.ts 1 Intricate claim/attach/checkpoint/reconcile/outbox SQL has no integration test against real Postgres (carried forward; author declined per project direction)
Files Reviewed (13 files in incremental diff)

Incremental review of c7d3cfa3..b5593b2b:

  • apps/web/src/lib/user-data-export-worker-client.ts - 0 new issues; prior '::1' finding verified fixed ('[::1]' matches WHATWG URL.hostname)
  • apps/web/src/lib/user-data-export-worker-client.test.ts - regression coverage for the [::1] allowlist case
  • packages/db/src/migrations/0209_windy_mindworm.sql (renamed from 0209_nasty_paladin.sql) - 0 new issues; ON DELETE RESTRICT verified compatible with softDeleteUser's explicit tombstone-then-delete flow; IDX_user_data_export_object_deletions_ready and partial IDX_user_data_exports_failed_multipart added; sha256 column and constraint dropped
  • packages/db/src/migrations/meta/0209_snapshot.json, packages/db/src/migrations/meta/_journal.json - generated artifacts consistent with the schema
  • packages/db/src/schema.ts - 0 new issues; mirrors the regenerated migration
  • services/user-data-export/src/contracts.ts - cursor schema now requires the six-digit UTC fraction produced by the queries
  • services/user-data-export/src/source-adapters.ts - 0 new issues; prior keyset-truncation finding verified fixed via to_char(... 'US') microsecond cursors, zone-safe on timestamptz columns
  • services/user-data-export/src/databases.ts - prior outbox-backoff finding verified fixed (recordOutboxFailure increments attempt_count, caps backoff at 1h); 1 prior testing finding carried (declined)
  • services/user-data-export/src/worker.ts - failed outbox sends now recorded; 0 new issues
  • services/user-data-export/src/worker.test.ts, contracts.test.ts, source-adapters.test.ts - regression coverage for the fixes

Fix these issues in Kilo Cloud

Previous Review Summaries (2 snapshots, latest commit c7d3cfa)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit c7d3cfa)

Status: 8 Issues Found | Recommendation: Address before merge

Executive Summary

The export pipeline's keyset pagination truncates timestamptz cursors to milliseconds and systematically duplicates boundary rows in every export, and a hard user delete outside softDeleteUser can still bypass the deletion-tombstone design via ON DELETE CASCADE.

Incremental update at c7d3cfa: both prior wrangler.jsonc findings are fixed — R2_BUCKET_NAME is now defined in production vars and the EXPORT_BUCKET binding now points at user-data-export instead of the local placeholder. The remaining findings are in files unchanged by this increment and are still valid at HEAD.

Overview

Severity Count
CRITICAL 0
WARNING 3
SUGGESTION 5
Issue Details (click to expand)

WARNING

File Line Issue
services/user-data-export/src/source-adapters.ts 52 Keyset cursor truncates created_at to milliseconds; boundary rows are re-selected and duplicated in every export, inflating row_count
packages/db/src/migrations/0209_nasty_paladin.sql 91 ON DELETE CASCADE on kilo_user_id bypasses the tombstone design; any hard delete outside softDeleteUser permanently leaks PII-bearing R2 objects
services/user-data-export/src/databases.ts 1 Intricate claim/attach/checkpoint/reconcile SQL has no integration test against real Postgres (carried forward; author declined per project direction)

SUGGESTION

File Line Issue
apps/web/src/lib/user-data-export-worker-client.ts 6 '::1' allowlist entry never matches WHATWG URL.hostname ('[::1]' keeps brackets)
packages/db/src/migrations/0209_nasty_paladin.sql 6 No index supports the pendingObjectDeletions drain (available_at, created_at, object_key)
packages/db/src/migrations/0209_nasty_paladin.sql 97 Missing partial index for the failedMultipartUploads drain (status = 'failed' AND multipart_upload_id IS NOT NULL)
packages/db/src/schema.ts 531 sha256 column is never written or read
packages/db/src/schema.ts 683 Outbox attempt_count is never incremented; failed dispatches retry every tick with no backoff or bound
Files Reviewed (2 files in incremental diff)

Incremental review of 7bc695032..c7d3cfa3:

  • services/user-data-export/wrangler.jsonc - 0 new issues; 2 prior findings verified fixed (R2_BUCKET_NAME production var added matching the binding, EXPORT_BUCKET bucket renamed to user-data-export)
  • services/user-data-export/worker-configuration.d.ts - generated file, consistent with the config change

Unchanged files carrying still-valid prior findings: services/user-data-export/src/source-adapters.ts, services/user-data-export/src/databases.ts, apps/web/src/lib/user-data-export-worker-client.ts, packages/db/src/migrations/0209_nasty_paladin.sql, packages/db/src/schema.ts.

Fix these issues in Kilo Cloud

Previous review (commit 7bc6950)

Status: 10 Issues Found | Recommendation: Address before merge

Executive Summary

The export pipeline's keyset pagination truncates timestamptz cursors to milliseconds and systematically duplicates boundary rows in every export, and the production R2 binding still points at the local placeholder bucket name.

Overview

Severity Count
CRITICAL 0
WARNING 4
SUGGESTION 6

Prior-review reconciliation: 3 of the 4 existing findings are fixed at HEAD (the backwards multipart_checkpoint_shape CHECK constraint was removed from the migration, dispatchContinuation now sends the next generation immediately after checkpoint, and isAllowedWebCallbackUrl now guards the new URL call in dispatchReadyNotifications). The databases.ts integration-test gap remains and is carried forward below.

Issue Details (click to expand)

WARNING

File Line Issue
services/user-data-export/src/source-adapters.ts 52 Keyset cursor truncates created_at to milliseconds; boundary rows are re-selected and duplicated in every export, inflating row_count
packages/db/src/migrations/0209_nasty_paladin.sql 91 ON DELETE CASCADE on kilo_user_id bypasses the tombstone design; any hard delete outside softDeleteUser permanently leaks PII-bearing R2 objects
services/user-data-export/wrangler.jsonc 44 Production EXPORT_BUCKET binding keeps the user-data-export-local placeholder bucket name
services/user-data-export/src/databases.ts 1 (carried forward from prior review, still valid) Intricate claim/attach/checkpoint/reconcile SQL has no integration test against real Postgres

SUGGESTION

File Line Issue
apps/web/src/lib/user-data-export-worker-client.ts 6 '::1' allowlist entry never matches WHATWG URL.hostname ('[::1]' keeps brackets)
packages/db/src/migrations/0209_nasty_paladin.sql 6 No index supports the pendingObjectDeletions drain (available_at, created_at, object_key)
packages/db/src/migrations/0209_nasty_paladin.sql 97 Missing partial index for the failedMultipartUploads drain (status = 'failed' AND multipart_upload_id IS NOT NULL)
packages/db/src/schema.ts 531 sha256 column is never written or read
packages/db/src/schema.ts 683 Outbox attempt_count is never incremented; failed dispatches retry every tick with no backoff or bound
services/user-data-export/wrangler.jsonc 21 R2_BUCKET_NAME is required by the download signer but defined nowhere for production
Files Reviewed (47 files)
  • services/user-data-export/src/source-adapters.ts - 1 issue
  • services/user-data-export/src/databases.ts - 1 issue (carried forward)
  • services/user-data-export/src/worker.ts - 0 new issues (2 prior findings verified fixed)
  • services/user-data-export/src/gzip.ts - no issues (memory-bounded: single 5 MiB part buffer, sequential uploads)
  • services/user-data-export/src/contracts.ts - no issues
  • services/user-data-export/src/index.ts - no issues
  • services/user-data-export/src/worker.test.ts, source-adapters.test.ts, gzip.test.ts, contracts.test.ts, index.test.ts, test/worker.test.ts - no issues
  • services/user-data-export/wrangler.jsonc - 2 issues
  • services/user-data-export/wrangler.test.jsonc, package.json, tsconfig.json, vitest.config.ts, vitest.workers.config.ts, .dev.vars.example - no issues
  • packages/db/src/migrations/0209_nasty_paladin.sql - 3 issues
  • packages/db/src/schema.ts - 2 issues
  • packages/worker-utils/src/r2-client.ts, r2-client.test.ts, package.json - no issues
  • apps/web/src/routers/user-exports-router.ts, user-exports-router.test.ts, root-router.ts - no issues (ownership predicates and admin gating verified)
  • apps/web/src/app/api/internal/user-data-exports/ready/route.ts, delivery-state.ts, delivery-state.test.ts - no issues (timing-safe key check verified)
  • apps/web/src/lib/user-data-export-worker-client.ts - 1 issue
  • apps/web/src/lib/user/index.ts, index.test.ts - no issues (tombstone write precedes export delete in one transaction)
  • apps/web/src/lib/email.ts, email.test.ts, config.server.ts - no issues
  • apps/web/src/app/(app)/data-exports/DataExportsClient.tsx - no issues (React Query refetchInterval only; no manual timers/listeners to leak; no XSS surface)
  • apps/web/src/app/(app)/data-exports/page.tsx, page.test.tsx, data-export-contract.ts, data-export-contract.test.ts - no issues
  • apps/web/src/app/(app)/components/AppSidebar.tsx, SidebarUserFooter.tsx, apps/storybook/stories/Sidebar.stories.tsx - no issues
  • apps/web/src/emails/userDataExportReady.html, emails/AGENTS.md - no issues (template vars HTML-escaped; no HTML img tags in markdown)
  • dev/local/services.ts, services.test.ts, env-sync/plan.test.ts, ENVIRONMENT.md, apps/web/.env.development.local.example - no issues
  • Generated files skimmed/excluded per review rules: worker-configuration.d.ts, migration snapshot/journal, pnpm-lock.yaml, PR asset PNGs

Fix these issues in Kilo Cloud


Reviewed by kimi-k3 · Input: 58.7K · Output: 17.8K · Cached: 704.8K

Review guidance: REVIEW.md from base branch main

@pandemicsyn
pandemicsyn merged commit 93c013a into main Aug 9, 2026
71 checks passed
@pandemicsyn
pandemicsyn deleted the feat/user-data-export branch August 9, 2026 17:55
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