Files
dashboard/backend/database/db.js
T

153 lines
4.5 KiB
JavaScript

const { Pool } = require('pg');
const fs = require('fs');
const path = require('path');
require('dotenv').config();
const SCHEMA_PATH = path.join(__dirname, 'schema.sql');
// Database configuration
const config = {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432'),
user: process.env.DB_USER || 'dashboard_user',
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME || 'dashboard_db',
// Connection pool settings
min: parseInt(process.env.DB_POOL_MIN || '2'),
max: parseInt(process.env.DB_POOL_MAX || '10'),
// Connection timeout
connectionTimeoutMillis: 5000,
// Idle timeout (30 seconds)
idleTimeoutMillis: 30000,
// SSL configuration
ssl:
process.env.DB_SSL === 'true'
? {
rejectUnauthorized: false,
}
: false,
};
// Create connection pool
const pool = new Pool(config);
// Handle pool errors
pool.on('error', (err) => {
console.error('Unexpected error on idle PostgreSQL client', err);
process.exit(-1);
});
/**
* Arbitrary but fixed key identifying the "applying schema.sql" critical
* section. Any value works as long as every process uses the same one.
*/
const SCHEMA_LOCK_KEY = 918273645;
// Initialize database with schema
async function initializeDatabase() {
const client = await pool.connect();
let locked = false;
try {
// Applying the schema is DDL, so it takes AccessExclusiveLock on each table
// it touches. This module runs it on import, and several processes import
// it at once (the server plus one worker per test file) — that DDL racing
// against ordinary queries is what surfaced as "deadlock detected" in the
// Mortality suite. A session-level advisory lock serialises just this
// section; it blocks nothing else.
await client.query('SELECT pg_advisory_lock($1)', [SCHEMA_LOCK_KEY]);
locked = true;
const schema = fs.readFileSync(SCHEMA_PATH, 'utf8');
await client.query(schema);
} catch (error) {
console.error('✗ Error initializing database:', error.message);
throw error;
} finally {
if (locked) {
await client
.query('SELECT pg_advisory_unlock($1)', [SCHEMA_LOCK_KEY])
.catch((err) => console.error('Failed to release schema lock:', err.message));
}
client.release();
}
}
// Helper function to convert date to ISO string (YYYY-MM-DD for DATE columns)
function toISODate(date) {
if (!date) return null;
if (date instanceof Date) {
return date.toISOString().split('T')[0];
}
return date;
}
// Helper function to parse ISO date
function parseISODate(dateString) {
if (!dateString) return null;
return new Date(dateString);
}
// Query helper with parameter binding
async function query(text, params) {
// Wait for the schema DDL to finish. Without this, ordinary queries ran while
// `initializeDatabase` still held AccessExclusiveLocks, which Postgres
// resolved by killing one side — "deadlock detected". It also means early
// requests can no longer hit a half-applied schema on startup.
await dbReady;
const start = Date.now();
try {
const res = await pool.query(text, params);
const duration = Date.now() - start;
if (process.env.NODE_ENV === 'development') {
}
return res;
} catch (error) {
console.error('Query error:', error.message);
console.error('Query:', text);
console.error('Params:', params);
throw error;
}
}
// Transaction helper
async function transaction(callback) {
await dbReady; // same reason as in `query`
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await callback(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
// Graceful shutdown
async function closePool() {
await pool.end();
}
// Initialize on first load - store promise so callers can await it
const dbReady = initializeDatabase().catch((error) => {
console.error('Failed to initialize database:', error);
// Under a test runner, exiting the process kills the worker and fails every
// other suite sharing it — Cycle.test.js passes alone but took unrelated
// tests down with it in a full run. Tests that need the database mock it or
// await `dbReady`, so leaving it resolved keeps the failure local.
if (process.env.VITEST || process.env.NODE_ENV === 'test') return;
process.exit(1);
});
module.exports = {
pool,
query,
transaction,
toISODate,
parseISODate,
closePool,
dbReady,
};