phase-9: domain-driven architecture complete

- Restructured server layer with 5 domains: shared, screener, portfolio, calls, finance
- Migrated 58 TypeScript files to domain-driven structure
- Updated CLAUDE.md with new architecture documentation
- Added .gitignore rules for .md files (except CLAUDE.md)
- Removed unused CatalystAnalyst import from app.ts
- Fixed lint errors: removed unused imports, fixed regex escape, added console suppressions
- Verified no sensitive data in git history
- Server code compiles cleanly with TypeScript strict mode
This commit is contained in:
Kazuma
2026-06-06 13:21:24 -04:00
committed by Kazuma
parent fbd166b1b7
commit 2e7860637e
88 changed files with 3576 additions and 3493 deletions
+15
View File
@@ -0,0 +1,15 @@
/**
* Split an array into smaller chunks of specified size.
* @param array The array to split
* @param size The size of each chunk
* @returns Array of chunks
* @example chunkArray([1,2,3,4,5], 2) → [[1,2], [3,4], [5]]
*/
export const chunkArray = <T>(array: T[], size: number): T[][] => {
const chunkCount = Math.ceil(array.length / size);
return Array.from({ length: chunkCount }, (_, index) => {
const start = index * size;
const end = start + size;
return array.slice(start, end);
});
};
@@ -0,0 +1,55 @@
import * as queries from '../db/queries.constant';
export class QueryBuilder {
readonly sql: string;
readonly queryParams: unknown[];
/**
* Create a QueryBuilder from a query constant path.
*
* @param queryPath Path to query in queries.constant.ts (e.g., 'MARKET_CALLS_QUERIES.SELECT_ALL')
* @param params Parameters to bind (? placeholders in SQL)
*/
constructor(queryPath: string, params: unknown[] = []) {
this.sql = this.lookupQuery(queryPath);
this.queryParams = params;
// Validate parameter count matches placeholders
const placeholderCount = (this.sql.match(/\?/g) || []).length;
if (this.queryParams.length !== placeholderCount) {
throw new Error(
`Parameter mismatch for query "${queryPath}": expected ${placeholderCount}, got ${this.queryParams.length}`,
);
}
}
/**
* Look up a query from queries.constant.ts.
* Supports nested paths like "MARKET_CALLS_QUERIES.SELECT_ALL".
*
* @param queryPath Path to query (e.g., 'MARKET_CALLS_QUERIES.SELECT_ALL')
* @returns The SQL query string
* @throws Error if query not found
*/
private lookupQuery(queryPath: string): string {
const parts = queryPath.split('.');
// Navigate through the nested objects
let current: any = queries;
for (const part of parts) {
if (!(part in current)) {
throw new Error(
`Query not found: "${queryPath}". Make sure it exists in queries.constant.ts`,
);
}
current = current[part];
}
if (typeof current !== 'string') {
throw new Error(`Invalid query: "${queryPath}" must be a string, got ${typeof current}`);
}
// Clean up the SQL (remove extra whitespace)
return current.trim();
}
}
+15
View File
@@ -0,0 +1,15 @@
import type { Logger } from '../types';
/**
* Shared server-side logger utilities.
*
* noopLogger — silent logger for use in API server context where stdout
* output from screener/analyst classes would pollute the request log.
* Pass as { logger: noopLogger } to ScreenerEngine, BenchmarkProvider,
* CatalystAnalyst, SimpleFINClient, LLMAnalyst.
*/
export const noopLogger: Logger = {
write: () => {},
log: () => {},
warn: () => {},
};
+142
View File
@@ -0,0 +1,142 @@
/**
* Sanitize a ticker symbol.
* - Converts to uppercase
* - Trims whitespace
* - Validates non-empty
*
* @param ticker The ticker symbol (e.g. "aapl", " MSFT ", "BRK.B")
* @returns Normalized ticker (e.g. "AAPL", "MSFT", "BRK.B")
* @throws Error if ticker is empty or invalid
*/
export function sanitizeTicker(ticker: string): string {
if (!ticker || typeof ticker !== 'string') {
throw new Error('Invalid ticker: must be a non-empty string');
}
const normalized = ticker.trim().toUpperCase();
if (!normalized) {
throw new Error('Invalid ticker: cannot be empty or whitespace');
}
// Optional: validate ticker format (alphanumeric + dots/hyphens)
if (!/^[A-Z0-9-.]+$/.test(normalized)) {
throw new Error(`Invalid ticker format: ${normalized}`);
}
return normalized;
}
/**
* Sanitize an array of tickers.
*
* @param tickers Array of ticker symbols
* @returns Array of normalized tickers
* @throws Error if any ticker is invalid
*/
export function sanitizeTickers(tickers: unknown): string[] {
if (!Array.isArray(tickers)) {
throw new Error('Invalid tickers: must be an array');
}
if (tickers.length === 0) {
throw new Error('Invalid tickers: array cannot be empty');
}
return tickers.map((t) => {
if (typeof t !== 'string') {
throw new Error(`Invalid ticker in array: ${t} (expected string)`);
}
return sanitizeTicker(t);
});
}
/**
* Sanitize a string field.
* - Trims whitespace
* - Validates non-empty
* - Optional: enforces max length
*
* @param value The string value
* @param fieldName Name of the field (for error messages)
* @param maxLength Maximum allowed length (optional)
* @returns Trimmed string
* @throws Error if value is invalid
*/
export function sanitizeString(value: unknown, fieldName: string, maxLength?: number): string {
if (typeof value !== 'string') {
throw new Error(`Invalid ${fieldName}: must be a string`);
}
const trimmed = value.trim();
if (!trimmed) {
throw new Error(`Invalid ${fieldName}: cannot be empty or whitespace`);
}
if (maxLength && trimmed.length > maxLength) {
throw new Error(`Invalid ${fieldName}: exceeds max length of ${maxLength} characters`);
}
return trimmed;
}
/**
* Sanitize a number field.
* - Validates it's a number
* - Optional: enforces min/max bounds
*
* @param value The numeric value
* @param fieldName Name of the field (for error messages)
* @param min Minimum allowed value (optional)
* @param max Maximum allowed value (optional)
* @returns The validated number
* @throws Error if value is invalid
*/
export function sanitizeNumber(
value: unknown,
fieldName: string,
options?: { min?: number; max?: number },
): number {
const num = typeof value === 'number' ? value : Number(value);
if (isNaN(num)) {
throw new Error(`Invalid ${fieldName}: must be a valid number`);
}
if (options?.min !== undefined && num < options.min) {
throw new Error(`Invalid ${fieldName}: must be at least ${options.min}`);
}
if (options?.max !== undefined && num > options.max) {
throw new Error(`Invalid ${fieldName}: must be at most ${options.max}`);
}
return num;
}
/**
* Sanitize an ISO date string.
* - Validates it's a valid ISO date
* - Converts to string format YYYY-MM-DD
*
* @param value The date value (ISO string or Date)
* @param fieldName Name of the field (for error messages)
* @returns Date as YYYY-MM-DD string
* @throws Error if date is invalid
*/
export function sanitizeDate(value: unknown, fieldName: string): string {
let date: Date | null = null;
if (typeof value === 'string') {
date = new Date(value);
} else if (value instanceof Date) {
date = value;
}
if (!date || isNaN(date.getTime())) {
throw new Error(`Invalid ${fieldName}: must be a valid date`);
}
return date.toISOString().slice(0, 10); // YYYY-MM-DD
}