created new config lib

This commit is contained in:
Boki 2025-06-18 14:01:45 -04:00
parent bc14acaeba
commit 68a4c2d550
36 changed files with 2681 additions and 134 deletions

View file

@ -0,0 +1,7 @@
{
"service": {
"name": "data-sync-service",
"port": 3005,
"environment": "development"
}
}

View file

@ -12,7 +12,7 @@
"clean": "rm -rf dist"
},
"dependencies": {
"@stock-bot/config": "*",
"@stock-bot/config-new": "*",
"@stock-bot/logger": "*",
"@stock-bot/mongodb-client": "*",
"@stock-bot/postgres-client": "*",

View file

@ -3,7 +3,7 @@
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { loadEnvVariables } from '@stock-bot/config';
import { initializeConfig, getServiceConfig } from '@stock-bot/config-new';
import { getLogger, shutdownLoggers } from '@stock-bot/logger';
import { connectMongoDB, disconnectMongoDB } from '@stock-bot/mongodb-client';
import { connectPostgreSQL, disconnectPostgreSQL } from '@stock-bot/postgres-client';
@ -11,8 +11,8 @@ import { Shutdown } from '@stock-bot/shutdown';
import { enhancedSyncManager } from './services/enhanced-sync-manager';
import { syncManager } from './services/sync-manager';
// Load environment variables
loadEnvVariables();
// Initialize configuration
await initializeConfig();
const app = new Hono();
@ -28,7 +28,8 @@ app.use(
);
const logger = getLogger('data-sync-service');
const PORT = parseInt(process.env.DATA_SYNC_SERVICE_PORT || '3005');
const serviceConfig = getServiceConfig();
const PORT = serviceConfig.port;
let server: ReturnType<typeof Bun.serve> | null = null;
// Initialize shutdown manager
@ -204,7 +205,7 @@ async function startServer() {
server = Bun.serve({
port: PORT,
fetch: app.fetch,
development: process.env.NODE_ENV === 'development',
development: serviceConfig.environment === 'development',
});
logger.info(`Data Sync Service started on port ${PORT}`);

View file

@ -0,0 +1,7 @@
{
"service": {
"name": "web-api",
"port": 4000,
"environment": "development"
}
}

View file

@ -12,7 +12,7 @@
"clean": "rm -rf dist"
},
"dependencies": {
"@stock-bot/config": "*",
"@stock-bot/config-new": "*",
"@stock-bot/logger": "*",
"@stock-bot/mongodb-client": "*",
"@stock-bot/postgres-client": "*",

View file

@ -3,7 +3,7 @@
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { loadEnvVariables } from '@stock-bot/config';
import { initializeConfig, getServiceConfig } from '@stock-bot/config-new';
import { getLogger, shutdownLoggers } from '@stock-bot/logger';
import { connectMongoDB, disconnectMongoDB } from '@stock-bot/mongodb-client';
import { connectPostgreSQL, disconnectPostgreSQL } from '@stock-bot/postgres-client';
@ -12,8 +12,8 @@ import { Shutdown } from '@stock-bot/shutdown';
import { exchangeRoutes } from './routes/exchange.routes';
import { healthRoutes } from './routes/health.routes';
// Load environment variables
loadEnvVariables();
// Initialize configuration
await initializeConfig();
const app = new Hono();
@ -29,7 +29,8 @@ app.use(
);
const logger = getLogger('web-api');
const PORT = parseInt(process.env.WEB_API_PORT || '4000');
const serviceConfig = getServiceConfig();
const PORT = serviceConfig.port;
let server: ReturnType<typeof Bun.serve> | null = null;
// Initialize shutdown manager
@ -82,7 +83,7 @@ async function startServer() {
server = Bun.serve({
port: PORT,
fetch: app.fetch,
development: process.env.NODE_ENV === 'development',
development: serviceConfig.environment === 'development',
});
logger.info(`Stock Bot Web API started on port ${PORT}`);

9
apps/web-app/.env Normal file
View file

@ -0,0 +1,9 @@
# API Configuration
VITE_API_BASE_URL=http://localhost:4000/api
VITE_DATA_SERVICE_URL=http://localhost:3001
VITE_PORTFOLIO_SERVICE_URL=http://localhost:3002
VITE_STRATEGY_SERVICE_URL=http://localhost:3003
VITE_EXECUTION_SERVICE_URL=http://localhost:3004
# Environment
VITE_NODE_ENV=development

View file

@ -11,7 +11,7 @@ import {
UpdateProviderMappingRequest,
} from '../types';
const API_BASE_URL = 'http://localhost:4000/api';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:4000/api';
class ExchangeApiService {
private async request<T>(

136
bun.lock
View file

@ -71,7 +71,7 @@
"name": "@stock-bot/data-sync-service",
"version": "1.0.0",
"dependencies": {
"@stock-bot/config": "*",
"@stock-bot/config-new": "*",
"@stock-bot/logger": "*",
"@stock-bot/mongodb-client": "*",
"@stock-bot/postgres-client": "*",
@ -82,84 +82,11 @@
"typescript": "^5.0.0",
},
},
"apps/execution-service": {
"name": "@stock-bot/execution-service",
"version": "1.0.0",
"dependencies": {
"@hono/node-server": "^1.12.0",
"@stock-bot/config": "*",
"@stock-bot/event-bus": "*",
"@stock-bot/logger": "*",
"@stock-bot/types": "*",
"@stock-bot/utils": "*",
"hono": "^4.6.1",
},
"devDependencies": {
"@types/node": "^22.5.0",
"typescript": "^5.5.4",
},
},
"apps/portfolio-service": {
"name": "@stock-bot/portfolio-service",
"version": "1.0.0",
"dependencies": {
"@hono/node-server": "^1.12.0",
"@stock-bot/config": "*",
"@stock-bot/data-frame": "*",
"@stock-bot/logger": "*",
"@stock-bot/questdb-client": "*",
"@stock-bot/types": "*",
"@stock-bot/utils": "*",
"hono": "^4.6.1",
},
"devDependencies": {
"@types/node": "^22.5.0",
"typescript": "^5.5.4",
},
},
"apps/processing-service": {
"name": "@stock-bot/processing-service",
"version": "1.0.0",
"dependencies": {
"@stock-bot/config": "*",
"@stock-bot/event-bus": "*",
"@stock-bot/logger": "*",
"@stock-bot/shutdown": "*",
"@stock-bot/types": "*",
"@stock-bot/utils": "*",
"@stock-bot/vector-engine": "*",
"hono": "^4.0.0",
},
"devDependencies": {
"typescript": "^5.0.0",
},
},
"apps/strategy-service": {
"name": "@stock-bot/strategy-service",
"version": "1.0.0",
"dependencies": {
"@stock-bot/config": "*",
"@stock-bot/data-frame": "*",
"@stock-bot/event-bus": "*",
"@stock-bot/logger": "*",
"@stock-bot/questdb-client": "*",
"@stock-bot/strategy-engine": "*",
"@stock-bot/types": "*",
"@stock-bot/utils": "*",
"@stock-bot/vector-engine": "*",
"commander": "^11.0.0",
"hono": "^4.0.0",
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.0.0",
},
},
"apps/web-api": {
"name": "@stock-bot/web-api",
"version": "1.0.0",
"dependencies": {
"@stock-bot/config": "*",
"@stock-bot/config-new": "*",
"@stock-bot/logger": "*",
"@stock-bot/mongodb-client": "*",
"@stock-bot/postgres-client": "*",
@ -250,19 +177,22 @@
"typescript": "^5.3.0",
},
},
"libs/data-adjustments": {
"name": "@stock-bot/data-adjustments",
"libs/config-new": {
"name": "@stock-bot/config-new",
"version": "1.0.0",
"bin": {
"config-cli": "./dist/cli.js",
},
"dependencies": {
"@stock-bot/logger": "*",
"@stock-bot/types": "*",
"zod": "^3.22.4",
},
"devDependencies": {
"bun-types": "^1.1.12",
"typescript": "^5.4.5",
"@types/bun": "^1.0.0",
"@types/node": "^20.10.5",
"typescript": "^5.3.3",
},
"peerDependencies": {
"typescript": "^5.0.0",
"bun": "^1.0.0",
},
},
"libs/data-frame": {
@ -681,8 +611,6 @@
"@heroicons/react": ["@heroicons/react@2.2.0", "", { "peerDependencies": { "react": ">= 16 || ^19.0.0-rc" } }, "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ=="],
"@hono/node-server": ["@hono/node-server@1.14.4", "", { "peerDependencies": { "hono": "^4" } }, "sha512-DnxpshhYewr2q9ZN8ez/M5mmc3sucr8CT1sIgIy1bkeUXut9XWDkqHoFHRhWIQgkYnKpVRxunyhK7WzpJeJ6qQ=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="],
@ -759,6 +687,28 @@
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.2.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-NLVU9YDORq/3WuJOE5TQv5of3R99n56gYZPfdqP4U0/5nllbC8yzRxA2BWwAS2RxxD0Y3bxqEVUsIGiTNN2jxg=="],
"@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.2.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-HpcSVCTH9n+9bG2zu3OUJ9h22m6HzNgZpqib9r4NEVZg7Z2U86bOUMKlTCA0ZANaWsK9czl2VIhMWbLF4fgvLA=="],
"@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.2.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-FtKr6FwLN+QfrF0/vJtOwBMU72krmrHlxhRSElbKEOWox2n2vWSZ/sNNkHePEsrxGfqaHC5GhEZk2lnaZTavBQ=="],
"@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.2.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-nd0eZhihfgrDtfI/NdEqOAQ8KY87SWNQLZKjRB8WoYkqcY1BGwtZqvJOc2bEn2oERJ8K2etJRynXz+MKngiYxw=="],
"@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.2.16", "", { "os": "linux", "cpu": "none" }, "sha512-MhvQ0hecunZnbac9cEOqA1CGk/ISDhhnF35i9l90Jgc/osfgGndViLkMp3wk1EO5UG4/Kbil1OlfLmyOHKq0SQ=="],
"@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.2.16", "", { "os": "linux", "cpu": "x64" }, "sha512-qYUXPXbT4S+MImv51+dLBHKFYy40QIowwCRtzUFGf3TG+9MQQUXHNXryMNSdHveHqecd9rO1EIQ8hroAPBl+Sg=="],
"@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.2.16", "", { "os": "linux", "cpu": "x64" }, "sha512-ZysDeqDfUAqKrQu2R+ddRgSCY30qSnn0LQLr6fAm7Pw9lU2yhWVNa8R3DavddmZQc1vUw6j3ITIAE+DDT9OBCg=="],
"@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.2.16", "", { "os": "linux", "cpu": "x64" }, "sha512-6o5Oi5ARKYErF6nIBrewxtl20PGhM97faPemJ+v26D47dRNAlUWN5lMVuOqZOhYjqzOe4V+NpxIFBHtXWEmoNQ=="],
"@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.2.16", "", { "os": "linux", "cpu": "x64" }, "sha512-cWwny3cxYkvV9fYnSDb2brXodWV7IcG+Bwd3q3b8OUYbeC3ekHN3zm+TYdSxIVhMm7z46CkiDz5QnnQWVVfZ5A=="],
"@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.2.16", "", { "os": "win32", "cpu": "x64" }, "sha512-1xUlHHbMZ3DMZlEcppBAQ5vQDgNHDMIGB/AXO+dxQJl/3GiO/Ek4pMDzcqMnlbGDaDcTmTXyZ6cEXEF4C2qygQ=="],
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.2.16", "", { "os": "win32", "cpu": "x64" }, "sha512-tHdtHqH6c5ScNusLWOzZCTeuV2rSc3mvlLQQ+DYefTy+XwtjXmY47MbBSgNuBWVYePIob9BqDFOtTHYIWRZTww=="],
"@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.2.2", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
@ -887,7 +837,7 @@
"@stock-bot/config": ["@stock-bot/config@workspace:libs/config"],
"@stock-bot/data-adjustments": ["@stock-bot/data-adjustments@workspace:libs/data-adjustments"],
"@stock-bot/config-new": ["@stock-bot/config-new@workspace:libs/config-new"],
"@stock-bot/data-frame": ["@stock-bot/data-frame@workspace:libs/data-frame"],
@ -897,20 +847,14 @@
"@stock-bot/event-bus": ["@stock-bot/event-bus@workspace:libs/event-bus"],
"@stock-bot/execution-service": ["@stock-bot/execution-service@workspace:apps/execution-service"],
"@stock-bot/http": ["@stock-bot/http@workspace:libs/http"],
"@stock-bot/logger": ["@stock-bot/logger@workspace:libs/logger"],
"@stock-bot/mongodb-client": ["@stock-bot/mongodb-client@workspace:libs/mongodb-client"],
"@stock-bot/portfolio-service": ["@stock-bot/portfolio-service@workspace:apps/portfolio-service"],
"@stock-bot/postgres-client": ["@stock-bot/postgres-client@workspace:libs/postgres-client"],
"@stock-bot/processing-service": ["@stock-bot/processing-service@workspace:apps/processing-service"],
"@stock-bot/proxy": ["@stock-bot/proxy@workspace:libs/proxy"],
"@stock-bot/questdb-client": ["@stock-bot/questdb-client@workspace:libs/questdb-client"],
@ -921,8 +865,6 @@
"@stock-bot/strategy-engine": ["@stock-bot/strategy-engine@workspace:libs/strategy-engine"],
"@stock-bot/strategy-service": ["@stock-bot/strategy-service@workspace:apps/strategy-service"],
"@stock-bot/types": ["@stock-bot/types@workspace:libs/types"],
"@stock-bot/utils": ["@stock-bot/utils@workspace:libs/utils"],
@ -1177,6 +1119,8 @@
"bullmq": ["bullmq@5.53.2", "", { "dependencies": { "cron-parser": "^4.9.0", "ioredis": "^5.4.1", "msgpackr": "^1.11.2", "node-abort-controller": "^3.1.1", "semver": "^7.5.4", "tslib": "^2.0.0", "uuid": "^9.0.0" } }, "sha512-xHgxrP/yNJHD7VCw1h+eRBh+2TCPBCM39uC9gCyksYc6ufcJP+HTZ/A2lzB2x7qMFWrvsX7tM40AT2BmdkYL/Q=="],
"bun": ["bun@1.2.16", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.2.16", "@oven/bun-darwin-x64": "1.2.16", "@oven/bun-darwin-x64-baseline": "1.2.16", "@oven/bun-linux-aarch64": "1.2.16", "@oven/bun-linux-aarch64-musl": "1.2.16", "@oven/bun-linux-x64": "1.2.16", "@oven/bun-linux-x64-baseline": "1.2.16", "@oven/bun-linux-x64-musl": "1.2.16", "@oven/bun-linux-x64-musl-baseline": "1.2.16", "@oven/bun-windows-x64": "1.2.16", "@oven/bun-windows-x64-baseline": "1.2.16" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bun.exe" } }, "sha512-sjZH6rr1P6yu44+XPA8r+ZojwmK9Kbz9lO6KAA/4HRIupdpC31k7b93crLBm19wEYmd6f2+3+57/7tbOcmHbGg=="],
"bun-types": ["bun-types@1.2.15", "", { "dependencies": { "@types/node": "*" } }, "sha512-NarRIaS+iOaQU1JPfyKhZm4AsUOrwUOqRNHY0XxI8GI8jYxiLXLcdjYMG9UKS+fwWasc1uw1htV9AX24dD+p4w=="],
"bundle-name": ["bundle-name@3.0.0", "", { "dependencies": { "run-applescript": "^5.0.0" } }, "sha512-PKA4BeSvBpQKQ8iPOGCSiell+N8P+Tf1DlwqmYhpe2gAhKPHn8EYOxVT+ShuGmhg8lN8XiSlS80yiExKXrURlw=="],
@ -2509,6 +2453,8 @@
"@stock-bot/config/eslint": ["eslint@8.57.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.4", "@eslint/js": "8.57.1", "@humanwhocodes/config-array": "^0.13.0", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "@ungap/structured-clone": "^1.2.0", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", "eslint-scope": "^7.2.2", "eslint-visitor-keys": "^3.4.3", "espree": "^9.6.1", "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^6.0.1", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "globals": "^13.19.0", "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3", "strip-ansi": "^6.0.1", "text-table": "^0.2.0" }, "bin": { "eslint": "bin/eslint.js" } }, "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA=="],
"@stock-bot/config-new/@types/node": ["@types/node@20.19.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-hfrc+1tud1xcdVTABC2JiomZJEklMcXYNTVtZLAeqTVWD+qL5jkHKT+1lOtqDdGxt+mB53DTtiz673vfjU8D1Q=="],
"@stock-bot/data-frame/@types/node": ["@types/node@20.19.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-hfrc+1tud1xcdVTABC2JiomZJEklMcXYNTVtZLAeqTVWD+qL5jkHKT+1lOtqDdGxt+mB53DTtiz673vfjU8D1Q=="],
"@stock-bot/event-bus/@types/node": ["@types/node@20.19.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-hfrc+1tud1xcdVTABC2JiomZJEklMcXYNTVtZLAeqTVWD+qL5jkHKT+1lOtqDdGxt+mB53DTtiz673vfjU8D1Q=="],
@ -2555,10 +2501,6 @@
"@stock-bot/strategy-engine/@types/node": ["@types/node@20.19.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-hfrc+1tud1xcdVTABC2JiomZJEklMcXYNTVtZLAeqTVWD+qL5jkHKT+1lOtqDdGxt+mB53DTtiz673vfjU8D1Q=="],
"@stock-bot/strategy-service/@types/node": ["@types/node@20.19.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-hfrc+1tud1xcdVTABC2JiomZJEklMcXYNTVtZLAeqTVWD+qL5jkHKT+1lOtqDdGxt+mB53DTtiz673vfjU8D1Q=="],
"@stock-bot/strategy-service/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"@stock-bot/types/@types/node": ["@types/node@20.19.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-hfrc+1tud1xcdVTABC2JiomZJEklMcXYNTVtZLAeqTVWD+qL5jkHKT+1lOtqDdGxt+mB53DTtiz673vfjU8D1Q=="],
"@stock-bot/utils/@types/node": ["@types/node@20.19.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-hfrc+1tud1xcdVTABC2JiomZJEklMcXYNTVtZLAeqTVWD+qL5jkHKT+1lOtqDdGxt+mB53DTtiz673vfjU8D1Q=="],

71
config/default.json Normal file
View file

@ -0,0 +1,71 @@
{
"app": {
"name": "stock-bot",
"version": "1.0.0"
},
"service": {
"name": "default-service",
"port": 3000,
"environment": "development"
},
"database": {
"postgres": {
"host": "localhost",
"port": 5432,
"database": "trading_bot",
"username": "trading_user",
"password": "trading_pass_dev",
"maxConnections": 10
},
"questdb": {
"host": "localhost",
"httpPort": 9000,
"pgPort": 8812,
"database": "questdb"
},
"mongodb": {
"uri": "mongodb://trading_admin:trading_mongo_dev@localhost:27017/stock?authSource=admin",
"database": "stock",
"connectionTimeout": 30000,
"serverSelectionTimeout": 5000
},
"dragonfly": {
"host": "localhost",
"port": 6379,
"password": "",
"db": 0,
"keyPrefix": "stock-bot:"
}
},
"logging": {
"level": "info",
"format": "json",
"prettyPrint": true
},
"providers": {
"yahoo": {
"enabled": true,
"rateLimit": 5,
"timeout": 30000
},
"quoteMedia": {
"enabled": false,
"apiKey": "",
"baseUrl": "https://app.quotemedia.com/data",
"rateLimit": 10,
"timeout": 30000
},
"interactiveBrokers": {
"enabled": false,
"host": "localhost",
"port": 7497,
"clientId": 1
}
},
"features": {
"realtime": true,
"backtesting": true,
"paperTrading": true,
"notifications": false
}
}

View file

@ -0,0 +1,50 @@
# Environment
NODE_ENV=development
# Service Configuration
STOCKBOT_SERVICE_NAME=stock-bot-service
STOCKBOT_SERVICE_PORT=3000
# Database Configuration
STOCKBOT_DATABASE_POSTGRES_HOST=localhost
STOCKBOT_DATABASE_POSTGRES_PORT=5432
STOCKBOT_DATABASE_POSTGRES_DATABASE=stockbot
STOCKBOT_DATABASE_POSTGRES_USER=postgres
STOCKBOT_DATABASE_POSTGRES_PASSWORD=postgres
STOCKBOT_DATABASE_QUESTDB_HOST=localhost
STOCKBOT_DATABASE_QUESTDB_ILP_PORT=9009
STOCKBOT_DATABASE_QUESTDB_HTTP_PORT=9000
STOCKBOT_DATABASE_MONGODB_HOST=localhost
STOCKBOT_DATABASE_MONGODB_PORT=27017
STOCKBOT_DATABASE_MONGODB_DATABASE=stockbot
STOCKBOT_DATABASE_DRAGONFLY_HOST=localhost
STOCKBOT_DATABASE_DRAGONFLY_PORT=6379
# Provider Configuration
STOCKBOT_PROVIDERS_EOD_API_KEY=your_eod_api_key
STOCKBOT_PROVIDERS_EOD_ENABLED=true
STOCKBOT_PROVIDERS_IB_ENABLED=false
STOCKBOT_PROVIDERS_IB_GATEWAY_HOST=localhost
STOCKBOT_PROVIDERS_IB_GATEWAY_PORT=5000
STOCKBOT_PROVIDERS_IB_ACCOUNT=your_account_id
STOCKBOT_PROVIDERS_QM_ENABLED=false
STOCKBOT_PROVIDERS_QM_USERNAME=your_username
STOCKBOT_PROVIDERS_QM_PASSWORD=your_password
STOCKBOT_PROVIDERS_QM_WEBMASTER_ID=your_webmaster_id
# Logging
STOCKBOT_LOGGING_LEVEL=info
STOCKBOT_LOGGING_LOKI_ENABLED=false
STOCKBOT_LOGGING_LOKI_HOST=localhost
STOCKBOT_LOGGING_LOKI_PORT=3100
# HTTP Proxy (optional)
STOCKBOT_HTTP_PROXY_ENABLED=false
STOCKBOT_HTTP_PROXY_URL=http://proxy.example.com:8080
STOCKBOT_HTTP_PROXY_AUTH_USERNAME=username
STOCKBOT_HTTP_PROXY_AUTH_PASSWORD=password

243
libs/config-new/README.md Normal file
View file

@ -0,0 +1,243 @@
# @stock-bot/config-new
A robust, type-safe configuration library for the Stock Bot application. Built with Zod for validation and supports multiple configuration sources with proper precedence.
## Features
- **Type-safe configuration** with Zod schemas
- **Multiple configuration sources**: JSON files and environment variables
- **Environment-specific overrides** (development, test, production)
- **Dynamic provider configurations**
- **No circular dependencies** - designed to be used by all other libraries
- **Clear error messages** with validation
- **Runtime configuration updates** (useful for testing)
- **Singleton pattern** for global configuration access
## Installation
```bash
bun add @stock-bot/config-new
```
## Usage
### Basic Usage
```typescript
import { initializeConfig, getConfig } from '@stock-bot/config-new';
// Initialize configuration (call once at app startup)
await initializeConfig();
// Get configuration
const config = getConfig();
console.log(config.database.postgres.host);
// Use convenience functions
import { getDatabaseConfig, isProduction } from '@stock-bot/config-new';
const dbConfig = getDatabaseConfig();
if (isProduction()) {
// Production-specific logic
}
```
### Custom Configuration
```typescript
import { ConfigManager } from '@stock-bot/config-new';
import { z } from 'zod';
// Define your schema
const myConfigSchema = z.object({
app: z.object({
name: z.string(),
version: z.string(),
}),
features: z.object({
enableBeta: z.boolean().default(false),
}),
});
// Create config manager
const configManager = new ConfigManager({
configPath: './my-config',
});
// Initialize with schema
const config = await configManager.initialize(myConfigSchema);
```
### Provider-Specific Configuration
```typescript
import { getProviderConfig } from '@stock-bot/config-new';
// Get provider configuration
const eodConfig = getProviderConfig('eod');
console.log(eodConfig.apiKey);
// Check if provider is enabled
if (eodConfig.enabled) {
// Use EOD provider
}
```
### Environment Variables
Environment variables are loaded with the `STOCKBOT_` prefix and follow a naming convention:
```bash
# Database configuration
STOCKBOT_DATABASE_POSTGRES_HOST=localhost
STOCKBOT_DATABASE_POSTGRES_PORT=5432
# Provider configuration
STOCKBOT_PROVIDERS_EOD_API_KEY=your_api_key
STOCKBOT_PROVIDERS_EOD_ENABLED=true
# Service configuration
STOCKBOT_SERVICE_PORT=3000
STOCKBOT_LOGGING_LEVEL=debug
```
### Configuration Precedence
Configuration is loaded in the following order (later sources override earlier ones):
1. `config/default.json` - Base configuration
2. `config/{environment}.json` - Environment-specific overrides
3. Environment variables - Highest priority
### Advanced Usage
```typescript
import { getConfigManager } from '@stock-bot/config-new';
const manager = getConfigManager();
// Get specific value by path
const port = manager.getValue<number>('service.port');
// Check if configuration exists
if (manager.has('providers.ib')) {
// IB provider is configured
}
// Update configuration at runtime (useful for testing)
manager.set({
logging: {
level: 'debug'
}
});
// Create typed getter
const getQueueConfig = manager.createTypedGetter(queueConfigSchema);
const queueConfig = getQueueConfig();
```
## Configuration Schema
The library provides pre-defined schemas for common configurations:
### Base Configuration
- `environment` - Current environment (development/test/production)
- `name` - Application name
- `version` - Application version
- `debug` - Debug mode flag
### Service Configuration
- `name` - Service name
- `port` - Service port
- `host` - Service host
- `healthCheckPath` - Health check endpoint
- `cors` - CORS configuration
### Database Configuration
- `postgres` - PostgreSQL configuration
- `questdb` - QuestDB configuration
- `mongodb` - MongoDB configuration
- `dragonfly` - Dragonfly/Redis configuration
### Provider Configuration
- `eod` - EOD Historical Data provider
- `ib` - Interactive Brokers provider
- `qm` - QuoteMedia provider
- `yahoo` - Yahoo Finance provider
## Testing
```typescript
import { resetConfig, initializeConfig } from '@stock-bot/config-new';
beforeEach(() => {
resetConfig();
});
test('custom config', async () => {
process.env.NODE_ENV = 'test';
process.env.STOCKBOT_SERVICE_PORT = '4000';
await initializeConfig();
const config = getConfig();
expect(config.service.port).toBe(4000);
});
```
## Custom Loaders
You can create custom configuration loaders:
```typescript
import { ConfigLoader } from '@stock-bot/config-new';
class ApiConfigLoader implements ConfigLoader {
readonly priority = 75; // Between file (50) and env (100)
async load(): Promise<Record<string, unknown>> {
// Fetch configuration from API
const response = await fetch('https://api.example.com/config');
return response.json();
}
}
// Use custom loader
const configManager = new ConfigManager({
loaders: [
new FileLoader('./config', 'production'),
new ApiConfigLoader(),
new EnvLoader('STOCKBOT_'),
],
});
```
## Error Handling
The library provides specific error types:
```typescript
import { ConfigError, ConfigValidationError } from '@stock-bot/config-new';
try {
await initializeConfig();
} catch (error) {
if (error instanceof ConfigValidationError) {
console.error('Validation failed:', error.errors);
} else if (error instanceof ConfigError) {
console.error('Configuration error:', error.message);
}
}
```
## Best Practices
1. **Initialize once**: Call `initializeConfig()` once at application startup
2. **Use schemas**: Always define and validate configurations with Zod schemas
3. **Environment variables**: Use the `STOCKBOT_` prefix for all env vars
4. **Type safety**: Leverage TypeScript types from the schemas
5. **Testing**: Reset configuration between tests with `resetConfig()`
## License
MIT

View file

@ -0,0 +1,90 @@
{
"name": "stock-bot",
"version": "1.0.0",
"debug": false,
"service": {
"name": "stock-bot-service",
"port": 3000,
"host": "0.0.0.0",
"healthCheckPath": "/health",
"metricsPath": "/metrics",
"shutdownTimeout": 30000,
"cors": {
"enabled": true,
"origin": "*",
"credentials": true
}
},
"logging": {
"level": "info",
"format": "json",
"loki": {
"enabled": false,
"host": "localhost",
"port": 3100,
"labels": {}
}
},
"database": {
"postgres": {
"host": "localhost",
"port": 5432,
"database": "stockbot",
"user": "postgres",
"password": "postgres",
"ssl": false,
"poolSize": 10,
"connectionTimeout": 30000,
"idleTimeout": 10000
},
"questdb": {
"host": "localhost",
"ilpPort": 9009,
"httpPort": 9000,
"pgPort": 8812,
"database": "questdb",
"user": "admin",
"password": "quest",
"bufferSize": 65536,
"flushInterval": 1000
},
"mongodb": {
"host": "localhost",
"port": 27017,
"database": "stockbot",
"authSource": "admin",
"poolSize": 10
},
"dragonfly": {
"host": "localhost",
"port": 6379,
"db": 0,
"maxRetries": 3,
"retryDelay": 100
}
},
"queue": {
"redis": {
"host": "localhost",
"port": 6379,
"db": 1
},
"defaultJobOptions": {
"attempts": 3,
"backoff": {
"type": "exponential",
"delay": 1000
},
"removeOnComplete": true,
"removeOnFail": false
}
},
"http": {
"timeout": 30000,
"retries": 3,
"retryDelay": 1000,
"proxy": {
"enabled": false
}
}
}

View file

@ -0,0 +1,48 @@
{
"debug": true,
"logging": {
"level": "debug",
"format": "pretty"
},
"providers": {
"eod": {
"name": "eod-historical-data",
"enabled": true,
"priority": 1,
"apiKey": "demo",
"tier": "free",
"rateLimit": {
"maxRequests": 20,
"windowMs": 60000
}
},
"yahoo": {
"name": "yahoo-finance",
"enabled": true,
"priority": 2,
"rateLimit": {
"maxRequests": 100,
"windowMs": 60000
}
},
"ib": {
"name": "interactive-brokers",
"enabled": false,
"priority": 0,
"gateway": {
"host": "localhost",
"port": 5000,
"clientId": 1
},
"marketDataType": "delayed"
},
"qm": {
"name": "quotemedia",
"enabled": false,
"priority": 3,
"username": "",
"password": "",
"webmasterId": ""
}
}
}

View file

@ -0,0 +1,32 @@
{
"debug": false,
"logging": {
"level": "warn",
"format": "json",
"loki": {
"enabled": true,
"labels": {
"app": "stock-bot",
"env": "production"
}
}
},
"database": {
"postgres": {
"ssl": true,
"poolSize": 20
},
"questdb": {
"bufferSize": 131072,
"flushInterval": 500
},
"mongodb": {
"poolSize": 20
}
},
"http": {
"timeout": 60000,
"retries": 5,
"retryDelay": 2000
}
}

View file

@ -0,0 +1,42 @@
{
"debug": true,
"logging": {
"level": "error",
"format": "json"
},
"service": {
"port": 0,
"shutdownTimeout": 5000
},
"database": {
"postgres": {
"database": "stockbot_test",
"poolSize": 5
},
"questdb": {
"database": "questdb_test"
},
"mongodb": {
"database": "stockbot_test",
"poolSize": 5
},
"dragonfly": {
"db": 15,
"keyPrefix": "test:"
}
},
"queue": {
"redis": {
"db": 15
},
"defaultJobOptions": {
"attempts": 1,
"removeOnComplete": false,
"removeOnFail": false
}
},
"http": {
"timeout": 5000,
"retries": 1
}
}

View file

@ -0,0 +1,36 @@
{
"name": "@stock-bot/config-new",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"test": "bun test",
"clean": "rm -rf dist",
"cli": "bun run src/cli.ts",
"validate": "bun run src/cli.ts --validate",
"check": "bun run src/cli.ts --check"
},
"bin": {
"config-cli": "./dist/cli.js"
},
"dependencies": {
"zod": "^3.22.4"
},
"devDependencies": {
"@types/bun": "^1.0.0",
"@types/node": "^20.10.5",
"typescript": "^5.3.3"
},
"peerDependencies": {
"bun": "^1.0.0"
}
}

