Add company overview skeleton and cache

This commit is contained in:
2026-03-13 19:05:17 -04:00
parent b1c9c0ef08
commit 0394f4e795
18 changed files with 1571 additions and 158 deletions

View File

@@ -0,0 +1,202 @@
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it
} from 'bun:test';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Database } from 'bun:sqlite';
import type { CompanyAnalysis } from '@/lib/types';
const TEST_USER_ID = 'overview-cache-user';
let tempDir: string | null = null;
let sqliteClient: Database | null = null;
let overviewCacheRepo: typeof import('./company-overview-cache') | null = null;
function resetDbSingletons() {
const globalState = globalThis as typeof globalThis & {
__fiscalSqliteClient?: Database;
__fiscalDrizzleDb?: unknown;
};
globalState.__fiscalSqliteClient?.close();
globalState.__fiscalSqliteClient = undefined;
globalState.__fiscalDrizzleDb = undefined;
}
function applyMigration(client: Database, fileName: string) {
const sql = readFileSync(join(process.cwd(), 'drizzle', fileName), 'utf8');
client.exec(sql);
}
function ensureUser(client: Database) {
const now = Date.now();
client.exec(`
INSERT OR REPLACE INTO user (id, name, email, emailVerified, image, createdAt, updatedAt, role, banned, banReason, banExpires)
VALUES ('${TEST_USER_ID}', 'Overview Cache User', 'overview-cache@example.com', 1, NULL, ${now}, ${now}, NULL, 0, NULL, NULL);
`);
}
function clearCache(client: Database) {
client.exec('DELETE FROM company_overview_cache;');
}
function buildAnalysisPayload(companyName: string): CompanyAnalysis {
return {
company: {
ticker: 'MSFT',
companyName,
sector: null,
category: null,
tags: [],
cik: null
},
quote: 100,
position: null,
priceHistory: [],
benchmarkHistory: [],
financials: [],
filings: [],
aiReports: [],
coverage: null,
journalPreview: [],
recentAiReports: [],
latestFilingSummary: null,
keyMetrics: {
referenceDate: null,
revenue: null,
netIncome: null,
totalAssets: null,
cash: null,
debt: null,
netMargin: null
},
companyProfile: {
description: null,
exchange: null,
industry: null,
country: null,
website: null,
fiscalYearEnd: null,
employeeCount: null,
source: 'unavailable'
},
valuationSnapshot: {
sharesOutstanding: null,
marketCap: null,
enterpriseValue: null,
trailingPe: null,
evToRevenue: null,
evToEbitda: null,
source: 'unavailable'
},
bullBear: {
source: 'unavailable',
bull: [],
bear: [],
updatedAt: null
},
recentDevelopments: {
status: 'unavailable',
items: [],
weeklySnapshot: null
}
};
}
describe('company overview cache repo', () => {
beforeAll(async () => {
tempDir = mkdtempSync(join(tmpdir(), 'fiscal-overview-cache-'));
const env = process.env as Record<string, string | undefined>;
env.DATABASE_URL = `file:${join(tempDir, 'repo.sqlite')}`;
env.NODE_ENV = 'test';
resetDbSingletons();
sqliteClient = new Database(join(tempDir, 'repo.sqlite'), { create: true });
sqliteClient.exec('PRAGMA foreign_keys = ON;');
applyMigration(sqliteClient, '0000_cold_silver_centurion.sql');
applyMigration(sqliteClient, '0012_company_overview_cache.sql');
ensureUser(sqliteClient);
const globalState = globalThis as typeof globalThis & {
__fiscalSqliteClient?: Database;
__fiscalDrizzleDb?: unknown;
};
globalState.__fiscalSqliteClient = sqliteClient;
globalState.__fiscalDrizzleDb = undefined;
overviewCacheRepo = await import('./company-overview-cache');
});
afterAll(() => {
sqliteClient?.close();
resetDbSingletons();
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true });
}
});
beforeEach(() => {
if (!sqliteClient) {
throw new Error('sqlite client not initialized');
}
clearCache(sqliteClient);
});
it('upserts and reloads cached overview payloads', async () => {
if (!overviewCacheRepo) {
throw new Error('overview cache repo not initialized');
}
const first = await overviewCacheRepo.upsertCompanyOverviewCache({
userId: TEST_USER_ID,
ticker: 'msft',
sourceSignature: 'sig-1',
payload: buildAnalysisPayload('Microsoft Corporation')
});
const loaded = await overviewCacheRepo.getCompanyOverviewCache({
userId: TEST_USER_ID,
ticker: 'MSFT'
});
expect(first.ticker).toBe('MSFT');
expect(loaded?.source_signature).toBe('sig-1');
expect(loaded?.payload.company.companyName).toBe('Microsoft Corporation');
});
it('updates existing cached rows in place', async () => {
if (!overviewCacheRepo) {
throw new Error('overview cache repo not initialized');
}
const first = await overviewCacheRepo.upsertCompanyOverviewCache({
userId: TEST_USER_ID,
ticker: 'MSFT',
sourceSignature: 'sig-1',
payload: buildAnalysisPayload('Old Name')
});
const second = await overviewCacheRepo.upsertCompanyOverviewCache({
userId: TEST_USER_ID,
ticker: 'MSFT',
sourceSignature: 'sig-2',
payload: buildAnalysisPayload('New Name')
});
const loaded = await overviewCacheRepo.getCompanyOverviewCache({
userId: TEST_USER_ID,
ticker: 'MSFT'
});
expect(second.id).toBe(first.id);
expect(loaded?.source_signature).toBe('sig-2');
expect(loaded?.payload.company.companyName).toBe('New Name');
});
});

