/** * Base CDC Transport for postgres.do * * Provides shared functionality for CDC transports: * - Common connection lifecycle methods * - Shared error handling * - Common reconnection logic with exponential backoff and jitter * - Shared metrics collection * - Abstract methods for protocol-specific behavior */ import type { CDCChangeEvent, CDCTransport, CDCSubscriptionOptions, } from '../types' import { ConnectionError } from '../types' /** * Common default configuration values */ export const BASE_DEFAULTS = { connectTimeout: 10000, maxReconnectAttempts: 10, reconnectBaseDelay: 1000, maxReconnectDelay: 60000, } /** * Pending request waiting for a response */ export interface PendingRequest { resolve: (value: T) => void reject: (error: Error) => void timeout?: ReturnType } /** * Active subscription information */ export interface ActiveSubscription { table: string schema: string options: CDCSubscriptionOptions } /** * Base transport metrics */ export interface BaseTransportMetrics { eventsReceived: number reconnections: number bytesReceived: number } /** * Base transport configuration */ export interface BaseTransportConfig { /** Connection timeout in milliseconds */ connectTimeout?: number /** Maximum reconnection attempts */ maxReconnectAttempts?: number /** Base delay for reconnection backoff (ms) */ reconnectBaseDelay?: number /** Maximum delay for reconnection backoff (ms) */ maxReconnectDelay?: number } /** * Abstract base class for CDC transports * * Provides common functionality for: * - Event handler management * - Connection state tracking * - Reconnection with exponential backoff and jitter * - Active subscription tracking * - Pending request management * - Metrics collection */ export abstract class BaseCDCTransport implements CDCTransport { // Connection state protected connected = false protected shouldReconnect = true protected reconnectAttempt = 0 protected reconnectTimer: ReturnType | null = null protected lastEventId: string | null = null // Configuration protected connectTimeout: number protected maxReconnectAttempts: number protected reconnectBaseDelay: number protected maxReconnectDelay: number // Event handlers protected eventHandler: ((subscriptionId: string, event: CDCChangeEvent) => void) | null = null protected errorHandler: ((subscriptionId: string, error: Error) => void) | null = null protected reconnectHandler: ((attempt: number, lastLsn: string | undefined) => void) | null = null protected closeHandler: (() => void) | null = null // Active subscriptions for reconnection protected activeSubscriptions = new Map() // Pending subscription/unsubscription requests protected pendingSubscriptions = new Map>() protected pendingUnsubscriptions = new Map>() // Base metrics protected baseMetrics: BaseTransportMetrics = { eventsReceived: 0, reconnections: 0, bytesReceived: 0, } constructor(config: BaseTransportConfig = {}) { this.connectTimeout = config.connectTimeout ?? BASE_DEFAULTS.connectTimeout this.maxReconnectAttempts = config.maxReconnectAttempts ?? BASE_DEFAULTS.maxReconnectAttempts this.reconnectBaseDelay = config.reconnectBaseDelay ?? BASE_DEFAULTS.reconnectBaseDelay this.maxReconnectDelay = config.maxReconnectDelay ?? BASE_DEFAULTS.maxReconnectDelay } // ========================================================================== // Abstract methods - must be implemented by subclasses // ========================================================================== /** * Protocol-specific connect implementation */ protected abstract doConnect(): Promise /** * Protocol-specific disconnect implementation */ protected abstract doDisconnect(): Promise /** * Protocol-specific subscribe implementation */ protected abstract doSubscribe( subscriptionId: string, table: string, schema: string, options: CDCSubscriptionOptions ): Promise /** * Protocol-specific unsubscribe implementation */ protected abstract doUnsubscribe(subscriptionId: string): Promise /** * Protocol-specific reconnect implementation * Called after connection is re-established to restore subscriptions */ protected abstract doReconnect(): Promise // ========================================================================== // Common connection lifecycle methods // ========================================================================== /** * Connect to the CDC stream */ async connect(): Promise { if (this.connected) { return } this.shouldReconnect = true await this.doConnect() } /** * Disconnect from the CDC stream */ async disconnect(): Promise { this.shouldReconnect = false this.clearReconnectTimer() await this.doDisconnect() this.connected = false this.activeSubscriptions.clear() this.rejectAllPending(new ConnectionError('Connection closed')) this.closeHandler?.() } /** * Check if connected */ isConnected(): boolean { return this.connected } /** * Get the last event ID received */ getLastEventId(): string | null { return this.lastEventId } // ========================================================================== // Subscription management // ========================================================================== /** * Subscribe to a table */ async subscribe( subscriptionId: string, table: string, schema: string, options: CDCSubscriptionOptions ): Promise { // Store the subscription for reconnection this.activeSubscriptions.set(subscriptionId, { table, schema, options }) return this.doSubscribe(subscriptionId, table, schema, options) } /** * Unsubscribe from a subscription */ async unsubscribe(subscriptionId: string): Promise { this.activeSubscriptions.delete(subscriptionId) // If no more subscriptions, just disconnect if (this.activeSubscriptions.size === 0) { await this.disconnect() return } return this.doUnsubscribe(subscriptionId) } // ========================================================================== // Event handler setters // ========================================================================== /** * Set the event handler */ onEvent(handler: (subscriptionId: string, event: CDCChangeEvent) => void): void { this.eventHandler = handler } /** * Set the error handler */ onError(handler: (subscriptionId: string, error: Error) => void): void { this.errorHandler = handler } /** * Set the reconnect handler */ onReconnect(handler: (attempt: number, lastLsn: string | undefined) => void): void { this.reconnectHandler = handler } /** * Set the close handler */ onClose(handler: () => void): void { this.closeHandler = handler } // ========================================================================== // Reconnection with exponential backoff and jitter // ========================================================================== /** * Schedule reconnection with exponential backoff and jitter * * Uses a decorrelated jitter algorithm for optimal reconnection behavior: * - Exponential backoff: delay = baseDelay * 2^attempt * - Jitter: adds random variation to prevent thundering herd * - Capped at maxReconnectDelay to prevent excessive waits */ protected scheduleReconnect(): void { if (!this.shouldReconnect) { return } this.reconnectAttempt++ this.baseMetrics.reconnections++ if (this.reconnectAttempt > this.maxReconnectAttempts) { const error = new ConnectionError('Max reconnection attempts exceeded') this.emitErrorToAll(error) this.closeHandler?.() return } // Calculate delay with exponential backoff const baseDelay = this.reconnectBaseDelay * Math.pow(2, this.reconnectAttempt - 1) // Add jitter: random value between 0 and 50% of base delay // This helps prevent thundering herd when many clients reconnect const jitter = Math.random() * baseDelay * 0.5 // Final delay with cap const delay = Math.min(baseDelay + jitter, this.maxReconnectDelay) this.reconnectTimer = setTimeout(async () => { this.reconnectHandler?.(this.reconnectAttempt, this.lastEventId ?? undefined) try { await this.doReconnect() } catch { // Schedule another reconnect if (this.shouldReconnect) { this.scheduleReconnect() } } }, delay) } /** * Clear the reconnect timer */ protected clearReconnectTimer(): void { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer) this.reconnectTimer = null } } /** * Reset reconnection state after successful connection */ protected resetReconnectState(): void { this.reconnectAttempt = 0 this.connected = true } // ========================================================================== // Error handling utilities // ========================================================================== /** * Emit an error to all active subscriptions */ protected emitErrorToAll(error: Error): void { for (const subscriptionId of Array.from(this.activeSubscriptions.keys())) { this.errorHandler?.(subscriptionId, error) } } /** * Reject all pending requests with an error */ protected rejectAllPending(error: Error): void { for (const pending of Array.from(this.pendingSubscriptions.values())) { if (pending.timeout) { clearTimeout(pending.timeout) } pending.reject(error) } this.pendingSubscriptions.clear() for (const pending of Array.from(this.pendingUnsubscriptions.values())) { if (pending.timeout) { clearTimeout(pending.timeout) } pending.reject(error) } this.pendingUnsubscriptions.clear() } // ========================================================================== // Metrics // ========================================================================== /** * Get base transport metrics */ getBaseMetrics(): Readonly { return { ...this.baseMetrics } } /** * Reset base metrics */ resetBaseMetrics(): void { this.baseMetrics = { eventsReceived: 0, reconnections: 0, bytesReceived: 0, } } /** * Increment events received counter */ protected incrementEventsReceived(): void { this.baseMetrics.eventsReceived++ } /** * Add to bytes received counter */ protected addBytesReceived(bytes: number): void { this.baseMetrics.bytesReceived += bytes } /** * Update the last event ID */ protected updateLastEventId(id: string): void { this.lastEventId = id } }