194
libs/config-new/src/cli.ts Normal file
View file

@ -0,0 +1,194 @@
#!/usr/bin/env bun
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';
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 any;
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();
}

View file

@ -0,0 +1,220 @@
import { join } from 'path';
import { z } from 'zod';
import {
ConfigManagerOptions,
Environment,
ConfigLoader,
DeepPartial,
ConfigSchema
} from './types';
import { ConfigError, ConfigValidationError } from './errors';
import { EnvLoader } from './loaders/env.loader';
import { FileLoader } from './loaders/file.loader';
export class ConfigManager<T = Record<string, unknown>> {
private config: T | null = null;
private loaders: ConfigLoader[];
private environment: Environment;
private schema?: ConfigSchema;
constructor(options: ConfigManagerOptions = {}) {
this.environment = options.environment || this.detectEnvironment();
// Default loaders if none provided
if (options.loaders) {
this.loaders = options.loaders;
} else {
const configPath = options.configPath || join(process.cwd(), 'config');
this.loaders = [
new FileLoader(configPath, this.environment),
new EnvLoader('STOCKBOT_'), // Prefix for env vars
];
}
}
/**
* Initialize the configuration by loading from all sources
*/
async initialize(schema?: ConfigSchema): Promise<T> {
if (this.config) {
return this.config;
}
this.schema = schema;
// Sort loaders by priority (higher priority last)
const sortedLoaders = [...this.loaders].sort((a, b) => a.priority - b.priority);
// Load configurations from all sources
const configs: Record<string, unknown>[] = [];
for (const loader of sortedLoaders) {
const config = await loader.load();
if (config && Object.keys(config).length > 0) {
configs.push(config);
}
}
// Merge all configurations
const mergedConfig = this.deepMerge(...configs) as T;
// Add environment if not present
if (typeof mergedConfig === 'object' && mergedConfig !== null && !('environment' in mergedConfig)) {
(mergedConfig as any).environment = this.environment;
}
// Validate if schema provided
if (this.schema) {
try {
this.config = this.schema.parse(mergedConfig) as T;
} catch (error) {
if (error instanceof z.ZodError) {
throw new ConfigValidationError(
'Configuration validation failed',
error.errors
);
}
throw error;
}
} else {
this.config = mergedConfig;
}
return this.config;
}
/**
* Get the current configuration
*/
get(): T {
if (!this.config) {
throw new ConfigError('Configuration not initialized. Call initialize() first.');
}
return this.config;
}
/**
* Get a specific configuration value by path
*/
getValue<R = unknown>(path: string): R {
const config = this.get();
const keys = path.split('.');
let value: any = config;
for (const key of keys) {
if (value && typeof value === 'object' && key in value) {
value = value[key];
} else {
throw new ConfigError(`Configuration key not found: ${path}`);
}
}
return value as R;
}
/**
* Check if a configuration path exists
*/
has(path: string): boolean {
try {
this.getValue(path);
return true;
} catch {
return false;
}
}
/**
* Update configuration at runtime (useful for testing)
*/
set(updates: DeepPartial<T>): void {
if (!this.config) {
throw new ConfigError('Configuration not initialized. Call initialize() first.');
}
const updated = this.deepMerge(this.config as any, updates as any) as T;
// Re-validate if schema is present
if (this.schema) {
try {
this.config = this.schema.parse(updated) as T;
} catch (error) {
if (error instanceof z.ZodError) {
throw new ConfigValidationError(
'Configuration validation failed after update',
error.errors
);
}
throw error;
}
} else {
this.config = updated;
}
}
/**
* Get the current environment
*/
getEnvironment(): Environment {
return this.environment;
}
/**
* Reset configuration (useful for testing)
*/
reset(): void {
this.config = null;
}
/**
* Validate configuration against a schema
*/
validate<S extends ConfigSchema>(schema: S): z.infer<S> {
const config = this.get();
return schema.parse(config);
}
/**
* Create a typed configuration getter
*/
createTypedGetter<S extends z.ZodSchema>(schema: S): () => z.infer<S> {
return () => this.validate(schema);
}
private detectEnvironment(): Environment {
const env = process.env.NODE_ENV?.toLowerCase();
switch (env) {
case 'production':
case 'prod':
return 'production';
case 'test':
return 'test';
case 'development':
case 'dev':
default:
return 'development';
}
}
private deepMerge(...objects: Record<string, any>[]): Record<string, any> {
const result: Record<string, any> = {};
for (const obj of objects) {
for (const [key, value] of Object.entries(obj)) {
if (value === null || value === undefined) {
result[key] = value;
} else if (
typeof value === 'object' &&
!Array.isArray(value) &&
!(value instanceof Date) &&
!(value instanceof RegExp)
) {
result[key] = this.deepMerge(result[key] || {}, value);
} else {
result[key] = value;
}
}
}
return result;
}
}

