215 lines
6.3 KiB
TypeScript
215 lines
6.3 KiB
TypeScript
/**
|
|
* 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 type { DatabaseOptions } from '../types/index';
|
|
import { AuditAction } from '../types/index';
|
|
import { QueryBuilder } from '../utils/QueryBuilder';
|
|
import { QueryAudit } from './QueryAudit';
|
|
|
|
/**
|
|
* 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.sql;
|
|
const params = qb.queryParams;
|
|
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.sql;
|
|
const params = qb.queryParams;
|
|
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.sql;
|
|
const params = qb.queryParams;
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* Execute a raw SQL SELECT and return the first row.
|
|
* Use only when QueryBuilder is not practical (e.g. auth domain with static queries).
|
|
*/
|
|
rawGet<T = Record<string, unknown>>(sql: string, params: unknown[] = []): T | undefined {
|
|
const stmt = this.getOrCacheStatement(sql);
|
|
return stmt.get(...params) as T | undefined;
|
|
}
|
|
|
|
/**
|
|
* Execute a raw SQL INSERT/UPDATE/DELETE.
|
|
* Use only when QueryBuilder is not practical (e.g. auth domain with static queries).
|
|
*/
|
|
rawRun(sql: string, params: unknown[] = []): number {
|
|
const stmt = this.getOrCacheStatement(sql);
|
|
return stmt.run(...params).changes;
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
// eslint-disable-next-line no-console
|
|
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}`);
|
|
}
|
|
}
|
|
}
|