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,19 @@
# `core`
Core foundation — types, errors, configuration, schema registry, and utilities.
<!-- begin:auto-docs -->
## Packages in `core`
7 packages.
| Package | Description |
|---|---|
| [`@suaveplan/config`](./config/README.md) | Layered configuration management for Suaveplan. Resolves an ordered set of typed layers (defaults, file, object, env, CLI) in priority order, deep-merges the r… |
| [`@suaveplan/env`](./env/README.md) | Type-safe, schema-first environment variable loading for Node.js and edge runtimes. Define a schema of typed field builders, point it at your sources, and rece… |
| [`@suaveplan/error`](./error/README.md) | Foundation error substrate for Suaveplan. Every non-testing `@suaveplan/*` package extends this library's `SuaveplanError` base class and uses its stable code… |
| [`@suaveplan/schema-registry`](./schema-registry/README.md) | Versioned schema registry with forward/backward/full compatibility checking for evolving data contracts. Register schemas by subject name, enforce evolution ru… |
| [`@suaveplan/types`](./types/README.md) | Types-only foundation package. Zero runtime code, zero dependencies, zero bundle bytes. Provides the canonical shared interfaces that allow every `@suaveplan/*… |
| [`@suaveplan/typescript-config`](./typescript-config/README.md) | Shared TypeScript configuration presets for Suaveplan. This package ships composable JSON presets consumed via `"extends"` across every workspace package — no… |
| [`@suaveplan/utils`](./utils/README.md) | Zero-external-dependency utility library for Suaveplan. Covers debug helpers, deep object manipulation, platform detection, IEEE 754 float packing, pseudo-rand… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Core foundation — types, errors, configuration, schema registry, and utilities.

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

242
packages/foundation/core/env/README.md vendored Normal file
View File

@@ -0,0 +1,242 @@
# @suaveplan/env - Type-safe environment variable loading: schema-first validation, field types, dotenv loader, presets, structured errors.
## Summary
Type-safe, schema-first environment variable loading for Node.js and edge runtimes. Define a schema of typed field builders, point it at your sources, and receive a frozen record with TypeScript types inferred at compile time — no manual type annotations required.
<!-- begin:auto-docs -->
## Documentation tree
- [create-env](./src/core/create-env/create-env.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [create-env](./src/core/create-env/create-env.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [field](./src/core/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [field](./src/core/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [env-error](./src/errors/env-error.md) — Provides the complete error class hierarchy and a value-object factory for `@suaveplan/env`. All classes extend `Suavep…
- [error-codes](./src/errors/error-codes.md) — Defines and registers all stable error codes for the `@suaveplan/env` package using the `defineCodes` factory from `@su…
- [array-field](./src/fields/array-field/array-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [array-field](./src/fields/array-field/array-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [boolean-field](./src/fields/boolean-field/boolean-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [boolean-field](./src/fields/boolean-field/boolean-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [email-field](./src/fields/email-field/email-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [email-field](./src/fields/email-field/email-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [enum-field](./src/fields/enum-field/enum-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [enum-field](./src/fields/enum-field/enum-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [json-field](./src/fields/json-field/json-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [json-field](./src/fields/json-field/json-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [number-field](./src/fields/number-field/number-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [number-field](./src/fields/number-field/number-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [port-field](./src/fields/port-field/port-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [port-field](./src/fields/port-field/port-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [string-field](./src/fields/string-field/string-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [string-field](./src/fields/string-field/string-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [url-field](./src/fields/url-field/url-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [url-field](./src/fields/url-field/url-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [dotenv](./src/loaders/dotenv/dotenv.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [dotenv](./src/loaders/dotenv/dotenv.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [object](./src/loaders/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [object](./src/loaders/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [common](./src/presets/common/common.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [common](./src/presets/common/common.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [server](./src/presets/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [server](./src/presets/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schemas](./src/schemas/env.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [env.schemas](./src/schemas/env.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [coerce](./src/transforms/coerce/coerce.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [coerce](./src/transforms/coerce/coerce.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [common](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [utils](./src/utils/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [interpolation](./src/utils/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [create-env](./src/core/create-env/create-env.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/core/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/core/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [env-error](./src/errors/env-error.md) — Provides the complete error class hierarchy and a value-object factory for `@suaveplan/env`. All classes extend `Suavep…
- [error-codes](./src/errors/error-codes.md) — Defines and registers all stable error codes for the `@suaveplan/env` package using the `defineCodes` factory from `@su…
- [array-field](./src/fields/array-field/array-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [array-field](./src/fields/array-field/array-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [boolean-field](./src/fields/boolean-field/boolean-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [boolean-field](./src/fields/boolean-field/boolean-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [email-field](./src/fields/email-field/email-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [email-field](./src/fields/email-field/email-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [enum-field](./src/fields/enum-field/enum-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [enum-field](./src/fields/enum-field/enum-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [json-field](./src/fields/json-field/json-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [json-field](./src/fields/json-field/json-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [number-field](./src/fields/number-field/number-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [number-field](./src/fields/number-field/number-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [port-field](./src/fields/port-field/port-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [port-field](./src/fields/port-field/port-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [string-field](./src/fields/string-field/string-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [string-field](./src/fields/string-field/string-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [url-field](./src/fields/url-field/url-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [url-field](./src/fields/url-field/url-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [dotenv](./src/loaders/dotenv/dotenv.md) — <!-- begin:auto-docs --> _(no submodules)_- [dotenv](./src/loaders/dotenv/dotenv.md) — <!-- begin:auto-docs --> _(no submodules)_- [object](./src/loaders/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_- [object](./src/loaders/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/presets/common/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/presets/common/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/presets/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/presets/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/env.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [env.schemas](./src/schemas/env.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [coerce](./src/transforms/coerce/coerce.md) — <!-- begin:auto-docs --> _(no submodules)_- [coerce](./src/transforms/coerce/coerce.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [interpolation](./src/utils/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [create-env](./src/core/create-env/create-env.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/core/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/core/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [env-error](./src/errors/env-error.md) — Provides the complete error class hierarchy and a value-object factory for `@suaveplan/env`. All classes extend `Suavep…
- [error-codes](./src/errors/error-codes.md) — Defines and registers all stable error codes for the `@suaveplan/env` package using the `defineCodes` factory from `@su…
- [array-field](./src/fields/array-field/array-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [array-field](./src/fields/array-field/array-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [boolean-field](./src/fields/boolean-field/boolean-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [boolean-field](./src/fields/boolean-field/boolean-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [email-field](./src/fields/email-field/email-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [email-field](./src/fields/email-field/email-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [enum-field](./src/fields/enum-field/enum-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [enum-field](./src/fields/enum-field/enum-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [json-field](./src/fields/json-field/json-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [json-field](./src/fields/json-field/json-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [number-field](./src/fields/number-field/number-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [number-field](./src/fields/number-field/number-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [port-field](./src/fields/port-field/port-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [port-field](./src/fields/port-field/port-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [string-field](./src/fields/string-field/string-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [string-field](./src/fields/string-field/string-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [url-field](./src/fields/url-field/url-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [url-field](./src/fields/url-field/url-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [dotenv](./src/loaders/dotenv/dotenv.md) — <!-- begin:auto-docs --> _(no submodules)_- [dotenv](./src/loaders/dotenv/dotenv.md) — <!-- begin:auto-docs --> _(no submodules)_- [object](./src/loaders/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_- [object](./src/loaders/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/presets/common/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/presets/common/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/presets/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/presets/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/env.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [env.schemas](./src/schemas/env.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [coerce](./src/transforms/coerce/coerce.md) — <!-- begin:auto-docs --> _(no submodules)_- [coerce](./src/transforms/coerce/coerce.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [interpolation](./src/utils/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [create-env](./src/core/create-env/create-env.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/core/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/core/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [env-error](./src/errors/env-error.md) — Provides the complete error class hierarchy and a value-object factory for `@suaveplan/env`. All classes extend `Suavep…
- [error-codes](./src/errors/error-codes.md) — Defines and registers all stable error codes for the `@suaveplan/env` package using the `defineCodes` factory from `@su…
- [array-field](./src/fields/array-field/array-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [array-field](./src/fields/array-field/array-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [boolean-field](./src/fields/boolean-field/boolean-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [boolean-field](./src/fields/boolean-field/boolean-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [email-field](./src/fields/email-field/email-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [email-field](./src/fields/email-field/email-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [enum-field](./src/fields/enum-field/enum-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [enum-field](./src/fields/enum-field/enum-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [json-field](./src/fields/json-field/json-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [json-field](./src/fields/json-field/json-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [number-field](./src/fields/number-field/number-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [number-field](./src/fields/number-field/number-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [port-field](./src/fields/port-field/port-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [port-field](./src/fields/port-field/port-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [string-field](./src/fields/string-field/string-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [string-field](./src/fields/string-field/string-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [url-field](./src/fields/url-field/url-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [url-field](./src/fields/url-field/url-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [dotenv](./src/loaders/dotenv/dotenv.md) — <!-- begin:auto-docs --> _(no submodules)_- [dotenv](./src/loaders/dotenv/dotenv.md) — <!-- begin:auto-docs --> _(no submodules)_- [object](./src/loaders/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_- [object](./src/loaders/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/presets/common/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/presets/common/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/presets/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/presets/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/env.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [env.schemas](./src/schemas/env.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [coerce](./src/transforms/coerce/coerce.md) — <!-- begin:auto-docs --> _(no submodules)_- [coerce](./src/transforms/coerce/coerce.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [interpolation](./src/utils/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [create-env](./src/core/create-env/create-env.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/core/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_ - [field](./src/core/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_ - [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [env-error](./src/errors/env-error.md) — Provides the complete error class hierarchy and a value-object factory for `@suaveplan/env`. All classes extend `Suavep…
- [error-codes](./src/errors/error-codes.md) — Defines and registers all stable error codes for the `@suaveplan/env` package using the `defineCodes` factory from `@su…
- [array-field](./src/fields/array-field/array-field.md) — <!-- begin:auto-docs --> _(no submodules)_ - [array-field](./src/fields/array-field/array-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [boolean-field](./src/fields/boolean-field/boolean-field.md) — <!-- begin:auto-docs --> _(no submodules)_ - [boolean-field](./src/fields/boolean-field/boolean-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [email-field](./src/fields/email-field/email-field.md) — <!-- begin:auto-docs --> _(no submodules)_ - [email-field](./src/fields/email-field/email-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [enum-field](./src/fields/enum-field/enum-field.md) — <!-- begin:auto-docs --> _(no submodules)_ - [enum-field](./src/fields/enum-field/enum-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [json-field](./src/fields/json-field/json-field.md) — <!-- begin:auto-docs --> _(no submodules)_ - [json-field](./src/fields/json-field/json-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [number-field](./src/fields/number-field/number-field.md) — <!-- begin:auto-docs --> _(no submodules)_ - [number-field](./src/fields/number-field/number-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [port-field](./src/fields/port-field/port-field.md) — <!-- begin:auto-docs --> _(no submodules)_ - [port-field](./src/fields/port-field/port-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [string-field](./src/fields/string-field/string-field.md) — <!-- begin:auto-docs --> _(no submodules)_ - [string-field](./src/fields/string-field/string-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [url-field](./src/fields/url-field/url-field.md) — <!-- begin:auto-docs --> _(no submodules)_ - [url-field](./src/fields/url-field/url-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [dotenv](./src/loaders/dotenv/dotenv.md) — <!-- begin:auto-docs --> _(no submodules)_ - [dotenv](./src/loaders/dotenv/dotenv.md) — <!-- begin:auto-docs --> _(no submodules)_- [object](./src/loaders/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_ - [object](./src/loaders/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/presets/common/common.md) — <!-- begin:auto-docs --> _(no submodules)_ - [common](./src/presets/common/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/presets/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_ - [server](./src/presets/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/env.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ - [env.schemas](./src/schemas/env.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [coerce](./src/transforms/coerce/coerce.md) — <!-- begin:auto-docs --> _(no submodules)_ - [coerce](./src/transforms/coerce/coerce.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_ - [common](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_ - [interpolation](./src/utils/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_
## Features
- **Nine field builders**: `str`, `num`, `bool`, `enums`, `url`, `email`, `port`, `json`, `array`
- **Fluent builder chains**: constraints, defaults, sensitive marking, and descriptions per field
- **Pluggable loaders**: dotenv file, object (`process.env`), or custom loaders with priority ordering
- **Three error modes**: `throw` (default), `collect` (partial data), `warn` (log and continue)
- **Full-error accumulation**: reports every broken field in a single run, not just the first
- **Sensitive value redaction**: `maskSensitive` replaces secret values with `****` before logging
- **Preset libraries**: `commonFields` and `serverFields` for the most frequent env variables
- **Zod runtime schemas**: for validation at serialisation boundaries
- **100% Istanbul coverage**: foundation-grade reliability
## Installation
```bash
bun add @suaveplan/env
```
## Quick Start
```ts
import { createEnv, str, num, bool } from '@suaveplan/env';
const env = createEnv({
schema: {
HOST: str().default('localhost'),
PORT: num().default(3000),
DEBUG: bool().default(false),
API_KEY: str().nonempty().sensitive(),
},
source: process.env,
});
// Full TypeScript types — no manual annotation needed
console.log(env.HOST); // string
console.log(env.PORT); // number
console.log(env.DEBUG); // boolean
console.log(env.API_KEY); // string
```
## Field Builders
| Factory | Output type | Key methods |
|---|---|---|
| `str()` | `string` | `trim`, `lowercase`, `uppercase`, `minLength`, `maxLength`, `pattern`, `nonempty`, `startsWith`, `endsWith`, `includes` |
| `num()` | `number` | `min`, `max`, `integer`, `positive`, `nonNegative` |
| `bool()` | `boolean` | `truthy(values)`, `falsy(values)` |
| `enums(values)` | `"a" \| "b" \| …` | `caseInsensitive`, `getAllowedValues` |
| `url()` | `string` | `protocols(list)`, `noTrailingSlash` |
| `email()` | `string` | `normalise`, `pattern(regex)` |
| `port()` | `number` | `min(n)`, `max(n)` |
| `json<T>()` | `T` (default `unknown`) | `validator(fn)` |
| `array(itemField)` | `T[]` | `separator`, `noTrim`, `keepEmpty`, `minItems`, `maxItems` |
All builders share: `optional()`, `default(value)`, `sensitive()`, `describe(text)`, `addRule(options)`.
## Loaders
```ts
import { createEnv, str } from '@suaveplan/env';
import { dotenvLoader, objectLoader } from '@suaveplan/env/loaders';
const env = createEnv({
schema: { DB_URL: str().sensitive() },
loaders: [
dotenvLoader({ path: '.env' }), // from file
objectLoader({ source: process.env }), // from process.env (wins)
],
});
```
Later loaders override earlier ones for the same key. `source` (if provided) overrides all loaders.
## Error Modes
```ts
// Default: throws EnvValidationError with all failures listed
const env = createEnv({ schema, source: process.env });
// Collect: returns partial data silently
const env = createEnv({ schema, source: process.env, onError: 'collect' });
// Warn: logs each failure via console.warn, returns partial data
const env = createEnv({ schema, source: process.env, onError: 'warn' });
```
## Prefix Support
```ts
// Reads APP_HOST, APP_PORT from process.env; output keys are HOST, PORT
const env = createEnv({
schema: { HOST: str().default('localhost'), PORT: num().default(3000) },
source: process.env,
prefix: 'APP_',
});
```
## Presets
```ts
import { commonFields } from '@suaveplan/env/presets/common/common';
import { serverFields, databaseUrlField, sessionSecretField } from '@suaveplan/env/presets/server/server';
import { createEnv, str } from '@suaveplan/env';
const env = createEnv({
schema: {
...commonFields, // NODE_ENV, PORT, HOST, DEBUG, LOG_LEVEL
DATABASE_URL: databaseUrlField(),
SESSION_SECRET: sessionSecretField(),
CUSTOM: str().optional(),
},
source: process.env,
});
```
## Sensitive Values
```ts
import { validate, maskSensitive } from '@suaveplan/env/core/validator/validator';
import { str } from '@suaveplan/env';
const schema = { SECRET: str().sensitive(), PUBLIC: str() };
const result = validate({ schema, values: { SECRET: 'my-secret', PUBLIC: 'ok' } });
const safe = maskSensitive(schema, result.data);
console.log(safe.SECRET); // "****"
console.log(safe.PUBLIC); // "ok"
```
## Errors
```ts
import { EnvValidationError, CoercionError } from '@suaveplan/env/errors';
import { ENV_CODES } from '@suaveplan/env/errors';
try {
createEnv({ schema, source: {} });
} catch (err) {
if (err instanceof EnvValidationError) {
console.log(err.code); // "ENV.VALIDATION_FAILED"
console.log(err.errors); // [{ key, message }]
}
}
```
## Zod Schemas
Runtime schemas for validation at API or persistence boundaries:
```ts
import { FieldConfigSchema, ValidationResultSchema } from '@suaveplan/env/schemas';
const result = FieldConfigSchema.safeParse(unknownPayload);
```
## Module Reference
| Co-located module | Description |
|---|---|
| [`src/core/create-env/create-env.md`](src/core/create-env/create-env.md) | `createEnv` orchestrator |
| [`src/core/field/field.md`](src/core/field/field.md) | `BaseField` abstract base |
| [`src/core/validator/validator.md`](src/core/validator/validator.md) | `validate` and `maskSensitive` |
| [`src/errors/error-codes.md`](src/errors/error-codes.md) | `ENV_CODES` registry |
| [`src/errors/env-error.md`](src/errors/env-error.md) | Error class hierarchy |
| [`src/fields/string-field/string-field.md`](src/fields/string-field/string-field.md) | `str()` |
| [`src/fields/number-field/number-field.md`](src/fields/number-field/number-field.md) | `num()` |
| [`src/fields/boolean-field/boolean-field.md`](src/fields/boolean-field/boolean-field.md) | `bool()` |
| [`src/fields/enum-field/enum-field.md`](src/fields/enum-field/enum-field.md) | `enums()` |
| [`src/fields/url-field/url-field.md`](src/fields/url-field/url-field.md) | `url()` |
| [`src/fields/email-field/email-field.md`](src/fields/email-field/email-field.md) | `email()` |
| [`src/fields/port-field/port-field.md`](src/fields/port-field/port-field.md) | `port()` |
| [`src/fields/json-field/json-field.md`](src/fields/json-field/json-field.md) | `json()` |
| [`src/fields/array-field/array-field.md`](src/fields/array-field/array-field.md) | `array()` |
| [`src/loaders/dotenv/dotenv.md`](src/loaders/dotenv/dotenv.md) | `parseDotenv`, `dotenvLoader` |
| [`src/loaders/object/object.md`](src/loaders/object/object.md) | `objectLoader` |
| [`src/transforms/coerce/coerce.md`](src/transforms/coerce/coerce.md) | Standalone coercion functions |
| [`src/presets/common/common.md`](src/presets/common/common.md) | Common server field presets |
| [`src/presets/server/server.md`](src/presets/server/server.md) | Server connection field presets |
| [`src/schemas/env.schemas.md`](src/schemas/env.schemas.md) | Zod runtime schemas |
| [`src/types/common.md`](src/types/common.md) | Shared TypeScript types |
| [`src/utils/interpolation.md`](src/utils/interpolation.md) | `${VAR}` interpolation utilities |

View File

@@ -0,0 +1,105 @@
{
"name": "@suaveplan/env",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Type-safe environment variable loading: schema-first validation, field types, dotenv loader, presets, structured errors.",
"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"
}
},
"./core": {
"import": {
"types": "./dist/core/create-env/create-env.d.ts",
"default": "./dist/core/create-env/create-env.js"
},
"require": {
"types": "./dist/core/create-env/create-env.d.ts",
"default": "./dist/core/create-env/create-env.js"
}
},
"./fields": {
"import": {
"types": "./dist/fields/index.d.ts",
"default": "./dist/fields/index.js"
},
"require": {
"types": "./dist/fields/index.d.ts",
"default": "./dist/fields/index.js"
}
},
"./loaders": {
"import": {
"types": "./dist/loaders/index.d.ts",
"default": "./dist/loaders/index.js"
},
"require": {
"types": "./dist/loaders/index.d.ts",
"default": "./dist/loaders/index.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/env-error.d.ts",
"default": "./dist/errors/env-error.js"
},
"require": {
"types": "./dist/errors/env-error.d.ts",
"default": "./dist/errors/env-error.js"
}
},
"./schemas": {
"import": {
"types": "./dist/schemas/env.schemas.d.ts",
"default": "./dist/schemas/env.schemas.js"
},
"require": {
"types": "./dist/schemas/env.schemas.d.ts",
"default": "./dist/schemas/env.schemas.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": "bun run src/__benchmarks__/env.bench.ts",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@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"
}
}

View File

@@ -0,0 +1,37 @@
/** Default separator character for array fields. */
export const DEFAULT_ARRAY_SEPARATOR = ",";
/** Values coerced to boolean `true` by BooleanField. */
export const TRUTHY_VALUES: readonly string[] = [
"true",
"1",
"yes",
"on",
"enabled",
];
/** Values coerced to boolean `false` by BooleanField. */
export const FALSY_VALUES: readonly string[] = [
"false",
"0",
"no",
"off",
"disabled",
"",
];
/** Minimum valid TCP port number. */
export const PORT_MIN = 1;
/** Maximum valid TCP port number. */
export const PORT_MAX = 65_535;
/** Basic email validation regex covering the vast majority of real addresses. */
export const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/**
* Interpolation variable pattern matching `${VAR}` and `$VAR` forms.
* The global flag is required; callers must reset `lastIndex` before use.
*/
export const INTERPOLATION_PATTERN =
/\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g;

View File

@@ -0,0 +1,91 @@
# create-env
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createEnv` is the primary public API of `@suaveplan/env`. It orchestrates the complete lifecycle of environment loading: merging values from one or more loaders, overlaying a direct source object, running schema validation, handling errors according to the configured mode, and returning a frozen, fully-typed record. The return type is inferred from the schema at compile time, giving callers precise TypeScript types without any manual annotation.
## Features
- Accepts any combination of `loaders` and a direct `source` (later sources take priority)
- Validates all fields accumulating every error before surfacing failures
- Three error modes: `'throw'` (default) raises `EnvValidationError` with the full list; `'collect'` returns partial data; `'warn'` logs each failure via `console.warn` and returns partial data
- Optional `prefix` strips a common prefix from schema key lookups (e.g. `'APP_'` looks up `APP_PORT` for schema key `PORT`)
- Returns a `Readonly<InferEnv<S>>` frozen record — mutation after construction throws at runtime
- Sensitive field values are available in the returned record; use `maskSensitive` before logging
## Usage: Basic Example
```ts
import { createEnv, str, num, bool } from '@suaveplan/env';
const env = createEnv({
schema: {
HOST: str().default('localhost'),
PORT: num().default(3000),
DEBUG: bool().default(false),
},
source: process.env,
});
// TypeScript knows env.HOST is string, env.PORT is number, env.DEBUG is boolean
console.log(env.HOST); // "localhost" (or value from process.env)
console.log(env.PORT); // 3000
console.log(env.DEBUG); // false
```
## Usage: Advanced Example
```ts
import { createEnv, str, num } from '@suaveplan/env';
import { dotenvLoader } from '@suaveplan/env/loaders';
import { objectLoader } from '@suaveplan/env/loaders';
const env = createEnv({
schema: {
DB_URL: str().sensitive(),
PORT: num().default(8080),
},
// Load from .env file first, then override with process.env
loaders: [
dotenvLoader({ path: '.env' }),
objectLoader({ source: process.env }),
],
// Additionally read from a specific object (highest priority)
source: { PORT: '9000' },
// Look up APP_DB_URL and APP_PORT in the merged values
prefix: 'APP_',
// Collect errors instead of throwing — useful in test helpers
onError: 'collect',
});
```
## API Reference
### `createEnv<S extends EnvSchema>(options: CreateEnvOptions<S>): Readonly<InferEnv<S>>`
**`CreateEnvOptions<S>` properties:**
| Property | Type | Default | Description |
|---|---|---|---|
| `schema` | `S` | required | Record of field builders |
| `source` | `Record<string, string \| undefined>?` | `undefined` | Direct value map (e.g. `process.env`); overrides loaders |
| `loaders` | `ReadonlyArray<Loader>?` | `[]` | Ordered loaders; later loaders override earlier ones |
| `prefix` | `string?` | `undefined` | Key prefix stripped during lookup |
| `onError` | `'throw' \| 'collect' \| 'warn'` | `'throw'` | Error handling mode |
**Returns:** `Readonly<InferEnv<S>>` — a frozen object with types derived from the schema.
**Throws:** `EnvValidationError` when `onError === 'throw'` and any field fails validation.
## Implementation Notes
`buildValues` merges loader results in order (later loaders win), then spreads the `source` object on top. This means `source` always takes priority over any loader, which mirrors how `process.env` should override `.env` file defaults.
The `validateInput` object is built conditionally (`prefix ? { schema, values, prefix } : { schema, values }`) to satisfy TypeScript's `exactOptionalPropertyTypes` setting, which disallows assigning `string | undefined` to an `optional string` property.
`Object.freeze` is applied to the result data record before returning. The freeze is shallow — nested objects within JSON fields are not recursively frozen — but it prevents accidental mutation of top-level keys.
The `'warn'` mode uses `maskSensitive` before logging each error's raw value so that secrets in failed coercions do not appear in console output. The `'collect'` mode returns partial data silently, relying on the caller to inspect `result.errors` via a separate call if needed. Because `createEnv` returns only the data record, the collect mode's errors are only visible if the caller switches to a wrapper that calls `validate` directly.