View file

@ -0,0 +1,20 @@
export class ConfigError extends Error {
constructor(message: string) {
super(message);
this.name = 'ConfigError';
}
}
export class ConfigValidationError extends ConfigError {
constructor(message: string, public errors: unknown) {
super(message);
this.name = 'ConfigValidationError';
}
}
export class ConfigLoaderError extends ConfigError {
constructor(message: string, public loader: string) {
super(`${loader}: ${message}`);
this.name = 'ConfigLoaderError';
}
}

View file

@ -0,0 +1,104 @@
// Export all schemas
export * from './schemas';
// Export types
export * from './types';
// Export errors
export * from './errors';
// Export loaders
export { EnvLoader } from './loaders/env.loader';
export { FileLoader } from './loaders/file.loader';
// Export ConfigManager
export { ConfigManager } from './config-manager';
// Export utilities
export * from './utils/secrets';
export * from './utils/validation';
// Import necessary types for singleton
import { ConfigManager } from './config-manager';
import { AppConfig, appConfigSchema } from './schemas';
// Create singleton instance
let configInstance: ConfigManager<AppConfig> | null = null;
/**
* Initialize the global configuration
*/
export async function initializeConfig(
configPath?: string
): Promise<AppConfig> {
if (!configInstance) {
configInstance = new ConfigManager<AppConfig>({
configPath,
});
}
return configInstance.initialize(appConfigSchema);
}
/**
* Get the current configuration
*/
export function getConfig(): AppConfig {
if (!configInstance) {
throw new Error('Configuration not initialized. Call initializeConfig() first.');
}
return configInstance.get();
}
/**
* Get configuration manager instance
*/
export function getConfigManager(): ConfigManager<AppConfig> {
if (!configInstance) {
throw new Error('Configuration not initialized. Call initializeConfig() first.');
}
return configInstance;
}
/**
* Reset configuration (useful for testing)
*/
export function resetConfig(): void {
if (configInstance) {
configInstance.reset();
configInstance = null;
}
}
// Export convenience functions for common configs
export function getDatabaseConfig() {
return getConfig().database;
}
export function getServiceConfig() {
return getConfig().service;
}
export function getLoggingConfig() {
return getConfig().logging;
}
export function getProviderConfig(provider: string) {
const providers = getConfig().providers;
if (!providers || !(provider in providers)) {
throw new Error(`Provider configuration not found: ${provider}`);
}
return (providers as any)[provider];
}
// Export environment helpers
export function isDevelopment(): boolean {
return getConfig().environment === 'development';
}
export function isProduction(): boolean {
return getConfig().environment === 'production';
}
export function isTest(): boolean {
return getConfig().environment === 'test';
}

