@effected/config-file 0.8.0 → 0.10.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
@@ -14,11 +14,26 @@ import { Context, DateTime, Effect, FileSystem, Layer, Option, Path, PubSub, Sch
14
14
  *
15
15
  * @public
16
16
  */
17
- var ConfigFileNotFoundError = class extends Schema.TaggedError()("ConfigFileNotFoundError", {
18
- /** The names of the resolvers that were probed, in order. */
19
- searched: Schema.Array(Schema.String) }) {
17
+ var ConfigFileNotFoundError = class extends Schema.TaggedError()("ConfigFileNotFoundError", {
18
+ /** The names of the resolvers that were probed, in order. */
19
+ searched: Schema.Array(Schema.String),
20
+ /**
21
+ * The candidate paths the chain actually checked on disk, in probe order
22
+ * across every resolver — the failure-path mirror of {@link ConfigMatch}.
23
+ *
24
+ * @remarks
25
+ * One `searched` name can hide many paths: an `upwardWalk` with a
26
+ * `filenames` list probes every name at every ancestor. Resolvers that omit
27
+ * the optional `resolveProbe` member contribute nothing here, so the list
28
+ * can be shorter than the true search (or empty for a fully hand-rolled
29
+ * chain) — `searched` remains the complete resolver list either way.
30
+ */
31
+ candidates: Schema.Array(Schema.String)
32
+ }) {
20
33
  get message() {
21
- return `No config file found (searched: ${this.searched.join(", ")})`;
34
+ const count = this.candidates.length;
35
+ const probed = count === 0 ? "" : ` — ${count} candidate path${count === 1 ? "" : "s"} checked`;
36
+ return `No config file found (searched: ${this.searched.join(", ")}${probed})`;
22
37
  }
23
38
  };
24
39
  /**
@@ -120,6 +135,13 @@ const withCodecPath = (error, target) => error instanceof ConfigCodecError && er
120
135
  cause: error.cause,
121
136
  path: target
122
137
  }) : error;
138
+ /**
139
+ * Put `header` in front of `document`, separated by exactly one newline.
140
+ *
141
+ * A header that already ends in a newline is not given another — a caller who
142
+ * built the line with a trailing `"\n"` and one who did not get the same file.
143
+ */
144
+ const prependHeader = (document, header) => header === void 0 ? document : header.endsWith("\n") ? `${header}${document}` : `${header}\n${document}`;
123
145
  const makeImpl = (options, fs, path, resolverEnv) => {
124
146
  /**
125
147
  * Publish one event, or nothing at all.
@@ -189,8 +211,15 @@ const makeImpl = (options, fs, path, resolverEnv) => {
189
211
  });
190
212
  const discover = Effect.fn("ConfigFile.discover")(function* () {
191
213
  const sources = [];
214
+ const candidates = [];
192
215
  for (const resolver of options.resolvers) {
193
- const found = resolver.resolveMatch === void 0 ? Option.map(yield* Effect.provide(resolver.resolve, resolverEnv), (path) => ({ path })) : yield* Effect.provide(resolver.resolveMatch, resolverEnv);
216
+ let found;
217
+ if (resolver.resolveProbe !== void 0) {
218
+ const probe = yield* Effect.provide(resolver.resolveProbe, resolverEnv);
219
+ found = probe.match;
220
+ if (Option.isNone(found)) candidates.push(...probe.probed);
221
+ } else if (resolver.resolveMatch !== void 0) found = yield* Effect.provide(resolver.resolveMatch, resolverEnv);
222
+ else found = Option.map(yield* Effect.provide(resolver.resolve, resolverEnv), (path) => ({ path }));
194
223
  if (Option.isSome(found)) {
195
224
  const match = found.value;
196
225
  const target = match.path;
@@ -207,7 +236,10 @@ const makeImpl = (options, fs, path, resolverEnv) => {
207
236
  });
208
237
  }
209
238
  }
210
- return sources;
239
+ return {
240
+ sources,
241
+ candidates
242
+ };
211
243
  });
212
244
  const searched = options.resolvers.map((r) => r.name);
213
245
  /**
@@ -232,15 +264,18 @@ const makeImpl = (options, fs, path, resolverEnv) => {
232
264
  return value;
233
265
  });
234
266
  const load = Effect.fn("ConfigFile.load")(function* () {
235
- const sources = yield* discover();
267
+ const { sources, candidates } = yield* discover();
236
268
  if (sources.length === 0) {
237
269
  yield* emit({ _tag: "NotFound" });
238
- return yield* Effect.fail(new ConfigFileNotFoundError({ searched }));
270
+ return yield* Effect.fail(new ConfigFileNotFoundError({
271
+ searched,
272
+ candidates
273
+ }));
239
274
  }
240
275
  return yield* mergeAndEmit(sources);
241
276
  });
242
277
  const loadOrDefault = Effect.fn("ConfigFile.loadOrDefault")(function* (defaultValue) {
243
- const sources = yield* discover();
278
+ const { sources } = yield* discover();
244
279
  if (sources.length === 0) {
245
280
  yield* emit({ _tag: "NotFound" });
246
281
  return defaultValue;
@@ -252,24 +287,46 @@ const makeImpl = (options, fs, path, resolverEnv) => {
252
287
  return yield* runValidate(decoded);
253
288
  });
254
289
  /**
255
- * Shared by `write` and `save`. Not an `Effect.fn`: it is internal, and the
256
- * public boundaries that call it already open a span.
290
+ * Schema-encode, stringify, and prepend the header. Shared by `encode`,
291
+ * `write` and `save`. Not an `Effect.fn`: it is internal, and the public
292
+ * boundaries that call it already open a span.
293
+ *
294
+ * `target` is the file this text is destined for, when there is one. It is
295
+ * only ever used to NAME the file in an error — `encode` passes `none` and
296
+ * its errors honestly carry no path.
257
297
  */
258
- const encodeAndWrite = (value, target) => Effect.gen(function* () {
298
+ const encodeTo = (value, target, encodeOptions) => Effect.gen(function* () {
259
299
  const encoded = yield* Schema.encodeEffect(options.schema)(value).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(new ConfigValidationError({
260
- path: Option.some(target),
300
+ path: target,
261
301
  issue: error.issue
262
302
  }))));
263
- const serialized = yield* options.codec.stringify(encoded).pipe(Effect.mapError((error) => withCodecPath(error, target)), Effect.tapError((error) => emit({
303
+ const serialized = yield* options.codec.stringify(encoded).pipe(Effect.mapError((error) => Option.match(target, {
304
+ onNone: () => error,
305
+ onSome: (file) => withCodecPath(error, file)
306
+ })));
307
+ return prependHeader(serialized, encodeOptions?.header);
308
+ });
309
+ /**
310
+ * `encodeTo` a known file, then write it. Shared by `write` and `save`.
311
+ *
312
+ * `StringifyFailed` is emitted HERE, not in `encodeTo`: it is a write-path
313
+ * event, and `encode` promises to publish nothing. A subscriber counting
314
+ * failed writes must not see a dry run's codec failure.
315
+ */
316
+ const encodeAndWrite = (value, target, encodeOptions) => Effect.gen(function* () {
317
+ const serialized = yield* encodeTo(value, Option.some(target), encodeOptions).pipe(Effect.tapError((error) => error._tag === "ConfigCodecError" ? emit({
264
318
  _tag: "StringifyFailed",
265
319
  codec: options.codec.name,
266
320
  error
267
- })));
321
+ }) : Effect.void));
268
322
  yield* fs.writeFileString(target, serialized).pipe(Effect.mapError((cause) => new ConfigFileWriteError({
269
323
  path: target,
270
324
  cause
271
325
  })));
272
326
  });
327
+ const encode = Effect.fn("ConfigFile.encode")(function* (value, encodeOptions) {
328
+ return yield* encodeTo(value, Option.none(), encodeOptions);
329
+ });
273
330
  /**
274
331
  * Resolve `defaultPath`, `mkdir -p` its parent and write. Shared by `save`
275
332
  * and `update`, and — crucially — emits nothing.
@@ -290,8 +347,8 @@ const makeImpl = (options, fs, path, resolverEnv) => {
290
347
  yield* encodeAndWrite(value, target);
291
348
  return target;
292
349
  });
293
- const write = Effect.fn("ConfigFile.write")(function* (value, target) {
294
- yield* encodeAndWrite(value, target);
350
+ const write = Effect.fn("ConfigFile.write")(function* (value, target, encodeOptions) {
351
+ yield* encodeAndWrite(value, target, encodeOptions);
295
352
  yield* emit({
296
353
  _tag: "Written",
297
354
  path: target
@@ -332,9 +389,10 @@ const makeImpl = (options, fs, path, resolverEnv) => {
332
389
  return {
333
390
  load: load(),
334
391
  loadFrom,
335
- discover: discover(),
392
+ discover: Effect.map(discover(), (found) => found.sources),
336
393
  loadOrDefault,
337
394
  validate,
395
+ encode,
338
396
  write,
339
397
  save,
340
398
  update
package/ConfigResolver.js CHANGED
@@ -3,33 +3,44 @@ import { Walker } from "@effected/walker";
3
3
 
4
4
  //#region src/ConfigResolver.ts
5
5
  /**
6
- * Build a resolver from a match-reporting lookup, deriving `resolve` from it.
6
+ * Build a resolver from a probe-reporting lookup, deriving `resolveMatch` and
7
+ * `resolve` from it.
7
8
  *
8
9
  * @remarks
9
- * Every built-in goes through here, so `resolve` and `resolveMatch` are one
10
- * implementation and cannot drift.
10
+ * Every built-in goes through here, so `resolve`, `resolveMatch` and
11
+ * `resolveProbe` are one implementation and cannot drift.
11
12
  */
12
- const fromMatch = (name, resolveMatch) => ({
13
+ const fromProbe = (name, resolveProbe) => ({
13
14
  name,
14
- resolve: Effect.map(resolveMatch, Option.map((match) => match.path)),
15
- resolveMatch
15
+ resolve: Effect.map(resolveProbe, (probe) => Option.map(probe.match, (match) => match.path)),
16
+ resolveMatch: Effect.map(resolveProbe, (probe) => probe.match),
17
+ resolveProbe
16
18
  });
17
- /** Absorb any failure into `Option.none()` — the resolver contract. */
18
- const absorb = (effect) => Effect.catch(effect, () => Effect.succeed(Option.none()));
19
+ /** Absorb any failure into an empty probe — the resolver contract. */
20
+ const absorb = (effect) => Effect.catch(effect, () => Effect.succeed({
21
+ match: Option.none(),
22
+ probed: []
23
+ }));
19
24
  const cwdOf = (given) => given ?? globalThis.process?.cwd?.() ?? "/";
20
- const explicitPath = (target) => fromMatch("explicit", absorb(Effect.gen(function* () {
21
- return (yield* (yield* FileSystem.FileSystem).exists(target)) ? Option.some({ path: target }) : Option.none();
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
+ };
22
30
  })));
23
- const staticDir = (options) => fromMatch("static", absorb(Effect.gen(function* () {
31
+ const staticDir = (options) => fromProbe("static", absorb(Effect.gen(function* () {
24
32
  const fs = yield* FileSystem.FileSystem;
25
33
  const candidate = (yield* Path.Path).join(options.dir, options.filename);
26
- return (yield* fs.exists(candidate)) ? Option.some({
27
- path: candidate,
28
- dir: options.dir,
29
- filename: options.filename
30
- }) : Option.none();
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
+ };
31
42
  })));
32
- const upwardWalk = (options) => fromMatch(options.name ?? "walk", Effect.gen(function* () {
43
+ const upwardWalk = (options) => fromProbe(options.name ?? "walk", Effect.gen(function* () {
33
44
  const fs = yield* FileSystem.FileSystem;
34
45
  const path = yield* Path.Path;
35
46
  const subpaths = options.subpaths ?? ["."];
@@ -42,31 +53,50 @@ const upwardWalk = (options) => fromMatch(options.name ?? "walk", Effect.gen(fun
42
53
  filename,
43
54
  ...options.subpaths !== void 0 && { subpath: sub }
44
55
  });
45
- const found = yield* Walker.firstMatch(candidates.map((candidate) => candidate.path), (candidate) => fs.exists(candidate));
46
- return Option.flatMap(found, (target) => Option.fromNullishOr(candidates.find((candidate) => candidate.path === target)));
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
+ };
47
66
  }));
48
67
  /**
49
68
  * Ascend from `cwd` looking for the first directory where `isRoot` reports
50
69
  * true, then probe `subpaths` under it. Shared by `gitRoot` and
51
70
  * `workspaceRoot`, which differ only in how a "root" is detected.
52
71
  */
53
- const rootAnchored = (name, isRoot, options) => fromMatch(name, Effect.gen(function* () {
72
+ const rootAnchored = (name, isRoot, options) => fromProbe(name, Effect.gen(function* () {
54
73
  const fs = yield* FileSystem.FileSystem;
55
74
  const path = yield* Path.Path;
56
75
  const dirs = yield* Walker.ascend(cwdOf(options.cwd));
57
76
  const root = yield* Walker.findRoot(dirs, isRoot);
58
- if (Option.isNone(root)) return Option.none();
77
+ if (Option.isNone(root)) return {
78
+ match: Option.none(),
79
+ probed: []
80
+ };
59
81
  const subpaths = options.subpaths ?? ["."];
60
- const found = yield* Walker.firstMatch(subpaths.map((sub) => path.join(root.value, sub, options.filename)), (candidate) => fs.exists(candidate));
61
- return Option.map(found, (target) => {
62
- const matchedSubpath = options.subpaths === void 0 ? void 0 : subpaths.find((sub) => path.join(root.value, sub, options.filename) === target);
63
- return {
64
- path: target,
65
- dir: root.value,
66
- filename: options.filename,
67
- ...matchedSubpath !== void 0 && { subpath: matchedSubpath }
68
- };
82
+ const paths = subpaths.map((sub) => path.join(root.value, sub, options.filename));
83
+ const found = yield* Walker.firstMatch(paths, (candidate) => fs.exists(candidate));
84
+ const probed = Option.match(found, {
85
+ onNone: () => paths,
86
+ onSome: (target) => paths.slice(0, paths.indexOf(target) + 1)
69
87
  });
88
+ return {
89
+ match: Option.map(found, (target) => {
90
+ const matchedSubpath = options.subpaths === void 0 ? void 0 : subpaths.find((sub) => path.join(root.value, sub, options.filename) === target);
91
+ return {
92
+ path: target,
93
+ dir: root.value,
94
+ filename: options.filename,
95
+ ...matchedSubpath !== void 0 && { subpath: matchedSubpath }
96
+ };
97
+ }),
98
+ probed
99
+ };
70
100
  }));
71
101
  /** `.git` may be a directory (a normal repo) or a file (a worktree pointing at the real repo). */
72
102
  const isGitRoot = (dir) => Effect.gen(function* () {
@@ -90,18 +120,24 @@ const isWorkspaceRoot = (dir) => Effect.gen(function* () {
90
120
  });
91
121
  const gitRoot = (options) => rootAnchored("git", isGitRoot, options);
92
122
  const workspaceRoot = (options) => rootAnchored("workspace", isWorkspaceRoot, options);
93
- const systemEtc = (options) => fromMatch("system", absorb(Effect.gen(function* () {
94
- if (globalThis.process?.platform === "win32") return Option.none();
123
+ const systemEtc = (options) => fromProbe("system", absorb(Effect.gen(function* () {
124
+ if (globalThis.process?.platform === "win32") return {
125
+ match: Option.none(),
126
+ probed: []
127
+ };
95
128
  const fs = yield* FileSystem.FileSystem;
96
129
  const path = yield* Path.Path;
97
130
  const base = options.dir ?? "/etc";
98
131
  const dir = path.join(base, options.app);
99
132
  const candidate = path.join(dir, options.filename);
100
- return (yield* fs.exists(candidate)) ? Option.some({
101
- path: candidate,
102
- dir,
103
- filename: options.filename
104
- }) : Option.none();
133
+ return {
134
+ match: (yield* fs.exists(candidate)) ? Option.some({
135
+ path: candidate,
136
+ dir,
137
+ filename: options.filename
138
+ }) : Option.none(),
139
+ probed: [candidate]
140
+ };
105
141
  })));
106
142
  /**
107
143
  * Built-in resolvers, in the order a typical chain uses them.
package/README.md CHANGED
@@ -149,7 +149,7 @@ Every failure is a tagged error you route on with `Effect.catchTag`. The tags ex
149
149
 
150
150
  | Tag | Means | Recovery |
151
151
  | --- | --- | --- |
152
- | `ConfigFileNotFoundError` | The resolver chain matched nothing. Carries `searched`, the resolver names probed. | Fall back to defaults — the one failure that is often not an error. `loadOrDefault` handles it for you. |
152
+ | `ConfigFileNotFoundError` | The resolver chain matched nothing. Carries `searched`, the resolver names probed, and `candidates`, the paths they actually checked on disk. | Fall back to defaults — the one failure that is often not an error. `loadOrDefault` handles it for you. |
153
153
  | `ConfigFileReadError` | A file was found but could not be read. Carries `path` and the structural `cause`. | Usually fatal: the file exists and the process cannot read it. Check permissions. |
154
154
  | `ConfigFileWriteError` | A file could not be written. Carries `path` and the structural `cause`. | Retry elsewhere, or surface to the user. |
155
155
  | `ConfigDefaultPathMissingError` | `save` or `update` was called on a service configured without a `defaultPath`. | A wiring bug, not a data condition. Fix the layer, or call `write` with an explicit path. |
@@ -158,7 +158,7 @@ Every failure is a tagged error you route on with `Effect.catchTag`. The tags ex
158
158
  | `ConfigMigrationError` | A versioned migration failed. Carries `version`, `name`, `phase` and the structural `cause`. | Report which step failed; the config on disk is left untouched. |
159
159
  | `ConfigEncryptionError` | An encrypt, decrypt, key-derivation or base64 step failed. Carries `phase` and the structural `cause`. | A wrong passphrase and a corrupt envelope both land here; inspect `phase`. |
160
160
 
161
- `ConfigLoadError`, `ConfigReadError`, `ConfigWriteError`, `ConfigSaveError` and `ConfigUpdateError` are exported unions naming exactly the failures each method can produce. Catching one tag narrows the union, leaving the rest to propagate:
161
+ `ConfigLoadError`, `ConfigReadError`, `ConfigEncodeError`, `ConfigWriteError`, `ConfigSaveError` and `ConfigUpdateError` are exported unions naming exactly the failures each method can produce — `ConfigEncodeError` is `ConfigWriteError` minus `ConfigFileWriteError`, because `encode` never touches the disk. Catching one tag narrows the union, leaving the rest to propagate:
162
162
 
163
163
  ```ts
164
164
  import type { ConfigFileShape } from "@effected/config-file";
@@ -213,6 +213,7 @@ export const secret = EncryptedCodec(migrating, EncryptedCodecKey.fromPassphrase
213
213
  - `ConfigFile.Service` / `ConfigFile.layer` / `ConfigFile.testLayer` — a per-schema service class and its layers. `testLayer` seeds files into a temp directory and wires the *real* implementation over them, so tests exercise the actual pipeline rather than a stub that can drift from it.
214
214
  - `parseOptions` — decode options threaded into every decode, on the layer and on `read`. `onExcessProperty: "error"` is the only way to report a typo'd section or a field the schema deliberately removed.
215
215
  - `ConfigFile.read` — the one-shot escape from the service: read, decode and validate one explicit path, schema and codec named per call, with no resolver chain and no write path.
216
+ - `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.
216
217
  - `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.
217
218
  - `MergeStrategy` — `firstMatch` and `layeredMerge`, combining discovered sources in priority order.
218
219
  - `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.
package/index.d.ts CHANGED
@@ -281,6 +281,29 @@ export interface ConfigResolver<R = never> {
281
281
  * tail.
282
282
  */
283
283
  readonly resolveMatch?: Effect.Effect<Option.Option<ConfigMatch>, never, R>;
284
+ /**
285
+ * The same lookup, additionally reporting every candidate path actually
286
+ * checked on disk, in probe order.
287
+ *
288
+ * @remarks
289
+ * Optional, and optional forever: a hand-rolled resolver that omits it is a
290
+ * complete `ConfigResolver`, and the pipeline falls back to `resolveMatch`
291
+ * (then `resolve`), contributing nothing to
292
+ * `ConfigFileNotFoundError.candidates` — the failure-path mirror of
293
+ * `ConfigSource.match` degrading to a bare path. Every built-in implements
294
+ * it, and derives the other two from it, so the three can never disagree.
295
+ *
296
+ * It exists because a resolver's `name` under-reports a search once one
297
+ * resolver probes several candidates: after the `filenames`/`subpaths`
298
+ * forms of `upwardWalk`, one `searched` entry can hide dozens of probed
299
+ * paths, and "nothing found, here is what I looked for" becomes less
300
+ * informative than the chain actually is.
301
+ *
302
+ * `probed` reports what the lookup CHECKED, not everything it could have:
303
+ * a short-circuiting walk lists the prefix ending at the match, and an
304
+ * absorbed filesystem failure lists nothing.
305
+ */
306
+ readonly resolveProbe?: Effect.Effect<ConfigProbe, never, R>;
284
307
  }
285
308
  /**
286
309
  * How a resolver found a config file, not merely where.
@@ -305,6 +328,29 @@ interface ConfigMatch {
305
328
  /** The `filenames`/`filename` entry that matched, when the resolver takes one. */
306
329
  readonly filename?: string;
307
330
  }
331
+ /**
332
+ * The full report of one resolver lookup: what matched, and what was checked.
333
+ *
334
+ * @remarks
335
+ * `probed` carries the candidate paths the resolver actually asked the
336
+ * filesystem about, in probe order — for `upwardWalk`, the prefix of its
337
+ * directory-major candidate list ending at the match, or the full list when
338
+ * nothing matched. It exists so the failure path can be as informative as the
339
+ * success path: {@link ConfigMatch} says which candidate won and anchored
340
+ * where; `probed` says what lost, and where the search looked.
341
+ *
342
+ * @public
343
+ */
344
+ interface ConfigProbe {
345
+ /** The match, when a candidate existed on disk. */
346
+ readonly match: Option.Option<ConfigMatch>;
347
+ /**
348
+ * Candidate paths checked on disk, in probe order. Empty when the lookup
349
+ * produced no candidate to report — no root found, a platform short-circuit,
350
+ * or a filesystem failure absorbed across the whole probe.
351
+ */
352
+ readonly probed: ReadonlyArray<string>;
353
+ }
308
354
  /**
309
355
  * Options for `ConfigResolver.upwardWalk`.
310
356
  *
@@ -486,6 +532,18 @@ export declare const MergeStrategy: {
486
532
  declare const ConfigFileNotFoundError_base: Schema.Class<ConfigFileNotFoundError, Schema.TaggedStruct<"ConfigFileNotFoundError", {
487
533
  /** The names of the resolvers that were probed, in order. */
488
534
  readonly searched: Schema.$Array<Schema.String>;
535
+ /**
536
+ * The candidate paths the chain actually checked on disk, in probe order
537
+ * across every resolver — the failure-path mirror of {@link ConfigMatch}.
538
+ *
539
+ * @remarks
540
+ * One `searched` name can hide many paths: an `upwardWalk` with a
541
+ * `filenames` list probes every name at every ancestor. Resolvers that omit
542
+ * the optional `resolveProbe` member contribute nothing here, so the list
543
+ * can be shorter than the true search (or empty for a fully hand-rolled
544
+ * chain) — `searched` remains the complete resolver list either way.
545
+ */
546
+ readonly candidates: Schema.$Array<Schema.String>;
489
547
  }>, import("effect/Cause").YieldableError>;
490
548
  /**
491
549
  * Indicates that the resolver chain produced no configuration source.
@@ -600,6 +658,38 @@ type ConfigReadError = ConfigFileReadError | ConfigCodecError | ConfigValidation
600
658
  * @public
601
659
  */
602
660
  type ConfigWriteError = ConfigFileWriteError | ConfigCodecError | ConfigValidationError;
661
+ /**
662
+ * The failure modes of {@link ConfigFileShape.encode}: everything on the write
663
+ * path except the write itself.
664
+ *
665
+ * @remarks
666
+ * Deliberately excludes {@link ConfigFileWriteError} — nothing touches the
667
+ * filesystem — so a `--dry-run` caller's error channel is honest about that.
668
+ *
669
+ * @public
670
+ */
671
+ type ConfigEncodeError = ConfigCodecError | ConfigValidationError;
672
+ /**
673
+ * Options shared by {@link ConfigFileShape.encode} and {@link ConfigFileShape.write}.
674
+ *
675
+ * @public
676
+ */
677
+ interface ConfigEncodeOptions {
678
+ /**
679
+ * Text emitted **verbatim** in front of the serialized document, separated
680
+ * from it by exactly one newline. If `header` already ends in `"\n"`, no
681
+ * further newline is added.
682
+ *
683
+ * @remarks
684
+ * The caller owns the header's validity in the target format: `#` comment
685
+ * lines for TOML and YAML, `//` for JSONC. JSON has no comment syntax, so a
686
+ * header on a JSON codec yields an unparseable file by construction — the
687
+ * service does not check. The motivating case is a schema directive such as
688
+ * `#:schema https://example.com/config.schema.json` at the top of a TOML
689
+ * file, which editors read for completion and validation.
690
+ */
691
+ readonly header?: string;
692
+ }
603
693
  /**
604
694
  * The failure modes of {@link ConfigFileShape.save}.
605
695
  *
@@ -646,15 +736,27 @@ interface ConfigFileShape<A> {
646
736
  readonly loadOrDefault: (defaultValue: A) => Effect.Effect<A, ConfigReadError>;
647
737
  /** Decode and validate an in-memory value. */
648
738
  readonly validate: (value: unknown) => Effect.Effect<A, ConfigValidationError>;
739
+ /**
740
+ * Encode `value` to its serialized text without writing anything.
741
+ *
742
+ * @remarks
743
+ * Produces byte-for-byte what `write(value, path, options)` puts on disk —
744
+ * the primitive for `--dry-run` and "show me the file" callers. Emits no
745
+ * event, because nothing was written. A codec failure here carries no
746
+ * `path`, and a validation failure's `path` is `Option.none()`: there is no
747
+ * file to name.
748
+ */
749
+ readonly encode: (value: A, options?: ConfigEncodeOptions) => Effect.Effect<string, ConfigEncodeError>;
649
750
  /**
650
751
  * Encode `value` and write it to an explicit `path`.
651
752
  *
652
753
  * @remarks
653
754
  * Does **not** create the parent directory — that is
654
755
  * {@link ConfigFileShape.save}'s job, and the distinction is load-bearing:
655
- * `write` targets a path the caller already vouched for.
756
+ * `write` targets a path the caller already vouched for. `options` are the
757
+ * same as {@link ConfigFileShape.encode}'s, so the two stay in lockstep.
656
758
  */
657
- readonly write: (value: A, path: string) => Effect.Effect<void, ConfigWriteError>;
759
+ readonly write: (value: A, path: string, options?: ConfigEncodeOptions) => Effect.Effect<void, ConfigWriteError>;
658
760
  /**
659
761
  * Resolve `defaultPath`, `mkdir -p` its parent, encode `value` into it, and
660
762
  * return the path written.
@@ -1231,5 +1333,5 @@ export declare const TomlCodec: ConfigCodec;
1231
1333
  */
1232
1334
  export declare const YamlCodec: ConfigCodec;
1233
1335
  //#endregion
1234
- export type { ConfigCodec, ConfigEventsShape, ConfigFileMigration, ConfigFileOptions, ConfigFileShape, ConfigFileTestOptions, ConfigLoadError, ConfigMatch, ConfigMigrationOptions, ConfigReadError, ConfigReadOptions, ConfigSaveError, ConfigSource, ConfigUpdateError, ConfigWriteError, LayerConfigProviderOptions, NonEmptySources, UpwardWalkOptions };
1336
+ export type { ConfigCodec, ConfigEncodeError, ConfigEncodeOptions, ConfigEventsShape, ConfigFileMigration, ConfigFileOptions, ConfigFileShape, ConfigFileTestOptions, ConfigLoadError, ConfigMatch, ConfigMigrationOptions, ConfigProbe, ConfigReadError, ConfigReadOptions, ConfigSaveError, ConfigSource, ConfigUpdateError, ConfigWriteError, LayerConfigProviderOptions, NonEmptySources, UpwardWalkOptions };
1235
1337
  //# sourceMappingURL=index.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/config-file",
3
- "version": "0.8.0",
3
+ "version": "0.10.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": [
@@ -45,8 +45,8 @@
45
45
  "peerDependencies": {
46
46
  "@effected/jsonc": "^0.11.0",
47
47
  "@effected/toml": "^0.7.0",
48
- "@effected/walker": "^0.8.0",
49
- "@effected/yaml": "^0.15.0",
48
+ "@effected/walker": "^0.9.0",
49
+ "@effected/yaml": "^0.15.1",
50
50
  "effect": "4.0.0-rc.115"
51
51
  },
52
52
  "engines": {