format
This commit is contained in:
parent
d858222af7
commit
7d9044ab29
202 changed files with 10755 additions and 10972 deletions
|
|
@ -91,4 +91,4 @@
|
|||
"apiKey": "",
|
||||
"apiUrl": "https://proxy.webshare.io/api/v2/"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,4 +45,4 @@
|
|||
"webmasterId": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,4 +29,4 @@
|
|||
"retries": 5,
|
||||
"retryDelay": 2000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,4 +39,4 @@
|
|||
"timeout": 5000,
|
||||
"retries": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,196 +1,193 @@
|
|||
#!/usr/bin/env bun
|
||||
/* eslint-disable no-console */
|
||||
import { parseArgs } from 'util';
|
||||
import { join } from 'path';
|
||||
import { ConfigManager } from './config-manager';
|
||||
import { appConfigSchema } from './schemas';
|
||||
import {
|
||||
validateConfig,
|
||||
formatValidationResult,
|
||||
checkDeprecations,
|
||||
checkRequiredEnvVars,
|
||||
validateCompleteness
|
||||
} from './utils/validation';
|
||||
import { redactSecrets } from './utils/secrets';
|
||||
import type { Environment } from './types';
|
||||
|
||||
interface CliOptions {
|
||||
config?: string;
|
||||
env?: string;
|
||||
validate?: boolean;
|
||||
show?: boolean;
|
||||
check?: boolean;
|
||||
json?: boolean;
|
||||
help?: boolean;
|
||||
}
|
||||
|
||||
const DEPRECATIONS = {
|
||||
'service.legacyMode': 'Use service.mode instead',
|
||||
'database.redis': 'Use database.dragonfly instead',
|
||||
};
|
||||
|
||||
const REQUIRED_PATHS = [
|
||||
'service.name',
|
||||
'service.port',
|
||||
'database.postgres.host',
|
||||
'database.postgres.database',
|
||||
];
|
||||
|
||||
const REQUIRED_ENV_VARS = [
|
||||
'NODE_ENV',
|
||||
];
|
||||
|
||||
const SECRET_PATHS = [
|
||||
'database.postgres.password',
|
||||
'database.mongodb.uri',
|
||||
'providers.quoteMedia.apiKey',
|
||||
'providers.interactiveBrokers.clientId',
|
||||
];
|
||||
|
||||
function printUsage() {
|
||||
console.log(`
|
||||
Stock Bot Configuration CLI
|
||||
|
||||
Usage: bun run config-cli [options]
|
||||
|
||||
Options:
|
||||
--config <path> Path to config directory (default: ./config)
|
||||
--env <env> Environment to use (development, test, production)
|
||||
--validate Validate configuration against schema
|
||||
--show Show current configuration (secrets redacted)
|
||||
--check Run all configuration checks
|
||||
--json Output in JSON format
|
||||
--help Show this help message
|
||||
|
||||
Examples:
|
||||
# Validate configuration
|
||||
bun run config-cli --validate
|
||||
|
||||
# Show configuration for production
|
||||
bun run config-cli --env production --show
|
||||
|
||||
# Run all checks
|
||||
bun run config-cli --check
|
||||
|
||||
# Output configuration as JSON
|
||||
bun run config-cli --show --json
|
||||
`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { values } = parseArgs({
|
||||
args: process.argv.slice(2),
|
||||
options: {
|
||||
config: { type: 'string' },
|
||||
env: { type: 'string' },
|
||||
validate: { type: 'boolean' },
|
||||
show: { type: 'boolean' },
|
||||
check: { type: 'boolean' },
|
||||
json: { type: 'boolean' },
|
||||
help: { type: 'boolean' },
|
||||
},
|
||||
}) as { values: CliOptions };
|
||||
|
||||
if (values.help) {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const configPath = values.config || join(process.cwd(), 'config');
|
||||
const environment = values.env as Environment;
|
||||
|
||||
try {
|
||||
const manager = new ConfigManager({
|
||||
configPath,
|
||||
environment,
|
||||
});
|
||||
|
||||
const config = await manager.initialize(appConfigSchema);
|
||||
|
||||
if (values.validate) {
|
||||
const result = validateConfig(config, appConfigSchema);
|
||||
|
||||
if (values.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.log(formatValidationResult(result));
|
||||
}
|
||||
|
||||
process.exit(result.valid ? 0 : 1);
|
||||
}
|
||||
|
||||
if (values.show) {
|
||||
const redacted = redactSecrets(config, SECRET_PATHS);
|
||||
|
||||
if (values.json) {
|
||||
console.log(JSON.stringify(redacted, null, 2));
|
||||
} else {
|
||||
console.log('Current Configuration:');
|
||||
console.log(JSON.stringify(redacted, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
if (values.check) {
|
||||
console.log('Running configuration checks...\n');
|
||||
|
||||
// Schema validation
|
||||
console.log('1. Schema Validation:');
|
||||
const schemaResult = validateConfig(config, appConfigSchema);
|
||||
console.log(formatValidationResult(schemaResult));
|
||||
console.log();
|
||||
|
||||
// Environment variables
|
||||
console.log('2. Required Environment Variables:');
|
||||
const envResult = checkRequiredEnvVars(REQUIRED_ENV_VARS);
|
||||
console.log(formatValidationResult(envResult));
|
||||
console.log();
|
||||
|
||||
// Required paths
|
||||
console.log('3. Required Configuration Paths:');
|
||||
const pathResult = validateCompleteness(config, REQUIRED_PATHS);
|
||||
console.log(formatValidationResult(pathResult));
|
||||
console.log();
|
||||
|
||||
// Deprecations
|
||||
console.log('4. Deprecation Warnings:');
|
||||
const warnings = checkDeprecations(config, DEPRECATIONS);
|
||||
if (warnings && warnings.length > 0) {
|
||||
for (const warning of warnings) {
|
||||
console.log(` ⚠️ ${warning.path}: ${warning.message}`);
|
||||
}
|
||||
} else {
|
||||
console.log(' ✅ No deprecated options found');
|
||||
}
|
||||
console.log();
|
||||
|
||||
// Overall result
|
||||
const allValid = schemaResult.valid && envResult.valid && pathResult.valid;
|
||||
|
||||
if (allValid) {
|
||||
console.log('✅ All configuration checks passed!');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log('❌ Some configuration checks failed');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!values.validate && !values.show && !values.check) {
|
||||
console.log('No action specified. Use --help for usage information.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
if (values.json) {
|
||||
console.error(JSON.stringify({ error: String(error) }));
|
||||
} else {
|
||||
console.error('Error:', error);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run CLI
|
||||
if (import.meta.main) {
|
||||
main();
|
||||
}
|
||||
#!/usr/bin/env bun
|
||||
/* eslint-disable no-console */
|
||||
import { join } from 'path';
|
||||
import { parseArgs } from 'util';
|
||||
import { redactSecrets } from './utils/secrets';
|
||||
import {
|
||||
checkDeprecations,
|
||||
checkRequiredEnvVars,
|
||||
formatValidationResult,
|
||||
validateCompleteness,
|
||||
validateConfig,
|
||||
} from './utils/validation';
|
||||
import { ConfigManager } from './config-manager';
|
||||
import { appConfigSchema } from './schemas';
|
||||
import type { Environment } from './types';
|
||||
|
||||
interface CliOptions {
|
||||
config?: string;
|
||||
env?: string;
|
||||
validate?: boolean;
|
||||
show?: boolean;
|
||||
check?: boolean;
|
||||
json?: boolean;
|
||||
help?: boolean;
|
||||
}
|
||||
|
||||
const DEPRECATIONS = {
|
||||
'service.legacyMode': 'Use service.mode instead',
|
||||
'database.redis': 'Use database.dragonfly instead',
|
||||
};
|
||||
|
||||
const REQUIRED_PATHS = [
|
||||
'service.name',
|
||||
'service.port',
|
||||
'database.postgres.host',
|
||||
'database.postgres.database',
|
||||
];
|
||||
|
||||
const REQUIRED_ENV_VARS = ['NODE_ENV'];
|
||||
|
||||
const SECRET_PATHS = [
|
||||
'database.postgres.password',
|
||||
'database.mongodb.uri',
|
||||
'providers.quoteMedia.apiKey',
|
||||
'providers.interactiveBrokers.clientId',
|
||||
];
|
||||
|
||||
function printUsage() {
|
||||
console.log(`
|
||||
Stock Bot Configuration CLI
|
||||
|
||||
Usage: bun run config-cli [options]
|
||||
|
||||
Options:
|
||||
--config <path> Path to config directory (default: ./config)
|
||||
--env <env> Environment to use (development, test, production)
|
||||
--validate Validate configuration against schema
|
||||
--show Show current configuration (secrets redacted)
|
||||
--check Run all configuration checks
|
||||
--json Output in JSON format
|
||||
--help Show this help message
|
||||
|
||||
Examples:
|
||||
# Validate configuration
|
||||
bun run config-cli --validate
|
||||
|
||||
# Show configuration for production
|
||||
bun run config-cli --env production --show
|
||||
|
||||
# Run all checks
|
||||
bun run config-cli --check
|
||||
|
||||
# Output configuration as JSON
|
||||
bun run config-cli --show --json
|
||||
`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { values } = parseArgs({
|
||||
args: process.argv.slice(2),
|
||||
options: {
|
||||
config: { type: 'string' },
|
||||
env: { type: 'string' },
|
||||
validate: { type: 'boolean' },
|
||||
show: { type: 'boolean' },
|
||||
check: { type: 'boolean' },
|
||||
json: { type: 'boolean' },
|
||||
help: { type: 'boolean' },
|
||||
},
|
||||
}) as { values: CliOptions };
|
||||
|
||||
if (values.help) {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const configPath = values.config || join(process.cwd(), 'config');
|
||||
const environment = values.env as Environment;
|
||||
|
||||
try {
|
||||
const manager = new ConfigManager({
|
||||
configPath,
|
||||
environment,
|
||||
});
|
||||
|
||||
const config = await manager.initialize(appConfigSchema);
|
||||
|
||||
if (values.validate) {
|
||||
const result = validateConfig(config, appConfigSchema);
|
||||
|
||||
if (values.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.log(formatValidationResult(result));
|
||||
}
|
||||
|
||||
process.exit(result.valid ? 0 : 1);
|
||||
}
|
||||
|
||||
if (values.show) {
|
||||
const redacted = redactSecrets(config, SECRET_PATHS);
|
||||
|
||||
if (values.json) {
|
||||
console.log(JSON.stringify(redacted, null, 2));
|
||||
} else {
|
||||
console.log('Current Configuration:');
|
||||
console.log(JSON.stringify(redacted, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
if (values.check) {
|
||||
console.log('Running configuration checks...\n');
|
||||
|
||||
// Schema validation
|
||||
console.log('1. Schema Validation:');
|
||||
const schemaResult = validateConfig(config, appConfigSchema);
|
||||
console.log(formatValidationResult(schemaResult));
|
||||
console.log();
|
||||
|
||||
// Environment variables
|
||||
console.log('2. Required Environment Variables:');
|
||||
const envResult = checkRequiredEnvVars(REQUIRED_ENV_VARS);
|
||||
console.log(formatValidationResult(envResult));
|
||||
console.log();
|
||||
|
||||
// Required paths
|
||||
console.log('3. Required Configuration Paths:');
|
||||
const pathResult = validateCompleteness(config, REQUIRED_PATHS);
|
||||
console.log(formatValidationResult(pathResult));
|
||||
console.log();
|
||||
|
||||
// Deprecations
|
||||
console.log('4. Deprecation Warnings:');
|
||||
const warnings = checkDeprecations(config, DEPRECATIONS);
|
||||
if (warnings && warnings.length > 0) {
|
||||
for (const warning of warnings) {
|
||||
console.log(` ⚠️ ${warning.path}: ${warning.message}`);
|
||||
}
|
||||
} else {
|
||||
console.log(' ✅ No deprecated options found');
|
||||
}
|
||||
console.log();
|
||||
|
||||
// Overall result
|
||||
const allValid = schemaResult.valid && envResult.valid && pathResult.valid;
|
||||
|
||||
if (allValid) {
|
||||
console.log('✅ All configuration checks passed!');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log('❌ Some configuration checks failed');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!values.validate && !values.show && !values.check) {
|
||||
console.log('No action specified. Use --help for usage information.');
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
if (values.json) {
|
||||
console.error(JSON.stringify({ error: String(error) }));
|
||||
} else {
|
||||
console.error('Error:', error);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run CLI
|
||||
if (import.meta.main) {
|
||||
main();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,15 +6,21 @@ export class ConfigError extends Error {
|
|||
}
|
||||
|
||||
export class ConfigValidationError extends ConfigError {
|
||||
constructor(message: string, public errors: unknown) {
|
||||
constructor(
|
||||
message: string,
|
||||
public errors: unknown
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ConfigValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ConfigLoaderError extends ConfigError {
|
||||
constructor(message: string, public loader: string) {
|
||||
constructor(
|
||||
message: string,
|
||||
public loader: string
|
||||
) {
|
||||
super(`${loader}: ${message}`);
|
||||
this.name = 'ConfigLoaderError';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,4 +7,4 @@ export const baseConfigSchema = z.object({
|
|||
name: z.string().optional(),
|
||||
version: z.string().optional(),
|
||||
debug: z.boolean().default(false),
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -61,4 +61,4 @@ export const databaseConfigSchema = z.object({
|
|||
questdb: questdbConfigSchema,
|
||||
mongodb: mongodbConfigSchema,
|
||||
dragonfly: dragonflyConfigSchema,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,87 +1,105 @@
|
|||
export * from './base.schema';
|
||||
export * from './database.schema';
|
||||
export * from './provider.schema';
|
||||
export * from './service.schema';
|
||||
|
||||
import { z } from 'zod';
|
||||
import { baseConfigSchema, environmentSchema } from './base.schema';
|
||||
import { providerConfigSchema, webshareProviderConfigSchema } from './provider.schema';
|
||||
import { httpConfigSchema, queueConfigSchema } from './service.schema';
|
||||
|
||||
export * from './base.schema';
|
||||
export * from './database.schema';
|
||||
export * from './provider.schema';
|
||||
export * from './service.schema';
|
||||
|
||||
// Flexible service schema with defaults
|
||||
const flexibleServiceConfigSchema = z.object({
|
||||
name: z.string().default('default-service'),
|
||||
port: z.number().min(1).max(65535).default(3000),
|
||||
host: z.string().default('0.0.0.0'),
|
||||
healthCheckPath: z.string().default('/health'),
|
||||
metricsPath: z.string().default('/metrics'),
|
||||
shutdownTimeout: z.number().default(30000),
|
||||
cors: z.object({
|
||||
enabled: z.boolean().default(true),
|
||||
origin: z.union([z.string(), z.array(z.string())]).default('*'),
|
||||
credentials: z.boolean().default(true),
|
||||
}).default({}),
|
||||
}).default({});
|
||||
const flexibleServiceConfigSchema = z
|
||||
.object({
|
||||
name: z.string().default('default-service'),
|
||||
port: z.number().min(1).max(65535).default(3000),
|
||||
host: z.string().default('0.0.0.0'),
|
||||
healthCheckPath: z.string().default('/health'),
|
||||
metricsPath: z.string().default('/metrics'),
|
||||
shutdownTimeout: z.number().default(30000),
|
||||
cors: z
|
||||
.object({
|
||||
enabled: z.boolean().default(true),
|
||||
origin: z.union([z.string(), z.array(z.string())]).default('*'),
|
||||
credentials: z.boolean().default(true),
|
||||
})
|
||||
.default({}),
|
||||
})
|
||||
.default({});
|
||||
|
||||
// Flexible database schema with defaults
|
||||
const flexibleDatabaseConfigSchema = z.object({
|
||||
postgres: z.object({
|
||||
host: z.string().default('localhost'),
|
||||
port: z.number().default(5432),
|
||||
database: z.string().default('test_db'),
|
||||
user: z.string().default('test_user'),
|
||||
password: z.string().default('test_pass'),
|
||||
ssl: z.boolean().default(false),
|
||||
poolSize: z.number().min(1).max(100).default(10),
|
||||
connectionTimeout: z.number().default(30000),
|
||||
idleTimeout: z.number().default(10000),
|
||||
}).default({}),
|
||||
questdb: z.object({
|
||||
host: z.string().default('localhost'),
|
||||
ilpPort: z.number().default(9009),
|
||||
httpPort: z.number().default(9000),
|
||||
pgPort: z.number().default(8812),
|
||||
database: z.string().default('questdb'),
|
||||
user: z.string().default('admin'),
|
||||
password: z.string().default('quest'),
|
||||
bufferSize: z.number().default(65536),
|
||||
flushInterval: z.number().default(1000),
|
||||
}).default({}),
|
||||
mongodb: z.object({
|
||||
uri: z.string().url().optional(),
|
||||
host: z.string().default('localhost'),
|
||||
port: z.number().default(27017),
|
||||
database: z.string().default('test_mongo'),
|
||||
user: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
authSource: z.string().default('admin'),
|
||||
replicaSet: z.string().optional(),
|
||||
poolSize: z.number().min(1).max(100).default(10),
|
||||
}).default({}),
|
||||
dragonfly: z.object({
|
||||
host: z.string().default('localhost'),
|
||||
port: z.number().default(6379),
|
||||
password: z.string().optional(),
|
||||
db: z.number().min(0).max(15).default(0),
|
||||
keyPrefix: z.string().optional(),
|
||||
ttl: z.number().optional(),
|
||||
maxRetries: z.number().default(3),
|
||||
retryDelay: z.number().default(100),
|
||||
}).default({}),
|
||||
}).default({});
|
||||
const flexibleDatabaseConfigSchema = z
|
||||
.object({
|
||||
postgres: z
|
||||
.object({
|
||||
host: z.string().default('localhost'),
|
||||
port: z.number().default(5432),
|
||||
database: z.string().default('test_db'),
|
||||
user: z.string().default('test_user'),
|
||||
password: z.string().default('test_pass'),
|
||||
ssl: z.boolean().default(false),
|
||||
poolSize: z.number().min(1).max(100).default(10),
|
||||
connectionTimeout: z.number().default(30000),
|
||||
idleTimeout: z.number().default(10000),
|
||||
})
|
||||
.default({}),
|
||||
questdb: z
|
||||
.object({
|
||||
host: z.string().default('localhost'),
|
||||
ilpPort: z.number().default(9009),
|
||||
httpPort: z.number().default(9000),
|
||||
pgPort: z.number().default(8812),
|
||||
database: z.string().default('questdb'),
|
||||
user: z.string().default('admin'),
|
||||
password: z.string().default('quest'),
|
||||
bufferSize: z.number().default(65536),
|
||||
flushInterval: z.number().default(1000),
|
||||
})
|
||||
.default({}),
|
||||
mongodb: z
|
||||
.object({
|
||||
uri: z.string().url().optional(),
|
||||
host: z.string().default('localhost'),
|
||||
port: z.number().default(27017),
|
||||
database: z.string().default('test_mongo'),
|
||||
user: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
authSource: z.string().default('admin'),
|
||||
replicaSet: z.string().optional(),
|
||||
poolSize: z.number().min(1).max(100).default(10),
|
||||
})
|
||||
.default({}),
|
||||
dragonfly: z
|
||||
.object({
|
||||
host: z.string().default('localhost'),
|
||||
port: z.number().default(6379),
|
||||
password: z.string().optional(),
|
||||
db: z.number().min(0).max(15).default(0),
|
||||
keyPrefix: z.string().optional(),
|
||||
ttl: z.number().optional(),
|
||||
maxRetries: z.number().default(3),
|
||||
retryDelay: z.number().default(100),
|
||||
})
|
||||
.default({}),
|
||||
})
|
||||
.default({});
|
||||
|
||||
// Flexible log schema with defaults (renamed from logging)
|
||||
const flexibleLogConfigSchema = z.object({
|
||||
level: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'),
|
||||
format: z.enum(['json', 'pretty']).default('json'),
|
||||
hideObject: z.boolean().default(false),
|
||||
loki: z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
host: z.string().default('localhost'),
|
||||
port: z.number().default(3100),
|
||||
labels: z.record(z.string()).default({}),
|
||||
}).optional(),
|
||||
}).default({});
|
||||
const flexibleLogConfigSchema = z
|
||||
.object({
|
||||
level: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'),
|
||||
format: z.enum(['json', 'pretty']).default('json'),
|
||||
hideObject: z.boolean().default(false),
|
||||
loki: z
|
||||
.object({
|
||||
enabled: z.boolean().default(false),
|
||||
host: z.string().default('localhost'),
|
||||
port: z.number().default(3100),
|
||||
labels: z.record(z.string()).default({}),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.default({});
|
||||
|
||||
// Complete application configuration schema
|
||||
export const appConfigSchema = baseConfigSchema.extend({
|
||||
|
|
@ -95,4 +113,4 @@ export const appConfigSchema = baseConfigSchema.extend({
|
|||
webshare: webshareProviderConfigSchema.optional(),
|
||||
});
|
||||
|
||||
export type AppConfig = z.infer<typeof appConfigSchema>;
|
||||
export type AppConfig = z.infer<typeof appConfigSchema>;
|
||||
|
|
|
|||
|
|
@ -5,10 +5,12 @@ export const baseProviderConfigSchema = z.object({
|
|||
name: z.string(),
|
||||
enabled: z.boolean().default(true),
|
||||
priority: z.number().default(0),
|
||||
rateLimit: z.object({
|
||||
maxRequests: z.number().default(100),
|
||||
windowMs: z.number().default(60000),
|
||||
}).optional(),
|
||||
rateLimit: z
|
||||
.object({
|
||||
maxRequests: z.number().default(100),
|
||||
windowMs: z.number().default(60000),
|
||||
})
|
||||
.optional(),
|
||||
timeout: z.number().default(30000),
|
||||
retries: z.number().default(3),
|
||||
});
|
||||
|
|
@ -71,4 +73,4 @@ export const providerSchemas = {
|
|||
qm: qmProviderConfigSchema,
|
||||
yahoo: yahooProviderConfigSchema,
|
||||
webshare: webshareProviderConfigSchema,
|
||||
} as const;
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -8,23 +8,27 @@ export const serviceConfigSchema = z.object({
|
|||
healthCheckPath: z.string().default('/health'),
|
||||
metricsPath: z.string().default('/metrics'),
|
||||
shutdownTimeout: z.number().default(30000),
|
||||
cors: z.object({
|
||||
enabled: z.boolean().default(true),
|
||||
origin: z.union([z.string(), z.array(z.string())]).default('*'),
|
||||
credentials: z.boolean().default(true),
|
||||
}).default({}),
|
||||
cors: z
|
||||
.object({
|
||||
enabled: z.boolean().default(true),
|
||||
origin: z.union([z.string(), z.array(z.string())]).default('*'),
|
||||
credentials: z.boolean().default(true),
|
||||
})
|
||||
.default({}),
|
||||
});
|
||||
|
||||
// Logging configuration
|
||||
export const loggingConfigSchema = z.object({
|
||||
level: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'),
|
||||
format: z.enum(['json', 'pretty']).default('json'),
|
||||
loki: z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
host: z.string().default('localhost'),
|
||||
port: z.number().default(3100),
|
||||
labels: z.record(z.string()).default({}),
|
||||
}).optional(),
|
||||
loki: z
|
||||
.object({
|
||||
enabled: z.boolean().default(false),
|
||||
host: z.string().default('localhost'),
|
||||
port: z.number().default(3100),
|
||||
labels: z.record(z.string()).default({}),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// Queue configuration
|
||||
|
|
@ -35,15 +39,19 @@ export const queueConfigSchema = z.object({
|
|||
password: z.string().optional(),
|
||||
db: z.number().default(1),
|
||||
}),
|
||||
defaultJobOptions: z.object({
|
||||
attempts: z.number().default(3),
|
||||
backoff: z.object({
|
||||
type: z.enum(['exponential', 'fixed']).default('exponential'),
|
||||
delay: z.number().default(1000),
|
||||
}).default({}),
|
||||
removeOnComplete: z.number().default(10),
|
||||
removeOnFail: z.number().default(5),
|
||||
}).default({}),
|
||||
defaultJobOptions: z
|
||||
.object({
|
||||
attempts: z.number().default(3),
|
||||
backoff: z
|
||||
.object({
|
||||
type: z.enum(['exponential', 'fixed']).default('exponential'),
|
||||
delay: z.number().default(1000),
|
||||
})
|
||||
.default({}),
|
||||
removeOnComplete: z.number().default(10),
|
||||
removeOnFail: z.number().default(5),
|
||||
})
|
||||
.default({}),
|
||||
});
|
||||
|
||||
// HTTP client configuration
|
||||
|
|
@ -52,12 +60,16 @@ export const httpConfigSchema = z.object({
|
|||
retries: z.number().default(3),
|
||||
retryDelay: z.number().default(1000),
|
||||
userAgent: z.string().optional(),
|
||||
proxy: z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
url: z.string().url().optional(),
|
||||
auth: z.object({
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
}).optional(),
|
||||
}).optional(),
|
||||
});
|
||||
proxy: z
|
||||
.object({
|
||||
enabled: z.boolean().default(false),
|
||||
url: z.string().url().optional(),
|
||||
auth: z
|
||||
.object({
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,183 +1,178 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Secret value wrapper to prevent accidental logging
|
||||
*/
|
||||
export class SecretValue<T = string> {
|
||||
private readonly value: T;
|
||||
private readonly masked: string;
|
||||
|
||||
constructor(value: T, mask: string = '***') {
|
||||
this.value = value;
|
||||
this.masked = mask;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actual secret value
|
||||
* @param reason - Required reason for accessing the secret
|
||||
*/
|
||||
reveal(reason: string): T {
|
||||
if (!reason) {
|
||||
throw new Error('Reason required for revealing secret value');
|
||||
}
|
||||
return this.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get masked representation
|
||||
*/
|
||||
toString(): string {
|
||||
return this.masked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent JSON serialization of actual value
|
||||
*/
|
||||
toJSON(): string {
|
||||
return this.masked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if value matches without revealing it
|
||||
*/
|
||||
equals(other: T): boolean {
|
||||
return this.value === other;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform the secret value
|
||||
*/
|
||||
map<R>(fn: (value: T) => R, reason: string): SecretValue<R> {
|
||||
return new SecretValue(fn(this.reveal(reason)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zod schema for secret values
|
||||
*/
|
||||
export const secretSchema = <T extends z.ZodTypeAny>(_schema: T) => {
|
||||
return z.custom<SecretValue<z.infer<T>>>(
|
||||
(val) => val instanceof SecretValue,
|
||||
{
|
||||
message: 'Expected SecretValue instance',
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform string to SecretValue in Zod schema
|
||||
*/
|
||||
export const secretStringSchema = z
|
||||
.string()
|
||||
.transform((val) => new SecretValue(val));
|
||||
|
||||
/**
|
||||
* Create a secret value
|
||||
*/
|
||||
export function secret<T = string>(value: T, mask?: string): SecretValue<T> {
|
||||
return new SecretValue(value, mask);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value is a secret
|
||||
*/
|
||||
export function isSecret(value: unknown): value is SecretValue {
|
||||
return value instanceof SecretValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact secrets from an object
|
||||
*/
|
||||
export function redactSecrets<T extends Record<string, any>>(
|
||||
obj: T,
|
||||
secretPaths: string[] = []
|
||||
): T {
|
||||
const result = { ...obj };
|
||||
|
||||
// Redact known secret paths
|
||||
for (const path of secretPaths) {
|
||||
const keys = path.split('.');
|
||||
let current: any = result;
|
||||
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const key = keys[i];
|
||||
if (key && current[key] && typeof current[key] === 'object') {
|
||||
current = current[key];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const lastKey = keys[keys.length - 1];
|
||||
if (current && lastKey && lastKey in current) {
|
||||
current[lastKey] = '***REDACTED***';
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively redact SecretValue instances
|
||||
function redactSecretValues(obj: any): any {
|
||||
if (obj === null || obj === undefined) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (isSecret(obj)) {
|
||||
return obj.toString();
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(redactSecretValues);
|
||||
}
|
||||
|
||||
if (typeof obj === 'object') {
|
||||
const result: any = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
result[key] = redactSecretValues(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
return redactSecretValues(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Environment variable names that should be treated as secrets
|
||||
*/
|
||||
export const COMMON_SECRET_PATTERNS = [
|
||||
/password/i,
|
||||
/secret/i,
|
||||
/key/i,
|
||||
/token/i,
|
||||
/credential/i,
|
||||
/private/i,
|
||||
/auth/i,
|
||||
/api[-_]?key/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if an environment variable name indicates a secret
|
||||
*/
|
||||
export function isSecretEnvVar(name: string): boolean {
|
||||
return COMMON_SECRET_PATTERNS.some(pattern => pattern.test(name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap environment variables that look like secrets
|
||||
*/
|
||||
export function wrapSecretEnvVars(
|
||||
env: Record<string, string | undefined>
|
||||
): Record<string, string | SecretValue | undefined> {
|
||||
const result: Record<string, string | SecretValue | undefined> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (value !== undefined && isSecretEnvVar(key)) {
|
||||
result[key] = new SecretValue(value, `***${key}***`);
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Secret value wrapper to prevent accidental logging
|
||||
*/
|
||||
export class SecretValue<T = string> {
|
||||
private readonly value: T;
|
||||
private readonly masked: string;
|
||||
|
||||
constructor(value: T, mask: string = '***') {
|
||||
this.value = value;
|
||||
this.masked = mask;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actual secret value
|
||||
* @param reason - Required reason for accessing the secret
|
||||
*/
|
||||
reveal(reason: string): T {
|
||||
if (!reason) {
|
||||
throw new Error('Reason required for revealing secret value');
|
||||
}
|
||||
return this.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get masked representation
|
||||
*/
|
||||
toString(): string {
|
||||
return this.masked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent JSON serialization of actual value
|
||||
*/
|
||||
toJSON(): string {
|
||||
return this.masked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if value matches without revealing it
|
||||
*/
|
||||
equals(other: T): boolean {
|
||||
return this.value === other;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform the secret value
|
||||
*/
|
||||
map<R>(fn: (value: T) => R, reason: string): SecretValue<R> {
|
||||
return new SecretValue(fn(this.reveal(reason)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zod schema for secret values
|
||||
*/
|
||||
export const secretSchema = <T extends z.ZodTypeAny>(_schema: T) => {
|
||||
return z.custom<SecretValue<z.infer<T>>>(val => val instanceof SecretValue, {
|
||||
message: 'Expected SecretValue instance',
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform string to SecretValue in Zod schema
|
||||
*/
|
||||
export const secretStringSchema = z.string().transform(val => new SecretValue(val));
|
||||
|
||||
/**
|
||||
* Create a secret value
|
||||
*/
|
||||
export function secret<T = string>(value: T, mask?: string): SecretValue<T> {
|
||||
return new SecretValue(value, mask);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value is a secret
|
||||
*/
|
||||
export function isSecret(value: unknown): value is SecretValue {
|
||||
return value instanceof SecretValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact secrets from an object
|
||||
*/
|
||||
export function redactSecrets<T extends Record<string, any>>(
|
||||
obj: T,
|
||||
secretPaths: string[] = []
|
||||
): T {
|
||||
const result = { ...obj };
|
||||
|
||||
// Redact known secret paths
|
||||
for (const path of secretPaths) {
|
||||
const keys = path.split('.');
|
||||
let current: any = result;
|
||||
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const key = keys[i];
|
||||
if (key && current[key] && typeof current[key] === 'object') {
|
||||
current = current[key];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const lastKey = keys[keys.length - 1];
|
||||
if (current && lastKey && lastKey in current) {
|
||||
current[lastKey] = '***REDACTED***';
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively redact SecretValue instances
|
||||
function redactSecretValues(obj: any): any {
|
||||
if (obj === null || obj === undefined) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (isSecret(obj)) {
|
||||
return obj.toString();
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(redactSecretValues);
|
||||
}
|
||||
|
||||
if (typeof obj === 'object') {
|
||||
const result: any = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
result[key] = redactSecretValues(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
return redactSecretValues(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Environment variable names that should be treated as secrets
|
||||
*/
|
||||
export const COMMON_SECRET_PATTERNS = [
|
||||
/password/i,
|
||||
/secret/i,
|
||||
/key/i,
|
||||
/token/i,
|
||||
/credential/i,
|
||||
/private/i,
|
||||
/auth/i,
|
||||
/api[-_]?key/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if an environment variable name indicates a secret
|
||||
*/
|
||||
export function isSecretEnvVar(name: string): boolean {
|
||||
return COMMON_SECRET_PATTERNS.some(pattern => pattern.test(name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap environment variables that look like secrets
|
||||
*/
|
||||
export function wrapSecretEnvVars(
|
||||
env: Record<string, string | undefined>
|
||||
): Record<string, string | SecretValue | undefined> {
|
||||
const result: Record<string, string | SecretValue | undefined> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (value !== undefined && isSecretEnvVar(key)) {
|
||||
result[key] = new SecretValue(value, `***${key}***`);
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,195 +1,188 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors?: Array<{
|
||||
path: string;
|
||||
message: string;
|
||||
expected?: string;
|
||||
received?: string;
|
||||
}>;
|
||||
warnings?: Array<{
|
||||
path: string;
|
||||
message: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate configuration against a schema
|
||||
*/
|
||||
export function validateConfig<T>(
|
||||
config: unknown,
|
||||
schema: z.ZodSchema<T>
|
||||
): ValidationResult {
|
||||
try {
|
||||
schema.parse(config);
|
||||
return { valid: true };
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const errors = error.errors.map(err => ({
|
||||
path: err.path.join('.'),
|
||||
message: err.message,
|
||||
expected: 'expected' in err ? String(err.expected) : undefined,
|
||||
received: 'received' in err ? String(err.received) : undefined,
|
||||
}));
|
||||
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for deprecated configuration options
|
||||
*/
|
||||
export function checkDeprecations(
|
||||
config: Record<string, unknown>,
|
||||
deprecations: Record<string, string>
|
||||
): ValidationResult['warnings'] {
|
||||
const warnings: ValidationResult['warnings'] = [];
|
||||
|
||||
function checkObject(obj: Record<string, unknown>, path: string[] = []): void {
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const currentPath = [...path, key];
|
||||
const pathStr = currentPath.join('.');
|
||||
|
||||
if (pathStr in deprecations) {
|
||||
const deprecationMessage = deprecations[pathStr];
|
||||
if (deprecationMessage) {
|
||||
warnings?.push({
|
||||
path: pathStr,
|
||||
message: deprecationMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
checkObject(value as Record<string, unknown>, currentPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkObject(config);
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for required environment variables
|
||||
*/
|
||||
export function checkRequiredEnvVars(
|
||||
required: string[]
|
||||
): ValidationResult {
|
||||
const errors: ValidationResult['errors'] = [];
|
||||
|
||||
for (const envVar of required) {
|
||||
if (!process.env[envVar]) {
|
||||
errors.push({
|
||||
path: `env.${envVar}`,
|
||||
message: `Required environment variable ${envVar} is not set`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate configuration completeness
|
||||
*/
|
||||
export function validateCompleteness(
|
||||
config: Record<string, any>,
|
||||
required: string[]
|
||||
): ValidationResult {
|
||||
const errors: ValidationResult['errors'] = [];
|
||||
|
||||
for (const path of required) {
|
||||
const keys = path.split('.');
|
||||
let current: any = config;
|
||||
let found = true;
|
||||
|
||||
for (const key of keys) {
|
||||
if (current && typeof current === 'object' && key in current) {
|
||||
current = current[key];
|
||||
} else {
|
||||
found = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found || current === undefined || current === null) {
|
||||
errors.push({
|
||||
path,
|
||||
message: `Required configuration value is missing`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format validation result for display
|
||||
*/
|
||||
export function formatValidationResult(result: ValidationResult): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (result.valid) {
|
||||
lines.push('✅ Configuration is valid');
|
||||
} else {
|
||||
lines.push('❌ Configuration validation failed');
|
||||
}
|
||||
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
lines.push('\nErrors:');
|
||||
for (const error of result.errors) {
|
||||
lines.push(` - ${error.path}: ${error.message}`);
|
||||
if (error.expected && error.received) {
|
||||
lines.push(` Expected: ${error.expected}, Received: ${error.received}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.warnings && result.warnings.length > 0) {
|
||||
lines.push('\nWarnings:');
|
||||
for (const warning of result.warnings) {
|
||||
lines.push(` - ${warning.path}: ${warning.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a strict schema that doesn't allow extra properties
|
||||
*/
|
||||
export function createStrictSchema<T extends z.ZodRawShape>(
|
||||
shape: T
|
||||
): z.ZodObject<T, 'strict'> {
|
||||
return z.object(shape).strict();
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge multiple schemas
|
||||
*/
|
||||
export function mergeSchemas<T extends z.ZodSchema[]>(
|
||||
...schemas: T
|
||||
): z.ZodIntersection<T[0], T[1]> {
|
||||
if (schemas.length < 2) {
|
||||
throw new Error('At least two schemas required for merge');
|
||||
}
|
||||
|
||||
let result = schemas[0]!.and(schemas[1]!);
|
||||
|
||||
for (let i = 2; i < schemas.length; i++) {
|
||||
result = result.and(schemas[i]!) as any;
|
||||
}
|
||||
|
||||
return result as any;
|
||||
}
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors?: Array<{
|
||||
path: string;
|
||||
message: string;
|
||||
expected?: string;
|
||||
received?: string;
|
||||
}>;
|
||||
warnings?: Array<{
|
||||
path: string;
|
||||
message: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate configuration against a schema
|
||||
*/
|
||||
export function validateConfig<T>(config: unknown, schema: z.ZodSchema<T>): ValidationResult {
|
||||
try {
|
||||
schema.parse(config);
|
||||
return { valid: true };
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const errors = error.errors.map(err => ({
|
||||
path: err.path.join('.'),
|
||||
message: err.message,
|
||||
expected: 'expected' in err ? String(err.expected) : undefined,
|
||||
received: 'received' in err ? String(err.received) : undefined,
|
||||
}));
|
||||
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for deprecated configuration options
|
||||
*/
|
||||
export function checkDeprecations(
|
||||
config: Record<string, unknown>,
|
||||
deprecations: Record<string, string>
|
||||
): ValidationResult['warnings'] {
|
||||
const warnings: ValidationResult['warnings'] = [];
|
||||
|
||||
function checkObject(obj: Record<string, unknown>, path: string[] = []): void {
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const currentPath = [...path, key];
|
||||
const pathStr = currentPath.join('.');
|
||||
|
||||
if (pathStr in deprecations) {
|
||||
const deprecationMessage = deprecations[pathStr];
|
||||
if (deprecationMessage) {
|
||||
warnings?.push({
|
||||
path: pathStr,
|
||||
message: deprecationMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
checkObject(value as Record<string, unknown>, currentPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkObject(config);
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for required environment variables
|
||||
*/
|
||||
export function checkRequiredEnvVars(required: string[]): ValidationResult {
|
||||
const errors: ValidationResult['errors'] = [];
|
||||
|
||||
for (const envVar of required) {
|
||||
if (!process.env[envVar]) {
|
||||
errors.push({
|
||||
path: `env.${envVar}`,
|
||||
message: `Required environment variable ${envVar} is not set`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate configuration completeness
|
||||
*/
|
||||
export function validateCompleteness(
|
||||
config: Record<string, any>,
|
||||
required: string[]
|
||||
): ValidationResult {
|
||||
const errors: ValidationResult['errors'] = [];
|
||||
|
||||
for (const path of required) {
|
||||
const keys = path.split('.');
|
||||
let current: any = config;
|
||||
let found = true;
|
||||
|
||||
for (const key of keys) {
|
||||
if (current && typeof current === 'object' && key in current) {
|
||||
current = current[key];
|
||||
} else {
|
||||
found = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found || current === undefined || current === null) {
|
||||
errors.push({
|
||||
path,
|
||||
message: `Required configuration value is missing`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format validation result for display
|
||||
*/
|
||||
export function formatValidationResult(result: ValidationResult): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (result.valid) {
|
||||
lines.push('✅ Configuration is valid');
|
||||
} else {
|
||||
lines.push('❌ Configuration validation failed');
|
||||
}
|
||||
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
lines.push('\nErrors:');
|
||||
for (const error of result.errors) {
|
||||
lines.push(` - ${error.path}: ${error.message}`);
|
||||
if (error.expected && error.received) {
|
||||
lines.push(` Expected: ${error.expected}, Received: ${error.received}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.warnings && result.warnings.length > 0) {
|
||||
lines.push('\nWarnings:');
|
||||
for (const warning of result.warnings) {
|
||||
lines.push(` - ${warning.path}: ${warning.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a strict schema that doesn't allow extra properties
|
||||
*/
|
||||
export function createStrictSchema<T extends z.ZodRawShape>(shape: T): z.ZodObject<T, 'strict'> {
|
||||
return z.object(shape).strict();
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge multiple schemas
|
||||
*/
|
||||
export function mergeSchemas<T extends z.ZodSchema[]>(
|
||||
...schemas: T
|
||||
): z.ZodIntersection<T[0], T[1]> {
|
||||
if (schemas.length < 2) {
|
||||
throw new Error('At least two schemas required for merge');
|
||||
}
|
||||
|
||||
let result = schemas[0]!.and(schemas[1]!);
|
||||
|
||||
for (let i = 2; i < schemas.length; i++) {
|
||||
result = result.and(schemas[i]!) as any;
|
||||
}
|
||||
|
||||
return result as any;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,215 +1,221 @@
|
|||
import { describe, test, expect, beforeEach } from 'bun:test';
|
||||
import { z } from 'zod';
|
||||
import { ConfigManager } from '../src/config-manager';
|
||||
import { ConfigLoader } from '../src/types';
|
||||
import { ConfigValidationError } from '../src/errors';
|
||||
|
||||
// Mock loader for testing
|
||||
class MockLoader implements ConfigLoader {
|
||||
priority = 0;
|
||||
|
||||
constructor(
|
||||
private data: Record<string, unknown>,
|
||||
public override priority: number = 0
|
||||
) {}
|
||||
|
||||
async load(): Promise<Record<string, unknown>> {
|
||||
return this.data;
|
||||
}
|
||||
}
|
||||
|
||||
// Test schema
|
||||
const testSchema = z.object({
|
||||
app: z.object({
|
||||
name: z.string(),
|
||||
version: z.string(),
|
||||
port: z.number().positive(),
|
||||
}),
|
||||
database: z.object({
|
||||
host: z.string(),
|
||||
port: z.number(),
|
||||
}),
|
||||
environment: z.enum(['development', 'test', 'production']),
|
||||
});
|
||||
|
||||
type TestConfig = z.infer<typeof testSchema>;
|
||||
|
||||
describe('ConfigManager', () => {
|
||||
let manager: ConfigManager<TestConfig>;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new ConfigManager<TestConfig>({
|
||||
loaders: [
|
||||
new MockLoader({
|
||||
app: {
|
||||
name: 'test-app',
|
||||
version: '1.0.0',
|
||||
port: 3000,
|
||||
},
|
||||
database: {
|
||||
host: 'localhost',
|
||||
port: 5432,
|
||||
},
|
||||
}),
|
||||
],
|
||||
environment: 'test',
|
||||
});
|
||||
});
|
||||
|
||||
test('should initialize configuration', async () => {
|
||||
const config = await manager.initialize(testSchema);
|
||||
|
||||
expect(config.app.name).toBe('test-app');
|
||||
expect(config.app.version).toBe('1.0.0');
|
||||
expect(config.environment).toBe('test');
|
||||
});
|
||||
|
||||
test('should merge multiple loaders by priority', async () => {
|
||||
manager = new ConfigManager<TestConfig>({
|
||||
loaders: [
|
||||
new MockLoader({ app: { name: 'base', port: 3000 } }, 0),
|
||||
new MockLoader({ app: { name: 'override', version: '2.0.0' } }, 10),
|
||||
new MockLoader({ database: { host: 'prod-db' } }, 5),
|
||||
],
|
||||
environment: 'test',
|
||||
});
|
||||
|
||||
const config = await manager.initialize();
|
||||
|
||||
expect(config.app.name).toBe('override');
|
||||
expect(config.app.version).toBe('2.0.0');
|
||||
expect(config.app.port).toBe(3000);
|
||||
expect(config.database.host).toBe('prod-db');
|
||||
});
|
||||
|
||||
test('should validate configuration with schema', async () => {
|
||||
manager = new ConfigManager<TestConfig>({
|
||||
loaders: [
|
||||
new MockLoader({
|
||||
app: {
|
||||
name: 'test-app',
|
||||
version: '1.0.0',
|
||||
port: 'invalid', // Should be number
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await expect(manager.initialize(testSchema)).rejects.toThrow(ConfigValidationError);
|
||||
});
|
||||
|
||||
test('should get configuration value by path', async () => {
|
||||
await manager.initialize(testSchema);
|
||||
|
||||
expect(manager.getValue('app.name')).toBe('test-app');
|
||||
expect(manager.getValue<number>('database.port')).toBe(5432);
|
||||
});
|
||||
|
||||
test('should check if configuration path exists', async () => {
|
||||
await manager.initialize(testSchema);
|
||||
|
||||
expect(manager.has('app.name')).toBe(true);
|
||||
expect(manager.has('app.nonexistent')).toBe(false);
|
||||
});
|
||||
|
||||
test('should update configuration at runtime', async () => {
|
||||
await manager.initialize(testSchema);
|
||||
|
||||
manager.set({
|
||||
app: {
|
||||
name: 'updated-app',
|
||||
},
|
||||
});
|
||||
|
||||
const config = manager.get();
|
||||
expect(config.app.name).toBe('updated-app');
|
||||
expect(config.app.version).toBe('1.0.0'); // Should preserve other values
|
||||
});
|
||||
|
||||
test('should validate updates against schema', async () => {
|
||||
await manager.initialize(testSchema);
|
||||
|
||||
expect(() => {
|
||||
manager.set({
|
||||
app: {
|
||||
port: 'invalid' as any,
|
||||
},
|
||||
});
|
||||
}).toThrow(ConfigValidationError);
|
||||
});
|
||||
|
||||
test('should reset configuration', async () => {
|
||||
await manager.initialize(testSchema);
|
||||
manager.reset();
|
||||
|
||||
expect(() => manager.get()).toThrow('Configuration not initialized');
|
||||
});
|
||||
|
||||
test('should create typed getter', async () => {
|
||||
await manager.initialize(testSchema);
|
||||
|
||||
const appSchema = z.object({
|
||||
app: z.object({
|
||||
name: z.string(),
|
||||
version: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const getAppConfig = manager.createTypedGetter(appSchema);
|
||||
const appConfig = getAppConfig();
|
||||
|
||||
expect(appConfig.app.name).toBe('test-app');
|
||||
});
|
||||
|
||||
test('should detect environment correctly', () => {
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
|
||||
process.env.NODE_ENV = 'production';
|
||||
const prodManager = new ConfigManager({ loaders: [] });
|
||||
expect(prodManager.getEnvironment()).toBe('production');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
const testManager = new ConfigManager({ loaders: [] });
|
||||
expect(testManager.getEnvironment()).toBe('test');
|
||||
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
});
|
||||
|
||||
test('should handle deep merge correctly', async () => {
|
||||
manager = new ConfigManager({
|
||||
loaders: [
|
||||
new MockLoader({
|
||||
app: {
|
||||
settings: {
|
||||
feature1: true,
|
||||
feature2: false,
|
||||
nested: {
|
||||
value: 'base',
|
||||
},
|
||||
},
|
||||
},
|
||||
}, 0),
|
||||
new MockLoader({
|
||||
app: {
|
||||
settings: {
|
||||
feature2: true,
|
||||
feature3: true,
|
||||
nested: {
|
||||
value: 'override',
|
||||
extra: 'new',
|
||||
},
|
||||
},
|
||||
},
|
||||
}, 10),
|
||||
],
|
||||
});
|
||||
|
||||
const config = await manager.initialize();
|
||||
|
||||
expect(config.app.settings.feature1).toBe(true);
|
||||
expect(config.app.settings.feature2).toBe(true);
|
||||
expect(config.app.settings.feature3).toBe(true);
|
||||
expect(config.app.settings.nested.value).toBe('override');
|
||||
expect(config.app.settings.nested.extra).toBe('new');
|
||||
});
|
||||
});
|
||||
import { beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { z } from 'zod';
|
||||
import { ConfigManager } from '../src/config-manager';
|
||||
import { ConfigValidationError } from '../src/errors';
|
||||
import { ConfigLoader } from '../src/types';
|
||||
|
||||
// Mock loader for testing
|
||||
class MockLoader implements ConfigLoader {
|
||||
priority = 0;
|
||||
|
||||
constructor(
|
||||
private data: Record<string, unknown>,
|
||||
public override priority: number = 0
|
||||
) {}
|
||||
|
||||
async load(): Promise<Record<string, unknown>> {
|
||||
return this.data;
|
||||
}
|
||||
}
|
||||
|
||||
// Test schema
|
||||
const testSchema = z.object({
|
||||
app: z.object({
|
||||
name: z.string(),
|
||||
version: z.string(),
|
||||
port: z.number().positive(),
|
||||
}),
|
||||
database: z.object({
|
||||
host: z.string(),
|
||||
port: z.number(),
|
||||
}),
|
||||
environment: z.enum(['development', 'test', 'production']),
|
||||
});
|
||||
|
||||
type TestConfig = z.infer<typeof testSchema>;
|
||||
|
||||
describe('ConfigManager', () => {
|
||||
let manager: ConfigManager<TestConfig>;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new ConfigManager<TestConfig>({
|
||||
loaders: [
|
||||
new MockLoader({
|
||||
app: {
|
||||
name: 'test-app',
|
||||
version: '1.0.0',
|
||||
port: 3000,
|
||||
},
|
||||
database: {
|
||||
host: 'localhost',
|
||||
port: 5432,
|
||||
},
|
||||
}),
|
||||
],
|
||||
environment: 'test',
|
||||
});
|
||||
});
|
||||
|
||||
test('should initialize configuration', async () => {
|
||||
const config = await manager.initialize(testSchema);
|
||||
|
||||
expect(config.app.name).toBe('test-app');
|
||||
expect(config.app.version).toBe('1.0.0');
|
||||
expect(config.environment).toBe('test');
|
||||
});
|
||||
|
||||
test('should merge multiple loaders by priority', async () => {
|
||||
manager = new ConfigManager<TestConfig>({
|
||||
loaders: [
|
||||
new MockLoader({ app: { name: 'base', port: 3000 } }, 0),
|
||||
new MockLoader({ app: { name: 'override', version: '2.0.0' } }, 10),
|
||||
new MockLoader({ database: { host: 'prod-db' } }, 5),
|
||||
],
|
||||
environment: 'test',
|
||||
});
|
||||
|
||||
const config = await manager.initialize();
|
||||
|
||||
expect(config.app.name).toBe('override');
|
||||
expect(config.app.version).toBe('2.0.0');
|
||||
expect(config.app.port).toBe(3000);
|
||||
expect(config.database.host).toBe('prod-db');
|
||||
});
|
||||
|
||||
test('should validate configuration with schema', async () => {
|
||||
manager = new ConfigManager<TestConfig>({
|
||||
loaders: [
|
||||
new MockLoader({
|
||||
app: {
|
||||
name: 'test-app',
|
||||
version: '1.0.0',
|
||||
port: 'invalid', // Should be number
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await expect(manager.initialize(testSchema)).rejects.toThrow(ConfigValidationError);
|
||||
});
|
||||
|
||||
test('should get configuration value by path', async () => {
|
||||
await manager.initialize(testSchema);
|
||||
|
||||
expect(manager.getValue('app.name')).toBe('test-app');
|
||||
expect(manager.getValue<number>('database.port')).toBe(5432);
|
||||
});
|
||||
|
||||
test('should check if configuration path exists', async () => {
|
||||
await manager.initialize(testSchema);
|
||||
|
||||
expect(manager.has('app.name')).toBe(true);
|
||||
expect(manager.has('app.nonexistent')).toBe(false);
|
||||
});
|
||||
|
||||
test('should update configuration at runtime', async () => {
|
||||
await manager.initialize(testSchema);
|
||||
|
||||
manager.set({
|
||||
app: {
|
||||
name: 'updated-app',
|
||||
},
|
||||
});
|
||||
|
||||
const config = manager.get();
|
||||
expect(config.app.name).toBe('updated-app');
|
||||
expect(config.app.version).toBe('1.0.0'); // Should preserve other values
|
||||
});
|
||||
|
||||
test('should validate updates against schema', async () => {
|
||||
await manager.initialize(testSchema);
|
||||
|
||||
expect(() => {
|
||||
manager.set({
|
||||
app: {
|
||||
port: 'invalid' as any,
|
||||
},
|
||||
});
|
||||
}).toThrow(ConfigValidationError);
|
||||
});
|
||||
|
||||
test('should reset configuration', async () => {
|
||||
await manager.initialize(testSchema);
|
||||
manager.reset();
|
||||
|
||||
expect(() => manager.get()).toThrow('Configuration not initialized');
|
||||
});
|
||||
|
||||
test('should create typed getter', async () => {
|
||||
await manager.initialize(testSchema);
|
||||
|
||||
const appSchema = z.object({
|
||||
app: z.object({
|
||||
name: z.string(),
|
||||
version: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const getAppConfig = manager.createTypedGetter(appSchema);
|
||||
const appConfig = getAppConfig();
|
||||
|
||||
expect(appConfig.app.name).toBe('test-app');
|
||||
});
|
||||
|
||||
test('should detect environment correctly', () => {
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
|
||||
process.env.NODE_ENV = 'production';
|
||||
const prodManager = new ConfigManager({ loaders: [] });
|
||||
expect(prodManager.getEnvironment()).toBe('production');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
const testManager = new ConfigManager({ loaders: [] });
|
||||
expect(testManager.getEnvironment()).toBe('test');
|
||||
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
});
|
||||
|
||||
test('should handle deep merge correctly', async () => {
|
||||
manager = new ConfigManager({
|
||||
loaders: [
|
||||
new MockLoader(
|
||||
{
|
||||
app: {
|
||||
settings: {
|
||||
feature1: true,
|
||||
feature2: false,
|
||||
nested: {
|
||||
value: 'base',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
0
|
||||
),
|
||||
new MockLoader(
|
||||
{
|
||||
app: {
|
||||
settings: {
|
||||
feature2: true,
|
||||
feature3: true,
|
||||
nested: {
|
||||
value: 'override',
|
||||
extra: 'new',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
10
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const config = await manager.initialize();
|
||||
|
||||
expect(config.app.settings.feature1).toBe(true);
|
||||
expect(config.app.settings.feature2).toBe(true);
|
||||
expect(config.app.settings.feature3).toBe(true);
|
||||
expect(config.app.settings.nested.value).toBe('override');
|
||||
expect(config.app.settings.nested.extra).toBe('new');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs';
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { ConfigManager } from '../src/config-manager';
|
||||
import { FileLoader } from '../src/loaders/file.loader';
|
||||
import { EnvLoader } from '../src/loaders/env.loader';
|
||||
import { initializeConfig, initializeServiceConfig, resetConfig } from '../src/index';
|
||||
import { EnvLoader } from '../src/loaders/env.loader';
|
||||
import { FileLoader } from '../src/loaders/file.loader';
|
||||
import { appConfigSchema } from '../src/schemas';
|
||||
|
||||
// Test directories setup
|
||||
|
|
@ -23,33 +23,33 @@ describe('Dynamic Location Config Loading', () => {
|
|||
if (existsSync(TEST_ROOT)) {
|
||||
rmSync(TEST_ROOT, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
|
||||
// Reset config singleton
|
||||
resetConfig();
|
||||
|
||||
|
||||
// Create test directory structure
|
||||
setupTestScenarios();
|
||||
});
|
||||
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up test directories
|
||||
if (existsSync(TEST_ROOT)) {
|
||||
rmSync(TEST_ROOT, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
|
||||
// Reset config singleton
|
||||
resetConfig();
|
||||
});
|
||||
|
||||
test('should load config from monorepo root', async () => {
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
|
||||
try {
|
||||
// Change to monorepo root
|
||||
process.chdir(SCENARIOS.monorepoRoot);
|
||||
|
||||
|
||||
const config = await initializeConfig();
|
||||
|
||||
|
||||
expect(config.name).toBe('monorepo-root');
|
||||
expect(config.version).toBe('1.0.0');
|
||||
expect(config.database.postgres.host).toBe('localhost');
|
||||
|
|
@ -60,13 +60,13 @@ describe('Dynamic Location Config Loading', () => {
|
|||
|
||||
test('should load config from app service directory', async () => {
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
|
||||
try {
|
||||
// Change to app service directory
|
||||
process.chdir(SCENARIOS.appService);
|
||||
|
||||
|
||||
const config = await initializeServiceConfig();
|
||||
|
||||
|
||||
// Should inherit from root + override with service config
|
||||
expect(config.name).toBe('test-service'); // Overridden by service
|
||||
expect(config.version).toBe('1.0.0'); // From root
|
||||
|
|
@ -79,13 +79,13 @@ describe('Dynamic Location Config Loading', () => {
|
|||
|
||||
test('should load config from lib directory', async () => {
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
|
||||
try {
|
||||
// Change to lib directory
|
||||
process.chdir(SCENARIOS.libService);
|
||||
|
||||
|
||||
const config = await initializeServiceConfig();
|
||||
|
||||
|
||||
// Should inherit from root + override with lib config
|
||||
expect(config.name).toBe('test-lib'); // Overridden by lib
|
||||
expect(config.version).toBe('2.0.0'); // Overridden by lib
|
||||
|
|
@ -98,13 +98,13 @@ describe('Dynamic Location Config Loading', () => {
|
|||
|
||||
test('should load config from deeply nested service', async () => {
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
|
||||
try {
|
||||
// Change to nested service directory
|
||||
process.chdir(SCENARIOS.nestedService);
|
||||
|
||||
|
||||
const config = await initializeServiceConfig();
|
||||
|
||||
|
||||
// Should inherit from root + override with nested service config
|
||||
expect(config.name).toBe('deep-service'); // Overridden by nested service
|
||||
// NOTE: Version inheritance doesn't work for deeply nested services (3+ levels)
|
||||
|
|
@ -119,13 +119,13 @@ describe('Dynamic Location Config Loading', () => {
|
|||
|
||||
test('should load config from standalone project', async () => {
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
|
||||
try {
|
||||
// Change to standalone directory
|
||||
process.chdir(SCENARIOS.standalone);
|
||||
|
||||
|
||||
const config = await initializeConfig();
|
||||
|
||||
|
||||
expect(config.name).toBe('standalone-app');
|
||||
expect(config.version).toBe('0.1.0');
|
||||
expect(config.database.postgres.host).toBe('standalone-db');
|
||||
|
|
@ -136,16 +136,16 @@ describe('Dynamic Location Config Loading', () => {
|
|||
|
||||
test('should handle missing config files gracefully', async () => {
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
|
||||
try {
|
||||
// Change to directory with no config files
|
||||
const emptyDir = join(TEST_ROOT, 'empty');
|
||||
mkdirSync(emptyDir, { recursive: true });
|
||||
process.chdir(emptyDir);
|
||||
|
||||
|
||||
// Should not throw but use defaults and env vars
|
||||
const config = await initializeConfig();
|
||||
|
||||
|
||||
// Should have default values from schema
|
||||
expect(config.environment).toBe('test'); // Tests run with NODE_ENV=test
|
||||
expect(typeof config.service).toBe('object');
|
||||
|
|
@ -157,18 +157,18 @@ describe('Dynamic Location Config Loading', () => {
|
|||
test('should prioritize environment variables over file configs', async () => {
|
||||
const originalCwd = process.cwd();
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
|
||||
try {
|
||||
// Set environment variables
|
||||
process.env.NAME = 'env-override';
|
||||
process.env.VERSION = '3.0.0';
|
||||
process.env.DATABASE_POSTGRES_HOST = 'env-db';
|
||||
|
||||
|
||||
process.chdir(SCENARIOS.appService);
|
||||
|
||||
|
||||
resetConfig(); // Reset to test env override
|
||||
const config = await initializeServiceConfig();
|
||||
|
||||
|
||||
// Environment variables should override file configs
|
||||
expect(config.name).toBe('env-override');
|
||||
expect(config.version).toBe('3.0.0');
|
||||
|
|
@ -181,18 +181,18 @@ describe('Dynamic Location Config Loading', () => {
|
|||
|
||||
test('should work with custom config paths', async () => {
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
|
||||
try {
|
||||
process.chdir(SCENARIOS.monorepoRoot);
|
||||
|
||||
|
||||
// Initialize with custom config path
|
||||
resetConfig();
|
||||
const manager = new ConfigManager({
|
||||
configPath: join(SCENARIOS.appService, 'config')
|
||||
configPath: join(SCENARIOS.appService, 'config'),
|
||||
});
|
||||
|
||||
|
||||
const config = await manager.initialize(appConfigSchema);
|
||||
|
||||
|
||||
// Should load from the custom path
|
||||
expect(config.name).toBe('test-service');
|
||||
expect(config.service.port).toBe(4000);
|
||||
|
|
@ -217,7 +217,7 @@ function setupTestScenarios() {
|
|||
version: '1.0.0',
|
||||
service: {
|
||||
name: 'monorepo-root',
|
||||
port: 3000
|
||||
port: 3000,
|
||||
},
|
||||
database: {
|
||||
postgres: {
|
||||
|
|
@ -225,32 +225,32 @@ function setupTestScenarios() {
|
|||
port: 5432,
|
||||
database: 'test_db',
|
||||
user: 'test_user',
|
||||
password: 'test_pass'
|
||||
password: 'test_pass',
|
||||
},
|
||||
questdb: {
|
||||
host: 'localhost',
|
||||
ilpPort: 9009
|
||||
ilpPort: 9009,
|
||||
},
|
||||
mongodb: {
|
||||
host: 'localhost',
|
||||
port: 27017,
|
||||
database: 'test_mongo'
|
||||
database: 'test_mongo',
|
||||
},
|
||||
dragonfly: {
|
||||
host: 'localhost',
|
||||
port: 6379
|
||||
}
|
||||
port: 6379,
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
level: 'info'
|
||||
}
|
||||
level: 'info',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
writeFileSync(
|
||||
join(SCENARIOS.monorepoRoot, 'config', 'development.json'),
|
||||
JSON.stringify(rootConfig, null, 2)
|
||||
);
|
||||
|
||||
|
||||
writeFileSync(
|
||||
join(SCENARIOS.monorepoRoot, 'config', 'test.json'),
|
||||
JSON.stringify(rootConfig, null, 2)
|
||||
|
|
@ -261,20 +261,20 @@ function setupTestScenarios() {
|
|||
name: 'test-service',
|
||||
database: {
|
||||
postgres: {
|
||||
host: 'service-db'
|
||||
}
|
||||
host: 'service-db',
|
||||
},
|
||||
},
|
||||
service: {
|
||||
name: 'test-service',
|
||||
port: 4000
|
||||
}
|
||||
port: 4000,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
writeFileSync(
|
||||
join(SCENARIOS.appService, 'config', 'development.json'),
|
||||
JSON.stringify(appServiceConfig, null, 2)
|
||||
);
|
||||
|
||||
|
||||
writeFileSync(
|
||||
join(SCENARIOS.appService, 'config', 'test.json'),
|
||||
JSON.stringify(appServiceConfig, null, 2)
|
||||
|
|
@ -286,15 +286,15 @@ function setupTestScenarios() {
|
|||
version: '2.0.0',
|
||||
service: {
|
||||
name: 'test-lib',
|
||||
port: 5000
|
||||
}
|
||||
port: 5000,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
writeFileSync(
|
||||
join(SCENARIOS.libService, 'config', 'development.json'),
|
||||
JSON.stringify(libServiceConfig, null, 2)
|
||||
);
|
||||
|
||||
|
||||
writeFileSync(
|
||||
join(SCENARIOS.libService, 'config', 'test.json'),
|
||||
JSON.stringify(libServiceConfig, null, 2)
|
||||
|
|
@ -305,20 +305,20 @@ function setupTestScenarios() {
|
|||
name: 'deep-service',
|
||||
database: {
|
||||
postgres: {
|
||||
host: 'deep-db'
|
||||
}
|
||||
host: 'deep-db',
|
||||
},
|
||||
},
|
||||
service: {
|
||||
name: 'deep-service',
|
||||
port: 6000
|
||||
}
|
||||
port: 6000,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
writeFileSync(
|
||||
join(SCENARIOS.nestedService, 'config', 'development.json'),
|
||||
JSON.stringify(nestedServiceConfig, null, 2)
|
||||
);
|
||||
|
||||
|
||||
writeFileSync(
|
||||
join(SCENARIOS.nestedService, 'config', 'test.json'),
|
||||
JSON.stringify(nestedServiceConfig, null, 2)
|
||||
|
|
@ -330,7 +330,7 @@ function setupTestScenarios() {
|
|||
version: '0.1.0',
|
||||
service: {
|
||||
name: 'standalone-app',
|
||||
port: 7000
|
||||
port: 7000,
|
||||
},
|
||||
database: {
|
||||
postgres: {
|
||||
|
|
@ -338,32 +338,32 @@ function setupTestScenarios() {
|
|||
port: 5432,
|
||||
database: 'standalone_db',
|
||||
user: 'standalone_user',
|
||||
password: 'standalone_pass'
|
||||
password: 'standalone_pass',
|
||||
},
|
||||
questdb: {
|
||||
host: 'localhost',
|
||||
ilpPort: 9009
|
||||
ilpPort: 9009,
|
||||
},
|
||||
mongodb: {
|
||||
host: 'localhost',
|
||||
port: 27017,
|
||||
database: 'standalone_mongo'
|
||||
database: 'standalone_mongo',
|
||||
},
|
||||
dragonfly: {
|
||||
host: 'localhost',
|
||||
port: 6379
|
||||
}
|
||||
port: 6379,
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
level: 'debug'
|
||||
}
|
||||
level: 'debug',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
writeFileSync(
|
||||
join(SCENARIOS.standalone, 'config', 'development.json'),
|
||||
JSON.stringify(standaloneConfig, null, 2)
|
||||
);
|
||||
|
||||
|
||||
writeFileSync(
|
||||
join(SCENARIOS.standalone, 'config', 'test.json'),
|
||||
JSON.stringify(standaloneConfig, null, 2)
|
||||
|
|
@ -383,4 +383,4 @@ DEBUG=true
|
|||
APP_EXTRA_FEATURE=enabled
|
||||
`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { mkdirSync, writeFileSync, rmSync, existsSync, chmodSync } from 'fs';
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { ConfigManager } from '../src/config-manager';
|
||||
import { FileLoader } from '../src/loaders/file.loader';
|
||||
import { EnvLoader } from '../src/loaders/env.loader';
|
||||
import { initializeConfig, initializeServiceConfig, resetConfig } from '../src/index';
|
||||
import { appConfigSchema } from '../src/schemas';
|
||||
import { ConfigError, ConfigValidationError } from '../src/errors';
|
||||
import { initializeConfig, initializeServiceConfig, resetConfig } from '../src/index';
|
||||
import { EnvLoader } from '../src/loaders/env.loader';
|
||||
import { FileLoader } from '../src/loaders/file.loader';
|
||||
import { appConfigSchema } from '../src/schemas';
|
||||
|
||||
const TEST_DIR = join(__dirname, 'edge-case-tests');
|
||||
|
||||
|
|
@ -17,9 +17,9 @@ describe('Edge Cases and Error Handling', () => {
|
|||
beforeEach(() => {
|
||||
originalEnv = { ...process.env };
|
||||
originalCwd = process.cwd();
|
||||
|
||||
|
||||
resetConfig();
|
||||
|
||||
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -30,7 +30,7 @@ describe('Edge Cases and Error Handling', () => {
|
|||
process.env = originalEnv;
|
||||
process.chdir(originalCwd);
|
||||
resetConfig();
|
||||
|
||||
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -39,7 +39,7 @@ describe('Edge Cases and Error Handling', () => {
|
|||
test('should handle missing .env files gracefully', async () => {
|
||||
// No .env file exists
|
||||
const manager = new ConfigManager({
|
||||
loaders: [new EnvLoader('')]
|
||||
loaders: [new EnvLoader('')],
|
||||
});
|
||||
|
||||
// Should not throw even without .env file
|
||||
|
|
@ -50,15 +50,12 @@ describe('Edge Cases and Error Handling', () => {
|
|||
test('should handle corrupted JSON config files', async () => {
|
||||
const configDir = join(TEST_DIR, 'config');
|
||||
mkdirSync(configDir, { recursive: true });
|
||||
|
||||
|
||||
// Create corrupted JSON file
|
||||
writeFileSync(
|
||||
join(configDir, 'development.json'),
|
||||
'{ "app": { "name": "test", invalid json }'
|
||||
);
|
||||
writeFileSync(join(configDir, 'development.json'), '{ "app": { "name": "test", invalid json }');
|
||||
|
||||
const manager = new ConfigManager({
|
||||
loaders: [new FileLoader(configDir, 'development')]
|
||||
loaders: [new FileLoader(configDir, 'development')],
|
||||
});
|
||||
|
||||
// Should throw error for invalid JSON
|
||||
|
|
@ -67,9 +64,9 @@ describe('Edge Cases and Error Handling', () => {
|
|||
|
||||
test('should handle missing config directories', async () => {
|
||||
const nonExistentDir = join(TEST_DIR, 'nonexistent');
|
||||
|
||||
|
||||
const manager = new ConfigManager({
|
||||
loaders: [new FileLoader(nonExistentDir, 'development')]
|
||||
loaders: [new FileLoader(nonExistentDir, 'development')],
|
||||
});
|
||||
|
||||
// Should not throw, should return empty config
|
||||
|
|
@ -80,16 +77,16 @@ describe('Edge Cases and Error Handling', () => {
|
|||
test('should handle permission denied on config files', async () => {
|
||||
const configDir = join(TEST_DIR, 'config');
|
||||
mkdirSync(configDir, { recursive: true });
|
||||
|
||||
|
||||
const configFile = join(configDir, 'development.json');
|
||||
writeFileSync(configFile, JSON.stringify({ app: { name: 'test' } }));
|
||||
|
||||
|
||||
// Make file unreadable (this might not work on all systems)
|
||||
try {
|
||||
chmodSync(configFile, 0o000);
|
||||
|
||||
|
||||
const manager = new ConfigManager({
|
||||
loaders: [new FileLoader(configDir, 'development')]
|
||||
loaders: [new FileLoader(configDir, 'development')],
|
||||
});
|
||||
|
||||
// Should handle permission error gracefully
|
||||
|
|
@ -109,26 +106,23 @@ describe('Edge Cases and Error Handling', () => {
|
|||
// This tests deep merge with potential circular references
|
||||
const configDir = join(TEST_DIR, 'config');
|
||||
mkdirSync(configDir, { recursive: true });
|
||||
|
||||
|
||||
writeFileSync(
|
||||
join(configDir, 'development.json'),
|
||||
JSON.stringify({
|
||||
app: {
|
||||
name: 'test',
|
||||
settings: {
|
||||
ref: 'settings'
|
||||
}
|
||||
}
|
||||
ref: 'settings',
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
process.env.APP_SETTINGS_NESTED_VALUE = 'deep-value';
|
||||
|
||||
const manager = new ConfigManager({
|
||||
loaders: [
|
||||
new FileLoader(configDir, 'development'),
|
||||
new EnvLoader('')
|
||||
]
|
||||
loaders: [new FileLoader(configDir, 'development'), new EnvLoader('')],
|
||||
});
|
||||
|
||||
const config = await manager.initialize(appConfigSchema);
|
||||
|
|
@ -138,13 +132,13 @@ describe('Edge Cases and Error Handling', () => {
|
|||
test('should handle extremely deep nesting in environment variables', async () => {
|
||||
// Test very deep nesting
|
||||
process.env.LEVEL1_LEVEL2_LEVEL3_LEVEL4_LEVEL5_VALUE = 'deep-value';
|
||||
|
||||
|
||||
const manager = new ConfigManager({
|
||||
loaders: [new EnvLoader('', { nestedDelimiter: '_' })]
|
||||
loaders: [new EnvLoader('', { nestedDelimiter: '_' })],
|
||||
});
|
||||
|
||||
const config = await manager.initialize();
|
||||
|
||||
|
||||
// Should create nested structure
|
||||
expect((config as any).level1?.level2?.level3?.level4?.level5?.value).toBe('deep-value');
|
||||
});
|
||||
|
|
@ -152,15 +146,15 @@ describe('Edge Cases and Error Handling', () => {
|
|||
test('should handle conflicting data types in config merging', async () => {
|
||||
const configDir = join(TEST_DIR, 'config');
|
||||
mkdirSync(configDir, { recursive: true });
|
||||
|
||||
|
||||
// File config has object
|
||||
writeFileSync(
|
||||
join(configDir, 'development.json'),
|
||||
JSON.stringify({
|
||||
database: {
|
||||
host: 'localhost',
|
||||
port: 5432
|
||||
}
|
||||
port: 5432,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
|
|
@ -168,14 +162,11 @@ describe('Edge Cases and Error Handling', () => {
|
|||
process.env.DATABASE = 'simple-string';
|
||||
|
||||
const manager = new ConfigManager({
|
||||
loaders: [
|
||||
new FileLoader(configDir, 'development'),
|
||||
new EnvLoader('')
|
||||
]
|
||||
loaders: [new FileLoader(configDir, 'development'), new EnvLoader('')],
|
||||
});
|
||||
|
||||
const config = await manager.initialize(appConfigSchema);
|
||||
|
||||
|
||||
// Environment variable should win
|
||||
expect(config.database).toBe('simple-string');
|
||||
});
|
||||
|
|
@ -184,15 +175,15 @@ describe('Edge Cases and Error Handling', () => {
|
|||
// Create multiple config setups in different directories
|
||||
const dir1 = join(TEST_DIR, 'dir1');
|
||||
const dir2 = join(TEST_DIR, 'dir2');
|
||||
|
||||
|
||||
mkdirSync(join(dir1, 'config'), { recursive: true });
|
||||
mkdirSync(join(dir2, 'config'), { recursive: true });
|
||||
|
||||
|
||||
writeFileSync(
|
||||
join(dir1, 'config', 'development.json'),
|
||||
JSON.stringify({ app: { name: 'dir1-app' } })
|
||||
);
|
||||
|
||||
|
||||
writeFileSync(
|
||||
join(dir2, 'config', 'development.json'),
|
||||
JSON.stringify({ app: { name: 'dir2-app' } })
|
||||
|
|
@ -229,13 +220,13 @@ JSON_VALUE={"key": "value", "nested": {"array": [1, 2, 3]}}
|
|||
);
|
||||
|
||||
process.chdir(TEST_DIR);
|
||||
|
||||
|
||||
const manager = new ConfigManager({
|
||||
loaders: [new EnvLoader('')]
|
||||
loaders: [new EnvLoader('')],
|
||||
});
|
||||
|
||||
const config = await manager.initialize();
|
||||
|
||||
|
||||
// Should handle valid entries
|
||||
expect(process.env.VALID_KEY).toBe('valid_value');
|
||||
expect(process.env.KEY_WITH_QUOTES).toBe('quoted value');
|
||||
|
|
@ -245,12 +236,12 @@ JSON_VALUE={"key": "value", "nested": {"array": [1, 2, 3]}}
|
|||
test('should handle empty config files', async () => {
|
||||
const configDir = join(TEST_DIR, 'config');
|
||||
mkdirSync(configDir, { recursive: true });
|
||||
|
||||
|
||||
// Create empty JSON file
|
||||
writeFileSync(join(configDir, 'development.json'), '{}');
|
||||
|
||||
|
||||
const manager = new ConfigManager({
|
||||
loaders: [new FileLoader(configDir, 'development')]
|
||||
loaders: [new FileLoader(configDir, 'development')],
|
||||
});
|
||||
|
||||
const config = await manager.initialize(appConfigSchema);
|
||||
|
|
@ -260,7 +251,7 @@ JSON_VALUE={"key": "value", "nested": {"array": [1, 2, 3]}}
|
|||
|
||||
test('should handle config initialization without schema', async () => {
|
||||
const manager = new ConfigManager({
|
||||
loaders: [new EnvLoader('')]
|
||||
loaders: [new EnvLoader('')],
|
||||
});
|
||||
|
||||
// Initialize without schema
|
||||
|
|
@ -271,7 +262,7 @@ JSON_VALUE={"key": "value", "nested": {"array": [1, 2, 3]}}
|
|||
|
||||
test('should handle accessing config before initialization', () => {
|
||||
const manager = new ConfigManager({
|
||||
loaders: [new EnvLoader('')]
|
||||
loaders: [new EnvLoader('')],
|
||||
});
|
||||
|
||||
// Should throw error when accessing uninitialized config
|
||||
|
|
@ -282,15 +273,15 @@ JSON_VALUE={"key": "value", "nested": {"array": [1, 2, 3]}}
|
|||
|
||||
test('should handle invalid config paths in getValue', async () => {
|
||||
const manager = new ConfigManager({
|
||||
loaders: [new EnvLoader('')]
|
||||
loaders: [new EnvLoader('')],
|
||||
});
|
||||
|
||||
const config = await manager.initialize(appConfigSchema);
|
||||
|
||||
|
||||
// Should throw for invalid paths
|
||||
expect(() => manager.getValue('nonexistent.path')).toThrow('Configuration key not found');
|
||||
expect(() => manager.getValue('app.nonexistent')).toThrow('Configuration key not found');
|
||||
|
||||
|
||||
// Should work for valid paths
|
||||
expect(() => manager.getValue('environment')).not.toThrow();
|
||||
});
|
||||
|
|
@ -301,11 +292,11 @@ JSON_VALUE={"key": "value", "nested": {"array": [1, 2, 3]}}
|
|||
process.env.EMPTY_VALUE = '';
|
||||
|
||||
const manager = new ConfigManager({
|
||||
loaders: [new EnvLoader('')]
|
||||
loaders: [new EnvLoader('')],
|
||||
});
|
||||
|
||||
const config = await manager.initialize();
|
||||
|
||||
|
||||
expect((config as any).null_value).toBe(null);
|
||||
expect((config as any).undefined_value).toBe(undefined);
|
||||
expect((config as any).empty_value).toBe('');
|
||||
|
|
@ -318,7 +309,7 @@ JSON_VALUE={"key": "value", "nested": {"array": [1, 2, 3]}}
|
|||
process.env.SERVICE_PORT = 'not-a-number'; // This should cause validation to fail
|
||||
|
||||
const manager = new ConfigManager({
|
||||
loaders: [new EnvLoader('')]
|
||||
loaders: [new EnvLoader('')],
|
||||
});
|
||||
|
||||
await expect(manager.initialize(appConfigSchema)).rejects.toThrow(ConfigValidationError);
|
||||
|
|
@ -326,7 +317,7 @@ JSON_VALUE={"key": "value", "nested": {"array": [1, 2, 3]}}
|
|||
|
||||
test('should handle config updates with invalid schema', async () => {
|
||||
const manager = new ConfigManager({
|
||||
loaders: [new EnvLoader('')]
|
||||
loaders: [new EnvLoader('')],
|
||||
});
|
||||
|
||||
await manager.initialize(appConfigSchema);
|
||||
|
|
@ -335,8 +326,8 @@ JSON_VALUE={"key": "value", "nested": {"array": [1, 2, 3]}}
|
|||
expect(() => {
|
||||
manager.set({
|
||||
service: {
|
||||
port: 'invalid-port' as any
|
||||
}
|
||||
port: 'invalid-port' as any,
|
||||
},
|
||||
});
|
||||
}).toThrow(ConfigValidationError);
|
||||
});
|
||||
|
|
@ -344,7 +335,7 @@ JSON_VALUE={"key": "value", "nested": {"array": [1, 2, 3]}}
|
|||
test('should handle loader priority conflicts', async () => {
|
||||
const configDir = join(TEST_DIR, 'config');
|
||||
mkdirSync(configDir, { recursive: true });
|
||||
|
||||
|
||||
writeFileSync(
|
||||
join(configDir, 'development.json'),
|
||||
JSON.stringify({ app: { name: 'file-config' } })
|
||||
|
|
@ -356,12 +347,12 @@ JSON_VALUE={"key": "value", "nested": {"array": [1, 2, 3]}}
|
|||
const manager = new ConfigManager({
|
||||
loaders: [
|
||||
new FileLoader(configDir, 'development'), // priority 50
|
||||
new EnvLoader('') // priority 100
|
||||
]
|
||||
new EnvLoader(''), // priority 100
|
||||
],
|
||||
});
|
||||
|
||||
const config = await manager.initialize(appConfigSchema);
|
||||
|
||||
|
||||
// Environment should win due to higher priority
|
||||
expect(config.app.name).toBe('env-config');
|
||||
});
|
||||
|
|
@ -369,16 +360,16 @@ JSON_VALUE={"key": "value", "nested": {"array": [1, 2, 3]}}
|
|||
test('should handle readonly environment variables', async () => {
|
||||
// Some system environment variables might be readonly
|
||||
const originalPath = process.env.PATH;
|
||||
|
||||
|
||||
// This should not cause the loader to fail
|
||||
const manager = new ConfigManager({
|
||||
loaders: [new EnvLoader('')]
|
||||
loaders: [new EnvLoader('')],
|
||||
});
|
||||
|
||||
const config = await manager.initialize();
|
||||
expect(config).toBeDefined();
|
||||
|
||||
|
||||
// PATH should not be modified
|
||||
expect(process.env.PATH).toBe(originalPath);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,208 +1,202 @@
|
|||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { writeFileSync, mkdirSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
initializeConfig,
|
||||
getConfig,
|
||||
getConfigManager,
|
||||
resetConfig,
|
||||
getDatabaseConfig,
|
||||
getServiceConfig,
|
||||
getLoggingConfig,
|
||||
getProviderConfig,
|
||||
isDevelopment,
|
||||
isProduction,
|
||||
isTest,
|
||||
} from '../src';
|
||||
|
||||
describe('Config Module', () => {
|
||||
const testConfigDir = join(process.cwd(), 'test-config-module');
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
resetConfig();
|
||||
mkdirSync(testConfigDir, { recursive: true });
|
||||
|
||||
// Create test configuration files
|
||||
const config = {
|
||||
name: 'test-app',
|
||||
version: '1.0.0',
|
||||
service: {
|
||||
name: 'test-service',
|
||||
port: 3000,
|
||||
},
|
||||
database: {
|
||||
postgres: {
|
||||
host: 'localhost',
|
||||
port: 5432,
|
||||
database: 'testdb',
|
||||
user: 'testuser',
|
||||
password: 'testpass',
|
||||
},
|
||||
questdb: {
|
||||
host: 'localhost',
|
||||
httpPort: 9000,
|
||||
pgPort: 8812,
|
||||
},
|
||||
mongodb: {
|
||||
host: 'localhost',
|
||||
port: 27017,
|
||||
database: 'testdb',
|
||||
},
|
||||
dragonfly: {
|
||||
host: 'localhost',
|
||||
port: 6379,
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
level: 'info',
|
||||
format: 'json',
|
||||
},
|
||||
providers: {
|
||||
yahoo: {
|
||||
enabled: true,
|
||||
rateLimit: 5,
|
||||
},
|
||||
qm: {
|
||||
enabled: false,
|
||||
apiKey: 'test-key',
|
||||
},
|
||||
},
|
||||
environment: 'test',
|
||||
};
|
||||
|
||||
writeFileSync(
|
||||
join(testConfigDir, 'default.json'),
|
||||
JSON.stringify(config, null, 2)
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetConfig();
|
||||
rmSync(testConfigDir, { recursive: true, force: true });
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
test('should initialize configuration', async () => {
|
||||
const config = await initializeConfig(testConfigDir);
|
||||
|
||||
expect(config.app.name).toBe('test-app');
|
||||
expect(config.service.port).toBe(3000);
|
||||
expect(config.environment).toBe('test');
|
||||
});
|
||||
|
||||
test('should get configuration after initialization', async () => {
|
||||
await initializeConfig(testConfigDir);
|
||||
const config = getConfig();
|
||||
|
||||
expect(config.app.name).toBe('test-app');
|
||||
expect(config.database.postgres.host).toBe('localhost');
|
||||
});
|
||||
|
||||
test('should throw if getting config before initialization', () => {
|
||||
expect(() => getConfig()).toThrow('Configuration not initialized');
|
||||
});
|
||||
|
||||
test('should get config manager instance', async () => {
|
||||
await initializeConfig(testConfigDir);
|
||||
const manager = getConfigManager();
|
||||
|
||||
expect(manager).toBeDefined();
|
||||
expect(manager.get().app.name).toBe('test-app');
|
||||
});
|
||||
|
||||
test('should get database configuration', async () => {
|
||||
await initializeConfig(testConfigDir);
|
||||
const dbConfig = getDatabaseConfig();
|
||||
|
||||
expect(dbConfig.postgres.host).toBe('localhost');
|
||||
expect(dbConfig.questdb.httpPort).toBe(9000);
|
||||
expect(dbConfig.mongodb.database).toBe('testdb');
|
||||
});
|
||||
|
||||
test('should get service configuration', async () => {
|
||||
await initializeConfig(testConfigDir);
|
||||
const serviceConfig = getServiceConfig();
|
||||
|
||||
expect(serviceConfig.name).toBe('test-service');
|
||||
expect(serviceConfig.port).toBe(3000);
|
||||
});
|
||||
|
||||
test('should get logging configuration', async () => {
|
||||
await initializeConfig(testConfigDir);
|
||||
const loggingConfig = getLoggingConfig();
|
||||
|
||||
expect(loggingConfig.level).toBe('info');
|
||||
expect(loggingConfig.format).toBe('json');
|
||||
});
|
||||
|
||||
test('should get provider configuration', async () => {
|
||||
await initializeConfig(testConfigDir);
|
||||
|
||||
const yahooConfig = getProviderConfig('yahoo');
|
||||
expect(yahooConfig.enabled).toBe(true);
|
||||
expect(yahooConfig.rateLimit).toBe(5);
|
||||
|
||||
const qmConfig = getProviderConfig('quoteMedia');
|
||||
expect(qmConfig.enabled).toBe(false);
|
||||
expect(qmConfig.apiKey).toBe('test-key');
|
||||
});
|
||||
|
||||
test('should throw for non-existent provider', async () => {
|
||||
await initializeConfig(testConfigDir);
|
||||
|
||||
expect(() => getProviderConfig('nonexistent')).toThrow(
|
||||
'Provider configuration not found: nonexistent'
|
||||
);
|
||||
});
|
||||
|
||||
test('should check environment correctly', async () => {
|
||||
await initializeConfig(testConfigDir);
|
||||
|
||||
expect(isTest()).toBe(true);
|
||||
expect(isDevelopment()).toBe(false);
|
||||
expect(isProduction()).toBe(false);
|
||||
});
|
||||
|
||||
test('should handle environment overrides', async () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
process.env.STOCKBOT_APP__NAME = 'env-override-app';
|
||||
process.env.STOCKBOT_DATABASE__POSTGRES__HOST = 'prod-db';
|
||||
|
||||
const prodConfig = {
|
||||
database: {
|
||||
postgres: {
|
||||
host: 'prod-host',
|
||||
port: 5432,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
writeFileSync(
|
||||
join(testConfigDir, 'production.json'),
|
||||
JSON.stringify(prodConfig, null, 2)
|
||||
);
|
||||
|
||||
const config = await initializeConfig(testConfigDir);
|
||||
|
||||
expect(config.environment).toBe('production');
|
||||
expect(config.app.name).toBe('env-override-app');
|
||||
expect(config.database.postgres.host).toBe('prod-db');
|
||||
expect(isProduction()).toBe(true);
|
||||
});
|
||||
|
||||
test('should reset configuration', async () => {
|
||||
await initializeConfig(testConfigDir);
|
||||
expect(() => getConfig()).not.toThrow();
|
||||
|
||||
resetConfig();
|
||||
expect(() => getConfig()).toThrow('Configuration not initialized');
|
||||
});
|
||||
|
||||
test('should maintain singleton instance', async () => {
|
||||
const config1 = await initializeConfig(testConfigDir);
|
||||
const config2 = await initializeConfig(testConfigDir);
|
||||
|
||||
expect(config1).toBe(config2);
|
||||
});
|
||||
});
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
getConfig,
|
||||
getConfigManager,
|
||||
getDatabaseConfig,
|
||||
getLoggingConfig,
|
||||
getProviderConfig,
|
||||
getServiceConfig,
|
||||
initializeConfig,
|
||||
isDevelopment,
|
||||
isProduction,
|
||||
isTest,
|
||||
resetConfig,
|
||||
} from '../src';
|
||||
|
||||
describe('Config Module', () => {
|
||||
const testConfigDir = join(process.cwd(), 'test-config-module');
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
resetConfig();
|
||||
mkdirSync(testConfigDir, { recursive: true });
|
||||
|
||||
// Create test configuration files
|
||||
const config = {
|
||||
name: 'test-app',
|
||||
version: '1.0.0',
|
||||
service: {
|
||||
name: 'test-service',
|
||||
port: 3000,
|
||||
},
|
||||
database: {
|
||||
postgres: {
|
||||
host: 'localhost',
|
||||
port: 5432,
|
||||
database: 'testdb',
|
||||
user: 'testuser',
|
||||
password: 'testpass',
|
||||
},
|
||||
questdb: {
|
||||
host: 'localhost',
|
||||
httpPort: 9000,
|
||||
pgPort: 8812,
|
||||
},
|
||||
mongodb: {
|
||||
host: 'localhost',
|
||||
port: 27017,
|
||||
database: 'testdb',
|
||||
},
|
||||
dragonfly: {
|
||||
host: 'localhost',
|
||||
port: 6379,
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
level: 'info',
|
||||
format: 'json',
|
||||
},
|
||||
providers: {
|
||||
yahoo: {
|
||||
enabled: true,
|
||||
rateLimit: 5,
|
||||
},
|
||||
qm: {
|
||||
enabled: false,
|
||||
apiKey: 'test-key',
|
||||
},
|
||||
},
|
||||
environment: 'test',
|
||||
};
|
||||
|
||||
writeFileSync(join(testConfigDir, 'default.json'), JSON.stringify(config, null, 2));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetConfig();
|
||||
rmSync(testConfigDir, { recursive: true, force: true });
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
test('should initialize configuration', async () => {
|
||||
const config = await initializeConfig(testConfigDir);
|
||||
|
||||
expect(config.app.name).toBe('test-app');
|
||||
expect(config.service.port).toBe(3000);
|
||||
expect(config.environment).toBe('test');
|
||||
});
|
||||
|
||||
test('should get configuration after initialization', async () => {
|
||||
await initializeConfig(testConfigDir);
|
||||
const config = getConfig();
|
||||
|
||||
expect(config.app.name).toBe('test-app');
|
||||
expect(config.database.postgres.host).toBe('localhost');
|
||||
});
|
||||
|
||||
test('should throw if getting config before initialization', () => {
|
||||
expect(() => getConfig()).toThrow('Configuration not initialized');
|
||||
});
|
||||
|
||||
test('should get config manager instance', async () => {
|
||||
await initializeConfig(testConfigDir);
|
||||
const manager = getConfigManager();
|
||||
|
||||
expect(manager).toBeDefined();
|
||||
expect(manager.get().app.name).toBe('test-app');
|
||||
});
|
||||
|
||||
test('should get database configuration', async () => {
|
||||
await initializeConfig(testConfigDir);
|
||||
const dbConfig = getDatabaseConfig();
|
||||
|
||||
expect(dbConfig.postgres.host).toBe('localhost');
|
||||
expect(dbConfig.questdb.httpPort).toBe(9000);
|
||||
expect(dbConfig.mongodb.database).toBe('testdb');
|
||||
});
|
||||
|
||||
test('should get service configuration', async () => {
|
||||
await initializeConfig(testConfigDir);
|
||||
const serviceConfig = getServiceConfig();
|
||||
|
||||
expect(serviceConfig.name).toBe('test-service');
|
||||
expect(serviceConfig.port).toBe(3000);
|
||||
});
|
||||
|
||||
test('should get logging configuration', async () => {
|
||||
await initializeConfig(testConfigDir);
|
||||
const loggingConfig = getLoggingConfig();
|
||||
|
||||
expect(loggingConfig.level).toBe('info');
|
||||
expect(loggingConfig.format).toBe('json');
|
||||
});
|
||||
|
||||
test('should get provider configuration', async () => {
|
||||
await initializeConfig(testConfigDir);
|
||||
|
||||
const yahooConfig = getProviderConfig('yahoo');
|
||||
expect(yahooConfig.enabled).toBe(true);
|
||||
expect(yahooConfig.rateLimit).toBe(5);
|
||||
|
||||
const qmConfig = getProviderConfig('quoteMedia');
|
||||
expect(qmConfig.enabled).toBe(false);
|
||||
expect(qmConfig.apiKey).toBe('test-key');
|
||||
});
|
||||
|
||||
test('should throw for non-existent provider', async () => {
|
||||
await initializeConfig(testConfigDir);
|
||||
|
||||
expect(() => getProviderConfig('nonexistent')).toThrow(
|
||||
'Provider configuration not found: nonexistent'
|
||||
);
|
||||
});
|
||||
|
||||
test('should check environment correctly', async () => {
|
||||
await initializeConfig(testConfigDir);
|
||||
|
||||
expect(isTest()).toBe(true);
|
||||
expect(isDevelopment()).toBe(false);
|
||||
expect(isProduction()).toBe(false);
|
||||
});
|
||||
|
||||
test('should handle environment overrides', async () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
process.env.STOCKBOT_APP__NAME = 'env-override-app';
|
||||
process.env.STOCKBOT_DATABASE__POSTGRES__HOST = 'prod-db';
|
||||
|
||||
const prodConfig = {
|
||||
database: {
|
||||
postgres: {
|
||||
host: 'prod-host',
|
||||
port: 5432,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
writeFileSync(join(testConfigDir, 'production.json'), JSON.stringify(prodConfig, null, 2));
|
||||
|
||||
const config = await initializeConfig(testConfigDir);
|
||||
|
||||
expect(config.environment).toBe('production');
|
||||
expect(config.app.name).toBe('env-override-app');
|
||||
expect(config.database.postgres.host).toBe('prod-db');
|
||||
expect(isProduction()).toBe(true);
|
||||
});
|
||||
|
||||
test('should reset configuration', async () => {
|
||||
await initializeConfig(testConfigDir);
|
||||
expect(() => getConfig()).not.toThrow();
|
||||
|
||||
resetConfig();
|
||||
expect(() => getConfig()).toThrow('Configuration not initialized');
|
||||
});
|
||||
|
||||
test('should maintain singleton instance', async () => {
|
||||
const config1 = await initializeConfig(testConfigDir);
|
||||
const config2 = await initializeConfig(testConfigDir);
|
||||
|
||||
expect(config1).toBe(config2);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,181 +1,166 @@
|
|||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { writeFileSync, mkdirSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { EnvLoader } from '../src/loaders/env.loader';
|
||||
import { FileLoader } from '../src/loaders/file.loader';
|
||||
|
||||
describe('EnvLoader', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original environment
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
test('should load environment variables with prefix', async () => {
|
||||
process.env.TEST_APP_NAME = 'env-app';
|
||||
process.env.TEST_APP_VERSION = '1.0.0';
|
||||
process.env.TEST_DATABASE_HOST = 'env-host';
|
||||
process.env.TEST_DATABASE_PORT = '5432';
|
||||
process.env.OTHER_VAR = 'should-not-load';
|
||||
|
||||
const loader = new EnvLoader('TEST_', { convertCase: false, nestedDelimiter: null });
|
||||
const config = await loader.load();
|
||||
|
||||
expect(config.APP_NAME).toBe('env-app');
|
||||
expect(config.APP_VERSION).toBe('1.0.0');
|
||||
expect(config.DATABASE_HOST).toBe('env-host');
|
||||
expect(config.DATABASE_PORT).toBe(5432); // Should be parsed as number
|
||||
expect(config.OTHER_VAR).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should convert snake_case to camelCase', async () => {
|
||||
process.env.TEST_DATABASE_CONNECTION_STRING = 'postgres://localhost';
|
||||
process.env.TEST_API_KEY_SECRET = 'secret123';
|
||||
|
||||
const loader = new EnvLoader('TEST_', { convertCase: true });
|
||||
const config = await loader.load();
|
||||
|
||||
expect(config.databaseConnectionString).toBe('postgres://localhost');
|
||||
expect(config.apiKeySecret).toBe('secret123');
|
||||
});
|
||||
|
||||
test('should parse JSON values', async () => {
|
||||
process.env.TEST_SETTINGS = '{"feature": true, "limit": 100}';
|
||||
process.env.TEST_NUMBERS = '[1, 2, 3]';
|
||||
|
||||
const loader = new EnvLoader('TEST_', { parseJson: true });
|
||||
const config = await loader.load();
|
||||
|
||||
expect(config.SETTINGS).toEqual({ feature: true, limit: 100 });
|
||||
expect(config.NUMBERS).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
test('should parse boolean and number values', async () => {
|
||||
process.env.TEST_ENABLED = 'true';
|
||||
process.env.TEST_DISABLED = 'false';
|
||||
process.env.TEST_PORT = '3000';
|
||||
process.env.TEST_RATIO = '0.75';
|
||||
|
||||
const loader = new EnvLoader('TEST_', { parseValues: true });
|
||||
const config = await loader.load();
|
||||
|
||||
expect(config.ENABLED).toBe(true);
|
||||
expect(config.DISABLED).toBe(false);
|
||||
expect(config.PORT).toBe(3000);
|
||||
expect(config.RATIO).toBe(0.75);
|
||||
});
|
||||
|
||||
test('should handle nested object structure', async () => {
|
||||
process.env.TEST_APP__NAME = 'nested-app';
|
||||
process.env.TEST_APP__SETTINGS__ENABLED = 'true';
|
||||
process.env.TEST_DATABASE__HOST = 'localhost';
|
||||
|
||||
const loader = new EnvLoader('TEST_', {
|
||||
parseValues: true,
|
||||
nestedDelimiter: '__'
|
||||
});
|
||||
const config = await loader.load();
|
||||
|
||||
expect(config.APP).toEqual({
|
||||
NAME: 'nested-app',
|
||||
SETTINGS: {
|
||||
ENABLED: true
|
||||
}
|
||||
});
|
||||
expect(config.DATABASE).toEqual({
|
||||
HOST: 'localhost'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FileLoader', () => {
|
||||
const testDir = join(process.cwd(), 'test-config');
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('should load JSON configuration file', async () => {
|
||||
const config = {
|
||||
app: { name: 'file-app', version: '1.0.0' },
|
||||
database: { host: 'localhost', port: 5432 }
|
||||
};
|
||||
|
||||
writeFileSync(
|
||||
join(testDir, 'default.json'),
|
||||
JSON.stringify(config, null, 2)
|
||||
);
|
||||
|
||||
const loader = new FileLoader(testDir);
|
||||
const loaded = await loader.load();
|
||||
|
||||
expect(loaded).toEqual(config);
|
||||
});
|
||||
|
||||
test('should load environment-specific configuration', async () => {
|
||||
const defaultConfig = {
|
||||
app: { name: 'app', port: 3000 },
|
||||
database: { host: 'localhost' }
|
||||
};
|
||||
|
||||
const prodConfig = {
|
||||
app: { port: 8080 },
|
||||
database: { host: 'prod-db' }
|
||||
};
|
||||
|
||||
writeFileSync(
|
||||
join(testDir, 'default.json'),
|
||||
JSON.stringify(defaultConfig, null, 2)
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
join(testDir, 'production.json'),
|
||||
JSON.stringify(prodConfig, null, 2)
|
||||
);
|
||||
|
||||
const loader = new FileLoader(testDir, 'production');
|
||||
const loaded = await loader.load();
|
||||
|
||||
expect(loaded).toEqual({
|
||||
app: { name: 'app', port: 8080 },
|
||||
database: { host: 'prod-db' }
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle missing configuration files gracefully', async () => {
|
||||
const loader = new FileLoader(testDir);
|
||||
const loaded = await loader.load();
|
||||
|
||||
expect(loaded).toEqual({});
|
||||
});
|
||||
|
||||
test('should throw on invalid JSON', async () => {
|
||||
writeFileSync(
|
||||
join(testDir, 'default.json'),
|
||||
'invalid json content'
|
||||
);
|
||||
|
||||
const loader = new FileLoader(testDir);
|
||||
|
||||
await expect(loader.load()).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('should support custom configuration', async () => {
|
||||
const config = { custom: 'value' };
|
||||
|
||||
writeFileSync(
|
||||
join(testDir, 'custom.json'),
|
||||
JSON.stringify(config, null, 2)
|
||||
);
|
||||
|
||||
const loader = new FileLoader(testDir);
|
||||
const loaded = await loader.loadFile('custom.json');
|
||||
|
||||
expect(loaded).toEqual(config);
|
||||
});
|
||||
});
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { EnvLoader } from '../src/loaders/env.loader';
|
||||
import { FileLoader } from '../src/loaders/file.loader';
|
||||
|
||||
describe('EnvLoader', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original environment
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
test('should load environment variables with prefix', async () => {
|
||||
process.env.TEST_APP_NAME = 'env-app';
|
||||
process.env.TEST_APP_VERSION = '1.0.0';
|
||||
process.env.TEST_DATABASE_HOST = 'env-host';
|
||||
process.env.TEST_DATABASE_PORT = '5432';
|
||||
process.env.OTHER_VAR = 'should-not-load';
|
||||
|
||||
const loader = new EnvLoader('TEST_', { convertCase: false, nestedDelimiter: null });
|
||||
const config = await loader.load();
|
||||
|
||||
expect(config.APP_NAME).toBe('env-app');
|
||||
expect(config.APP_VERSION).toBe('1.0.0');
|
||||
expect(config.DATABASE_HOST).toBe('env-host');
|
||||
expect(config.DATABASE_PORT).toBe(5432); // Should be parsed as number
|
||||
expect(config.OTHER_VAR).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should convert snake_case to camelCase', async () => {
|
||||
process.env.TEST_DATABASE_CONNECTION_STRING = 'postgres://localhost';
|
||||
process.env.TEST_API_KEY_SECRET = 'secret123';
|
||||
|
||||
const loader = new EnvLoader('TEST_', { convertCase: true });
|
||||
const config = await loader.load();
|
||||
|
||||
expect(config.databaseConnectionString).toBe('postgres://localhost');
|
||||
expect(config.apiKeySecret).toBe('secret123');
|
||||
});
|
||||
|
||||
test('should parse JSON values', async () => {
|
||||
process.env.TEST_SETTINGS = '{"feature": true, "limit": 100}';
|
||||
process.env.TEST_NUMBERS = '[1, 2, 3]';
|
||||
|
||||
const loader = new EnvLoader('TEST_', { parseJson: true });
|
||||
const config = await loader.load();
|
||||
|
||||
expect(config.SETTINGS).toEqual({ feature: true, limit: 100 });
|
||||
expect(config.NUMBERS).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
test('should parse boolean and number values', async () => {
|
||||
process.env.TEST_ENABLED = 'true';
|
||||
process.env.TEST_DISABLED = 'false';
|
||||
process.env.TEST_PORT = '3000';
|
||||
process.env.TEST_RATIO = '0.75';
|
||||
|
||||
const loader = new EnvLoader('TEST_', { parseValues: true });
|
||||
const config = await loader.load();
|
||||
|
||||
expect(config.ENABLED).toBe(true);
|
||||
expect(config.DISABLED).toBe(false);
|
||||
expect(config.PORT).toBe(3000);
|
||||
expect(config.RATIO).toBe(0.75);
|
||||
});
|
||||
|
||||
test('should handle nested object structure', async () => {
|
||||
process.env.TEST_APP__NAME = 'nested-app';
|
||||
process.env.TEST_APP__SETTINGS__ENABLED = 'true';
|
||||
process.env.TEST_DATABASE__HOST = 'localhost';
|
||||
|
||||
const loader = new EnvLoader('TEST_', {
|
||||
parseValues: true,
|
||||
nestedDelimiter: '__',
|
||||
});
|
||||
const config = await loader.load();
|
||||
|
||||
expect(config.APP).toEqual({
|
||||
NAME: 'nested-app',
|
||||
SETTINGS: {
|
||||
ENABLED: true,
|
||||
},
|
||||
});
|
||||
expect(config.DATABASE).toEqual({
|
||||
HOST: 'localhost',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FileLoader', () => {
|
||||
const testDir = join(process.cwd(), 'test-config');
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('should load JSON configuration file', async () => {
|
||||
const config = {
|
||||
app: { name: 'file-app', version: '1.0.0' },
|
||||
database: { host: 'localhost', port: 5432 },
|
||||
};
|
||||
|
||||
writeFileSync(join(testDir, 'default.json'), JSON.stringify(config, null, 2));
|
||||
|
||||
const loader = new FileLoader(testDir);
|
||||
const loaded = await loader.load();
|
||||
|
||||
expect(loaded).toEqual(config);
|
||||
});
|
||||
|
||||
test('should load environment-specific configuration', async () => {
|
||||
const defaultConfig = {
|
||||
app: { name: 'app', port: 3000 },
|
||||
database: { host: 'localhost' },
|
||||
};
|
||||
|
||||
const prodConfig = {
|
||||
app: { port: 8080 },
|
||||
database: { host: 'prod-db' },
|
||||
};
|
||||
|
||||
writeFileSync(join(testDir, 'default.json'), JSON.stringify(defaultConfig, null, 2));
|
||||
|
||||
writeFileSync(join(testDir, 'production.json'), JSON.stringify(prodConfig, null, 2));
|
||||
|
||||
const loader = new FileLoader(testDir, 'production');
|
||||
const loaded = await loader.load();
|
||||
|
||||
expect(loaded).toEqual({
|
||||
app: { name: 'app', port: 8080 },
|
||||
database: { host: 'prod-db' },
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle missing configuration files gracefully', async () => {
|
||||
const loader = new FileLoader(testDir);
|
||||
const loaded = await loader.load();
|
||||
|
||||
expect(loaded).toEqual({});
|
||||
});
|
||||
|
||||
test('should throw on invalid JSON', async () => {
|
||||
writeFileSync(join(testDir, 'default.json'), 'invalid json content');
|
||||
|
||||
const loader = new FileLoader(testDir);
|
||||
|
||||
await expect(loader.load()).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('should support custom configuration', async () => {
|
||||
const config = { custom: 'value' };
|
||||
|
||||
writeFileSync(join(testDir, 'custom.json'), JSON.stringify(config, null, 2));
|
||||
|
||||
const loader = new FileLoader(testDir);
|
||||
const loaded = await loader.loadFile('custom.json');
|
||||
|
||||
expect(loaded).toEqual(config);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { ConfigManager } from '../src/config-manager';
|
||||
import { getProviderConfig, resetConfig } from '../src/index';
|
||||
import { EnvLoader } from '../src/loaders/env.loader';
|
||||
import { FileLoader } from '../src/loaders/file.loader';
|
||||
import { appConfigSchema } from '../src/schemas';
|
||||
import { resetConfig, getProviderConfig } from '../src/index';
|
||||
import { join } from 'path';
|
||||
import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs';
|
||||
|
||||
const TEST_DIR = join(__dirname, 'provider-tests');
|
||||
|
||||
|
|
@ -15,10 +15,10 @@ describe('Provider Configuration Tests', () => {
|
|||
beforeEach(() => {
|
||||
// Save original environment
|
||||
originalEnv = { ...process.env };
|
||||
|
||||
|
||||
// Reset config singleton
|
||||
resetConfig();
|
||||
|
||||
|
||||
// Clean up test directory
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
|
|
@ -29,7 +29,7 @@ describe('Provider Configuration Tests', () => {
|
|||
afterEach(() => {
|
||||
// Restore original environment
|
||||
process.env = originalEnv;
|
||||
|
||||
|
||||
// Clean up
|
||||
resetConfig();
|
||||
if (existsSync(TEST_DIR)) {
|
||||
|
|
@ -44,7 +44,7 @@ describe('Provider Configuration Tests', () => {
|
|||
process.env.WEBSHARE_ENABLED = 'true';
|
||||
|
||||
const manager = new ConfigManager({
|
||||
loaders: [new EnvLoader('')]
|
||||
loaders: [new EnvLoader('')],
|
||||
});
|
||||
|
||||
const config = await manager.initialize(appConfigSchema);
|
||||
|
|
@ -64,7 +64,7 @@ describe('Provider Configuration Tests', () => {
|
|||
process.env.EOD_PRIORITY = '10';
|
||||
|
||||
const manager = new ConfigManager({
|
||||
loaders: [new EnvLoader('')]
|
||||
loaders: [new EnvLoader('')],
|
||||
});
|
||||
|
||||
const config = await manager.initialize(appConfigSchema);
|
||||
|
|
@ -88,7 +88,7 @@ describe('Provider Configuration Tests', () => {
|
|||
process.env.IB_PRIORITY = '5';
|
||||
|
||||
const manager = new ConfigManager({
|
||||
loaders: [new EnvLoader('')]
|
||||
loaders: [new EnvLoader('')],
|
||||
});
|
||||
|
||||
const config = await manager.initialize(appConfigSchema);
|
||||
|
|
@ -113,7 +113,7 @@ describe('Provider Configuration Tests', () => {
|
|||
process.env.QM_PRIORITY = '15';
|
||||
|
||||
const manager = new ConfigManager({
|
||||
loaders: [new EnvLoader('')]
|
||||
loaders: [new EnvLoader('')],
|
||||
});
|
||||
|
||||
const config = await manager.initialize(appConfigSchema);
|
||||
|
|
@ -136,7 +136,7 @@ describe('Provider Configuration Tests', () => {
|
|||
process.env.YAHOO_PRIORITY = '20';
|
||||
|
||||
const manager = new ConfigManager({
|
||||
loaders: [new EnvLoader('')]
|
||||
loaders: [new EnvLoader('')],
|
||||
});
|
||||
|
||||
const config = await manager.initialize(appConfigSchema);
|
||||
|
|
@ -153,27 +153,31 @@ describe('Provider Configuration Tests', () => {
|
|||
// Create a config file
|
||||
const configDir = join(TEST_DIR, 'config');
|
||||
mkdirSync(configDir, { recursive: true });
|
||||
|
||||
|
||||
writeFileSync(
|
||||
join(configDir, 'development.json'),
|
||||
JSON.stringify({
|
||||
providers: {
|
||||
eod: {
|
||||
name: 'EOD Historical Data',
|
||||
apiKey: 'file-eod-key',
|
||||
baseUrl: 'https://file.eod.com/api',
|
||||
tier: 'free',
|
||||
enabled: false,
|
||||
priority: 1
|
||||
JSON.stringify(
|
||||
{
|
||||
providers: {
|
||||
eod: {
|
||||
name: 'EOD Historical Data',
|
||||
apiKey: 'file-eod-key',
|
||||
baseUrl: 'https://file.eod.com/api',
|
||||
tier: 'free',
|
||||
enabled: false,
|
||||
priority: 1,
|
||||
},
|
||||
yahoo: {
|
||||
name: 'Yahoo Finance',
|
||||
baseUrl: 'https://file.yahoo.com',
|
||||
enabled: true,
|
||||
priority: 2,
|
||||
},
|
||||
},
|
||||
yahoo: {
|
||||
name: 'Yahoo Finance',
|
||||
baseUrl: 'https://file.yahoo.com',
|
||||
enabled: true,
|
||||
priority: 2
|
||||
}
|
||||
}
|
||||
}, null, 2)
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
// Set environment variables that should override file config
|
||||
|
|
@ -183,10 +187,7 @@ describe('Provider Configuration Tests', () => {
|
|||
process.env.YAHOO_PRIORITY = '25';
|
||||
|
||||
const manager = new ConfigManager({
|
||||
loaders: [
|
||||
new FileLoader(configDir, 'development'),
|
||||
new EnvLoader('')
|
||||
]
|
||||
loaders: [new FileLoader(configDir, 'development'), new EnvLoader('')],
|
||||
});
|
||||
|
||||
const config = await manager.initialize(appConfigSchema);
|
||||
|
|
@ -211,7 +212,7 @@ describe('Provider Configuration Tests', () => {
|
|||
process.env.IB_GATEWAY_PORT = 'not-a-number'; // Should be a number
|
||||
|
||||
const manager = new ConfigManager({
|
||||
loaders: [new EnvLoader('')]
|
||||
loaders: [new EnvLoader('')],
|
||||
});
|
||||
|
||||
// Should throw validation error
|
||||
|
|
@ -226,7 +227,7 @@ describe('Provider Configuration Tests', () => {
|
|||
process.env.WEBSHARE_ENABLED = 'true';
|
||||
|
||||
const manager = new ConfigManager({
|
||||
loaders: [new EnvLoader('')]
|
||||
loaders: [new EnvLoader('')],
|
||||
});
|
||||
|
||||
await manager.initialize(appConfigSchema);
|
||||
|
|
@ -241,7 +242,9 @@ describe('Provider Configuration Tests', () => {
|
|||
expect((webshareConfig as any).apiKey).toBe('test-webshare-key');
|
||||
|
||||
// Test non-existent provider
|
||||
expect(() => getProviderConfig('nonexistent')).toThrow('Provider configuration not found: nonexistent');
|
||||
expect(() => getProviderConfig('nonexistent')).toThrow(
|
||||
'Provider configuration not found: nonexistent'
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle boolean string parsing correctly', async () => {
|
||||
|
|
@ -253,7 +256,7 @@ describe('Provider Configuration Tests', () => {
|
|||
process.env.WEBSHARE_ENABLED = 'yes'; // Should be treated as string, not boolean
|
||||
|
||||
const manager = new ConfigManager({
|
||||
loaders: [new EnvLoader('')]
|
||||
loaders: [new EnvLoader('')],
|
||||
});
|
||||
|
||||
const config = await manager.initialize(appConfigSchema);
|
||||
|
|
@ -272,7 +275,7 @@ describe('Provider Configuration Tests', () => {
|
|||
process.env.IB_GATEWAY_CLIENT_ID = '999';
|
||||
|
||||
const manager = new ConfigManager({
|
||||
loaders: [new EnvLoader('')]
|
||||
loaders: [new EnvLoader('')],
|
||||
});
|
||||
|
||||
const config = await manager.initialize(appConfigSchema);
|
||||
|
|
@ -300,9 +303,9 @@ YAHOO_BASE_URL=https://env-file.yahoo.com
|
|||
const originalCwd = process.cwd();
|
||||
try {
|
||||
process.chdir(TEST_DIR);
|
||||
|
||||
|
||||
const manager = new ConfigManager({
|
||||
loaders: [new EnvLoader('')]
|
||||
loaders: [new EnvLoader('')],
|
||||
});
|
||||
|
||||
const config = await manager.initialize(appConfigSchema);
|
||||
|
|
@ -317,4 +320,4 @@ YAHOO_BASE_URL=https://env-file.yahoo.com
|
|||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
getConfig,
|
||||
getDatabaseConfig,
|
||||
|
|
@ -11,7 +11,7 @@ import {
|
|||
isDevelopment,
|
||||
isProduction,
|
||||
isTest,
|
||||
resetConfig
|
||||
resetConfig,
|
||||
} from '../src/index';
|
||||
|
||||
const TEST_DIR = join(__dirname, 'real-usage-tests');
|
||||
|
|
@ -23,13 +23,13 @@ describe('Real Usage Scenarios', () => {
|
|||
beforeEach(() => {
|
||||
originalEnv = { ...process.env };
|
||||
originalCwd = process.cwd();
|
||||
|
||||
|
||||
resetConfig();
|
||||
|
||||
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
|
||||
setupRealUsageScenarios();
|
||||
});
|
||||
|
||||
|
|
@ -37,7 +37,7 @@ describe('Real Usage Scenarios', () => {
|
|||
process.env = originalEnv;
|
||||
process.chdir(originalCwd);
|
||||
resetConfig();
|
||||
|
||||
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -53,18 +53,18 @@ describe('Real Usage Scenarios', () => {
|
|||
// Test typical data-ingestion config access patterns
|
||||
expect(config.app.name).toBe('data-ingestion');
|
||||
expect(config.service.port).toBe(3001);
|
||||
|
||||
|
||||
// Test database config access
|
||||
const dbConfig = getDatabaseConfig();
|
||||
expect(dbConfig.postgres.host).toBe('localhost');
|
||||
expect(dbConfig.postgres.port).toBe(5432);
|
||||
expect(dbConfig.questdb.host).toBe('localhost');
|
||||
|
||||
|
||||
// Test provider access
|
||||
const yahooConfig = getProviderConfig('yahoo');
|
||||
expect(yahooConfig).toBeDefined();
|
||||
expect((yahooConfig as any).enabled).toBe(true);
|
||||
|
||||
|
||||
// Test environment helpers
|
||||
expect(isDevelopment()).toBe(true);
|
||||
expect(isProduction()).toBe(false);
|
||||
|
|
@ -78,11 +78,11 @@ describe('Real Usage Scenarios', () => {
|
|||
|
||||
expect(config.app.name).toBe('web-api');
|
||||
expect(config.service.port).toBe(4000);
|
||||
|
||||
|
||||
// Web API should have access to all the same configs
|
||||
const serviceConfig = getServiceConfig();
|
||||
expect(serviceConfig.name).toBe('web-api');
|
||||
|
||||
|
||||
const loggingConfig = getLoggingConfig();
|
||||
expect(loggingConfig.level).toBe('info');
|
||||
});
|
||||
|
|
@ -96,7 +96,7 @@ describe('Real Usage Scenarios', () => {
|
|||
// Libraries should inherit from root config
|
||||
expect(config.app.name).toBe('cache-lib');
|
||||
expect(config.app.version).toBe('1.0.0'); // From root
|
||||
|
||||
|
||||
// Should have access to cache config
|
||||
const dbConfig = getDatabaseConfig();
|
||||
expect(dbConfig.dragonfly).toBeDefined();
|
||||
|
|
@ -106,7 +106,7 @@ describe('Real Usage Scenarios', () => {
|
|||
|
||||
test('should handle production environment correctly', async () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
|
||||
const dataServiceDir = join(TEST_DIR, 'apps', 'data-ingestion');
|
||||
process.chdir(dataServiceDir);
|
||||
|
||||
|
|
@ -115,14 +115,14 @@ describe('Real Usage Scenarios', () => {
|
|||
|
||||
expect(config.environment).toBe('production');
|
||||
expect(config.logging.level).toBe('warn'); // Production should use different log level
|
||||
|
||||
|
||||
expect(isProduction()).toBe(true);
|
||||
expect(isDevelopment()).toBe(false);
|
||||
});
|
||||
|
||||
test('should handle test environment correctly', async () => {
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
|
||||
const dataServiceDir = join(TEST_DIR, 'apps', 'data-ingestion');
|
||||
process.chdir(dataServiceDir);
|
||||
|
||||
|
|
@ -131,7 +131,7 @@ describe('Real Usage Scenarios', () => {
|
|||
|
||||
expect(config.environment).toBe('test');
|
||||
expect(config.logging.level).toBe('debug'); // Test should use debug level
|
||||
|
||||
|
||||
expect(isTest()).toBe(true);
|
||||
expect(isDevelopment()).toBe(false);
|
||||
});
|
||||
|
|
@ -153,10 +153,10 @@ describe('Real Usage Scenarios', () => {
|
|||
const dbConfig = getDatabaseConfig();
|
||||
expect(dbConfig.postgres.host).toBe('prod-db.example.com');
|
||||
expect(dbConfig.postgres.port).toBe(5433);
|
||||
|
||||
|
||||
const serviceConfig = getServiceConfig();
|
||||
expect(serviceConfig.port).toBe(8080);
|
||||
|
||||
|
||||
const eodConfig = getProviderConfig('eod');
|
||||
expect((eodConfig as any).apiKey).toBe('prod-eod-key');
|
||||
});
|
||||
|
|
@ -168,8 +168,10 @@ describe('Real Usage Scenarios', () => {
|
|||
const config = await initializeServiceConfig();
|
||||
|
||||
// Should throw for non-existent providers
|
||||
expect(() => getProviderConfig('nonexistent')).toThrow('Provider configuration not found: nonexistent');
|
||||
|
||||
expect(() => getProviderConfig('nonexistent')).toThrow(
|
||||
'Provider configuration not found: nonexistent'
|
||||
);
|
||||
|
||||
// Should work for providers that exist but might not be configured
|
||||
// (they should have defaults from schema)
|
||||
const yahooConfig = getProviderConfig('yahoo');
|
||||
|
|
@ -181,18 +183,18 @@ describe('Real Usage Scenarios', () => {
|
|||
process.chdir(dataServiceDir);
|
||||
|
||||
const config = await initializeServiceConfig();
|
||||
|
||||
|
||||
// Test various access patterns used in real applications
|
||||
const configManager = (await import('../src/index')).getConfigManager();
|
||||
|
||||
|
||||
// Direct path access
|
||||
expect(configManager.getValue('app.name')).toBe('data-ingestion');
|
||||
expect(configManager.getValue('service.port')).toBe(3001);
|
||||
|
||||
|
||||
// Check if paths exist
|
||||
expect(configManager.has('app.name')).toBe(true);
|
||||
expect(configManager.has('nonexistent.path')).toBe(false);
|
||||
|
||||
|
||||
// Typed access
|
||||
const port = configManager.getValue<number>('service.port');
|
||||
expect(typeof port).toBe('number');
|
||||
|
|
@ -205,39 +207,39 @@ describe('Real Usage Scenarios', () => {
|
|||
|
||||
await initializeServiceConfig();
|
||||
const configManager = (await import('../src/index')).getConfigManager();
|
||||
|
||||
|
||||
// Update config at runtime (useful for testing)
|
||||
configManager.set({
|
||||
service: {
|
||||
port: 9999
|
||||
}
|
||||
port: 9999,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const updatedConfig = getConfig();
|
||||
expect(updatedConfig.service.port).toBe(9999);
|
||||
|
||||
|
||||
// Other values should be preserved
|
||||
expect(updatedConfig.app.name).toBe('data-ingestion');
|
||||
});
|
||||
|
||||
test('should work across multiple service initializations', async () => {
|
||||
// Simulate multiple services in the same process (like tests)
|
||||
|
||||
|
||||
// First service
|
||||
const dataServiceDir = join(TEST_DIR, 'apps', 'data-ingestion');
|
||||
process.chdir(dataServiceDir);
|
||||
|
||||
|
||||
let config = await initializeServiceConfig();
|
||||
expect(config.app.name).toBe('data-ingestion');
|
||||
|
||||
// Reset and switch to another service
|
||||
|
||||
// Reset and switch to another service
|
||||
resetConfig();
|
||||
const webApiDir = join(TEST_DIR, 'apps', 'web-api');
|
||||
process.chdir(webApiDir);
|
||||
|
||||
|
||||
config = await initializeServiceConfig();
|
||||
expect(config.app.name).toBe('web-api');
|
||||
|
||||
|
||||
// Each service should get its own config
|
||||
expect(config.service.port).toBe(4000); // web-api port
|
||||
});
|
||||
|
|
@ -263,7 +265,7 @@ function setupRealUsageScenarios() {
|
|||
development: {
|
||||
app: {
|
||||
name: 'stock-bot-monorepo',
|
||||
version: '1.0.0'
|
||||
version: '1.0.0',
|
||||
},
|
||||
database: {
|
||||
postgres: {
|
||||
|
|
@ -271,116 +273,125 @@ function setupRealUsageScenarios() {
|
|||
port: 5432,
|
||||
database: 'trading_bot',
|
||||
username: 'trading_user',
|
||||
password: 'trading_pass_dev'
|
||||
password: 'trading_pass_dev',
|
||||
},
|
||||
questdb: {
|
||||
host: 'localhost',
|
||||
port: 9009,
|
||||
database: 'questdb'
|
||||
database: 'questdb',
|
||||
},
|
||||
mongodb: {
|
||||
host: 'localhost',
|
||||
port: 27017,
|
||||
database: 'stock'
|
||||
database: 'stock',
|
||||
},
|
||||
dragonfly: {
|
||||
host: 'localhost',
|
||||
port: 6379
|
||||
}
|
||||
port: 6379,
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
level: 'info',
|
||||
format: 'json'
|
||||
format: 'json',
|
||||
},
|
||||
providers: {
|
||||
yahoo: {
|
||||
name: 'Yahoo Finance',
|
||||
enabled: true,
|
||||
priority: 1,
|
||||
baseUrl: 'https://query1.finance.yahoo.com'
|
||||
baseUrl: 'https://query1.finance.yahoo.com',
|
||||
},
|
||||
eod: {
|
||||
name: 'EOD Historical Data',
|
||||
enabled: false,
|
||||
priority: 2,
|
||||
apiKey: 'demo-api-key',
|
||||
baseUrl: 'https://eodhistoricaldata.com/api'
|
||||
}
|
||||
}
|
||||
baseUrl: 'https://eodhistoricaldata.com/api',
|
||||
},
|
||||
},
|
||||
},
|
||||
production: {
|
||||
logging: {
|
||||
level: 'warn'
|
||||
level: 'warn',
|
||||
},
|
||||
database: {
|
||||
postgres: {
|
||||
host: 'prod-postgres.internal',
|
||||
port: 5432
|
||||
}
|
||||
}
|
||||
port: 5432,
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
logging: {
|
||||
level: 'debug'
|
||||
level: 'debug',
|
||||
},
|
||||
database: {
|
||||
postgres: {
|
||||
database: 'trading_bot_test'
|
||||
}
|
||||
}
|
||||
}
|
||||
database: 'trading_bot_test',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Object.entries(rootConfigs).forEach(([env, config]) => {
|
||||
writeFileSync(
|
||||
join(scenarios.root, 'config', `${env}.json`),
|
||||
JSON.stringify(config, null, 2)
|
||||
);
|
||||
writeFileSync(join(scenarios.root, 'config', `${env}.json`), JSON.stringify(config, null, 2));
|
||||
});
|
||||
|
||||
// Data service config
|
||||
writeFileSync(
|
||||
join(scenarios.dataService, 'config', 'development.json'),
|
||||
JSON.stringify({
|
||||
app: {
|
||||
name: 'data-ingestion'
|
||||
JSON.stringify(
|
||||
{
|
||||
app: {
|
||||
name: 'data-ingestion',
|
||||
},
|
||||
service: {
|
||||
name: 'data-ingestion',
|
||||
port: 3001,
|
||||
workers: 2,
|
||||
},
|
||||
},
|
||||
service: {
|
||||
name: 'data-ingestion',
|
||||
port: 3001,
|
||||
workers: 2
|
||||
}
|
||||
}, null, 2)
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
// Web API config
|
||||
writeFileSync(
|
||||
join(scenarios.webApi, 'config', 'development.json'),
|
||||
JSON.stringify({
|
||||
app: {
|
||||
name: 'web-api'
|
||||
JSON.stringify(
|
||||
{
|
||||
app: {
|
||||
name: 'web-api',
|
||||
},
|
||||
service: {
|
||||
name: 'web-api',
|
||||
port: 4000,
|
||||
cors: {
|
||||
origin: ['http://localhost:3000', 'http://localhost:4200'],
|
||||
},
|
||||
},
|
||||
},
|
||||
service: {
|
||||
name: 'web-api',
|
||||
port: 4000,
|
||||
cors: {
|
||||
origin: ['http://localhost:3000', 'http://localhost:4200']
|
||||
}
|
||||
}
|
||||
}, null, 2)
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
// Cache lib config
|
||||
writeFileSync(
|
||||
join(scenarios.cacheLib, 'config', 'development.json'),
|
||||
JSON.stringify({
|
||||
app: {
|
||||
name: 'cache-lib'
|
||||
JSON.stringify(
|
||||
{
|
||||
app: {
|
||||
name: 'cache-lib',
|
||||
},
|
||||
service: {
|
||||
name: 'cache-lib',
|
||||
},
|
||||
},
|
||||
service: {
|
||||
name: 'cache-lib'
|
||||
}
|
||||
}, null, 2)
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
// Root .env file
|
||||
|
|
@ -401,4 +412,4 @@ WEBSHARE_API_KEY=demo-webshare-key
|
|||
DATA_SERVICE_RATE_LIMIT=1000
|
||||
`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,5 @@
|
|||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"references": [
|
||||
]
|
||||
}
|
||||
"references": []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
{
|
||||
"name": "@stock-bot/di",
|
||||
"version": "1.0.0",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@stock-bot/config": "workspace:*",
|
||||
"@stock-bot/logger": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pg": "^8.10.7"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "@stock-bot/di",
|
||||
"version": "1.0.0",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@stock-bot/config": "workspace:*",
|
||||
"@stock-bot/logger": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pg": "^8.10.7"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,294 +1,301 @@
|
|||
/**
|
||||
* Awilix DI Container Setup
|
||||
* Creates a decoupled, reusable dependency injection container
|
||||
*/
|
||||
|
||||
import { Browser } from '@stock-bot/browser';
|
||||
import { createCache, type CacheProvider } from '@stock-bot/cache';
|
||||
import type { IServiceContainer } from '@stock-bot/handlers';
|
||||
import { getLogger, 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 { z } from 'zod';
|
||||
|
||||
// Configuration schema with validation
|
||||
const appConfigSchema = z.object({
|
||||
redis: z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
host: z.string(),
|
||||
port: z.number(),
|
||||
password: z.string().optional(),
|
||||
username: z.string().optional(),
|
||||
db: z.number().optional(),
|
||||
}),
|
||||
mongodb: z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
uri: z.string(),
|
||||
database: z.string(),
|
||||
}),
|
||||
postgres: z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
host: z.string(),
|
||||
port: z.number(),
|
||||
database: z.string(),
|
||||
user: z.string(),
|
||||
password: z.string(),
|
||||
}),
|
||||
questdb: z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
host: z.string(),
|
||||
httpPort: z.number().optional(),
|
||||
pgPort: z.number().optional(),
|
||||
influxPort: z.number().optional(),
|
||||
database: z.string().optional(),
|
||||
}).optional(),
|
||||
proxy: z.object({
|
||||
cachePrefix: z.string().optional(),
|
||||
ttl: z.number().optional(),
|
||||
}).optional(),
|
||||
browser: z.object({
|
||||
headless: z.boolean().optional(),
|
||||
timeout: z.number().optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export type AppConfig = z.infer<typeof appConfigSchema>;
|
||||
|
||||
/**
|
||||
* Service type definitions for type-safe resolution
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: any = {
|
||||
// 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
|
||||
if (config.redis?.enabled !== false) {
|
||||
registrations.cache = asFunction(({ redisConfig, logger }) =>
|
||||
createCache({
|
||||
redisConfig,
|
||||
logger,
|
||||
keyPrefix: 'cache:',
|
||||
ttl: 3600,
|
||||
enableMetrics: true,
|
||||
})
|
||||
).singleton();
|
||||
} else {
|
||||
registrations.cache = asValue(null);
|
||||
}
|
||||
|
||||
// Proxy manager depends on cache
|
||||
registrations.proxyManager = asFunction(({ cache, config, logger }) => {
|
||||
if (!cache) {
|
||||
logger.warn('Cache is disabled, ProxyManager will have limited functionality');
|
||||
return null;
|
||||
}
|
||||
const manager = new ProxyManager(
|
||||
cache,
|
||||
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 }) => {
|
||||
console.log('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 - placeholder until decoupled from singleton
|
||||
registrations.queueManager = asFunction(({ redisConfig, cache, logger }) => {
|
||||
// Import dynamically to avoid circular dependency
|
||||
const { QueueManager } = require('@stock-bot/queue');
|
||||
|
||||
// Check if already initialized (singleton pattern)
|
||||
if (QueueManager.isInitialized()) {
|
||||
return QueueManager.getInstance();
|
||||
}
|
||||
|
||||
// Initialize if not already done
|
||||
return QueueManager.initialize({
|
||||
redis: { host: redisConfig.host, port: redisConfig.port, db: redisConfig.db },
|
||||
enableScheduledJobs: true,
|
||||
delayWorkerStart: true // We'll start workers manually
|
||||
});
|
||||
}).singleton();
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Export typed container
|
||||
export type ServiceContainer = AwilixContainer<ServiceDefinitions>;
|
||||
export type ServiceCradle = ServiceDefinitions;
|
||||
/**
|
||||
* Awilix DI Container Setup
|
||||
* Creates a decoupled, reusable dependency injection container
|
||||
*/
|
||||
|
||||
import { asFunction, asValue, createContainer, InjectionMode, type AwilixContainer } from 'awilix';
|
||||
import { z } from 'zod';
|
||||
import { Browser } from '@stock-bot/browser';
|
||||
import { createCache, type CacheProvider } from '@stock-bot/cache';
|
||||
import type { IServiceContainer } from '@stock-bot/handlers';
|
||||
import { getLogger, 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';
|
||||
|
||||
// Configuration schema with validation
|
||||
const appConfigSchema = z.object({
|
||||
redis: z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
host: z.string(),
|
||||
port: z.number(),
|
||||
password: z.string().optional(),
|
||||
username: z.string().optional(),
|
||||
db: z.number().optional(),
|
||||
}),
|
||||
mongodb: z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
uri: z.string(),
|
||||
database: z.string(),
|
||||
}),
|
||||
postgres: z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
host: z.string(),
|
||||
port: z.number(),
|
||||
database: z.string(),
|
||||
user: z.string(),
|
||||
password: z.string(),
|
||||
}),
|
||||
questdb: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
host: z.string(),
|
||||
httpPort: z.number().optional(),
|
||||
pgPort: z.number().optional(),
|
||||
influxPort: z.number().optional(),
|
||||
database: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
proxy: z
|
||||
.object({
|
||||
cachePrefix: z.string().optional(),
|
||||
ttl: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
browser: z
|
||||
.object({
|
||||
headless: z.boolean().optional(),
|
||||
timeout: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type AppConfig = z.infer<typeof appConfigSchema>;
|
||||
|
||||
/**
|
||||
* Service type definitions for type-safe resolution
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: any = {
|
||||
// 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
|
||||
if (config.redis?.enabled !== false) {
|
||||
registrations.cache = asFunction(({ redisConfig, logger }) =>
|
||||
createCache({
|
||||
redisConfig,
|
||||
logger,
|
||||
keyPrefix: 'cache:',
|
||||
ttl: 3600,
|
||||
enableMetrics: true,
|
||||
})
|
||||
).singleton();
|
||||
} else {
|
||||
registrations.cache = asValue(null);
|
||||
}
|
||||
|
||||
// Proxy manager depends on cache
|
||||
registrations.proxyManager = asFunction(({ cache, config, logger }) => {
|
||||
if (!cache) {
|
||||
logger.warn('Cache is disabled, ProxyManager will have limited functionality');
|
||||
return null;
|
||||
}
|
||||
const manager = new ProxyManager(cache, 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 }) => {
|
||||
console.log('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 - placeholder until decoupled from singleton
|
||||
registrations.queueManager = asFunction(({ redisConfig, cache, logger }) => {
|
||||
// Import dynamically to avoid circular dependency
|
||||
const { QueueManager } = require('@stock-bot/queue');
|
||||
|
||||
// Check if already initialized (singleton pattern)
|
||||
if (QueueManager.isInitialized()) {
|
||||
return QueueManager.getInstance();
|
||||
}
|
||||
|
||||
// Initialize if not already done
|
||||
return QueueManager.initialize({
|
||||
redis: { host: redisConfig.host, port: redisConfig.port, db: redisConfig.db },
|
||||
enableScheduledJobs: true,
|
||||
delayWorkerStart: true, // We'll start workers manually
|
||||
});
|
||||
}).singleton();
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Export typed container
|
||||
export type ServiceContainer = AwilixContainer<ServiceDefinitions>;
|
||||
export type ServiceCradle = ServiceDefinitions;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
// Export all dependency injection components
|
||||
export * from './operation-context';
|
||||
export * from './pool-size-calculator';
|
||||
export * from './types';
|
||||
|
||||
// Awilix container exports
|
||||
export {
|
||||
createServiceContainer,
|
||||
initializeServices,
|
||||
type AppConfig,
|
||||
type ServiceCradle,
|
||||
type ServiceContainer
|
||||
} from './awilix-container';
|
||||
// Export all dependency injection components
|
||||
export * from './operation-context';
|
||||
export * from './pool-size-calculator';
|
||||
export * from './types';
|
||||
|
||||
// Awilix container exports
|
||||
export {
|
||||
createServiceContainer,
|
||||
initializeServices,
|
||||
type AppConfig,
|
||||
type ServiceCradle,
|
||||
type ServiceContainer,
|
||||
} from './awilix-container';
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
*/
|
||||
|
||||
import { getLogger, type Logger } from '@stock-bot/logger';
|
||||
|
||||
interface ServiceResolver {
|
||||
resolve<T>(serviceName: string): T;
|
||||
resolveAsync<T>(serviceName: string): Promise<T>;
|
||||
|
|
@ -23,17 +24,19 @@ export class OperationContext {
|
|||
public readonly metadata: Record<string, any>;
|
||||
private readonly container?: ServiceResolver;
|
||||
private readonly startTime: Date;
|
||||
|
||||
|
||||
constructor(options: OperationContextOptions) {
|
||||
this.container = options.container;
|
||||
this.metadata = options.metadata || {};
|
||||
this.traceId = options.traceId || this.generateTraceId();
|
||||
this.startTime = new Date();
|
||||
|
||||
this.logger = options.parentLogger || getLogger(`${options.handlerName}:${options.operationName}`, {
|
||||
traceId: this.traceId,
|
||||
metadata: this.metadata,
|
||||
});
|
||||
|
||||
this.logger =
|
||||
options.parentLogger ||
|
||||
getLogger(`${options.handlerName}:${options.operationName}`, {
|
||||
traceId: this.traceId,
|
||||
metadata: this.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -42,8 +45,8 @@ export class OperationContext {
|
|||
static create(
|
||||
handlerName: string,
|
||||
operationName: string,
|
||||
options: {
|
||||
container?: ServiceResolver;
|
||||
options: {
|
||||
container?: ServiceResolver;
|
||||
parentLogger?: Logger;
|
||||
metadata?: Record<string, any>;
|
||||
traceId?: string;
|
||||
|
|
@ -95,7 +98,7 @@ export class OperationContext {
|
|||
*/
|
||||
logCompletion(success: boolean, error?: Error): void {
|
||||
const executionTime = this.getExecutionTime();
|
||||
|
||||
|
||||
if (success) {
|
||||
this.logger.info('Operation completed successfully', {
|
||||
executionTime,
|
||||
|
|
@ -138,4 +141,4 @@ export class OperationContext {
|
|||
private generateTraceId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,80 +1,82 @@
|
|||
import type { ConnectionPoolConfig } from './types';
|
||||
|
||||
export interface PoolSizeRecommendation {
|
||||
min: number;
|
||||
max: number;
|
||||
idle: number;
|
||||
}
|
||||
|
||||
export class PoolSizeCalculator {
|
||||
private static readonly DEFAULT_SIZES: Record<string, PoolSizeRecommendation> = {
|
||||
// Service-level defaults
|
||||
'data-ingestion': { min: 5, max: 50, idle: 10 },
|
||||
'data-pipeline': { min: 3, max: 30, idle: 5 },
|
||||
'processing-service': { min: 2, max: 20, idle: 3 },
|
||||
'web-api': { min: 2, max: 10, idle: 2 },
|
||||
'portfolio-service': { min: 2, max: 15, idle: 3 },
|
||||
'strategy-service': { min: 3, max: 25, idle: 5 },
|
||||
'execution-service': { min: 2, max: 10, idle: 2 },
|
||||
|
||||
// Handler-level defaults
|
||||
'batch-import': { min: 10, max: 100, idle: 20 },
|
||||
'real-time': { min: 2, max: 10, idle: 3 },
|
||||
'analytics': { min: 5, max: 30, idle: 10 },
|
||||
'reporting': { min: 3, max: 20, idle: 5 },
|
||||
};
|
||||
|
||||
static calculate(
|
||||
serviceName: string,
|
||||
handlerName?: string,
|
||||
customConfig?: Partial<ConnectionPoolConfig>
|
||||
): PoolSizeRecommendation {
|
||||
// Check for custom configuration first
|
||||
if (customConfig?.minConnections && customConfig?.maxConnections) {
|
||||
return {
|
||||
min: customConfig.minConnections,
|
||||
max: customConfig.maxConnections,
|
||||
idle: Math.floor((customConfig.minConnections + customConfig.maxConnections) / 4),
|
||||
};
|
||||
}
|
||||
|
||||
// Try handler-specific sizes first, then service-level
|
||||
const key = handlerName || serviceName;
|
||||
const recommendation = this.DEFAULT_SIZES[key] || this.DEFAULT_SIZES[serviceName];
|
||||
|
||||
if (recommendation) {
|
||||
return { ...recommendation };
|
||||
}
|
||||
|
||||
// Fall back to generic defaults
|
||||
return {
|
||||
min: 2,
|
||||
max: 10,
|
||||
idle: 3,
|
||||
};
|
||||
}
|
||||
|
||||
static getOptimalPoolSize(
|
||||
expectedConcurrency: number,
|
||||
averageQueryTimeMs: number,
|
||||
targetLatencyMs: number
|
||||
): number {
|
||||
// Little's Law: L = λ * W
|
||||
// L = number of connections needed
|
||||
// λ = arrival rate (requests per second)
|
||||
// W = average time in system (seconds)
|
||||
|
||||
const requestsPerSecond = expectedConcurrency;
|
||||
const averageTimeInSystem = averageQueryTimeMs / 1000;
|
||||
|
||||
const minConnections = Math.ceil(requestsPerSecond * averageTimeInSystem);
|
||||
|
||||
// Add buffer for burst traffic (20% overhead)
|
||||
const recommendedSize = Math.ceil(minConnections * 1.2);
|
||||
|
||||
// Ensure we meet target latency
|
||||
const latencyBasedSize = Math.ceil(expectedConcurrency * (averageQueryTimeMs / targetLatencyMs));
|
||||
|
||||
return Math.max(recommendedSize, latencyBasedSize, 2); // Minimum 2 connections
|
||||
}
|
||||
}
|
||||
import type { ConnectionPoolConfig } from './types';
|
||||
|
||||
export interface PoolSizeRecommendation {
|
||||
min: number;
|
||||
max: number;
|
||||
idle: number;
|
||||
}
|
||||
|
||||
export class PoolSizeCalculator {
|
||||
private static readonly DEFAULT_SIZES: Record<string, PoolSizeRecommendation> = {
|
||||
// Service-level defaults
|
||||
'data-ingestion': { min: 5, max: 50, idle: 10 },
|
||||
'data-pipeline': { min: 3, max: 30, idle: 5 },
|
||||
'processing-service': { min: 2, max: 20, idle: 3 },
|
||||
'web-api': { min: 2, max: 10, idle: 2 },
|
||||
'portfolio-service': { min: 2, max: 15, idle: 3 },
|
||||
'strategy-service': { min: 3, max: 25, idle: 5 },
|
||||
'execution-service': { min: 2, max: 10, idle: 2 },
|
||||
|
||||
// Handler-level defaults
|
||||
'batch-import': { min: 10, max: 100, idle: 20 },
|
||||
'real-time': { min: 2, max: 10, idle: 3 },
|
||||
analytics: { min: 5, max: 30, idle: 10 },
|
||||
reporting: { min: 3, max: 20, idle: 5 },
|
||||
};
|
||||
|
||||
static calculate(
|
||||
serviceName: string,
|
||||
handlerName?: string,
|
||||
customConfig?: Partial<ConnectionPoolConfig>
|
||||
): PoolSizeRecommendation {
|
||||
// Check for custom configuration first
|
||||
if (customConfig?.minConnections && customConfig?.maxConnections) {
|
||||
return {
|
||||
min: customConfig.minConnections,
|
||||
max: customConfig.maxConnections,
|
||||
idle: Math.floor((customConfig.minConnections + customConfig.maxConnections) / 4),
|
||||
};
|
||||
}
|
||||
|
||||
// Try handler-specific sizes first, then service-level
|
||||
const key = handlerName || serviceName;
|
||||
const recommendation = this.DEFAULT_SIZES[key] || this.DEFAULT_SIZES[serviceName];
|
||||
|
||||
if (recommendation) {
|
||||
return { ...recommendation };
|
||||
}
|
||||
|
||||
// Fall back to generic defaults
|
||||
return {
|
||||
min: 2,
|
||||
max: 10,
|
||||
idle: 3,
|
||||
};
|
||||
}
|
||||
|
||||
static getOptimalPoolSize(
|
||||
expectedConcurrency: number,
|
||||
averageQueryTimeMs: number,
|
||||
targetLatencyMs: number
|
||||
): number {
|
||||
// Little's Law: L = λ * W
|
||||
// L = number of connections needed
|
||||
// λ = arrival rate (requests per second)
|
||||
// W = average time in system (seconds)
|
||||
|
||||
const requestsPerSecond = expectedConcurrency;
|
||||
const averageTimeInSystem = averageQueryTimeMs / 1000;
|
||||
|
||||
const minConnections = Math.ceil(requestsPerSecond * averageTimeInSystem);
|
||||
|
||||
// Add buffer for burst traffic (20% overhead)
|
||||
const recommendedSize = Math.ceil(minConnections * 1.2);
|
||||
|
||||
// Ensure we meet target latency
|
||||
const latencyBasedSize = Math.ceil(
|
||||
expectedConcurrency * (averageQueryTimeMs / targetLatencyMs)
|
||||
);
|
||||
|
||||
return Math.max(recommendedSize, latencyBasedSize, 2); // Minimum 2 connections
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,68 +1,71 @@
|
|||
// Generic types to avoid circular dependencies
|
||||
export interface GenericClientConfig {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface ConnectionPoolConfig {
|
||||
name: string;
|
||||
poolSize?: number;
|
||||
minConnections?: number;
|
||||
maxConnections?: number;
|
||||
idleTimeoutMillis?: number;
|
||||
connectionTimeoutMillis?: number;
|
||||
enableMetrics?: boolean;
|
||||
}
|
||||
|
||||
export interface MongoDBPoolConfig extends ConnectionPoolConfig {
|
||||
config: GenericClientConfig;
|
||||
}
|
||||
|
||||
export interface PostgreSQLPoolConfig extends ConnectionPoolConfig {
|
||||
config: GenericClientConfig;
|
||||
}
|
||||
|
||||
export interface CachePoolConfig extends ConnectionPoolConfig {
|
||||
config: GenericClientConfig;
|
||||
}
|
||||
|
||||
export interface QueuePoolConfig extends ConnectionPoolConfig {
|
||||
config: GenericClientConfig;
|
||||
}
|
||||
|
||||
export interface ConnectionFactoryConfig {
|
||||
service: string;
|
||||
environment: 'development' | 'production' | 'test';
|
||||
pools?: {
|
||||
mongodb?: Partial<MongoDBPoolConfig>;
|
||||
postgres?: Partial<PostgreSQLPoolConfig>;
|
||||
cache?: Partial<CachePoolConfig>;
|
||||
queue?: Partial<QueuePoolConfig>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConnectionPool<T> {
|
||||
name: string;
|
||||
client: T;
|
||||
metrics: PoolMetrics;
|
||||
health(): Promise<boolean>;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PoolMetrics {
|
||||
created: Date;
|
||||
totalConnections: number;
|
||||
activeConnections: number;
|
||||
idleConnections: number;
|
||||
waitingRequests: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
export interface ConnectionFactory {
|
||||
createMongoDB(config: MongoDBPoolConfig): Promise<ConnectionPool<any>>;
|
||||
createPostgreSQL(config: PostgreSQLPoolConfig): Promise<ConnectionPool<any>>;
|
||||
createCache(config: CachePoolConfig): Promise<ConnectionPool<any>>;
|
||||
createQueue(config: QueuePoolConfig): Promise<ConnectionPool<any>>;
|
||||
getPool(type: 'mongodb' | 'postgres' | 'cache' | 'queue', name: string): ConnectionPool<any> | undefined;
|
||||
listPools(): Array<{ type: string; name: string; metrics: PoolMetrics }>;
|
||||
disposeAll(): Promise<void>;
|
||||
}
|
||||
// Generic types to avoid circular dependencies
|
||||
export interface GenericClientConfig {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface ConnectionPoolConfig {
|
||||
name: string;
|
||||
poolSize?: number;
|
||||
minConnections?: number;
|
||||
maxConnections?: number;
|
||||
idleTimeoutMillis?: number;
|
||||
connectionTimeoutMillis?: number;
|
||||
enableMetrics?: boolean;
|
||||
}
|
||||
|
||||
export interface MongoDBPoolConfig extends ConnectionPoolConfig {
|
||||
config: GenericClientConfig;
|
||||
}
|
||||
|
||||
export interface PostgreSQLPoolConfig extends ConnectionPoolConfig {
|
||||
config: GenericClientConfig;
|
||||
}
|
||||
|
||||
export interface CachePoolConfig extends ConnectionPoolConfig {
|
||||
config: GenericClientConfig;
|
||||
}
|
||||
|
||||
export interface QueuePoolConfig extends ConnectionPoolConfig {
|
||||
config: GenericClientConfig;
|
||||
}
|
||||
|
||||
export interface ConnectionFactoryConfig {
|
||||
service: string;
|
||||
environment: 'development' | 'production' | 'test';
|
||||
pools?: {
|
||||
mongodb?: Partial<MongoDBPoolConfig>;
|
||||
postgres?: Partial<PostgreSQLPoolConfig>;
|
||||
cache?: Partial<CachePoolConfig>;
|
||||
queue?: Partial<QueuePoolConfig>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConnectionPool<T> {
|
||||
name: string;
|
||||
client: T;
|
||||
metrics: PoolMetrics;
|
||||
health(): Promise<boolean>;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PoolMetrics {
|
||||
created: Date;
|
||||
totalConnections: number;
|
||||
activeConnections: number;
|
||||
idleConnections: number;
|
||||
waitingRequests: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
export interface ConnectionFactory {
|
||||
createMongoDB(config: MongoDBPoolConfig): Promise<ConnectionPool<any>>;
|
||||
createPostgreSQL(config: PostgreSQLPoolConfig): Promise<ConnectionPool<any>>;
|
||||
createCache(config: CachePoolConfig): Promise<ConnectionPool<any>>;
|
||||
createQueue(config: QueuePoolConfig): Promise<ConnectionPool<any>>;
|
||||
getPool(
|
||||
type: 'mongodb' | 'postgres' | 'cache' | 'queue',
|
||||
name: string
|
||||
): ConnectionPool<any> | undefined;
|
||||
listPools(): Array<{ type: string; name: string; metrics: PoolMetrics }>;
|
||||
disposeAll(): Promise<void>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,178 +1,183 @@
|
|||
/**
|
||||
* Test DI library functionality
|
||||
*/
|
||||
import { test, expect, describe } from 'bun:test';
|
||||
import { ServiceContainer, ConnectionFactory, OperationContext, PoolSizeCalculator } from '../src/index';
|
||||
|
||||
describe('DI Library', () => {
|
||||
test('ServiceContainer - sync resolution', () => {
|
||||
const container = new ServiceContainer('test');
|
||||
|
||||
container.register({
|
||||
name: 'testService',
|
||||
factory: () => ({ value: 'test' }),
|
||||
singleton: true,
|
||||
});
|
||||
|
||||
const service = container.resolve<{ value: string }>('testService');
|
||||
expect(service.value).toBe('test');
|
||||
});
|
||||
|
||||
test('ServiceContainer - async resolution', async () => {
|
||||
const container = new ServiceContainer('test');
|
||||
|
||||
container.register({
|
||||
name: 'asyncService',
|
||||
factory: async () => ({ value: 'async-test' }),
|
||||
singleton: true,
|
||||
});
|
||||
|
||||
const service = await container.resolveAsync<{ value: string }>('asyncService');
|
||||
expect(service.value).toBe('async-test');
|
||||
});
|
||||
|
||||
test('ServiceContainer - scoped container', () => {
|
||||
const container = new ServiceContainer('test');
|
||||
|
||||
container.register({
|
||||
name: 'testService',
|
||||
factory: () => ({ value: 'test' }),
|
||||
singleton: true,
|
||||
});
|
||||
|
||||
const scopedContainer = container.createScope();
|
||||
const service = scopedContainer.resolve<{ value: string }>('testService');
|
||||
expect(service.value).toBe('test');
|
||||
});
|
||||
|
||||
test('ServiceContainer - error on unregistered service', () => {
|
||||
const container = new ServiceContainer('test');
|
||||
|
||||
expect(() => {
|
||||
container.resolve('nonexistent');
|
||||
}).toThrow('Service nonexistent not registered');
|
||||
});
|
||||
|
||||
test('ServiceContainer - async service throws error on sync resolve', () => {
|
||||
const container = new ServiceContainer('test');
|
||||
|
||||
container.register({
|
||||
name: 'asyncService',
|
||||
factory: async () => ({ value: 'async' }),
|
||||
singleton: true,
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
container.resolve('asyncService');
|
||||
}).toThrow('Service asyncService is async. Use resolveAsync() instead.');
|
||||
});
|
||||
|
||||
test('ServiceContainer - disposal', async () => {
|
||||
const container = new ServiceContainer('test');
|
||||
let disposed = false;
|
||||
|
||||
container.register({
|
||||
name: 'disposableService',
|
||||
factory: () => ({ value: 'test' }),
|
||||
singleton: true,
|
||||
dispose: async () => {
|
||||
disposed = true;
|
||||
},
|
||||
});
|
||||
|
||||
// Create instance
|
||||
container.resolve('disposableService');
|
||||
|
||||
// Dispose container
|
||||
await container.dispose();
|
||||
expect(disposed).toBe(true);
|
||||
});
|
||||
|
||||
test('OperationContext - enhanced functionality', () => {
|
||||
const container = new ServiceContainer('test');
|
||||
const context = OperationContext.create('test-handler', 'test-operation', {
|
||||
container,
|
||||
metadata: { userId: '123' },
|
||||
});
|
||||
|
||||
expect(context).toBeDefined();
|
||||
expect(context.logger).toBeDefined();
|
||||
expect(context.traceId).toBeDefined();
|
||||
expect(context.metadata.userId).toBe('123');
|
||||
expect(context.getExecutionTime()).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test('OperationContext - service resolution', () => {
|
||||
const container = new ServiceContainer('test');
|
||||
|
||||
container.register({
|
||||
name: 'testService',
|
||||
factory: () => ({ value: 'resolved' }),
|
||||
singleton: true,
|
||||
});
|
||||
|
||||
const context = OperationContext.create('test-handler', 'test-operation', {
|
||||
container,
|
||||
});
|
||||
|
||||
const service = context.resolve<{ value: string }>('testService');
|
||||
expect(service.value).toBe('resolved');
|
||||
});
|
||||
|
||||
test('ConnectionFactory - creation', () => {
|
||||
const factory = new ConnectionFactory({
|
||||
service: 'test',
|
||||
environment: 'development',
|
||||
});
|
||||
|
||||
expect(factory).toBeDefined();
|
||||
expect(factory.listPools()).toEqual([]);
|
||||
});
|
||||
|
||||
test('OperationContext - creation', () => {
|
||||
const container = new ServiceContainer('test');
|
||||
const context = OperationContext.create('test-handler', 'test-operation', {
|
||||
container,
|
||||
});
|
||||
|
||||
expect(context).toBeDefined();
|
||||
expect(context.logger).toBeDefined();
|
||||
});
|
||||
|
||||
test('OperationContext - child context', () => {
|
||||
const context = OperationContext.create('test-handler', 'test-operation');
|
||||
const child = context.createChild('child-operation');
|
||||
|
||||
expect(child).toBeDefined();
|
||||
expect(child.logger).toBeDefined();
|
||||
});
|
||||
|
||||
test('PoolSizeCalculator - service defaults', () => {
|
||||
const poolSize = PoolSizeCalculator.calculate('data-ingestion');
|
||||
expect(poolSize).toEqual({ min: 5, max: 50, idle: 10 });
|
||||
});
|
||||
|
||||
test('PoolSizeCalculator - handler defaults', () => {
|
||||
const poolSize = PoolSizeCalculator.calculate('unknown-service', 'batch-import');
|
||||
expect(poolSize).toEqual({ min: 10, max: 100, idle: 20 });
|
||||
});
|
||||
|
||||
test('PoolSizeCalculator - fallback defaults', () => {
|
||||
const poolSize = PoolSizeCalculator.calculate('unknown-service', 'unknown-handler');
|
||||
expect(poolSize).toEqual({ min: 2, max: 10, idle: 3 });
|
||||
});
|
||||
|
||||
test('PoolSizeCalculator - custom config', () => {
|
||||
const poolSize = PoolSizeCalculator.calculate('test-service', undefined, {
|
||||
minConnections: 5,
|
||||
maxConnections: 15,
|
||||
});
|
||||
expect(poolSize).toEqual({ min: 5, max: 15, idle: 5 });
|
||||
});
|
||||
|
||||
test('PoolSizeCalculator - optimal size calculation', () => {
|
||||
const optimalSize = PoolSizeCalculator.getOptimalPoolSize(10, 100, 50);
|
||||
expect(optimalSize).toBeGreaterThan(0);
|
||||
expect(typeof optimalSize).toBe('number');
|
||||
});
|
||||
});
|
||||
/**
|
||||
* Test DI library functionality
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
ConnectionFactory,
|
||||
OperationContext,
|
||||
PoolSizeCalculator,
|
||||
ServiceContainer,
|
||||
} from '../src/index';
|
||||
|
||||
describe('DI Library', () => {
|
||||
test('ServiceContainer - sync resolution', () => {
|
||||
const container = new ServiceContainer('test');
|
||||
|
||||
container.register({
|
||||
name: 'testService',
|
||||
factory: () => ({ value: 'test' }),
|
||||
singleton: true,
|
||||
});
|
||||
|
||||
const service = container.resolve<{ value: string }>('testService');
|
||||
expect(service.value).toBe('test');
|
||||
});
|
||||
|
||||
test('ServiceContainer - async resolution', async () => {
|
||||
const container = new ServiceContainer('test');
|
||||
|
||||
container.register({
|
||||
name: 'asyncService',
|
||||
factory: async () => ({ value: 'async-test' }),
|
||||
singleton: true,
|
||||
});
|
||||
|
||||
const service = await container.resolveAsync<{ value: string }>('asyncService');
|
||||
expect(service.value).toBe('async-test');
|
||||
});
|
||||
|
||||
test('ServiceContainer - scoped container', () => {
|
||||
const container = new ServiceContainer('test');
|
||||
|
||||
container.register({
|
||||
name: 'testService',
|
||||
factory: () => ({ value: 'test' }),
|
||||
singleton: true,
|
||||
});
|
||||
|
||||
const scopedContainer = container.createScope();
|
||||
const service = scopedContainer.resolve<{ value: string }>('testService');
|
||||
expect(service.value).toBe('test');
|
||||
});
|
||||
|
||||
test('ServiceContainer - error on unregistered service', () => {
|
||||
const container = new ServiceContainer('test');
|
||||
|
||||
expect(() => {
|
||||
container.resolve('nonexistent');
|
||||
}).toThrow('Service nonexistent not registered');
|
||||
});
|
||||
|
||||
test('ServiceContainer - async service throws error on sync resolve', () => {
|
||||
const container = new ServiceContainer('test');
|
||||
|
||||
container.register({
|
||||
name: 'asyncService',
|
||||
factory: async () => ({ value: 'async' }),
|
||||
singleton: true,
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
container.resolve('asyncService');
|
||||
}).toThrow('Service asyncService is async. Use resolveAsync() instead.');
|
||||
});
|
||||
|
||||
test('ServiceContainer - disposal', async () => {
|
||||
const container = new ServiceContainer('test');
|
||||
let disposed = false;
|
||||
|
||||
container.register({
|
||||
name: 'disposableService',
|
||||
factory: () => ({ value: 'test' }),
|
||||
singleton: true,
|
||||
dispose: async () => {
|
||||
disposed = true;
|
||||
},
|
||||
});
|
||||
|
||||
// Create instance
|
||||
container.resolve('disposableService');
|
||||
|
||||
// Dispose container
|
||||
await container.dispose();
|
||||
expect(disposed).toBe(true);
|
||||
});
|
||||
|
||||
test('OperationContext - enhanced functionality', () => {
|
||||
const container = new ServiceContainer('test');
|
||||
const context = OperationContext.create('test-handler', 'test-operation', {
|
||||
container,
|
||||
metadata: { userId: '123' },
|
||||
});
|
||||
|
||||
expect(context).toBeDefined();
|
||||
expect(context.logger).toBeDefined();
|
||||
expect(context.traceId).toBeDefined();
|
||||
expect(context.metadata.userId).toBe('123');
|
||||
expect(context.getExecutionTime()).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test('OperationContext - service resolution', () => {
|
||||
const container = new ServiceContainer('test');
|
||||
|
||||
container.register({
|
||||
name: 'testService',
|
||||
factory: () => ({ value: 'resolved' }),
|
||||
singleton: true,
|
||||
});
|
||||
|
||||
const context = OperationContext.create('test-handler', 'test-operation', {
|
||||
container,
|
||||
});
|
||||
|
||||
const service = context.resolve<{ value: string }>('testService');
|
||||
expect(service.value).toBe('resolved');
|
||||
});
|
||||
|
||||
test('ConnectionFactory - creation', () => {
|
||||
const factory = new ConnectionFactory({
|
||||
service: 'test',
|
||||
environment: 'development',
|
||||
});
|
||||
|
||||
expect(factory).toBeDefined();
|
||||
expect(factory.listPools()).toEqual([]);
|
||||
});
|
||||
|
||||
test('OperationContext - creation', () => {
|
||||
const container = new ServiceContainer('test');
|
||||
const context = OperationContext.create('test-handler', 'test-operation', {
|
||||
container,
|
||||
});
|
||||
|
||||
expect(context).toBeDefined();
|
||||
expect(context.logger).toBeDefined();
|
||||
});
|
||||
|
||||
test('OperationContext - child context', () => {
|
||||
const context = OperationContext.create('test-handler', 'test-operation');
|
||||
const child = context.createChild('child-operation');
|
||||
|
||||
expect(child).toBeDefined();
|
||||
expect(child.logger).toBeDefined();
|
||||
});
|
||||
|
||||
test('PoolSizeCalculator - service defaults', () => {
|
||||
const poolSize = PoolSizeCalculator.calculate('data-ingestion');
|
||||
expect(poolSize).toEqual({ min: 5, max: 50, idle: 10 });
|
||||
});
|
||||
|
||||
test('PoolSizeCalculator - handler defaults', () => {
|
||||
const poolSize = PoolSizeCalculator.calculate('unknown-service', 'batch-import');
|
||||
expect(poolSize).toEqual({ min: 10, max: 100, idle: 20 });
|
||||
});
|
||||
|
||||
test('PoolSizeCalculator - fallback defaults', () => {
|
||||
const poolSize = PoolSizeCalculator.calculate('unknown-service', 'unknown-handler');
|
||||
expect(poolSize).toEqual({ min: 2, max: 10, idle: 3 });
|
||||
});
|
||||
|
||||
test('PoolSizeCalculator - custom config', () => {
|
||||
const poolSize = PoolSizeCalculator.calculate('test-service', undefined, {
|
||||
minConnections: 5,
|
||||
maxConnections: 15,
|
||||
});
|
||||
expect(poolSize).toEqual({ min: 5, max: 15, idle: 5 });
|
||||
});
|
||||
|
||||
test('PoolSizeCalculator - optimal size calculation', () => {
|
||||
const optimalSize = PoolSizeCalculator.getOptimalPoolSize(10, 100, 50);
|
||||
expect(optimalSize).toBeGreaterThan(0);
|
||||
expect(typeof optimalSize).toBe('number');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,17 +1,14 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"types": ["node", "bun-types"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "test"],
|
||||
"references": [
|
||||
{ "path": "../config" },
|
||||
{ "path": "../logger" }
|
||||
]
|
||||
}
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"types": ["node", "bun-types"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "test"],
|
||||
"references": [{ "path": "../config" }, { "path": "../logger" }]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,23 @@
|
|||
{
|
||||
"name": "@stock-bot/handlers",
|
||||
"version": "1.0.0",
|
||||
"description": "Universal handler system for queue and event-driven operations",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rimraf dist",
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@stock-bot/config": "workspace:*",
|
||||
"@stock-bot/logger": "workspace:*",
|
||||
"@stock-bot/types": "workspace:*",
|
||||
"@stock-bot/di": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.0",
|
||||
"typescript": "^5.3.0",
|
||||
"bun-types": "^1.2.15"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "@stock-bot/handlers",
|
||||
"version": "1.0.0",
|
||||
"description": "Universal handler system for queue and event-driven operations",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rimraf dist",
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@stock-bot/config": "workspace:*",
|
||||
"@stock-bot/logger": "workspace:*",
|
||||
"@stock-bot/types": "workspace:*",
|
||||
"@stock-bot/di": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.0",
|
||||
"typescript": "^5.3.0",
|
||||
"bun-types": "^1.2.15"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,297 +1,307 @@
|
|||
import { getLogger } from '@stock-bot/logger';
|
||||
import { createJobHandler, handlerRegistry, type HandlerConfigWithSchedule } from '@stock-bot/types';
|
||||
import { fetch } from '@stock-bot/utils';
|
||||
import type { Collection } from 'mongodb';
|
||||
import type { IServiceContainer } from '../types/service-container';
|
||||
import type { ExecutionContext, IHandler } from '../types/types';
|
||||
|
||||
/**
|
||||
* Abstract base class for all handlers with improved DI
|
||||
* Provides common functionality and structure for queue/event operations
|
||||
*/
|
||||
export abstract class BaseHandler implements IHandler {
|
||||
// Direct service properties - flattened for cleaner access
|
||||
readonly logger;
|
||||
readonly cache;
|
||||
readonly queue;
|
||||
readonly proxy;
|
||||
readonly browser;
|
||||
readonly mongodb;
|
||||
readonly postgres;
|
||||
readonly questdb;
|
||||
|
||||
private handlerName: string;
|
||||
|
||||
constructor(services: IServiceContainer, handlerName?: string) {
|
||||
// Flatten all services onto the handler instance
|
||||
this.logger = getLogger(this.constructor.name);
|
||||
this.cache = services.cache;
|
||||
this.queue = services.queue;
|
||||
this.proxy = services.proxy;
|
||||
this.browser = services.browser;
|
||||
this.mongodb = services.mongodb;
|
||||
this.postgres = services.postgres;
|
||||
this.questdb = services.questdb;
|
||||
|
||||
// Read handler name from decorator first, then fallback to parameter or class name
|
||||
const constructor = this.constructor as any;
|
||||
this.handlerName = constructor.__handlerName || handlerName || this.constructor.name.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Main execution method - automatically routes to decorated methods
|
||||
* Works with queue (events commented for future)
|
||||
*/
|
||||
async execute(operation: string, input: unknown, context: ExecutionContext): Promise<unknown> {
|
||||
const constructor = this.constructor as any;
|
||||
const operations = constructor.__operations || [];
|
||||
|
||||
// Debug logging
|
||||
this.logger.debug('Handler execute called', {
|
||||
handler: this.handlerName,
|
||||
operation,
|
||||
availableOperations: operations.map((op: any) => ({ name: op.name, method: op.method }))
|
||||
});
|
||||
|
||||
// Find the operation metadata
|
||||
const operationMeta = operations.find((op: any) => op.name === operation);
|
||||
if (!operationMeta) {
|
||||
this.logger.error('Operation not found', {
|
||||
requestedOperation: operation,
|
||||
availableOperations: operations.map((op: any) => op.name)
|
||||
});
|
||||
throw new Error(`Unknown operation: ${operation}`);
|
||||
}
|
||||
|
||||
// Get the method from the instance and call it
|
||||
const method = (this as any)[operationMeta.method];
|
||||
if (typeof method !== 'function') {
|
||||
throw new Error(`Operation method '${operationMeta.method}' not found on handler`);
|
||||
}
|
||||
|
||||
this.logger.debug('Executing operation method', {
|
||||
operation,
|
||||
method: operationMeta.method
|
||||
});
|
||||
|
||||
return await method.call(this, input, context);
|
||||
}
|
||||
|
||||
async scheduleOperation(operation: string, payload: unknown, delay?: number): Promise<void> {
|
||||
if (!this.queue) {
|
||||
throw new Error('Queue service is not available');
|
||||
}
|
||||
const queue = this.queue.getQueue(this.handlerName);
|
||||
const jobData = {
|
||||
handler: this.handlerName,
|
||||
operation,
|
||||
payload
|
||||
};
|
||||
await queue.add(operation, jobData, { delay });
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Create execution context for operations
|
||||
*/
|
||||
protected createExecutionContext(type: 'http' | 'queue' | 'scheduled', metadata: Record<string, any> = {}): ExecutionContext {
|
||||
return {
|
||||
type,
|
||||
metadata: {
|
||||
...metadata,
|
||||
timestamp: Date.now(),
|
||||
traceId: `${this.constructor.name}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper methods for common operations
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get a MongoDB collection with type safety
|
||||
*/
|
||||
protected collection<T extends {} = any>(name: string): Collection<T> {
|
||||
if (!this.mongodb) {
|
||||
throw new Error('MongoDB service is not available');
|
||||
}
|
||||
return this.mongodb.collection(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cache with handler-prefixed key
|
||||
*/
|
||||
protected async cacheSet(key: string, value: any, ttl?: number): Promise<void> {
|
||||
if (!this.cache) {
|
||||
return;
|
||||
}
|
||||
return this.cache.set(`${this.handlerName}:${key}`, value, ttl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache with handler-prefixed key
|
||||
*/
|
||||
protected async cacheGet<T = any>(key: string): Promise<T | null> {
|
||||
if (!this.cache) {
|
||||
return null;
|
||||
}
|
||||
return this.cache.get(`${this.handlerName}:${key}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete cache with handler-prefixed key
|
||||
*/
|
||||
protected async cacheDel(key: string): Promise<void> {
|
||||
if (!this.cache) {
|
||||
return;
|
||||
}
|
||||
return this.cache.del(`${this.handlerName}:${key}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule operation with delay in seconds
|
||||
*/
|
||||
protected async scheduleIn(operation: string, payload: unknown, delaySeconds: number): Promise<void> {
|
||||
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 });
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP client helper using fetch from utils
|
||||
*/
|
||||
protected get http() {
|
||||
return {
|
||||
get: (url: string, options?: any) =>
|
||||
fetch(url, { ...options, method: 'GET', logger: this.logger }),
|
||||
post: (url: string, data?: any, options?: any) =>
|
||||
fetch(url, {
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
logger: this.logger
|
||||
}),
|
||||
put: (url: string, data?: any, options?: any) =>
|
||||
fetch(url, {
|
||||
...options,
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
logger: this.logger
|
||||
}),
|
||||
delete: (url: string, options?: any) =>
|
||||
fetch(url, { ...options, method: 'DELETE', logger: this.logger }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a service is available
|
||||
*/
|
||||
protected hasService(name: keyof IServiceContainer): boolean {
|
||||
const service = this[name as keyof this];
|
||||
return service !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event methods - commented for future
|
||||
*/
|
||||
// protected async publishEvent(eventName: string, payload: unknown): Promise<void> {
|
||||
// const eventBus = await this.container.resolveAsync('eventBus');
|
||||
// await eventBus.publish(eventName, payload);
|
||||
// }
|
||||
|
||||
/**
|
||||
* Register this handler using decorator metadata
|
||||
* Automatically reads @Handler, @Operation, and @QueueSchedule decorators
|
||||
*/
|
||||
register(): void {
|
||||
const constructor = this.constructor as any;
|
||||
const handlerName = constructor.__handlerName || this.handlerName;
|
||||
const operations = constructor.__operations || [];
|
||||
const schedules = constructor.__schedules || [];
|
||||
|
||||
// Create operation handlers from decorator metadata
|
||||
const operationHandlers: Record<string, any> = {};
|
||||
for (const op of operations) {
|
||||
operationHandlers[op.name] = createJobHandler(async (payload) => {
|
||||
const context: ExecutionContext = {
|
||||
type: 'queue',
|
||||
metadata: { source: 'queue', timestamp: Date.now() }
|
||||
};
|
||||
return await this.execute(op.name, payload, context);
|
||||
});
|
||||
}
|
||||
|
||||
// Create scheduled jobs from decorator metadata
|
||||
const scheduledJobs = schedules.map((schedule: any) => {
|
||||
// Find the operation name from the method name
|
||||
const operation = operations.find((op: any) => op.method === schedule.operation);
|
||||
return {
|
||||
type: `${handlerName}-${schedule.operation}`,
|
||||
operation: operation?.name || schedule.operation,
|
||||
cronPattern: schedule.cronPattern,
|
||||
priority: schedule.priority || 5,
|
||||
immediately: schedule.immediately || false,
|
||||
description: schedule.description || `${handlerName} ${schedule.operation}`,
|
||||
payload: this.getScheduledJobPayload?.(schedule.operation),
|
||||
};
|
||||
});
|
||||
|
||||
const config: HandlerConfigWithSchedule = {
|
||||
name: handlerName,
|
||||
operations: operationHandlers,
|
||||
scheduledJobs,
|
||||
};
|
||||
|
||||
handlerRegistry.registerWithSchedule(config);
|
||||
this.logger.info('Handler registered using decorator metadata', {
|
||||
handlerName,
|
||||
operations: operations.map((op: any) => ({ name: op.name, method: op.method })),
|
||||
scheduledJobs: scheduledJobs.map((job: any) => ({
|
||||
operation: job.operation,
|
||||
cronPattern: job.cronPattern,
|
||||
immediately: job.immediately
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Override this method to provide payloads for scheduled jobs
|
||||
* @param operation The operation name that needs a payload
|
||||
* @returns The payload for the scheduled job, or undefined
|
||||
*/
|
||||
protected getScheduledJobPayload?(operation: string): any;
|
||||
|
||||
/**
|
||||
* Lifecycle hooks - can be overridden by subclasses
|
||||
*/
|
||||
async onInit?(): Promise<void>;
|
||||
async onStart?(): Promise<void>;
|
||||
async onStop?(): Promise<void>;
|
||||
async onDispose?(): Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Specialized handler for operations that have scheduled jobs
|
||||
*/
|
||||
export abstract class ScheduledHandler extends BaseHandler {
|
||||
/**
|
||||
* Get scheduled job configurations for this handler
|
||||
* Override in subclasses to define schedules
|
||||
*/
|
||||
getScheduledJobs?(): Array<{
|
||||
operation: string;
|
||||
cronPattern: string;
|
||||
priority?: number;
|
||||
immediately?: boolean;
|
||||
description?: string;
|
||||
}>;
|
||||
}
|
||||
import type { Collection } from 'mongodb';
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
import {
|
||||
createJobHandler,
|
||||
handlerRegistry,
|
||||
type HandlerConfigWithSchedule,
|
||||
} from '@stock-bot/types';
|
||||
import { fetch } from '@stock-bot/utils';
|
||||
import type { IServiceContainer } from '../types/service-container';
|
||||
import type { ExecutionContext, IHandler } from '../types/types';
|
||||
|
||||
/**
|
||||
* Abstract base class for all handlers with improved DI
|
||||
* Provides common functionality and structure for queue/event operations
|
||||
*/
|
||||
export abstract class BaseHandler implements IHandler {
|
||||
// Direct service properties - flattened for cleaner access
|
||||
readonly logger;
|
||||
readonly cache;
|
||||
readonly queue;
|
||||
readonly proxy;
|
||||
readonly browser;
|
||||
readonly mongodb;
|
||||
readonly postgres;
|
||||
readonly questdb;
|
||||
|
||||
private handlerName: string;
|
||||
|
||||
constructor(services: IServiceContainer, handlerName?: string) {
|
||||
// Flatten all services onto the handler instance
|
||||
this.logger = getLogger(this.constructor.name);
|
||||
this.cache = services.cache;
|
||||
this.queue = services.queue;
|
||||
this.proxy = services.proxy;
|
||||
this.browser = services.browser;
|
||||
this.mongodb = services.mongodb;
|
||||
this.postgres = services.postgres;
|
||||
this.questdb = services.questdb;
|
||||
|
||||
// Read handler name from decorator first, then fallback to parameter or class name
|
||||
const constructor = this.constructor as any;
|
||||
this.handlerName =
|
||||
constructor.__handlerName || handlerName || this.constructor.name.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Main execution method - automatically routes to decorated methods
|
||||
* Works with queue (events commented for future)
|
||||
*/
|
||||
async execute(operation: string, input: unknown, context: ExecutionContext): Promise<unknown> {
|
||||
const constructor = this.constructor as any;
|
||||
const operations = constructor.__operations || [];
|
||||
|
||||
// Debug logging
|
||||
this.logger.debug('Handler execute called', {
|
||||
handler: this.handlerName,
|
||||
operation,
|
||||
availableOperations: operations.map((op: any) => ({ name: op.name, method: op.method })),
|
||||
});
|
||||
|
||||
// Find the operation metadata
|
||||
const operationMeta = operations.find((op: any) => op.name === operation);
|
||||
if (!operationMeta) {
|
||||
this.logger.error('Operation not found', {
|
||||
requestedOperation: operation,
|
||||
availableOperations: operations.map((op: any) => op.name),
|
||||
});
|
||||
throw new Error(`Unknown operation: ${operation}`);
|
||||
}
|
||||
|
||||
// Get the method from the instance and call it
|
||||
const method = (this as any)[operationMeta.method];
|
||||
if (typeof method !== 'function') {
|
||||
throw new Error(`Operation method '${operationMeta.method}' not found on handler`);
|
||||
}
|
||||
|
||||
this.logger.debug('Executing operation method', {
|
||||
operation,
|
||||
method: operationMeta.method,
|
||||
});
|
||||
|
||||
return await method.call(this, input, context);
|
||||
}
|
||||
|
||||
async scheduleOperation(operation: string, payload: unknown, delay?: number): Promise<void> {
|
||||
if (!this.queue) {
|
||||
throw new Error('Queue service is not available');
|
||||
}
|
||||
const queue = this.queue.getQueue(this.handlerName);
|
||||
const jobData = {
|
||||
handler: this.handlerName,
|
||||
operation,
|
||||
payload,
|
||||
};
|
||||
await queue.add(operation, jobData, { delay });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create execution context for operations
|
||||
*/
|
||||
protected createExecutionContext(
|
||||
type: 'http' | 'queue' | 'scheduled',
|
||||
metadata: Record<string, any> = {}
|
||||
): ExecutionContext {
|
||||
return {
|
||||
type,
|
||||
metadata: {
|
||||
...metadata,
|
||||
timestamp: Date.now(),
|
||||
traceId: `${this.constructor.name}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper methods for common operations
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get a MongoDB collection with type safety
|
||||
*/
|
||||
protected collection<T extends {} = any>(name: string): Collection<T> {
|
||||
if (!this.mongodb) {
|
||||
throw new Error('MongoDB service is not available');
|
||||
}
|
||||
return this.mongodb.collection(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cache with handler-prefixed key
|
||||
*/
|
||||
protected async cacheSet(key: string, value: any, ttl?: number): Promise<void> {
|
||||
if (!this.cache) {
|
||||
return;
|
||||
}
|
||||
return this.cache.set(`${this.handlerName}:${key}`, value, ttl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache with handler-prefixed key
|
||||
*/
|
||||
protected async cacheGet<T = any>(key: string): Promise<T | null> {
|
||||
if (!this.cache) {
|
||||
return null;
|
||||
}
|
||||
return this.cache.get(`${this.handlerName}:${key}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete cache with handler-prefixed key
|
||||
*/
|
||||
protected async cacheDel(key: string): Promise<void> {
|
||||
if (!this.cache) {
|
||||
return;
|
||||
}
|
||||
return this.cache.del(`${this.handlerName}:${key}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule operation with delay in seconds
|
||||
*/
|
||||
protected async scheduleIn(
|
||||
operation: string,
|
||||
payload: unknown,
|
||||
delaySeconds: number
|
||||
): Promise<void> {
|
||||
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 });
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP client helper using fetch from utils
|
||||
*/
|
||||
protected get http() {
|
||||
return {
|
||||
get: (url: string, options?: any) =>
|
||||
fetch(url, { ...options, method: 'GET', logger: this.logger }),
|
||||
post: (url: string, data?: any, options?: any) =>
|
||||
fetch(url, {
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
logger: this.logger,
|
||||
}),
|
||||
put: (url: string, data?: any, options?: any) =>
|
||||
fetch(url, {
|
||||
...options,
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
logger: this.logger,
|
||||
}),
|
||||
delete: (url: string, options?: any) =>
|
||||
fetch(url, { ...options, method: 'DELETE', logger: this.logger }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a service is available
|
||||
*/
|
||||
protected hasService(name: keyof IServiceContainer): boolean {
|
||||
const service = this[name as keyof this];
|
||||
return service !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event methods - commented for future
|
||||
*/
|
||||
// protected async publishEvent(eventName: string, payload: unknown): Promise<void> {
|
||||
// const eventBus = await this.container.resolveAsync('eventBus');
|
||||
// await eventBus.publish(eventName, payload);
|
||||
// }
|
||||
|
||||
/**
|
||||
* Register this handler using decorator metadata
|
||||
* Automatically reads @Handler, @Operation, and @QueueSchedule decorators
|
||||
*/
|
||||
register(): void {
|
||||
const constructor = this.constructor as any;
|
||||
const handlerName = constructor.__handlerName || this.handlerName;
|
||||
const operations = constructor.__operations || [];
|
||||
const schedules = constructor.__schedules || [];
|
||||
|
||||
// Create operation handlers from decorator metadata
|
||||
const operationHandlers: Record<string, any> = {};
|
||||
for (const op of operations) {
|
||||
operationHandlers[op.name] = createJobHandler(async payload => {
|
||||
const context: ExecutionContext = {
|
||||
type: 'queue',
|
||||
metadata: { source: 'queue', timestamp: Date.now() },
|
||||
};
|
||||
return await this.execute(op.name, payload, context);
|
||||
});
|
||||
}
|
||||
|
||||
// Create scheduled jobs from decorator metadata
|
||||
const scheduledJobs = schedules.map((schedule: any) => {
|
||||
// Find the operation name from the method name
|
||||
const operation = operations.find((op: any) => op.method === schedule.operation);
|
||||
return {
|
||||
type: `${handlerName}-${schedule.operation}`,
|
||||
operation: operation?.name || schedule.operation,
|
||||
cronPattern: schedule.cronPattern,
|
||||
priority: schedule.priority || 5,
|
||||
immediately: schedule.immediately || false,
|
||||
description: schedule.description || `${handlerName} ${schedule.operation}`,
|
||||
payload: this.getScheduledJobPayload?.(schedule.operation),
|
||||
};
|
||||
});
|
||||
|
||||
const config: HandlerConfigWithSchedule = {
|
||||
name: handlerName,
|
||||
operations: operationHandlers,
|
||||
scheduledJobs,
|
||||
};
|
||||
|
||||
handlerRegistry.registerWithSchedule(config);
|
||||
this.logger.info('Handler registered using decorator metadata', {
|
||||
handlerName,
|
||||
operations: operations.map((op: any) => ({ name: op.name, method: op.method })),
|
||||
scheduledJobs: scheduledJobs.map((job: any) => ({
|
||||
operation: job.operation,
|
||||
cronPattern: job.cronPattern,
|
||||
immediately: job.immediately,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Override this method to provide payloads for scheduled jobs
|
||||
* @param operation The operation name that needs a payload
|
||||
* @returns The payload for the scheduled job, or undefined
|
||||
*/
|
||||
protected getScheduledJobPayload?(operation: string): any;
|
||||
|
||||
/**
|
||||
* Lifecycle hooks - can be overridden by subclasses
|
||||
*/
|
||||
async onInit?(): Promise<void>;
|
||||
async onStart?(): Promise<void>;
|
||||
async onStop?(): Promise<void>;
|
||||
async onDispose?(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Specialized handler for operations that have scheduled jobs
|
||||
*/
|
||||
export abstract class ScheduledHandler extends BaseHandler {
|
||||
/**
|
||||
* Get scheduled job configurations for this handler
|
||||
* Override in subclasses to define schedules
|
||||
*/
|
||||
getScheduledJobs?(): Array<{
|
||||
operation: string;
|
||||
cronPattern: string;
|
||||
priority?: number;
|
||||
immediately?: boolean;
|
||||
description?: string;
|
||||
}>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,148 +1,130 @@
|
|||
// Bun-compatible decorators (hybrid approach)
|
||||
|
||||
/**
|
||||
* Handler decorator - marks a class as a handler
|
||||
* @param name Handler name for registration
|
||||
*/
|
||||
export function Handler(name: string) {
|
||||
return function <T extends { new (...args: any[]): {} }>(
|
||||
target: T,
|
||||
_context?: any
|
||||
) {
|
||||
// Store handler name on the constructor
|
||||
(target as any).__handlerName = name;
|
||||
(target as any).__needsAutoRegistration = true;
|
||||
|
||||
return target;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Operation decorator - marks a method as an operation
|
||||
* @param name Operation name
|
||||
*/
|
||||
export function Operation(name: string): any {
|
||||
return function (
|
||||
target: any,
|
||||
methodName: string,
|
||||
descriptor?: PropertyDescriptor
|
||||
): any {
|
||||
// Store metadata directly on the class constructor
|
||||
const constructor = target.constructor;
|
||||
|
||||
if (!constructor.__operations) {
|
||||
constructor.__operations = [];
|
||||
}
|
||||
constructor.__operations.push({
|
||||
name,
|
||||
method: methodName,
|
||||
});
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue schedule decorator - marks an operation as scheduled
|
||||
* @param cronPattern Cron pattern for scheduling
|
||||
* @param options Additional scheduling options
|
||||
*/
|
||||
export function QueueSchedule(
|
||||
cronPattern: string,
|
||||
options?: {
|
||||
priority?: number;
|
||||
immediately?: boolean;
|
||||
description?: string;
|
||||
}
|
||||
): any {
|
||||
return function (
|
||||
target: any,
|
||||
methodName: string,
|
||||
descriptor?: PropertyDescriptor
|
||||
): any {
|
||||
// Store metadata directly on the class constructor
|
||||
const constructor = target.constructor;
|
||||
|
||||
if (!constructor.__schedules) {
|
||||
constructor.__schedules = [];
|
||||
}
|
||||
constructor.__schedules.push({
|
||||
operation: methodName,
|
||||
cronPattern,
|
||||
...options,
|
||||
});
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Disabled decorator - marks a handler as disabled for auto-registration
|
||||
* Handlers marked with @Disabled() will be skipped during auto-registration
|
||||
*/
|
||||
export function Disabled() {
|
||||
return function <T extends { new (...args: any[]): {} }>(
|
||||
target: T,
|
||||
_context?: any
|
||||
) {
|
||||
// Store disabled flag on the constructor
|
||||
(target as any).__disabled = true;
|
||||
|
||||
return target;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
// if (!target.constructor.__eventListeners) {
|
||||
// target.constructor.__eventListeners = [];
|
||||
// }
|
||||
// target.constructor.__eventListeners.push({
|
||||
// eventName,
|
||||
// method: propertyName,
|
||||
// });
|
||||
// return descriptor;
|
||||
// };
|
||||
// }
|
||||
|
||||
// export function EventPublisher(eventName: string) {
|
||||
// return function (target: any, propertyName: string, descriptor: PropertyDescriptor) {
|
||||
// if (!target.constructor.__eventPublishers) {
|
||||
// target.constructor.__eventPublishers = [];
|
||||
// }
|
||||
// target.constructor.__eventPublishers.push({
|
||||
// eventName,
|
||||
// method: propertyName,
|
||||
// });
|
||||
// return descriptor;
|
||||
// };
|
||||
// }
|
||||
// Bun-compatible decorators (hybrid approach)
|
||||
|
||||
/**
|
||||
* Handler decorator - marks a class as a handler
|
||||
* @param name Handler name for registration
|
||||
*/
|
||||
export function Handler(name: string) {
|
||||
return function <T extends { new (...args: any[]): {} }>(target: T, _context?: any) {
|
||||
// Store handler name on the constructor
|
||||
(target as any).__handlerName = name;
|
||||
(target as any).__needsAutoRegistration = true;
|
||||
|
||||
return target;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Operation decorator - marks a method as an operation
|
||||
* @param name Operation name
|
||||
*/
|
||||
export function Operation(name: string): any {
|
||||
return function (target: any, methodName: string, descriptor?: PropertyDescriptor): any {
|
||||
// Store metadata directly on the class constructor
|
||||
const constructor = target.constructor;
|
||||
|
||||
if (!constructor.__operations) {
|
||||
constructor.__operations = [];
|
||||
}
|
||||
constructor.__operations.push({
|
||||
name,
|
||||
method: methodName,
|
||||
});
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue schedule decorator - marks an operation as scheduled
|
||||
* @param cronPattern Cron pattern for scheduling
|
||||
* @param options Additional scheduling options
|
||||
*/
|
||||
export function QueueSchedule(
|
||||
cronPattern: string,
|
||||
options?: {
|
||||
priority?: number;
|
||||
immediately?: boolean;
|
||||
description?: string;
|
||||
}
|
||||
): any {
|
||||
return function (target: any, methodName: string, descriptor?: PropertyDescriptor): any {
|
||||
// Store metadata directly on the class constructor
|
||||
const constructor = target.constructor;
|
||||
|
||||
if (!constructor.__schedules) {
|
||||
constructor.__schedules = [];
|
||||
}
|
||||
constructor.__schedules.push({
|
||||
operation: methodName,
|
||||
cronPattern,
|
||||
...options,
|
||||
});
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Disabled decorator - marks a handler as disabled for auto-registration
|
||||
* Handlers marked with @Disabled() will be skipped during auto-registration
|
||||
*/
|
||||
export function Disabled() {
|
||||
return function <T extends { new (...args: any[]): {} }>(target: T, _context?: any) {
|
||||
// Store disabled flag on the constructor
|
||||
(target as any).__disabled = true;
|
||||
|
||||
return target;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
// if (!target.constructor.__eventListeners) {
|
||||
// target.constructor.__eventListeners = [];
|
||||
// }
|
||||
// target.constructor.__eventListeners.push({
|
||||
// eventName,
|
||||
// method: propertyName,
|
||||
// });
|
||||
// return descriptor;
|
||||
// };
|
||||
// }
|
||||
|
||||
// export function EventPublisher(eventName: string) {
|
||||
// return function (target: any, propertyName: string, descriptor: PropertyDescriptor) {
|
||||
// if (!target.constructor.__eventPublishers) {
|
||||
// target.constructor.__eventPublishers = [];
|
||||
// }
|
||||
// target.constructor.__eventPublishers.push({
|
||||
// eventName,
|
||||
// method: propertyName,
|
||||
// });
|
||||
// return descriptor;
|
||||
// };
|
||||
// }
|
||||
|
|
|
|||
|
|
@ -1,31 +1,37 @@
|
|||
// Base handler classes
|
||||
export { BaseHandler, ScheduledHandler } from './base/BaseHandler';
|
||||
|
||||
// Handler registry (re-exported from types to avoid circular deps)
|
||||
export { handlerRegistry } from '@stock-bot/types';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
ExecutionContext,
|
||||
IHandler,
|
||||
JobHandler,
|
||||
ScheduledJob,
|
||||
HandlerConfig,
|
||||
HandlerConfigWithSchedule,
|
||||
TypedJobHandler,
|
||||
HandlerMetadata,
|
||||
OperationMetadata,
|
||||
} from './types/types';
|
||||
|
||||
export type { IServiceContainer } from './types/service-container';
|
||||
|
||||
export { createJobHandler } from './types/types';
|
||||
|
||||
// Decorators
|
||||
export { Handler, Operation, QueueSchedule, ScheduledOperation, Disabled } from './decorators/decorators';
|
||||
|
||||
// Auto-registration utilities
|
||||
export { autoRegisterHandlers, createAutoHandlerRegistry } from './registry/auto-register';
|
||||
|
||||
// Future exports - commented for now
|
||||
// export { EventListener, EventPublisher } from './decorators/decorators';
|
||||
// Base handler classes
|
||||
export { BaseHandler, ScheduledHandler } from './base/BaseHandler';
|
||||
|
||||
// Handler registry (re-exported from types to avoid circular deps)
|
||||
export { handlerRegistry } from '@stock-bot/types';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
ExecutionContext,
|
||||
IHandler,
|
||||
JobHandler,
|
||||
ScheduledJob,
|
||||
HandlerConfig,
|
||||
HandlerConfigWithSchedule,
|
||||
TypedJobHandler,
|
||||
HandlerMetadata,
|
||||
OperationMetadata,
|
||||
} from './types/types';
|
||||
|
||||
export type { IServiceContainer } from './types/service-container';
|
||||
|
||||
export { createJobHandler } from './types/types';
|
||||
|
||||
// Decorators
|
||||
export {
|
||||
Handler,
|
||||
Operation,
|
||||
QueueSchedule,
|
||||
ScheduledOperation,
|
||||
Disabled,
|
||||
} from './decorators/decorators';
|
||||
|
||||
// Auto-registration utilities
|
||||
export { autoRegisterHandlers, createAutoHandlerRegistry } from './registry/auto-register';
|
||||
|
||||
// Future exports - commented for now
|
||||
// export { EventListener, EventPublisher } from './decorators/decorators';
|
||||
|
|
|
|||
|
|
@ -1,191 +1,193 @@
|
|||
import { getLogger } from '@stock-bot/logger';
|
||||
import type { JobHandler, HandlerConfig, HandlerConfigWithSchedule, ScheduledJob } from '../types/types';
|
||||
|
||||
const logger = getLogger('handler-registry');
|
||||
|
||||
class HandlerRegistry {
|
||||
private handlers = new Map<string, HandlerConfig>();
|
||||
private handlerSchedules = new Map<string, ScheduledJob[]>();
|
||||
|
||||
/**
|
||||
* Register a handler with its operations (simple config)
|
||||
*/
|
||||
register(handlerName: string, config: HandlerConfig): void {
|
||||
logger.info(`Registering handler: ${handlerName}`, {
|
||||
operations: Object.keys(config),
|
||||
});
|
||||
|
||||
this.handlers.set(handlerName, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a handler with operations and scheduled jobs (full config)
|
||||
*/
|
||||
registerWithSchedule(config: HandlerConfigWithSchedule): void {
|
||||
logger.info(`Registering handler with schedule: ${config.name}`, {
|
||||
operations: Object.keys(config.operations),
|
||||
scheduledJobs: config.scheduledJobs?.length || 0,
|
||||
});
|
||||
|
||||
this.handlers.set(config.name, config.operations);
|
||||
|
||||
if (config.scheduledJobs && config.scheduledJobs.length > 0) {
|
||||
this.handlerSchedules.set(config.name, config.scheduledJobs);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a handler for a specific handler and operation
|
||||
*/
|
||||
getHandler(handler: string, operation: string): JobHandler | null {
|
||||
const handlerConfig = this.handlers.get(handler);
|
||||
if (!handlerConfig) {
|
||||
logger.warn(`Handler not found: ${handler}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const jobHandler = handlerConfig[operation];
|
||||
if (!jobHandler) {
|
||||
logger.warn(`Operation not found: ${handler}:${operation}`, {
|
||||
availableOperations: Object.keys(handlerConfig),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return jobHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all scheduled jobs from all handlers
|
||||
*/
|
||||
getAllScheduledJobs(): Array<{ handler: string; job: ScheduledJob }> {
|
||||
const allJobs: Array<{ handler: string; job: ScheduledJob }> = [];
|
||||
|
||||
for (const [handlerName, jobs] of this.handlerSchedules) {
|
||||
for (const job of jobs) {
|
||||
allJobs.push({
|
||||
handler: handlerName,
|
||||
job,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return allJobs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scheduled jobs for a specific handler
|
||||
*/
|
||||
getScheduledJobs(handler: string): ScheduledJob[] {
|
||||
return this.handlerSchedules.get(handler) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a handler has scheduled jobs
|
||||
*/
|
||||
hasScheduledJobs(handler: string): boolean {
|
||||
return this.handlerSchedules.has(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered handlers with their configurations
|
||||
*/
|
||||
getHandlerConfigs(): Array<{ name: string; operations: string[]; scheduledJobs: number }> {
|
||||
return Array.from(this.handlers.keys()).map(name => ({
|
||||
name,
|
||||
operations: Object.keys(this.handlers.get(name) || {}),
|
||||
scheduledJobs: this.handlerSchedules.get(name)?.length || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all handlers with their full configurations for queue manager registration
|
||||
*/
|
||||
getAllHandlers(): Map<string, { operations: HandlerConfig; scheduledJobs?: ScheduledJob[] }> {
|
||||
const result = new Map<
|
||||
string,
|
||||
{ operations: HandlerConfig; scheduledJobs?: ScheduledJob[] }
|
||||
>();
|
||||
|
||||
for (const [name, operations] of this.handlers) {
|
||||
const scheduledJobs = this.handlerSchedules.get(name);
|
||||
result.set(name, {
|
||||
operations,
|
||||
scheduledJobs,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered handlers
|
||||
*/
|
||||
getHandlers(): string[] {
|
||||
return Array.from(this.handlers.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get operations for a specific handler
|
||||
*/
|
||||
getOperations(handler: string): string[] {
|
||||
const handlerConfig = this.handlers.get(handler);
|
||||
return handlerConfig ? Object.keys(handlerConfig) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a handler exists
|
||||
*/
|
||||
hasHandler(handler: string): boolean {
|
||||
return this.handlers.has(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a handler has a specific operation
|
||||
*/
|
||||
hasOperation(handler: string, operation: string): boolean {
|
||||
const handlerConfig = this.handlers.get(handler);
|
||||
return handlerConfig ? operation in handlerConfig : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a handler
|
||||
*/
|
||||
unregister(handler: string): boolean {
|
||||
this.handlerSchedules.delete(handler);
|
||||
return this.handlers.delete(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all handlers
|
||||
*/
|
||||
clear(): void {
|
||||
this.handlers.clear();
|
||||
this.handlerSchedules.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get registry statistics
|
||||
*/
|
||||
getStats(): { handlers: number; totalOperations: number; totalScheduledJobs: number } {
|
||||
let totalOperations = 0;
|
||||
let totalScheduledJobs = 0;
|
||||
|
||||
for (const config of this.handlers.values()) {
|
||||
totalOperations += Object.keys(config).length;
|
||||
}
|
||||
|
||||
for (const jobs of this.handlerSchedules.values()) {
|
||||
totalScheduledJobs += jobs.length;
|
||||
}
|
||||
|
||||
return {
|
||||
handlers: this.handlers.size,
|
||||
totalOperations,
|
||||
totalScheduledJobs,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const handlerRegistry = new HandlerRegistry();
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
import type {
|
||||
HandlerConfig,
|
||||
HandlerConfigWithSchedule,
|
||||
JobHandler,
|
||||
ScheduledJob,
|
||||
} from '../types/types';
|
||||
|
||||
const logger = getLogger('handler-registry');
|
||||
|
||||
class HandlerRegistry {
|
||||
private handlers = new Map<string, HandlerConfig>();
|
||||
private handlerSchedules = new Map<string, ScheduledJob[]>();
|
||||
|
||||
/**
|
||||
* Register a handler with its operations (simple config)
|
||||
*/
|
||||
register(handlerName: string, config: HandlerConfig): void {
|
||||
logger.info(`Registering handler: ${handlerName}`, {
|
||||
operations: Object.keys(config),
|
||||
});
|
||||
|
||||
this.handlers.set(handlerName, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a handler with operations and scheduled jobs (full config)
|
||||
*/
|
||||
registerWithSchedule(config: HandlerConfigWithSchedule): void {
|
||||
logger.info(`Registering handler with schedule: ${config.name}`, {
|
||||
operations: Object.keys(config.operations),
|
||||
scheduledJobs: config.scheduledJobs?.length || 0,
|
||||
});
|
||||
|
||||
this.handlers.set(config.name, config.operations);
|
||||
|
||||
if (config.scheduledJobs && config.scheduledJobs.length > 0) {
|
||||
this.handlerSchedules.set(config.name, config.scheduledJobs);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a handler for a specific handler and operation
|
||||
*/
|
||||
getHandler(handler: string, operation: string): JobHandler | null {
|
||||
const handlerConfig = this.handlers.get(handler);
|
||||
if (!handlerConfig) {
|
||||
logger.warn(`Handler not found: ${handler}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const jobHandler = handlerConfig[operation];
|
||||
if (!jobHandler) {
|
||||
logger.warn(`Operation not found: ${handler}:${operation}`, {
|
||||
availableOperations: Object.keys(handlerConfig),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return jobHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all scheduled jobs from all handlers
|
||||
*/
|
||||
getAllScheduledJobs(): Array<{ handler: string; job: ScheduledJob }> {
|
||||
const allJobs: Array<{ handler: string; job: ScheduledJob }> = [];
|
||||
|
||||
for (const [handlerName, jobs] of this.handlerSchedules) {
|
||||
for (const job of jobs) {
|
||||
allJobs.push({
|
||||
handler: handlerName,
|
||||
job,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return allJobs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scheduled jobs for a specific handler
|
||||
*/
|
||||
getScheduledJobs(handler: string): ScheduledJob[] {
|
||||
return this.handlerSchedules.get(handler) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a handler has scheduled jobs
|
||||
*/
|
||||
hasScheduledJobs(handler: string): boolean {
|
||||
return this.handlerSchedules.has(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered handlers with their configurations
|
||||
*/
|
||||
getHandlerConfigs(): Array<{ name: string; operations: string[]; scheduledJobs: number }> {
|
||||
return Array.from(this.handlers.keys()).map(name => ({
|
||||
name,
|
||||
operations: Object.keys(this.handlers.get(name) || {}),
|
||||
scheduledJobs: this.handlerSchedules.get(name)?.length || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all handlers with their full configurations for queue manager registration
|
||||
*/
|
||||
getAllHandlers(): Map<string, { operations: HandlerConfig; scheduledJobs?: ScheduledJob[] }> {
|
||||
const result = new Map<string, { operations: HandlerConfig; scheduledJobs?: ScheduledJob[] }>();
|
||||
|
||||
for (const [name, operations] of this.handlers) {
|
||||
const scheduledJobs = this.handlerSchedules.get(name);
|
||||
result.set(name, {
|
||||
operations,
|
||||
scheduledJobs,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered handlers
|
||||
*/
|
||||
getHandlers(): string[] {
|
||||
return Array.from(this.handlers.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get operations for a specific handler
|
||||
*/
|
||||
getOperations(handler: string): string[] {
|
||||
const handlerConfig = this.handlers.get(handler);
|
||||
return handlerConfig ? Object.keys(handlerConfig) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a handler exists
|
||||
*/
|
||||
hasHandler(handler: string): boolean {
|
||||
return this.handlers.has(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a handler has a specific operation
|
||||
*/
|
||||
hasOperation(handler: string, operation: string): boolean {
|
||||
const handlerConfig = this.handlers.get(handler);
|
||||
return handlerConfig ? operation in handlerConfig : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a handler
|
||||
*/
|
||||
unregister(handler: string): boolean {
|
||||
this.handlerSchedules.delete(handler);
|
||||
return this.handlers.delete(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all handlers
|
||||
*/
|
||||
clear(): void {
|
||||
this.handlers.clear();
|
||||
this.handlerSchedules.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get registry statistics
|
||||
*/
|
||||
getStats(): { handlers: number; totalOperations: number; totalScheduledJobs: number } {
|
||||
let totalOperations = 0;
|
||||
let totalScheduledJobs = 0;
|
||||
|
||||
for (const config of this.handlers.values()) {
|
||||
totalOperations += Object.keys(config).length;
|
||||
}
|
||||
|
||||
for (const jobs of this.handlerSchedules.values()) {
|
||||
totalScheduledJobs += jobs.length;
|
||||
}
|
||||
|
||||
return {
|
||||
handlers: this.handlers.size,
|
||||
totalOperations,
|
||||
totalScheduledJobs,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const handlerRegistry = new HandlerRegistry();
|
||||
|
|
|
|||
|
|
@ -1,180 +1,188 @@
|
|||
/**
|
||||
* 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<new (services: IServiceContainer) => BaseHandler> {
|
||||
const handlers: Array<new (services: IServiceContainer) => 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;
|
||||
|
||||
// Check if handler is disabled
|
||||
if ((HandlerClass as any).__disabled) {
|
||||
logger.info(`Skipping disabled handler: ${handlerName} from ${relativePath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
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<typeof autoRegisterHandlers>[2]) {
|
||||
return autoRegisterHandlers(directory, services, options);
|
||||
},
|
||||
|
||||
/**
|
||||
* Register handlers from multiple directories
|
||||
*/
|
||||
async registerDirectories(directories: string[], options?: Parameters<typeof autoRegisterHandlers>[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;
|
||||
}
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Auto-registration utilities for handlers
|
||||
* Automatically discovers and registers handlers based on file patterns
|
||||
*/
|
||||
|
||||
import { readdirSync, statSync } from 'fs';
|
||||
import { join, relative } from 'path';
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
import { BaseHandler } from '../base/BaseHandler';
|
||||
import type { IServiceContainer } from '../types/service-container';
|
||||
|
||||
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<new (services: IServiceContainer) => BaseHandler> {
|
||||
const handlers: Array<new (services: IServiceContainer) => 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;
|
||||
|
||||
// Check if handler is disabled
|
||||
if ((HandlerClass as any).__disabled) {
|
||||
logger.info(`Skipping disabled handler: ${handlerName} from ${relativePath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
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<typeof autoRegisterHandlers>[2]
|
||||
) {
|
||||
return autoRegisterHandlers(directory, services, options);
|
||||
},
|
||||
|
||||
/**
|
||||
* Register handlers from multiple directories
|
||||
*/
|
||||
async registerDirectories(
|
||||
directories: string[],
|
||||
options?: Parameters<typeof autoRegisterHandlers>[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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,27 @@
|
|||
/**
|
||||
* Universal Service Container for Handlers
|
||||
* Simple, comprehensive container with all services available
|
||||
*/
|
||||
|
||||
import type { ProxyManager } from '@stock-bot/proxy';
|
||||
|
||||
/**
|
||||
* Universal service container with all common services
|
||||
* Designed to work across different service contexts (data-ingestion, processing, etc.)
|
||||
*/
|
||||
export interface IServiceContainer {
|
||||
// Core infrastructure
|
||||
readonly logger: any; // Logger instance
|
||||
readonly cache?: any; // Cache provider (Redis/Dragonfly) - optional
|
||||
readonly queue?: any; // Queue manager (BullMQ) - optional
|
||||
readonly proxy?: ProxyManager; // Proxy manager service - optional (depends on cache)
|
||||
readonly browser?: any; // Browser automation (Playwright)
|
||||
|
||||
// Database clients - all optional to support selective enabling
|
||||
readonly mongodb?: any; // MongoDB client
|
||||
readonly postgres?: any; // PostgreSQL client
|
||||
readonly questdb?: any; // QuestDB client (time-series)
|
||||
|
||||
// Optional extensions for future use
|
||||
readonly custom?: Record<string, any>;
|
||||
}
|
||||
/**
|
||||
* Universal Service Container for Handlers
|
||||
* Simple, comprehensive container with all services available
|
||||
*/
|
||||
|
||||
import type { ProxyManager } from '@stock-bot/proxy';
|
||||
|
||||
/**
|
||||
* Universal service container with all common services
|
||||
* Designed to work across different service contexts (data-ingestion, processing, etc.)
|
||||
*/
|
||||
export interface IServiceContainer {
|
||||
// Core infrastructure
|
||||
readonly logger: any; // Logger instance
|
||||
readonly cache?: any; // Cache provider (Redis/Dragonfly) - optional
|
||||
readonly queue?: any; // Queue manager (BullMQ) - optional
|
||||
readonly proxy?: ProxyManager; // Proxy manager service - optional (depends on cache)
|
||||
readonly browser?: any; // Browser automation (Playwright)
|
||||
|
||||
// Database clients - all optional to support selective enabling
|
||||
readonly mongodb?: any; // MongoDB client
|
||||
readonly postgres?: any; // PostgreSQL client
|
||||
readonly questdb?: any; // QuestDB client (time-series)
|
||||
|
||||
// Optional extensions for future use
|
||||
readonly custom?: Record<string, any>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
// Re-export all handler types from the shared types package
|
||||
export type {
|
||||
ExecutionContext,
|
||||
HandlerConfig,
|
||||
HandlerConfigWithSchedule,
|
||||
HandlerMetadata,
|
||||
IHandler,
|
||||
JobHandler,
|
||||
OperationMetadata,
|
||||
ScheduledJob,
|
||||
TypedJobHandler,
|
||||
} from '@stock-bot/types';
|
||||
|
||||
export { createJobHandler } from '@stock-bot/types';
|
||||
// Re-export all handler types from the shared types package
|
||||
export type {
|
||||
ExecutionContext,
|
||||
HandlerConfig,
|
||||
HandlerConfigWithSchedule,
|
||||
HandlerMetadata,
|
||||
IHandler,
|
||||
JobHandler,
|
||||
OperationMetadata,
|
||||
ScheduledJob,
|
||||
TypedJobHandler,
|
||||
} from '@stock-bot/types';
|
||||
|
||||
export { createJobHandler } from '@stock-bot/types';
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"references": [
|
||||
{ "path": "../config" },
|
||||
{ "path": "../logger" },
|
||||
{ "path": "../di" },
|
||||
{ "path": "../../utils" }
|
||||
]
|
||||
}
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"references": [
|
||||
{ "path": "../config" },
|
||||
{ "path": "../logger" },
|
||||
{ "path": "../di" },
|
||||
{ "path": "../../utils" }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,12 +58,12 @@ function createDestination(
|
|||
// Console: In-process pretty stream for dev (fast shutdown)
|
||||
if (config.logConsole && config.environment !== 'production') {
|
||||
const prettyStream = pretty({
|
||||
sync: true, // IMPORTANT: Make async to prevent blocking the event loop
|
||||
sync: true, // IMPORTANT: Make async to prevent blocking the event loop
|
||||
colorize: true,
|
||||
translateTime: 'yyyy-mm-dd HH:MM:ss.l',
|
||||
messageFormat: '[{service}{childName}] {msg}',
|
||||
singleLine: false, // This was causing logs to be on one line
|
||||
hideObject: false, // Hide metadata objects
|
||||
singleLine: false, // This was causing logs to be on one line
|
||||
hideObject: false, // Hide metadata objects
|
||||
ignore: 'pid,hostname,service,environment,version,childName',
|
||||
errorLikeObjectKeys: ['err', 'error'],
|
||||
errorProps: 'message,stack,name,code',
|
||||
|
|
@ -193,7 +193,6 @@ export class Logger {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
// Simple log level methods
|
||||
trace(message: string | object, metadata?: LogMetadata): void {
|
||||
this.log('trace', message, metadata);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,5 @@
|
|||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"references": [
|
||||
]
|
||||
"references": []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
* Types for strategy backtesting and analysis
|
||||
*/
|
||||
|
||||
import type { TradeExecution, TradePerformance } from './trading';
|
||||
import type { PortfolioAnalysis } from './portfolio';
|
||||
import type { RiskMetrics, DrawdownAnalysis } from './risk-metrics';
|
||||
import type { DrawdownAnalysis, RiskMetrics } from './risk-metrics';
|
||||
import type { TradeExecution, TradePerformance } from './trading';
|
||||
|
||||
/**
|
||||
* Backtesting results
|
||||
|
|
@ -31,4 +31,4 @@ export interface BacktestResults {
|
|||
initialCapital: number;
|
||||
/** Final value */
|
||||
finalValue: number;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export interface BalanceSheet {
|
|||
period: string;
|
||||
/** Currency */
|
||||
currency: string;
|
||||
|
||||
|
||||
// Assets
|
||||
/** Total current assets */
|
||||
totalCurrentAssets: number;
|
||||
|
|
@ -29,7 +29,7 @@ export interface BalanceSheet {
|
|||
prepaidExpenses?: number;
|
||||
/** Other current assets */
|
||||
otherCurrentAssets?: number;
|
||||
|
||||
|
||||
/** Total non-current assets */
|
||||
totalNonCurrentAssets: number;
|
||||
/** Property, plant & equipment (net) */
|
||||
|
|
@ -42,10 +42,10 @@ export interface BalanceSheet {
|
|||
longTermInvestments?: number;
|
||||
/** Other non-current assets */
|
||||
otherNonCurrentAssets?: number;
|
||||
|
||||
|
||||
/** Total assets */
|
||||
totalAssets: number;
|
||||
|
||||
|
||||
// Liabilities
|
||||
/** Total current liabilities */
|
||||
totalCurrentLiabilities: number;
|
||||
|
|
@ -57,7 +57,7 @@ export interface BalanceSheet {
|
|||
accruedLiabilities?: number;
|
||||
/** Other current liabilities */
|
||||
otherCurrentLiabilities?: number;
|
||||
|
||||
|
||||
/** Total non-current liabilities */
|
||||
totalNonCurrentLiabilities: number;
|
||||
/** Long-term debt */
|
||||
|
|
@ -66,10 +66,10 @@ export interface BalanceSheet {
|
|||
deferredTaxLiabilities?: number;
|
||||
/** Other non-current liabilities */
|
||||
otherNonCurrentLiabilities?: number;
|
||||
|
||||
|
||||
/** Total liabilities */
|
||||
totalLiabilities: number;
|
||||
|
||||
|
||||
// Equity
|
||||
/** Total stockholders' equity */
|
||||
totalStockholdersEquity: number;
|
||||
|
|
@ -95,14 +95,14 @@ export interface IncomeStatement {
|
|||
period: string;
|
||||
/** Currency */
|
||||
currency: string;
|
||||
|
||||
|
||||
/** Total revenue/net sales */
|
||||
totalRevenue: number;
|
||||
/** Cost of goods sold */
|
||||
costOfGoodsSold: number;
|
||||
/** Gross profit */
|
||||
grossProfit: number;
|
||||
|
||||
|
||||
/** Operating expenses */
|
||||
operatingExpenses: number;
|
||||
/** Research and development */
|
||||
|
|
@ -113,24 +113,24 @@ export interface IncomeStatement {
|
|||
depreciationAmortization?: number;
|
||||
/** Other operating expenses */
|
||||
otherOperatingExpenses?: number;
|
||||
|
||||
|
||||
/** Operating income */
|
||||
operatingIncome: number;
|
||||
|
||||
|
||||
/** Interest income */
|
||||
interestIncome?: number;
|
||||
/** Interest expense */
|
||||
interestExpense?: number;
|
||||
/** Other income/expense */
|
||||
otherIncomeExpense?: number;
|
||||
|
||||
|
||||
/** Income before taxes */
|
||||
incomeBeforeTaxes: number;
|
||||
/** Income tax expense */
|
||||
incomeTaxExpense: number;
|
||||
/** Net income */
|
||||
netIncome: number;
|
||||
|
||||
|
||||
/** Earnings per share (basic) */
|
||||
earningsPerShareBasic: number;
|
||||
/** Earnings per share (diluted) */
|
||||
|
|
@ -151,7 +151,7 @@ export interface CashFlowStatement {
|
|||
period: string;
|
||||
/** Currency */
|
||||
currency: string;
|
||||
|
||||
|
||||
// Operating Activities
|
||||
/** Net income */
|
||||
netIncome: number;
|
||||
|
|
@ -163,8 +163,8 @@ export interface CashFlowStatement {
|
|||
otherOperatingActivities?: number;
|
||||
/** Net cash from operating activities */
|
||||
netCashFromOperatingActivities: number;
|
||||
|
||||
// Investing Activities
|
||||
|
||||
// Investing Activities
|
||||
/** Capital expenditures */
|
||||
capitalExpenditures: number;
|
||||
/** Acquisitions */
|
||||
|
|
@ -175,7 +175,7 @@ export interface CashFlowStatement {
|
|||
otherInvestingActivities?: number;
|
||||
/** Net cash from investing activities */
|
||||
netCashFromInvestingActivities: number;
|
||||
|
||||
|
||||
// Financing Activities
|
||||
/** Debt issuance/repayment */
|
||||
debtIssuanceRepayment?: number;
|
||||
|
|
@ -187,11 +187,11 @@ export interface CashFlowStatement {
|
|||
otherFinancingActivities?: number;
|
||||
/** Net cash from financing activities */
|
||||
netCashFromFinancingActivities: number;
|
||||
|
||||
|
||||
/** Net change in cash */
|
||||
netChangeInCash: number;
|
||||
/** Cash at beginning of period */
|
||||
cashAtBeginningOfPeriod: number;
|
||||
/** Cash at end of period */
|
||||
cashAtEndOfPeriod: number;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,111 +1,119 @@
|
|||
/**
|
||||
* Handler Registry - Lightweight registry for queue handlers
|
||||
* Moved here to avoid circular dependencies between handlers and queue
|
||||
*/
|
||||
|
||||
import type { JobHandler, HandlerConfig, HandlerConfigWithSchedule, ScheduledJob } from './handlers';
|
||||
|
||||
class HandlerRegistry {
|
||||
private handlers = new Map<string, HandlerConfig>();
|
||||
private handlerSchedules = new Map<string, ScheduledJob[]>();
|
||||
|
||||
/**
|
||||
* Register a handler with its operations (simple config)
|
||||
*/
|
||||
register(handlerName: string, config: HandlerConfig): void {
|
||||
console.log(`Registering handler: ${handlerName}`, {
|
||||
operations: Object.keys(config),
|
||||
});
|
||||
|
||||
this.handlers.set(handlerName, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a handler with scheduled jobs (enhanced config)
|
||||
*/
|
||||
registerWithSchedule(config: HandlerConfigWithSchedule): void {
|
||||
console.log(`Registering handler with schedule: ${config.name}`, {
|
||||
operations: Object.keys(config.operations),
|
||||
scheduledJobs: config.scheduledJobs?.length || 0,
|
||||
});
|
||||
|
||||
this.handlers.set(config.name, config.operations);
|
||||
|
||||
if (config.scheduledJobs && config.scheduledJobs.length > 0) {
|
||||
this.handlerSchedules.set(config.name, config.scheduledJobs);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific handler's configuration
|
||||
*/
|
||||
getHandler(handlerName: string): HandlerConfig | undefined {
|
||||
return this.handlers.get(handlerName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered handlers
|
||||
*/
|
||||
getAllHandlers(): Map<string, HandlerConfig> {
|
||||
return new Map(this.handlers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scheduled jobs for a handler
|
||||
*/
|
||||
getScheduledJobs(handlerName: string): ScheduledJob[] {
|
||||
return this.handlerSchedules.get(handlerName) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all handlers with their scheduled jobs
|
||||
*/
|
||||
getAllHandlersWithSchedule(): Map<string, { operations: HandlerConfig; scheduledJobs: ScheduledJob[] }> {
|
||||
const result = new Map<string, { operations: HandlerConfig; scheduledJobs: ScheduledJob[] }>();
|
||||
|
||||
for (const [name, operations] of this.handlers) {
|
||||
result.set(name, {
|
||||
operations,
|
||||
scheduledJobs: this.handlerSchedules.get(name) || []
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific operation from a handler
|
||||
*/
|
||||
getOperation(handlerName: string, operationName: string): JobHandler | undefined {
|
||||
const handler = this.handlers.get(handlerName);
|
||||
if (!handler) {
|
||||
return undefined;
|
||||
}
|
||||
return handler[operationName];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a handler is registered
|
||||
*/
|
||||
hasHandler(handlerName: string): boolean {
|
||||
return this.handlers.has(handlerName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of all registered handler names
|
||||
*/
|
||||
getHandlerNames(): string[] {
|
||||
return Array.from(this.handlers.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all registrations (useful for testing)
|
||||
*/
|
||||
clear(): void {
|
||||
this.handlers.clear();
|
||||
this.handlerSchedules.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const handlerRegistry = new HandlerRegistry();
|
||||
/**
|
||||
* Handler Registry - Lightweight registry for queue handlers
|
||||
* Moved here to avoid circular dependencies between handlers and queue
|
||||
*/
|
||||
|
||||
import type {
|
||||
HandlerConfig,
|
||||
HandlerConfigWithSchedule,
|
||||
JobHandler,
|
||||
ScheduledJob,
|
||||
} from './handlers';
|
||||
|
||||
class HandlerRegistry {
|
||||
private handlers = new Map<string, HandlerConfig>();
|
||||
private handlerSchedules = new Map<string, ScheduledJob[]>();
|
||||
|
||||
/**
|
||||
* Register a handler with its operations (simple config)
|
||||
*/
|
||||
register(handlerName: string, config: HandlerConfig): void {
|
||||
console.log(`Registering handler: ${handlerName}`, {
|
||||
operations: Object.keys(config),
|
||||
});
|
||||
|
||||
this.handlers.set(handlerName, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a handler with scheduled jobs (enhanced config)
|
||||
*/
|
||||
registerWithSchedule(config: HandlerConfigWithSchedule): void {
|
||||
console.log(`Registering handler with schedule: ${config.name}`, {
|
||||
operations: Object.keys(config.operations),
|
||||
scheduledJobs: config.scheduledJobs?.length || 0,
|
||||
});
|
||||
|
||||
this.handlers.set(config.name, config.operations);
|
||||
|
||||
if (config.scheduledJobs && config.scheduledJobs.length > 0) {
|
||||
this.handlerSchedules.set(config.name, config.scheduledJobs);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific handler's configuration
|
||||
*/
|
||||
getHandler(handlerName: string): HandlerConfig | undefined {
|
||||
return this.handlers.get(handlerName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered handlers
|
||||
*/
|
||||
getAllHandlers(): Map<string, HandlerConfig> {
|
||||
return new Map(this.handlers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scheduled jobs for a handler
|
||||
*/
|
||||
getScheduledJobs(handlerName: string): ScheduledJob[] {
|
||||
return this.handlerSchedules.get(handlerName) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all handlers with their scheduled jobs
|
||||
*/
|
||||
getAllHandlersWithSchedule(): Map<
|
||||
string,
|
||||
{ operations: HandlerConfig; scheduledJobs: ScheduledJob[] }
|
||||
> {
|
||||
const result = new Map<string, { operations: HandlerConfig; scheduledJobs: ScheduledJob[] }>();
|
||||
|
||||
for (const [name, operations] of this.handlers) {
|
||||
result.set(name, {
|
||||
operations,
|
||||
scheduledJobs: this.handlerSchedules.get(name) || [],
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific operation from a handler
|
||||
*/
|
||||
getOperation(handlerName: string, operationName: string): JobHandler | undefined {
|
||||
const handler = this.handlers.get(handlerName);
|
||||
if (!handler) {
|
||||
return undefined;
|
||||
}
|
||||
return handler[operationName];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a handler is registered
|
||||
*/
|
||||
hasHandler(handlerName: string): boolean {
|
||||
return this.handlers.has(handlerName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of all registered handler names
|
||||
*/
|
||||
getHandlerNames(): string[] {
|
||||
return Array.from(this.handlers.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all registrations (useful for testing)
|
||||
*/
|
||||
clear(): void {
|
||||
this.handlers.clear();
|
||||
this.handlerSchedules.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const handlerRegistry = new HandlerRegistry();
|
||||
|
|
|
|||
|
|
@ -1,83 +1,83 @@
|
|||
/**
|
||||
* Handler and Queue Types
|
||||
* Shared types for handler system and queue operations
|
||||
*/
|
||||
|
||||
// Generic execution context - decoupled from service implementations
|
||||
export interface ExecutionContext {
|
||||
type: 'http' | 'queue' | 'scheduled' | 'event';
|
||||
metadata: {
|
||||
source?: string;
|
||||
jobId?: string;
|
||||
attempts?: number;
|
||||
timestamp?: number;
|
||||
traceId?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
// Simple handler interface
|
||||
export interface IHandler {
|
||||
execute(operation: string, input: unknown, context: ExecutionContext): Promise<unknown>;
|
||||
}
|
||||
|
||||
// Job handler type for queue operations
|
||||
export interface JobHandler<TPayload = unknown, TResult = unknown> {
|
||||
(payload: TPayload): Promise<TResult>;
|
||||
}
|
||||
|
||||
// Type-safe wrapper for creating job handlers
|
||||
export type TypedJobHandler<TPayload, TResult = unknown> = (payload: TPayload) => Promise<TResult>;
|
||||
|
||||
// Scheduled job configuration
|
||||
export interface ScheduledJob<T = unknown> {
|
||||
type: string;
|
||||
operation: string;
|
||||
payload?: T;
|
||||
cronPattern: string;
|
||||
priority?: number;
|
||||
description?: string;
|
||||
immediately?: boolean;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
// Handler configuration
|
||||
export interface HandlerConfig {
|
||||
[operation: string]: JobHandler;
|
||||
}
|
||||
|
||||
// Handler configuration with schedule
|
||||
export interface HandlerConfigWithSchedule {
|
||||
name: string;
|
||||
operations: Record<string, JobHandler>;
|
||||
scheduledJobs?: ScheduledJob[];
|
||||
}
|
||||
|
||||
// Handler metadata for registry
|
||||
export interface HandlerMetadata {
|
||||
name: string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
operations: string[];
|
||||
scheduledJobs?: ScheduledJob[];
|
||||
}
|
||||
|
||||
// Operation metadata for decorators
|
||||
export interface OperationMetadata {
|
||||
name: string;
|
||||
schedules?: string[];
|
||||
operation?: string;
|
||||
description?: string;
|
||||
validation?: (input: unknown) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a typed job handler with validation
|
||||
*/
|
||||
export function createJobHandler<TPayload = unknown, TResult = unknown>(
|
||||
handler: TypedJobHandler<TPayload, TResult>
|
||||
): JobHandler<unknown, TResult> {
|
||||
return async (payload: unknown): Promise<TResult> => {
|
||||
return handler(payload as TPayload);
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Handler and Queue Types
|
||||
* Shared types for handler system and queue operations
|
||||
*/
|
||||
|
||||
// Generic execution context - decoupled from service implementations
|
||||
export interface ExecutionContext {
|
||||
type: 'http' | 'queue' | 'scheduled' | 'event';
|
||||
metadata: {
|
||||
source?: string;
|
||||
jobId?: string;
|
||||
attempts?: number;
|
||||
timestamp?: number;
|
||||
traceId?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
// Simple handler interface
|
||||
export interface IHandler {
|
||||
execute(operation: string, input: unknown, context: ExecutionContext): Promise<unknown>;
|
||||
}
|
||||
|
||||
// Job handler type for queue operations
|
||||
export interface JobHandler<TPayload = unknown, TResult = unknown> {
|
||||
(payload: TPayload): Promise<TResult>;
|
||||
}
|
||||
|
||||
// Type-safe wrapper for creating job handlers
|
||||
export type TypedJobHandler<TPayload, TResult = unknown> = (payload: TPayload) => Promise<TResult>;
|
||||
|
||||
// Scheduled job configuration
|
||||
export interface ScheduledJob<T = unknown> {
|
||||
type: string;
|
||||
operation: string;
|
||||
payload?: T;
|
||||
cronPattern: string;
|
||||
priority?: number;
|
||||
description?: string;
|
||||
immediately?: boolean;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
// Handler configuration
|
||||
export interface HandlerConfig {
|
||||
[operation: string]: JobHandler;
|
||||
}
|
||||
|
||||
// Handler configuration with schedule
|
||||
export interface HandlerConfigWithSchedule {
|
||||
name: string;
|
||||
operations: Record<string, JobHandler>;
|
||||
scheduledJobs?: ScheduledJob[];
|
||||
}
|
||||
|
||||
// Handler metadata for registry
|
||||
export interface HandlerMetadata {
|
||||
name: string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
operations: string[];
|
||||
scheduledJobs?: ScheduledJob[];
|
||||
}
|
||||
|
||||
// Operation metadata for decorators
|
||||
export interface OperationMetadata {
|
||||
name: string;
|
||||
schedules?: string[];
|
||||
operation?: string;
|
||||
description?: string;
|
||||
validation?: (input: unknown) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a typed job handler with validation
|
||||
*/
|
||||
export function createJobHandler<TPayload = unknown, TResult = unknown>(
|
||||
handler: TypedJobHandler<TPayload, TResult>
|
||||
): JobHandler<unknown, TResult> {
|
||||
return async (payload: unknown): Promise<TResult> => {
|
||||
return handler(payload as TPayload);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,4 +33,4 @@ export interface HasVolume {
|
|||
*/
|
||||
export interface HasTimestamp {
|
||||
timestamp: number;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,4 +104,4 @@ export interface MarketRegime {
|
|||
trendDirection?: 'up' | 'down';
|
||||
/** Volatility level */
|
||||
volatilityLevel: 'low' | 'medium' | 'high';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,4 +55,4 @@ export interface GreeksCalculation {
|
|||
vega: number;
|
||||
/** Rho - interest rate sensitivity */
|
||||
rho: number;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,4 +105,4 @@ export interface KellyParams {
|
|||
averageLoss: number;
|
||||
/** Risk-free rate */
|
||||
riskFreeRate?: number;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,4 +83,4 @@ export interface ReturnAnalysis {
|
|||
averagePositiveReturn: number;
|
||||
/** Average negative return */
|
||||
averageNegativeReturn: number;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,23 +14,23 @@ export interface TechnicalIndicators {
|
|||
/** Relative Strength Index */
|
||||
rsi: number[];
|
||||
/** MACD indicator */
|
||||
macd: {
|
||||
macd: number[];
|
||||
signal: number[];
|
||||
histogram: number[];
|
||||
macd: {
|
||||
macd: number[];
|
||||
signal: number[];
|
||||
histogram: number[];
|
||||
};
|
||||
/** Bollinger Bands */
|
||||
bollinger: {
|
||||
upper: number[];
|
||||
middle: number[];
|
||||
lower: number[];
|
||||
bollinger: {
|
||||
upper: number[];
|
||||
middle: number[];
|
||||
lower: number[];
|
||||
};
|
||||
/** Average True Range */
|
||||
atr: number[];
|
||||
/** Stochastic Oscillator */
|
||||
stochastic: {
|
||||
k: number[];
|
||||
d: number[];
|
||||
stochastic: {
|
||||
k: number[];
|
||||
d: number[];
|
||||
};
|
||||
/** Williams %R */
|
||||
williams_r: number[];
|
||||
|
|
@ -106,4 +106,4 @@ export interface GARCHParameters {
|
|||
aic: number;
|
||||
/** BIC (Bayesian Information Criterion) */
|
||||
bic: number;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,4 +59,4 @@ export interface TradePerformance {
|
|||
grossLoss: number;
|
||||
/** Net profit */
|
||||
netProfit: number;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,5 @@
|
|||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"references": [
|
||||
]
|
||||
"references": []
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue