feat(queue): adapter queue overhaul — pause/resume, error capture, scheduling

- Pause/resume persisted in queue_state; drain loop checks isPaused() and skips
- Error capture: every failed/backoff attempt logged to queue_errors with the
  full stack trace; admin queue page expands a failed job to stream it
- Retry controls: retryJob(key), retrySource(kind), clearDone(olderThanMs)
- Per-source scheduling: queue_schedules table + 30s enqueueDueSchedules loop
  (seed defaults sec-fetch 24h, yfinance 5min); admin UI lists/adds/deletes
- Startup recovery: interrupted in_flight jobs reset to pending on boot
- fix(edgar): archive URLs use the filer CIK (accession-number prefix), not the
  company CIK — resolves Cloudflare 429 that left SEC backfills sparse/empty
- Migration: add queue_errors, queue_schedules, queue_state tables plus error/
  scheduled_for columns on adapter_queue (idempotent ALTER on startup)

Verified: full sec-fetch backfill now succeeds for all watched symbols
(NVDA 14,675 institution filings, CIFR 274 insider txns, TSLA 6,011, etc.).
503 backend tests pass.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Investor Flow Build
2026-07-12 12:55:59 -04:00
co-authored by Claude
parent 55f07e6b42
commit ca385c1960
10 changed files with 1345 additions and 141 deletions
+103 -19
View File
@@ -9,7 +9,7 @@ import { buildAuthorizeUrl, generateState, exchangeCode, type OAuthProvider } fr
import { STARTER_WATCHLIST, defaultDrawdownTolerancePct, defaultRiskTolerance, ONBOARDING_DISCLAIMER, type Complexity } from '../onboarding/starter.ts';
import type { Quote, PriceCandle, SymbolMeta } from '../cache/CacheRepository.ts';
import { emaFromCandles, rsi as rsiFn, relativeVolume } from '../analysis/indicators.ts';
import { listUsers, resetPassword, gdprExport, queueHealth, resetQueueBackoff, NotOwnerError } from '../admin/admin.ts';
import { listUsers, resetPassword, gdprExport, queueHealth, resetQueueBackoff, NotOwnerError, listUserSessions, listAuditLog, queueSecFetch } from '../admin/admin.ts';
import { EdgarAdapter } from '../adapters/EdgarAdapter.ts';
import { OptionsAdapter, parseOptionChainRows } from '../adapters/OptionsAdapter.ts';
import type { OptionChainRow, OptionGreeks } from '../adapters/OptionsAdapter.ts';
@@ -156,6 +156,7 @@ const onboardingRouter = router({
for (const sym of symbols) {
const kind = (STARTER_WATCHLIST.find((s) => s.symbol === sym)?.tickerKind ?? 'equity') as 'equity' | 'crypto' | 'etf' | 'index';
await ctx.cache.subscribe(sym, kind);
queueSecFetch(ctx.db, sym);
}
if (input.portfolio) {
const ins = ctx.db.prepare('INSERT INTO portfolio_holdings (id, owner_id, symbol, qty, avg_cost, acquired_at, status) VALUES (?,?,?,?,?,?,?)');
@@ -254,6 +255,79 @@ const adminRouter = router({
resetBackoff: adminProcedure
.input(z.object({ sourceKind: z.string().regex(/^[a-z0-9_]+$/i) }))
.mutation(({ ctx, input }) => resetQueueBackoff(ctx.db, ctx.userId, input.sourceKind)),
userSessions: adminProcedure
.input(z.object({ userId: z.string().uuid() }))
.query(({ ctx, input }) => listUserSessions(ctx.db, input.userId)),
auditLog: adminProcedure
.input(z.object({
limit: z.number().int().min(1).max(200).default(50),
offset: z.number().int().min(0).default(0),
actor: z.string().optional(),
action: z.string().optional(),
}))
.query(({ ctx, input }) => listAuditLog(ctx.db, { limit: input.limit, offset: input.offset, actor: input.actor ?? null, action: input.action ?? null })),
queueSecFetch: adminProcedure
.input(z.object({ symbol: z.string().min(1).max(10) }))
.mutation(({ ctx, input }) => {
try { queueSecFetch(ctx.db, input.symbol); return { ok: true }; }
catch (e) { throw new TRPCError({ code: 'BAD_REQUEST', message: e instanceof Error ? e.message : 'Failed to queue fetch.' }); }
}),
queueStatus: adminProcedure.query(({ ctx }) => ctx.queue.health()),
queuePause: adminProcedure.mutation(({ ctx }) => {
ctx.queue.setPaused(true);
return { paused: true };
}),
queueResume: adminProcedure.mutation(({ ctx }) => {
ctx.queue.setPaused(false);
return { paused: false };
}),
queueLogs: adminProcedure
.input(z.object({ key: z.string().min(1), limit: z.number().int().min(1).max(100).optional().default(10) }))
.query(({ ctx, input }) => ctx.queue.getErrorLog(input.key, input.limit)),
queueRetryJob: adminProcedure
.input(z.object({ key: z.string().min(1) }))
.mutation(({ ctx, input }) => {
ctx.queue.retryJob(input.key);
return { ok: true };
}),
queueRetrySource: adminProcedure
.input(z.object({ sourceKind: z.string().min(1) }))
.mutation(({ ctx, input }) => {
const cleared = ctx.queue.retrySource(input.sourceKind);
return { cleared };
}),
queueClearDone: adminProcedure
.input(z.object({ olderThanHours: z.number().min(1).max(336).optional().default(24) }))
.mutation(({ ctx, input }) => {
const cleared = ctx.queue.clearDone(input.olderThanHours * 3600000);
return { cleared };
}),
queueSchedules: adminProcedure.query(({ ctx }) => ctx.queue.listSchedules()),
queueSetSchedule: adminProcedure
.input(z.object({ sourceKind: z.string().min(1), intervalMs: z.number().int().min(60000).max(604800000) }))
.mutation(({ ctx, input }) => {
ctx.queue.setSchedule(input.sourceKind, input.intervalMs);
return { ok: true };
}),
queueDeleteSchedule: adminProcedure
.input(z.object({ sourceKind: z.string().min(1) }))
.mutation(({ ctx, input }) => {
ctx.queue.deleteSchedule(input.sourceKind);
return { ok: true };
}),
});
@@ -262,7 +336,7 @@ const dashboardRouter = router({
rollup: publicProcedure
.input(z.object({}))
.query(async ({ ctx }) => {
const userId = ctx.userId as string;
const userId = ctx.userId ?? 'anonymous';
// Import lazily to avoid circular deps.
const { DashboardRollupEngine } = await import('../analysis/dashboardRollup.ts');
const { InstitutionFlowEngine } = await import('../analysis/institutionFlowEngine.ts');
@@ -410,7 +484,7 @@ const institutionalRouter = router({
}>;
if (rows.length === 0) {
return { symbol, flow: [], quarters: [] };
return { symbol, flow: [], quarters: [], filings: [] };
}
// Group by filer_cik, then by reported_quarter to compute QoQ flow
@@ -468,26 +542,42 @@ const institutionalRouter = router({
// Collect unique quarters
const quarters = [...new Set(rows.map(r => r.reported_quarter))].sort().reverse();
return { symbol, flow: allFlow, quarters };
// Return individual filing rows for month-granularity grouping
const filings = rows.map(r => ({
filerCik: r.filer_cik,
filerName: r.filer_name,
shares: r.shares ?? 0,
valueUsd: r.value_usd ?? 0,
reportedQuarter: r.reported_quarter,
filedAt: r.filed_at,
form: r.form,
}));
return { symbol, flow: allFlow, quarters, filings };
}),
/** Get insider activity stream for a symbol (M5 quarterly price strip). */
insiderStream: publicProcedure
.input(z.object({
symbol: z.string().min(1),
limit: z.number().int().min(1).max(100).optional().default(50),
limit: z.number().int().min(1).max(500).optional().default(200),
}))
.query(async ({ ctx, input }) => {
const symbol = input.symbol.toUpperCase();
// Go back 5 years so the monthly chart has real depth.
const fiveYearsAgo = new Date();
fiveYearsAgo.setFullYear(fiveYearsAgo.getFullYear() - 5);
const since = fiveYearsAgo.toISOString().slice(0, 10);
const transactions = ctx.db.prepare(`
SELECT form4_id, symbol, insider_name, insider_role, tx_date, tx_code,
tx_type, shares, price, is_10b5_1, classification, filed_at
FROM insider_transactions
WHERE symbol = ?
WHERE symbol = ? AND tx_date >= ?
ORDER BY tx_date DESC
LIMIT ?
`).all(symbol, input.limit) as Array<{
`).all(symbol, since, input.limit) as Array<{
form4_id: string;
symbol: string;
insider_name: string;
@@ -509,11 +599,11 @@ const institutionalRouter = router({
netShares += shares * direction;
return {
insiderName: tx.insider_name,
role: tx.insider_role,
txDate: tx.tx_date,
txCode: tx.tx_code,
txType: tx.tx_type,
reporter: tx.insider_name,
relationship: tx.insider_role,
transactionDate: tx.tx_date,
transactionCode: tx.tx_code,
transactionType: tx.tx_type,
shares,
price: tx.price,
is10b5: tx.is_10b5_1 === 1,
@@ -678,14 +768,8 @@ const watchlistRouter = router({
const { addSymbol } = await import('../db/watchlistRepository.ts');
const added = addSymbol(ctx.db, userId, input.symbol);
// Fire-and-forget: fetch SEC data (13F + Form 4) for this symbol in background.
// Don't await — let the response return immediately.
if (added) {
import('../services/secDataFetcher.ts').then(({ fetchAndStoreSecData }) => {
fetchAndStoreSecData(ctx.db, input.symbol).catch((e) => {
console.error(`[sec fetch] failed for ${input.symbol}:`, e);
});
}).catch(() => { /* sec fetcher not available */ });
queueSecFetch(ctx.db, input.symbol);
}
return { added };