/** * Query Middleware Layer for postgres.do * * Provides a middleware chain pattern for intercepting and transforming queries. * Enables cross-cutting concerns like logging, metrics, rate limiting, and tracing. * * ## Architecture * * Middleware functions follow an "onion" pattern where each middleware wraps * the next one in the chain. Execution flows inward through all middlewares, * reaches the executor, then flows outward back through the middlewares. * * ``` * Request → [Middleware A] → [Middleware B] → [Executor] * Response ← [Middleware A] ← [Middleware B] ← * ``` * * ## Creating Custom Middleware * * A middleware is an async function that receives a request and a `next` function. * Call `next()` to proceed to the next middleware or executor. You can: * * - Modify the request before calling `next()` * - Modify the response after calling `next()` * - Short-circuit by returning a response without calling `next()` * - Handle errors by wrapping `next()` in try/catch * * @example Basic usage * ```typescript * import postgres from 'postgres.do' * import { loggingMiddleware, metricsMiddleware } from 'postgres.do/middleware' * * const sql = postgres('postgres://db.postgres.do/mydb') * .use(loggingMiddleware({ level: 'debug' })) * .use(metricsMiddleware({ onMetric: console.log })) * * // All queries now flow through the middleware chain * const users = await sql`SELECT * FROM users` * ``` * * @example Custom middleware * ```typescript * import type { QueryMiddleware } from 'postgres.do/middleware' * * const myMiddleware: QueryMiddleware = async (request, next) => { * console.log('Before query:', request.sql) * const response = await next() * console.log('After query:', response.durationMs, 'ms') * return response * } * ``` * * @module middleware */ import type { QueryResult, Row } from './types' // ============================================================================ // Constants // ============================================================================ /** * Maximum length for query strings in metrics to prevent memory bloat. * Queries longer than this will be truncated. */ const METRICS_QUERY_TRUNCATE_LENGTH = 100 /** * Milliseconds per second for rate limit time calculations. */ const MS_PER_SECOND = 1000 /** * Default maximum number of retry attempts for retryMiddleware. */ const DEFAULT_MAX_RETRIES = 3 /** * Default base delay in milliseconds between retry attempts. */ const DEFAULT_RETRY_BASE_DELAY_MS = 100 /** * Default maximum delay in milliseconds between retry attempts. * Used as an upper bound when exponential backoff is enabled. */ const DEFAULT_RETRY_MAX_DELAY_MS = 5000 /** * Default histogram bucket boundaries in milliseconds for query duration metrics. * Based on typical web application latency percentiles. */ const DEFAULT_HISTOGRAM_BUCKETS = [1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000] /** * Length of the random suffix in generated query IDs. */ const QUERY_ID_RANDOM_SUFFIX_LENGTH = 9 /** * Start index for slicing the random portion of query IDs. */ const QUERY_ID_RANDOM_SLICE_START = 2 /** * Length of span IDs generated for distributed tracing. */ const SPAN_ID_LENGTH = 10 // ============================================================================ // Core Types // ============================================================================ /** * Query request passed to middleware. * * Contains all information about a query before execution. * Middleware can read and modify this object before passing to `next()`. * * @example * ```typescript * const middleware: QueryMiddleware = async (request, next) => { * console.log('Query:', request.sql) * console.log('Params:', request.params) * console.log('Query ID:', request.queryId) * return next() * } * ``` */ export interface QueryRequest { /** The SQL query string (with parameter placeholders) */ sql: string /** Query parameters */ params: unknown[] /** Timestamp when the query was initiated */ timestamp: number /** Unique ID for this query (useful for tracing) */ queryId: string /** Optional context passed through the middleware chain */ context?: Record } /** * Query response returned from middleware. * * Contains the result of query execution along with timing and status information. * Middleware can modify this object before returning it to the caller. * * @example * ```typescript * const middleware: QueryMiddleware = async (request, next) => { * const response = await next() * if (!response.success) { * console.error('Query failed:', response.error) * } * return response * } * ``` */ export interface QueryResponse { /** Query result */ result: QueryResult /** Duration in milliseconds */ durationMs: number /** Was the query successful */ success: boolean /** Error if query failed */ error?: Error /** Optional metadata added by middleware */ metadata?: Record } /** * Next function to call the next middleware in the chain. * * Calling `next()` invokes the next middleware in the chain, or the query * executor if this is the last middleware. Always returns a Promise that * resolves to a QueryResponse. * * @remarks * - You must call `next()` exactly once to continue the chain * - Not calling `next()` short-circuits the chain (useful for caching, auth) * - Calling `next()` multiple times may cause unexpected behavior */ export type NextFunction = () => Promise> /** * Query middleware function signature. * * Middleware functions receive a request and a `next` function, and must return * a Promise that resolves to a QueryResponse. This is the core type for building * custom middleware. * * @param request - The query request containing SQL, params, and context * @param next - Function to call the next middleware or executor * @returns The query response (possibly transformed) * * @example Simple logging middleware * ```typescript * const loggingMiddleware: QueryMiddleware = async (request, next) => { * console.log('Query:', request.sql) * const response = await next() * console.log('Duration:', response.durationMs, 'ms') * return response * } * ``` * * @example Error handling middleware * ```typescript * const errorHandler: QueryMiddleware = async (request, next) => { * try { * return await next() * } catch (error) { * // Handle or transform the error * return { * result: { rows: [], rowCount: 0, fields: [], command: 'ERROR' }, * durationMs: 0, * success: false, * error: error instanceof Error ? error : new Error(String(error)), * } * } * } * ``` */ export type QueryMiddleware = ( request: QueryRequest, next: NextFunction ) => Promise> // ============================================================================ // Core Functions // ============================================================================ /** * Generate a unique query ID for tracing and logging. * * The ID format is `q__` where: * - `q_` is a fixed prefix for easy identification * - `` is the current time in base-36 encoding * - `` is a random string for uniqueness within the same millisecond * * @returns A unique query identifier string * * @example * ```typescript * const queryId = generateQueryId() * // => "q_lz4k8x_abc123d" * ``` */ export function generateQueryId(): string { const timestamp = Date.now().toString(36) const random = Math.random().toString(36).slice(QUERY_ID_RANDOM_SLICE_START, QUERY_ID_RANDOM_SUFFIX_LENGTH) return `q_${timestamp}_${random}` } /** * Execute a query through a middleware chain. * * This is the core function that orchestrates middleware execution. * It builds a chain from the provided middlewares using a right-to-left * reduction, where each middleware wraps the next one. * * @param middlewares - Array of middleware functions to execute in order * @param request - The query request containing SQL, params, and context * @param executor - The final query executor function that runs the actual query * @returns The query response, possibly transformed by middlewares * * @remarks * - Middlewares execute in array order (first middleware runs first) * - Errors in the executor are caught and returned as failed responses * - Errors thrown by middleware propagate up and must be caught by the caller * * @example * ```typescript * const response = await executeWithMiddleware( * [loggingMiddleware(), metricsMiddleware({ onMetric: console.log })], * { sql: 'SELECT 1', params: [], timestamp: Date.now(), queryId: 'q_123' }, * async () => ({ rows: [{ id: 1 }], rowCount: 1, fields: [], command: 'SELECT' }) * ) * ``` */ export async function executeWithMiddleware( middlewares: QueryMiddleware[], request: QueryRequest, executor: () => Promise> ): Promise> { const startTime = Date.now() // Build the middleware chain from right to left // The last middleware calls the executor const chain = middlewares.reduceRight>( (next, middleware) => { return () => middleware(request, next) }, // Base executor (innermost function) async (): Promise> => { try { const result = await executor() return { result, durationMs: Date.now() - startTime, success: true, } } catch (error) { return { result: { rows: [], rowCount: 0, fields: [], command: 'ERROR', }, durationMs: Date.now() - startTime, success: false, error: error instanceof Error ? error : new Error(String(error)), } } } ) // Execute the chain return chain() } // ============================================================================ // Built-in Middlewares // ============================================================================ /** * Options for configuring the logging middleware. */ export interface LoggingMiddlewareOptions { /** Log level: 'debug' | 'info' | 'warn' | 'error'. Defaults to 'info'. */ level?: 'debug' | 'info' | 'warn' | 'error' /** Custom logger function. Defaults to console methods. */ logger?: (level: string, message: string, data?: Record) => void /** Whether to log query parameters (default: false for security) */ logParams?: boolean /** Only log queries slower than this threshold (ms). Omit to log all queries. */ slowQueryThreshold?: number /** Custom prefix for log messages. Defaults to '[postgres.do]'. */ prefix?: string } /** * Create a logging middleware for query observability. * * Logs query execution details including SQL, duration, row count, and errors. * By default, query parameters are not logged for security reasons. * * @param options - Configuration options for the logging middleware * @returns A middleware function that logs query information * * @example Basic usage * ```typescript * const sql = postgres() * .use(loggingMiddleware({ level: 'info', logParams: false })) * ``` * * @example Slow query logging * ```typescript * const sql = postgres() * .use(loggingMiddleware({ * slowQueryThreshold: 100, // Only log queries taking > 100ms * level: 'warn' * })) * ``` * * @example Custom logger * ```typescript * const sql = postgres() * .use(loggingMiddleware({ * logger: (level, message, data) => { * myLogger[level](message, data) * } * })) * ``` */ export function loggingMiddleware(options: LoggingMiddlewareOptions = {}): QueryMiddleware { const { level = 'info', logger = defaultLogger, logParams = false, slowQueryThreshold, prefix = '[postgres.do]', } = options return async (request, next) => { const response = await next() // Skip logging if below slow query threshold if (slowQueryThreshold !== undefined && response.durationMs < slowQueryThreshold) { return response } const logData: Record = { queryId: request.queryId, sql: request.sql, durationMs: response.durationMs, success: response.success, rowCount: response.result.rowCount, } if (logParams) { logData.params = request.params } if (!response.success && response.error) { logData.error = response.error.message } const message = response.success ? `${prefix} Query completed in ${response.durationMs}ms` : `${prefix} Query failed after ${response.durationMs}ms: ${response.error?.message}` logger(response.success ? level : 'error', message, logData) return response } } /** * Default console logger implementation. * Maps log levels to appropriate console methods. * * @internal */ function defaultLogger(level: string, message: string, data?: Record): void { const logFn = level === 'error' ? console.error : level === 'warn' ? console.warn : level === 'debug' ? console.debug : console.info if (data) { logFn(message, data) } else { logFn(message) } } /** * Options for configuring the metrics middleware. */ export interface MetricsMiddlewareOptions { /** Callback invoked for each metric event. Required. */ onMetric: (metric: QueryMetric) => void /** Custom tags to add to all metrics. */ tags?: Record /** Whether to include histogram bucket information. Defaults to false. */ histogram?: boolean /** Custom histogram bucket boundaries (ms). Defaults to standard latency percentiles. */ buckets?: number[] } /** * Query metric data emitted by the metrics middleware. * * Each query execution emits multiple metrics: * - `query_duration`: Time taken to execute the query (ms) * - `query_count`: Incremented for each query (value is always 1) * - `query_rows`: Number of rows returned * - `query_error`: Incremented for failed queries (value is always 1) */ export interface QueryMetric { /** Metric type */ type: 'query_duration' | 'query_count' | 'query_error' | 'query_rows' /** Metric value */ value: number /** Query ID for correlation */ queryId: string /** SQL query (truncated to prevent memory bloat) */ query: string /** Whether the query succeeded */ success: boolean /** Command type (SELECT, INSERT, UPDATE, DELETE, etc.) */ command: string /** Timestamp when the metric was recorded */ timestamp: number /** Custom tags */ tags?: Record /** Histogram bucket label (if enabled) */ bucket?: string } /** * Create a metrics middleware for query instrumentation. * * Emits metrics for query duration, count, row count, and errors. * Useful for monitoring, alerting, and performance analysis. * * @param options - Configuration options for the metrics middleware * @returns A middleware function that emits query metrics * * @example Basic usage * ```typescript * const metrics: QueryMetric[] = [] * const sql = postgres() * .use(metricsMiddleware({ * onMetric: (m) => metrics.push(m), * tags: { service: 'api', env: 'production' } * })) * ``` * * @example With histogram buckets * ```typescript * const sql = postgres() * .use(metricsMiddleware({ * onMetric: (m) => prometheus.observe(m.type, m.value, m.tags), * histogram: true, * buckets: [5, 10, 25, 50, 100, 250, 500, 1000] * })) * ``` */ export function metricsMiddleware(options: MetricsMiddlewareOptions): QueryMiddleware { const { onMetric, tags = {}, histogram = false, buckets = DEFAULT_HISTOGRAM_BUCKETS, } = options return async (request, next) => { const response = await next() const baseMetric = { queryId: request.queryId, query: request.sql.slice(0, METRICS_QUERY_TRUNCATE_LENGTH), success: response.success, command: response.result.command, timestamp: Date.now(), tags, } // Duration metric const durationMetric: QueryMetric = { ...baseMetric, type: 'query_duration', value: response.durationMs, } if (histogram) { durationMetric.bucket = findBucket(response.durationMs, buckets) } onMetric(durationMetric) // Count metric onMetric({ ...baseMetric, type: 'query_count', value: 1, }) // Row count metric onMetric({ ...baseMetric, type: 'query_rows', value: response.result.rowCount, }) // Error metric (if failed) if (!response.success) { onMetric({ ...baseMetric, type: 'query_error', value: 1, }) } return response } } /** * Find the histogram bucket for a duration value. * * Returns a bucket label in the format `le_` for Prometheus-style * histogram metrics. Values exceeding all buckets return `le_inf`. * * @internal */ function findBucket(value: number, buckets: number[]): string { for (const bucket of buckets) { if (value <= bucket) { return `le_${bucket}` } } return 'le_inf' } /** * Options for configuring the rate limiting middleware. */ export interface RateLimitMiddlewareOptions { /** Maximum queries allowed per window. Required. */ maxQueries: number /** Window size in milliseconds. Required. */ windowMs: number /** Function to determine the rate limit key (e.g., by user). Defaults to 'default'. */ keyFn?: (request: QueryRequest) => string /** Callback invoked when rate limit is exceeded. */ onRateLimited?: (request: QueryRequest, remainingSeconds: number) => void } /** * Create a rate limiting middleware to prevent query abuse. * * Uses a sliding window algorithm to track query counts per key. * When the limit is exceeded, returns an error response with retry information. * * @param options - Configuration options for the rate limiting middleware * @returns A middleware function that enforces rate limits * * @example Basic usage * ```typescript * const sql = postgres() * .use(rateLimitMiddleware({ * maxQueries: 100, * windowMs: 60000, // 1 minute * })) * ``` * * @example Per-user rate limiting * ```typescript * const sql = postgres() * .use(rateLimitMiddleware({ * maxQueries: 100, * windowMs: 60000, * keyFn: (req) => req.context?.userId as string || 'anonymous', * onRateLimited: (req, remaining) => { * console.warn(`Rate limited user ${req.context?.userId}, retry in ${remaining}s`) * } * })) * ``` */ export function rateLimitMiddleware(options: RateLimitMiddlewareOptions): QueryMiddleware { const { maxQueries, windowMs, keyFn = () => 'default', onRateLimited } = options const windows = new Map() return async (request, next) => { const key = keyFn(request) const now = Date.now() let window = windows.get(key) if (!window || now >= window.resetAt) { window = { count: 0, resetAt: now + windowMs } windows.set(key, window) } window.count++ if (window.count > maxQueries) { const remainingSeconds = Math.ceil((window.resetAt - now) / MS_PER_SECOND) onRateLimited?.(request, remainingSeconds) return { result: { rows: [], rowCount: 0, fields: [], command: 'ERROR', }, durationMs: 0, success: false, error: new Error(`Rate limit exceeded. Try again in ${remainingSeconds} seconds.`), metadata: { rateLimited: true, retryAfter: remainingSeconds }, } } return next() } } /** * Options for configuring the tracing middleware. */ export interface TracingMiddlewareOptions { /** Trace ID: a static string, generator function, or undefined to use context/auto-generate. */ traceId?: string | ((request: QueryRequest) => string) /** Span ID generator function. Defaults to random base-36 string. */ spanId?: () => string /** Callback invoked with trace data after each query. */ onTrace?: (trace: QueryTrace) => void } /** * Query trace data for distributed tracing. * * Compatible with OpenTelemetry and similar tracing systems. */ export interface QueryTrace { /** Unique trace identifier (spans same trace across services) */ traceId: string /** Unique span identifier for this query */ spanId: string /** Parent span ID if this query is part of a larger operation */ parentSpanId?: string | undefined /** Query identifier for correlation with other middleware */ queryId: string /** SQL query string */ sql: string /** Unix timestamp when query started */ startTime: number /** Unix timestamp when query completed */ endTime: number /** Query duration in milliseconds */ durationMs: number /** Whether the query succeeded */ success: boolean /** Error message if query failed */ error?: string | undefined } /** * Create a tracing middleware for distributed tracing. * * Generates trace and span IDs for each query, enabling correlation * across services and query analysis. * * @param options - Configuration options for the tracing middleware * @returns A middleware function that adds trace information * * @example Basic usage * ```typescript * const sql = postgres() * .use(tracingMiddleware({ * onTrace: (trace) => tracer.export(trace) * })) * ``` * * @example With OpenTelemetry * ```typescript * const sql = postgres() * .use(tracingMiddleware({ * traceId: (req) => req.context?.traceId as string, * onTrace: (trace) => { * const span = tracer.startSpan('postgres.query', { * attributes: { 'db.statement': trace.sql } * }) * span.end() * } * })) * ``` */ export function tracingMiddleware(options: TracingMiddlewareOptions = {}): QueryMiddleware { const { traceId: traceIdOption, spanId = () => Math.random().toString(36).slice(QUERY_ID_RANDOM_SLICE_START, SPAN_ID_LENGTH), onTrace, } = options return async (request, next) => { const startTime = Date.now() const currentSpanId = spanId() const traceId = typeof traceIdOption === 'function' ? traceIdOption(request) : traceIdOption || (request.context?.traceId as string) || generateQueryId() const response = await next() const trace: QueryTrace = { traceId, spanId: currentSpanId, parentSpanId: request.context?.spanId as string | undefined, queryId: request.queryId, sql: request.sql, startTime, endTime: Date.now(), durationMs: response.durationMs, success: response.success, error: response.error?.message, } onTrace?.(trace) // Add trace info to response metadata return { ...response, metadata: { ...response.metadata, traceId, spanId: currentSpanId, }, } } } /** * Options for configuring the transformation middleware. */ export interface TransformMiddlewareOptions { /** Transform the SQL query and params before execution. */ transformQuery?: (sql: string, params: unknown[]) => { sql: string; params: unknown[] } /** Transform the result after successful execution. */ transformResult?: (result: QueryResult) => QueryResult } /** * Create a query transformation middleware. * * Allows modifying queries before execution and results after execution. * Useful for query rewriting, result mapping, and data sanitization. * * @param options - Configuration options for the transformation middleware * @returns A middleware function that transforms queries and/or results * * @example Query rewriting * ```typescript * const sql = postgres() * .use(transformMiddleware({ * transformQuery: (query, params) => ({ * sql: query.replace('SELECT *', 'SELECT id, name, email'), * params * }) * })) * ``` * * @example Result transformation * ```typescript * const sql = postgres() * .use(transformMiddleware({ * transformResult: (result) => ({ * ...result, * rows: result.rows.map(row => ({ * ...row, * createdAt: new Date(row.created_at) * })) * }) * })) * ``` */ export function transformMiddleware(options: TransformMiddlewareOptions): QueryMiddleware { const { transformQuery, transformResult } = options return async (request, next) => { // Transform the query if transformer is provided if (transformQuery) { const transformed = transformQuery(request.sql, request.params) request.sql = transformed.sql request.params = transformed.params } const response = await next() // Transform the result if transformer is provided if (transformResult && response.success) { return { ...response, result: transformResult(response.result), } } return response } } /** * Options for configuring the retry middleware. */ export interface RetryMiddlewareOptions { /** Maximum number of retry attempts. Defaults to 3. */ maxRetries?: number /** Base delay between retries in milliseconds. Defaults to 100. */ baseDelayMs?: number /** Maximum delay between retries in milliseconds. Defaults to 5000. */ maxDelayMs?: number /** Whether to use exponential backoff. Defaults to true. */ exponentialBackoff?: boolean /** Function to determine if an error is retryable. Defaults to always true. */ isRetryable?: (error: Error) => boolean /** Callback invoked before each retry attempt. */ onRetry?: (attempt: number, error: Error, request: QueryRequest) => void } /** * Create a retry middleware with exponential backoff. * * Automatically retries failed queries based on configurable rules. * Supports exponential backoff to prevent overwhelming the database. * * @param options - Configuration options for the retry middleware * @returns A middleware function that retries failed queries * * @example Basic usage * ```typescript * const sql = postgres() * .use(retryMiddleware({ * maxRetries: 3, * baseDelayMs: 100, * })) * ``` * * @example Selective retry * ```typescript * const sql = postgres() * .use(retryMiddleware({ * maxRetries: 5, * baseDelayMs: 200, * isRetryable: (error) => { * // Only retry connection errors * return error.message.includes('connection') || * error.message.includes('timeout') * }, * onRetry: (attempt, error) => { * console.warn(`Retry attempt ${attempt}: ${error.message}`) * } * })) * ``` */ export function retryMiddleware(options: RetryMiddlewareOptions = {}): QueryMiddleware { const { maxRetries = DEFAULT_MAX_RETRIES, baseDelayMs = DEFAULT_RETRY_BASE_DELAY_MS, maxDelayMs = DEFAULT_RETRY_MAX_DELAY_MS, exponentialBackoff = true, isRetryable = () => true, onRetry, } = options return async (request, next) => { let lastResponse: QueryResponse let attempt = 0 while (attempt <= maxRetries) { lastResponse = await next() if (lastResponse.success) { return lastResponse } if (!lastResponse.error || !isRetryable(lastResponse.error)) { return lastResponse } if (attempt < maxRetries) { const delay = exponentialBackoff ? Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs) : baseDelayMs onRetry?.(attempt + 1, lastResponse.error, request) await new Promise((resolve) => setTimeout(resolve, delay)) } attempt++ } return lastResponse! } } // ============================================================================ // Composition Utilities // ============================================================================ /** * Combine multiple middlewares into a single middleware. * * Useful for creating reusable middleware bundles or organizing * middleware by concern. * * @param middlewares - Middleware functions to compose * @returns A single middleware that executes all provided middlewares in order * * @example Creating a middleware bundle * ```typescript * const observabilityMiddleware = composeMiddleware( * loggingMiddleware({ level: 'info' }), * metricsMiddleware({ onMetric: console.log }), * tracingMiddleware({ onTrace: tracer.export }) * ) * * const sql = postgres().use(observabilityMiddleware) * ``` * * @example Conditional composition * ```typescript * const prodMiddleware = composeMiddleware( * metricsMiddleware({ onMetric: prometheus.record }), * rateLimitMiddleware({ maxQueries: 1000, windowMs: 60000 }) * ) * * const devMiddleware = composeMiddleware( * loggingMiddleware({ level: 'debug', logParams: true }) * ) * * const sql = postgres() * .use(process.env.NODE_ENV === 'production' ? prodMiddleware : devMiddleware) * ``` */ export function composeMiddleware(...middlewares: QueryMiddleware[]): QueryMiddleware { return async (request, next) => { const chain = middlewares.reduceRight( (nextFn, middleware) => () => middleware(request, nextFn), next ) return chain() } }