196 lines
6.2 KiB
TypeScript
196 lines
6.2 KiB
TypeScript
/**
|
|
* 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<string, unknown>;
|
|
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
|
|
}
|
|
}
|