diff --git a/apps/data-sync-service/config/default.json b/apps/data-sync-service/config/default.json new file mode 100644 index 0000000..b22641d --- /dev/null +++ b/apps/data-sync-service/config/default.json @@ -0,0 +1,7 @@ +{ + "service": { + "name": "data-sync-service", + "port": 3005, + "environment": "development" + } +} \ No newline at end of file diff --git a/apps/data-sync-service/package.json b/apps/data-sync-service/package.json index 8e0bebf..3d2b614 100644 --- a/apps/data-sync-service/package.json +++ b/apps/data-sync-service/package.json @@ -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": "*", diff --git a/apps/data-sync-service/src/index.ts b/apps/data-sync-service/src/index.ts index e734908..c689fb5 100644 --- a/apps/data-sync-service/src/index.ts +++ b/apps/data-sync-service/src/index.ts @@ -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 | 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}`); diff --git a/apps/web-api/config/default.json b/apps/web-api/config/default.json new file mode 100644 index 0000000..8c64dff --- /dev/null +++ b/apps/web-api/config/default.json @@ -0,0 +1,7 @@ +{ + "service": { + "name": "web-api", + "port": 4000, + "environment": "development" + } +} \ No newline at end of file diff --git a/apps/web-api/package.json b/apps/web-api/package.json index 9fa41b1..f5c9d4b 100644 --- a/apps/web-api/package.json +++ b/apps/web-api/package.json @@ -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": "*", diff --git a/apps/web-api/src/index.ts b/apps/web-api/src/index.ts index 54cd139..9effdce 100644 --- a/apps/web-api/src/index.ts +++ b/apps/web-api/src/index.ts @@ -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 | 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}`); diff --git a/apps/web-app/.env b/apps/web-app/.env new file mode 100644 index 0000000..1988dde --- /dev/null +++ b/apps/web-app/.env @@ -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 \ No newline at end of file diff --git a/apps/web-app/src/features/exchanges/services/exchangeApi.ts b/apps/web-app/src/features/exchanges/services/exchangeApi.ts index f14220b..3a416dc 100644 --- a/apps/web-app/src/features/exchanges/services/exchangeApi.ts +++ b/apps/web-app/src/features/exchanges/services/exchangeApi.ts @@ -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( diff --git a/bun.lock b/bun.lock index f80a063..c9ae2f9 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/config/default.json b/config/default.json new file mode 100644 index 0000000..51ab131 --- /dev/null +++ b/config/default.json @@ -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 + } +} \ No newline at end of file diff --git a/libs/config-new/.env.example b/libs/config-new/.env.example new file mode 100644 index 0000000..135c4e4 --- /dev/null +++ b/libs/config-new/.env.example @@ -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 \ No newline at end of file diff --git a/libs/config-new/README.md b/libs/config-new/README.md new file mode 100644 index 0000000..be5c83e --- /dev/null +++ b/libs/config-new/README.md @@ -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('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> { + // 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 \ No newline at end of file diff --git a/libs/config-new/config/default.json b/libs/config-new/config/default.json new file mode 100644 index 0000000..d5e1049 --- /dev/null +++ b/libs/config-new/config/default.json @@ -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 + } + } +} \ No newline at end of file diff --git a/libs/config-new/config/development.json b/libs/config-new/config/development.json new file mode 100644 index 0000000..839c7e9 --- /dev/null +++ b/libs/config-new/config/development.json @@ -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": "" + } + } +} \ No newline at end of file diff --git a/libs/config-new/config/production.json b/libs/config-new/config/production.json new file mode 100644 index 0000000..fe7a792 --- /dev/null +++ b/libs/config-new/config/production.json @@ -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 + } +} \ No newline at end of file diff --git a/libs/config-new/config/test.json b/libs/config-new/config/test.json new file mode 100644 index 0000000..f362037 --- /dev/null +++ b/libs/config-new/config/test.json @@ -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 + } +} \ No newline at end of file diff --git a/libs/config-new/package.json b/libs/config-new/package.json new file mode 100644 index 0000000..98ec227 --- /dev/null +++ b/libs/config-new/package.json @@ -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" + } +} \ No newline at end of file diff --git a/libs/config-new/src/cli.ts b/libs/config-new/src/cli.ts new file mode 100644 index 0000000..029f122 --- /dev/null +++ b/libs/config-new/src/cli.ts @@ -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 to config directory (default: ./config) + --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(); +} \ No newline at end of file diff --git a/libs/config-new/src/config-manager.ts b/libs/config-new/src/config-manager.ts new file mode 100644 index 0000000..220eac8 --- /dev/null +++ b/libs/config-new/src/config-manager.ts @@ -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> { + 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 { + 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[] = []; + 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(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): 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(schema: S): z.infer { + const config = this.get(); + return schema.parse(config); + } + + /** + * Create a typed configuration getter + */ + createTypedGetter(schema: S): () => z.infer { + 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[]): Record { + const result: Record = {}; + + 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; + } +} \ No newline at end of file diff --git a/libs/config-new/src/errors.ts b/libs/config-new/src/errors.ts new file mode 100644 index 0000000..a0d4bee --- /dev/null +++ b/libs/config-new/src/errors.ts @@ -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'; + } +} \ No newline at end of file diff --git a/libs/config-new/src/index.ts b/libs/config-new/src/index.ts new file mode 100644 index 0000000..db45332 --- /dev/null +++ b/libs/config-new/src/index.ts @@ -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 | null = null; + +/** + * Initialize the global configuration + */ +export async function initializeConfig( + configPath?: string +): Promise { + if (!configInstance) { + configInstance = new ConfigManager({ + 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 { + 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'; +} \ No newline at end of file diff --git a/libs/config-new/src/loaders/env.loader.ts b/libs/config-new/src/loaders/env.loader.ts new file mode 100644 index 0000000..68b241e --- /dev/null +++ b/libs/config-new/src/loaders/env.loader.ts @@ -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> { + try { + const config: Record = {}; + 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, 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, 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; + } +} \ No newline at end of file diff --git a/libs/config-new/src/loaders/file.loader.ts b/libs/config-new/src/loaders/file.loader.ts new file mode 100644 index 0000000..2079340 --- /dev/null +++ b/libs/config-new/src/loaders/file.loader.ts @@ -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> { + try { + const configs: Record[] = []; + + // 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 | 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[]): Record { + const result: Record = {}; + + 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; + } +} \ No newline at end of file diff --git a/libs/config-new/src/schemas/base.schema.ts b/libs/config-new/src/schemas/base.schema.ts new file mode 100644 index 0000000..2adb6bc --- /dev/null +++ b/libs/config-new/src/schemas/base.schema.ts @@ -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), +}); \ No newline at end of file diff --git a/libs/config-new/src/schemas/database.schema.ts b/libs/config-new/src/schemas/database.schema.ts new file mode 100644 index 0000000..d0b1666 --- /dev/null +++ b/libs/config-new/src/schemas/database.schema.ts @@ -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, +}); \ No newline at end of file diff --git a/libs/config-new/src/schemas/index.ts b/libs/config-new/src/schemas/index.ts new file mode 100644 index 0000000..40fe22f --- /dev/null +++ b/libs/config-new/src/schemas/index.ts @@ -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; \ No newline at end of file diff --git a/libs/config-new/src/schemas/provider.schema.ts b/libs/config-new/src/schemas/provider.schema.ts new file mode 100644 index 0000000..87d106b --- /dev/null +++ b/libs/config-new/src/schemas/provider.schema.ts @@ -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; \ No newline at end of file diff --git a/libs/config-new/src/schemas/service.schema.ts b/libs/config-new/src/schemas/service.schema.ts new file mode 100644 index 0000000..10f11e5 --- /dev/null +++ b/libs/config-new/src/schemas/service.schema.ts @@ -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(), +}); \ No newline at end of file diff --git a/libs/config-new/src/types/index.ts b/libs/config-new/src/types/index.ts new file mode 100644 index 0000000..8c9ce50 --- /dev/null +++ b/libs/config-new/src/types/index.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +export type Environment = 'development' | 'test' | 'production'; + +export interface ConfigLoader { + load(): Promise>; + readonly priority: number; +} + +export interface ConfigManagerOptions { + environment?: Environment; + configPath?: string; + loaders?: ConfigLoader[]; +} + +export type DeepPartial = T extends object + ? { + [P in keyof T]?: DeepPartial; + } + : T; + +export type ConfigSchema = z.ZodSchema; + +export interface ProviderConfig { + name: string; + enabled: boolean; + [key: string]: unknown; +} \ No newline at end of file diff --git a/libs/config-new/src/utils/secrets.ts b/libs/config-new/src/utils/secrets.ts new file mode 100644 index 0000000..35e8456 --- /dev/null +++ b/libs/config-new/src/utils/secrets.ts @@ -0,0 +1,182 @@ +import { z } from 'zod'; + +/** + * Secret value wrapper to prevent accidental logging + */ +export class SecretValue { + 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(fn: (value: T) => R, reason: string): SecretValue { + return new SecretValue(fn(this.reveal(reason))); + } +} + +/** + * Zod schema for secret values + */ +export const secretSchema = (schema: T) => { + return z.custom>>( + (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(value: T, mask?: string): SecretValue { + 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>( + 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 +): Record { + const result: Record = {}; + + 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; +} \ No newline at end of file diff --git a/libs/config-new/src/utils/validation.ts b/libs/config-new/src/utils/validation.ts new file mode 100644 index 0000000..338a1b0 --- /dev/null +++ b/libs/config-new/src/utils/validation.ts @@ -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( + config: unknown, + schema: z.ZodSchema +): 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, + deprecations: Record +): 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, + 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( + shape: T +): z.ZodObject { + return z.object(shape).strict(); +} + +/** + * Merge multiple schemas + */ +export function mergeSchemas( + ...schemas: T +): z.ZodIntersection { + 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; +} \ No newline at end of file diff --git a/libs/config-new/test/config-manager.test.ts b/libs/config-new/test/config-manager.test.ts new file mode 100644 index 0000000..bce9edb --- /dev/null +++ b/libs/config-new/test/config-manager.test.ts @@ -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, + public override priority: number = 0 + ) {} + + async load(): Promise> { + 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; + +describe('ConfigManager', () => { + let manager: ConfigManager; + + beforeEach(() => { + manager = new ConfigManager({ + 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({ + 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({ + 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('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'); + }); +}); \ No newline at end of file diff --git a/libs/config-new/test/index.test.ts b/libs/config-new/test/index.test.ts new file mode 100644 index 0000000..7ff6b8c --- /dev/null +++ b/libs/config-new/test/index.test.ts @@ -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); + }); +}); \ No newline at end of file diff --git a/libs/config-new/test/loaders.test.ts b/libs/config-new/test/loaders.test.ts new file mode 100644 index 0000000..40a484c --- /dev/null +++ b/libs/config-new/test/loaders.test.ts @@ -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); + }); +}); \ No newline at end of file diff --git a/libs/config-new/tsconfig.json b/libs/config-new/tsconfig.json new file mode 100644 index 0000000..64c38b3 --- /dev/null +++ b/libs/config-new/tsconfig.json @@ -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"] +} \ No newline at end of file diff --git a/libs/data-adjustments/package.json b/libs/data-adjustments/package.json deleted file mode 100644 index a41bdd9..0000000 --- a/libs/data-adjustments/package.json +++ /dev/null @@ -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" - } -}