From 2099316c863db774364c6dab5d513a31a1839dee Mon Sep 17 00:00:00 2001 From: "Zakariya Jabbar (Viper Droid)" Date: Wed, 27 May 2026 02:18:53 +0300 Subject: [PATCH] feat(export): stream /export/dump to R2 with DO alarm resumption (#59) The legacy /export/dump route buffers the entire dump in memory and runs synchronously, so it falls over on databases that exceed the 30s Worker timeout or the Durable Object memory ceiling (currently 1GB, soon 10GB). This change adds a streaming path that lives inside the Durable Object: - POST /export/dump kicks off a job, opens an R2 multipart upload, and returns 202 with a jobId. Supports format=sql|csv|json plus optional callbackUrl/table/chunkSize. - GET /export/dump/status/:jobId returns progress (tables, rows, bytes, parts uploaded) and a downloadUrl once status is 'completed'. - GET /export/dump/download/:jobId streams the finished object back from R2 to the client. - DELETE /export/dump/:jobId aborts an in-flight upload. The engine paginates 1000 rows at a time, buffers up to the R2 multipart 5 MiB minimum, flushes parts as they fill, and budgets each tick at 20s. When a tick yields, the leftover bytes are persisted to a temp R2 object (DO storage values are capped at 128 KiB and cannot hold the buffer directly). The DO alarm() handler dispatches dump work first, then falls through to the existing cron logic, so the two co-exist on the same alarm channel. A new [[r2_buckets]] binding named DATABASE_DUMPS gates the streaming path. The legacy GET /export/dump remains untouched for small databases and existing clients. Tests: 17 new unit tests covering the engine (mid-tick yield/resume, multipart flushing at the 5 MiB threshold, error abort, BLOB literals, empty databases, CSV/JSON formats) and the HTTP routes. --- README.md | 48 +++ src/do.ts | 257 +++++++++++- src/export/do-dump-host.ts | 95 +++++ src/export/dump-engine.test.ts | 364 +++++++++++++++++ src/export/dump-engine.ts | 497 ++++++++++++++++++++++++ src/export/dump.ts | 230 +++++++++++ src/export/streaming-dump-route.test.ts | 212 ++++++++++ src/export/streaming-dump.ts | 228 +++++++++++ src/handler.test.ts | 3 + src/handler.ts | 52 ++- src/index.ts | 5 + worker-configuration.d.ts | 1 + wrangler.toml | 7 + 13 files changed, 1994 insertions(+), 5 deletions(-) create mode 100644 src/export/do-dump-host.ts create mode 100644 src/export/dump-engine.test.ts create mode 100644 src/export/dump-engine.ts create mode 100644 src/export/streaming-dump-route.test.ts create mode 100644 src/export/streaming-dump.ts diff --git a/README.md b/README.md index 5931b1c..88e1508 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,54 @@ curl --location 'https://starbasedb.YOUR-ID-HERE.workers.dev/export/dump' \ +

Streaming SQL dumps for large databases

+The synchronous endpoint above buffers the whole dump in memory and is bounded by the 30-second Worker timeout. For databases that exceed that budget, switch to the streaming variant — it paginates rows, writes them straight into R2 over (potentially many) Durable Object alarm ticks, and lets you download the finished artifact when ready. + +Add an R2 binding called `DATABASE_DUMPS` to your `wrangler.toml`: + +
+
+[[r2_buckets]]
+binding = "DATABASE_DUMPS"
+bucket_name = "starbasedb-dumps"
+
+
+ +Kick off the job (returns 202 with a `jobId`): + +
+
+curl -X POST 'https://starbasedb.YOUR-ID-HERE.workers.dev/export/dump' \
+--header 'Authorization: Bearer ABC123' \
+--header 'Content-Type: application/json' \
+--data '{ "format": "sql", "callbackUrl": "https://hooks.example.com/dump-done" }'
+
+
+ +`format` may be `sql`, `csv`, or `json`. Optional fields: `callbackUrl` (POSTed the status view on completion), `table` (export a single table only), `chunkSize` (rows per SELECT batch; default 1000). + +Poll the status, then download once it reads `completed`: + +
+
+curl 'https://starbasedb.YOUR-ID-HERE.workers.dev/export/dump/status/JOB_ID' \
+--header 'Authorization: Bearer ABC123'
+
+curl 'https://starbasedb.YOUR-ID-HERE.workers.dev/export/dump/download/JOB_ID' \
+--header 'Authorization: Bearer ABC123' \
+--output database_dump.sql
+
+
+ +To cancel an in-flight job: + +
+
+curl -X DELETE 'https://starbasedb.YOUR-ID-HERE.workers.dev/export/dump/JOB_ID' \
+--header 'Authorization: Bearer ABC123'
+
+
+

JSON Data Export

 
diff --git a/src/do.ts b/src/do.ts
index b6bb2b6..5bb4468 100644
--- a/src/do.ts
+++ b/src/do.ts
@@ -1,4 +1,17 @@
 import { DurableObject } from 'cloudflare:workers'
+import { runTick } from './export/dump-engine'
+import { createDumpHost, jobStateKey } from './export/do-dump-host'
+import {
+    DumpFormat,
+    DumpJobOptions,
+    DumpJobState,
+    DumpJobStatusView,
+    newJobState,
+    toStatusView,
+} from './export/streaming-dump'
+
+const ACTIVE_DUMP_KEY = 'dump:active'
+const DUMP_RESUME_DELAY_MS = 1_000
 
 export class StarbaseDBDurableObject extends DurableObject {
     // Durable storage for the SQL database
@@ -9,6 +22,8 @@ export class StarbaseDBDurableObject extends DurableObject {
     public connections = new Map()
     // Store the client auth token for requests back to our Worker
     private clientAuthToken: string
+    // R2 bucket binding for streaming dump output (optional).
+    private dumpBucket: R2Bucket | undefined
 
     /**
      * The constructor is invoked once upon creation of the Durable Object, i.e. the first call to
@@ -22,6 +37,7 @@ export class StarbaseDBDurableObject extends DurableObject {
         this.clientAuthToken = env.CLIENT_AUTHORIZATION_TOKEN
         this.sql = ctx.storage.sql
         this.storage = ctx.storage
+        this.dumpBucket = env.DATABASE_DUMPS
 
         // Install default necessary `tmp_` tables for various features here.
         const cacheStatement = `
@@ -72,6 +88,10 @@ export class StarbaseDBDurableObject extends DurableObject {
             deleteAlarm: this.deleteAlarm.bind(this),
             getStatistics: this.getStatistics.bind(this),
             executeQuery: this.executeQuery.bind(this),
+            startDumpJob: this.startDumpJob.bind(this),
+            getDumpJob: this.getDumpJob.bind(this),
+            getDumpDownloadBody: this.getDumpDownloadBody.bind(this),
+            cancelDumpJob: this.cancelDumpJob.bind(this),
         }
     }
 
@@ -105,12 +125,28 @@ export class StarbaseDBDurableObject extends DurableObject {
     }
 
     async alarm() {
+        // Dispatch streaming dump work first so a long-running export does not
+        // get starved by other alarm-driven features. The dump engine sets its
+        // own continuation alarm if it needs another tick.
+        try {
+            await this.continueActiveDumpJob()
+        } catch (err) {
+            console.error('Failed to continue dump job:', err)
+        }
+
         try {
             // Fetch all the tasks that are marked to emit an event for this cycle.
-            const task = (await this.executeQuery({
-                sql: 'SELECT * FROM tmp_cron_tasks WHERE is_active = 1;',
-                isRaw: false,
-            })) as Record[]
+            // The cron table is created lazily by the CronPlugin; if it does not
+            // exist yet (fresh DO with no cron plugin installed) we silently skip.
+            let task: Record[] = []
+            try {
+                task = (await this.executeQuery({
+                    sql: 'SELECT * FROM tmp_cron_tasks WHERE is_active = 1;',
+                    isRaw: false,
+                })) as Record[]
+            } catch {
+                return
+            }
 
             if (!task.length) {
                 return
@@ -148,6 +184,219 @@ export class StarbaseDBDurableObject extends DurableObject {
         }
     }
 
+    // -- Streaming dump job machinery -------------------------------------
+
+    /**
+     * Begin a new streaming dump job. Creates the R2 multipart upload,
+     * persists initial state, and schedules an immediate alarm to kick off
+     * the first tick. Returns a status view the worker can hand back to the
+     * client right away.
+     */
+    public async startDumpJob(
+        options: DumpJobOptions
+    ): Promise {
+        if (!this.dumpBucket) {
+            throw new Error(
+                'Streaming dump requires the DATABASE_DUMPS R2 binding. ' +
+                    'Add an [[r2_buckets]] entry to wrangler.toml.'
+            )
+        }
+
+        const tables = await this.listUserTables(options.table)
+        const jobId = crypto.randomUUID()
+        const state = newJobState(jobId, options, tables)
+
+        // Open the R2 multipart upload up-front so part numbers and uploadId
+        // are stable across alarm-driven continuations.
+        const upload = await this.dumpBucket.createMultipartUpload(
+            state.objectKey,
+            {
+                httpMetadata: {
+                    contentType: this.contentTypeFor(options.format),
+                    contentDisposition: `attachment; filename="${state.objectKey.split('/').pop()}"`,
+                },
+            }
+        )
+        state.uploadId = upload.uploadId
+
+        await this.storage.put(jobStateKey(jobId), state)
+        await this.storage.put(ACTIVE_DUMP_KEY, jobId)
+        await this.setAlarm(Date.now() + 1000)
+        return toStatusView(state)
+    }
+
+    /** Read the current state of a dump job for the status endpoint. */
+    public async getDumpJob(jobId: string): Promise {
+        const state = await this.storage.get(jobStateKey(jobId))
+        if (!state) return null
+        return toStatusView(state)
+    }
+
+    /**
+     * Stream the dump body from R2 back to the worker so it can be relayed to
+     * the client. Returns null when the job is not finished yet or missing.
+     */
+    public async getDumpDownloadBody(jobId: string): Promise<{
+        body: ReadableStream
+        size: number
+        contentType: string
+        filename: string
+    } | null> {
+        if (!this.dumpBucket) return null
+        const state = await this.storage.get(jobStateKey(jobId))
+        if (!state || state.status !== 'completed') return null
+        const obj = await this.dumpBucket.get(state.objectKey)
+        if (!obj) return null
+        return {
+            body: obj.body,
+            size: obj.size,
+            contentType:
+                obj.httpMetadata?.contentType ??
+                this.contentTypeFor(state.format),
+            filename: state.objectKey.split('/').pop() ?? state.objectKey,
+        }
+    }
+
+    /** Cancel an in-flight dump job. Aborts the R2 multipart and marks failed. */
+    public async cancelDumpJob(
+        jobId: string
+    ): Promise {
+        const state = await this.storage.get(jobStateKey(jobId))
+        if (!state) return null
+        if (state.status === 'completed' || state.status === 'failed')
+            return toStatusView(state)
+
+        if (this.dumpBucket && state.uploadId) {
+            try {
+                const upload = this.dumpBucket.resumeMultipartUpload(
+                    state.objectKey,
+                    state.uploadId
+                )
+                await upload.abort()
+            } catch (err) {
+                console.error('Failed to abort R2 multipart upload:', err)
+            }
+        }
+        if (this.dumpBucket && state.pendingTempKey) {
+            await this.dumpBucket.delete(state.pendingTempKey).catch(() => {})
+        }
+        state.status = 'cancelled'
+        state.error = 'Cancelled by user'
+        state.progress.updatedAt = Date.now()
+        await this.storage.put(jobStateKey(jobId), state)
+        const active = await this.storage.get(ACTIVE_DUMP_KEY)
+        if (active === jobId) await this.storage.delete(ACTIVE_DUMP_KEY)
+        return toStatusView(state)
+    }
+
+    /**
+     * Run one tick of the active dump job (if any). Called from alarm().
+     * Persists state after every tick and re-arms the alarm if more work
+     * remains.
+     */
+    private async continueActiveDumpJob(): Promise {
+        const activeId = await this.storage.get(ACTIVE_DUMP_KEY)
+        if (!activeId) return
+        if (!this.dumpBucket) {
+            console.error(
+                'Active dump job exists but DATABASE_DUMPS R2 binding is missing.'
+            )
+            await this.storage.delete(ACTIVE_DUMP_KEY)
+            return
+        }
+        const state = await this.storage.get(
+            jobStateKey(activeId)
+        )
+        if (!state) {
+            await this.storage.delete(ACTIVE_DUMP_KEY)
+            return
+        }
+        if (
+            state.status === 'completed' ||
+            state.status === 'failed' ||
+            state.status === 'cancelled'
+        ) {
+            await this.storage.delete(ACTIVE_DUMP_KEY)
+            return
+        }
+
+        const host = createDumpHost({
+            sql: this.sql,
+            storage: this.storage,
+            bucket: this.dumpBucket,
+        })
+
+        try {
+            const { done } = await runTick(state, host)
+            await this.storage.put(jobStateKey(state.jobId), state)
+            if (done) {
+                await this.storage.delete(ACTIVE_DUMP_KEY)
+                if (state.callbackUrl) {
+                    this.fireDumpCallback(state).catch((err) =>
+                        console.error('Dump callback failed:', err)
+                    )
+                }
+            } else {
+                await this.setAlarm(Date.now() + DUMP_RESUME_DELAY_MS)
+            }
+        } catch (err) {
+            console.error('Dump engine error:', err)
+            // The engine sets state.status='failed' before throwing. Persist
+            // the failure so callers see it via the status endpoint.
+            state.status = 'failed'
+            state.error =
+                err instanceof Error ? err.message : String(err ?? 'unknown')
+            state.progress.updatedAt = Date.now()
+            await this.storage.put(jobStateKey(state.jobId), state)
+            await this.storage.delete(ACTIVE_DUMP_KEY)
+            if (state.callbackUrl) {
+                this.fireDumpCallback(state).catch((cbErr) =>
+                    console.error('Dump failure callback failed:', cbErr)
+                )
+            }
+        }
+    }
+
+    private async fireDumpCallback(state: DumpJobState): Promise {
+        if (!state.callbackUrl) return
+        try {
+            await fetch(state.callbackUrl, {
+                method: 'POST',
+                headers: { 'Content-Type': 'application/json' },
+                body: JSON.stringify(toStatusView(state)),
+            })
+        } catch (err) {
+            console.error('Failed to fire dump completion callback:', err)
+        }
+    }
+
+    /**
+     * List the user-facing tables this DO holds. Excludes SQLite internal
+     * tables and the tmp_* feature tables which would otherwise leak into
+     * the export.
+     */
+    private async listUserTables(only?: string): Promise {
+        if (only) {
+            const exists = (await this.executeQuery({
+                sql: 'SELECT name FROM sqlite_master WHERE type = ? AND name = ?;',
+                params: ['table', only],
+                isRaw: false,
+            })) as Record[]
+            return exists.length ? [only] : []
+        }
+        const rows = (await this.executeQuery({
+            sql: "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'tmp_%' AND name NOT LIKE '_cf_%' ORDER BY name;",
+            isRaw: false,
+        })) as Record[]
+        return rows.map((r) => String(r.name))
+    }
+
+    private contentTypeFor(format: DumpFormat): string {
+        if (format === 'csv') return 'text/csv'
+        if (format === 'json') return 'application/json'
+        return 'application/sql'
+    }
+
     public async getStatistics(): Promise<{
         databaseSize: number
         activeConnections: number
diff --git a/src/export/do-dump-host.ts b/src/export/do-dump-host.ts
new file mode 100644
index 0000000..a323320
--- /dev/null
+++ b/src/export/do-dump-host.ts
@@ -0,0 +1,95 @@
+/**
+ * Durable Object adapter for the streaming dump engine.
+ *
+ * Bridges the abstract DumpEngineHost interface (defined in dump-engine.ts)
+ * to the concrete Cloudflare APIs: ctx.storage.sql, ctx.storage for state,
+ * the R2 bucket binding for parts and pending buffer.
+ */
+
+import type { DumpEngineHost, RowCursor } from './dump-engine'
+import { DumpJobState } from './streaming-dump'
+
+const STATE_KEY_PREFIX = 'dump:job:'
+
+export function jobStateKey(jobId: string): string {
+    return STATE_KEY_PREFIX + jobId
+}
+
+export interface DumpHostOptions {
+    sql: SqlStorage
+    storage: DurableObjectStorage
+    bucket: R2Bucket
+}
+
+export function createDumpHost(opts: DumpHostOptions): DumpEngineHost {
+    const { sql, storage, bucket } = opts
+
+    return {
+        query(sqlText: string, params?: unknown[]): RowCursor {
+            const cursor =
+                params && params.length
+                    ? sql.exec(sqlText, ...(params as any[]))
+                    : sql.exec(sqlText)
+
+            // SqlStorageCursor exposes a JS iterator directly, but we adapt it
+            // to the engine's RowCursor.next() contract (returns null at end
+            // instead of throwing IteratorResult.done).
+            return {
+                columns: cursor.columnNames as string[],
+                next(): Record | null {
+                    const r = cursor.next()
+                    if (r.done) return null
+                    return r.value as Record
+                },
+            }
+        },
+
+        async saveState(state: DumpJobState): Promise {
+            await storage.put(jobStateKey(state.jobId), state)
+        },
+
+        async uploadPart(
+            uploadId: string,
+            key: string,
+            partNumber: number,
+            body: Uint8Array
+        ): Promise {
+            const upload = bucket.resumeMultipartUpload(key, uploadId)
+            // `uploadPart` accepts ArrayBuffer / ArrayBufferView. We need a
+            // detached copy so the underlying buffer cannot be mutated while
+            // the upload is in-flight.
+            const copy = new Uint8Array(body.byteLength)
+            copy.set(body)
+            return await upload.uploadPart(partNumber, copy)
+        },
+
+        async completeUpload(
+            uploadId: string,
+            key: string,
+            parts: R2UploadedPart[]
+        ): Promise {
+            const upload = bucket.resumeMultipartUpload(key, uploadId)
+            await upload.complete(parts)
+        },
+
+        async abortUpload(uploadId: string, key: string): Promise {
+            const upload = bucket.resumeMultipartUpload(key, uploadId)
+            await upload.abort()
+        },
+
+        async readPending(key: string): Promise {
+            const obj = await bucket.get(key)
+            if (!obj) return null
+            const buf = await obj.arrayBuffer()
+            return new Uint8Array(buf)
+        },
+
+        async writePending(key: string, bytes: Uint8Array): Promise {
+            await bucket.put(key, bytes)
+        },
+
+        async deletePending(key: string): Promise {
+            await bucket.delete(key)
+        },
+    }
+}
diff --git a/src/export/dump-engine.test.ts b/src/export/dump-engine.test.ts
new file mode 100644
index 0000000..1d1107e
--- /dev/null
+++ b/src/export/dump-engine.test.ts
@@ -0,0 +1,364 @@
+/**
+ * Tests for the streaming dump engine.
+ *
+ * These exercise the engine against a pure-JS fake host so we can drive
+ * deterministic scenarios (multiple ticks, mid-table yields, schema fan-out)
+ * without touching Cloudflare runtime APIs.
+ */
+
+import { describe, expect, it } from 'vitest'
+import { runTick, RowCursor, DumpEngineHost } from './dump-engine'
+import {
+    DumpFormat,
+    DumpJobState,
+    R2_MIN_PART_SIZE,
+    newJobState,
+} from './streaming-dump'
+
+type Table = {
+    name: string
+    columns: string[]
+    rows: Record[]
+    schemaSql?: string
+}
+
+/**
+ * In-memory test harness implementing DumpEngineHost. Captures all data
+ * "uploaded" to R2 so we can assert on the produced dump.
+ */
+class FakeHost implements DumpEngineHost {
+    public parts: { partNumber: number; body: Uint8Array }[] = []
+    public completed = false
+    public aborted = false
+    public pending = new Map()
+    public savedStates = 0
+    public clockMs = 1_000_000
+
+    constructor(private tables: Table[]) {}
+
+    query(sqlText: string, params?: unknown[]): RowCursor {
+        const lower = sqlText.toLowerCase()
+        if (lower.includes('from sqlite_master')) {
+            const target = String(params?.[0] ?? '')
+            const t = this.tables.find((x) => x.name === target)
+            const rows = t
+                ? [
+                      {
+                          type: 'table',
+                          sql:
+                              t.schemaSql ??
+                              `CREATE TABLE ${t.name} (id INTEGER)`,
+                      },
+                  ]
+                : []
+            return cursorFromRows(['type', 'sql'], rows)
+        }
+        // SELECT * FROM "name" LIMIT N OFFSET M;
+        const m = sqlText.match(
+            /from\s+"([^"]+)"\s+limit\s+(\d+)\s+offset\s+(\d+)/i
+        )
+        if (!m) {
+            return cursorFromRows([], [])
+        }
+        const tableName = m[1].replace(/""/g, '"')
+        const limit = parseInt(m[2], 10)
+        const offset = parseInt(m[3], 10)
+        const t = this.tables.find((x) => x.name === tableName)
+        if (!t) return cursorFromRows([], [])
+        const slice = t.rows.slice(offset, offset + limit)
+        return cursorFromRows(t.columns, slice)
+    }
+
+    async saveState(_state: DumpJobState): Promise {
+        this.savedStates++
+    }
+
+    async uploadPart(
+        _uploadId: string,
+        _key: string,
+        partNumber: number,
+        body: Uint8Array
+    ): Promise {
+        // Copy so subsequent buffer mutations don't change recorded parts.
+        const copy = new Uint8Array(body.byteLength)
+        copy.set(body)
+        this.parts.push({ partNumber, body: copy })
+        return { partNumber, etag: `etag-${partNumber}` }
+    }
+
+    async completeUpload(): Promise {
+        this.completed = true
+    }
+
+    async abortUpload(): Promise {
+        this.aborted = true
+    }
+
+    async readPending(key: string): Promise {
+        return this.pending.get(key) ?? null
+    }
+
+    async writePending(key: string, bytes: Uint8Array): Promise {
+        const copy = new Uint8Array(bytes.byteLength)
+        copy.set(bytes)
+        this.pending.set(key, copy)
+    }
+
+    async deletePending(key: string): Promise {
+        this.pending.delete(key)
+    }
+
+    now = (): number => this.clockMs
+
+    advance(ms: number): void {
+        this.clockMs += ms
+    }
+
+    /** Assemble all uploaded parts into a single string for assertions. */
+    fullOutput(): string {
+        const sorted = [...this.parts].sort(
+            (a, b) => a.partNumber - b.partNumber
+        )
+        const total = sorted.reduce((n, p) => n + p.body.byteLength, 0)
+        const merged = new Uint8Array(total)
+        let off = 0
+        for (const p of sorted) {
+            merged.set(p.body, off)
+            off += p.body.byteLength
+        }
+        return new TextDecoder().decode(merged)
+    }
+}
+
+function cursorFromRows(
+    columns: string[],
+    rows: Record[]
+): RowCursor {
+    let i = 0
+    return {
+        columns,
+        next() {
+            if (i >= rows.length) return null
+            return rows[i++]
+        },
+    }
+}
+
+function makeState(
+    format: DumpFormat,
+    tables: string[],
+    overrides: Partial = {}
+): DumpJobState {
+    const state = newJobState('test-job-id', { format }, tables)
+    state.uploadId = 'upload-1'
+    Object.assign(state, overrides)
+    return state
+}
+
+describe('streaming dump engine', () => {
+    it('emits a SQL dump with schema and INSERT statements in one tick', async () => {
+        const host = new FakeHost([
+            {
+                name: 'users',
+                columns: ['id', 'name'],
+                rows: [
+                    { id: 1, name: 'Alice' },
+                    { id: 2, name: "O'Brien" },
+                ],
+                schemaSql: 'CREATE TABLE users (id INTEGER, name TEXT)',
+            },
+            {
+                name: 'orders',
+                columns: ['id', 'total'],
+                rows: [{ id: 7, total: 99.5 }],
+                schemaSql: 'CREATE TABLE orders (id INTEGER, total REAL)',
+            },
+        ])
+        const state = makeState('sql', ['users', 'orders'])
+        const { done } = await runTick(state, host)
+
+        expect(done).toBe(true)
+        expect(host.completed).toBe(true)
+        expect(state.status).toBe('completed')
+        const out = host.fullOutput()
+        expect(out).toContain('CREATE TABLE users (id INTEGER, name TEXT);')
+        expect(out).toContain(
+            'INSERT INTO "users" ("id", "name") VALUES (1, \'Alice\');'
+        )
+        expect(out).toContain(
+            'INSERT INTO "users" ("id", "name") VALUES (2, \'O\'\'Brien\');'
+        )
+        expect(out).toContain('CREATE TABLE orders (id INTEGER, total REAL);')
+        expect(out).toContain(
+            'INSERT INTO "orders" ("id", "total") VALUES (7, 99.5);'
+        )
+        expect(out).toContain('COMMIT;')
+    })
+
+    it('emits a CSV dump with per-table headers', async () => {
+        const host = new FakeHost([
+            {
+                name: 'users',
+                columns: ['id', 'name'],
+                rows: [
+                    { id: 1, name: 'Alice' },
+                    { id: 2, name: 'comma, name' },
+                ],
+            },
+        ])
+        const state = makeState('csv', ['users'])
+        await runTick(state, host)
+
+        const out = host.fullOutput()
+        expect(out).toContain('# table: users')
+        expect(out).toContain('id,name')
+        expect(out).toContain('1,Alice')
+        expect(out).toContain('2,"comma, name"')
+    })
+
+    it('emits a JSON dump as a tables-keyed object', async () => {
+        const host = new FakeHost([
+            {
+                name: 'users',
+                columns: ['id', 'name'],
+                rows: [
+                    { id: 1, name: 'Alice' },
+                    { id: 2, name: 'Bob' },
+                ],
+            },
+        ])
+        const state = makeState('json', ['users'])
+        await runTick(state, host)
+
+        const text = host.fullOutput().trim()
+        const parsed = JSON.parse(text)
+        expect(parsed.users).toEqual([
+            { id: 1, name: 'Alice' },
+            { id: 2, name: 'Bob' },
+        ])
+    })
+
+    it('handles empty databases by writing only the wrapper', async () => {
+        const host = new FakeHost([])
+        const state = makeState('sql', [])
+        const { done } = await runTick(state, host)
+        expect(done).toBe(true)
+        const out = host.fullOutput()
+        expect(out).toContain('PRAGMA foreign_keys=OFF;')
+        expect(out).toContain('COMMIT;')
+        expect(out).not.toContain('INSERT INTO')
+    })
+
+    it('handles tables with zero rows by still emitting CSV headers', async () => {
+        const host = new FakeHost([
+            { name: 'empty', columns: ['id', 'name'], rows: [] },
+        ])
+        const state = makeState('csv', ['empty'])
+        await runTick(state, host)
+        const out = host.fullOutput()
+        expect(out).toContain('# table: empty')
+        expect(out).toContain('id,name')
+    })
+
+    it('yields when the time budget is hit and resumes on the next tick', async () => {
+        // 25 rows, chunkSize=5. The host's `now` jumps past the deadline after
+        // a couple of chunks worth of timestamp reads so the first tick yields
+        // before draining the table.
+        const rows = Array.from({ length: 25 }, (_, i) => ({ id: i + 1 }))
+        const host = new FakeHost([
+            {
+                name: 'big',
+                columns: ['id'],
+                rows,
+                schemaSql: 'CREATE TABLE big (id INTEGER)',
+            },
+        ])
+        const baseClock = host.clockMs
+        let callCount = 0
+        host.now = () => {
+            callCount++
+            // Let several calls through at baseClock so the engine can emit a
+            // couple of chunks before we fast-forward past the deadline. The
+            // engine checks `now()` once per outer loop and once per row batch,
+            // so we need to let ~6 calls return baseClock before jumping.
+            if (callCount <= 6) return baseClock
+            return baseClock + 25_000
+        }
+        const state = makeState('sql', ['big'])
+        state.options.chunkSize = 5
+        const first = await runTick(state, host)
+        expect(first.done).toBe(false)
+        const rowsBeforeResume = state.progress.rowsDumped
+        expect(rowsBeforeResume).toBeGreaterThan(0)
+        expect(rowsBeforeResume).toBeLessThan(25)
+
+        // Resume with a stable clock — second tick should finish the job.
+        host.now = () => host.clockMs
+        const second = await runTick(state, host)
+        expect(second.done).toBe(true)
+        expect(state.status).toBe('completed')
+        expect(state.progress.rowsDumped).toBe(25)
+        const out = host.fullOutput()
+        for (let i = 1; i <= 25; i++) {
+            expect(out).toContain(`VALUES (${i});`)
+        }
+    })
+
+    it('flushes multipart parts when the buffer crosses 5 MiB', async () => {
+        // Produce one row whose string value alone is ~2 MiB so we cross the
+        // 5 MiB part threshold quickly with only a handful of rows.
+        const big = 'x'.repeat(2 * 1024 * 1024)
+        const rows = Array.from({ length: 4 }, (_, i) => ({
+            id: i + 1,
+            payload: big,
+        }))
+        const host = new FakeHost([
+            {
+                name: 'fat',
+                columns: ['id', 'payload'],
+                rows,
+            },
+        ])
+        const state = makeState('sql', ['fat'])
+        await runTick(state, host)
+        // We should have flushed at least one full part and the final part.
+        const totalBytes = host.parts.reduce((n, p) => n + p.body.byteLength, 0)
+        expect(host.parts.length).toBeGreaterThanOrEqual(2)
+        expect(totalBytes).toBeGreaterThan(R2_MIN_PART_SIZE)
+        expect(host.completed).toBe(true)
+    })
+
+    it('marks the job failed and aborts the upload on engine error', async () => {
+        const host = new FakeHost([
+            {
+                name: 'broken',
+                columns: ['id'],
+                rows: [{ id: 1 }],
+            },
+        ])
+        // Force completeUpload to fail so we exercise the error path.
+        host.completeUpload = async () => {
+            throw new Error('boom')
+        }
+        const state = makeState('sql', ['broken'])
+        await expect(runTick(state, host)).rejects.toThrow('boom')
+        expect(state.status).toBe('failed')
+        expect(state.error).toBe('boom')
+        expect(host.aborted).toBe(true)
+    })
+
+    it("emits BLOB values as x'...' literals in SQL dumps", async () => {
+        const blob = new Uint8Array([0xde, 0xad, 0xbe, 0xef])
+        const host = new FakeHost([
+            {
+                name: 'bin',
+                columns: ['id', 'data'],
+                rows: [{ id: 1, data: blob }],
+            },
+        ])
+        const state = makeState('sql', ['bin'])
+        await runTick(state, host)
+        const out = host.fullOutput()
+        expect(out).toContain("x'deadbeef'")
+    })
+})
diff --git a/src/export/dump-engine.ts b/src/export/dump-engine.ts
new file mode 100644
index 0000000..81bb951
--- /dev/null
+++ b/src/export/dump-engine.ts
@@ -0,0 +1,497 @@
+/**
+ * Engine that drives a streaming dump through one or more DO invocations.
+ *
+ * Pulled out of the DO class itself so the logic can be unit-tested without
+ * spinning up the full Durable Object harness. The engine is stateless across
+ * ticks — all progress lives in DumpJobState which is loaded/persisted by the
+ * DO between invocations.
+ *
+ * Buffer strategy:
+ *   - Within a tick, the pending bytes live in a Uint8Array in instance memory.
+ *   - When the buffer reaches the R2 multipart minimum (5 MiB) it is flushed
+ *     to R2 as the next part.
+ *   - When a tick yields (deadline reached, work remaining), the leftover bytes
+ *     are written to a temporary R2 object so the next tick can resume them.
+ *   - When the job completes, the leftover bytes — however small — are flushed
+ *     as the final multipart part, and the multipart upload is completed.
+ */
+
+import {
+    DumpJobState,
+    DEFAULT_CHUNK_SIZE,
+    R2_MIN_PART_SIZE,
+    TICK_BUDGET_MS,
+    csvCell,
+    quoteIdent,
+    sqlLiteral,
+} from './streaming-dump'
+
+/**
+ * Minimal cursor-like interface so the engine can be tested without depending
+ * on the Cloudflare SqlStorage runtime. Returning `null` from next() signals
+ * the end of the cursor; `columns` is materialized lazily.
+ */
+export interface RowCursor {
+    columns: string[]
+    next(): Record | null
+    close?(): void
+}
+
+/**
+ * The narrow runtime contract the engine needs from its host. The DO supplies
+ * a concrete implementation that reaches into ctx.storage.sql, the R2 bucket
+ * binding, and DO storage for persistence.
+ */
+export interface DumpEngineHost {
+    /** Run a SELECT and stream rows back. */
+    query(sql: string, params?: unknown[]): RowCursor
+    /** Persist the job state so the next tick / status read can see it. */
+    saveState(state: DumpJobState): Promise
+    /** Upload a multipart segment and return the part metadata. */
+    uploadPart(
+        uploadId: string,
+        key: string,
+        partNumber: number,
+        body: Uint8Array
+    ): Promise
+    /** Finalize the multipart upload once all parts are recorded. */
+    completeUpload(
+        uploadId: string,
+        key: string,
+        parts: R2UploadedPart[]
+    ): Promise
+    /** Abort the multipart upload — invoked when the job fails. */
+    abortUpload(uploadId: string, key: string): Promise
+    /** Read the pending buffer carried over from a previous tick. */
+    readPending(key: string): Promise
+    /** Persist the pending buffer for the next tick to resume from. */
+    writePending(key: string, bytes: Uint8Array): Promise
+    /** Clear the pending buffer after final flush. */
+    deletePending(key: string): Promise
+    /** Optional clock override for tests. */
+    now?(): number
+}
+
+const encoder = new TextEncoder()
+
+/**
+ * Mutable per-tick buffer wrapper. Maintains a Uint8Array internally and
+ * grows it geometrically to amortize the cost of repeated appends. Flushes a
+ * multipart part to R2 whenever the buffer crosses R2_MIN_PART_SIZE.
+ */
+class TickBuffer {
+    private buf: Uint8Array
+    private len: number
+
+    constructor(initial: Uint8Array = new Uint8Array()) {
+        // Reserve a chunk slightly above the multipart threshold so we rarely
+        // need to grow the backing array during a normal tick.
+        const capacity = Math.max(
+            R2_MIN_PART_SIZE + 64 * 1024,
+            initial.byteLength
+        )
+        this.buf = new Uint8Array(capacity)
+        this.buf.set(initial, 0)
+        this.len = initial.byteLength
+    }
+
+    get length(): number {
+        return this.len
+    }
+
+    /** Return the live buffer slice — caller must not retain across appends. */
+    view(): Uint8Array {
+        return this.buf.subarray(0, this.len)
+    }
+
+    /** Detach the current contents as an owned copy and reset the buffer. */
+    drain(): Uint8Array {
+        const out = new Uint8Array(this.len)
+        out.set(this.buf.subarray(0, this.len))
+        this.len = 0
+        return out
+    }
+
+    append(bytes: Uint8Array): void {
+        const needed = this.len + bytes.byteLength
+        if (needed > this.buf.byteLength) {
+            let cap = this.buf.byteLength * 2
+            while (cap < needed) cap *= 2
+            const grown = new Uint8Array(cap)
+            grown.set(this.buf.subarray(0, this.len), 0)
+            this.buf = grown
+        }
+        this.buf.set(bytes, this.len)
+        this.len += bytes.byteLength
+    }
+}
+
+async function maybeFlushPart(
+    state: DumpJobState,
+    host: DumpEngineHost,
+    buffer: TickBuffer
+): Promise {
+    if (buffer.length < R2_MIN_PART_SIZE) return
+    if (!state.uploadId)
+        throw new Error('maybeFlushPart called without an active upload')
+    const slice = buffer.drain()
+    const partNumber = state.parts.length + 1
+    const part = await host.uploadPart(
+        state.uploadId,
+        state.objectKey,
+        partNumber,
+        slice
+    )
+    state.parts.push(part)
+    state.progress.partsUploaded = state.parts.length
+}
+
+async function flushFinalPart(
+    state: DumpJobState,
+    host: DumpEngineHost,
+    buffer: TickBuffer
+): Promise {
+    if (!state.uploadId)
+        throw new Error('flushFinalPart called without an active upload')
+    if (buffer.length === 0) return
+    const slice = buffer.drain()
+    const partNumber = state.parts.length + 1
+    const part = await host.uploadPart(
+        state.uploadId,
+        state.objectKey,
+        partNumber,
+        slice
+    )
+    state.parts.push(part)
+    state.progress.partsUploaded = state.parts.length
+}
+
+/** Append a UTF-8 string into the running buffer and update byte counters. */
+function writeStr(state: DumpJobState, buffer: TickBuffer, str: string): void {
+    const bytes = encoder.encode(str)
+    buffer.append(bytes)
+    state.progress.bytesWritten += bytes.byteLength
+}
+
+/** Build the SQL fragment that pages through a single table in stable order. */
+function pageSql(table: string, chunkSize: number, offset: number): string {
+    // We rely on whatever natural order SQLite gives us — adding an ORDER BY
+    // on an unindexed table would force a full sort per page, which is far
+    // worse than the slightly weaker ordering guarantee. For tables with a
+    // ROWID the order is stable across the dump because we hold the DO alone.
+    return `SELECT * FROM ${quoteIdent(table)} LIMIT ${chunkSize} OFFSET ${offset};`
+}
+
+/**
+ * Execute a single tick. Returns true when the whole dump is finished. The
+ * caller is responsible for persisting state and re-arming alarms based on
+ * the returned value.
+ */
+export async function runTick(
+    state: DumpJobState,
+    host: DumpEngineHost
+): Promise<{ done: boolean }> {
+    const now = host.now ?? Date.now
+    const deadline = now() + TICK_BUDGET_MS
+    state.status = 'processing'
+
+    // Hydrate the pending buffer from R2 if a previous tick left one behind.
+    const initial = state.pendingTempKey
+        ? ((await host.readPending(state.pendingTempKey)) ?? new Uint8Array())
+        : new Uint8Array()
+    const buffer = new TickBuffer(initial)
+
+    try {
+        if (state.phase === 'header') {
+            if (state.format === 'sql') {
+                writeStr(
+                    state,
+                    buffer,
+                    '-- StarbaseDB streaming dump\n' +
+                        `-- Job: ${state.jobId}\n` +
+                        `-- Generated: ${new Date().toISOString()}\n` +
+                        'PRAGMA foreign_keys=OFF;\n' +
+                        'BEGIN TRANSACTION;\n\n'
+                )
+            } else if (state.format === 'json') {
+                writeStr(state, buffer, '{\n')
+            }
+            state.phase =
+                state.progress.tables.length === 0 ? 'finalize' : 'schema'
+            state.progress.updatedAt = now()
+        }
+
+        while (state.phase !== 'finalize' && state.phase !== 'done') {
+            if (now() >= deadline) {
+                return await yieldTick(state, host, buffer, now())
+            }
+
+            const idx = state.progress.currentTableIndex
+            if (idx >= state.progress.tables.length) {
+                state.phase = 'finalize'
+                break
+            }
+            const table = state.progress.tables[idx]
+            state.progress.currentTable = table
+
+            if (state.phase === 'schema') {
+                await emitSchema(state, host, buffer, table, idx)
+                state.schemaEmitted = true
+                state.phase = 'rows'
+                state.currentColumns = null
+                state.jsonRowWritten = false
+            }
+
+            if (state.phase === 'rows') {
+                const done = emitRowBatches(
+                    state,
+                    host,
+                    buffer,
+                    table,
+                    deadline,
+                    now
+                )
+                // emitRowBatches may flush parts mid-loop. It returns when
+                // either time is up (returns false) or the table is exhausted.
+                if (!(await done)) {
+                    return await yieldTick(state, host, buffer, now())
+                }
+                // Close JSON array for this table.
+                if (state.format === 'json' && state.jsonTableOpened) {
+                    writeStr(
+                        state,
+                        buffer,
+                        state.jsonRowWritten ? '\n  ]' : ']'
+                    )
+                    state.jsonTableOpened = false
+                }
+                // Maybe flush after each table boundary so memory stays bounded
+                // even if the next table is much larger.
+                await maybeFlushPart(state, host, buffer)
+            }
+
+            // Advance to the next table.
+            state.progress.currentTableIndex = idx + 1
+            state.progress.rowOffset = 0
+            state.schemaEmitted = false
+            state.currentColumns = null
+            state.jsonTableOpened = false
+            state.jsonRowWritten = false
+            state.phase = 'schema'
+        }
+
+        if (state.phase === 'finalize') {
+            if (state.format === 'sql') {
+                writeStr(state, buffer, '\nCOMMIT;\n')
+            } else if (state.format === 'json') {
+                writeStr(state, buffer, '\n}\n')
+            }
+            await flushFinalPart(state, host, buffer)
+            if (state.uploadId) {
+                await host.completeUpload(
+                    state.uploadId,
+                    state.objectKey,
+                    state.parts
+                )
+            }
+            if (state.pendingTempKey) {
+                await host.deletePending(state.pendingTempKey).catch(() => {})
+                state.pendingTempKey = undefined
+                state.pendingBufferBytes = 0
+            }
+            state.phase = 'done'
+            state.status = 'completed'
+            state.progress.completedAt = now()
+            state.progress.updatedAt = now()
+            return { done: true }
+        }
+
+        state.progress.updatedAt = now()
+        return { done: false }
+    } catch (err) {
+        state.error = err instanceof Error ? err.message : String(err)
+        state.status = 'failed'
+        state.phase = 'error'
+        state.progress.updatedAt = now()
+        if (state.uploadId) {
+            await host
+                .abortUpload(state.uploadId, state.objectKey)
+                .catch(() => {})
+        }
+        if (state.pendingTempKey) {
+            await host.deletePending(state.pendingTempKey).catch(() => {})
+            state.pendingTempKey = undefined
+            state.pendingBufferBytes = 0
+        }
+        throw err
+    }
+}
+
+/**
+ * Save the pending buffer to R2 (so the next tick can hydrate it) and update
+ * progress metadata before yielding control back to the DO alarm scheduler.
+ */
+async function yieldTick(
+    state: DumpJobState,
+    host: DumpEngineHost,
+    buffer: TickBuffer,
+    nowMs: number
+): Promise<{ done: boolean }> {
+    const bytes = buffer.drain()
+    if (bytes.byteLength > 0) {
+        const key = state.pendingTempKey ?? `dumps/.pending/${state.jobId}.tmp`
+        await host.writePending(key, bytes)
+        state.pendingTempKey = key
+        state.pendingBufferBytes = bytes.byteLength
+    } else if (state.pendingTempKey) {
+        // Buffer was flushed mid-tick; clear the stale temp object.
+        await host.deletePending(state.pendingTempKey).catch(() => {})
+        state.pendingTempKey = undefined
+        state.pendingBufferBytes = 0
+    }
+    state.progress.updatedAt = nowMs
+    return { done: false }
+}
+
+/**
+ * Emit the schema / per-format prelude for a given table. SQL dumps mirror
+ * sqlite_master entries (table + indexes + triggers + views). CSV/JSON emit
+ * just a separator/header line.
+ */
+async function emitSchema(
+    state: DumpJobState,
+    host: DumpEngineHost,
+    buffer: TickBuffer,
+    table: string,
+    tableIndex: number
+): Promise {
+    if (state.format === 'sql') {
+        const cursor = host.query(
+            `SELECT type, sql FROM sqlite_master WHERE tbl_name = ? AND sql IS NOT NULL AND type IN ('table','index','trigger','view');`,
+            [table]
+        )
+        let header = `-- Table: ${table}\n`
+        const stmts: string[] = []
+        try {
+            while (true) {
+                const row = cursor.next()
+                if (!row) break
+                const sql = String(row.sql ?? '').trim()
+                if (!sql) continue
+                stmts.push(`${sql};\n`)
+            }
+        } finally {
+            cursor.close?.()
+        }
+        writeStr(state, buffer, header + stmts.join('') + '\n')
+    } else if (state.format === 'csv') {
+        if (tableIndex > 0) writeStr(state, buffer, '\n')
+        writeStr(state, buffer, `# table: ${table}\n`)
+    } else if (state.format === 'json') {
+        if (tableIndex > 0) writeStr(state, buffer, ',\n')
+        writeStr(state, buffer, `  ${JSON.stringify(table)}: [`)
+        state.jsonTableOpened = true
+        state.jsonRowWritten = false
+    }
+}
+
+/**
+ * Emit row chunks for a table. Each chunk is bounded by `chunkSize` rows.
+ * Returns true when the table is fully drained, false if we exit early
+ * because the deadline is approaching.
+ */
+async function emitRowBatches(
+    state: DumpJobState,
+    host: DumpEngineHost,
+    buffer: TickBuffer,
+    table: string,
+    deadline: number,
+    now: () => number
+): Promise {
+    const chunkSize = state.options.chunkSize || DEFAULT_CHUNK_SIZE
+
+    while (true) {
+        if (now() >= deadline) return false
+
+        const cursor = host.query(
+            pageSql(table, chunkSize, state.progress.rowOffset)
+        )
+
+        if (!state.currentColumns) {
+            state.currentColumns = cursor.columns
+            // Emit the CSV header on the very first batch of this table.
+            if (state.format === 'csv') {
+                writeStr(
+                    state,
+                    buffer,
+                    state.currentColumns.map(csvCell).join(',') + '\n'
+                )
+            }
+        }
+
+        let rowsThisBatch = 0
+        try {
+            while (true) {
+                const row = cursor.next()
+                if (!row) break
+                emitRow(state, buffer, table, row)
+                rowsThisBatch++
+                state.progress.rowsDumped++
+                state.progress.rowOffset++
+            }
+        } finally {
+            cursor.close?.()
+        }
+
+        // Flush whatever crossed the 5 MiB line during this chunk.
+        await maybeFlushPart(state, host, buffer)
+
+        if (rowsThisBatch < chunkSize) {
+            // Table is fully drained.
+            if (state.format === 'sql') {
+                writeStr(state, buffer, '\n')
+            }
+            return true
+        }
+
+        // Persist progress so a crash after a flush is recoverable from this
+        // exact offset, then loop and continue with the next chunk.
+        await host.saveState(state)
+    }
+}
+
+/** Serialize a single row into the active format and append it to the buffer. */
+function emitRow(
+    state: DumpJobState,
+    buffer: TickBuffer,
+    table: string,
+    row: Record
+): void {
+    if (state.format === 'sql') {
+        const cols = state.currentColumns!
+        const values = cols.map((c) => sqlLiteral(row[c]))
+        const colList = cols.map(quoteIdent).join(', ')
+        writeStr(
+            state,
+            buffer,
+            `INSERT INTO ${quoteIdent(table)} (${colList}) VALUES (${values.join(', ')});\n`
+        )
+        return
+    }
+
+    if (state.format === 'csv') {
+        const cols = state.currentColumns!
+        writeStr(
+            state,
+            buffer,
+            cols.map((c) => csvCell(row[c])).join(',') + '\n'
+        )
+        return
+    }
+
+    if (state.format === 'json') {
+        const prefix = state.jsonRowWritten ? ',\n    ' : '\n    '
+        writeStr(state, buffer, prefix + JSON.stringify(row))
+        state.jsonRowWritten = true
+    }
+}
diff --git a/src/export/dump.ts b/src/export/dump.ts
index 91a2e89..ef17a58 100644
--- a/src/export/dump.ts
+++ b/src/export/dump.ts
@@ -1,8 +1,32 @@
+/**
+ * /export/dump entry points.
+ *
+ * Two execution paths exist behind these routes:
+ *
+ *   1. Legacy synchronous path (preserved for backwards compatibility):
+ *      buffers the entire dump into memory and returns it inline. This still
+ *      works for small databases and clients hitting GET /export/dump with
+ *      the old contract.
+ *
+ *   2. Streaming path (new): when the DATABASE_DUMPS R2 binding is wired and
+ *      the caller opts in (POST /export/dump or appends ?stream=true), the
+ *      job is handed off to the Durable Object which paginates rows, flushes
+ *      chunks into R2, and uses an alarm to survive past the 30-second worker
+ *      limit. The response contains a jobId the client can poll until the
+ *      object is fully written.
+ */
+
 import { executeOperation } from '.'
 import { StarbaseDBConfiguration } from '../handler'
 import { DataSource } from '../types'
 import { createResponse } from '../utils'
+import { DumpFormat, DumpJobOptions, DumpJobStatusView } from './streaming-dump'
 
+/**
+ * Legacy in-memory SQL dump. Retained for very small databases and existing
+ * clients that depend on the original `GET /export/dump` contract. Internal
+ * databases that exceed a few MB should use the streaming endpoint instead.
+ */
 export async function dumpDatabaseRoute(
     dataSource: DataSource,
     config: StarbaseDBConfiguration
@@ -69,3 +93,209 @@ export async function dumpDatabaseRoute(
         return createResponse(undefined, 'Failed to create database dump', 500)
     }
 }
+
+// -- Streaming entry points -----------------------------------------------
+
+/** Optional body for POST /export/dump. All fields are optional. */
+export interface StartDumpRequestBody {
+    format?: DumpFormat
+    callbackUrl?: string
+    table?: string
+    chunkSize?: number
+}
+
+function parseFormat(value: string | null | undefined): DumpFormat {
+    const v = String(value ?? 'sql').toLowerCase()
+    if (v === 'csv' || v === 'json' || v === 'sql') return v
+    return 'sql'
+}
+
+/**
+ * Start a streaming dump job. Returns 202 with the job descriptor. The caller
+ * polls `/export/dump/status/:jobId` and downloads the finished artifact
+ * from `/export/dump/download/:jobId`.
+ */
+export async function startStreamingDumpRoute(
+    request: Request,
+    dataSource: DataSource,
+    config: StarbaseDBConfiguration
+): Promise {
+    try {
+        if (dataSource.source !== 'internal' || !dataSource.rpc.startDumpJob) {
+            return createResponse(
+                undefined,
+                'Streaming dump is only available for the internal data source.',
+                400
+            )
+        }
+
+        const url = new URL(request.url)
+        let body: StartDumpRequestBody = {}
+        if (request.method !== 'GET') {
+            const contentType =
+                request.headers.get('Content-Type')?.toLowerCase() ?? ''
+            if (contentType.includes('application/json')) {
+                try {
+                    body =
+                        ((await request.json()) as StartDumpRequestBody) ?? {}
+                } catch {
+                    return createResponse(undefined, 'Invalid JSON body.', 400)
+                }
+            }
+        }
+
+        const options: DumpJobOptions = {
+            format: parseFormat(body.format ?? url.searchParams.get('format')),
+            callbackUrl:
+                body.callbackUrl ??
+                url.searchParams.get('callbackUrl') ??
+                undefined,
+            table: body.table ?? url.searchParams.get('table') ?? undefined,
+            chunkSize: body.chunkSize ?? undefined,
+        }
+
+        const status = await dataSource.rpc.startDumpJob(options)
+
+        const baseUrl = `${url.origin}`
+        const statusUrl = `${baseUrl}/export/dump/status/${status.jobId}`
+        const downloadUrl = `${baseUrl}/export/dump/download/${status.jobId}`
+
+        return new Response(
+            JSON.stringify({
+                result: {
+                    ...status,
+                    statusUrl,
+                    downloadUrl,
+                },
+                error: undefined,
+            }),
+            {
+                status: 202,
+                headers: {
+                    'Content-Type': 'application/json',
+                    Location: statusUrl,
+                },
+            }
+        )
+    } catch (error: any) {
+        console.error('Start streaming dump error:', error)
+        return createResponse(
+            undefined,
+            error?.message ?? 'Failed to start dump job.',
+            500
+        )
+    }
+}
+
+/** GET /export/dump/status/:jobId — returns the job status view. */
+export async function getDumpJobStatusRoute(
+    jobId: string,
+    request: Request,
+    dataSource: DataSource
+): Promise {
+    try {
+        if (!dataSource.rpc.getDumpJob) {
+            return createResponse(undefined, 'Not supported.', 400)
+        }
+        const view = (await dataSource.rpc.getDumpJob(
+            jobId
+        )) as DumpJobStatusView | null
+        if (!view) {
+            return createResponse(undefined, `Job '${jobId}' not found.`, 404)
+        }
+        const url = new URL(request.url)
+        const downloadUrl =
+            view.status === 'completed'
+                ? `${url.origin}/export/dump/download/${jobId}`
+                : undefined
+        return new Response(
+            JSON.stringify({
+                result: { ...view, downloadUrl },
+                error: undefined,
+            }),
+            {
+                status: 200,
+                headers: { 'Content-Type': 'application/json' },
+            }
+        )
+    } catch (error: any) {
+        console.error('Get dump job status error:', error)
+        return createResponse(
+            undefined,
+            error?.message ?? 'Failed to fetch job status.',
+            500
+        )
+    }
+}
+
+/** GET /export/dump/download/:jobId — streams the finished file from R2. */
+export async function downloadDumpJobRoute(
+    jobId: string,
+    dataSource: DataSource
+): Promise {
+    try {
+        if (!dataSource.rpc.getDumpDownloadBody) {
+            return createResponse(undefined, 'Not supported.', 400)
+        }
+        const result = (await dataSource.rpc.getDumpDownloadBody(jobId)) as {
+            body: ReadableStream
+            size: number
+            contentType: string
+            filename: string
+        } | null
+        if (!result) {
+            return createResponse(
+                undefined,
+                `Dump for job '${jobId}' is not available yet.`,
+                404
+            )
+        }
+        return new Response(result.body, {
+            status: 200,
+            headers: {
+                'Content-Type': result.contentType,
+                'Content-Length': String(result.size),
+                'Content-Disposition': `attachment; filename="${result.filename}"`,
+            },
+        })
+    } catch (error: any) {
+        console.error('Download dump job error:', error)
+        return createResponse(
+            undefined,
+            error?.message ?? 'Failed to download dump.',
+            500
+        )
+    }
+}
+
+/** DELETE /export/dump/:jobId — cancel an in-flight job. */
+export async function cancelDumpJobRoute(
+    jobId: string,
+    dataSource: DataSource
+): Promise {
+    try {
+        if (!dataSource.rpc.cancelDumpJob) {
+            return createResponse(undefined, 'Not supported.', 400)
+        }
+        const view = (await dataSource.rpc.cancelDumpJob(
+            jobId
+        )) as DumpJobStatusView | null
+        if (!view) {
+            return createResponse(undefined, `Job '${jobId}' not found.`, 404)
+        }
+        return new Response(
+            JSON.stringify({ result: view, error: undefined }),
+            {
+                status: 200,
+                headers: { 'Content-Type': 'application/json' },
+            }
+        )
+    } catch (error: any) {
+        console.error('Cancel dump job error:', error)
+        return createResponse(
+            undefined,
+            error?.message ?? 'Failed to cancel job.',
+            500
+        )
+    }
+}
diff --git a/src/export/streaming-dump-route.test.ts b/src/export/streaming-dump-route.test.ts
new file mode 100644
index 0000000..4a4a8e9
--- /dev/null
+++ b/src/export/streaming-dump-route.test.ts
@@ -0,0 +1,212 @@
+/**
+ * Smoke tests for the streaming dump HTTP entry points in src/export/dump.ts.
+ * The real DO RPC is mocked — these tests just confirm parameter parsing,
+ * status code shape, and routing of the call into the RPC surface.
+ */
+
+import { describe, expect, it, vi, beforeEach } from 'vitest'
+import {
+    cancelDumpJobRoute,
+    downloadDumpJobRoute,
+    getDumpJobStatusRoute,
+    startStreamingDumpRoute,
+} from './dump'
+import type { DataSource } from '../types'
+import type { StarbaseDBConfiguration } from '../handler'
+
+function makeRpcMock(overrides: Record = {}) {
+    return {
+        executeQuery: vi.fn(),
+        startDumpJob: vi.fn().mockResolvedValue({
+            jobId: 'job-abc',
+            status: 'queued',
+            format: 'sql',
+            objectKey: 'dumps/dump.sql',
+            progress: {
+                tables: ['users'],
+                currentTableIndex: 0,
+                currentTable: 'users',
+                rowOffset: 0,
+                rowsDumped: 0,
+                bytesWritten: 0,
+                partsUploaded: 0,
+                startedAt: 1,
+                updatedAt: 1,
+            },
+        }),
+        getDumpJob: vi.fn().mockResolvedValue({
+            jobId: 'job-abc',
+            status: 'processing',
+            format: 'sql',
+            objectKey: 'dumps/dump.sql',
+            progress: {
+                tables: ['users'],
+                currentTableIndex: 0,
+                currentTable: 'users',
+                rowOffset: 0,
+                rowsDumped: 100,
+                bytesWritten: 4096,
+                partsUploaded: 0,
+                startedAt: 1,
+                updatedAt: 2,
+            },
+        }),
+        getDumpDownloadBody: vi.fn(),
+        cancelDumpJob: vi.fn(),
+        ...overrides,
+    }
+}
+
+let dataSource: DataSource
+let config: StarbaseDBConfiguration
+
+beforeEach(() => {
+    dataSource = {
+        source: 'internal',
+        rpc: makeRpcMock() as any,
+    } as any
+    config = {
+        role: 'admin',
+        features: { export: true },
+    }
+})
+
+describe('streaming dump HTTP routes', () => {
+    it('startStreamingDumpRoute returns 202 with statusUrl/downloadUrl', async () => {
+        const req = new Request('https://api.example.com/export/dump', {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify({ format: 'sql' }),
+        })
+        const res = await startStreamingDumpRoute(req, dataSource, config)
+        expect(res.status).toBe(202)
+        expect(res.headers.get('Location')).toContain(
+            '/export/dump/status/job-abc'
+        )
+        const json: any = await res.json()
+        expect(json.result.jobId).toBe('job-abc')
+        expect(json.result.statusUrl).toBe(
+            'https://api.example.com/export/dump/status/job-abc'
+        )
+        expect(json.result.downloadUrl).toBe(
+            'https://api.example.com/export/dump/download/job-abc'
+        )
+        expect(dataSource.rpc.startDumpJob).toHaveBeenCalledWith(
+            expect.objectContaining({ format: 'sql' })
+        )
+    })
+
+    it('startStreamingDumpRoute accepts format via query string', async () => {
+        const req = new Request(
+            'https://api.example.com/export/dump?format=csv',
+            { method: 'POST' }
+        )
+        await startStreamingDumpRoute(req, dataSource, config)
+        expect(dataSource.rpc.startDumpJob).toHaveBeenCalledWith(
+            expect.objectContaining({ format: 'csv' })
+        )
+    })
+
+    it('startStreamingDumpRoute rejects external data sources', async () => {
+        const external: DataSource = {
+            source: 'external',
+            rpc: makeRpcMock() as any,
+        } as any
+        const req = new Request('https://api.example.com/export/dump', {
+            method: 'POST',
+        })
+        const res = await startStreamingDumpRoute(req, external, config)
+        expect(res.status).toBe(400)
+    })
+
+    it('getDumpJobStatusRoute returns 404 for missing jobs', async () => {
+        ;(dataSource.rpc as any).getDumpJob = vi.fn().mockResolvedValue(null)
+        const req = new Request(
+            'https://api.example.com/export/dump/status/nope'
+        )
+        const res = await getDumpJobStatusRoute('nope', req, dataSource)
+        expect(res.status).toBe(404)
+    })
+
+    it('getDumpJobStatusRoute adds downloadUrl only on completion', async () => {
+        const req = new Request(
+            'https://api.example.com/export/dump/status/job-abc'
+        )
+        const res = await getDumpJobStatusRoute('job-abc', req, dataSource)
+        const json: any = await res.json()
+        expect(json.result.downloadUrl).toBeUndefined() // still processing
+        ;(dataSource.rpc as any).getDumpJob = vi.fn().mockResolvedValue({
+            jobId: 'job-abc',
+            status: 'completed',
+            format: 'sql',
+            objectKey: 'dumps/dump.sql',
+            progress: {
+                tables: [],
+                currentTableIndex: 0,
+                currentTable: null,
+                rowOffset: 0,
+                rowsDumped: 0,
+                bytesWritten: 0,
+                partsUploaded: 0,
+                startedAt: 1,
+                updatedAt: 2,
+            },
+        })
+        const res2 = await getDumpJobStatusRoute('job-abc', req, dataSource)
+        const json2: any = await res2.json()
+        expect(json2.result.downloadUrl).toBe(
+            'https://api.example.com/export/dump/download/job-abc'
+        )
+    })
+
+    it('downloadDumpJobRoute streams the R2 body when available', async () => {
+        const body = new Response('hello world').body
+        ;(dataSource.rpc as any).getDumpDownloadBody = vi
+            .fn()
+            .mockResolvedValue({
+                body,
+                size: 11,
+                contentType: 'application/sql',
+                filename: 'dump.sql',
+            })
+        const res = await downloadDumpJobRoute('job-abc', dataSource)
+        expect(res.status).toBe(200)
+        expect(res.headers.get('Content-Length')).toBe('11')
+        expect(res.headers.get('Content-Disposition')).toBe(
+            'attachment; filename="dump.sql"'
+        )
+        expect(await res.text()).toBe('hello world')
+    })
+
+    it('downloadDumpJobRoute returns 404 when dump is not ready', async () => {
+        ;(dataSource.rpc as any).getDumpDownloadBody = vi
+            .fn()
+            .mockResolvedValue(null)
+        const res = await downloadDumpJobRoute('job-abc', dataSource)
+        expect(res.status).toBe(404)
+    })
+
+    it('cancelDumpJobRoute round-trips through the DO RPC', async () => {
+        ;(dataSource.rpc as any).cancelDumpJob = vi.fn().mockResolvedValue({
+            jobId: 'job-abc',
+            status: 'cancelled',
+            format: 'sql',
+            objectKey: 'dumps/dump.sql',
+            progress: {
+                tables: [],
+                currentTableIndex: 0,
+                currentTable: null,
+                rowOffset: 0,
+                rowsDumped: 0,
+                bytesWritten: 0,
+                partsUploaded: 0,
+                startedAt: 1,
+                updatedAt: 2,
+            },
+        })
+        const res = await cancelDumpJobRoute('job-abc', dataSource)
+        expect(res.status).toBe(200)
+        const json: any = await res.json()
+        expect(json.result.status).toBe('cancelled')
+    })
+})
diff --git a/src/export/streaming-dump.ts b/src/export/streaming-dump.ts
new file mode 100644
index 0000000..6957e79
--- /dev/null
+++ b/src/export/streaming-dump.ts
@@ -0,0 +1,228 @@
+/**
+ * Streaming database dump implementation.
+ *
+ * Designed to work around two Cloudflare constraints that the legacy
+ * src/export/dump.ts cannot:
+ *   1. Durable Object memory ceiling (currently 1GB, soon 10GB) — the legacy
+ *      path accumulates the whole dump in a JS string before responding.
+ *   2. 30-second wall-clock limit on a single Worker / DO request.
+ *
+ * The job runs inside the Durable Object itself: state is held in DO storage
+ * so it survives invocations, dump bytes are flushed straight into an R2
+ * multipart upload, and an alarm re-enters the DO to continue work whenever a
+ * single tick approaches the time budget.
+ *
+ * Pending (sub-5MiB) bytes that haven't been flushed as a multipart part yet
+ * live in a temporary R2 object — DO storage values are capped at 128 KiB, so
+ * the buffer cannot live in storage directly.
+ */
+
+export type DumpFormat = 'sql' | 'csv' | 'json'
+
+export type DumpStatus =
+    | 'queued'
+    | 'processing'
+    | 'completed'
+    | 'failed'
+    | 'cancelled'
+
+export interface DumpJobOptions {
+    format: DumpFormat
+    /** Optional callback URL invoked once the dump completes (success or failure). */
+    callbackUrl?: string
+    /** Optional single table to dump (CSV/JSON default to first table when omitted). */
+    table?: string
+    /** Optional chunk size override (rows per SELECT batch). Default 1000. */
+    chunkSize?: number
+}
+
+export interface DumpJobProgress {
+    tables: string[]
+    currentTableIndex: number
+    currentTable: string | null
+    rowOffset: number
+    rowsDumped: number
+    bytesWritten: number
+    partsUploaded: number
+    startedAt: number
+    updatedAt: number
+    completedAt?: number
+}
+
+export interface DumpJobState {
+    jobId: string
+    status: DumpStatus
+    format: DumpFormat
+    callbackUrl?: string
+    /** R2 object key the finished dump will be written under. */
+    objectKey: string
+    /** Multipart upload identifier, present while parts are being streamed. */
+    uploadId?: string
+    /** Completed parts of the multipart upload. */
+    parts: R2UploadedPart[]
+    /** R2 key for the not-yet-flushed leftover bytes, if any. */
+    pendingTempKey?: string
+    /** Size of the pending temp buffer (informational). */
+    pendingBufferBytes: number
+    /** Phase tracking — controls what runTick() does next. */
+    phase: 'header' | 'schema' | 'rows' | 'finalize' | 'done' | 'error'
+    /** Whether the schema for the current table has been emitted yet. */
+    schemaEmitted: boolean
+    /** Cached column names for the current table when streaming rows. */
+    currentColumns: string[] | null
+    /** Whether the JSON array for the current table has been opened. */
+    jsonTableOpened: boolean
+    /** Whether at least one row has been written into the current JSON array. */
+    jsonRowWritten: boolean
+    error?: string
+    options: { chunkSize: number }
+    progress: DumpJobProgress
+}
+
+/** R2 multipart minimum part size — 5 MiB per Cloudflare docs. Last part may be smaller. */
+export const R2_MIN_PART_SIZE = 5 * 1024 * 1024
+
+/** Maximum wall-clock time we spend in a single tick before yielding to the alarm. */
+export const TICK_BUDGET_MS = 20_000
+
+/** Default rows fetched per SELECT during the dump. */
+export const DEFAULT_CHUNK_SIZE = 1000
+
+/**
+ * Quote a SQLite identifier (table / column name) by wrapping it in double
+ * quotes and doubling any embedded double quotes. Mirrors sqlite_master's
+ * own encoding rules so the produced dump round-trips through `sqlite3`.
+ */
+export function quoteIdent(name: string): string {
+    return `"${String(name).replace(/"/g, '""')}"`
+}
+
+/** Quote a SQL string literal — single quotes are escaped by doubling. */
+export function quoteString(value: string): string {
+    return `'${value.replace(/'/g, "''")}'`
+}
+
+/**
+ * Serialize a single JS value into a SQLite SQL literal suitable for an
+ * INSERT statement. Handles NULL, numbers, strings, booleans, and bytes
+ * (rendered as the x'...' BLOB literal SQLite understands).
+ */
+export function sqlLiteral(value: unknown): string {
+    if (value === null || value === undefined) return 'NULL'
+    if (typeof value === 'number') {
+        if (!Number.isFinite(value)) return 'NULL'
+        return String(value)
+    }
+    if (typeof value === 'bigint') return value.toString()
+    if (typeof value === 'boolean') return value ? '1' : '0'
+    if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
+        const bytes =
+            value instanceof ArrayBuffer
+                ? new Uint8Array(value)
+                : new Uint8Array(
+                      (value as ArrayBufferView).buffer,
+                      (value as ArrayBufferView).byteOffset,
+                      (value as ArrayBufferView).byteLength
+                  )
+        let hex = ''
+        for (const b of bytes) hex += b.toString(16).padStart(2, '0')
+        return `x'${hex}'`
+    }
+    if (typeof value === 'object') return quoteString(JSON.stringify(value))
+    return quoteString(String(value))
+}
+
+/** Quote a value for inclusion in a CSV cell, following RFC 4180. */
+export function csvCell(value: unknown): string {
+    if (value === null || value === undefined) return ''
+    if (typeof value === 'number' || typeof value === 'bigint')
+        return String(value)
+    if (typeof value === 'boolean') return value ? 'true' : 'false'
+    const str =
+        typeof value === 'object' ? JSON.stringify(value) : String(value)
+    if (/[",\r\n]/.test(str)) return `"${str.replace(/"/g, '""')}"`
+    return str
+}
+
+/**
+ * Build the R2 object key used to store a job's dump. Embeds the timestamp
+ * and a short random suffix so concurrent jobs cannot collide on the same key.
+ */
+export function buildObjectKey(jobId: string, format: DumpFormat): string {
+    const ext = format === 'sql' ? 'sql' : format
+    const stamp = new Date()
+        .toISOString()
+        .replace(/[:.]/g, '-')
+        .replace('T', '_')
+        .replace('Z', '')
+    return `dumps/dump_${stamp}_${jobId.slice(0, 8)}.${ext}`
+}
+
+/** Build the R2 key for the temporary pending-buffer object. */
+export function buildPendingKey(jobId: string): string {
+    return `dumps/.pending/${jobId}.tmp`
+}
+
+/**
+ * Create the initial state for a freshly queued job. The caller is expected
+ * to attach an R2 uploadId once the multipart upload has been created.
+ */
+export function newJobState(
+    jobId: string,
+    options: DumpJobOptions,
+    tables: string[]
+): DumpJobState {
+    const now = Date.now()
+    return {
+        jobId,
+        status: 'queued',
+        format: options.format,
+        callbackUrl: options.callbackUrl,
+        objectKey: buildObjectKey(jobId, options.format),
+        uploadId: undefined,
+        parts: [],
+        pendingTempKey: undefined,
+        pendingBufferBytes: 0,
+        phase: 'header',
+        schemaEmitted: false,
+        currentColumns: null,
+        jsonTableOpened: false,
+        jsonRowWritten: false,
+        options: { chunkSize: options.chunkSize ?? DEFAULT_CHUNK_SIZE },
+        progress: {
+            tables,
+            currentTableIndex: 0,
+            currentTable: tables[0] ?? null,
+            rowOffset: 0,
+            rowsDumped: 0,
+            bytesWritten: 0,
+            partsUploaded: 0,
+            startedAt: now,
+            updatedAt: now,
+        },
+    }
+}
+
+/**
+ * A public-facing view of the job state, suitable for serializing into a
+ * status endpoint response. Strips engine internals.
+ */
+export interface DumpJobStatusView {
+    jobId: string
+    status: DumpStatus
+    format: DumpFormat
+    objectKey: string
+    error?: string
+    progress: DumpJobProgress
+}
+
+export function toStatusView(state: DumpJobState): DumpJobStatusView {
+    return {
+        jobId: state.jobId,
+        status: state.status,
+        format: state.format,
+        objectKey: state.objectKey,
+        error: state.error,
+        progress: state.progress,
+    }
+}
diff --git a/src/handler.test.ts b/src/handler.test.ts
index 86bb328..c292d5a 100644
--- a/src/handler.test.ts
+++ b/src/handler.test.ts
@@ -22,6 +22,9 @@ vi.mock('hono', () => {
             use: vi.fn(),
             post: vi.fn(),
             get: vi.fn(),
+            put: vi.fn(),
+            delete: vi.fn(),
+            patch: vi.fn(),
             all: vi.fn(),
             fetch: vi.fn().mockResolvedValue(new Response('mock-response')),
             notFound: vi.fn(),
diff --git a/src/handler.ts b/src/handler.ts
index 3fa0085..fa0241e 100644
--- a/src/handler.ts
+++ b/src/handler.ts
@@ -6,7 +6,13 @@ import { DataSource } from './types'
 import { LiteREST } from './literest'
 import { executeQuery, executeTransaction } from './operation'
 import { createResponse, QueryRequest, QueryTransactionRequest } from './utils'
-import { dumpDatabaseRoute } from './export/dump'
+import {
+    cancelDumpJobRoute,
+    downloadDumpJobRoute,
+    dumpDatabaseRoute,
+    getDumpJobStatusRoute,
+    startStreamingDumpRoute,
+} from './export/dump'
 import { exportTableToJsonRoute } from './export/json'
 import { exportTableToCsvRoute } from './export/csv'
 import { importDumpRoute } from './import/dump'
@@ -120,10 +126,54 @@ export class StarbaseDB {
         }
 
         if (this.getFeature('export')) {
+            // Legacy synchronous dump — still works for small databases that
+            // can finish within the 30s worker budget.
             this.app.get('/export/dump', this.isInternalSource, async () => {
                 return dumpDatabaseRoute(this.dataSource, this.config)
             })
 
+            // Streaming dump: kicks off a background job whose output is
+            // written to R2 over potentially many DO alarm ticks. Returns 202
+            // with a jobId the client uses to poll status and download.
+            this.app.post('/export/dump', this.isInternalSource, async (c) => {
+                return startStreamingDumpRoute(
+                    c.req.raw,
+                    this.dataSource,
+                    this.config
+                )
+            })
+
+            this.app.get(
+                '/export/dump/status/:jobId',
+                this.isInternalSource,
+                async (c) => {
+                    const jobId = c.req.param('jobId')
+                    return getDumpJobStatusRoute(
+                        jobId,
+                        c.req.raw,
+                        this.dataSource
+                    )
+                }
+            )
+
+            this.app.get(
+                '/export/dump/download/:jobId',
+                this.isInternalSource,
+                async (c) => {
+                    const jobId = c.req.param('jobId')
+                    return downloadDumpJobRoute(jobId, this.dataSource)
+                }
+            )
+
+            this.app.delete(
+                '/export/dump/:jobId',
+                this.isInternalSource,
+                async (c) => {
+                    const jobId = c.req.param('jobId')
+                    return cancelDumpJobRoute(jobId, this.dataSource)
+                }
+            )
+
             this.app.get(
                 '/export/json/:tableName',
                 this.isInternalSource,
diff --git a/src/index.ts b/src/index.ts
index 4d08932..c878284 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -56,6 +56,11 @@ export interface Env {
 
     HYPERDRIVE: Hyperdrive
 
+    // Optional R2 bucket used to persist long-running database dumps so they
+    // can survive the 30 second Worker / DO request limit. When unbound the
+    // /export/dump endpoint falls back to the original synchronous behavior.
+    DATABASE_DUMPS?: R2Bucket
+
     // ## DO NOT REMOVE: TEMPLATE INTERFACE ##
 }
 
diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts
index 6c35c6f..37409ae 100644
--- a/worker-configuration.d.ts
+++ b/worker-configuration.d.ts
@@ -13,4 +13,5 @@ interface Env {
     DATABASE_DURABLE_OBJECT: DurableObjectNamespace<
         import('./src/index').StarbaseDBDurableObject
     >
+    DATABASE_DUMPS?: R2Bucket
 }
diff --git a/wrangler.toml b/wrangler.toml
index 395c4ac..22f119f 100644
--- a/wrangler.toml
+++ b/wrangler.toml
@@ -33,6 +33,13 @@ class_name = "StarbaseDBDurableObject"
 tag = "v1"
 new_sqlite_classes = ["StarbaseDBDurableObject"]
 
+# R2 bucket used to stream large database dumps. Required for /export/dump on
+# databases that exceed the inline 30-second / 1GB synchronous limits.
+# Create the bucket with: `wrangler r2 bucket create starbasedb-dumps`
+[[r2_buckets]]
+binding = "DATABASE_DUMPS"
+bucket_name = "starbasedb-dumps"
+
 [vars]
 # Use this in your Authorization header for full database access
 ADMIN_AUTHORIZATION_TOKEN = "ABC123"