/** * SQLite database initialisation. * * Call createDb() once in server/app.ts and pass the instance to repositories. * Uses WAL journal mode for safe concurrent reads alongside the single writer. * * Migration: if the legacy JSON files (portfolio.json / market-calls.json) exist * they are imported once into SQLite, then renamed to *.json.migrated so the * import never runs again. * * SECURITY: * - All queries use parameterized statements (QueryBuilder + DatabaseConnection) * - No SQL injection possible via table/column/parameter names * - Audit trail tracks all mutations for compliance * - Statement caching improves performance */ import BetterSqlite3 from 'better-sqlite3'; import { existsSync, readFileSync, renameSync } from 'fs'; import { randomUUID } from 'crypto'; import { DatabaseConnection } from './DatabaseConnection.js'; import { QueryBuilder } from './QueryBuilder.js'; import { QueryAudit } from './QueryAudit.js'; export type Db = BetterSqlite3.Database; export { DatabaseConnection, QueryBuilder, QueryAudit }; const DDL = ` CREATE TABLE IF NOT EXISTS holdings ( ticker TEXT PRIMARY KEY, shares REAL NOT NULL, cost_basis REAL NOT NULL DEFAULT 0, type TEXT NOT NULL DEFAULT 'stock', source TEXT NOT NULL DEFAULT 'Manual' ); CREATE TABLE IF NOT EXISTS market_calls ( id TEXT PRIMARY KEY, title TEXT NOT NULL, quarter TEXT NOT NULL, date TEXT NOT NULL, thesis TEXT NOT NULL, tickers TEXT NOT NULL, -- JSON array snapshot TEXT NOT NULL, -- JSON object created_at TEXT NOT NULL ); `; 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; } // ── One-time JSON → SQLite migration ───────────────────────────────────────── function migrateJson(db: Db): void { migratePortfolio(db); migrateCalls(db); } function migratePortfolio(db: Db): void { const src = './portfolio.json'; if (!existsSync(src)) return; try { const { holdings } = JSON.parse(readFileSync(src, 'utf8')) as { holdings: Array<{ ticker: string; shares: number; costBasis: number; type: string; source: string; }>; }; const insert = db.prepare( 'INSERT OR IGNORE INTO holdings (ticker, shares, cost_basis, type, source) VALUES (?,?,?,?,?)', ); const insertAll = db.transaction((rows: typeof holdings) => { for (const h of rows) { insert.run( h.ticker.toUpperCase(), h.shares, h.costBasis ?? 0, h.type ?? 'stock', h.source ?? 'Manual', ); } }); insertAll(holdings); renameSync(src, src + '.migrated'); } catch { // non-fatal — leave file in place if migration fails } } function migrateCalls(db: Db): void { const src = './market-calls.json'; if (!existsSync(src)) return; try { const { calls } = JSON.parse(readFileSync(src, 'utf8')) as { calls: Array<{ id?: string; title: string; quarter: string; date: string; thesis: string; tickers: string[]; snapshot: Record; createdAt: string; }>; }; const insert = db.prepare( 'INSERT OR IGNORE INTO market_calls (id, title, quarter, date, thesis, tickers, snapshot, created_at) VALUES (?,?,?,?,?,?,?,?)', ); const insertAll = db.transaction((rows: typeof calls) => { for (const c of rows) { insert.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 } }