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
+211 -80
View File
@@ -17,22 +17,23 @@ const OPERATOR_EMAIL = process.env.SEC_OPERATOR_EMAIL ?? 'research@example.com';
const UA = `Investor Flow (${OPERATOR_EMAIL})`;
/**
* Token-bucket rate limiter: 8 tokens, refilled at 8/sec, min 125ms between
* calls. Guarantees we stay under EDGAR's 10 req/s rule with headroom.
* Token-bucket rate limiter: 6 tokens, refilled at 6/sec, min ~167ms between
* calls. Stays within EDGAR's 10 req/s rule with headroom.
*/
class TokenBucket {
private tokens = 8;
private tokens = 6;
private lastDrain = Date.now();
private static readonly MAX_TOKENS = 6;
private static readonly REFILL_RATE = 6; // tokens/sec
async wait(): Promise<void> {
const now = Date.now();
const elapsed = (now - this.lastDrain) / 1000;
// Refill tokens up to maxRate (8).
this.tokens = Math.min(8, this.tokens + elapsed * 8);
this.tokens = Math.min(TokenBucket.MAX_TOKENS, this.tokens + elapsed * TokenBucket.REFILL_RATE);
this.lastDrain = now;
if (this.tokens < 1) {
const waitMs = Math.ceil(((1 - this.tokens) / 8) * 1000);
const waitMs = Math.ceil(((1 - this.tokens) / TokenBucket.REFILL_RATE) * 1000);
await new Promise((r) => setTimeout(r, waitMs));
this.tokens = 0;
this.lastDrain = Date.now();
@@ -47,34 +48,46 @@ const bucket = new TokenBucket();
/**
* Fetch a URL with EDGAR-compliant headers, rate limiting, and ETag caching.
* Returns null on 304 (caller should return cached row).
* Retries with exponential backoff on 429 (rate limited).
*/
async function edgarFetch(
url: string,
extraHeaders?: Record<string, string>,
retries = 3,
): Promise<{ status: number; headers: { etag?: string | null; lastModified?: string | null }; body: unknown } | null> {
await bucket.wait();
for (let attempt = 1; attempt <= retries; attempt++) {
await bucket.wait();
const headers: Record<string, string> = {
'User-Agent': UA,
Accept: 'application/json',
...extraHeaders,
};
const headers: Record<string, string> = {
'User-Agent': UA,
Accept: 'application/json',
...extraHeaders,
};
const resp = await fetch(url, { method: 'GET', headers });
const resp = await fetch(url, { method: 'GET', headers });
const etag = resp.headers.get('etag');
const lastModified = resp.headers.get('last-modified');
const etag = resp.headers.get('etag');
const lastModified = resp.headers.get('last-modified');
if (resp.status === 304) {
return { status: 304, headers: { etag, lastModified }, body: null };
if (resp.status === 304) {
return { status: 304, headers: { etag, lastModified }, body: null };
}
if (resp.status === 429 && attempt < retries) {
const backoff = Math.pow(2, attempt) * 1000;
await new Promise((r) => setTimeout(r, backoff));
continue;
}
if (resp.status >= 400) {
throw new Error(`EDGAR ${resp.status} ${resp.statusText} for ${url}`);
}
const body = (await resp.json()) as unknown;
return { status: resp.status, headers: { etag, lastModified }, body };
}
if (resp.status >= 400) {
throw new Error(`EDGAR ${resp.status} ${resp.statusText} for ${url}`);
}
const body = (await resp.json()) as unknown;
return { status: resp.status, headers: { etag, lastModified }, body };
throw new Error(`EDGAR max retries (${retries}) exceeded for ${url}`);
}
// ---------------------------------------------------------------------------
@@ -160,12 +173,34 @@ export class EdgarAdapter implements SourceFetch {
throw new Error(`EDGAR 304 but no cached data for ${key}`);
}
const rawData = resp?.body as { name?: string; filings?: { recent?: Array<{ form?: string; dateReporter?: string; accessionNumber?: string; accessionNormalization?: string; reportDate?: string; reportFile?: string; primaryDocument?: string }> } } | undefined;
const recentFilings = rawData?.filings?.recent;
if (!recentFilings) {
const rawData = resp?.body as { name?: string; filings?: { recent?: unknown } } | undefined;
const recentObj = rawData?.filings?.recent;
if (!recentObj) {
throw new Error(`EDGAR filings_index: no recent filings for CIK${padded}`);
}
// SEC EDGAR returns recent filings as either:
// (a) an array of objects: [{form, dateReporter, ...}, ...] (current format)
// (b) parallel arrays: {form: [...], dateReporter: [...], ...} (legacy)
let recentFilings: Array<Record<string, unknown>>;
if (Array.isArray(recentObj)) {
recentFilings = recentObj as Array<Record<string, unknown>>;
} else if (typeof recentObj === 'object') {
// Parallel arrays — convert to array of objects.
const keys = Object.keys(recentObj);
const len = (recentObj[keys[0]] as unknown[])?.length ?? 0;
recentFilings = [];
for (let i = 0; i < len; i++) {
const row: Record<string, unknown> = {};
for (const k of keys) {
row[k] = (recentObj as Record<string, unknown[]>)[k]?.[i];
}
recentFilings.push(row);
}
} else {
throw new Error(`EDGAR filings_index: unexpected recent filings format for CIK${padded}`);
}
// Filter by form type.
let filings = recentFilings;
if (opts?.formTypes && opts.formTypes.length > 0) {
@@ -178,7 +213,7 @@ export class EdgarAdapter implements SourceFetch {
const from = opts.dateRange.from ? new Date(opts.dateRange.from).getTime() : null;
const to = opts.dateRange.to ? new Date(opts.dateRange.to).getTime() : null;
filings = filings.filter((f) => {
const ts = new Date(f.dateReporter ?? f.accessionNormalization ?? '').getTime();
const ts = new Date(f.reportDate ?? f.filingDate ?? f.dateReporter ?? '').getTime();
if (Number.isNaN(ts)) return false;
if (from !== null && ts < from) return false;
if (to !== null && ts > to) return false;
@@ -318,7 +353,8 @@ export class EdgarAdapter implements SourceFetch {
cik: string,
accession: string,
): Promise<FetchResult> {
const padded = padCik(cik);
const filerCik = accession.split('-')[0];
const padded = padCik(filerCik);
const accessionNoDashes = accession.replace(/-/g, '');
// --- Step 1: filing index (JSON) ---------------------------------------
@@ -332,29 +368,49 @@ export class EdgarAdapter implements SourceFetch {
const indexBody = indexResp.body as {
fileDate?: string;
documents?: Array<{ name?: string; type?: string; size?: string | number; path?: string }>;
directory?: { item?: Array<{ name?: string; type?: string; size?: string | number }> };
partialSubmissionIndicator?: unknown;
};
if (!indexBody?.documents || indexBody.documents.length === 0) {
// EDGAR API may return documents as `documents` (old) or `directory.item` (new)
const docList = indexBody?.documents ?? indexBody?.directory?.item ?? [];
if (docList.length === 0) {
throw new Error(`EDGAR 13F: no documents in index for ${padded}/${accessionNoDashes}`);
}
// Pick the primary document (usually the .txt or .xml filing).
const primaryDoc = indexBody.documents.find(
(d) => d.type === '13F' || d.type === '13F-infoTable'
) ?? indexBody.documents[0];
// Pick candidates: XML documents (excluding index files), sorted by size desc
const xmlCandidates = docList
.filter((d) => d.name?.toLowerCase().endsWith('.xml') && !d.name?.includes('index'))
.sort((a, b) => {
const sa = typeof a.size === 'string' ? parseInt(a.size, 10) || 0 : (a.size as number) ?? 0;
const sb = typeof b.size === 'string' ? parseInt(b.size, 10) || 0 : (b.size as number) ?? 0;
return sb - sa;
});
const primaryName = primaryDoc.name;
if (!primaryName) {
throw new Error(`EDGAR 13F: primary document has no name for ${padded}/${accessionNoDashes}`);
// Try each candidate until we find one with holdings; fall back to any non-HTML doc
let docText = '';
let parsed: Array<{ cusip: string; issuerName: string; value: number; sshPrnamt: number }> = [];
for (const cand of xmlCandidates) {
const docUrl = `https://www.sec.gov/Archives/edgar/data/${padded}/${accessionNoDashes}/${cand.name}`;
docText = await this.edgarXmlFetch(docUrl);
parsed = parse13fHoldings(docText);
if (parsed.length > 0) break;
}
// --- Step 2: fetch the primary document (XML/HTML) ---------------------
const docUrl = `https://www.sec.gov/Archives/edgar/data/${padded}/${accessionNoDashes}/${primaryName}`;
const docText = await this.edgarXmlFetch(docUrl);
// Fallback: try first non-HTML doc if XML candidates had no holdings
if (parsed.length === 0) {
const fallback = docList.find(
(d) => !d.name?.toLowerCase().endsWith('.html') && !d.name?.includes('index')
) ?? docList[0];
if (fallback && fallback.name) {
const docUrl = `https://www.sec.gov/Archives/edgar/data/${padded}/${accessionNoDashes}/${fallback.name}`;
docText = await this.edgarXmlFetch(docUrl);
parsed = parse13fHoldings(docText);
}
}
// --- Step 3: parse holdings table --------------------------------------
const holdings = parse13fHoldings(docText);
const holdings = parsed;
return {
value: { holdings, accession: `${padded}/${accessionNoDashes}` },
@@ -389,7 +445,8 @@ export class EdgarAdapter implements SourceFetch {
cik: string,
accession: string,
): Promise<FetchResult> {
const padded = padCik(cik);
const filerCik = accession.split('-')[0];
const padded = padCik(filerCik);
const accessionNoDashes = accession.replace(/-/g, '');
// --- Step 1: filing index (JSON) ---------------------------------------
@@ -403,17 +460,21 @@ export class EdgarAdapter implements SourceFetch {
const indexBody = indexResp.body as {
fileDate?: string;
documents?: Array<{ name?: string; type?: string; size?: string | number; path?: string }>;
directory?: { item?: Array<{ name?: string; type?: string; size?: string | number }> };
partialSubmissionIndicator?: unknown;
};
if (!indexBody?.documents || indexBody.documents.length === 0) {
// EDGAR API may return documents as `documents` (old) or `directory.item` (new)
const docList = indexBody?.documents ?? indexBody?.directory?.item ?? [];
if (docList.length === 0) {
throw new Error(`EDGAR Form 4: no documents in index for ${padded}/${accessionNoDashes}`);
}
// Form 4 is typically filed as a single XML. Pick the .xml document.
const xmlDoc = indexBody.documents.find(
const xmlDoc = docList.find(
(d) => d.name?.toLowerCase().endsWith('.xml')
) ?? indexBody.documents[0];
) ?? docList[0];
const xmlName = xmlDoc.name;
if (!xmlName) {
@@ -446,24 +507,35 @@ export class EdgarAdapter implements SourceFetch {
* Fetch a URL that returns XML/HTML (not JSON), using the rate limiter.
* EDGAR returns XML for primary filing documents; we strip the body here
* rather than calling `resp.json()`.
* Retries with exponential backoff on 429 (rate limited).
*/
async edgarXmlFetch(url: string): Promise<string> {
await bucket.wait();
async edgarXmlFetch(url: string, retries = 3): Promise<string> {
for (let attempt = 1; attempt <= retries; attempt++) {
await bucket.wait();
const resp = await fetch(url, {
method: 'GET',
headers: {
'User-Agent': UA,
Accept: 'application/xml, text/xml, */*',
},
});
const resp = await fetch(url, {
method: 'GET',
headers: {
'User-Agent': UA,
Accept: 'application/xml, text/xml, */*',
},
});
if (resp.status >= 400) {
throw new Error(`EDGAR XML ${resp.status} ${resp.statusText} for ${url}`);
if (resp.status === 429 && attempt < retries) {
const backoff = Math.pow(2, attempt) * 1000;
await new Promise((r) => setTimeout(r, backoff));
continue;
}
if (resp.status >= 400) {
throw new Error(`EDGAR XML ${resp.status} ${resp.statusText} for ${url}`);
}
const text = await resp.text();
return text;
}
const text = await resp.text();
return text;
throw new Error(`EDGAR XML max retries (${retries}) exceeded for ${url}`);
}
/** SourceFetch.fetchOne dispatch. */
@@ -512,7 +584,30 @@ function parse13fHoldings(text: string): Array<{
}> {
const holdings: Array<{ cusip: string; issuerName: string; value: number; sshPrnamt: number }> = [];
// Try XML-style <table> parsing first (13F filings are often XML).
// Try 13F XML format: <infoTable> (possibly namespaced) with <nameOfIssuer>, <cusip>, <value>, <sshPrnamt>
// SEC 13F XML uses namespaces like <ns1:infoTable><ns1:nameOfIssuer>...
const infoTableMatches = text.match(/<(?:\w+:)?infoTable[^>]*>([\s\S]*?)<\/(?:\w+:)?infoTable>/gi);
if (infoTableMatches && infoTableMatches.length > 0) {
for (const block of infoTableMatches) {
const nameMatch = block.match(/<(?:\w+:)?nameOfIssuer>\s*([\s\S]*?)\s*<\/(?:\w+:)?nameOfIssuer>/i);
const cusipMatch = block.match(/<(?:\w+:)?cusip>\s*([\s\S]*?)\s*<\/(?:\w+:)?cusip>/i);
const valueMatch = block.match(/<(?:\w+:)?value>\s*([\s\S]*?)\s*<\/(?:\w+:)?value>/i);
const sharesMatch = block.match(/<(?:\w+:)?sshPrnamt>\s*([\s\S]*?)\s*<\/(?:\w+:)?sshPrnamt>/i);
if (!cusipMatch) continue;
const issuerName = nameMatch ? nameMatch[1].replace(/<[^>]+>/g, '').trim() : '';
const cusip = cusipMatch ? cusipMatch[1].replace(/<[^>]+>/g, '').trim() : '';
const value = valueMatch ? parseFloat(valueMatch[1].replace(/<[^>]+>/g, '').replace(/,/g, '')) || 0 : 0;
const sshPrnamt = sharesMatch ? parseFloat(sharesMatch[1].replace(/<[^>]+>/g, '').replace(/,/g, '')) || 0 : 0;
holdings.push({ cusip, issuerName, value, sshPrnamt });
}
if (holdings.length > 0) return holdings;
}
// Try HTML-style <table> parsing (older 13F filings may use HTML tables).
const xmlMatch = text.match(/<table[^>]*>([\s\S]*?)<\/table>/gi);
if (xmlMatch) {
for (const tbl of xmlMatch) {
@@ -596,11 +691,16 @@ function parse13fHoldings(text: string): Array<{
/**
* Extract transactions from a Form 4 XML filing.
*
* Form 4 uses an XML structure with <infotable> elements containing:
* <reportOwner>, <securityTitle>, <transactionDate>,
* <transactionCode>, <shares>, <pricePerShare>
* Modern EDGAR Form 4 uses <ownershipDocument> with:
* <reportingOwner><reportingOwnerId><rptOwnerName>...</rptOwnerName>...
* <nonDerivativeTable><nonDerivativeTransaction>...
* <securityTitle><value>...</value></securityTitle>
* <transactionDate><value>YYYY-MM-DD</value></transactionDate>
* <transactionCoding><transactionCode>...</transactionCode>...
* <transactionAmounts><transactionShares><value>...</value>...
* <transactionPricePerShare><value>...</value>...
*
* This is a thin parser that extracts the key fields.
* The old <infotable> format is also handled as a fallback.
*/
function parseForm4Transactions(text: string): Array<{
reporter: string;
@@ -621,12 +721,57 @@ function parseForm4Transactions(text: string): Array<{
price: number;
}> = [];
// Extract infotable blocks.
// Try modern format: <nonDerivativeTable> with <nonDerivativeTransaction> blocks
const hasNonDerivative = /<nonDerivativeTable>/i.test(text);
if (hasNonDerivative) {
// Get reporting owner info from document root (shared across all transactions)
const ownerNameMatch = text.match(/<rptOwnerName>\s*([\s\S]*?)<\/rptOwnerName>/i);
const reporter = ownerNameMatch ? ownerNameMatch[1].replace(/<[^>]+>/g, '').trim() : '';
const relBlockMatch = text.match(/<reportingOwnerRelationship>([\s\S]*?)<\/reportingOwnerRelationship>/i);
let relationship = '';
if (relBlockMatch) {
const relBlock = relBlockMatch[1];
const parts: string[] = [];
if (/<isDirector>\s*1/i.test(relBlock)) parts.push('Director');
if (/<isOfficer>\s*1/i.test(relBlock)) {
const titleMatch = relBlock.match(/<officerTitle>\s*([\s\S]*?)<\/officerTitle>/i);
parts.push(titleMatch ? titleMatch[1].replace(/<[^>]+>/g, '').trim() : 'Officer');
}
if (/<isTenPercentOwner>\s*1/i.test(relBlock)) parts.push('10% Owner');
if (/<isOther>\s*1/i.test(relBlock)) parts.push('Other');
relationship = parts.join(', ');
}
const txBlocks = text.match(/<nonDerivativeTransaction[\s\S]*?<\/nonDerivativeTransaction>/gi);
if (txBlocks) {
for (const block of txBlocks) {
const titleMatch = block.match(/<securityTitle[\s\S]*?<value>\s*([\s\S]*?)\s*<\/value>/i);
const securityTitle = titleMatch ? titleMatch[1].replace(/<[^>]+>/g, '').trim() : '';
const dateMatch = block.match(/<transactionDate[\s\S]*?<value>\s*(\d{4}-\d{2}-\d{2})/);
const transactionDate = dateMatch ? dateMatch[1] : '';
const codeMatch = block.match(/<transactionCode>\s*([A-HV])/i);
const transactionCode = codeMatch ? codeMatch[1].toUpperCase() : '';
const sharesMatch = block.match(/<transactionShares[\s\S]*?<value>\s*([\d]+)/i);
const shares = sharesMatch ? parseInt(sharesMatch[1], 10) : 0;
const priceMatch = block.match(/<transactionPricePerShare[\s\S]*?<value>\s*([\d,.]+)/i);
const price = priceMatch ? parseFloat(priceMatch[1].replace(/,/g, '')) : 0;
transactions.push({ reporter, relationship, securityTitle, transactionDate, transactionCode, shares, price });
}
}
return transactions;
}
// Fallback: old <infotable> format.
const infotables = text.match(/<infotable[\s\S]*?<\/infotable>/gi);
if (!infotables) return transactions;
for (const table of infotables) {
// Extract reporter CIK and name from <rptOwner>.
const rptOwnerMatch = table.match(/<rptOwner>([\s\S]*?)<\/rptOwner>/i);
let reporter = '';
if (rptOwnerMatch) {
@@ -639,7 +784,6 @@ function parseForm4Transactions(text: string): Array<{
}
}
// Extract relationship from <rptOwnerRelationship>.
const relBlockMatch = table.match(/<rptOwnerRelationship>([\s\S]*?)<\/rptOwnerRelationship>/i);
let relationship = '';
if (relBlockMatch) {
@@ -655,35 +799,22 @@ function parseForm4Transactions(text: string): Array<{
relationship = parts.join(', ');
}
// Extract security title.
const titleMatch = table.match(/<securityTitle>\s*([\s\S]*?)<\/securityTitle>/i);
const securityTitle = titleMatch ? titleMatch[1].trim() : '';
// Extract transaction date.
const dateMatch = table.match(/<transactionDate>\s*(\d{4}-\d{2}-\d{2})/);
const transactionDate = dateMatch ? dateMatch[1] : '';
// Extract transaction code.
const codeMatch = table.match(/<transactionCode>\s*([A-HV])/i);
const transactionCode = codeMatch ? codeMatch[1].toUpperCase() : '';
// Extract shares (non-decimal, integer).
const sharesMatch = table.match(/<nonDerivativeShares>\s*([\d]+)/);
const shares = sharesMatch ? parseInt(sharesMatch[1], 10) : 0;
// Extract price per share.
const priceMatch = table.match(/<priceOrStrike\s*Price>\s*([\d,.]+)/);
const price = priceMatch ? parseFloat(priceMatch[1].replace(/,/g, '')) : 0;
transactions.push({
reporter,
relationship,
securityTitle,
transactionDate,
transactionCode,
shares,
price,
});
transactions.push({ reporter, relationship, securityTitle, transactionDate, transactionCode, shares, price });
}
return transactions;
@@ -98,10 +98,10 @@ function makeFilingsResponse() {
name: 'TEST COMPANY INC',
filings: {
recent: [
{ form: '10-K', dateReporter: '2026-03-15', accessionNumber: '0001234567-26-000001', accessionNormalization: '2026-03-15', reportDate: '2026-02-28', reportFile: 'http://example.com/10k.pdf', primaryDocument: 'form10k.pdf' },
{ form: '10-Q', dateReporter: '2026-01-15', accessionNumber: '0001234567-26-000002', accessionNormalization: '2026-01-15', reportDate: '2025-12-31', reportFile: 'http://example.com/10q.pdf', primaryDocument: 'form10q.pdf' },
{ form: '8-K', dateReporter: '2025-12-01', accessionNumber: '0001234567-25-000003', accessionNormalization: '2025-12-01', reportDate: '2025-12-01', reportFile: 'http://example.com/8k.pdf', primaryDocument: 'form8k.pdf' },
{ form: 'SC 13G', dateReporter: '2025-06-30', accessionNumber: '0001234567-25-000004', accessionNormalization: '2025-06-30', reportDate: '2025-06-30', reportFile: 'http://example.com/13g.pdf', primaryDocument: 'form13g.pdf' },
{ form: '10-K', filingDate: '2026-03-15', accessionNumber: '0001234567-26-000001', accessionNormalization: '2026-03-15', reportDate: '2026-02-28', reportFile: 'http://example.com/10k.pdf', primaryDocument: 'form10k.pdf' },
{ form: '10-Q', filingDate: '2026-01-15', accessionNumber: '0001234567-26-000002', accessionNormalization: '2026-01-15', reportDate: '2026-01-15', reportFile: 'http://example.com/10q.pdf', primaryDocument: 'form10q.pdf' },
{ form: '8-K', filingDate: '2025-12-01', accessionNumber: '0001234567-25-000003', accessionNormalization: '2025-12-01', reportDate: '2025-12-01', reportFile: 'http://example.com/8k.pdf', primaryDocument: 'form8k.pdf' },
{ form: 'SC 13G', filingDate: '2025-06-30', accessionNumber: '0001234567-25-000004', accessionNormalization: '2025-06-30', reportDate: '2025-06-30', reportFile: 'http://example.com/13g.pdf', primaryDocument: 'form13g.pdf' },
],
},
};
@@ -195,7 +195,7 @@ test('filings_index filters by dateRange (from + to)', async () => {
assert.equal(getCallCount(), 1);
const filings = result.value as Array<Record<string, unknown>>;
// 10-K (2026-03-15), 10-Q (2026-01-15), 8-K (2025-12-01) are in range; SC 13G (2025-06-30) is out
// 10-K (2026-02-28), 10-Q (2026-01-15), 8-K (2025-12-01) are in range; SC 13G (2025-06-30) is out
assert.equal(filings.length, 3, 'should include filings within date range');
const forms = filings.map((f) => f.form);
assert.ok(forms.includes('10-K'));
@@ -222,7 +222,7 @@ test('filings_index filters by formTypes + dateRange combined', async () => {
assert.equal(getCallCount(), 1);
const filings = result.value as Array<Record<string, unknown>>;
// 10-K (2026-03-15) and 10-Q (2026-01-15) both have dateReporter >= 2026-01-01
// 10-K (2026-02-28) and 10-Q (2026-01-15) both have reportDate >= 2026-01-01
assert.equal(filings.length, 2, '10-K and 10-Q are in date range with form filter');
const forms = filings.map((f) => f.form);
assert.ok(forms.includes('10-K'));
+12
View File
@@ -44,12 +44,24 @@ export function initSchema(database: DatabaseSync): void {
database.exec(sql);
}
/** Idempotent migrations for existing databases (new columns, tables). */
function runMigrations(db: DatabaseSync): void {
const migrations: string[] = [
`ALTER TABLE adapter_queue ADD COLUMN error TEXT`,
`ALTER TABLE adapter_queue ADD COLUMN scheduled_for TEXT`,
];
for (const sql of migrations) {
try { db.exec(sql); } catch { /* column already exists */ }
}
}
let _db: DatabaseSync | null = null;
/** Process-wide singleton connection (auto-creates + inits schema on first call). */
export function db(): DatabaseSync {
if (!_db) {
_db = createDb();
initSchema(_db);
runMigrations(_db);
// Ensure the anonymous user exists for unauthenticated watchlist/portfolio usage.
try {
_db.prepare("INSERT OR IGNORE INTO users (id, email, pw_hash, created_at) VALUES (?, ?, ?, ?)")
+161 -2
View File
@@ -13,6 +13,7 @@ CREATE TABLE IF NOT EXISTS users (
pw_hash TEXT NOT NULL, -- scrypt(verifiable); design specified argon2id, adapted to built-in scrypt (reversible)
totp_secret TEXT, -- 2FA secret, encrypted at rest (slice 2)
is_2fa_enabled INTEGER NOT NULL DEFAULT 0,
is_admin INTEGER NOT NULL DEFAULT 0, -- 1 = operator (admin CLI/route access, Slice 25); never user-editable
oauth_subject TEXT, -- nullable; social OAuth link (slice 2)
oauth_provider TEXT, -- 'github' | 'google' | NULL
complexity TEXT NOT NULL DEFAULT 'beginner', -- beginner|intermediate|advanced
@@ -180,12 +181,44 @@ CREATE TABLE IF NOT EXISTS threads (
PRIMARY KEY (source, source_id)
);
CREATE TABLE IF NOT EXISTS admin_audit (
id TEXT PRIMARY KEY, -- uuid
actor TEXT NOT NULL, -- admin user id (or 'cli' when none)
action TEXT NOT NULL, -- users.reset-password|users.gdpr-export|queue.reset-backoff|...
target TEXT, -- target user id / sourceKind / null
ts TEXT NOT NULL, -- ISO timestamp
meta TEXT -- optional JSON blob (never secrets)
);
CREATE TABLE IF NOT EXISTS adapter_queue (
key TEXT PRIMARY KEY, -- e.g. "yfinance:quote:NVDA"
status TEXT NOT NULL, -- pending|in_flight|done|backoff
status TEXT NOT NULL, -- pending|in_flight|done|backoff|failed
last_attempt TEXT,
retry_count INTEGER NOT NULL DEFAULT 0,
backoff_until TEXT
backoff_until TEXT,
error TEXT, -- last error message (for failed/backoff)
scheduled_for TEXT -- ISO timestamp; job won't run before this
);
CREATE TABLE IF NOT EXISTS queue_errors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
queue_key TEXT NOT NULL,
attempt INTEGER NOT NULL,
error_message TEXT,
error_stack TEXT,
ts TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS queue_schedules (
source_kind TEXT PRIMARY KEY,
interval_ms INTEGER NOT NULL,
last_enqueued TEXT,
next_enqueue TEXT
);
CREATE TABLE IF NOT EXISTS queue_state (
key TEXT PRIMARY KEY, -- 'paused'
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS symbol_demand (
@@ -206,6 +239,8 @@ CREATE TABLE IF NOT EXISTS watchlists (
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_watchlists_owner_name ON watchlists(owner_id, name);
CREATE TABLE IF NOT EXISTS portfolio_holdings (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -216,6 +251,9 @@ CREATE TABLE IF NOT EXISTS portfolio_holdings (
status TEXT NOT NULL DEFAULT 'open' -- open|closed
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_portfolio_owner_symbol
ON portfolio_holdings(owner_id, symbol);
CREATE TABLE IF NOT EXISTS trades (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -306,6 +344,28 @@ CREATE TABLE IF NOT EXISTS trade_key_levels (
);
-- ===== Shared LLM cache (output deterministic by prompt-hash; not per-user) =====
-- ===== Slice 22 — Emotion Logger: per-trade emotion snapshots =====
CREATE TABLE IF NOT EXISTS trade_executions (
id TEXT PRIMARY KEY,
trade_id TEXT NOT NULL REFERENCES trades(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'active', -- active|closed
started_at TEXT NOT NULL,
completed_at TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS emotion_logs (
id TEXT PRIMARY KEY,
trade_execution_id TEXT NOT NULL REFERENCES trade_executions(id) ON DELETE CASCADE,
timestamp INTEGER NOT NULL,
price_at_event REAL, -- price at moment of emotion event
emotion TEXT NOT NULL, -- e.g. 'fear', 'greed', 'FOMO', 'regret'
note TEXT, -- optional user note
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_emotion_logs_trade ON emotion_logs(trade_execution_id);
CREATE TABLE IF NOT EXISTS llm_summaries (
hash TEXT PRIMARY KEY, -- hash(prompt + source-payload + complexity)
feature TEXT NOT NULL, -- filing_summary|post_summary|dashboard_rollup|explainer|qa|sizing_explain|macro_commentary|...
@@ -391,3 +451,102 @@ CREATE INDEX IF NOT EXISTS idx_threads_symbol_ts ON threads(symbol, ts);
CREATE INDEX IF NOT EXISTS idx_trades_owner ON trades(owner_id);
CREATE INDEX IF NOT EXISTS idx_alerts_owner_sym ON alerts(owner_id, symbol);
CREATE INDEX IF NOT EXISTS idx_signals_sector ON rotation_signals(sector);
-- ===== Slice 18 — Risk Engine: Gentle-halt circuit breaker state =====
CREATE TABLE IF NOT EXISTS halt_state (
user_id TEXT PRIMARY KEY,
halted_until TEXT NOT NULL, -- ISO8601; 24h cooldown window
triggered_by TEXT NOT NULL, -- e.g. 'max_drawdown_tolerance_breach'
ts TEXT NOT NULL -- ISO8601; when the halt was recorded
);
-- ===== Slice 17 — AlertEngine: individual alert event instances =====
CREATE TABLE IF NOT EXISTS alert_events (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL, -- informed_buy|informed_sell|...
severity TEXT NOT NULL, -- info|warning|critical
title TEXT NOT NULL,
description TEXT NOT NULL,
symbol TEXT,
created_at TEXT NOT NULL,
acknowledged INTEGER NOT NULL DEFAULT 0,
dedup_key TEXT NOT NULL,
payload TEXT NOT NULL DEFAULT '{}', -- JSON
throttle_key TEXT -- for rate limiting: "user:type:symbol:YYYYMMDDHH"
);
CREATE INDEX IF NOT EXISTS idx_alert_events_user ON alert_events(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_alert_events_dedup ON alert_events(dedup_key);
CREATE INDEX IF NOT EXISTS idx_alert_events_throttle ON alert_events(throttle_key);
-- ===== Slice 12 — Strategy Lab + Backtest =====
CREATE TABLE IF NOT EXISTS strategies (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
components TEXT NOT NULL DEFAULT '[]', -- JSON array of StrategyComponent
unlocked INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS backtest_results (
id TEXT PRIMARY KEY,
strategy_id TEXT NOT NULL REFERENCES strategies(id) ON DELETE CASCADE,
symbol TEXT NOT NULL,
timeframe TEXT NOT NULL DEFAULT '1d',
equity_curve TEXT NOT NULL DEFAULT '[]', -- JSON array
exit_reasons TEXT NOT NULL DEFAULT '[]', -- JSON array
sample_size INTEGER NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_strategies_owner ON strategies(owner_id);
CREATE INDEX IF NOT EXISTS idx_backtest_strategy ON backtest_results(strategy_id);
-- ===== Slice 13 — Saved Filter Sets =====
CREATE TABLE IF NOT EXISTS saved_filters (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
expression TEXT NOT NULL,
scope TEXT NOT NULL DEFAULT 'watchlist',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_saved_filters_user ON saved_filters(user_id);
-- ===== Slice 19 — Options Convexity Sleeve Unlock State =====
CREATE TABLE IF NOT EXISTS options_unlock (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
state INTEGER NOT NULL DEFAULT 0,
last_unlock_at TEXT,
understanding INTEGER NOT NULL DEFAULT 0 -- demonstrated understanding flag
);
-- ===== Slice 20 — Macro Regime History =====
CREATE TABLE IF NOT EXISTS regime_history (
id TEXT PRIMARY KEY,
date TEXT NOT NULL,
regime TEXT NOT NULL,
confidence INTEGER NOT NULL,
factors TEXT NOT NULL DEFAULT '[]', -- JSON array
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_regime_history_date ON regime_history(date DESC);
-- ===== Slice 21 — Thesis Monitoring =====
CREATE TABLE IF NOT EXISTS theses (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
symbol TEXT NOT NULL,
statement TEXT NOT NULL,
invalidation_criteria TEXT NOT NULL DEFAULT '[]', -- JSON array
status TEXT NOT NULL DEFAULT 'intact',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_theses_user ON theses(user_id);
CREATE INDEX IF NOT EXISTS idx_theses_symbol ON theses(symbol);
+30 -5
View File
@@ -5,17 +5,31 @@ 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';
import { SecFetchAdapter } from './adapters/SecFetchAdapter.ts';
import type { SourceFetch } from './adapters/SourceAdapter.ts';
import { AdapterQueue } from './queue/AdapterQueue.ts';
import { makeCreateContext } from './trpc/context.ts';
import { appRouter } from './trpc/router.ts';
const PORT = Number(process.env.PORT ?? 3001);
const database = db();
const adapters = new Map<SourceKind, YFinanceAdapter>([['yfinance', new YFinanceAdapter()]]);
const adapters: Map<SourceKind, SourceFetch> = new Map([
['yfinance', new YFinanceAdapter()],
['sec-fetch', new SecFetchAdapter(database)],
]);
const queue = new AdapterQueue({ db: database, adapters });
const cache = createCacheRepository({ db: database, scheduler: queue });
queue.cache = cache; // break the cache<->scheduler cycle
const createContext = makeCreateContext({ db: database, cache });
// Seed default schedules (noop if already seeded)
queue.seedDefaultSchedules();
// 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`);
const createContext = makeCreateContext({ db: database, cache, queue });
// Background drain: stale-while-revalidate refreshes are queued by CacheRepository.get;
// this loop drains them (fetch via adapter -> write to cache), deduped + backed off.
@@ -23,6 +37,11 @@ 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();
// 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();
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
let data = '';
@@ -44,7 +63,12 @@ const server = createServer(async (req, res) => {
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);
const request = new Request(`http://localhost:${PORT}${url.pathname}${url.search}`, { method, headers, body });
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);
try {
const response = await fetchRequestHandler({ router: appRouter, createContext, endpoint: '/api/trpc', req: request });
const buf = Buffer.from(await response.arrayBuffer());
@@ -61,6 +85,7 @@ const server = createServer(async (req, res) => {
res.end(JSON.stringify({ error: 'not found' }));
});
server.listen(PORT, () => {
console.log(`[investor-flow] backend on http://localhost:${PORT} (tRPC /api/trpc, health /health, drain every ${DRAIN_MS}ms)`);
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)`);
});
+154 -26
View File
@@ -5,7 +5,7 @@
import type { DatabaseSync } from 'node:sqlite';
import type { CacheKey, SourceKind, CacheRepository, CacheScheduler } from '../cache/CacheRepository.ts';
import { parseCacheKey } from '../cache/CacheRepository.ts';
import type { SourceFetch, AdapterHealth } from '../adapters/SourceAdapter.ts';
import type { SourceFetch } from '../adapters/SourceAdapter.ts';
export interface AdapterQueueOptions {
db: DatabaseSync;
@@ -14,12 +14,39 @@ export interface AdapterQueueOptions {
rateLimitMs?: Partial<Record<SourceKind, number>>;
}
const DEFAULT_RATE_MS: Record<SourceKind, number> = { yfinance: 1000, sec: 125, reddit: 1000, x: 3000, macro: 1000, llm: 0 };
const BACKOFF_MS = [2000, 4000, 8000, 16000, 60000]; // yfinance policy §5 (2s..60s ceiling)
const DEFAULT_RATE_MS: Record<SourceKind, number> = { yfinance: 1000, sec: 125, 'sec-fetch': 1000, reddit: 1000, x: 3000, macro: 1000, llm: 0 };
const BACKOFF_MS = [2000, 4000, 8000, 16000, 60000];
const MAX_ATTEMPTS = 5;
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
export interface QueueHealthDetailed {
queued: number;
in_flight: number;
failed: number;
backoff: number;
done: number;
backoff_until: string | null;
last_error: string | undefined;
paused: boolean;
counts: Record<string, number>;
lastErrors: Array<{ key: string; error: string | null }>;
}
export interface QueueErrorEntry {
attempt: number;
errorMessage: string | null;
errorStack: string | null;
ts: string;
}
export interface ScheduleEntry {
sourceKind: string;
intervalMs: number;
lastEnqueued: string | null;
nextEnqueue: string | null;
}
export class AdapterQueue implements CacheScheduler {
private readonly _db: DatabaseSync;
private readonly _adapters: Map<SourceKind, SourceFetch>;
@@ -34,58 +61,160 @@ export class AdapterQueue implements CacheScheduler {
this._rate = { ...DEFAULT_RATE_MS, ...(opts.rateLimitMs ?? {}) } as Record<SourceKind, number>;
}
/** Link the cache (results are written here after a successful fetch). Breaks the cache<->queue cycle. */
set cache(c: CacheRepository) { this._cache = c; }
/** Enqueue a fetch. Dedupes against pending/in_flight; respects active backoff. force bypasses dedupe. */
async queue(key: CacheKey): Promise<void> {
isPaused(): boolean {
const row = this._db.prepare("SELECT value FROM queue_state WHERE key='paused'").get() as { value: string } | undefined;
return row?.value === 'true';
}
setPaused(paused: boolean): void {
this._db.prepare("INSERT OR REPLACE INTO queue_state (key, value) VALUES ('paused', ?)").run(String(paused));
}
async queue(key: CacheKey, scheduledFor?: string): Promise<void> {
const row = this._db.prepare('SELECT status, backoff_until FROM adapter_queue WHERE key=?').get(key) as { status?: string; backoff_until?: string | null } | undefined;
if (row) {
if (row.status === 'pending' || row.status === 'in_flight') return; // dedupe: already queued
if (row.status === 'backoff' && row.backoff_until && Date.parse(row.backoff_until) > Date.now()) return; // still backing off
if (row.status === 'pending' || row.status === 'in_flight') return;
if (row.status === 'backoff' && row.backoff_until && Date.parse(row.backoff_until) > Date.now()) return;
}
this._db.prepare('INSERT OR REPLACE INTO adapter_queue (key,status,last_attempt,retry_count,backoff_until) VALUES (?,?,?,?,?)').run(key, 'pending', null, 0, null);
this._db.prepare('INSERT OR REPLACE INTO adapter_queue (key,status,last_attempt,retry_count,backoff_until,error,scheduled_for) VALUES (?,?,?,?,?,?,?)').run(key, 'pending', null, 0, null, null, scheduledFor ?? null);
}
/** Process pending/backoff-expired jobs: fetch via the source adapter, write to cache, set done/backoff/failed. */
async drain(): Promise<void> {
if (!this._cache) return;
if (this.isPaused()) return;
const now = Date.now();
const jobs = this._db.prepare(
"SELECT key, retry_count, backoff_until FROM adapter_queue WHERE status IN ('pending','backoff') ORDER BY (last_attempt IS NULL) DESC, last_attempt ASC",
).all() as Array<{ key: string; retry_count: number; backoff_until?: string | null }>;
"SELECT key, retry_count, backoff_until, scheduled_for FROM adapter_queue WHERE status IN ('pending','backoff') ORDER BY (last_attempt IS NULL) DESC, last_attempt ASC",
).all() as Array<{ key: string; retry_count: number; backoff_until?: string | null; scheduled_for?: string | null }>;
for (const job of jobs) {
if (job.backoff_until && Date.parse(job.backoff_until) > now) continue; // backoff not yet expired
if (job.backoff_until && Date.parse(job.backoff_until) > now) continue;
if (job.scheduled_for && Date.parse(job.scheduled_for) > now) continue;
const { source } = parseCacheKey(job.key);
const adapter = this._adapters.get(source);
if (!adapter) { this._setStatus(job.key, 'done'); continue; } // no adapter registered -> don't leave stuck
// token bucket: enforce per-source min interval
if (!adapter) { this._setStatus(job.key, 'done'); continue; }
const last = this._lastFetchAt[source] ?? 0;
const wait = (this._rate[source] ?? 0) - (Date.now() - last);
if (wait > 0) await sleep(wait);
this._lastFetchAt[source] = Date.now();
this._setStatus(job.key, 'in_flight');
const attempt = job.retry_count + 1;
try {
const res = await adapter.fetchOne(job.key);
await this._cache.set(job.key, res.value, res.ttlClass, res.provenance);
this._db.prepare("UPDATE adapter_queue SET status='done', last_attempt=? WHERE key=?").run(new Date().toISOString(), job.key);
this._db.prepare("UPDATE adapter_queue SET status='done', last_attempt=?, error=NULL WHERE key=?").run(new Date().toISOString(), job.key);
} catch (e) {
const retry = job.retry_count + 1;
this._lastError = e instanceof Error ? e.message : String(e);
if (retry >= MAX_ATTEMPTS) {
this._db.prepare("UPDATE adapter_queue SET status='failed', last_attempt=?, retry_count=?, backoff_until=NULL WHERE key=?").run(new Date().toISOString(), retry, job.key);
const msg = e instanceof Error ? e.message : String(e);
const stack = e instanceof Error ? (e.stack ?? msg) : msg;
this._lastError = msg;
this._db.prepare("INSERT INTO queue_errors (queue_key, attempt, error_message, error_stack, ts) VALUES (?,?,?,?,?)").run(job.key, attempt, msg, stack, new Date().toISOString());
if (attempt >= MAX_ATTEMPTS) {
this._db.prepare("UPDATE adapter_queue SET status='failed', last_attempt=?, retry_count=?, backoff_until=NULL, error=? WHERE key=?").run(new Date().toISOString(), attempt, msg, job.key);
} else {
const bo = BACKOFF_MS[Math.min(retry - 1, BACKOFF_MS.length - 1)];
this._db.prepare("UPDATE adapter_queue SET status='backoff', last_attempt=?, retry_count=?, backoff_until=? WHERE key=?").run(new Date().toISOString(), retry, new Date(Date.now() + bo).toISOString(), job.key);
const bo = BACKOFF_MS[Math.min(attempt - 1, BACKOFF_MS.length - 1)];
this._db.prepare("UPDATE adapter_queue SET status='backoff', last_attempt=?, retry_count=?, backoff_until=?, error=? WHERE key=?").run(new Date().toISOString(), attempt, new Date(Date.now() + bo).toISOString(), msg, job.key);
}
}
}
}
health(): AdapterHealth {
const row = this._db.prepare("SELECT SUM(status='pending') AS q, SUM(status='in_flight') AS i, MAX(backoff_until) AS bu FROM adapter_queue").get() as { q: number | null; i: number | null; bu: string | null };
return { queued: row.q ?? 0, in_flight: row.i ?? 0, backoff_until: row.bu ?? null, last_error: this._lastError ?? undefined };
retryJob(key: string): void {
this._db.prepare("UPDATE adapter_queue SET status='pending', backoff_until=NULL, retry_count=0, error=NULL WHERE key=?").run(key);
}
retrySource(kind: string): number {
const res = this._db.prepare(
"UPDATE adapter_queue SET status='pending', backoff_until=NULL, retry_count=0, error=NULL WHERE status IN ('failed','backoff') AND substr(key,1,instr(key,':')-1)=?"
).run(kind);
return Number(res.changes);
}
clearDone(olderThanMs: number = 86400000): number {
const cutoff = new Date(Date.now() - olderThanMs).toISOString();
const res = this._db.prepare("DELETE FROM adapter_queue WHERE status='done' AND last_attempt < ?").run(cutoff);
return Number(res.changes);
}
setSchedule(sourceKind: string, intervalMs: number): void {
this._db.prepare("INSERT OR REPLACE INTO queue_schedules (source_kind, interval_ms, last_enqueued, next_enqueue) VALUES (?,?,?,?)").run(sourceKind, intervalMs, null, new Date(Date.now() + intervalMs).toISOString());
}
deleteSchedule(sourceKind: string): void {
this._db.prepare("DELETE FROM queue_schedules WHERE source_kind=?").run(sourceKind);
}
listSchedules(): ScheduleEntry[] {
const rows = this._db.prepare("SELECT source_kind, interval_ms, last_enqueued, next_enqueue FROM queue_schedules ORDER BY source_kind").all() as Array<{ source_kind: string; interval_ms: number; last_enqueued: string | null; next_enqueue: string | null }>;
return rows.map((r) => ({
sourceKind: r.source_kind,
intervalMs: r.interval_ms,
lastEnqueued: r.last_enqueued,
nextEnqueue: r.next_enqueue,
}));
}
async enqueueDueSchedules(): Promise<void> {
const now = new Date().toISOString();
const due = this._db.prepare("SELECT * FROM queue_schedules WHERE next_enqueue IS NOT NULL AND next_enqueue <= ?").all(now) as Array<{ source_kind: string; interval_ms: number; last_enqueued: string | null; next_enqueue: string | null }>;
for (const s of due) {
const symbols = this._db.prepare("SELECT symbol FROM symbol_demand WHERE in_demand=1").all() as Array<{ symbol: string }>;
if (s.source_kind === 'sec-fetch') {
for (const sym of symbols) {
await this.queue(`sec-fetch:fetch:${sym.symbol}`);
}
} else if (s.source_kind === 'yfinance') {
for (const sym of symbols) {
await this.queue(`yfinance:quote:${sym.symbol}`);
await this.queue(`yfinance:candles:${sym.symbol}:1d`);
await this.queue(`yfinance:symbol:${sym.symbol}`);
}
}
const nextEnqueue = new Date(Date.now() + s.interval_ms).toISOString();
this._db.prepare("UPDATE queue_schedules SET last_enqueued=?, next_enqueue=? WHERE source_kind=?").run(now, nextEnqueue, s.source_kind);
}
}
seedDefaultSchedules(): void {
const existing = this._db.prepare("SELECT COUNT(*) AS c FROM queue_schedules").get() as { c: number };
if (existing.c > 0) return;
const defaults: Array<[string, number]> = [
['sec-fetch', 86400000],
['yfinance', 300000],
];
const insert = this._db.prepare("INSERT OR IGNORE INTO queue_schedules (source_kind, interval_ms, last_enqueued, next_enqueue) VALUES (?,?,?,?)");
for (const [kind, ms] of defaults) {
insert.run(kind, ms, null, new Date(Date.now() + ms).toISOString());
}
}
health(): QueueHealthDetailed {
const paused = this.isPaused();
const row = this._db.prepare("SELECT SUM(status='pending') AS q, SUM(status='in_flight') AS i, SUM(status='failed') AS f, SUM(status='backoff') AS b, SUM(status='done') AS d, MAX(backoff_until) AS bu FROM adapter_queue").get() as { q: number | null; i: number | null; f: number | null; b: number | null; d: number | null; bu: string | null };
const failedJobs = this._db.prepare("SELECT key, error FROM adapter_queue WHERE status='failed' AND error IS NOT NULL ORDER BY last_attempt DESC LIMIT 5").all() as Array<{ key: string; error: string | null }>;
return {
queued: row.q ?? 0,
in_flight: row.i ?? 0,
failed: row.f ?? 0,
backoff: row.b ?? 0,
done: row.d ?? 0,
backoff_until: row.bu ?? null,
last_error: this._lastError ?? undefined,
paused,
counts: { pending: row.q ?? 0, in_flight: row.i ?? 0, failed: row.f ?? 0, backoff: row.b ?? 0, done: row.d ?? 0 },
lastErrors: failedJobs,
};
}
getErrorLog(key: string, limit: number = 10): QueueErrorEntry[] {
const rows = this._db.prepare("SELECT attempt, error_message, error_stack, ts FROM queue_errors WHERE queue_key=? ORDER BY attempt DESC LIMIT ?").all(key, limit) as Array<{ attempt: number; error_message: string | null; error_stack: string | null; ts: string }>;
return rows.map((r) => ({
attempt: r.attempt,
errorMessage: r.error_message,
errorStack: r.error_stack,
ts: r.ts,
}));
}
private _setStatus(key: string, status: string): void {
@@ -93,7 +222,6 @@ export class AdapterQueue implements CacheScheduler {
}
}
/** Wire queue + cache together (breaks the cache<->scheduler cycle). Used by slice 1f. */
export function createAdapterQueue(opts: AdapterQueueOptions): AdapterQueue {
return new AdapterQueue(opts);
}
+4 -1
View File
@@ -4,6 +4,7 @@
import { randomUUID, randomBytes, scryptSync, timingSafeEqual, createHmac } from 'node:crypto';
import type { DatabaseSync } from 'node:sqlite';
import type { CacheRepository } from '../cache/CacheRepository.ts';
import type { AdapterQueue } from '../queue/AdapterQueue.ts';
export const SESSION_COOKIE = 'iflow_session';
export const OAUTH_STATE_COOKIE = 'iflow_oauth_state';
@@ -19,6 +20,7 @@ const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
export interface Context {
db: DatabaseSync;
cache: CacheRepository;
queue: AdapterQueue;
resHeaders: Headers; // mutable; procedures append Set-Cookie here (applied to Response by the fetch adapter)
userId: string | null; // resolved from the session cookie; null = unauthenticated
cookies: Record<string, string>; // parsed request cookies (session + oauth state)
@@ -100,12 +102,13 @@ export function verifyPassword(pw: string, stored: string): boolean {
}
// ----- context factory: 1f wires real db+cache; tests inject in-memory -----
export function makeCreateContext(opts: { db: DatabaseSync; cache: CacheRepository }) {
export function makeCreateContext(opts: { db: DatabaseSync; cache: CacheRepository; queue: AdapterQueue }) {
return ({ req, resHeaders }: CreateContextOpts): Context => {
const cookies = parseCookies(req);
return {
db: opts.db,
cache: opts.cache,
queue: opts.queue,
resHeaders,
cookies,
userId: resolveSessionUserId(opts.db, req),
+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 };
+614
View File
@@ -0,0 +1,614 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { AdminLayout } from "@/components/AdminLayout";
import { api } from "@/lib/trpc";
interface QueueItem {
source: string;
status: string;
count: number;
last_attempt: string | null;
retry_count: number;
backoff_until: string | null;
}
interface QueueStatus {
paused: boolean;
queued: number;
in_flight: number;
failed: number;
backoff: number;
done: number;
backoff_until: string | null;
last_error: string | undefined;
counts: Record<string, number>;
lastErrors: Array<{ key: string; error: string | null }>;
}
interface ErrorLogEntry {
attempt: number;
errorMessage: string | null;
errorStack: string | null;
ts: string;
}
interface ScheduleEntry {
sourceKind: string;
intervalMs: number;
lastEnqueued: string | null;
nextEnqueue: string | null;
}
function Spinner() {
return (
<div className="flex items-center justify-center py-16">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent border-t-transparent" />
</div>
);
}
function Banner({ type, message, onDismiss }: { type: "success" | "error"; message: string; onDismiss?: () => void }) {
const styles = type === "success"
? "border-green-500/30 bg-green-950/20 text-up"
: "border-red-500/30 bg-red-950/20 text-danger";
return (
<div className={`rounded-lg border ${styles} px-4 py-3 text-sm flex items-center justify-between`}>
<span>{message}</span>
{onDismiss && (
<button onClick={onDismiss} className="text-fg-faint hover:text-fg ml-4">&times;</button>
)}
</div>
);
}
function formatTimestamp(ts: string | null): string {
if (!ts) return "Never";
try {
const d = new Date(ts);
return d.toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
} catch {
return ts.slice(0, 16);
}
}
function msToHuman(ms: number): string {
if (ms < 60000) return `${Math.round(ms / 1000)}s`;
if (ms < 3600000) return `${Math.round(ms / 60000)}m`;
if (ms < 86400000) return `${Math.round(ms / 3600000)}h`;
return `${Math.round(ms / 86400000)}d`;
}
export default function QueuePage() {
const [queue, setQueue] = useState<QueueItem[]>([]);
const [status, setStatus] = useState<QueueStatus | null>(null);
const [schedules, setSchedules] = useState<ScheduleEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [logs, setLogs] = useState<Record<string, ErrorLogEntry[]>>({});
const [expandedLog, setExpandedLog] = useState<string | null>(null);
const [symbol, setSymbol] = useState("");
const [triggering, setTriggering] = useState(false);
const [resettingSource, setResettingSource] = useState<string | null>(null);
const [resetResult, setResetResult] = useState<Record<string, number>>({});
const [newScheduleKind, setNewScheduleKind] = useState("sec-fetch");
const [newScheduleInterval, setNewScheduleInterval] = useState("86400000");
const [addingSchedule, setAddingSchedule] = useState(false);
const [pausing, setPausing] = useState(false);
const loadData = useCallback(async () => {
try {
const [queueData, statusData, scheduleData] = await Promise.all([
api.admin.queueHealth(),
api.admin.queueStatus(),
api.admin.queueSchedules(),
]);
setQueue(queueData);
setStatus(statusData);
setSchedules(scheduleData);
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load queue data");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadData();
const interval = setInterval(loadData, 5000);
return () => { clearInterval(interval); };
}, [loadData]);
const handlePause = async () => {
setPausing(true);
try {
if (status?.paused) {
await api.admin.queueResume();
setSuccess("Queue resumed");
} else {
await api.admin.queuePause();
setSuccess("Queue paused");
}
await loadData();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to toggle pause");
} finally {
setPausing(false);
}
};
const handleClearDone = async () => {
try {
const result = await api.admin.queueClearDone(24);
setSuccess(`Cleared ${result.cleared} done jobs`);
await loadData();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to clear done jobs");
}
};
const handleQueueFetch = async (e: React.FormEvent) => {
e.preventDefault();
if (!symbol.trim()) return;
setTriggering(true);
setError(null);
setSuccess(null);
try {
await api.admin.queueSecFetch(symbol.trim().toUpperCase());
setSuccess(`Queued SEC fetch for ${symbol.toUpperCase()}`);
setSymbol("");
await loadData();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to queue fetch");
} finally {
setTriggering(false);
}
};
const handleResetBackoff = async (sourceKind: string) => {
setResettingSource(sourceKind);
try {
const result = await api.admin.queueRetrySource(sourceKind);
setResetResult((prev) => ({ ...prev, [sourceKind]: result.cleared }));
await loadData();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to reset backoff");
} finally {
setResettingSource(null);
}
};
const handleRetryJob = async (key: string) => {
try {
await api.admin.queueRetryJob(key);
setSuccess(`Retried job: ${key}`);
await loadData();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to retry job");
}
};
const toggleLog = async (key: string) => {
if (expandedLog === key) {
setExpandedLog(null);
return;
}
setExpandedLog(key);
if (!logs[key]) {
try {
const entries = await api.admin.queueLogs(key, 10);
setLogs((prev) => ({ ...prev, [key]: entries }));
} catch {
// ignore
}
}
};
const handleAddSchedule = async (e: React.FormEvent) => {
e.preventDefault();
setAddingSchedule(true);
try {
await api.admin.queueSetSchedule(newScheduleKind, Number(newScheduleInterval));
setSuccess(`Schedule added for ${newScheduleKind}`);
setNewScheduleKind("sec-fetch");
setNewScheduleInterval("86400000");
await loadData();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to add schedule");
} finally {
setAddingSchedule(false);
}
};
const handleDeleteSchedule = async (sourceKind: string) => {
try {
await api.admin.queueDeleteSchedule(sourceKind);
setSuccess(`Schedule deleted for ${sourceKind}`);
await loadData();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to delete schedule");
}
};
type SourceAgg = {
pending: number;
inFlight: number;
done: number;
backoff: number;
failed: number;
lastAttempt: string | null;
hasBackoff: boolean;
};
const sources: Record<string, SourceAgg> = {};
for (const item of queue) {
if (!sources[item.source]) {
sources[item.source] = { pending: 0, inFlight: 0, done: 0, backoff: 0, failed: 0, lastAttempt: null, hasBackoff: false };
}
const agg = sources[item.source];
if (item.status === "backoff") { agg.backoff += item.count; agg.hasBackoff = true; }
else if (item.status === "failed") { agg.failed += item.count; }
else if (item.status === "in_flight") { agg.inFlight += item.count; }
else if (item.status === "pending") { agg.pending += item.count; }
else if (item.status === "done") { agg.done += item.count; }
if (item.last_attempt) {
if (!agg.lastAttempt || new Date(item.last_attempt) > new Date(agg.lastAttempt)) {
agg.lastAttempt = item.last_attempt;
}
}
}
const sortedSources = Object.entries(sources).sort(([a], [b]) => a.localeCompare(b));
if (loading) {
return <AdminLayout><Spinner /></AdminLayout>;
}
return (
<AdminLayout>
<div className="space-y-6">
<div>
<h1 className="text-xl font-bold text-fg">Queue Management</h1>
<p className="text-sm text-fg-faint mt-1">Monitor queue health, schedules, and manually trigger data fetches</p>
</div>
{error && <Banner type="error" message={error} onDismiss={() => setError(null)} />}
{success && <Banner type="success" message={success} onDismiss={() => setSuccess(null)} />}
{Object.entries(resetResult).map(([src, cleared]) => (
<Banner key={`reset-${src}`} type="success" message={`${cleared} job${cleared !== 1 ? "s" : ""} retried for ${src}`} onDismiss={() => setResetResult({})} />
))}
{/* Control Bar */}
<section className="rounded-lg border border-line-strong bg-surface-raised overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 bg-surface">
<div className="flex items-center gap-4">
<button
onClick={handlePause}
disabled={pausing}
className={`inline-flex items-center gap-2 rounded border px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-40 ${
status?.paused
? "border-green-500/40 bg-green-950/30 text-up hover:bg-green-950/50"
: "border-orange-500/40 bg-orange-950/30 text-[#fb923c] hover:bg-orange-950/50"
}`}
>
{pausing ? (
<span className="h-3 w-3 border border-current border-t-transparent rounded-full animate-spin" />
) : null}
{status?.paused ? "Resume" : "Pause"}
</button>
<button
onClick={handleClearDone}
className="inline-flex items-center gap-2 rounded border border-line-strong bg-surface px-3 py-1.5 text-xs font-medium text-fg-faint hover:text-fg transition-colors"
>
Clear Done
</button>
</div>
<div className="flex items-center gap-3 text-xs text-fg-faint">
<span className="flex items-center gap-1.5">
<span className={`h-2 w-2 rounded-full ${status?.paused ? "bg-[#fb923c]" : "bg-[#34d399]"}`} />
{status?.paused ? "Paused" : "Active"}
</span>
<span className="font-mono text-fg-muted">
{status?.counts.pending ?? 0}p / {status?.counts.in_flight ?? 0}i / {status?.counts.failed ?? 0}f / {status?.counts.backoff ?? 0}b
</span>
</div>
</div>
</section>
{/* Queue Health Overview */}
<section className="rounded-lg border border-line-strong bg-surface-raised overflow-hidden">
<div className="flex items-center justify-between border-b border-line-strong px-4 py-3 bg-surface">
<h2 className="text-sm font-semibold text-fg flex items-center gap-2">
<span className={`inline-block h-2 w-2 rounded-full ${status?.paused ? "bg-[#fb923c]" : "bg-[#34d399]"}`} />
Queue Health Overview
</h2>
<span className="text-xs font-mono text-fg-faint">{sortedSources.length} sources</span>
</div>
{sortedSources.length === 0 ? (
<p className="text-xs text-fg-faint italic py-8 text-center">No queue data available</p>
) : (
<>
<div className="flex items-center gap-4 px-4 pt-3 border-b border-[#1a1b28]">
<span className="text-[10px] uppercase tracking-wide text-fg-faint flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-accent" /> Pending
</span>
<span className="text-[10px] uppercase tracking-wide text-fg-faint flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-[#34d399]" /> In-flight
</span>
<span className="text-[10px] uppercase tracking-wide text-fg-faint flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-fg-muted/40" /> Done
</span>
<span className="text-[10px] uppercase tracking-wide text-fg-faint flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-[#fb923c]" /> Backoff
</span>
<span className="text-[10px] uppercase tracking-wide text-fg-faint flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-danger" /> Failed
</span>
</div>
{sortedSources.some(([, v]) => v.backoff > 0) && (
<div className="mx-4 mt-3 flex items-center gap-2 rounded border border-orange-500/30 bg-orange-950/20 px-3 py-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-[#fb923c] animate-pulse" />
<span className="text-xs text-[#fb923c] font-medium">Backoff active — jobs stalled and awaiting retry</span>
</div>
)}
<table className="w-full text-xs mt-3">
<thead>
<tr className="border-b border-line-strong text-left text-fg-faint uppercase tracking-wide text-[10px]">
<th className="pb-2 pr-4 pl-4 font-medium w-48">Source</th>
<th className="pb-2 pr-4 font-medium text-right"><span className="text-accent">Pending</span></th>
<th className="pb-2 pr-4 font-medium text-right"><span className="text-up">In-flight</span></th>
<th className="pb-2 pr-4 font-medium text-right"><span className="text-fg-muted/60">Done</span></th>
<th className="pb-2 pr-4 font-medium text-right"><span className="text-[#fb923c]">Backoff</span></th>
<th className="pb-2 pr-4 font-medium text-right"><span className="text-danger">Failed</span></th>
<th className="pb-2 pr-4 font-medium">Last Attempt</th>
<th className="pb-2 font-medium w-48 text-center">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-[#1a1b28]">
{sortedSources.map(([source, agg]) => (
<tr key={source} className={`hover:bg-surface/50 transition-colors ${agg.hasBackoff ? "bg-orange-950/10" : ""}`}>
<td className="py-2.5 pr-4 pl-4 font-mono text-fg">{source}</td>
<td className={`py-2.5 pr-4 font-mono text-right ${agg.pending > 0 ? "text-accent" : "text-fg-faint"}`}>{agg.pending}</td>
<td className={`py-2.5 pr-4 font-mono text-right ${agg.inFlight > 0 ? "text-up" : "text-fg-faint"}`}>{agg.inFlight}</td>
<td className="py-2.5 pr-4 font-mono text-right text-fg-muted">{agg.done}</td>
<td className="py-2.5 pr-4 font-mono text-right text-[#fb923c]">{agg.backoff}</td>
<td className={`py-2.5 pr-4 font-mono text-right ${agg.failed > 0 ? "text-danger" : "text-fg-faint"}`}>{agg.failed}</td>
<td className="py-2.5 pr-4 text-[10px] text-fg-faint whitespace-nowrap font-mono">{formatTimestamp(agg.lastAttempt)}</td>
<td className="py-2.5 text-center">
<div className="flex items-center justify-center gap-1.5">
{agg.hasBackoff && (
<button
onClick={() => handleResetBackoff(source)}
disabled={resettingSource === source}
className="inline-flex items-center gap-1 rounded border border-orange-500/40 bg-orange-950/30 px-2 py-1 text-[10px] font-medium text-[#fb923c] hover:bg-orange-950/60 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{resettingSource === source ? (
<span className="h-3 w-3 border border-[#fb923c] border-t-transparent rounded-full animate-spin" />
) : null}
Retry
</button>
)}
{agg.failed > 0 && (
<button
onClick={() => handleResetBackoff(source)}
disabled={resettingSource === source}
className="inline-flex items-center gap-1 rounded border border-red-500/40 bg-red-950/30 px-2 py-1 text-[10px] font-medium text-danger hover:bg-red-950/60 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Retry All
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
{sortedSources.some(([, v]) => v.failed > 0) && (
<div className="mx-4 mb-3 mt-2 flex items-center gap-2 rounded border border-red-500/30 bg-red-950/20 px-3 py-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-danger animate-pulse" />
<span className="text-xs text-danger font-medium">Failed jobs detected — expand below for details</span>
</div>
)}
</>
)}
</section>
{/* Failed Jobs Log */}
{status && status.lastErrors.length > 0 && (
<section className="rounded-lg border border-line-strong bg-surface-raised overflow-hidden">
<div className="flex items-center justify-between border-b border-line-strong px-4 py-3 bg-surface">
<h2 className="text-sm font-semibold text-fg flex items-center gap-2">
<span className="inline-block h-2 w-2 rounded-full bg-danger" />
Failed Jobs
</h2>
<span className="text-xs text-fg-faint">{status.lastErrors.length} recent</span>
</div>
<div className="divide-y divide-[#1a1b28]">
{status.lastErrors.map((job) => (
<div key={job.key}>
<button
onClick={() => toggleLog(job.key)}
className="w-full flex items-center justify-between px-4 py-2.5 hover:bg-surface/50 transition-colors text-left"
>
<div className="flex items-center gap-3 min-w-0">
<span className="h-1.5 w-1.5 rounded-full bg-danger shrink-0" />
<code className="text-xs font-mono text-fg truncate">{job.key}</code>
</div>
<div className="flex items-center gap-3 shrink-0">
<span className="text-[10px] text-fg-faint max-w-[200px] truncate">{job.error}</span>
<svg className={`h-3 w-3 text-fg-faint transition-transform ${expandedLog === job.key ? "rotate-180" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</div>
</button>
{expandedLog === job.key && (
<div className="px-4 pb-3">
<div className="flex gap-2 mb-2">
<button
onClick={() => handleRetryJob(job.key)}
className="inline-flex items-center gap-1 rounded border border-red-500/40 bg-red-950/30 px-2 py-1 text-[10px] font-medium text-danger hover:bg-red-950/60 transition-colors"
>
Retry
</button>
</div>
<div className="bg-surface rounded border border-line-strong p-3 max-h-64 overflow-y-auto">
{logs[job.key]?.length ? (
logs[job.key].map((entry, i) => (
<div key={i} className="mb-2 last:mb-0">
<div className="text-[10px] text-fg-faint font-mono mb-0.5">
Attempt #{entry.attempt} — {formatTimestamp(entry.ts)}
</div>
<div className="text-xs text-danger font-mono whitespace-pre-wrap break-all bg-surface-raised rounded p-2 border border-line-strong">
{entry.errorStack ?? entry.errorMessage ?? "Unknown error"}
</div>
</div>
))
) : (
<div className="flex items-center gap-2">
<span className="h-3 w-3 border border-fg-faint border-t-transparent rounded-full animate-spin" />
<span className="text-[10px] text-fg-faint">Loading logs...</span>
</div>
)}
</div>
</div>
)}
</div>
))}
</div>
</section>
)}
{/* Schedule Configuration */}
<section className="rounded-lg border border-line-strong bg-surface-raised overflow-hidden">
<div className="flex items-center justify-between border-b border-line-strong px-4 py-3 bg-surface">
<h2 className="text-sm font-semibold text-fg flex items-center gap-2">
<span className="inline-block h-2 w-2 rounded-full bg-accent" />
Schedule Configuration
</h2>
<span className="text-xs text-fg-faint">{schedules.length} schedules</span>
</div>
{schedules.length > 0 && (
<table className="w-full text-xs">
<thead>
<tr className="border-b border-line-strong text-left text-fg-faint uppercase tracking-wide text-[10px]">
<th className="pb-2 pr-4 pl-4 font-medium">Source</th>
<th className="pb-2 pr-4 font-medium">Interval</th>
<th className="pb-2 pr-4 font-medium">Last Enqueued</th>
<th className="pb-2 pr-4 font-medium">Next Enqueue</th>
<th className="pb-2 font-medium w-24 text-center">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-[#1a1b28]">
{schedules.map((s) => (
<tr key={s.sourceKind} className="hover:bg-surface/50 transition-colors">
<td className="py-2.5 pr-4 pl-4 font-mono text-fg">{s.sourceKind}</td>
<td className="py-2.5 pr-4 text-fg-muted">{msToHuman(s.intervalMs)}</td>
<td className="py-2.5 pr-4 text-[10px] text-fg-faint font-mono">{formatTimestamp(s.lastEnqueued)}</td>
<td className="py-2.5 pr-4 text-[10px] text-fg-faint font-mono">{formatTimestamp(s.nextEnqueue)}</td>
<td className="py-2.5 text-center">
<button
onClick={() => handleDeleteSchedule(s.sourceKind)}
className="inline-flex items-center gap-1 rounded border border-red-500/40 bg-red-950/30 px-2 py-1 text-[10px] font-medium text-danger hover:bg-red-950/60 transition-colors"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
{/* Add Schedule Form */}
<form onSubmit={handleAddSchedule} className="p-4 border-t border-line-strong">
<div className="flex items-end gap-3">
<div className="flex-1">
<label className="block text-[10px] font-medium text-fg-faint mb-1 uppercase tracking-wide">Source Kind</label>
<select
value={newScheduleKind}
onChange={(e) => setNewScheduleKind(e.target.value)}
className="w-full rounded border border-line-strong bg-surface px-3 py-2 text-xs font-mono text-fg focus:outline-none focus:border-accent/50 transition-colors"
disabled={addingSchedule}
>
<option value="sec-fetch">sec-fetch</option>
<option value="yfinance">yfinance</option>
<option value="sec">sec</option>
<option value="x">x</option>
<option value="reddit">reddit</option>
</select>
</div>
<div className="flex-1">
<label className="block text-[10px] font-medium text-fg-faint mb-1 uppercase tracking-wide">Interval (ms)</label>
<input
type="number"
value={newScheduleInterval}
onChange={(e) => setNewScheduleInterval(e.target.value)}
min={60000}
max={604800000}
className="w-full rounded border border-line-strong bg-surface px-3 py-2 text-xs font-mono text-fg focus:outline-none focus:border-accent/50 transition-colors"
disabled={addingSchedule}
/>
</div>
<button
type="submit"
disabled={addingSchedule}
className="inline-flex items-center gap-2 rounded border border-accent bg-accent/10 px-4 py-2 text-xs font-medium text-accent hover:bg-accent/20 transition-colors disabled:opacity-30"
>
{addingSchedule ? (
<span className="h-3 w-3 border border-accent border-t-transparent rounded-full animate-spin" />
) : null}
Add Schedule
</button>
</div>
</form>
</section>
{/* Manual Trigger */}
<section className="rounded-lg border border-line-strong bg-surface-raised overflow-hidden">
<div className="flex items-center justify-between border-b border-line-strong px-4 py-3 bg-surface">
<h2 className="text-sm font-semibold text-fg flex items-center gap-2">
<span className="inline-block h-2 w-2 rounded-full bg-accent" />
Manual Trigger
</h2>
<span className="text-xs text-fg-faint">Queue a fresh SEC data fetch for any symbol</span>
</div>
<form onSubmit={handleQueueFetch} className="p-4 flex items-end gap-3">
<div className="flex-1">
<label htmlFor="symbol-input" className="block text-xs font-medium text-fg-faint mb-1.5 uppercase tracking-wide">Symbol</label>
<input
id="symbol-input"
type="text"
value={symbol}
onChange={(e) => setSymbol(e.target.value.toUpperCase().slice(0, 10))}
placeholder="NVDA"
maxLength={10}
className="w-full rounded border border-line-strong bg-surface px-3 py-2 text-sm font-mono text-fg placeholder:text-fg-faint focus:outline-none focus:border-accent/50 transition-colors"
disabled={triggering}
/>
</div>
<button
type="submit"
disabled={!symbol.trim() || triggering}
className="inline-flex items-center gap-2 rounded border border-accent bg-accent/10 px-4 py-2 text-sm font-medium text-accent hover:bg-accent/20 transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
{triggering ? (
<span className="h-4 w-4 border border-accent border-t-transparent rounded-full animate-spin" />
) : null}
Queue Fetch
</button>
</form>
</section>
</div>
</AdminLayout>
);
}
+50 -2
View File
@@ -210,12 +210,12 @@ export const api = {
removeHolding: (symbol: string) => trpcMutate<{ removed: boolean }>("portfolio.removeHolding", { symbol }),
},
dashboard: {
rollup: () => trpcQuery<Record<string, unknown>[]>("dashboard.rollup"),
rollup: () => trpcQuery<Record<string, unknown>[]>("dashboard.rollup", {}),
},
institutional: {
flow: (symbol: string) =>
trpcQuery<{ symbol: string; flow: Array<Record<string, unknown>>; quarters: string[] }>("institutional.flow", { symbol }),
insiderStream: (symbol: string, limit = 50) =>
insiderStream: (symbol: string, limit = 200) =>
trpcQuery<{ symbol: string; events: Array<Record<string, unknown>>; netShares: number; count: number }>("institutional.insiderStream", { symbol, limit }),
},
emotionLogger: {
@@ -230,6 +230,26 @@ export const api = {
unackedCount: () => trpcQuery<{ count: number }>("alerts.unackedCount"),
acknowledgeAll: () => trpcMutate<{ ok: boolean }>("alerts.acknowledgeAll"),
},
admin: {
usersList: () => trpcQuery<Array<{ id: string; email: string; complexity: string; created_at: string; is_admin: number }>>("admin.usersList"),
userSessions: (userId: string) => trpcQuery<Array<{ id: string; user_id: string; expires_at: string; created_at: string }>>("admin.userSessions", { userId }),
resetPassword: (email: string, tempPassword: string) => trpcMutate<{ userId: string }>("admin.resetPassword", { email, tempPassword }),
queueHealth: () => trpcQuery<Array<{ source: string; status: string; count: number; last_attempt: string | null; retry_count: number; backoff_until: string | null }>>("admin.queueHealth"),
resetBackoff: (sourceKind: string) => trpcMutate<{ cleared: number }>("admin.resetBackoff", { sourceKind }),
queueSecFetch: (symbol: string) => trpcMutate<{ ok: boolean }>("admin.queueSecFetch", { symbol }),
queueStatus: () => trpcQuery<QueueStatus>("admin.queueStatus"),
queuePause: () => trpcMutate<{ paused: boolean }>("admin.queuePause", undefined),
queueResume: () => trpcMutate<{ paused: boolean }>("admin.queueResume", undefined),
queueClearDone: (olderThanHours: number) => trpcMutate<{ cleared: number }>("admin.queueClearDone", { olderThanHours }),
queueSchedules: () => trpcQuery<ScheduleEntry[]>("admin.queueSchedules"),
queueSetSchedule: (sourceKind: string, intervalMs: number) => trpcMutate<{ ok: boolean }>("admin.queueSetSchedule", { sourceKind, intervalMs }),
queueDeleteSchedule: (sourceKind: string) => trpcMutate<{ ok: boolean }>("admin.queueDeleteSchedule", { sourceKind }),
queueLogs: (key: string, limit: number) => trpcQuery<ErrorLogEntry[]>("admin.queueLogs", { key, limit }),
queueRetryJob: (key: string) => trpcMutate<{ ok: boolean }>("admin.queueRetryJob", { key }),
queueRetrySource: (sourceKind: string) => trpcMutate<{ cleared: number }>("admin.queueRetrySource", { sourceKind }),
auditLog: (limit = 50, offset = 0, actor?: string, action?: string) =>
trpcQuery<Array<{ id: string; actor: string; action: string; target: string | null; ts: string; meta: string | null }>>("admin.auditLog", { limit, offset, ...(actor ? { actor } : {}), ...(action ? { action } : {}) }),
},
};
export interface WatchlistEntry {
@@ -244,3 +264,31 @@ export interface PortfolioHolding {
avg_cost: number;
added_at: string;
}
export interface QueueStatus {
paused: boolean;
queued: number;
in_flight: number;
failed: number;
backoff: number;
done: number;
backoff_until: string | null;
last_error: string | undefined;
counts: Record<string, number>;
lastErrors: Array<{ key: string; error: string | null }>;
}
export interface ScheduleEntry {
sourceKind: string;
intervalMs: number;
lastEnqueued: string | null;
nextEnqueue: string | null;
}
export interface ErrorLogEntry {
attempt: number;
errorMessage: string | null;
errorStack: string | null;
ts: string;
}