/** * Subscription State Machine * * Provides a clear lifecycle for CDC subscriptions with: * - Well-defined states and transitions * - Guards for transition validation * - Event emission on state changes * - History tracking for debugging * * ## State Transition Diagram * * ``` * ┌─────────────────────────────────────────────────────────────┐ * │ FATAL_ERROR │ * ▼ │ * ┌──────┐ ┌───────────┐ ┌────────────┐ ┌────────┐ │ * │ IDLE │────▶│CONNECTING │────▶│SUBSCRIBING │────▶│ ACTIVE │ │ * └──────┘ └───────────┘ └────────────┘ └────────┘ │ * │ │ │ │ │ * │ │ ERROR/ │ DISCONNECTED/ │ DISCONNECTED/ │ * │ │ UNSUBSCRIBE │ ERROR │ ERROR/PAUSE │ * │ ▼ ▼ ▼ │ * │ ┌────────┐ ┌─────────────┐ ┌────────┐ │ * │ │ CLOSED │◀───────│RECONNECTING │ │ PAUSED │ │ * │ └────────┘ └─────────────┘ └────────┘ │ * │ ▲ │ │ │ * │ │ RESET │ RECONNECT │ RESUME/ │ * │ │ ▼ │ UNSUBSCRIBE │ * │ ┌────────┐ ┌───────────┐ │ │ * │◀────────│ ERROR │◀───────│UNSUBSCRIBING│◀───────┘ │ * │ RESET └────────┘ └───────────┘ │ * │ │ * └────────────────────────────────────────────────────────────────────────────┘ * ``` * * ## State Summary * * | State | Description | Terminal | Can Receive Events | * |--------------|------------------------------------------------|----------|-------------------| * | IDLE | Subscription created but not yet started | No | No | * | CONNECTING | Establishing transport connection | No | No | * | SUBSCRIBING | Connected, waiting for subscription ack | No | Yes (buffered) | * | ACTIVE | Actively receiving and processing events | No | Yes | * | RECONNECTING | Connection lost, attempting automatic reconnect| No | No | * | PAUSED | User-initiated pause, can be resumed | No | No | * | UNSUBSCRIBING| Gracefully unsubscribing from server | No | No | * | CLOSED | Successfully closed, can be reset | Yes | No | * | ERROR | Unrecoverable error, can be reset | Yes | No | * * @module cdc/state-machine */ import { CDCStateError } from './errors' import { CDCLogger, defaultLogger } from './logger' // ============================================================================= // Constants // ============================================================================= /** Default maximum number of state transitions to retain in history */ export const DEFAULT_MAX_HISTORY_SIZE = 50 /** Number of recent transitions to include in debug output */ export const DEBUG_RECENT_HISTORY_COUNT = 5 // ============================================================================= // State Definitions // ============================================================================= /** * All possible states for a CDC subscription. * * The state machine follows a lifecycle pattern: * 1. Initialization: IDLE -> CONNECTING -> SUBSCRIBING -> ACTIVE * 2. Event Processing: ACTIVE (self-loop on EVENT_RECEIVED) * 3. Error Recovery: ACTIVE -> RECONNECTING -> CONNECTING -> ... * 4. User Control: ACTIVE <-> PAUSED * 5. Termination: Any -> CLOSED or ERROR */ export enum SubscriptionState { /** Initial state - subscription created but not started */ IDLE = 'IDLE', /** Connecting to the CDC stream */ CONNECTING = 'CONNECTING', /** Connected and waiting for server subscription confirmation */ SUBSCRIBING = 'SUBSCRIBING', /** Actively receiving events */ ACTIVE = 'ACTIVE', /** Connection lost, will reconnect */ RECONNECTING = 'RECONNECTING', /** Paused by user, can be resumed */ PAUSED = 'PAUSED', /** Unsubscribing from the stream */ UNSUBSCRIBING = 'UNSUBSCRIBING', /** Subscription has been closed */ CLOSED = 'CLOSED', /** Subscription has encountered an unrecoverable error */ ERROR = 'ERROR', } /** * Events that trigger state transitions */ export enum SubscriptionEvent { /** User calls subscribe */ SUBSCRIBE = 'SUBSCRIBE', /** Transport connection established */ CONNECTED = 'CONNECTED', /** Server confirmed subscription */ SUBSCRIBED = 'SUBSCRIBED', /** Received a change event */ EVENT_RECEIVED = 'EVENT_RECEIVED', /** Transport connection lost */ DISCONNECTED = 'DISCONNECTED', /** Reconnection attempt started */ RECONNECT = 'RECONNECT', /** User pauses subscription */ PAUSE = 'PAUSE', /** User resumes subscription */ RESUME = 'RESUME', /** User unsubscribes */ UNSUBSCRIBE = 'UNSUBSCRIBE', /** Server confirmed unsubscription */ UNSUBSCRIBED = 'UNSUBSCRIBED', /** Recoverable error occurred */ ERROR = 'ERROR', /** Unrecoverable error occurred */ FATAL_ERROR = 'FATAL_ERROR', /** Reset to initial state */ RESET = 'RESET', } // ============================================================================= // Type Definitions // ============================================================================= /** * Record of a single state transition for history/debugging. */ export interface StateTransition { /** State before the transition */ readonly from: SubscriptionState /** State after the transition */ readonly to: SubscriptionState /** Event that triggered the transition */ readonly event: SubscriptionEvent /** When the transition occurred */ readonly timestamp: Date /** Optional context data provided with the transition */ readonly context?: Readonly> | undefined } // ============================================================================= // State Transition Table // ============================================================================= /** * Defines all valid state transitions. * * This is the authoritative source for the state machine's behavior. * Each entry maps a source state to its valid events and target states. * * Transition Rules: * - IDLE: Entry point. Can only SUBSCRIBE or fail with FATAL_ERROR. * - CONNECTING: CONNECTED->SUBSCRIBING, ERROR->RECONNECTING, FATAL_ERROR->ERROR * - SUBSCRIBING: SUBSCRIBED->ACTIVE, ERROR/DISCONNECTED->RECONNECTING * - ACTIVE: EVENT_RECEIVED->ACTIVE (self-loop), DISCONNECTED/ERROR->RECONNECTING * - RECONNECTING: RECONNECT->CONNECTING, FATAL_ERROR->ERROR * - PAUSED: RESUME->CONNECTING, UNSUBSCRIBE->CLOSED * - UNSUBSCRIBING: UNSUBSCRIBED/ERROR/DISCONNECTED->CLOSED (best effort) * - CLOSED: RESET->IDLE (terminal state) * - ERROR: RESET->IDLE, UNSUBSCRIBE->CLOSED (terminal state) */ const VALID_TRANSITIONS: Record>> = { [SubscriptionState.IDLE]: { [SubscriptionEvent.SUBSCRIBE]: SubscriptionState.CONNECTING, [SubscriptionEvent.FATAL_ERROR]: SubscriptionState.ERROR, }, [SubscriptionState.CONNECTING]: { [SubscriptionEvent.CONNECTED]: SubscriptionState.SUBSCRIBING, [SubscriptionEvent.ERROR]: SubscriptionState.RECONNECTING, [SubscriptionEvent.FATAL_ERROR]: SubscriptionState.ERROR, [SubscriptionEvent.UNSUBSCRIBE]: SubscriptionState.CLOSED, }, [SubscriptionState.SUBSCRIBING]: { [SubscriptionEvent.SUBSCRIBED]: SubscriptionState.ACTIVE, [SubscriptionEvent.ERROR]: SubscriptionState.RECONNECTING, [SubscriptionEvent.FATAL_ERROR]: SubscriptionState.ERROR, [SubscriptionEvent.UNSUBSCRIBE]: SubscriptionState.UNSUBSCRIBING, [SubscriptionEvent.DISCONNECTED]: SubscriptionState.RECONNECTING, }, [SubscriptionState.ACTIVE]: { [SubscriptionEvent.EVENT_RECEIVED]: SubscriptionState.ACTIVE, // Self-transition [SubscriptionEvent.DISCONNECTED]: SubscriptionState.RECONNECTING, [SubscriptionEvent.PAUSE]: SubscriptionState.PAUSED, [SubscriptionEvent.UNSUBSCRIBE]: SubscriptionState.UNSUBSCRIBING, [SubscriptionEvent.ERROR]: SubscriptionState.RECONNECTING, [SubscriptionEvent.FATAL_ERROR]: SubscriptionState.ERROR, }, [SubscriptionState.RECONNECTING]: { [SubscriptionEvent.RECONNECT]: SubscriptionState.CONNECTING, [SubscriptionEvent.FATAL_ERROR]: SubscriptionState.ERROR, [SubscriptionEvent.UNSUBSCRIBE]: SubscriptionState.CLOSED, }, [SubscriptionState.PAUSED]: { [SubscriptionEvent.RESUME]: SubscriptionState.CONNECTING, [SubscriptionEvent.UNSUBSCRIBE]: SubscriptionState.CLOSED, [SubscriptionEvent.FATAL_ERROR]: SubscriptionState.ERROR, }, [SubscriptionState.UNSUBSCRIBING]: { [SubscriptionEvent.UNSUBSCRIBED]: SubscriptionState.CLOSED, [SubscriptionEvent.ERROR]: SubscriptionState.CLOSED, // Best effort [SubscriptionEvent.DISCONNECTED]: SubscriptionState.CLOSED, }, [SubscriptionState.CLOSED]: { [SubscriptionEvent.RESET]: SubscriptionState.IDLE, }, [SubscriptionState.ERROR]: { [SubscriptionEvent.RESET]: SubscriptionState.IDLE, [SubscriptionEvent.UNSUBSCRIBE]: SubscriptionState.CLOSED, }, } /** * State machine configuration */ export interface StateMachineConfig { /** Initial state (default: IDLE) */ initialState?: SubscriptionState /** Maximum history entries to keep (default: 50) */ maxHistorySize?: number /** Callback when state changes */ onStateChange?: ( from: SubscriptionState, to: SubscriptionState, event: SubscriptionEvent, context?: Record ) => void /** Callback when an invalid transition is attempted */ onInvalidTransition?: (from: SubscriptionState, event: SubscriptionEvent) => void /** Logger instance */ logger?: CDCLogger } /** * Subscription State Machine */ export class SubscriptionStateMachine { private state: SubscriptionState private history: StateTransition[] = [] private stateEnteredAt: Date private readonly config: Required> & { onStateChange?: StateMachineConfig['onStateChange'] onInvalidTransition?: StateMachineConfig['onInvalidTransition'] logger: CDCLogger } constructor(config: StateMachineConfig = {}) { this.config = { initialState: config.initialState ?? SubscriptionState.IDLE, maxHistorySize: config.maxHistorySize ?? DEFAULT_MAX_HISTORY_SIZE, onStateChange: config.onStateChange, onInvalidTransition: config.onInvalidTransition, logger: config.logger ?? defaultLogger, } this.state = this.config.initialState this.stateEnteredAt = new Date() } /** * Get current state */ getState(): SubscriptionState { return this.state } /** * Check if a transition is valid */ canTransition(event: SubscriptionEvent): boolean { const transitions = VALID_TRANSITIONS[this.state] return transitions !== undefined && event in transitions } /** * Get the target state for an event (if valid) */ getTargetState(event: SubscriptionEvent): SubscriptionState | undefined { const transitions = VALID_TRANSITIONS[this.state] return transitions?.[event] } /** * Trigger a state transition * @throws CDCStateError if transition is invalid */ transition(event: SubscriptionEvent, context?: Record): SubscriptionState { const targetState = this.getTargetState(event) if (targetState === undefined) { this.config.onInvalidTransition?.(this.state, event) this.config.logger.warn('Invalid state transition attempted', { data: { currentState: this.state, event }, }) throw new CDCStateError(this.state, event, { context }) } const from = this.state const to = targetState // Record transition const transition: StateTransition = { from, to, event, timestamp: new Date(), context, } this.history.push(transition) if (this.history.length > this.config.maxHistorySize) { this.history.shift() } // Update state this.state = to this.stateEnteredAt = new Date() // Only log if state actually changed if (from !== to) { this.config.logger.debug('State transition', { data: { from, to, event, context }, }) } // Notify listener this.config.onStateChange?.(from, to, event, context) return to } /** * Try to transition, returns false instead of throwing */ tryTransition(event: SubscriptionEvent, context?: Record): boolean { if (!this.canTransition(event)) { return false } this.transition(event, context) return true } /** * Get time spent in current state */ getTimeInState(): number { return Date.now() - this.stateEnteredAt.getTime() } /** * Get transition history */ getHistory(): StateTransition[] { return [...this.history] } /** * Get the last N transitions */ getRecentHistory(n: number): StateTransition[] { return this.history.slice(-n) } /** * Check if subscription is in a terminal state */ isTerminal(): boolean { return this.state === SubscriptionState.CLOSED || this.state === SubscriptionState.ERROR } /** * Check if subscription is active (receiving events) */ isActive(): boolean { return this.state === SubscriptionState.ACTIVE } /** * Check if subscription is in a connecting state */ isConnecting(): boolean { return ( this.state === SubscriptionState.CONNECTING || this.state === SubscriptionState.SUBSCRIBING || this.state === SubscriptionState.RECONNECTING ) } /** * Check if subscription can receive events */ canReceiveEvents(): boolean { return this.state === SubscriptionState.ACTIVE || this.state === SubscriptionState.SUBSCRIBING } /** * Reset to initial state */ reset(): void { const wasTerminal = this.isTerminal() if (wasTerminal) { this.transition(SubscriptionEvent.RESET) } else if (this.state !== SubscriptionState.IDLE) { // Force reset for non-terminal states (for testing/admin) this.state = SubscriptionState.IDLE this.stateEnteredAt = new Date() this.history = [] } } /** * Get state machine info for debugging */ getDebugInfo(): Record { return { currentState: this.state, timeInState: this.getTimeInState(), stateEnteredAt: this.stateEnteredAt.toISOString(), isTerminal: this.isTerminal(), isActive: this.isActive(), historyLength: this.history.length, recentTransitions: this.getRecentHistory(DEBUG_RECENT_HISTORY_COUNT).map((t) => ({ from: t.from, to: t.to, event: t.event, timestamp: t.timestamp.toISOString(), })), } } } /** * Create a state machine instance */ export function createStateMachine(config?: StateMachineConfig): SubscriptionStateMachine { return new SubscriptionStateMachine(config) } /** * Check if a state is an active/connected state */ export function isConnectedState(state: SubscriptionState): boolean { return state === SubscriptionState.ACTIVE || state === SubscriptionState.SUBSCRIBING } /** * Check if a state allows reconnection */ export function canReconnect(state: SubscriptionState): boolean { return state === SubscriptionState.RECONNECTING } /** * Get human-readable state description */ export function getStateDescription(state: SubscriptionState): string { const descriptions: Record = { [SubscriptionState.IDLE]: 'Subscription created but not yet started', [SubscriptionState.CONNECTING]: 'Establishing connection to CDC stream', [SubscriptionState.SUBSCRIBING]: 'Connected, waiting for subscription confirmation', [SubscriptionState.ACTIVE]: 'Actively receiving change events', [SubscriptionState.RECONNECTING]: 'Connection lost, attempting to reconnect', [SubscriptionState.PAUSED]: 'Subscription paused by user', [SubscriptionState.UNSUBSCRIBING]: 'Unsubscription in progress', [SubscriptionState.CLOSED]: 'Subscription has been closed', [SubscriptionState.ERROR]: 'Subscription encountered an unrecoverable error', } return descriptions[state] } /** * Get the list of valid events from a given state */ export function getValidEventsFromState(state: SubscriptionState): SubscriptionEvent[] { const transitions = VALID_TRANSITIONS[state] return Object.keys(transitions) as SubscriptionEvent[] } /** * Get all valid transitions from a given state */ export function getTransitionsFromState( state: SubscriptionState ): Partial> { return { ...VALID_TRANSITIONS[state] } }