Add company overview skeleton and cache
This commit is contained in:
202
lib/server/repos/company-overview-cache.test.ts
Normal file
202
lib/server/repos/company-overview-cache.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
102
lib/server/repos/company-overview-cache.ts
Normal file
102
lib/server/repos/company-overview-cache.ts
Normal 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
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user