mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-02 21:19:05 +00:00
176 lines
4.9 KiB
TypeScript
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);
|
|
}
|