From fc881703c373d161c9d123b1c90ade6a16f8a22e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bramer=20Schmidt?= Date: Tue, 24 Feb 2026 21:06:39 +0700 Subject: [PATCH] Add startup postgresConfig and WAL-compatible pgoutput support --- packages/pglite/src/interface.ts | 6 ++ packages/pglite/src/pglite.ts | 73 +++++++++++++++++++ packages/pglite/tests/postgres-config.test.ts | 48 ++++++++++++ postgres-pglite | 2 +- 4 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 packages/pglite/tests/postgres-config.test.ts diff --git a/packages/pglite/src/interface.ts b/packages/pglite/src/interface.ts index d75110976..4857ee4e2 100644 --- a/packages/pglite/src/interface.ts +++ b/packages/pglite/src/interface.ts @@ -81,6 +81,12 @@ export interface PGliteOptions { dataDir?: string username?: string database?: string + /** + * Extra postgres GUC settings passed as `-c key=value` at backend startup. + * Use this for postmaster settings that must be set before the server starts + * (for example `wal_level=logical`). + */ + postgresConfig?: Record fs?: Filesystem debug?: DebugLevel relaxedDurability?: boolean diff --git a/packages/pglite/src/pglite.ts b/packages/pglite/src/pglite.ts index de5fedefa..d4363c5f1 100644 --- a/packages/pglite/src/pglite.ts +++ b/packages/pglite/src/pglite.ts @@ -37,6 +37,8 @@ import { NotificationResponseMessage, } from '@electric-sql/pg-protocol/messages' +const POSTGRES_CONFIG_ENV_NAME = 'PGLITE_POSTGRES_CONFIG' + export class PGlite extends BasePGlite implements PGliteInterface, AsyncDisposable @@ -212,12 +214,18 @@ export class PGlite const extensionBundlePromises: Record> = {} const extensionInitFns: Array<() => Promise> = [] + const serializedPostgresConfig = serializePostgresConfig( + options.postgresConfig, + ) const args = [ `PGDATA=${PGDATA}`, `PREFIX=${WASM_PREFIX}`, `PGUSER=${options.username ?? 'postgres'}`, `PGDATABASE=${options.database ?? 'template1'}`, + ...(serializedPostgresConfig + ? [`${POSTGRES_CONFIG_ENV_NAME}=${serializedPostgresConfig}`] + : []), 'MODE=REACT', 'REPL=N', // "-F", // Disable fsync (TODO: Only for in-memory mode?) @@ -584,6 +592,9 @@ export class PGlite // Database closed successfully // An earlier build of PGlite would throw an error here when closing // leaving this here for now. I believe it was a bug in Emscripten. + } else if (e === Infinity) { + // Some emscripten shutdown paths can surface a numeric sentinel + // instead of ExitStatus(0) even though shutdown completed. } else { throw e } @@ -1002,3 +1013,65 @@ export class PGlite return this.#listenMutex.runExclusive(fn) } } + +const POSTGRES_GUC_NAME_RE = /^[A-Za-z_][A-Za-z0-9_.-]*$/ +const POSTGRES_CONFIG_ENTRY_SEPARATOR = ';' + +function serializePostgresConfig( + config?: Record, +) { + if (!config) { + return null + } + + const entries = Object.entries(config).sort(([a], [b]) => a.localeCompare(b)) + if (entries.length === 0) { + return null + } + + return entries + .map( + ([name, value]) => + `${formatPostgresGucName(name)}=${formatPostgresGucValue(value)}`, + ) + .join(POSTGRES_CONFIG_ENTRY_SEPARATOR) +} + +function formatPostgresGucName(name: string) { + if (!POSTGRES_GUC_NAME_RE.test(name)) { + throw new Error(`Invalid postgresConfig key: ${name}`) + } + if (name.includes(POSTGRES_CONFIG_ENTRY_SEPARATOR)) { + throw new Error( + `Invalid postgresConfig key (contains '${POSTGRES_CONFIG_ENTRY_SEPARATOR}'): ${name}`, + ) + } + return name +} + +function formatPostgresGucValue(value: string | number | boolean) { + let normalized: string + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new Error(`Invalid postgresConfig value: ${value}`) + } + normalized = String(value) + } else if (typeof value === 'boolean') { + normalized = value ? 'on' : 'off' + } else { + normalized = value + } + + if ( + normalized.includes(POSTGRES_CONFIG_ENTRY_SEPARATOR) || + normalized.includes('\n') || + normalized.includes('\r') || + normalized.includes('\0') + ) { + throw new Error( + `Invalid postgresConfig value (contains disallowed separator/control character): ${normalized}`, + ) + } + + return normalized +} diff --git a/packages/pglite/tests/postgres-config.test.ts b/packages/pglite/tests/postgres-config.test.ts new file mode 100644 index 000000000..83bfa5be1 --- /dev/null +++ b/packages/pglite/tests/postgres-config.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { testEsmCjsAndDTC } from './test-utils.ts' + +await testEsmCjsAndDTC(async (importType) => { + const { PGlite } = + importType === 'esm' + ? await import('../dist/index.js') + : ((await import( + '../dist/index.cjs' + )) as unknown as typeof import('../dist/index.js')) + + describe('postgresConfig', () => { + it('applies postmaster settings at startup', async () => { + const db = await PGlite.create({ + dataDir: 'memory://', + postgresConfig: { + max_replication_slots: 12, + max_wal_senders: 12, + wal_level: 'logical', + }, + }) + + const settings = await db.query<{ + name: string + setting: string + }>( + "SELECT name, setting FROM pg_settings WHERE name IN ('max_replication_slots', 'max_wal_senders', 'wal_level') ORDER BY name", + ) + + expect(settings.rows).toEqual([ + { + name: 'max_replication_slots', + setting: '12', + }, + { + name: 'max_wal_senders', + setting: '12', + }, + { + name: 'wal_level', + setting: 'logical', + }, + ]) + + await db.close() + }) + }) +}) diff --git a/postgres-pglite b/postgres-pglite index bee4a36b7..37960945f 160000 --- a/postgres-pglite +++ b/postgres-pglite @@ -1 +1 @@ -Subproject commit bee4a36b76d2607f5c1d2ca61fd013958b17d0e9 +Subproject commit 37960945f28adf9e952289cd3bdd1f5ec6644dec