12 KiB
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
LAYER_PRIORITIES: { DEFAULTS: 0; FILE: 10; OBJECT: 20; ENV: 30; CLI: 40 }
MASK_STRING: "****"
Core
// 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
function createConfigStore<T extends ConfigData>(options: ConfigStoreOptions<T>): ConfigStore<T>
interface ConfigStoreOptions<T extends ConfigData = ConfigData> {
data: T
sensitive: string[]
}
Layers
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
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
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
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:
CreateConfigOptionsSchemaConfigStoreOptionsSchemaEnvLayerOptionsSchemaCliLayerOptionsSchemaFileLayerOptionsSchema
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):
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.
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).
interface CreateConfigOptions<T extends ConfigData = ConfigData> {
// ... existing fields ...
logger?: LoggerLike // genesis addition
}
8. OTel instrumentation
createConfig— spanconfig.create, attributes:config.layer_count,config.environmentstore.get— counterconfig.get_total, attributeconfig.hit(bool)store.reload— spanconfig.reload, counterconfig.reload_totalmergeLayers— histogramconfig.merge_duration_ms
9. Property-based tests (fast-check)
deepMergeassociativity:merge(merge(a, b), c) ≡ merge(a, merge(b, c))for plain-object inputs- Layer priority ordering: result of
mergeLayerswith N random layers equals result when layers are sorted by priority then merged left-to-right setByPath/getByPathround-trip:getByPath(setByPath({}, path, v), path) === vfor any valid dot-pathmaskSensitiveidempotency: applying mask twice yields the same output as applying oncecoerceValuestability: callingcoerceValueon the string representation of a non-string output returns the same value
10. Benchmark suite (src/__benchmarks__/)
createConfigwith 5 layers (cold path)store.get(hot path, deeply nested key)mergeLayerswith 2, 5, 10, 20 layers
File layout
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.