import { join, dirname } from 'node:path' import { mkdirSync, existsSync } from 'node:fs' import { createRequire } from 'node:module' import { promisify } from 'node:util' import { bootstrapAdmin } from './bootstrap.js' const require = createRequire(import.meta.url) const sqlite3 = require('sqlite3') const SCHEMA_VERSION = 3 const DB_BUSY_TIMEOUT_MS = 5000 let dbInstance = null let testPath = null const SCHEMA = { schema_version: 'CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)', users: `CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, identifier TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'member', created_at TEXT NOT NULL )`, users_v2: `CREATE TABLE users_new ( id TEXT PRIMARY KEY, identifier TEXT UNIQUE NOT NULL, password_hash TEXT, role TEXT NOT NULL DEFAULT 'member', created_at TEXT NOT NULL, auth_provider TEXT NOT NULL DEFAULT 'local', oidc_issuer TEXT, oidc_sub TEXT )`, users_oidc_index: `CREATE UNIQUE INDEX IF NOT EXISTS users_oidc_unique ON users(oidc_issuer, oidc_sub) WHERE oidc_issuer IS NOT NULL AND oidc_sub IS NOT NULL`, sessions: `CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, created_at TEXT NOT NULL, expires_at TEXT NOT NULL, FOREIGN KEY(user_id) REFERENCES users(id) )`, pois: `CREATE TABLE IF NOT EXISTS pois ( id TEXT PRIMARY KEY, lat REAL NOT NULL, lng REAL NOT NULL, label TEXT NOT NULL DEFAULT '', icon_type TEXT NOT NULL DEFAULT 'pin' )`, devices: `CREATE TABLE IF NOT EXISTS devices ( id TEXT PRIMARY KEY, name TEXT NOT NULL DEFAULT '', device_type TEXT NOT NULL, vendor TEXT, lat REAL NOT NULL, lng REAL NOT NULL, stream_url TEXT NOT NULL DEFAULT '', source_type TEXT NOT NULL DEFAULT 'mjpeg', config TEXT )`, } const getDbPath = () => { if (testPath) return testPath if (process.env.DB_PATH) return process.env.DB_PATH const dir = join(process.cwd(), 'data') if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) return join(dir, 'kestrelos.db') } export const getAvatarsDir = () => { const dir = join(dirname(getDbPath()), 'avatars') if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) return dir } const getSchemaVersion = async (get) => { try { const row = await get('SELECT version FROM schema_version ORDER BY version DESC LIMIT 1') return row?.version || 0 } catch { return 0 } } const setSchemaVersion = (run, version) => run('INSERT OR REPLACE INTO schema_version (version) VALUES (?)', [version]) const migrateToV2 = async (run, all) => { const info = await all('PRAGMA table_info(users)') if (info.some(c => c.name === 'auth_provider')) return await run('BEGIN TRANSACTION') try { await run(SCHEMA.users_v2) await run('INSERT INTO users_new (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) SELECT id, identifier, password_hash, role, created_at, ?, ?, ? FROM users', ['local', null, null]) await run('DROP TABLE users') await run('ALTER TABLE users_new RENAME TO users') await run(SCHEMA.users_oidc_index) await run('COMMIT') } catch (error) { await run('ROLLBACK').catch(() => {}) throw error } } const migrateToV3 = async (run, all) => { const info = await all('PRAGMA table_info(users)') if (info.some(c => c.name === 'avatar_path')) return await run('ALTER TABLE users ADD COLUMN avatar_path TEXT') } const runMigrations = async (run, all, get) => { const version = await getSchemaVersion(get) if (version >= SCHEMA_VERSION) return if (version < 2) { await migrateToV2(run, all) await setSchemaVersion(run, 2) } if (version < 3) { await migrateToV3(run, all) await setSchemaVersion(run, 3) } } const initDb = async (db, run, all, get) => { try { await run('PRAGMA journal_mode = WAL') } catch { // WAL not supported (e.g., network filesystem) } db.configure('busyTimeout', DB_BUSY_TIMEOUT_MS) await run(SCHEMA.schema_version) await run(SCHEMA.users) await runMigrations(run, all, get) await run(SCHEMA.sessions) await run(SCHEMA.pois) await run(SCHEMA.devices) if (!testPath) await bootstrapAdmin(run, get) } export async function getDb() { if (dbInstance) return dbInstance const db = new sqlite3.Database(getDbPath(), (err) => { if (err) { console.error('[db] Failed to open database:', err.message) throw err } }) const run = promisify(db.run.bind(db)) const all = promisify(db.all.bind(db)) const get = promisify(db.get.bind(db)) try { await initDb(db, run, all, get) } catch (error) { db.close() console.error('[db] Database initialization failed:', error.message) throw error } dbInstance = { db, run, all, get } return dbInstance } export function closeDb() { if (!dbInstance) return try { dbInstance.db.close((err) => { if (err) console.error('[db] Error closing database:', err.message) }) } catch (error) { console.error('[db] Error closing database:', error.message) } dbInstance = null } export function setDbPathForTest(path) { testPath = path || null closeDb() }