/** * React Hooks for CDC Subscriptions * * Provides React hooks for subscribing to database changes: * - useSubscription: Subscribe to table changes * - useTable: Live-updating table data * - useCDCConnection: Monitor CDC connection status * * Note: This module should be used in a React environment. * It will gracefully handle non-React environments. */ import type { CDCChangeEvent, Row, CDCOperation, Sql } from '../types' import type { HealthCheckResult, HealthStatus } from './health-monitor' // Type definitions for React hooks (allows running without React) type SetStateAction = S | ((prevState: S) => S) type Dispatch = (value: A) => void interface ReactHooks { useState: (initialState: S | (() => S)) => [S, Dispatch>] useEffect: (effect: () => void | (() => void), deps?: readonly unknown[]) => void useRef: (initialValue: T) => { current: T } // eslint-disable-next-line @typescript-eslint/no-explicit-any useCallback: any>(callback: T, deps: readonly unknown[]) => T useMemo: (factory: () => T, deps: readonly unknown[]) => T } // Try to import React, but gracefully handle if not available let React: ReactHooks | null = null try { // Dynamic import to avoid bundling issues // eslint-disable-next-line @typescript-eslint/no-require-imports React = require('react') as ReactHooks } catch { // React not available - hooks will throw helpful error } /** * Check if React is available */ function requireReact(): ReactHooks { if (!React) { throw new Error( 'React is required for CDC hooks. Install react as a dependency or use the callback-based API.' ) } return React } /** * Subscription status */ export type SubscriptionStatus = | 'idle' | 'connecting' | 'active' | 'reconnecting' | 'paused' | 'error' | 'closed' /** * Options for useSubscription hook */ export interface UseSubscriptionOptions { /** Event types to subscribe to (default: all) */ events?: CDCOperation[] /** SQL filter expression */ filter?: string /** Resume from LSN */ resumeFrom?: string /** Include old row data */ includeOldRow?: boolean /** Whether to start paused (default: false) */ startPaused?: boolean /** Callback for insert events */ onInsert?: (row: T) => void /** Callback for update events */ onUpdate?: (newRow: T, oldRow?: T) => void /** Callback for delete events */ onDelete?: (oldRow: T) => void /** Callback for any change */ onChange?: (event: CDCChangeEvent & { newRow?: T; oldRow?: T }) => void /** Callback for errors */ onError?: (error: Error) => void /** Callback for status changes */ onStatusChange?: (status: SubscriptionStatus) => void } /** * Return type for useSubscription hook */ export interface UseSubscriptionResult { /** Current subscription status */ status: SubscriptionStatus /** Whether subscription is active */ isActive: boolean /** Whether subscription is connected */ isConnected: boolean /** Last error if any */ error: Error | null /** Last LSN received */ lastLsn: string | null /** Recent events (last 100) */ recentEvents: Array /** Event count */ eventCount: number /** Pause the subscription */ pause: () => void /** Resume the subscription */ resume: () => void /** Unsubscribe and cleanup */ unsubscribe: () => void } /** * Hook for subscribing to table changes * * @example * ```tsx * function UserList() { * const { status, recentEvents, isActive } = useSubscription( * pg, * 'users', * { * onInsert: (user) => console.log('New user:', user), * onUpdate: (user) => console.log('Updated:', user), * } * ) * * if (status === 'error') { * return
Error connecting to CDC
* } * * return ( *
*

Status: {status}

*

Events: {recentEvents.length}

*
* ) * } * ``` */ export function useSubscription( client: Sql, table: string, options: UseSubscriptionOptions = {} ): UseSubscriptionResult { const { useState, useEffect, useRef, useCallback } = requireReact() const [status, setStatus] = useState( options.startPaused ? 'paused' : 'idle' ) const [error, setError] = useState(null) const [lastLsn, setLastLsn] = useState(null) const [recentEvents, setRecentEvents] = useState< Array >([]) const [eventCount, setEventCount] = useState(0) const subscriptionRef = useRef>> | null>(null) const optionsRef = useRef(options) optionsRef.current = options const pause = useCallback(() => { // Note: Full pause implementation would require transport support setStatus('paused') }, []) const resume = useCallback(() => { setStatus('connecting') // Trigger reconnection }, []) const unsubscribe = useCallback(async () => { if (subscriptionRef.current) { await subscriptionRef.current.unsubscribe() subscriptionRef.current = null } setStatus('closed') }, []) useEffect(() => { if (options.startPaused) { return } let cancelled = false const subscribe = async () => { try { setStatus('connecting') setError(null) const sub = await client.subscribe(table, { events: options.events, filter: options.filter, resumeFrom: options.resumeFrom, includeOldRow: options.includeOldRow, onChange: (event) => { if (cancelled) return setLastLsn(event.lsn) setEventCount((c) => c + 1) setRecentEvents((prev) => [...prev.slice(-99), event]) optionsRef.current.onChange?.(event) }, onInsert: (row) => { if (cancelled) return optionsRef.current.onInsert?.(row) }, onUpdate: (newRow, oldRow) => { if (cancelled) return optionsRef.current.onUpdate?.(newRow, oldRow) }, onDelete: (oldRow) => { if (cancelled) return optionsRef.current.onDelete?.(oldRow) }, onError: (err) => { if (cancelled) return setError(err) setStatus('error') optionsRef.current.onError?.(err) }, }) if (cancelled) { await sub.unsubscribe() return } subscriptionRef.current = sub setStatus('active') optionsRef.current.onStatusChange?.('active') } catch (err) { if (cancelled) return const error = err instanceof Error ? err : new Error(String(err)) setError(error) setStatus('error') optionsRef.current.onError?.(error) optionsRef.current.onStatusChange?.('error') } } subscribe() return () => { cancelled = true if (subscriptionRef.current) { subscriptionRef.current.unsubscribe() subscriptionRef.current = null } } }, [client, table, options.events, options.filter, options.resumeFrom, options.startPaused, options.includeOldRow]) return { status, isActive: status === 'active', isConnected: status === 'active' || status === 'reconnecting', error, lastLsn, recentEvents, eventCount, pause, resume, unsubscribe, } } /** * Options for useTable hook */ export interface UseTableOptions { /** Initial data (useful for SSR) */ initialData?: T[] /** Primary key field(s) for row identification */ primaryKey?: keyof T | Array /** Maximum rows to keep (default: 1000) */ maxRows?: number /** Sort function for rows */ sortFn?: (a: T, b: T) => number /** Filter function for rows */ filterFn?: (row: T) => boolean /** Events to subscribe to */ events?: CDCOperation[] /** SQL filter expression */ filter?: string /** Callback for changes */ onDataChange?: (data: T[]) => void } /** * Return type for useTable hook */ export interface UseTableResult { /** Current table data */ data: T[] /** Loading state */ loading: boolean /** Error if any */ error: Error | null /** Whether connected and receiving updates */ isLive: boolean /** Number of rows */ count: number /** Manually refresh data */ refresh: () => Promise /** Find a row by primary key */ findById: (id: unknown) => T | undefined } /** * Hook for live-updating table data * * @example * ```tsx * function UserTable() { * const { data, loading, isLive, count } = useTable( * pg, * 'users', * { * primaryKey: 'id', * sortFn: (a, b) => a.name.localeCompare(b.name), * } * ) * * if (loading) return
Loading...
* * return ( * * * * * * * * * {data.map(user => ( * * * * * ))} * *
NameEmail
{user.name}{user.email}
* ) * } * ``` */ export function useTable( client: Sql, table: string, options: UseTableOptions = {} ): UseTableResult { const { useState, useEffect, useCallback, useMemo } = requireReact() const [data, setData] = useState(options.initialData ?? []) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [isLive, setIsLive] = useState(false) const primaryKey = options.primaryKey ?? 'id' const maxRows = options.maxRows ?? 1000 // Get primary key value from a row const getRowKey = useCallback( (row: T): string => { if (Array.isArray(primaryKey)) { return primaryKey.map((k) => String(row[k])).join(':') } return String(row[primaryKey]) }, [primaryKey] ) // Find a row by ID const findById = useCallback( (id: unknown): T | undefined => { const idStr = String(id) return data.find((row) => getRowKey(row) === idStr) }, [data, getRowKey] ) // Refresh data from server const refresh = useCallback(async () => { try { setLoading(true) const result = await client.unsafe(`SELECT * FROM ${table} LIMIT ${maxRows}`) let rows = result if (options.filterFn) { rows = rows.filter(options.filterFn) } if (options.sortFn) { rows = rows.sort(options.sortFn) } setData(rows) options.onDataChange?.(rows) } catch (err) { setError(err instanceof Error ? err : new Error(String(err))) } finally { setLoading(false) } }, [client, table, maxRows, options]) // Subscribe to changes useSubscription(client, table, { ...(options.events && { events: options.events }), ...(options.filter && { filter: options.filter }), includeOldRow: true, onInsert: (row) => { setData((prev) => { if (options.filterFn && !options.filterFn(row)) { return prev } let next = [...prev, row] if (options.sortFn) { next = next.sort(options.sortFn) } if (next.length > maxRows) { next = next.slice(0, maxRows) } options.onDataChange?.(next) return next }) }, onUpdate: (newRow) => { setData((prev) => { const key = getRowKey(newRow) let next = prev.map((row) => (getRowKey(row) === key ? newRow : row)) if (options.filterFn) { next = next.filter(options.filterFn) } if (options.sortFn) { next = next.sort(options.sortFn) } options.onDataChange?.(next) return next }) }, onDelete: (oldRow) => { setData((prev) => { const key = getRowKey(oldRow) const next = prev.filter((row) => getRowKey(row) !== key) options.onDataChange?.(next) return next }) }, onStatusChange: (status) => { setIsLive(status === 'active') }, onError: setError, }) // Load initial data useEffect(() => { if (!options.initialData) { refresh() } else { setLoading(false) } }, [refresh, options.initialData]) const count = useMemo(() => data.length, [data]) return { data, loading, error, isLive, count, refresh, findById, } } /** * Options for useCDCConnection hook */ export interface UseCDCConnectionOptions { /** Polling interval for health checks (ms) (default: 5000) */ healthCheckInterval?: number /** Callback for health changes */ onHealthChange?: (health: HealthCheckResult) => void } /** * Return type for useCDCConnection hook */ export interface UseCDCConnectionResult { /** Whether client has active subscriptions */ hasActiveSubscriptions: boolean /** Number of active subscriptions */ subscriptionCount: number /** Health status (if available) */ healthStatus?: HealthStatus /** Health details (if available) */ health?: HealthCheckResult /** List of active subscription IDs */ subscriptionIds: string[] } /** * Hook for monitoring CDC connection status * * @example * ```tsx * function ConnectionStatus() { * const { hasActiveSubscriptions, subscriptionCount, healthStatus } = useCDCConnection(pg) * * return ( *
* * {healthStatus ?? 'Unknown'} * * {subscriptionCount} active subscriptions *
* ) * } * ``` */ export function useCDCConnection( client: Sql, options: UseCDCConnectionOptions = {} ): UseCDCConnectionResult { const { useState, useEffect } = requireReact() const [subscriptionCount, setSubscriptionCount] = useState(0) const [subscriptionIds, setSubscriptionIds] = useState([]) const [health, _setHealth] = useState(undefined) useEffect(() => { const update = () => { const subs = client.subscriptions() setSubscriptionCount(subs.length) setSubscriptionIds(subs.map((s) => s.id)) } update() const interval = setInterval(update, options.healthCheckInterval ?? 5000) return () => clearInterval(interval) }, [client, options.healthCheckInterval]) const result: UseCDCConnectionResult = { hasActiveSubscriptions: subscriptionCount > 0, subscriptionCount, subscriptionIds, } if (health?.status !== undefined) { result.healthStatus = health.status } if (health !== undefined) { result.health = health } return result } /** * Check if React hooks are available */ export function isReactAvailable(): boolean { return React !== null }