@@ -105,6 +105,116 @@ 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 a `$`-prefixed key
113+ * (real config keys are never `$`-prefixed), so `flattenObject` treats it as a leaf
114+ * rather than recursing into `tls.uses.$union`.
115+ *
116+ * `$union` guarantees the listed items are present in the target array — the
117+ * order-preserving union of (existing ∪ listed). It is idempotent under repeated
118+ * application and never removes entries it didn't name, which is what lets a platform
119+ * layer reapply its required entries on every restart without dropping an app's
120+ * additions (even on the HARPER_SET_CONFIG force/drift path). We deliberately do not
121+ * add `$append` (not idempotent) or `$replace` (a bare array already replaces); the
122+ * vocabulary stays open so further directives can be added non-breaking.
123+ *
124+ * Note on HARPER_DEFAULT_CONFIG: a `$union` there composes at install (or against a
125+ * value DEFAULT previously set), but at runtime DEFAULT yields to an existing
126+ * un-sourced array and the union no-ops — matching DEFAULT's "only update values we
127+ * previously set" contract. Use HARPER_SET_CONFIG to compose at runtime.
128+ */
129+ const DIRECTIVE_UNION = '$union' ;
130+
131+ /**
132+ * True if value is a plain object carrying a directive (a `$`-prefixed key).
133+ */
134+ function isDirectiveObject ( value : any ) : boolean {
135+ return isPlainObject ( value ) && Object . keys ( value ) . some ( ( key ) => key . startsWith ( '$' ) ) ;
136+ }
137+
138+ /**
139+ * Validate a directive object and return its operands. Throws on a malformed directive
140+ * so misconfiguration surfaces loudly rather than silently misbehaving.
141+ */
142+ function parseDirective ( value : Record < string , any > , path : string ) : { items : any [ ] } {
143+ const keys = Object . keys ( value ) ;
144+ if ( keys . length !== 1 ) {
145+ throw new ConfigEnvVarError ( `Config directive at "${ path } " must be the only key, got: ${ keys . join ( ', ' ) } ` ) ;
146+ }
147+ if ( keys [ 0 ] !== DIRECTIVE_UNION ) {
148+ throw new ConfigEnvVarError ( `Unknown config directive "${ keys [ 0 ] } " at "${ path } " (supported: ${ DIRECTIVE_UNION } )` ) ;
149+ }
150+ const items = value [ DIRECTIVE_UNION ] ;
151+ if ( ! Array . isArray ( items ) ) {
152+ throw new ConfigEnvVarError ( `Config directive "${ DIRECTIVE_UNION } " at "${ path } " requires an array value` ) ;
153+ }
154+ return { items } ;
155+ }
156+
157+ /**
158+ * Deterministic JSON string with object keys sorted at every level, so two
159+ * structurally-equal values compare equal regardless of property insertion order.
160+ * Shared by snapshot hashing and by `$union`'s idempotent dedup of object entries.
161+ */
162+ function stableStringify ( value : any ) : string {
163+ // Honor toJSON (e.g. Date) so values serialize the way JSON.stringify would.
164+ if ( value && typeof value . toJSON === 'function' ) {
165+ value = value . toJSON ( ) ;
166+ }
167+ if ( value === null || typeof value !== 'object' ) {
168+ // undefined/function/symbol stringify to undefined → normalize to 'null' (matches
169+ // JSON.stringify of an array slot) and keep the declared string return type honest.
170+ return JSON . stringify ( value ) ?? 'null' ;
171+ }
172+ if ( Array . isArray ( value ) ) {
173+ return '[' + value . map ( ( item ) => stableStringify ( item ) ) . join ( ',' ) + ']' ;
174+ }
175+ // Match JSON.stringify, which omits keys whose value is undefined/function/symbol.
176+ const pairs : string [ ] = [ ] ;
177+ for ( const key of Object . keys ( value ) . sort ( ) ) {
178+ const item = value [ key ] ;
179+ if ( item !== undefined && typeof item !== 'function' && typeof item !== 'symbol' ) {
180+ pairs . push ( JSON . stringify ( key ) + ':' + stableStringify ( item ) ) ;
181+ }
182+ }
183+ return '{' + pairs . join ( ',' ) + '}' ;
184+ }
185+
186+ /**
187+ * Order-preserving union: existing entries kept in place, listed items appended only
188+ * when not already present. Idempotent (re-applying is a no-op, no duplicates) and
189+ * never removes entries the directive didn't name. Dedup uses key-order-insensitive
190+ * equality so object entries (e.g. `{ port, host }` vs `{ host, port }`) don't
191+ * re-append across boots.
192+ */
193+ function unionArrays ( current : any , items : any [ ] ) : any [ ] {
194+ const result = Array . isArray ( current ) ? [ ...current ] : [ ] ;
195+ // Pre-stringify existing entries once, then stringify each candidate once (O(N+M)).
196+ const seen = result . map ( ( existing ) => stableStringify ( existing ) ) ;
197+ for ( const item of items ) {
198+ const key = stableStringify ( item ) ;
199+ if ( ! seen . includes ( key ) ) {
200+ result . push ( item ) ;
201+ seen . push ( key ) ;
202+ }
203+ }
204+ return result ;
205+ }
206+
207+ /**
208+ * Resolve the value to write for a flattened leaf given the value currently at that
209+ * path. Plain leaves overwrite (default); directive leaves compose against current.
210+ */
211+ function resolveLeafValue ( currentValue : any , leafValue : any , path : string ) : any {
212+ if ( isDirectiveObject ( leafValue ) ) {
213+ return unionArrays ( currentValue , parseDirective ( leafValue , path ) . items ) ;
214+ }
215+ return leafValue ;
216+ }
217+
108218/**
109219 * Filters out arguments that are already set in HARPER_SET_CONFIG.
110220 * This prevents individual environment variables from overriding runtime configuration.
@@ -148,7 +258,12 @@ export function filterArgsAgainstRuntimeConfig(args: Record<string, any>): Recor
148258 const keys = new Set < string > ( ) ;
149259 for ( const key in obj ) {
150260 const newKey = prefix ? `${ prefix } _${ key } ` : key ;
151- if ( obj [ key ] !== null && typeof obj [ key ] === 'object' && ! Array . isArray ( obj [ key ] ) ) {
261+ if (
262+ obj [ key ] !== null &&
263+ typeof obj [ key ] === 'object' &&
264+ ! Array . isArray ( obj [ key ] ) &&
265+ ! isDirectiveObject ( obj [ key ] )
266+ ) {
152267 flattenSetConfig ( obj [ key ] , newKey ) . forEach ( ( k ) => keys . add ( k ) ) ;
153268 } else {
154269 keys . add ( newKey . toLowerCase ( ) ) ;
@@ -182,11 +297,11 @@ function flattenObject(obj: ConfigObject, prefix = ''): Record<string, any> {
182297 const value = obj [ key ] ;
183298 const newKey = prefix ? `${ prefix } .${ key } ` : key ;
184299
185- if ( isPlainObject ( value ) ) {
300+ if ( isPlainObject ( value ) && ! isDirectiveObject ( value ) ) {
186301 // Recurse for nested objects
187302 Object . assign ( result , flattenObject ( value , newKey ) ) ;
188303 } else {
189- // Store primitive or array
304+ // Store primitive, array, or directive ({ $union: [...] }) as a leaf
190305 result [ newKey ] = value ;
191306 }
192307 }
@@ -251,21 +366,7 @@ function deleteNestedValue(obj: ConfigObject, path: string): void {
251366 * Hash config object for snapshot comparison
252367 */
253368function 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' ) ;
369+ return crypto . createHash ( 'sha256' ) . update ( stableStringify ( config ) ) . digest ( 'hex' ) ;
269370}
270371
271372/**
@@ -402,8 +503,9 @@ function applyConfigLayer(
402503 }
403504 }
404505
405- // Set the value and track the source
406- setNestedValue ( fileConfig , path , value ) ;
506+ // Set the value and track the source (directive leaves compose against current,
507+ // so a $union keeps existing/app entries instead of overwriting them)
508+ setNestedValue ( fileConfig , path , resolveLeafValue ( currentValue , value , path ) ) ;
407509 state . sources [ path ] = sourceName ;
408510 }
409511}
@@ -532,8 +634,8 @@ function processEnvVar(
532634 }
533635 }
534636
535- // Set the value and track the source
536- setNestedValue ( fileConfig , path , value ) ;
637+ // Set the value and track the source (directive leaves compose against current)
638+ setNestedValue ( fileConfig , path , resolveLeafValue ( currentValue , value , path ) ) ;
537639 state . sources [ path ] = sourceName ;
538640 }
539641 }
@@ -617,7 +719,8 @@ export function composeConfigFromEnv(base: ConfigObject = {}): ConfigObject {
617719 for ( const layer of layers ) {
618720 if ( ! layer ) continue ;
619721 for ( const [ p , value ] of Object . entries ( flattenObject ( layer ) ) ) {
620- setNestedValue ( result , p , value ) ;
722+ // directive leaves compose against the value accumulated by prior layers
723+ setNestedValue ( result , p , resolveLeafValue ( getNestedValue ( result , p ) , value , p ) ) ;
621724 }
622725 }
623726
0 commit comments