2026-06-29 17:40:42 -04:00
// Investor Flow — backend entry (DESIGN.md §2.1: one process exposes /api/trpc/*).
// Adapted from Bun.serve to node:http + @trpc/server fetch adapter (runtime glue only).
import { createServer , type IncomingMessage , type ServerResponse } from 'node:http' ;
import { fetchRequestHandler } from '@trpc/server/adapters/fetch' ;
import { db } from './db/client.ts' ;
import { createCacheRepository , type SourceKind } from './cache/CacheRepository.ts' ;
import { YFinanceAdapter } from './adapters/YFinanceAdapter.ts' ;
2026-08-10 13:36:26 -04:00
import { OptionsAdapter } from './adapters/OptionsAdapter.ts' ;
2026-07-25 12:59:43 -04:00
import { NasdaqAdapter } from './adapters/NasdaqAdapter.ts' ;
2026-07-25 13:03:49 -04:00
import { FinraBulkAdapter } from './adapters/FinraBulkAdapter.ts' ;
2026-08-10 13:36:26 -04:00
import { FinraShortInterestAdapter } from './adapters/FinraShortInterestAdapter.ts' ;
2026-07-12 12:55:59 -04:00
import { SecFetchAdapter } from './adapters/SecFetchAdapter.ts' ;
2026-08-10 13:36:26 -04:00
import { SecCompanyTickersAdapter } from './adapters/SecCompanyTickersAdapter.ts' ;
2026-07-12 20:19:12 -04:00
import { SecLintAdapter } from './adapters/SecLintAdapter.ts' ;
2026-07-23 18:02:24 -04:00
import { XCookieAdapter } from './adapters/XCookieAdapter.ts' ;
2026-08-10 13:36:26 -04:00
import { FredAdapterImpl } from './macro/FredAdapter.ts' ;
2026-07-12 12:55:59 -04:00
import type { SourceFetch } from './adapters/SourceAdapter.ts' ;
2026-08-10 13:36:26 -04:00
import { composeYFinanceWithOptions } from './options/OptionsChainRouter.ts' ;
2026-07-23 18:02:24 -04:00
import cryptoMod from './lib/crypto.ts' ;
2026-06-29 17:40:42 -04:00
import { AdapterQueue } from './queue/AdapterQueue.ts' ;
2026-08-10 13:36:26 -04:00
import { seedCuratedCusips } from './services/cusipRegistry.ts' ;
import { seedAdminDefaultAlertSubscriptions } from './db/alertSubscriptionRepository.ts' ;
2026-06-29 17:40:42 -04:00
import { makeCreateContext } from './trpc/context.ts' ;
import { appRouter } from './trpc/router.ts' ;
2026-08-10 13:36:26 -04:00
import type { Alert } from './alerts/AlertEngine.ts' ;
2026-06-29 17:40:42 -04:00
const PORT = Number ( process . env . PORT ?? 3001 );
const database = db ();
2026-08-10 13:36:26 -04:00
const yfinanceAdapter = composeYFinanceWithOptions (
new YFinanceAdapter ({ db : database }) as unknown as SourceFetch ,
new OptionsAdapter () as unknown as SourceFetch ,
);
2026-07-12 20:19:12 -04:00
const adapters = new Map < SourceKind , SourceFetch >([
2026-08-10 13:36:26 -04:00
[ 'yfinance' as const , yfinanceAdapter ],
2026-07-25 12:59:43 -04:00
[ 'nasdaq' as const , new NasdaqAdapter () as unknown as SourceFetch ],
2026-07-25 13:03:49 -04:00
[ 'finra-bulk' as const , new FinraBulkAdapter ( database ) as unknown as SourceFetch ],
2026-08-10 13:36:26 -04:00
[ 'finra-si' as const , new FinraShortInterestAdapter ( database ) as unknown as SourceFetch ],
2026-07-12 20:19:12 -04:00
[ 'sec-fetch' as const , new SecFetchAdapter ( database ) as unknown as SourceFetch ],
2026-08-10 13:36:26 -04:00
[ 'sec-sc-fetch' as const , new SecFetchAdapter ( database ) as unknown as SourceFetch ],
[ 'sec-tickers' as const , new SecCompanyTickersAdapter ( database ) as unknown as SourceFetch ],
2026-07-12 20:19:12 -04:00
[ 'sec-lint-holders' as const , new SecLintAdapter (() => database , 'sec-lint-holders' ) as unknown as SourceFetch ],
[ 'sec-lint-insiders' as const , new SecLintAdapter (() => database , 'sec-lint-insiders' ) as unknown as SourceFetch ],
2026-07-12 12:55:59 -04:00
]);
2026-07-23 18:02:24 -04:00
// Load X credentials at startup and register XCookieAdapter if available.
let xAdapter : XCookieAdapter | null = null ;
( function initXAdapter() {
const row = database . prepare ( 'SELECT ct0_enc, auth_token_enc FROM x_credentials WHERE id=?' ). get ( 'singleton' ) as { ct0_enc? : string ; auth_token_enc? : string } | undefined ;
if ( ! row ? . ct0_enc || ! row ? . auth_token_enc ) return ;
let creds : { ct0 : string ; auth_token : string };
try { creds = { ct0 : cryptoMod.decrypt ( row . ct0_enc ), auth_token : cryptoMod.decrypt ( row . auth_token_enc ) }; }
catch { return ; }
const updateHealth = ( status : string , err? : string | null ) => {
database . prepare (
`INSERT INTO x_credentials (id, healthy, last_error, updated_at) VALUES ('singleton', ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET healthy=excluded.healthy, last_error=excluded.last_error, updated_at=excluded.updated_at`
). run ( status === 'healthy' ? 1 : 0 , err ?? null , new Date (). toISOString ());
};
xAdapter = new XCookieAdapter ( creds , ( health ) => updateHealth ( health . sourceStatus , health . lastError ), database );
adapters . set ( 'x' as const , xAdapter as unknown as SourceFetch );
console . log ( '[investor-flow] X adapter registered (credentials configured)' );
})();
2026-08-10 13:36:26 -04:00
// Register FredAdapter when a FRED API key is configured (encrypted in x_credentials).
// FRED series are warmed by the queue schedule (fred_macro tier), never on the
// market.condition request path.
( function initFredAdapter() {
const row = database . prepare ( 'SELECT fred_api_key_enc FROM x_credentials WHERE id=?' ). get ( 'singleton' ) as { fred_api_key_enc? : string | null } | undefined ;
if ( ! row ? . fred_api_key_enc ) return ;
let apiKey : string ;
try { apiKey = cryptoMod . decrypt ( row . fred_api_key_enc ); } catch { return ; }
if ( ! apiKey ) return ;
adapters . set ( 'fred' as const , new FredAdapterImpl ( apiKey ) as unknown as SourceFetch );
console . log ( '[investor-flow] FRED adapter registered (series warm-up via queue schedule)' );
})();
2026-06-29 17:40:42 -04:00
const queue = new AdapterQueue ({ db : database , adapters });
const cache = createCacheRepository ({ db : database , scheduler : queue });
queue . cache = cache ; // break the cache<->scheduler cycle
2026-07-12 12:55:59 -04:00
2026-08-10 13:36:26 -04:00
// Seed / migrate tiered schedules (safe every boot)
2026-07-12 12:55:59 -04:00
queue . seedDefaultSchedules ();
2026-08-10 13:36:26 -04:00
// Offline CUSIP registry → kv_cache so institutional alerts never depend solely on EFTS.
try {
const seeded = seedCuratedCusips ( database );
if ( seeded > 0 ) console . log ( `[investor-flow] seeded ${ seeded } curated sec:cusip cache entries` );
} catch ( e ) {
console . error ( '[investor-flow] cusip seed failed' , e );
}
// Admin default alert subscriptions: every catalog alert type ON for is_admin=1
// (idempotent; respects any toggled preference). Seeded now, not just on the
// 10-min batch producer tick, so operators receive alerts immediately.
try {
const seeded = seedAdminDefaultAlertSubscriptions ( database );
if ( seeded > 0 ) console . log ( `[investor-flow] seeded ${ seeded } admin default alert subscription(s)` );
} catch ( e ) {
console . error ( '[investor-flow] admin alert seed failed' , e );
}
// Demand hygiene: junk test symbols + inflated refcounts from page-view subscribe spam
try {
const hygiene = queue . cleanupDemandHygiene ();
if ( hygiene . removedJunk || hygiene . cappedRefcounts ) {
console . log ( `[investor-flow] demand hygiene: removedJunk= ${ hygiene . removedJunk } cappedRefcounts= ${ hygiene . cappedRefcounts } ` );
}
} catch ( e ) {
console . error ( '[investor-flow] demand hygiene failed' , e );
}
// Pin rotation universe + SPY + VIX (no refcount inflation)
queue . pinSystemUniverse (). then (() => {
console . log ( '[investor-flow] system pins (rotation universe) ready' );
}). catch (( e ) => console . error ( '[investor-flow] pinSystemUniverse failed' , e ));
2026-07-12 12:55:59 -04:00
// Startup recovery: any job left 'in_flight' was interrupted by a restart/crash.
// Reset to 'pending' so the drain loop reprocesses it.
const recovered = database . prepare ( "UPDATE adapter_queue SET status='pending', error=NULL, retry_count=0 WHERE status='in_flight'" ). run ();
if ( Number ( recovered . changes ) > 0 ) console . log ( `[investor-flow] recovered ${ recovered . changes } interrupted in_flight jobs` );
2026-07-23 18:02:24 -04:00
const createContext = makeCreateContext ({ db : database , cache , queue , xAdapter });
2026-06-29 17:40:42 -04:00
// Background drain: stale-while-revalidate refreshes are queued by CacheRepository.get;
// this loop drains them (fetch via adapter -> write to cache), deduped + backed off.
const DRAIN_MS = Number ( process . env . IFLOW_DRAIN_MS ?? 2000 );
const drainTimer = setInterval (() => { queue . drain (). catch (( e ) => console . error ( '[drain error]' , e )); }, DRAIN_MS );
drainTimer . unref ();
2026-07-12 12:55:59 -04:00
// Auto-scheduler: every 30s, enqueue refreshes for due schedules
const SCHEDULE_MS = 30 _000 ;
const scheduleTimer = setInterval (() => { queue . enqueueDueSchedules (). catch (( e ) => console . error ( '[schedule error]' , e )); }, SCHEDULE_MS );
scheduleTimer . unref ();
2026-08-10 13:36:26 -04:00
// Realtime + per-fetch alert check: run alongside the schedule cycle for
// low-latency alert types (e.g. VIX band crosses). Enqueues to the email
// outbox only; SMTP is drained on its own timer so it never blocks this loop.
2026-07-23 20:51:47 -04:00
const perFetchAlertTimer = setInterval ( async () => {
try {
2026-08-10 13:36:26 -04:00
const alerts : Alert [] = [
...( await runProducers ( database , 'realtime' )),
...( await runProducers ( database , 'per-fetch' )),
];
2026-07-23 20:51:47 -04:00
if ( alerts . length > 0 ) {
2026-08-10 13:36:26 -04:00
console . log ( `[alert] ${ alerts . length } realtime alert(s) created` );
const { enqueueAlertEmail } = await import ( './services/emailAlertService.ts' );
alerts . forEach (( a ) => enqueueAlertEmail ( database , a ));
2026-07-23 20:51:47 -04:00
}
} catch ( e ) {
2026-08-10 13:36:26 -04:00
console . error ( '[alert] realtime check failed:' , e );
2026-07-23 20:51:47 -04:00
}
}, SCHEDULE_MS );
perFetchAlertTimer . unref ();
// Register alert producers.
2026-08-10 13:36:26 -04:00
import { registerProducer , runProducers , registerMirrorProducers } from './alerts/producers/index.ts' ;
2026-07-23 20:51:47 -04:00
import { informedBuyProducer , informedSellProducer } from './alerts/producers/insiderProducer.ts' ;
2026-08-10 13:36:26 -04:00
import { new13daProducer , new13fFilingProducer } from './alerts/producers/new13daProducer.ts' ;
import { vixLevelProducer } from './alerts/producers/vixLevelProducer.ts' ;
import { rotationIncipientProducer , regimeShiftProducer } from './alerts/producers/rotationProducer.ts' ;
import { convictionUnlockProducer } from './alerts/producers/unlockProducer.ts' ;
import { thesisBrokenProducer , thesisWeakeningProducer } from './alerts/producers/thesisProducer.ts' ;
import { clusterBreachProducer , drawdownHaltProducer , asymmetryWarningProducer } from './alerts/producers/portfolioRiskProducer.ts' ;
2026-08-10 16:02:20 -04:00
import { confluenceChangeProducer } from './alerts/producers/confluenceProducer.ts' ;
2026-07-23 20:51:47 -04:00
registerProducer ( informedBuyProducer );
registerProducer ( informedSellProducer );
registerProducer ( new13daProducer );
2026-08-10 13:36:26 -04:00
registerProducer ( new13fFilingProducer );
registerProducer ( vixLevelProducer );
registerProducer ( rotationIncipientProducer );
registerProducer ( regimeShiftProducer );
registerProducer ( convictionUnlockProducer );
registerProducer ( thesisBrokenProducer );
registerProducer ( thesisWeakeningProducer );
registerProducer ( clusterBreachProducer );
registerProducer ( drawdownHaltProducer );
registerProducer ( asymmetryWarningProducer );
2026-08-10 16:02:20 -04:00
registerProducer ( confluenceChangeProducer );
2026-08-10 13:36:26 -04:00
registerMirrorProducers (); // fund_capture / fund_13f / mirror_diff (lazy, offline-safe)
2026-07-23 20:51:47 -04:00
// Batched alert check: run every 10 minutes for non-critical producers.
const ALERT_BATCH_MS = 10 * 60 * 1000 ;
const alertBatchTimer = setInterval ( async () => {
try {
const alerts = await runProducers ( database , 'batched' );
if ( alerts . length > 0 ) console . log ( `[alert] ${ alerts . length } batch alert(s) created` );
2026-08-10 13:36:26 -04:00
// Enqueue email for each new alert (SMTP drained separately).
const { enqueueAlertEmail } = await import ( './services/emailAlertService.ts' );
for ( const alert of alerts ) enqueueAlertEmail ( database , alert );
2026-07-23 20:51:47 -04:00
} catch ( e ) {
console . error ( '[alert] batch check failed:' , e );
}
}, ALERT_BATCH_MS );
alertBatchTimer . unref ();
2026-08-10 13:36:26 -04:00
// Email outbox drain: decouples SMTP latency/errors from the alert loops.
const OUTBOX_DRAIN_MS = 60 _000 ;
const outboxDrainTimer = setInterval ( async () => {
try {
const { drainEmailOutbox } = await import ( './services/emailAlertService.ts' );
const sent = await drainEmailOutbox ( database );
if ( sent > 0 ) console . log ( `[alert:email] drained ${ sent } outbox email(s)` );
} catch ( e ) {
console . error ( '[alert:email] outbox drain failed:' , e );
}
}, OUTBOX_DRAIN_MS );
outboxDrainTimer . unref ();
// Daily queue housekeeping: purge stale 'done' rows and age out queue_errors.
// Both tables previously grew unbounded (live DB had 819k error rows).
const DAILY_HOUSEKEEP_MS = 24 * 60 * 60 * 1000 ;
const housekeepTimer = setInterval (() => {
try {
const cleared = queue . clearDone ();
const pruned = queue . pruneQueueErrors ();
if ( cleared > 0 || pruned > 0 ) {
console . log ( `[queue] housekeeping: cleared ${ cleared } done jobs, pruned ${ pruned } old queue_errors` );
}
} catch ( e ) {
console . error ( '[queue] daily housekeeping failed:' , e );
}
}, DAILY_HOUSEKEEP_MS );
housekeepTimer . unref ();
2026-06-29 17:40:42 -04:00
function readBody ( req : IncomingMessage ) : Promise < string > {
return new Promise (( resolve , reject ) => {
let data = '' ;
req . on ( 'data' , ( c : Buffer ) => { data += c . toString (); });
req . on ( 'end' , () => resolve ( data ));
req . on ( 'error' , reject );
});
}
const server = createServer ( async ( req , res ) => {
const url = new URL ( req . url ?? '/' , `http://localhost: ${ PORT } ` );
if ( url . pathname === '/health' ) {
res . writeHead ( 200 , { 'content-type' : 'application/json' });
res . end ( JSON . stringify ({ ok : true , queue : queue.health () }));
return ;
}
if ( url . pathname . startsWith ( '/api/trpc' )) {
const headers = new Headers ();
for ( const [ k , v ] of Object . entries ( req . headers )) if ( v != null ) headers . set ( k , Array . isArray ( v ) ? v . join ( ', ' ) : String ( v ));
const method = req . method ?? 'GET' ;
const body = method === 'GET' || method === 'HEAD' ? undefined : await readBody ( req );
2026-07-12 12:55:59 -04:00
const requestUrl = `http://localhost: ${ PORT }${ url . pathname }${ url . search } ` ;
const init : RequestInit = { method , headers };
if ( body ) {
init . body = new Blob ([ body ], { type : 'application/json' });
}
const request = new Request ( requestUrl , init );
2026-06-29 17:40:42 -04:00
try {
const response = await fetchRequestHandler ({ router : appRouter , createContext , endpoint : '/api/trpc' , req : request });
const buf = Buffer . from ( await response . arrayBuffer ());
res . writeHead ( response . status , Object . fromEntries ( response . headers . entries ()));
res . end ( buf );
} catch ( e ) {
console . error ( '[trpc error]' , e );
res . writeHead ( 500 , { 'content-type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'tRPC handler error' }));
}
return ;
}
res . writeHead ( 404 , { 'content-type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'not found' }));
});
2026-07-12 12:55:59 -04:00
const HOST = process . env . HOST ?? '0.0.0.0' ;
server . listen ( PORT , HOST , () => {
console . log ( `[investor-flow] backend on http:// ${ HOST } : ${ PORT } (tRPC /api/trpc, health /health, drain every ${ DRAIN_MS } ms)` );
2026-06-29 17:40:42 -04:00
});