Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View 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._

View 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"
]
}

View 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",
];

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
export { createConfig } from "./config-builder.js";
export type {
ConfigErrorDetail,
MergeResult,
ResolvedLayer,
} from "./config-builder.types.js";

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { createConfigStore } from "./config-store.js";

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
export type { ConfigPackageErrorCode } from "./error-codes.js";
export {
CONFIG_CODES,
ConfigError,
layerError,
reloadFailed,
validationFailed,
} from "./error-codes.js";

View 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";

View File

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

View File

@@ -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({});
});
});

View File

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

View File

@@ -0,0 +1 @@
export { cliLayer, parseArgs } from "./cli-layer.js";

View File

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

View File

@@ -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({});
});
});

View File

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

View File

@@ -0,0 +1 @@
export { defaultsLayer } from "./defaults-layer.js";

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { coerceValue, envLayer } from "./env-layer.js";

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { fileLayer, wireLayerReload } from "./file-layer.js";

View File

@@ -0,0 +1 @@
export { objectLayer } from "./object-layer.js";

View File

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

View File

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

View File

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

View 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`.

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

View 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";

View 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.

View 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();
});
});

View 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
>;

View 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.

View 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);
});
});

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

View 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).

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

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

View 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);
});
});

View 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 }
);
});
});

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

View 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"]
}

View 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:/,
],
},
},
});

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