Skip to content

Commit 7845a09

Browse files
authored
Merge pull request #1217 from HarperFast/feat/config-union-directive
feat(config): add `$union` array directive for config env vars
2 parents a9c5db2 + 7751615 commit 7845a09

2 files changed

Lines changed: 422 additions & 23 deletions

File tree

config/harperConfigEnvVars.ts

Lines changed: 131 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
253373
function 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

Comments
 (0)