Skip to content

Commit 0034b65

Browse files
committed
fix(export): stream and paginate database dumps to support large databases
Resolves OOM/timeout failures when dumping large databases by switching the /export/dump response to a ReadableStream and paginating per-table SELECTs with LIMIT/OFFSET instead of loading the entire result set into memory at once. Closes #59
1 parent bb22735 commit 0034b65

2 files changed

Lines changed: 123 additions & 38 deletions

File tree

src/export/dump.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,57 @@ describe('Database Dump Module', () => {
128128
)
129129
})
130130

131+
it('should paginate large tables across multiple queries', async () => {
132+
const firstPage = Array.from({ length: 1000 }, (_, i) => ({
133+
id: i + 1,
134+
name: `User${i + 1}`,
135+
}))
136+
const secondPage = [{ id: 1001, name: 'User1001' }]
137+
138+
vi.mocked(executeOperation)
139+
.mockResolvedValueOnce([{ name: 'users' }])
140+
.mockResolvedValueOnce([
141+
{ sql: 'CREATE TABLE users (id INTEGER, name TEXT);' },
142+
])
143+
.mockResolvedValueOnce(firstPage)
144+
.mockResolvedValueOnce(secondPage)
145+
146+
const response = await dumpDatabaseRoute(mockDataSource, mockConfig)
147+
const dumpText = await response.text()
148+
149+
expect(dumpText).toContain("INSERT INTO users VALUES (1, 'User1');")
150+
expect(dumpText).toContain(
151+
"INSERT INTO users VALUES (1000, 'User1000');"
152+
)
153+
expect(dumpText).toContain(
154+
"INSERT INTO users VALUES (1001, 'User1001');"
155+
)
156+
157+
const issuedSql = vi
158+
.mocked(executeOperation)
159+
.mock.calls.map((c) => (c[0] as any)[0].sql)
160+
expect(issuedSql).toContain(
161+
'SELECT * FROM users LIMIT 1000 OFFSET 0;'
162+
)
163+
expect(issuedSql).toContain(
164+
'SELECT * FROM users LIMIT 1000 OFFSET 1000;'
165+
)
166+
})
167+
168+
it('should serialize NULL values as the NULL keyword', async () => {
169+
vi.mocked(executeOperation)
170+
.mockResolvedValueOnce([{ name: 'users' }])
171+
.mockResolvedValueOnce([
172+
{ sql: 'CREATE TABLE users (id INTEGER, name TEXT);' },
173+
])
174+
.mockResolvedValueOnce([{ id: 1, name: null }])
175+
176+
const response = await dumpDatabaseRoute(mockDataSource, mockConfig)
177+
const dumpText = await response.text()
178+
179+
expect(dumpText).toContain('INSERT INTO users VALUES (1, NULL);')
180+
})
181+
131182
it('should return a 500 response when an error occurs', async () => {
132183
const consoleErrorMock = vi
133184
.spyOn(console, 'error')

src/export/dump.ts

Lines changed: 72 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,67 +3,101 @@ import { StarbaseDBConfiguration } from '../handler'
33
import { DataSource } from '../types'
44
import { createResponse } from '../utils'
55

6+
// Number of rows fetched per page when dumping a table. Keeping this
7+
// bounded avoids loading entire tables into Worker memory, which is
8+
// what previously caused dumps of large databases to fail.
9+
const DUMP_PAGE_SIZE = 1000
10+
11+
function formatValue(value: unknown): string {
12+
if (value === null || value === undefined) return 'NULL'
13+
if (typeof value === 'string') return `'${value.replace(/'/g, "''")}'`
14+
return String(value)
15+
}
16+
617
export async function dumpDatabaseRoute(
718
dataSource: DataSource,
819
config: StarbaseDBConfiguration
920
): Promise<Response> {
1021
try {
11-
// Get all table names
22+
// Resolve the list of tables up front so any failure surfaces as a
23+
// 500 (matching prior behavior) rather than mid-stream.
1224
const tablesResult = await executeOperation(
1325
[{ sql: "SELECT name FROM sqlite_master WHERE type='table';" }],
1426
dataSource,
1527
config
1628
)
17-
1829
const tables = tablesResult.map((row: any) => row.name)
19-
let dumpContent = 'SQLite format 3\0' // SQLite file header
2030

21-
// Iterate through all tables
22-
for (const table of tables) {
23-
// Get table schema
24-
const schemaResult = await executeOperation(
25-
[
26-
{
27-
sql: `SELECT sql FROM sqlite_master WHERE type='table' AND name='${table}';`,
28-
},
29-
],
30-
dataSource,
31-
config
32-
)
31+
const encoder = new TextEncoder()
32+
const stream = new ReadableStream<Uint8Array>({
33+
async start(controller) {
34+
try {
35+
controller.enqueue(encoder.encode('SQLite format 3\0'))
3336

34-
if (schemaResult.length) {
35-
const schema = schemaResult[0].sql
36-
dumpContent += `\n-- Table: ${table}\n${schema};\n\n`
37-
}
37+
for (const table of tables) {
38+
const schemaResult = await executeOperation(
39+
[
40+
{
41+
sql: `SELECT sql FROM sqlite_master WHERE type='table' AND name='${table}';`,
42+
},
43+
],
44+
dataSource,
45+
config
46+
)
3847

39-
// Get table data
40-
const dataResult = await executeOperation(
41-
[{ sql: `SELECT * FROM ${table};` }],
42-
dataSource,
43-
config
44-
)
48+
if (schemaResult.length) {
49+
const schema = schemaResult[0].sql
50+
controller.enqueue(
51+
encoder.encode(
52+
`\n-- Table: ${table}\n${schema};\n\n`
53+
)
54+
)
55+
}
4556

46-
for (const row of dataResult) {
47-
const values = Object.values(row).map((value) =>
48-
typeof value === 'string'
49-
? `'${value.replace(/'/g, "''")}'`
50-
: value
51-
)
52-
dumpContent += `INSERT INTO ${table} VALUES (${values.join(', ')});\n`
53-
}
57+
// Page through the table so we never materialize the
58+
// full result set in memory.
59+
let offset = 0
60+
while (true) {
61+
const dataResult = await executeOperation(
62+
[
63+
{
64+
sql: `SELECT * FROM ${table} LIMIT ${DUMP_PAGE_SIZE} OFFSET ${offset};`,
65+
},
66+
],
67+
dataSource,
68+
config
69+
)
5470

55-
dumpContent += '\n'
56-
}
71+
if (!dataResult.length) break
5772

58-
// Create a Blob from the dump content
59-
const blob = new Blob([dumpContent], { type: 'application/x-sqlite3' })
73+
let chunk = ''
74+
for (const row of dataResult) {
75+
const values =
76+
Object.values(row).map(formatValue)
77+
chunk += `INSERT INTO ${table} VALUES (${values.join(', ')});\n`
78+
}
79+
controller.enqueue(encoder.encode(chunk))
80+
81+
if (dataResult.length < DUMP_PAGE_SIZE) break
82+
offset += DUMP_PAGE_SIZE
83+
}
84+
85+
controller.enqueue(encoder.encode('\n'))
86+
}
87+
88+
controller.close()
89+
} catch (error) {
90+
controller.error(error)
91+
}
92+
},
93+
})
6094

6195
const headers = new Headers({
6296
'Content-Type': 'application/x-sqlite3',
6397
'Content-Disposition': 'attachment; filename="database_dump.sql"',
6498
})
6599

66-
return new Response(blob, { headers })
100+
return new Response(stream, { headers })
67101
} catch (error: any) {
68102
console.error('Database Dump Error:', error)
69103
return createResponse(undefined, 'Failed to create database dump', 500)

0 commit comments

Comments
 (0)