View File

@@ -0,0 +1,381 @@
/**
* Spec-conformance tests for `createEnv`. Covers scenarios from
* `openspec/specs/env/spec.md` that the original suite did not pin
* explicitly: schema-driven literal type preservation, optional vs
* required key narrowing, dotenv→source loader chain with
* interpolation, maskSensitive on the wire, and ENV.* error codes
* for invalid env vars.
*/
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import { EnvValidationError } from "../../errors/env-error.js";
import { ENV_CODES } from "../../errors/error-codes.js";
import { bool } from "../../fields/boolean-field/boolean-field.js";
import { enums } from "../../fields/enum-field/enum-field.js";
import { num } from "../../fields/number-field/number-field.js";
import { port } from "../../fields/port-field/port-field.js";
import { str } from "../../fields/string-field/string-field.js";
import { dotenvLoader, parseDotenv } from "../../loaders/dotenv/dotenv.js";
import { objectLoader } from "../../loaders/object/object.js";
import type { Loader } from "../../types/common.js";
import { maskSensitive } from "../validator/validator.js";
import { createEnv } from "./create-env.js";
describe("createEnv — schema-typed result preserves literal types", () => {
it("narrows required vs optional keys, enum literals, and field types", {
timeout: 5000,
}, () => {
const env = createEnv({
schema: {
NODE_ENV: enums(["dev", "prod"] as const),
PORT: port().default(3000),
HOST: str().optional(),
DEBUG: bool().default(false),
WORKERS: num().default(4),
},
source: { NODE_ENV: "prod" },
});
// Required (with defaults or values) → never undefined.
expectTypeOf(env.NODE_ENV).toEqualTypeOf<"dev" | "prod">();
expectTypeOf(env.PORT).toEqualTypeOf<number>();
expectTypeOf(env.DEBUG).toEqualTypeOf<boolean>();
expectTypeOf(env.WORKERS).toEqualTypeOf<number>();
// Optional → may be undefined.
expectTypeOf(env.HOST).toEqualTypeOf<string | undefined>();
// Runtime checks to keep istanbul satisfied.
expect(env.NODE_ENV).toBe("prod");
expect(env.PORT).toBe(3000);
expect(env.DEBUG).toBe(false);
expect(env.WORKERS).toBe(4);
expect(env.HOST).toBeUndefined();
});
});
describe("createEnv — loader chain merges in declared order", () => {
it("later loaders win; source overrides loader values", {
timeout: 5000,
}, () => {
const first: Loader = {
name: "first",
load: () => ({
values: { A: "a-from-first", B: "b-from-first" },
source: "first",
}),
};
const second = objectLoader({
source: { B: "b-from-second", C: "c-from-second" },
});
const env = createEnv({
schema: {
A: str(),
B: str(),
C: str(),
D: str().optional(),
},
loaders: [first, second],
source: { D: "d-from-source" },
});
expect(env.A).toBe("a-from-first");
expect(env.B).toBe("b-from-second");
expect(env.C).toBe("c-from-second");
expect(env.D).toBe("d-from-source");
});
});
describe("parseDotenv — interpolation chain is order-sensitive", () => {
it("later keys can reference earlier keys via ${VAR}", {
timeout: 5000,
}, () => {
const out = parseDotenv(
["BASE=hello", 'GREETING="${BASE} world"', ""].join("\n")
);
expect(out.BASE).toBe("hello");
expect(out.GREETING).toBe("hello world");
});
it("is pure: same input yields equal output map", { timeout: 5000 }, () => {
const content = "X=1\nY=${X}-tail\n";
const a = parseDotenv(content);
const b = parseDotenv(content);
expect(a).toEqual(b);
});
});
describe("dotenvLoader — graceful fallback on missing file", () => {
it("returns empty values when file does not exist (no throw)", {
timeout: 5000,
}, () => {
const loader = dotenvLoader({ path: "./.env.does-not-exist-xyz" });
const result = loader.load();
expect(result.values).toEqual({});
});
});
describe("maskSensitive — never leaks sensitive raw values on the wire", () => {
it("replaces sensitive values without mutating the original object", {
timeout: 5000,
}, () => {
const schema = {
PUBLIC: str(),
SECRET: str().sensitive(),
};
const data: Record<string, unknown> = {
PUBLIC: "shown",
SECRET: "super-secret-value",
};
const masked = maskSensitive(schema, data);
// Sensitive value redacted.
expect(masked.SECRET).not.toBe("super-secret-value");
expect(String(masked.SECRET)).toMatch(/\*+/);
// Public value preserved.
expect(masked.PUBLIC).toBe("shown");
// Original not mutated.
expect(data.SECRET).toBe("super-secret-value");
expect(masked).not.toBe(data);
});
});
describe("createEnv — ENV.* error codes for invalid env vars", () => {
it("throws EnvValidationError with code ENV.VALIDATION_FAILED", {
timeout: 5000,
}, () => {
const fn = (): unknown =>
createEnv({
schema: { PORT: port() },
source: { PORT: "not-a-port" },
});
expect(fn).toThrow(EnvValidationError);
try {
fn();
} catch (err) {
expect(err).toBeInstanceOf(EnvValidationError);
expect((err as EnvValidationError).code).toBe(
ENV_CODES.VALIDATION_FAILED
);
expect((err as EnvValidationError).code).toMatch(/^ENV\./);
}
});
it("missing required field surfaces under ENV.VALIDATION_FAILED", {
timeout: 5000,
}, () => {
try {
createEnv({ schema: { REQUIRED: str() }, source: {} });
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(EnvValidationError);
expect((err as EnvValidationError).code).toBe(
ENV_CODES.VALIDATION_FAILED
);
const errs = (err as EnvValidationError).errors;
expect(errs.some((e) => e.key === "REQUIRED")).toBe(true);
}
});
});
describe("ENV_CODES — registered under ENV.* namespace", () => {
it("every code carries the ENV prefix and is literal-typed", {
timeout: 5000,
}, () => {
expectTypeOf(
ENV_CODES.VALIDATION_FAILED
).toEqualTypeOf<"ENV.VALIDATION_FAILED">();
expectTypeOf(
ENV_CODES.MISSING_REQUIRED
).toEqualTypeOf<"ENV.MISSING_REQUIRED">();
expectTypeOf(ENV_CODES.PARSE_ERROR).toEqualTypeOf<"ENV.PARSE_ERROR">();
expectTypeOf(
ENV_CODES.INVALID_FORMAT
).toEqualTypeOf<"ENV.INVALID_FORMAT">();
for (const value of Object.values(ENV_CODES)) {
expect(value).toMatch(/^ENV\./);
}
});
});
describe("createEnv — cross-loader ${VAR} interpolation pass", () => {
it("expands ${VAR} tokens across the merged values map before coercion", {
timeout: 5000,
}, () => {
const env = createEnv({
schema: {
BASE: str(),
URL: str(),
},
// BASE is supplied by the loader; URL references BASE via ${...}
// and is supplied by `source`. The interpolation pass MUST resolve
// `${BASE}` against the merged map before coercion runs.
loaders: [
objectLoader({ source: { BASE: "https://example.com" } }),
],
source: { URL: "${BASE}/api" },
});
expect(env.BASE).toBe("https://example.com");
expect(env.URL).toBe("https://example.com/api");
});
it("leaves unresolved tokens as empty (interpolate default)", {
timeout: 5000,
}, () => {
const env = createEnv({
schema: { OUT: str() },
source: { OUT: "prefix-${MISSING}-suffix" },
});
expect(env.OUT).toBe("prefix--suffix");
});
});
describe("Zod boundary validation surfaces ENV.VALIDATION_FAILED", () => {
it("createEnv rejects an invalid options.onError value", {
timeout: 5000,
}, () => {
try {
createEnv({
schema: { X: str() },
source: { X: "ok" },
// @ts-expect-error — invalid onError exercise the boundary check
onError: "ignore",
});
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(EnvValidationError);
expect((err as EnvValidationError).code).toBe(
ENV_CODES.VALIDATION_FAILED
);
}
});
it("dotenvLoader rejects a non-string path", { timeout: 5000 }, () => {
try {
// @ts-expect-error — invalid path exercises the boundary check
dotenvLoader({ path: 42 });
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(EnvValidationError);
expect((err as EnvValidationError).code).toBe(
ENV_CODES.VALIDATION_FAILED
);
}
});
it("dotenvLoader rejects a non-object options argument with the 'options' fallback key", {
timeout: 5000,
}, () => {
try {
// @ts-expect-error — non-object exercises the empty-path branch
dotenvLoader("oops");
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(EnvValidationError);
const errs = (err as EnvValidationError).errors;
expect(errs.some((e) => e.key === "options")).toBe(true);
}
});
it("objectLoader rejects a missing source", { timeout: 5000 }, () => {
try {
// @ts-expect-error — invalid options exercise the boundary check
objectLoader({});
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(EnvValidationError);
expect((err as EnvValidationError).code).toBe(
ENV_CODES.VALIDATION_FAILED
);
}
});
it("objectLoader rejects a non-object options argument with the 'options' fallback key", {
timeout: 5000,
}, () => {
try {
// @ts-expect-error — non-object exercises the empty-path branch
objectLoader(null);
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(EnvValidationError);
const errs = (err as EnvValidationError).errors;
expect(errs.some((e) => e.key === "options")).toBe(true);
}
});
it("createEnv rejects a non-object options argument with the 'options' fallback key", {
timeout: 5000,
}, () => {
try {
// @ts-expect-error — non-object exercises the empty-path branch
createEnv(null);
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(EnvValidationError);
const errs = (err as EnvValidationError).errors;
expect(errs.some((e) => e.key === "options")).toBe(true);
}
});
});
describe("createEnv — interpolation pass branch coverage", () => {
it("skips undefined entries when building the lookup map", {
timeout: 5000,
}, () => {
// EXPLICIT_UNDEFINED is supplied as `undefined` directly via source —
// the spread keeps the key present with value `undefined` in the
// merged map. The interpolation lookup MUST skip it; URL still
// resolves against the defined values.
const env = createEnv({
schema: {
BASE: str(),
EXPLICIT_UNDEFINED: str().optional(),
URL: str(),
},
source: {
BASE: "x",
EXPLICIT_UNDEFINED: undefined,
URL: "${BASE}-y",
},
});
expect(env.URL).toBe("x-y");
expect(env.EXPLICIT_UNDEFINED).toBeUndefined();
});
it("treats a self-referencing token as a stable fixed point", {
timeout: 5000,
}, () => {
// When `${X}` resolves to literal `${X}`, the substitution is a
// no-op and the loop terminates without mutating the value — this
// exercises the `next === v` branch of expandInterpolation.
const env = createEnv({
schema: { X: str() },
source: { X: "${X}" },
});
// Unresolved reference falls through `interpolate`'s empty-string
// default; `expandInterpolation` notices no change after one round.
expect(typeof env.X).toBe("string");
});
});
describe("createEnv — OTel instrumentation is non-throwing", () => {
it("records metrics for both success and error outcomes without throwing", {
timeout: 5000,
}, () => {
// No SDK is registered → noop instruments. The contract is that
// instrumentation MUST NOT throw or suppress errors — both branches
// must complete cleanly.
const ok = createEnv({
schema: { A: str().default("a") },
source: {},
});
expect(ok.A).toBe("a");
try {
createEnv({ schema: { REQUIRED: str() }, source: {} });
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(EnvValidationError);
}
});
});