View file

@ -0,0 +1,127 @@
import { ConfigLoader } from '../types';
import { ConfigLoaderError } from '../errors';
export interface EnvLoaderOptions {
convertCase?: boolean;
parseJson?: boolean;
parseValues?: boolean;
nestedDelimiter?: string;
}
export class EnvLoader implements ConfigLoader {
readonly priority = 100; // Highest priority
constructor(
private prefix = '',
private options: EnvLoaderOptions = {}
) {
this.options = {
convertCase: false,
parseJson: true,
parseValues: true,
nestedDelimiter: '_',
...options
};
}
async load(): Promise<Record<string, unknown>> {
try {
const config: Record<string, unknown> = {};
const envVars = process.env;
for (const [key, value] of Object.entries(envVars)) {
if (this.prefix && !key.startsWith(this.prefix)) {
continue;
}
const configKey = this.prefix
? key.slice(this.prefix.length)
: key;
if (!this.options.convertCase && !this.options.nestedDelimiter) {
// Simple case - just keep the key as is
config[configKey] = this.parseValue(value || '');
} else {
// Handle nested structure or case conversion
this.setConfigValue(config, configKey, value || '');
}
}
return config;
} catch (error) {
throw new ConfigLoaderError(
`Failed to load environment variables: ${error}`,
'EnvLoader'
);
}
}
private setConfigValue(config: Record<string, any>, key: string, value: string): void {
const parsedValue = this.parseValue(value);
if (this.options.nestedDelimiter && key.includes(this.options.nestedDelimiter)) {
// Handle nested delimiter (e.g., APP__NAME -> { APP: { NAME: value } })
const parts = key.split(this.options.nestedDelimiter);
this.setNestedValue(config, parts, parsedValue);
} else if (this.options.convertCase) {
// Convert to camelCase
const camelKey = this.toCamelCase(key);
config[camelKey] = parsedValue;
} else {
// Convert to nested structure based on underscores
const path = key.toLowerCase().split('_');
this.setNestedValue(config, path, parsedValue);
}
}
private setNestedValue(obj: Record<string, any>, path: string[], value: unknown): void {
const lastKey = path.pop()!;
const target = path.reduce((acc, key) => {
if (!acc[key]) {
acc[key] = {};
}
return acc[key];
}, obj);
target[lastKey] = value;
}
private toCamelCase(str: string): string {
return str
.toLowerCase()
.replace(/_([a-z])/g, (_, char) => char.toUpperCase());
}
private parseValue(value: string): unknown {
if (!this.options.parseValues && !this.options.parseJson) {
return value;
}
// Try to parse as JSON first if enabled
if (this.options.parseJson) {
try {
return JSON.parse(value);
} catch {
// Not JSON, continue with other parsing
}
}
if (!this.options.parseValues) {
return value;
}
// Handle booleans
if (value.toLowerCase() === 'true') return true;
if (value.toLowerCase() === 'false') return false;
// Handle numbers
const num = Number(value);
if (!isNaN(num) && value !== '') return num;
// Handle null/undefined
if (value.toLowerCase() === 'null') return null;
if (value.toLowerCase() === 'undefined') return undefined;
// Return as string
return value;
}
}

