@effected/config-file 0.21.0 → 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 CHANGED
@@ -260,24 +260,65 @@ const makeImpl = (options, fs, path, resolverEnv) => {
260
260
  });
261
261
  return value;
262
262
  });
263
- const load = Effect.fn("ConfigFile.load")(function* () {
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
- if (sources.length === 0) {
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 yield* mergeAndEmit(sources);
278
+ return {
279
+ value: yield* mergeAndEmit(sources),
280
+ source: first.path
281
+ };
273
282
  });
274
- const loadOrDefault = Effect.fn("ConfigFile.loadOrDefault")(function* (defaultValue) {
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
- if (sources.length === 0) {
286
+ const [first] = sources;
287
+ if (first === void 0) {
277
288
  yield* emit({ _tag: "NotFound" });
278
- return defaultValue;
289
+ return {
290
+ value: defaultValue,
291
+ source: Option.none()
292
+ };
279
293
  }
280
- return yield* mergeAndEmit(sources);
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
- const configured = options.defaultPath;
343
- if (configured === void 0) return yield* Effect.fail(new ConfigDefaultPathMissingError({}));
344
- const target = yield* Effect.provide(configured, resolverEnv);
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 updated = fn(defaultValue !== void 0 ? yield* loadOrDefault(defaultValue) : yield* load());
383
- const target = yield* saveTo(updated);
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/MergeStrategy.js CHANGED
@@ -1,4 +1,4 @@
1
- import { canMerge, deepMerge } from "./internal/deepMerge.js";
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
@@ -617,6 +617,10 @@ export declare const MergeStrategy: {
617
617
  * `{}`.
618
618
  *
619
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.
620
624
  */
621
625
  readonly layeredMerge: <A>() => MergeStrategy<A>;
622
626
  };
@@ -865,8 +869,13 @@ interface ConfigFileShape<A, CE = ConfigCodecError> {
865
869
  */
866
870
  readonly write: (value: A, path: string, options?: ConfigEncodeOptions) => Effect.Effect<void, ConfigWriteError<CE>>;
867
871
  /**
868
- * Resolve `defaultPath`, `mkdir -p` its parent, encode `value` into it, and
869
- * 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`.
870
879
  */
871
880
  readonly save: (value: A) => Effect.Effect<string, ConfigSaveError<CE>>;
872
881
  /**
@@ -876,6 +885,11 @@ interface ConfigFileShape<A, CE = ConfigCodecError> {
876
885
  * @remarks
877
886
  * With `defaultValue` the load cannot fail with
878
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`.
879
893
  */
880
894
  readonly update: (fn: (current: A) => A, defaultValue?: A) => Effect.Effect<A, ConfigUpdateError<CE>>;
881
895
  }
@@ -953,6 +967,39 @@ interface ConfigFileOptions<A, I, RR, CE = ConfigCodecError> {
953
967
  * {@link ConfigDefaultPathMissingError}.
954
968
  */
955
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";
956
1003
  /**
957
1004
  * The opt-in event hook. Pass the `ConfigEvents` class itself.
958
1005
  *
@@ -975,8 +1022,10 @@ interface ConfigFileOptions<A, I, RR, CE = ConfigCodecError> {
975
1022
  *
976
1023
  * It also has no `defaultPath`. Nothing in the temp directory is a defensible
977
1024
  * default write target, so `save` and `update` fail with
978
- * {@link ConfigDefaultPathMissingError} under this layer — the honest answer.
979
- * Exercise the write path with {@link ConfigFile.layer} instead.
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}.
980
1029
  *
981
1030
  * `CE` is the codec's error channel, as on {@link ConfigFileOptions}.
982
1031
  *
@@ -1005,6 +1054,18 @@ interface ConfigFileTestOptions<A, I, CE = ConfigCodecError> {
1005
1054
  readonly files: Record<string, string>;
1006
1055
  /** An optional caller-supplied check run after schema decoding. */
1007
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";
1008
1069
  }
1009
1070
  /**
1010
1071
  * Options for {@link ConfigFile.read}.
@@ -1183,6 +1244,72 @@ export declare class ConfigFile {
1183
1244
  */
1184
1245
  static readonly read: <A, I, CE = ConfigCodecError>(path: string, options: ConfigReadOptions<A, I, CE>) => Effect.Effect<A, ConfigReadError<CE>, FileSystem.FileSystem>;
1185
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
+ }
1186
1313
  declare const ConfigMigrationError_base: Schema.Class<ConfigMigrationError, Schema.TaggedStruct<"ConfigMigrationError", {
1187
1314
  /** The target version of the step that failed. `0` when reading the version failed. */
1188
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 };
@@ -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
- * Two values may be merged only if both are record-like and share a prototype.
27
- * Same-prototype instances merge field-wise; anything else is atomic.
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
- * Requiring an identical prototype is what keeps the merge honest: a document
31
- * decoded through `Schema.Class` merges with another of the same class and
32
- * keeps its identity, and nothing else is ever silently reshaped.
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
- * Recursively merge `source` into `target`; keys already present on `target`
42
- * win. Nested plain objects merge; every other value is atomic.
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 `target`'s prototype rather than spread into `{}`, so
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`), so a prototype
52
- * getter on `target` never shadows a real key on `source`.
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 deepMerge = (target, source) => {
55
- const result = Object.create(Object.getPrototypeOf(target));
56
- for (const key of Object.keys(target)) {
57
- if (FORBIDDEN.has(key)) continue;
58
- define(result, key, target[key]);
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
- for (const key of Object.keys(source)) {
94
+ byOverride.set(override, result);
95
+ for (const key of Object.keys(override)) {
61
96
  if (FORBIDDEN.has(key)) continue;
62
- const sourceValue = source[key];
63
- const targetValue = result[key];
64
- if (Object.hasOwn(result, key) && isPlainObject(targetValue) && isPlainObject(sourceValue)) define(result, key, deepMerge(targetValue, sourceValue));
65
- else if (!Object.hasOwn(result, key)) define(result, key, sourceValue);
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.21.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.15.0",
62
- "@effected/jsonc": "^0.20.1",
63
- "@effected/toml": "^0.16.0",
64
- "@effected/walker": "^0.21.0",
65
- "@effected/yaml": "^0.25.1",
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": {