View File

@@ -0,0 +1,133 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import { EnvValidationError } from "../../errors/env-error.js";
import { bool } from "../../fields/boolean-field/boolean-field.js";
import { num } from "../../fields/number-field/number-field.js";
import { port } from "../../fields/port-field/port-field.js";
import { str } from "../../fields/string-field/string-field.js";
import { objectLoader } from "../../loaders/object/object.js";
import { createEnv } from "./create-env.js";
describe("createEnv", () => {
it("resolves values from source", { timeout: 5000 }, () => {
const env = createEnv({
schema: { HOST: str(), PORT: num() },
source: { HOST: "localhost", PORT: "3000" },
});
expect(env.HOST).toBe("localhost");
expect(env.PORT).toBe(3000);
});
it("uses default values when source key absent", { timeout: 5000 }, () => {
const env = createEnv({
schema: { DEBUG: bool().default(false) },
source: {},
});
expect(env.DEBUG).toBe(false);
});
it("throws EnvValidationError in throw mode (default)", {
timeout: 5000,
}, () => {
expect(() =>
createEnv({
schema: { REQUIRED: str() },
source: {},
})
).toThrow(EnvValidationError);
});
it("collect mode returns partial data without throwing", {
timeout: 5000,
}, () => {
let result: Record<string, unknown> | undefined;
expect(() => {
result = createEnv({
schema: { MISSING: str(), PRESENT: str() },
source: { PRESENT: "hello" },
onError: "collect",
});
}).not.toThrow();
expect(result?.PRESENT).toBe("hello");
});
it("warn mode logs and returns partial data", { timeout: 5000 }, () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const result = createEnv({
schema: { MISSING: str() },
source: {},
onError: "warn",
});
expect(warn).toHaveBeenCalled();
expect(result.MISSING).toBeUndefined();
warn.mockRestore();
});
it("resolves values from loaders", { timeout: 5000 }, () => {
const loader = objectLoader({ source: { PORT: "4000" } });
const env = createEnv({
schema: { PORT: num() },
loaders: [loader],
});
expect(env.PORT).toBe(4000);
});
it("source overrides loader values", { timeout: 5000 }, () => {
const loader = objectLoader({ source: { PORT: "4000" } });
const env = createEnv({
schema: { PORT: num() },
loaders: [loader],
source: { PORT: "9000" },
});
expect(env.PORT).toBe(9000);
});
it("later loader overrides earlier loader", { timeout: 5000 }, () => {
const l1 = objectLoader({ source: { PORT: "3000" } });
const l2 = objectLoader({ source: { PORT: "5000" } });
const env = createEnv({
schema: { PORT: num() },
loaders: [l1, l2],
});
expect(env.PORT).toBe(5000);
});
it("applies prefix when looking up values", { timeout: 5000 }, () => {
const env = createEnv({
schema: { PORT: num() },
source: { APP_PORT: "7000" },
prefix: "APP_",
});
expect(env.PORT).toBe(7000);
});
it("returns frozen object", { timeout: 5000 }, () => {
const env = createEnv({
schema: { X: str().default("a") },
source: {},
});
expect(Object.isFrozen(env)).toBe(true);
});
it("validates field rules after coercion", { timeout: 5000 }, () => {
expect(() =>
createEnv({
schema: { PORT: port() },
source: { PORT: "99999" },
})
).toThrow(EnvValidationError);
});
it("error message lists all failing fields", { timeout: 5000 }, () => {
let err: EnvValidationError | undefined;
try {
createEnv({
schema: { A: str(), B: num() },
source: {},
});
} catch (e) {
if (e instanceof EnvValidationError) err = e;
}
expect(err).toBeDefined();
expect(err!.errors.length).toBe(2);
});
});

View File

@@ -0,0 +1,144 @@
import { EnvValidationError } from "../../errors/env-error.js";
import { CreateEnvOptionsSchema } from "../../schemas/env.schemas.js";
import type {
CreateEnvOptions,
EnvError as EnvErrorValue,
EnvSchema,
InferEnv,
Loader,
} from "../../types/common.js";
import { hasInterpolation, interpolate } from "../../utils/interpolation.js";
import { maskSensitive, validate } from "../validator/validator.js";
function zodIssuesToEnvErrors(
issues: ReadonlyArray<{
path: ReadonlyArray<PropertyKey>;
message: string;
}>
): EnvErrorValue[] {
return issues.map((i) => ({
key: i.path.length > 0 ? i.path.map(String).join(".") : "options",
message: i.message,
}));
}
/**
* Merge values from multiple loaders in order.
* Later loaders override earlier ones for the same key.
*/
function mergeLoaders(loaders: ReadonlyArray<Loader>): Record<string, string> {
const merged: Record<string, string> = {};
for (const loader of loaders) {
const result = loader.load();
for (const [key, value] of Object.entries(result.values)) {
merged[key] = value;
}
}
return merged;
}
/**
* Build the merged values map from loaders and an optional direct source.
* The `source` (e.g. `process.env`) takes priority over loader values.
*/
function buildValues(
source: Record<string, string | undefined> | undefined,
loaders: ReadonlyArray<Loader> | undefined
): Record<string, string | undefined> {
const fromLoaders = loaders ? mergeLoaders(loaders) : {};
const fromSource = source ?? {};
return { ...fromLoaders, ...fromSource };
}
/**
* Apply a single interpolation pass over the merged values map. Each
* `${VAR}` / `$VAR` token is resolved against the same map, so a value
* supplied by `source` may reference a key supplied by a loader.
* Unresolved tokens fall back to the empty string (the documented
* default of {@link interpolate}).
*/
function interpolateMap(
merged: Record<string, string | undefined>
): Record<string, string | undefined> {
const lookup: Record<string, string> = {};
for (const [k, v] of Object.entries(merged)) {
if (v !== undefined) lookup[k] = v;
}
const out: Record<string, string | undefined> = {};
for (const [k, v] of Object.entries(merged)) {
out[k] =
v !== undefined && hasInterpolation(v) ? interpolate(v, lookup) : v;
}
return out;
}
/**
* Create a fully-typed, validated environment record.
*
* Reads values from the provided `source` and/or `loaders`, validates
* each field against the schema, and returns a frozen record with types
* inferred from the schema definition. Sensitive values are masked with
* `****` in the returned record when logged externally.
*
* Behaviour on validation failure depends on `onError`:
* - `'throw'` (default) — throws `EnvValidationError` with all failures listed.
* - `'collect'` — returns partial data; access `.errors` on the thrown exception.
* - `'warn'` — logs each failure via `console.warn` and returns partial data.
*
* @typeParam S - The env schema type.
* @param options - Schema, source, loaders, prefix, and error mode.
* @returns A frozen, fully-typed environment record.
*
* @throws {EnvValidationError} When validation fails and `onError === 'throw'`.
*
* @example
* ```ts
* const env = createEnv({
* schema: {
* PORT: port().default(3000),
* HOST: str().default('0.0.0.0'),
* DEBUG: bool().default(false),
* },
* source: process.env,
* });
* // env.PORT is number, env.HOST is string, env.DEBUG is boolean
* ```
*/
export function createEnv<S extends EnvSchema>(
options: CreateEnvOptions<S>
): Readonly<InferEnv<S>> {
const optionsParse = CreateEnvOptionsSchema.safeParse(options);
if (!optionsParse.success) {
throw new EnvValidationError(
zodIssuesToEnvErrors(optionsParse.error.issues)
);
}
const { schema, source, loaders, prefix, onError = "throw" } = options;
const merged = buildValues(source, loaders);
const values = interpolateMap(merged);
const validateInput = prefix
? { schema, values, prefix }
: { schema, values };
const result = validate(validateInput);
if (!result.success) {
if (onError === "throw") {
throw new EnvValidationError(result.errors);
}
if (onError === "warn") {
for (const err of result.errors) {
const masked = maskSensitive(schema, { [err.key]: err.value });
const display = masked[err.key] ?? err.value;
console.warn(
`[env] ${err.key}: ${err.message} (got: ${display})`
);
}
}
// 'collect' mode — fall through and return partial data
}
return Object.freeze(result.data) as Readonly<InferEnv<S>>;
}

