@effected/config-file 0.20.0 → 0.21.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 +41 -45
- package/ConfigMigration.js +18 -19
- package/ConfigResolver.js +65 -71
- package/MergeStrategy.js +34 -35
- package/index.d.ts +61 -20
- package/package.json +4 -4
- package/tsdoc-metadata.json +1 -1
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 =
|
|
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 =
|
|
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 =
|
|
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 };
|
package/ConfigMigration.js
CHANGED
|
@@ -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 =
|
|
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,12 +91,28 @@ 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 =
|
|
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 =
|
|
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
|
-
* Ascends from `cwd` (or the process cwd) toward `stopAt
|
|
162
|
-
* first `subpaths/filename` combination found at each level.
|
|
114
|
+
* Ascends from `cwd` (or the process cwd) toward `stopAt` or `ceiling`,
|
|
115
|
+
* resolving the first `subpaths/filename` combination found at each level.
|
|
163
116
|
*
|
|
164
117
|
* @remarks
|
|
165
118
|
* `filenames` replaces `filename` with a per-directory candidate list: every
|
|
@@ -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 =
|
|
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 = options.ceiling !== void 0 ? yield* Walker.ascendWithin(cwdOf(options.cwd), Option.some(options.ceiling)) : 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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
57
|
-
|
|
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/index.d.ts
CHANGED
|
@@ -411,16 +411,53 @@ interface ConfigProbe {
|
|
|
411
411
|
* (`".config/app.toml"`), which is how a mixed convention is spelled without a
|
|
412
412
|
* `subpaths` cross product.
|
|
413
413
|
*
|
|
414
|
+
* At most one of `stopAt` (a lexical ceiling) and `ceiling` (a physical
|
|
415
|
+
* one) is given. Bound the walk by a path git answered — `Git.repoRoot` — with
|
|
416
|
+
* `ceiling`; `stopAt` never matches a symlink-resolved spelling of the start.
|
|
417
|
+
*
|
|
414
418
|
* @public
|
|
415
419
|
*/
|
|
416
|
-
type UpwardWalkOptions = {
|
|
420
|
+
type UpwardWalkOptions = ({
|
|
417
421
|
/** The single file name probed at each ancestor directory. */
|
|
418
422
|
readonly filename: string;
|
|
419
423
|
readonly filenames?: undefined;
|
|
424
|
+
} | {
|
|
425
|
+
/**
|
|
426
|
+
* The candidate file names probed at each ancestor directory, in order,
|
|
427
|
+
* before the walk ascends.
|
|
428
|
+
*/
|
|
429
|
+
readonly filenames: ReadonlyArray<string>;
|
|
430
|
+
readonly filename?: undefined;
|
|
431
|
+
}) & ({
|
|
432
|
+
/**
|
|
433
|
+
* An absolute ceiling the walk stops at, inclusive, compared
|
|
434
|
+
* LEXICALLY (`Walker.ascend`'s `stopAt`).
|
|
435
|
+
*
|
|
436
|
+
* @remarks
|
|
437
|
+
* A ceiling spelled differently from the walk's own path never
|
|
438
|
+
* matches, and the walk runs on to the filesystem root. That is
|
|
439
|
+
* every physical path — `Git.repoRoot`'s answer from a symlinked or
|
|
440
|
+
* macOS-tmpdir start — so bound by such a path with `ceiling`.
|
|
441
|
+
*/
|
|
442
|
+
readonly stopAt?: string;
|
|
443
|
+
readonly ceiling?: undefined;
|
|
444
|
+
} | {
|
|
445
|
+
/**
|
|
446
|
+
* A ceiling the walk stops at, inclusive, compared PHYSICALLY: the
|
|
447
|
+
* nearest ancestor whose real path is the ceiling's real path
|
|
448
|
+
* (`Walker.ascendWithin`).
|
|
449
|
+
*
|
|
450
|
+
* @remarks
|
|
451
|
+
* For a ceiling that may be spelled differently from the walk's own
|
|
452
|
+
* path, above all `Git.repoRoot`'s symlink-resolved answer. Omit it
|
|
453
|
+
* to walk to the filesystem root. A relative ceiling is a defect.
|
|
454
|
+
* Cannot be combined with `stopAt`.
|
|
455
|
+
*/
|
|
456
|
+
readonly ceiling?: string;
|
|
457
|
+
readonly stopAt?: undefined;
|
|
458
|
+
}) & {
|
|
420
459
|
/** Where the walk starts. Defaults to the process cwd. */
|
|
421
460
|
readonly cwd?: string;
|
|
422
|
-
/** An absolute ceiling the walk stops at, inclusive. */
|
|
423
|
-
readonly stopAt?: string;
|
|
424
461
|
/** Directories probed under each ancestor, in order. Defaults to `["."]`. */
|
|
425
462
|
readonly subpaths?: ReadonlyArray<string>;
|
|
426
463
|
/**
|
|
@@ -433,21 +470,6 @@ type UpwardWalkOptions = {
|
|
|
433
470
|
* identifies the matching candidate rather than the resolver.
|
|
434
471
|
*/
|
|
435
472
|
readonly name?: string;
|
|
436
|
-
} | {
|
|
437
|
-
/**
|
|
438
|
-
* The candidate file names probed at each ancestor directory, in order,
|
|
439
|
-
* before the walk ascends.
|
|
440
|
-
*/
|
|
441
|
-
readonly filenames: ReadonlyArray<string>;
|
|
442
|
-
readonly filename?: undefined;
|
|
443
|
-
/** Where the walk starts. Defaults to the process cwd. */
|
|
444
|
-
readonly cwd?: string;
|
|
445
|
-
/** An absolute ceiling the walk stops at, inclusive. */
|
|
446
|
-
readonly stopAt?: string;
|
|
447
|
-
/** Directories probed under each ancestor, in order. Defaults to `["."]`. */
|
|
448
|
-
readonly subpaths?: ReadonlyArray<string>;
|
|
449
|
-
/** The resolver's reported name. Defaults to `"walk"`. */
|
|
450
|
-
readonly name?: string;
|
|
451
473
|
};
|
|
452
474
|
/**
|
|
453
475
|
* Built-in resolvers, in the order a typical chain uses them.
|
|
@@ -464,8 +486,8 @@ export declare class ConfigResolver {
|
|
|
464
486
|
readonly filename: string;
|
|
465
487
|
}) => ConfigResolver<FileSystem.FileSystem | Path.Path>;
|
|
466
488
|
/**
|
|
467
|
-
* Ascends from `cwd` (or the process cwd) toward `stopAt
|
|
468
|
-
* first `subpaths/filename` combination found at each level.
|
|
489
|
+
* Ascends from `cwd` (or the process cwd) toward `stopAt` or `ceiling`,
|
|
490
|
+
* resolving the first `subpaths/filename` combination found at each level.
|
|
469
491
|
*
|
|
470
492
|
* @remarks
|
|
471
493
|
* `filenames` replaces `filename` with a per-directory candidate list: every
|
|
@@ -576,7 +598,26 @@ export interface MergeStrategy<A> {
|
|
|
576
598
|
* @public
|
|
577
599
|
*/
|
|
578
600
|
export declare const MergeStrategy: {
|
|
601
|
+
/** Take the highest-priority source whole. */
|
|
579
602
|
readonly firstMatch: <A>() => MergeStrategy<A>;
|
|
603
|
+
/**
|
|
604
|
+
* Deep-merge every contributing source, higher priority winning on conflict.
|
|
605
|
+
*
|
|
606
|
+
* @remarks
|
|
607
|
+
* Two values merge only when both are record-like and share a prototype, so a
|
|
608
|
+
* document decoded through `Schema.Class` merges with another of the same class
|
|
609
|
+
* and **survives as a real instance** — `instanceof` holds and its getters
|
|
610
|
+
* still work. Everything else is atomic: a nested `Date`, `Map`, `Set`,
|
|
611
|
+
* `RegExp`, array, or class instance is taken whole from the highest-priority
|
|
612
|
+
* source that defines it, never reshaped field-by-field.
|
|
613
|
+
*
|
|
614
|
+
* The alternative — spreading each value into a fresh object — would make
|
|
615
|
+
* `load`'s declared `Effect<A>` a lie, handing back a structurally-equal plain
|
|
616
|
+
* object whose class methods are gone and whose `Date` fields have decayed to
|
|
617
|
+
* `{}`.
|
|
618
|
+
*
|
|
619
|
+
* Nested *plain* objects (a `Schema.Struct` section) still merge field-wise.
|
|
620
|
+
*/
|
|
580
621
|
readonly layeredMerge: <A>() => MergeStrategy<A>;
|
|
581
622
|
};
|
|
582
623
|
declare const ConfigFileNotFoundError_base: Schema.Class<ConfigFileNotFoundError, Schema.TaggedStruct<"ConfigFileNotFoundError", {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effected/config-file",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Composable config file loading for Effect: JSON, JSONC, YAML and TOML codecs, resolution strategies, and merge behaviors.",
|
|
6
6
|
"keywords": [
|
|
@@ -58,10 +58,10 @@
|
|
|
58
58
|
"./package.json": "./package.json"
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
61
|
-
"@effected/glob": "^0.
|
|
62
|
-
"@effected/jsonc": "^0.20.
|
|
61
|
+
"@effected/glob": "^0.15.0",
|
|
62
|
+
"@effected/jsonc": "^0.20.1",
|
|
63
63
|
"@effected/toml": "^0.16.0",
|
|
64
|
-
"@effected/walker": "^0.
|
|
64
|
+
"@effected/walker": "^0.21.0",
|
|
65
65
|
"@effected/yaml": "^0.25.1",
|
|
66
66
|
"effect": "^4.0.0"
|
|
67
67
|
},
|