@effected/config-file 0.19.1 → 0.20.1

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
@@ -114,7 +114,6 @@ var ConfigValidationError = class extends Schema.TaggedError()("ConfigValidation
114
114
  })}`;
115
115
  }
116
116
  };
117
- const Service = () => (id) => Context.Service()(id);
118
117
  /**
119
118
  * Re-raise a codec failure with the file it came from attached.
120
119
  *
@@ -401,46 +400,6 @@ const makeImpl = (options, fs, path, resolverEnv) => {
401
400
  update
402
401
  };
403
402
  };
404
- const layer = (tag, options) => Layer.effect(tag, Effect.gen(function* () {
405
- const fs = yield* FileSystem.FileSystem;
406
- const path = yield* Path.Path;
407
- const resolverEnv = yield* Effect.context();
408
- return makeImpl(options, fs, path, resolverEnv);
409
- }));
410
- const testLayer = (tag, options) => Layer.effect(tag, Effect.gen(function* () {
411
- const fs = yield* FileSystem.FileSystem;
412
- const path = yield* Path.Path;
413
- const resolverEnv = yield* Effect.context();
414
- const dir = yield* fs.makeTempDirectory({ prefix: "effected-config-file-" }).pipe(Effect.orDie);
415
- yield* Effect.addFinalizer(() => fs.remove(dir, { recursive: true }).pipe(Effect.orDie));
416
- for (const [name, content] of Object.entries(options.files)) {
417
- const target = path.join(dir, name);
418
- yield* fs.makeDirectory(path.dirname(target), { recursive: true }).pipe(Effect.orDie);
419
- yield* fs.writeFileString(target, content).pipe(Effect.orDie);
420
- }
421
- const resolvers = Object.keys(options.files).map((name) => ConfigResolver.staticDir({
422
- dir,
423
- filename: name
424
- }));
425
- return makeImpl({
426
- schema: options.schema,
427
- codec: options.codec,
428
- strategy: options.strategy,
429
- resolvers,
430
- ...options.validate !== void 0 && { validate: options.validate }
431
- }, fs, path, resolverEnv);
432
- }));
433
- const read = (path, options) => Effect.gen(function* () {
434
- const raw = yield* (yield* FileSystem.FileSystem).readFileString(path).pipe(Effect.mapError((cause) => new ConfigFileReadError({
435
- path,
436
- cause
437
- })));
438
- const parsed = yield* options.codec.parse(raw).pipe(Effect.mapError((error) => withCodecPath(error, path)));
439
- return yield* Schema.decodeUnknownEffect(options.schema)(parsed, options.parseOptions).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(new ConfigValidationError({
440
- path: Option.some(path),
441
- issue: error.issue
442
- }))));
443
- }).pipe(Effect.withSpan("ConfigFile.read", { attributes: { path } }));
444
403
  /**
445
404
  * The config file service: a per-schema service factory, its layers, and the
446
405
  * one-shot {@link ConfigFile.read}.
@@ -471,7 +430,7 @@ var ConfigFile = class {
471
430
  * >()("app/Secrets") {}
472
431
  * ```
473
432
  */
474
- static Service = Service;
433
+ static Service = () => (id) => Context.Service()(id);
475
434
  /**
476
435
  * Build the live layer for a config service class.
477
436
  *
@@ -509,7 +468,12 @@ var ConfigFile = class {
509
468
  * });
510
469
  * ```
511
470
  */
512
- static layer = layer;
471
+ static layer = (tag, options) => Layer.effect(tag, Effect.gen(function* () {
472
+ const fs = yield* FileSystem.FileSystem;
473
+ const path = yield* Path.Path;
474
+ const resolverEnv = yield* Effect.context();
475
+ return makeImpl(options, fs, path, resolverEnv);
476
+ }));
513
477
  /**
514
478
  * A scoped layer that seeds `files` into a temp directory, wires the **real**
515
479
  * live implementation over them, and removes the directory when the scope
@@ -543,7 +507,29 @@ var ConfigFile = class {
543
507
  * }).pipe(Layer.provide(NodeServices.layer));
544
508
  * ```
545
509
  */
546
- static testLayer = testLayer;
510
+ static testLayer = (tag, options) => Layer.effect(tag, Effect.gen(function* () {
511
+ const fs = yield* FileSystem.FileSystem;
512
+ const path = yield* Path.Path;
513
+ const resolverEnv = yield* Effect.context();
514
+ const dir = yield* fs.makeTempDirectory({ prefix: "effected-config-file-" }).pipe(Effect.orDie);
515
+ yield* Effect.addFinalizer(() => fs.remove(dir, { recursive: true }).pipe(Effect.orDie));
516
+ for (const [name, content] of Object.entries(options.files)) {
517
+ const target = path.join(dir, name);
518
+ yield* fs.makeDirectory(path.dirname(target), { recursive: true }).pipe(Effect.orDie);
519
+ yield* fs.writeFileString(target, content).pipe(Effect.orDie);
520
+ }
521
+ const resolvers = Object.keys(options.files).map((name) => ConfigResolver.staticDir({
522
+ dir,
523
+ filename: name
524
+ }));
525
+ return makeImpl({
526
+ schema: options.schema,
527
+ codec: options.codec,
528
+ strategy: options.strategy,
529
+ resolvers,
530
+ ...options.validate !== void 0 && { validate: options.validate }
531
+ }, fs, path, resolverEnv);
532
+ }));
547
533
  /**
548
534
  * Read, decode and validate one explicit path — no service, no layer, no tag.
549
535
  *
@@ -581,7 +567,17 @@ var ConfigFile = class {
581
567
  * });
582
568
  * ```
583
569
  */
