/** * Database initialization and migration. * * Handles: * - Creating/opening SQLite database * - Running DDL schema setup * - Runtime ALTER TABLE migrations (safe to re-run) * - Seeding the admin user from ADMIN_EMAIL + ADMIN_PASSWORD env vars * - Migrating legacy JSON files (one-time) */ import BetterSqlite3 from 'better-sqlite3'; import { existsSync, readFileSync, renameSync } from 'fs'; import { randomUUID, randomBytes, scryptSync } from 'crypto'; import { DDL, RUNTIME_MIGRATIONS, HOLDINGS_QUERIES, USER_QUERIES } from './queries.constant.js'; export type Db = BetterSqlite3.Database; // ── Types ──────────────────────────────────────────────────────────────────── interface LegacyHolding { ticker: string; shares: number; costBasis: number; type: string; source: string; } interface LegacyCall { id?: string; title: string; quarter: string; date: string; thesis: string; tickers: string[]; snapshot: Record; createdAt: string; } // ── Main Export ────────────────────────────────────────────────────────────── /** * Initialize and open the SQLite database. * * Steps: * 1. Create/open database file * 2. Enable WAL mode + foreign keys * 3. Run DDL (create tables if missing) * 4. Run runtime ALTER TABLE migrations (adds user_id etc. to existing DBs) * 5. Seed admin user from env vars * 6. Migrate legacy JSON files (one-time) */ export function createDb(path = './market-screener.db'): Db { const db = new BetterSqlite3(path); db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = OFF'); // off during schema changes, back on after db.exec(DDL); runRuntimeMigrations(db); db.pragma('foreign_keys = ON'); seedAdmin(db); // Upgrade any legacy 'viewer' accounts to 'trader' so all users have full access db.prepare("UPDATE users SET role = 'trader' WHERE role = 'viewer'").run(); migrateJson(db); return db; } // ── Runtime migrations ─────────────────────────────────────────────────────── /** * Run ALTER TABLE statements that bring existing DBs up to the current schema. * Each statement is wrapped in try/catch — SQLite throws if column already exists. */ function runRuntimeMigrations(db: Db): void { for (const sql of RUNTIME_MIGRATIONS) { try { db.exec(sql); } catch { // Column already exists — safe to ignore } } } // ── Admin seeding ──────────────────────────────────────────────────────────── /** * Create the admin account on first boot if ADMIN_EMAIL + ADMIN_PASSWORD are set. * No-ops if the admin already exists. */ function seedAdmin(db: Db): void { const email = process.env.ADMIN_EMAIL; const password = process.env.ADMIN_PASSWORD; if (!email || !password) return; const existing = db.prepare(USER_QUERIES.SELECT_BY_EMAIL).get(email); if (existing) { // Migrate any ownerless holdings from before auth was added to this admin const adminRow = existing as { id: string }; db.prepare(HOLDINGS_QUERIES.MIGRATE_TO_ADMIN).run(adminRow.id); return; } // Hash password using the same scrypt approach as AuthService // (inline here to avoid circular imports with the auth domain) const salt = randomBytes(16).toString('hex'); const hash = scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 }).toString('hex'); const passwordHash = `${salt}:${hash}`; const id = randomUUID(); const createdAt = new Date().toISOString(); db.prepare(USER_QUERIES.INSERT).run(id, email, passwordHash, 'admin', createdAt); // Migrate any ownerless holdings to this new admin db.prepare(HOLDINGS_QUERIES.MIGRATE_TO_ADMIN).run(id); } // ── JSON migration helpers ─────────────────────────────────────────────────── function migrateJson(db: Db): void { migratePortfolio(db); migrateCalls(db); } function migratePortfolio(db: Db): void { const src = './portfolio.json'; if (!existsSync(src)) return; // Need admin id to assign migrated holdings const adminEmail = process.env.ADMIN_EMAIL; if (!adminEmail) return; const adminRow = db.prepare(USER_QUERIES.SELECT_BY_EMAIL).get(adminEmail) as | { id: string } | undefined; if (!adminRow) return; try { const { holdings } = JSON.parse(readFileSync(src, 'utf8')) as { holdings: LegacyHolding[]; }; const insertAll = db.transaction((rows: LegacyHolding[]) => { const stmt = db.prepare(` INSERT OR IGNORE INTO holdings (ticker, shares, cost_basis, type, source, user_id) VALUES (?, ?, ?, ?, ?, ?) `); for (const h of rows) { stmt.run( h.ticker.toUpperCase(), h.shares, h.costBasis ?? 0, h.type ?? 'stock', h.source ?? 'Manual', adminRow.id, ); } }); insertAll(holdings); renameSync(src, `${src}.migrated`); } catch { // Non-fatal } } function migrateCalls(db: Db): void { const src = './market-calls.json'; if (!existsSync(src)) return; try { const { calls } = JSON.parse(readFileSync(src, 'utf8')) as { calls: LegacyCall[] }; const insertAll = db.transaction((rows: LegacyCall[]) => { const stmt = db.prepare(` INSERT OR IGNORE INTO market_calls (id, title, quarter, date, thesis, tickers, snapshot, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `); for (const c of rows) { stmt.run( c.id ?? randomUUID(), c.title, c.quarter, c.date, c.thesis, JSON.stringify(c.tickers ?? []), JSON.stringify(c.snapshot ?? {}), c.createdAt, ); } }); insertAll(calls); renameSync(src, `${src}.migrated`); } catch { // Non-fatal } }