refactored di into more composable parts

This commit is contained in:
Boki 2025-06-22 21:47:39 -04:00
parent 177fe30586
commit 26ebc77fe6
22 changed files with 908 additions and 281 deletions

View file

@ -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');

View file

@ -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<ServiceDefinitions> {
// Validate configuration
const config = appConfigSchema.parse(rawConfig);
const container = createContainer<ServiceDefinitions>({
injectionMode: InjectionMode.PROXY,
});
// Register configuration values
const registrations: Record<string, unknown> = {
// 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<void> {
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<ServiceDefinitions>;
export type ServiceCradle = ServiceDefinitions;
@ -333,66 +138,8 @@ export function createServiceContainerFromConfig(
appConfig: StockBotAppConfig,
options: ServiceContainerOptions = {}
): AwilixContainer<ServiceDefinitions> {
// 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);
}

View file

@ -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<typeof appConfigSchema>;
// 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';

View file

@ -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<typeof mongodbConfigSchema>;

View file

@ -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<typeof postgresConfigSchema>;

View file

@ -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<typeof questdbConfigSchema>;

View file

@ -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<typeof redisConfigSchema>;

View file

@ -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<typeof proxyConfigSchema>;
export type BrowserConfig = z.infer<typeof browserConfigSchema>;
export type QueueConfig = z.infer<typeof queueConfigSchema>;

View file

@ -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

View file

@ -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<AppConfig> = {};
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<ContainerBuildOptions>): this {
Object.assign(this.options, options);
return this;
}
enableService(service: keyof Omit<ContainerBuildOptions, 'skipInitialization' | 'initializationTimeout'>, enabled = true): this {
this.options[service] = enabled;
return this;
}
skipInitialization(skip = true): this {
this.options.skipInitialization = skip;
return this;
}
async build(): Promise<AwilixContainer<ServiceDefinitions>> {
// Validate and prepare config
const validatedConfig = this.prepareConfig();
// Create container
const container = createContainer<ServiceDefinitions>({
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>): 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<ServiceDefinitions>, 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<AppConfig> {
// 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';

View file

@ -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<ServiceDefinitions> {
// 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<ServiceDefinitions> {
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<AwilixContainer<ServiceDefinitions>> {
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<ServiceDefinitions>
): Promise<void> {
const { ServiceLifecycleManager } = await import('../utils/lifecycle');
const lifecycleManager = new ServiceLifecycleManager();
await lifecycleManager.initializeServices(container);
}

View file

@ -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;
}

View file

@ -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<ServiceDefinitions>,
serviceName: string
): CacheProvider | null {
const baseCache = container.cradle.cache;
if (!baseCache) return null;
return this.createNamespacedCache(baseCache, serviceName);
}
static createCacheForHandler(
container: AwilixContainer<ServiceDefinitions>,
handlerName: string
): CacheProvider | null {
const baseCache = container.cradle.cache;
if (!baseCache) return null;
return this.createNamespacedCache(baseCache, `handler:${handlerName}`);
}
static createCacheWithPrefix(
container: AwilixContainer<ServiceDefinitions>,
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);
}
}

View file

@ -0,0 +1 @@
export { CacheFactory } from './cache.factory';

View file

@ -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,

View file

@ -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<ServiceDefinitions>,
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),
});
}
}

View file

@ -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<ServiceDefinitions>,
config: AppConfig
): void {
container.register({
config: asValue(config),
logger: asValue(getLogger('di-container')),
});
}

View file

@ -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<ServiceDefinitions>,
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),
});
}
}

View file

@ -0,0 +1,4 @@
export { registerCoreServices } from './core.registration';
export { registerCacheServices } from './cache.registration';
export { registerDatabaseServices } from './database.registration';
export { registerApplicationServices } from './service.registration';

View file

@ -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<ServiceDefinitions>,
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),
});
}
}

View file

@ -0,0 +1,96 @@
import type { AwilixContainer } from 'awilix';
import type { ServiceDefinitions } from '../container/types';
interface ServiceWithLifecycle {
connect?: () => Promise<void>;
disconnect?: () => Promise<void>;
close?: () => Promise<void>;
initialize?: () => Promise<void>;
shutdown?: () => Promise<void>;
}
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<ServiceDefinitions>,
timeout = 30000
): Promise<void> {
const initPromises: Promise<void>[] = [];
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<ServiceDefinitions>): Promise<void> {
const shutdownPromises: Promise<void>[] = [];
// 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<void> {
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<void> {
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<never> {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error(message)), timeout);
});
}
}