View file

@ -0,0 +1,72 @@
import { readFile } from 'fs/promises';
import { join } from 'path';
import { ConfigLoader } from '../types';
import { ConfigLoaderError } from '../errors';
export class FileLoader implements ConfigLoader {
readonly priority = 50; // Medium priority
constructor(
private configPath: string,
private environment: string
) {}
async load(): Promise<Record<string, unknown>> {
try {
const configs: Record<string, unknown>[] = [];
// Load default config
const defaultConfig = await this.loadFile('default.json');
if (defaultConfig) {
configs.push(defaultConfig);
}
// Load environment-specific config
const envConfig = await this.loadFile(`${this.environment}.json`);
if (envConfig) {
configs.push(envConfig);
}
// Merge configs (later configs override earlier ones)
return this.deepMerge(...configs);
} catch (error) {
throw new ConfigLoaderError(
`Failed to load configuration files: ${error}`,
'FileLoader'
);
}
}
private async loadFile(filename: string): Promise<Record<string, unknown> | null> {
const filepath = join(this.configPath, filename);
try {
const content = await readFile(filepath, 'utf-8');
return JSON.parse(content);
} catch (error: any) {
// File not found is not an error (configs are optional)
if (error.code === 'ENOENT') {
return null;
}
throw error;
}
}
private deepMerge(...objects: Record<string, any>[]): Record<string, any> {
const result: Record<string, any> = {};
for (const obj of objects) {
for (const [key, value] of Object.entries(obj)) {
if (value === null || value === undefined) {
result[key] = value;
} else if (typeof value === 'object' && !Array.isArray(value)) {
result[key] = this.deepMerge(result[key] || {}, value);
} else {
result[key] = value;
}
}
}
return result;
}
}

