@electric-sql/pglite-tools
Version:
Tools for working with PGlite databases
247 lines (218 loc) • 5.92 kB
text/typescript
import { WasiPreview1 } from './wasi/easywasi'
import { postgresMod } from '@electric-sql/pglite'
import { PGlite } from '@electric-sql/pglite'
type FS = postgresMod.FS
type FSInterface = any // WASI FS interface
const IN_NODE =
typeof process === 'object' &&
typeof process.versions === 'object' &&
typeof process.versions.node === 'string'
/**
* Emscripten FS is not quite compatible with WASI
* so we need to patch it
*/
function emscriptenFsToWasiFS(fs: FS, acc: any[]): FSInterface & FS {
const requiredMethods = [
'appendFileSync',
'fsyncSync',
'linkSync',
'setFlagsSync',
'mkdirSync',
'readdirSync',
'readFileSync',
'readlinkSync',
'renameSync',
'rmdirSync',
'statSync',
'symlinkSync',
'truncateSync',
'unlinkSync',
'utimesSync',
'writeFileSync',
]
return {
// Bind all methods to the FS instance
...fs,
// Add missing methods
...Object.fromEntries(
requiredMethods
.map((method) => {
const target = method.slice(0, method.length - 4)
if (!(target in fs)) {
return [
method,
() => {
throw new Error(`${target} not implemented.`)
},
]
}
return [
method,
(...args: any[]) => {
if (method === 'writeFileSync' && args[0] === '/tmp/out.sql') {
acc.push(args[1])
}
return (fs as any)[target](...args)
},
]
})
.filter(
(entry): entry is [string, (fs: FS) => any] => entry !== undefined,
),
),
} as FSInterface & FS
}
/**
* Inner function to execute pg_dump
*/
async function execPgDump({
pg,
args,
}: {
pg: PGlite
args: string[]
}): Promise<[number, Uint8Array[], string]> {
const bin = new URL('./pg_dump.wasm', import.meta.url)
const acc: Uint8Array[] = []
const FS = emscriptenFsToWasiFS(pg.Module.FS, acc)
const wasi = new WasiPreview1({
fs: FS,
args: ['pg_dump', ...args],
env: {
PWD: '/',
},
})
wasi.stdout = (_buffer) => {
// console.log('stdout', _buffer)
}
const textDecoder = new TextDecoder()
let errorMessage = ''
wasi.stderr = (_buffer) => {
const text = textDecoder.decode(_buffer)
if (text) errorMessage += text
}
wasi.sched_yield = () => {
const pgIn = '/tmp/pglite/base/.s.PGSQL.5432.in'
const pgOut = '/tmp/pglite/base/.s.PGSQL.5432.out'
if (FS.analyzePath(pgIn).exists) {
// call interactive one
const msgIn = FS.readFileSync(pgIn)
// BYPASS the file socket emulation in PGlite
FS.unlinkSync(pgIn)
// Handle auth request
if (msgIn[0] === 0) {
const reply = new Uint8Array([
...authOk,
...versionParam,
...readyForQuery,
])
FS.writeFileSync(pgOut, reply)
return 0
}
// Handle query
const reply = pg.execProtocolRawSync(msgIn)
FS.writeFileSync(pgOut, reply)
}
return 0
}
// Postgres can complain if the binary is not on the filesystem
// so we create a dummy file
await FS.writeFile('/pg_dump', '\0', { mode: 18 })
let app: WebAssembly.WebAssemblyInstantiatedSource
if (IN_NODE) {
const fs = await import('fs/promises')
const blob = await fs.readFile(bin.toString().slice(7))
app = await WebAssembly.instantiate(blob, {
wasi_snapshot_preview1: wasi as any,
})
} else {
app = await WebAssembly.instantiateStreaming(fetch(bin), {
wasi_snapshot_preview1: wasi as any,
})
}
let exitCode: number
await pg.runExclusive(async () => {
exitCode = wasi.start(app.instance.exports)
})
return [exitCode!, acc, errorMessage]
}
interface PgDumpOptions {
pg: PGlite
args?: string[]
fileName?: string
}
/**
* Execute pg_dump
*/
export async function pgDump({
pg,
args,
fileName = 'dump.sql',
}: PgDumpOptions) {
const getSearchPath = await pg.query<{ search_path: string }>(
'SHOW SEARCH_PATH;',
)
const search_path = getSearchPath.rows[0].search_path
const outFile = `/tmp/out.sql`
const baseArgs = [
'-U',
'postgres',
'--inserts',
'-j',
'1',
'-f',
outFile,
'postgres',
]
const [exitCode, acc, errorMessage] = await execPgDump({
pg,
args: [...(args ?? []), ...baseArgs],
})
pg.exec(`DEALLOCATE ALL; SET SEARCH_PATH = ${search_path}`)
if (exitCode !== 0) {
throw new Error(
`pg_dump failed with exit code ${exitCode}. \nError message: ${errorMessage}`,
)
}
const file = new File(acc, fileName, {
type: 'text/plain',
})
pg.Module.FS.unlink(outFile)
return file
}
// Wire protocol messages for simulating auth handshake:
function charToByte(char: string) {
return char.charCodeAt(0)
}
// Function to convert an integer to a 4-byte array (Int32)
function int32ToBytes(value: number) {
const buffer = new ArrayBuffer(4)
const view = new DataView(buffer)
view.setInt32(0, value, false) // false for big-endian
return new Uint8Array(buffer)
}
// Convert a string to a Uint8Array with a null terminator (C string)
function stringToBytes(str: string) {
const utf8Encoder = new TextEncoder()
const strBytes = utf8Encoder.encode(str) // UTF-8 encoding
return new Uint8Array([...strBytes, 0]) // Append null terminator
}
const authOk = new Uint8Array([
charToByte('R'),
...int32ToBytes(8),
...int32ToBytes(0),
])
const readyForQuery = new Uint8Array([
charToByte('Z'),
...int32ToBytes(5),
charToByte('I'),
])
const svParamName = stringToBytes('server_version')
const svParamValue = stringToBytes('16.3 (PGlite 0.2.0)')
const svTotalLength = 4 + svParamName.length + svParamValue.length
const versionParam = new Uint8Array([
charToByte('S'),
...int32ToBytes(svTotalLength),
...svParamName,
...svParamValue,
])