phase-8g: add sqllite.

This commit is contained in:
Kazuma
2026-06-05 23:34:25 -04:00
committed by Kazuma
parent ca449b4300
commit 09f2444157
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}`);
}
}
}