/** * PGLite Connection Pool * * A connection pool implementation for PGLite that efficiently manages * database connections, particularly optimized for Cloudflare Workers * where memory is constrained and each Durable Object is the sole client. * * ## Design Philosophy * * Unlike traditional connection pools that manage multiple physical database * connections, this pool manages "virtual" clients over a single PGLite instance. * Since PGLite runs PostgreSQL in-process (WASM), there's no network latency, * making client acquisition essentially free. The pool's value comes from: * * 1. **Controlled concurrency** - Prevents overwhelming the single-threaded WASM * instance with parallel operations that could cause memory pressure * 2. **Familiar API** - Provides the standard pool.query/transaction interface * that developers expect from postgres.js, pg-pool, etc. * 3. **Queue management** - FIFO request queuing with configurable timeouts * * ## Key Features * * - **Configurable pool size** - Defaults to 1 (optimal for Workers with max_connections=1) * - **FIFO queue** - Requests are processed in order with timeout support * - **Automatic cleanup** - Clients released after query/transaction completion * - **Transaction support** - Full isolation level and read-only transaction support * - **Statistics tracking** - Real-time pool metrics for monitoring * - **Idempotent operations** - Safe to call release() multiple times * * ## Memory Optimization * * For Cloudflare Workers (128MB limit), use maxSize=1 to serialize operations. * This prevents memory spikes from concurrent queries and aligns with PGLite's * max_connections=1 optimization in WASM builds. * * @example Basic usage * ```typescript * import { PGlitePool } from './pool' * * const pool = new PGlitePool({ * pglite: myPGLiteInstance, * maxSize: 1, // Optimal for Workers (max_connections=1) * acquireTimeout: 30000, * }) * * // Simple query - client automatically acquired and released * const result = await pool.query('SELECT * FROM users WHERE id = $1', [1]) * ``` * * @example Transactions * ```typescript * // Transaction with automatic commit/rollback * const user = await pool.transaction(async (client) => { * await client.query('INSERT INTO users (name) VALUES ($1)', ['Alice']) * return await client.query('SELECT * FROM users WHERE name = $1', ['Alice']) * }) * ``` * * @example Manual client management * ```typescript * // Manual acquire/release when you need more control * const client = await pool.acquire() * try { * await client.query('SELECT 1') * await client.query('SELECT 2') * } finally { * pool.release(client) * } * * await pool.end() * ``` * * @example Statistics monitoring * ```typescript * const stats = pool.getStats() * console.log(`Active: ${stats.activeCount}/${stats.maxSize}`) * console.log(`Waiting: ${stats.waitingCount}`) * ``` * * @module */ import { PoolError } from '@dotdo/postgres-shared' // Re-export PoolError for backwards compatibility export { PoolError } // ============================================================================= // Type Definitions // ============================================================================= /** * PGLite-like interface that the pool works with. * * This interface defines the minimal contract required from a PGLite instance. * It allows the pool to work with different PGLite variants (full, tiny) and * enables easy mocking in tests. * * @remarks * The pool does not own the PGLite instance lifecycle - call {@link PGlitePool.destroy} * if you want the pool to also close the underlying connection. */ export interface PGLiteLike { /** * Promise that resolves when PGLite is ready to accept queries. * The pool awaits this before creating clients. */ waitReady: Promise /** * Synchronous ready check. True after waitReady resolves. */ ready: boolean /** * Execute a SQL query with optional parameters. * * @typeParam T - The row type for the query result * @param sql - SQL query string with $1, $2, etc. placeholders * @param params - Parameter values to bind to placeholders * @returns Query result with rows, field metadata, and affected row count */ query(sql: string, params?: unknown[]): Promise<{ rows: T[] fields: Array<{ name: string; dataTypeID: number }> affectedRows?: number }> /** * Close the PGLite connection and release resources. */ close(): Promise } /** * Query result from a pool client operation. * * @typeParam T - The row type for typed queries */ export interface PoolQueryResult { /** Array of result rows, typed as T */ rows: T[] /** Field metadata including column names and PostgreSQL type OIDs */ fields: Array<{ name: string; dataTypeID: number }> /** Number of rows affected (for INSERT/UPDATE/DELETE) */ affectedRows?: number } /** * Pool client that wraps access to the underlying PGLite connection. * * Clients are acquired from the pool, used for queries, and then released back. * The pool tracks client state to prevent use-after-release bugs. * * @example * ```typescript * const client = await pool.acquire() * try { * const result = await client.query('SELECT * FROM users WHERE id = $1', [1]) * console.log(result.rows[0]) * } finally { * pool.release(client) * } * ``` */ export interface PoolClient { /** Unique client ID within the pool (monotonically increasing) */ readonly id: number /** Whether this client is currently in use (acquired but not released) */ inUse: boolean /** Reference to the underlying PGLite instance */ readonly pglite: PGLiteLike /** Timestamp when the client was acquired, undefined when idle */ acquiredAt?: Date | undefined /** * Execute a query using this client. * * @typeParam T - The expected row type for typed results * @param sql - SQL query string with $1, $2, etc. placeholders * @param params - Parameter values to bind to placeholders * @returns Query result with typed rows * @throws {PoolError} If the client has been released (use-after-release) * * @example * ```typescript * const result = await client.query( * 'SELECT * FROM users WHERE id = $1', * [userId] * ) * ``` */ query(sql: string, params?: unknown[]): Promise> } /** * PostgreSQL transaction isolation levels. * * @see {@link https://www.postgresql.org/docs/current/transaction-iso.html PostgreSQL Isolation Levels} * * - `'read uncommitted'` - Allows dirty reads (PostgreSQL treats as read committed) * - `'read committed'` - Default level, prevents dirty reads * - `'repeatable read'` - Snapshot isolation, prevents non-repeatable reads * - `'serializable'` - Strictest level, prevents phantom reads */ export type PoolIsolationLevel = | 'read uncommitted' | 'read committed' | 'repeatable read' | 'serializable' /** * Options for transaction execution. */ export interface PoolTransactionOptions { /** * Transaction isolation level. * Higher isolation levels provide stronger consistency guarantees * but may reduce concurrency. * * @default 'read committed' (PostgreSQL default) */ isolationLevel?: PoolIsolationLevel /** * Whether the transaction is read-only. * Read-only transactions can be optimized by the database engine * and will error if write operations are attempted. * * @default false */ readOnly?: boolean } /** * Configuration for creating a PGLite pool. */ export interface PGlitePoolConfig { /** * The PGLite instance to pool. * The pool does not own this instance - it will not be closed * when {@link PGlitePool.end} is called. Use {@link PGlitePool.destroy} * to close both the pool and the underlying PGLite instance. */ pglite: PGLiteLike /** * Maximum number of virtual clients in the pool. * * Since PGLite runs in-process (no network), this effectively controls * how many concurrent operations can execute before new requests queue. * * **Cloudflare Workers recommendation:** Use `maxSize: 1` to serialize * operations and prevent memory spikes from concurrent WASM execution. * This aligns with PGLite's `max_connections=1` optimization. * * @default 1 */ maxSize?: number /** * Timeout in milliseconds for acquiring a client from the pool. * * When the pool is at capacity, new acquire requests wait in a queue. * If a client cannot be acquired within this timeout, the request fails * with a {@link PoolError}. * * Set to `0` for no timeout (wait indefinitely - not recommended for production). * * @default 30000 (30 seconds) */ acquireTimeout?: number } /** * Real-time pool statistics for monitoring and debugging. * * Use {@link PGlitePool.getStats} to retrieve current statistics. * * @example * ```typescript * const stats = pool.getStats() * * // Check pool utilization * const utilization = stats.activeCount / stats.maxSize * * // Alert if requests are queuing * if (stats.waitingCount > 10) { * console.warn('High queue depth:', stats.waitingCount) * } * ``` */ export interface PoolStats { /** Total number of clients created (active + idle) */ totalCount: number /** Number of idle clients ready to be acquired */ idleCount: number /** Number of clients currently in use */ activeCount: number /** Number of requests waiting in queue for a client */ waitingCount: number /** Maximum pool size from configuration */ maxSize: number } // ============================================================================= // Internal Types and Implementation // ============================================================================= /** * Queued request waiting for a client. * @internal */ interface QueuedRequest { resolve: (client: PoolClient) => void reject: (error: Error) => void timeoutId: ReturnType } /** * Internal pool client implementation. * * This class tracks the client lifecycle (acquired/released) and prevents * use-after-release bugs by checking state before every query. * * @internal */ class PoolClientImpl implements PoolClient { readonly id: number inUse: boolean = false readonly pglite: PGLiteLike acquiredAt?: Date | undefined /** True after release, prevents use-after-release */ private released: boolean = false constructor(id: number, pglite: PGLiteLike) { this.id = id this.pglite = pglite } async query(sql: string, params?: unknown[]): Promise> { if (!this.inUse || this.released) { throw new PoolError('Cannot use a released client', { context: { clientId: this.id }, }) } return this.pglite.query(sql, params) } /** * Mark this client as released. * Called by the pool when releasing - resets state for reuse. * @internal */ _markReleased(): void { this.released = true this.inUse = false this.acquiredAt = undefined } /** * Mark this client as acquired. * Called by the pool when acquiring - prepares for use. * @internal */ _markAcquired(): void { this.released = false this.inUse = true this.acquiredAt = new Date() } } // ============================================================================= // PGlitePool Class // ============================================================================= /** * PGLite Connection Pool. * * Manages virtual connections to a PGLite instance, providing connection * pooling semantics even though PGLite is a single-connection in-process * database. * * @see {@link PGlitePoolConfig} for configuration options * @see {@link PoolStats} for available statistics */ export class PGlitePool { // --------------------------------------------------------------------------- // Configuration (immutable after construction) // --------------------------------------------------------------------------- private readonly pglite: PGLiteLike private readonly maxSize: number private readonly acquireTimeout: number // --------------------------------------------------------------------------- // Client tracking // --------------------------------------------------------------------------- /** All clients in the pool (by ID) */ private readonly clients: Map = new Map() /** Stack of idle clients available for acquisition (LIFO for cache locality) */ private readonly idleClients: PoolClientImpl[] = [] /** Queue of requests waiting for a client (FIFO for fairness) */ private readonly waitingRequests: QueuedRequest[] = [] // --------------------------------------------------------------------------- // Pool state // --------------------------------------------------------------------------- /** Auto-incrementing client ID counter */ private nextClientId: number = 0 /** Whether the pool has been closed via end() or destroy() */ private closed: boolean = false /** Cached ready promise to avoid re-awaiting */ private readyPromise: Promise | null = null /** * Create a new PGLite connection pool. * * @param config - Pool configuration options * * @example * ```typescript * const pool = new PGlitePool({ * pglite: myPGLiteInstance, * maxSize: 1, * acquireTimeout: 30000, * }) * ``` */ constructor(config: PGlitePoolConfig) { this.pglite = config.pglite this.maxSize = config.maxSize ?? 1 this.acquireTimeout = config.acquireTimeout ?? 30000 } // --------------------------------------------------------------------------- // Statistics Getters // --------------------------------------------------------------------------- /** * Total number of clients created in the pool. * Clients are created lazily as needed, up to maxSize. */ get totalCount(): number { return this.clients.size } /** * Number of idle clients available for immediate acquisition. */ get idleCount(): number { return this.idleClients.length } /** * Number of clients currently in use (acquired but not released). */ get activeCount(): number { let count = 0 for (const client of this.clients.values()) { if (client.inUse) { count++ } } return count } /** * Number of requests waiting in queue for a client. * Non-zero indicates pool is at capacity. */ get waitingCount(): number { return this.waitingRequests.length } /** * Whether the pool has been closed via {@link end} or {@link destroy}. * Closed pools reject all new acquire requests. */ get isClosed(): boolean { return this.closed } /** * Get a snapshot of current pool statistics. * * @returns Current pool statistics for monitoring * * @example * ```typescript * const stats = pool.getStats() * console.log(`Pool: ${stats.activeCount}/${stats.maxSize} active, ${stats.waitingCount} waiting`) * ``` */ getStats(): PoolStats { return { totalCount: this.totalCount, idleCount: this.idleCount, activeCount: this.activeCount, waitingCount: this.waitingCount, maxSize: this.maxSize, } } // --------------------------------------------------------------------------- // Private Helper Methods // --------------------------------------------------------------------------- /** * Check if PGLite is ready (synchronous check). * Used to determine if we can skip the async ready wait. */ private isReady(): boolean { return this.readyPromise !== null || this.pglite.ready } /** * Ensure PGLite is ready before operations. * Caches the ready promise to avoid re-awaiting. */ private async ensureReady(): Promise { if (!this.readyPromise) { this.readyPromise = this.pglite.waitReady } await this.readyPromise } /** * Create a new pool client. * Called when pool capacity allows expansion. */ private createClient(): PoolClientImpl { const id = ++this.nextClientId const client = new PoolClientImpl(id, this.pglite) this.clients.set(id, client) return client } /** * Create a queued request that waits for a client with timeout. * This encapsulates the common pattern of timeout-bounded waiting. * * @returns Promise that resolves with a client or rejects on timeout */ private createQueuedRequest(): Promise { return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { // Remove from waiting queue on timeout const index = this.waitingRequests.findIndex( (r) => r.resolve === resolve ) if (index !== -1) { this.waitingRequests.splice(index, 1) } reject(new PoolError('Timeout waiting for available client', { context: { timeoutMs: this.acquireTimeout, waitingCount: this.waitingRequests.length, activeCount: this.activeCount, }, })) }, this.acquireTimeout) this.waitingRequests.push({ resolve, reject, timeoutId }) }) } // --------------------------------------------------------------------------- // Client Acquisition and Release // --------------------------------------------------------------------------- /** * Acquire a client from the pool. * * If a client is immediately available (idle client exists or pool can grow), * the acquisition is nearly instantaneous. Otherwise, the request is queued * and waits until a client becomes available or the timeout is reached. * * **Important:** Always release the client when done, preferably using * try/finally to ensure release even on errors. * * @returns A pool client that must be released after use * @throws {PoolError} If the pool is closed * @throws {PoolError} If timeout is reached while waiting for a client * * @example * ```typescript * const client = await pool.acquire() * try { * await client.query('SELECT * FROM users') * } finally { * pool.release(client) * } * ``` */ acquire(): Promise { if (this.closed) { return Promise.reject(new PoolError('Pool has been closed')) } // Fast path: If PGLite is ready and pool is at capacity with no idle clients, // queue immediately. This makes queuing synchronous which is important for // correct waitingCount tracking in tests and monitoring. if (this.isReady() && this.clients.size >= this.maxSize && this.idleClients.length === 0) { return this.createQueuedRequest() } // Otherwise, do the full async flow return this.acquireAsync() } /** * Internal async acquisition logic. * Handles the full flow: wait for ready, check idle, create new, or queue. */ private async acquireAsync(): Promise { // Ensure PGLite is ready before proceeding await this.ensureReady() // Check if closed after awaiting (pool may have closed while waiting) if (this.closed) { throw new PoolError('Pool has been closed') } // Try to reuse an idle client (LIFO for better cache locality) const idleClient = this.idleClients.pop() if (idleClient) { idleClient._markAcquired() return idleClient } // If we can create more clients, expand the pool if (this.clients.size < this.maxSize) { const client = this.createClient() client._markAcquired() return client } // Pool at capacity - queue and wait for a client to be released return this.createQueuedRequest() } /** * Release a client back to the pool. * * This operation is idempotent - calling release() multiple times on the * same client is safe and will only have effect the first time. * * When released, if there are queued requests waiting for a client, * the first waiting request receives this client immediately (FIFO). * Otherwise, the client is added to the idle pool for reuse. * * @param client - The client to release (must have been acquired from this pool) * @throws {PoolError} If the client is not from this pool (wrong pool or invalid) * * @example * ```typescript * const client = await pool.acquire() * try { * await client.query('SELECT 1') * } finally { * pool.release(client) // Always release, even on error * } * ``` */ release(client: PoolClient): void { const poolClient = this.clients.get(client.id) if (!poolClient) { throw new PoolError('Invalid client', { context: { clientId: client.id }, }) } // Idempotent: already released, no-op if (!poolClient.inUse) { return } // Mark as released poolClient._markReleased() // If there are waiting requests and pool isn't closed, fulfill the first one if (this.waitingRequests.length > 0 && !this.closed) { const request = this.waitingRequests.shift()! clearTimeout(request.timeoutId) poolClient._markAcquired() request.resolve(poolClient) return } // Otherwise, add to idle pool for reuse this.idleClients.push(poolClient) } // --------------------------------------------------------------------------- // Query Execution // --------------------------------------------------------------------------- /** * Execute a query using a pooled client. * * This is the simplest way to execute queries - the client is automatically * acquired before the query and released after, even if the query fails. * * For multiple queries in sequence, consider using {@link acquire} manually * or {@link transaction} to avoid acquire/release overhead per query. * * @typeParam T - The expected row type for typed results * @param sql - SQL query string with $1, $2, etc. placeholders * @param params - Parameter values to bind to placeholders * @returns Query result with typed rows * @throws {PoolError} If client acquisition fails * @throws {Error} If the query execution fails * * @example * ```typescript * // Simple query * const result = await pool.query('SELECT * FROM users WHERE id = $1', [userId]) * * // Typed query * interface User { id: number; name: string } * const { rows } = await pool.query('SELECT * FROM users') * const user = rows[0] // Type: User * ``` */ async query( sql: string, params?: unknown[] ): Promise> { const client = await this.acquire() try { return await client.query(sql, params) } finally { this.release(client) } } // --------------------------------------------------------------------------- // Transaction Support // --------------------------------------------------------------------------- /** Valid PostgreSQL isolation levels (lowercase for comparison) */ private static readonly VALID_ISOLATION_LEVELS = [ 'read uncommitted', 'read committed', 'repeatable read', 'serializable', ] as const /** * Build the BEGIN statement for a transaction with options. * * @param options - Transaction options * @returns SQL string for BEGIN with isolation level and access mode * @throws {Error} If isolation level is invalid */ private buildBeginStatement(options?: PoolTransactionOptions): string { let beginSql = 'BEGIN' if (options?.isolationLevel) { const normalized = options.isolationLevel.toLowerCase() if (!PGlitePool.VALID_ISOLATION_LEVELS.includes(normalized as PoolIsolationLevel)) { throw new Error(`Invalid isolation level: "${options.isolationLevel}"`) } beginSql += ` ISOLATION LEVEL ${normalized.toUpperCase()}` } if (options?.readOnly) { beginSql += ' READ ONLY' } return beginSql } /** * Execute a transaction using a pooled client. * * The client is held for the duration of the transaction and automatically * released when complete. The transaction is committed on success or rolled * back if the callback throws an error. * * @typeParam T - The return type of the transaction callback * @param fn - Transaction callback function that receives the client * @param options - Transaction options (isolation level, read-only) * @returns Result of the transaction callback * @throws {PoolError} If client acquisition fails * @throws {Error} If isolation level validation fails * @throws {Error} Any error thrown by the callback (transaction is rolled back) * * @example * ```typescript * // Basic transaction * const result = await pool.transaction(async (client) => { * await client.query('INSERT INTO users (name) VALUES ($1)', ['Alice']) * const { rows } = await client.query('SELECT * FROM users WHERE name = $1', ['Alice']) * return rows[0] * }) * * // Transaction with options * await pool.transaction(async (client) => { * const { rows } = await client.query('SELECT * FROM accounts') * return rows * }, { isolationLevel: 'serializable', readOnly: true }) * ``` */ async transaction( fn: (client: PoolClient) => Promise, options?: PoolTransactionOptions ): Promise { const client = await this.acquire() try { const beginSql = this.buildBeginStatement(options) await client.query(beginSql) try { const result = await fn(client) await client.query('COMMIT') return result } catch (error) { // Always attempt rollback on error try { await client.query('ROLLBACK') } catch { // Ignore rollback errors - original error is more important } throw error } } finally { this.release(client) } } // --------------------------------------------------------------------------- // Pool Lifecycle // --------------------------------------------------------------------------- /** * Close the pool and release all clients. * * This operation: * 1. Marks the pool as closed (rejects new acquire requests) * 2. Rejects all pending queued requests with PoolError * 3. Clears all client references * * The underlying PGLite instance is NOT closed - use {@link destroy} * if you want to close both the pool and the PGLite connection. * * This operation is idempotent - calling end() multiple times is safe. * * @example * ```typescript * // Clean shutdown * await pool.end() * // Pool is now closed, but PGLite can still be used directly * ``` */ async end(): Promise { if (this.closed) { return } this.closed = true // Reject all waiting requests with clear error message for (const request of this.waitingRequests) { clearTimeout(request.timeoutId) request.reject(new PoolError('Pool is closing')) } this.waitingRequests.length = 0 // Clear idle clients this.idleClients.length = 0 // Clear all clients this.clients.clear() } /** * Destroy the pool and close the underlying PGLite instance. * * This is the "full shutdown" method that: * 1. Calls {@link end} to close the pool * 2. Calls close() on the PGLite instance * * After destroy(), neither the pool nor the PGLite instance can be used. * * @example * ```typescript * // Full cleanup when completely done with the database * await pool.destroy() * ``` */ async destroy(): Promise { await this.end() await this.pglite.close() } }