diff --git a/.serena/cache/typescript/document_symbols_cache_v20-05-25.pkl b/.serena/cache/typescript/document_symbols_cache_v20-05-25.pkl index c4d0e39..3e4099f 100644 Binary files a/.serena/cache/typescript/document_symbols_cache_v20-05-25.pkl and b/.serena/cache/typescript/document_symbols_cache_v20-05-25.pkl differ diff --git a/apps/data-ingestion/src/index.ts b/apps/data-ingestion/src/index.ts index fa2e354..d814152 100644 --- a/apps/data-ingestion/src/index.ts +++ b/apps/data-ingestion/src/index.ts @@ -6,8 +6,6 @@ import { initializeServiceConfig } from '@stock-bot/config'; import { ServiceApplication, - createServiceContainerFromConfig, - initializeServices as initializeAwilixServices, } from '@stock-bot/di'; import { getLogger } from '@stock-bot/logger'; @@ -52,19 +50,25 @@ const app = new ServiceApplication( // Container factory function async function createContainer(config: any) { - const container = createServiceContainerFromConfig(config, { - enableQuestDB: false, // Data ingestion doesn't need QuestDB yet - enableMongoDB: true, - enablePostgres: true, - enableCache: true, - enableQueue: true, - enableBrowser: true, // Data ingestion needs browser for web scraping - enableProxy: true, // Data ingestion needs proxy for rate limiting - }); - await initializeAwilixServices(container); + const { ServiceContainerBuilder } = await import('@stock-bot/di'); + + const container = await new ServiceContainerBuilder() + .withConfig(config) + .withOptions({ + enableQuestDB: false, // Data ingestion doesn't need QuestDB yet + enableMongoDB: true, + enablePostgres: true, + enableCache: true, + enableQueue: true, + enableBrowser: true, // Data ingestion needs browser for web scraping + enableProxy: true, // Data ingestion needs proxy for rate limiting + }) + .build(); // This automatically initializes services + return container; } + // Start the service app.start(createContainer, createRoutes, initializeAllHandlers).catch(error => { const logger = getLogger('data-ingestion'); diff --git a/libs/core/di/src/awilix-container.ts b/libs/core/di/src/awilix-container.ts index dcef384..ca1318b 100644 --- a/libs/core/di/src/awilix-container.ts +++ b/libs/core/di/src/awilix-container.ts @@ -4,16 +4,16 @@ */ import { Browser } from '@stock-bot/browser'; -import { createCache, type CacheProvider } from '@stock-bot/cache'; +import { type CacheProvider } from '@stock-bot/cache'; import type { AppConfig as StockBotAppConfig } from '@stock-bot/config'; import type { IServiceContainer } from '@stock-bot/handlers'; -import { getLogger, type Logger } from '@stock-bot/logger'; +import { type Logger } from '@stock-bot/logger'; import { MongoDBClient } from '@stock-bot/mongodb'; import { PostgreSQLClient } from '@stock-bot/postgres'; import { ProxyManager } from '@stock-bot/proxy'; import { QuestDBClient } from '@stock-bot/questdb'; import { type QueueManager } from '@stock-bot/queue'; -import { asFunction, asValue, createContainer, InjectionMode, type AwilixContainer } from 'awilix'; +import { type AwilixContainer } from 'awilix'; import { z } from 'zod'; // Configuration schema with validation @@ -97,217 +97,22 @@ export interface ServiceDefinitions { * Create and configure the DI container with type safety */ export function createServiceContainer(rawConfig: unknown): AwilixContainer { - // Validate configuration - const config = appConfigSchema.parse(rawConfig); - - const container = createContainer({ - injectionMode: InjectionMode.PROXY, - }); - - // Register configuration values - const registrations: Record = { - // Configuration - config: asValue(config), - redisConfig: asValue(config.redis), - mongoConfig: asValue(config.mongodb), - postgresConfig: asValue(config.postgres), - questdbConfig: asValue( - config.questdb || { host: 'localhost', httpPort: 9000, pgPort: 8812, influxPort: 9009 } - ), - - // Core services with dependency injection - logger: asFunction(() => getLogger('app')).singleton(), - }; - - // Conditionally register cache/dragonfly instances - if (config.redis?.enabled !== false) { - // Main cache instance - registrations.cache = asFunction(({ redisConfig, logger }) => - createCache({ - redisConfig, - logger, - keyPrefix: '', // No prefix at this level, namespaces will handle it - ttl: 3600, - enableMetrics: true, - }) - ).singleton(); - } else { - registrations.cache = asValue(null); - } - - // Proxy manager creates its own namespaced cache - registrations.proxyManager = asFunction(({ cache, config, logger }) => { - if (!cache) { - logger.warn('Cache is disabled, ProxyManager will have limited functionality'); - return null; - } - const { NamespacedCache } = require('@stock-bot/cache'); - const proxyCache = new NamespacedCache(cache, 'proxy'); - const manager = new ProxyManager(proxyCache, config.proxy || {}, logger); - return manager; - }).singleton(); - - // Conditionally register MongoDB client - if (config.mongodb?.enabled !== false) { - registrations.mongoClient = asFunction(({ mongoConfig, logger }) => { - return new MongoDBClient(mongoConfig, logger); - }).singleton(); - } else { - registrations.mongoClient = asValue(null); - } - - // Conditionally register PostgreSQL client - if (config.postgres?.enabled !== false) { - registrations.postgresClient = asFunction(({ postgresConfig, logger }) => { - return new PostgreSQLClient( - { - host: postgresConfig.host, - port: postgresConfig.port, - database: postgresConfig.database, - username: postgresConfig.user, - password: postgresConfig.password, - }, - logger - ); - }).singleton(); - } else { - registrations.postgresClient = asValue(null); - } - - // Conditionally register QuestDB client - if (config.questdb?.enabled !== false) { - registrations.questdbClient = asFunction(({ questdbConfig, logger }) => { - logger.debug('Creating QuestDB client with config:', questdbConfig); - return new QuestDBClient( - { - host: questdbConfig.host, - httpPort: questdbConfig.httpPort, - pgPort: questdbConfig.pgPort, - influxPort: questdbConfig.influxPort, - database: questdbConfig.database, - // QuestDB appears to require default credentials - user: 'admin', - password: 'quest', - }, - logger - ); - }).singleton(); - } else { - registrations.questdbClient = asValue(null); - } - - // Queue manager - conditionally registered with logger injection - if (config.redis?.enabled !== false && config.queue?.enabled !== false) { - registrations.queueManager = asFunction(({ redisConfig, logger }) => { - const { QueueManager } = require('@stock-bot/queue'); - - return new QueueManager({ - redis: { - host: redisConfig.host, - port: redisConfig.port, - db: redisConfig.db, - password: redisConfig.password, - username: redisConfig.username, - }, - enableScheduledJobs: true, - delayWorkerStart: true, // We'll start workers manually - }, logger); // Pass logger to QueueManager - }).singleton(); - } else { - registrations.queueManager = asValue(null); - } - - // Browser automation - registrations.browser = asFunction(({ config, logger }) => { - return new Browser(logger, config.browser); - }).singleton(); - - // Build the IServiceContainer for handlers - registrations.serviceContainer = asFunction( - cradle => - ({ - logger: cradle.logger, - cache: cradle.cache, - proxy: cradle.proxyManager, - browser: cradle.browser, - mongodb: cradle.mongoClient, - postgres: cradle.postgresClient, - questdb: cradle.questdbClient, - queue: cradle.queueManager, - }) as IServiceContainer - ).singleton(); - - container.register(registrations); - return container; + // Deprecated - use the new modular structure + const { createServiceContainer: newCreateServiceContainer } = require('./container/factory'); + return newCreateServiceContainer(rawConfig); } + /** * Initialize async services after container creation */ export async function initializeServices(container: AwilixContainer): Promise { - const logger = container.resolve('logger'); - const config = container.resolve('config'); - - try { - // Wait for cache to be ready first (if enabled) - const cache = container.resolve('cache'); - if (cache && typeof cache.waitForReady === 'function') { - await cache.waitForReady(10000); - logger.info('Cache is ready'); - } else if (config.redis?.enabled === false) { - logger.info('Cache is disabled'); - } - - // Initialize proxy manager (depends on cache) - const proxyManager = container.resolve('proxyManager'); - if (proxyManager && typeof proxyManager.initialize === 'function') { - await proxyManager.initialize(); - logger.info('Proxy manager initialized'); - } else { - logger.info('Proxy manager is disabled (requires cache)'); - } - - // Connect MongoDB client (if enabled) - const mongoClient = container.resolve('mongoClient'); - if (mongoClient && typeof mongoClient.connect === 'function') { - await mongoClient.connect(); - logger.info('MongoDB connected'); - } else if (config.mongodb?.enabled === false) { - logger.info('MongoDB is disabled'); - } - - // Connect PostgreSQL client (if enabled) - const postgresClient = container.resolve('postgresClient'); - if (postgresClient && typeof postgresClient.connect === 'function') { - await postgresClient.connect(); - logger.info('PostgreSQL connected'); - } else if (config.postgres?.enabled === false) { - logger.info('PostgreSQL is disabled'); - } - - // Connect QuestDB client (if enabled) - const questdbClient = container.resolve('questdbClient'); - if (questdbClient && typeof questdbClient.connect === 'function') { - await questdbClient.connect(); - logger.info('QuestDB connected'); - } else if (config.questdb?.enabled === false) { - logger.info('QuestDB is disabled'); - } - - // Initialize browser if configured - const browser = container.resolve('browser'); - if (browser && typeof browser.initialize === 'function') { - await browser.initialize(); - logger.info('Browser initialized'); - } - - logger.info('All services initialized successfully'); - } catch (error) { - logger.error('Failed to initialize services', { error }); - throw error; - } + // Deprecated - use the new modular structure + const { initializeServices: newInitializeServices } = await import('./container/factory'); + return newInitializeServices(container as any); } + // Export typed container export type ServiceContainer = AwilixContainer; export type ServiceCradle = ServiceDefinitions; @@ -333,66 +138,8 @@ export function createServiceContainerFromConfig( appConfig: StockBotAppConfig, options: ServiceContainerOptions = {} ): AwilixContainer { - // Apply defaults for options - const { - enableQuestDB = true, - enableMongoDB = true, - enablePostgres = true, - enableCache = true, - enableQueue = true, - enableBrowser = true, - enableProxy = true, - } = options; - - // Build the config object expected by createServiceContainer - const containerConfig = { - redis: { - enabled: enableCache && appConfig.database?.dragonfly ? true : false, - host: appConfig.database?.dragonfly?.host || 'localhost', - port: appConfig.database?.dragonfly?.port || 6379, - password: appConfig.database?.dragonfly?.password, - db: appConfig.database?.dragonfly?.db || 0, - }, - mongodb: { - enabled: enableMongoDB && appConfig.database?.mongodb ? true : false, - uri: appConfig.database?.mongodb?.uri || - `mongodb://${appConfig.database?.mongodb?.user || ''}:${appConfig.database?.mongodb?.password || ''}@${appConfig.database?.mongodb?.host || 'localhost'}:${appConfig.database?.mongodb?.port || 27017}/${appConfig.database?.mongodb?.database || 'test'}?authSource=${appConfig.database?.mongodb?.authSource || 'admin'}`, - database: appConfig.database?.mongodb?.database || 'test', - }, - postgres: { - enabled: enablePostgres && appConfig.database?.postgres ? true : false, - host: appConfig.database?.postgres?.host || 'localhost', - port: appConfig.database?.postgres?.port || 5432, - database: appConfig.database?.postgres?.database || 'test', - user: appConfig.database?.postgres?.user || 'test', - password: appConfig.database?.postgres?.password || 'test', - }, - questdb: enableQuestDB && appConfig.database?.questdb ? { - enabled: true, - host: appConfig.database.questdb.host || 'localhost', - httpPort: appConfig.database.questdb.httpPort || 9000, - pgPort: appConfig.database.questdb.pgPort || 8812, - influxPort: appConfig.database.questdb.ilpPort || 9009, - database: appConfig.database.questdb.database || 'questdb', - } : { - enabled: false, - host: 'localhost', - httpPort: 9000, - pgPort: 8812, - influxPort: 9009, - }, - proxy: enableProxy ? { - cachePrefix: 'proxy:', - ttl: 3600, - } : undefined, - browser: enableBrowser ? { - headless: true, - timeout: 30000, - } : undefined, - queue: { - enabled: enableQueue && enableCache, // Queue depends on Redis/cache - }, - }; - - return createServiceContainer(containerConfig); + // Deprecated - use the new modular structure + const { createServiceContainerFromConfig: newCreateServiceContainerFromConfig } = require('./container/factory'); + return newCreateServiceContainerFromConfig(appConfig, options); } + diff --git a/libs/core/di/src/config/schemas/index.ts b/libs/core/di/src/config/schemas/index.ts new file mode 100644 index 0000000..bfd8de6 --- /dev/null +++ b/libs/core/di/src/config/schemas/index.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; +import { redisConfigSchema } from './redis.schema'; +import { mongodbConfigSchema } from './mongodb.schema'; +import { postgresConfigSchema } from './postgres.schema'; +import { questdbConfigSchema } from './questdb.schema'; +import { proxyConfigSchema, browserConfigSchema, queueConfigSchema } from './service.schema'; + +export const appConfigSchema = z.object({ + redis: redisConfigSchema, + mongodb: mongodbConfigSchema, + postgres: postgresConfigSchema, + questdb: questdbConfigSchema.optional(), + proxy: proxyConfigSchema.optional(), + browser: browserConfigSchema.optional(), + queue: queueConfigSchema.optional(), +}); + +export type AppConfig = z.infer; + +// Re-export individual schemas and types +export * from './redis.schema'; +export * from './mongodb.schema'; +export * from './postgres.schema'; +export * from './questdb.schema'; +export * from './service.schema'; \ No newline at end of file diff --git a/libs/core/di/src/config/schemas/mongodb.schema.ts b/libs/core/di/src/config/schemas/mongodb.schema.ts new file mode 100644 index 0000000..b05cee5 --- /dev/null +++ b/libs/core/di/src/config/schemas/mongodb.schema.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const mongodbConfigSchema = z.object({ + enabled: z.boolean().optional().default(true), + uri: z.string(), + database: z.string(), +}); + +export type MongoDBConfig = z.infer; \ No newline at end of file diff --git a/libs/core/di/src/config/schemas/postgres.schema.ts b/libs/core/di/src/config/schemas/postgres.schema.ts new file mode 100644 index 0000000..ecb3e93 --- /dev/null +++ b/libs/core/di/src/config/schemas/postgres.schema.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +export const postgresConfigSchema = z.object({ + enabled: z.boolean().optional().default(true), + host: z.string().default('localhost'), + port: z.number().default(5432), + database: z.string(), + user: z.string(), + password: z.string(), +}); + +export type PostgresConfig = z.infer; \ No newline at end of file diff --git a/libs/core/di/src/config/schemas/questdb.schema.ts b/libs/core/di/src/config/schemas/questdb.schema.ts new file mode 100644 index 0000000..cff9160 --- /dev/null +++ b/libs/core/di/src/config/schemas/questdb.schema.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +export const questdbConfigSchema = z.object({ + enabled: z.boolean().optional().default(true), + host: z.string().default('localhost'), + httpPort: z.number().optional().default(9000), + pgPort: z.number().optional().default(8812), + influxPort: z.number().optional().default(9009), + database: z.string().optional().default('questdb'), +}); + +export type QuestDBConfig = z.infer; \ No newline at end of file diff --git a/libs/core/di/src/config/schemas/redis.schema.ts b/libs/core/di/src/config/schemas/redis.schema.ts new file mode 100644 index 0000000..79b057f --- /dev/null +++ b/libs/core/di/src/config/schemas/redis.schema.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +export const redisConfigSchema = z.object({ + enabled: z.boolean().optional().default(true), + host: z.string().default('localhost'), + port: z.number().default(6379), + password: z.string().optional(), + username: z.string().optional(), + db: z.number().optional().default(0), +}); + +export type RedisConfig = z.infer; \ No newline at end of file diff --git a/libs/core/di/src/config/schemas/service.schema.ts b/libs/core/di/src/config/schemas/service.schema.ts new file mode 100644 index 0000000..2a76d26 --- /dev/null +++ b/libs/core/di/src/config/schemas/service.schema.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +export const proxyConfigSchema = z.object({ + cachePrefix: z.string().optional().default('proxy:'), + ttl: z.number().optional().default(3600), +}); + +export const browserConfigSchema = z.object({ + headless: z.boolean().optional().default(true), + timeout: z.number().optional().default(30000), +}); + +export const queueConfigSchema = z.object({ + enabled: z.boolean().optional().default(true), +}); + +export type ProxyConfig = z.infer; +export type BrowserConfig = z.infer; +export type QueueConfig = z.infer; \ No newline at end of file diff --git a/libs/core/di/src/container/README.md b/libs/core/di/src/container/README.md new file mode 100644 index 0000000..f22d082 --- /dev/null +++ b/libs/core/di/src/container/README.md @@ -0,0 +1,106 @@ +# DI Container - Modular Structure + +## Overview + +The DI container has been refactored into a modular structure for better organization and maintainability. + +## Directory Structure + +``` +├── container/ # Core container logic +│ ├── builder.ts # Fluent API for building containers +│ ├── factory.ts # Factory functions (legacy compatibility) +│ └── types.ts # Type definitions +├── registrations/ # Service registration modules +│ ├── core.ts # Core services (config, logger) +│ ├── cache.ts # Cache services +│ ├── database.ts # Database clients +│ └── service.ts # Application services +├── config/ # Configuration management +│ └── schemas/ # Zod schemas for validation +├── factories/ # Service factories +│ └── cache.factory.ts # Cache factory utilities +└── utils/ # Utilities + └── lifecycle.ts # Service lifecycle management +``` + +## Usage Examples + +### Using the Builder Pattern (Recommended) + +```typescript +import { ServiceContainerBuilder } from '@stock-bot/di'; + +// Create container with fluent API +const container = await new ServiceContainerBuilder() + .withConfig({ + redis: { host: 'localhost', port: 6379 }, + mongodb: { uri: 'mongodb://localhost', database: 'mydb' }, + postgres: { host: 'localhost', database: 'mydb', user: 'user', password: 'pass' } + }) + .enableService('enableQueue', false) // Disable queue service + .enableService('enableBrowser', false) // Disable browser service + .build(); + +// Services are automatically initialized +const cache = container.cradle.cache; +const mongoClient = container.cradle.mongoClient; +``` + +### Creating Namespaced Caches + +```typescript +import { CacheFactory } from '@stock-bot/di'; + +// Create a cache for a specific service +const serviceCache = CacheFactory.createCacheForService(container, 'myservice'); + +// Create a cache for a handler +const handlerCache = CacheFactory.createCacheForHandler(container, 'myhandler'); + +// Create a cache with custom prefix +const customCache = CacheFactory.createCacheWithPrefix(container, 'custom'); +``` + +### Manual Service Lifecycle + +```typescript +import { ServiceContainerBuilder, ServiceLifecycleManager } from '@stock-bot/di'; + +// Create container without auto-initialization +const container = await new ServiceContainerBuilder() + .withConfig(config) + .skipInitialization() + .build(); + +// Manually initialize services +const lifecycle = new ServiceLifecycleManager(); +await lifecycle.initializeServices(container); + +// ... use services ... + +// Manually shutdown services +await lifecycle.shutdownServices(container); +``` + +### Legacy API (Backward Compatible) + +```typescript +import { createServiceContainerFromConfig } from '@stock-bot/di'; + +// Old way still works +const container = createServiceContainerFromConfig(appConfig, { + enableQueue: true, + enableCache: true +}); + +// Manual initialization required with legacy API +await initializeServices(container); +``` + +## Migration Guide + +1. Replace direct container creation with `ServiceContainerBuilder` +2. Use `CacheFactory` instead of manually creating `NamespacedCache` +3. Let the builder handle service initialization automatically +4. Use typed configuration schemas for better validation \ No newline at end of file diff --git a/libs/core/di/src/container/builder.ts b/libs/core/di/src/container/builder.ts new file mode 100644 index 0000000..c086f97 --- /dev/null +++ b/libs/core/di/src/container/builder.ts @@ -0,0 +1,171 @@ +import { createContainer, InjectionMode, type AwilixContainer } from 'awilix'; +import type { AppConfig as StockBotAppConfig } from '@stock-bot/config'; +import { appConfigSchema, type AppConfig } from '../config/schemas'; +import { + registerCoreServices, + registerCacheServices, + registerDatabaseServices, + registerApplicationServices +} from '../registrations'; +import { ServiceLifecycleManager } from '../utils/lifecycle'; +import type { ServiceDefinitions, ContainerBuildOptions } from './types'; + +export class ServiceContainerBuilder { + private config: Partial = {}; + private options: ContainerBuildOptions = { + enableCache: true, + enableQueue: true, + enableMongoDB: true, + enablePostgres: true, + enableQuestDB: true, + enableBrowser: true, + enableProxy: true, + skipInitialization: false, + initializationTimeout: 30000, + }; + + withConfig(config: AppConfig | StockBotAppConfig): this { + this.config = this.transformStockBotConfig(config); + return this; + } + + withOptions(options: Partial): this { + Object.assign(this.options, options); + return this; + } + + enableService(service: keyof Omit, enabled = true): this { + this.options[service] = enabled; + return this; + } + + skipInitialization(skip = true): this { + this.options.skipInitialization = skip; + return this; + } + + async build(): Promise> { + // Validate and prepare config + const validatedConfig = this.prepareConfig(); + + // Create container + const container = createContainer({ + injectionMode: InjectionMode.PROXY, + strict: true, + }); + + // Register services + this.registerServices(container, validatedConfig); + + // Initialize services if not skipped + if (!this.options.skipInitialization) { + const lifecycleManager = new ServiceLifecycleManager(); + await lifecycleManager.initializeServices(container, this.options.initializationTimeout); + } + + return container; + } + + private prepareConfig(): AppConfig { + const finalConfig = this.applyServiceOptions(this.config); + return appConfigSchema.parse(finalConfig); + } + + private applyServiceOptions(config: Partial): AppConfig { + return { + redis: { + enabled: this.options.enableCache ?? true, + host: config.redis?.host || 'localhost', + port: config.redis?.port || 6379, + password: config.redis?.password, + username: config.redis?.username, + db: config.redis?.db || 0, + }, + mongodb: { + enabled: this.options.enableMongoDB ?? true, + uri: config.mongodb?.uri || '', + database: config.mongodb?.database || '', + }, + postgres: { + enabled: this.options.enablePostgres ?? true, + host: config.postgres?.host || 'localhost', + port: config.postgres?.port || 5432, + database: config.postgres?.database || '', + user: config.postgres?.user || '', + password: config.postgres?.password || '', + }, + questdb: this.options.enableQuestDB ? config.questdb : undefined, + proxy: this.options.enableProxy ? (config.proxy || { cachePrefix: 'proxy:', ttl: 3600 }) : undefined, + browser: this.options.enableBrowser ? config.browser : undefined, + queue: this.options.enableQueue ? { enabled: this.options.enableQueue } : undefined, + }; + } + + private registerServices(container: AwilixContainer, config: AppConfig): void { + registerCoreServices(container, config); + registerCacheServices(container, config); + registerDatabaseServices(container, config); + registerApplicationServices(container, config); + + // Register service container aggregate + container.register({ + serviceContainer: asFunction(({ + config, logger, cache, proxyManager, browser, + queueManager, mongoClient, postgresClient, questdbClient + }) => ({ + logger, + cache, + proxy: proxyManager, // Map proxyManager to proxy + browser, + queue: queueManager, // Map queueManager to queue + mongodb: mongoClient, // Map mongoClient to mongodb + postgres: postgresClient, // Map postgresClient to postgres + questdb: questdbClient, // Map questdbClient to questdb + })).singleton(), + }); + } + + private transformStockBotConfig(config: AppConfig | StockBotAppConfig): Partial { + // If it's already in the new format, return as is + if ('redis' in config) { + return config as AppConfig; + } + + // Transform from StockBotAppConfig format + const stockBotConfig = config as StockBotAppConfig; + return { + redis: stockBotConfig.database?.dragonfly ? { + enabled: true, + host: stockBotConfig.database.dragonfly.host || 'localhost', + port: stockBotConfig.database.dragonfly.port || 6379, + password: stockBotConfig.database.dragonfly.password, + db: stockBotConfig.database.dragonfly.db || 0, + } : undefined, + mongodb: stockBotConfig.database?.mongodb ? { + enabled: true, + uri: stockBotConfig.database.mongodb.uri || + `mongodb://${stockBotConfig.database.mongodb.user || ''}:${stockBotConfig.database.mongodb.password || ''}@${stockBotConfig.database.mongodb.host || 'localhost'}:${stockBotConfig.database.mongodb.port || 27017}/${stockBotConfig.database.mongodb.database || 'test'}?authSource=${stockBotConfig.database.mongodb.authSource || 'admin'}`, + database: stockBotConfig.database.mongodb.database || 'test', + } : undefined, + postgres: stockBotConfig.database?.postgres ? { + enabled: true, + host: stockBotConfig.database.postgres.host || 'localhost', + port: stockBotConfig.database.postgres.port || 5432, + database: stockBotConfig.database.postgres.database || 'test', + user: stockBotConfig.database.postgres.user || 'test', + password: stockBotConfig.database.postgres.password || 'test', + } : undefined, + questdb: stockBotConfig.database?.questdb ? { + enabled: true, + host: stockBotConfig.database.questdb.host || 'localhost', + httpPort: stockBotConfig.database.questdb.httpPort || 9000, + pgPort: stockBotConfig.database.questdb.pgPort || 8812, + influxPort: stockBotConfig.database.questdb.ilpPort || 9009, + database: stockBotConfig.database.questdb.database || 'questdb', + } : undefined, + }; + } +} + +// Add missing import +import { asFunction } from 'awilix'; \ No newline at end of file diff --git a/libs/core/di/src/container/factory.ts b/libs/core/di/src/container/factory.ts new file mode 100644 index 0000000..c4ce2d6 --- /dev/null +++ b/libs/core/di/src/container/factory.ts @@ -0,0 +1,99 @@ +import type { AwilixContainer } from 'awilix'; +import type { AppConfig as StockBotAppConfig } from '@stock-bot/config'; +import { ServiceContainerBuilder } from './builder'; +import type { ServiceDefinitions, ServiceContainerOptions } from './types'; + +/** + * Creates a service container from raw configuration + * @deprecated Use ServiceContainerBuilder instead + */ +export function createServiceContainer(rawConfig: unknown): AwilixContainer { + // For backward compatibility, we need to create the container synchronously + // This means we'll use the original implementation pattern + const { createContainer, InjectionMode, asValue, asFunction, asClass } = require('awilix'); + const { appConfigSchema } = require('../config/schemas'); + const config = appConfigSchema.parse(rawConfig); + + const container = createContainer({ + injectionMode: InjectionMode.PROXY, + strict: true, + }); + + // Register all services synchronously + const { + registerCoreServices, + registerCacheServices, + registerDatabaseServices, + registerApplicationServices + } = require('../registrations'); + + registerCoreServices(container, config); + registerCacheServices(container, config); + registerDatabaseServices(container, config); + registerApplicationServices(container, config); + + // Register service container aggregate + container.register({ + serviceContainer: asFunction((cradle: ServiceDefinitions) => ({ + logger: cradle.logger, + cache: cradle.cache, + proxy: cradle.proxyManager, // Map proxyManager to proxy + browser: cradle.browser, + queue: cradle.queueManager, // Map queueManager to queue + mongodb: cradle.mongoClient, // Map mongoClient to mongodb + postgres: cradle.postgresClient, // Map postgresClient to postgres + questdb: cradle.questdbClient, // Map questdbClient to questdb + })).singleton(), + }); + + return container; +} + + +/** + * Creates a service container from StockBotAppConfig + * @deprecated Use ServiceContainerBuilder instead + */ +export function createServiceContainerFromConfig( + appConfig: StockBotAppConfig, + options: ServiceContainerOptions = {} +): AwilixContainer { + const builder = new ServiceContainerBuilder(); + return builder + .withConfig(appConfig) + .withOptions({ + ...options, + skipInitialization: true, // Legacy behavior + }) + .build() + .then(container => container) + .catch(error => { + throw error; + }) as any; // Sync interface for backward compatibility +} + +/** + * Modern async factory for creating service containers + */ +export async function createServiceContainerAsync( + config: StockBotAppConfig, + options: ServiceContainerOptions = {} +): Promise> { + const builder = new ServiceContainerBuilder(); + return builder + .withConfig(config) + .withOptions(options) + .build(); +} + +/** + * Initialize services in an existing container + * @deprecated Handled automatically by ServiceContainerBuilder + */ +export async function initializeServices( + container: AwilixContainer +): Promise { + const { ServiceLifecycleManager } = await import('../utils/lifecycle'); + const lifecycleManager = new ServiceLifecycleManager(); + await lifecycleManager.initializeServices(container); +} \ No newline at end of file diff --git a/libs/core/di/src/container/types.ts b/libs/core/di/src/container/types.ts new file mode 100644 index 0000000..afe0593 --- /dev/null +++ b/libs/core/di/src/container/types.ts @@ -0,0 +1,47 @@ +import type { IServiceContainer } from '@stock-bot/handlers'; +import type { Logger } from '@stock-bot/logger'; +import type { AppConfig } from '../config/schemas'; +import type { CacheProvider } from '@stock-bot/cache'; +import type { ProxyManager } from '@stock-bot/proxy'; +import type { Browser } from '@stock-bot/browser'; +import type { QueueManager } from '@stock-bot/queue'; +import type { MongoDBClient } from '@stock-bot/mongodb'; +import type { PostgreSQLClient } from '@stock-bot/postgres'; +import type { QuestDBClient } from '@stock-bot/questdb'; + +export interface ServiceDefinitions { + // Configuration + config: AppConfig; + logger: Logger; + + // Core services + cache: CacheProvider | null; + proxyManager: ProxyManager | null; + browser: Browser; + queueManager: QueueManager | null; + + // Database clients + mongoClient: MongoDBClient | null; + postgresClient: PostgreSQLClient | null; + questdbClient: QuestDBClient | null; + + // Aggregate service container + serviceContainer: IServiceContainer; +} + +export type ServiceCradle = ServiceDefinitions; + +export interface ServiceContainerOptions { + enableQuestDB?: boolean; + enableMongoDB?: boolean; + enablePostgres?: boolean; + enableCache?: boolean; + enableQueue?: boolean; + enableBrowser?: boolean; + enableProxy?: boolean; +} + +export interface ContainerBuildOptions extends ServiceContainerOptions { + skipInitialization?: boolean; + initializationTimeout?: number; +} \ No newline at end of file diff --git a/libs/core/di/src/factories/cache.factory.ts b/libs/core/di/src/factories/cache.factory.ts new file mode 100644 index 0000000..e70b6fe --- /dev/null +++ b/libs/core/di/src/factories/cache.factory.ts @@ -0,0 +1,44 @@ +import type { AwilixContainer } from 'awilix'; +import { NamespacedCache, type CacheProvider } from '@stock-bot/cache'; +import type { ServiceDefinitions } from '../container/types'; + +export class CacheFactory { + static createNamespacedCache( + baseCache: CacheProvider, + namespace: string + ): NamespacedCache { + return new NamespacedCache(baseCache, namespace); + } + + static createCacheForService( + container: AwilixContainer, + serviceName: string + ): CacheProvider | null { + const baseCache = container.cradle.cache; + if (!baseCache) return null; + + return this.createNamespacedCache(baseCache, serviceName); + } + + static createCacheForHandler( + container: AwilixContainer, + handlerName: string + ): CacheProvider | null { + const baseCache = container.cradle.cache; + if (!baseCache) return null; + + return this.createNamespacedCache(baseCache, `handler:${handlerName}`); + } + + static createCacheWithPrefix( + container: AwilixContainer, + prefix: string + ): CacheProvider | null { + const baseCache = container.cradle.cache; + if (!baseCache) return null; + + // Remove 'cache:' prefix if already included + const cleanPrefix = prefix.replace(/^cache:/, ''); + return this.createNamespacedCache(baseCache, cleanPrefix); + } +} \ No newline at end of file diff --git a/libs/core/di/src/factories/index.ts b/libs/core/di/src/factories/index.ts new file mode 100644 index 0000000..83df8b5 --- /dev/null +++ b/libs/core/di/src/factories/index.ts @@ -0,0 +1 @@ +export { CacheFactory } from './cache.factory'; \ No newline at end of file diff --git a/libs/core/di/src/index.ts b/libs/core/di/src/index.ts index 69e3ef8..6b330d2 100644 --- a/libs/core/di/src/index.ts +++ b/libs/core/di/src/index.ts @@ -3,7 +3,7 @@ export * from './operation-context'; export * from './pool-size-calculator'; export * from './types'; -// Awilix container exports +// Legacy exports for backward compatibility export { createServiceContainer, createServiceContainerFromConfig, @@ -14,6 +14,24 @@ export { type ServiceContainerOptions, } from './awilix-container'; +// New modular structure exports +export * from './container/types'; +export { ServiceContainerBuilder } from './container/builder'; +export { + createServiceContainerAsync, + createServiceContainer as createServiceContainerNew, + createServiceContainerFromConfig as createServiceContainerFromConfigNew +} from './container/factory'; + +// Configuration exports +export * from './config/schemas'; + +// Factory exports +export * from './factories'; + +// Utility exports +export { ServiceLifecycleManager } from './utils/lifecycle'; + // Service application framework export { ServiceApplication, diff --git a/libs/core/di/src/registrations/cache.registration.ts b/libs/core/di/src/registrations/cache.registration.ts new file mode 100644 index 0000000..1dddc9e --- /dev/null +++ b/libs/core/di/src/registrations/cache.registration.ts @@ -0,0 +1,27 @@ +import { asClass, asFunction, asValue, type AwilixContainer } from 'awilix'; +import { createCache, type CacheProvider } from '@stock-bot/cache'; +import type { AppConfig } from '../config/schemas'; +import type { ServiceDefinitions } from '../container/types'; + +export function registerCacheServices( + container: AwilixContainer, + config: AppConfig +): void { + if (config.redis.enabled) { + container.register({ + cache: asFunction(() => { + return createCache({ + redisConfig: { + host: config.redis.host, + port: config.redis.port, + password: config.redis.password, + }, + }); + }).singleton(), + }); + } else { + container.register({ + cache: asValue(null), + }); + } +} \ No newline at end of file diff --git a/libs/core/di/src/registrations/core.registration.ts b/libs/core/di/src/registrations/core.registration.ts new file mode 100644 index 0000000..be19285 --- /dev/null +++ b/libs/core/di/src/registrations/core.registration.ts @@ -0,0 +1,14 @@ +import { asValue, type AwilixContainer } from 'awilix'; +import { getLogger, type Logger } from '@stock-bot/logger'; +import type { AppConfig } from '../config/schemas'; +import type { ServiceDefinitions } from '../container/types'; + +export function registerCoreServices( + container: AwilixContainer, + config: AppConfig +): void { + container.register({ + config: asValue(config), + logger: asValue(getLogger('di-container')), + }); +} \ No newline at end of file diff --git a/libs/core/di/src/registrations/database.registration.ts b/libs/core/di/src/registrations/database.registration.ts new file mode 100644 index 0000000..68386cd --- /dev/null +++ b/libs/core/di/src/registrations/database.registration.ts @@ -0,0 +1,79 @@ +import { MongoDBClient } from '@stock-bot/mongodb'; +import { PostgreSQLClient } from '@stock-bot/postgres'; +import { QuestDBClient } from '@stock-bot/questdb'; +import { asFunction, asValue, type AwilixContainer } from 'awilix'; +import type { AppConfig } from '../config/schemas'; +import type { ServiceDefinitions } from '../container/types'; + +export function registerDatabaseServices( + container: AwilixContainer, + config: AppConfig +): void { + // MongoDB + if (config.mongodb.enabled) { + container.register({ + mongoClient: asFunction(({ logger }) => { + // Parse MongoDB URI to extract components + const uriMatch = config.mongodb.uri.match(/mongodb:\/\/(?:([^:]+):([^@]+)@)?([^:\/]+):(\d+)\/([^?]+)(?:\?authSource=(.+))?/); + const mongoConfig = { + host: uriMatch?.[3] || 'localhost', + port: parseInt(uriMatch?.[4] || '27017'), + database: config.mongodb.database, + username: uriMatch?.[1], + password: uriMatch?.[2], + authSource: uriMatch?.[6] || 'admin', + uri: config.mongodb.uri, + }; + return new MongoDBClient(mongoConfig, logger); + }).singleton(), + }); + } else { + container.register({ + mongoClient: asValue(null), + }); + } + + // PostgreSQL + if (config.postgres.enabled) { + container.register({ + postgresClient: asFunction(({ logger }) => { + return new PostgreSQLClient( + { + host: config.postgres.host, + port: config.postgres.port, + database: config.postgres.database, + username: config.postgres.user, + password: config.postgres.password, + }, + logger + ); + }).singleton(), + }); + } else { + container.register({ + postgresClient: asValue(null), + }); + } + + // QuestDB + if (config.questdb?.enabled) { + container.register({ + questdbClient: asFunction(({ logger }) => { + return new QuestDBClient( + { + host: config.questdb!.host, + httpPort: config.questdb!.httpPort, + pgPort: config.questdb!.pgPort, + influxPort: config.questdb!.influxPort, + database: config.questdb!.database, + }, + logger + ); + }).singleton(), + }); + } else { + container.register({ + questdbClient: asValue(null), + }); + } +} \ No newline at end of file diff --git a/libs/core/di/src/registrations/index.ts b/libs/core/di/src/registrations/index.ts new file mode 100644 index 0000000..db37593 --- /dev/null +++ b/libs/core/di/src/registrations/index.ts @@ -0,0 +1,4 @@ +export { registerCoreServices } from './core.registration'; +export { registerCacheServices } from './cache.registration'; +export { registerDatabaseServices } from './database.registration'; +export { registerApplicationServices } from './service.registration'; \ No newline at end of file diff --git a/libs/core/di/src/registrations/service.registration.ts b/libs/core/di/src/registrations/service.registration.ts new file mode 100644 index 0000000..734c8fe --- /dev/null +++ b/libs/core/di/src/registrations/service.registration.ts @@ -0,0 +1,81 @@ +import { asClass, asFunction, asValue, type AwilixContainer } from 'awilix'; +import { Browser } from '@stock-bot/browser'; +import { ProxyManager } from '@stock-bot/proxy'; +import { NamespacedCache } from '@stock-bot/cache'; +import type { QueueManager } from '@stock-bot/queue'; +import type { AppConfig } from '../config/schemas'; +import type { ServiceDefinitions } from '../container/types'; + +export function registerApplicationServices( + container: AwilixContainer, + config: AppConfig +): void { + // Browser + if (config.browser) { + container.register({ + browser: asClass(Browser) + .singleton() + .inject(() => ({ + options: { + headless: config.browser!.headless, + timeout: config.browser!.timeout, + }, + })), + }); + } else { + container.register({ + browser: asValue(null as any), // Required field + }); + } + + // Proxy Manager + if (config.proxy && config.redis.enabled) { + container.register({ + proxyManager: asFunction(({ cache, logger }) => { + if (!cache) return null; + const proxyCache = new NamespacedCache(cache, 'proxy'); + return new ProxyManager(proxyCache, logger); + }).singleton(), + }); + } else { + container.register({ + proxyManager: asValue(null), + }); + } + + // Queue Manager + if (config.queue?.enabled && config.redis.enabled) { + container.register({ + queueManager: asFunction(({ logger }) => { + const { QueueManager } = require('@stock-bot/queue'); + const queueConfig = { + redis: { + host: config.redis.host, + port: config.redis.port, + password: config.redis.password, + db: config.redis.db, + }, + defaultQueueOptions: { + workers: 1, + concurrency: 1, + defaultJobOptions: { + removeOnComplete: 100, + removeOnFail: 50, + attempts: 3, + backoff: { + type: 'exponential', + delay: 1000, + }, + }, + }, + enableScheduledJobs: true, + }; + return new QueueManager(queueConfig, logger); + }).singleton(), + }); + } else { + container.register({ + queueManager: asValue(null), + }); + } +} \ No newline at end of file diff --git a/libs/core/di/src/utils/lifecycle.ts b/libs/core/di/src/utils/lifecycle.ts new file mode 100644 index 0000000..44ae23d --- /dev/null +++ b/libs/core/di/src/utils/lifecycle.ts @@ -0,0 +1,96 @@ +import type { AwilixContainer } from 'awilix'; +import type { ServiceDefinitions } from '../container/types'; + +interface ServiceWithLifecycle { + connect?: () => Promise; + disconnect?: () => Promise; + close?: () => Promise; + initialize?: () => Promise; + shutdown?: () => Promise; +} + +export class ServiceLifecycleManager { + private readonly services = [ + { name: 'cache', key: 'cache' as const }, + { name: 'mongoClient', key: 'mongoClient' as const }, + { name: 'postgresClient', key: 'postgresClient' as const }, + { name: 'questdbClient', key: 'questdbClient' as const }, + { name: 'queueManager', key: 'queueManager' as const }, + ]; + + async initializeServices( + container: AwilixContainer, + timeout = 30000 + ): Promise { + const initPromises: Promise[] = []; + + for (const { name, key } of this.services) { + const service = container.cradle[key] as ServiceWithLifecycle | null; + + if (service) { + const initPromise = this.initializeService(name, service); + initPromises.push( + Promise.race([ + initPromise, + this.createTimeoutPromise(timeout, `${name} initialization timed out after ${timeout}ms`), + ]) + ); + } + } + + await Promise.all(initPromises); + console.log('✅ All services initialized successfully'); + } + + async shutdownServices(container: AwilixContainer): Promise { + const shutdownPromises: Promise[] = []; + + // Shutdown in reverse order + for (const { name, key } of [...this.services].reverse()) { + const service = container.cradle[key] as ServiceWithLifecycle | null; + + if (service) { + shutdownPromises.push(this.shutdownService(name, service)); + } + } + + await Promise.allSettled(shutdownPromises); + console.log('✅ All services shut down'); + } + + private async initializeService(name: string, service: ServiceWithLifecycle): Promise { + try { + if (typeof service.connect === 'function') { + await service.connect(); + console.log(`✅ ${name} connected`); + } else if (typeof service.initialize === 'function') { + await service.initialize(); + console.log(`✅ ${name} initialized`); + } + } catch (error) { + console.error(`❌ Failed to initialize ${name}:`, error); + throw error; + } + } + + private async shutdownService(name: string, service: ServiceWithLifecycle): Promise { + try { + if (typeof service.disconnect === 'function') { + await service.disconnect(); + } else if (typeof service.close === 'function') { + await service.close(); + } else if (typeof service.shutdown === 'function') { + await service.shutdown(); + } + console.log(`✅ ${name} shut down`); + } catch (error) { + console.error(`❌ Error shutting down ${name}:`, error); + } + } + + private createTimeoutPromise(timeout: number, message: string): Promise { + return new Promise((_, reject) => { + setTimeout(() => reject(new Error(message)), timeout); + }); + } +} \ No newline at end of file