@effected/config-file 0.9.0 → 0.10.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
@@ -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
  /**
@@ -196,8 +211,15 @@ const makeImpl = (options, fs, path, resolverEnv) => {
196
211
  });
197
212
  const discover = Effect.fn("ConfigFile.discover")(function* () {
198
213
  const sources = [];
214
+ const candidates = [];
199
215
  for (const resolver of options.resolvers) {
200
- 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 }));
201
223
  if (Option.isSome(found)) {
202
224
  const match = found.value;
203
225
  const target = match.path;
@@ -214,7 +236,10 @@ const makeImpl = (options, fs, path, resolverEnv) => {
214
236
  });
215
237
  }
216
238
  }
217
- return sources;
239
+ return {
240
+ sources,
241
+ candidates
242
+ };
218
243
  });
219
244
  const searched = options.resolvers.map((r) => r.name);
220
245
  /**
@@ -239,15 +264,18 @@ const makeImpl = (options, fs, path, resolverEnv) => {
239
264
  return value;
240
265
  });
241
266
  const load = Effect.fn("ConfigFile.load")(function* () {
242
- const sources = yield* discover();
267
+ const { sources, candidates } = yield* discover();
243
268
  if (sources.length === 0) {
244
269
  yield* emit({ _tag: "NotFound" });
245
- return yield* Effect.fail(new ConfigFileNotFoundError({ searched }));
270
+ return yield* Effect.fail(new ConfigFileNotFoundError({
271
+ searched,
272
+ candidates
273
+ }));
246
274
  }
247
275
  return yield* mergeAndEmit(sources);
248
276
  });
249
277
  const loadOrDefault = Effect.fn("ConfigFile.loadOrDefault")(function* (defaultValue) {
250
- const sources = yield* discover();
278
+ const { sources } = yield* discover();
251
279
  if (sources.length === 0) {
252
280
  yield* emit({ _tag: "NotFound" });
253
281
  return defaultValue;
@@ -361,7 +389,7 @@ const makeImpl = (options, fs, path, resolverEnv) => {
361
389
  return {
362
390
  load: load(),
363
391
  loadFrom,
364
- discover: discover(),
392
+ discover: Effect.map(discover(), (found) => found.sources),
365
393
  loadOrDefault,
366
394
  validate,
367
395
  encode,
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. |
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.
@@ -1275,5 +1333,5 @@ export declare const TomlCodec: ConfigCodec;
1275
1333
  */
1276
1334
  export declare const YamlCodec: ConfigCodec;
1277
1335
  //#endregion
1278
- export type { ConfigCodec, ConfigEncodeError, ConfigEncodeOptions, 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 };
1279
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.9.0",
3
+ "version": "0.10.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": [
@@ -43,10 +43,10 @@
43
43
  "./package.json": "./package.json"
44
44
  },
45
45
  "peerDependencies": {
46
- "@effected/jsonc": "^0.11.0",
47
- "@effected/toml": "^0.7.0",
48
- "@effected/walker": "^0.9.0",
49
- "@effected/yaml": "^0.15.1",
46
+ "@effected/jsonc": "^0.11.1",
47
+ "@effected/toml": "^0.7.1",
48
+ "@effected/walker": "^0.9.1",
49
+ "@effected/yaml": "^0.15.2",
50
50
  "effect": "4.0.0-rc.115"
51
51
  },
52
52
  "engines": {