Merge branch 't3code/company-overview-loading-cache'

This commit is contained in:
2026-03-13 19:05:25 -04:00
18 changed files with 1571 additions and 158 deletions

View File

@@ -4,6 +4,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { AppShell } from '@/components/shell/app-shell';
import { CompanyAnalysisSkeleton } from '@/components/analysis/company-analysis-skeleton';
import { AnalysisToolbar } from '@/components/analysis/analysis-toolbar';
import { BullBearPanel } from '@/components/analysis/bull-bear-panel';
import { CompanyOverviewCard } from '@/components/analysis/company-overview-card';
@@ -55,21 +56,28 @@ function AnalysisPageContent() {
setTicker(normalized);
}, [searchParams]);
const loadAnalysis = useCallback(async (symbol: string) => {
const options = companyAnalysisQueryOptions(symbol);
const loadAnalysis = useCallback(async (symbol: string, options?: { refresh?: boolean }) => {
const queryOptions = companyAnalysisQueryOptions(symbol, options);
if (!queryClient.getQueryData(options.queryKey)) {
if (!queryClient.getQueryData(queryOptions.queryKey)) {
setLoading(true);
}
setError(null);
try {
const response = await queryClient.fetchQuery(options);
const response = await queryClient.fetchQuery(queryOptions);
setAnalysis(response.analysis);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to load company overview');
setAnalysis(null);
setAnalysis((current) => {
const normalizedTicker = symbol.trim().toUpperCase();
if (options?.refresh && current?.company.ticker === normalizedTicker) {
return current;
}
return null;
});
} finally {
setLoading(false);
}
@@ -116,7 +124,7 @@ function AnalysisPageContent() {
onRefresh={() => {
const normalizedTicker = activeTicker.trim().toUpperCase();
void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(normalizedTicker) });
void loadAnalysis(normalizedTicker);
void loadAnalysis(normalizedTicker, { refresh: true });
}}
quickLinks={quickLinks}
onLinkPrefetch={() => prefetchResearchTicker(activeTicker)}
@@ -128,7 +136,9 @@ function AnalysisPageContent() {
</Panel>
) : null}
{analysis ? (
{!analysis && loading ? (
<CompanyAnalysisSkeleton />
) : analysis ? (
<>
<section className="grid gap-6 xl:grid-cols-[minmax(320px,1fr)_minmax(0,2fr)]">
<CompanyOverviewCard

View File

@@ -0,0 +1,141 @@
import { Panel } from '@/components/ui/panel';
function SkeletonLine(props: { className: string }) {
return (
<div
aria-hidden="true"
className={`rounded-full bg-[color:var(--panel-soft)] motion-safe:animate-pulse ${props.className}`}
/>
);
}
function SkeletonCard(props: {
title?: string;
subtitle?: string;
children: React.ReactNode;
className?: string;
}) {
return (
<Panel title={props.title} subtitle={props.subtitle} className={props.className}>
{props.children}
</Panel>
);
}
export function CompanyAnalysisSkeleton() {
return (
<div className="space-y-6" data-testid="analysis-overview-skeleton" aria-live="polite" aria-busy="true">
<span className="sr-only">Loading company overview</span>
<section className="grid gap-6 xl:grid-cols-[minmax(320px,1fr)_minmax(0,2fr)]">
<Panel className="h-full pt-2">
<div className="space-y-5">
<div className="space-y-3">
<SkeletonLine className="h-8 w-3/4" />
<SkeletonLine className="h-3 w-1/2" />
</div>
<div className="border-t border-[color:var(--line-weak)] py-4">
<div className="space-y-3">
<SkeletonLine className="h-3 w-28" />
<SkeletonLine className="h-4 w-full" />
<SkeletonLine className="h-4 w-[92%]" />
<SkeletonLine className="h-4 w-[84%]" />
<SkeletonLine className="h-4 w-[66%]" />
</div>
</div>
</div>
</Panel>
<SkeletonCard title="Price chart" className="pt-2">
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{Array.from({ length: 3 }, (_, index) => (
<div key={index} className="border-b border-[color:var(--line-weak)] px-4 py-4 sm:border-b-0 sm:border-r last:border-r-0">
<SkeletonLine className="h-3 w-24" />
<SkeletonLine className="mt-3 h-7 w-28" />
<SkeletonLine className="mt-3 h-3 w-20" />
</div>
))}
</div>
<div className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
<SkeletonLine className="h-[288px] w-full rounded-xl" />
</div>
</div>
</SkeletonCard>
</section>
<section className="grid gap-6 xl:grid-cols-2">
<SkeletonCard title="Company profile facts" className="pt-2">
<div className="space-y-3">
{Array.from({ length: 4 }, (_, index) => (
<div key={index} className="grid grid-cols-[18%_32%_18%_32%] gap-3 border-t border-[color:var(--line-weak)] py-2">
<SkeletonLine className="h-3 w-16" />
<SkeletonLine className="h-4 w-20" />
<SkeletonLine className="h-3 w-16" />
<SkeletonLine className="h-4 w-24" />
</div>
))}
</div>
</SkeletonCard>
<SkeletonCard title="Valuation" className="pt-2">
<div className="space-y-3">
{Array.from({ length: 4 }, (_, index) => (
<div key={index} className="grid grid-cols-[18%_32%_18%_32%] gap-3 border-t border-[color:var(--line-weak)] py-2">
<SkeletonLine className="h-3 w-20" />
<SkeletonLine className="h-4 w-24" />
<SkeletonLine className="h-3 w-20" />
<SkeletonLine className="h-4 w-20" />
</div>
))}
</div>
</SkeletonCard>
</section>
<SkeletonCard title="Bull vs Bear" subtitle="The highest-level reasons investors may lean in or lean out right now." className="pt-2">
<div className="grid gap-4 lg:grid-cols-2">
{Array.from({ length: 2 }, (_, index) => (
<section key={index} className="border-t border-[color:var(--line-weak)] pt-5">
<SkeletonLine className="h-6 w-28" />
<div className="mt-4 space-y-3">
{Array.from({ length: 3 }, (_, bulletIndex) => (
<div key={bulletIndex} className="border-t border-[color:var(--line-weak)] pt-3">
<SkeletonLine className="h-4 w-full" />
</div>
))}
</div>
</section>
))}
</div>
</SkeletonCard>
<section className="grid gap-6 xl:grid-cols-[minmax(280px,0.72fr)_minmax(0,1.28fr)]">
<SkeletonCard title="Past 7 Days" className="pt-2">
<div className="space-y-3">
<SkeletonLine className="h-4 w-full" />
<SkeletonLine className="h-4 w-[88%]" />
<div className="space-y-2 pt-2">
{Array.from({ length: 3 }, (_, index) => (
<SkeletonLine key={index} className="h-4 w-full" />
))}
</div>
</div>
</SkeletonCard>
<SkeletonCard title="Recent Developments" subtitle="SEC-first event cards sourced from filings and attached analysis." className="pt-2">
<div className="grid gap-3 md:grid-cols-2">
{Array.from({ length: 4 }, (_, index) => (
<article key={index} className="border-t border-[color:var(--line-weak)] pt-4">
<SkeletonLine className="h-3 w-28" />
<SkeletonLine className="mt-3 h-5 w-3/4" />
<SkeletonLine className="mt-3 h-4 w-full" />
<SkeletonLine className="mt-2 h-4 w-[90%]" />
<SkeletonLine className="mt-4 h-3 w-24" />
</article>
))}
</div>
</SkeletonCard>
</section>
</div>
);
}

View File

@@ -0,0 +1,15 @@
CREATE TABLE `company_overview_cache` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` text NOT NULL,
`ticker` text NOT NULL,
`cache_version` integer NOT NULL,
`source_signature` text NOT NULL,
`payload` text NOT NULL,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `company_overview_cache_uidx` ON `company_overview_cache` (`user_id`,`ticker`);
--> statement-breakpoint
CREATE INDEX `company_overview_cache_lookup_idx` ON `company_overview_cache` (`user_id`,`ticker`,`updated_at`);

127
e2e/analysis.spec.ts Normal file
View File

@@ -0,0 +1,127 @@
import { expect, test, type Page, type TestInfo } from '@playwright/test';
const PASSWORD = 'Sup3rSecure!123';
function toSlug(value: string) {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48);
}
async function signUp(page: Page, testInfo: TestInfo) {
const email = `playwright-analysis-${testInfo.workerIndex}-${toSlug(testInfo.title)}-${Date.now()}@example.com`;
await page.goto('/auth/signup');
await page.locator('input[autocomplete="name"]').fill('Playwright Analysis User');
await page.locator('input[autocomplete="email"]').fill(email);
await page.locator('input[autocomplete="new-password"]').first().fill(PASSWORD);
await page.locator('input[autocomplete="new-password"]').nth(1).fill(PASSWORD);
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByRole('heading', { name: 'Command Center' })).toBeVisible({ timeout: 30_000 });
await expect(page).toHaveURL(/\/$/, { timeout: 30_000 });
}
test('shows the overview skeleton while analysis is loading', async ({ page }, testInfo) => {
await signUp(page, testInfo);
await page.route('**/api/analysis/company**', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 700));
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
analysis: {
company: {
ticker: 'MSFT',
companyName: 'Microsoft Corporation',
sector: 'Technology',
category: null,
tags: [],
cik: '0000789019'
},
quote: 425.12,
position: null,
priceHistory: [
{ date: '2025-01-01T00:00:00.000Z', close: 380 },
{ date: '2026-01-01T00:00:00.000Z', close: 425.12 }
],
benchmarkHistory: [
{ date: '2025-01-01T00:00:00.000Z', close: 5000 },
{ date: '2026-01-01T00:00:00.000Z', close: 5400 }
],
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: 'Microsoft builds cloud and software products worldwide.',
exchange: 'NASDAQ',
industry: 'Software',
country: 'United States',
website: 'https://www.microsoft.com',
fiscalYearEnd: '06/30',
employeeCount: 220000,
source: 'sec_derived'
},
valuationSnapshot: {
sharesOutstanding: 7430000000,
marketCap: 3150000000000,
enterpriseValue: 3200000000000,
trailingPe: 35,
evToRevenue: 12,
evToEbitda: null,
source: 'derived'
},
bullBear: {
source: 'memo_fallback',
bull: ['Azure and Copilot demand remain durable.'],
bear: ['Valuation leaves less room for execution misses.'],
updatedAt: '2026-03-13T00:00:00.000Z'
},
recentDevelopments: {
status: 'ready',
items: [{
id: 'msft-1',
kind: '8-K',
title: 'Microsoft filed an 8-K',
url: 'https://www.sec.gov/Archives/test.htm',
source: 'SEC filings',
publishedAt: '2026-03-10',
summary: 'The company disclosed a current report with updated commercial details.',
accessionNumber: '0000000000-26-000001'
}],
weeklySnapshot: {
summary: 'The week centered on filing-driven updates.',
highlights: ['An 8-K added current commercial context.'],
itemCount: 1,
startDate: '2026-03-07',
endDate: '2026-03-13',
updatedAt: '2026-03-13T00:00:00.000Z',
source: 'heuristic'
}
}
}
})
});
});
await page.goto('/analysis?ticker=MSFT');
await expect(page.getByTestId('analysis-overview-skeleton')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Microsoft Corporation' })).toBeVisible();
await expect(page.getByText('Bull vs Bear')).toBeVisible();
});

View File

@@ -559,10 +559,11 @@ export async function getSearchAnswer(input: {
}, 'Unable to generate cited answer');
}
export async function getCompanyAnalysis(ticker: string) {
export async function getCompanyAnalysis(ticker: string, options?: { refresh?: boolean }) {
const result = await client.api.analysis.company.get({
$query: {
ticker: ticker.trim().toUpperCase()
ticker: ticker.trim().toUpperCase(),
...(options?.refresh ? { refresh: 'true' } : {})
}
});

View File

@@ -27,12 +27,12 @@ import type {
ResearchArtifactSource
} from '@/lib/types';
export function companyAnalysisQueryOptions(ticker: string) {
export function companyAnalysisQueryOptions(ticker: string, options?: { refresh?: boolean }) {
const normalizedTicker = ticker.trim().toUpperCase();
return queryOptions({
queryKey: queryKeys.companyAnalysis(normalizedTicker),
queryFn: () => getCompanyAnalysis(normalizedTicker),
queryFn: () => getCompanyAnalysis(normalizedTicker, options),
staleTime: 120_000
});
}

View File

@@ -70,12 +70,6 @@ import {
updateWatchlistReviewByTicker,
upsertWatchlistItemRecord
} from '@/lib/server/repos/watchlist';
import { getPriceHistory, getQuote } from '@/lib/server/prices';
import { synthesizeCompanyOverview } from '@/lib/server/company-overview-synthesis';
import { getRecentDevelopments } from '@/lib/server/recent-developments';
import { deriveValuationSnapshot, getSecCompanyProfile, toCompanyProfile } from '@/lib/server/sec-company-profile';
import { getCompanyDescription } from '@/lib/server/sec-description';
import { getYahooCompanyDescription } from '@/lib/server/yahoo-company-profile';
import { answerSearchQuery, searchKnowledgeBase } from '@/lib/server/search';
import {
enqueueTask,
@@ -86,6 +80,7 @@ import {
listRecentTasks,
updateTaskNotification
} from '@/lib/server/tasks';
import { getCompanyAnalysisPayload } from '@/lib/server/company-analysis';
const ALLOWED_STATUSES: TaskStatus[] = ['queued', 'running', 'completed', 'failed'];
const FINANCIAL_FORMS: ReadonlySet<Filing['filing_type']> = new Set(['10-K', '10-Q']);
@@ -1390,145 +1385,20 @@ export const app = new Elysia({ prefix: '/api' })
if (!ticker) {
return jsonError('ticker is required');
}
const [filings, holding, watchlistItem, liveQuote, priceHistory, benchmarkHistory, journalPreview, memo, secProfile] = await Promise.all([
listFilingsRecords({ ticker, limit: 40 }),
getHoldingByTicker(session.user.id, ticker),
getWatchlistItemByTicker(session.user.id, ticker),
getQuote(ticker),
getPriceHistory(ticker),
getPriceHistory('^GSPC'),
listResearchJournalEntries(session.user.id, ticker, 6),
getResearchMemoByTicker(session.user.id, ticker),
getSecCompanyProfile(ticker)
]);
const redactedFilings = filings
.map(redactInternalFilingAnalysisFields)
.map(withFinancialMetricsPolicy);
const latestFiling = redactedFilings[0] ?? null;
const companyName = latestFiling?.company_name
?? secProfile?.companyName
?? holding?.company_name
?? watchlistItem?.company_name
?? ticker;
const financials = redactedFilings
.filter((entry) => entry.metrics && FINANCIAL_FORMS.has(entry.filing_type))
.map((entry) => ({
filingDate: entry.filing_date,
filingType: entry.filing_type,
revenue: entry.metrics?.revenue ?? null,
netIncome: entry.metrics?.netIncome ?? null,
totalAssets: entry.metrics?.totalAssets ?? null,
cash: entry.metrics?.cash ?? null,
debt: entry.metrics?.debt ?? null
}));
const aiReports = redactedFilings
.filter((entry) => entry.analysis?.text || entry.analysis?.legacyInsights)
.slice(0, 8)
.map((entry) => ({
accessionNumber: entry.accession_number,
filingDate: entry.filing_date,
filingType: entry.filing_type,
provider: entry.analysis?.provider ?? 'unknown',
model: entry.analysis?.model ?? 'unknown',
summary: entry.analysis?.text ?? entry.analysis?.legacyInsights ?? ''
}));
const latestMetricsFiling = redactedFilings.find((entry) => entry.metrics) ?? null;
const referenceMetrics = latestMetricsFiling?.metrics ?? null;
const keyMetrics = {
referenceDate: latestMetricsFiling?.filing_date ?? latestFiling?.filing_date ?? null,
revenue: referenceMetrics?.revenue ?? null,
netIncome: referenceMetrics?.netIncome ?? null,
totalAssets: referenceMetrics?.totalAssets ?? null,
cash: referenceMetrics?.cash ?? null,
debt: referenceMetrics?.debt ?? null,
netMargin: referenceMetrics?.revenue && referenceMetrics.netIncome !== null
? (referenceMetrics.netIncome / referenceMetrics.revenue) * 100
: null
};
const annualFiling = redactedFilings.find((entry) => entry.filing_type === '10-K') ?? null;
const [secDescription, yahooDescription, synthesizedDevelopments] = await Promise.all([
getCompanyDescription(annualFiling),
getYahooCompanyDescription(ticker),
getRecentDevelopments(ticker, { filings: redactedFilings })
]);
const description = yahooDescription ?? secDescription;
const latestFilingSummary = latestFiling
? {
accessionNumber: latestFiling.accession_number,
filingDate: latestFiling.filing_date,
filingType: latestFiling.filing_type,
filingUrl: latestFiling.filing_url,
submissionUrl: latestFiling.submission_url ?? null,
summary: latestFiling.analysis?.text ?? latestFiling.analysis?.legacyInsights ?? null,
hasAnalysis: Boolean(latestFiling.analysis?.text || latestFiling.analysis?.legacyInsights)
}
: null;
const companyProfile = toCompanyProfile(secProfile, description);
const valuationSnapshot = deriveValuationSnapshot({
quote: liveQuote,
sharesOutstanding: secProfile?.sharesOutstanding ?? null,
revenue: keyMetrics.revenue,
cash: keyMetrics.cash,
debt: keyMetrics.debt,
netIncome: keyMetrics.netIncome
});
const synthesis = await synthesizeCompanyOverview({
const refresh = asBoolean(query.refresh, false);
const analysis = await getCompanyAnalysisPayload({
userId: session.user.id,
ticker,
companyName,
description,
memo,
latestFilingSummary,
recentAiReports: aiReports.slice(0, 5),
recentDevelopments: synthesizedDevelopments.items
refresh
});
const recentDevelopments = {
...synthesizedDevelopments,
weeklySnapshot: synthesis.weeklySnapshot,
status: synthesizedDevelopments.items.length > 0
? synthesis.weeklySnapshot ? 'ready' : 'partial'
: synthesis.weeklySnapshot ? 'partial' : 'unavailable'
} as const;
return Response.json({
analysis: {
company: {
ticker,
companyName,
sector: watchlistItem?.sector ?? null,
category: watchlistItem?.category ?? null,
tags: watchlistItem?.tags ?? [],
cik: latestFiling?.cik ?? null
},
quote: liveQuote,
position: holding,
priceHistory,
benchmarkHistory,
financials,
filings: redactedFilings.slice(0, 20),
aiReports,
coverage: watchlistItem
? {
...watchlistItem,
latest_filing_date: latestFiling?.filing_date ?? watchlistItem.latest_filing_date ?? null
}
: null,
journalPreview,
recentAiReports: aiReports.slice(0, 5),
latestFilingSummary,
keyMetrics,
companyProfile,
valuationSnapshot,
bullBear: synthesis.bullBear,
recentDevelopments
}
analysis
});
}, {
query: t.Object({
ticker: t.String({ minLength: 1 })
ticker: t.String({ minLength: 1 }),
refresh: t.Optional(t.String())
})
})
.get('/financials/company', async ({ query }) => {

View File

@@ -127,7 +127,8 @@ function applySqlMigrations(client: { exec: (query: string) => void }) {
'0008_research_workspace.sql',
'0009_task_notification_context.sql',
'0010_taxonomy_surface_sidecar.sql',
'0011_remove_legacy_xbrl_defaults.sql'
'0011_remove_legacy_xbrl_defaults.sql',
'0012_company_overview_cache.sql'
];
for (const file of migrationFiles) {
@@ -165,6 +166,7 @@ function clearProjectionTables(client: { exec: (query: string) => void }) {
client.exec('DELETE FROM holding;');
client.exec('DELETE FROM watchlist_item;');
client.exec('DELETE FROM portfolio_insight;');
client.exec('DELETE FROM company_overview_cache;');
client.exec('DELETE FROM filing;');
}
@@ -246,6 +248,73 @@ async function jsonRequest(
};
}
function buildCachedAnalysisPayload(input: {
ticker: string;
companyName: string;
bull?: string[];
}) {
return {
company: {
ticker: input.ticker,
companyName: input.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: input.bull && input.bull.length > 0 ? 'memo_fallback' : 'unavailable',
bull: input.bull ?? [],
bear: [],
updatedAt: new Date().toISOString()
},
recentDevelopments: {
status: 'unavailable',
items: [],
weeklySnapshot: null
}
};
}
if (process.env.RUN_TASK_WORKFLOW_E2E === '1') {
describe('task workflow hybrid migration e2e', () => {
beforeAll(async () => {
@@ -472,7 +541,7 @@ if (process.env.RUN_TASK_WORKFLOW_E2E === '1') {
ticker: 'NFLX',
accessionNumber: '0000000000-26-000777',
filingType: '10-K',
filingDate: '2026-02-15',
filingDate: '2026-03-10',
companyName: 'Netflix, Inc.',
metrics: {
revenue: 41000000000,
@@ -575,6 +644,157 @@ if (process.env.RUN_TASK_WORKFLOW_E2E === '1') {
}).entries).toHaveLength(0);
});
it('serves cached analysis until refresh is requested', async () => {
if (!sqliteClient) {
throw new Error('sqlite client not initialized');
}
seedFilingRecord(sqliteClient, {
ticker: 'CACH',
accessionNumber: '0000000000-26-000901',
filingType: '10-K',
filingDate: '2026-02-20',
companyName: 'Live Corp'
});
const filingRow = sqliteClient.query(`
SELECT created_at, updated_at
FROM filing
WHERE ticker = 'CACH'
ORDER BY id DESC
LIMIT 1
`).get() as { created_at: string; updated_at: string } | null;
if (!filingRow) {
throw new Error('cached filing row not found');
}
const { __companyAnalysisInternals } = await import('../company-analysis');
const sourceSignature = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({
ticker: 'CACH',
localInputs: {
filings: [{
id: 1,
ticker: 'CACH',
filing_type: '10-K',
filing_date: '2026-02-20',
accession_number: '0000000000-26-000901',
cik: '0000000000',
company_name: 'Live Corp',
filing_url: 'https://www.sec.gov/Archives/0000000000-26-000901.htm',
submission_url: 'https://www.sec.gov/submissions/0000000000-26-000901.json',
primary_document: '0000000000-26-000901.htm',
metrics: null,
analysis: null,
created_at: filingRow.created_at,
updated_at: filingRow.updated_at
}],
holding: null,
watchlistItem: null,
journalPreview: [],
memo: null
}
});
const now = new Date().toISOString();
sqliteClient.query(`
INSERT INTO company_overview_cache (
user_id,
ticker,
cache_version,
source_signature,
payload,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
TEST_USER_ID,
'CACH',
1,
sourceSignature,
JSON.stringify(buildCachedAnalysisPayload({
ticker: 'CACH',
companyName: 'Cached Corp'
})),
now,
now
);
const cached = await jsonRequest('GET', '/api/analysis/company?ticker=CACH');
expect(cached.response.status).toBe(200);
expect((cached.json as {
analysis: { company: { companyName: string } };
}).analysis.company.companyName).toBe('Cached Corp');
const refreshed = await jsonRequest('GET', '/api/analysis/company?ticker=CACH&refresh=true');
expect(refreshed.response.status).toBe(200);
expect((refreshed.json as {
analysis: { company: { companyName: string } };
}).analysis.company.companyName).toBe('Live Corp');
});
it('invalidates cached analysis when the memo changes', async () => {
if (!sqliteClient) {
throw new Error('sqlite client not initialized');
}
seedFilingRecord(sqliteClient, {
ticker: 'MEMO',
accessionNumber: '0000000000-26-000902',
filingType: '10-K',
filingDate: '2026-02-20',
companyName: 'Memo Corp'
});
sqliteClient.query(`
INSERT INTO research_memo (
user_id,
organization_id,
ticker,
rating,
conviction,
time_horizon_months,
packet_title,
packet_subtitle,
thesis_markdown,
variant_view_markdown,
catalysts_markdown,
risks_markdown,
disconfirming_evidence_markdown,
next_actions_markdown,
created_at,
updated_at
) VALUES (?, NULL, ?, 'buy', 'high', 24, NULL, NULL, ?, '', '', '', '', '', ?, ?)
`).run(
TEST_USER_ID,
'MEMO',
'Legacy thesis still holds.',
'2026-03-13T00:00:00.000Z',
'2026-03-13T00:00:00.000Z'
);
const first = await jsonRequest('GET', '/api/analysis/company?ticker=MEMO');
expect(first.response.status).toBe(200);
expect((first.json as {
analysis: { bullBear: { bull: string[] } };
}).analysis.bullBear.bull.join(' ')).toContain('Legacy thesis');
sqliteClient.query(`
UPDATE research_memo
SET thesis_markdown = ?, updated_at = ?
WHERE user_id = ? AND ticker = ?
`).run(
'Updated thesis drives the next refresh.',
'2026-03-13T01:00:00.000Z',
TEST_USER_ID,
'MEMO'
);
const second = await jsonRequest('GET', '/api/analysis/company?ticker=MEMO');
expect(second.response.status).toBe(200);
expect((second.json as {
analysis: { bullBear: { bull: string[] } };
}).analysis.bullBear.bull.join(' ')).toContain('Updated thesis');
});
it('persists nullable holding company names and allows later enrichment', async () => {
const created = await jsonRequest('POST', '/api/portfolio/holdings', {
ticker: 'ORCL',

View File

@@ -0,0 +1,251 @@
import { describe, expect, it, mock } from 'bun:test';
import type {
CompanyAnalysis,
Filing,
ResearchMemo
} from '@/lib/types';
import {
__companyAnalysisInternals,
getCompanyAnalysisPayload
} from './company-analysis';
function buildFiling(updatedAt = '2026-03-10T00:00:00.000Z'): Filing {
return {
id: 1,
ticker: 'MSFT',
filing_type: '10-K',
filing_date: '2026-02-01',
accession_number: '0000000000-26-000001',
cik: '0000789019',
company_name: 'Microsoft Corporation',
filing_url: 'https://www.sec.gov/Archives/test.htm',
submission_url: 'https://www.sec.gov/submissions/test.json',
primary_document: 'test.htm',
metrics: null,
analysis: null,
created_at: updatedAt,
updated_at: updatedAt
};
}
function buildMemo(updatedAt = '2026-03-10T00:00:00.000Z', thesis = 'Azure remains durable.'): ResearchMemo {
return {
id: 1,
user_id: 'user-1',
organization_id: null,
ticker: 'MSFT',
rating: 'buy',
conviction: 'high',
time_horizon_months: 24,
packet_title: null,
packet_subtitle: null,
thesis_markdown: thesis,
variant_view_markdown: '',
catalysts_markdown: '',
risks_markdown: '',
disconfirming_evidence_markdown: '',
next_actions_markdown: '',
created_at: updatedAt,
updated_at: updatedAt
};
}
function buildLocalInputs(overrides: Partial<Parameters<typeof __companyAnalysisInternals.buildCompanyAnalysisSourceSignature>[0]['localInputs']> = {}) {
return {
filings: [buildFiling()],
holding: null,
watchlistItem: null,
journalPreview: [],
memo: null,
...overrides
};
}
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
}
};
}
function buildCacheRecord(payload: CompanyAnalysis, sourceSignature: string) {
return {
id: 1,
user_id: 'user-1',
ticker: 'MSFT',
cache_version: 1,
source_signature: sourceSignature,
payload,
created_at: '2026-03-13T11:55:00.000Z',
updated_at: '2026-03-13T11:55:00.000Z'
};
}
describe('company analysis cache orchestration', () => {
it('changes the source signature when local inputs change', () => {
const base = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({
ticker: 'MSFT',
localInputs: buildLocalInputs()
});
const memoChanged = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({
ticker: 'MSFT',
localInputs: buildLocalInputs({ memo: buildMemo('2026-03-11T00:00:00.000Z') })
});
const filingChanged = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({
ticker: 'MSFT',
localInputs: buildLocalInputs({ filings: [buildFiling('2026-03-11T00:00:00.000Z')] })
});
const journalChanged = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({
ticker: 'MSFT',
localInputs: buildLocalInputs({
journalPreview: [{
id: 7,
user_id: 'user-1',
ticker: 'MSFT',
accession_number: null,
entry_type: 'note',
title: 'Updated note',
body_markdown: 'Body',
metadata: null,
created_at: '2026-03-01T00:00:00.000Z',
updated_at: '2026-03-11T00:00:00.000Z'
}]
})
});
expect(memoChanged).not.toBe(base);
expect(filingChanged).not.toBe(base);
expect(journalChanged).not.toBe(base);
});
it('returns a fresh cached payload when signature and ttl match', async () => {
const localInputs = buildLocalInputs();
const sourceSignature = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({
ticker: 'MSFT',
localInputs
});
const buildOverview = mock(async () => buildAnalysisPayload('Built Corp'));
const upsertCachedOverview = mock(async () => buildCacheRecord(buildAnalysisPayload('Cached Corp'), sourceSignature));
const analysis = await getCompanyAnalysisPayload({
userId: 'user-1',
ticker: 'MSFT',
now: new Date('2026-03-13T12:00:00.000Z')
}, {
getLocalInputs: async () => localInputs,
getCachedOverview: async () => buildCacheRecord(buildAnalysisPayload('Cached Corp'), sourceSignature),
buildOverview,
upsertCachedOverview
});
expect(analysis.company.companyName).toBe('Cached Corp');
expect(buildOverview).not.toHaveBeenCalled();
expect(upsertCachedOverview).not.toHaveBeenCalled();
});
it('bypasses the cache when refresh is requested', async () => {
const localInputs = buildLocalInputs();
const sourceSignature = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({
ticker: 'MSFT',
localInputs
});
const buildOverview = mock(async () => buildAnalysisPayload('Refreshed Corp'));
const upsertCachedOverview = mock(async () => buildCacheRecord(buildAnalysisPayload('Refreshed Corp'), sourceSignature));
const analysis = await getCompanyAnalysisPayload({
userId: 'user-1',
ticker: 'MSFT',
refresh: true,
now: new Date('2026-03-13T12:00:00.000Z')
}, {
getLocalInputs: async () => localInputs,
getCachedOverview: async () => buildCacheRecord(buildAnalysisPayload('Cached Corp'), sourceSignature),
buildOverview,
upsertCachedOverview
});
expect(analysis.company.companyName).toBe('Refreshed Corp');
expect(buildOverview).toHaveBeenCalledTimes(1);
expect(upsertCachedOverview).toHaveBeenCalledTimes(1);
});
it('rebuilds when local inputs invalidate the cached signature', async () => {
const cachedInputs = buildLocalInputs({ memo: buildMemo('2026-03-10T00:00:00.000Z', 'Old memo') });
const freshInputs = buildLocalInputs({ memo: buildMemo('2026-03-11T00:00:00.000Z', 'New memo') });
const staleSignature = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({
ticker: 'MSFT',
localInputs: cachedInputs
});
const buildOverview = mock(async () => buildAnalysisPayload('Rebuilt Corp'));
const analysis = await getCompanyAnalysisPayload({
userId: 'user-1',
ticker: 'MSFT',
now: new Date('2026-03-13T12:00:00.000Z')
}, {
getLocalInputs: async () => freshInputs,
getCachedOverview: async () => buildCacheRecord(buildAnalysisPayload('Cached Corp'), staleSignature),
buildOverview,
upsertCachedOverview: async () => buildCacheRecord(buildAnalysisPayload('Rebuilt Corp'), staleSignature)
});
expect(analysis.company.companyName).toBe('Rebuilt Corp');
expect(buildOverview).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,336 @@
import { createHash } from 'node:crypto';
import type {
CompanyAiReport,
CompanyAnalysis,
Filing,
Holding,
ResearchJournalEntry,
ResearchMemo,
WatchlistItem
} from '@/lib/types';
import { synthesizeCompanyOverview } from '@/lib/server/company-overview-synthesis';
import { getPriceHistory, getQuote } from '@/lib/server/prices';
import { getRecentDevelopments } from '@/lib/server/recent-developments';
import { deriveValuationSnapshot, getSecCompanyProfile, toCompanyProfile } from '@/lib/server/sec-company-profile';
import { getCompanyDescription } from '@/lib/server/sec-description';
import { getYahooCompanyDescription } from '@/lib/server/yahoo-company-profile';
import { redactInternalFilingAnalysisFields } from '@/lib/server/api/filing-redaction';
import { listFilingsRecords } from '@/lib/server/repos/filings';
import { getHoldingByTicker } from '@/lib/server/repos/holdings';
import { getResearchMemoByTicker } from '@/lib/server/repos/research-library';
import { listResearchJournalEntries } from '@/lib/server/repos/research-journal';
import { getWatchlistItemByTicker } from '@/lib/server/repos/watchlist';
import {
CURRENT_COMPANY_OVERVIEW_CACHE_VERSION,
getCompanyOverviewCache,
upsertCompanyOverviewCache
} from '@/lib/server/repos/company-overview-cache';
const FINANCIAL_FORMS = new Set<Filing['filing_type']>(['10-K', '10-Q']);
const COMPANY_OVERVIEW_CACHE_TTL_MS = 1000 * 60 * 15;
export type CompanyAnalysisLocalInputs = {
filings: Filing[];
holding: Holding | null;
watchlistItem: WatchlistItem | null;
journalPreview: ResearchJournalEntry[];
memo: ResearchMemo | null;
};
function withFinancialMetricsPolicy(filing: Filing): Filing {
if (FINANCIAL_FORMS.has(filing.filing_type)) {
return filing;
}
return {
...filing,
metrics: null
};
}
export function buildCompanyAnalysisSourceSignature(input: {
ticker: string;
localInputs: CompanyAnalysisLocalInputs;
cacheVersion?: number;
}) {
const payload = {
ticker: input.ticker.trim().toUpperCase(),
cacheVersion: input.cacheVersion ?? CURRENT_COMPANY_OVERVIEW_CACHE_VERSION,
filings: input.localInputs.filings.map((filing) => ({
accessionNumber: filing.accession_number,
updatedAt: filing.updated_at
})),
memoUpdatedAt: input.localInputs.memo?.updated_at ?? null,
watchlistUpdatedAt: input.localInputs.watchlistItem?.updated_at ?? null,
holdingUpdatedAt: input.localInputs.holding?.updated_at ?? null,
journalPreview: input.localInputs.journalPreview.map((entry) => ({
id: entry.id,
updatedAt: entry.updated_at
}))
};
return createHash('sha256').update(JSON.stringify(payload)).digest('hex');
}
export function isCompanyOverviewCacheFresh(input: {
updatedAt: string;
sourceSignature: string;
expectedSourceSignature: string;
cacheVersion: number;
refresh?: boolean;
ttlMs?: number;
now?: Date;
}) {
if (input.refresh) {
return false;
}
if (input.cacheVersion !== CURRENT_COMPANY_OVERVIEW_CACHE_VERSION) {
return false;
}
if (input.sourceSignature !== input.expectedSourceSignature) {
return false;
}
const now = input.now ?? new Date();
const updatedAt = Date.parse(input.updatedAt);
if (!Number.isFinite(updatedAt)) {
return false;
}
return now.getTime() - updatedAt <= (input.ttlMs ?? COMPANY_OVERVIEW_CACHE_TTL_MS);
}
async function getCompanyAnalysisLocalInputs(input: { userId: string; ticker: string }): Promise<CompanyAnalysisLocalInputs> {
const ticker = input.ticker.trim().toUpperCase();
const [filings, holding, watchlistItem, journalPreview, memo] = await Promise.all([
listFilingsRecords({ ticker, limit: 40 }),
getHoldingByTicker(input.userId, ticker),
getWatchlistItemByTicker(input.userId, ticker),
listResearchJournalEntries(input.userId, ticker, 6),
getResearchMemoByTicker(input.userId, ticker)
]);
return {
filings,
holding,
watchlistItem,
journalPreview,
memo
};
}
async function buildCompanyAnalysisPayload(input: {
userId: string;
ticker: string;
localInputs: CompanyAnalysisLocalInputs;
}): Promise<CompanyAnalysis> {
const ticker = input.ticker.trim().toUpperCase();
const redactedFilings = input.localInputs.filings
.map(redactInternalFilingAnalysisFields)
.map(withFinancialMetricsPolicy);
const [liveQuote, priceHistory, benchmarkHistory, secProfile] = await Promise.all([
getQuote(ticker),
getPriceHistory(ticker),
getPriceHistory('^GSPC'),
getSecCompanyProfile(ticker)
]);
const latestFiling = redactedFilings[0] ?? null;
const companyName = latestFiling?.company_name
?? secProfile?.companyName
?? input.localInputs.holding?.company_name
?? input.localInputs.watchlistItem?.company_name
?? ticker;
const financials = redactedFilings
.filter((entry) => entry.metrics && FINANCIAL_FORMS.has(entry.filing_type))
.map((entry) => ({
filingDate: entry.filing_date,
filingType: entry.filing_type,
revenue: entry.metrics?.revenue ?? null,
netIncome: entry.metrics?.netIncome ?? null,
totalAssets: entry.metrics?.totalAssets ?? null,
cash: entry.metrics?.cash ?? null,
debt: entry.metrics?.debt ?? null
}));
const aiReports: CompanyAiReport[] = redactedFilings
.filter((entry) => entry.analysis?.text || entry.analysis?.legacyInsights)
.slice(0, 8)
.map((entry) => ({
accessionNumber: entry.accession_number,
filingDate: entry.filing_date,
filingType: entry.filing_type,
provider: entry.analysis?.provider ?? 'unknown',
model: entry.analysis?.model ?? 'unknown',
summary: entry.analysis?.text ?? entry.analysis?.legacyInsights ?? ''
}));
const latestMetricsFiling = redactedFilings.find((entry) => entry.metrics) ?? null;
const referenceMetrics = latestMetricsFiling?.metrics ?? null;
const keyMetrics = {
referenceDate: latestMetricsFiling?.filing_date ?? latestFiling?.filing_date ?? null,
revenue: referenceMetrics?.revenue ?? null,
netIncome: referenceMetrics?.netIncome ?? null,
totalAssets: referenceMetrics?.totalAssets ?? null,
cash: referenceMetrics?.cash ?? null,
debt: referenceMetrics?.debt ?? null,
netMargin: referenceMetrics?.revenue && referenceMetrics.netIncome !== null
? (referenceMetrics.netIncome / referenceMetrics.revenue) * 100
: null
};
const annualFiling = redactedFilings.find((entry) => entry.filing_type === '10-K') ?? null;
const [secDescription, yahooDescription, synthesizedDevelopments] = await Promise.all([
getCompanyDescription(annualFiling),
getYahooCompanyDescription(ticker),
getRecentDevelopments(ticker, { filings: redactedFilings })
]);
const description = yahooDescription ?? secDescription;
const latestFilingSummary = latestFiling
? {
accessionNumber: latestFiling.accession_number,
filingDate: latestFiling.filing_date,
filingType: latestFiling.filing_type,
filingUrl: latestFiling.filing_url,
submissionUrl: latestFiling.submission_url ?? null,
summary: latestFiling.analysis?.text ?? latestFiling.analysis?.legacyInsights ?? null,
hasAnalysis: Boolean(latestFiling.analysis?.text || latestFiling.analysis?.legacyInsights)
}
: null;
const companyProfile = toCompanyProfile(secProfile, description);
const valuationSnapshot = deriveValuationSnapshot({
quote: liveQuote,
sharesOutstanding: secProfile?.sharesOutstanding ?? null,
revenue: keyMetrics.revenue,
cash: keyMetrics.cash,
debt: keyMetrics.debt,
netIncome: keyMetrics.netIncome
});
const synthesis = await synthesizeCompanyOverview({
ticker,
companyName,
description,
memo: input.localInputs.memo,
latestFilingSummary,
recentAiReports: aiReports.slice(0, 5),
recentDevelopments: synthesizedDevelopments.items
});
const recentDevelopments = {
...synthesizedDevelopments,
weeklySnapshot: synthesis.weeklySnapshot,
status: synthesizedDevelopments.items.length > 0
? synthesis.weeklySnapshot ? 'ready' : 'partial'
: synthesis.weeklySnapshot ? 'partial' : 'unavailable'
} as const;
return {
company: {
ticker,
companyName,
sector: input.localInputs.watchlistItem?.sector ?? null,
category: input.localInputs.watchlistItem?.category ?? null,
tags: input.localInputs.watchlistItem?.tags ?? [],
cik: latestFiling?.cik ?? null
},
quote: liveQuote,
position: input.localInputs.holding,
priceHistory,
benchmarkHistory,
financials,
filings: redactedFilings.slice(0, 20),
aiReports,
coverage: input.localInputs.watchlistItem
? {
...input.localInputs.watchlistItem,
latest_filing_date: latestFiling?.filing_date ?? input.localInputs.watchlistItem.latest_filing_date ?? null
}
: null,
journalPreview: input.localInputs.journalPreview,
recentAiReports: aiReports.slice(0, 5),
latestFilingSummary,
keyMetrics,
companyProfile,
valuationSnapshot,
bullBear: synthesis.bullBear,
recentDevelopments
};
}
type GetCompanyAnalysisPayloadOptions = {
userId: string;
ticker: string;
refresh?: boolean;
now?: Date;
};
type GetCompanyAnalysisPayloadDeps = {
getLocalInputs?: (input: { userId: string; ticker: string }) => Promise<CompanyAnalysisLocalInputs>;
getCachedOverview?: typeof getCompanyOverviewCache;
upsertCachedOverview?: typeof upsertCompanyOverviewCache;
buildOverview?: (input: {
userId: string;
ticker: string;
localInputs: CompanyAnalysisLocalInputs;
}) => Promise<CompanyAnalysis>;
};
export async function getCompanyAnalysisPayload(
input: GetCompanyAnalysisPayloadOptions,
deps?: GetCompanyAnalysisPayloadDeps
): Promise<CompanyAnalysis> {
const ticker = input.ticker.trim().toUpperCase();
const now = input.now ?? new Date();
const localInputs = await (deps?.getLocalInputs ?? getCompanyAnalysisLocalInputs)({
userId: input.userId,
ticker
});
const sourceSignature = buildCompanyAnalysisSourceSignature({
ticker,
localInputs
});
const cached = await (deps?.getCachedOverview ?? getCompanyOverviewCache)({
userId: input.userId,
ticker
});
if (cached && isCompanyOverviewCacheFresh({
updatedAt: cached.updated_at,
sourceSignature: cached.source_signature,
expectedSourceSignature: sourceSignature,
cacheVersion: cached.cache_version,
refresh: input.refresh,
now
})) {
return cached.payload;
}
const analysis = await (deps?.buildOverview ?? buildCompanyAnalysisPayload)({
userId: input.userId,
ticker,
localInputs
});
await (deps?.upsertCachedOverview ?? upsertCompanyOverviewCache)({
userId: input.userId,
ticker,
sourceSignature,
payload: analysis
});
return analysis;
}
export const __companyAnalysisInternals = {
COMPANY_OVERVIEW_CACHE_TTL_MS,
buildCompanyAnalysisPayload,
buildCompanyAnalysisSourceSignature,
getCompanyAnalysisLocalInputs,
isCompanyOverviewCacheFresh,
withFinancialMetricsPolicy
};

View File

@@ -47,6 +47,7 @@ describe('sqlite schema compatibility bootstrap', () => {
expect(__dbInternals.hasTable(client, 'research_artifact')).toBe(true);
expect(__dbInternals.hasTable(client, 'research_memo')).toBe(true);
expect(__dbInternals.hasTable(client, 'research_memo_evidence')).toBe(true);
expect(__dbInternals.hasTable(client, 'company_overview_cache')).toBe(true);
__dbInternals.loadSqliteExtensions(client);
__dbInternals.ensureSearchVirtualTables(client);

View File

@@ -471,6 +471,10 @@ function ensureLocalSqliteSchema(client: Database) {
applySqlFile(client, '0007_company_financial_bundles.sql');
}
if (!hasTable(client, 'company_overview_cache')) {
applySqlFile(client, '0012_company_overview_cache.sql');
}
if (!hasTable(client, 'research_journal_entry')) {
client.exec(`
CREATE TABLE IF NOT EXISTS \`research_journal_entry\` (

View File

@@ -607,6 +607,20 @@ export const companyFinancialBundle = sqliteTable('company_financial_bundle', {
companyFinancialBundleTickerIndex: index('company_financial_bundle_ticker_idx').on(table.ticker, table.updated_at)
}));
export const companyOverviewCache = sqliteTable('company_overview_cache', {
id: integer('id').primaryKey({ autoIncrement: true }),
user_id: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
ticker: text('ticker').notNull(),
cache_version: integer('cache_version').notNull(),
source_signature: text('source_signature').notNull(),
payload: text('payload', { mode: 'json' }).$type<Record<string, unknown>>().notNull(),
created_at: text('created_at').notNull(),
updated_at: text('updated_at').notNull()
}, (table) => ({
companyOverviewCacheUnique: uniqueIndex('company_overview_cache_uidx').on(table.user_id, table.ticker),
companyOverviewCacheLookupIndex: index('company_overview_cache_lookup_idx').on(table.user_id, table.ticker, table.updated_at)
}));
export const filingLink = sqliteTable('filing_link', {
id: integer('id').primaryKey({ autoIncrement: true }),
filing_id: integer('filing_id').notNull().references(() => filing.id, { onDelete: 'cascade' }),
@@ -831,6 +845,7 @@ export const appSchema = {
filingTaxonomyFact,
filingTaxonomyMetricValidation,
companyFinancialBundle,
companyOverviewCache,
filingLink,
taskRun,
taskStageEvent,

64
lib/server/prices.test.ts Normal file
View File

@@ -0,0 +1,64 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
import { __pricesInternals, getPriceHistory, getQuote } from './prices';
describe('price caching', () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
__pricesInternals.resetCaches();
});
afterEach(() => {
globalThis.fetch = originalFetch;
__pricesInternals.resetCaches();
});
it('reuses the cached quote within the ttl window', async () => {
const fetchMock = mock(async () => Response.json({
chart: {
result: [
{
meta: {
regularMarketPrice: 123.45
}
}
]
}
})) as unknown as typeof fetch;
globalThis.fetch = fetchMock;
const first = await getQuote('MSFT');
const second = await getQuote('MSFT');
expect(first).toBe(123.45);
expect(second).toBe(123.45);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('reuses cached price history within the ttl window', async () => {
const fetchMock = mock(async () => Response.json({
chart: {
result: [
{
timestamp: [1735689600, 1736294400],
indicators: {
quote: [
{
close: [100, 105]
}
]
}
}
]
}
})) as unknown as typeof fetch;
globalThis.fetch = fetchMock;
const first = await getPriceHistory('MSFT');
const second = await getPriceHistory('MSFT');
expect(first).toHaveLength(2);
expect(second).toEqual(first);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,4 +1,14 @@
const YAHOO_BASE = 'https://query1.finance.yahoo.com/v8/finance/chart';
const QUOTE_CACHE_TTL_MS = 1000 * 60;
const PRICE_HISTORY_CACHE_TTL_MS = 1000 * 60 * 15;
type CacheEntry<T> = {
expiresAt: number;
value: T;
};
const quoteCache = new Map<string, CacheEntry<number>>();
const priceHistoryCache = new Map<string, CacheEntry<Array<{ date: string; close: number }>>>();
function buildYahooChartUrl(ticker: string, params: string) {
return `${YAHOO_BASE}/${encodeURIComponent(ticker.trim().toUpperCase())}?${params}`;
@@ -17,7 +27,12 @@ function fallbackQuote(ticker: string) {
export async function getQuote(ticker: string): Promise<number> {
const normalizedTicker = ticker.trim().toUpperCase();
const cached = quoteCache.get(normalizedTicker);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
let quote = fallbackQuote(normalizedTicker);
try {
const response = await fetch(buildYahooChartUrl(normalizedTicker, 'interval=1d&range=1d'), {
headers: {
@@ -27,7 +42,11 @@ export async function getQuote(ticker: string): Promise<number> {
});
if (!response.ok) {
return fallbackQuote(normalizedTicker);
quoteCache.set(normalizedTicker, {
value: quote,
expiresAt: Date.now() + QUOTE_CACHE_TTL_MS
});
return quote;
}
const payload = await response.json() as {
@@ -38,13 +57,23 @@ export async function getQuote(ticker: string): Promise<number> {
const price = payload.chart?.result?.[0]?.meta?.regularMarketPrice;
if (typeof price !== 'number' || !Number.isFinite(price)) {
return fallbackQuote(normalizedTicker);
quoteCache.set(normalizedTicker, {
value: quote,
expiresAt: Date.now() + QUOTE_CACHE_TTL_MS
});
return quote;
}
quote = price;
} catch {
// fall through to cached fallback
}
return price;
} catch {
return fallbackQuote(normalizedTicker);
}
quoteCache.set(normalizedTicker, {
value: quote,
expiresAt: Date.now() + QUOTE_CACHE_TTL_MS
});
return quote;
}
export async function getQuoteOrNull(ticker: string): Promise<number | null> {
@@ -145,6 +174,10 @@ export async function getHistoricalClosingPrices(ticker: string, dates: string[]
export async function getPriceHistory(ticker: string): Promise<Array<{ date: string; close: number }>> {
const normalizedTicker = ticker.trim().toUpperCase();
const cached = priceHistoryCache.get(normalizedTicker);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
try {
const response = await fetch(buildYahooChartUrl(normalizedTicker, 'interval=1wk&range=20y'), {
@@ -190,6 +223,10 @@ export async function getPriceHistory(ticker: string): Promise<Array<{ date: str
.filter((entry): entry is { date: string; close: number } => entry !== null);
if (points.length > 0) {
priceHistoryCache.set(normalizedTicker, {
value: points,
expiresAt: Date.now() + PRICE_HISTORY_CACHE_TTL_MS
});
return points;
}
} catch {
@@ -201,7 +238,7 @@ export async function getPriceHistory(ticker: string): Promise<Array<{ date: str
const totalWeeks = 20 * 52;
return Array.from({ length: totalWeeks }, (_, index) => {
const syntheticHistory = Array.from({ length: totalWeeks }, (_, index) => {
const step = (totalWeeks - 1) - index;
const date = new Date(now - step * 7 * 24 * 60 * 60 * 1000).toISOString();
const wave = Math.sin(index / 8) * 0.06;
@@ -213,4 +250,20 @@ export async function getPriceHistory(ticker: string): Promise<Array<{ date: str
close: Number(close.toFixed(2))
};
});
priceHistoryCache.set(normalizedTicker, {
value: syntheticHistory,
expiresAt: Date.now() + PRICE_HISTORY_CACHE_TTL_MS
});
return syntheticHistory;
}
export const __pricesInternals = {
PRICE_HISTORY_CACHE_TTL_MS,
QUOTE_CACHE_TTL_MS,
resetCaches() {
quoteCache.clear();
priceHistoryCache.clear();
}
};

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);
}