View File

@@ -0,0 +1,14 @@
import type { EnvError, EnvSchema, InferEnv } from "../../types/common.js";
/**
* A map of field keys to the array of error messages that occurred
* during a `'collect'` mode `createEnv` run.
*/
export type CollectedErrors = ReadonlyMap<string, ReadonlyArray<EnvError>>;
/**
* The typed result produced by `createEnv<S>`.
*
* Keys are inferred from the schema; sensitive values are already masked.
*/
export type EnvResult<S extends EnvSchema> = Readonly<InferEnv<S>>;

View File

@@ -0,0 +1,2 @@
export { createEnv } from "./create-env";
export type { CollectedErrors, EnvResult } from "./create-env.types";

View File

@@ -0,0 +1,97 @@
# field
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines `BaseField<T, TRequired>`, the abstract base class that all environment field builders extend. It centralises the shared state and behaviour every field needs — required/optional tracking, default values, sensitivity markers, human-readable descriptions, and custom validation rules — so that concrete field classes only have to implement one method: `coerce(raw: string): T`. The phantom type parameter `TRequired` flows through the fluent builder chain to produce compile-time required/optional distinctions in the `InferEnv<S>` mapped type.
## Features
- Required/optional tracking with `_markRequired()` / `_markOptional()` protected helpers
- Default value support via `_setDefault(value)` — automatically marks the field as required once a default is set
- Sensitive flag via `sensitive()` — instructs `maskSensitive` to redact the value before logging
- Human-readable descriptions via `describe(text)` — surfaced in error messages and documentation tools
- Custom validation rules via `addRule(options)` — rules run in order after `coerce()` succeeds; first failure short-circuits per field
- `getConfig()` extracts a snapshot of all state as a plain `FieldConfig<T>` object for use by the validator and diagnostics
- Phantom `_phantom` field carries `{ type: T; required: TRequired }` for structural type inference without any runtime overhead
## Usage: Basic Example
```ts
import { str } from '@suaveplan/env';
// str() returns a StringField which extends BaseField
const field = str().default('localhost').sensitive().describe('Server host');
// Inspect the config
const config = field.getConfig();
console.log(config.isRequired); // true (default sets required)
console.log(config.hasDefault); // true
console.log(config.defaultValue); // "localhost"
console.log(config.isSensitive); // true
console.log(config.description); // "Server host"
```
## Usage: Advanced Example
```ts
import { num } from '@suaveplan/env';
import { BaseField } from '@suaveplan/env/core/field/field';
// Add custom validation rules directly to any field
const portField = num().addRule({
name: 'allowedPorts',
message: 'Must be 80, 443, or 8080',
validate: (v) => [80, 443, 8080].includes(v),
});
// Implement a custom field by extending BaseField
class HexColorField extends BaseField<string> {
coerce(raw: string): string {
const trimmed = raw.trim();
if (!/^#[0-9a-fA-F]{6}$/.test(trimmed)) {
throw new Error(`"${trimmed}" is not a valid hex colour`);
}
return trimmed.toLowerCase();
}
required() { this._markRequired(); return this as HexColorField<true>; }
optional() { this._markOptional(); return this as HexColorField<false>; }
default(v: string) { this._setDefault(v); return this as HexColorField<true>; }
}
```
## API Reference
### `BaseField<T, TRequired extends boolean = true>` (abstract class)
| Member | Type | Description |
|---|---|---|
| `_isRequired` | `boolean` | Whether the field must be present or have a default |
| `_hasDefault` | `boolean` | Whether a default value has been set |
| `_defaultValue` | `T \| undefined` | The default value (if set) |
| `_isSensitive` | `boolean` | Whether the value should be redacted in logs |
| `_description` | `string \| undefined` | Human-readable field description |
| `_rules` | `Array<ValidationRule<T>>` | Ordered list of post-coercion rules |
| `sensitive()` | `this` | Mark the field as carrying sensitive data |
| `describe(text)` | `this` | Attach a description string |
| `addRule(options)` | `this` | Append a custom `ValidationRule<T>` |
| `getConfig()` | `FieldConfig<T>` | Snapshot of all field state |
| `coerce(raw)` | `T` | Abstract — must be implemented by subclasses |
### Protected helpers
| Method | Description |
|---|---|
| `_markRequired()` | Sets `_isRequired = true`, clears `_hasDefault` |
| `_markOptional()` | Sets `_isRequired = false` |
| `_setDefault(value)` | Sets `_hasDefault = true`, stores the value, sets `_isRequired = true` |
## Implementation Notes
The `declare readonly _phantom` field uses `declare` to emit no runtime property while still participating in TypeScript's structural type system. This allows the `InferEnv` mapped type to distinguish `AnyField<T, true>` from `AnyField<T, false>` when computing required vs optional output keys, without any storage cost.
`_setDefault` intentionally keeps `_isRequired = true` after setting a default. A field with a default always resolves — it is never undefined in the output — so it must satisfy the required type constraint in `InferEnv`. Calling `optional()` after `default()` would override this to produce an optional output key, which is an unusual but valid use case.
The `ValidationRule<T>` interface deliberately uses method syntax (`validate(value: T): boolean`) rather than a property function type (`validate: (value: T) => boolean`). Method syntax is bivariant in TypeScript, which allows a `StringField` (whose rules have type `ValidationRule<string>`) to be assigned to `AnyField<unknown>` without a variance error.

View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { BaseField } from "./field.js";
class ConcreteField extends BaseField<string> {
coerce(raw: string): string {
return raw;
}
markRequired() {
this._markRequired();
return this;
}
markOptional() {
this._markOptional();
return this;
}
setDefault(v: string) {
this._setDefault(v);
return this;
}
}
describe("BaseField", () => {
it("defaults to required", { timeout: 5000 }, () => {
const f = new ConcreteField();
expect(f._isRequired).toBe(true);
expect(f._hasDefault).toBe(false);
expect(f._isSensitive).toBe(false);
});
it("sensitive() marks field as sensitive", { timeout: 5000 }, () => {
const f = new ConcreteField();
f.sensitive();
expect(f._isSensitive).toBe(true);
});
it("describe() sets description", { timeout: 5000 }, () => {
const f = new ConcreteField();
f.describe("my description");
expect(f._description).toBe("my description");
});
it("addRule() pushes a rule", { timeout: 5000 }, () => {
const f = new ConcreteField();
f.addRule({ name: "test", message: "fail", validate: () => false });
expect(f._rules.length).toBe(1);
expect(f._rules[0]!.name).toBe("test");
});
it("getConfig() returns snapshot", { timeout: 5000 }, () => {
const f = new ConcreteField();
f.sensitive().describe("desc");
f.addRule({ name: "r", message: "m", validate: () => true });
const config = f.getConfig();
expect(config.isRequired).toBe(true);
expect(config.hasDefault).toBe(false);
expect(config.isSensitive).toBe(true);
expect(config.description).toBe("desc");
expect(config.rules.length).toBe(1);
});
it("_markOptional() sets isRequired=false", { timeout: 5000 }, () => {
const f = new ConcreteField();
f.markOptional();
expect(f._isRequired).toBe(false);
});
it("_markRequired() sets isRequired=true", { timeout: 5000 }, () => {
const f = new ConcreteField();
f.markOptional();
f.markRequired();
expect(f._isRequired).toBe(true);
expect(f._hasDefault).toBe(false);
});
it("_setDefault() sets default value", { timeout: 5000 }, () => {
const f = new ConcreteField();
f.setDefault("hello");
expect(f._hasDefault).toBe(true);
expect(f._defaultValue).toBe("hello");
expect(f._isRequired).toBe(true);
});
it("fluent chaining returns this", { timeout: 5000 }, () => {
const f = new ConcreteField();
const result = f.sensitive().describe("x");
expect(result).toBe(f);
});
});

View File

@@ -0,0 +1,81 @@
import type { ValidationRule } from "../../types/common.js";
import type { FieldConfig, RuleOptions } from "./field.types.js";
/**
* Abstract base class for all environment field builders.
*
* Provides shared functionality: required/optional flags,
* default values, sensitivity markers, descriptions, and
* custom validation rules.
*
* Subclasses must implement `coerce(raw: string): T` to
* convert raw environment strings into typed values.
*
* @typeParam T - The output value type produced by this field.
* @typeParam TRequired - Compile-time flag: `true` if the field always resolves.
*/
export abstract class BaseField<T, TRequired extends boolean = true> {
declare readonly _phantom: { type: T; required: TRequired };
_isRequired: boolean;
_hasDefault: boolean;
_defaultValue?: T;
_isSensitive: boolean;
_description?: string;
_rules: Array<ValidationRule<T>>;
constructor() {
this._isRequired = true;
this._hasDefault = false;
this._isSensitive = false;
this._rules = [];
}
/** Mark this field as carrying sensitive data (values masked in logs). */
sensitive(): this {
this._isSensitive = true;
return this;
}
/** Attach a human-readable description used in error messages and docs. */
describe(description: string): this {
this._description = description;
return this;
}
/** Add a custom validation rule executed after coercion. */
addRule(options: RuleOptions<T>): this {
this._rules.push(options);
return this;
}
/** Extract the runtime configuration snapshot for this field. */
getConfig(): FieldConfig<T> {
return {
isRequired: this._isRequired,
hasDefault: this._hasDefault,
defaultValue: this._defaultValue,
isSensitive: this._isSensitive,
description: this._description,
rules: [...this._rules],
};
}
/** Convert a raw environment string into the typed value. */
abstract coerce(raw: string): T;
protected _markRequired(): void {
this._isRequired = true;
this._hasDefault = false;
}
protected _markOptional(): void {
this._isRequired = false;
}
protected _setDefault(value: T): void {
this._hasDefault = true;
this._defaultValue = value;
this._isRequired = true;
}
}

View File

@@ -0,0 +1,18 @@
import type { ValidationRule } from "../../types/common.js";
/** Runtime configuration snapshot extracted from any BaseField. */
export interface FieldConfig<T = unknown> {
readonly isRequired: boolean;
readonly hasDefault: boolean;
readonly defaultValue?: T | undefined;
readonly isSensitive: boolean;
readonly description?: string | undefined;
readonly rules: ReadonlyArray<ValidationRule<T>>;
}
/** Options for adding a named validation rule via BaseField.addRule(). */
export interface RuleOptions<T = unknown> {
readonly name: string;
readonly message: string;
validate(value: T): boolean;
}

View File

@@ -0,0 +1,2 @@
export { BaseField } from "./field";
export type { FieldConfig, RuleOptions } from "./field.types";

View File

@@ -0,0 +1,8 @@
export { type CollectedErrors, createEnv, type EnvResult } from "./create-env";
export { BaseField, type FieldConfig, type RuleOptions } from "./field";
export {
maskSensitive,
type ValidateInput,
type ValidationResult,
validate,
} from "./validator";

View File

@@ -0,0 +1,2 @@
export { maskSensitive, validate } from "./validator";
export type { ValidateInput, ValidationResult } from "./validator.types";

View File

