465 lines
13 KiB
Markdown
465 lines
13 KiB
Markdown
# 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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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`:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```bash
|
|
npm test -- tests/MarketCallRepository.test.ts
|
|
```
|
|
|
|
## Step 5: Add to Existing Tests
|
|
|
|
If you already have integration tests, add an audit check:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```bash
|
|
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.
|