View File

@@ -0,0 +1,102 @@
import { and, eq } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/bun-sqlite';
import type { CompanyAnalysis } from '@/lib/types';
import { getSqliteClient } from '@/lib/server/db';
import { companyOverviewCache, schema } from '@/lib/server/db/schema';
export const CURRENT_COMPANY_OVERVIEW_CACHE_VERSION = 1;
export type CompanyOverviewCacheRecord = {
id: number;
user_id: string;
ticker: string;
cache_version: number;
source_signature: string;
payload: CompanyAnalysis;
created_at: string;
updated_at: string;
};
function toRecord(row: typeof companyOverviewCache.$inferSelect): CompanyOverviewCacheRecord {
return {
id: row.id,
user_id: row.user_id,
ticker: row.ticker,
cache_version: row.cache_version,
source_signature: row.source_signature,
payload: row.payload as CompanyAnalysis,
created_at: row.created_at,
updated_at: row.updated_at
};
}
function getDb() {
return drizzle(getSqliteClient(), { schema });
}
export async function getCompanyOverviewCache(input: { userId: string; ticker: string }) {
const normalizedTicker = input.ticker.trim().toUpperCase();
if (!normalizedTicker) {
return null;
}
const [row] = await getDb()
.select()
.from(companyOverviewCache)
.where(and(
eq(companyOverviewCache.user_id, input.userId),
eq(companyOverviewCache.ticker, normalizedTicker)
))
.limit(1);
return row ? toRecord(row) : null;
}
export async function upsertCompanyOverviewCache(input: {
userId: string;
ticker: string;
sourceSignature: string;
payload: CompanyAnalysis;
}) {
const now = new Date().toISOString();
const normalizedTicker = input.ticker.trim().toUpperCase();
const [saved] = await getDb()
.insert(companyOverviewCache)
.values({
user_id: input.userId,
ticker: normalizedTicker,
cache_version: CURRENT_COMPANY_OVERVIEW_CACHE_VERSION,
source_signature: input.sourceSignature,
payload: input.payload as unknown as Record<string, unknown>,
created_at: now,
updated_at: now
})
.onConflictDoUpdate({
target: [companyOverviewCache.user_id, companyOverviewCache.ticker],
set: {
cache_version: CURRENT_COMPANY_OVERVIEW_CACHE_VERSION,
source_signature: input.sourceSignature,
payload: input.payload as unknown as Record<string, unknown>,
updated_at: now
}
})
.returning();
return toRecord(saved);
}
export async function deleteCompanyOverviewCache(input: { userId: string; ticker: string }) {
const normalizedTicker = input.ticker.trim().toUpperCase();
return await getDb()
.delete(companyOverviewCache)
.where(and(
eq(companyOverviewCache.user_id, input.userId),
eq(companyOverviewCache.ticker, normalizedTicker)
));
}
export const __companyOverviewCacheInternals = {
CACHE_VERSION: CURRENT_COMPANY_OVERVIEW_CACHE_VERSION
};

View File

@@ -67,7 +67,8 @@ describe('task repos', () => {
'0006_coverage_journal_tracking.sql',
'0007_company_financial_bundles.sql',
'0008_research_workspace.sql',
'0009_task_notification_context.sql'
'0009_task_notification_context.sql',
'0012_company_overview_cache.sql'
]) {
applyMigration(sqliteClient, file);
}