584
- static read = read;
570
+ static read = (path, options) => Effect.gen(function* () {
571
+ const raw = yield* (yield* FileSystem.FileSystem).readFileString(path).pipe(Effect.mapError((cause) => new ConfigFileReadError({
572
+ path,
573
+ cause
574
+ })));
575
+ const parsed = yield* options.codec.parse(raw).pipe(Effect.mapError((error) => withCodecPath(error, path)));
576
+ return yield* Schema.decodeUnknownEffect(options.schema)(parsed, options.parseOptions).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(new ConfigValidationError({
577
+ path: Option.some(path),
578
+ issue: error.issue
579
+ }))));
580
+ }).pipe(Effect.withSpan("ConfigFile.read", { attributes: { path } }));
585
581
  };
586
582
 
587
583
  export { ConfigDefaultPathMissingError, ConfigFile, ConfigFileNotFoundError, ConfigFileReadError, ConfigFileWriteError, ConfigValidationError };
@@ -60,24 +60,6 @@ const runPhase = (phase, version, name, run) => Effect.suspend(run).pipe(Effect.
60
60
  phase,
61
61
  cause
62
62
  })));
63
- const make = (options) => {
64
- const access = options.versionAccess ?? VersionAccess.default;
65
- const sorted = [...options.migrations].sort((a, b) => a.version - b.version);
66
- return {
67
- name: options.codec.name,
68
- stringify: options.codec.stringify,
69
- parse: (raw) => Effect.gen(function* () {
70
- let parsed = yield* options.codec.parse(raw);
71
- if (sorted.length === 0) return parsed;
72
- const current = yield* runPhase("read-version", 0, "", () => access.get(parsed));
73
- for (const migration of sorted.filter((m) => m.version > current)) {
74
- parsed = yield* runPhase("apply", migration.version, migration.name, () => migration.up(parsed));
75
- parsed = yield* runPhase("write-version", migration.version, migration.name, () => access.set(parsed, migration.version));
76
- }
77
- return parsed;
78
- })
79
- };
80
- };
81
63
  /** Versioned migration support for config codecs. @public */
82
64
  var ConfigMigration = class {
83
65
  constructor() {}
@@ -111,7 +93,24 @@ var ConfigMigration = class {
111
93
  * });
