Files
market_screener/INTEGRATION_EXAMPLE.md
T
2026-06-05 23:34:25 -04:00

13 KiB

Integration Example: Hardened Database Layer

This document shows step-by-step how to integrate the new QueryBuilder + DatabaseConnection + QueryAudit into your existing codebase.

Step 1: Update server/app.ts

Change from passing raw Db to passing DatabaseConnection:

Before

import { createDb, type Db } from './db/index.js';
import { MarketCallRepository } from './repositories/MarketCallRepository.js';
import { PortfolioRepository } from './repositories/PortfolioRepository.js';

export async function buildApp(): Promise<FastifyInstance> {
  const app = fastify();
  const rawDb: Db = createDb();

  // Pass raw Db to repositories
  const callsRepo = new MarketCallRepository(rawDb);
  const portfolioRepo = new PortfolioRepository(rawDb);

  // Register routes...
  return app;
}

After

import BetterSqlite3 from 'better-sqlite3';
import { createDb, DatabaseConnection, QueryAudit } from './db/index.js';
import { MarketCallRepository } from './repositories/MarketCallRepository.js';
import { PortfolioRepository } from './repositories/PortfolioRepository.js';

export async function buildApp(): Promise<FastifyInstance> {
  const app = fastify();

  // Create the raw database and initialize schema
  const rawDb = createDb();

  // Wrap with audit and caching
  const audit = new QueryAudit((entry) => {
    // Optional: send to logging service
    if (process.env.LOG_SLOW_QUERIES && entry.durationMs > 100) {
      console.warn(`[SLOW] ${entry.sql} (${entry.durationMs.toFixed(1)}ms)`);
    }
  });

  const db = new DatabaseConnection(rawDb, {
    audit,
    logSlowQueries: 100, // warn on >100ms queries
  });

  // Pass DatabaseConnection to repositories (not raw Db)
  const callsRepo = new MarketCallRepository(db);
  const portfolioRepo = new PortfolioRepository(db);

  // Register routes...
  return app;
}

Step 2: Update MarketCallRepository

Refactor to use QueryBuilder and DatabaseConnection:

Complete Refactored Repository

import { randomUUID } from 'crypto';
import { DatabaseConnection, QueryBuilder } from '../db/index.js';
import type { MarketCall, CreateCallInput } from '../types/index.js';

interface CallRow {
  id:         string;
  title:      string;
  quarter:    string;
  date:       string;
  thesis:     string;
  tickers:    string;  // JSON
  snapshot:   string;  // JSON
  created_at: string;
}

export class MarketCallRepository {
  constructor(private readonly db: DatabaseConnection) {}

  /**
   * List all market calls, newest first.
   */
  list(): (MarketCall & { createdAt: string })[] {
    const qb = new QueryBuilder('market_calls')
      .select(['id', 'title', 'quarter', 'date', 'thesis', 'tickers', 'snapshot', 'created_at'])
      .orderBy('created_at', 'DESC');

    const rows = this.db.all<CallRow>(qb);
    return rows.map(MarketCallRepository.toCall);
  }

  /**
   * Get a single market call by ID.
   */
  get(id: string): (MarketCall & { createdAt: string }) | null {
    const qb = new QueryBuilder('market_calls')
      .select(['id', 'title', 'quarter', 'date', 'thesis', 'tickers', 'snapshot', 'created_at'])
      .where('id = ?', [id]);

    const row = this.db.get<CallRow>(qb);
    return row ? MarketCallRepository.toCall(row) : null;
  }

  /**
   * Create a new market call with snapshot of current prices.
   */
  create({
    title, quarter, date, thesis, tickers, snapshot,
  }: CreateCallInput): MarketCall & { createdAt: string } {
    const call = {
      id:        randomUUID(),
      title,
      quarter,
      date:      date ?? new Date().toISOString().slice(0, 10),
      thesis,
      tickers:   tickers ?? [],
      snapshot:  snapshot ?? {},
      createdAt: new Date().toISOString(),
    };

    const qb = new QueryBuilder('market_calls')
      .insert(
        ['id', 'title', 'quarter', 'date', 'thesis', 'tickers', 'snapshot', 'created_at'],
        [
          call.id,
          call.title,
          call.quarter,
          call.date,
          call.thesis,
          JSON.stringify(call.tickers),
          JSON.stringify(call.snapshot),
          call.createdAt,
        ],
      );

    this.db.run(qb);
    return call as MarketCall & { createdAt: string };
  }

