import { mkdirSync } from "node:fs"; import { dirname } from "node:path"; import Database from "better-sqlite3"; export type CompanionDatabase = Database.Database; export function openDatabase(path: string): CompanionDatabase { if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true, mode: 0o750 }); const database = new Database(path); database.pragma("journal_mode = WAL"); database.pragma("foreign_keys = ON"); database.pragma("busy_timeout = 5000"); migrate(database); return database; } function migrate(database: CompanionDatabase): void { database.exec(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE COLLATE NOCASE, password_hash TEXT NOT NULL, role TEXT NOT NULL CHECK (role = 'admin'), created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS sessions ( id_hash TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS sessions_expiry_idx ON sessions(expires_at); CREATE TABLE IF NOT EXISTS audit_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, actor_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, action TEXT NOT NULL, result TEXT NOT NULL CHECK (result IN ('success', 'failure')), ip_address TEXT, details_json TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS job_runs ( id TEXT PRIMARY KEY, job_id TEXT NOT NULL, actor_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, status TEXT NOT NULL CHECK (status IN ( 'queued', 'running', 'succeeded', 'failed', 'timed_out', 'interrupted' )), input_json TEXT NOT NULL DEFAULT '{}', output_json TEXT, error_code TEXT, created_at TEXT NOT NULL, started_at TEXT, finished_at TEXT ); CREATE INDEX IF NOT EXISTS job_runs_created_idx ON job_runs(created_at DESC); CREATE TABLE IF NOT EXISTS adb_shortcuts ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, target_type TEXT NOT NULL CHECK (target_type IN ('component', 'package')), target_value TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE(target_type, target_value) ); CREATE TABLE IF NOT EXISTS mobile_pairing_codes ( id INTEGER PRIMARY KEY CHECK (id = 1), code_hash TEXT NOT NULL, expires_at TEXT NOT NULL, created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS mobile_devices ( id TEXT PRIMARY KEY, name TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL, last_seen_at TEXT NOT NULL, revoked_at TEXT ); CREATE TABLE IF NOT EXISTS mobile_phone_state ( device_id TEXT PRIMARY KEY REFERENCES mobile_devices(id) ON DELETE CASCADE, received_at TEXT NOT NULL, captured_at TEXT NOT NULL, latitude REAL NOT NULL, longitude REAL NOT NULL, accuracy_meters REAL NOT NULL, altitude_meters REAL, speed_kph REAL, bearing_degrees REAL, battery_percent REAL, network_type TEXT ); CREATE TABLE IF NOT EXISTS car_telemetry_samples ( recorded_at INTEGER PRIMARY KEY, speed_kph REAL, wheel_angle_degrees REAL, battery_percent REAL, range_km REAL, battery_voltage REAL, total_consumption_kwh REAL, charging_status INTEGER, charging_power_kw REAL ) WITHOUT ROWID; CREATE TABLE IF NOT EXISTS car_telemetry_hourly ( bucket_start INTEGER PRIMARY KEY, sample_count INTEGER NOT NULL, speed_sum REAL NOT NULL, speed_count INTEGER NOT NULL, speed_max REAL, soc_sum REAL NOT NULL, soc_count INTEGER NOT NULL, soc_min REAL, soc_max REAL, range_sum REAL NOT NULL, range_count INTEGER NOT NULL, range_max REAL, charge_power_sum REAL NOT NULL, charge_power_count INTEGER NOT NULL, charge_power_max REAL, distance_km REAL NOT NULL, driving_seconds REAL NOT NULL, charging_seconds REAL NOT NULL ) WITHOUT ROWID; CREATE TABLE IF NOT EXISTS car_telemetry_stats ( id INTEGER PRIMARY KEY CHECK (id = 1), first_sample_at INTEGER, last_sample_at INTEGER, sample_count INTEGER NOT NULL DEFAULT 0, peak_speed_kph REAL, peak_speed_at INTEGER, peak_charging_power_kw REAL, peak_charging_power_at INTEGER, max_observed_range_km REAL, max_observed_range_at INTEGER, tracked_distance_km REAL NOT NULL DEFAULT 0, driving_seconds REAL NOT NULL DEFAULT 0, charging_seconds REAL NOT NULL DEFAULT 0, charge_sessions INTEGER NOT NULL DEFAULT 0, soc_used_percent REAL NOT NULL DEFAULT 0 ); INSERT OR IGNORE INTO car_telemetry_stats (id) VALUES (1); `); database .prepare( `UPDATE job_runs SET status = 'interrupted', error_code = 'PROCESS_RESTARTED', finished_at = ? WHERE status IN ('queued', 'running')` ) .run(new Date().toISOString()); }