@@ -0,0 +1,108 @@
# validator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the core `validate` function that processes an entire env schema against a raw values map, accumulating all errors before returning. A complete error report — rather than fail-fast — is the defining characteristic of this module: developers see every broken field in a single run rather than fixing one variable and re-running to discover the next failure. The complementary `maskSensitive` function redacts sensitive field values so the parsed record can be logged safely.
## Features
- Accumulates errors across all fields before returning — no fail-fast behaviour
- Per-field processing: absence check → default resolution → coercion → rule chain
- Stops rule evaluation at the first failing rule per field (fail-fast within a field, accumulate across fields)
- Optional key prefix: looks up `${prefix}${schemaKey}` in the values map while using `schemaKey` in the output
- Graceful handling of non-`Error` coercion throws — produces `"Coercion failed"` rather than crashing
- `maskSensitive` replaces values of fields marked `_isSensitive` with `"****"`, leaving non-sensitive keys untouched
- Returns a `ValidationResult` with a boolean `success` flag, the typed `data` record, and the `errors` array
## Usage: Basic Example
```ts
import { validate } from '@suaveplan/env/core/validator/validator';
import { str, num } from '@suaveplan/env';
const result = validate({
schema: { HOST: str(), PORT: num() },
values: { HOST: 'localhost', PORT: '3000' },
});
if (result.success) {
console.log(result.data.HOST); // "localhost"
console.log(result.data.PORT); // 3000
} else {
for (const err of result.errors) {
console.error(`${err.key}: ${err.message}`);
}
}
```
## Usage: Advanced Example
```ts
import { validate, maskSensitive } from '@suaveplan/env/core/validator/validator';
import { str, num, bool } from '@suaveplan/env';
const schema = {
DB_URL: str().sensitive(),
PORT: num().default(3000),
DEBUG: bool().default(false),
};
// Prefix lookup: reads APP_DB_URL, APP_PORT, APP_DEBUG from values
const result = validate({
schema,
values: { APP_DB_URL: 'postgres://user:pass@host/db' },
prefix: 'APP_',
});
// Mask before logging
const safe = maskSensitive(schema, result.data);
console.log(safe.DB_URL); // "****"
console.log(safe.PORT); // 3000
console.log(safe.DEBUG); // false
```
## API Reference
### `validate(input: ValidateInput): ValidationResult`
Validates all fields in the schema against the values map.
**Parameters (`ValidateInput`):**
| Property | Type | Description |
|---|---|---|
| `schema` | `EnvSchema` | Record of field builders keyed by env variable name |
| `values` | `Record<string, string \| undefined>` | Raw key→value map to look up values from |
| `prefix` | `string?` | Optional prefix prepended to each schema key during lookup |
**Returns (`ValidationResult`):**
| Property | Type | Description |
|---|---|---|
| `success` | `boolean` | `true` if no errors were accumulated |
| `data` | `Record<string, unknown>` | Coerced values for all fields that resolved successfully |
| `errors` | `EnvError[]` | All per-field errors, in schema iteration order |
### `maskSensitive(schema, data): Record<string, unknown>`
Returns a shallow copy of `data` with sensitive field values replaced by `"****"`.
**Parameters:**
| Parameter | Type | Description |
|---|---|---|
| `schema` | `Record<string, AnyField>` | The same schema passed to `validate` |
| `data` | `Record<string, unknown>` | The parsed data record from `validate` |
**Returns:** A new record; the original `data` is not mutated. Keys not present in `schema` are passed through unchanged.
## Implementation Notes
`processField` is a private helper that handles the resolution order for a single field: check for absence, check for default, attempt coercion, run rules. The rule loop uses an early `return` on the first failure, so only one error per field reaches the accumulator. This avoids confusing multi-error noise for a single field while still giving the full cross-field picture.
The `err instanceof Error ? err.message : "Coercion failed"` branch in the catch block handles the edge case where a `coerce` implementation throws a non-`Error` value (e.g. a string literal). This is not a normal case for built-in fields but is possible for custom field implementations.
`maskSensitive` iterates `data` rather than `schema` so that extra keys that happen to be in `data` but not in `schema` are preserved. It checks `field?._isSensitive` with optional chaining to handle that case gracefully.

View File

@@ -0,0 +1,173 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { bool } from "../../fields/boolean-field/boolean-field.js";
import { num } from "../../fields/number-field/number-field.js";
import { str } from "../../fields/string-field/string-field.js";
import { maskSensitive, validate } from "./validator.js";
describe("validate", () => {
it("passes when all fields resolve", { timeout: 5000 }, () => {
const result = validate({
schema: { NAME: str(), AGE: num() },
values: { NAME: "alice", AGE: "30" },
});
expect(result.success).toBe(true);
expect(result.data.NAME).toBe("alice");
expect(result.data.AGE).toBe(30);
expect(result.errors.length).toBe(0);
});
it("accumulates errors for all failing fields", { timeout: 5000 }, () => {
const result = validate({
schema: {
NAME: str(),
AGE: num(),
},
values: { NAME: "", AGE: "" },
});
// NAME is empty but str() doesn't enforce nonempty by default
// AGE empty → coercion fails
expect(result.errors.length).toBeGreaterThan(0);
});
it("returns missing required error", { timeout: 5000 }, () => {
const result = validate({
schema: { HOST: str() },
values: {},
});
expect(result.success).toBe(false);
expect(result.errors[0]?.key).toBe("HOST");
expect(result.errors[0]?.message).toContain("missing");
});
it("uses default value when raw is undefined", { timeout: 5000 }, () => {
const result = validate({
schema: { DEBUG: bool().default(false) },
values: {},
});
expect(result.success).toBe(true);
expect(result.data.DEBUG).toBe(false);
});
it("returns undefined for optional field with no value", {
timeout: 5000,
}, () => {
const result = validate({
schema: { EXTRA: str().optional() },
values: {},
});
expect(result.success).toBe(true);
expect(result.data.EXTRA).toBeUndefined();
});
it("applies prefix when looking up values", { timeout: 5000 }, () => {
const result = validate({
schema: { PORT: num() },
values: { APP_PORT: "4000" },
prefix: "APP_",
});
expect(result.success).toBe(true);
expect(result.data.PORT).toBe(4000);
});
it("returns error when rule fails", { timeout: 5000 }, () => {
const result = validate({
schema: { NAME: str().minLength(5) },
values: { NAME: "hi" },
});
expect(result.success).toBe(false);
expect(result.errors[0]?.key).toBe("NAME");
expect(result.errors[0]?.message).toContain("at least 5");
});
it("accumulates multiple field errors", { timeout: 5000 }, () => {
const result = validate({
schema: { A: str(), B: num() },
values: {},
});
expect(result.errors.length).toBe(2);
});
it("stops at first rule failure per field", { timeout: 5000 }, () => {
const result = validate({
schema: { NAME: str().minLength(10).maxLength(5) },
values: { NAME: "hi" },
});
// Only one error per field (first failing rule)
expect(result.errors.length).toBe(1);
});
it("handles non-Error coercion failures gracefully", {
timeout: 5000,
}, () => {
// Create a field whose coerce() throws a non-Error value
const field = str();
(field as unknown as { coerce: (raw: string) => string }).coerce = (
_raw: string
): string => {
throw "string error" as any;
};
const result = validate({ schema: { X: field }, values: { X: "bad" } });
expect(result.success).toBe(false);
expect(result.errors[0]?.message).toBe("Coercion failed");
});
});
describe("maskSensitive", () => {
it("masks sensitive values with ****", { timeout: 5000 }, () => {
const schema = { SECRET: str().sensitive(), PUBLIC: str() };
const data = { SECRET: "my-secret", PUBLIC: "public-value" };
const masked = maskSensitive(schema, data);
expect(masked.SECRET).toBe("****");
expect(masked.PUBLIC).toBe("public-value");
});
it("does not mask non-sensitive values", { timeout: 5000 }, () => {
const schema = { PORT: num() };
const data = { PORT: 3000 };
const masked = maskSensitive(schema, data);
expect(masked.PORT).toBe(3000);
});
it("handles keys not in schema", { timeout: 5000 }, () => {
const schema = { A: str() };
const data = { A: "hello", B: "extra" };
const masked = maskSensitive(schema, data);
expect(masked.A).toBe("hello");
expect(masked.B).toBe("extra");
});
});
describe("validate — property-based tests", () => {
it("if N rules fail, all N errors are accumulated via separate fields", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(fc.integer({ min: 1, max: 6 }), (n) => {
// Build n required string fields, all missing
const schema: Record<string, ReturnType<typeof str>> = {};
for (let i = 0; i < n; i++) {
schema[`FIELD_${i}`] = str();
}
const result = validate({ schema, values: {} });
expect(result.errors.length).toBe(n);
}),
{ numRuns: 50 }
);
});
it("maskSensitive never leaks sensitive raw values", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(fc.string({ minLength: 1 }), (rawSecret) => {
const schema = { SECRET: str().sensitive() };
const data = { SECRET: rawSecret };
const masked = maskSensitive(schema, data);
expect(masked.SECRET).not.toBe(rawSecret);
expect(masked.SECRET).toBe("****");
}),
{ numRuns: 100 }
);
});
});

View File

@@ -0,0 +1,103 @@
import { createEnvError } from "../../errors/env-error.js";
import type { AnyField, EnvError } from "../../types/common.js";
import type { ValidateInput, ValidationResult } from "./validator.types.js";
/**
* Process a single field against its raw value.
*
* Returns either the coerced value or an error object.
*/
function processField(
key: string,
field: AnyField,
raw: string | undefined
): { value?: unknown; error?: EnvError } {
if (raw === undefined || raw === "") {
if (field._hasDefault) {
return { value: field._defaultValue };
}
if (field._isRequired) {
return {
error: createEnvError(key, "Required variable is missing"),
};
}
return { value: undefined };
}
let coerced: unknown;
try {
coerced = field.coerce(raw);
} catch (err) {
const message = err instanceof Error ? err.message : "Coercion failed";
return { error: createEnvError(key, message, raw) };
}
for (const rule of field._rules) {
if (!rule.validate(coerced as never)) {
return {
error: createEnvError(key, rule.message, raw),
};
}
}
return { value: coerced };
}
/**
* Validates a set of environment variables against a schema.
*
* Iterates over every field in the schema, resolves the raw value
* from the provided values map (applying optional prefix), coerces
* each raw string, and runs all validation rules. All errors are
* accumulated before returning so the caller sees the full picture.
*
* @param input - The schema, values map, and optional prefix.
* @returns A result containing parsed data and any accumulated errors.
*/
export function validate(input: ValidateInput): ValidationResult {
const { schema, values, prefix } = input;
const errors: EnvError[] = [];
const data: Record<string, unknown> = {};
for (const [schemaKey, field] of Object.entries(schema)) {
const lookupKey = prefix ? `${prefix}${schemaKey}` : schemaKey;
const raw = values[lookupKey];
const { value, error } = processField(schemaKey, field, raw);
if (error) {
errors.push(error);
} else {
data[schemaKey] = value;
}
}
return {
success: errors.length === 0,
data,
errors,
};
}
/**
* Replaces sensitive field values with `****` in a data record.
*
* Used before logging or surfacing parsed env values to prevent
* secrets from leaking into logs or error messages.
*
* @param schema - The env schema providing sensitivity flags.
* @param data - The parsed data record to mask.
* @returns A new record with sensitive values replaced by `****`.
*/
export function maskSensitive(
schema: Record<string, AnyField>,
data: Record<string, unknown>
): Record<string, unknown> {
const masked: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data)) {
const field = schema[key];
masked[key] = field?._isSensitive ? "****" : value;
}
return masked;
}

View File

@@ -0,0 +1,15 @@
import type { EnvError, EnvSchema } from "../../types/common.js";
/** The result of validating a full set of environment variables. */
export interface ValidationResult<T = Record<string, unknown>> {
readonly success: boolean;
readonly data: T;
readonly errors: ReadonlyArray<EnvError>;
}
/** Input to the validate() function. */
export interface ValidateInput {
readonly schema: EnvSchema;
readonly values: Record<string, string | undefined>;
readonly prefix?: string;
}

View File

@@ -0,0 +1,106 @@
# env-error
## Purpose
Provides the complete error class hierarchy and a value-object factory for `@suaveplan/env`. All classes extend `SuaveplanError` from `@suaveplan/error` via the `EnvBaseError` abstract base, ensuring every thrown error carries a stable machine-readable `ENV.*` code, a human-readable message, and optional cause chaining. Structured error classes make it possible for callers to programmatically distinguish between a missing variable, a coercion failure, a format violation, and a dotenv parse error without string-matching.
## Features
- `EnvBaseError` abstract base typed to the `EnvCode` union — all concrete classes extend this
- `EnvValidationError` — aggregates multiple per-field errors into a single throw; exposes `errors: ReadonlyArray<EnvError>` for structured inspection
- `MissingEnvError` — carries the `key` string of the absent required variable
- `CoercionError` — carries `key`, `rawValue`, and `targetType` for precise diagnostic messages
- `DotenvParseError` — carries `lineNumber` for easy file-navigation on parse failures
- `createEnvError` — plain factory producing the `EnvError` value-object interface shape used by the validator (no class instantiation needed for accumulation)
## Usage: Basic Example
```ts
import {
EnvValidationError,
MissingEnvError,
CoercionError,
DotenvParseError,
createEnvError,
} from '@suaveplan/env/errors';
// Thrown automatically by createEnv when onError === 'throw'
try {
throw new EnvValidationError([
{ key: 'PORT', message: 'Required variable is missing' },
]);
} catch (err) {
if (err instanceof EnvValidationError) {
for (const e of err.errors) {
console.error(`${e.key}: ${e.message}`);
}
}
}
```
## Usage: Advanced Example
```ts
import { CoercionError, createEnvError } from '@suaveplan/env/errors';
// Build a value-object array to pass to EnvValidationError
const errors = [];
// createEnvError returns a plain object — used internally by the validator
errors.push(createEnvError('HOST', 'Required variable is missing'));
errors.push(createEnvError('PORT', 'Cannot coerce "abc" to number', 'abc'));
// CoercionError carries structured fields for programmatic use
const err = new CoercionError('PORT', 'abc', 'number');
console.log(err.code); // "ENV.COERCE_ERROR"
console.log(err.key); // "PORT"
console.log(err.rawValue); // "abc"
console.log(err.targetType); // "number"
```
## API Reference
### `EnvBaseError` (abstract class)
Extends `SuaveplanError<EnvCode>`. All env error classes extend this. Not directly instantiable.
### `EnvValidationError`
`constructor(errors: ReadonlyArray<EnvError>, options?: SuaveplanErrorOptions)`
- `errors` — structured list of per-field failures, also available as `err.errors`
- Code: `ENV.VALIDATION_FAILED`
- Message: multiline listing each failing field
### `MissingEnvError`
`constructor(key: string, options?: SuaveplanErrorOptions)`
- `key` — name of the missing environment variable, also available as `err.key`
- Code: `ENV.MISSING_REQUIRED`
### `CoercionError`
`constructor(key: string, rawValue: string, targetType: string, options?: SuaveplanErrorOptions)`
- `key` — variable name (may be empty string when thrown from a coerce function)
- `rawValue` — the string that could not be converted
- `targetType` — description of the expected type (e.g. `"number"`, `"URL"`, `"one of: a, b"`)
- Code: `ENV.COERCE_ERROR`
### `DotenvParseError`
`constructor(message: string, lineNumber: number, options?: SuaveplanErrorOptions)`
- `lineNumber` — 1-based line number in the dotenv file where the error occurred
- Code: `ENV.PARSE_ERROR`
### `createEnvError(key, message, value?)`
Returns `EnvError` (plain interface, not a class). Conditionally includes `value` only when defined to satisfy `exactOptionalPropertyTypes`. Used internally by the validator to accumulate errors without class instantiation overhead.
## Implementation Notes
The abstract base class is named `EnvBaseError` rather than `EnvError` to avoid a duplicate-identifier conflict with the `EnvError` interface exported from `types/common.ts`. The `EnvError` interface represents a plain value object `{ key, message, value? }` used for accumulation, while `EnvBaseError` (and its subclasses) are throwable classes.
`createEnvError` uses a conditional return (`if (value !== undefined)`) to avoid assigning `string | undefined` to the optional `value` property under TypeScript's `exactOptionalPropertyTypes` mode, which disallows explicit `undefined` assignments to optional fields.

