import { Pool, type PoolClient, type PoolConfig, type QueryResultRow, } from 'pg'; import { isIP } from 'node:net'; import type { PostgresClient, PostgresDatabaseResource, PostgresPool, PostgresQueryResult, } from '@qinglong/runtime-core'; const DEFAULT_RUNTIME_APPLICATION_NAME = 'qinglong-cluster-runtime'; const DEFAULT_ADMIN_APPLICATION_NAME = 'qinglong-cluster-admin'; const DEFAULT_AI_CREDENTIAL_TESTER_APPLICATION_NAME = 'qinglong-ai-credential-tester'; const DEFAULT_AUTOMATION_MANAGER_APPLICATION_NAME = 'qinglong-automation-manager'; const DEFAULT_APPROVAL_MANAGER_APPLICATION_NAME = 'qinglong-approval-manager'; const DEFAULT_RUN_MANAGER_APPLICATION_NAME = 'qinglong-run-manager'; const DEFAULT_PACKAGE_MANAGER_APPLICATION_NAME = 'qinglong-package-manager'; const DEFAULT_PACKAGE_EXECUTOR_APPLICATION_NAME = 'qinglong-package-executor'; const DEFAULT_WORKER_CREDENTIAL_MANAGER_APPLICATION_NAME = 'qinglong-worker-credential-manager'; const DEFAULT_WORKER_CREDENTIAL_EXECUTOR_APPLICATION_NAME = 'qinglong-worker-credential-executor'; const DEFAULT_WORKER_INGRESS_APPLICATION_NAME = 'qinglong-worker-ingress'; const DEFAULT_MIGRATION_APPLICATION_NAME = 'qinglong-cluster-migration'; const DEFAULT_RUNTIME_POOL_SIZE = 8; const DEFAULT_ADMIN_POOL_SIZE = 2; const DEFAULT_WORKER_INGRESS_POOL_SIZE = 4; const DEFAULT_MIGRATION_POOL_SIZE = 1; const MAX_RUNTIME_POOL_SIZE = 64; const MAX_ADMIN_POOL_SIZE = 4; const MAX_WORKER_INGRESS_POOL_SIZE = 16; const MAX_MIGRATION_POOL_SIZE = 4; const DEFAULT_CONNECTION_TIMEOUT_MS = 5_000; const DEFAULT_IDLE_TIMEOUT_MS = 30_000; const DEFAULT_MAX_LIFETIME_SECONDS = 300; const DEFAULT_RUNTIME_STATEMENT_TIMEOUT_MS = 5_000; const DEFAULT_MIGRATION_STATEMENT_TIMEOUT_MS = 15_000; const DEFAULT_RUNTIME_LOCK_TIMEOUT_MS = 1_000; const DEFAULT_MIGRATION_LOCK_TIMEOUT_MS = 5_000; const DEFAULT_RUNTIME_IDLE_TRANSACTION_TIMEOUT_MS = 10_000; const DEFAULT_MIGRATION_IDLE_TRANSACTION_TIMEOUT_MS = 15_000; const FIXED_SEARCH_PATH = '-c search_path=ql3,pg_catalog'; export const POSTGRES_AVAILABILITY_SQLSTATE_CLASSES = Object.freeze([ '08', ] as const); export const POSTGRES_AVAILABILITY_SQLSTATES = Object.freeze([ '08000', '08001', '08003', '08004', '08006', '08007', '08P01', '25006', '57P01', '57P02', '57P03', '57P04', ] as const); export const POSTGRES_AVAILABILITY_SYSTEM_ERROR_CODES = Object.freeze([ 'EAI_AGAIN', 'ECONNREFUSED', 'ECONNRESET', 'EHOSTUNREACH', 'ENETUNREACH', 'ENOTFOUND', 'EPIPE', 'ETIMEDOUT', ] as const); const POSTGRES_AVAILABILITY_CODES = new Set([ ...POSTGRES_AVAILABILITY_SQLSTATES, ...POSTGRES_AVAILABILITY_SYSTEM_ERROR_CODES, ]); export type PostgresDatabaseRole = | 'runtime' | 'ai-maintenance' | 'ai-credential-manager' | 'ai-credential-tester' | 'admin' | 'automation-manager' | 'approval-manager' | 'run-manager' | 'package-manager' | 'package-executor' | 'worker-credential-manager' | 'worker-credential-executor' | 'worker-ingress' | 'migration'; export type PostgresTlsOptions = | Readonly<{ mode: 'verify-full'; ca?: string; servername: string; }> | Readonly<{ mode: 'disable'; }>; export interface PostgresConnectionOptions { readonly connectionString?: string; readonly host?: string; readonly port?: number; readonly database?: string; readonly user?: string; readonly password?: string | (() => string | Promise); readonly tls?: PostgresTlsOptions; } export interface PostgresPoolOptions { readonly applicationName?: string; readonly maxConnections?: number; readonly connectionTimeoutMs?: number; readonly idleTimeoutMs?: number; readonly maxLifetimeSeconds?: number; readonly keepAliveInitialDelayMs?: number; } export interface OpenPostgresDatabaseOptions { readonly role: PostgresDatabaseRole; readonly connection: PostgresConnectionOptions; readonly pool?: PostgresPoolOptions; readonly onPoolError: (error: Error) => void; } export function isPostgresTlsDnsServername(value: unknown): value is string { if ( typeof value !== 'string' || value.length < 3 || value.length > 253 || isIP(value) !== 0 ) { return false; } const labels = value.split('.'); return ( labels.length >= 2 && labels.every((label) => /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/.test(label), ) ); } export type QingLongPostgresQueryResult< TRow extends Record = Record, > = PostgresQueryResult; export type QingLongPostgresClient = PostgresClient; export type QingLongPostgresPool = PostgresPool; export type QingLongPostgresDatabaseResource = PostgresDatabaseResource; export function isPostgresAvailabilityError(error: unknown): error is Error { if (!(error instanceof Error)) return false; const code = (error as Error & { readonly code?: unknown }).code; if (typeof code !== 'string') return false; return ( POSTGRES_AVAILABILITY_CODES.has(code) || (code.length === 5 && POSTGRES_AVAILABILITY_SQLSTATE_CLASSES.some((prefix) => code.startsWith(prefix), )) ); } function reportPostgresAvailabilityError( error: unknown, listener: (error: Error) => void, force = false, ): void { if (!(error instanceof Error)) return; if (!force && !isPostgresAvailabilityError(error)) return; try { listener(error); } catch { // Availability reporting must never replace the original query/Pool error. } } function requireIntegerInRange( name: string, value: number, minimum: number, maximum: number, ): number { if (!Number.isInteger(value) || value < minimum || value > maximum) { throw new RangeError( `${name} must be an integer between ${minimum} and ${maximum}`, ); } return value; } function validateApplicationName(value: string): string { if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,62}$/.test(value)) { throw new TypeError( 'PostgreSQL applicationName must contain 1-63 safe identifier characters', ); } return value; } function assertNoConnectionStringTlsOverrides(connectionString: string): void { let parsed: URL; try { parsed = new URL(connectionString); } catch { throw new TypeError('PostgreSQL connectionString must be a valid URL'); } if (parsed.protocol !== 'postgres:' && parsed.protocol !== 'postgresql:') { throw new TypeError( 'PostgreSQL connectionString must use postgres or postgresql', ); } for (const key of parsed.searchParams.keys()) { if (key.toLowerCase().startsWith('ssl')) { throw new TypeError( 'PostgreSQL TLS settings must use the explicit tls option', ); } } } function tlsConfig(tls: PostgresTlsOptions | undefined): PoolConfig['ssl'] { if (tls?.mode === 'disable') return false; if (tls !== undefined && !isPostgresTlsDnsServername(tls.servername)) { throw new TypeError( 'PostgreSQL verify-full TLS requires an explicit DNS servername', ); } return { rejectUnauthorized: true, ...(tls?.ca === undefined ? {} : { ca: tls.ca }), ...(tls === undefined ? {} : { servername: tls.servername }), }; } function buildPoolConfig(options: OpenPostgresDatabaseOptions): PoolConfig { if ( ![ 'runtime', 'ai-maintenance', 'ai-credential-manager', 'ai-credential-tester', 'admin', 'automation-manager', 'approval-manager', 'run-manager', 'package-manager', 'package-executor', 'worker-credential-manager', 'worker-credential-executor', 'worker-ingress', 'migration', ].includes(options.role) ) { throw new TypeError('PostgreSQL database role is invalid'); } const connection = options.connection; const hasConnectionString = connection.connectionString !== undefined; const hasDiscreteAddress = connection.host !== undefined || connection.port !== undefined || connection.database !== undefined || connection.user !== undefined; if (hasConnectionString && hasDiscreteAddress) { throw new TypeError( 'PostgreSQL connectionString cannot be combined with host, port, database, or user', ); } if (hasConnectionString) { assertNoConnectionStringTlsOverrides(connection.connectionString!); } else if ( connection.host === undefined || connection.database === undefined || connection.user === undefined ) { throw new TypeError( 'PostgreSQL connection requires connectionString or host, database, and user', ); } const isRuntime = options.role === 'runtime'; const isAiMaintenance = options.role === 'ai-maintenance'; const isAiCredentialManager = options.role === 'ai-credential-manager'; const isAiCredentialTester = options.role === 'ai-credential-tester'; const isAdmin = options.role === 'admin'; const isAutomationManager = options.role === 'automation-manager'; const isApprovalManager = options.role === 'approval-manager'; const isRunManager = options.role === 'run-manager'; const isPackageManager = options.role === 'package-manager'; const isPackageExecutor = options.role === 'package-executor'; const isWorkerCredentialManager = options.role === 'worker-credential-manager'; const isWorkerCredentialExecutor = options.role === 'worker-credential-executor'; const isShortLivedAuthority = isAiMaintenance || isAiCredentialManager || isAiCredentialTester || isAdmin || isAutomationManager || isApprovalManager || isRunManager || isPackageManager || isPackageExecutor || isWorkerCredentialManager || isWorkerCredentialExecutor; const isWorkerIngress = options.role === 'worker-ingress'; const maximumPoolSize = isRuntime ? MAX_RUNTIME_POOL_SIZE : isShortLivedAuthority ? MAX_ADMIN_POOL_SIZE : isWorkerIngress ? MAX_WORKER_INGRESS_POOL_SIZE : MAX_MIGRATION_POOL_SIZE; const maxConnections = requireIntegerInRange( 'PostgreSQL maxConnections', options.pool?.maxConnections ?? (isRuntime ? DEFAULT_RUNTIME_POOL_SIZE : isShortLivedAuthority ? DEFAULT_ADMIN_POOL_SIZE : isWorkerIngress ? DEFAULT_WORKER_INGRESS_POOL_SIZE : DEFAULT_MIGRATION_POOL_SIZE), 1, maximumPoolSize, ); const connectionTimeoutMs = requireIntegerInRange( 'PostgreSQL connectionTimeoutMs', options.pool?.connectionTimeoutMs ?? DEFAULT_CONNECTION_TIMEOUT_MS, 100, 60_000, ); const idleTimeoutMs = requireIntegerInRange( 'PostgreSQL idleTimeoutMs', options.pool?.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS, 1_000, 300_000, ); const maxLifetimeSeconds = requireIntegerInRange( 'PostgreSQL maxLifetimeSeconds', options.pool?.maxLifetimeSeconds ?? DEFAULT_MAX_LIFETIME_SECONDS, 30, 3_600, ); const keepAliveInitialDelayMs = requireIntegerInRange( 'PostgreSQL keepAliveInitialDelayMs', options.pool?.keepAliveInitialDelayMs ?? 1_000, 0, 60_000, ); const applicationName = validateApplicationName( options.pool?.applicationName ?? (isRuntime ? DEFAULT_RUNTIME_APPLICATION_NAME : isAdmin ? DEFAULT_ADMIN_APPLICATION_NAME : isAiCredentialTester ? DEFAULT_AI_CREDENTIAL_TESTER_APPLICATION_NAME : isAutomationManager ? DEFAULT_AUTOMATION_MANAGER_APPLICATION_NAME : isApprovalManager ? DEFAULT_APPROVAL_MANAGER_APPLICATION_NAME : isRunManager ? DEFAULT_RUN_MANAGER_APPLICATION_NAME : isPackageManager ? DEFAULT_PACKAGE_MANAGER_APPLICATION_NAME : isPackageExecutor ? DEFAULT_PACKAGE_EXECUTOR_APPLICATION_NAME : isWorkerCredentialManager ? DEFAULT_WORKER_CREDENTIAL_MANAGER_APPLICATION_NAME : isWorkerCredentialExecutor ? DEFAULT_WORKER_CREDENTIAL_EXECUTOR_APPLICATION_NAME : isWorkerIngress ? DEFAULT_WORKER_INGRESS_APPLICATION_NAME : DEFAULT_MIGRATION_APPLICATION_NAME), ); return { ...(connection.connectionString === undefined ? { host: connection.host, ...(connection.port === undefined ? {} : { port: requireIntegerInRange( 'PostgreSQL port', connection.port, 1, 65_535, ), }), database: connection.database, user: connection.user, } : { connectionString: connection.connectionString }), ...(connection.password === undefined ? {} : { password: connection.password }), ssl: tlsConfig(connection.tls), application_name: applicationName, options: FIXED_SEARCH_PATH, max: maxConnections, min: 0, connectionTimeoutMillis: connectionTimeoutMs, idleTimeoutMillis: idleTimeoutMs, maxLifetimeSeconds, keepAlive: true, keepAliveInitialDelayMillis: keepAliveInitialDelayMs, allowExitOnIdle: false, statement_timeout: isRuntime || isWorkerIngress ? DEFAULT_RUNTIME_STATEMENT_TIMEOUT_MS : DEFAULT_MIGRATION_STATEMENT_TIMEOUT_MS, query_timeout: isRuntime || isWorkerIngress ? DEFAULT_RUNTIME_STATEMENT_TIMEOUT_MS + 1_000 : DEFAULT_MIGRATION_STATEMENT_TIMEOUT_MS + 1_000, lock_timeout: isRuntime || isWorkerIngress ? DEFAULT_RUNTIME_LOCK_TIMEOUT_MS : DEFAULT_MIGRATION_LOCK_TIMEOUT_MS, idle_in_transaction_session_timeout: isRuntime || isWorkerIngress ? DEFAULT_RUNTIME_IDLE_TRANSACTION_TIMEOUT_MS : DEFAULT_MIGRATION_IDLE_TRANSACTION_TIMEOUT_MS, }; } async function query>( queryable: Pick, text: string, values?: readonly unknown[], onAvailabilityError: (error: Error) => void = () => {}, ): Promise> { try { const result = await queryable.query( text, values === undefined ? undefined : [...values], ); return { rows: result.rows, rowCount: result.rowCount }; } catch (error) { reportPostgresAvailabilityError(error, onAvailabilityError); throw error; } } class PgClientBinding implements PostgresClient { private readonly onClientError: (error: Error) => void; constructor( private readonly client: PoolClient, private readonly onAvailabilityError: (error: Error) => void, ) { this.onClientError = (error) => reportPostgresAvailabilityError(error, this.onAvailabilityError, true); this.client.on('error', this.onClientError); } query = Record>( text: string, values?: readonly unknown[], ): Promise> { return query(this.client, text, values, this.onAvailabilityError); } release(): void { this.client.removeListener('error', this.onClientError); this.client.release(); } } export class PgPoolBinding implements PostgresPool { constructor( private readonly driverPool: Pool, private readonly onAvailabilityError: (error: Error) => void = () => {}, ) {} query = Record>( text: string, values?: readonly unknown[], ): Promise> { return query(this.driverPool, text, values, this.onAvailabilityError); } async connect(): Promise { try { return new PgClientBinding( await this.driverPool.connect(), this.onAvailabilityError, ); } catch (error) { reportPostgresAvailabilityError(error, this.onAvailabilityError); throw error; } } } class PgDatabaseResource implements PostgresDatabaseResource { readonly pool: PostgresPool; private closePromise: Promise | undefined; constructor( private readonly driverPool: Pool, onAvailabilityError: (error: Error) => void, ) { this.pool = new PgPoolBinding(driverPool, onAvailabilityError); } close(): Promise { this.closePromise ??= this.driverPool.end(); return this.closePromise; } } /** * Builds a lazy database opener for the core cluster bootstrap. Constructing * the opener, and invoking it, do not acquire a PostgreSQL connection; the * first readiness or repository query triggers pg.Pool connection creation. */ export function createPostgresDatabaseOpener( options: OpenPostgresDatabaseOptions, ): () => Promise { const config = buildPoolConfig(options); return async () => { const driverPool = new Pool(config); driverPool.on('connect', (client) => { // Keep one listener for the physical connection lifetime. pg swaps its // own idle listener while a client is checked out, and a failover can // otherwise emit a second socket error after a checkout binding has // released but before Pool teardown has completed. client.on('error', (error) => reportPostgresAvailabilityError(error, options.onPoolError, true), ); }); driverPool.on('error', (error) => reportPostgresAvailabilityError(error, options.onPoolError, true), ); return new PgDatabaseResource(driverPool, options.onPoolError); }; }