@effected/config-file 0.20.1 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ConfigFile.js +65 -15
- package/ConfigMerge.js +73 -0
- package/ConfigResolver.js +3 -3
- package/MergeStrategy.js +6 -5
- package/index.d.ts +173 -24
- package/index.js +2 -1
- package/internal/deepMerge.js +62 -23
- package/package.json +6 -6
package/ConfigFile.js
CHANGED
|
@@ -260,24 +260,65 @@ const makeImpl = (options, fs, path, resolverEnv) => {
|
|
|
260
260
|
});
|
|
261
261
|
return value;
|
|
262
262
|
});
|
|
263
|
-
|
|
263
|
+
/**
|
|
264
|
+
* `load`, also reporting the highest-priority source's path. `update` needs
|
|
265
|
+
* the path its own load read, so `saveTarget: "source"` writes where it
|
|
266
|
+
* read. Not an `Effect.fn`: the public boundaries that call it open the span.
|
|
267
|
+
*/
|
|
268
|
+
const loadWithSource = () => Effect.gen(function* () {
|
|
264
269
|
const { sources, candidates } = yield* discover();
|
|
265
|
-
|
|
270
|
+
const [first] = sources;
|
|
271
|
+
if (first === void 0) {
|
|
266
272
|
yield* emit({ _tag: "NotFound" });
|
|
267
273
|
return yield* Effect.fail(new ConfigFileNotFoundError({
|
|
268
274
|
searched,
|
|
269
275
|
candidates
|
|
270
276
|
}));
|
|
271
277
|
}
|
|
272
|
-
return
|
|
278
|
+
return {
|
|
279
|
+
value: yield* mergeAndEmit(sources),
|
|
280
|
+
source: first.path
|
|
281
|
+
};
|
|
273
282
|
});
|
|
274
|
-
|
|
283
|
+
/** `loadOrDefault`, also reporting the highest-priority source's path when there is one. */
|
|
284
|
+
const loadOrDefaultWithSource = (defaultValue) => Effect.gen(function* () {
|
|
275
285
|
const { sources } = yield* discover();
|
|
276
|
-
|
|
286
|
+
const [first] = sources;
|
|
287
|
+
if (first === void 0) {
|
|
277
288
|
yield* emit({ _tag: "NotFound" });
|
|
278
|
-
return
|
|
289
|
+
return {
|
|
290
|
+
value: defaultValue,
|
|
291
|
+
source: Option.none()
|
|
292
|
+
};
|
|
279
293
|
}
|
|
280
|
-
return
|
|
294
|
+
return {
|
|
295
|
+
value: yield* mergeAndEmit(sources),
|
|
296
|
+
source: Option.some(first.path)
|
|
297
|
+
};
|
|
298
|
+
});
|
|
299
|
+
const load = Effect.fn("ConfigFile.load")(function* () {
|
|
300
|
+
return (yield* loadWithSource()).value;
|
|
301
|
+
});
|
|
302
|
+
const loadOrDefault = Effect.fn("ConfigFile.loadOrDefault")(function* (defaultValue) {
|
|
303
|
+
return (yield* loadOrDefaultWithSource(defaultValue)).value;
|
|
304
|
+
});
|
|
305
|
+
/**
|
|
306
|
+
* Whether `save` and `update` write to the source they found rather than `defaultPath`. Unset, it follows the
|
|
307
|
+
* strategy: `firstMatch` writes the file that is in effect; `layeredMerge` and any custom strategy write
|
|
308
|
+
* `defaultPath`, where the merged value flattens least harmfully.
|
|
309
|
+
*/
|
|
310
|
+
const toSource = (options.saveTarget ?? (options.strategy.name === "first-match" ? "source" : "default")) === "source";
|
|
311
|
+
/**
|
|
312
|
+
* The first path the resolver chain finds, by lookup alone: no file is read,
|
|
313
|
+
* so `save` gains no read or decode failure. Every resolver's `resolve` is
|
|
314
|
+
* total by contract.
|
|
315
|
+
*/
|
|
316
|
+
const locate = Effect.gen(function* () {
|
|
317
|
+
for (const resolver of options.resolvers) {
|
|
318
|
+
const found = yield* Effect.provide(resolver.resolve, resolverEnv);
|
|
319
|
+
if (Option.isSome(found)) return found;
|
|
320
|
+
}
|
|
321
|
+
return Option.none();
|
|
281
322
|
});
|
|
282
323
|
const validate = Effect.fn("ConfigFile.validate")(function* (value) {
|
|
283
324
|
const decoded = yield* decode(value, Option.none());
|
|
@@ -338,10 +379,14 @@ const makeImpl = (options, fs, path, resolverEnv) => {
|
|
|
338
379
|
* emitting boundary sits in the public method, never in the shared internals
|
|
339
380
|
* it delegates to.
|
|
340
381
|
*/
|
|
341
|
-
const saveTo = (value) => Effect.gen(function* () {
|
|
342
|
-
|
|
343
|
-
if (
|
|
344
|
-
|
|
382
|
+
const saveTo = (value, source) => Effect.gen(function* () {
|
|
383
|
+
let target;
|
|
384
|
+
if (Option.isSome(source)) target = source.value;
|
|
385
|
+
else {
|
|
386
|
+
const configured = options.defaultPath;
|
|
387
|
+
if (configured === void 0) return yield* Effect.fail(new ConfigDefaultPathMissingError({}));
|
|
388
|
+
target = yield* Effect.provide(configured, resolverEnv);
|
|
389
|
+
}
|
|
345
390
|
yield* fs.makeDirectory(path.dirname(target), { recursive: true }).pipe(Effect.mapError((cause) => new ConfigFileWriteError({
|
|
346
391
|
path: target,
|
|
347
392
|
cause
|
|
@@ -357,7 +402,7 @@ const makeImpl = (options, fs, path, resolverEnv) => {
|
|
|
357
402
|
});
|
|
358
403
|
});
|
|
359
404
|
const save = Effect.fn("ConfigFile.save")(function* (value) {
|
|
360
|
-
const target = yield* saveTo(value);
|
|
405
|
+
const target = yield* saveTo(value, toSource ? yield* locate : Option.none());
|
|
361
406
|
yield* emit({
|
|
362
407
|
_tag: "Saved",
|
|
363
408
|
path: target
|
|
@@ -379,8 +424,12 @@ const makeImpl = (options, fs, path, resolverEnv) => {
|
|
|
379
424
|
const updateLock = Semaphore.makeUnsafe(1);
|
|
380
425
|
const update = Effect.fn("ConfigFile.update")(function* (fn, defaultValue) {
|
|
381
426
|
return yield* updateLock.withPermits(1)(Effect.gen(function* () {
|
|
382
|
-
const
|
|
383
|
-
|
|
427
|
+
const current = defaultValue !== void 0 ? yield* loadOrDefaultWithSource(defaultValue) : yield* Effect.map(loadWithSource(), ({ value, source }) => ({
|
|
428
|
+
value,
|
|
429
|
+
source: Option.some(source)
|
|
430
|
+
}));
|
|
431
|
+
const updated = fn(current.value);
|
|
432
|
+
const target = yield* saveTo(updated, toSource ? current.source : Option.none());
|
|
384
433
|
yield* emit({
|
|
385
434
|
_tag: "Updated",
|
|
386
435
|
path: target
|
|
@@ -527,7 +576,8 @@ var ConfigFile = class {
|
|
|
527
576
|
codec: options.codec,
|
|
528
577
|
strategy: options.strategy,
|
|
529
578
|
resolvers,
|
|
530
|
-
...options.validate !== void 0 && { validate: options.validate }
|
|
579
|
+
...options.validate !== void 0 && { validate: options.validate },
|
|
580
|
+
...options.saveTarget !== void 0 && { saveTarget: options.saveTarget }
|
|
531
581
|
}, fs, path, resolverEnv);
|
|
532
582
|
}));
|
|
533
583
|
/**
|
package/ConfigMerge.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { deepMerge } from "./internal/deepMerge.js";
|
|
2
|
+
|
|
3
|
+
// src/ConfigMerge.ts
|
|
4
|
+
/**
|
|
5
|
+
* Pure, synchronous merging of config values: the merge
|
|
6
|
+
* `MergeStrategy.layeredMerge` runs, without `ConfigSource` wrappers or an
|
|
7
|
+
* `Effect`.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* A module of its own, imported from the root: it reaches no codec and no
|
|
11
|
+
* parsing engine, so importing it costs a consumer nothing else.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* import { ConfigMerge } from "@effected/config-file";
|
|
16
|
+
*
|
|
17
|
+
* const profile = { server: { host: "localhost", port: 8080 }, tags: ["a"] };
|
|
18
|
+
* const user = { server: { port: 9090 }, tags: ["b"] };
|
|
19
|
+
* console.log(ConfigMerge.deep(profile, user));
|
|
20
|
+
* // => { server: { port: 9090, host: "localhost" }, tags: ["b"] }
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* @public
|
|
24
|
+
*/
|
|
25
|
+
var ConfigMerge = class {
|
|
26
|
+
constructor() {}
|
|
27
|
+
/**
|
|
28
|
+
* Merges `override` over `base`, `override` winning on every conflict.
|
|
29
|
+
* Neither input is modified.
|
|
30
|
+
*
|
|
31
|
+
* @remarks
|
|
32
|
+
* The rules, which are `MergeStrategy.layeredMerge`'s too:
|
|
33
|
+
*
|
|
34
|
+
* - **Which values merge.** Two plain objects merge field-wise, whichever
|
|
35
|
+
* of `Object.prototype` and `null` each has as its prototype, so a
|
|
36
|
+
* null-prototype object and an ordinary one merge with each other. At the
|
|
37
|
+
* top level only, two instances of one class (a decoded `Schema.Class`
|
|
38
|
+
* document) merge too. Any other pair is atomic and `override` wins whole.
|
|
39
|
+
* - **The result's prototype** is `override`'s, so a class instance stays
|
|
40
|
+
* an instance and a null-prototype override yields a null-prototype
|
|
41
|
+
* result.
|
|
42
|
+
* - **Arrays are atomic.** `override`'s array replaces `base`'s whole;
|
|
43
|
+
* nothing is concatenated or merged by index. So are `Date`, `Map`,
|
|
44
|
+
* `Set`, `RegExp` and nested class instances.
|
|
45
|
+
* - **Presence decides, not value.** An own key on `override` wins even
|
|
46
|
+
* when its value is `undefined`; to inherit `base`'s value, omit the key.
|
|
47
|
+
* - **Prototype keys are dropped.** At every level that merges,
|
|
48
|
+
* `__proto__`, `constructor` and `prototype` are skipped on both sides,
|
|
49
|
+
* and every key is written as an own data property, so neither input can
|
|
50
|
+
* reach `Object.prototype` or replace the result's prototype. A config
|
|
51
|
+
* whose real data uses one of those names loses that key at those
|
|
52
|
+
* levels. A value taken whole is shared as it is, so an own `__proto__`
|
|
53
|
+
* data key inside it (as `JSON.parse` writes one) stays, and stays inert.
|
|
54
|
+
* - **Only own enumerable string keys** are read. Symbol keys,
|
|
55
|
+
* non-enumerable properties and getters on the prototype are not copied.
|
|
56
|
+
* - **Nothing is cloned.** Merged levels are fresh objects; every value
|
|
57
|
+
* taken whole is the same reference as in its input.
|
|
58
|
+
* - **Shared and cyclic inputs keep their shape.** Each pair of objects
|
|
59
|
+
* merges once: a subobject that two keys share on both sides yields one
|
|
60
|
+
* merged object both keys share, and a cycle in the inputs becomes the
|
|
61
|
+
* same cycle in the result. That also bounds the work to one merge per
|
|
62
|
+
* distinct pair, so a cyclic input returns promptly.
|
|
63
|
+
* - **It is total.** Nested levels merge down to a depth of 1024, far below
|
|
64
|
+
* any decoded document; below that, `override`'s value is taken whole,
|
|
65
|
+
* so a pathologically deep input returns instead of overflowing the
|
|
66
|
+
* stack.
|
|
67
|
+
*/
|
|
68
|
+
static deep(base, override) {
|
|
69
|
+
return deepMerge(base, override);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export { ConfigMerge };
|
package/ConfigResolver.js
CHANGED
|
@@ -111,8 +111,8 @@ var ConfigResolver = class {
|
|
|
111
111
|
};
|
|
112
112
|
})));
|
|
113
113
|
/**
|
|
114
|
-
* Ascends from `cwd` (or the process cwd) toward `stopAt
|
|
115
|
-
* first `subpaths/filename` combination found at each level.
|
|
114
|
+
* Ascends from `cwd` (or the process cwd) toward `stopAt` or `ceiling`,
|
|
115
|
+
* resolving the first `subpaths/filename` combination found at each level.
|
|
116
116
|
*
|
|
117
117
|
* @remarks
|
|
118
118
|
* `filenames` replaces `filename` with a per-directory candidate list: every
|
|
@@ -130,7 +130,7 @@ var ConfigResolver = class {
|
|
|
130
130
|
const path = yield* Path.Path;
|
|
131
131
|
const subpaths = options.subpaths ?? ["."];
|
|
132
132
|
const filenames = options.filenames ?? [options.filename];
|
|
133
|
-
const dirs = yield* Walker.ascend(cwdOf(options.cwd), { ...options.stopAt !== void 0 && { stopAt: options.stopAt } });
|
|
133
|
+
const dirs = options.ceiling !== void 0 ? yield* Walker.ascendWithin(cwdOf(options.cwd), Option.some(options.ceiling)) : yield* Walker.ascend(cwdOf(options.cwd), { ...options.stopAt !== void 0 && { stopAt: options.stopAt } });
|
|
134
134
|
const candidates = [];
|
|
135
135
|
for (const dir of dirs) for (const sub of subpaths) for (const filename of filenames) candidates.push({
|
|
136
136
|
path: path.join(dir, sub, filename),
|
package/MergeStrategy.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { deepMerge } from "./internal/deepMerge.js";
|
|
2
2
|
import { Effect } from "effect";
|
|
3
3
|
|
|
4
4
|
// src/MergeStrategy.ts
|
|
@@ -42,15 +42,16 @@ const MergeStrategy = {
|
|
|
42
42
|
* `{}`.
|
|
43
43
|
*
|
|
44
44
|
* Nested *plain* objects (a `Schema.Struct` section) still merge field-wise.
|
|
45
|
+
*
|
|
46
|
+
* Each step of the fold is `ConfigMerge.deep(lower, higher)`, so the rules
|
|
47
|
+
* documented there (arrays, `undefined`, prototype keys, null-prototype
|
|
48
|
+
* objects) are this strategy's rules too.
|
|
45
49
|
*/
|
|
46
50
|
layeredMerge: () => ({
|
|
47
51
|
name: "layered-merge",
|
|
48
52
|
resolve: (sources) => {
|
|
49
53
|
let merged = sources[sources.length - 1]?.value;
|
|
50
|
-
for (let i = sources.length - 2; i >= 0; i--)
|
|
51
|
-
const higher = sources[i]?.value;
|
|
52
|
-
merged = canMerge(merged, higher) ? deepMerge(higher, merged) : higher;
|
|
53
|
-
}
|
|
54
|
+
for (let i = sources.length - 2; i >= 0; i--) merged = deepMerge(merged, sources[i]?.value);
|
|
54
55
|
return Effect.succeed(merged);
|
|
55
56
|
}
|
|
56
57
|
})
|
package/index.d.ts
CHANGED
|
@@ -411,16 +411,53 @@ interface ConfigProbe {
|
|
|
411
411
|
* (`".config/app.toml"`), which is how a mixed convention is spelled without a
|
|
412
412
|
* `subpaths` cross product.
|
|
413
413
|
*
|
|
414
|
+
* At most one of `stopAt` (a lexical ceiling) and `ceiling` (a physical
|
|
415
|
+
* one) is given. Bound the walk by a path git answered — `Git.repoRoot` — with
|
|
416
|
+
* `ceiling`; `stopAt` never matches a symlink-resolved spelling of the start.
|
|
417
|
+
*
|
|
414
418
|
* @public
|
|
415
419
|
*/
|
|
416
|
-
type UpwardWalkOptions = {
|
|
420
|
+
type UpwardWalkOptions = ({
|
|
417
421
|
/** The single file name probed at each ancestor directory. */
|
|
418
422
|
readonly filename: string;
|
|
419
423
|
readonly filenames?: undefined;
|
|
424
|
+
} | {
|
|
425
|
+
/**
|
|
426
|
+
* The candidate file names probed at each ancestor directory, in order,
|
|
427
|
+
* before the walk ascends.
|
|
428
|
+
*/
|
|
429
|
+
readonly filenames: ReadonlyArray<string>;
|
|
430
|
+
readonly filename?: undefined;
|
|
431
|
+
}) & ({
|
|
432
|
+
/**
|
|
433
|
+
* An absolute ceiling the walk stops at, inclusive, compared
|
|
434
|
+
* LEXICALLY (`Walker.ascend`'s `stopAt`).
|
|
435
|
+
*
|
|
436
|
+
* @remarks
|
|
437
|
+
* A ceiling spelled differently from the walk's own path never
|
|
438
|
+
* matches, and the walk runs on to the filesystem root. That is
|
|
439
|
+
* every physical path — `Git.repoRoot`'s answer from a symlinked or
|
|
440
|
+
* macOS-tmpdir start — so bound by such a path with `ceiling`.
|
|
441
|
+
*/
|
|
442
|
+
readonly stopAt?: string;
|
|
443
|
+
readonly ceiling?: undefined;
|
|
444
|
+
} | {
|
|
445
|
+
/**
|
|
446
|
+
* A ceiling the walk stops at, inclusive, compared PHYSICALLY: the
|
|
447
|
+
* nearest ancestor whose real path is the ceiling's real path
|
|
448
|
+
* (`Walker.ascendWithin`).
|
|
449
|
+
*
|
|
450
|
+
* @remarks
|
|
451
|
+
* For a ceiling that may be spelled differently from the walk's own
|
|
452
|
+
* path, above all `Git.repoRoot`'s symlink-resolved answer. Omit it
|
|
453
|
+
* to walk to the filesystem root. A relative ceiling is a defect.
|
|
454
|
+
* Cannot be combined with `stopAt`.
|
|
455
|
+
*/
|
|
456
|
+
readonly ceiling?: string;
|
|
457
|
+
readonly stopAt?: undefined;
|
|
458
|
+
}) & {
|
|
420
459
|
/** Where the walk starts. Defaults to the process cwd. */
|
|
421
460
|
readonly cwd?: string;
|
|
422
|
-
/** An absolute ceiling the walk stops at, inclusive. */
|
|
423
|
-
readonly stopAt?: string;
|
|
424
461
|
/** Directories probed under each ancestor, in order. Defaults to `["."]`. */
|
|
425
462
|
readonly subpaths?: ReadonlyArray<string>;
|
|
426
463
|
/**
|
|
@@ -433,21 +470,6 @@ type UpwardWalkOptions = {
|
|
|
433
470
|
* identifies the matching candidate rather than the resolver.
|
|
434
471
|
*/
|
|
435
472
|
readonly name?: string;
|
|
436
|
-
} | {
|
|
437
|
-
/**
|
|
438
|
-
* The candidate file names probed at each ancestor directory, in order,
|
|
439
|
-
* before the walk ascends.
|
|
440
|
-
*/
|
|
441
|
-
readonly filenames: ReadonlyArray<string>;
|
|
442
|
-
readonly filename?: undefined;
|
|
443
|
-
/** Where the walk starts. Defaults to the process cwd. */
|
|
444
|
-
readonly cwd?: string;
|
|
445
|
-
/** An absolute ceiling the walk stops at, inclusive. */
|
|
446
|
-
readonly stopAt?: string;
|
|
447
|
-
/** Directories probed under each ancestor, in order. Defaults to `["."]`. */
|
|
448
|
-
readonly subpaths?: ReadonlyArray<string>;
|
|
449
|
-
/** The resolver's reported name. Defaults to `"walk"`. */
|
|
450
|
-
readonly name?: string;
|
|
451
473
|
};
|
|
452
474
|
/**
|
|
453
475
|
* Built-in resolvers, in the order a typical chain uses them.
|
|
@@ -464,8 +486,8 @@ export declare class ConfigResolver {
|
|
|
464
486
|
readonly filename: string;
|
|
465
487
|
}) => ConfigResolver<FileSystem.FileSystem | Path.Path>;
|
|
466
488
|
/**
|
|
467
|
-
* Ascends from `cwd` (or the process cwd) toward `stopAt
|
|
468
|
-
* first `subpaths/filename` combination found at each level.
|
|
489
|
+
* Ascends from `cwd` (or the process cwd) toward `stopAt` or `ceiling`,
|
|
490
|
+
* resolving the first `subpaths/filename` combination found at each level.
|
|
469
491
|
*
|
|
470
492
|
* @remarks
|
|
471
493
|
* `filenames` replaces `filename` with a per-directory candidate list: every
|
|
@@ -595,6 +617,10 @@ export declare const MergeStrategy: {
|
|
|
595
617
|
* `{}`.
|
|
596
618
|
*
|
|
597
619
|
* Nested *plain* objects (a `Schema.Struct` section) still merge field-wise.
|
|
620
|
+
*
|
|
621
|
+
* Each step of the fold is `ConfigMerge.deep(lower, higher)`, so the rules
|
|
622
|
+
* documented there (arrays, `undefined`, prototype keys, null-prototype
|
|
623
|
+
* objects) are this strategy's rules too.
|
|
598
624
|
*/
|
|
599
625
|
readonly layeredMerge: <A>() => MergeStrategy<A>;
|
|
600
626
|
};
|
|
@@ -843,8 +869,13 @@ interface ConfigFileShape<A, CE = ConfigCodecError> {
|
|
|
843
869
|
*/
|
|
844
870
|
readonly write: (value: A, path: string, options?: ConfigEncodeOptions) => Effect.Effect<void, ConfigWriteError<CE>>;
|
|
845
871
|
/**
|
|
846
|
-
* Resolve
|
|
847
|
-
* return the path written.
|
|
872
|
+
* Resolve the write target, `mkdir -p` its parent, encode `value` into it,
|
|
873
|
+
* and return the path written.
|
|
874
|
+
*
|
|
875
|
+
* @remarks
|
|
876
|
+
* The target follows {@link ConfigFileOptions.saveTarget}: `defaultPath`,
|
|
877
|
+
* or under `"source"` (the default for `firstMatch`) the first file the
|
|
878
|
+
* resolver chain finds, falling back to `defaultPath`.
|
|
848
879
|
*/
|
|
849
880
|
readonly save: (value: A) => Effect.Effect<string, ConfigSaveError<CE>>;
|
|
850
881
|
/**
|
|
@@ -854,6 +885,11 @@ interface ConfigFileShape<A, CE = ConfigCodecError> {
|
|
|
854
885
|
* @remarks
|
|
855
886
|
* With `defaultValue` the load cannot fail with
|
|
856
887
|
* {@link ConfigFileNotFoundError}; without it, it can.
|
|
888
|
+
*
|
|
889
|
+
* The result goes where {@link ConfigFileOptions.saveTarget} says: under
|
|
890
|
+
* `"source"` (the default for `firstMatch`) back to the highest-priority
|
|
891
|
+
* file the load read, or `defaultPath` when the load found none and fell
|
|
892
|
+
* back to `defaultValue`; under `"default"` to `defaultPath`.
|
|
857
893
|
*/
|
|
858
894
|
readonly update: (fn: (current: A) => A, defaultValue?: A) => Effect.Effect<A, ConfigUpdateError<CE>>;
|
|
859
895
|
}
|
|
@@ -931,6 +967,39 @@ interface ConfigFileOptions<A, I, RR, CE = ConfigCodecError> {
|
|
|
931
967
|
* {@link ConfigDefaultPathMissingError}.
|
|
932
968
|
*/
|
|
933
969
|
readonly defaultPath?: Effect.Effect<string, never, RR>;
|
|
970
|
+
/**
|
|
971
|
+
* Where {@link ConfigFileShape.save} and {@link ConfigFileShape.update}
|
|
972
|
+
* write: `"default"` always writes `defaultPath`; `"source"` writes the
|
|
973
|
+
* highest-priority file the resolver chain finds, and `defaultPath` only
|
|
974
|
+
* when it finds none.
|
|
975
|
+
*
|
|
976
|
+
* @remarks
|
|
977
|
+
* Unset, it follows the strategy.
|
|
978
|
+
* {@link (MergeStrategy:variable).firstMatch} defaults to `"source"`:
|
|
979
|
+
* the file that was read is the file in effect, so writing anywhere else
|
|
980
|
+
* puts the change in a file the next run does not read (or creates a
|
|
981
|
+
* second, competing config). {@link (MergeStrategy:variable).layeredMerge}
|
|
982
|
+
* and any custom strategy default to `"default"`. A custom strategy is
|
|
983
|
+
* matched by its `name`, so one that calls itself `"first-match"` takes
|
|
984
|
+
* `firstMatch`'s default.
|
|
985
|
+
*
|
|
986
|
+
* Pin `"default"` when the program writes state of its own rather than
|
|
987
|
+
* the user's settings: under `"source"` a write can land in a file found
|
|
988
|
+
* by walking up, such as a project file checked into a repository.
|
|
989
|
+
*
|
|
990
|
+
* `update` writes to the first source of the load it just ran, under its
|
|
991
|
+
* lock. `save` runs the resolver chain's lookups to find the target and
|
|
992
|
+
* reads no file. With nothing found and no `defaultPath`, both fail with
|
|
993
|
+
* {@link ConfigDefaultPathMissingError}, as under `"default"`.
|
|
994
|
+
*
|
|
995
|
+
* Under {@link (MergeStrategy:variable).layeredMerge}, `update`'s value
|
|
996
|
+
* is the merge of every source, so `"source"` writes the lower layers'
|
|
997
|
+
* values into the top file. That is the same flattening `"default"` does
|
|
998
|
+
* into `defaultPath`, but in a file the user reads; pair `"source"` with
|
|
999
|
+
* {@link (MergeStrategy:variable).firstMatch} when each file should keep
|
|
1000
|
+
* only its own keys.
|
|
1001
|
+
*/
|
|
1002
|
+
readonly saveTarget?: "default" | "source";
|
|
934
1003
|
/**
|
|
935
1004
|
* The opt-in event hook. Pass the `ConfigEvents` class itself.
|
|
936
1005
|
*
|
|
@@ -953,8 +1022,10 @@ interface ConfigFileOptions<A, I, RR, CE = ConfigCodecError> {
|
|
|
953
1022
|
*
|
|
954
1023
|
* It also has no `defaultPath`. Nothing in the temp directory is a defensible
|
|
955
1024
|
* default write target, so `save` and `update` fail with
|
|
956
|
-
* {@link ConfigDefaultPathMissingError} under this layer — the honest answer
|
|
957
|
-
*
|
|
1025
|
+
* {@link ConfigDefaultPathMissingError} under this layer — the honest answer —
|
|
1026
|
+
* unless the write target is `"source"` (the default under `firstMatch`) and
|
|
1027
|
+
* a seeded file was found, which they then write. Otherwise exercise the
|
|
1028
|
+
* write path with {@link ConfigFile.layer}.
|
|
958
1029
|
*
|
|
959
1030
|
* `CE` is the codec's error channel, as on {@link ConfigFileOptions}.
|
|
960
1031
|
*
|
|
@@ -983,6 +1054,18 @@ interface ConfigFileTestOptions<A, I, CE = ConfigCodecError> {
|
|
|
983
1054
|
readonly files: Record<string, string>;
|
|
984
1055
|
/** An optional caller-supplied check run after schema decoding. */
|
|
985
1056
|
readonly validate?: (value: A) => Effect.Effect<A, ConfigValidationError>;
|
|
1057
|
+
/**
|
|
1058
|
+
* Where `save` and `update` write, as on
|
|
1059
|
+
* {@link ConfigFileOptions.saveTarget}, including its strategy-dependent
|
|
1060
|
+
* default.
|
|
1061
|
+
*
|
|
1062
|
+
* @remarks
|
|
1063
|
+
* Under `"source"` they write the seeded file the load read, so the
|
|
1064
|
+
* write path can be tested over this layer. With nothing found there is no
|
|
1065
|
+
* `defaultPath` to fall back to, so they fail with
|
|
1066
|
+
* {@link ConfigDefaultPathMissingError}.
|
|
1067
|
+
*/
|
|
1068
|
+
readonly saveTarget?: "default" | "source";
|
|
986
1069
|
}
|
|
987
1070
|
/**
|
|
988
1071
|
* Options for {@link ConfigFile.read}.
|
|
@@ -1161,6 +1244,72 @@ export declare class ConfigFile {
|
|
|
1161
1244
|
*/
|
|
1162
1245
|
static readonly read: <A, I, CE = ConfigCodecError>(path: string, options: ConfigReadOptions<A, I, CE>) => Effect.Effect<A, ConfigReadError<CE>, FileSystem.FileSystem>;
|
|
1163
1246
|
}
|
|
1247
|
+
/**
|
|
1248
|
+
* Pure, synchronous merging of config values: the merge
|
|
1249
|
+
* `MergeStrategy.layeredMerge` runs, without `ConfigSource` wrappers or an
|
|
1250
|
+
* `Effect`.
|
|
1251
|
+
*
|
|
1252
|
+
* @remarks
|
|
1253
|
+
* A module of its own, imported from the root: it reaches no codec and no
|
|
1254
|
+
* parsing engine, so importing it costs a consumer nothing else.
|
|
1255
|
+
*
|
|
1256
|
+
* @example
|
|
1257
|
+
* ```ts
|
|
1258
|
+
* import { ConfigMerge } from "@effected/config-file";
|
|
1259
|
+
*
|
|
1260
|
+
* const profile = { server: { host: "localhost", port: 8080 }, tags: ["a"] };
|
|
1261
|
+
* const user = { server: { port: 9090 }, tags: ["b"] };
|
|
1262
|
+
* console.log(ConfigMerge.deep(profile, user));
|
|
1263
|
+
* // => { server: { port: 9090, host: "localhost" }, tags: ["b"] }
|
|
1264
|
+
* ```
|
|
1265
|
+
*
|
|
1266
|
+
* @public
|
|
1267
|
+
*/
|
|
1268
|
+
export declare class ConfigMerge {
|
|
1269
|
+
private constructor();
|
|
1270
|
+
/**
|
|
1271
|
+
* Merges `override` over `base`, `override` winning on every conflict.
|
|
1272
|
+
* Neither input is modified.
|
|
1273
|
+
*
|
|
1274
|
+
* @remarks
|
|
1275
|
+
* The rules, which are `MergeStrategy.layeredMerge`'s too:
|
|
1276
|
+
*
|
|
1277
|
+
* - **Which values merge.** Two plain objects merge field-wise, whichever
|
|
1278
|
+
* of `Object.prototype` and `null` each has as its prototype, so a
|
|
1279
|
+
* null-prototype object and an ordinary one merge with each other. At the
|
|
1280
|
+
* top level only, two instances of one class (a decoded `Schema.Class`
|
|
1281
|
+
* document) merge too. Any other pair is atomic and `override` wins whole.
|
|
1282
|
+
* - **The result's prototype** is `override`'s, so a class instance stays
|
|
1283
|
+
* an instance and a null-prototype override yields a null-prototype
|
|
1284
|
+
* result.
|
|
1285
|
+
* - **Arrays are atomic.** `override`'s array replaces `base`'s whole;
|
|
1286
|
+
* nothing is concatenated or merged by index. So are `Date`, `Map`,
|
|
1287
|
+
* `Set`, `RegExp` and nested class instances.
|
|
1288
|
+
* - **Presence decides, not value.** An own key on `override` wins even
|
|
1289
|
+
* when its value is `undefined`; to inherit `base`'s value, omit the key.
|
|
1290
|
+
* - **Prototype keys are dropped.** At every level that merges,
|
|
1291
|
+
* `__proto__`, `constructor` and `prototype` are skipped on both sides,
|
|
1292
|
+
* and every key is written as an own data property, so neither input can
|
|
1293
|
+
* reach `Object.prototype` or replace the result's prototype. A config
|
|
1294
|
+
* whose real data uses one of those names loses that key at those
|
|
1295
|
+
* levels. A value taken whole is shared as it is, so an own `__proto__`
|
|
1296
|
+
* data key inside it (as `JSON.parse` writes one) stays, and stays inert.
|
|
1297
|
+
* - **Only own enumerable string keys** are read. Symbol keys,
|
|
1298
|
+
* non-enumerable properties and getters on the prototype are not copied.
|
|
1299
|
+
* - **Nothing is cloned.** Merged levels are fresh objects; every value
|
|
1300
|
+
* taken whole is the same reference as in its input.
|
|
1301
|
+
* - **Shared and cyclic inputs keep their shape.** Each pair of objects
|
|
1302
|
+
* merges once: a subobject that two keys share on both sides yields one
|
|
1303
|
+
* merged object both keys share, and a cycle in the inputs becomes the
|
|
1304
|
+
* same cycle in the result. That also bounds the work to one merge per
|
|
1305
|
+
* distinct pair, so a cyclic input returns promptly.
|
|
1306
|
+
* - **It is total.** Nested levels merge down to a depth of 1024, far below
|
|
1307
|
+
* any decoded document; below that, `override`'s value is taken whole,
|
|
1308
|
+
* so a pathologically deep input returns instead of overflowing the
|
|
1309
|
+
* stack.
|
|
1310
|
+
*/
|
|
1311
|
+
static deep<A>(base: A, override: A): A;
|
|
1312
|
+
}
|
|
1164
1313
|
declare const ConfigMigrationError_base: Schema.Class<ConfigMigrationError, Schema.TaggedStruct<"ConfigMigrationError", {
|
|
1165
1314
|
/** The target version of the step that failed. `0` when reading the version failed. */
|
|
1166
1315
|
readonly version: Schema.Number;
|
package/index.js
CHANGED
|
@@ -2,10 +2,11 @@ import { ConfigCodecError } from "./ConfigCodec.js";
|
|
|
2
2
|
import { ConfigEvent, ConfigEventPayload, ConfigEvents, ConfigSourceRef } from "./ConfigEvent.js";
|
|
3
3
|
import { ConfigResolver } from "./ConfigResolver.js";
|
|
4
4
|
import { ConfigDefaultPathMissingError, ConfigFile, ConfigFileNotFoundError, ConfigFileReadError, ConfigFileWriteError, ConfigValidationError } from "./ConfigFile.js";
|
|
5
|
+
import { ConfigMerge } from "./ConfigMerge.js";
|
|
5
6
|
import { ConfigMigration, ConfigMigrationError, VersionAccess } from "./ConfigMigration.js";
|
|
6
7
|
import { asConfigProvider, layerConfigProvider } from "./ConfigProvider.js";
|
|
7
8
|
import { ConfigEncryptionError, EncryptedCodec, EncryptedCodecKey } from "./EncryptedCodec.js";
|
|
8
9
|
import { JsonCodec } from "./JsonCodec.js";
|
|
9
10
|
import { MergeStrategy } from "./MergeStrategy.js";
|
|
10
11
|
|
|
11
|
-
export { ConfigCodecError, ConfigDefaultPathMissingError, ConfigEncryptionError, ConfigEvent, ConfigEventPayload, ConfigEvents, ConfigFile, ConfigFileNotFoundError, ConfigFileReadError, ConfigFileWriteError, ConfigMigration, ConfigMigrationError, ConfigResolver, ConfigSourceRef, ConfigValidationError, EncryptedCodec, EncryptedCodecKey, JsonCodec, MergeStrategy, VersionAccess, asConfigProvider, layerConfigProvider };
|
|
12
|
+
export { ConfigCodecError, ConfigDefaultPathMissingError, ConfigEncryptionError, ConfigEvent, ConfigEventPayload, ConfigEvents, ConfigFile, ConfigFileNotFoundError, ConfigFileReadError, ConfigFileWriteError, ConfigMerge, ConfigMigration, ConfigMigrationError, ConfigResolver, ConfigSourceRef, ConfigValidationError, EncryptedCodec, EncryptedCodecKey, JsonCodec, MergeStrategy, VersionAccess, asConfigProvider, layerConfigProvider };
|
package/internal/deepMerge.js
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* instance loses its prototype, so `instanceof` fails and its getters vanish.
|
|
10
10
|
* Recursion is gated on this predicate so nested values of those kinds stay
|
|
11
11
|
* atomic — the higher-priority source wins them whole.
|
|
12
|
+
*
|
|
13
|
+
* @internal
|
|
12
14
|
*/
|
|
13
15
|
const isPlainObject = (value) => {
|
|
14
16
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
@@ -23,46 +25,83 @@ const isPlainObject = (value) => {
|
|
|
23
25
|
*/
|
|
24
26
|
const isRecordLike = (value) => typeof value === "object" && value !== null && Object.prototype.toString.call(value) === "[object Object]";
|
|
25
27
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
+
* Whether two TOP-LEVEL values merge field-wise: both plain objects (either
|
|
29
|
+
* prototype, `Object.prototype` or `null`), or both record-like on one shared
|
|
30
|
+
* prototype. Anything else is atomic.
|
|
28
31
|
*
|
|
29
32
|
* @remarks
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
+
* The shared-prototype arm is what lets a document decoded through
|
|
34
|
+
* `Schema.Class` merge with another of the same class and keep its identity;
|
|
35
|
+
* nothing else is ever silently reshaped. The plain arm admits a
|
|
36
|
+
* null-prototype object beside an ordinary one, the same pairing every nested
|
|
37
|
+
* level has always merged.
|
|
38
|
+
*
|
|
39
|
+
* @internal
|
|
33
40
|
*/
|
|
34
|
-
const canMerge = (a, b) => isRecordLike(a) && isRecordLike(b) && Object.getPrototypeOf(a) === Object.getPrototypeOf(b);
|
|
41
|
+
const canMerge = (a, b) => isPlainObject(a) && isPlainObject(b) || isRecordLike(a) && isRecordLike(b) && Object.getPrototypeOf(a) === Object.getPrototypeOf(b);
|
|
35
42
|
const FORBIDDEN = /* @__PURE__ */ new Set([
|
|
36
43
|
"__proto__",
|
|
37
44
|
"constructor",
|
|
38
45
|
"prototype"
|
|
39
46
|
]);
|
|
40
47
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
48
|
+
* How many nested levels merge field-wise. At this depth the override's value
|
|
49
|
+
* is taken whole.
|
|
50
|
+
*
|
|
51
|
+
* @remarks
|
|
52
|
+
* Far above the 256-level nesting cap of the kit's parsers, so a decoded
|
|
53
|
+
* document never reaches it. It exists so a pathologically deep pair of inputs
|
|
54
|
+
* cannot overflow the stack. Cycles do not depend on it: the per-pair memo in
|
|
55
|
+
* {@link deepMerge} bounds them, because a depth cap bounds depth, not work —
|
|
56
|
+
* a self-cycle through two keys recursed into both at every level, about
|
|
57
|
+
* 2^depth calls, before the memo existed.
|
|
58
|
+
*
|
|
59
|
+
* @internal
|
|
60
|
+
*/
|
|
61
|
+
const MAX_MERGE_DEPTH = 1024;
|
|
62
|
+
/**
|
|
63
|
+
* Merge `override` over `base`. Two mergeable values ({@link canMerge}) merge
|
|
64
|
+
* field-wise; otherwise `override` wins whole.
|
|
65
|
+
*
|
|
66
|
+
* @internal
|
|
67
|
+
*/
|
|
68
|
+
const deepMerge = (base, override) => canMerge(base, override) ? mergeRecords(base, override, 1, /* @__PURE__ */ new WeakMap()) : override;
|
|
69
|
+
/**
|
|
70
|
+
* Field-wise merge, `override`'s own keys winning. Nested plain objects merge;
|
|
71
|
+
* every other value is atomic.
|
|
43
72
|
*
|
|
44
73
|
* @remarks
|
|
45
|
-
* The result is built on `
|
|
46
|
-
* a decoded `Schema.Class` document survives the merge as a real instance
|
|
74
|
+
* The result is built on `override`'s prototype rather than spread into `{}`,
|
|
75
|
+
* so a decoded `Schema.Class` document survives the merge as a real instance:
|
|
47
76
|
* `instanceof` holds and its getters still work. Without this, `load` would
|
|
48
77
|
* declare `Effect<A>` and hand back a structurally-equal plain object, and a
|
|
49
78
|
* consumer calling a class method would get a `TypeError` that typechecked.
|
|
50
79
|
*
|
|
51
|
-
* Only own enumerable keys are consulted (`Object.hasOwn`),
|
|
52
|
-
* getter on `
|
|
80
|
+
* Only own enumerable string keys are consulted (`Object.keys`, `Object.hasOwn`),
|
|
81
|
+
* so a prototype getter on `override` never shadows a real key on `base`.
|
|
82
|
+
* Presence decides, not value: an own key on `override` wins even when it holds
|
|
83
|
+
* `undefined`.
|
|
53
84
|
*/
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
85
|
+
const mergeRecords = (base, override, depth, memo) => {
|
|
86
|
+
const seen = memo.get(base)?.get(override);
|
|
87
|
+
if (seen !== void 0) return seen;
|
|
88
|
+
const result = Object.create(Object.getPrototypeOf(override));
|
|
89
|
+
let byOverride = memo.get(base);
|
|
90
|
+
if (byOverride === void 0) {
|
|
91
|
+
byOverride = /* @__PURE__ */ new WeakMap();
|
|
92
|
+
memo.set(base, byOverride);
|
|
59
93
|
}
|
|
60
|
-
|
|
94
|
+
byOverride.set(override, result);
|
|
95
|
+
for (const key of Object.keys(override)) {
|
|
61
96
|
if (FORBIDDEN.has(key)) continue;
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
if (
|
|
65
|
-
else
|
|
97
|
+
const overrideValue = override[key];
|
|
98
|
+
const baseValue = Object.hasOwn(base, key) ? base[key] : void 0;
|
|
99
|
+
if (depth < 1024 && isPlainObject(baseValue) && isPlainObject(overrideValue)) define(result, key, mergeRecords(baseValue, overrideValue, depth + 1, memo));
|
|
100
|
+
else define(result, key, overrideValue);
|
|
101
|
+
}
|
|
102
|
+
for (const key of Object.keys(base)) {
|
|
103
|
+
if (FORBIDDEN.has(key) || Object.hasOwn(result, key)) continue;
|
|
104
|
+
define(result, key, base[key]);
|
|
66
105
|
}
|
|
67
106
|
return result;
|
|
68
107
|
};
|
|
@@ -76,4 +115,4 @@ const define = (target, key, value) => {
|
|
|
76
115
|
});
|
|
77
116
|
};
|
|
78
117
|
|
|
79
|
-
export { canMerge, deepMerge, isPlainObject };
|
|
118
|
+
export { MAX_MERGE_DEPTH, canMerge, deepMerge, isPlainObject };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effected/config-file",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Composable config file loading for Effect: JSON, JSONC, YAML and TOML codecs, resolution strategies, and merge behaviors.",
|
|
6
6
|
"keywords": [
|
|
@@ -58,11 +58,11 @@
|
|
|
58
58
|
"./package.json": "./package.json"
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
61
|
-
"@effected/glob": "^0.
|
|
62
|
-
"@effected/jsonc": "^0.
|
|
63
|
-
"@effected/toml": "^0.
|
|
64
|
-
"@effected/walker": "^0.
|
|
65
|
-
"@effected/yaml": "^0.
|
|
61
|
+
"@effected/glob": "^0.16.0",
|
|
62
|
+
"@effected/jsonc": "^0.21.0",
|
|
63
|
+
"@effected/toml": "^0.17.0",
|
|
64
|
+
"@effected/walker": "^0.22.0",
|
|
65
|
+
"@effected/yaml": "^0.26.0",
|
|
66
66
|
"effect": "^4.0.0"
|
|
67
67
|
},
|
|
68
68
|
"peerDependenciesMeta": {
|