View File

@@ -0,0 +1,158 @@
import { SuaveplanError } from "@suaveplan/error";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CoercionError,
createEnvError,
DotenvParseError,
EnvBaseError,
EnvValidationError,
MissingEnvError,
} from "./env-error.js";
import { ENV_CODES } from "./error-codes.js";
describe("ENV_CODES", () => {
it("all codes have ENV. prefix", { timeout: 5000 }, () => {
for (const code of Object.values(ENV_CODES)) {
expect(code).toMatch(/^ENV\./);
}
});
it("has expected codes", { timeout: 5000 }, () => {
expect(ENV_CODES.VALIDATION_FAILED).toBe("ENV.VALIDATION_FAILED");
expect(ENV_CODES.MISSING_REQUIRED).toBe("ENV.MISSING_REQUIRED");
expect(ENV_CODES.INVALID_FORMAT).toBe("ENV.INVALID_FORMAT");
expect(ENV_CODES.PARSE_ERROR).toBe("ENV.PARSE_ERROR");
expect(ENV_CODES.COERCE_ERROR).toBe("ENV.COERCE_ERROR");
});
});
describe("EnvValidationError", () => {
it("extends SuaveplanError and EnvBaseError", { timeout: 5000 }, () => {
const err = new EnvValidationError([
{ key: "PORT", message: "Required variable is missing" },
]);
expect(err).toBeInstanceOf(SuaveplanError);
expect(err).toBeInstanceOf(EnvBaseError);
expect(err).toBeInstanceOf(EnvValidationError);
});
it("sets code to VALIDATION_FAILED", { timeout: 5000 }, () => {
const err = new EnvValidationError([]);
expect(err.code).toBe(ENV_CODES.VALIDATION_FAILED);
});
it("stores errors array", { timeout: 5000 }, () => {
const errors = [
{ key: "HOST", message: "Required" },
{ key: "PORT", message: "Invalid" },
];
const err = new EnvValidationError(errors);
expect(err.errors).toEqual(errors);
expect(err.errors.length).toBe(2);
});
it("message includes all field errors", { timeout: 5000 }, () => {
const err = new EnvValidationError([
{ key: "PORT", message: "Must be a number" },
]);
expect(err.message).toContain("PORT");
expect(err.message).toContain("Must be a number");
});
it("name is EnvValidationError", { timeout: 5000 }, () => {
const err = new EnvValidationError([]);
expect(err.name).toBe("EnvValidationError");
});
});
describe("MissingEnvError", () => {
it("extends EnvBaseError", { timeout: 5000 }, () => {
const err = new MissingEnvError("DATABASE_URL");
expect(err).toBeInstanceOf(EnvBaseError);
expect(err).toBeInstanceOf(MissingEnvError);
});
it("sets code to MISSING_REQUIRED", { timeout: 5000 }, () => {
const err = new MissingEnvError("SECRET");
expect(err.code).toBe(ENV_CODES.MISSING_REQUIRED);
});
it("stores key", { timeout: 5000 }, () => {
const err = new MissingEnvError("MY_VAR");
expect(err.key).toBe("MY_VAR");
});
it("message includes variable name", { timeout: 5000 }, () => {
const err = new MissingEnvError("MY_VAR");
expect(err.message).toContain("MY_VAR");
});
});
describe("CoercionError", () => {
it("extends EnvBaseError", { timeout: 5000 }, () => {
const err = new CoercionError("PORT", "abc", "number");
expect(err).toBeInstanceOf(EnvBaseError);
expect(err).toBeInstanceOf(CoercionError);
});
it("sets code to COERCE_ERROR", { timeout: 5000 }, () => {
const err = new CoercionError("", "bad", "number");
expect(err.code).toBe(ENV_CODES.COERCE_ERROR);
});
it("stores key, rawValue, targetType", { timeout: 5000 }, () => {
const err = new CoercionError("PORT", "abc", "number");
expect(err.key).toBe("PORT");
expect(err.rawValue).toBe("abc");
expect(err.targetType).toBe("number");
});
it("message includes rawValue and targetType", { timeout: 5000 }, () => {
const err = new CoercionError("", "abc", "number");
expect(err.message).toContain("abc");
expect(err.message).toContain("number");
});
it("message includes key when provided", { timeout: 5000 }, () => {
const err = new CoercionError("PORT", "abc", "number");
expect(err.message).toContain("PORT");
});
});
describe("DotenvParseError", () => {
it("extends EnvBaseError", { timeout: 5000 }, () => {
const err = new DotenvParseError("unexpected token", 5);
expect(err).toBeInstanceOf(EnvBaseError);
expect(err).toBeInstanceOf(DotenvParseError);
});
it("sets code to PARSE_ERROR", { timeout: 5000 }, () => {
const err = new DotenvParseError("bad line", 1);
expect(err.code).toBe(ENV_CODES.PARSE_ERROR);
});
it("stores lineNumber", { timeout: 5000 }, () => {
const err = new DotenvParseError("bad line", 42);
expect(err.lineNumber).toBe(42);
});
it("message includes lineNumber and message", { timeout: 5000 }, () => {
const err = new DotenvParseError("unexpected token", 7);
expect(err.message).toContain("7");
expect(err.message).toContain("unexpected token");
});
});
describe("createEnvError", () => {
it("returns a plain EnvError value object", { timeout: 5000 }, () => {
const ev = createEnvError("PORT", "Required", "bad");
expect(ev.key).toBe("PORT");
expect(ev.message).toBe("Required");
expect(ev.value).toBe("bad");
});
it("value is optional", { timeout: 5000 }, () => {
const ev = createEnvError("HOST", "Missing");
expect(ev.value).toBeUndefined();
});
});

View File

@@ -0,0 +1,114 @@
import { SuaveplanError, type SuaveplanErrorOptions } from "@suaveplan/error";
import type { EnvError as EnvErrorValue } from "../types/common.js";
import { ENV_CODES, type EnvCode } from "./error-codes.js";
/**
* Abstract base class for all @suaveplan/env error classes.
*
* Extends SuaveplanError with the ENV code namespace. All concrete
* error classes in this package extend EnvBaseError.
*/
export abstract class EnvBaseError extends SuaveplanError<EnvCode> {
protected constructor(
code: EnvCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
}
}
/**
* Thrown when one or more fields fail validation and
* `onError` is set to `'throw'` (the default).
*/
export class EnvValidationError extends EnvBaseError {
readonly errors: ReadonlyArray<EnvErrorValue>;
constructor(
errors: ReadonlyArray<EnvErrorValue>,
options?: SuaveplanErrorOptions
) {
const lines = errors.map((e) => ` - ${e.key}: ${e.message}`);
super(
ENV_CODES.VALIDATION_FAILED,
`Environment validation failed:\n${lines.join("\n")}`,
options
);
this.errors = errors;
}
}
/**
* Thrown when a required environment variable is absent and
* has no default value.
*/
export class MissingEnvError extends EnvBaseError {
readonly key: string;
constructor(key: string, options?: SuaveplanErrorOptions) {
super(
ENV_CODES.MISSING_REQUIRED,
`Missing required environment variable: ${key}`,
options
);
this.key = key;
}
}
/**
* Thrown when a raw string cannot be coerced to the expected type.
*/
export class CoercionError extends EnvBaseError {
readonly key: string;
readonly rawValue: string;
readonly targetType: string;
constructor(
key: string,
rawValue: string,
targetType: string,
options?: SuaveplanErrorOptions
) {
super(
ENV_CODES.COERCE_ERROR,
`Cannot coerce "${rawValue}" to ${targetType}${key ? ` for variable ${key}` : ""}`,
options
);
this.key = key;
this.rawValue = rawValue;
this.targetType = targetType;
}
}
/**
* Thrown when a dotenv file cannot be parsed due to malformed syntax.
*/
export class DotenvParseError extends EnvBaseError {
readonly lineNumber: number;
constructor(
message: string,
lineNumber: number,
options?: SuaveplanErrorOptions
) {
super(
ENV_CODES.PARSE_ERROR,
`Dotenv parse error at line ${lineNumber}: ${message}`,
options
);
this.lineNumber = lineNumber;
}
}
/** Creates a standardised EnvError value object (not a class instance). */
export function createEnvError(
key: string,
message: string,
value?: string
): EnvErrorValue {
if (value !== undefined) {
return { key, message, value };
}
return { key, message };
}

View File

@@ -0,0 +1,69 @@
# error-codes
## Purpose
Defines and registers all stable error codes for the `@suaveplan/env` package using the `defineCodes` factory from `@suaveplan/error`. Grouping codes under the `ENV` namespace ensures they are globally unique across the entire project and prevents collisions with error codes from other packages. Every concrete error class in this package uses one of these codes.
## Features
- Five registered codes under the `ENV` namespace: `VALIDATION_FAILED`, `MISSING_REQUIRED`, `INVALID_FORMAT`, `PARSE_ERROR`, `COERCE_ERROR`
- Fully literal-inferred return type — the TypeScript type of each code is its exact string value, not `string`
- `EnvCode` union type derived from the registry for use in function signatures and switch statements
- Zero runtime overhead beyond the single `defineCodes` call at module initialisation
## Usage: Basic Example
```ts
import { ENV_CODES, type EnvCode } from '@suaveplan/env/errors';
// Access a specific code
console.log(ENV_CODES.VALIDATION_FAILED); // "ENV.VALIDATION_FAILED"
console.log(ENV_CODES.MISSING_REQUIRED); // "ENV.MISSING_REQUIRED"
console.log(ENV_CODES.COERCE_ERROR); // "ENV.COERCE_ERROR"
```
## Usage: Advanced Example
```ts
import { ENV_CODES, type EnvCode } from '@suaveplan/env/errors';
// Use EnvCode in a type-safe switch
function describe(code: EnvCode): string {
switch (code) {
case ENV_CODES.VALIDATION_FAILED:
return 'One or more fields failed validation';
case ENV_CODES.MISSING_REQUIRED:
return 'A required variable was absent';
case ENV_CODES.INVALID_FORMAT:
return 'The value did not match the expected format';
case ENV_CODES.PARSE_ERROR:
return 'The dotenv file contained malformed syntax';
case ENV_CODES.COERCE_ERROR:
return 'The raw string could not be coerced to the target type';
}
}
```
## API Reference
### `ENV_CODES`
`Record<string, string>` — object mapping each code name to its namespaced string value.
| Name | Value |
|---|---|
| `VALIDATION_FAILED` | `"ENV.VALIDATION_FAILED"` |
| `MISSING_REQUIRED` | `"ENV.MISSING_REQUIRED"` |
| `INVALID_FORMAT` | `"ENV.INVALID_FORMAT"` |
| `PARSE_ERROR` | `"ENV.PARSE_ERROR"` |
| `COERCE_ERROR` | `"ENV.COERCE_ERROR"` |
### `EnvCode`
`type EnvCode = (typeof ENV_CODES)[keyof typeof ENV_CODES]`
String union of all registered ENV error code values. Use this as the type for `code` parameters in functions that accept or return env errors.
## Implementation Notes
`defineCodes` from `@suaveplan/error` takes a namespace prefix and a `const` tuple of code names. It returns an object whose values are `"${namespace}.${name}"` strings with each value typed as a string literal, so `typeof ENV_CODES.VALIDATION_FAILED` is `"ENV.VALIDATION_FAILED"` rather than `string`. The `as const` assertion on the array is required to trigger literal inference. The `EnvCode` type is derived from the object rather than declared independently to ensure it stays in sync automatically as codes are added or removed.

View File

@@ -0,0 +1,18 @@
import { defineCodes } from "@suaveplan/error";
/**
* Registered error codes for the @suaveplan/env package.
*
* All codes live under the `ENV` namespace and are produced by
* `defineCodes`, so the return type is fully literal-inferred.
*/
export const ENV_CODES = defineCodes("ENV", [
"VALIDATION_FAILED",
"MISSING_REQUIRED",
"INVALID_FORMAT",
"PARSE_ERROR",
"COERCE_ERROR",
] as const);
/** Union of every ENV error code string. */
export type EnvCode = (typeof ENV_CODES)[keyof typeof ENV_CODES];

View File

@@ -0,0 +1,9 @@
export {
CoercionError,
createEnvError,
DotenvParseError,
EnvBaseError,
EnvValidationError,
MissingEnvError,
} from "./env-error";
export { ENV_CODES, type EnvCode } from "./error-codes";

View File