112
94
  * ```
113
95
  */
114
- static make = make;
96
+ static make = (options) => {
97
+ const access = options.versionAccess ?? VersionAccess.default;
98
+ const sorted = [...options.migrations].sort((a, b) => a.version - b.version);
99
+ return {
100
+ name: options.codec.name,
101
+ stringify: options.codec.stringify,
102
+ parse: (raw) => Effect.gen(function* () {
103
+ let parsed = yield* options.codec.parse(raw);
104
+ if (sorted.length === 0) return parsed;
105
+ const current = yield* runPhase("read-version", 0, "", () => access.get(parsed));
106
+ for (const migration of sorted.filter((m) => m.version > current)) {
107
+ parsed = yield* runPhase("apply", migration.version, migration.name, () => migration.up(parsed));
108
+ parsed = yield* runPhase("write-version", migration.version, migration.name, () => access.set(parsed, migration.version));
109
+ }
110
+ return parsed;
111
+ })
112
+ };
113
+ };
115
114
  };
116
115
 
117
116
  export { ConfigMigration, ConfigMigrationError, VersionAccess };
package/ConfigResolver.js CHANGED
@@ -22,48 +22,6 @@ const absorb = (effect) => Effect.catch(effect, () => Effect.succeed({
22
22
  probed: []
23
23
  }));
24
24
  const cwdOf = (given) => given ?? globalThis.process?.cwd?.() ?? "/";
25
- const explicitPath = (target) => fromProbe("explicit", absorb(Effect.gen(function* () {
26
- return {
27
- match: (yield* (yield* FileSystem.FileSystem).exists(target)) ? Option.some({ path: target }) : Option.none(),
28
- probed: [target]
29
- };
30
- })));
31
- const staticDir = (options) => fromProbe("static", absorb(Effect.gen(function* () {
32
- const fs = yield* FileSystem.FileSystem;
33
- const candidate = (yield* Path.Path).join(options.dir, options.filename);
34
- return {
35
- match: (yield* fs.exists(candidate)) ? Option.some({
36
- path: candidate,
37
- dir: options.dir,
38
- filename: options.filename
39
- }) : Option.none(),
40
- probed: [candidate]
41
- };
42
- })));
43
- const upwardWalk = (options) => fromProbe(options.name ?? "walk", Effect.gen(function* () {
44
- const fs = yield* FileSystem.FileSystem;
45
- const path = yield* Path.Path;
46
- const subpaths = options.subpaths ?? ["."];
47
- const filenames = options.filenames ?? [options.filename];
48
- const dirs = yield* Walker.ascend(cwdOf(options.cwd), { ...options.stopAt !== void 0 && { stopAt: options.stopAt } });
49
- const candidates = [];
50
- for (const dir of dirs) for (const sub of subpaths) for (const filename of filenames) candidates.push({
51
- path: path.join(dir, sub, filename),
52
- dir,
53
- filename,
54
- ...options.subpaths !== void 0 && { subpath: sub }
55
- });
56
- const paths = candidates.map((candidate) => candidate.path);
57
- const found = yield* Walker.firstMatch(paths, (candidate) => fs.exists(candidate));
58
- const probed = Option.match(found, {
59
- onNone: () => paths,
60
- onSome: (target) => paths.slice(0, paths.indexOf(target) + 1)
61
- });
62
- return {
63
- match: Option.flatMap(found, (target) => Option.fromNullishOr(candidates.find((candidate) => candidate.path === target))),
64
- probed
65
- };
66
- }));
67
25
  /**
68
26
  * Ascend from `cwd` looking for the first directory where `isRoot` reports
69
27
  * true, then probe `subpaths` under it. Shared by `gitRoot` and
@@ -125,27 +83,6 @@ const isWorkspaceRoot = (dir) => Effect.gen(function* () {
125
83
  if (yield* fs.exists(pkgPath)) return Option.isSome(decodeWorkspacesManifest(yield* fs.readFileString(pkgPath)));
126
84
  return false;
127
85
  });
128
- const gitRoot = (options) => rootAnchored("git", isGitRoot, options);
129
- const workspaceRoot = (options) => rootAnchored("workspace", isWorkspaceRoot, options);
130
- const systemEtc = (options) => fromProbe("system", absorb(Effect.gen(function* () {
131
- if (globalThis.process?.platform === "win32") return {
132
- match: Option.none(),
133
- probed: []
134
- };
135
- const fs = yield* FileSystem.FileSystem;
136
- const path = yield* Path.Path;
137
- const base = options.dir ?? "/etc";
138
- const dir = path.join(base, options.app);
139
- const candidate = path.join(dir, options.filename);
140
- return {
141
- match: (yield* fs.exists(candidate)) ? Option.some({
142
- path: candidate,
143
- dir,
144
- filename: options.filename
145
- }) : Option.none(),
146
- probed: [candidate]
147
- };
148
- })));
149
86
  /**
150
87
  * Built-in resolvers, in the order a typical chain uses them.
151
88
  *
@@ -154,9 +91,25 @@ const systemEtc = (options) => fromProbe("system", absorb(Effect.gen(function* (
154
91
  var ConfigResolver = class {
155
92
  constructor() {}
156
93
  /** Resolves to `target` when it exists on disk, or `Option.none()` when it does not — no walking, no filename convention. */
157
- static explicitPath = explicitPath;
94
+ static explicitPath = (target) => fromProbe("explicit", absorb(Effect.gen(function* () {
95
+ return {
96
+ match: (yield* (yield* FileSystem.FileSystem).exists(target)) ? Option.some({ path: target }) : Option.none(),
97
+ probed: [target]
98
+ };
99
+ })));
158
100
  /** Resolves `path.join(dir, filename)` when it exists on disk, or `Option.none()` when it does not. */
159
- static staticDir = staticDir;
101
+ static staticDir = (options) => fromProbe("static", absorb(Effect.gen(function* () {
102
+ const fs = yield* FileSystem.FileSystem;
103
+ const candidate = (yield* Path.Path).join(options.dir, options.filename);
104
+ return {
105
+ match: (yield* fs.exists(candidate)) ? Option.some({
106
+ path: candidate,
107
+ dir: options.dir,
108
+ filename: options.filename
109
+ }) : Option.none(),
110
+ probed: [candidate]
111
+ };
112
+ })));
160
113
  /**
161
114
  * Ascends from `cwd` (or the process cwd) toward `stopAt`, resolving the
162
115
  * first `subpaths/filename` combination found at each level.
@@ -172,24 +125,65 @@ var ConfigResolver = class {
172
125
  * distinguishable; `ConfigSource.match` identifies the matching candidate
173
126
  * more precisely still.
174
127
  */
175
- static upwardWalk = upwardWalk;
128
+ static upwardWalk = (options) => fromProbe(options.name ?? "walk", Effect.gen(function* () {
129
+ const fs = yield* FileSystem.FileSystem;
130
+ const path = yield* Path.Path;
131
+ const subpaths = options.subpaths ?? ["."];
132
+ const filenames = options.filenames ?? [options.filename];
133
+ const dirs = yield* Walker.ascend(cwdOf(options.cwd), { ...options.stopAt !== void 0 && { stopAt: options.stopAt } });
134
+ const candidates = [];
135
+ for (const dir of dirs) for (const sub of subpaths) for (const filename of filenames) candidates.push({
136
+ path: path.join(dir, sub, filename),
137
+ dir,
138
+ filename,
139
+ ...options.subpaths !== void 0 && { subpath: sub }
140
+ });
141
+ const paths = candidates.map((candidate) => candidate.path);
142
+ const found = yield* Walker.firstMatch(paths, (candidate) => fs.exists(candidate));
143
+ const probed = Option.match(found, {
144
+ onNone: () => paths,
145
+ onSome: (target) => paths.slice(0, paths.indexOf(target) + 1)
146
+ });
147
+ return {
148
+ match: Option.flatMap(found, (target) => Option.fromNullishOr(candidates.find((candidate) => candidate.path === target))),
149
+ probed
150
+ };
151
+ }));
176
152
  /**
177
153
  * Ascends from `cwd` to the nearest workspace root — a directory with a
178
154
  * `pnpm-workspace.yaml`, or a `package.json` carrying a `workspaces` field —
179
155
  * then resolves the first `subpaths/filename` combination found under it.
180
156
  */
181
- static workspaceRoot = workspaceRoot;
157
+ static workspaceRoot = (options) => rootAnchored("workspace", isWorkspaceRoot, options);
182
158
  /**
183
159
  * Ascends from `cwd` to the nearest git root — a directory containing a
184
160
  * `.git` entry, directory or file (the latter for a worktree) — then
185
161
  * resolves the first `subpaths/filename` combination found under it.
186
162
  */
187
- static gitRoot = gitRoot;
163
+ static gitRoot = (options) => rootAnchored("git", isGitRoot, options);
188
164
  /**
189
165
  * Resolves `<dir>/<app>/<filename>` under the system config root (`/etc` by
190
166
  * default). Always `Option.none()` on Windows, where `/etc` has no meaning.
191
167
  */
192
- static systemEtc = systemEtc;
168
+ static systemEtc = (options) => fromProbe("system", absorb(Effect.gen(function* () {
169
+ if (globalThis.process?.platform === "win32") return {
170
+ match: Option.none(),
171
+ probed: []
172
+ };
173
+ const fs = yield* FileSystem.FileSystem;
174
+ const path = yield* Path.Path;
175
+ const base = options.dir ?? "/etc";
176
+ const dir = path.join(base, options.app);
177
+ const candidate = path.join(dir, options.filename);
178
+ return {
179
+ match: (yield* fs.exists(candidate)) ? Option.some({
180
+ path: candidate,
181
+ dir,
182
+ filename: options.filename
183
+ }) : Option.none(),
184
+ probed: [candidate]
185
+ };
186
+ })));
193
187
  };
194
188
 
195
189
  export { ConfigResolver };
package/MergeStrategy.js CHANGED
@@ -2,39 +2,6 @@ import { canMerge, deepMerge } from "./internal/deepMerge.js";
2
2
  import { Effect } from "effect";
3
3
 
4
4
  // src/MergeStrategy.ts
5
- const firstMatch = () => ({
6
- name: "first-match",
7
- resolve: (sources) => Effect.succeed(sources[0].value)
8
- });
9
- /**
10
- * Deep-merge every contributing source, higher priority winning on conflict.
11
- *
12
- * @remarks
13
- * Two values merge only when both are record-like and share a prototype, so a
14
- * document decoded through `Schema.Class` merges with another of the same class
15
- * and **survives as a real instance** — `instanceof` holds and its getters
16
- * still work. Everything else is atomic: a nested `Date`, `Map`, `Set`,
17
- * `RegExp`, array, or class instance is taken whole from the highest-priority
18
- * source that defines it, never reshaped field-by-field.
19
- *
20
- * The alternative — spreading each value into a fresh object — would make
21
- * `load`'s declared `Effect<A>` a lie, handing back a structurally-equal plain
22
- * object whose class methods are gone and whose `Date` fields have decayed to
23
- * `{}`.
24
- *
25
- * Nested *plain* objects (a `Schema.Struct` section) still merge field-wise.
26
- */
27
- const layeredMerge = () => ({
28
- name: "layered-merge",
29
- resolve: (sources) => {
30
- let merged = sources[sources.length - 1]?.value;
31
- for (let i = sources.length - 2; i >= 0; i--) {
32
- const higher = sources[i]?.value;
33
- merged = canMerge(merged, higher) ? deepMerge(higher, merged) : higher;
34
- }
35
- return Effect.succeed(merged);
36
- }
37
- });
38
5
  /**
39
6
  * Built-in merge strategies: `firstMatch` (the highest-priority source wins
40
7
  * whole) and `layeredMerge` (every source deep-merged, higher priority winning).
@@ -53,8 +20,40 @@ const layeredMerge = () => ({
53
20
  * @public
54
21
  */
55
22
  const MergeStrategy = {
56
- firstMatch,
57
- layeredMerge
23
+ /** Take the highest-priority source whole. */
24
+ firstMatch: () => ({
25
+ name: "first-match",
26
+ resolve: (sources) => Effect.succeed(sources[0].value)
27
+ }),
28
+ /**
29
+ * Deep-merge every contributing source, higher priority winning on conflict.
30
+ *
31
+ * @remarks
32
+ * Two values merge only when both are record-like and share a prototype, so a
33
+ * document decoded through `Schema.Class` merges with another of the same class
34
+ * and **survives as a real instance** — `instanceof` holds and its getters
35
+ * still work. Everything else is atomic: a nested `Date`, `Map`, `Set`,
36
+ * `RegExp`, array, or class instance is taken whole from the highest-priority
37
+ * source that defines it, never reshaped field-by-field.
38
+ *
39
+ * The alternative — spreading each value into a fresh object — would make
40
+ * `load`'s declared `Effect<A>` a lie, handing back a structurally-equal plain
41
+ * object whose class methods are gone and whose `Date` fields have decayed to
42
+ * `{}`.
43
+ *
44
+ * Nested *plain* objects (a `Schema.Struct` section) still merge field-wise.
45
+ */
46
+ layeredMerge: () => ({
47
+ name: "layered-merge",
48
+ resolve: (sources) => {
49
+ 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
+ return Effect.succeed(merged);
55
+ }
56
+ })
58
57
  };
59
58
 
60
59
  export { MergeStrategy };
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![Node.js %3E%3D24.11.0](https://img.shields.io/badge/Node.js-%3E%3D24.11.0-5fa04e.svg)](https://nodejs.org/)
6
6
  [![TypeScript 7.0](https://img.shields.io/badge/TypeScript-7.0-3178c6.svg)](https://www.typescriptlang.org/)
7
7
 
8
- Composable config file loading for Effect. Declare a resolver chain — an explicit path, an upward walk from the cwd, the workspace or git root, `/etc` — decode every discovered file through an Effect `Schema`, and combine the results with a merge strategy. JSON, JSONC, YAML and TOML all decode with no extra install. Codecs, resolvers and merge strategies are pluggable seams, and failures arrive as tagged errors carrying structured payloads rather than prose, so "no config anywhere" is routable separately from "the config I found is broken".
8
+ Composable config file loading for Effect. Declare a resolver chain — an explicit path, an upward walk from the cwd, the workspace or git root, `/etc` — decode every discovered file through an Effect `Schema`, and combine the results with a merge strategy. JSON decodes out of the box; JSONC, YAML and TOML each take one optional engine package. Codecs, resolvers and merge strategies are pluggable seams, and failures arrive as tagged errors carrying structured payloads rather than prose, so "no config anywhere" is routable separately from "the config I found is broken".
9
9
 
10
10
  > **Pre-`1.0.0`.** This package is part of the `@effected/*` kit, built on stable
11
11
  > Effect v4 (`effect` `^4.0.0`) and still in `0.x` development. Stable Effect
@@ -33,11 +33,17 @@ npm install @effected/config-file effect @effect/platform-node
33
33
  pnpm add @effected/config-file effect @effect/platform-node
34
34
  ```
35
35
 
36
- Requires Node.js >=24.11.0. Every format is covered by that one install; there is no separate package to add for YAML or TOML.
36
+ Requires Node.js >=24.11.0. That install covers JSON. To read JSONC, YAML or TOML, add the matching engine:
37
+
38
+ ```bash
39
+ pnpm add @effected/jsonc # for @effected/config-file/jsonc
40
+ pnpm add @effected/yaml # for @effected/config-file/yaml
41
+ pnpm add @effected/toml # for @effected/config-file/toml
42
+ ```
37
43
 
38
44
  All `@effected/*` packages are ESM-only: the exports maps publish only `import` conditions, so `require()` — including tools that resolve in CJS mode — fails with Node's `ERR_PACKAGE_PATH_NOT_EXPORTED` rather than loading a CJS build that does not exist. Import from an ES module.
39
45
 
40
- `effect` v4 is a peer dependency, and so are `@effected/jsonc`, `@effected/toml`, `@effected/walker` and `@effected/yaml` — the first-party engines behind the JSONC, YAML and TOML codecs, plus the traversal primitive the `upwardWalk`, `workspaceRoot` and `gitRoot` resolvers are built on. Package managers that install peers automatically will pull them in; add them to your manifest explicitly if yours does not. The package declares no runtime dependencies of its own, so nothing it drags into your tree comes from outside `effect` and `@effected/*`.
46
+ `effect` v4 is a peer dependency, and so are `@effected/walker` and `@effected/glob` — the traversal primitive the `upwardWalk`, `workspaceRoot` and `gitRoot` resolvers are built on, and the matcher it peers on. Package managers that install peers automatically will pull them in; add them to your manifest explicitly if yours does not. `@effected/jsonc`, `@effected/yaml` and `@effected/toml` are **optional** peers: only the `./jsonc`, `./yaml` and `./toml` subpaths load them, and the package root never does, so a JSON-only program installs and loads none of them. The package declares no runtime dependencies of its own, so nothing it drags into your tree comes from outside `effect` and `@effected/*`.
41
47
 
42
48
  Reading and writing files needs a `FileSystem` and a `Path` implementation, provided once at the edge — from `@effect/platform-node` on Node.
43
49
 
@@ -80,7 +86,8 @@ Effect.runPromise(program.pipe(Effect.provide(AppConfigLive), Effect.provide(Pla
80
86
  Resolvers are consulted in priority order, highest first. `MergeStrategy.firstMatch` takes the winner; `MergeStrategy.layeredMerge` deep-merges every source that matched, with higher-priority keys overwriting lower ones:
81
87
 
82
88
  ```ts
83
- import { ConfigFile, ConfigResolver, MergeStrategy, YamlCodec } from "@effected/config-file";
89
+ import { ConfigFile, ConfigResolver, MergeStrategy } from "@effected/config-file";
90
+ import { YamlCodec } from "@effected/config-file/yaml";
84
91
  import { Schema } from "effect";
85
92
 
86
93
  class Settings extends Schema.Class<Settings>("Settings")({ port: Schema.Number }) {}
@@ -176,16 +183,16 @@ export const loadOrFallback = (config: ConfigFileShape<AppShape>) =>
176
183
 
177
184
  ## Codecs
178
185
 
179
- Four codecs ship in the package, each a free-standing named export:
186
+ Four codecs ship in the package, each a free-standing named export. `JsonCodec` comes from the package root; the other three each have their own subpath and an optional engine peer to install beside it:
180
187
 
181
- | Codec | Format | Engine |
182
- | ----- | ------ | ------ |
183
- | `JsonCodec` | JSON | the host `JSON` global, no parser at all |
184
- | `JsoncCodec` | JSONC | `@effected/jsonc` |
185
- | `YamlCodec` | YAML | `@effected/yaml` |
186
- | `TomlCodec` | TOML | `@effected/toml` |
188
+ | Codec | Format | Import from | Engine |
189
+ | ----- | ------ | ----------- | ------ |
190
+ | `JsonCodec` | JSON | `@effected/config-file` | the host `JSON` global, no parser at all |
191
+ | `JsoncCodec` | JSONC | `@effected/config-file/jsonc` | `@effected/jsonc` |
192
+ | `YamlCodec` | YAML | `@effected/config-file/yaml` | `@effected/yaml` |
193
+ | `TomlCodec` | TOML | `@effected/config-file/toml` | `@effected/toml` |
187
194
 
188
- One install covers every format, and you still pay only for the parser you name. The codecs are free-standing exports rather than properties of a namespace object, so importing `TomlCodec` never references the YAML or JSONC bindings, their parsing engines are unreachable from your entrypoint and a bundler drops them. A JSON-only application ships no parser at all.
195
+ You install and load only the parser you name. The root never reaches a subpath, so a JSON-only application installs no parser and ships none. Each subpath reaches only its own engine, and the codecs are never collected into a namespace object, so importing `TomlCodec` never references the YAML or JSONC bindings.
189
196
 
190
197
  Codecs compose. `EncryptedCodec` wraps any codec with AES-GCM, and `ConfigMigration.make` wraps any codec so parsed content is brought up to the latest version. Each *widens* the error channel rather than flattening its failures into the inner codec's error, and a decorated codec plugs into `ConfigFile.layer`, `ConfigFile.testLayer` and `ConfigFile.read` like any other: their error unions widen with it. A service key names the widened error as its third type argument:
191
198
 
@@ -242,7 +249,7 @@ Leave the third type argument off and the `ConfigFile.layer` call is a compile e
242
249
  - `encode` / `write` / `save` / `update` — the write path. `encode(value, options?)` returns the serialized text a `write` would put on disk without writing, emitting an event or naming a path in its errors: the primitive behind a `--dry-run` flag. `write` takes an explicit path the caller vouches for; `save` and `update` resolve the layer's `defaultPath` and create its parent directory. `encode` and `write` accept `{ header }`, text prepended verbatim above the document with exactly one newline between — a `#:schema https://…` directive atop a TOML file, say. The caller owns its validity in the target format; JSON has no comment syntax, so a header on `JsonCodec` yields an unparseable file by construction.
243
250
  - `ConfigResolver` — `explicitPath`, `staticDir`, `upwardWalk`, `workspaceRoot`, `gitRoot` and `systemEtc`. A resolver's error channel is `never` by contract: every filesystem failure becomes `Option.none()`, so one unreadable tier never aborts the chain.
244
251
  - `MergeStrategy` — `firstMatch` and `layeredMerge`, combining discovered sources in priority order.
245
- - `JsonCodec`, `JsoncCodec`, `YamlCodec`, `TomlCodec` — JSON, JSONC, YAML and TOML in the box, exported free-standing so an unused format's engine is tree-shaken away.
252
+ - `JsonCodec` (root), `JsoncCodec` (`./jsonc`), `YamlCodec` (`./yaml`), `TomlCodec` (`./toml`) — JSON, JSONC, YAML and TOML, exported free-standing and split by subpath so an unused format's engine is neither installed nor loaded.
246
253
  - `ConfigCodec` / `EncryptedCodec` / `ConfigMigration` — a pluggable codec seam, generic in its error type so decorators widen rather than flatten. `ConfigCodec` is the interface: bring your own format by satisfying it.
247
254
  - `ConfigEvents` — an opt-in `PubSub` of `ConfigEvent`, honestly zero-cost when omitted: no `events` option means no context lookup at all. Failure events carry the structured typed error, never a `reason` string.
248
255
  - `asConfigProvider` / `layerConfigProvider` — expose a loaded, validated document as a v4 `ConfigProvider`, layered beneath the ambient one so an environment variable overrides the file it was deployed with.
package/index.d.ts CHANGED
@@ -48,13 +48,14 @@ export declare class ConfigCodecError extends ConfigCodecError_base {
48
48
  * decorator codecs such as `EncryptedCodec` and `ConfigMigration.make` widen it
49
49
  * rather than flattening their own failures into a string.
50
50
  *
51
- * The four built-in codecs — `JsonCodec`, `JsoncCodec`, `YamlCodec` and
52
- * `TomlCodec` — are free-standing named exports, one per module, and are
53
- * deliberately never collected into a namespace object. Collecting them would
54
- * make this module a dispatch table: referencing it at all would reach every
55
- * codec, and every codec would reach its parsing engine, so importing the JSON
56
- * codec alone would drag the JSONC, YAML and TOML engines into the bundle.
57
- * Name the one codec you use and a bundler drops the rest.
51
+ * The four built-in codecs are free-standing named exports, one per module,
52
+ * and are deliberately never collected into a namespace object. `JsonCodec`
53
+ * ships from the package root; `JsoncCodec`, `YamlCodec` and `TomlCodec` ship
54
+ * from the `./jsonc`, `./yaml` and `./toml` subpaths, whose engines are
55
+ * optional peers the root never reaches. Collecting them would make this module
56
+ * a dispatch table: referencing it at all would reach every codec, and every
57
+ * codec would reach its parsing engine, so importing the JSON codec alone would
58
+ * drag the JSONC, YAML and TOML engines into the bundle.
58
59
  *
59
60
  * @public
60
61
  */
