Skip to content

Commit 0d3be49

Browse files
heskewclaude
andcommitted
feat(config): add $union array directive for config env vars
Arrays in HARPER_DEFAULT_CONFIG / HARPER_SET_CONFIG are replaced wholesale. Add a { $union: [...] } directive that composes instead: an order-preserving, idempotent union that guarantees the listed items are present while never removing entries it didn't name — honored even on HARPER_SET_CONFIG's force/drift deletion path. This lets a platform layer reapply required entries (e.g. tls.uses) on every restart without clobbering an app's additions. Bare arrays still replace (unchanged default). A shared resolveLeafValue resolves directive leaves across flattenObject, applyConfigLayer, the DEFAULT runtime branch, and composeConfigFromEnv. Refs #1213 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 88c94e6 commit 0d3be49

2 files changed

Lines changed: 370 additions & 23 deletions

File tree

config/harperConfigEnvVars.ts

Lines changed: 126 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
253368
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');
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

Comments
 (0)