Initial Commit
This commit is contained in:
184
packages/foundation/core/config/README.md
Normal file
184
packages/foundation/core/config/README.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# @suaveplan/config - Layered configuration management: defaults, file, object, env, CLI layers merged in priority order with deep-merge, Zod validation, OTel instrumentation, and event-bus watch support.
|
||||
|
||||
## Summary
|
||||
|
||||
Layered configuration management for Suaveplan. Resolves an ordered set of typed layers (defaults, file, object, env, CLI) in priority order, deep-merges the results, applies per-environment overrides, validates via a caller-supplied Zod schema or custom function, and exposes an immutable `ConfigStore` with live reload and event-bus change propagation.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [config-builder](./src/core/config-builder/config-builder.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [config-builder](./src/core/config-builder/config-builder.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [config-store](./src/core/config-store/config-store.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [config-store](./src/core/config-store/config-store.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [cli-layer](./src/layers/cli-layer/cli-layer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [cli-layer](./src/layers/cli-layer/cli-layer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [defaults-layer](./src/layers/defaults-layer/defaults-layer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [defaults-layer](./src/layers/defaults-layer/defaults-layer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [env-layer](./src/layers/env-layer/env-layer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [env-layer](./src/layers/env-layer/env-layer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [file-layer](./src/layers/file-layer/file-layer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [file-layer](./src/layers/file-layer/file-layer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [object-layer](./src/layers/object-layer/object-layer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [object-layer](./src/layers/object-layer/object-layer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [common](./src/types/common.md) — This module defines every public TypeScript interface and type alias used across the `@suaveplan/config` package. It is…
|
||||
- [schemas](./src/types/schemas.md) — This module exposes Zod schemas for every public options interface in `@suaveplan/config`. Using these schemas, callers…
|
||||
- [mask](./src/utils/mask.md) — This module provides low-level dot-notation path traversal utilities (`getByPath`, `setByPath`, `hasByPath`) and the `m…
|
||||
- [merge](./src/utils/merge.md) — This module provides two functions that form the merge backbone of the config pipeline: `deepMerge` performs a structur…
|
||||
<!-- end:auto-docs --> - [config-builder](./src/core/config-builder/config-builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [config-store](./src/core/config-store/config-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [config-store](./src/core/config-store/config-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [cli-layer](./src/layers/cli-layer/cli-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [cli-layer](./src/layers/cli-layer/cli-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [defaults-layer](./src/layers/defaults-layer/defaults-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [defaults-layer](./src/layers/defaults-layer/defaults-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [env-layer](./src/layers/env-layer/env-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [env-layer](./src/layers/env-layer/env-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [file-layer](./src/layers/file-layer/file-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [file-layer](./src/layers/file-layer/file-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [object-layer](./src/layers/object-layer/object-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [object-layer](./src/layers/object-layer/object-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — This module defines every public TypeScript interface and type alias used across the `@suaveplan/config` package. It is…
|
||||
- [schemas](./src/types/schemas.md) — This module exposes Zod schemas for every public options interface in `@suaveplan/config`. Using these schemas, callers…
|
||||
- [mask](./src/utils/mask.md) — This module provides low-level dot-notation path traversal utilities (`getByPath`, `setByPath`, `hasByPath`) and the `m…
|
||||
- [merge](./src/utils/merge.md) — This module provides two functions that form the merge backbone of the config pipeline: `deepMerge` performs a structur…
|
||||
- [config-builder](./src/core/config-builder/config-builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [config-store](./src/core/config-store/config-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [config-store](./src/core/config-store/config-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [cli-layer](./src/layers/cli-layer/cli-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [cli-layer](./src/layers/cli-layer/cli-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [defaults-layer](./src/layers/defaults-layer/defaults-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [defaults-layer](./src/layers/defaults-layer/defaults-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [env-layer](./src/layers/env-layer/env-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [env-layer](./src/layers/env-layer/env-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [file-layer](./src/layers/file-layer/file-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [file-layer](./src/layers/file-layer/file-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [object-layer](./src/layers/object-layer/object-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [object-layer](./src/layers/object-layer/object-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — This module defines every public TypeScript interface and type alias used across the `@suaveplan/config` package. It is…
|
||||
- [schemas](./src/types/schemas.md) — This module exposes Zod schemas for every public options interface in `@suaveplan/config`. Using these schemas, callers…
|
||||
- [mask](./src/utils/mask.md) — This module provides low-level dot-notation path traversal utilities (`getByPath`, `setByPath`, `hasByPath`) and the `m…
|
||||
- [merge](./src/utils/merge.md) — This module provides two functions that form the merge backbone of the config pipeline: `deepMerge` performs a structur…
|
||||
- [config-builder](./src/core/config-builder/config-builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [config-store](./src/core/config-store/config-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [config-store](./src/core/config-store/config-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [cli-layer](./src/layers/cli-layer/cli-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [cli-layer](./src/layers/cli-layer/cli-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [defaults-layer](./src/layers/defaults-layer/defaults-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [defaults-layer](./src/layers/defaults-layer/defaults-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [env-layer](./src/layers/env-layer/env-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [env-layer](./src/layers/env-layer/env-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [file-layer](./src/layers/file-layer/file-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [file-layer](./src/layers/file-layer/file-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [object-layer](./src/layers/object-layer/object-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [object-layer](./src/layers/object-layer/object-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — This module defines every public TypeScript interface and type alias used across the `@suaveplan/config` package. It is…
|
||||
- [schemas](./src/types/schemas.md) — This module exposes Zod schemas for every public options interface in `@suaveplan/config`. Using these schemas, callers…
|
||||
- [mask](./src/utils/mask.md) — This module provides low-level dot-notation path traversal utilities (`getByPath`, `setByPath`, `hasByPath`) and the `m…
|
||||
- [merge](./src/utils/merge.md) — This module provides two functions that form the merge backbone of the config pipeline: `deepMerge` performs a structur…
|
||||
- [config-builder](./src/core/config-builder/config-builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [config-store](./src/core/config-store/config-store.md) — <!-- begin:auto-docs --> _(no submodules)_ - [config-store](./src/core/config-store/config-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [cli-layer](./src/layers/cli-layer/cli-layer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [cli-layer](./src/layers/cli-layer/cli-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [defaults-layer](./src/layers/defaults-layer/defaults-layer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [defaults-layer](./src/layers/defaults-layer/defaults-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [env-layer](./src/layers/env-layer/env-layer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [env-layer](./src/layers/env-layer/env-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [file-layer](./src/layers/file-layer/file-layer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [file-layer](./src/layers/file-layer/file-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [object-layer](./src/layers/object-layer/object-layer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [object-layer](./src/layers/object-layer/object-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — This module defines every public TypeScript interface and type alias used across the `@suaveplan/config` package. It is…
|
||||
- [schemas](./src/types/schemas.md) — This module exposes Zod schemas for every public options interface in `@suaveplan/config`. Using these schemas, callers…
|
||||
- [mask](./src/utils/mask.md) — This module provides low-level dot-notation path traversal utilities (`getByPath`, `setByPath`, `hasByPath`) and the `m…
|
||||
- [merge](./src/utils/merge.md) — This module provides two functions that form the merge backbone of the config pipeline: `deepMerge` performs a structur…
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/config
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { createConfig, defaultsLayer, fileLayer, envLayer, cliLayer } from '@suaveplan/config';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import z from 'zod';
|
||||
|
||||
const Schema = z.object({
|
||||
server: z.object({ port: z.number(), host: z.string() }),
|
||||
db: z.object({ url: z.string(), pool: z.number(), password: z.string() }),
|
||||
log: z.object({ level: z.string() }),
|
||||
});
|
||||
type AppConfig = z.infer<typeof Schema>;
|
||||
|
||||
const store = createConfig<AppConfig>({
|
||||
layers: [
|
||||
defaultsLayer({ server: { port: 3000, host: '0.0.0.0' }, db: { pool: 5, password: '' }, log: { level: 'info' } }),
|
||||
fileLayer({ content: JSON.parse(readFileSync('./config.json', 'utf-8')) }),
|
||||
envLayer({ prefix: 'APP_', mapping: { PORT: 'server.port', DB_URL: 'db.url', DB_PASS: 'db.password' } }),
|
||||
cliLayer({ args: process.argv.slice(2), mapping: { port: 'server.port' } }),
|
||||
],
|
||||
validate: (raw) => Schema.parse(raw),
|
||||
sensitive: ['db.password', 'db.url'],
|
||||
environments: { production: { log: { level: 'warn' } }, development: { log: { level: 'debug' } } },
|
||||
environment: process.env.NODE_ENV ?? 'development',
|
||||
});
|
||||
|
||||
console.log(store.get('server.port')); // number
|
||||
console.log(store.toMasked()); // db.password = '****'
|
||||
|
||||
// Live reload
|
||||
await store.reload();
|
||||
|
||||
// Subscribe to changes
|
||||
const unsub = store.subscribe((next) => console.log('Config updated:', next));
|
||||
```
|
||||
|
||||
## Module Documentation
|
||||
|
||||
| Module | Description |
|
||||
|--------|-------------|
|
||||
| [`src/types/common.md`](src/types/common.md) | All public TypeScript interfaces and type aliases |
|
||||
| [`src/types/schemas.md`](src/types/schemas.md) | Zod option schemas for runtime validation |
|
||||
| [`src/core/config-builder/config-builder.md`](src/core/config-builder/config-builder.md) | `createConfig` orchestrator |
|
||||
| [`src/core/config-store/config-store.md`](src/core/config-store/config-store.md) | `createConfigStore` static store factory |
|
||||
| [`src/layers/defaults-layer/defaults-layer.md`](src/layers/defaults-layer/defaults-layer.md) | Static default values layer |
|
||||
| [`src/layers/object-layer/object-layer.md`](src/layers/object-layer/object-layer.md) | In-memory config object layer |
|
||||
| [`src/layers/env-layer/env-layer.md`](src/layers/env-layer/env-layer.md) | Environment variable layer + `coerceValue` |
|
||||
| [`src/layers/file-layer/file-layer.md`](src/layers/file-layer/file-layer.md) | Pre-parsed file content layer with watch mode |
|
||||
| [`src/layers/cli-layer/cli-layer.md`](src/layers/cli-layer/cli-layer.md) | CLI argument layer + `parseArgs` |
|
||||
| [`src/utils/merge.md`](src/utils/merge.md) | `deepMerge` and `mergeLayers` utilities |
|
||||
| [`src/utils/mask.md`](src/utils/mask.md) | Path utilities and `maskSensitive` |
|
||||
|
||||
## Layer Priorities
|
||||
|
||||
| Layer | Priority | Description |
|
||||
|-------|----------|-------------|
|
||||
| `defaultsLayer` | 0 | Static baseline — always overridden |
|
||||
| `fileLayer` | 10 | Pre-parsed file content |
|
||||
| `objectLayer` | 20 | In-memory programmatic overrides |
|
||||
| `envLayer` | 30 | Environment variables |
|
||||
| `cliLayer` | 40 | CLI arguments — highest priority |
|
||||
|
||||
## API
|
||||
|
||||
### Core
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `createConfig(options)` | Resolves layers, merges, validates, returns live store |
|
||||
| `createConfigStore(options)` | Low-level static store factory |
|
||||
|
||||
### Layers
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `defaultsLayer(defaults)` | Static defaults at priority 0 |
|
||||
| `fileLayer(options)` | Pre-parsed file content at priority 10 |
|
||||
| `objectLayer(data, priority?)` | In-memory object at priority 20 |
|
||||
| `envLayer(options)` | Environment variables at priority 30 |
|
||||
| `cliLayer(options)` | CLI arguments at priority 40 |
|
||||
| `parseArgs(args, mapping)` | Parse raw argv into flag→value map |
|
||||
|
||||
### Utilities
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `deepMerge(base, override)` | Recursive object merge |
|
||||
| `mergeLayers(layers)` | Fold ordered layers with deepMerge |
|
||||
| `getByPath(obj, path)` | Read dot-notation path |
|
||||
| `setByPath(obj, path, value)` | Write dot-notation path |
|
||||
| `hasByPath(obj, path)` | Check dot-notation path existence |
|
||||
| `maskSensitive(data, paths, mask?)` | Replace sensitive paths |
|
||||
| `coerceValue(raw)` | Convert env-var string to typed value |
|
||||
|
||||
### Error Codes
|
||||
|
||||
| Code | Description |
|
||||
|------|-------------|
|
||||
| `CONFIG.MISSING` | Required config key not found |
|
||||
| `CONFIG.INVALID_TYPE` | Value has wrong type |
|
||||
| `CONFIG.PARSE_FAILED` | Layer resolution threw |
|
||||
| `CONFIG.FILE_NOT_FOUND` | File layer file not found |
|
||||
| `CONFIG.ENV_NOT_SET` | Required env var not set |
|
||||
| `CONFIG.RELOAD_FAILED` | Reload aborted or layer threw |
|
||||
| `CONFIG.VALIDATION_FAILED` | Validator threw |
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Package | Role |
|
||||
|---------|------|
|
||||
| `@suaveplan/error` | `SuaveplanError` base, `defineCodes` |
|
||||
| `@suaveplan/types` | `LoggerLike`, `EventBusLike` |
|
||||
| `@suaveplan/events` | `EventBus` for file-layer watch mode |
|
||||
| `@suaveplan/env` | Coercion functions for env-var values |
|
||||
| `zod` | Option schemas |
|
||||
| `@opentelemetry/api` | Spans, counters, histograms |
|
||||
| `tslib` | TypeScript runtime helpers |
|
||||
|
||||
## Benchmarks
|
||||
|
||||
Run with `bun run bench`.
|
||||
|
||||
| Benchmark | Operations / sec (approx.) |
|
||||
|-----------|---------------------------|
|
||||
| `createConfig` 5 layers (cold) | TBD |
|
||||
| `store.get` 100x deeply nested key | TBD |
|
||||
| `mergeLayers` 2 layers | TBD |
|
||||
| `mergeLayers` 5 layers | TBD |
|
||||
| `mergeLayers` 10 layers | TBD |
|
||||
| `mergeLayers` 20 layers | TBD |
|
||||
|
||||
_Run `bun run bench` to populate these numbers on your hardware._
|
||||
|
||||
198
packages/foundation/core/config/package.json
Normal file
198
packages/foundation/core/config/package.json
Normal file
@@ -0,0 +1,198 @@
|
||||
{
|
||||
"name": "@suaveplan/config",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Layered configuration management: defaults, file, object, env, CLI layers merged in priority order with deep-merge, Zod validation, OTel instrumentation, and event-bus watch support.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./constants": {
|
||||
"import": {
|
||||
"types": "./dist/constants/index.d.ts",
|
||||
"default": "./dist/constants/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/constants/index.d.ts",
|
||||
"default": "./dist/constants/index.js"
|
||||
}
|
||||
},
|
||||
"./types/common": {
|
||||
"import": {
|
||||
"types": "./dist/types/common.d.ts",
|
||||
"default": "./dist/types/common.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/types/common.d.ts",
|
||||
"default": "./dist/types/common.js"
|
||||
}
|
||||
},
|
||||
"./types/schemas": {
|
||||
"import": {
|
||||
"types": "./dist/types/schemas.d.ts",
|
||||
"default": "./dist/types/schemas.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/types/schemas.d.ts",
|
||||
"default": "./dist/types/schemas.js"
|
||||
}
|
||||
},
|
||||
"./core/config-builder/config-builder": {
|
||||
"import": {
|
||||
"types": "./dist/core/config-builder/config-builder.d.ts",
|
||||
"default": "./dist/core/config-builder/config-builder.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/core/config-builder/config-builder.d.ts",
|
||||
"default": "./dist/core/config-builder/config-builder.js"
|
||||
}
|
||||
},
|
||||
"./core/config-store/config-store": {
|
||||
"import": {
|
||||
"types": "./dist/core/config-store/config-store.d.ts",
|
||||
"default": "./dist/core/config-store/config-store.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/core/config-store/config-store.d.ts",
|
||||
"default": "./dist/core/config-store/config-store.js"
|
||||
}
|
||||
},
|
||||
"./layers/defaults-layer/defaults-layer": {
|
||||
"import": {
|
||||
"types": "./dist/layers/defaults-layer/defaults-layer.d.ts",
|
||||
"default": "./dist/layers/defaults-layer/defaults-layer.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/layers/defaults-layer/defaults-layer.d.ts",
|
||||
"default": "./dist/layers/defaults-layer/defaults-layer.js"
|
||||
}
|
||||
},
|
||||
"./layers/object-layer/object-layer": {
|
||||
"import": {
|
||||
"types": "./dist/layers/object-layer/object-layer.d.ts",
|
||||
"default": "./dist/layers/object-layer/object-layer.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/layers/object-layer/object-layer.d.ts",
|
||||
"default": "./dist/layers/object-layer/object-layer.js"
|
||||
}
|
||||
},
|
||||
"./layers/env-layer/env-layer": {
|
||||
"import": {
|
||||
"types": "./dist/layers/env-layer/env-layer.d.ts",
|
||||
"default": "./dist/layers/env-layer/env-layer.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/layers/env-layer/env-layer.d.ts",
|
||||
"default": "./dist/layers/env-layer/env-layer.js"
|
||||
}
|
||||
},
|
||||
"./layers/file-layer/file-layer": {
|
||||
"import": {
|
||||
"types": "./dist/layers/file-layer/file-layer.d.ts",
|
||||
"default": "./dist/layers/file-layer/file-layer.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/layers/file-layer/file-layer.d.ts",
|
||||
"default": "./dist/layers/file-layer/file-layer.js"
|
||||
}
|
||||
},
|
||||
"./layers/cli-layer/cli-layer": {
|
||||
"import": {
|
||||
"types": "./dist/layers/cli-layer/cli-layer.d.ts",
|
||||
"default": "./dist/layers/cli-layer/cli-layer.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/layers/cli-layer/cli-layer.d.ts",
|
||||
"default": "./dist/layers/cli-layer/cli-layer.js"
|
||||
}
|
||||
},
|
||||
"./utils/merge": {
|
||||
"import": {
|
||||
"types": "./dist/utils/merge.d.ts",
|
||||
"default": "./dist/utils/merge.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/utils/merge.d.ts",
|
||||
"default": "./dist/utils/merge.js"
|
||||
}
|
||||
},
|
||||
"./utils/mask": {
|
||||
"import": {
|
||||
"types": "./dist/utils/mask.d.ts",
|
||||
"default": "./dist/utils/mask.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/utils/mask.d.ts",
|
||||
"default": "./dist/utils/mask.js"
|
||||
}
|
||||
},
|
||||
"./errors/error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/index.d.ts",
|
||||
"default": "./dist/error-codes/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/error-codes/index.d.ts",
|
||||
"default": "./dist/error-codes/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"bench": "vitest bench",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"@suaveplan/env": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms",
|
||||
"@opentelemetry/api": "catalog:observability"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/feature-flags": "workspace:^",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"mitata": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
32
packages/foundation/core/config/src/constants/index.ts
Normal file
32
packages/foundation/core/config/src/constants/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Priority values for each built-in config layer.
|
||||
* Lower numbers are resolved first; higher numbers win in the merge.
|
||||
*/
|
||||
export const LAYER_PRIORITIES = {
|
||||
DEFAULTS: 0,
|
||||
FILE: 10,
|
||||
OBJECT: 20,
|
||||
ENV: 30,
|
||||
CLI: 40,
|
||||
} as const;
|
||||
|
||||
/** Replacement string used by maskSensitive when redacting sensitive paths. */
|
||||
export const MASK_STRING = "****";
|
||||
|
||||
/** String tokens that coerceValue maps to boolean `true`. */
|
||||
export const TRUTHY_STRINGS: readonly string[] = [
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
"on",
|
||||
"enabled",
|
||||
];
|
||||
|
||||
/** String tokens that coerceValue maps to boolean `false`. */
|
||||
export const FALSY_STRINGS: readonly string[] = [
|
||||
"false",
|
||||
"0",
|
||||
"no",
|
||||
"off",
|
||||
"disabled",
|
||||
];
|
||||
@@ -0,0 +1,108 @@
|
||||
# config-builder — `createConfig` orchestrator for `@suaveplan/config`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`createConfig` is the primary entry point to `@suaveplan/config`. It accepts
|
||||
a list of `ConfigLayer` objects, resolves each in turn, sorts them by priority,
|
||||
deep-merges the results, applies any per-environment overrides, runs the
|
||||
caller-supplied validator, and returns a live `ConfigStore`. The live store
|
||||
supports `reload()` to re-run the full pipeline and `subscribe()` to receive
|
||||
change notifications after each successful reload. All lifecycle events are
|
||||
traced via OTel spans and optionally logged via the injected `LoggerLike`.
|
||||
|
||||
## Features
|
||||
|
||||
- Sorts layers ascending by `priority` before merging
|
||||
- Wraps each layer's `resolve()` in a try/catch and rethrows as
|
||||
`ConfigError(CONFIG.PARSE_FAILED)`
|
||||
- Applies `environments[environment]` override after merging (if present)
|
||||
- Calls `options.validate` if provided; wraps throws as
|
||||
`ConfigError(CONFIG.VALIDATION_FAILED)`
|
||||
- Returns a live store with `reload(signal?)` and `subscribe(listener)`
|
||||
- `reload` re-runs full pipeline; notifies all subscribers on success
|
||||
- `subscribe` returns an unsubscribe function
|
||||
- OTel span `config.create` and counter `config.get_total` / `config.reload_total`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createConfig, defaultsLayer, envLayer } from '@suaveplan/config';
|
||||
|
||||
const store = createConfig({
|
||||
layers: [
|
||||
defaultsLayer({ port: 3000, debug: false }),
|
||||
envLayer({ mapping: { PORT: 'port', DEBUG: 'debug' } }),
|
||||
],
|
||||
});
|
||||
|
||||
store.get('port'); // value from env or 3000
|
||||
store.get('debug'); // true/false from env or false
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createConfig, defaultsLayer, fileLayer, envLayer, cliLayer } from '@suaveplan/config';
|
||||
import z from 'zod';
|
||||
|
||||
const Schema = z.object({
|
||||
port: z.number().int().min(1).max(65535),
|
||||
debug: z.boolean(),
|
||||
db: z.object({ url: z.string().url(), pool: z.number().int().positive() }),
|
||||
});
|
||||
type AppConfig = z.infer<typeof Schema>;
|
||||
|
||||
const store = createConfig<AppConfig>({
|
||||
layers: [
|
||||
defaultsLayer({ port: 3000, debug: false, db: { url: 'postgres://localhost/app', pool: 5 } }),
|
||||
fileLayer({ content: JSON.parse(readFileSync('./config.json', 'utf-8')) }),
|
||||
envLayer({ prefix: 'APP_', mapping: { PORT: 'port', DB_URL: 'db.url' } }),
|
||||
cliLayer({ args: process.argv.slice(2), mapping: { port: 'port', debug: 'debug' } }),
|
||||
],
|
||||
validate: (raw) => Schema.parse(raw),
|
||||
sensitive: ['db.url'],
|
||||
environments: { production: { debug: false }, development: { debug: true } },
|
||||
environment: process.env.NODE_ENV ?? 'development',
|
||||
});
|
||||
|
||||
// Live reload on SIGHUP
|
||||
process.on('SIGHUP', () => store.reload());
|
||||
|
||||
// Subscribe to config changes
|
||||
const unsub = store.subscribe((next) => {
|
||||
console.log('Config updated, new port:', next.port);
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createConfig<T extends ConfigData>(options: CreateConfigOptions<T>): ConfigStore<T>`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `options.layers` | `ConfigLayer[]` | Layers to resolve; sorted by priority |
|
||||
| `options.validate` | `ConfigValidator<T>` | Optional post-merge validator |
|
||||
| `options.sensitive` | `string[]` | Paths to mask in `toMasked()` |
|
||||
| `options.environments` | `EnvironmentOverrides<T>` | Per-env overrides |
|
||||
| `options.environment` | `string` | Active environment name |
|
||||
| `options.logger` | `LoggerLike` | Optional structured logger |
|
||||
|
||||
**Returns** a live `ConfigStore<T>` with real `reload` and `subscribe`.
|
||||
|
||||
**Throws:**
|
||||
|
||||
- `ConfigError(CONFIG.PARSE_FAILED)` — a layer's `resolve()` threw
|
||||
- `ConfigError(CONFIG.VALIDATION_FAILED)` — the validator threw
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Layer resolution is synchronous — all layer `resolve()` methods must be
|
||||
synchronous. The `reload()` method is async (returns a `Promise<void>`) to
|
||||
accommodate future async layer support and to allow AbortSignal handling.
|
||||
The store's internal `frozenData` reference is atomically swapped after a
|
||||
successful reload; subscribers see only fully validated data. Partial failures
|
||||
(e.g. one layer throws during reload) leave the store at the last good state
|
||||
and propagate the error to the `reload()` caller.
|
||||
@@ -0,0 +1,263 @@
|
||||
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
|
||||
import { ConfigError } from "../../error-codes/error-codes.js";
|
||||
import { cliLayer } from "../../layers/cli-layer/cli-layer.js";
|
||||
import { defaultsLayer } from "../../layers/defaults-layer/defaults-layer.js";
|
||||
import { envLayer } from "../../layers/env-layer/env-layer.js";
|
||||
import { fileLayer } from "../../layers/file-layer/file-layer.js";
|
||||
import { objectLayer } from "../../layers/object-layer/object-layer.js";
|
||||
import { createConfig } from "./config-builder.js";
|
||||
|
||||
describe("createConfig — happy path", () => {
|
||||
it("resolves a single defaults layer", { timeout: 5000 }, () => {
|
||||
const store = createConfig({
|
||||
layers: [defaultsLayer({ port: 3000, debug: false })],
|
||||
});
|
||||
expect(store.get("port")).toBe(3000);
|
||||
expect(store.get("debug")).toBe(false);
|
||||
});
|
||||
|
||||
it("merges multiple layers in priority order", { timeout: 5000 }, () => {
|
||||
const store = createConfig({
|
||||
layers: [
|
||||
defaultsLayer({ port: 3000, debug: false, host: "localhost" }),
|
||||
objectLayer({ port: 4000 }),
|
||||
envLayer({
|
||||
mapping: { DEBUG: "debug" },
|
||||
source: { DEBUG: "true" },
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(store.get("port")).toBe(4000);
|
||||
expect(store.get("debug")).toBe(true);
|
||||
expect(store.get("host")).toBe("localhost");
|
||||
});
|
||||
|
||||
it("applies environment override", { timeout: 5000 }, () => {
|
||||
const store = createConfig({
|
||||
layers: [defaultsLayer({ debug: false })],
|
||||
environments: { development: { debug: true } },
|
||||
environment: "development",
|
||||
});
|
||||
expect(store.get("debug")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not apply override when environment does not match", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const store = createConfig({
|
||||
layers: [defaultsLayer({ debug: false })],
|
||||
environments: { development: { debug: true } },
|
||||
environment: "production",
|
||||
});
|
||||
expect(store.get("debug")).toBe(false);
|
||||
});
|
||||
|
||||
it("calls validate with merged data", { timeout: 5000 }, () => {
|
||||
const validate = vi.fn(
|
||||
(d: Record<string, unknown>) => d as Record<string, unknown>
|
||||
);
|
||||
createConfig({
|
||||
layers: [defaultsLayer({ port: 3000 })],
|
||||
validate,
|
||||
});
|
||||
expect(validate).toHaveBeenCalledOnce();
|
||||
expect(validate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ port: 3000 })
|
||||
);
|
||||
});
|
||||
|
||||
it("returns validated data from store", { timeout: 5000 }, () => {
|
||||
const store = createConfig({
|
||||
layers: [defaultsLayer({ port: "3000" })],
|
||||
validate: (d) => ({
|
||||
...d,
|
||||
port: Number((d as Record<string, unknown>).port),
|
||||
}),
|
||||
});
|
||||
expect(store.get("port")).toBe(3000);
|
||||
});
|
||||
|
||||
it("exposes sensitive masking via toMasked", { timeout: 5000 }, () => {
|
||||
const store = createConfig({
|
||||
layers: [defaultsLayer({ db: { password: "secret" } })],
|
||||
sensitive: ["db.password"],
|
||||
});
|
||||
const masked = store.toMasked() as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
expect(masked.db.password).toBe("****");
|
||||
expect(store.get("db.password")).toBe("secret");
|
||||
});
|
||||
|
||||
it("calls logger.debug on start and done", { timeout: 5000 }, () => {
|
||||
const logger = { debug: vi.fn() };
|
||||
createConfig({
|
||||
layers: [defaultsLayer({ port: 3000 })],
|
||||
logger,
|
||||
});
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
"config:create:start",
|
||||
expect.objectContaining({ layerCount: 1 })
|
||||
);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
"config:create:done",
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createConfig — error handling", () => {
|
||||
it("throws ConfigError CONFIG.PARSE_FAILED when layer throws", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const badLayer = {
|
||||
name: "bad",
|
||||
priority: 0,
|
||||
resolve: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
};
|
||||
expect(() => createConfig({ layers: [badLayer] })).toThrow(ConfigError);
|
||||
});
|
||||
|
||||
it("throws ConfigError CONFIG.VALIDATION_FAILED when validator throws", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
createConfig({
|
||||
layers: [defaultsLayer({ port: 3000 })],
|
||||
validate: () => {
|
||||
throw new Error("invalid");
|
||||
},
|
||||
})
|
||||
).toThrow(ConfigError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createConfig — reload", () => {
|
||||
it("reload re-resolves layers", { timeout: 30000 }, async () => {
|
||||
let counter = 0;
|
||||
const dynamic = {
|
||||
name: "dynamic",
|
||||
priority: 0,
|
||||
resolve: () => ({ count: ++counter }),
|
||||
};
|
||||
const store = createConfig({ layers: [dynamic] });
|
||||
expect(store.get("count")).toBe(1);
|
||||
await store.reload();
|
||||
expect(store.get("count")).toBe(2);
|
||||
});
|
||||
|
||||
it("subscribe is notified on reload", { timeout: 30000 }, async () => {
|
||||
let counter = 0;
|
||||
const dynamic = {
|
||||
name: "dynamic",
|
||||
priority: 0,
|
||||
resolve: () => ({ count: ++counter }),
|
||||
};
|
||||
const store = createConfig({ layers: [dynamic] });
|
||||
|
||||
const received: number[] = [];
|
||||
store.subscribe((next) => {
|
||||
received.push((next as Record<string, unknown>).count as number);
|
||||
});
|
||||
|
||||
await store.reload();
|
||||
expect(received).toEqual([2]);
|
||||
});
|
||||
|
||||
it("unsubscribe stops notifications", { timeout: 30000 }, async () => {
|
||||
let counter = 0;
|
||||
const dynamic = {
|
||||
name: "dynamic",
|
||||
priority: 0,
|
||||
resolve: () => ({ count: ++counter }),
|
||||
};
|
||||
const store = createConfig({ layers: [dynamic] });
|
||||
|
||||
const received: number[] = [];
|
||||
const unsub = store.subscribe((next) => {
|
||||
received.push((next as Record<string, unknown>).count as number);
|
||||
});
|
||||
|
||||
await store.reload(); // received = [2]
|
||||
unsub();
|
||||
await store.reload(); // should not push
|
||||
expect(received).toEqual([2]);
|
||||
});
|
||||
|
||||
it("reload with already-aborted signal throws", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const store = createConfig({ layers: [defaultsLayer({ port: 3000 })] });
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
await expect(store.reload(ctrl.signal)).rejects.toThrow(ConfigError);
|
||||
});
|
||||
|
||||
it("reload throws when layer throws during re-resolution", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
let shouldThrow = false;
|
||||
const dynamic = {
|
||||
name: "dynamic",
|
||||
priority: 0,
|
||||
resolve: () => {
|
||||
if (shouldThrow) throw new Error("layer error");
|
||||
return { port: 3000 };
|
||||
},
|
||||
};
|
||||
const store = createConfig({ layers: [dynamic] });
|
||||
shouldThrow = true;
|
||||
await expect(store.reload()).rejects.toThrow(ConfigError);
|
||||
// Store remains at last good state
|
||||
expect(store.get("port")).toBe(3000);
|
||||
});
|
||||
|
||||
it("reload wraps non-ConfigError from layer in reloadFailed", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// The try/catch inside reload catches non-ConfigError and wraps with reloadFailed
|
||||
// We simulate by having validation throw a plain Error AFTER a successful layer resolve
|
||||
let shouldFail = false;
|
||||
const dynamic = {
|
||||
name: "dynamic",
|
||||
priority: 0,
|
||||
resolve: () => ({ port: 3000 }),
|
||||
};
|
||||
const store = createConfig({
|
||||
layers: [dynamic],
|
||||
validate: (d) => {
|
||||
if (shouldFail) throw new Error("validation boom");
|
||||
return d;
|
||||
},
|
||||
});
|
||||
shouldFail = true;
|
||||
const err = await store.reload().catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(ConfigError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createConfig — full pipeline", () => {
|
||||
it("five-layer pipeline merges in correct order", { timeout: 5000 }, () => {
|
||||
const store = createConfig({
|
||||
layers: [
|
||||
defaultsLayer({ port: 3000, debug: false, host: "localhost" }),
|
||||
fileLayer({ content: { port: 3100, timeout: 30000 } }),
|
||||
objectLayer({ port: 3200 }),
|
||||
envLayer({
|
||||
mapping: { PORT: "port" },
|
||||
source: { PORT: "3300" },
|
||||
}),
|
||||
cliLayer({ args: ["--port=3400"], mapping: { port: "port" } }),
|
||||
],
|
||||
});
|
||||
// CLI (priority 40) wins for port
|
||||
expect(store.get("port")).toBe(3400);
|
||||
// file layer contributes timeout
|
||||
expect(store.get("timeout")).toBe(30000);
|
||||
// defaults provides host
|
||||
expect(store.get("host")).toBe("localhost");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import { metrics, trace } from "@opentelemetry/api";
|
||||
import {
|
||||
ConfigError,
|
||||
layerError,
|
||||
validationFailed,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { wireLayerReload } from "../../layers/file-layer/file-layer.js";
|
||||
import type {
|
||||
ConfigData,
|
||||
ConfigLayer,
|
||||
ConfigStore,
|
||||
CreateConfigOptions,
|
||||
} from "../../types/common.js";
|
||||
import { getByPath, hasByPath, maskSensitive } from "../../utils/mask.js";
|
||||
import { deepMerge, mergeLayers } from "../../utils/merge.js";
|
||||
import type { MergeResult, ResolvedLayer } from "./config-builder.types.js";
|
||||
|
||||
const tracer = trace.getTracer("config");
|
||||
const meter = metrics.getMeter("config");
|
||||
const getCounter = meter.createCounter("config.get_total", {
|
||||
description: "Number of config.get() calls",
|
||||
});
|
||||
const reloadCounter = meter.createCounter("config.reload_total", {
|
||||
description: "Number of store reload calls",
|
||||
});
|
||||
const reloadDurationHistogram = meter.createHistogram(
|
||||
"config.reload_duration_ms",
|
||||
{
|
||||
description: "Duration of store reload in milliseconds",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
|
||||
// ── Internal helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function resolveLayers(layers: ConfigLayer[]): MergeResult {
|
||||
const resolved: ResolvedLayer[] = [];
|
||||
|
||||
for (const layer of layers) {
|
||||
try {
|
||||
const data = layer.resolve();
|
||||
resolved.push({ name: layer.name, priority: layer.priority, data });
|
||||
} catch (err) {
|
||||
throw layerError(layer.name, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort ascending so higher-priority layers come last in merge (they win).
|
||||
resolved.sort((a, b) => a.priority - b.priority);
|
||||
|
||||
const data = mergeLayers(resolved);
|
||||
return { data, layers: resolved };
|
||||
}
|
||||
|
||||
function applyEnvironment<T extends ConfigData>(
|
||||
data: T,
|
||||
options: CreateConfigOptions<T>
|
||||
): T {
|
||||
if (
|
||||
options.environment === undefined ||
|
||||
options.environments === undefined
|
||||
) {
|
||||
return data;
|
||||
}
|
||||
const override = options.environments[options.environment];
|
||||
if (override === undefined) return data;
|
||||
return deepMerge(data, override as Partial<T>);
|
||||
}
|
||||
|
||||
function buildLiveStore<T extends ConfigData>(
|
||||
initialData: T,
|
||||
options: CreateConfigOptions<T>
|
||||
): ConfigStore<T> {
|
||||
const sensitive = options.sensitive ?? [];
|
||||
let frozenData = Object.freeze(structuredClone(initialData)) as Readonly<T>;
|
||||
const listeners = new Set<(next: Readonly<T>) => void>();
|
||||
|
||||
function deepFreeze<U>(obj: U): Readonly<U> {
|
||||
if (obj === null || typeof obj !== "object") return obj;
|
||||
for (const key of Object.keys(obj as object)) {
|
||||
deepFreeze((obj as Record<string, unknown>)[key]);
|
||||
}
|
||||
return Object.freeze(obj);
|
||||
}
|
||||
|
||||
const store: ConfigStore<T> = {
|
||||
get<K extends string>(key: K): unknown {
|
||||
const hit = hasByPath(frozenData as ConfigData, key);
|
||||
getCounter.add(1, { "config.hit": hit });
|
||||
return getByPath(frozenData as ConfigData, key);
|
||||
},
|
||||
|
||||
has(key: string): boolean {
|
||||
return hasByPath(frozenData as ConfigData, key);
|
||||
},
|
||||
|
||||
toJSON(): Readonly<T> {
|
||||
return frozenData;
|
||||
},
|
||||
|
||||
toMasked(): Readonly<T> {
|
||||
const masked = maskSensitive(frozenData as ConfigData, sensitive);
|
||||
return deepFreeze(masked) as Readonly<T>;
|
||||
},
|
||||
|
||||
async reload(signal?: AbortSignal): Promise<void> {
|
||||
const start = Date.now();
|
||||
return tracer.startActiveSpan("config.reload", async (span) => {
|
||||
try {
|
||||
if (signal?.aborted) {
|
||||
throw new ConfigError(
|
||||
"CONFIG.RELOAD_FAILED",
|
||||
"Reload aborted before starting"
|
||||
);
|
||||
}
|
||||
|
||||
const result = resolveLayers(options.layers);
|
||||
const envApplied = applyEnvironment(
|
||||
result.data as T,
|
||||
options
|
||||
);
|
||||
let merged: T;
|
||||
if (options.validate !== undefined) {
|
||||
try {
|
||||
merged = options.validate(envApplied);
|
||||
} catch (err) {
|
||||
throw validationFailed(err);
|
||||
}
|
||||
} else {
|
||||
merged = envApplied;
|
||||
}
|
||||
|
||||
frozenData = deepFreeze(
|
||||
structuredClone(merged)
|
||||
) as Readonly<T>;
|
||||
|
||||
for (const listener of listeners) {
|
||||
listener(frozenData);
|
||||
}
|
||||
|
||||
reloadCounter.add(1);
|
||||
} catch (err) {
|
||||
span.recordException(err as Error);
|
||||
throw err;
|
||||
} finally {
|
||||
const duration = Date.now() - start;
|
||||
reloadDurationHistogram.record(duration);
|
||||
span.end();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
subscribe(listener: (next: Readonly<T>) => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// Wire all watchable layers to call reload when their watch event fires.
|
||||
for (const layer of options.layers) {
|
||||
if (typeof layer.teardown === "function") {
|
||||
wireLayerReload(layer, () => {
|
||||
store.reload().catch(
|
||||
/* istanbul ignore next */
|
||||
() => {
|
||||
// Reload errors are surfaced to subscribers; swallow here.
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve all layers, apply environment overrides, validate, and return a live
|
||||
* `ConfigStore`.
|
||||
*
|
||||
* The returned store supports `reload()` (re-resolves all layers) and
|
||||
* `subscribe()` (notified on every successful reload).
|
||||
*
|
||||
* @param options - Layer list, validator, sensitive keys, environment overrides.
|
||||
* @returns A live, immutable `ConfigStore<T>`.
|
||||
*
|
||||
* @throws {ConfigError} `CONFIG.PARSE_FAILED` when a layer throws during resolution.
|
||||
* @throws {ConfigError} `CONFIG.VALIDATION_FAILED` when the validator throws.
|
||||
*/
|
||||
export function createConfig<T extends ConfigData>(
|
||||
options: CreateConfigOptions<T>
|
||||
): ConfigStore<T> {
|
||||
return tracer.startActiveSpan("config.create", (span) => {
|
||||
try {
|
||||
span.setAttribute("config.layer_count", options.layers.length);
|
||||
span.setAttribute(
|
||||
"config.environment",
|
||||
options.environment ?? "(none)"
|
||||
);
|
||||
|
||||
options.logger?.debug("config:create:start", {
|
||||
layerCount: options.layers.length,
|
||||
} as Record<string, unknown>);
|
||||
|
||||
const { data } = resolveLayers(options.layers);
|
||||
const withEnv = applyEnvironment(data as T, options);
|
||||
|
||||
let validated: T;
|
||||
if (options.validate !== undefined) {
|
||||
try {
|
||||
validated = options.validate(withEnv);
|
||||
} catch (err) {
|
||||
throw validationFailed(err);
|
||||
}
|
||||
} else {
|
||||
validated = withEnv;
|
||||
}
|
||||
|
||||
const store = buildLiveStore(validated, options);
|
||||
|
||||
options.logger?.debug(
|
||||
"config:create:done",
|
||||
{} as Record<string, unknown>
|
||||
);
|
||||
|
||||
return store;
|
||||
} catch (err) {
|
||||
span.recordException(err as Error);
|
||||
throw err;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ConfigData } from "../../types/common.js";
|
||||
|
||||
/**
|
||||
* Describes a single layer after resolution — its name, declared priority,
|
||||
* and the data snapshot produced by `layer.resolve()`.
|
||||
*/
|
||||
export interface ResolvedLayer {
|
||||
/** Human-readable layer identifier. */
|
||||
name: string;
|
||||
/** Layer priority used for merge-order sorting. */
|
||||
priority: number;
|
||||
/** The data snapshot returned by `layer.resolve()`. */
|
||||
data: ConfigData;
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of resolving and merging all registered layers.
|
||||
*/
|
||||
export interface MergeResult {
|
||||
/** The merged config data, ready for validation and environment overrides. */
|
||||
data: ConfigData;
|
||||
/** Ordered list of resolved layers (sorted ascending by priority). */
|
||||
layers: ResolvedLayer[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured detail about a config error, surfaced in error context.
|
||||
*/
|
||||
export interface ConfigErrorDetail {
|
||||
/** Dot-notation path where the error occurred, if applicable. */
|
||||
path?: string;
|
||||
/** Human-readable error description. */
|
||||
message: string;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { createConfig } from "./config-builder.js";
|
||||
export type {
|
||||
ConfigErrorDetail,
|
||||
MergeResult,
|
||||
ResolvedLayer,
|
||||
} from "./config-builder.types.js";
|
||||
@@ -0,0 +1,114 @@
|
||||
# config-store — Immutable config store factory for `@suaveplan/config`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`createConfigStore` constructs a static, immutable view of a pre-resolved
|
||||
config object. It deep-freezes the data (including all nested objects) so that
|
||||
accidental runtime mutations are caught in strict mode. The store provides
|
||||
`get`, `has`, `toJSON`, and `toMasked` methods for reading config values, plus
|
||||
no-op stubs for `reload` and `subscribe` that are replaced with live
|
||||
implementations by `createConfig` when a full config pipeline is constructed.
|
||||
|
||||
## Features
|
||||
|
||||
- `structuredClone` + `deepFreeze` on construction — data is immutable
|
||||
- `get(key)` — dot-notation path traversal via `getByPath`
|
||||
- `has(key)` — presence check via `hasByPath`
|
||||
- `toJSON()` — returns the frozen data reference
|
||||
- `toMasked()` — returns a frozen deep clone with sensitive paths replaced
|
||||
- `reload()` — no-op returning `Promise.resolve()` (stub for static stores)
|
||||
- `subscribe()` — no-op returning a no-op unsubscribe (stub for static stores)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createConfigStore } from '@suaveplan/config/core/config-store/config-store';
|
||||
|
||||
const store = createConfigStore({
|
||||
data: { server: { port: 3000 }, db: { password: 'secret' } },
|
||||
sensitive: ['db.password'],
|
||||
});
|
||||
|
||||
store.get('server.port'); // 3000
|
||||
store.has('server.port'); // true
|
||||
store.has('missing.key'); // false
|
||||
store.toMasked();
|
||||
// { server: { port: 3000 }, db: { password: '****' } }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createConfigStore } from '@suaveplan/config/core/config-store/config-store';
|
||||
|
||||
interface AppConfig {
|
||||
server: { port: number; host: string };
|
||||
db: { url: string; pool: number; password: string };
|
||||
log: { level: string };
|
||||
}
|
||||
|
||||
const store = createConfigStore<AppConfig>({
|
||||
data: {
|
||||
server: { port: 4000, host: '0.0.0.0' },
|
||||
db: { url: 'postgres://...', pool: 10, password: 'hunter2' },
|
||||
log: { level: 'info' },
|
||||
},
|
||||
sensitive: ['db.password', 'db.url'],
|
||||
});
|
||||
|
||||
// JSON-serialise the full config (includes sensitive values — do not log):
|
||||
JSON.stringify(store.toJSON());
|
||||
|
||||
// Safe for logging — sensitive paths are masked:
|
||||
console.log(store.toMasked());
|
||||
// { server: ..., db: { url: '****', pool: 10, password: '****' }, log: ... }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createConfigStore<T extends ConfigData>(options: ConfigStoreOptions<T>): ConfigStore<T>`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `options.data` | `T` | Resolved config data |
|
||||
| `options.sensitive` | `string[]` | Dot-notation paths to mask in `toMasked()` |
|
||||
|
||||
**Returns** a `ConfigStore<T>` with all methods implemented.
|
||||
|
||||
#### `store.get<K extends string>(key: K): unknown`
|
||||
|
||||
Returns the value at the dot-notation `key` path, or `undefined` if not found.
|
||||
|
||||
#### `store.has(key: string): boolean`
|
||||
|
||||
Returns `true` if the dot-notation path exists and is not `undefined`.
|
||||
|
||||
#### `store.toJSON(): Readonly<T>`
|
||||
|
||||
Returns the frozen config data reference. This is the full unmasked data; do
|
||||
not log this value if it contains sensitive fields.
|
||||
|
||||
#### `store.toMasked(): Readonly<T>`
|
||||
|
||||
Returns a frozen deep clone with all `sensitive` paths replaced by `"****"`.
|
||||
Safe for logging and telemetry.
|
||||
|
||||
#### `store.reload(signal?: AbortSignal): Promise<void>`
|
||||
|
||||
No-op stub. Returns `Promise.resolve()`. Live reload is provided by `createConfig`.
|
||||
|
||||
#### `store.subscribe(listener): () => void`
|
||||
|
||||
No-op stub. Returns a no-op unsubscribe function. Live subscriptions are
|
||||
provided by `createConfig`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`deepFreeze` is applied recursively after `structuredClone` to prevent both
|
||||
shallow and deep mutations. The frozen data object is returned directly from
|
||||
`toJSON()` (no additional clone) for performance — callers who need a mutable
|
||||
copy must clone it themselves. `toMasked()` calls `structuredClone` internally
|
||||
via `maskSensitive`, producing a new mutable copy before re-freezing it.
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { MASK_STRING } from "../../constants/index.js";
|
||||
import { createConfigStore } from "./config-store.js";
|
||||
|
||||
describe("createConfigStore", () => {
|
||||
it("get returns top-level value", { timeout: 5000 }, () => {
|
||||
const store = createConfigStore({
|
||||
data: { port: 3000 },
|
||||
sensitive: [],
|
||||
});
|
||||
expect(store.get("port")).toBe(3000);
|
||||
});
|
||||
|
||||
it("get returns nested value via dot-notation", { timeout: 5000 }, () => {
|
||||
const store = createConfigStore({
|
||||
data: { db: { host: "localhost" } },
|
||||
sensitive: [],
|
||||
});
|
||||
expect(store.get("db.host")).toBe("localhost");
|
||||
});
|
||||
|
||||
it("get returns deeply nested value", { timeout: 5000 }, () => {
|
||||
const store = createConfigStore({
|
||||
data: { a: { b: { c: { d: 42 } } } },
|
||||
sensitive: [],
|
||||
});
|
||||
expect(store.get("a.b.c.d")).toBe(42);
|
||||
});
|
||||
|
||||
it("get returns undefined for missing key", { timeout: 5000 }, () => {
|
||||
const store = createConfigStore({
|
||||
data: { port: 3000 },
|
||||
sensitive: [],
|
||||
});
|
||||
expect(store.get("missing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("has returns true for existing key", { timeout: 5000 }, () => {
|
||||
const store = createConfigStore({
|
||||
data: { port: 3000 },
|
||||
sensitive: [],
|
||||
});
|
||||
expect(store.has("port")).toBe(true);
|
||||
});
|
||||
|
||||
it("has returns false for missing key", { timeout: 5000 }, () => {
|
||||
const store = createConfigStore({
|
||||
data: { port: 3000 },
|
||||
sensitive: [],
|
||||
});
|
||||
expect(store.has("missing")).toBe(false);
|
||||
});
|
||||
|
||||
it("toJSON returns the full config", { timeout: 5000 }, () => {
|
||||
const data = { port: 3000, db: { host: "localhost" } };
|
||||
const store = createConfigStore({ data, sensitive: [] });
|
||||
expect(store.toJSON()).toEqual(data);
|
||||
});
|
||||
|
||||
it("toJSON data is frozen — mutation throws in strict mode", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const store = createConfigStore({
|
||||
data: { port: 3000 },
|
||||
sensitive: [],
|
||||
});
|
||||
const json = store.toJSON();
|
||||
expect(() => {
|
||||
(json as Record<string, unknown>).port = 9999;
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("toMasked replaces sensitive paths with MASK_STRING", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const store = createConfigStore({
|
||||
data: { db: { password: "secret" } },
|
||||
sensitive: ["db.password"],
|
||||
});
|
||||
const masked = store.toMasked();
|
||||
expect(
|
||||
(masked as Record<string, Record<string, unknown>>).db.password
|
||||
).toBe(MASK_STRING);
|
||||
});
|
||||
|
||||
it("toMasked does not reveal original data", { timeout: 5000 }, () => {
|
||||
const store = createConfigStore({
|
||||
data: { token: "abc123" },
|
||||
sensitive: ["token"],
|
||||
});
|
||||
const masked = store.toMasked();
|
||||
expect((masked as Record<string, unknown>).token).toBe(MASK_STRING);
|
||||
expect(store.toJSON()).toEqual({ token: "abc123" });
|
||||
});
|
||||
|
||||
it("toMasked masks multiple paths", { timeout: 5000 }, () => {
|
||||
const store = createConfigStore({
|
||||
data: { db: { password: "pw" }, api: { key: "key" } },
|
||||
sensitive: ["db.password", "api.key"],
|
||||
});
|
||||
const masked = store.toMasked();
|
||||
const m = masked as Record<string, Record<string, unknown>>;
|
||||
expect(m.db.password).toBe(MASK_STRING);
|
||||
expect(m.api.key).toBe(MASK_STRING);
|
||||
});
|
||||
|
||||
it("reload is a no-op returning resolved promise", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const store = createConfigStore({
|
||||
data: { port: 3000 },
|
||||
sensitive: [],
|
||||
});
|
||||
await expect(store.reload()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("subscribe returns a no-op unsubscribe", { timeout: 5000 }, () => {
|
||||
const store = createConfigStore({
|
||||
data: { port: 3000 },
|
||||
sensitive: [],
|
||||
});
|
||||
const unsub = store.subscribe(() => {
|
||||
/* no-op */
|
||||
});
|
||||
expect(() => unsub()).not.toThrow();
|
||||
});
|
||||
|
||||
it("external mutation of input data does not affect store", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const data: Record<string, unknown> = { port: 3000 };
|
||||
const store = createConfigStore({ data, sensitive: [] });
|
||||
data.port = 9999;
|
||||
expect(store.get("port")).toBe(3000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { MASK_STRING } from "../../constants/index.js";
|
||||
import type {
|
||||
ConfigData,
|
||||
ConfigStore,
|
||||
ConfigStoreOptions,
|
||||
} from "../../types/common.js";
|
||||
import { getByPath, hasByPath, maskSensitive } from "../../utils/mask.js";
|
||||
|
||||
/**
|
||||
* Deep-freeze an object recursively so that any attempt to mutate it at
|
||||
* runtime throws in strict mode.
|
||||
*/
|
||||
function deepFreeze<T>(obj: T): Readonly<T> {
|
||||
if (obj === null || typeof obj !== "object") return obj;
|
||||
for (const key of Object.keys(obj as object)) {
|
||||
deepFreeze((obj as Record<string, unknown>)[key]);
|
||||
}
|
||||
return Object.freeze(obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a static, immutable `ConfigStore` from pre-resolved config data.
|
||||
*
|
||||
* The provided `data` is deep-frozen so that any attempt to mutate it at
|
||||
* runtime throws. `toMasked()` returns a frozen copy with sensitive paths
|
||||
* replaced by `MASK_STRING`.
|
||||
*
|
||||
* `reload` and `subscribe` are no-op stubs on the returned store; live reload
|
||||
* is only available on stores created via `createConfig`.
|
||||
*
|
||||
* @param options - Store data and sensitive path list.
|
||||
* @returns An immutable `ConfigStore`.
|
||||
*/
|
||||
export function createConfigStore<T extends ConfigData>(
|
||||
options: ConfigStoreOptions<T>
|
||||
): ConfigStore<T> {
|
||||
const frozenData = deepFreeze(structuredClone(options.data)) as Readonly<T>;
|
||||
const sensitive = [...options.sensitive];
|
||||
|
||||
return {
|
||||
get<K extends string>(key: K): unknown {
|
||||
return getByPath(frozenData as ConfigData, key);
|
||||
},
|
||||
|
||||
has(key: string): boolean {
|
||||
return hasByPath(frozenData as ConfigData, key);
|
||||
},
|
||||
|
||||
toJSON(): Readonly<T> {
|
||||
return frozenData;
|
||||
},
|
||||
|
||||
toMasked(): Readonly<T> {
|
||||
const masked = maskSensitive(
|
||||
frozenData as ConfigData,
|
||||
sensitive,
|
||||
MASK_STRING
|
||||
);
|
||||
return deepFreeze(masked) as Readonly<T>;
|
||||
},
|
||||
|
||||
reload(_signal?: AbortSignal): Promise<void> {
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
subscribe(_listener: (next: Readonly<T>) => void): () => void {
|
||||
return () => {
|
||||
// no-op unsubscribe
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { createConfigStore } from "./config-store.js";
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
CONFIG_CODES,
|
||||
ConfigError,
|
||||
layerError,
|
||||
reloadFailed,
|
||||
validationFailed,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("ConfigError", () => {
|
||||
it("creates an error with the given code and message", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new ConfigError(CONFIG_CODES.MISSING, "missing key");
|
||||
expect(err.code).toBe(CONFIG_CODES.MISSING);
|
||||
expect(err.message).toBe("missing key");
|
||||
expect(err.name).toBe("ConfigError");
|
||||
expect(err).toBeInstanceOf(ConfigError);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it("includes cause when provided", { timeout: 5000 }, () => {
|
||||
const cause = new Error("root cause");
|
||||
const err = new ConfigError(CONFIG_CODES.PARSE_FAILED, "parse failed", {
|
||||
cause,
|
||||
});
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("includes context when provided", { timeout: 5000 }, () => {
|
||||
const err = new ConfigError(CONFIG_CODES.INVALID_TYPE, "bad type", {
|
||||
context: { path: "server.port" },
|
||||
});
|
||||
expect(err.context).toEqual({ path: "server.port" });
|
||||
});
|
||||
|
||||
it("handles non-Error cause gracefully", { timeout: 5000 }, () => {
|
||||
const err = new ConfigError(CONFIG_CODES.PARSE_FAILED, "parse failed", {
|
||||
cause: "string cause",
|
||||
});
|
||||
expect(err).toBeInstanceOf(ConfigError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("layerError", () => {
|
||||
it("creates a PARSE_FAILED error with layer name", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = new Error("layer boom");
|
||||
const err = layerError("my-layer", cause);
|
||||
expect(err.code).toBe(CONFIG_CODES.PARSE_FAILED);
|
||||
expect(err.message).toContain("my-layer");
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validationFailed", () => {
|
||||
it("creates a VALIDATION_FAILED error", { timeout: 5000 }, () => {
|
||||
const cause = new Error("zod error");
|
||||
const err = validationFailed(cause);
|
||||
expect(err.code).toBe(CONFIG_CODES.VALIDATION_FAILED);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reloadFailed", () => {
|
||||
it("creates a RELOAD_FAILED error", { timeout: 5000 }, () => {
|
||||
const cause = new Error("network error");
|
||||
const err = reloadFailed(cause);
|
||||
expect(err.code).toBe(CONFIG_CODES.RELOAD_FAILED);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("accepts non-Error cause", { timeout: 5000 }, () => {
|
||||
const err = reloadFailed("string cause");
|
||||
expect(err.code).toBe(CONFIG_CODES.RELOAD_FAILED);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/config`.
|
||||
*
|
||||
* All codes follow the pattern `CONFIG.<REASON>` and are produced by
|
||||
* `defineCodes` so callers can discriminate errors without string literals.
|
||||
*/
|
||||
export const CONFIG_CODES = defineCodes("CONFIG", [
|
||||
"MISSING",
|
||||
"INVALID_TYPE",
|
||||
"PARSE_FAILED",
|
||||
"FILE_NOT_FOUND",
|
||||
"ENV_NOT_SET",
|
||||
"RELOAD_FAILED",
|
||||
"VALIDATION_FAILED",
|
||||
] as const);
|
||||
|
||||
/** Union of all stable config error-code strings. */
|
||||
export type ConfigPackageErrorCode =
|
||||
(typeof CONFIG_CODES)[keyof typeof CONFIG_CODES];
|
||||
|
||||
/**
|
||||
* Typed error for all `@suaveplan/config` failures.
|
||||
*
|
||||
* Extends `SuaveplanError` so that callers can discriminate errors from this
|
||||
* package via `error instanceof ConfigError` or the stable `error.code`
|
||||
* property.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { ConfigError, CONFIG_CODES } from '@suaveplan/config';
|
||||
*
|
||||
* try {
|
||||
* const store = createConfig({ layers: [] });
|
||||
* } catch (err) {
|
||||
* if (err instanceof ConfigError && err.code === CONFIG_CODES.PARSE_FAILED) {
|
||||
* console.error('Layer resolution failed', err.context);
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class ConfigError extends SuaveplanError<ConfigPackageErrorCode> {
|
||||
public constructor(
|
||||
code: ConfigPackageErrorCode,
|
||||
message: string,
|
||||
options?: { cause?: unknown; context?: Record<string, unknown> }
|
||||
) {
|
||||
const superOptions: {
|
||||
cause?: Error;
|
||||
context?: Record<string, unknown>;
|
||||
} = {};
|
||||
if (options?.cause instanceof Error) {
|
||||
superOptions.cause = options.cause;
|
||||
}
|
||||
if (options?.context !== undefined) {
|
||||
superOptions.context = options.context;
|
||||
}
|
||||
super(code, message, superOptions);
|
||||
this.name = "ConfigError";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal factory helpers ─────────────────────────────────────────────────
|
||||
|
||||
/** Thrown when a layer's resolve() throws. */
|
||||
export function layerError(layerName: string, cause: unknown): ConfigError {
|
||||
return new ConfigError(
|
||||
CONFIG_CODES.PARSE_FAILED,
|
||||
`Layer "${layerName}" failed to resolve`,
|
||||
{ cause, context: { layer: layerName } }
|
||||
);
|
||||
}
|
||||
|
||||
/** Thrown when the caller-supplied validator throws or returns invalid data. */
|
||||
export function validationFailed(cause: unknown): ConfigError {
|
||||
return new ConfigError(
|
||||
CONFIG_CODES.VALIDATION_FAILED,
|
||||
"Config validation failed",
|
||||
{ cause }
|
||||
);
|
||||
}
|
||||
|
||||
/** Thrown when a reload is aborted or a layer throws during reload. */
|
||||
export function reloadFailed(cause: unknown): ConfigError {
|
||||
return new ConfigError(CONFIG_CODES.RELOAD_FAILED, "Config reload failed", {
|
||||
cause,
|
||||
});
|
||||
}
|
||||
8
packages/foundation/core/config/src/error-codes/index.ts
Normal file
8
packages/foundation/core/config/src/error-codes/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type { ConfigPackageErrorCode } from "./error-codes.js";
|
||||
export {
|
||||
CONFIG_CODES,
|
||||
ConfigError,
|
||||
layerError,
|
||||
reloadFailed,
|
||||
validationFailed,
|
||||
} from "./error-codes.js";
|
||||
67
packages/foundation/core/config/src/index.ts
Normal file
67
packages/foundation/core/config/src/index.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
// Constants
|
||||
export {
|
||||
FALSY_STRINGS,
|
||||
LAYER_PRIORITIES,
|
||||
MASK_STRING,
|
||||
TRUTHY_STRINGS,
|
||||
} from "./constants/index.js";
|
||||
// Core
|
||||
export { createConfig } from "./core/config-builder/config-builder.js";
|
||||
// Builder types
|
||||
export type {
|
||||
ConfigErrorDetail,
|
||||
MergeResult,
|
||||
ResolvedLayer,
|
||||
} from "./core/config-builder/config-builder.types.js";
|
||||
export { createConfigStore } from "./core/config-store/config-store.js";
|
||||
export type { ConfigPackageErrorCode } from "./error-codes/index.js";
|
||||
// Error codes + error class
|
||||
export {
|
||||
CONFIG_CODES,
|
||||
ConfigError,
|
||||
layerError,
|
||||
reloadFailed,
|
||||
validationFailed,
|
||||
} from "./error-codes/index.js";
|
||||
// Layers
|
||||
export { cliLayer, parseArgs } from "./layers/cli-layer/cli-layer.js";
|
||||
export { defaultsLayer } from "./layers/defaults-layer/defaults-layer.js";
|
||||
export { coerceValue, envLayer } from "./layers/env-layer/env-layer.js";
|
||||
export { fileLayer, wireLayerReload } from "./layers/file-layer/file-layer.js";
|
||||
export { objectLayer } from "./layers/object-layer/object-layer.js";
|
||||
// Types
|
||||
export type {
|
||||
CliLayerOptions,
|
||||
ConfigData,
|
||||
ConfigLayer,
|
||||
ConfigStore,
|
||||
ConfigStoreOptions,
|
||||
ConfigValidator,
|
||||
CreateConfigOptions,
|
||||
EnvironmentOverrides,
|
||||
EnvLayerOptions,
|
||||
FileLayerOptions,
|
||||
} from "./types/common.js";
|
||||
export type {
|
||||
CliLayerOptionsInput,
|
||||
ConfigStoreOptionsInput,
|
||||
CreateConfigOptionsInput,
|
||||
EnvLayerOptionsInput,
|
||||
FileLayerOptionsInput,
|
||||
} from "./types/schemas.js";
|
||||
// Zod schemas
|
||||
export {
|
||||
CliLayerOptionsSchema,
|
||||
ConfigStoreOptionsSchema,
|
||||
CreateConfigOptionsSchema,
|
||||
EnvLayerOptionsSchema,
|
||||
FileLayerOptionsSchema,
|
||||
} from "./types/schemas.js";
|
||||
export {
|
||||
getByPath,
|
||||
hasByPath,
|
||||
maskSensitive,
|
||||
setByPath,
|
||||
} from "./utils/mask.js";
|
||||
// Utilities
|
||||
export { deepMerge, mergeLayers } from "./utils/merge.js";
|
||||
@@ -0,0 +1,101 @@
|
||||
# cli-layer — CLI argument config layer for `@suaveplan/config`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`cliLayer` creates a config layer that reads values from command-line arguments
|
||||
and maps them to dot-notation config paths. It is assigned the highest built-in
|
||||
priority (`CLI = 40`) so that operator-supplied flags always win over defaults,
|
||||
file content, environment variables, and programmatic object overrides. The
|
||||
companion `parseArgs` function handles the full range of common CLI flag forms
|
||||
and is exported for standalone use in argument-parsing utilities.
|
||||
|
||||
## Features
|
||||
|
||||
- Priority `40` (`LAYER_PRIORITIES.CLI`) — overrides all other built-in layers
|
||||
- `parseArgs` handles `--key=value`, `--key value`, `--flag` (boolean), `-k value`
|
||||
- Unknown flags not in `mapping` are silently ignored
|
||||
- Values are coerced via `coerceValue` — numerics, booleans, and JSON objects
|
||||
are automatically converted from their string representations
|
||||
- Writes to arbitrary dot-notation paths via `setByPath`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { cliLayer, createConfig } from '@suaveplan/config';
|
||||
|
||||
const store = createConfig({
|
||||
layers: [
|
||||
cliLayer({
|
||||
args: process.argv.slice(2),
|
||||
mapping: { port: 'server.port', debug: 'log.debug' },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// When invoked as: node app.js --port 9000 --debug
|
||||
store.get('server.port'); // 9000 (number)
|
||||
store.get('log.debug'); // true (boolean)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { parseArgs } from '@suaveplan/config';
|
||||
|
||||
// Use parseArgs standalone to inspect raw flag values before coercion:
|
||||
const raw = parseArgs(
|
||||
['--host=prod.example.com', '--port', '443', '--tls'],
|
||||
{ host: 'server.host', port: 'server.port', tls: 'server.tls', p: 'server.port' }
|
||||
);
|
||||
// raw = { host: 'prod.example.com', port: '443', tls: 'true' }
|
||||
|
||||
// Short flags:
|
||||
const short = parseArgs(['-p', '8080'], { p: 'server.port' });
|
||||
// short = { p: '8080' }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `cliLayer(options: CliLayerOptions): ConfigLayer`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `options.args` | `string[]` | Raw argv array (e.g. `process.argv.slice(2)`) |
|
||||
| `options.mapping` | `Record<string, string>` | Flag name → config path map |
|
||||
|
||||
**Returns** a `ConfigLayer` with:
|
||||
|
||||
- `name`: `"cli"`
|
||||
- `priority`: `40` (`LAYER_PRIORITIES.CLI`)
|
||||
- `resolve()`: parses `args`, coerces values, writes to config paths
|
||||
|
||||
### `parseArgs(args: string[], mapping: Record<string, string>): Record<string, string>`
|
||||
|
||||
Parses raw argv tokens into a flat `{ flagName: rawStringValue }` record.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `args` | `string[]` | Raw argument tokens |
|
||||
| `mapping` | `Record<string, string>` | Allowed flag names (without prefix) |
|
||||
|
||||
**Returns** `Record<string, string>` — only flags present in `mapping` are included.
|
||||
|
||||
**Supported forms:**
|
||||
|
||||
| Form | Example | Key | Value |
|
||||
|------|---------|-----|-------|
|
||||
| `--key=value` | `--port=4000` | `port` | `"4000"` |
|
||||
| `--key value` | `--port 4000` | `port` | `"4000"` |
|
||||
| `--flag` | `--debug` | `debug` | `"true"` |
|
||||
| `-k value` | `-p 4000` | `p` | `"4000"` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Short flags (`-k`) are limited to single-character names. Multi-character short
|
||||
options (e.g. `-vv`) are not supported and are silently skipped. The parser
|
||||
does not validate flag values — coercion happens later in `cliLayer.resolve()`
|
||||
via `coerceValue`. Boolean flags are mapped to the string `"true"` so they
|
||||
flow through the coercion pipeline consistently.
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { LAYER_PRIORITIES } from "../../constants/index.js";
|
||||
import { cliLayer, parseArgs } from "./cli-layer.js";
|
||||
|
||||
describe("parseArgs", () => {
|
||||
const mapping = {
|
||||
port: "server.port",
|
||||
debug: "log.debug",
|
||||
host: "server.host",
|
||||
p: "server.port",
|
||||
d: "log.debug",
|
||||
};
|
||||
|
||||
it("parses --key value form", { timeout: 5000 }, () => {
|
||||
expect(parseArgs(["--port", "4000"], mapping)).toEqual({
|
||||
port: "4000",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses --key=value form", { timeout: 5000 }, () => {
|
||||
expect(parseArgs(["--port=4000"], mapping)).toEqual({ port: "4000" });
|
||||
});
|
||||
|
||||
it("parses --flag (boolean true) when no value follows", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(parseArgs(["--debug"], mapping)).toEqual({ debug: "true" });
|
||||
});
|
||||
|
||||
it("parses --flag as true when next arg starts with '-'", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = parseArgs(["--debug", "--port", "3000"], mapping);
|
||||
expect(result.debug).toBe("true");
|
||||
expect(result.port).toBe("3000");
|
||||
});
|
||||
|
||||
it("parses -k value short flag", { timeout: 5000 }, () => {
|
||||
expect(parseArgs(["-p", "8080"], mapping)).toEqual({ p: "8080" });
|
||||
});
|
||||
|
||||
it("parses -k flag (boolean) when no value follows", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(parseArgs(["-d"], mapping)).toEqual({ d: "true" });
|
||||
});
|
||||
|
||||
it("ignores flags not in mapping", { timeout: 5000 }, () => {
|
||||
expect(parseArgs(["--unknown", "value"], mapping)).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty object for empty args", { timeout: 5000 }, () => {
|
||||
expect(parseArgs([], mapping)).toEqual({});
|
||||
});
|
||||
|
||||
it("handles multiple flags in sequence", { timeout: 5000 }, () => {
|
||||
const result = parseArgs(
|
||||
["--port", "9000", "--host", "example.com", "--debug"],
|
||||
mapping
|
||||
);
|
||||
expect(result).toEqual({
|
||||
port: "9000",
|
||||
host: "example.com",
|
||||
debug: "true",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses --key=value when value contains '='", { timeout: 5000 }, () => {
|
||||
const m = { url: "db.url" };
|
||||
const result = parseArgs(["--url=postgres://host:5432/db?ssl=true"], m);
|
||||
expect(result.url).toBe("postgres://host:5432/db?ssl=true");
|
||||
});
|
||||
|
||||
it("skips positional arguments", { timeout: 5000 }, () => {
|
||||
const result = parseArgs(["positional", "--port", "3000"], mapping);
|
||||
expect(result).toEqual({ port: "3000" });
|
||||
});
|
||||
|
||||
it("ignores --key=value when key not in mapping", { timeout: 5000 }, () => {
|
||||
const result = parseArgs(["--unknown=value"], mapping);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("ignores short -k when key not in mapping", { timeout: 5000 }, () => {
|
||||
const result = parseArgs(["-z"], mapping);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("ignores short -k value when key not in mapping", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = parseArgs(["-z", "value"], mapping);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cliLayer", () => {
|
||||
it("has name 'cli'", { timeout: 5000 }, () => {
|
||||
expect(cliLayer({ args: [], mapping: {} }).name).toBe("cli");
|
||||
});
|
||||
|
||||
it("has priority LAYER_PRIORITIES.CLI (40)", { timeout: 5000 }, () => {
|
||||
expect(cliLayer({ args: [], mapping: {} }).priority).toBe(
|
||||
LAYER_PRIORITIES.CLI
|
||||
);
|
||||
expect(cliLayer({ args: [], mapping: {} }).priority).toBe(40);
|
||||
});
|
||||
|
||||
it("resolves --key=value and coerces to number", { timeout: 5000 }, () => {
|
||||
const layer = cliLayer({
|
||||
args: ["--port=4000"],
|
||||
mapping: { port: "server.port" },
|
||||
});
|
||||
expect(layer.resolve()).toEqual({ server: { port: 4000 } });
|
||||
});
|
||||
|
||||
it("resolves --flag and coerces to boolean true", { timeout: 5000 }, () => {
|
||||
const layer = cliLayer({
|
||||
args: ["--debug"],
|
||||
mapping: { debug: "log.debug" },
|
||||
});
|
||||
expect(layer.resolve()).toEqual({ log: { debug: true } });
|
||||
});
|
||||
|
||||
it("resolves --key value form", { timeout: 5000 }, () => {
|
||||
const layer = cliLayer({
|
||||
args: ["--host", "localhost"],
|
||||
mapping: { host: "server.host" },
|
||||
});
|
||||
expect(layer.resolve()).toEqual({ server: { host: "localhost" } });
|
||||
});
|
||||
|
||||
it("resolves -k value short flag", { timeout: 5000 }, () => {
|
||||
const layer = cliLayer({
|
||||
args: ["-p", "8080"],
|
||||
mapping: { p: "server.port" },
|
||||
});
|
||||
expect(layer.resolve()).toEqual({ server: { port: 8080 } });
|
||||
});
|
||||
|
||||
it("ignores unknown flags", { timeout: 5000 }, () => {
|
||||
const layer = cliLayer({
|
||||
args: ["--unknown", "value"],
|
||||
mapping: { port: "server.port" },
|
||||
});
|
||||
expect(layer.resolve()).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty object for empty args", { timeout: 5000 }, () => {
|
||||
const layer = cliLayer({ args: [], mapping: { port: "server.port" } });
|
||||
expect(layer.resolve()).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { LAYER_PRIORITIES } from "../../constants/index.js";
|
||||
import type {
|
||||
CliLayerOptions,
|
||||
ConfigData,
|
||||
ConfigLayer,
|
||||
} from "../../types/common.js";
|
||||
import { setByPath } from "../../utils/mask.js";
|
||||
import { coerceValue } from "../env-layer/env-layer.js";
|
||||
|
||||
/**
|
||||
* Parse a raw argv array into a flat record of flag-name → raw-string-value
|
||||
* pairs, using the provided `mapping` to filter which flags are captured.
|
||||
*
|
||||
* Handled forms:
|
||||
* - `--key=value` — split on first `=`
|
||||
* - `--key value` — next element is the value
|
||||
* - `--flag` — mapped to `"true"` when no value follows or next starts with `-`
|
||||
* - `-k value` — short flag; next element is the value
|
||||
*
|
||||
* Unknown flags (those not in `mapping`) are silently ignored.
|
||||
*
|
||||
* @param args - Raw argument strings (e.g. `process.argv.slice(2)`).
|
||||
* @param mapping - Map of flag names (without `--`/`-` prefix) to config paths.
|
||||
* @returns Record of flag names to their raw string values.
|
||||
*/
|
||||
export function parseArgs(
|
||||
args: string[],
|
||||
mapping: Record<string, string>
|
||||
): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
let i = 0;
|
||||
|
||||
while (i < args.length) {
|
||||
const arg = args[i] as string;
|
||||
|
||||
if (arg.startsWith("--")) {
|
||||
const withoutDash = arg.slice(2);
|
||||
const eqIndex = withoutDash.indexOf("=");
|
||||
|
||||
if (eqIndex !== -1) {
|
||||
// --key=value form
|
||||
const key = withoutDash.slice(0, eqIndex);
|
||||
const value = withoutDash.slice(eqIndex + 1);
|
||||
if (key in mapping) {
|
||||
result[key] = value;
|
||||
}
|
||||
i++;
|
||||
} else {
|
||||
// --key or --key value form
|
||||
const key = withoutDash;
|
||||
const next = args[i + 1];
|
||||
if (
|
||||
next !== undefined &&
|
||||
!next.startsWith("-") &&
|
||||
key in mapping
|
||||
) {
|
||||
result[key] = next;
|
||||
i += 2;
|
||||
} else {
|
||||
if (key in mapping) {
|
||||
result[key] = "true";
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
} else if (arg.startsWith("-") && arg.length === 2) {
|
||||
// -k value form (single-char short flag)
|
||||
const key = arg.slice(1);
|
||||
const next = args[i + 1];
|
||||
if (next !== undefined && !next.startsWith("-") && key in mapping) {
|
||||
result[key] = next;
|
||||
i += 2;
|
||||
} else {
|
||||
if (key in mapping) {
|
||||
result[key] = "true";
|
||||
}
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a config layer that reads values from CLI arguments.
|
||||
*
|
||||
* Priority: `LAYER_PRIORITIES.CLI = 40` — the highest of all built-in layers,
|
||||
* so CLI flags override defaults, file, object, and env layers.
|
||||
*
|
||||
* @param options - CLI layer configuration.
|
||||
* @returns A `ConfigLayer` named `"cli"` with priority `40`.
|
||||
*/
|
||||
export function cliLayer(options: CliLayerOptions): ConfigLayer {
|
||||
return {
|
||||
name: "cli",
|
||||
priority: LAYER_PRIORITIES.CLI,
|
||||
resolve(): ConfigData {
|
||||
const parsed = parseArgs(options.args, options.mapping);
|
||||
const result: ConfigData = {};
|
||||
|
||||
for (const [flagName, rawValue] of Object.entries(parsed)) {
|
||||
const configPath = options.mapping[flagName] as string;
|
||||
setByPath(result, configPath, coerceValue(rawValue));
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { cliLayer, parseArgs } from "./cli-layer.js";
|
||||
@@ -0,0 +1,91 @@
|
||||
# defaults-layer — Static default values layer for `@suaveplan/config`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`defaultsLayer` creates a configuration layer that supplies baseline default
|
||||
values for every key in the config schema. It is assigned the lowest possible
|
||||
priority (`LAYER_PRIORITIES.DEFAULTS = 0`) so that every other layer in the
|
||||
stack — file, object, env, or CLI — can override it without any special
|
||||
configuration. This makes it the natural first layer to register when building
|
||||
a `createConfig` call: supply all your application defaults here, and trust that
|
||||
higher-priority layers will selectively override them.
|
||||
|
||||
## Features
|
||||
|
||||
- Priority `0` — always the lowest; all other built-in layers override it
|
||||
- Returns a shallow copy of the provided defaults on `resolve()` — external
|
||||
mutations to the original object do not affect subsequent resolutions
|
||||
- Generic `<T extends ConfigData>` so TypeScript can infer the shape
|
||||
- Named `"defaults"` for clear identification in error messages and logs
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { defaultsLayer, createConfig } from '@suaveplan/config';
|
||||
|
||||
interface AppConfig {
|
||||
port: number;
|
||||
debug: boolean;
|
||||
logLevel: string;
|
||||
}
|
||||
|
||||
const store = createConfig<AppConfig>({
|
||||
layers: [
|
||||
defaultsLayer<AppConfig>({ port: 3000, debug: false, logLevel: 'info' }),
|
||||
],
|
||||
});
|
||||
|
||||
store.get('port'); // 3000
|
||||
store.get('debug'); // false
|
||||
store.get('logLevel'); // 'info'
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { defaultsLayer, envLayer, cliLayer, createConfig } from '@suaveplan/config';
|
||||
|
||||
const defaults = defaultsLayer({
|
||||
server: { port: 3000, host: '0.0.0.0' },
|
||||
db: { pool: 5, timeout: 30_000 },
|
||||
log: { level: 'info', pretty: true },
|
||||
});
|
||||
|
||||
// Override only what differs per environment:
|
||||
const env = envLayer({
|
||||
mapping: { PORT: 'server.port', DB_POOL: 'db.pool', LOG_LEVEL: 'log.level' },
|
||||
});
|
||||
|
||||
const cli = cliLayer({
|
||||
args: process.argv.slice(2),
|
||||
mapping: { port: 'server.port', debug: 'log.pretty' },
|
||||
});
|
||||
|
||||
const store = createConfig({ layers: [defaults, env, cli] });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `defaultsLayer<T extends ConfigData>(defaults: T): ConfigLayer`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `defaults` | `T` | Plain object containing default config values |
|
||||
|
||||
**Returns** a `ConfigLayer` with:
|
||||
|
||||
- `name`: `"defaults"`
|
||||
- `priority`: `0` (`LAYER_PRIORITIES.DEFAULTS`)
|
||||
- `resolve()`: returns `{ ...defaults }` (shallow copy)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The shallow copy returned by `resolve()` prevents the caller from mutating
|
||||
the internal defaults reference across successive `reload()` calls. For deeply
|
||||
nested mutable objects, callers should pass plain JSON-serialisable data to
|
||||
guarantee immutability. The function is intentionally minimal — no async
|
||||
resolution, no file I/O, no validation — because defaults are static values
|
||||
known at build time.
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { LAYER_PRIORITIES } from "../../constants/index.js";
|
||||
import { defaultsLayer } from "./defaults-layer.js";
|
||||
|
||||
describe("defaultsLayer", () => {
|
||||
it("has name 'defaults'", { timeout: 5000 }, () => {
|
||||
const layer = defaultsLayer({ port: 3000 });
|
||||
expect(layer.name).toBe("defaults");
|
||||
});
|
||||
|
||||
it("has priority LAYER_PRIORITIES.DEFAULTS (0)", { timeout: 5000 }, () => {
|
||||
const layer = defaultsLayer({ port: 3000 });
|
||||
expect(layer.priority).toBe(LAYER_PRIORITIES.DEFAULTS);
|
||||
expect(layer.priority).toBe(0);
|
||||
});
|
||||
|
||||
it("returns the default values on resolve", { timeout: 5000 }, () => {
|
||||
const layer = defaultsLayer({ port: 3000, debug: false });
|
||||
expect(layer.resolve()).toEqual({ port: 3000, debug: false });
|
||||
});
|
||||
|
||||
it("returns a copy — mutation safety: mutating result does not affect next resolve", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const layer = defaultsLayer({ port: 3000 });
|
||||
const first = layer.resolve();
|
||||
(first as Record<string, unknown>).port = 9999;
|
||||
const second = layer.resolve();
|
||||
expect(second.port).toBe(3000);
|
||||
});
|
||||
|
||||
it("mutation of original defaults does not affect resolve", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const orig = { port: 3000 };
|
||||
const layer = defaultsLayer(orig);
|
||||
orig.port = 9999;
|
||||
expect(layer.resolve().port).toBe(3000);
|
||||
});
|
||||
|
||||
it("handles nested objects in defaults", { timeout: 5000 }, () => {
|
||||
const layer = defaultsLayer({ db: { host: "localhost", port: 5432 } });
|
||||
expect(layer.resolve()).toEqual({
|
||||
db: { host: "localhost", port: 5432 },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns empty object when defaults is empty", { timeout: 5000 }, () => {
|
||||
const layer = defaultsLayer({});
|
||||
expect(layer.resolve()).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { LAYER_PRIORITIES } from "../../constants/index.js";
|
||||
import type { ConfigData, ConfigLayer } from "../../types/common.js";
|
||||
|
||||
/**
|
||||
* Create a config layer that supplies static default values.
|
||||
*
|
||||
* The layer is assigned the lowest built-in priority (`DEFAULTS = 0`) so it
|
||||
* is always overridden by any other layer in the stack.
|
||||
*
|
||||
* `resolve()` returns a shallow copy of the provided defaults so that external
|
||||
* mutations to the original object do not affect the resolved data.
|
||||
*
|
||||
* @param defaults - The default config values to provide.
|
||||
* @returns A `ConfigLayer` named `"defaults"` with priority `0`.
|
||||
*/
|
||||
export function defaultsLayer<T extends ConfigData>(defaults: T): ConfigLayer {
|
||||
// Snapshot at construction time to prevent external mutations from
|
||||
// affecting future resolve() calls.
|
||||
const snapshot = structuredClone(defaults) as T;
|
||||
return {
|
||||
name: "defaults",
|
||||
priority: LAYER_PRIORITIES.DEFAULTS,
|
||||
resolve(): ConfigData {
|
||||
return { ...snapshot };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { defaultsLayer } from "./defaults-layer.js";
|
||||
@@ -0,0 +1,107 @@
|
||||
# env-layer — Environment variable config layer for `@suaveplan/config`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`envLayer` creates a config layer that reads environment variables and maps
|
||||
them to dot-notation config paths. It handles prefix stripping, type coercion
|
||||
via `coerceValue`, and missing-variable safety (keys absent from the environment
|
||||
source are simply excluded from the resolved data). With priority
|
||||
`LAYER_PRIORITIES.ENV = 30`, it overrides defaults, file, and object layers but
|
||||
is itself overridden by the CLI layer at priority 40.
|
||||
|
||||
## Features
|
||||
|
||||
- Maps env-var names to arbitrary dot-notation config paths via `mapping`
|
||||
- Optional `prefix` prepended to each env-var name before lookup
|
||||
- `source` override for testing without polluting `process.env`
|
||||
- `coerceValue` exported for standalone use — converts string tokens to the
|
||||
most specific JS type (boolean, number, parsed JSON, or string)
|
||||
- Missing or empty env vars are silently skipped (no error thrown)
|
||||
- Priority `30` — overrides defaults (0), file (10), and object (20) layers
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { envLayer, createConfig } from '@suaveplan/config';
|
||||
|
||||
const store = createConfig({
|
||||
layers: [
|
||||
envLayer({
|
||||
mapping: { PORT: 'server.port', DEBUG: 'log.debug' },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// If PORT=4000 and DEBUG=true are set:
|
||||
store.get('server.port'); // 4000 (number, not string)
|
||||
store.get('log.debug'); // true (boolean)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { envLayer, defaultsLayer, createConfig } from '@suaveplan/config';
|
||||
|
||||
// With prefix — all env vars are prefixed with "APP_"
|
||||
const store = createConfig({
|
||||
layers: [
|
||||
defaultsLayer({ server: { port: 3000 }, db: { pool: 5 } }),
|
||||
envLayer({
|
||||
prefix: 'APP_',
|
||||
mapping: {
|
||||
PORT: 'server.port',
|
||||
DB_POOL: 'db.pool',
|
||||
DB_URL: 'db.url',
|
||||
},
|
||||
// Can override source for testing:
|
||||
source: { APP_PORT: '8080', APP_DB_POOL: '20', APP_DB_URL: '{"host":"db"}' },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
store.get('server.port'); // 8080 (number)
|
||||
store.get('db.pool'); // 20 (number)
|
||||
store.get('db.url'); // { host: 'db' } (parsed JSON)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `envLayer(options: EnvLayerOptions): ConfigLayer`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `options.mapping` | `Record<string, string>` | Env-var name → config path map |
|
||||
| `options.prefix` | `string` | Optional prefix prepended to each env-var name |
|
||||
| `options.source` | `Record<string, string \| undefined>` | Override for `process.env` |
|
||||
| `options.watchBus` | `EventBusLike` | Optional watch bus (reserved for future use) |
|
||||
| `options.watchEvent` | `string` | Watch event name; default `'config:env:changed'` |
|
||||
|
||||
**Returns** a `ConfigLayer` with:
|
||||
|
||||
- `name`: `"env"`
|
||||
- `priority`: `30` (`LAYER_PRIORITIES.ENV`)
|
||||
|
||||
### `coerceValue(raw: string): unknown`
|
||||
|
||||
Converts a raw environment-variable string to the most specific JavaScript type.
|
||||
|
||||
| Input | Output |
|
||||
|-------|--------|
|
||||
| `""` | `""` (empty string returned as-is) |
|
||||
| `"true"`, `"1"`, `"yes"`, `"on"`, `"enabled"` | `true` |
|
||||
| `"false"`, `"0"`, `"no"`, `"off"`, `"disabled"` | `false` |
|
||||
| Finite numeric string (`"42"`, `"3.14"`) | `number` |
|
||||
| JSON object/array string (`'{"a":1}'`) | parsed value |
|
||||
| Anything else | original `string` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Coercion is case-insensitive for boolean tokens. Numeric coercion uses
|
||||
`Number()` + `isFinite` guard to avoid accepting `Infinity` or `NaN`.
|
||||
JSON parsing is attempted only when the trimmed string starts with `{` or `[`
|
||||
to avoid unnecessary `try/catch` overhead on every string value. The `source`
|
||||
parameter defaults to `process.env` in Node/Bun environments and falls back to
|
||||
`{}` in browser environments where `process` is not defined.
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { LAYER_PRIORITIES } from "../../constants/index.js";
|
||||
import { coerceValue, envLayer } from "./env-layer.js";
|
||||
|
||||
describe("coerceValue", () => {
|
||||
it("returns empty string unchanged", { timeout: 5000 }, () => {
|
||||
expect(coerceValue("")).toBe("");
|
||||
});
|
||||
|
||||
it("coerces 'true' → true", { timeout: 5000 }, () => {
|
||||
expect(coerceValue("true")).toBe(true);
|
||||
});
|
||||
|
||||
it("coerces 'TRUE' (case-insensitive) → true", { timeout: 5000 }, () => {
|
||||
expect(coerceValue("TRUE")).toBe(true);
|
||||
});
|
||||
|
||||
it("coerces '1' → true", { timeout: 5000 }, () => {
|
||||
expect(coerceValue("1")).toBe(true);
|
||||
});
|
||||
|
||||
it("coerces 'yes' → true", { timeout: 5000 }, () => {
|
||||
expect(coerceValue("yes")).toBe(true);
|
||||
});
|
||||
|
||||
it("coerces 'on' → true", { timeout: 5000 }, () => {
|
||||
expect(coerceValue("on")).toBe(true);
|
||||
});
|
||||
|
||||
it("coerces 'enabled' → true", { timeout: 5000 }, () => {
|
||||
expect(coerceValue("enabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("coerces 'false' → false", { timeout: 5000 }, () => {
|
||||
expect(coerceValue("false")).toBe(false);
|
||||
});
|
||||
|
||||
it("coerces 'FALSE' (case-insensitive) → false", { timeout: 5000 }, () => {
|
||||
expect(coerceValue("FALSE")).toBe(false);
|
||||
});
|
||||
|
||||
it("coerces '0' → false", { timeout: 5000 }, () => {
|
||||
expect(coerceValue("0")).toBe(false);
|
||||
});
|
||||
|
||||
it("coerces 'no' → false", { timeout: 5000 }, () => {
|
||||
expect(coerceValue("no")).toBe(false);
|
||||
});
|
||||
|
||||
it("coerces 'off' → false", { timeout: 5000 }, () => {
|
||||
expect(coerceValue("off")).toBe(false);
|
||||
});
|
||||
|
||||
it("coerces 'disabled' → false", { timeout: 5000 }, () => {
|
||||
expect(coerceValue("disabled")).toBe(false);
|
||||
});
|
||||
|
||||
it("coerces integer string → number", { timeout: 5000 }, () => {
|
||||
expect(coerceValue("42")).toBe(42);
|
||||
});
|
||||
|
||||
it("coerces float string → number", { timeout: 5000 }, () => {
|
||||
expect(coerceValue("3.14")).toBe(3.14);
|
||||
});
|
||||
|
||||
it("coerces negative number string → number", { timeout: 5000 }, () => {
|
||||
expect(coerceValue("-5")).toBe(-5);
|
||||
});
|
||||
|
||||
it("coerces valid JSON object string → parsed object", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(coerceValue('{"host":"localhost","port":5432}')).toEqual({
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
});
|
||||
});
|
||||
|
||||
it("coerces valid JSON array string → parsed array", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(coerceValue('["a","b","c"]')).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("returns string when JSON parsing fails", { timeout: 5000 }, () => {
|
||||
expect(coerceValue("{not valid json}")).toBe("{not valid json}");
|
||||
});
|
||||
|
||||
it("returns plain string for non-special value", { timeout: 5000 }, () => {
|
||||
expect(coerceValue("hello")).toBe("hello");
|
||||
});
|
||||
|
||||
it("does not coerce Infinity string to number", { timeout: 5000 }, () => {
|
||||
expect(coerceValue("Infinity")).toBe("Infinity");
|
||||
});
|
||||
});
|
||||
|
||||
describe("envLayer", () => {
|
||||
it("has name 'env'", { timeout: 5000 }, () => {
|
||||
expect(envLayer({ mapping: {} }).name).toBe("env");
|
||||
});
|
||||
|
||||
it("has priority LAYER_PRIORITIES.ENV (30)", { timeout: 5000 }, () => {
|
||||
expect(envLayer({ mapping: {} }).priority).toBe(LAYER_PRIORITIES.ENV);
|
||||
expect(envLayer({ mapping: {} }).priority).toBe(30);
|
||||
});
|
||||
|
||||
it("reads values from source via mapping", { timeout: 5000 }, () => {
|
||||
const layer = envLayer({
|
||||
mapping: { PORT: "server.port" },
|
||||
source: { PORT: "4000" },
|
||||
});
|
||||
expect(layer.resolve()).toEqual({ server: { port: 4000 } });
|
||||
});
|
||||
|
||||
it("strips prefix before lookup", { timeout: 5000 }, () => {
|
||||
const layer = envLayer({
|
||||
prefix: "APP_",
|
||||
mapping: { PORT: "server.port" },
|
||||
source: { APP_PORT: "8080" },
|
||||
});
|
||||
expect(layer.resolve()).toEqual({ server: { port: 8080 } });
|
||||
});
|
||||
|
||||
it("maps multiple keys to different paths", { timeout: 5000 }, () => {
|
||||
const layer = envLayer({
|
||||
mapping: { HOST: "db.host", POOL: "db.pool" },
|
||||
source: { HOST: "prod.db", POOL: "20" },
|
||||
});
|
||||
const result = layer.resolve();
|
||||
expect(result).toEqual({ db: { host: "prod.db", pool: 20 } });
|
||||
});
|
||||
|
||||
it("ignores keys not in source", { timeout: 5000 }, () => {
|
||||
const layer = envLayer({
|
||||
mapping: { PORT: "server.port", MISSING: "server.missing" },
|
||||
source: { PORT: "3000" },
|
||||
});
|
||||
const result = layer.resolve();
|
||||
expect(result).toEqual({ server: { port: 3000 } });
|
||||
});
|
||||
|
||||
it("ignores empty string values", { timeout: 5000 }, () => {
|
||||
const layer = envLayer({
|
||||
mapping: { PORT: "server.port" },
|
||||
source: { PORT: "" },
|
||||
});
|
||||
expect(layer.resolve()).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty object when mapping is empty", { timeout: 5000 }, () => {
|
||||
const layer = envLayer({ mapping: {}, source: { PORT: "3000" } });
|
||||
expect(layer.resolve()).toEqual({});
|
||||
});
|
||||
|
||||
it("coerces boolean value in env source", { timeout: 5000 }, () => {
|
||||
const layer = envLayer({
|
||||
mapping: { DEBUG: "log.debug" },
|
||||
source: { DEBUG: "true" },
|
||||
});
|
||||
expect(layer.resolve()).toEqual({ log: { debug: true } });
|
||||
});
|
||||
|
||||
it("coerces JSON object value in env source", { timeout: 5000 }, () => {
|
||||
const layer = envLayer({
|
||||
mapping: { DB_CONFIG: "db" },
|
||||
source: { DB_CONFIG: '{"host":"localhost"}' },
|
||||
});
|
||||
expect(layer.resolve()).toEqual({ db: { host: "localhost" } });
|
||||
});
|
||||
|
||||
it("uses process.env when no source is provided", { timeout: 5000 }, () => {
|
||||
// Set a unique env var, resolve without source, verify it's read
|
||||
const key = "_SUAVE_CONFIG_TEST_PORT_12345";
|
||||
const origVal = process.env[key];
|
||||
process.env[key] = "9876";
|
||||
try {
|
||||
const layer = envLayer({ mapping: { [key]: "test.port" } });
|
||||
const result = layer.resolve();
|
||||
expect(result).toEqual({ test: { port: 9876 } });
|
||||
} finally {
|
||||
if (origVal === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = origVal;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
FALSY_STRINGS,
|
||||
LAYER_PRIORITIES,
|
||||
TRUTHY_STRINGS,
|
||||
} from "../../constants/index.js";
|
||||
import type {
|
||||
ConfigData,
|
||||
ConfigLayer,
|
||||
EnvLayerOptions,
|
||||
} from "../../types/common.js";
|
||||
import { setByPath } from "../../utils/mask.js";
|
||||
|
||||
/**
|
||||
* Coerce a raw environment-variable string to the most specific JavaScript
|
||||
* value it represents.
|
||||
*
|
||||
* Coercion order:
|
||||
* 1. Empty string → empty string (returned as-is)
|
||||
* 2. Boolean-like tokens (`true/1/yes/on/enabled` → `true`,
|
||||
* `false/0/no/off/disabled` → `false`)
|
||||
* 3. Finite numeric string → `number`
|
||||
* 4. Valid JSON object or array → parsed value
|
||||
* 5. Everything else → original string
|
||||
*/
|
||||
export function coerceValue(raw: string): unknown {
|
||||
if (raw === "") return raw;
|
||||
|
||||
const lower = raw.trim().toLowerCase();
|
||||
|
||||
if (TRUTHY_STRINGS.includes(lower)) return true;
|
||||
if (FALSY_STRINGS.includes(lower)) return false;
|
||||
|
||||
const trimmed = raw.trim();
|
||||
const asNumber = Number(trimmed);
|
||||
if (
|
||||
trimmed !== "" &&
|
||||
!Number.isNaN(asNumber) &&
|
||||
Number.isFinite(asNumber)
|
||||
) {
|
||||
return asNumber;
|
||||
}
|
||||
|
||||
if (
|
||||
(trimmed.startsWith("{") && trimmed.endsWith("}")) ||
|
||||
(trimmed.startsWith("[") && trimmed.endsWith("]"))
|
||||
) {
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
// Fall through to string
|
||||
}
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the environment source.
|
||||
* Returns `process.env` in Node/Bun; falls back to `{}` in browser environments.
|
||||
*/
|
||||
function getDefaultSource(): Record<string, string | undefined> {
|
||||
// In browser environments where process is not defined, callers must
|
||||
// provide an explicit `source` option. This function is only called
|
||||
// in Node / Bun environments.
|
||||
return process.env as Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a config layer that reads values from environment variables.
|
||||
*
|
||||
* Only env-vars listed in `options.mapping` are read. Each key in `mapping`
|
||||
* is the env-var name (after optional prefix prepending); the value is the
|
||||
* dot-notation config path to write the coerced value into.
|
||||
*
|
||||
* Priority: `LAYER_PRIORITIES.ENV = 30`.
|
||||
*
|
||||
* @param options - Env layer configuration.
|
||||
* @returns A `ConfigLayer` named `"env"` with priority `30`.
|
||||
*/
|
||||
export function envLayer(options: EnvLayerOptions): ConfigLayer {
|
||||
return {
|
||||
name: "env",
|
||||
priority: LAYER_PRIORITIES.ENV,
|
||||
resolve(): ConfigData {
|
||||
const source = options.source ?? getDefaultSource();
|
||||
const result: ConfigData = {};
|
||||
|
||||
for (const [envKey, configPath] of Object.entries(
|
||||
options.mapping
|
||||
)) {
|
||||
const fullKey = options.prefix
|
||||
? `${options.prefix}${envKey}`
|
||||
: envKey;
|
||||
const raw = source[fullKey];
|
||||
if (raw !== undefined && raw !== "") {
|
||||
setByPath(result, configPath, coerceValue(raw));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { coerceValue, envLayer } from "./env-layer.js";
|
||||
@@ -0,0 +1,94 @@
|
||||
# file-layer — Pre-parsed file content config layer for `@suaveplan/config`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`fileLayer` creates a config layer that serves pre-parsed configuration data
|
||||
read from a file. The layer itself does not perform file I/O — that is the
|
||||
caller's responsibility. This separation keeps the layer synchronous and
|
||||
testable without file-system access. When an optional `watchBus` event bus is
|
||||
provided, the layer subscribes to a configurable event name and triggers a
|
||||
parent-store reload when the event fires, enabling live config reloading
|
||||
without polling.
|
||||
|
||||
## Features
|
||||
|
||||
- Priority `10` (`LAYER_PRIORITIES.FILE`) — above defaults, below object/env/CLI
|
||||
- Serves `structuredClone`-safe shallow copy of provided `content` on `resolve()`
|
||||
- Optional watch mode via `EventBusLike` + configurable event name
|
||||
- `teardown()` method unsubscribes the watch listener to prevent memory leaks
|
||||
- File I/O is the caller's responsibility; this layer is format-agnostic
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { fileLayer, createConfig } from '@suaveplan/config';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const content = JSON.parse(readFileSync('./config.json', 'utf-8'));
|
||||
|
||||
const store = createConfig({
|
||||
layers: [fileLayer({ content })],
|
||||
});
|
||||
|
||||
store.get('server.port'); // value from config.json
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { fileLayer, defaultsLayer, createConfig } from '@suaveplan/config';
|
||||
import { EventBus } from '@suaveplan/events';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { watch } from 'node:fs';
|
||||
|
||||
let content = JSON.parse(readFileSync('./app.json', 'utf-8'));
|
||||
const watchBus = new EventBus();
|
||||
|
||||
// Re-parse the file when it changes and signal the bus
|
||||
watch('./app.json', () => {
|
||||
content = JSON.parse(readFileSync('./app.json', 'utf-8'));
|
||||
watchBus.emit('config:file:changed', {});
|
||||
});
|
||||
|
||||
const fileL = fileLayer({
|
||||
content,
|
||||
watchBus,
|
||||
watchEvent: 'config:file:changed',
|
||||
});
|
||||
|
||||
const store = createConfig({
|
||||
layers: [defaultsLayer({ debug: false }), fileL],
|
||||
});
|
||||
|
||||
// Reloads automatically when app.json changes
|
||||
await store.reload();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `fileLayer(options: FileLayerOptions): ConfigLayer`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `options.content` | `ConfigData` | Pre-parsed config object |
|
||||
| `options.watchBus` | `EventBusLike` | Optional event bus for watch mode |
|
||||
| `options.watchEvent` | `string` | Event name to listen for; default `'config:file:changed'` |
|
||||
|
||||
**Returns** a `ConfigLayer` with:
|
||||
|
||||
- `name`: `"file"`
|
||||
- `priority`: `10` (`LAYER_PRIORITIES.FILE`)
|
||||
- `resolve()`: returns `{ ...options.content }` (shallow copy)
|
||||
- `teardown()`: unsubscribes from the watch bus if one was provided
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The watch mechanism is intentionally passive — the layer subscribes to an
|
||||
event but does not read files itself. The event payload is ignored; only its
|
||||
arrival matters. The parent `createConfig` live store injects a `_onReload`
|
||||
callback into the layer after construction to close the loop. If `teardown()`
|
||||
is not called, the subscription remains active; callers responsible for
|
||||
application shutdown should ensure all layer teardowns are invoked.
|
||||
@@ -0,0 +1,124 @@
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
|
||||
import { LAYER_PRIORITIES } from "../../constants/index.js";
|
||||
import { fileLayer, wireLayerReload } from "./file-layer.js";
|
||||
|
||||
describe("fileLayer (static)", () => {
|
||||
it("has name 'file'", { timeout: 5000 }, () => {
|
||||
expect(fileLayer({ content: {} }).name).toBe("file");
|
||||
});
|
||||
|
||||
it("has priority LAYER_PRIORITIES.FILE (10)", { timeout: 5000 }, () => {
|
||||
expect(fileLayer({ content: {} }).priority).toBe(LAYER_PRIORITIES.FILE);
|
||||
expect(fileLayer({ content: {} }).priority).toBe(10);
|
||||
});
|
||||
|
||||
it("returns provided content on resolve", { timeout: 5000 }, () => {
|
||||
const content = { port: 4000, debug: true };
|
||||
const layer = fileLayer({ content });
|
||||
expect(layer.resolve()).toEqual({ port: 4000, debug: true });
|
||||
});
|
||||
|
||||
it("returns a shallow copy — mutating result does not affect next resolve", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const layer = fileLayer({ content: { port: 4000 } });
|
||||
const result = layer.resolve();
|
||||
(result as Record<string, unknown>).port = 9999;
|
||||
expect(layer.resolve().port).toBe(4000);
|
||||
});
|
||||
|
||||
it("teardown is a no-op when no watchBus is provided", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const layer = fileLayer({ content: {} });
|
||||
expect(() => layer.teardown?.()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fileLayer (watch mode)", () => {
|
||||
it("subscribes to the default watch event", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
type BusMap = { "config:file:changed": Record<string, never> };
|
||||
const bus = new EventBus<BusMap>();
|
||||
const onReload = vi.fn<() => void>();
|
||||
|
||||
const layer = fileLayer({
|
||||
content: { port: 3000 },
|
||||
watchBus: bus,
|
||||
});
|
||||
|
||||
wireLayerReload(layer, onReload);
|
||||
|
||||
await bus.emit("config:file:changed", {});
|
||||
expect(onReload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("subscribes to a custom watch event", { timeout: 30000 }, async () => {
|
||||
type BusMap = { "my:custom:event": Record<string, never> };
|
||||
const bus = new EventBus<BusMap>();
|
||||
const onReload = vi.fn<() => void>();
|
||||
|
||||
const layer = fileLayer({
|
||||
content: { port: 3000 },
|
||||
watchBus: bus,
|
||||
watchEvent: "my:custom:event",
|
||||
});
|
||||
|
||||
wireLayerReload(layer, onReload);
|
||||
|
||||
await bus.emit("my:custom:event", {});
|
||||
expect(onReload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("teardown unsubscribes so no more reload calls", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
type BusMap = { "config:file:changed": Record<string, never> };
|
||||
const bus = new EventBus<BusMap>();
|
||||
const onReload = vi.fn<() => void>();
|
||||
|
||||
const layer = fileLayer({
|
||||
content: { port: 3000 },
|
||||
watchBus: bus,
|
||||
});
|
||||
|
||||
wireLayerReload(layer, onReload);
|
||||
layer.teardown?.();
|
||||
|
||||
await bus.emit("config:file:changed", {});
|
||||
expect(onReload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not call reload before wireLayerReload is called", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
type BusMap = { "config:file:changed": Record<string, never> };
|
||||
const bus = new EventBus<BusMap>();
|
||||
const onReload = vi.fn<() => void>();
|
||||
|
||||
const _layer = fileLayer({
|
||||
content: { port: 3000 },
|
||||
watchBus: bus,
|
||||
});
|
||||
|
||||
// wireLayerReload NOT called yet
|
||||
await bus.emit("config:file:changed", {});
|
||||
expect(onReload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("wireLayerReload", () => {
|
||||
it("does not throw for a layer with no teardown and no _onReload", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// A minimal layer without teardown — wireLayerReload should be a no-op
|
||||
const layer = { name: "plain", priority: 0, resolve: () => ({}) };
|
||||
expect(() =>
|
||||
wireLayerReload(layer, () => {
|
||||
/* no-op */
|
||||
})
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { EventBusLike } from "@suaveplan/types";
|
||||
import { LAYER_PRIORITIES } from "../../constants/index.js";
|
||||
import type {
|
||||
ConfigData,
|
||||
ConfigLayer,
|
||||
FileLayerOptions,
|
||||
} from "../../types/common.js";
|
||||
|
||||
const DEFAULT_FILE_WATCH_EVENT = "config:file:changed";
|
||||
|
||||
/**
|
||||
* Extended layer type with an optional reload callback used by watch mode.
|
||||
* The parent store injects `onReload` after construction.
|
||||
*/
|
||||
interface WatchableLayer extends ConfigLayer {
|
||||
/** Injected by the live store to trigger reload on watch event. */
|
||||
_onReload?: () => void;
|
||||
teardown(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a config layer that serves pre-parsed file content.
|
||||
*
|
||||
* File I/O is the caller's responsibility — this layer receives already-decoded
|
||||
* `ConfigData`. When `options.watchBus` is provided, the layer subscribes to
|
||||
* `options.watchEvent` (default `'config:file:changed'`) and calls the parent
|
||||
* store's reload function when the event fires.
|
||||
*
|
||||
* Priority: `LAYER_PRIORITIES.FILE = 10`.
|
||||
*
|
||||
* @param options - File layer configuration.
|
||||
* @returns A `ConfigLayer` named `"file"` with priority `10`.
|
||||
*/
|
||||
export function fileLayer(options: FileLayerOptions): ConfigLayer {
|
||||
let unsub: (() => void) | undefined;
|
||||
|
||||
const layer: WatchableLayer = {
|
||||
name: "file",
|
||||
priority: LAYER_PRIORITIES.FILE,
|
||||
|
||||
resolve(): ConfigData {
|
||||
return { ...options.content };
|
||||
},
|
||||
|
||||
teardown(): void {
|
||||
if (unsub !== undefined) {
|
||||
unsub();
|
||||
unsub = undefined;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if (options.watchBus !== undefined) {
|
||||
const bus = options.watchBus as EventBusLike & {
|
||||
on(
|
||||
event: string,
|
||||
listener: (payload: unknown) => unknown
|
||||
): () => void;
|
||||
};
|
||||
const event = options.watchEvent ?? DEFAULT_FILE_WATCH_EVENT;
|
||||
|
||||
// Subscribe; when the event fires, delegate to _onReload if wired.
|
||||
unsub = bus.on(event, () => {
|
||||
if (layer._onReload !== undefined) {
|
||||
layer._onReload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return layer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire a reloadable layer to call `onReload` when its watch event fires.
|
||||
* Called internally by `createConfig` when it wires the live store.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function wireLayerReload(
|
||||
layer: ConfigLayer,
|
||||
onReload: () => void
|
||||
): void {
|
||||
const watchable = layer as WatchableLayer;
|
||||
if (
|
||||
watchable._onReload !== undefined ||
|
||||
typeof watchable.teardown === "function"
|
||||
) {
|
||||
watchable._onReload = onReload;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { fileLayer, wireLayerReload } from "./file-layer.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { objectLayer } from "./object-layer.js";
|
||||
@@ -0,0 +1,83 @@
|
||||
# object-layer — In-memory config object layer for `@suaveplan/config`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`objectLayer` wraps a plain JavaScript object as a named config layer, making it
|
||||
easy to inject programmatically constructed configuration into the `createConfig`
|
||||
pipeline. It is the simplest way to supply test overrides, feature-flag-driven
|
||||
values, or dynamically computed config blobs without resorting to the full
|
||||
`fileLayer` or `envLayer` machinery. The default priority (`OBJECT = 20`) places
|
||||
it above the file layer and defaults layer but below environment variable and CLI
|
||||
layers.
|
||||
|
||||
## Features
|
||||
|
||||
- Default priority `20` (`LAYER_PRIORITIES.OBJECT`) — overrides file and defaults
|
||||
- Configurable priority via the optional second argument
|
||||
- Returns a shallow copy on `resolve()` for mutation safety
|
||||
- Named `"object"` for clear identification in logs and error messages
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { objectLayer, createConfig } from '@suaveplan/config';
|
||||
|
||||
const store = createConfig({
|
||||
layers: [
|
||||
objectLayer({ port: 4000, debug: true }),
|
||||
],
|
||||
});
|
||||
|
||||
store.get('port'); // 4000
|
||||
store.get('debug'); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { defaultsLayer, objectLayer, envLayer, createConfig } from '@suaveplan/config';
|
||||
import { LAYER_PRIORITIES } from '@suaveplan/config/constants';
|
||||
|
||||
// Two object layers at different priorities:
|
||||
const baseObject = objectLayer({ db: { pool: 5, timeout: 30_000 } });
|
||||
const testOverride = objectLayer(
|
||||
{ db: { pool: 1 } },
|
||||
LAYER_PRIORITIES.ENV + 5 // higher than ENV, used in tests
|
||||
);
|
||||
|
||||
const store = createConfig({
|
||||
layers: [
|
||||
defaultsLayer({ port: 3000 }),
|
||||
baseObject,
|
||||
testOverride,
|
||||
],
|
||||
});
|
||||
|
||||
store.get('db.pool'); // 1 (test override wins)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `objectLayer(data: ConfigData, priority?: number): ConfigLayer`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `data` | `ConfigData` | Plain object to expose as config values |
|
||||
| `priority` | `number` | Optional priority; defaults to `LAYER_PRIORITIES.OBJECT` (20) |
|
||||
|
||||
**Returns** a `ConfigLayer` with:
|
||||
|
||||
- `name`: `"object"`
|
||||
- `priority`: provided or `20`
|
||||
- `resolve()`: returns `{ ...data }` (shallow copy)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The shallow copy in `resolve()` means that top-level key mutations to the
|
||||
returned object do not affect subsequent resolutions. Callers who supply deeply
|
||||
nested mutable objects should be aware that nested references are not deep-cloned.
|
||||
If immutability at all depths is required, pass `structuredClone(data)` as the
|
||||
argument. The layer carries no teardown logic since it holds no external resources.
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { LAYER_PRIORITIES } from "../../constants/index.js";
|
||||
import { objectLayer } from "./object-layer.js";
|
||||
|
||||
describe("objectLayer", () => {
|
||||
it("has name 'object'", { timeout: 5000 }, () => {
|
||||
expect(objectLayer({}).name).toBe("object");
|
||||
});
|
||||
|
||||
it("has default priority LAYER_PRIORITIES.OBJECT (20)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(objectLayer({}).priority).toBe(LAYER_PRIORITIES.OBJECT);
|
||||
expect(objectLayer({}).priority).toBe(20);
|
||||
});
|
||||
|
||||
it("accepts a custom priority", { timeout: 5000 }, () => {
|
||||
expect(objectLayer({}, 99).priority).toBe(99);
|
||||
});
|
||||
|
||||
it("returns the provided data on resolve", { timeout: 5000 }, () => {
|
||||
const layer = objectLayer({ port: 4000, debug: true });
|
||||
expect(layer.resolve()).toEqual({ port: 4000, debug: true });
|
||||
});
|
||||
|
||||
it("returns a copy — mutating result does not affect next resolve", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const layer = objectLayer({ port: 4000 });
|
||||
const first = layer.resolve();
|
||||
(first as Record<string, unknown>).port = 9999;
|
||||
expect(layer.resolve().port).toBe(4000);
|
||||
});
|
||||
|
||||
it("returns empty object when data is empty", { timeout: 5000 }, () => {
|
||||
expect(objectLayer({}).resolve()).toEqual({});
|
||||
});
|
||||
|
||||
it("handles nested data", { timeout: 5000 }, () => {
|
||||
const layer = objectLayer({ db: { host: "localhost" } });
|
||||
expect(layer.resolve()).toEqual({ db: { host: "localhost" } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { LAYER_PRIORITIES } from "../../constants/index.js";
|
||||
import type { ConfigData, ConfigLayer } from "../../types/common.js";
|
||||
|
||||
/**
|
||||
* Create a config layer that wraps an arbitrary in-memory config object.
|
||||
*
|
||||
* The default priority is `LAYER_PRIORITIES.OBJECT = 20`, placing it above
|
||||
* file and defaults layers but below env and CLI layers. A custom priority
|
||||
* can be supplied when multiple object layers are used in the same stack.
|
||||
*
|
||||
* `resolve()` returns a shallow copy of the provided data.
|
||||
*
|
||||
* @param data - The config values to expose from this layer.
|
||||
* @param priority - Optional override for the layer priority.
|
||||
* @returns A `ConfigLayer` named `"object"` at the given priority.
|
||||
*/
|
||||
export function objectLayer(
|
||||
data: ConfigData,
|
||||
priority: number = LAYER_PRIORITIES.OBJECT
|
||||
): ConfigLayer {
|
||||
return {
|
||||
name: "object",
|
||||
priority,
|
||||
resolve(): ConfigData {
|
||||
return { ...data };
|
||||
},
|
||||
};
|
||||
}
|
||||
181
packages/foundation/core/config/src/types/common.md
Normal file
181
packages/foundation/core/config/src/types/common.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# common — Public TypeScript types for `@suaveplan/config`
|
||||
|
||||
## Purpose
|
||||
|
||||
This module defines every public TypeScript interface and type alias used across
|
||||
the `@suaveplan/config` package. It is the single source of truth for the
|
||||
package's public API surface, ensuring that all layer implementations, the
|
||||
config builder, and the config store agree on the same type signatures. By
|
||||
keeping types in a dedicated file, downstream consumers can import only the
|
||||
types they need (zero runtime cost) while benefiting from full IntelliSense
|
||||
support and strict TypeScript narrowing.
|
||||
|
||||
## Features
|
||||
|
||||
- `ConfigData` — base record type for all config objects
|
||||
- `ConfigLayer` — contract every layer implementation must satisfy, with optional
|
||||
`teardown` hook for resource cleanup
|
||||
- `ConfigValidator<T>` — typed validation/transformation callback
|
||||
- `EnvironmentOverrides<T>` — per-environment partial override map
|
||||
- `CreateConfigOptions<T>` — full orchestrator options including `logger` injection
|
||||
- `ConfigStore<T>` — immutable runtime view with `get`, `has`, `toJSON`, `toMasked`,
|
||||
`reload`, and `subscribe`
|
||||
- `ConfigStoreOptions<T>` — low-level static store factory options
|
||||
- `EnvLayerOptions` — env-var source, prefix, mapping, and optional watch bus
|
||||
- `CliLayerOptions` — argv array and flag-to-path mapping
|
||||
- `FileLayerOptions` — pre-parsed content and optional watch bus
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { ConfigData, ConfigLayer, ConfigStore } from '@suaveplan/config/types/common';
|
||||
|
||||
interface AppConfig extends ConfigData {
|
||||
port: number;
|
||||
debug: boolean;
|
||||
}
|
||||
|
||||
const layer: ConfigLayer = {
|
||||
name: 'app-defaults',
|
||||
priority: 0,
|
||||
resolve: () => ({ port: 3000, debug: false }),
|
||||
};
|
||||
|
||||
function display(store: ConfigStore<AppConfig>): void {
|
||||
const port = store.get('port');
|
||||
console.log(`Listening on port ${port}`);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
CreateConfigOptions,
|
||||
ConfigStore,
|
||||
ConfigData,
|
||||
ConfigValidator,
|
||||
EnvironmentOverrides,
|
||||
} from '@suaveplan/config/types/common';
|
||||
import z from 'zod';
|
||||
|
||||
const Schema = z.object({ port: z.number(), debug: z.boolean() });
|
||||
type AppConfig = z.infer<typeof Schema> & ConfigData;
|
||||
|
||||
const validate: ConfigValidator<AppConfig> = (raw) => Schema.parse(raw);
|
||||
|
||||
const overrides: EnvironmentOverrides<AppConfig> = {
|
||||
production: { debug: false },
|
||||
development: { debug: true },
|
||||
};
|
||||
|
||||
const opts: CreateConfigOptions<AppConfig> = {
|
||||
layers: [],
|
||||
validate,
|
||||
sensitive: ['db.password'],
|
||||
environments: overrides,
|
||||
environment: process.env.NODE_ENV ?? 'development',
|
||||
logger: console,
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `ConfigData`
|
||||
|
||||
```ts
|
||||
type ConfigData = Record<string, unknown>
|
||||
```
|
||||
|
||||
Base type for all config data objects. Allows arbitrary string keys and any
|
||||
value type, including nested objects.
|
||||
|
||||
### `ConfigLayer`
|
||||
|
||||
```ts
|
||||
interface ConfigLayer {
|
||||
readonly name: string;
|
||||
readonly priority: number;
|
||||
resolve(): ConfigData;
|
||||
teardown?(): void;
|
||||
}
|
||||
```
|
||||
|
||||
The contract every config layer must implement. `teardown` is optional and is
|
||||
called when the parent store is torn down (e.g. to unsubscribe from an event bus).
|
||||
|
||||
### `ConfigValidator<T>`
|
||||
|
||||
```ts
|
||||
type ConfigValidator<T extends ConfigData = ConfigData> =
|
||||
(data: ConfigData) => T
|
||||
```
|
||||
|
||||
A synchronous function that validates and transforms raw merged config data. Must
|
||||
throw on invalid input; the error is caught and wrapped as `CONFIG.VALIDATION_FAILED`.
|
||||
|
||||
### `EnvironmentOverrides<T>`
|
||||
|
||||
```ts
|
||||
type EnvironmentOverrides<T extends ConfigData = ConfigData> =
|
||||
Record<string, Partial<T>>
|
||||
```
|
||||
|
||||
Maps environment names to partial config overrides. Applied on top of the merged
|
||||
layer data when the active `environment` key matches.
|
||||
|
||||
### `CreateConfigOptions<T>`
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `layers` | `ConfigLayer[]` | Yes | Layers to resolve and merge |
|
||||
| `validate` | `ConfigValidator<T>` | No | Post-merge validator |
|
||||
| `sensitive` | `string[]` | No | Paths to mask in `toMasked()` |
|
||||
| `environments` | `EnvironmentOverrides<T>` | No | Per-env overrides |
|
||||
| `environment` | `string` | No | Active environment name |
|
||||
| `logger` | `LoggerLike` | No | Structured logger for lifecycle events |
|
||||
|
||||
### `ConfigStore<T>`
|
||||
|
||||
| Method | Returns | Description |
|
||||
|--------|---------|-------------|
|
||||
| `get(key)` | `unknown` | Value at dot-notation path, or `undefined` |
|
||||
| `has(key)` | `boolean` | `true` if path exists |
|
||||
| `toJSON()` | `Readonly<T>` | Frozen resolved config |
|
||||
| `toMasked()` | `Readonly<T>` | Config with sensitive paths replaced |
|
||||
| `reload(signal?)` | `Promise<void>` | Re-resolves all layers |
|
||||
| `subscribe(fn)` | `() => void` | Listen for reload events; returns unsubscribe |
|
||||
|
||||
### `EnvLayerOptions`
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `mapping` | `Record<string, string>` | Yes | Env-var name → config path |
|
||||
| `prefix` | `string` | No | Prefix stripped before mapping |
|
||||
| `source` | `Record<string, string \| undefined>` | No | Override for `process.env` |
|
||||
| `watchBus` | `EventBusLike` | No | Event bus for watch-mode |
|
||||
| `watchEvent` | `string` | No | Event name (default: `'config:env:changed'`) |
|
||||
|
||||
### `CliLayerOptions`
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `args` | `string[]` | Yes | Raw argv (e.g. `process.argv.slice(2)`) |
|
||||
| `mapping` | `Record<string, string>` | Yes | Flag name → config path |
|
||||
|
||||
### `FileLayerOptions`
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `content` | `ConfigData` | Yes | Pre-parsed file content |
|
||||
| `watchBus` | `EventBusLike` | No | Event bus for watch-mode |
|
||||
| `watchEvent` | `string` | No | Event name (default: `'config:file:changed'`) |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All types in this module are pure TypeScript interfaces and type aliases — they
|
||||
produce zero runtime output. The `ConfigLayer.teardown` optional method was added
|
||||
to support watch-mode layers that hold event-bus subscriptions. The
|
||||
`ConfigStore.reload` and `ConfigStore.subscribe` methods are optional on the
|
||||
interface for backward compatibility but are always present on concrete stores
|
||||
returned by `createConfig`.
|
||||
165
packages/foundation/core/config/src/types/common.ts
Normal file
165
packages/foundation/core/config/src/types/common.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import type { EventBusLike, LoggerLike } from "@suaveplan/types";
|
||||
|
||||
/**
|
||||
* The fundamental unit of configuration data: a plain, string-keyed record
|
||||
* of unknown values. Deep nesting is permitted.
|
||||
*/
|
||||
export type ConfigData = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* A single configuration source that contributes data at a specific priority.
|
||||
* The optional `teardown` method is called when the layer should release any
|
||||
* held resources (e.g. event-bus subscriptions from `fileLayer` watch mode).
|
||||
*/
|
||||
export interface ConfigLayer {
|
||||
/** Human-readable identifier shown in error messages and logs. */
|
||||
readonly name: string;
|
||||
/**
|
||||
* Resolution priority. Higher numbers win during deep-merge.
|
||||
* Built-in values: DEFAULTS=0, FILE=10, OBJECT=20, ENV=30, CLI=40.
|
||||
*/
|
||||
readonly priority: number;
|
||||
/** Synchronously resolve and return the layer's current data snapshot. */
|
||||
resolve(): ConfigData;
|
||||
/** Optional cleanup hook; called when the parent store is torn down. */
|
||||
teardown?(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A function that validates and possibly transforms raw merged config data
|
||||
* into a typed `T`. When validation fails the function MUST throw; the error
|
||||
* is caught and re-wrapped as a `ConfigError` with code
|
||||
* `CONFIG.VALIDATION_FAILED`.
|
||||
*/
|
||||
export type ConfigValidator<T extends ConfigData = ConfigData> = (
|
||||
data: ConfigData
|
||||
) => T;
|
||||
|
||||
/**
|
||||
* A map of environment names to partial config overrides that are deep-merged
|
||||
* on top of the resolved layer data when the matching `environment` key is
|
||||
* active.
|
||||
*/
|
||||
export type EnvironmentOverrides<T extends ConfigData = ConfigData> = Record<
|
||||
string,
|
||||
Partial<T>
|
||||
>;
|
||||
|
||||
/**
|
||||
* Options accepted by `createConfig`. All fields except `layers` are optional.
|
||||
*/
|
||||
export interface CreateConfigOptions<T extends ConfigData = ConfigData> {
|
||||
/** Ordered list of layers to resolve and merge. */
|
||||
layers: ConfigLayer[];
|
||||
/** Optional validator called after merging and environment overrides. */
|
||||
validate?: ConfigValidator<T>;
|
||||
/** Dot-notation paths of keys whose values must be masked in `toMasked()`. */
|
||||
sensitive?: string[];
|
||||
/** Named per-environment overrides applied when `environment` matches. */
|
||||
environments?: EnvironmentOverrides<T>;
|
||||
/** Active environment name (e.g. `"production"`). */
|
||||
environment?: string;
|
||||
/** Optional structured logger; receives debug entries during config lifecycle. */
|
||||
logger?: LoggerLike;
|
||||
}
|
||||
|
||||
/**
|
||||
* The immutable runtime view of resolved configuration data. Methods are
|
||||
* pure and never mutate internal state.
|
||||
*
|
||||
* `reload` and `subscribe` are available on stores created by `createConfig`
|
||||
* (live stores). Stores created by the bare `createConfigStore` factory return
|
||||
* no-op stubs for those methods.
|
||||
*/
|
||||
export interface ConfigStore<T extends ConfigData = ConfigData> {
|
||||
/**
|
||||
* Retrieve a value by dot-notation path.
|
||||
* Returns `undefined` if the path does not exist.
|
||||
*/
|
||||
get<K extends string>(key: K): unknown;
|
||||
/** Returns `true` if the dot-notation path exists in the config. */
|
||||
has(key: string): boolean;
|
||||
/** Returns the fully resolved, frozen config object. */
|
||||
toJSON(): Readonly<T>;
|
||||
/** Returns a copy with all sensitive paths replaced by `MASK_STRING`. */
|
||||
toMasked(): Readonly<T>;
|
||||
/**
|
||||
* Re-resolves all layers and updates the store's internal data.
|
||||
* An `AbortSignal` can be passed to cancel an in-flight reload.
|
||||
* On static stores created by `createConfigStore` this is a no-op.
|
||||
*/
|
||||
reload(signal?: AbortSignal): Promise<void>;
|
||||
/**
|
||||
* Subscribe to config changes triggered by `reload()`.
|
||||
* Returns an unsubscribe function.
|
||||
* On static stores this is a no-op that returns a no-op unsubscribe.
|
||||
*/
|
||||
subscribe(listener: (next: Readonly<T>) => void): () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for the low-level `createConfigStore` factory.
|
||||
* Use `createConfig` for the full orchestrated pipeline.
|
||||
*/
|
||||
export interface ConfigStoreOptions<T extends ConfigData = ConfigData> {
|
||||
/** The resolved config data to freeze and expose. */
|
||||
data: T;
|
||||
/** Dot-notation paths of keys to mask in `toMasked()`. */
|
||||
sensitive: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for `envLayer`.
|
||||
*/
|
||||
export interface EnvLayerOptions {
|
||||
/** Optional prefix stripped from env-var names before mapping. */
|
||||
prefix?: string;
|
||||
/**
|
||||
* Map of env-var names (after prefix stripping) to dot-notation config
|
||||
* paths. Only listed env vars are read.
|
||||
*/
|
||||
mapping: Record<string, string>;
|
||||
/**
|
||||
* Override the environment source. Defaults to `process.env` in
|
||||
* Node/Bun; falls back to `{}` in browser environments.
|
||||
*/
|
||||
source?: Record<string, string | undefined>;
|
||||
/** Optional event bus for watch-mode reload signalling. */
|
||||
watchBus?: EventBusLike;
|
||||
/** Event name to listen for on the watch bus. Default: `'config:env:changed'`. */
|
||||
watchEvent?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for `cliLayer`.
|
||||
*/
|
||||
export interface CliLayerOptions {
|
||||
/** Raw argv array (e.g. `process.argv.slice(2)`). */
|
||||
args: string[];
|
||||
/**
|
||||
* Map of CLI flag names (without `--` or `-` prefix) to dot-notation
|
||||
* config paths.
|
||||
*/
|
||||
mapping: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for `fileLayer`.
|
||||
*/
|
||||
export interface FileLayerOptions {
|
||||
/**
|
||||
* Pre-parsed config content. File I/O is the caller's responsibility;
|
||||
* this layer receives the already-decoded data.
|
||||
*/
|
||||
content: ConfigData;
|
||||
/**
|
||||
* Optional event bus. When provided, the layer subscribes to `watchEvent`
|
||||
* and calls the parent store's `reload()` on receipt.
|
||||
*/
|
||||
watchBus?: EventBusLike;
|
||||
/**
|
||||
* Event name to listen for on the watch bus.
|
||||
* Default: `'config:file:changed'`.
|
||||
*/
|
||||
watchEvent?: string;
|
||||
}
|
||||
26
packages/foundation/core/config/src/types/index.ts
Normal file
26
packages/foundation/core/config/src/types/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export type {
|
||||
CliLayerOptions,
|
||||
ConfigData,
|
||||
ConfigLayer,
|
||||
ConfigStore,
|
||||
ConfigStoreOptions,
|
||||
ConfigValidator,
|
||||
CreateConfigOptions,
|
||||
EnvironmentOverrides,
|
||||
EnvLayerOptions,
|
||||
FileLayerOptions,
|
||||
} from "./common.js";
|
||||
export type {
|
||||
CliLayerOptionsInput,
|
||||
ConfigStoreOptionsInput,
|
||||
CreateConfigOptionsInput,
|
||||
EnvLayerOptionsInput,
|
||||
FileLayerOptionsInput,
|
||||
} from "./schemas.js";
|
||||
export {
|
||||
CliLayerOptionsSchema,
|
||||
ConfigStoreOptionsSchema,
|
||||
CreateConfigOptionsSchema,
|
||||
EnvLayerOptionsSchema,
|
||||
FileLayerOptionsSchema,
|
||||
} from "./schemas.js";
|
||||
110
packages/foundation/core/config/src/types/schemas.md
Normal file
110
packages/foundation/core/config/src/types/schemas.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# schemas — Zod option schemas for `@suaveplan/config`
|
||||
|
||||
## Purpose
|
||||
|
||||
This module exposes Zod schemas for every public options interface in
|
||||
`@suaveplan/config`. Using these schemas, callers can validate externally
|
||||
sourced configuration objects at runtime — for instance, when reading layer
|
||||
options from a database row, an HTTP request body, or an untrusted JSON file.
|
||||
The schemas complement the TypeScript interfaces in `types/common.ts` by adding
|
||||
runtime validation without duplicating the structural definitions.
|
||||
|
||||
## Features
|
||||
|
||||
- `EnvLayerOptionsSchema` — validates `EnvLayerOptions` (mapping required, prefix optional)
|
||||
- `CliLayerOptionsSchema` — validates `CliLayerOptions` (args array + mapping required)
|
||||
- `FileLayerOptionsSchema` — validates `FileLayerOptions` (content record required)
|
||||
- `ConfigStoreOptionsSchema` — validates `ConfigStoreOptions` (data + sensitive array)
|
||||
- `CreateConfigOptionsSchema` — validates the shape-checkable subset of `CreateConfigOptions`
|
||||
(functions and loggers are excluded, layer structural fields only)
|
||||
- Inferred companion types (`EnvLayerOptionsInput`, etc.) for consumers that want
|
||||
the Zod-inferred type rather than the hand-written TS interface
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { EnvLayerOptionsSchema } from '@suaveplan/config/types/schemas';
|
||||
|
||||
const raw = JSON.parse(process.env.ENV_LAYER_CONFIG ?? '{}');
|
||||
const opts = EnvLayerOptionsSchema.parse(raw);
|
||||
// opts is now typed as EnvLayerOptionsInput
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { CreateConfigOptionsSchema } from '@suaveplan/config/types/schemas';
|
||||
import z from 'zod';
|
||||
|
||||
// Validate only the serialisable portion of options coming from a config API.
|
||||
function parseRemoteConfigOptions(body: unknown) {
|
||||
const result = CreateConfigOptionsSchema.safeParse(body);
|
||||
if (!result.success) {
|
||||
throw new Error(`Invalid config options: ${result.error.message}`);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
// Then merge the validated shape with local layer/validate functions before
|
||||
// passing to createConfig.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `EnvLayerOptionsSchema`
|
||||
|
||||
Validates `{ mapping: Record<string, string>, prefix?: string, source?: Record<string, string|undefined>, watchEvent?: string }`.
|
||||
|
||||
- `mapping` — required; record of env-var names to dot-notation config paths
|
||||
- `prefix` — optional string stripped from env-var names before mapping
|
||||
- `source` — optional environment source override
|
||||
- `watchEvent` — optional event name for watch-mode
|
||||
|
||||
**Inferred type**: `EnvLayerOptionsInput`
|
||||
|
||||
### `CliLayerOptionsSchema`
|
||||
|
||||
Validates `{ args: string[], mapping: Record<string, string> }`.
|
||||
|
||||
- `args` — required array of raw CLI arguments
|
||||
- `mapping` — required; record of flag names to config paths
|
||||
|
||||
**Inferred type**: `CliLayerOptionsInput`
|
||||
|
||||
### `FileLayerOptionsSchema`
|
||||
|
||||
Validates `{ content: Record<string, unknown>, watchEvent?: string }`.
|
||||
|
||||
- `content` — required; pre-parsed file content
|
||||
- `watchEvent` — optional event name for watch-mode
|
||||
|
||||
**Inferred type**: `FileLayerOptionsInput`
|
||||
|
||||
### `ConfigStoreOptionsSchema`
|
||||
|
||||
Validates `{ data: Record<string, unknown>, sensitive: string[] }`.
|
||||
|
||||
- `data` — required; the resolved config data
|
||||
- `sensitive` — required; list of dot-notation paths to mask
|
||||
|
||||
**Inferred type**: `ConfigStoreOptionsInput`
|
||||
|
||||
### `CreateConfigOptionsSchema`
|
||||
|
||||
Validates the serialisable subset of `CreateConfigOptions`:
|
||||
`{ layers: Array<{ name: string, priority: number }>, sensitive?: string[], environments?: Record<string, Record<string, unknown>>, environment?: string }`.
|
||||
|
||||
Functions (`validate`, `logger`) and layer `resolve` methods are intentionally
|
||||
excluded — they cannot be serialised to JSON or validated with Zod.
|
||||
|
||||
**Inferred type**: `CreateConfigOptionsInput`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `CreateConfigOptionsSchema` deliberately omits the `validate` callback and
|
||||
`logger` fields from the Zod schema. These are functions and cannot be round-
|
||||
tripped through a serialisation boundary. Callers who build `CreateConfigOptions`
|
||||
programmatically should merge the validated structural fields with their local
|
||||
function references before calling `createConfig`. The `watchBus` fields on
|
||||
`EnvLayerOptionsSchema` and `FileLayerOptionsSchema` are also excluded because
|
||||
`EventBusLike` instances are not serialisable; pass them directly in code.
|
||||
148
packages/foundation/core/config/src/types/schemas.test.ts
Normal file
148
packages/foundation/core/config/src/types/schemas.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
CliLayerOptionsSchema,
|
||||
ConfigStoreOptionsSchema,
|
||||
CreateConfigOptionsSchema,
|
||||
EnvLayerOptionsSchema,
|
||||
FileLayerOptionsSchema,
|
||||
} from "./schemas.js";
|
||||
|
||||
describe("EnvLayerOptionsSchema", () => {
|
||||
it("accepts valid options", { timeout: 5000 }, () => {
|
||||
const input = { mapping: { PORT: "server.port" } };
|
||||
expect(EnvLayerOptionsSchema.parse(input)).toEqual(input);
|
||||
});
|
||||
|
||||
it("accepts optional prefix", { timeout: 5000 }, () => {
|
||||
const input = { prefix: "APP_", mapping: { PORT: "server.port" } };
|
||||
expect(EnvLayerOptionsSchema.parse(input)).toEqual(input);
|
||||
});
|
||||
|
||||
it("accepts optional source", { timeout: 5000 }, () => {
|
||||
const input = {
|
||||
mapping: { PORT: "server.port" },
|
||||
source: { PORT: "3000" },
|
||||
};
|
||||
expect(EnvLayerOptionsSchema.parse(input)).toEqual(input);
|
||||
});
|
||||
|
||||
it("accepts optional watchEvent", { timeout: 5000 }, () => {
|
||||
const input = {
|
||||
mapping: { PORT: "server.port" },
|
||||
watchEvent: "my:event",
|
||||
};
|
||||
expect(EnvLayerOptionsSchema.parse(input)).toEqual(input);
|
||||
});
|
||||
|
||||
it("rejects missing mapping", { timeout: 5000 }, () => {
|
||||
expect(() => EnvLayerOptionsSchema.parse({})).toThrow();
|
||||
});
|
||||
|
||||
it("rejects non-string mapping values", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
EnvLayerOptionsSchema.parse({ mapping: { PORT: 3000 } })
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CliLayerOptionsSchema", () => {
|
||||
it("accepts valid options", { timeout: 5000 }, () => {
|
||||
const input = {
|
||||
args: ["--port", "3000"],
|
||||
mapping: { port: "server.port" },
|
||||
};
|
||||
expect(CliLayerOptionsSchema.parse(input)).toEqual(input);
|
||||
});
|
||||
|
||||
it("accepts empty args array", { timeout: 5000 }, () => {
|
||||
const input = { args: [], mapping: {} };
|
||||
expect(CliLayerOptionsSchema.parse(input)).toEqual(input);
|
||||
});
|
||||
|
||||
it("rejects missing args", { timeout: 5000 }, () => {
|
||||
expect(() => CliLayerOptionsSchema.parse({ mapping: {} })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing mapping", { timeout: 5000 }, () => {
|
||||
expect(() => CliLayerOptionsSchema.parse({ args: [] })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileLayerOptionsSchema", () => {
|
||||
it("accepts valid options", { timeout: 5000 }, () => {
|
||||
const input = { content: { port: 3000 } };
|
||||
expect(FileLayerOptionsSchema.parse(input)).toEqual(input);
|
||||
});
|
||||
|
||||
it("accepts optional watchEvent", { timeout: 5000 }, () => {
|
||||
const input = { content: {}, watchEvent: "config:file:changed" };
|
||||
expect(FileLayerOptionsSchema.parse(input)).toEqual(input);
|
||||
});
|
||||
|
||||
it("rejects missing content", { timeout: 5000 }, () => {
|
||||
expect(() => FileLayerOptionsSchema.parse({})).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConfigStoreOptionsSchema", () => {
|
||||
it("accepts valid options", { timeout: 5000 }, () => {
|
||||
const input = { data: { port: 3000 }, sensitive: ["db.password"] };
|
||||
expect(ConfigStoreOptionsSchema.parse(input)).toEqual(input);
|
||||
});
|
||||
|
||||
it("accepts empty sensitive array", { timeout: 5000 }, () => {
|
||||
const input = { data: {}, sensitive: [] };
|
||||
expect(ConfigStoreOptionsSchema.parse(input)).toEqual(input);
|
||||
});
|
||||
|
||||
it("rejects missing sensitive", { timeout: 5000 }, () => {
|
||||
expect(() => ConfigStoreOptionsSchema.parse({ data: {} })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing data", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
ConfigStoreOptionsSchema.parse({ sensitive: [] })
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CreateConfigOptionsSchema", () => {
|
||||
it("accepts valid options", { timeout: 5000 }, () => {
|
||||
const input = {
|
||||
layers: [{ name: "defaults", priority: 0 }],
|
||||
};
|
||||
expect(CreateConfigOptionsSchema.parse(input)).toEqual(input);
|
||||
});
|
||||
|
||||
it("accepts optional sensitive, environments, environment", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const input = {
|
||||
layers: [],
|
||||
sensitive: ["db.password"],
|
||||
environments: { production: { debug: false } },
|
||||
environment: "production",
|
||||
};
|
||||
expect(CreateConfigOptionsSchema.parse(input)).toEqual(input);
|
||||
});
|
||||
|
||||
it("rejects missing layers", { timeout: 5000 }, () => {
|
||||
expect(() => CreateConfigOptionsSchema.parse({})).toThrow();
|
||||
});
|
||||
|
||||
it("rejects layer without name", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
CreateConfigOptionsSchema.parse({
|
||||
layers: [{ priority: 0 }],
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects layer without priority", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
CreateConfigOptionsSchema.parse({
|
||||
layers: [{ name: "x" }],
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
71
packages/foundation/core/config/src/types/schemas.ts
Normal file
71
packages/foundation/core/config/src/types/schemas.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Zod schema for `EnvLayerOptions`.
|
||||
* Validates the shape of options passed to `envLayer()`.
|
||||
*/
|
||||
export const EnvLayerOptionsSchema = z.object({
|
||||
prefix: z.string().optional(),
|
||||
mapping: z.record(z.string(), z.string()),
|
||||
source: z.record(z.string(), z.string().optional()).optional(),
|
||||
watchEvent: z.string().optional(),
|
||||
});
|
||||
|
||||
export type EnvLayerOptionsInput = z.infer<typeof EnvLayerOptionsSchema>;
|
||||
|
||||
/**
|
||||
* Zod schema for `CliLayerOptions`.
|
||||
* Validates the shape of options passed to `cliLayer()`.
|
||||
*/
|
||||
export const CliLayerOptionsSchema = z.object({
|
||||
args: z.array(z.string()),
|
||||
mapping: z.record(z.string(), z.string()),
|
||||
});
|
||||
|
||||
export type CliLayerOptionsInput = z.infer<typeof CliLayerOptionsSchema>;
|
||||
|
||||
/**
|
||||
* Zod schema for `FileLayerOptions`.
|
||||
* Validates the shape of options passed to `fileLayer()`.
|
||||
*/
|
||||
export const FileLayerOptionsSchema = z.object({
|
||||
content: z.record(z.string(), z.unknown()),
|
||||
watchEvent: z.string().optional(),
|
||||
});
|
||||
|
||||
export type FileLayerOptionsInput = z.infer<typeof FileLayerOptionsSchema>;
|
||||
|
||||
/**
|
||||
* Zod schema for `ConfigStoreOptions`.
|
||||
* Validates options passed to `createConfigStore()`.
|
||||
*/
|
||||
export const ConfigStoreOptionsSchema = z.object({
|
||||
data: z.record(z.string(), z.unknown()),
|
||||
sensitive: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type ConfigStoreOptionsInput = z.infer<typeof ConfigStoreOptionsSchema>;
|
||||
|
||||
/**
|
||||
* Zod schema for the shape-checkable part of `CreateConfigOptions`.
|
||||
* The `validate` function field and `logger` are intentionally excluded from
|
||||
* Zod validation (functions are not representable in Zod schemas). The layer
|
||||
* `resolve` function is likewise not validated — only the structural fields are.
|
||||
*/
|
||||
export const CreateConfigOptionsSchema = z.object({
|
||||
layers: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
priority: z.number(),
|
||||
})
|
||||
),
|
||||
sensitive: z.array(z.string()).optional(),
|
||||
environments: z
|
||||
.record(z.string(), z.record(z.string(), z.unknown()))
|
||||
.optional(),
|
||||
environment: z.string().optional(),
|
||||
});
|
||||
|
||||
export type CreateConfigOptionsInput = z.infer<
|
||||
typeof CreateConfigOptionsSchema
|
||||
>;
|
||||
115
packages/foundation/core/config/src/utils/mask.md
Normal file
115
packages/foundation/core/config/src/utils/mask.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# mask — Path utilities and sensitive-key masking for `@suaveplan/config`
|
||||
|
||||
## Purpose
|
||||
|
||||
This module provides low-level dot-notation path traversal utilities
|
||||
(`getByPath`, `setByPath`, `hasByPath`) and the `maskSensitive` function that
|
||||
uses them to redact sensitive keys from config snapshots before they are logged
|
||||
or transmitted. The path utilities are also used internally by `envLayer` and
|
||||
`cliLayer` to write coerced values into the result object without requiring
|
||||
the caller to construct deeply nested structures manually.
|
||||
|
||||
## Features
|
||||
|
||||
- `getByPath` — safely reads a value at any dot-notation depth; returns `undefined`
|
||||
on any missing or non-object intermediate segment
|
||||
- `hasByPath` — `true` if `getByPath` resolves to a non-`undefined` value
|
||||
- `setByPath` — writes a value at a dot-notation path, creating intermediate objects
|
||||
as needed; mutates the target and returns it
|
||||
- `maskSensitive` — `structuredClone` + `setByPath` each sensitive path with
|
||||
`MASK_STRING` (`"****"`) or a custom mask string
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { getByPath, setByPath, maskSensitive } from '@suaveplan/config/utils/mask';
|
||||
|
||||
const cfg = { db: { host: 'localhost', password: 'secret' } };
|
||||
|
||||
getByPath(cfg, 'db.host'); // 'localhost'
|
||||
getByPath(cfg, 'db.missing'); // undefined
|
||||
|
||||
const masked = maskSensitive(cfg, ['db.password']);
|
||||
// { db: { host: 'localhost', password: '****' } }
|
||||
// cfg is unchanged
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { setByPath, getByPath, maskSensitive } from '@suaveplan/config/utils/mask';
|
||||
|
||||
// Build a config object from flat CLI flags via dot paths
|
||||
const result = {};
|
||||
setByPath(result, 'server.port', 4000);
|
||||
setByPath(result, 'server.tls.enabled', true);
|
||||
setByPath(result, 'db.password', 'hunter2');
|
||||
|
||||
// result = { server: { port: 4000, tls: { enabled: true } }, db: { password: 'hunter2' } }
|
||||
|
||||
// Multi-path masking with custom mask string
|
||||
const safe = maskSensitive(result, ['db.password', 'server.tls.enabled'], '[REDACTED]');
|
||||
// { server: { port: 4000, tls: { enabled: '[REDACTED]' } }, db: { password: '[REDACTED]' } }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `getByPath(obj: ConfigData, path: string): unknown`
|
||||
|
||||
Traverses `obj` by splitting `path` on `"."` and descending at each segment.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `obj` | `ConfigData` | Root object to traverse |
|
||||
| `path` | `string` | Dot-notation path, e.g. `"db.host"` |
|
||||
|
||||
**Returns** the resolved value or `undefined` if any segment is missing or an
|
||||
intermediate value is not a plain object (e.g. is `null`, an array, or a scalar).
|
||||
|
||||
### `hasByPath(obj: ConfigData, path: string): boolean`
|
||||
|
||||
Returns `getByPath(obj, path) !== undefined`.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `obj` | `ConfigData` | Root object |
|
||||
| `path` | `string` | Dot-notation path |
|
||||
|
||||
### `setByPath(obj: ConfigData, path: string, value: unknown): ConfigData`
|
||||
|
||||
Writes `value` at the specified dot-notation path, creating intermediate plain
|
||||
objects as needed. Mutates `obj` in place.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `obj` | `ConfigData` | Root object to mutate |
|
||||
| `path` | `string` | Dot-notation path |
|
||||
| `value` | `unknown` | Value to write |
|
||||
|
||||
**Returns** `obj` (same reference, mutated).
|
||||
|
||||
**Side effect:** Any intermediate segment that is not a plain object (null,
|
||||
array, scalar) is replaced with a new `{}`.
|
||||
|
||||
### `maskSensitive(data: ConfigData, sensitive: string[], mask?: string): ConfigData`
|
||||
|
||||
Returns a deep clone of `data` with every path in `sensitive` replaced by
|
||||
`mask`. Does not modify the original `data`.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `data` | `ConfigData` | Source config object |
|
||||
| `sensitive` | `string[]` | Dot-notation paths to redact |
|
||||
| `mask` | `string` | Replacement string; defaults to `"****"` |
|
||||
|
||||
**Returns** a new `ConfigData` with sensitive values replaced. Paths that do
|
||||
not exist in `data` are silently skipped.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`structuredClone` is used in `maskSensitive` to produce a deep copy before
|
||||
mutation. This is safe for all JSON-serialisable values (the only kind that
|
||||
should appear in config data). The function is idempotent: applying it twice
|
||||
with the same arguments yields the same result as applying it once, because the
|
||||
mask string `"****"` is itself a valid value that survives the next clone-and-
|
||||
replace cycle unchanged.
|
||||
180
packages/foundation/core/config/src/utils/mask.test.ts
Normal file
180
packages/foundation/core/config/src/utils/mask.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { MASK_STRING } from "../constants/index.js";
|
||||
import { getByPath, hasByPath, maskSensitive, setByPath } from "./mask.js";
|
||||
|
||||
describe("getByPath", () => {
|
||||
it("returns top-level value", { timeout: 5000 }, () => {
|
||||
expect(getByPath({ port: 3000 }, "port")).toBe(3000);
|
||||
});
|
||||
|
||||
it("returns nested value", { timeout: 5000 }, () => {
|
||||
expect(getByPath({ db: { host: "localhost" } }, "db.host")).toBe(
|
||||
"localhost"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns deeply nested value", { timeout: 5000 }, () => {
|
||||
const obj = { a: { b: { c: { d: 42 } } } };
|
||||
expect(getByPath(obj, "a.b.c.d")).toBe(42);
|
||||
});
|
||||
|
||||
it("returns undefined for missing top-level key", { timeout: 5000 }, () => {
|
||||
expect(getByPath({}, "missing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for missing nested key", { timeout: 5000 }, () => {
|
||||
expect(getByPath({ db: { host: "x" } }, "db.port")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when intermediate is null", { timeout: 5000 }, () => {
|
||||
expect(getByPath({ db: null } as never, "db.host")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when intermediate is an array", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
getByPath({ items: [1, 2] } as never, "items.0")
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when intermediate is a scalar", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(getByPath({ db: "string" } as never, "db.host")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns falsy value 0", { timeout: 5000 }, () => {
|
||||
expect(getByPath({ count: 0 }, "count")).toBe(0);
|
||||
});
|
||||
|
||||
it("returns false value", { timeout: 5000 }, () => {
|
||||
expect(getByPath({ flag: false }, "flag")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasByPath", () => {
|
||||
it("returns true for existing path", { timeout: 5000 }, () => {
|
||||
expect(hasByPath({ port: 3000 }, "port")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for missing path", { timeout: 5000 }, () => {
|
||||
expect(hasByPath({}, "missing")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when value is undefined", { timeout: 5000 }, () => {
|
||||
const obj = { key: undefined } as Record<string, unknown>;
|
||||
expect(hasByPath(obj, "key")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for nested path", { timeout: 5000 }, () => {
|
||||
expect(hasByPath({ db: { port: 5432 } }, "db.port")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setByPath", () => {
|
||||
it("sets a top-level value", { timeout: 5000 }, () => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
setByPath(obj, "port", 3000);
|
||||
expect(obj.port).toBe(3000);
|
||||
});
|
||||
|
||||
it("sets a nested value, creating intermediates", { timeout: 5000 }, () => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
setByPath(obj, "db.host", "localhost");
|
||||
expect((obj.db as Record<string, unknown>).host).toBe("localhost");
|
||||
});
|
||||
|
||||
it("sets a deeply nested value", { timeout: 5000 }, () => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
setByPath(obj, "a.b.c.d", 42);
|
||||
expect(getByPath(obj, "a.b.c.d")).toBe(42);
|
||||
});
|
||||
|
||||
it("returns the same object reference", { timeout: 5000 }, () => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
const result = setByPath(obj, "port", 3000);
|
||||
expect(result).toBe(obj);
|
||||
});
|
||||
|
||||
it("overwrites an existing value", { timeout: 5000 }, () => {
|
||||
const obj = { port: 3000 };
|
||||
setByPath(obj, "port", 9999);
|
||||
expect(obj.port).toBe(9999);
|
||||
});
|
||||
|
||||
it("replaces intermediate scalar with object", { timeout: 5000 }, () => {
|
||||
const obj = { db: "string" } as Record<string, unknown>;
|
||||
setByPath(obj, "db.host", "localhost");
|
||||
expect((obj.db as Record<string, unknown>).host).toBe("localhost");
|
||||
});
|
||||
|
||||
it("replaces intermediate null with object", { timeout: 5000 }, () => {
|
||||
const obj = { db: null } as Record<string, unknown>;
|
||||
setByPath(obj, "db.host", "localhost");
|
||||
expect((obj.db as Record<string, unknown>).host).toBe("localhost");
|
||||
});
|
||||
|
||||
it("replaces intermediate array with object", { timeout: 5000 }, () => {
|
||||
const obj = { db: [1, 2] } as Record<string, unknown>;
|
||||
setByPath(obj, "db.host", "localhost");
|
||||
expect((obj.db as Record<string, unknown>).host).toBe("localhost");
|
||||
});
|
||||
});
|
||||
|
||||
describe("maskSensitive", () => {
|
||||
it("replaces a single sensitive path with MASK_STRING", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const data = { db: { password: "secret" } };
|
||||
const result = maskSensitive(data, ["db.password"]);
|
||||
expect(getByPath(result, "db.password")).toBe(MASK_STRING);
|
||||
});
|
||||
|
||||
it("does not mutate original data", { timeout: 5000 }, () => {
|
||||
const data = { db: { password: "secret" } };
|
||||
maskSensitive(data, ["db.password"]);
|
||||
expect(data.db.password).toBe("secret");
|
||||
});
|
||||
|
||||
it("masks multiple sensitive paths", { timeout: 5000 }, () => {
|
||||
const data = { db: { password: "secret" }, api: { key: "key123" } };
|
||||
const result = maskSensitive(data, ["db.password", "api.key"]);
|
||||
expect(getByPath(result, "db.password")).toBe(MASK_STRING);
|
||||
expect(getByPath(result, "api.key")).toBe(MASK_STRING);
|
||||
});
|
||||
|
||||
it("ignores paths that do not exist", { timeout: 5000 }, () => {
|
||||
const data = { port: 3000 };
|
||||
const result = maskSensitive(data, ["missing.path"]);
|
||||
expect(result).toEqual({ port: 3000 });
|
||||
});
|
||||
|
||||
it("returns clone when sensitive list is empty", { timeout: 5000 }, () => {
|
||||
const data = { port: 3000 };
|
||||
const result = maskSensitive(data, []);
|
||||
expect(result).toEqual(data);
|
||||
expect(result).not.toBe(data);
|
||||
});
|
||||
|
||||
it("accepts a custom mask string", { timeout: 5000 }, () => {
|
||||
const data = { secret: "value" };
|
||||
const result = maskSensitive(data, ["secret"], "[REDACTED]");
|
||||
expect(result.secret).toBe("[REDACTED]");
|
||||
});
|
||||
|
||||
it("is idempotent — applying twice equals applying once", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const data = { db: { password: "secret" } };
|
||||
const once = maskSensitive(data, ["db.password"]);
|
||||
const twice = maskSensitive(once, ["db.password"]);
|
||||
expect(twice).toEqual(once);
|
||||
});
|
||||
|
||||
it("masks top-level sensitive path", { timeout: 5000 }, () => {
|
||||
const data = { token: "abc123" };
|
||||
const result = maskSensitive(data, ["token"]);
|
||||
expect(result.token).toBe(MASK_STRING);
|
||||
});
|
||||
});
|
||||
81
packages/foundation/core/config/src/utils/mask.ts
Normal file
81
packages/foundation/core/config/src/utils/mask.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { MASK_STRING } from "../constants/index.js";
|
||||
import type { ConfigData } from "../types/common.js";
|
||||
|
||||
/**
|
||||
* Retrieve a value from `obj` using a dot-notation path string.
|
||||
*
|
||||
* Returns `undefined` if any segment along the path is missing or if an
|
||||
* intermediate value is not an object.
|
||||
*/
|
||||
export function getByPath(obj: ConfigData, path: string): unknown {
|
||||
const segments = path.split(".");
|
||||
let current: unknown = obj;
|
||||
for (const segment of segments) {
|
||||
if (
|
||||
current === null ||
|
||||
current === undefined ||
|
||||
typeof current !== "object" ||
|
||||
Array.isArray(current)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[segment];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if `getByPath(obj, path)` resolves to a non-`undefined` value.
|
||||
*/
|
||||
export function hasByPath(obj: ConfigData, path: string): boolean {
|
||||
return getByPath(obj, path) !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a value at a dot-notation `path` within `obj`, creating intermediate
|
||||
* plain objects as needed. Mutates `obj` in place and returns it.
|
||||
*/
|
||||
export function setByPath(
|
||||
obj: ConfigData,
|
||||
path: string,
|
||||
value: unknown
|
||||
): ConfigData {
|
||||
const segments = path.split(".");
|
||||
let current: Record<string, unknown> = obj;
|
||||
|
||||
for (let i = 0; i < segments.length - 1; i++) {
|
||||
const segment = segments[i] as string;
|
||||
if (
|
||||
typeof current[segment] !== "object" ||
|
||||
current[segment] === null ||
|
||||
Array.isArray(current[segment])
|
||||
) {
|
||||
current[segment] = {};
|
||||
}
|
||||
current = current[segment] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const lastSegment = segments[segments.length - 1] as string;
|
||||
current[lastSegment] = value;
|
||||
return obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a deep clone of `data` with every path listed in `sensitive`
|
||||
* replaced by `mask` (defaults to `MASK_STRING` = `"****"`).
|
||||
*
|
||||
* Does not modify the original `data` object.
|
||||
*/
|
||||
export function maskSensitive(
|
||||
data: ConfigData,
|
||||
sensitive: string[],
|
||||
mask: string = MASK_STRING
|
||||
): ConfigData {
|
||||
const cloned = structuredClone(data) as ConfigData;
|
||||
for (const path of sensitive) {
|
||||
if (hasByPath(cloned, path)) {
|
||||
setByPath(cloned, path, mask);
|
||||
}
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
90
packages/foundation/core/config/src/utils/merge.md
Normal file
90
packages/foundation/core/config/src/utils/merge.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# merge — Deep-merge and layer-folding utilities for `@suaveplan/config`
|
||||
|
||||
## Purpose
|
||||
|
||||
This module provides two functions that form the merge backbone of the config
|
||||
pipeline: `deepMerge` performs a structural recursive merge of two plain-object
|
||||
trees, and `mergeLayers` folds an ordered array of named config layers using
|
||||
`deepMerge`. These utilities are deliberately small and self-contained — they
|
||||
carry no external runtime dependencies beyond the OTel API no-op SDK, and are
|
||||
therefore safe to import in any environment.
|
||||
|
||||
## Features
|
||||
|
||||
- `deepMerge` — recursively merges two plain objects; arrays replaced, `undefined`
|
||||
override values skipped, non-object scalars overwrite
|
||||
- `mergeLayers` — folds a pre-sorted list of `{ name, data }` entries using
|
||||
`deepMerge`; records merge duration to `config.merge_duration_ms` OTel histogram
|
||||
- No mutation of input objects — both functions return new objects
|
||||
- TypeScript generics preserve the inferred type of the base object
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { deepMerge } from '@suaveplan/config/utils/merge';
|
||||
|
||||
const base = { port: 3000, db: { host: 'localhost', port: 5432 } };
|
||||
const override = { db: { host: 'prod.db.example.com' } };
|
||||
|
||||
const merged = deepMerge(base, override);
|
||||
// { port: 3000, db: { host: 'prod.db.example.com', port: 5432 } }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { mergeLayers } from '@suaveplan/config/utils/merge';
|
||||
|
||||
const layers = [
|
||||
{ name: 'defaults', data: { port: 3000, debug: true, db: { pool: 5 } } },
|
||||
{ name: 'file', data: { port: 4000, db: { pool: 10 } } },
|
||||
{ name: 'env', data: { debug: false } },
|
||||
];
|
||||
|
||||
const result = mergeLayers(layers);
|
||||
// { port: 4000, debug: false, db: { pool: 10 } }
|
||||
// Note: layers must be pre-sorted by priority — mergeLayers does not sort.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `deepMerge<T extends ConfigData>(base: T, override: Partial<T>): T`
|
||||
|
||||
Recursively merges `override` into `base` and returns a new object.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `base` | `T` | The base object to merge into |
|
||||
| `override` | `Partial<T>` | Values to apply on top of `base` |
|
||||
|
||||
**Returns** a new `T`-typed object; neither argument is mutated.
|
||||
|
||||
**Merge rules:**
|
||||
|
||||
- Plain objects (non-array, non-null, prototype === `Object.prototype`) are merged recursively.
|
||||
- Arrays in `override` replace the corresponding value in `base`.
|
||||
- Keys with `undefined` values in `override` are skipped (base value preserved).
|
||||
- All other scalar values in `override` overwrite the base value.
|
||||
|
||||
### `mergeLayers(layers: Array<{ name: string; data: ConfigData }>): ConfigData`
|
||||
|
||||
Folds `layers` left-to-right using `deepMerge`.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `layers` | `Array<{ name: string; data: ConfigData }>` | Pre-sorted layer descriptors |
|
||||
|
||||
**Returns** the merged `ConfigData`. Layers must be sorted by priority before
|
||||
calling this function; `mergeLayers` itself does not sort.
|
||||
|
||||
**OTel:** Records the total duration to the `config.merge_duration_ms` histogram
|
||||
with a `config.layer_count` attribute. This is automatically a no-op when no OTel
|
||||
SDK is installed.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`isPlainObject` guards against merging class instances, `Date` objects, `Map`,
|
||||
`Set`, and other non-plain-object values — those are replaced rather than merged.
|
||||
This is intentional: config values should be JSON-serialisable plain data. The
|
||||
`deepMerge` function does not handle circular references; config objects are
|
||||
assumed to be acyclic (a reasonable constraint for any serialisable config tree).
|
||||
131
packages/foundation/core/config/src/utils/merge.test.ts
Normal file
131
packages/foundation/core/config/src/utils/merge.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { deepMerge, mergeLayers } from "./merge.js";
|
||||
|
||||
describe("deepMerge", () => {
|
||||
it("returns base values when override is empty", { timeout: 5000 }, () => {
|
||||
const base = { port: 3000, debug: true };
|
||||
expect(deepMerge(base, {})).toEqual({ port: 3000, debug: true });
|
||||
});
|
||||
|
||||
it("overwrites primitive values", { timeout: 5000 }, () => {
|
||||
const result = deepMerge({ port: 3000 }, { port: 4000 });
|
||||
expect(result).toEqual({ port: 4000 });
|
||||
});
|
||||
|
||||
it("adds new keys from override", { timeout: 5000 }, () => {
|
||||
const result = deepMerge({ port: 3000 }, { debug: true } as never);
|
||||
expect(result).toEqual({ port: 3000, debug: true });
|
||||
});
|
||||
|
||||
it("skips undefined override values (keeps base)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = deepMerge({ port: 3000 }, { port: undefined });
|
||||
expect(result).toEqual({ port: 3000 });
|
||||
});
|
||||
|
||||
it("replaces arrays (does not merge element-wise)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = deepMerge({ items: [1, 2, 3] }, {
|
||||
items: [4, 5],
|
||||
} as never);
|
||||
expect(result.items).toEqual([4, 5]);
|
||||
});
|
||||
|
||||
it("recursively merges nested plain objects", { timeout: 5000 }, () => {
|
||||
const base = { db: { host: "localhost", port: 5432 } };
|
||||
const override = { db: { host: "prod" } };
|
||||
const result = deepMerge(base, override);
|
||||
expect(result).toEqual({ db: { host: "prod", port: 5432 } });
|
||||
});
|
||||
|
||||
it("does not mutate base", { timeout: 5000 }, () => {
|
||||
const base = { port: 3000 };
|
||||
deepMerge(base, { port: 9999 });
|
||||
expect(base.port).toBe(3000);
|
||||
});
|
||||
|
||||
it("does not mutate override", { timeout: 5000 }, () => {
|
||||
const override = { port: 9999 };
|
||||
deepMerge({ port: 3000 }, override);
|
||||
expect(override.port).toBe(9999);
|
||||
});
|
||||
|
||||
it("handles deeply nested structures", { timeout: 5000 }, () => {
|
||||
const result = deepMerge(
|
||||
{ a: { b: { c: 1, d: 2 } } },
|
||||
{ a: { b: { c: 99 } } }
|
||||
);
|
||||
expect(result).toEqual({ a: { b: { c: 99, d: 2 } } });
|
||||
});
|
||||
|
||||
it("overrides nested object with scalar", { timeout: 5000 }, () => {
|
||||
const result = deepMerge({ db: { host: "localhost" } }, {
|
||||
db: "disabled",
|
||||
} as never);
|
||||
expect(result.db).toBe("disabled");
|
||||
});
|
||||
|
||||
it("overrides scalar with nested object", { timeout: 5000 }, () => {
|
||||
const result = deepMerge({ db: "disabled" }, {
|
||||
db: { host: "localhost" },
|
||||
} as never);
|
||||
expect(result.db).toEqual({ host: "localhost" });
|
||||
});
|
||||
|
||||
it("handles null override value replacing a base value", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = deepMerge({ flag: true }, { flag: null } as never);
|
||||
expect(result.flag).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeLayers", () => {
|
||||
it("returns empty object for empty layers", { timeout: 5000 }, () => {
|
||||
expect(mergeLayers([])).toEqual({});
|
||||
});
|
||||
|
||||
it("returns single layer data unchanged", { timeout: 5000 }, () => {
|
||||
const result = mergeLayers([{ name: "a", data: { port: 3000 } }]);
|
||||
expect(result).toEqual({ port: 3000 });
|
||||
});
|
||||
|
||||
it("merges two layers with later winning", { timeout: 5000 }, () => {
|
||||
const layers = [
|
||||
{ name: "defaults", data: { port: 3000, debug: false } },
|
||||
{ name: "env", data: { port: 4000 } },
|
||||
];
|
||||
expect(mergeLayers(layers)).toEqual({ port: 4000, debug: false });
|
||||
});
|
||||
|
||||
it("merges N layers in order", { timeout: 5000 }, () => {
|
||||
const layers = [
|
||||
{ name: "a", data: { x: 1, y: 1 } },
|
||||
{ name: "b", data: { y: 2, z: 2 } },
|
||||
{ name: "c", data: { z: 3 } },
|
||||
];
|
||||
expect(mergeLayers(layers)).toEqual({ x: 1, y: 2, z: 3 });
|
||||
});
|
||||
|
||||
it("skips undefined values during multi-layer fold", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const layers = [
|
||||
{ name: "a", data: { port: 3000 } },
|
||||
{ name: "b", data: { port: undefined } as Record<string, unknown> },
|
||||
];
|
||||
expect(mergeLayers(layers)).toEqual({ port: 3000 });
|
||||
});
|
||||
|
||||
it("deep-merges nested objects across layers", { timeout: 5000 }, () => {
|
||||
const layers = [
|
||||
{ name: "defaults", data: { db: { host: "localhost", pool: 5 } } },
|
||||
{ name: "env", data: { db: { pool: 20 } } },
|
||||
];
|
||||
expect(mergeLayers(layers)).toEqual({
|
||||
db: { host: "localhost", pool: 20 },
|
||||
});
|
||||
});
|
||||
});
|
||||
84
packages/foundation/core/config/src/utils/merge.ts
Normal file
84
packages/foundation/core/config/src/utils/merge.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import type { ConfigData } from "../types/common.js";
|
||||
|
||||
const meter = metrics.getMeter("config");
|
||||
const mergeDurationHistogram = meter.createHistogram(
|
||||
"config.merge_duration_ms",
|
||||
{
|
||||
description: "Duration of mergeLayers calls in milliseconds",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns `true` if `value` is a plain (non-Array, non-null) object.
|
||||
*/
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.getPrototypeOf(value) === Object.prototype
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-merge `override` into `base`, returning a new object.
|
||||
*
|
||||
* Rules:
|
||||
* - Plain objects are merged recursively.
|
||||
* - Arrays in `override` replace the corresponding array in `base`.
|
||||
* - `undefined` values in `override` are skipped (base value is kept).
|
||||
* - All other types overwrite the base value.
|
||||
*
|
||||
* Neither `base` nor `override` is mutated.
|
||||
*/
|
||||
export function deepMerge<T extends ConfigData>(
|
||||
base: T,
|
||||
override: Partial<T>
|
||||
): T {
|
||||
const result: Record<string, unknown> = { ...base };
|
||||
|
||||
for (const key of Object.keys(override)) {
|
||||
const overrideVal = (override as Record<string, unknown>)[key];
|
||||
if (overrideVal === undefined) {
|
||||
// Skip — keep base value.
|
||||
continue;
|
||||
}
|
||||
const baseVal = (base as Record<string, unknown>)[key];
|
||||
if (isPlainObject(overrideVal) && isPlainObject(baseVal)) {
|
||||
result[key] = deepMerge(
|
||||
baseVal as ConfigData,
|
||||
overrideVal as ConfigData
|
||||
);
|
||||
} else {
|
||||
result[key] = overrideVal;
|
||||
}
|
||||
}
|
||||
|
||||
return result as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge an array of named layers into a single `ConfigData` object.
|
||||
*
|
||||
* Layers are consumed in the order supplied (i.e. callers must pre-sort by
|
||||
* priority). Each layer is folded left-to-right with `deepMerge`, so later
|
||||
* layers win.
|
||||
*
|
||||
* OTel: records the merge duration to `config.merge_duration_ms`.
|
||||
*/
|
||||
export function mergeLayers(
|
||||
layers: Array<{ name: string; data: ConfigData }>
|
||||
): ConfigData {
|
||||
const start = Date.now();
|
||||
let result: ConfigData = {};
|
||||
for (const layer of layers) {
|
||||
result = deepMerge(result, layer.data);
|
||||
}
|
||||
const duration = Date.now() - start;
|
||||
mergeDurationHistogram.record(duration, {
|
||||
"config.layer_count": layers.length,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
154
packages/foundation/core/config/tests/integration.test.ts
Normal file
154
packages/foundation/core/config/tests/integration.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createConfig } from "../src/core/config-builder/config-builder.js";
|
||||
import { ConfigError } from "../src/error-codes/error-codes.js";
|
||||
import { cliLayer } from "../src/layers/cli-layer/cli-layer.js";
|
||||
import { defaultsLayer } from "../src/layers/defaults-layer/defaults-layer.js";
|
||||
import { envLayer } from "../src/layers/env-layer/env-layer.js";
|
||||
import { fileLayer } from "../src/layers/file-layer/file-layer.js";
|
||||
import { objectLayer } from "../src/layers/object-layer/object-layer.js";
|
||||
|
||||
describe("createConfig integration", () => {
|
||||
it("full 5-layer pipeline produces correct merged config", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const store = createConfig({
|
||||
layers: [
|
||||
defaultsLayer({
|
||||
server: { port: 3000, host: "0.0.0.0" },
|
||||
db: { pool: 5, timeout: 30_000, password: "defaultpw" },
|
||||
log: { level: "info", pretty: true },
|
||||
}),
|
||||
fileLayer({
|
||||
content: {
|
||||
server: { port: 3100 },
|
||||
db: { pool: 10 },
|
||||
},
|
||||
}),
|
||||
objectLayer({
|
||||
server: { port: 3200 },
|
||||
}),
|
||||
envLayer({
|
||||
mapping: { PORT: "server.port", DB_POOL: "db.pool" },
|
||||
source: { PORT: "3300", DB_POOL: "15" },
|
||||
}),
|
||||
cliLayer({
|
||||
args: ["--port=3400", "--debug"],
|
||||
mapping: { port: "server.port", debug: "log.debug" },
|
||||
}),
|
||||
],
|
||||
sensitive: ["db.password"],
|
||||
});
|
||||
|
||||
// CLI (priority 40) wins for port
|
||||
expect(store.get("server.port")).toBe(3400);
|
||||
// ENV (priority 30) wins for db.pool over file/object/defaults
|
||||
expect(store.get("db.pool")).toBe(15);
|
||||
// CLI added debug flag
|
||||
expect(store.get("log.debug")).toBe(true);
|
||||
// Defaults survive for host
|
||||
expect(store.get("server.host")).toBe("0.0.0.0");
|
||||
// File layer contributed timeout (no higher layer overrode it)
|
||||
expect(store.get("db.timeout")).toBe(30_000);
|
||||
|
||||
// sensitive masking
|
||||
const masked = store.toMasked() as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
expect(masked.db.password).toBe("****");
|
||||
expect(store.get("db.password")).toBe("defaultpw");
|
||||
});
|
||||
|
||||
it("has() returns true/false correctly", { timeout: 5000 }, () => {
|
||||
const store = createConfig({
|
||||
layers: [defaultsLayer({ port: 3000 })],
|
||||
});
|
||||
expect(store.has("port")).toBe(true);
|
||||
expect(store.has("missing")).toBe(false);
|
||||
});
|
||||
|
||||
it("toJSON returns full frozen config", { timeout: 5000 }, () => {
|
||||
const store = createConfig({
|
||||
layers: [defaultsLayer({ port: 3000 })],
|
||||
});
|
||||
const json = store.toJSON();
|
||||
expect(json).toEqual({ port: 3000 });
|
||||
expect(() => {
|
||||
(json as Record<string, unknown>).port = 9999;
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("reload with validation throws when validator rejects", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
let pass = true;
|
||||
const dynamic = {
|
||||
name: "dynamic",
|
||||
priority: 0,
|
||||
resolve: () => ({ port: pass ? 3000 : "bad" }),
|
||||
};
|
||||
const store = createConfig({
|
||||
layers: [dynamic],
|
||||
validate: (d) => {
|
||||
if (typeof (d as Record<string, unknown>).port !== "number") {
|
||||
throw new Error("port must be number");
|
||||
}
|
||||
return d;
|
||||
},
|
||||
});
|
||||
|
||||
pass = false;
|
||||
await expect(store.reload()).rejects.toBeInstanceOf(ConfigError);
|
||||
});
|
||||
|
||||
it("watch-mode: fileLayer with EventBus triggers reload", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
type BusMap = { "config:file:changed": Record<string, never> };
|
||||
const bus = new EventBus<BusMap>();
|
||||
|
||||
let fileContent = { port: 3000 };
|
||||
const fLayer = fileLayer({
|
||||
get content() {
|
||||
return fileContent;
|
||||
},
|
||||
watchBus: bus,
|
||||
});
|
||||
|
||||
const store = createConfig({
|
||||
layers: [fLayer],
|
||||
});
|
||||
|
||||
expect(store.get("port")).toBe(3000);
|
||||
|
||||
// Simulate file change
|
||||
fileContent = { port: 4000 };
|
||||
await bus.emit("config:file:changed", {});
|
||||
|
||||
// Give the async reload a tick to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(store.get("port")).toBe(4000);
|
||||
});
|
||||
|
||||
it("environment override applies on reload", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
let counter = 0;
|
||||
const store = createConfig({
|
||||
layers: [
|
||||
{
|
||||
name: "dynamic",
|
||||
priority: 0,
|
||||
resolve: () => ({ count: ++counter, debug: false }),
|
||||
},
|
||||
],
|
||||
environments: { test: { debug: true } },
|
||||
environment: "test",
|
||||
});
|
||||
|
||||
expect(store.get("debug")).toBe(true);
|
||||
await store.reload();
|
||||
expect(store.get("debug")).toBe(true);
|
||||
});
|
||||
});
|
||||
240
packages/foundation/core/config/tests/property.test.ts
Normal file
240
packages/foundation/core/config/tests/property.test.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import { parseArgs } from "../src/layers/cli-layer/cli-layer.js";
|
||||
import { coerceValue } from "../src/layers/env-layer/env-layer.js";
|
||||
import type { ConfigData } from "../src/types/common.js";
|
||||
import { getByPath, maskSensitive, setByPath } from "../src/utils/mask.js";
|
||||
import { deepMerge, mergeLayers } from "../src/utils/merge.js";
|
||||
|
||||
/**
|
||||
* Arbitrarily generates a plain JSON-serialisable config object with no
|
||||
* undefined values (required for associativity property).
|
||||
*/
|
||||
const plainObjectArb = fc.dictionary(
|
||||
fc
|
||||
.string({ minLength: 1, maxLength: 10 })
|
||||
.filter((s) => /^[a-z]+$/i.test(s)),
|
||||
fc.oneof(
|
||||
fc.integer(),
|
||||
fc.string({ maxLength: 20 }),
|
||||
fc.boolean(),
|
||||
fc.constant(null)
|
||||
),
|
||||
{ minKeys: 0, maxKeys: 5 }
|
||||
) as fc.Arbitrary<ConfigData>;
|
||||
|
||||
/**
|
||||
* Valid dot-path: 1–4 alphanum segments.
|
||||
*/
|
||||
const validPathArb = fc
|
||||
.array(
|
||||
fc
|
||||
.string({ minLength: 1, maxLength: 8 })
|
||||
.filter((s) => /^[a-zA-Z0-9]+$/.test(s)),
|
||||
{ minLength: 1, maxLength: 4 }
|
||||
)
|
||||
.map((parts) => parts.join("."));
|
||||
|
||||
describe("deepMerge associativity (no undefined values)", () => {
|
||||
it("merge(merge(a,b),c) === merge(a,merge(b,c))", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
plainObjectArb,
|
||||
plainObjectArb,
|
||||
plainObjectArb,
|
||||
(a, b, c) => {
|
||||
const left = deepMerge(deepMerge(a, b), c);
|
||||
const right = deepMerge(a, deepMerge(b, c));
|
||||
expect(left).toEqual(right);
|
||||
}
|
||||
),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeLayers priority ordering", () => {
|
||||
it("result equals manually sorted-then-merged layers", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
const layerArb = fc
|
||||
.record({
|
||||
priority: fc.integer({ min: 0, max: 100 }),
|
||||
data: plainObjectArb,
|
||||
})
|
||||
.map((l) => ({ name: `layer-${l.priority}`, ...l }));
|
||||
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(layerArb, { minLength: 2, maxLength: 8 }),
|
||||
(layers) => {
|
||||
const resultFromMergeLayers = mergeLayers(
|
||||
[...layers].sort((a, b) => a.priority - b.priority)
|
||||
);
|
||||
const sortedAndFolded = layers
|
||||
.slice()
|
||||
.sort((a, b) => a.priority - b.priority)
|
||||
.reduce<ConfigData>(
|
||||
(acc, l) => deepMerge(acc, l.data),
|
||||
{}
|
||||
);
|
||||
expect(resultFromMergeLayers).toEqual(sortedAndFolded);
|
||||
}
|
||||
),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setByPath / getByPath round-trip", () => {
|
||||
it("getByPath(setByPath({}, path, v), path) === v", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
const leafArb = fc.oneof(
|
||||
fc.integer(),
|
||||
fc.string({ maxLength: 20 }),
|
||||
fc.boolean(),
|
||||
fc.constant(null)
|
||||
);
|
||||
|
||||
fc.assert(
|
||||
fc.property(validPathArb, leafArb, (path, value) => {
|
||||
const obj: ConfigData = {};
|
||||
setByPath(obj, path, value);
|
||||
expect(getByPath(obj, path)).toStrictEqual(value);
|
||||
}),
|
||||
{ numRuns: 300 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("maskSensitive idempotency", () => {
|
||||
it("applying mask twice equals applying once", { timeout: 30000 }, () => {
|
||||
const pathsArb = fc.array(validPathArb, { minLength: 0, maxLength: 4 });
|
||||
|
||||
fc.assert(
|
||||
fc.property(plainObjectArb, pathsArb, (data, paths) => {
|
||||
const once = maskSensitive(data, paths);
|
||||
const twice = maskSensitive(once, paths);
|
||||
expect(twice).toEqual(once);
|
||||
}),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("coerceValue stability", () => {
|
||||
it("coercing the string representation of a non-zero safe integer yields the same number", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
// "0" is in FALSY_STRINGS and maps to false, not 0 — excluded here.
|
||||
// "1" is in TRUTHY_STRINGS and maps to true — also excluded.
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ min: 2, max: 1_000_000 }), (n) => {
|
||||
const coerced = coerceValue(String(n));
|
||||
expect(coerced).toBe(n);
|
||||
}),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
|
||||
it("coercing the string representation of a negative integer (not -1) yields the same number", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ min: -1_000_000, max: -2 }), (n) => {
|
||||
const coerced = coerceValue(String(n));
|
||||
expect(coerced).toBe(n);
|
||||
}),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
|
||||
it("coercing the string representation of a boolean yields the same boolean", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(fc.boolean(), (b) => {
|
||||
const coerced = coerceValue(String(b));
|
||||
expect(coerced).toBe(b);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it("coercing JSON.stringify of an object yields a deeply equal object", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.dictionary(
|
||||
fc
|
||||
.string({ minLength: 1, maxLength: 8 })
|
||||
.filter((s) => /^[a-z]+$/.test(s)),
|
||||
fc.oneof(
|
||||
fc.integer(),
|
||||
fc.string({ maxLength: 10 }),
|
||||
fc.boolean()
|
||||
),
|
||||
{ minKeys: 1, maxKeys: 4 }
|
||||
),
|
||||
(obj) => {
|
||||
const coerced = coerceValue(JSON.stringify(obj));
|
||||
expect(coerced).toEqual(obj);
|
||||
}
|
||||
),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseArgs completeness", () => {
|
||||
it("every --key value pair appears in output when key is in mapping", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
const keyArb = fc
|
||||
.string({ minLength: 2, maxLength: 10 })
|
||||
.filter((s) => /^[a-z][a-z0-9-]*$/.test(s));
|
||||
const valueArb = fc
|
||||
.string({ minLength: 1, maxLength: 20 })
|
||||
.filter((s) => !s.startsWith("-"));
|
||||
|
||||
const pairsArb = fc.array(fc.tuple(keyArb, valueArb), {
|
||||
minLength: 1,
|
||||
maxLength: 6,
|
||||
});
|
||||
|
||||
fc.assert(
|
||||
fc.property(pairsArb, (pairs) => {
|
||||
// Deduplicate keys (last wins in args, but first in mapping)
|
||||
const seen = new Set<string>();
|
||||
const unique: [string, string][] = [];
|
||||
for (const [k, v] of pairs) {
|
||||
if (!seen.has(k)) {
|
||||
seen.add(k);
|
||||
unique.push([k, v]);
|
||||
}
|
||||
}
|
||||
|
||||
const mapping: Record<string, string> = {};
|
||||
for (const [k] of unique) {
|
||||
mapping[k] = `config.${k}`;
|
||||
}
|
||||
|
||||
const args: string[] = [];
|
||||
for (const [k, v] of unique) {
|
||||
args.push(`--${k}`, v);
|
||||
}
|
||||
|
||||
const result = parseArgs(args, mapping);
|
||||
|
||||
for (const [k, v] of unique) {
|
||||
expect(result[k]).toBe(v);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
});
|
||||
8
packages/foundation/core/config/tests/setup/polyfills.ts
Normal file
8
packages/foundation/core/config/tests/setup/polyfills.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Ensure `globalThis.crypto` is available in bare-Node vitest runs.
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
12
packages/foundation/core/config/tsconfig.lib.json
Normal file
12
packages/foundation/core/config/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
39
packages/foundation/core/config/vite.config.ts
Normal file
39
packages/foundation/core/config/vite.config.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/config",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"@suaveplan/events",
|
||||
"@suaveplan/env",
|
||||
"tslib",
|
||||
"zod",
|
||||
"@opentelemetry/api",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
26
packages/foundation/core/config/vitest.config.ts
Normal file
26
packages/foundation/core/config/vitest.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**", "src/**/*.bench.ts"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user