@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 +76 -18
- package/ConfigResolver.js +73 -37
- package/README.md +3 -2
- package/index.d.ts +105 -3
- package/package.json +3 -3
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
|
-
|
|
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
|
-
|
|
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
|
|
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({
|
|
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
|
-
*
|
|
256
|
-
*
|
|
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
|
|
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:
|
|
300
|
+
path: target,
|
|
261
301
|
issue: error.issue
|
|
262
302
|
}))));
|
|
263
|
-
const serialized = yield* options.codec.stringify(encoded).pipe(Effect.mapError((error) =>
|
|
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
|
|
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
|
|
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
|
|
13
|
+
const fromProbe = (name, resolveProbe) => ({
|
|
13
14
|
name,
|
|
14
|
-
resolve: Effect.map(
|
|
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
|
|
18
|
-
const absorb = (effect) => Effect.catch(effect, () => Effect.succeed(
|
|
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) =>
|
|
21
|
-
return
|
|
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) =>
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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) =>
|
|
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
|
|
46
|
-
|
|
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) =>
|
|
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
|
|
77
|
+
if (Option.isNone(root)) return {
|
|
78
|
+
match: Option.none(),
|
|
79
|
+
probed: []
|
|
80
|
+
};
|
|
59
81
|
const subpaths = options.subpaths ?? ["."];
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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) =>
|
|
94
|
-
if (globalThis.process?.platform === "win32") return
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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.
|
|
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.
|
|
49
|
-
"@effected/yaml": "^0.15.
|
|
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": {
|