17 KiB
17 KiB
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/withsrc/,tests/,src/__benchmarks__/, and all feature subdirectories per the layout inproposal.md. - Write
package.jsonat version0.1.0with:"type": "module","sideEffects": falseexportsmap 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:builddevDependencies:@suaveplan/feature-flags workspace:^,vitest catalog:testing,@vitest/coverage-istanbul catalog:testing,fast-check catalog:testing,mitata catalog:testing,@suaveplan/testing workspace:^
- Write
tsconfig.jsonextending@suaveplan/typescript-config/library.json. - Write
vitest.config.tsusingcreateTestConfig()from@suaveplan/testing/config(pool:forks). - Add
lint,typecheck,test,test:coverage,build,docs:checkscripts topackage.json. - Register the package in the root
bun.lockbworkspace by adding"universal/config/config"to the workspace globs (if not already covered byuniversal/*/*).
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
CodeMetadataandErrorCodesfrom@suaveplan/error. - Define
ConfigPackageErrorCodeunion — seven codes:CONFIG_MISSING,CONFIG_INVALID_TYPE,CONFIG_PARSE_FAILED,CONFIG_FILE_NOT_FOUND,CONFIG_ENV_NOT_SET(preserved from main), plusCONFIG_RELOAD_FAILED,CONFIG_VALIDATION_FAILED(genesis additions).
- Define
CONFIG_ERROR_METADATAconstant withhttpStatus,severity,retryable,userMessageKey,category,descriptionfor each code. - Export
registerConfigErrorCodes()— callsErrorCodes.registerMany(CONFIG_ERROR_METADATA). Idempotent. - Call
registerConfigErrorCodes()as an eager side-effect at the bottom of the file. - Re-export
ConfigErrorfrom@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,FileLayerOptionsSchemaConfigStoreOptionsSchemaCreateConfigOptionsSchema(layers validated asz.array(z.object({ name: z.string(), priority: z.number() }))— theresolvefunction 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, returnsundefinedon missing segment. - Implement
setByPath(obj, path, value)— creates intermediate objects, mutates in place, returnsobj. - Implement
hasByPath(obj, path)— returnsgetByPath(obj, path) !== undefined. - Implement
maskSensitive(data, sensitive, mask?)—structuredClonethensetByPatheach sensitive path. - Export
MASK_STRINGre-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;
undefinedvalues in override are skipped; all other types overwrite. - No dependency on
@suaveplan/utils(genesis does not have that package yet — implement natively; ≤60 lines).
- Plain objects recurse; arrays are replaced;
- Implement
mergeLayers(layers)— iterates sorted array, accumulates withdeepMerge. - 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— priorityLAYER_PRIORITIES.DEFAULTS(0),resolvereturns 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 priorityLAYER_PRIORITIES.OBJECT(20),resolvereturns 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— priorityLAYER_PRIORITIES.ENV(30).- Source defaults to
process.envwhen running in Node/Bun; falls back to{}in browser environments. - Apply optional
prefixstripping: prepend prefix to each key before lookup. - For each
mappingentry, callcoerceValueon the raw string and write viasetByPath. - Delegate prefix stripping and coercion to
@suaveplan/envinternals where the API allows; fall back to localcoerceValuefor paths@suaveplan/envdoes not expose.
- Source defaults to
- 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— priorityLAYER_PRIORITIES.FILE(10).resolvereturns{ ...options.content }.- If
options.watchBusis provided, subscribe tooptions.watchEvent ?? 'config:file:changed'on the bus; on receipt, call the parent store'sreload()if the layer has been registered with a reloadable store. Store the unsubscribe handle and expose ateardown()method on the returned layer (added to the interface via optionalteardown?(): voidonConfigLayer). - 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/eventsEventBus, 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— priorityLAYER_PRIORITIES.CLI(40), callsparseArgsthencoerceValue+setByPathfor 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+deepFreezethe data.get(key)—getByPath(frozenData, key).has(key)—hasByPath(frozenData, key).toJSON()— returnsfrozenData.toMasked()—deepFreeze(maskSensitive(frozenData, sensitiveKeys)).reload()— returnsPromise.resolve()(no-op; live reload is wired bycreateConfig).subscribe(listener)— no-op stub that returns a no-op unsubscribe (live subscriptions wired bycreateConfig).
- 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
createConfigis called with layers that include at least one withresolve(all layers do), wire a realreloadimplementation:- Accepts optional
AbortSignal; if signal is already aborted before starting, throwConfigErrorwith codeCONFIG_RELOAD_FAILED. - Re-runs layer resolution + environment overrides + validation.
- On success, swaps internal
frozenDatareference and notifies allsubscribelisteners. - Throws
ConfigErrorwith codeCONFIG_RELOAD_FAILEDon layer or validation error.
- Accepts optional
- Wire
subscribeto maintain aSet<listener>returned by the live store closure; unsubscribe removes from set. - Instrument
reloadwith an OTel spanconfig.reloadand a counterconfig.reload_total.
6. createConfig orchestrator
File: src/core/config-builder/config-builder.ts
- Implement
resolveLayers(options):- Iterate
options.layers, calllayer.resolve()in a try/catch, collectResolvedLayer[]. - Sort ascending by
priority. - Fold with
deepMergeto produceMergeResult. - On layer error, throw
ConfigErrorwith codeCONFIG_PARSE_FAILED, wrapping the cause.
- Iterate
- Implement
applyEnvironment(data, options):- If
options.environmentandoptions.environmentsexist, look up the override anddeepMergeit on top.
- If
- Implement
createConfig<T>(options):- Log start via
options.logger?.debug('config:create:start', { layerCount: options.layers.length }). - Call
resolveLayers. - Call
applyEnvironment. - Call
options.validate(merged)if provided; on throw, wrap inConfigErrorwith codeCONFIG_VALIDATION_FAILED. - Build the live store (with wired reload/subscribe).
- Log success via
options.logger?.debug('config:create:done'). - Return the store.
- Log start via
- Add internal
layerErrorandvalidationFailedfactory 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.tsexportingResolvedLayer,MergeResult,ConfigErrorDetail.
7. OTel instrumentation
- Import
trace,metricsfrom@opentelemetry/api; obtain tracer (config) and meter (config) once at module scope (lazy viatrace.getTracer/metrics.getMeter). createConfig: wrap execution intracer.startActiveSpan('config.create', ...). Set span attributesconfig.layer_countandconfig.environment(string). Record exception on error; end span infinally.store.get: increment counterconfig.get_totalwith attributeconfig.hit(trueifhasByPathreturns true).store.reload: wrap in spanconfig.reload; increment counterconfig.reload_total; record duration as histogramconfig.reload_duration_ms.mergeLayers: record duration as histogramconfig.merge_duration_mswith attributeconfig.layer_count.- All OTel calls must be guarded so they degrade gracefully when no SDK is installed (the
@opentelemetry/apino-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 }.
deepMergeassociativity: for arbitrary plain-object triples(a, b, c), assertdeepMerge(deepMerge(a, b), c)equalsdeepMerge(a, deepMerge(b, c)). Constraint: inputs contain noundefinedvalues (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
mergeLayersapplied to the full list equals iterating the list sorted ascending by priority and folding withdeepMerge. setByPath/getByPathround-trip: for arbitrary dot-path strings (1–4 segments, alphanum only) and arbitrary leaf values (fc.jsonValue()), assertgetByPath(setByPath({}, path, v), path) === v.maskSensitiveidempotency: for arbitrary config objects and arbitrary arrays of dot-paths, assertmaskSensitive(maskSensitive(data, paths), paths)equalsmaskSensitive(data, paths).coerceValuestability: for numeric, boolean, and object inputs, serialise to string viaString()/JSON.stringify(), callcoerceValue, assert result deep-equals the original value.parseArgscompleteness: for arbitrary--key valuepairs, 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:createConfigwith 5 layers (defaults + file + object + env + cli), no validation. - Benchmark
store_get_hot: 100store.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:mergeLayerswith increasing layer counts (each layer is a flat 10-key object). - Add a
benchscript topackage.json:"bench": "vitest bench". - Document benchmark numbers (placeholder row) in the package
README.mdunder a## Benchmarkssection — 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.mdsrc/types/schemas.mdsrc/core/config-builder/config-builder.mdsrc/core/config-store/config-store.mdsrc/layers/defaults-layer/defaults-layer.mdsrc/layers/object-layer/object-layer.mdsrc/layers/env-layer/env-layer.mdsrc/layers/file-layer/file-layer.mdsrc/layers/cli-layer/cli-layer.mdsrc/utils/merge.mdsrc/utils/mask.md
Root README
- Write
universal/config/config/README.md(package root only — no category-level READMEs insidesrc/):- Purpose, installation, quick-start (5-layer
createConfigexample) - Table linking each co-located
.md ## Benchmarkstable with placeholder row- Dependencies table
## APIsection listing every exported symbol
- Purpose, installation, quick-start (5-layer
docs:check script
- Add
"docs:check": "..."script (e.g.bunx typedoc --validationor 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, untypedany,ts-ignore, or.skipremain insrc/ortests/. - Confirm
"sideEffects": falseis set inpackage.json. - Run
bun scripts/publish.ts --package @suaveplan/config. - Update
CLAUDE.mdpackage listing to include@suaveplan/config. - Archive this change: move
openspec/changes/enterprise-config/toopenspec/archive/2026-04-23-enterprise-config/and fold the spec delta intoopenspec/specs/config/spec.md.