@@ -575,7 +576,26 @@ export interface MergeStrategy<A> {
575
576
  * @public
576
577
  */
577
578
  export declare const MergeStrategy: {
579
+ /** Take the highest-priority source whole. */
578
580
  readonly firstMatch: <A>() => MergeStrategy<A>;
581
+ /**
582
+ * Deep-merge every contributing source, higher priority winning on conflict.
583
+ *
584
+ * @remarks
585
+ * Two values merge only when both are record-like and share a prototype, so a
586
+ * document decoded through `Schema.Class` merges with another of the same class
587
+ * and **survives as a real instance** — `instanceof` holds and its getters
588
+ * still work. Everything else is atomic: a nested `Date`, `Map`, `Set`,
589
+ * `RegExp`, array, or class instance is taken whole from the highest-priority
590
+ * source that defines it, never reshaped field-by-field.
591
+ *
592
+ * The alternative — spreading each value into a fresh object — would make
593
+ * `load`'s declared `Effect<A>` a lie, handing back a structurally-equal plain
594
+ * object whose class methods are gone and whose `Date` fields have decayed to
595
+ * `{}`.
596
+ *
597
+ * Nested *plain* objects (a `Schema.Struct` section) still merge field-wise.
598
+ */
579
599
  readonly layeredMerge: <A>() => MergeStrategy<A>;
580
600
  };
581
601
  declare const ConfigFileNotFoundError_base: Schema.Class<ConfigFileNotFoundError, Schema.TaggedStruct<"ConfigFileNotFoundError", {
@@ -1464,53 +1484,5 @@ export declare function EncryptedCodec<E>(inner: ConfigCodec<E>, keySource: Encr
1464
1484
  * @public
1465
1485
  */
1466
1486
  export declare const JsonCodec: ConfigCodec;
1467
- /**
1468
- * A `ConfigCodec` backed by `@effected/jsonc`: JSON with comments and
1469
- * trailing commas.
1470
- *
1471
- * @remarks
1472
- * `@effected/jsonc` does not expose a `stringify` — its schema layer's encode
1473
- * direction is `JSON.stringify` (comments never survive a round-trip encode;
1474
- * see `Jsonc.fromString`'s remarks), so `stringify` here calls `JSON.stringify`
1475
- * directly and wraps a thrown defect the same way `JsonCodec` does.
1476
- * Both directions preserve the underlying failure structurally in `cause` —
1477
- * never stringified.
1478
- *
1479
- * @public
1480
- */
1481
- export declare const JsoncCodec: ConfigCodec;
1482
- /**
1483
- * A `ConfigCodec` backed by `@effected/toml`.
1484
- *
1485
- * @remarks
1486
- * `@effected/toml`'s input hardening — a nesting-depth cap on arrays and
1487
- * inline tables, enforced independently on both the parse and stringify
1488
- * sides — fails through the typed error channel, so a hostile config file
1489
- * surfaces as a `ConfigCodecError` rather than crashing the process. Both
1490
- * directions preserve the underlying failure structurally in `cause` —
1491
- * never stringified.
1492
- *
1493
- * Stringify is genuinely fallible beyond hostile input: TOML has no null,
1494
- * so a document carrying `null` (or any other unrepresentable value, an
1495
- * out-of-int64-range `bigint`, a circular reference) fails with a
1496
- * `ConfigCodecError` whose `cause` is the structured `TomlStringifyError`.
1497
- *
1498
- * @public
1499
- */
1500
- export declare const TomlCodec: ConfigCodec;
1501
- /**
1502
- * A `ConfigCodec` backed by `@effected/yaml`.
1503
- *
1504
- * @remarks
1505
- * `@effected/yaml`'s input hardening — an alias-expansion budget guarding
1506
- * against "billion laughs" alias bombs, and a collection-nesting depth cap —
1507
- * fails through the typed error channel, so a hostile config file surfaces
1508
- * as a `ConfigCodecError` rather than crashing the process. Both directions
1509
- * preserve the underlying failure structurally in `cause` — never
1510
- * stringified.
1511
- *
1512
- * @public
1513
- */
1514
- export declare const YamlCodec: ConfigCodec;
1515
1487
  export type { ConfigCodec, ConfigEncodeError, ConfigEncodeOptions, ConfigEventsShape, ConfigFileMigration, ConfigFileOptions, ConfigFileShape, ConfigFileTestOptions, ConfigLoadError, ConfigMatch, ConfigMigrationOptions, ConfigProbe, ConfigReadError, ConfigReadOptions, ConfigSaveError, ConfigSource, ConfigUpdateError, ConfigWriteError, LayerConfigProviderOptions, NonEmptySources, UpwardWalkOptions };
1516
1488
  //# sourceMappingURL=index.d.ts.map
package/index.js CHANGED
@@ -6,9 +6,6 @@ import { ConfigMigration, ConfigMigrationError, VersionAccess } from "./ConfigMi
6
6
  import { asConfigProvider, layerConfigProvider } from "./ConfigProvider.js";
7
7
  import { ConfigEncryptionError, EncryptedCodec, EncryptedCodecKey } from "./EncryptedCodec.js";
8
8
  import { JsonCodec } from "./JsonCodec.js";
9
- import { JsoncCodec } from "./JsoncCodec.js";
10
9
  import { MergeStrategy } from "./MergeStrategy.js";
11
- import { TomlCodec } from "./TomlCodec.js";
12
- import { YamlCodec } from "./YamlCodec.js";
13
10
 
14
- export { ConfigCodecError, ConfigDefaultPathMissingError, ConfigEncryptionError, ConfigEvent, ConfigEventPayload, ConfigEvents, ConfigFile, ConfigFileNotFoundError, ConfigFileReadError, ConfigFileWriteError, ConfigMigration, ConfigMigrationError, ConfigResolver, ConfigSourceRef, ConfigValidationError, EncryptedCodec, EncryptedCodecKey, JsonCodec, JsoncCodec, MergeStrategy, TomlCodec, VersionAccess, YamlCodec, asConfigProvider, layerConfigProvider };
11
+ export { ConfigCodecError, ConfigDefaultPathMissingError, ConfigEncryptionError, ConfigEvent, ConfigEventPayload, ConfigEvents, ConfigFile, ConfigFileNotFoundError, ConfigFileReadError, ConfigFileWriteError, ConfigMigration, ConfigMigrationError, ConfigResolver, ConfigSourceRef, ConfigValidationError, EncryptedCodec, EncryptedCodecKey, JsonCodec, MergeStrategy, VersionAccess, asConfigProvider, layerConfigProvider };
package/jsonc.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { ConfigCodec } from "@effected/config-file";
2
+ /**
3
+ * A `ConfigCodec` backed by `@effected/jsonc`: JSON with comments and
4
+ * trailing commas.
5
+ *
6
+ * @remarks
7
+ * `@effected/jsonc` does not expose a `stringify` — its schema layer's encode
8
+ * direction is `JSON.stringify` (comments never survive a round-trip encode;
9
+ * see `Jsonc.fromString`'s remarks), so `stringify` here calls `JSON.stringify`
10
+ * directly and wraps a thrown defect the same way `JsonCodec` does.
11
+ * Both directions preserve the underlying failure structurally in `cause` —
12
+ * never stringified.
13
+ *
14
+ * @public
15
+ */
16
+ export declare const JsoncCodec: ConfigCodec;
17
+ //# sourceMappingURL=jsonc.d.ts.map
package/jsonc.js ADDED
@@ -0,0 +1,3 @@
1
+ import { JsoncCodec } from "./JsoncCodec.js";
2
+
3
+ export { JsoncCodec };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/config-file",
3
- "version": "0.19.1",
3
+ "version": "0.20.1",
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": [
@@ -40,16 +40,42 @@
40
40
  "import": "./index.js",
41
41
  "default": "./index.js"
42
42
  },
43
+ "./jsonc": {
44
+ "types": "./jsonc.d.ts",
45
+ "import": "./jsonc.js",
46
+ "default": "./jsonc.js"
47
+ },
48
+ "./toml": {
49
+ "types": "./toml.d.ts",
50
+ "import": "./toml.js",
51
+ "default": "./toml.js"
52
+ },
53
+ "./yaml": {
54
+ "types": "./yaml.d.ts",
55
+ "import": "./yaml.js",
56
+ "default": "./yaml.js"
57
+ },
43
58
  "./package.json": "./package.json"
44
59
  },
45
60
  "peerDependencies": {
46
61
  "@effected/glob": "^0.14.0",
47
62
  "@effected/jsonc": "^0.20.0",
48
63
  "@effected/toml": "^0.16.0",
49
- "@effected/walker": "^0.19.0",
50
- "@effected/yaml": "^0.25.0",
64
+ "@effected/walker": "^0.20.1",
65
+ "@effected/yaml": "^0.25.1",
51
66
  "effect": "^4.0.0"
52
67
  },
68
+ "peerDependenciesMeta": {
69
+ "@effected/jsonc": {
70
+ "optional": true
71
+ },
72
+ "@effected/toml": {
73
+ "optional": true
74
+ },
75
+ "@effected/yaml": {
76
+ "optional": true
77
+ }
78
+ },
53
79
  "engines": {
54
80
  "node": ">=24.11.0"
55
81
  }
package/toml.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { ConfigCodec } from "@effected/config-file";
2
+ /**
3
+ * A `ConfigCodec` backed by `@effected/toml`.
4
+ *
5
+ * @remarks
6
+ * `@effected/toml`'s input hardening — a nesting-depth cap on arrays and
7
+ * inline tables, enforced independently on both the parse and stringify
8
+ * sides — fails through the typed error channel, so a hostile config file
9
+ * surfaces as a `ConfigCodecError` rather than crashing the process. Both
10
+ * directions preserve the underlying failure structurally in `cause` —
11
+ * never stringified.
12
+ *
13
+ * Stringify is genuinely fallible beyond hostile input: TOML has no null,
14
+ * so a document carrying `null` (or any other unrepresentable value, an
15
+ * out-of-int64-range `bigint`, a circular reference) fails with a
16
+ * `ConfigCodecError` whose `cause` is the structured `TomlStringifyError`.
17
+ *
18
+ * @public
19
+ */
20
+ export declare const TomlCodec: ConfigCodec;
21
+ //# sourceMappingURL=toml.d.ts.map
package/toml.js ADDED
@@ -0,0 +1,3 @@
1
+ import { TomlCodec } from "./TomlCodec.js";
2
+
3
+ export { TomlCodec };
@@ -5,7 +5,7 @@
5
5
  "toolPackages": [
6
6
  {
7
7
  "packageName": "@microsoft/api-extractor",
8
- "packageVersion": "7.59.1"
8
+ "packageVersion": "7.59.4"
9
9
  }
10
10
  ]
11
11
  }
package/yaml.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { ConfigCodec } from "@effected/config-file";
2
+ /**
3
+ * A `ConfigCodec` backed by `@effected/yaml`.
4
+ *
5
+ * @remarks
6
+ * `@effected/yaml`'s input hardening — an alias-expansion budget guarding
7
+ * against "billion laughs" alias bombs, and a collection-nesting depth cap —
8
+ * fails through the typed error channel, so a hostile config file surfaces
9
+ * as a `ConfigCodecError` rather than crashing the process. Both directions
10
+ * preserve the underlying failure structurally in `cause` — never
11
+ * stringified.
12
+ *
13
+ * @public
14
+ */
15
+ export declare const YamlCodec: ConfigCodec;
16
+ //# sourceMappingURL=yaml.d.ts.map
package/yaml.js ADDED
@@ -0,0 +1,3 @@
1
+ import { YamlCodec } from "./YamlCodec.js";
2
+
3
+ export { YamlCodec };