phase-8g: add sqllite.

This commit is contained in:
Sai Kiran Vella
2026-06-05 23:34:25 -04:00
committed by saikiranvella
parent d1556f2a67
commit c7e39c3e4e
20 changed files with 2514 additions and 239 deletions
+198
View File
@@ -0,0 +1,198 @@
/**
* DatabaseConnection — High-level database abstraction.
*
* Wraps better-sqlite3 with:
* - QueryBuilder for type-safe, injection-proof queries
* - QueryAudit for logging and compliance
* - Statement caching for performance
* - Transaction support
*
* Usage:
* const db = new DatabaseConnection(betterSqlite3Db, options);
* const qb = new QueryBuilder('holdings').select(['ticker', 'shares']).where('type = ?', ['stock']);
* const rows = db.all(qb);
* const row = db.get(qb);
* db.run(qb);
*/
import type BetterSqlite3 from 'better-sqlite3';
import { QueryBuilder } from './QueryBuilder';
import { QueryAudit, AuditAction } from './QueryAudit';
export interface DatabaseOptions {
audit?: QueryAudit;
logSlowQueries?: number; // milliseconds; logs queries slower than this
}
/**
* DatabaseConnection — Safe, auditable, performant SQLite wrapper.
*/
export class DatabaseConnection {
private db: BetterSqlite3.Database;
private audit: QueryAudit;
private logSlowQueries: number;
private statementCache = new Map<string, BetterSqlite3.Statement>();
constructor(db: BetterSqlite3.Database, options: DatabaseOptions = {}) {
this.db = db;
this.audit = options.audit ?? new QueryAudit();
this.logSlowQueries = options.logSlowQueries ?? 100; // 100ms default
}
/**
* Execute a SELECT query and return all rows.
* Logs the query to the audit trail.
*/
all<T = Record<string, unknown>>(qb: QueryBuilder): T[] {
const sql = qb.build();
const params = qb.params();
const startMs = performance.now();
try {
const stmt = this.getOrCacheStatement(sql);
const rows = stmt.all(...params) as T[];
const durationMs = performance.now() - startMs;
this.audit.log(sql, params, AuditAction.READ, durationMs, rows.length);
this.logIfSlow(sql, durationMs);
return rows;
} catch (err) {
const durationMs = performance.now() - startMs;
const errorMsg = err instanceof Error ? err.message : String(err);
this.audit.log(sql, params, AuditAction.READ, durationMs, undefined, errorMsg);
throw err;
}
}
/**
* Execute a SELECT query and return the first row only.
* Returns null if no rows match.
* Logs the query to the audit trail.
*/
get<T = Record<string, unknown>>(qb: QueryBuilder): T | null {
const sql = qb.build();
const params = qb.params();
const startMs = performance.now();
try {
const stmt = this.getOrCacheStatement(sql);
const row = stmt.get(...params) as T | undefined;
const durationMs = performance.now() - startMs;
this.audit.log(sql, params, AuditAction.READ, durationMs, row ? 1 : 0);
this.logIfSlow(sql, durationMs);
return row ?? null;
} catch (err) {
const durationMs = performance.now() - startMs;
const errorMsg = err instanceof Error ? err.message : String(err);
this.audit.log(sql, params, AuditAction.READ, durationMs, undefined, errorMsg);
throw err;
}
}
/**
* Execute an INSERT, UPDATE, or DELETE query.
* Returns the number of rows affected.
* Logs the query to the audit trail.
*/
run(qb: QueryBuilder): number {
const sql = qb.build();
const params = qb.params();
const startMs = performance.now();
// Determine audit action from SQL
const sqlUpper = sql.toUpperCase().trim();
const action = sqlUpper.startsWith('DELETE')
? AuditAction.DELETE
: sqlUpper.startsWith('INSERT')
? AuditAction.WRITE
: AuditAction.WRITE;
try {
const stmt = this.getOrCacheStatement(sql);
const result = stmt.run(...params);
const durationMs = performance.now() - startMs;
this.audit.log(sql, params, action, durationMs, result.changes);
this.logIfSlow(sql, durationMs);
return result.changes;
} catch (err) {
const durationMs = performance.now() - startMs;
const errorMsg = err instanceof Error ? err.message : String(err);
this.audit.log(sql, params, action, durationMs, 0, errorMsg);
throw err;
}
}
/**
* Execute a transaction — multiple queries as an atomic unit.
* All queries must succeed, or all are rolled back.
*
* Usage:
* db.transaction(() => {
* db.run(qb1);
* db.run(qb2);
* });
*/
transaction<T>(fn: () => T): T {
const txn = this.db.transaction(fn);
return txn();
}
/**
* Get the raw better-sqlite3 Db instance (for advanced use only).
* Prefer the DatabaseConnection methods.
*/
raw(): BetterSqlite3.Database {
return this.db;
}
/**
* Get the audit trail instance.
*/
getAudit(): QueryAudit {
return this.audit;
}
/**
* Clear the statement cache (for testing or extreme memory pressure).
*/
clearStatementCache(): void {
this.statementCache.clear();
}
/**
* Get the audit trail instance.
* Call db.printAudit() to see the most recent 100 queries.
*/
printAudit(): void {
console.log(this.audit.report());
}
// ── Private helpers ─────────────────────────────────────────────────────
/**
* Get or create a cached prepared statement.
* Reduces compilation overhead for frequently-run queries.
*/
private getOrCacheStatement(sql: string): BetterSqlite3.Statement {
let stmt = this.statementCache.get(sql);
if (!stmt) {
stmt = this.db.prepare(sql);
this.statementCache.set(sql, stmt);
}
return stmt;
}
/**
* Log slow queries to console.
*/
private logIfSlow(sql: string, durationMs: number): void {
if (durationMs > this.logSlowQueries) {
console.warn(`[SLOW QUERY] ${durationMs.toFixed(2)}ms\n ${sql}`);
}
}
}
+140
View File
@@ -0,0 +1,140 @@
/**
* Query audit logging — tracks all database mutations.
*
* Usage:
* const audit = new QueryAudit();
* audit.logQuery('SELECT * FROM holdings', [], 'READ');
* audit.logQuery('UPDATE holdings SET shares = ? WHERE ticker = ?', [100, 'AAPL'], 'WRITE');
*
* Provides:
* - Audit trail of all queries executed
* - Timing information (for performance monitoring)
* - Clear distinction between READ/WRITE operations
* - Optional persistent storage for compliance
*/
export enum AuditAction {
READ = 'READ',
WRITE = 'WRITE',
DELETE = 'DELETE',
}
export interface AuditEntry {
timestamp: string; // ISO 8601
action: AuditAction;
sql: string;
params: unknown[];
durationMs: number;
rowsAffected?: number;
error?: string;
}
/**
* QueryAudit — in-memory audit trail with optional callbacks.
*/
export class QueryAudit {
private entries: AuditEntry[] = [];
private onLog?: (entry: AuditEntry) => void | Promise<void>;
constructor(onLog?: (entry: AuditEntry) => void | Promise<void>) {
this.onLog = onLog;
}
/**
* Log a query execution.
* @param sql The SQL string (with ? placeholders intact)
* @param params The parameter array (safe to log; no raw values in SQL)
* @param action The operation type (READ, WRITE, DELETE)
* @param durationMs Execution time in milliseconds
* @param rowsAffected Number of rows affected (for INSERT/UPDATE/DELETE)
* @param error If execution failed, the error message
*/
log(
sql: string,
params: unknown[],
action: AuditAction,
durationMs: number,
rowsAffected?: number,
error?: string,
): void {
const entry: AuditEntry = {
timestamp: new Date().toISOString(),
action,
sql,
params,
durationMs,
rowsAffected,
error,
};
this.entries.push(entry);
// Call the optional callback (could write to file, logger, or remote service)
if (this.onLog) {
const result = this.onLog(entry);
if (result instanceof Promise) {
result.catch((err) => {
console.error('QueryAudit callback failed:', err);
});
}
}
}
/**
* Get all audit entries.
*/
all(): AuditEntry[] {
return [...this.entries];
}
/**
* Filter audit entries by action type.
*/
byAction(action: AuditAction): AuditEntry[] {
return this.entries.filter((e) => e.action === action);
}
/**
* Get the most recent N entries.
*/
recent(count: number = 100): AuditEntry[] {
return this.entries.slice(-count);
}
/**
* Clear the audit trail.
* (Typically not needed unless for testing or cleanup.)
*/
clear(): void {
this.entries = [];
}
/**
* Generate a human-readable audit report.
*/
report(limitEntries: number = 100): string {
const recent = this.recent(limitEntries);
let report = `\n=== Query Audit Report ===\n`;
report += `Total entries: ${this.entries.length}\n`;
report += `Showing last ${recent.length} entries:\n\n`;
for (const entry of recent) {
report += `[${entry.timestamp}] ${entry.action}`;
if (entry.error) {
report += ` ❌ (${entry.error})`;
} else {
report += ` ✓ (${entry.durationMs}ms)`;
if (entry.rowsAffected !== undefined) {
report += `${entry.rowsAffected} rows`;
}
}
report += `\n SQL: ${entry.sql}\n`;
if (entry.params.length > 0) {
report += ` Params: [${entry.params.map((p) => JSON.stringify(p)).join(', ')}]\n`;
}
report += '\n';
}
return report;
}
}
+262
View File
@@ -0,0 +1,262 @@
/**
* Type-safe query builder for SQLite.
*
* Prevents SQL injection by:
* 1. Enforcing parameterized queries (? placeholders)
* 2. Building SQL dynamically only for schema-safe values (table/column names are validated against a whitelist)
* 3. Keeping all user input in parameter arrays, never in the SQL string
*
* Usage:
* const qb = new QueryBuilder('holdings');
* qb.select(['ticker', 'shares']).where('type = ?', ['stock']).orderBy('ticker');
* const stmt = db.prepare(qb.build());
* stmt.all(...qb.params());
*/
type QueryType = 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE';
interface WhereClause {
expression: string;
params: unknown[];
}
/**
* Whitelist of safe column and table names.
* Prevents injection via column/table names.
*/
const SAFE_COLUMNS = new Set([
// holdings table
'ticker',
'shares',
'cost_basis',
'type',
'source',
// market_calls table
'id',
'title',
'quarter',
'date',
'thesis',
'tickers',
'snapshot',
'created_at',
]);
const SAFE_TABLES = new Set(['holdings', 'market_calls']);
/**
* Validates a column name against the whitelist.
* Throws if not in whitelist to prevent column name injection.
*/
function validateColumn(col: string): void {
if (!SAFE_COLUMNS.has(col.toLowerCase())) {
throw new Error(`Unsafe column name: ${col}. Only whitelisted columns allowed.`);
}
}
/**
* Validates a table name against the whitelist.
* Throws if not in whitelist to prevent table name injection.
*/
function validateTable(table: string): void {
if (!SAFE_TABLES.has(table.toLowerCase())) {
throw new Error(`Unsafe table name: ${table}. Only whitelisted tables allowed.`);
}
}
/**
* QueryBuilder — type-safe, injectable-resistant query construction.
*/
export class QueryBuilder {
private type: QueryType | null = null;
private table: string;
private selectCols: string[] = [];
private whereClausesList: WhereClause[] = [];
private orderByCols: { col: string; direction: 'ASC' | 'DESC' }[] = [];
private limitVal: number | null = null;
private offsetVal: number | null = null;
// For INSERT
private insertCols: string[] = [];
private insertParamCount = 0;
// For UPDATE
private updateAssignments: { col: string; paramIndex: number }[] = [];
private allParams: unknown[] = [];
constructor(table: string) {
validateTable(table);
this.table = table;
}
/**
* SELECT query builder.
* Columns are validated against whitelist.
*/
select(columns: string[]): this {
if (this.type !== null) throw new Error('Query type already set');
this.type = 'SELECT';
for (const col of columns) {
validateColumn(col);
this.selectCols.push(col);
}
return this;
}
/**
* INSERT query builder.
* Columns are validated; values go into parameter array.
*/
insert(columns: string[], values: unknown[]): this {
if (this.type !== null) throw new Error('Query type already set');
if (columns.length !== values.length) {
throw new Error('Column/value count mismatch');
}
this.type = 'INSERT';
for (const col of columns) {
validateColumn(col);
this.insertCols.push(col);
}
this.insertParamCount = values.length;
this.allParams.push(...values);
return this;
}
/**
* UPDATE query builder.
* Column names validated; values go into parameter array.
*/
update(updates: Record<string, unknown>): this {
if (this.type !== null) throw new Error('Query type already set');
this.type = 'UPDATE';
let paramIndex = 0;
for (const [col, value] of Object.entries(updates)) {
validateColumn(col);
this.updateAssignments.push({ col, paramIndex });
this.allParams.push(value);
paramIndex++;
}
return this;
}
/**
* DELETE query builder.
*/
delete(): this {
if (this.type !== null) throw new Error('Query type already set');
this.type = 'DELETE';
return this;
}
/**
* WHERE clause(s).
* Expression is NOT validated (it should be safe from app logic);
* params are added to the parameter array.
*
* Example: .where('type = ? AND shares > ?', ['stock', 10])
*/
where(expression: string, params: unknown[] = []): this {
this.whereClausesList.push({ expression, params });
this.allParams.push(...params);
return this;
}
/**
* ORDER BY clause.
* Column names are validated.
*/
orderBy(column: string, direction: 'ASC' | 'DESC' = 'ASC'): this {
validateColumn(column);
this.orderByCols.push({ col: column, direction });
return this;
}
/**
* LIMIT clause.
*/
limit(count: number): this {
if (count < 0) throw new Error('LIMIT must be non-negative');
this.limitVal = count;
return this;
}
/**
* OFFSET clause.
*/
offset(count: number): this {
if (count < 0) throw new Error('OFFSET must be non-negative');
this.offsetVal = count;
return this;
}
/**
* Build the final SQL string.
* The query is built dynamically but with no injection points:
* - Table/column names from whitelist only
* - All user input in the parameter array
*/
build(): string {
if (this.type === null) throw new Error('Query type not set');
let sql = '';
switch (this.type) {
case 'SELECT': {
const cols = this.selectCols.length > 0 ? this.selectCols.join(', ') : '*';
sql = `SELECT ${cols} FROM ${this.table}`;
break;
}
case 'INSERT': {
const cols = this.insertCols.join(', ');
const placeholders = Array(this.insertParamCount).fill('?').join(', ');
sql = `INSERT INTO ${this.table} (${cols}) VALUES (${placeholders})`;
break;
}
case 'UPDATE': {
const assignments = this.updateAssignments.map((a) => `${a.col} = ?`).join(', ');
sql = `UPDATE ${this.table} SET ${assignments}`;
break;
}
case 'DELETE': {
sql = `DELETE FROM ${this.table}`;
break;
}
}
// Add WHERE clause(s)
if (this.whereClausesList.length > 0) {
const whereExpressions = this.whereClausesList.map((w) => `(${w.expression})`).join(' AND ');
sql += ` WHERE ${whereExpressions}`;
}
// Add ORDER BY
if (this.orderByCols.length > 0) {
const orderExpressions = this.orderByCols.map((o) => `${o.col} ${o.direction}`).join(', ');
sql += ` ORDER BY ${orderExpressions}`;
}
// Add LIMIT
if (this.limitVal !== null) {
sql += ` LIMIT ${this.limitVal}`;
}
// Add OFFSET
if (this.offsetVal !== null) {
sql += ` OFFSET ${this.offsetVal}`;
}
return sql;
}
/**
* Return the accumulated parameter array.
* This is what gets passed to db.prepare(...).run(...params).
*/
params(): unknown[] {
return this.allParams;
}
}
+137
View File
@@ -0,0 +1,137 @@
/**
* 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<string, unknown>;
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
}
}