diff --git a/README.md b/README.md index 96f6d9a..5002515 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,7 @@ seen here: | password | string | The password for logging into the Postgres database. Defaults to `password` | | authMethod | 'scram-sha-256' \| 'password' \| 'md5' | The authentication method to use when authenticating against Postgres. Defaults to `password` | | persistent | boolean | Whether all data should be left in place when the database is shut down. Defaults to `true`. | +| lifecycleMode | 'managed' \| 'detached' | Controls how the Postgres process is tied to the parent Node process. `managed` preserves the historical behavior: the child stays attached and is stopped automatically when the parent exits. `detached` isolates the postmaster from parent terminal signals and requires explicit `stop()` calls. Defaults to `managed`. | | initdbFlags | string[] | Pass any additional flags to the initdb process. You can find all available flags here: https://www.postgresql.org/docs/current/app-initdb.html. Flags should be passed as a string array, e.g. `["--debug"]` or `["--locale=en-GB"]` Defaults to `[]` | postgresFlags | string[] | Pass any additional flags to the postgres process. You can find all available flags here: https://www.postgresql.org/docs/current/app-postgres.html. Flags should be passed as a string array, e.g. `["--debug"]` or `["--locale=en-GB"]`. Defaults to `[]`. | | createPostgresUser | boolean | Postgres does not allow binaries to be run by root. In case you're running in root-only enviroments, such as Docker containers, you may need to create an extra user on your system in order to be able to call the binaries.
NOTE: This WILL irreversibly modify your host system. The effects are somewhat minor, but it's still recommend to only use this in Docker containers. Defaults to `false`. | diff --git a/packages/embedded-postgres/README.md b/packages/embedded-postgres/README.md index 59d2a15..79bbd8a 100644 --- a/packages/embedded-postgres/README.md +++ b/packages/embedded-postgres/README.md @@ -117,6 +117,7 @@ seen here: | password | string | The password for logging into the Postgres database. Defaults to `password` | | authMethod | 'scram-sha-256' \| 'password' \| 'md5' | The authentication method to use when authenticating against Postgres. Defaults to `password` | | persistent | boolean | Whether all data should be left in place when the database is shut down. Defaults to `true`. | +| lifecycleMode | 'managed' \| 'detached' | Controls how the Postgres process is tied to the parent Node process. `managed` preserves the historical behavior: the child stays attached and is stopped automatically when the parent exits. `detached` isolates the postmaster from parent terminal signals and requires explicit `stop()` calls. Defaults to `managed`. | | initdbFlags | string[] | Pass any additional flags to the initdb process. You can find all available flags here: https://www.postgresql.org/docs/current/app-initdb.html. Flags should be passed as a string array, e.g. `["--debug"]` or `["--locale=en-GB"]` Defaults to `[]` | postgresFlags | string[] | Pass any additional flags to the postgres process. You can find all available flags here: https://www.postgresql.org/docs/current/app-postgres.html. Flags should be passed as a string array, e.g. `["--debug"]` or `["--locale=en-GB"]`. Defaults to `[]`. | | createPostgresUser | boolean | Postgres does not allow binaries to be run by root. In case you're running in root-only enviroments, such as Docker containers, you may need to create an extra user on your system in order to be able to call the binaries.
NOTE: This WILL irreversibly modify your host system. The effects are somewhat minor, but it's still recommend to only use this in Docker containers. Defaults to `false`. | diff --git a/packages/embedded-postgres/src/index.ts b/packages/embedded-postgres/src/index.ts index b11391b..fcfbd9f 100644 --- a/packages/embedded-postgres/src/index.ts +++ b/packages/embedded-postgres/src/index.ts @@ -13,6 +13,9 @@ import { PostgresOptions } from './types.js'; const bin = getBinaries(); const { Client } = pg; +const POSTGRES_READY_POLL_INTERVAL_MS = 250; +const POSTGRES_READY_TIMEOUT_MS = 300_000; + /** * We have to specify the LC_MESSAGES locale because we rely on inspecting the * output of the `initdb` command to see if Postgres is ready. As we're looking @@ -57,6 +60,7 @@ const defaults: PostgresOptions = { password: 'password', authMethod: 'password', persistent: true, + lifecycleMode: 'managed', initdbFlags: [], postgresFlags: [], createPostgresUser: false, @@ -78,6 +82,18 @@ const ensureBinIsExecutable = async (filePath: string) => { } }; + +const quoteWindowsCommandLineArgument = (value: string) => { + const stringValue = String(value); + if (!/[ \t\n\v"]/.test(stringValue)) { + return stringValue; + } + + return `"${stringValue + .replace(/(\\*)"/g, '$1$1\\"') + .replace(/(\\+)$/g, '$1$1')}"`; +}; + /** * This will track instances of all current initialised clusters. We need this * because we want to be able to shutdown any clusters when the script is exited. @@ -94,6 +110,8 @@ class EmbeddedPostgres { private process?: ChildProcess; + private detachedPostmasterPid?: number; + private isRootUser: boolean; constructor(options: Partial = {}) { @@ -110,7 +128,9 @@ class EmbeddedPostgres { // Assign default options to options object this.options = Object.assign({}, defaults, legacyOptions, options); - instances.add(this); + if (this.options.lifecycleMode === 'managed') { + instances.add(this); + } this.isRootUser = userInfo().uid === 0; } @@ -217,9 +237,10 @@ class EmbeddedPostgres { } /** - * Start the Postgres cluster with the given configuration. The cluster is - * started as a seperate process, unmanaged by NodeJS. It is automatically - * shut down when the script exits. + * Start the Postgres cluster with the given configuration. Managed clusters + * keep the historical parent-process lifecycle and are automatically shut + * down when the script exits. Detached clusters are isolated from the parent + * lifecycle and must be stopped explicitly. */ async start() { const { postgres } = await bin; @@ -234,6 +255,16 @@ class EmbeddedPostgres { // Make the file executable, in case it is not ensureBinIsExecutable(postgres); + if (this.options.lifecycleMode === 'detached') { + if (platform() === 'win32') { + await this.startDetachedWindows(postgres, locale); + } else { + await this.startDetachedPosix(postgres, locale, permissionIds); + } + await this.waitUntilReady(); + return; + } + await new Promise((resolve, reject) => { // Spawn a postgres server this.process = spawn(postgres, [ @@ -269,6 +300,165 @@ class EmbeddedPostgres { }); } + private async startDetachedPosix( + postgres: string, + locale: string, + permissionIds: Record | { uid: number; gid: number } + ) { + await new Promise((resolve, reject) => { + this.process = spawn(postgres, [ + '-D', + this.options.databaseDir, + '-p', + this.options.port.toString(), + ...this.options.postgresFlags, + ], { + ...permissionIds, + detached: true, + env: { + ...process.env, + LC_MESSAGES: locale, + }, + }); + + this.process.stderr?.on('data', (chunk: Buffer) => { + this.options.onLog(chunk.toString('utf-8')); + }); + + this.process.once('error', reject); + this.process.once('spawn', resolve); + + this.process.unref(); + (this.process.stdin as { unref?: () => void } | null)?.unref?.(); + (this.process.stdout as { unref?: () => void } | null)?.unref?.(); + (this.process.stderr as { unref?: () => void } | null)?.unref?.(); + }); + } + + private async startDetachedWindows(postgres: string, locale: string) { + const commandLine = [ + postgres, + '-D', + this.options.databaseDir, + '-p', + this.options.port.toString(), + ...this.options.postgresFlags, + ].map(quoteWindowsCommandLineArgument).join(' '); + + const launcher = [ + '$source = @\'', + 'using System;', + 'using System.Text;', + 'using System.Runtime.InteropServices;', + 'public static class EmbeddedPostgresLauncher {', + ' [StructLayout(LayoutKind.Sequential)] public struct StartupInfo { public int cb; public IntPtr reserved; public IntPtr desktop; public IntPtr title; public int x; public int y; public int xSize; public int ySize; public int xCountChars; public int yCountChars; public int fillAttribute; public int flags; public short showWindow; public short reserved2; public IntPtr reserved3; public IntPtr stdInput; public IntPtr stdOutput; public IntPtr stdError; }', + ' [StructLayout(LayoutKind.Sequential)] public struct ProcessInformation { public IntPtr process; public IntPtr thread; public int processId; public int threadId; }', + ' [DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)] public static extern bool CreateProcessW(string applicationName, StringBuilder commandLine, IntPtr processAttributes, IntPtr threadAttributes, bool inheritHandles, uint creationFlags, IntPtr environment, string currentDirectory, ref StartupInfo startupInfo, out ProcessInformation processInformation);', + ' [DllImport("kernel32.dll", SetLastError=true)] public static extern bool CloseHandle(IntPtr handle);', + '}', + '\'@', + 'Add-Type -TypeDefinition $source', + '$startupInfo = New-Object EmbeddedPostgresLauncher+StartupInfo', + '$startupInfo.cb = [Runtime.InteropServices.Marshal]::SizeOf([type][EmbeddedPostgresLauncher+StartupInfo])', + '$processInformation = New-Object EmbeddedPostgresLauncher+ProcessInformation', + '$commandLine = New-Object System.Text.StringBuilder', + '[void]$commandLine.Append($env:EMBEDDED_POSTGRES_COMMAND_LINE)', + '# CREATE_NO_WINDOW | CREATE_UNICODE_ENVIRONMENT', + '$started = [EmbeddedPostgresLauncher]::CreateProcessW([NullString]::Value, $commandLine, [IntPtr]::Zero, [IntPtr]::Zero, $false, 0x08000400, [IntPtr]::Zero, [NullString]::Value, [ref]$startupInfo, [ref]$processInformation)', + 'if (-not $started) { Write-Output ("LAUNCH_ERR " + [Runtime.InteropServices.Marshal]::GetLastWin32Error()); exit 1 }', + '[void][EmbeddedPostgresLauncher]::CloseHandle($processInformation.process)', + '[void][EmbeddedPostgresLauncher]::CloseHandle($processInformation.thread)', + 'Write-Output ("LAUNCH_PID " + $processInformation.processId)', + ].join('\n'); + + await new Promise((resolve, reject) => { + const launcherProcess = spawn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', launcher], { + windowsHide: true, + env: { + ...process.env, + LC_MESSAGES: locale, + EMBEDDED_POSTGRES_COMMAND_LINE: commandLine, + }, + }); + let stdout = ''; + + launcherProcess.stdout?.on('data', (chunk: Buffer) => { + stdout += chunk.toString('utf-8'); + }); + launcherProcess.stderr?.on('data', (chunk: Buffer) => { + this.options.onError(chunk.toString('utf-8')); + }); + launcherProcess.once('error', reject); + launcherProcess.once('close', (code) => { + const pidMatch = stdout.match(/LAUNCH_PID (\d+)/); + if (code === 0 && pidMatch) { + this.detachedPostmasterPid = Number(pidMatch[1]); + this.process = undefined; + resolve(); + return; + } + + reject(new Error(`Postgres detached launch failed (${stdout.trim() || `powershell exit ${code}`})`)); + }); + }); + } + + private async waitUntilReady() { + const startedAt = Date.now(); + + while (Date.now() - startedAt < POSTGRES_READY_TIMEOUT_MS) { + if (this.process !== undefined && (this.process.exitCode !== null || this.process.signalCode !== null)) { + throw new Error(`Postgres process exited before becoming ready on port ${this.options.port}`); + } + if (platform() === 'win32' && this.detachedPostmasterPid !== undefined) { + try { + process.kill(this.detachedPostmasterPid, 0); + } catch { + throw new Error(`Postgres process exited before becoming ready on port ${this.options.port}`); + } + } + + const client = new Client({ + user: this.options.user, + password: this.options.password, + port: this.options.port, + host: 'localhost', + database: 'postgres', + connectionTimeoutMillis: 2000, + }); + client.on('error', () => undefined); + + try { + await client.connect(); + await client.query('SELECT 1'); + await client.end().catch(() => undefined); + return; + } catch { + await client.end().catch(() => undefined); + await new Promise((resolve) => setTimeout(resolve, POSTGRES_READY_POLL_INTERVAL_MS)); + } + } + + throw new Error(`Postgres cluster on port ${this.options.port} did not become ready within ${POSTGRES_READY_TIMEOUT_MS}ms`); + } + + private isStarted() { + return this.process !== undefined || this.detachedPostmasterPid !== undefined; + } + + private async waitUntilWindowsProcessStops(pid: number) { + const startedAt = Date.now(); + + while (Date.now() - startedAt < 15_000) { + const taskListOutput = await execAsync(`tasklist /FI "PID eq ${pid}" /NH`).catch(() => ''); + if (!new RegExp(`\\s${pid}\\s`).test(taskListOutput)) { + return; + } + + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + /** * Stop an already started cluster with the given configuration. * NOTE: If you have `persisent` set to false, this method WILL DELETE your @@ -276,38 +466,45 @@ class EmbeddedPostgres { * this method. */ async stop() { + const pid = this.detachedPostmasterPid ?? this.process?.pid; + // GUARD: If no database is running, immdiately return the function. - if (!this.process) { + if (!this.isStarted()) { return; } // Kill the existing postgres process - await new Promise((resolve) => { - // Register a handler for when the process finally exists - this.process?.on('exit', resolve); + if (platform() === 'win32') { + if (!pid) { + throw new Error('Could not find process PID'); + } - // GUARD: Check if we're on Windows, since Windows doesn't support SIGINT - if (platform() === 'win32') { - // GUARD: Double check the pid is there to keep TypeScript happy - if (!this.process?.pid) { - throw new Error('Could not find process PID'); - } + await new Promise((resolve) => { + const killer = spawn('taskkill', ['/pid', pid.toString(), '/f', '/t'], { + windowsHide: true, + }); + killer.on('error', () => resolve()); + killer.on('close', () => resolve()); + }); + await this.waitUntilWindowsProcessStops(pid); + } else { + await new Promise((resolve) => { + // Register a handler for when the process finally exists + this.process?.on('exit', resolve); - // Actually kill the process using the Windows taskkill command - spawn('taskkill', ['/pid', this.process.pid.toString(), '/f', '/t']); - } else { // If on a sane OS, simply kill using SIGINT this.process?.kill('SIGINT'); - } - }); + }); + } // Clean up process this.process = undefined; + this.detachedPostmasterPid = undefined; // GUARD: Additional work if database is not persistent if (this.options.persistent === false) { // Delete the data directory - await fs.rm(this.options.databaseDir, { recursive: true, force: true }); + await fs.rm(this.options.databaseDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } } @@ -340,7 +537,7 @@ class EmbeddedPostgres { */ async createDatabase(name: string) { // GUARD: Cluster must be running for performing database operations - if (!this.process) { + if (!this.isStarted()) { throw new Error('Your cluster must be running before you can create a database'); } @@ -358,7 +555,7 @@ class EmbeddedPostgres { */ async dropDatabase(name: string) { // GUARD: Cluster must be running for performing database operations - if (!this.process) { + if (!this.isStarted()) { throw new Error('Your cluster must be running before you can create a database'); } diff --git a/packages/embedded-postgres/src/types.ts b/packages/embedded-postgres/src/types.ts index 58e1802..d6d11c8 100644 --- a/packages/embedded-postgres/src/types.ts +++ b/packages/embedded-postgres/src/types.ts @@ -1,3 +1,5 @@ +export type PostgresLifecycleMode = 'managed' | 'detached'; + /** * The options that are optionally specified for launching the Postgres database. */ @@ -17,6 +19,12 @@ export interface PostgresOptions { /** Whether all data should be left in place when the database is shut down. * Defaults to `true`. */ persistent: boolean; + /** Controls how the Postgres process is tied to the parent Node process. + * `managed` preserves the historical behavior: the child stays attached + * and is stopped automatically when the parent exits. `detached` isolates + * the postmaster from parent terminal signals and requires explicit + * `stop()` calls. Defaults to `managed`. */ + lifecycleMode: PostgresLifecycleMode; /** Pass any additional flags to the initdb process. You can find all * available flags here: * https://www.postgresql.org/docs/current/app-initdb.html. Flags should be diff --git a/packages/embedded-postgres/tests/index.test.ts b/packages/embedded-postgres/tests/index.test.ts index 8acbb1a..069ca78 100644 --- a/packages/embedded-postgres/tests/index.test.ts +++ b/packages/embedded-postgres/tests/index.test.ts @@ -7,12 +7,19 @@ import { beforeEach } from 'node:test'; const DB_NAME = 'embedded-pg-test-db'; const DB_PATH = path.join(__dirname, 'data', 'db'); +const DETACHED_DB_PATH = path.join(__dirname, 'data', 'detached-db'); const DEFAULT_SETTINGS: Partial = { port: 5433, databaseDir: DB_PATH, }; +const DETACHED_SETTINGS: Partial = { + port: 5434, + databaseDir: DETACHED_DB_PATH, + lifecycleMode: 'detached', +}; + let pg: EmbeddedPostgres | undefined; beforeEach(async () => { @@ -27,7 +34,8 @@ afterEach(async () => { await pg?.stop(); // Remove all cluster files - await fs.rm(path.join(DB_PATH), { recursive: true, force: true }); + await fs.rm(path.join(DB_PATH), { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + await fs.rm(path.join(DETACHED_DB_PATH), { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); }); it('should be able to initialise a cluster', async () => { @@ -49,6 +57,23 @@ it('should be able to start and stop a cluster', async () => { await pg.start(); }); +it('should start and stop a detached cluster explicitly', async () => { + pg = new EmbeddedPostgres(DETACHED_SETTINGS); + await pg.initialise(); + await pg.start(); + + const client = pg.getPgClient(); + await client.connect(); + try { + const result = await client.query('SELECT 1 AS value;'); + expect(result.rows).toEqual([{ value: 1 }]); + } finally { + await client.end(); + } + + await expect(pg.stop()).resolves.toBeUndefined(); +}); + // it('should throw an error if the cluster is attempted to be started without initialising', async () => { // pg = new EmbeddedPostgres(DEFAULT_SETTINGS); // try {