Files
suaveplan/openspec/archive/2026-04-24-enterprise-config/tasks.md
2026-05-27 23:29:53 +01:00

17 KiB
Raw Blame History

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

  • Create directory universal/config/config/ with src/, tests/, src/__benchmarks__/, and all feature subdirectories per the layout in proposal.md.
  • 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:^
  • Write tsconfig.json extending @suaveplan/typescript-config/library.json.
  • Write vitest.config.ts using createTestConfig() from @suaveplan/testing/config (pool: forks).
  • Add lint, typecheck, test, test:coverage, build, docs:check scripts to package.json.
  • 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)

  • Define all public TS interfaces and type aliases (preserved from main branch):
    • ConfigData, ConfigLayer, ConfigValidator, EnvironmentOverrides, CreateConfigOptions, ConfigStore, ConfigStoreOptions, EnvLayerOptions, CliLayerOptions, FileLayerOptions
  • Add genesis-only optional fields to CreateConfigOptions: logger?: LoggerLike (import from @suaveplan/types).
  • Add genesis-only optional fields to FileLayerOptions: watchBus?: EventBusLike, watchEvent?: string.
  • Add genesis-only optional methods to ConfigStore: reload(signal?: AbortSignal): Promise<void>, subscribe(listener: (next: Readonly<T>) => void): () => void.
  • Write src/types/common.md (≥200 words, both simple + advanced examples, API reference for every exported type).

2b. Error codes (src/error-codes.ts)

  • Import CodeMetadata and ErrorCodes from @suaveplan/error.
  • 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).
  • Define CONFIG_ERROR_METADATA constant with httpStatus, severity, retryable, userMessageKey, category, description for each code.
  • Export registerConfigErrorCodes() — calls ErrorCodes.registerMany(CONFIG_ERROR_METADATA). Idempotent.
  • Call registerConfigErrorCodes() as an eager side-effect at the bottom of the file.
  • Re-export ConfigError from @suaveplan/error (not a local sub-class).

2c. Zod option schemas (src/types/schemas.ts + src/types/schemas.md)

  • 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)
  • Export inferred types as z.infer<typeof XxxSchema> companions where they diverge from the hand-written types.
  • Write src/types/schemas.md.

3. Core utilities

3a. Path utilities (src/utils/mask.ts + src/utils/mask.md)

  • Implement getByPath(obj, path) — splits on ".", walks object chain, returns undefined on missing segment.
  • Implement setByPath(obj, path, value) — creates intermediate objects, mutates in place, returns obj.
  • Implement hasByPath(obj, path) — returns getByPath(obj, path) !== undefined.
  • Implement maskSensitive(data, sensitive, mask?) — structuredClone then setByPath each sensitive path.
  • Export MASK_STRING re-export from constants.
  • 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.
  • Write src/utils/mask.md (≥200 words, API reference, examples).

3b. Merge utilities (src/utils/merge.ts + src/utils/merge.md)

  • 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).
  • Implement mergeLayers(layers) — iterates sorted array, accumulates with deepMerge.
  • Write src/utils/merge.test.ts (≤500 lines): primitive override, nested merge, array replace, undefined skip, N-layer merge, empty layers array.
  • Write src/utils/merge.md (≥200 words, API reference, examples).

4. Layer implementations

4a. Defaults layer (src/layers/defaults-layer/)

  • Implement defaultsLayer<T extends ConfigData>(defaults: T): ConfigLayer — priority LAYER_PRIORITIES.DEFAULTS (0), resolve returns shallow copy.
  • Write defaults-layer.test.ts: returns copy (mutation safety), correct priority, correct name.
  • Write defaults-layer.md.

4b. Object layer (src/layers/object-layer/)

  • Implement objectLayer(data, priority?) — default priority LAYER_PRIORITIES.OBJECT (20), resolve returns shallow copy.
  • Write object-layer.test.ts: custom priority, default priority, mutation safety.
  • Write object-layer.md.

4c. Env layer (src/layers/env-layer/)

  • 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.
  • 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.
  • Write env-layer.test.ts: prefix, mapping, source override, coercion for each type, missing keys ignored, empty mapping.
  • Write env-layer.md.