View file

@ -0,0 +1,10 @@
import { z } from 'zod';
export const environmentSchema = z.enum(['development', 'test', 'production']);
export const baseConfigSchema = z.object({
environment: environmentSchema.optional(),
name: z.string().optional(),
version: z.string().optional(),
debug: z.boolean().default(false),
});

View file

@ -0,0 +1,60 @@
import { z } from 'zod';
// PostgreSQL configuration
export const postgresConfigSchema = z.object({
host: z.string().default('localhost'),
port: z.number().default(5432),
database: z.string(),
user: z.string(),
password: z.string(),
ssl: z.boolean().default(false),
poolSize: z.number().min(1).max(100).default(10),
connectionTimeout: z.number().default(30000),
idleTimeout: z.number().default(10000),
});
// QuestDB configuration
export const questdbConfigSchema = 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),
});
// MongoDB configuration
export const mongodbConfigSchema = z.object({
uri: z.string().url().optional(),
host: z.string().default('localhost'),
port: z.number().default(27017),
database: z.string(),
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),
});
// Dragonfly/Redis configuration
export const dragonflyConfigSchema = 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),
});
// Combined database configuration
export const databaseConfigSchema = z.object({
postgres: postgresConfigSchema,
questdb: questdbConfigSchema,
mongodb: mongodbConfigSchema,
dragonfly: dragonflyConfigSchema,
});