  /**
   * Delete a market call by ID.
   * Returns true if the call existed and was deleted, false otherwise.
   */
  delete(id: string): boolean {
    const qb = new QueryBuilder('market_calls')
      .delete()
      .where('id = ?', [id]);

    const changes = this.db.run(qb);
    return changes > 0;
  }

  /**
   * Private helper to convert database row to domain object.
   */
  private static toCall(row: CallRow): MarketCall & { createdAt: string } {
    return {
      id:        row.id,
      title:     row.title,
      quarter:   row.quarter,
      date:      row.date,
      thesis:    row.thesis,
      tickers:   JSON.parse(row.tickers),
      snapshot:  JSON.parse(row.snapshot),
      createdAt: row.created_at,
    } as MarketCall & { createdAt: string };
  }
}

Changes:

  • Constructor now accepts DatabaseConnection instead of raw Db
  • All .prepare() calls replaced with QueryBuilder + db.all() / db.get() / db.run()
  • Explicit column selection in SELECT statements
  • Audit trail automatically generated for every query

Step 3: Update PortfolioRepository

Similar refactoring:

import { DatabaseConnection, QueryBuilder } from '../db/index.js';
import type { PortfolioData, PortfolioHolding } from '../types/index.js';

interface HoldingRow {
  ticker:     string;
  shares:     number;
  cost_basis: number;
  type:       string;
  source:     string;
}

export class PortfolioRepository {
  constructor(private readonly db: DatabaseConnection) {}

  /**
   * Check if portfolio has any holdings.
   */
  exists(): boolean {
    const qb = new QueryBuilder('holdings')
      .select(['ticker'])
      .limit(1);

    const row = this.db.get<{ ticker: string }>(qb);
    return row !== null;
  }

  /**
   * Read all holdings.
   */
  read(): PortfolioData {
    const qb = new QueryBuilder('holdings')
      .select(['ticker', 'shares', 'cost_basis', 'type', 'source'])
      .orderBy('ticker', 'ASC');

    const rows = this.db.all<HoldingRow>(qb);
    return { holdings: rows.map(PortfolioRepository.toHolding) };
  }

  /**
   * Insert or update a holding.
   */
  upsert(entry: PortfolioHolding): PortfolioHolding {
    const ticker = entry.ticker.toUpperCase().trim();

    // Use raw db.prepare() for UPSERT syntax (not yet wrapped in QueryBuilder)
    // This is acceptable because the values are all parameterized
    this.db.raw().prepare(
      `INSERT INTO holdings (ticker, shares, cost_basis, type, source)
       VALUES (?, ?, ?, ?, ?)
       ON CONFLICT(ticker) DO UPDATE SET
         shares     = excluded.shares,
         cost_basis = excluded.cost_basis,
         type       = excluded.type,
         source     = excluded.source`,
    ).run(ticker, entry.shares, entry.costBasis ?? 0, entry.type ?? 'stock', entry.source ?? 'Manual');

    return { ...entry, ticker };
  }

  /**
   * Delete a holding by ticker.
   */
  remove(ticker: string): boolean {
    const qb = new QueryBuilder('holdings')
      .delete()
      .where('ticker = ?', [ticker.toUpperCase()]);

    const changes = this.db.run(qb);
    return changes > 0;
  }

  /**
   * Private helper to convert database row to domain object.
   */
  private static toHolding(row: HoldingRow): PortfolioHolding {
    return {
      ticker:    row.ticker,
      shares:    row.shares,
      costBasis: row.cost_basis,
      type:      row.type as PortfolioHolding['type'],
      source:    row.source,
    };
  }
}

Note: The upsert() method still uses db.raw().prepare() because QueryBuilder doesn't yet support ON CONFLICT. This is acceptable because the SQL is still parameterized. A future enhancement could add onConflict() to QueryBuilder if needed.

Step 4: Add a Simple Test

Create tests/MarketCallRepository.test.ts:

import test from 'node:test';
import assert from 'node:assert/strict';
import BetterSqlite3 from 'better-sqlite3';
import { DatabaseConnection, QueryAudit } from '../server/db/index.js';
import { MarketCallRepository } from '../server/repositories/MarketCallRepository.js';

