From 742e590382e8c909be081e527f238c12bf62715f Mon Sep 17 00:00:00 2001 From: Boki Date: Sun, 22 Jun 2025 07:31:00 -0400 Subject: [PATCH] cleaner dev experience refactor --- .../src/handlers/example/example.handler.ts | 94 ++++++++++ apps/data-ingestion/src/handlers/index.ts | 75 ++++++-- apps/data-ingestion/src/index.ts | 2 +- libs/core/handlers/src/base/BaseHandler.ts | 46 +++++ .../handlers/src/decorators/decorators.ts | 28 +++ libs/core/handlers/src/index.ts | 5 +- .../handlers/src/registry/auto-register.ts | 174 ++++++++++++++++++ 7 files changed, 407 insertions(+), 17 deletions(-) create mode 100644 apps/data-ingestion/src/handlers/example/example.handler.ts create mode 100644 libs/core/handlers/src/registry/auto-register.ts diff --git a/apps/data-ingestion/src/handlers/example/example.handler.ts b/apps/data-ingestion/src/handlers/example/example.handler.ts new file mode 100644 index 0000000..1b2d70c --- /dev/null +++ b/apps/data-ingestion/src/handlers/example/example.handler.ts @@ -0,0 +1,94 @@ +/** + * Example Handler - Demonstrates ergonomic handler patterns + * Shows inline operations, service helpers, and scheduled operations + */ + +import { + BaseHandler, + Handler, + Operation, + ScheduledOperation, + type ExecutionContext, + type IServiceContainer +} from '@stock-bot/handlers'; + +@Handler('example') +export class ExampleHandler extends BaseHandler { + constructor(services: IServiceContainer) { + super(services); + } + + /** + * Simple inline operation - no separate action file needed + */ + @Operation('get-stats') + async getStats(): Promise<{ total: number; active: number; cached: boolean }> { + // Use collection helper for cleaner MongoDB access + const total = await this.collection('items').countDocuments(); + const active = await this.collection('items').countDocuments({ status: 'active' }); + + // Use cache helpers with automatic prefixing + const cached = await this.cacheGet('last-total'); + await this.cacheSet('last-total', total, 300); // 5 minutes + + // Use log helper with automatic handler context + this.log('info', 'Stats retrieved', { total, active }); + + return { total, active, cached: cached !== null }; + } + + /** + * Scheduled operation using combined decorator + */ + @ScheduledOperation('cleanup-old-items', '0 2 * * *', { + priority: 5, + description: 'Clean up items older than 30 days' + }) + async cleanupOldItems(): Promise<{ deleted: number }> { + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + + const result = await this.collection('items').deleteMany({ + createdAt: { $lt: thirtyDaysAgo } + }); + + this.log('info', 'Cleanup completed', { deleted: result.deletedCount }); + + // Schedule a follow-up task + await this.scheduleIn('generate-report', { type: 'cleanup' }, 60); // 1 minute + + return { deleted: result.deletedCount }; + } + + /** + * Operation that uses proxy service + */ + @Operation('fetch-external-data') + async fetchExternalData(input: { url: string }): Promise<{ data: any }> { + const proxyUrl = this.proxy.getProxy(); + + if (!proxyUrl) { + throw new Error('No proxy available'); + } + + // Use HTTP client with proxy + const response = await this.http.get(input.url, { + proxy: proxyUrl, + timeout: 10000 + }); + + // Cache the result + await this.cacheSet(`external:${input.url}`, response.data, 3600); + + return { data: response.data }; + } + + /** + * Complex operation that still uses action file + */ + @Operation('process-batch') + async processBatch(input: any, context: ExecutionContext): Promise { + // For complex operations, still use action files + const { processBatch } = await import('./actions/batch.action'); + return processBatch(this, input); + } +} \ No newline at end of file diff --git a/apps/data-ingestion/src/handlers/index.ts b/apps/data-ingestion/src/handlers/index.ts index 3766052..c65cf9b 100644 --- a/apps/data-ingestion/src/handlers/index.ts +++ b/apps/data-ingestion/src/handlers/index.ts @@ -1,29 +1,74 @@ /** * Handler auto-registration - * Import all handlers here to trigger auto-registration + * Automatically discovers and registers all handlers */ import type { IDataIngestionServices } from '@stock-bot/di'; import { createServiceAdapter } from '@stock-bot/di'; -import { QMHandler } from './qm/qm.handler'; -import { WebShareHandler } from './webshare/webshare.handler'; +import { autoRegisterHandlers } from '@stock-bot/handlers'; +import { getLogger } from '@stock-bot/logger'; +import { join } from 'path'; + +// Import handlers for bundling (ensures they're included in the build) +import './qm/qm.handler'; +import './webshare/webshare.handler'; +// Add more handler imports as needed + +const logger = getLogger('handler-init'); /** - * Initialize and register all handlers + * Initialize and register all handlers automatically */ -export function initializeAllHandlers(services: IDataIngestionServices): void { +export async function initializeAllHandlers(services: IDataIngestionServices): Promise { // Create generic service container adapter const serviceContainer = createServiceAdapter(services); - // QM Handler - const qmHandler = new QMHandler(serviceContainer); - qmHandler.register(); + try { + // Auto-register all handlers in this directory + const result = await autoRegisterHandlers( + __dirname, + serviceContainer, + { + pattern: '.handler.', + exclude: ['test', 'spec'], + dryRun: false + } + ); + + logger.info('Handler auto-registration complete', { + registered: result.registered, + failed: result.failed + }); + + if (result.failed.length > 0) { + logger.error('Some handlers failed to register', { failed: result.failed }); + } + } catch (error) { + logger.error('Handler auto-registration failed', { error }); + // Fall back to manual registration + await manualHandlerRegistration(serviceContainer); + } +} + +/** + * Manual fallback registration + */ +async function manualHandlerRegistration(serviceContainer: any): Promise { + logger.warn('Falling back to manual handler registration'); - // WebShare Handler - const webShareHandler = new WebShareHandler(serviceContainer); - webShareHandler.register(); - - // TODO: Add other handlers here as they're converted - // const ibHandler = new IBHandler(serviceContainer); - // ibHandler.register(); + try { + // Import and register handlers manually + const { QMHandler } = await import('./qm/qm.handler'); + const qmHandler = new QMHandler(serviceContainer); + qmHandler.register(); + + const { WebShareHandler } = await import('./webshare/webshare.handler'); + const webShareHandler = new WebShareHandler(serviceContainer); + webShareHandler.register(); + + logger.info('Manual handler registration complete'); + } catch (error) { + logger.error('Manual handler registration failed', { error }); + throw error; + } } \ No newline at end of file diff --git a/apps/data-ingestion/src/index.ts b/apps/data-ingestion/src/index.ts index 0dc0f06..6265342 100644 --- a/apps/data-ingestion/src/index.ts +++ b/apps/data-ingestion/src/index.ts @@ -85,7 +85,7 @@ async function initializeServices() { logger.debug('Initializing data handlers with new DI pattern...'); // Auto-register all handlers - initializeAllHandlers(services); + await initializeAllHandlers(services); logger.info('Data handlers initialized with new DI pattern'); diff --git a/libs/core/handlers/src/base/BaseHandler.ts b/libs/core/handlers/src/base/BaseHandler.ts index 5338c6f..f895888 100644 --- a/libs/core/handlers/src/base/BaseHandler.ts +++ b/libs/core/handlers/src/base/BaseHandler.ts @@ -99,6 +99,52 @@ export abstract class BaseHandler implements IHandler { }; } + /** + * Helper methods for common operations + */ + + /** + * Get a MongoDB collection with type safety + */ + protected collection(name: string) { + return this.mongodb.collection(name); + } + + /** + * Set cache with handler-prefixed key + */ + protected async cacheSet(key: string, value: any, ttl?: number): Promise { + return this.cache.set(`${this.handlerName}:${key}`, value, ttl); + } + + /** + * Get cache with handler-prefixed key + */ + protected async cacheGet(key: string): Promise { + return this.cache.get(`${this.handlerName}:${key}`); + } + + /** + * Delete cache with handler-prefixed key + */ + protected async cacheDel(key: string): Promise { + return this.cache.del(`${this.handlerName}:${key}`); + } + + /** + * Schedule operation with delay in seconds + */ + protected async scheduleIn(operation: string, payload: unknown, delaySeconds: number): Promise { + return this.scheduleOperation(operation, payload, delaySeconds * 1000); + } + + /** + * Log with handler context + */ + protected log(level: 'info' | 'warn' | 'error' | 'debug', message: string, meta?: any): void { + this.logger[level](message, { handler: this.handlerName, ...meta }); + } + /** * Event methods - commented for future */ diff --git a/libs/core/handlers/src/decorators/decorators.ts b/libs/core/handlers/src/decorators/decorators.ts index 8327d34..f37d358 100644 --- a/libs/core/handlers/src/decorators/decorators.ts +++ b/libs/core/handlers/src/decorators/decorators.ts @@ -76,6 +76,34 @@ export function QueueSchedule( }; } +/** + * Combined decorator for scheduled operations + * Automatically creates both an operation and a schedule + * @param name Operation name + * @param cronPattern Cron pattern for scheduling + * @param options Schedule options + */ +export function ScheduledOperation( + name: string, + cronPattern: string, + options?: { + priority?: number; + immediately?: boolean; + description?: string; + } +): any { + return function ( + target: any, + methodName: string, + descriptor?: PropertyDescriptor + ): any { + // Apply both decorators + Operation(name)(target, methodName, descriptor); + QueueSchedule(cronPattern, options)(target, methodName, descriptor); + return descriptor; + }; +} + // Future event decorators - commented for now // export function EventListener(eventName: string) { // return function (target: any, propertyName: string, descriptor: PropertyDescriptor) { diff --git a/libs/core/handlers/src/index.ts b/libs/core/handlers/src/index.ts index e39bc4d..54a2c6a 100644 --- a/libs/core/handlers/src/index.ts +++ b/libs/core/handlers/src/index.ts @@ -22,7 +22,10 @@ export type { IServiceContainer } from './types/service-container'; export { createJobHandler } from './types/types'; // Decorators -export { Handler, Operation, QueueSchedule } from './decorators/decorators'; +export { Handler, Operation, QueueSchedule, ScheduledOperation } from './decorators/decorators'; + +// Auto-registration utilities +export { autoRegisterHandlers, createAutoHandlerRegistry } from './registry/auto-register'; // Future exports - commented for now // export { EventListener, EventPublisher } from './decorators/decorators'; \ No newline at end of file diff --git a/libs/core/handlers/src/registry/auto-register.ts b/libs/core/handlers/src/registry/auto-register.ts new file mode 100644 index 0000000..1564c7d --- /dev/null +++ b/libs/core/handlers/src/registry/auto-register.ts @@ -0,0 +1,174 @@ +/** + * Auto-registration utilities for handlers + * Automatically discovers and registers handlers based on file patterns + */ + +import { getLogger } from '@stock-bot/logger'; +import type { IServiceContainer } from '../types/service-container'; +import { BaseHandler } from '../base/BaseHandler'; +import { readdirSync, statSync } from 'fs'; +import { join, relative } from 'path'; + +const logger = getLogger('handler-auto-register'); + +/** + * Recursively find all handler files in a directory + */ +function findHandlerFiles(dir: string, pattern = '.handler.'): string[] { + const files: string[] = []; + + function scan(currentDir: string) { + const entries = readdirSync(currentDir); + + for (const entry of entries) { + const fullPath = join(currentDir, entry); + const stat = statSync(fullPath); + + if (stat.isDirectory() && !entry.startsWith('.') && entry !== 'node_modules') { + scan(fullPath); + } else if (stat.isFile() && entry.includes(pattern) && entry.endsWith('.ts')) { + files.push(fullPath); + } + } + } + + scan(dir); + return files; +} + +/** + * Extract handler classes from a module + */ +function extractHandlerClasses(module: any): Array BaseHandler> { + const handlers: Array BaseHandler> = []; + + for (const key of Object.keys(module)) { + const exported = module[key]; + + // Check if it's a class that extends BaseHandler + if ( + typeof exported === 'function' && + exported.prototype && + exported.prototype instanceof BaseHandler + ) { + handlers.push(exported); + } + } + + return handlers; +} + +/** + * Auto-register all handlers in a directory + * @param directory The directory to scan for handlers + * @param services The service container to inject into handlers + * @param options Configuration options + */ +export async function autoRegisterHandlers( + directory: string, + services: IServiceContainer, + options: { + pattern?: string; + exclude?: string[]; + dryRun?: boolean; + } = {} +): Promise<{ registered: string[]; failed: string[] }> { + const { pattern = '.handler.', exclude = [], dryRun = false } = options; + const registered: string[] = []; + const failed: string[] = []; + + try { + logger.info('Starting auto-registration of handlers', { directory, pattern }); + + // Find all handler files + const handlerFiles = findHandlerFiles(directory, pattern); + logger.debug(`Found ${handlerFiles.length} handler files`, { files: handlerFiles }); + + // Process each handler file + for (const file of handlerFiles) { + const relativePath = relative(directory, file); + + // Skip excluded files + if (exclude.some(ex => relativePath.includes(ex))) { + logger.debug(`Skipping excluded file: ${relativePath}`); + continue; + } + + try { + // Import the module + const module = await import(file); + const handlerClasses = extractHandlerClasses(module); + + if (handlerClasses.length === 0) { + logger.warn(`No handler classes found in ${relativePath}`); + continue; + } + + // Register each handler class + for (const HandlerClass of handlerClasses) { + const handlerName = HandlerClass.name; + + if (dryRun) { + logger.info(`[DRY RUN] Would register handler: ${handlerName} from ${relativePath}`); + registered.push(handlerName); + } else { + logger.info(`Registering handler: ${handlerName} from ${relativePath}`); + + // Create instance and register + const handler = new HandlerClass(services); + handler.register(); + + registered.push(handlerName); + logger.info(`Successfully registered handler: ${handlerName}`); + } + } + } catch (error) { + logger.error(`Failed to process handler file: ${relativePath}`, { error }); + failed.push(relativePath); + } + } + + logger.info('Auto-registration complete', { + totalFiles: handlerFiles.length, + registered: registered.length, + failed: failed.length + }); + + return { registered, failed }; + } catch (error) { + logger.error('Auto-registration failed', { error }); + throw error; + } +} + +/** + * Create a handler registry that auto-discovers handlers + */ +export function createAutoHandlerRegistry(services: IServiceContainer) { + return { + /** + * Register all handlers from a directory + */ + async registerDirectory(directory: string, options?: Parameters[2]) { + return autoRegisterHandlers(directory, services, options); + }, + + /** + * Register handlers from multiple directories + */ + async registerDirectories(directories: string[], options?: Parameters[2]) { + const results = { + registered: [] as string[], + failed: [] as string[] + }; + + for (const dir of directories) { + const result = await autoRegisterHandlers(dir, services, options); + results.registered.push(...result.registered); + results.failed.push(...result.failed); + } + + return results; + } + }; +} \ No newline at end of file