@@ -105,6 +105,121 @@ function isPlainObject(value: any): value is Record<string, any> {
105105 ) ;
106106}
107107
108+ /**
109+ * Array-composition directive: `{ $union: [...] }`.
110+ *
111+ * A "directive" is a plain object that encodes a non-default merge operation for a
112+ * leaf value instead of the usual overwrite. It is recognized by the presence of a
113+ * supported directive key (see SUPPORTED_DIRECTIVES), so `flattenObject` treats it as
114+ * a leaf rather than recursing into `tls.uses.$union`. Other `$`-prefixed keys (e.g. a
115+ * JSON Schema's `$schema`/`$ref` inside component config) are NOT directives and pass
116+ * through as ordinary config values.
117+ *
118+ * `$union` guarantees the listed items are present in the target array — the
119+ * order-preserving union of (existing ∪ listed). It is idempotent under repeated
120+ * application and never removes entries it didn't name, which is what lets a platform
121+ * layer reapply its required entries on every restart without dropping an app's
122+ * additions (even on the HARPER_SET_CONFIG force/drift path). We deliberately do not
123+ * add `$append` (not idempotent) or `$replace` (a bare array already replaces); the
124+ * vocabulary stays open so further directives can be added non-breaking.
125+ *
126+ * Note on HARPER_DEFAULT_CONFIG: a `$union` there composes at install (or against a
127+ * value DEFAULT previously set), but at runtime DEFAULT yields to an existing
128+ * un-sourced array and the union no-ops — matching DEFAULT's "only update values we
129+ * previously set" contract. Use HARPER_SET_CONFIG to compose at runtime.
130+ */
131+ const DIRECTIVE_UNION = '$union' ;
132+ const SUPPORTED_DIRECTIVES = [ DIRECTIVE_UNION ] ;
133+
134+ /**
135+ * True if value is a plain object carrying a supported directive key. Detection is
136+ * deliberately narrowed to the known sentinels (not any `$`-prefixed key) so ordinary
137+ * config that happens to contain `$`-keys — e.g. a component config embedding a JSON
138+ * Schema with `$schema`/`$ref` — keeps flattening and applying as plain values.
139+ */
140+ function isDirectiveObject ( value : any ) : boolean {
141+ return isPlainObject ( value ) && SUPPORTED_DIRECTIVES . some ( ( directive ) => directive in value ) ;
142+ }
143+
144+ /**
145+ * Validate a directive object and return its operands. Throws on a malformed directive
146+ * so misconfiguration surfaces loudly rather than silently misbehaving.
147+ */
148+ function parseDirective ( value : Record < string , any > , path : string ) : { items : any [ ] } {
149+ const keys = Object . keys ( value ) ;
150+ if ( keys . length !== 1 ) {
151+ throw new ConfigEnvVarError (
152+ `Config directive "${ DIRECTIVE_UNION } " at "${ path } " must be the only key, got: ${ keys . join ( ', ' ) } `
153+ ) ;
154+ }
155+ const items = value [ DIRECTIVE_UNION ] ;
156+ if ( ! Array . isArray ( items ) ) {
157+ throw new ConfigEnvVarError ( `Config directive "${ DIRECTIVE_UNION } " at "${ path } " requires an array value` ) ;
158+ }
159+ return { items } ;
160+ }
161+
162+ /**
163+ * Deterministic JSON string with object keys sorted at every level, so two
164+ * structurally-equal values compare equal regardless of property insertion order.
165+ * Shared by snapshot hashing and by `$union`'s idempotent dedup of object entries.
166+ */
167+ function stableStringify ( value : any ) : string {
168+ // Honor toJSON (e.g. Date) so values serialize the way JSON.stringify would.
169+ if ( value && typeof value . toJSON === 'function' ) {
170+ value = value . toJSON ( ) ;
171+ }
172+ if ( value === null || typeof value !== 'object' ) {
173+ // undefined/function/symbol stringify to undefined → normalize to 'null' (matches
174+ // JSON.stringify of an array slot) and keep the declared string return type honest.
175+ return JSON . stringify ( value ) ?? 'null' ;
176+ }
177+ if ( Array . isArray ( value ) ) {
178+ return '[' + value . map ( ( item ) => stableStringify ( item ) ) . join ( ',' ) + ']' ;
179+ }
180+ // Match JSON.stringify, which omits keys whose value is undefined/function/symbol.
181+ const pairs : string [ ] = [ ] ;
182+ for ( const key of Object . keys ( value ) . sort ( ) ) {
183+ const item = value [ key ] ;
184+ if ( item !== undefined && typeof item !== 'function' && typeof item !== 'symbol' ) {
185+ pairs . push ( JSON . stringify ( key ) + ':' + stableStringify ( item ) ) ;
186+ }
187+ }
188+ return '{' + pairs . join ( ',' ) + '}' ;
189+ }
190+
191+ /**
192+ * Order-preserving union: existing entries kept in place, listed items appended only
193+ * when not already present. Idempotent (re-applying is a no-op, no duplicates) and
194+ * never removes entries the directive didn't name. Dedup uses key-order-insensitive
195+ * equality so object entries (e.g. `{ port, host }` vs `{ host, port }`) don't
196+ * re-append across boots.
197+ */
198+ function unionArrays ( current : any , items : any [ ] ) : any [ ] {
199+ const result = Array . isArray ( current ) ? [ ...current ] : [ ] ;
200+ // Pre-stringify existing entries once, then stringify each candidate once (O(N+M)).
201+ const seen = result . map ( ( existing ) => stableStringify ( existing ) ) ;
202+ for ( const item of items ) {
203+ const key = stableStringify ( item ) ;
204+ if ( ! seen . includes ( key ) ) {
205+ result . push ( item ) ;
206+ seen . push ( key ) ;
207+ }
208+ }
209+ return result ;
210+ }
211+
212+ /**
213+ * Resolve the value to write for a flattened leaf given the value currently at that
214+ * path. Plain leaves overwrite (default); directive leaves compose against current.
215+ */
216+ function resolveLeafValue ( currentValue : any , leafValue : any , path : string ) : any {
217+ if ( isDirectiveObject ( leafValue ) ) {
218+ return unionArrays ( currentValue , parseDirective ( leafValue , path ) . items ) ;
219+ }
220+ return leafValue ;
221+ }
222+
108223/**
109224 * Filters out arguments that are already set in HARPER_SET_CONFIG.
110225 * This prevents individual environment variables from overriding runtime configuration.
@@ -148,7 +263,12 @@ export function filterArgsAgainstRuntimeConfig(args: Record<string, any>): Recor
148263 const keys = new Set < string > ( ) ;
149264 for ( const key in obj ) {
150265 const newKey = prefix ? `${ prefix } _${ key } ` : key ;
151- if ( obj [ key ] !== null && typeof obj [ key ] === 'object' && ! Array . isArray ( obj [ key ] ) ) {
266+ if (
267+ obj [ key ] !== null &&
268+ typeof obj [ key ] === 'object' &&
269+ ! Array . isArray ( obj [ key ] ) &&
270+ ! isDirectiveObject ( obj [ key ] )
271+ ) {
152272 flattenSetConfig ( obj [ key ] , newKey ) . forEach ( ( k ) => keys . add ( k ) ) ;
153273 } else {
154274 keys . add ( newKey . toLowerCase ( ) ) ;
@@ -182,11 +302,11 @@ function flattenObject(obj: ConfigObject, prefix = ''): Record<string, any> {
182302 const value = obj [ key ] ;
183303 const newKey = prefix ? `${ prefix } .${ key } ` : key ;
184304
185- if ( isPlainObject ( value ) ) {
305+ if ( isPlainObject ( value ) && ! isDirectiveObject ( value ) ) {
186306 // Recurse for nested objects
187307 Object . assign ( result , flattenObject ( value , newKey ) ) ;
188308 } else {
189- // Store primitive or array
309+ // Store primitive, array, or directive ({ $union: [...] }) as a leaf
190310 result [ newKey ] = value ;
191311 }
192312 }
@@ -251,21 +371,7 @@ function deleteNestedValue(obj: ConfigObject, path: string): void {
251371 * Hash config object for snapshot comparison
252372 */
253373function hashConfig ( config : ConfigObject ) : string {
254- // Deterministic JSON stringify with sorted keys at all levels
255- const sortedStringify = ( obj : any ) : string => {
256- if ( obj === null || typeof obj !== 'object' ) {
257- return JSON . stringify ( obj ) ;
258- }
259- if ( Array . isArray ( obj ) ) {
260- return '[' + obj . map ( sortedStringify ) . join ( ',' ) + ']' ;
261- }
262- const keys = Object . keys ( obj ) . sort ( ) ;
263- const pairs = keys . map ( ( key ) => JSON . stringify ( key ) + ':' + sortedStringify ( obj [ key ] ) ) ;
264- return '{' + pairs . join ( ',' ) + '}' ;
265- } ;
266-
267- const json = sortedStringify ( config ) ;
268- return crypto . createHash ( 'sha256' ) . update ( json ) . digest ( 'hex' ) ;
374+ return crypto . createHash ( 'sha256' ) . update ( stableStringify ( config ) ) . digest ( 'hex' ) ;
269375}
270376
271377/**
@@ -402,8 +508,9 @@ function applyConfigLayer(
402508 }
403509 }
404510
405- // Set the value and track the source
406- setNestedValue ( fileConfig , path , value ) ;
511+ // Set the value and track the source (directive leaves compose against current,
512+ // so a $union keeps existing/app entries instead of overwriting them)
513+ setNestedValue ( fileConfig , path , resolveLeafValue ( currentValue , value , path ) ) ;
407514 state . sources [ path ] = sourceName ;
408515 }
409516}
@@ -532,8 +639,8 @@ function processEnvVar(
532639 }
533640 }
534641
535- // Set the value and track the source
536- setNestedValue ( fileConfig , path , value ) ;
642+ // Set the value and track the source (directive leaves compose against current)
643+ setNestedValue ( fileConfig , path , resolveLeafValue ( currentValue , value , path ) ) ;
537644 state . sources [ path ] = sourceName ;
538645 }
539646 }
@@ -617,7 +724,8 @@ export function composeConfigFromEnv(base: ConfigObject = {}): ConfigObject {
617724 for ( const layer of layers ) {
618725 if ( ! layer ) continue ;
619726 for ( const [ p , value ] of Object . entries ( flattenObject ( layer ) ) ) {
620- setNestedValue ( result , p , value ) ;
727+ // directive leaves compose against the value accumulated by prior layers
728+ setNestedValue ( result , p , resolveLeafValue ( getNestedValue ( result , p ) , value , p ) ) ;
621729 }
622730 }
623731
0 commit comments