@t1mmen/srtd
Version:
Supabase Repeatable Template Definitions (srtd): 🪄 Live-reloading SQL templates for Supabase DX. Make your database changes reviewable and migrations maintainable! 🚀
343 lines • 12.7 kB
JavaScript
/**
* DatabaseService - Centralized database connection and SQL execution service
* Handles connection pooling, retry logic, and SQL execution with transaction management
*/
import { EventEmitter } from 'node:events';
import pg from 'pg';
import { getErrorHint } from '../utils/errorHints.js';
import { logger } from '../utils/logger.js';
export var DatabaseErrorType;
(function (DatabaseErrorType) {
DatabaseErrorType["CONNECTION_ERROR"] = "CONNECTION_ERROR";
DatabaseErrorType["SYNTAX_ERROR"] = "SYNTAX_ERROR";
DatabaseErrorType["CONSTRAINT_VIOLATION"] = "CONSTRAINT_VIOLATION";
DatabaseErrorType["TIMEOUT_ERROR"] = "TIMEOUT_ERROR";
DatabaseErrorType["TRANSACTION_ERROR"] = "TRANSACTION_ERROR";
DatabaseErrorType["POOL_EXHAUSTED"] = "POOL_EXHAUSTED";
DatabaseErrorType["UNKNOWN_ERROR"] = "UNKNOWN_ERROR";
})(DatabaseErrorType || (DatabaseErrorType = {}));
export class DatabaseService extends EventEmitter {
config;
pool;
connectionAttempts = 0;
disposed = false;
constructor(config) {
super();
this.config = {
connectionTimeoutMillis: 5000,
maxConnections: 6, // Default maxRetries * 2
idleTimeoutMillis: 2000,
maxUses: 500,
maxRetries: 3,
retryDelayMs: 500,
wrapInTransaction: false,
...config,
};
// Note: No process signal handlers here - cleanup is handled by the owner
// (Orchestrator or command) via the dispose() method and 'await using' pattern.
// This prevents race conditions with multiple handlers.
}
/**
* Categorize database errors for better error handling
*/
categorizeError(error) {
const pgError = error;
const errorCode = pgError?.code;
const errorMessage = pgError?.message || String(error);
const originalError = error instanceof Error ? error : new Error(String(error));
// Compute hint once - reused in all return paths
const hint = getErrorHint(errorCode, errorMessage);
// Connection errors
if (errorCode === 'ECONNREFUSED' || errorCode === 'ENOTFOUND' || errorCode === 'ECONNRESET') {
return {
type: DatabaseErrorType.CONNECTION_ERROR,
message: 'Database connection failed',
originalError,
code: errorCode,
detail: errorMessage,
hint,
};
}
// Pool exhaustion
if (errorMessage.includes('pool is exhausted') || errorMessage.includes('too many clients')) {
return {
type: DatabaseErrorType.POOL_EXHAUSTED,
message: 'Database connection pool exhausted',
originalError,
code: errorCode,
detail: errorMessage,
hint,
};
}
// Timeout errors
if (errorCode === 'ETIMEOUT' || errorMessage.includes('timeout')) {
return {
type: DatabaseErrorType.TIMEOUT_ERROR,
message: 'Database operation timed out',
originalError,
code: errorCode,
detail: errorMessage,
hint,
};
}
// PostgreSQL specific error codes
if (errorCode) {
// Syntax errors (42xxx)
if (errorCode.startsWith('42')) {
return {
type: DatabaseErrorType.SYNTAX_ERROR,
message: 'SQL syntax error',
originalError,
code: errorCode,
detail: errorMessage,
hint,
};
}
// Constraint violations (23xxx)
if (errorCode.startsWith('23')) {
return {
type: DatabaseErrorType.CONSTRAINT_VIOLATION,
message: 'Database constraint violation',
originalError,
code: errorCode,
detail: errorMessage,
hint,
};
}
// Transaction errors (25xxx, 40xxx)
if (errorCode.startsWith('25') || errorCode.startsWith('40')) {
return {
type: DatabaseErrorType.TRANSACTION_ERROR,
message: 'Transaction error',
originalError,
code: errorCode,
detail: errorMessage,
hint,
};
}
}
// Default to unknown error
return {
type: DatabaseErrorType.UNKNOWN_ERROR,
message: errorMessage,
originalError,
code: errorCode,
detail: errorMessage,
hint,
};
}
/**
* Create and configure database connection pool
*/
async createPool() {
// Only create a new pool if one doesn't exist or has been ended
if (!this.pool || this.pool.ended) {
this.pool = new pg.Pool({
connectionString: this.config.connectionString,
connectionTimeoutMillis: this.config.connectionTimeoutMillis,
max: this.config.maxConnections,
idleTimeoutMillis: this.config.idleTimeoutMillis,
maxUses: this.config.maxUses,
});
// Handle pool errors
this.pool.on('error', err => {
const errorMessage = `Unexpected pool error: ${err}`;
logger.error(errorMessage);
this.emit('error', new Error(errorMessage));
});
this.emit('pool:created');
}
return this.pool;
}
/**
* Establish database connection with retry logic
*/
async retryConnection(params) {
const { silent = true } = params || {};
this.connectionAttempts++;
logger.debug(`Connection attempt ${this.connectionAttempts}`);
try {
const currentPool = await this.createPool();
const client = await currentPool.connect();
this.emit('connection:established');
return client;
}
catch (err) {
if (this.connectionAttempts < (this.config.maxRetries ?? 3)) {
if (!silent) {
logger.warn(`Connection failed, retrying in ${this.config.retryDelayMs}ms...`);
}
await new Promise(resolve => setTimeout(resolve, this.config.retryDelayMs));
return this.retryConnection(params);
}
const databaseError = this.categorizeError(err);
const error = new Error(`Database connection failed after ${this.config.maxRetries} attempts: ${databaseError.message}`);
this.emit('connection:failed', { error, databaseError });
throw error;
}
}
/**
* Get a database connection with retry logic
*/
async connect(params) {
this.connectionAttempts = 0;
return await this.retryConnection(params);
}
/**
* Test database connectivity
*/
async testConnection() {
try {
const client = await this.connect({ silent: true });
try {
await client.query('SELECT 1');
return true;
}
finally {
client.release();
}
}
catch {
return false;
}
}
/**
* Execute SQL with optional transaction wrapping and parameterized queries
* Core method for running template content or any SQL
*/
async executeSQL(sql, config) {
const { templateName = 'unknown', useTransaction = this.config.wrapInTransaction, silent = false, parameters = [], isolationLevel, } = config || {};
const client = await this.connect({ silent });
try {
if (useTransaction) {
await client.query('BEGIN');
// Set isolation level if specified
if (isolationLevel) {
await client.query(`SET TRANSACTION ISOLATION LEVEL ${isolationLevel}`);
}
// Use advisory lock for templates to prevent concurrent modifications
if (templateName !== 'unknown') {
const lockKey = Math.abs(Buffer.from(templateName).reduce((acc, byte) => acc + byte, 0));
await client.query(`SELECT pg_advisory_xact_lock(${lockKey}::bigint)`);
}
}
// Execute with or without parameters for security
const result = parameters.length > 0 ? await client.query(sql, parameters) : await client.query(sql);
if (useTransaction) {
await client.query('COMMIT');
}
if (!silent) {
logger.success(`SQL executed successfully for ${templateName}`);
}
this.emit('sql:success', { templateName, rowCount: result.rowCount });
return {
success: true,
rows: result.rows,
rowCount: result.rowCount ?? 0,
data: result.rows,
};
}
catch (error) {
if (useTransaction) {
try {
await client.query('ROLLBACK');
}
catch (rollbackError) {
logger.error(`Rollback failed: ${rollbackError}`);
}
}
const databaseError = this.categorizeError(error);
const errorMessage = databaseError.message;
if (!silent) {
logger.error(`SQL execution failed for ${templateName}: ${errorMessage} (${databaseError.type})`);
}
this.emit('sql:error', { templateName, error: errorMessage, errorType: databaseError.type });
return {
success: false,
error: errorMessage,
databaseError,
};
}
finally {
client.release();
}
}
/**
* Execute SQL and return migration-compatible result
* Used by migration/template application logic
*/
async executeMigration(content, templateName, silent = false) {
const result = await this.executeSQL(content, {
templateName,
useTransaction: true,
silent,
isolationLevel: 'READ COMMITTED', // Safe default for migrations
});
if (result.success) {
return true;
}
return {
file: templateName,
error: result.error || 'Unknown error',
templateName,
hint: result.databaseError?.hint,
};
}
/**
* Get connection pool statistics
*/
getConnectionStats() {
if (!this.pool)
return null;
return {
total: this.pool.totalCount,
idle: this.pool.idleCount,
active: this.pool.totalCount - this.pool.idleCount,
};
}
/**
* Gracefully close all database connections
*/
async dispose() {
if (this.disposed)
return;
this.disposed = true;
if (this.pool && !this.pool.ended) {
try {
// Suppress errors during shutdown
this.pool.on('error', () => {
// Empty handler on purpose
});
// End the pool with a timeout
const endPromise = this.pool.end();
const timeoutPromise = new Promise(resolve => {
setTimeout(() => {
logger.debug('Pool end timed out, proceeding anyway');
resolve();
}, 1000);
});
await Promise.race([endPromise, timeoutPromise]);
this.emit('pool:closed');
}
catch (e) {
logger.error(`Pool end error: ${e}`);
// Don't re-throw disposal errors, just log them
}
finally {
this.pool = undefined; // Important: clear the pool reference
}
}
// Remove all listeners
this.removeAllListeners();
}
/**
* Create DatabaseService from CLI config
*/
static fromConfig(config) {
return new DatabaseService({
connectionString: config.pgConnection,
wrapInTransaction: config.wrapInTransaction,
});
}
}
//# sourceMappingURL=DatabaseService.js.map