View file

@ -0,0 +1,23 @@
export * from './base.schema';
export * from './database.schema';
export * from './service.schema';
export * from './provider.schema';
import { z } from 'zod';
import { baseConfigSchema, environmentSchema } from './base.schema';
import { databaseConfigSchema } from './database.schema';
import { serviceConfigSchema, loggingConfigSchema, queueConfigSchema, httpConfigSchema } from './service.schema';
import { providerConfigSchema } from './provider.schema';
// Complete application configuration schema
export const appConfigSchema = baseConfigSchema.extend({
environment: environmentSchema.default('development'),
service: serviceConfigSchema,
logging: loggingConfigSchema,
database: databaseConfigSchema,
queue: queueConfigSchema.optional(),
http: httpConfigSchema.optional(),
providers: providerConfigSchema.optional(),
});
export type AppConfig = z.infer<typeof appConfigSchema>;

View file

@ -0,0 +1,65 @@
import { z } from 'zod';
// Base provider configuration
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(),
timeout: z.number().default(30000),
retries: z.number().default(3),
});
// EOD Historical Data provider
export const eodProviderConfigSchema = baseProviderConfigSchema.extend({
apiKey: z.string(),
baseUrl: z.string().default('https://eodhistoricaldata.com/api'),
tier: z.enum(['free', 'fundamentals', 'all-in-one']).default('free'),
});
// Interactive Brokers provider
export const ibProviderConfigSchema = baseProviderConfigSchema.extend({
gateway: z.object({
host: z.string().default('localhost'),
port: z.number().default(5000),
clientId: z.number().default(1),
}),
account: z.string().optional(),
marketDataType: z.enum(['live', 'delayed', 'frozen']).default('delayed'),
});
// QuoteMedia provider
export const qmProviderConfigSchema = baseProviderConfigSchema.extend({
username: z.string(),
password: z.string(),
baseUrl: z.string().default('https://app.quotemedia.com/quotetools'),
webmasterId: z.string(),
});
// Yahoo Finance provider
export const yahooProviderConfigSchema = baseProviderConfigSchema.extend({
baseUrl: z.string().default('https://query1.finance.yahoo.com'),
cookieJar: z.boolean().default(true),
crumb: z.string().optional(),
});
// Combined provider configuration
export const providerConfigSchema = z.object({
eod: eodProviderConfigSchema.optional(),
ib: ibProviderConfigSchema.optional(),
qm: qmProviderConfigSchema.optional(),
yahoo: yahooProviderConfigSchema.optional(),
});
// Dynamic provider configuration type
export type ProviderName = 'eod' | 'ib' | 'qm' | 'yahoo';
export const providerSchemas = {
eod: eodProviderConfigSchema,
ib: ibProviderConfigSchema,
qm: qmProviderConfigSchema,
yahoo: yahooProviderConfigSchema,
} as const;