// Mini DDL for testing
const DDL = `
  CREATE TABLE 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,
    snapshot   TEXT NOT NULL,
    created_at TEXT NOT NULL
  );
`;

test('MarketCallRepository', async (t) => {
  // Set up in-memory database
  const rawDb = new BetterSqlite3(':memory:');
  rawDb.exec(DDL);

  const audit = new QueryAudit();
  const db = new DatabaseConnection(rawDb, { audit });
  const repo = new MarketCallRepository(db);

  await t.test('should create and retrieve a call', () => {
    const created = repo.create({
      title: 'Q4 Earnings Blitz',
      quarter: 'Q4',
      thesis: 'Mega cap tech breakout',
      tickers: ['AAPL', 'MSFT', 'GOOGL'],
    });

    assert.ok(created.id);
    assert.equal(created.title, 'Q4 Earnings Blitz');

    const retrieved = repo.get(created.id);
    assert.deepEqual(retrieved, created);
  });

  await t.test('should list calls in order', () => {
    const call1 = repo.create({
      title: 'Call 1',
      quarter: 'Q1',
      thesis: 'Test 1',
      tickers: ['AAPL'],
    });

    const call2 = repo.create({
      title: 'Call 2',
      quarter: 'Q2',
      thesis: 'Test 2',
      tickers: ['MSFT'],
    });

    const list = repo.list();
    assert.equal(list.length, 2);
    // Most recent first
    assert.equal(list[0].id, call2.id);
    assert.equal(list[1].id, call1.id);
  });

  await t.test('should delete a call', () => {
    const call = repo.create({
      title: 'Deletable',
      quarter: 'Q1',
      thesis: 'This will be deleted',
      tickers: ['TEST'],
    });

    assert.ok(repo.delete(call.id));
    assert.equal(repo.get(call.id), null);
    assert.ok(!repo.delete(call.id)); // Already deleted
  });

  await t.test('should track queries in audit', () => {
    repo.create({
      title: 'Audited',
      quarter: 'Q1',
      thesis: 'Tracked',
      tickers: ['AAPL'],
    });

    const auditLog = audit.all();
    assert.ok(auditLog.length > 0);

    // Find the INSERT
    const inserts = audit.byAction('WRITE');
    assert.ok(inserts.some(e => e.sql.includes('INSERT INTO market_calls')));
  });
});

Run it:

npm test -- tests/MarketCallRepository.test.ts

Step 5: Add to Existing Tests

If you already have integration tests, add an audit check:

test('screening creates an audit trail', async (t) => {
  const result = await app.inject({
    method: 'POST',
    url: '/api/screen',
    payload: { tickers: ['AAPL', 'MSFT'] },
  });

  assert.equal(result.statusCode, 200);

  // Verify the database was accessed
  const audit = db.getAudit();
  const reads = audit.byAction('READ');
  assert.ok(reads.length > 0, 'SELECT queries should have been executed');
});

Step 6: Enable Audit Output (Optional)

If you want to log all queries to a file or external service:

import fs from 'fs/promises';

const audit = new QueryAudit(async (entry) => {
  // Only log WRITE operations to a log file
  if (entry.action !== 'READ') {
    const logLine = JSON.stringify({
      timestamp: entry.timestamp,
      action: entry.action,
      sql: entry.sql,
      params: entry.params,
      rowsAffected: entry.rowsAffected,
      error: entry.error,
    });

    await fs.appendFile('./audit.log', logLine + '\n');
  }
});

const db = new DatabaseConnection(rawDb, { audit, logSlowQueries: 50 });

Then tail the log:

tail -f audit.log | jq .

Summary

File Change
server/app.ts Create DatabaseConnection and pass to repositories
server/repositories/MarketCallRepository.ts Use QueryBuilder + DatabaseConnection
server/repositories/PortfolioRepository.ts Use QueryBuilder + DatabaseConnection
tests/MarketCallRepository.test.ts Add tests with audit verification

All changes maintain backward compatibility with the existing API — only the internals change. Your controllers don't need to be modified.

Next: Audit Trail in Production

Once deployed, you can:

  1. Review recent queries: db.getAudit().recent(100)
  2. Find slow queries: db.getAudit().all().filter(e => e.durationMs > 500)
  3. Track mutations: db.getAudit().byAction('WRITE')
  4. Generate compliance reports: db.printAudit()

This gives you visibility into exactly what's happening in your database — invaluable for debugging, security audits, and performance optimization.