4d. File layer (src/layers/file-layer/)

  • 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.
  • Write file-layer.test.ts: static path (no bus), watch path (mock EventBus via real @suaveplan/events EventBus, not vi.mock), teardown unsubscribes.
  • Write file-layer.md.

4e. CLI layer (src/layers/cli-layer/)

  • Implement parseArgs(args, mapping) — handles --key=value, --key value, --flag (boolean true), -k value (short flags). Unknown flags silently ignored.
  • Implement cliLayer(options: CliLayerOptions): ConfigLayer — priority LAYER_PRIORITIES.CLI (40), calls parseArgs then coerceValue + setByPath for each matched flag.
  • Write cli-layer.test.ts: --key value, --key=value, --flag (bool), -k value, unknown flags ignored, empty args.
  • Write cli-layer.md.

5. ConfigStore

5a. Static store (src/core/config-store/config-store.ts)

  • 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).
  • 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.
  • Write config-store.md.

5b. Live store wiring (inside createConfig orchestrator)

  • 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.
  • Wire subscribe to maintain a Set<listener> returned by the live store closure; unsubscribe removes from set.
  • 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

  • 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.
  • Implement applyEnvironment(data, options):
    • If options.environment and options.environments exist, look up the override and deepMerge it on top.
  • 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.
  • Add internal layerError and validationFailed factory helpers so error construction is never stringly-typed at the call site.
  • 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.
  • Write config-builder.md.
  • Write config-builder.types.ts exporting ResolvedLayer, MergeResult, ConfigErrorDetail.

7. OTel instrumentation

  • Import trace, metrics from @opentelemetry/api; obtain tracer (config) and meter (config) once at module scope (lazy via trace.getTracer / metrics.getMeter).
  • 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.
  • store.get: increment counter config.get_total with attribute config.hit (true if hasByPath returns true).
  • store.reload: wrap in span config.reload; increment counter config.reload_total; record duration as histogram config.reload_duration_ms.
  • mergeLayers: record duration as histogram config.merge_duration_ms with attribute config.layer_count.
  • 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 }.

  • 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).
  • 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.
  • 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.
  • maskSensitive idempotency: for arbitrary config objects and arbitrary arrays of dot-paths, assert maskSensitive(maskSensitive(data, paths), paths) equals maskSensitive(data, paths).
  • coerceValue stability: for numeric, boolean, and object inputs, serialise to string via String() / JSON.stringify(), call coerceValue, assert result deep-equals the original value.
  • 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.

  • Benchmark createConfig_cold: createConfig with 5 layers (defaults + file + object + env + cli), no validation.
  • Benchmark store_get_hot: 100 store.get() calls on a deeply nested key ('a.b.c.d') against a pre-built store.
  • Benchmark mergeLayers_2, mergeLayers_5, mergeLayers_10, mergeLayers_20: mergeLayers with increasing layer counts (each layer is a flat 10-key object).
  • Add a bench script to package.json: "bench": "vitest bench".
  • 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)

  • src/types/common.md
  • src/types/schemas.md
  • src/core/config-builder/config-builder.md
  • src/core/config-store/config-store.md
  • src/layers/defaults-layer/defaults-layer.md
  • src/layers/object-layer/object-layer.md
  • src/layers/env-layer/env-layer.md
  • src/layers/file-layer/file-layer.md
  • src/layers/cli-layer/cli-layer.md
  • src/utils/merge.md
  • src/utils/mask.md

Root README

  • 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

  • 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

  • Run bunx turbo lint --filter=@suaveplan/config — zero errors.
  • Run bunx turbo typecheck --filter=@suaveplan/config — zero errors.
  • Run bunx turbo test --filter=@suaveplan/config — all tests pass (unit + integration + property).
  • 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.
  • Run bun run docs:check --filter=@suaveplan/config — zero errors.
  • Confirm no TODO, FIXME, @deprecated, untyped any, ts-ignore, or .skip remain in src/ or tests/.
  • Confirm "sideEffects": false is set in package.json.
  • Run bun scripts/publish.ts --package @suaveplan/config.
  • Update CLAUDE.md package listing to include @suaveplan/config.
  • 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.