Files
investor-flow/app/server/src/db/client.ts
T

72 lines
2.9 KiB
TypeScript
Raw Normal View History

// Investor Flow — SQLite DB client (node:sqlite). DESIGN.md §1 schema seam.
//
// Runtime adaptation (reversible): design specified Bun + bun:sqlite. Implemented on
// Node 26 native TS (type-stripping) + node:sqlite (built-in) so the backend has ZERO
// native dependencies. Deep-module architecture (CacheRepository, etc.) is unchanged.
import { DatabaseSync } from 'node:sqlite';
import { readFileSync, mkdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SCHEMA_PATH = join(__dirname, 'schema.sql');
const DEFAULT_DB_DIR = join(__dirname, '..', '..', 'data');
const DEFAULT_DB_PATH = join(DEFAULT_DB_DIR, 'investor-flow.db');
export interface DbOptions {
/** SQLite file path. Defaults to $IFLOW_DB_PATH or <server>/data/investor-flow.db. Use ':memory:' for tests. */
path?: string;
readonly?: boolean;
}
/** Open a SQLite connection with foreign keys + WAL enabled. */
export function createDb(opts: DbOptions = {}): DatabaseSync {
const path = opts.path ?? process.env.IFLOW_DB_PATH ?? DEFAULT_DB_PATH;
if (!opts.readonly && path !== ':memory:') {
mkdirSync(dirname(path), { recursive: true });
}
const database = new DatabaseSync(path, {
readOnly: opts.readonly ?? false,
enableForeignKeyConstraints: true,
});
// WAL is persistent on the file; setting per-connection is harmless and ensures it.
try {
database.exec('PRAGMA journal_mode = WAL;');
} catch {
/* readOnly or disallowed — ignore */
}
return database;
}
/** Apply the full schema (CREATE TABLE IF NOT EXISTS). Idempotent. */
export function initSchema(database: DatabaseSync): void {
const sql = readFileSync(SCHEMA_PATH, 'utf8');
database.exec(sql);
}
let _db: DatabaseSync | null = null;
/** Process-wide singleton connection (auto-creates + inits schema on first call). */
export function db(): DatabaseSync {
if (!_db) {
_db = createDb();
initSchema(_db);
// Ensure the anonymous user exists for unauthenticated watchlist/portfolio usage.
try {
_db.prepare("INSERT OR IGNORE INTO users (id, email, pw_hash, created_at) VALUES (?, ?, ?, ?)")
.run('anonymous', 'anonymous@investor-flow.local', '', new Date().toISOString());
} catch { /* table may not exist yet or already exists */ }
}
return _db;
}
/** CLI entry: `node src/db/client.ts` initializes the on-disk database. */
const isMain = process.argv[1] ? resolve(process.argv[1]) === fileURLToPath(import.meta.url) : false;
if (isMain) {
const d = createDb();
initSchema(d);
const path = process.env.IFLOW_DB_PATH ?? DEFAULT_DB_PATH;
const rows = d.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all() as Array<{ name: string }>;
console.log(`[investor-flow] schema initialized at ${path} (${rows.length} tables)`);
d.close();
}