Initial Commit
This commit is contained in:
335
openspec/archive/2026-04-24-enterprise-config/proposal.md
Normal file
335
openspec/archive/2026-04-24-enterprise-config/proposal.md
Normal file
@@ -0,0 +1,335 @@
|
||||
# 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.
|
||||
254
openspec/archive/2026-04-24-enterprise-config/tasks.md
Normal file
254
openspec/archive/2026-04-24-enterprise-config/tasks.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# Tasks: enterprise-config
|
||||
|
||||
**Change ID**: enterprise-config
|
||||
**Package**: `@suaveplan/config`
|
||||
**Target path**: `universal/config/config`
|
||||
**Prerequisite packages on Verdaccio**: `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/events`, `@suaveplan/env`, `@suaveplan/feature-flags`
|
||||
|
||||
---
|
||||
|
||||
## 1. Scaffold
|
||||
|
||||
- [x] Create directory `universal/config/config/` with `src/`, `tests/`, `src/__benchmarks__/`, and all feature subdirectories per the layout in `proposal.md`.
|
||||
- [x] Write `package.json` at version `0.1.0` with:
|
||||
- `"type": "module"`, `"sideEffects": false`
|
||||
- `exports` map with a top-level `"."` barrel **plus** one subpath per feature module (`"./constants"`, `"./types/common"`, `"./core/config-builder/config-builder"`, `"./core/config-store/config-store"`, `"./layers/defaults-layer/defaults-layer"`, `"./layers/object-layer/object-layer"`, `"./layers/env-layer/env-layer"`, `"./layers/file-layer/file-layer"`, `"./layers/cli-layer/cli-layer"`, `"./utils/merge"`, `"./utils/mask"`)
|
||||
- `dependencies`: `@suaveplan/error workspace:^`, `@suaveplan/types workspace:^`, `@suaveplan/events workspace:^`, `@suaveplan/env workspace:^`, `zod catalog:forms`, `@opentelemetry/api catalog:observability`, `tslib catalog:build`
|
||||
- `devDependencies`: `@suaveplan/feature-flags workspace:^`, `vitest catalog:testing`, `@vitest/coverage-istanbul catalog:testing`, `fast-check catalog:testing`, `mitata catalog:testing`, `@suaveplan/testing workspace:^`
|
||||
- [x] Write `tsconfig.json` extending `@suaveplan/typescript-config/library.json`.
|
||||
- [x] Write `vitest.config.ts` using `createTestConfig()` from `@suaveplan/testing/config` (pool: `forks`).
|
||||
- [x] Add `lint`, `typecheck`, `test`, `test:coverage`, `build`, `docs:check` scripts to `package.json`.
|
||||
- [x] Register the package in the root `bun.lockb` workspace by adding `"universal/config/config"` to the workspace globs (if not already covered by `universal/*/*`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Types + errors
|
||||
|
||||
### 2a. Types (`src/types/common.ts` + `src/types/common.md`)
|
||||
|
||||
- [x] Define all public TS interfaces and type aliases (preserved from main branch):
|
||||
- `ConfigData`, `ConfigLayer`, `ConfigValidator`, `EnvironmentOverrides`, `CreateConfigOptions`, `ConfigStore`, `ConfigStoreOptions`, `EnvLayerOptions`, `CliLayerOptions`, `FileLayerOptions`
|
||||
- [x] Add genesis-only optional fields to `CreateConfigOptions`: `logger?: LoggerLike` (import from `@suaveplan/types`).
|
||||
- [x] Add genesis-only optional fields to `FileLayerOptions`: `watchBus?: EventBusLike`, `watchEvent?: string`.
|
||||
- [x] Add genesis-only optional methods to `ConfigStore`: `reload(signal?: AbortSignal): Promise<void>`, `subscribe(listener: (next: Readonly<T>) => void): () => void`.
|
||||
- [x] Write `src/types/common.md` (≥200 words, both simple + advanced examples, API reference for every exported type).
|
||||
|
||||
### 2b. Error codes (`src/error-codes.ts`)
|
||||
|
||||
- [x] Import `CodeMetadata` and `ErrorCodes` from `@suaveplan/error`.
|
||||
- [x] Define `ConfigPackageErrorCode` union — seven codes:
|
||||
- `CONFIG_MISSING`, `CONFIG_INVALID_TYPE`, `CONFIG_PARSE_FAILED`, `CONFIG_FILE_NOT_FOUND`, `CONFIG_ENV_NOT_SET` (preserved from main), plus `CONFIG_RELOAD_FAILED`, `CONFIG_VALIDATION_FAILED` (genesis additions).
|
||||
- [x] Define `CONFIG_ERROR_METADATA` constant with `httpStatus`, `severity`, `retryable`, `userMessageKey`, `category`, `description` for each code.
|
||||
- [x] Export `registerConfigErrorCodes()` — calls `ErrorCodes.registerMany(CONFIG_ERROR_METADATA)`. Idempotent.
|
||||
- [x] Call `registerConfigErrorCodes()` as an eager side-effect at the bottom of the file.
|
||||
- [x] Re-export `ConfigError` from `@suaveplan/error` (not a local sub-class).
|
||||
|
||||
### 2c. Zod option schemas (`src/types/schemas.ts` + `src/types/schemas.md`)
|
||||
|
||||
- [x] Write Zod schemas for every public options interface:
|
||||
- `EnvLayerOptionsSchema`, `CliLayerOptionsSchema`, `FileLayerOptionsSchema`
|
||||
- `ConfigStoreOptionsSchema`
|
||||
- `CreateConfigOptionsSchema` (layers validated as `z.array(z.object({ name: z.string(), priority: z.number() }))` — the `resolve` function is not Zod-validated but the shape is checked)
|
||||
- [x] Export inferred types as `z.infer<typeof XxxSchema>` companions where they diverge from the hand-written types.
|
||||
- [x] Write `src/types/schemas.md`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Core utilities
|
||||
|
||||
### 3a. Path utilities (`src/utils/mask.ts` + `src/utils/mask.md`)
|
||||
|
||||
- [x] Implement `getByPath(obj, path)` — splits on `"."`, walks object chain, returns `undefined` on missing segment.
|
||||
- [x] Implement `setByPath(obj, path, value)` — creates intermediate objects, mutates in place, returns `obj`.
|
||||
- [x] Implement `hasByPath(obj, path)` — returns `getByPath(obj, path) !== undefined`.
|
||||
- [x] Implement `maskSensitive(data, sensitive, mask?)` — `structuredClone` then `setByPath` each sensitive path.
|
||||
- [x] Export `MASK_STRING` re-export from constants.
|
||||
- [x] Write `src/utils/mask.test.ts` (≤500 lines): happy path, nested paths, missing paths, array-valued paths, multi-sensitive mask, custom mask string. Every branch covered.
|
||||
- [x] Write `src/utils/mask.md` (≥200 words, API reference, examples).
|
||||
|
||||
### 3b. Merge utilities (`src/utils/merge.ts` + `src/utils/merge.md`)
|
||||
|
||||
- [x] Implement `deepMerge<T extends ConfigData>(base, override)`:
|
||||
- Plain objects recurse; arrays are replaced; `undefined` values in override are skipped; all other types overwrite.
|
||||
- No dependency on `@suaveplan/utils` (genesis does not have that package yet — implement natively; ≤60 lines).
|
||||
- [x] Implement `mergeLayers(layers)` — iterates sorted array, accumulates with `deepMerge`.
|
||||
- [x] Write `src/utils/merge.test.ts` (≤500 lines): primitive override, nested merge, array replace, undefined skip, N-layer merge, empty layers array.
|
||||
- [x] Write `src/utils/merge.md` (≥200 words, API reference, examples).
|
||||
|
||||
---
|
||||
|
||||
## 4. Layer implementations
|
||||
|
||||
### 4a. Defaults layer (`src/layers/defaults-layer/`)
|
||||
|
||||
- [x] Implement `defaultsLayer<T extends ConfigData>(defaults: T): ConfigLayer` — priority `LAYER_PRIORITIES.DEFAULTS` (0), `resolve` returns shallow copy.
|
||||
- [x] Write `defaults-layer.test.ts`: returns copy (mutation safety), correct priority, correct name.
|
||||
- [x] Write `defaults-layer.md`.
|
||||
|
||||
### 4b. Object layer (`src/layers/object-layer/`)
|
||||
|
||||
- [x] Implement `objectLayer(data, priority?)` — default priority `LAYER_PRIORITIES.OBJECT` (20), `resolve` returns shallow copy.
|
||||
- [x] Write `object-layer.test.ts`: custom priority, default priority, mutation safety.
|
||||
- [x] Write `object-layer.md`.
|
||||
|
||||
### 4c. Env layer (`src/layers/env-layer/`)
|
||||
|
||||
- [x] Implement `envLayer(options: EnvLayerOptions): ConfigLayer` — priority `LAYER_PRIORITIES.ENV` (30).
|
||||
- Source defaults to `process.env` when running in Node/Bun; falls back to `{}` in browser environments.
|
||||
- Apply optional `prefix` stripping: prepend prefix to each key before lookup.
|
||||
- For each `mapping` entry, call `coerceValue` on the raw string and write via `setByPath`.
|
||||
- Delegate prefix stripping and coercion to `@suaveplan/env` internals where the API allows; fall back to local `coerceValue` for paths `@suaveplan/env` does not expose.
|
||||
- [x] Export `coerceValue(raw: string): unknown` — thin wrapper that handles the seven cases: empty string, boolean-like strings (`true/1/yes/on/enabled`, `false/0/no/off/disabled`), numeric strings, JSON object/array strings, everything else stays string.
|
||||
- [x] Write `env-layer.test.ts`: prefix, mapping, source override, coercion for each type, missing keys ignored, empty mapping.
|
||||
- [x] Write `env-layer.md`.
|
||||
|
||||
### 4d. File layer (`src/layers/file-layer/`)
|
||||
|
||||
- [x] Implement `fileLayer(options: FileLayerOptions): ConfigLayer` — priority `LAYER_PRIORITIES.FILE` (10).
|
||||
- `resolve` returns `{ ...options.content }`.
|
||||
- If `options.watchBus` is provided, subscribe to `options.watchEvent ?? 'config:file:changed'` on the bus; on receipt, call the parent store's `reload()` if the layer has been registered with a reloadable store. Store the unsubscribe handle and expose a `teardown()` method on the returned layer (added to the interface via optional `teardown?(): void` on `ConfigLayer`).
|
||||
- File I/O remains the caller's responsibility — the layer receives pre-parsed `content`; the watch mechanism only signals re-resolution.
|
||||
- [x] Write `file-layer.test.ts`: static path (no bus), watch path (mock EventBus via real `@suaveplan/events` EventBus, not vi.mock), teardown unsubscribes.
|
||||
- [x] Write `file-layer.md`.
|
||||
|
||||
### 4e. CLI layer (`src/layers/cli-layer/`)
|
||||
|
||||
- [x] Implement `parseArgs(args, mapping)` — handles `--key=value`, `--key value`, `--flag` (boolean true), `-k value` (short flags). Unknown flags silently ignored.
|
||||
- [x] Implement `cliLayer(options: CliLayerOptions): ConfigLayer` — priority `LAYER_PRIORITIES.CLI` (40), calls `parseArgs` then `coerceValue` + `setByPath` for each matched flag.
|
||||
- [x] Write `cli-layer.test.ts`: `--key value`, `--key=value`, `--flag` (bool), `-k value`, unknown flags ignored, empty args.
|
||||
- [x] Write `cli-layer.md`.
|
||||
|
||||
---
|
||||
|
||||
## 5. ConfigStore
|
||||
|
||||
### 5a. Static store (`src/core/config-store/config-store.ts`)
|
||||
|
||||
- [x] Implement `createConfigStore<T>(options: ConfigStoreOptions<T>): ConfigStore<T>`:
|
||||
- `structuredClone` + `deepFreeze` the data.
|
||||
- `get(key)` — `getByPath(frozenData, key)`.
|
||||
- `has(key)` — `hasByPath(frozenData, key)`.
|
||||
- `toJSON()` — returns `frozenData`.
|
||||
- `toMasked()` — `deepFreeze(maskSensitive(frozenData, sensitiveKeys))`.
|
||||
- `reload()` — returns `Promise.resolve()` (no-op; live reload is wired by `createConfig`).
|
||||
- `subscribe(listener)` — no-op stub that returns a no-op unsubscribe (live subscriptions wired by `createConfig`).
|
||||
- [x] Write `config-store.test.ts`: get/has/toJSON/toMasked, deeply nested key, missing key returns undefined, frozen data cannot be mutated externally, sensitive masking with multiple paths.
|
||||
- [x] Write `config-store.md`.
|
||||
|
||||
### 5b. Live store wiring (inside `createConfig` orchestrator)
|
||||
|
||||
- [x] When `createConfig` is called with layers that include at least one with `resolve` (all layers do), wire a real `reload` implementation:
|
||||
- Accepts optional `AbortSignal`; if signal is already aborted before starting, throw `ConfigError` with code `CONFIG_RELOAD_FAILED`.
|
||||
- Re-runs layer resolution + environment overrides + validation.
|
||||
- On success, swaps internal `frozenData` reference and notifies all `subscribe` listeners.
|
||||
- Throws `ConfigError` with code `CONFIG_RELOAD_FAILED` on layer or validation error.
|
||||
- [x] Wire `subscribe` to maintain a `Set<listener>` returned by the live store closure; unsubscribe removes from set.
|
||||
- [x] Instrument `reload` with an OTel span `config.reload` and a counter `config.reload_total`.
|
||||
|
||||
---
|
||||
|
||||
## 6. `createConfig` orchestrator
|
||||
|
||||
File: `src/core/config-builder/config-builder.ts`
|
||||
|
||||
- [x] Implement `resolveLayers(options)`:
|
||||
- Iterate `options.layers`, call `layer.resolve()` in a try/catch, collect `ResolvedLayer[]`.
|
||||
- Sort ascending by `priority`.
|
||||
- Fold with `deepMerge` to produce `MergeResult`.
|
||||
- On layer error, throw `ConfigError` with code `CONFIG_PARSE_FAILED`, wrapping the cause.
|
||||
- [x] Implement `applyEnvironment(data, options)`:
|
||||
- If `options.environment` and `options.environments` exist, look up the override and `deepMerge` it on top.
|
||||
- [x] Implement `createConfig<T>(options)`:
|
||||
1. Log start via `options.logger?.debug('config:create:start', { layerCount: options.layers.length })`.
|
||||
2. Call `resolveLayers`.
|
||||
3. Call `applyEnvironment`.
|
||||
4. Call `options.validate(merged)` if provided; on throw, wrap in `ConfigError` with code `CONFIG_VALIDATION_FAILED`.
|
||||
5. Build the live store (with wired reload/subscribe).
|
||||
6. Log success via `options.logger?.debug('config:create:done')`.
|
||||
7. Return the store.
|
||||
- [x] Add internal `layerError` and `validationFailed` factory helpers so error construction is never stringly-typed at the call site.
|
||||
- [x] Write `config-builder.test.ts` (≤500 lines): happy path with multiple layers, env override applied, validate called and throws, layer resolution throws, sensitive masking end-to-end, logger receives expected calls, reload re-resolves layers, subscribe notified on reload.
|
||||
- [x] Write `config-builder.md`.
|
||||
- [x] Write `config-builder.types.ts` exporting `ResolvedLayer`, `MergeResult`, `ConfigErrorDetail`.
|
||||
|
||||
---
|
||||
|
||||
## 7. OTel instrumentation
|
||||
|
||||
- [x] Import `trace`, `metrics` from `@opentelemetry/api`; obtain tracer (`config`) and meter (`config`) once at module scope (lazy via `trace.getTracer` / `metrics.getMeter`).
|
||||
- [x] `createConfig`: wrap execution in `tracer.startActiveSpan('config.create', ...)`. Set span attributes `config.layer_count` and `config.environment` (string). Record exception on error; end span in `finally`.
|
||||
- [x] `store.get`: increment counter `config.get_total` with attribute `config.hit` (`true` if `hasByPath` returns true).
|
||||
- [x] `store.reload`: wrap in span `config.reload`; increment counter `config.reload_total`; record duration as histogram `config.reload_duration_ms`.
|
||||
- [x] `mergeLayers`: record duration as histogram `config.merge_duration_ms` with attribute `config.layer_count`.
|
||||
- [x] All OTel calls must be guarded so they degrade gracefully when no SDK is installed (the `@opentelemetry/api` no-op implementation handles this automatically — no extra guarding needed beyond not calling `.start()` outside a span context).
|
||||
|
||||
---
|
||||
|
||||
## 8. Property-based tests (`tests/property.test.ts`)
|
||||
|
||||
Use `fast-check` (`fc`). All test timeouts: `{ timeout: 30000 }`.
|
||||
|
||||
- [x] **`deepMerge` associativity**: for arbitrary plain-object triples `(a, b, c)`, assert `deepMerge(deepMerge(a, b), c)` equals `deepMerge(a, deepMerge(b, c))`. Constraint: inputs contain no `undefined` values (undefined-skip rule makes strict associativity hold only without undefined).
|
||||
- [x] **Layer priority ordering**: generate N random layers (2–8) with random priority values; assert that `mergeLayers` applied to the full list equals iterating the list sorted ascending by priority and folding with `deepMerge`.
|
||||
- [x] **`setByPath` / `getByPath` round-trip**: for arbitrary dot-path strings (1–4 segments, alphanum only) and arbitrary leaf values (`fc.jsonValue()`), assert `getByPath(setByPath({}, path, v), path) === v`.
|
||||
- [x] **`maskSensitive` idempotency**: for arbitrary config objects and arbitrary arrays of dot-paths, assert `maskSensitive(maskSensitive(data, paths), paths)` equals `maskSensitive(data, paths)`.
|
||||
- [x] **`coerceValue` stability**: for numeric, boolean, and object inputs, serialise to string via `String()` / `JSON.stringify()`, call `coerceValue`, assert result deep-equals the original value.
|
||||
- [x] **`parseArgs` completeness**: for arbitrary `--key value` pairs, assert that every supplied key appears in the output with the correct raw string value (assumes all keys are in the mapping).
|
||||
|
||||
---
|
||||
|
||||
## 9. Benchmarks (`src/__benchmarks__/config.bench.ts`)
|
||||
|
||||
Use `mitata`. No test timeout assertions — benchmarks are informational.
|
||||
|
||||
- [x] Benchmark `createConfig_cold`: `createConfig` with 5 layers (defaults + file + object + env + cli), no validation.
|
||||
- [x] Benchmark `store_get_hot`: 100 `store.get()` calls on a deeply nested key (`'a.b.c.d'`) against a pre-built store.
|
||||
- [x] Benchmark `mergeLayers_2`, `mergeLayers_5`, `mergeLayers_10`, `mergeLayers_20`: `mergeLayers` with increasing layer counts (each layer is a flat 10-key object).
|
||||
- [x] Add a `bench` script to `package.json`: `"bench": "vitest bench"`.
|
||||
- [x] Document benchmark numbers (placeholder row) in the package `README.md` under a `## Benchmarks` section — actual numbers are filled in after the first green run.
|
||||
|
||||
---
|
||||
|
||||
## 10. Docs
|
||||
|
||||
### Per-file `.md` (all must be ≥200 words, both simple + advanced examples, API reference)
|
||||
|
||||
- [x] `src/types/common.md`
|
||||
- [x] `src/types/schemas.md`
|
||||
- [x] `src/core/config-builder/config-builder.md`
|
||||
- [x] `src/core/config-store/config-store.md`
|
||||
- [x] `src/layers/defaults-layer/defaults-layer.md`
|
||||
- [x] `src/layers/object-layer/object-layer.md`
|
||||
- [x] `src/layers/env-layer/env-layer.md`
|
||||
- [x] `src/layers/file-layer/file-layer.md`
|
||||
- [x] `src/layers/cli-layer/cli-layer.md`
|
||||
- [x] `src/utils/merge.md`
|
||||
- [x] `src/utils/mask.md`
|
||||
|
||||
### Root README
|
||||
|
||||
- [x] Write `universal/config/config/README.md` (package root only — no category-level READMEs inside `src/`):
|
||||
- Purpose, installation, quick-start (5-layer `createConfig` example)
|
||||
- Table linking each co-located `.md`
|
||||
- `## Benchmarks` table with placeholder row
|
||||
- Dependencies table
|
||||
- `## API` section listing every exported symbol
|
||||
|
||||
### `docs:check` script
|
||||
|
||||
- [x] Add `"docs:check": "..."` script (e.g. `bunx typedoc --validation` or a custom checker) consistent with other genesis packages; confirm it passes before the verify step.
|
||||
|
||||
---
|
||||
|
||||
## 11. Verify and publish
|
||||
|
||||
- [x] Run `bunx turbo lint --filter=@suaveplan/config` — zero errors.
|
||||
- [x] Run `bunx turbo typecheck --filter=@suaveplan/config` — zero errors.
|
||||
- [x] Run `bunx turbo test --filter=@suaveplan/config` — all tests pass (unit + integration + property).
|
||||
- [x] Run `bunx turbo test:coverage --filter=@suaveplan/config` — statements/branches/functions/lines all 100%. If any module is below threshold, add missing test cases; do NOT lower the threshold.
|
||||
- [x] Run `bun run docs:check --filter=@suaveplan/config` — zero errors.
|
||||
- [x] Confirm no `TODO`, `FIXME`, `@deprecated`, untyped `any`, `ts-ignore`, or `.skip` remain in `src/` or `tests/`.
|
||||
- [x] Confirm `"sideEffects": false` is set in `package.json`.
|
||||
- [x] Run `bun scripts/publish.ts --package @suaveplan/config`.
|
||||
- [x] Update `CLAUDE.md` package listing to include `@suaveplan/config`.
|
||||
- [x] Archive this change: move `openspec/changes/enterprise-config/` to `openspec/archive/2026-04-23-enterprise-config/` and fold the spec delta into `openspec/specs/config/spec.md`.
|
||||
Reference in New Issue
Block a user