Implement fiscal-style research MVP flows
Some checks failed
PR Checks / typecheck-and-build (push) Has been cancelled

This commit is contained in:
2026-03-07 09:51:18 -05:00
parent f69e5b671b
commit 52136271d3
26 changed files with 2719 additions and 243 deletions

View File

@@ -20,14 +20,23 @@ describe('sqlite schema compatibility bootstrap', () => {
applyMigration(client, '0003_task_stage_event_timeline.sql');
expect(__dbInternals.hasColumn(client, 'watchlist_item', 'category')).toBe(false);
expect(__dbInternals.hasColumn(client, 'watchlist_item', 'status')).toBe(false);
expect(__dbInternals.hasColumn(client, 'holding', 'company_name')).toBe(false);
expect(__dbInternals.hasTable(client, 'filing_taxonomy_snapshot')).toBe(false);
expect(__dbInternals.hasTable(client, 'research_journal_entry')).toBe(false);
__dbInternals.ensureLocalSqliteSchema(client);
expect(__dbInternals.hasColumn(client, 'watchlist_item', 'category')).toBe(true);
expect(__dbInternals.hasColumn(client, 'watchlist_item', 'tags')).toBe(true);
expect(__dbInternals.hasColumn(client, 'watchlist_item', 'status')).toBe(true);
expect(__dbInternals.hasColumn(client, 'watchlist_item', 'priority')).toBe(true);
expect(__dbInternals.hasColumn(client, 'watchlist_item', 'updated_at')).toBe(true);
expect(__dbInternals.hasColumn(client, 'watchlist_item', 'last_reviewed_at')).toBe(true);
expect(__dbInternals.hasColumn(client, 'holding', 'company_name')).toBe(true);
expect(__dbInternals.hasTable(client, 'filing_taxonomy_snapshot')).toBe(true);
expect(__dbInternals.hasTable(client, 'filing_taxonomy_fact')).toBe(true);
expect(__dbInternals.hasTable(client, 'research_journal_entry')).toBe(true);
client.close();
});

View File

@@ -78,7 +78,11 @@ function ensureLocalSqliteSchema(client: Database) {
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: '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) {
@@ -86,11 +90,54 @@ function ensureLocalSqliteSchema(client: Database) {
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, '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() {

View File

@@ -27,6 +27,9 @@ type TaxonomyAssetType =
type TaxonomyParseStatus = 'ready' | 'partial' | 'failed';
type TaxonomyMetricValidationStatus = 'not_run' | 'matched' | 'mismatch' | 'error';
type CoverageStatus = 'backlog' | 'active' | 'watch' | 'archive';
type CoveragePriority = 'low' | 'medium' | 'high';
type ResearchJournalEntryType = 'note' | 'filing_note' | 'status_change';
type FilingAnalysis = {
provider?: string;
@@ -269,16 +272,22 @@ export const watchlistItem = sqliteTable('watchlist_item', {
sector: text('sector'),
category: text('category'),
tags: text('tags', { mode: 'json' }).$type<string[]>(),
created_at: text('created_at').notNull()
status: text('status').$type<CoverageStatus>().notNull().default('backlog'),
priority: text('priority').$type<CoveragePriority>().notNull().default('medium'),
created_at: text('created_at').notNull(),
updated_at: text('updated_at').notNull(),
last_reviewed_at: text('last_reviewed_at')
}, (table) => ({
watchlistUserTickerUnique: uniqueIndex('watchlist_user_ticker_uidx').on(table.user_id, table.ticker),
watchlistUserCreatedIndex: index('watchlist_user_created_idx').on(table.user_id, table.created_at)
watchlistUserCreatedIndex: index('watchlist_user_created_idx').on(table.user_id, table.created_at),
watchlistUserUpdatedIndex: index('watchlist_user_updated_idx').on(table.user_id, table.updated_at)
}));
export const holding = sqliteTable('holding', {
id: integer('id').primaryKey({ autoIncrement: true }),
user_id: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
ticker: text('ticker').notNull(),
company_name: text('company_name'),
shares: numeric('shares').notNull(),
avg_cost: numeric('avg_cost').notNull(),
current_price: numeric('current_price'),
@@ -520,6 +529,22 @@ export const portfolioInsight = sqliteTable('portfolio_insight', {
insightUserCreatedIndex: index('insight_user_created_idx').on(table.user_id, table.created_at)
}));
export const researchJournalEntry = sqliteTable('research_journal_entry', {
id: integer('id').primaryKey({ autoIncrement: true }),
user_id: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
ticker: text('ticker').notNull(),
accession_number: text('accession_number'),
entry_type: text('entry_type').$type<ResearchJournalEntryType>().notNull(),
title: text('title'),
body_markdown: text('body_markdown').notNull(),
metadata: text('metadata', { mode: 'json' }).$type<Record<string, unknown> | null>(),
created_at: text('created_at').notNull(),
updated_at: text('updated_at').notNull()
}, (table) => ({
researchJournalTickerIndex: index('research_journal_ticker_idx').on(table.user_id, table.ticker, table.created_at),
researchJournalAccessionIndex: index('research_journal_accession_idx').on(table.user_id, table.accession_number)
}));
export const authSchema = {
user,
session,
@@ -543,7 +568,8 @@ export const appSchema = {
filingLink,
taskRun,
taskStageEvent,
portfolioInsight
portfolioInsight,
researchJournalEntry
};
export const schema = {