feat(config): add Zod-based simulator configuration syntax validation (#1874)
* feat(config): scaffold simulator configuration schema and validator skeleton
- Add ConfigurationMigrations.ts: CURRENT_CONFIGURATION_SCHEMA_VERSION=1,
coerceConfigurationVersion, applyConfigurationMigration, migrateV0ToV1
with DEPRECATED_KEY_REMAPPINGS (~25 legacy keys)
- Add ConfigurationSchema.ts: strict Zod v4 schema for all config sections
(log, worker, performanceStorage, uiServer, stationTemplateUrls) with
deprecated keys as .optional()
- Add ConfigurationValidation.ts: validateConfiguration pipeline
(guard→clone→coerce→migrate→safeParse→transform) + ConfigurationValidationError
extends BaseError with structured fieldErrors
- Add $schemaVersion: 1 to config-template.json
- Add ConfigurationFixtures.ts test helpers
- Add ConfigurationSchema.test.ts (63 tests), ConfigurationMigrations.test.ts (44 tests)
- Wire validateConfiguration into Configuration.getConfigurationData() with
hard-throw at boot (console.error+chalk+process.exit(1))
- Hot-reload snapshot rollback: pre-clear snapshot, restore on failure
- Replace ConfigurationData interface with z.infer<typeof ConfigurationSchema>
- Migrate all consumers atomically (SimulatorState, Configuration, types barrel)
- Delete checkWorkerProcessType and checkWorkerElementsPerWorker (subsumed by schema)
- Delete ConfigurationMigration.ts (logic moved to ConfigurationMigrations.ts)
- Add ConfigurationValidation.test.ts (39 tests), Configuration-hot-reload.test.ts
- Add ConfigurationValidation-perf.test.ts (p99 < 50ms budget)
- Add error message snapshot test
- Update README.md with $schemaVersion documentation
* feat(config): remove legacy ConfigurationMigration.ts and stale call sites
- Delete src/utils/ConfigurationMigration.ts (deprecated-key logic now
owned by ConfigurationMigrations.ts v0→v1 migration step)
- Remove checkDeprecatedConfigurationKeys import and call site from
Configuration.getStationTemplateUrls() — validation pipeline in
getConfigurationData() already handles deprecated key remapping
* chore(config): silence lint warnings on configuration migrations
- Add JSDoc descriptions for setAtPath() params
- Add JSDoc descriptions and @returns for migrateV0ToV1
- Whitelist 'emerg' (syslog level) and 'REMAPPINGS' in cspell dictionary
* refactor(config): rebuild configuration validation pipeline
Address audit findings on PR #1874:
- Extract deprecated-key sweep into pure remapDeprecatedKeys() that
reports warnings and field errors via return value instead of side
effects. Runs unconditionally regardless of $schemaVersion so v1
configs still containing deprecated keys never silently drop user
values (B3).
- Replace silent-drop setAtPath with collision- and intermediate-aware
variant: equal-value writes are idempotent no-ops, unequal values
produce a typed field error (B4), non-object intermediates are
reported and stop traversal instead of overwriting user data (N7).
- Type DEPRECATED_KEY_REMAPPINGS as Record<string, string | null>;
null marks deprecated keys with no canonical destination, replacing
the autoReconnectMaxRetries self-mapping that silently dropped the
user value (B2). Add 'worker.elementStartDelay' as a dotted source
key so nested deprecations live in the same single source of truth.
- Refactor ConfigurationValidationError: primary constructor takes
FieldError[] + a context with explicit phase ('migration' | 'schema');
static fromZodError() factory wraps Zod failures. Error messages now
carry the phase in their tag for clearer diagnostics.
- Switch deprecation-warning channel from logger.warn to console.warn
to break a re-entrant boot path where the Logger proxy lazily
resolved Configuration.getConfigurationSection('log'), recursing into
the validation pipeline (B1). transformConfiguration is now a pure
deep clone — its previous warning loop moved upstream to the sweep.
- Delete dead post-migration remapping in Configuration.ts:
deprecatedLogKeyMap, deprecatedWorkerKeyMap, the
delete configurationData.workerPoolStrategy mutation, the cast hiding
the legacy 'supervisionURLs' read, and the worker.elementStartDelay
fallback (B6). All deprecation handling now flows through the
migration's single source of truth.
- Make hot-reload reload loop async end-to-end. Awaits the change
callback inside the same lock so subsequent reloads cannot interleave
with an in-flight callback. Adds configurationFileReloadPending so
events arriving during a reload coalesce into exactly one drain
reload after the current one completes (N8).
- Polish schema: use z.enum(NativeEnum) directly for ApplicationProtocol
and ApplicationProtocolVersion to preserve literal-type narrowing on
UIServerConfiguration (N4); drop the BaseConfigurationSchema alias
(N5).
* test(config): cover migration edge cases and hot-reload rollback
Tests aligned with the rebuilt validation pipeline:
- Split migration tests into a remapDeprecatedKeys block (per-key sweep,
null-destination removal for autoReconnectMaxRetries, equal-value
collision idempotency, unequal-value collision field error, non-object
intermediate field error, nested worker.elementStartDelay) and a lean
applyConfigurationMigration block (version-bump only, immutability).
- Replace the empty self-mapping branch with explicit hasOwnProperty
removal assertions to defeat the previously vacuous test.
- ConfigurationValidation tests: switch deprecation-warning channel
spies from logger.warn to console.warn (B1 regression also asserts
logger.warn is never called from the pipeline), tolerate
schema-incompatible remap targets so the warning fires before the
downstream throw, add B3 sweep / B6 SSOT / future-version pipeline /
ConfigurationValidationError shape (FieldError[] + phase, fromZodError
factory, schema-phase aggregation) tests, and update the error-message
snapshot to include the new [phase] tag.
- transformConfiguration immutability test verifies a fresh validation
is unaffected by mutating a prior return value.
- ConfigurationSchema tests: strict parity for performanceStorage and
uiServer.authentication; StationTemplateUrl entry constraints
(empty file, negative numberOfStations, deprecated numberOfStation,
unknown key); worker.elementsPerWorker and poolMaxSize/MinSize
positive-integer constraints; log.statisticsInterval non-negative
integer constraints; bidirectional schema/DEPRECATED_KEY_REMAPPINGS
sync meta-test that walks both top-level and nested @deprecated
describe markers.
- Hot-reload tests rebuilt around the async runReloadLoop: validation
failure asserts the logged error is a ConfigurationValidationError;
JSON parse error path asserts configurationData and section cache are
fully restored (Gap 7); a sentinel watcher survives a failed reload;
N8 rapid double-save coalesces into exactly one drain reload reflecting
the latest content; flag reset is exercised on both paths.
- Configuration validation perf test: relative p99 budget (20× median
with a 1ms floor) plus an absolute 500ms catastrophic ceiling,
replacing the flaky absolute 50ms threshold (N1).
- Fixtures: new buildInvalidJsonString, buildV1WithDeprecatedKey
(handles top-level and dotted source keys), and
buildV0WithDeprecatedKeyCollision builders for the new test cases.
* docs(config): trim verbose JSDocs and harmonize with Template* conventions
Polish pass after PR audit: bring comments and docstrings in line with
the existing TemplateMigrations / TemplateValidation / TemplateSchema
patterns, drop ceremonial AI-generated narration, and fix two precision
defects.
- ConfigurationMigrations.ts: trim getAtPath / setAtPath / remapDeprecatedKeys /
migrateV0ToV1 / applyConfigurationMigration JSDocs to match Template*
density; drop the historical "now handled unconditionally upstream"
paragraph (AGENTS.md "exclude historical evolution").
- ConfigurationValidation.ts: collapse the duplicated pipeline narration
(the function-level JSDoc already enumerates stages, the inline
// Stage N — markers were removing them); shorten the
ConfigurationValidationError class docstring; tighten transformConfiguration
JSDoc and drop the aspirational "future cross-field invariants" sentence;
collapse the 5-line re-entrancy comment to a 2-line rationale.
- ConfigurationSchema.ts: fix two precision defects in JSDoc — stale
module name (ConfigurationMigration → ConfigurationMigrations) and wrong
subject (numberOfStation is not in the remap table; nothing auto-migrates
it). Disambiguate WorkerConfigurationSchema description (resourceLimits
is bridged, not deprecated; elementStartDelay is the deprecated alias).
- Configuration.ts: shrink runReloadLoop and performReload JSDocs to two-
line summaries matching the surrounding private-method conventions in
the same file; collapse the 4-line ESLint-disable rationale to a single
-- suffix on the disable directive.
- Test fixtures and headers: drop internal review codes (B1/B3/B4/B6/N7/N8/
Gap-7/RG-4) from JSDocs and test names — they had no lookup table and
would be opaque to future maintainers; trim test-file headers to the
one-line Template* shape.
* refactor(config): address post-audit findings on validation pipeline
* fix(config): drop incompatible v0 remaps and tighten validation invariants
Addresses 4 PR review findings cross-validated by 2 oracles.
- ConfigurationMigrations: drop `useWorkerPool` (boolean→enum),
`distributeStationToTenantEqually` and `distributeStationsToTenantsEqually`
(boolean→enum), and `uiWebSocketServer` (legacy shape→strict object) to
`null` (warn-and-delete) — same pattern as `workerPoolStrategy` and
`autoReconnectMaxRetries`. v0 configurations carrying any of these
legacy keys would otherwise auto-write a value the strict schema
rejects, killing the simulator at boot. Migration now warns and asks
the user to set the canonical key explicitly. Fixes the BLOCKING
finding from the PR review (regression on 'v0 configs remain valid').
- ConfigurationMigrations: remove redundant `out.$schemaVersion = 1`
from `migrateV0ToV1`. The `applyConfigurationMigration` loop is the
single owner of version stamping; per-step writes are silently
overwritten and become misleading once a second migration is added.
- ConfigurationSchema: tighten `StationTemplateUrlSchema.numberOfStations`
from `.nonnegative()` to `.positive()` — `0` was schema-valid but
semantically meaningless (a template entry that spawns no station)
and conflicts with `isValidNumberOfStations()` which rejects `<= 0`.
Deprecated `numberOfStation` and `provisionedNumberOfStations` keep
`.nonnegative()` (back-compat / 'none provisioned' is meaningful).
- ConfigurationValidation: import `isEmpty` directly from
`utils/Utils.js` instead of the `utils/index.js` barrel, which
re-exports `Configuration` and creates the cycle
`ConfigurationValidation → utils/index → Configuration → ConfigurationValidation`.
Mirrors the existing direct-path `BaseError` import in
`ConfigurationMigrations.ts` for the same TDZ-cycle-avoidance reason.
* docs(config): fix precision defects in schema JSDocs and README
- ConfigurationSchema.ts: correct StorageConfigurationSchema JSDoc —
'URI' is accepted but not auto-migrated (not in DEPRECATED_KEY_REMAPPINGS);
narrow ConfigurationSchema JSDoc meta-test scope claim to 'top-level
and worker.* keys' to match actual test coverage.
- README.md: clarify that deprecated-key remapping is unconditional
(runs on every load, not only on v0 migration).
* fix(config): keep numberOfStations as .nonnegative() to preserve disabled-entry convention
Revert the .positive() tightening from
95cd26dd: docker/config.json ships
5 stationTemplateUrls entries with `numberOfStations: 0` (used as a
'keep on disk but disabled' convention), and this file is copied to
src/assets/config.json by docker/Dockerfile at image build time. With
.positive(), Docker images built from this branch would fail to start
on validateConfiguration's strict parse → process.exit(1).
Bootstrap loops already tolerate 0 (no-op, no crash). The original
.nonnegative() correctly models the on-disk convention; isValidNumberOfStations()
in UIServerSecurity.ts is for runtime UI add-station requests, a
separate concern from config-file parsing.
* docs(config): trim re-entrancy and clone rationales to one-liners