Files
dockhand/lib/server/db/connection.ts
Jarek Krochmalski 62e3c6439e Initial commit
2025-12-28 21:16:03 +01:00

176 lines
4.9 KiB
TypeScript

/**
* Database Connection Module
*
* Provides a unified database connection using Bun's SQL API.
* Supports both SQLite (default) and PostgreSQL (via DATABASE_URL).
*/
import { SQL } from 'bun';
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
// Database configuration
const databaseUrl = process.env.DATABASE_URL;
const dataDir = process.env.DATA_DIR || './data';
// Detect database type
export const isPostgres = databaseUrl && (databaseUrl.startsWith('postgres://') || databaseUrl.startsWith('postgresql://'));
export const isSqlite = !isPostgres;
/**
* Read a SQL file from the appropriate sql directory.
*/
function readSql(filename: string): string {
const sqlDir = isPostgres ? 'postgres' : 'sqlite';
return readFileSync(join(__dirname, sqlDir, 'sql', filename), 'utf-8');
}
/**
* Validate PostgreSQL connection URL format.
*/
function validatePostgresUrl(url: string): void {
try {
const parsed = new URL(url);
if (parsed.protocol !== 'postgres:' && parsed.protocol !== 'postgresql:') {
exitWithError(`Invalid protocol "${parsed.protocol}". Expected "postgres:" or "postgresql:"`, url);
}
if (!parsed.hostname) {
exitWithError('Missing hostname in DATABASE_URL', url);
}
if (!parsed.pathname || parsed.pathname === '/') {
exitWithError('Missing database name in DATABASE_URL', url);
}
} catch {
exitWithError('Invalid URL format', url);
}
}
/**
* Print connection error and exit.
*/
function exitWithError(error: string, url?: string): never {
console.error('\n' + '='.repeat(70));
console.error('DATABASE CONNECTION ERROR');
console.error('='.repeat(70));
console.error(`\nError: ${error}`);
if (url) {
try {
const parsed = new URL(url);
if (parsed.password) parsed.password = '***';
console.error(`\nProvided URL: ${parsed.toString()}`);
} catch {
console.error(`\nProvided URL: ${url.replace(/:[^:@]+@/, ':***@')}`);
}
}
console.error('\n' + '-'.repeat(70));
console.error('DATABASE_URL format:');
console.error('-'.repeat(70));
console.error('\n postgres://USER:PASSWORD@HOST:PORT/DATABASE');
console.error('\nExamples:');
console.error(' postgres://dockhand:secret@localhost:5432/dockhand');
console.error(' postgres://admin:p4ssw0rd@192.168.1.100:5432/dockhand');
console.error(' postgresql://user:pass@db.example.com/mydb?sslmode=require');
console.error('\n' + '-'.repeat(70));
console.error('To use SQLite instead, remove the DATABASE_URL environment variable.');
console.error('='.repeat(70) + '\n');
process.exit(1);
}
/**
* Create the database connection.
*/
function createConnection(): SQL {
if (isPostgres) {
// Validate PostgreSQL URL
validatePostgresUrl(databaseUrl!);
console.log('Connecting to PostgreSQL database...');
try {
const sql = new SQL(databaseUrl!);
return sql;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
exitWithError(`Failed to connect to PostgreSQL: ${message}`, databaseUrl);
}
} else {
// SQLite: Ensure db directory exists
const dbDir = join(dataDir, 'db');
if (!existsSync(dbDir)) {
mkdirSync(dbDir, { recursive: true });
}
const dbPath = join(dbDir, 'dockhand.db');
console.log(`Using SQLite database at: ${dbPath}`);
const sql = new SQL(`sqlite://${dbPath}`);
// Enable WAL mode for better performance
sql.run('PRAGMA journal_mode = WAL');
return sql;
}
}
/**
* Initialize the database schema.
*/
async function initializeSchema(sql: SQL): Promise<void> {
try {
// Create schema (tables)
await sql.run(readSql('schema.sql'));
// Create indexes
await sql.run(readSql('indexes.sql'));
// Insert seed data
await sql.run(readSql('seed.sql'));
// Update system roles
await sql.run(readSql('system-roles.sql'));
// Run maintenance
await sql.run(readSql('maintenance.sql'));
console.log(`Database initialized successfully (${isPostgres ? 'PostgreSQL' : 'SQLite'})`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error('Failed to initialize database schema:', message);
throw error;
}
}
// Create and export the database connection
export const sql = createConnection();
// Initialize schema (runs async but we handle it)
initializeSchema(sql).catch((error) => {
console.error('Database initialization failed:', error);
process.exit(1);
});
/**
* Helper to convert SQLite integer booleans to JS booleans.
* PostgreSQL returns actual booleans, SQLite returns 0/1.
*/
export function toBool(value: any): boolean {
if (typeof value === 'boolean') return value;
return Boolean(value);
}
/**
* Helper to convert JS boolean to database value.
* PostgreSQL uses boolean, SQLite uses 0/1.
*/
export function fromBool(value: boolean): boolean | number {
return isPostgres ? value : (value ? 1 : 0);
}