This commit is contained in:
jarek committed 2026-03-02 07:59:58 +01:00
1 parent 4b430340db
commit 9c451aedf9
120 files changed
+7777 -6046

No files matched your search

-176
View File
@@ -1,176 +0,0 @@
/**
* 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) => {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error('[DB] Database initialization failed:', errorMsg);
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);
}
+11 -11
View File
@@ -208,11 +208,11 @@ function readMigrationJournal(migrationsFolder: string): MigrationJournal | null
async function getAppliedMigrations(client: any, postgres: boolean): Promise<AppliedMigration[]> {
try {
if (postgres) {
// PostgreSQL using Bun.sql - note the 'drizzle' schema
// PostgreSQL using postgres-js - note the 'drizzle' schema
const result = await client`SELECT hash, created_at FROM drizzle.__drizzle_migrations ORDER BY id`;
return result.map((r: any) => ({ hash: r.hash, createdAt: r.created_at }));
} else {
// SQLite using bun:sqlite
// SQLite using better-sqlite3
const stmt = client.prepare('SELECT hash, created_at FROM __drizzle_migrations ORDER BY id');
return stmt.all().map((r: any) => ({ hash: r.hash, createdAt: r.created_at }));
}
@@ -484,10 +484,10 @@ async function runMigrations(
// Run migrations
try {
if (postgres) {
const { migrate } = await import('drizzle-orm/bun-sql/migrator');
const { migrate } = await import('drizzle-orm/postgres-js/migrator');
await migrate(database, { migrationsFolder });
} else {
const { migrate } = await import('drizzle-orm/bun-sqlite/migrator');
const { migrate } = await import('drizzle-orm/better-sqlite3/migrator');
await migrate(database, { migrationsFolder });
}
@@ -605,7 +605,7 @@ async function initializeDatabase() {
logHeader('DATABASE INITIALIZATION');
if (isPostgres) {
// PostgreSQL via postgres-js (more stable than bun:sql for concurrent queries)
// PostgreSQL via postgres-js
validatePostgresUrl(config.databaseUrl!);
logInfo(`Database: PostgreSQL`);
@@ -634,7 +634,7 @@ async function initializeDatabase() {
handleMigrationFailure(result.error, true);
}
} else {
// SQLite via bun:sqlite
// SQLite via better-sqlite3
const dbDir = join(config.dataDir, 'db');
if (!existsSync(dbDir)) {
mkdirSync(dbDir, { recursive: true });
@@ -645,8 +645,8 @@ async function initializeDatabase() {
logInfo(`Database: SQLite`);
logInfo(`Path: ${dbPath}`);
const { drizzle } = await import('drizzle-orm/bun-sqlite');
const { Database } = await import('bun:sqlite');
const { drizzle } = await import('drizzle-orm/better-sqlite3');
const Database = (await import('better-sqlite3')).default;
// Import SQLite schema
schema = await import('./schema/index.js');
@@ -655,11 +655,11 @@ async function initializeDatabase() {
rawClient = new Database(dbPath);
// Enable WAL mode for better performance and concurrency
rawClient.exec('PRAGMA journal_mode = WAL');
rawClient.pragma('journal_mode = WAL');
// Synchronous NORMAL is a good balance between safety and speed
rawClient.exec('PRAGMA synchronous = NORMAL');
rawClient.pragma('synchronous = NORMAL');
// Increase busy timeout to handle concurrent access (5 seconds)
rawClient.exec('PRAGMA busy_timeout = 5000');
rawClient.pragma('busy_timeout = 5000');
db = drizzle({ client: rawClient, schema });
logSuccess('SQLite database opened');