diff --git a/src/export/dump.ts b/src/export/dump.ts index 91a2e89..99ab8b5 100644 --- a/src/export/dump.ts +++ b/src/export/dump.ts @@ -3,11 +3,51 @@ import { StarbaseDBConfiguration } from '../handler' import { DataSource } from '../types' import { createResponse } from '../utils' +const MAX_RUNTIME_MS = 20 * 1000 // stop ~10s before 30s DO request limit +const PAGE_SIZE_BYTES = 4096 // SQLite default page size + export async function dumpDatabaseRoute( dataSource: DataSource, config: StarbaseDBConfiguration ): Promise { + const startTime = Date.now() + try { + // Estimate dump size before loading anything into memory. + // pragma_page_count gives total pages; each page is 4096 bytes by default. + let estimatedBytes = 0 + try { + const pageResult = await executeOperation( + [{ sql: 'SELECT page_count FROM pragma_page_count();' }], + dataSource, + config + ) + const schemaResult = await executeOperation( + [ + { + sql: "SELECT SUM(LENGTH(sql)) FROM sqlite_master WHERE type IN ('table','index','trigger','view');", + }, + ], + dataSource, + config + ) + const pageCount = pageResult?.[0]?.page_count ?? 0 + const schemaBytes = schemaResult?.[0]?.['SUM(LENGTH(sql))'] ?? 0 + estimatedBytes = pageCount * PAGE_SIZE_BYTES + schemaBytes * 2 + } catch (_) { + // Estimate unavailable — proceed with caution + } + + if (estimatedBytes > 100 * 1024 * 1024) { + // >100 MB — too large for in-memory; user must chunk export + return createResponse( + undefined, + 'Database exceeds 100 MB — in-memory dump not supported. ' + + 'Export individual tables via /export/csv/:tableName or /export/json/:tableName.', + 413 + ) + } + // Get all table names const tablesResult = await executeOperation( [{ sql: "SELECT name FROM sqlite_master WHERE type='table';" }], @@ -16,11 +56,21 @@ export async function dumpDatabaseRoute( ) const tables = tablesResult.map((row: any) => row.name) - let dumpContent = 'SQLite format 3\0' // SQLite file header + let dumpContent = 'SQLite format 3\0' + + for (let i = 0; i < tables.length; i++) { + const table = tables[i] + + // Time budget check between tables (not mid-INSERT) + if (Date.now() - startTime > MAX_RUNTIME_MS) { + return createResponse( + undefined, + `Dump time budget exceeded (${MAX_RUNTIME_MS / 1000}s) after ${i + 1}/${tables.length} tables. ` + + 'Export individual tables via /export/csv/:tableName or /export/json/:tableName.', + 413 + ) + } - // Iterate through all tables - for (const table of tables) { - // Get table schema const schemaResult = await executeOperation( [ { @@ -36,7 +86,11 @@ export async function dumpDatabaseRoute( dumpContent += `\n-- Table: ${table}\n${schema};\n\n` } - // Get table data + // Stream each table's rows with a per-table timeout + const tableStart = Date.now() + const MAX_TABLE_MS = 10 * 1000 + let rowCount = 0 + const dataResult = await executeOperation( [{ sql: `SELECT * FROM ${table};` }], dataSource, @@ -44,18 +98,28 @@ export async function dumpDatabaseRoute( ) for (const row of dataResult) { + // Individual row timeout guard + if (Date.now() - tableStart > MAX_TABLE_MS || rowCount > 500_000) { + return createResponse( + undefined, + `Table "${table}" exceeds size limits (${rowCount}+ rows or ${MAX_TABLE_MS / 1000}s). ` + + 'Use /export/csv/:tableName for large tables.', + 413 + ) + } + const values = Object.values(row).map((value) => typeof value === 'string' ? `'${value.replace(/'/g, "''")}'` : value ) dumpContent += `INSERT INTO ${table} VALUES (${values.join(', ')});\n` + rowCount++ } dumpContent += '\n' } - // Create a Blob from the dump content const blob = new Blob([dumpContent], { type: 'application/x-sqlite3' }) const headers = new Headers({