336 lines
12 KiB
Markdown
336 lines
12 KiB
Markdown
# OpenSpec Change: enterprise-config
|
|
|
|
- **Change ID**: enterprise-config
|
|
- **Package**: `@suaveplan/config`
|
|
- **Category**: universal
|
|
- **Target path**: `universal/config/config`
|
|
- **Status**: IN PROGRESS
|
|
- **Date**: 2026-04-23
|
|
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/config.md`
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
Deliver `@suaveplan/config` on the genesis branch from scratch at enterprise quality. The package provides layered configuration management: a set of typed layers (defaults, file, object, env, CLI) are resolved in priority order, deep-merged, optionally validated via a caller-supplied Zod-compatible schema, and exposed through an immutable `ConfigStore`. Genesis adds structured errors via `@suaveplan/error`, Zod option schemas, OTel instrumentation, `AbortSignal` on async reload, event-bus change propagation via `@suaveplan/events`, delegation of env-var coercion to `@suaveplan/env`, property-based tests (fast-check), and a mitata benchmark suite.
|
|
|
|
---
|
|
|
|
## Current-state audit (genesis)
|
|
|
|
The package does not exist on genesis yet — this is a fresh build. No prior source, no test results, no coverage data.
|
|
|
|
---
|
|
|
|
## Available workspace deps on genesis
|
|
|
|
| Package | Role |
|
|
|---|---|
|
|
| `@suaveplan/error` (`workspace:^`) | Base class for `ConfigError`; `defineCodes` / `ErrorCodes.registerMany` for `CONFIG.*` codes |
|
|
| `@suaveplan/types` (`workspace:^`) | `LoggerLike`, `EventBusLike`, `Clock` (injectable clock for future reload scheduling) |
|
|
| `@suaveplan/events` (`workspace:^`) | `EventBus` for file-layer watch-mode change notifications |
|
|
| `@suaveplan/env` (`workspace:^`) | `createEnv` / field descriptors — used inside `envLayer` for enterprise-grade coercion and prefix stripping |
|
|
| `@suaveplan/feature-flags` (`workspace:^`) | `FlagManager` — `ConfigStore` can optionally accept a flag manager to gate experimental read paths |
|
|
| `zod` (`catalog:forms`) | Zod schemas for all public option types |
|
|
| `@opentelemetry/api` (`catalog:observability`) | Spans + counters on `createConfig`, `store.get`, `store.set`, `store.reload` |
|
|
| `tslib` (`catalog:build`) | Runtime helpers for TS decorators (if needed) |
|
|
|
|
Dev deps: `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata` (all `catalog:testing`).
|
|
|
|
---
|
|
|
|
## Preserved public API surface (from main branch)
|
|
|
|
All symbols below are carried forward verbatim; signatures may gain optional genesis-only fields but the existing call sites remain valid without changes.
|
|
|
|
### Constants
|
|
|
|
```ts
|
|
LAYER_PRIORITIES: { DEFAULTS: 0; FILE: 10; OBJECT: 20; ENV: 30; CLI: 40 }
|
|
MASK_STRING: "****"
|
|
```
|
|
|
|
### Core
|
|
|
|
```ts
|
|
// Re-exported from @suaveplan/error
|
|
class ConfigError extends SuaveplanError
|
|
|
|
// Orchestrator — resolves layers, merges, applies env overrides, validates
|
|
function createConfig<T extends ConfigData>(options: CreateConfigOptions<T>): ConfigStore<T>
|
|
|
|
// Internal types surfaced for downstream consumers
|
|
interface ConfigErrorDetail { path?: string; message: string }
|
|
interface MergeResult { data: ConfigData; layers: ResolvedLayer[] }
|
|
interface ResolvedLayer { name: string; priority: number; data: ConfigData }
|
|
```
|
|
|
|
### Store
|
|
|
|
```ts
|
|
function createConfigStore<T extends ConfigData>(options: ConfigStoreOptions<T>): ConfigStore<T>
|
|
|
|
interface ConfigStoreOptions<T extends ConfigData = ConfigData> {
|
|
data: T
|
|
sensitive: string[]
|
|
}
|
|
```
|
|
|
|
### Layers
|
|
|
|
```ts
|
|
function defaultsLayer<T extends ConfigData>(defaults: T): ConfigLayer
|
|
function objectLayer(data: ConfigData, priority?: number): ConfigLayer
|
|
function envLayer(options: EnvLayerOptions): ConfigLayer
|
|
function fileLayer(options: FileLayerOptions): ConfigLayer
|
|
function cliLayer(options: CliLayerOptions): ConfigLayer // wraps parseArgs internally
|
|
function parseArgs(args: string[], mapping: Record<string, string>): Record<string, string>
|
|
```
|
|
|
|
### Types
|
|
|
|
```ts
|
|
type ConfigData = Record<string, unknown>
|
|
|
|
interface ConfigLayer {
|
|
readonly name: string
|
|
readonly priority: number
|
|
resolve(): ConfigData
|
|
}
|
|
|
|
type ConfigValidator<T extends ConfigData = ConfigData> = (data: ConfigData) => T
|
|
|
|
type EnvironmentOverrides<T extends ConfigData = ConfigData> = Record<string, Partial<T>>
|
|
|
|
interface CreateConfigOptions<T extends ConfigData = ConfigData> {
|
|
layers: ConfigLayer[]
|
|
validate?: ConfigValidator<T>
|
|
sensitive?: string[]
|
|
environments?: EnvironmentOverrides<T>
|
|
environment?: string
|
|
}
|
|
|
|
interface ConfigStore<T extends ConfigData = ConfigData> {
|
|
get<K extends string>(key: K): unknown
|
|
has(key: string): boolean
|
|
toJSON(): Readonly<T>
|
|
toMasked(): Readonly<T>
|
|
}
|
|
|
|
interface EnvLayerOptions {
|
|
prefix?: string
|
|
mapping: Record<string, string>
|
|
source?: Record<string, string | undefined>
|
|
}
|
|
|
|
interface CliLayerOptions {
|
|
args: string[]
|
|
mapping: Record<string, string>
|
|
}
|
|
|
|
interface FileLayerOptions {
|
|
content: ConfigData
|
|
}
|
|
```
|
|
|
|
### Utils
|
|
|
|
```ts
|
|
function deepMerge<T extends ConfigData>(base: T, override: Partial<T>): T
|
|
function mergeLayers(layers: Array<{ name: string; data: ConfigData }>): ConfigData
|
|
function getByPath(obj: ConfigData, path: string): unknown
|
|
function hasByPath(obj: ConfigData, path: string): boolean
|
|
function setByPath(obj: ConfigData, path: string, value: unknown): ConfigData
|
|
function maskSensitive(data: ConfigData, sensitive: string[], mask?: string): ConfigData
|
|
function coerceValue(raw: string): unknown
|
|
```
|
|
|
|
### Error codes
|
|
|
|
```ts
|
|
type ConfigPackageErrorCode =
|
|
| "CONFIG_MISSING"
|
|
| "CONFIG_INVALID_TYPE"
|
|
| "CONFIG_PARSE_FAILED"
|
|
| "CONFIG_FILE_NOT_FOUND"
|
|
| "CONFIG_ENV_NOT_SET"
|
|
|
|
const CONFIG_ERROR_METADATA: Readonly<Record<ConfigPackageErrorCode, CodeMetadata>>
|
|
function registerConfigErrorCodes(): void
|
|
```
|
|
|
|
---
|
|
|
|
## New additions for genesis
|
|
|
|
### 1. Extended error codes
|
|
|
|
Two additional codes beyond the main-branch set:
|
|
|
|
| Code | Severity | Retryable | Trigger |
|
|
|---|---|---|---|
|
|
| `CONFIG_RELOAD_FAILED` | `error` | `true` | Reload aborted or layer re-resolution threw |
|
|
| `CONFIG_VALIDATION_FAILED` | `fatal` | `false` | Validator threw (previously mapped to `CONFIG_INVALID` which does not exist in the registry) |
|
|
|
|
### 2. Zod option schemas
|
|
|
|
Zod schemas (not just TS types) for every public options object so callers can validate config at the boundary:
|
|
|
|
- `CreateConfigOptionsSchema`
|
|
- `ConfigStoreOptionsSchema`
|
|
- `EnvLayerOptionsSchema`
|
|
- `CliLayerOptionsSchema`
|
|
- `FileLayerOptionsSchema`
|
|
|
|
### 3. Structured `ConfigError`
|
|
|
|
`ConfigError` extends `SuaveplanError` from `@suaveplan/error` (not a local sub-class), carrying a `ConfigPackageErrorCode` as its `.code` discriminant. All internal throw sites use named factory helpers so codes are never stringly-typed at the call site.
|
|
|
|
### 4. `envLayer` delegates to `@suaveplan/env`
|
|
|
|
Genesis `envLayer` wraps `@suaveplan/env`'s `createEnv` internally for prefix stripping and type coercion instead of duplicating the logic. The public `EnvLayerOptions` interface remains stable; `coerceValue` is still exported from the package as a utility (it delegates to `@suaveplan/env`'s internals).
|
|
|
|
### 5. `ConfigStore` enhancements
|
|
|
|
The `ConfigStore` interface gains two optional genesis additions (both present on the concrete returned object but typed as optional on the interface to remain backward-compatible):
|
|
|
|
```ts
|
|
interface ConfigStore<T extends ConfigData = ConfigData> {
|
|
// --- existing ---
|
|
get<K extends string>(key: K): unknown
|
|
has(key: string): boolean
|
|
toJSON(): Readonly<T>
|
|
toMasked(): Readonly<T>
|
|
// --- genesis additions ---
|
|
reload(signal?: AbortSignal): Promise<void> // re-resolves all layers; no-op on static stores
|
|
subscribe(listener: (next: Readonly<T>) => void): () => void // returns unsubscribe fn
|
|
}
|
|
```
|
|
|
|
`createConfigStore` is kept synchronous and static; `reload` / `subscribe` are only wired when `createConfig` constructs the store with a live layer set.
|
|
|
|
### 6. `fileLayer` watch mode
|
|
|
|
`fileLayer` accepts an optional `EventBus` from `@suaveplan/events`. When provided and the layer is attached to a reloadable `ConfigStore`, a file-change signal from the bus triggers a reload cycle. This is opt-in; the sync path is unchanged.
|
|
|
|
```ts
|
|
interface FileLayerOptions {
|
|
content: ConfigData
|
|
// genesis addition — optional watch support
|
|
watchBus?: EventBusLike
|
|
watchEvent?: string // event name to listen for; default: 'config:file:changed'
|
|
}
|
|
```
|
|
|
|
### 7. `LoggerLike` injection into `createConfig`
|
|
|
|
`CreateConfigOptions` accepts an optional `logger?: LoggerLike` (from `@suaveplan/types`). When present, layer resolution counts, merge durations, and validation outcomes are written as structured log entries (not OTel — OTel is additive).
|
|
|
|
```ts
|
|
interface CreateConfigOptions<T extends ConfigData = ConfigData> {
|
|
// ... existing fields ...
|
|
logger?: LoggerLike // genesis addition
|
|
}
|
|
```
|
|
|
|
### 8. OTel instrumentation
|
|
|
|
- `createConfig` — span `config.create`, attributes: `config.layer_count`, `config.environment`
|
|
- `store.get` — counter `config.get_total`, attribute `config.hit` (bool)
|
|
- `store.reload` — span `config.reload`, counter `config.reload_total`
|
|
- `mergeLayers` — histogram `config.merge_duration_ms`
|
|
|
|
### 9. Property-based tests (fast-check)
|
|
|
|
- `deepMerge` associativity: `merge(merge(a, b), c) ≡ merge(a, merge(b, c))` for plain-object inputs
|
|
- Layer priority ordering: result of `mergeLayers` with N random layers equals result when layers are sorted by priority then merged left-to-right
|
|
- `setByPath` / `getByPath` round-trip: `getByPath(setByPath({}, path, v), path) === v` for any valid dot-path
|
|
- `maskSensitive` idempotency: applying mask twice yields the same output as applying once
|
|
- `coerceValue` stability: calling `coerceValue` on the string representation of a non-string output returns the same value
|
|
|
|
### 10. Benchmark suite (`src/__benchmarks__/`)
|
|
|
|
- `createConfig` with 5 layers (cold path)
|
|
- `store.get` (hot path, deeply nested key)
|
|
- `mergeLayers` with 2, 5, 10, 20 layers
|
|
|
|
---
|
|
|
|
## File layout
|
|
|
|
```text
|
|
universal/config/config/
|
|
package.json
|
|
tsconfig.json
|
|
vitest.config.ts
|
|
src/
|
|
index.ts # Public barrel
|
|
error-codes.ts # CONFIG.* codes + registerConfigErrorCodes
|
|
constants/
|
|
index.ts # LAYER_PRIORITIES, MASK_STRING, TRUTHY_STRINGS, FALSY_STRINGS
|
|
types/
|
|
common.ts # All public TS interfaces and type aliases
|
|
common.md
|
|
core/
|
|
config-builder/
|
|
config-builder.ts # createConfig orchestrator
|
|
config-builder.md
|
|
config-builder.types.ts # ResolvedLayer, MergeResult, ConfigErrorDetail
|
|
config-builder.test.ts
|
|
index.ts
|
|
config-store/
|
|
config-store.ts # createConfigStore
|
|
config-store.md
|
|
config-store.types.ts # ConfigStoreOptions
|
|
config-store.test.ts
|
|
index.ts
|
|
layers/
|
|
defaults-layer/
|
|
defaults-layer.ts
|
|
defaults-layer.md
|
|
defaults-layer.test.ts
|
|
index.ts
|
|
object-layer/
|
|
object-layer.ts
|
|
object-layer.md
|
|
object-layer.test.ts
|
|
index.ts
|
|
env-layer/
|
|
env-layer.ts # envLayer + coerceValue (delegates to @suaveplan/env)
|
|
env-layer.md
|
|
env-layer.test.ts
|
|
index.ts
|
|
file-layer/
|
|
file-layer.ts # fileLayer + optional watchBus support
|
|
file-layer.md
|
|
file-layer.test.ts
|
|
index.ts
|
|
cli-layer/
|
|
cli-layer.ts # cliLayer + parseArgs
|
|
cli-layer.md
|
|
cli-layer.test.ts
|
|
index.ts
|
|
utils/
|
|
merge.ts # deepMerge, mergeLayers
|
|
merge.md
|
|
merge.test.ts
|
|
mask.ts # getByPath, hasByPath, setByPath, maskSensitive
|
|
mask.md
|
|
mask.test.ts
|
|
__benchmarks__/
|
|
config.bench.ts
|
|
tests/
|
|
integration.test.ts # cross-cutting: full createConfig → store round-trip
|
|
property.test.ts # fast-check property tests
|
|
```
|
|
|
|
---
|
|
|
|
## Impact
|
|
|
|
**Downstream packages that will consume `@suaveplan/config`**: any server-side or CLI package that needs structured config (e.g. future `@suaveplan/server`, API plugins, CLI tools built on `@suaveplan/cli`). No existing genesis package currently depends on it — this is a forward dependency.
|
|
|
|
**Breaking changes from main-branch API**: none. All main-branch call sites compile unchanged. New optional fields (`logger`, `watchBus`, `watchEvent`, `reload`, `subscribe`) are additive.
|
|
|
|
**Workspace dep prerequisites**: `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/events`, `@suaveplan/env`, `@suaveplan/feature-flags` must all be published to Verdaccio before this package can be built or tested.
|