/** * Database initialization and migration. * * Handles: * - Creating/opening SQLite database * - Running DDL schema setup * - Migrating legacy JSON files (one-time) */ import BetterSqlite3 from 'better-sqlite3'; import { existsSync, readFileSync, renameSync } from 'fs'; import { randomUUID } from 'crypto'; import { DDL } from './queries.constant'; import { QueryBuilder } from '../utils/QueryBuilder'; 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 (concurrent read safety) * 3. Enable foreign keys * 4. Run DDL (create tables if missing) * 5. Migrate legacy JSON files (one-time) * * @param path Path to database file (default: ./market-screener.db) * @returns Opened database instance (wrap in DatabaseConnection for safe access) */ export function createDb(path = './market-screener.db'): Db { const db = new BetterSqlite3(path); db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = ON'); db.exec(DDL); migrateJson(db); return db; } // ── Migration Helpers ──────────────────────────────────────────────────────── /** * Migrate legacy JSON files to SQLite (one-time, non-fatal). * Called automatically during database initialization. */ function migrateJson(db: Db): void { migratePortfolio(db); migrateCalls(db); } /** * Migrate portfolio.json → holdings table. * If portfolio.json exists, import all holdings and rename to portfolio.json.migrated. * If import fails, leave portfolio.json in place (non-fatal). */ function migratePortfolio(db: Db): void { const src = './portfolio.json'; if (!existsSync(src)) return; try { const { holdings } = JSON.parse(readFileSync(src, 'utf8')) as { holdings: LegacyHolding[]; }; const insertAll = db.transaction((rows: LegacyHolding[]) => { for (const h of rows) { const qb = new QueryBuilder('MIGRATION_QUERIES.HOLDINGS_INSERT_OR_IGNORE', [ h.ticker.toUpperCase(), h.shares, h.costBasis ?? 0, h.type ?? 'stock', h.source ?? 'Manual', ]); db.prepare(qb.sql).run(...qb.queryParams); } }); insertAll(holdings); renameSync(src, `${src}.migrated`); } catch { // Non-fatal: leave portfolio.json in place if migration fails } } /** * Migrate market-calls.json → market_calls table. * If market-calls.json exists, import all calls and rename to market-calls.json.migrated. * If import fails, leave market-calls.json in place (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[]) => { for (const c of rows) { const qb = new QueryBuilder('MIGRATION_QUERIES.MARKET_CALLS_INSERT_OR_IGNORE', [ c.id ?? randomUUID(), c.title, c.quarter, c.date, c.thesis, JSON.stringify(c.tickers ?? []), JSON.stringify(c.snapshot ?? {}), c.createdAt, ]); db.prepare(qb.sql).run(...qb.queryParams); } }); insertAll(calls); renameSync(src, `${src}.migrated`); } catch { // Non-fatal: leave market-calls.json in place if migration fails } }