@@ -0,0 +1,91 @@
# array-field
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ArrayField` handles environment variables that encode multiple values in a single string by separating them with a delimiter (most commonly a comma). It delegates element-level coercion to any other field builder, enabling typed arrays of strings, numbers, booleans, URLs, or any custom field type. This composition pattern — wrapping an existing field — keeps `ArrayField` focused on splitting and filtering while the item field handles type coercion.
## Features
- Splits on a configurable separator (default: `","`)
- Trims whitespace from each item by default (disable with `noTrim()`)
- Filters empty strings after splitting by default (disable with `keepEmpty()`)
- `minItems(n)` / `maxItems(n)` — validate the resulting array length
- Item-level coercion and validation errors bubble up from the item field's `coerce()` call
- `optional()` / `default(value: T[])` / `sensitive()` inherited from `BaseField`
- The output type `T[]` is inferred from the item field's generic parameter
## Usage: Basic Example
```ts
import { array, str } from '@suaveplan/env';
const field = array(str());
console.log(field.coerce('a,b,c')); // ["a", "b", "c"]
console.log(field.coerce(' a , b , c ')); // ["a", "b", "c"] (trimmed)
console.log(field.coerce('')); // []
```
## Usage: Advanced Example
```ts
import { createEnv, array, str, num, url } from '@suaveplan/env';
const env = createEnv({
schema: {
ALLOWED_HOSTS: array(str())
.minItems(1)
.default(['localhost']),
REDIS_PORTS: array(num().integer().min(1).max(65535))
.separator(':')
.default([6379]),
CORS_ORIGINS: array(url().protocols(['https', 'http']))
.minItems(1)
.maxItems(10),
// Semicolon-separated, keeping empty strings
RAW_TAGS: array(str()).separator(';').noTrim().keepEmpty(),
},
source: process.env,
});
// env.ALLOWED_HOSTS: string[]
// env.REDIS_PORTS: number[]
// env.CORS_ORIGINS: string[]
```
## API Reference
### `array<T>(itemField: AnyField<T>): ArrayField<T, true>`
Factory function. Takes any field builder as the item coercer and returns an `ArrayField<T, true>`.
### `class ArrayField<T, TRequired extends boolean = true>`
Extends `BaseField<T[], TRequired>`.
| Method | Returns | Description |
|---|---|---|
| `separator(sep)` | `this` | Override the split delimiter (default `","`) |
| `noTrim()` | `this` | Disable whitespace trimming of items |
| `keepEmpty()` | `this` | Retain empty strings after splitting |
| `minItems(n)` | `this` | Add a minimum array length rule |
| `maxItems(n)` | `this` | Add a maximum array length rule |
| `required()` | `ArrayField<T, true>` | Mark as required |
| `optional()` | `ArrayField<T, false>` | Mark as optional |
| `default(value: T[])` | `ArrayField<T, true>` | Set a default array (already-coerced values) |
### `coerce(raw: string): T[]`
Splits on `_separator`, optionally trims each part, optionally filters empty parts, then maps each non-empty part through `_itemField.coerce()`. Returns an empty array `[]` when the input is entirely empty after splitting and filtering.
**Throws:** Propagates any `CoercionError` thrown by `_itemField.coerce()` for individual items.
## Implementation Notes
`coerce` returns an empty array for empty input rather than throwing. This is correct because the field may be required but validly set to an empty string; `minItems(1)` is the mechanism to enforce non-empty arrays. An absent (undefined) required `ArrayField` still produces a "missing required" error — the empty array shortcut only applies when the raw value is present but blank.
`default()` takes an already-coerced `T[]` value. The default bypasses `coerce` and goes directly into the output, which is consistent with all other field builders. `minItems` and `maxItems` rules are not applied to the default value.
Item coercion errors are not caught by `ArrayField.coerce` — they propagate up to the `processField` function in `validator.ts`, which records them as field errors. The `key` in the resulting `CoercionError` will be the schema key of the `ArrayField`, not an item index, which is a known limitation when diagnosing which element failed.

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CoercionError } from "../../errors/env-error.js";
import { num } from "../number-field/number-field.js";
import { str } from "../string-field/string-field.js";
import { ArrayField, array } from "./array-field.js";
describe("array / ArrayField", () => {
it("factory returns ArrayField instance", { timeout: 5000 }, () => {
expect(array(str())).toBeInstanceOf(ArrayField);
});
it("splits on comma by default", { timeout: 5000 }, () => {
expect(array(str()).coerce("a,b,c")).toEqual(["a", "b", "c"]);
});
it("trims items by default", { timeout: 5000 }, () => {
expect(array(str()).coerce(" a , b , c ")).toEqual(["a", "b", "c"]);
});
it("filters empty strings by default", { timeout: 5000 }, () => {
expect(array(str()).coerce("a,,b")).toEqual(["a", "b"]);
});
it("empty raw returns empty array", { timeout: 5000 }, () => {
expect(array(str()).coerce("")).toEqual([]);
expect(array(str()).coerce(" ")).toEqual([]);
});
it("coerces items with number field", { timeout: 5000 }, () => {
expect(array(num()).coerce("1,2,3")).toEqual([1, 2, 3]);
});
it("throws CoercionError when item coercion fails", {
timeout: 5000,
}, () => {
expect(() => array(num()).coerce("1,abc,3")).toThrow(CoercionError);
});
it("separator() uses custom delimiter", { timeout: 5000 }, () => {
expect(array(str()).separator("|").coerce("a|b|c")).toEqual([
"a",
"b",
"c",
]);
});
it("noTrim() preserves whitespace in items", { timeout: 5000 }, () => {
expect(array(str()).noTrim().coerce(" a , b ")).toEqual([" a ", " b "]);
});
it("keepEmpty() preserves empty strings", { timeout: 5000 }, () => {
const result = array(str()).keepEmpty().coerce("a,,b");
expect(result).toContain("");
});
it("minItems() rule", { timeout: 5000 }, () => {
const f = array(str()).minItems(2);
expect(f._rules[0]!.validate(["a", "b"])).toBe(true);
expect(f._rules[0]!.validate(["a"])).toBe(false);
});
it("maxItems() rule", { timeout: 5000 }, () => {
const f = array(str()).maxItems(2);
expect(f._rules[0]!.validate(["a", "b"])).toBe(true);
expect(f._rules[0]!.validate(["a", "b", "c"])).toBe(false);
});
it("optional() marks optional", { timeout: 5000 }, () => {
const f = array(str()).optional();
expect(f._isRequired).toBe(false);
});
it("default() sets default", { timeout: 5000 }, () => {
const f = array(str()).default(["x"]);
expect(f._defaultValue).toEqual(["x"]);
});
it("required() marks required", { timeout: 5000 }, () => {
const f = array(str()).optional().required();
expect(f._isRequired).toBe(true);
});
});

View File

@@ -0,0 +1,96 @@
import { DEFAULT_ARRAY_SEPARATOR } from "../../constants/index.js";
import { BaseField } from "../../core/field/field.js";
import type { AnyField } from "../../types/common.js";
/**
* A field builder for array environment variables.
*
* Splits a delimited string and coerces each element through a
* provided item field builder. Supports custom separators, trim
* control, and min/max item count rules.
*
* @typeParam T - The type of each array element.
*/
export class ArrayField<T, TRequired extends boolean = true> extends BaseField<
T[],
TRequired
> {
protected readonly _itemField: AnyField<T>;
protected _separator: string = DEFAULT_ARRAY_SEPARATOR;
protected _trimItems = true;
protected _filterEmpty = true;
constructor(itemField: AnyField<T>) {
super();
this._itemField = itemField;
}
required(): ArrayField<T, true> {
this._markRequired();
return this as unknown as ArrayField<T, true>;
}
optional(): ArrayField<T, false> {
this._markOptional();
return this as unknown as ArrayField<T, false>;
}
default(value: T[]): ArrayField<T, true> {
this._setDefault(value);
return this as unknown as ArrayField<T, true>;
}
/** Override the separator used to split the raw string. */
separator(sep: string): this {
this._separator = sep;
return this;
}
/** Disable automatic whitespace trimming of individual items. */
noTrim(): this {
this._trimItems = false;
return this;
}
/** Keep empty strings after splitting instead of filtering them out. */
keepEmpty(): this {
this._filterEmpty = false;
return this;
}
/** Require at least `n` items in the resulting array. */
minItems(n: number): this {
this._rules.push({
name: "minItems",
message: `Must have at least ${n} items`,
validate: (v) => v.length >= n,
});
return this;
}
/** Require at most `n` items in the resulting array. */
maxItems(n: number): this {
this._rules.push({
name: "maxItems",
message: `Must have at most ${n} items`,
validate: (v) => v.length <= n,
});
return this;
}
coerce(raw: string): T[] {
let parts = raw.split(this._separator);
if (this._trimItems) {
parts = parts.map((p) => p.trim());
}
if (this._filterEmpty) {
parts = parts.filter((p) => p.length > 0);
}
return parts.map((p) => this._itemField.coerce(p));
}
}
/** Create an array field builder with an item field applied to each element. */
export function array<T>(itemField: AnyField<T>): ArrayField<T, true> {
return new ArrayField(itemField);
}

View File

@@ -0,0 +1,7 @@
import type { AnyField } from "../../types/common.js";
/**
* A minimal field descriptor applied to each element of an ArrayField.
* Typically a concrete field builder like `str()`, `num()`, etc.
*/
export type ItemField<T> = AnyField<T>;

View File

@@ -0,0 +1,2 @@
export { ArrayField, array } from "./array-field";
export type { ItemField } from "./array-field.types";

View File

@@ -0,0 +1,84 @@
# boolean-field
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`BooleanField` converts environment variable strings to JavaScript `boolean` values using configurable sets of truthy and falsy tokens. Environment variables rarely contain `"true"` or `"false"` verbatim; operators use `"1"`, `"yes"`, `"on"`, `"enabled"`, and their opposites. `BooleanField` recognises all of these by default and allows the caller to replace the token sets entirely when a project uses a different convention.
## Features
- Default truthy tokens: `"true"`, `"1"`, `"yes"`, `"on"`, `"enabled"`
- Default falsy tokens: `"false"`, `"0"`, `"no"`, `"off"`, `"disabled"`, `""`
- Case-insensitive comparison — input is lowercased before lookup
- `truthy(values)` — replace the entire set of truthy tokens
- `falsy(values)` — replace the entire set of falsy tokens
- Throws `CoercionError` with a descriptive message listing all accepted tokens when the input matches neither set
- `optional()` / `default(value)` / `sensitive()` inherited from `BaseField`
## Usage: Basic Example
```ts
import { bool } from '@suaveplan/env';
const field = bool().default(false);
console.log(field.coerce('true')); // true
console.log(field.coerce('1')); // true
console.log(field.coerce('yes')); // true
console.log(field.coerce('FALSE')); // false (case insensitive)
console.log(field.coerce('off')); // false
console.log(field._defaultValue); // false
```
## Usage: Advanced Example
```ts
import { createEnv, bool } from '@suaveplan/env';
const env = createEnv({
schema: {
DEBUG: bool().default(false),
// Custom token sets for a legacy system that uses Y/N
FEATURE_FLAG: bool()
.truthy(['Y', 'YES', 'TRUE'])
.falsy(['N', 'NO', 'FALSE'])
.default(false),
},
source: process.env,
});
// env.DEBUG and env.FEATURE_FLAG are both typed as boolean
```
## API Reference
### `bool(): BooleanField<true>`
Factory function. Returns a new `BooleanField` with the default truthy/falsy sets and `_isRequired = true`.
### `class BooleanField<TRequired extends boolean = true>`
Extends `BaseField<boolean, TRequired>`.
| Method | Returns | Description |
|---|---|---|
| `truthy(values)` | `this` | Replace the truthy token set (lowercased internally) |
| `falsy(values)` | `this` | Replace the falsy token set (lowercased internally) |
| `required()` | `BooleanField<true>` | Mark as required |
| `optional()` | `BooleanField<false>` | Mark as optional |
| `default(value)` | `BooleanField<true>` | Set a boolean default |
### `coerce(raw: string): boolean`
Trims and lowercases the input, then checks `_truthyValues.has(lower)` and `_falsyValues.has(lower)`. Returns `true`, `false`, or throws.
**Throws:** `CoercionError` listing all accepted tokens when neither set matches.
## Implementation Notes
Internally `_truthyValues` and `_falsyValues` are `Set<string>` instances for O(1) lookup, initialised from the `TRUTHY_VALUES` and `FALSY_VALUES` readonly arrays defined in `constants/index.ts`. The `truthy()` and `falsy()` methods replace the sets entirely rather than merging — this prevents accidental accumulation of tokens when chaining and makes the configuration explicit.
The error message in `CoercionError` includes `[...this._truthyValues, ...this._falsyValues].join(", ")` so the user sees exactly which tokens are accepted. This is more helpful than a generic `"expected boolean"` message, especially when custom token sets are in use.
The `coerce` function in `transforms/coerce/coerce.ts` implements a simpler boolean coercion (using array `.includes()` on the constants) for use outside of `BooleanField`. `BooleanField.coerce` always uses the instance's `Set` members, so custom token sets specified via `truthy()`/`falsy()` are respected.

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FALSY_VALUES, TRUTHY_VALUES } from "../../constants/index.js";
import { CoercionError } from "../../errors/env-error.js";
import { BooleanField, bool } from "./boolean-field.js";
describe("bool / BooleanField", () => {
it("factory returns BooleanField instance", { timeout: 5000 }, () => {
expect(bool()).toBeInstanceOf(BooleanField);
});
it("coerces all TRUTHY_VALUES to true", { timeout: 5000 }, () => {
for (const v of TRUTHY_VALUES) {
expect(bool().coerce(v)).toBe(true);
}
});
it("coerces all FALSY_VALUES to false", { timeout: 5000 }, () => {
for (const v of FALSY_VALUES) {
expect(bool().coerce(v)).toBe(false);
}
});
it("coerces case-insensitively", { timeout: 5000 }, () => {
expect(bool().coerce("TRUE")).toBe(true);
expect(bool().coerce("FALSE")).toBe(false);
expect(bool().coerce("YES")).toBe(true);
});
it("throws CoercionError for unknown value", { timeout: 5000 }, () => {
expect(() => bool().coerce("maybe")).toThrow(CoercionError);
});
it("truthy() overrides truthy set", { timeout: 5000 }, () => {
const f = bool().truthy(["yep"]);
expect(f.coerce("yep")).toBe(true);
expect(() => f.coerce("true")).toThrow(CoercionError);
});
it("falsy() overrides falsy set", { timeout: 5000 }, () => {
const f = bool().falsy(["nope"]);
expect(f.coerce("nope")).toBe(false);
expect(() => f.coerce("false")).toThrow(CoercionError);
});
it("optional() marks optional", { timeout: 5000 }, () => {
const f = bool().optional();
expect(f._isRequired).toBe(false);
});
it("default() sets boolean default", { timeout: 5000 }, () => {
const f = bool().default(true);
expect(f._defaultValue).toBe(true);
});
it("required() marks required", { timeout: 5000 }, () => {
const f = bool().optional().required();
expect(f._isRequired).toBe(true);
});
});

View File

@@ -0,0 +1,60 @@
import { FALSY_VALUES, TRUTHY_VALUES } from "../../constants/index.js";
import { BaseField } from "../../core/field/field.js";
import { CoercionError } from "../../errors/env-error.js";
/**
* A field builder for boolean environment variables.
*
* Recognises common truthy/falsy string representations and
* supports overriding the accepted value sets.
*/
export class BooleanField<TRequired extends boolean = true> extends BaseField<
boolean,
TRequired
> {
protected _truthyValues: Set<string> = new Set(TRUTHY_VALUES);
protected _falsyValues: Set<string> = new Set(FALSY_VALUES);
required(): BooleanField<true> {
this._markRequired();
return this as unknown as BooleanField<true>;
}
optional(): BooleanField<false> {
this._markOptional();
return this as unknown as BooleanField<false>;
}
default(value: boolean): BooleanField<true> {
this._setDefault(value);
return this as unknown as BooleanField<true>;
}
/** Override the set of strings coerced to `true`. */
truthy(values: string[]): this {
this._truthyValues = new Set(values.map((v) => v.toLowerCase()));
return this;
}
/** Override the set of strings coerced to `false`. */
falsy(values: string[]): this {
this._falsyValues = new Set(values.map((v) => v.toLowerCase()));
return this;
}
coerce(raw: string): boolean {
const lower = raw.trim().toLowerCase();
if (this._truthyValues.has(lower)) return true;
if (this._falsyValues.has(lower)) return false;
throw new CoercionError(
"",
raw,
`boolean (expected one of: ${[...this._truthyValues, ...this._falsyValues].join(", ")})`
);
}
}
/** Create a boolean field builder. */
export function bool(): BooleanField<true> {
return new BooleanField();
}

View File

@@ -0,0 +1 @@
export { BooleanField, bool } from "./boolean-field";

View File

@@ -0,0 +1,84 @@
# email-field
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`EmailField` validates that an environment variable contains a syntactically plausible email address before returning it as a string. It uses a regex pattern that covers the vast majority of real-world email addresses (<local-part@domain.tld>) without the complexity of full RFC 5321 compliance, striking a practical balance between correctness and simplicity. It is the appropriate field to use for `SMTP_FROM`, notification sender addresses, and any other configuration that requires a contact email.
## Features
- Validates against a built-in `EMAIL_PATTERN` regex covering standard email formats
- `normalise()` — lowercases the entire address before returning (useful for case-insensitive comparison later)
- `pattern(regex)` — swap in a custom validation regex when stricter or looser checking is needed
- `optional()` / `default(value)` / `sensitive()` inherited from `BaseField`
- Throws `CoercionError` with `targetType = "email address"` for invalid inputs
## Usage: Basic Example
```ts
import { email } from '@suaveplan/env';
const field = email();
console.log(field.coerce('Admin@Example.COM')); // "Admin@Example.COM"
const normalised = email().normalise();
console.log(normalised.coerce('Admin@Example.COM')); // "admin@example.com"
```
## Usage: Advanced Example
```ts
import { createEnv, email } from '@suaveplan/env';
const env = createEnv({
schema: {
SMTP_FROM: email()
.normalise()
.describe('Default sender address for outgoing mail'),
SUPPORT_EMAIL: email()
.optional()
.describe('Support contact email (optional)'),
// Strict internal-only email: must end with @company.com
ADMIN_EMAIL: email()
.pattern(/^[^@]+@company\.com$/)
.describe('Admin email — must be a company address'),
},
source: process.env,
});
// env.SMTP_FROM: string, env.SUPPORT_EMAIL: string | undefined
```
## API Reference
### `email(): EmailField<true>`
Factory function. Returns a new `EmailField` with the default `EMAIL_PATTERN` and `_isRequired = true`.
### `class EmailField<TRequired extends boolean = true>`
Extends `BaseField<string, TRequired>`.
| Method | Returns | Description |
|---|---|---|
| `pattern(regex)` | `this` | Replace the validation regex |
| `normalise()` | `this` | Lowercase the value during coercion |
| `required()` | `EmailField<true>` | Mark as required |
| `optional()` | `EmailField<false>` | Mark as optional |
| `default(value)` | `EmailField<true>` | Set a default email address |
### `coerce(raw: string): string`
Trims the input, optionally lowercases, then tests against `_pattern`. Returns the (possibly normalised) value on match.
**Throws:** `CoercionError` with `targetType = "email address"` when the pattern test fails.
## Implementation Notes
The `EMAIL_PATTERN` constant is defined in `constants/index.ts` as a regex that matches `local@domain.tld` with reasonable permissiveness — it allows plus signs, dots, and hyphens in the local part but does not support quoted local parts or IP-literal hosts. This covers `>99%` of real-world addresses in practice.
The `normalise()` option lowercases the entire address. Email addresses are technically case-sensitive in the local part (before `@`) per RFC 5321, but essentially all major mail services treat the local part as case-insensitive in practice. Normalising avoids duplicates when addresses are stored or compared downstream.
Normalisation occurs before pattern testing, so a custom `pattern()` receives the lowercased string when `normalise()` is also set. This means patterns can rely on lowercase input when the two options are combined.

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CoercionError } from "../../errors/env-error.js";
import { EmailField, email } from "./email-field.js";
describe("email / EmailField", () => {
it("factory returns EmailField instance", { timeout: 5000 }, () => {
expect(email()).toBeInstanceOf(EmailField);
});
it("coerce accepts valid email", { timeout: 5000 }, () => {
expect(email().coerce("user@example.com")).toBe("user@example.com");
});
it("coerce trims whitespace", { timeout: 5000 }, () => {
expect(email().coerce(" user@example.com ")).toBe("user@example.com");
});
it("coerce throws for invalid email", { timeout: 5000 }, () => {
expect(() => email().coerce("not-an-email")).toThrow(CoercionError);
expect(() => email().coerce("@example.com")).toThrow(CoercionError);
expect(() => email().coerce("user@")).toThrow(CoercionError);
});
it("normalise() lowercases the email", { timeout: 5000 }, () => {
expect(email().normalise().coerce("User@EXAMPLE.COM")).toBe(
"user@example.com"
);
});
it("pattern() uses custom regex", { timeout: 5000 }, () => {
const f = email().pattern(/^admin@/);
expect(f.coerce("admin@example.com")).toBe("admin@example.com");
expect(() => f.coerce("user@example.com")).toThrow(CoercionError);
});
it("optional() marks optional", { timeout: 5000 }, () => {
const f = email().optional();
expect(f._isRequired).toBe(false);
});
it("default() sets default", { timeout: 5000 }, () => {
const f = email().default("admin@example.com");
expect(f._defaultValue).toBe("admin@example.com");
});
it("required() marks required", { timeout: 5000 }, () => {
const f = email().optional().required();
expect(f._isRequired).toBe(true);
});
});

View File

@@ -0,0 +1,61 @@
import { EMAIL_PATTERN } from "../../constants/index.js";
import { BaseField } from "../../core/field/field.js";
import { CoercionError } from "../../errors/env-error.js";
/**
* A field builder for email address environment variables.
*
* Validates that the value looks like a valid email using a
* basic pattern check covering the vast majority of real addresses.
* Supports custom validation patterns and lowercase normalisation.
*/
export class EmailField<TRequired extends boolean = true> extends BaseField<
string,
TRequired
> {
protected _pattern: RegExp = EMAIL_PATTERN;
protected _normalise = false;
required(): EmailField<true> {
this._markRequired();
return this as unknown as EmailField<true>;
}
optional(): EmailField<false> {
this._markOptional();
return this as unknown as EmailField<false>;
}
default(value: string): EmailField<true> {
this._setDefault(value);
return this as unknown as EmailField<true>;
}
/** Use a custom email validation pattern. */
pattern(regex: RegExp): this {
this._pattern = regex;
return this;
}
/** Normalise the email to lowercase before returning. */
normalise(): this {
this._normalise = true;
return this;
}
coerce(raw: string): string {
const trimmed = raw.trim();
const value = this._normalise ? trimmed.toLowerCase() : trimmed;
if (!this._pattern.test(value)) {
throw new CoercionError("", trimmed, "email address");
}
return value;
}
}
/** Create an email field builder. */
export function email(): EmailField<true> {
return new EmailField();
}

View File

@@ -0,0 +1 @@
export { EmailField, email } from "./email-field";

View File

@@ -0,0 +1,85 @@
# enum-field
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`EnumField` constrains an environment variable to a fixed set of allowed string values and infers the output type as the string-literal union of those values. This is the correct field to use when only a small number of known strings are valid — for example `NODE_ENV` or `LOG_LEVEL` — because TypeScript tracks which specific strings are permitted at compile time and the runtime check ensures the running process cannot use an undeclared value.
## Features
- Infers the output type as a string-literal union: `enums(['a', 'b'])` produces `EnumField<"a" | "b">`
- Strict exact-match coercion by default; unrecognised values throw `CoercionError` listing all allowed strings
- `caseInsensitive()` — normalises both the input and the allowed values to lowercase before comparing
- `getAllowedValues()` — returns the readonly tuple of allowed values for documentation and tooling
- `default(value: T)` — the default must be one of the allowed values (enforced by the `T` type)
- `optional()` / `sensitive()` inherited from `BaseField`
## Usage: Basic Example
```ts
import { enums } from '@suaveplan/env';
const field = enums(['development', 'production', 'test', 'staging'] as const)
.default('development');
console.log(field.coerce('production')); // "production"
console.log(field._defaultValue); // "development"
// TypeScript type: EnumField<"development" | "production" | "test" | "staging">
```
## Usage: Advanced Example
```ts
import { createEnv, enums } from '@suaveplan/env';
const env = createEnv({
schema: {
NODE_ENV: enums(['development', 'production', 'test', 'staging'] as const)
.default('development'),
LOG_LEVEL: enums(['trace', 'debug', 'info', 'warn', 'error', 'fatal'] as const)
.default('info'),
// Case-insensitive: 'PROD', 'prod', 'Prod' all match 'production'
REGION: enums(['us-east-1', 'eu-west-1', 'ap-southeast-1'] as const)
.caseInsensitive()
.default('us-east-1'),
},
source: process.env,
});
// env.NODE_ENV: "development" | "production" | "test" | "staging"
// env.LOG_LEVEL: "trace" | "debug" | "info" | "warn" | "error" | "fatal"
```
## API Reference
### `enums<const T extends readonly string[]>(values: T): EnumField<T[number], true>`
Factory function. Takes a const tuple of allowed strings and returns an `EnumField` typed to the union of those strings.
### `class EnumField<T extends string, TRequired extends boolean = true>`
Extends `BaseField<T, TRequired>`.
| Method | Returns | Description |
|---|---|---|
| `caseInsensitive()` | `this` | Enable case-insensitive matching |
| `getAllowedValues()` | `ReadonlyArray<T>` | The original allowed values array |
| `required()` | `EnumField<T, true>` | Mark as required |
| `optional()` | `EnumField<T, false>` | Mark as optional |
| `default(value: T)` | `EnumField<T, true>` | Set a default (must be an allowed value) |
### `coerce(raw: string): T`
Trims input. In case-insensitive mode, lowercases both input and each allowed value before comparing; returns the original casing of the matching allowed value. Throws `CoercionError` listing all allowed values when no match is found.
**Throws:** `CoercionError` with `targetType = "one of: a, b, c"` when the input is not in the allowed set.
## Implementation Notes
The `enums` factory uses `const T extends readonly string[]` to capture the literal types from a `const` tuple. Without the `const` modifier the type degrades to `string[]` and literal inference is lost. The `as const` assertion on the passed array is required from the call site for the same reason.
Case-insensitive mode returns the original casing of the matched allowed value (`return allowed`) rather than the lowercased input. This ensures consistent output regardless of input casing — useful when the value is later used as a dictionary key or log string where the canonical casing matters.
`getAllowedValues()` is primarily useful for generating documentation, CLI `--help` output, or OpenAPI schema `enum` arrays without duplicating the literal list.

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CoercionError } from "../../errors/env-error.js";
import { EnumField, enums } from "./enum-field.js";
describe("enums / EnumField", () => {
const VALUES = ["development", "production", "test"] as const;
it("factory returns EnumField instance", { timeout: 5000 }, () => {
expect(enums(VALUES)).toBeInstanceOf(EnumField);
});
it("coerce returns matching value", { timeout: 5000 }, () => {
expect(enums(VALUES).coerce("development")).toBe("development");
expect(enums(VALUES).coerce("production")).toBe("production");
});
it("throws CoercionError for invalid value", { timeout: 5000 }, () => {
expect(() => enums(VALUES).coerce("staging")).toThrow(CoercionError);
});
it("trims whitespace from raw", { timeout: 5000 }, () => {
expect(enums(VALUES).coerce(" development ")).toBe("development");
});
it("caseInsensitive() allows uppercase input", { timeout: 5000 }, () => {
const f = enums(VALUES).caseInsensitive();
expect(f.coerce("DEVELOPMENT")).toBe("development");
});
it("getAllowedValues() returns allowed list", { timeout: 5000 }, () => {
const f = enums(VALUES);
expect(f.getAllowedValues()).toEqual([...VALUES]);
});
it("optional() marks optional", { timeout: 5000 }, () => {
const f = enums(VALUES).optional();
expect(f._isRequired).toBe(false);
});
it("default() sets default", { timeout: 5000 }, () => {
const f = enums(VALUES).default("test");
expect(f._defaultValue).toBe("test");
});
it("required() marks required", { timeout: 5000 }, () => {
const f = enums(VALUES).optional().required();
expect(f._isRequired).toBe(true);
});
});

View File

@@ -0,0 +1,74 @@
import { BaseField } from "../../core/field/field.js";
import { CoercionError } from "../../errors/env-error.js";
/**
* A field builder for enum (string-union) environment variables.
*
* Constrains the value to a fixed set of allowed strings and
* infers the output type as the union of those string literals.
*
* @typeParam T - The string literal union of allowed values.
*/
export class EnumField<
T extends string,
TRequired extends boolean = true,
> extends BaseField<T, TRequired> {
protected readonly _allowedValues: ReadonlyArray<T>;
protected _caseInsensitive = false;
constructor(values: ReadonlyArray<T>) {
super();
this._allowedValues = values;
}
required(): EnumField<T, true> {
this._markRequired();
return this as unknown as EnumField<T, true>;
}
optional(): EnumField<T, false> {
this._markOptional();
return this as unknown as EnumField<T, false>;
}
default(value: T): EnumField<T, true> {
this._setDefault(value);
return this as unknown as EnumField<T, true>;
}
/** Allow case-insensitive matching against the allowed values. */
caseInsensitive(): this {
this._caseInsensitive = true;
return this;
}
/** Return the list of allowed values for documentation purposes. */
getAllowedValues(): ReadonlyArray<T> {
return this._allowedValues;
}
coerce(raw: string): T {
const trimmed = raw.trim();
const compare = this._caseInsensitive ? trimmed.toLowerCase() : trimmed;
for (const allowed of this._allowedValues) {
const target = this._caseInsensitive
? allowed.toLowerCase()
: allowed;
if (compare === target) return allowed;
}
throw new CoercionError(
"",
raw,
`one of: ${this._allowedValues.join(", ")}`
);
}
}
/** Create an enum field builder from a const tuple of allowed values. */
export function enums<const T extends readonly string[]>(
values: T
): EnumField<T[number], true> {
return new EnumField([...values]);
}

View File

@@ -0,0 +1,2 @@
/** A readonly tuple of string literals used to constrain EnumField. */
export type EnumValues = readonly string[];

View File

@@ -0,0 +1,2 @@
export { EnumField, enums } from "./enum-field";
export type { EnumValues } from "./enum-field.types";

Some files were not shown because too many files have changed in this diff Show More