Skip to content

Commit f2396d3

Browse files
authored
Merge pull request #142 from supabase-community/feat/pg_dump-in-the-browser
feat: use pg_dump to download the database schema and data
2 parents 5b23ed1 + fd2be63 commit f2396d3

File tree

7 files changed

+530
-951
lines changed

7 files changed

+530
-951
lines changed

apps/web/components/sidebar/database-menu-item.tsx

+16-6
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import { useDatabaseUpdateMutation } from '~/data/databases/database-update-muta
4141
import { useIntegrationQuery } from '~/data/integrations/integration-query'
4242
import { MergedDatabase } from '~/data/merged-databases/merged-databases'
4343
import { useQueryEvent } from '~/lib/hooks'
44-
import { downloadFile, getDeployUrl, getOauthUrl, titleToKebabCase } from '~/lib/util'
44+
import { downloadFileFromUrl, getDeployUrl, getOauthUrl, titleToKebabCase } from '~/lib/util'
4545
import { cn } from '~/lib/utils'
4646

4747
export type DatabaseMenuItemProps = {
@@ -319,13 +319,23 @@ export function DatabaseMenuItem({ database, isActive, onClick }: DatabaseMenuIt
319319
throw new Error('dbManager is not available')
320320
}
321321

322-
const db = await dbManager.getDbInstance(database.id)
323-
const dumpBlob = await db.dumpDataDir()
322+
// Ensure the db worker is ready
323+
await dbManager.getDbInstance(database.id)
324324

325-
const fileName = `${titleToKebabCase(database.name ?? 'My Database')}-${Date.now()}`
326-
const file = new File([dumpBlob], fileName, { type: dumpBlob.type })
325+
const bc = new BroadcastChannel(`${database.id}:pg-dump`)
327326

328-
downloadFile(file)
327+
bc.addEventListener('message', (event) => {
328+
if (event.data.action === 'dump-result') {
329+
downloadFileFromUrl(event.data.url, event.data.filename)
330+
bc.close()
331+
setIsPopoverOpen(false)
332+
}
333+
})
334+
335+
bc.postMessage({
336+
action: 'execute-dump',
337+
filename: `${titleToKebabCase(database.name ?? 'My Database')}-${Date.now()}.sql`,
338+
})
329339
}}
330340
>
331341
<Download

apps/web/lib/db/index.ts

+5-4
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { PGliteWorker } from '@electric-sql/pglite/worker'
44
import { Message as AiMessage, ToolInvocation } from 'ai'
55
import { codeBlock } from 'common-tags'
66
import { nanoid } from 'nanoid'
7+
import { downloadFileFromUrl } from '../util'
78