View file

@ -0,0 +1,63 @@
import { z } from 'zod';
// Common service configuration
export const serviceConfigSchema = z.object({
name: z.string(),
port: z.number().min(1).max(65535),
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({}),
});
// 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(),
});
// Queue configuration
export const queueConfigSchema = z.object({
redis: z.object({
host: z.string().default('localhost'),
port: z.number().default(6379),
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.boolean().default(true),
removeOnFail: z.boolean().default(false),
}).default({}),
});
// HTTP client configuration
export const httpConfigSchema = z.object({
timeout: z.number().default(30000),
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(),
});

View file

@ -0,0 +1,28 @@
import { z } from 'zod';
export type Environment = 'development' | 'test' | 'production';
export interface ConfigLoader {
load(): Promise<Record<string, unknown>>;
readonly priority: number;
}
export interface ConfigManagerOptions {
environment?: Environment;
configPath?: string;
loaders?: ConfigLoader[];
}
export type DeepPartial<T> = T extends object
? {
[P in keyof T]?: DeepPartial<T[P]>;
}
: T;
export type ConfigSchema = z.ZodSchema<any>;
export interface ProviderConfig {
name: string;
enabled: boolean;
[key: string]: unknown;
}

View file

@ -0,0 +1,182 @@
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++) {
if (current[keys[i]] && typeof current[keys[i]] === 'object') {
current = current[keys[i]];
} else {
break;
}
}
const lastKey = keys[keys.length - 1];
if (current && 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;
}

View file

@ -0,0 +1,193 @@
import { z } from 'zod';
import { ConfigValidationError } from '../errors';
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, any>,
deprecations: Record<string, string>
): ValidationResult['warnings'] {
const warnings: ValidationResult['warnings'] = [];
function checkObject(obj: any, path: string[] = []): void {
for (const [key, value] of Object.entries(obj)) {
const currentPath = [...path, key];
const pathStr = currentPath.join('.');
if (pathStr in deprecations) {
warnings?.push({
path: pathStr,
message: deprecations[pathStr],
});
}
if (value && typeof value === 'object' && !Array.isArray(value)) {
checkObject(value, 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;
}

View file

@ -0,0 +1,215 @@
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');
});
});

View file

@ -0,0 +1,213 @@
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 = {
app: {
name: 'test-app',
version: '1.0.0',
},
service: {
name: 'test-service',
port: 3000,
},
database: {
postgres: {
host: 'localhost',
port: 5432,
database: 'testdb',
username: 'testuser',
password: 'testpass',
},
questdb: {
host: 'localhost',
httpPort: 9000,
pgPort: 8812,
},
mongodb: {
uri: 'mongodb://localhost:27017',
database: 'testdb',
},
dragonfly: {
host: 'localhost',
port: 6379,
},
},
logging: {
level: 'info',
format: 'json',
},
providers: {
yahoo: {
enabled: true,
rateLimit: 5,
},
quoteMedia: {
enabled: false,
apiKey: 'test-key',
},
},
features: {
realtime: true,
backtesting: true,
},
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);
});
});

View file

@ -0,0 +1,181 @@
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);
});
});

View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"lib": ["ES2022"],
"moduleResolution": "bundler",
"types": ["bun-types"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src",
"declarationDir": "./dist",
"composite": true,
"tsBuildInfoFile": "./tsconfig.tsbuildinfo"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]
}

View file

@ -1,24 +0,0 @@
{
"name": "@stock-bot/data-adjustments",
"version": "1.0.0",
"description": "Stock split and dividend adjustment utilities for market data",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"test": "bun test",
"test:watch": "bun test --watch"
},
"dependencies": {
"@stock-bot/types": "*",
"@stock-bot/logger": "*"
},
"devDependencies": {
"typescript": "^5.4.5",
"bun-types": "^1.1.12"
},
"peerDependencies": {
"typescript": "^5.0.0"
}
}