Files
2026-05-27 23:29:53 +01:00

255 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`.