89
export type Database = {
910
id: string
@@ -47,7 +48,7 @@ export class DbManager {
4748
/**
4849
* Creates a PGlite instance that runs in a web worker
4950
*/
50-
static async createPGlite(options?: PGliteOptions): Promise<PGliteInterface> {
51+
static async createPGlite(options?: PGliteOptions & { id?: string }) {
5152
if (typeof window === 'undefined') {
5253
throw new Error('PGlite worker instances are only available in the browser')
5354
}
@@ -59,7 +60,7 @@ export class DbManager {
5960
new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }),
6061
{
6162
// Opt out of PGlite worker leader election / shared DBs
62-
id: nanoid(),
63+
id: options?.id ?? nanoid(),
6364
...options,
6465
}
6566
)
@@ -274,7 +275,7 @@ export class DbManager {
274275
return metaDb.sql`insert into databases (id, name, created_at, is_hidden) values ${join(values, ',')} on conflict (id) do nothing`
275276
}
276277

277-
async getDbInstance(id: string, loadDataDir?: Blob | File) {
278+
async getDbInstance(id: string, loadDataDir?: Blob | File): Promise<PGliteInterface> {
278279
const openDatabasePromise = this.databaseConnections.get(id)
279280

280281
if (openDatabasePromise) {
@@ -292,7 +293,7 @@ export class DbManager {
292293

293294
await this.handleUnsupportedPGVersion(dbPath)
294295

295-
const db = await DbManager.createPGlite({ dataDir: `idb://${dbPath}`, loadDataDir })
296+
const db = await DbManager.createPGlite({ dataDir: `idb://${dbPath}`, loadDataDir, id })
296297
await runMigrations(db, migrations)
297298

298299
return db

apps/web/lib/db/worker.ts

+40-1
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { PGlite } from '@electric-sql/pglite'
22
import { vector } from '@electric-sql/pglite/vector'
33
import { PGliteWorkerOptions, worker } from '@electric-sql/pglite/worker'
4+
import { pgDump } from '@electric-sql/pglite-tools/pg_dump'
5+
import { codeBlock } from 'common-tags'
46

57
worker({
68
async init(options: PGliteWorkerOptions) {
7-
return new PGlite({
9+
const db = new PGlite({
810
...options,
911
extensions: {
1012
...options.extensions,
@@ -13,5 +15,42 @@ worker({
1315
vector,
1416
},
1517
})
18+
19+
const bc = new BroadcastChannel(`${options.id}:pg-dump`)
20+
21+
bc.addEventListener('message', async (event) => {
22+
if (event.data.action === 'execute-dump') {
23+
let dump = await pgDump({ pg: db })
24+
// clear prepared statements
25+
await db.query('deallocate all')
26+
let dumpContent = await dump.text()
27+
// patch for old PGlite versions where the vector extension was not included in the dump
28+
if (!dumpContent.includes('CREATE EXTENSION IF NOT EXISTS vector WITH SCHEMA public;')) {
29+
const insertPoint = 'ALTER SCHEMA meta OWNER TO postgres;'
30+
const insertPointIndex = dumpContent.indexOf(insertPoint) + insertPoint.length
31+
dumpContent = codeBlock`
32+
${dumpContent.slice(0, insertPointIndex)}
33+
34+
--
35+
-- Name: vector; Type: EXTENSION; Schema: -; Owner: -
36+
--
37+
38+
CREATE EXTENSION IF NOT EXISTS vector WITH SCHEMA public;
39+
40+
${dumpContent.slice(insertPointIndex)}`
41+
42+
// Create new blob with modified content
43+
dump = new File([dumpContent], event.data.filename)
44+
}
45+
const url = URL.createObjectURL(dump)
46+
bc.postMessage({
47+
action: 'dump-result',
48+
filename: event.data.filename,
49+
url,
50+
})
51+
}
52+
})
53+
54+
return db
1655
},
1756
})

apps/web/lib/util.ts

+11-4
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,25 @@ export const currentDomainUrl = process.env.NEXT_PUBLIC_CURRENT_DOMAIN!
77
export const currentDomainHostname = new URL(currentDomainUrl).hostname
88

99
/**
10-
* Programmatically download a `File`.
10+
* Programmatically download a `File` from a given URL.
1111
*/
12-
export function downloadFile(file: File) {
13-
const url = URL.createObjectURL(file)
12+
export function downloadFileFromUrl(url: string, filename: string) {
1413
const a = document.createElement('a')
1514
a.href = url
16-
a.download = file.name
15+
a.download = filename
1716
document.body.appendChild(a)
1817
a.click()
1918
a.remove()
2019
}
2120

21+
/**
22+
* Programmatically download a `File`.
23+
*/
24+
export function downloadFile(file: File) {
25+
const url = URL.createObjectURL(file)
26+
downloadFileFromUrl(url, file.name)
27+
}
28+
2229
export async function requestFileUpload() {
2330
return new Promise<File>((resolve, reject) => {
2431
// Create a temporary file input element

apps/web/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
"@ai-sdk/openai": "^1.0.4",
1616
"@dagrejs/dagre": "^1.1.2",
1717
"@database.build/deploy": "*",
18-
"@electric-sql/pglite": "^0.2.9",
18+
"@electric-sql/pglite": "^0.2.14",
19+
"@electric-sql/pglite-tools": "^0.2.2",
1920
"@gregnr/postgres-meta": "^0.82.0-dev.2",
2021
"@hookform/resolvers": "^3.9.1",
2122
"@monaco-editor/react": "^4.6.0",

0 commit comments

Comments
 (0)