Files
Neon-Desk/lib/server/db/index.ts
francy51 db01f207a5 Expand financials surfaces with ratios, KPIs, and cadence support
- Add bundled financial modeling pipeline (ratios, KPI dimensions/notes, trend series, standardization)
- Introduce company financial bundles storage (Drizzle migration + repo wiring)
- Refactor financials page/API/query flow to use surfaceKind + cadence and new response shapes
2026-03-07 15:16:35 -05:00

183 lines
6.2 KiB
TypeScript

import { mkdirSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { Database } from 'bun:sqlite';
import { drizzle } from 'drizzle-orm/bun-sqlite';
import { schema } from './schema';
type AppDrizzleDb = ReturnType<typeof createDb>;
declare global {
// eslint-disable-next-line no-var
var __fiscalSqliteClient: Database | undefined;
// eslint-disable-next-line no-var
var __fiscalDrizzleDb: AppDrizzleDb | undefined;
}
function getDatabasePath() {
const raw = process.env.DATABASE_URL?.trim() || 'file:data/fiscal.sqlite';
let databasePath = raw.startsWith('file:') ? raw.slice(5) : raw;
if (databasePath.startsWith('///')) {
databasePath = databasePath.slice(2);
}
if (!databasePath) {
throw new Error('DATABASE_URL must point to a SQLite file path.');
}
return databasePath;
}
function hasTable(client: Database, tableName: string) {
const row = client
.query('SELECT name FROM sqlite_master WHERE type = ? AND name = ? LIMIT 1')
.get('table', tableName) as { name: string } | null;
return row !== null;
}
function hasColumn(client: Database, tableName: string, columnName: string) {
if (!hasTable(client, tableName)) {
return false;
}
const rows = client.query(`PRAGMA table_info(${tableName})`).all() as Array<{ name: string }>;
return rows.some((row) => row.name === columnName);
}
function applySqlFile(client: Database, fileName: string) {
const sql = readFileSync(join(process.cwd(), 'drizzle', fileName), 'utf8');
client.exec(sql);
}
function ensureLocalSqliteSchema(client: Database) {
if (!hasTable(client, 'filing_statement_snapshot')) {
applySqlFile(client, '0001_glossy_statement_snapshots.sql');
}
if (hasTable(client, 'task_run')) {
const missingTaskColumns: Array<{ name: string; sql: string }> = [
{ name: 'stage', sql: "ALTER TABLE `task_run` ADD `stage` text NOT NULL DEFAULT 'queued';" },
{ name: 'stage_detail', sql: 'ALTER TABLE `task_run` ADD `stage_detail` text;' },
{ name: 'resource_key', sql: 'ALTER TABLE `task_run` ADD `resource_key` text;' },
{ name: 'notification_read_at', sql: 'ALTER TABLE `task_run` ADD `notification_read_at` text;' },
{ name: 'notification_silenced_at', sql: 'ALTER TABLE `task_run` ADD `notification_silenced_at` text;' }
];
for (const column of missingTaskColumns) {
if (!hasColumn(client, 'task_run', column.name)) {
client.exec(column.sql);
}
}
}
if (!hasTable(client, 'task_stage_event')) {
applySqlFile(client, '0003_task_stage_event_timeline.sql');
}
if (hasTable(client, 'watchlist_item')) {
const missingWatchlistColumns: Array<{ name: string; sql: string }> = [
{ name: 'category', sql: 'ALTER TABLE `watchlist_item` ADD `category` text;' },
{ name: 'tags', sql: 'ALTER TABLE `watchlist_item` ADD `tags` text;' },
{ name: 'status', sql: "ALTER TABLE `watchlist_item` ADD `status` text NOT NULL DEFAULT 'backlog';" },
{ name: 'priority', sql: "ALTER TABLE `watchlist_item` ADD `priority` text NOT NULL DEFAULT 'medium';" },
{ name: 'updated_at', sql: "ALTER TABLE `watchlist_item` ADD `updated_at` text NOT NULL DEFAULT '';" },
{ name: 'last_reviewed_at', sql: 'ALTER TABLE `watchlist_item` ADD `last_reviewed_at` text;' }
];
for (const column of missingWatchlistColumns) {
if (!hasColumn(client, 'watchlist_item', column.name)) {
client.exec(column.sql);
}
}
client.exec(`
UPDATE \`watchlist_item\`
SET
\`status\` = CASE
WHEN \`status\` IS NULL OR TRIM(\`status\`) = '' THEN 'backlog'
ELSE \`status\`
END,
\`priority\` = CASE
WHEN \`priority\` IS NULL OR TRIM(\`priority\`) = '' THEN 'medium'
ELSE \`priority\`
END,
\`updated_at\` = CASE
WHEN \`updated_at\` IS NULL OR TRIM(\`updated_at\`) = '' THEN COALESCE(NULLIF(\`created_at\`, ''), CURRENT_TIMESTAMP)
ELSE \`updated_at\`
END;
`);
client.exec('CREATE INDEX IF NOT EXISTS `watchlist_user_updated_idx` ON `watchlist_item` (`user_id`, `updated_at`);');
}
if (hasTable(client, 'holding') && !hasColumn(client, 'holding', 'company_name')) {
client.exec('ALTER TABLE `holding` ADD `company_name` text;');
}
if (!hasTable(client, 'filing_taxonomy_snapshot')) {
applySqlFile(client, '0005_financial_taxonomy_v3.sql');
}
if (!hasTable(client, 'company_financial_bundle')) {
applySqlFile(client, '0007_company_financial_bundles.sql');
}
if (!hasTable(client, 'research_journal_entry')) {
client.exec(`
CREATE TABLE IF NOT EXISTS \`research_journal_entry\` (
\`id\` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
\`user_id\` text NOT NULL,
\`ticker\` text NOT NULL,
\`accession_number\` text,
\`entry_type\` text NOT NULL,
\`title\` text,
\`body_markdown\` text NOT NULL,
\`metadata\` text,
\`created_at\` text NOT NULL,
\`updated_at\` text NOT NULL,
FOREIGN KEY (\`user_id\`) REFERENCES \`user\`(\`id\`) ON UPDATE no action ON DELETE cascade
);
`);
client.exec('CREATE INDEX IF NOT EXISTS `research_journal_ticker_idx` ON `research_journal_entry` (`user_id`, `ticker`, `created_at`);');
client.exec('CREATE INDEX IF NOT EXISTS `research_journal_accession_idx` ON `research_journal_entry` (`user_id`, `accession_number`);');
}
}
export function getSqliteClient() {
if (!globalThis.__fiscalSqliteClient) {
const databasePath = getDatabasePath();
if (databasePath !== ':memory:') {
mkdirSync(dirname(databasePath), { recursive: true });
}
const client = new Database(databasePath, { create: true });
client.exec('PRAGMA foreign_keys = ON;');
client.exec('PRAGMA journal_mode = WAL;');
client.exec('PRAGMA busy_timeout = 5000;');
ensureLocalSqliteSchema(client);
globalThis.__fiscalSqliteClient = client;
}
return globalThis.__fiscalSqliteClient;
}
function createDb() {
return drizzle(getSqliteClient(), { schema });
}
export const db = globalThis.__fiscalDrizzleDb ?? createDb();
if (!globalThis.__fiscalDrizzleDb) {
globalThis.__fiscalDrizzleDb = db;
}
export const __dbInternals = {
ensureLocalSqliteSchema,
getDatabasePath,
hasColumn,
hasTable
};