@effected/config-file 0.1.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/ConfigCodec.js +27 -0
- package/ConfigEvent.js +119 -0
- package/ConfigFile.js +421 -0
- package/ConfigMigration.js +97 -0
- package/ConfigProvider.js +87 -0
- package/ConfigResolver.js +95 -0
- package/EncryptedCodec.js +123 -0
- package/JsonCodec.js +38 -0
- package/JsoncCodec.js +38 -0
- package/LICENSE +21 -0
- package/MergeStrategy.js +53 -0
- package/README.md +176 -0
- package/TomlCodec.js +39 -0
- package/YamlCodec.js +34 -0
- package/index.d.ts +932 -0
- package/index.js +14 -0
- package/internal/crypto.js +115 -0
- package/internal/deepMerge.js +80 -0
- package/package.json +54 -0
- package/tsdoc-metadata.json +11 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { ConfigProvider, Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/ConfigProvider.ts
|
|
4
|
+
/**
|
|
5
|
+
* Expose a loaded, merged, schema-validated document as a v4 `ConfigProvider`,
|
|
6
|
+
* so it can be read through `Config.string("port")` and layered beneath other
|
|
7
|
+
* providers.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* Strictly additive, and deliberately in its own module so it never becomes a
|
|
11
|
+
* required import. The schema-validated whole-document
|
|
12
|
+
* {@link ConfigFileShape.load} remains the primary API: v4's `Config` has no
|
|
13
|
+
* whole-document story, and this function is the bridge, not a replacement.
|
|
14
|
+
*
|
|
15
|
+
* Three properties, in order of how easy they are to lose:
|
|
16
|
+
*
|
|
17
|
+
* 1. **A missing file is a failure, not an empty provider.** The
|
|
18
|
+
* {@link ConfigFileNotFoundError} propagates. Handing back an empty provider
|
|
19
|
+
* would silently turn every subsequent `Config` read into "not found", which
|
|
20
|
+
* is the exact class of lie this port exists to undo.
|
|
21
|
+
* 2. **Nested keys are structural, not dotted.** `fromUnknown` descends one path
|
|
22
|
+
* segment at a time, so `{ db: { host } }` is reached with
|
|
23
|
+
* `Config.nested(Config.string("host"), "db")` — never `Config.string("db.host")`,
|
|
24
|
+
* which is looked up as a single literal key and fails. No flattening happens
|
|
25
|
+
* here, because none is needed.
|
|
26
|
+
* 3. **The decoded value is handed over as-is.** `fromUnknown` descends with
|
|
27
|
+
* `Object.hasOwn`, and a `Schema.Class` instance carries its fields as own
|
|
28
|
+
* properties, so no encoding step is required. The corollary: leaves are read
|
|
29
|
+
* in their **decoded** form, and a field whose decoded type is not a JSON
|
|
30
|
+
* primitive is exposed **structurally**, not turned into a value — with no
|
|
31
|
+
* two decoded types exposed the same way. A `Date` has no own enumerable
|
|
32
|
+
* properties, so it descends as an empty record and any nested read finds
|
|
33
|
+
* nothing. Such a field reports the same `ConfigError` as a missing key —
|
|
34
|
+
* `Expected string, got undefined` — so a `Config` read of a present `Date`
|
|
35
|
+
* field looks exactly like a typo in the key name. An `Option.some`
|
|
36
|
+
* descends as a record carrying Effect's internal `value` own-property, so
|
|
37
|
+
* `Config.nested(Config.string("value"), "field")` happens to read the
|
|
38
|
+
* wrapped value straight through — an internal representation, not a
|
|
39
|
+
* supported spelling. An `Option.none` has no own keys and so reads as
|
|
40
|
+
* absent, making `Some` and `None` asymmetric. None of this is a supported
|
|
41
|
+
* way to read such a field: `load` is the API that was designed to carry it.
|
|
42
|
+
*
|
|
43
|
+
* Descent by own property is also why a prototype getter and `__proto__` are
|
|
44
|
+
* both unreachable through the returned provider.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```ts
|
|
48
|
+
* const fileProvider = yield* asConfigProvider(cfg);
|
|
49
|
+
* const provider = ConfigProvider.orElse(ConfigProvider.fromEnv(), fileProvider);
|
|
50
|
+
* ```
|
|
51
|
+
*
|
|
52
|
+
* @public
|
|
53
|
+
*/
|
|
54
|
+
const asConfigProvider = (service) => Effect.map(service.load, (value) => ConfigProvider.fromUnknown(value));
|
|
55
|
+
/**
|
|
56
|
+
* Install a loaded config document as a fallback beneath the **ambient**
|
|
57
|
+
* `ConfigProvider`, so `Config` accessors read env first and the file second.
|
|
58
|
+
*
|
|
59
|
+
* @remarks
|
|
60
|
+
* This is the composition the v3 library could not express. This layer
|
|
61
|
+
* composes beneath the **ambient** `ConfigProvider`, v4's `Context.Reference`
|
|
62
|
+
* for it, so a consumer supplying `ConfigProvider.layer(...)` controls
|
|
63
|
+
* precedence explicitly: whatever that provider resolves wins, and the config
|
|
64
|
+
* file supplies whatever it lacks. That reference's own default is
|
|
65
|
+
* `ConfigProvider.fromEnv()`, which is what most applications want; this
|
|
66
|
+
* module composes beneath whichever provider is ambient rather than pinning
|
|
67
|
+
* that default itself, so verify the precedence you need with the provider
|
|
68
|
+
* you actually wire in.
|
|
69
|
+
*
|
|
70
|
+
* The load happens when the layer is built, and a {@link ConfigFileNotFoundError}
|
|
71
|
+
* surfaces in the layer's error channel rather than degrading to an empty
|
|
72
|
+
* provider — the same honesty {@link asConfigProvider} keeps.
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* ```ts
|
|
76
|
+
* const stack = layerConfigProvider(AppConfig).pipe(Layer.provide(AppConfigLive));
|
|
77
|
+
* ```
|
|
78
|
+
*
|
|
79
|
+
* @public
|
|
80
|
+
*/
|
|
81
|
+
const layerConfigProvider = (tag, options) => {
|
|
82
|
+
const provider = Effect.flatMap(tag, asConfigProvider);
|
|
83
|
+
return ConfigProvider.layerAdd(provider, { asPrimary: options?.asPrimary });
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
//#endregion
|
|
87
|
+
export { asConfigProvider, layerConfigProvider };
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Effect, FileSystem, Option, Path } from "effect";
|
|
2
|
+
import { Walker } from "@effected/walker";
|
|
3
|
+
|
|
4
|
+
//#region src/ConfigResolver.ts
|
|
5
|
+
/** Absorb any failure into `Option.none()` — the resolver contract. */
|
|
6
|
+
const absorb = (effect) => Effect.catch(effect, () => Effect.succeed(Option.none()));
|
|
7
|
+
const cwdOf = (given) => given ?? globalThis.process?.cwd?.() ?? "/";
|
|
8
|
+
const explicitPath = (target) => ({
|
|
9
|
+
name: "explicit",
|
|
10
|
+
resolve: absorb(Effect.gen(function* () {
|
|
11
|
+
return (yield* (yield* FileSystem.FileSystem).exists(target)) ? Option.some(target) : Option.none();
|
|
12
|
+
}))
|
|
13
|
+
});
|
|
14
|
+
const staticDir = (options) => ({
|
|
15
|
+
name: "static",
|
|
16
|
+
resolve: absorb(Effect.gen(function* () {
|
|
17
|
+
const fs = yield* FileSystem.FileSystem;
|
|
18
|
+
const candidate = (yield* Path.Path).join(options.dir, options.filename);
|
|
19
|
+
return (yield* fs.exists(candidate)) ? Option.some(candidate) : Option.none();
|
|
20
|
+
}))
|
|
21
|
+
});
|
|
22
|
+
const upwardWalk = (options) => ({
|
|
23
|
+
name: "walk",
|
|
24
|
+
resolve: Effect.gen(function* () {
|
|
25
|
+
const path = yield* Path.Path;
|
|
26
|
+
const subpaths = options.subpaths ?? ["."];
|
|
27
|
+
const dirs = yield* Walker.ascend(cwdOf(options.cwd), { ...options.stopAt !== void 0 && { stopAt: options.stopAt } });
|
|
28
|
+
return yield* Walker.findUpward(dirs, (dir) => subpaths.map((sub) => path.join(dir, sub, options.filename)));
|
|
29
|
+
})
|
|
30
|
+
});
|
|
31
|
+
/**
|
|
32
|
+
* Ascend from `cwd` looking for the first directory where `isRoot` reports
|
|
33
|
+
* true, then probe `subpaths` under it. Shared by `gitRoot` and
|
|
34
|
+
* `workspaceRoot`, which differ only in how a "root" is detected.
|
|
35
|
+
*/
|
|
36
|
+
const rootAnchored = (name, isRoot, options) => ({
|
|
37
|
+
name,
|
|
38
|
+
resolve: Effect.gen(function* () {
|
|
39
|
+
const path = yield* Path.Path;
|
|
40
|
+
const dirs = yield* Walker.ascend(cwdOf(options.cwd));
|
|
41
|
+
const root = yield* Walker.findRoot(dirs, isRoot);
|
|
42
|
+
if (Option.isNone(root)) return Option.none();
|
|
43
|
+
const subpaths = options.subpaths ?? ["."];
|
|
44
|
+
return yield* Walker.findUpward([root.value], (dir) => subpaths.map((sub) => path.join(dir, sub, options.filename)));
|
|
45
|
+
})
|
|
46
|
+
});
|
|
47
|
+
/** `.git` may be a directory (a normal repo) or a file (a worktree pointing at the real repo). */
|
|
48
|
+
const isGitRoot = (dir) => Effect.gen(function* () {
|
|
49
|
+
const fs = yield* FileSystem.FileSystem;
|
|
50
|
+
const path = yield* Path.Path;
|
|
51
|
+
return yield* fs.exists(path.join(dir, ".git"));
|
|
52
|
+
});
|
|
53
|
+
/** A workspace root is marked by `pnpm-workspace.yaml`, or a `package.json` with a `workspaces` field. */
|
|
54
|
+
const isWorkspaceRoot = (dir) => Effect.gen(function* () {
|
|
55
|
+
const fs = yield* FileSystem.FileSystem;
|
|
56
|
+
const path = yield* Path.Path;
|
|
57
|
+
if (yield* fs.exists(path.join(dir, "pnpm-workspace.yaml"))) return true;
|
|
58
|
+
const pkgPath = path.join(dir, "package.json");
|
|
59
|
+
if (yield* fs.exists(pkgPath)) {
|
|
60
|
+
const content = yield* fs.readFileString(pkgPath);
|
|
61
|
+
try {
|
|
62
|
+
if ("workspaces" in JSON.parse(content)) return true;
|
|
63
|
+
} catch {}
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
});
|
|
67
|
+
const gitRoot = (options) => rootAnchored("git", isGitRoot, options);
|
|
68
|
+
const workspaceRoot = (options) => rootAnchored("workspace", isWorkspaceRoot, options);
|
|
69
|
+
const systemEtc = (options) => ({
|
|
70
|
+
name: "system",
|
|
71
|
+
resolve: absorb(Effect.gen(function* () {
|
|
72
|
+
if (globalThis.process?.platform === "win32") return Option.none();
|
|
73
|
+
const fs = yield* FileSystem.FileSystem;
|
|
74
|
+
const path = yield* Path.Path;
|
|
75
|
+
const base = options.dir ?? "/etc";
|
|
76
|
+
const candidate = path.join(base, options.app, options.filename);
|
|
77
|
+
return (yield* fs.exists(candidate)) ? Option.some(candidate) : Option.none();
|
|
78
|
+
}))
|
|
79
|
+
});
|
|
80
|
+
/**
|
|
81
|
+
* Built-in resolvers, in the order a typical chain uses them.
|
|
82
|
+
*
|
|
83
|
+
* @public
|
|
84
|
+
*/
|
|
85
|
+
const ConfigResolver = {
|
|
86
|
+
explicitPath,
|
|
87
|
+
staticDir,
|
|
88
|
+
upwardWalk,
|
|
89
|
+
workspaceRoot,
|
|
90
|
+
gitRoot,
|
|
91
|
+
systemEtc
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
//#endregion
|
|
95
|
+
export { ConfigResolver };
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { decrypt, deriveKey, encrypt, fromBase64, randomIv, toBase64 } from "./internal/crypto.js";
|
|
2
|
+
import { Duration, Effect, Exit, Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region src/EncryptedCodec.ts
|
|
5
|
+
/**
|
|
6
|
+
* Indicates that an encryption, decryption, key-derivation or base64 step
|
|
7
|
+
* failed.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* Its own error rather than v3's `"key-derivation"` value on the generic
|
|
11
|
+
* `ConfigCodecError.operation` union — an encryption-only concern was leaking
|
|
12
|
+
* into every codec's error type. `cause` preserves the underlying host failure
|
|
13
|
+
* structurally; v3 assembled it into a prose `reason` string.
|
|
14
|
+
*
|
|
15
|
+
* @public
|
|
16
|
+
*/
|
|
17
|
+
var ConfigEncryptionError = class extends Schema.TaggedErrorClass()("ConfigEncryptionError", {
|
|
18
|
+
/** Which cryptographic stage failed. */
|
|
19
|
+
phase: Schema.Literals([
|
|
20
|
+
"key-derivation",
|
|
21
|
+
"encrypt",
|
|
22
|
+
"decrypt",
|
|
23
|
+
"encoding"
|
|
24
|
+
]),
|
|
25
|
+
/** The underlying failure, preserved structurally. */
|
|
26
|
+
cause: Schema.Defect()
|
|
27
|
+
}) {
|
|
28
|
+
get message() {
|
|
29
|
+
return `Config encryption failed during ${this.phase}`;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
/** Lift `internal/crypto`'s dependency-free failure into the public error. */
|
|
33
|
+
const toPublic = (failure) => new ConfigEncryptionError({
|
|
34
|
+
phase: failure.phase,
|
|
35
|
+
cause: failure.cause
|
|
36
|
+
});
|
|
37
|
+
/**
|
|
38
|
+
* Convenience constructors for {@link (EncryptedCodecKey:type)}.
|
|
39
|
+
*
|
|
40
|
+
* @public
|
|
41
|
+
*/
|
|
42
|
+
const EncryptedCodecKey = {
|
|
43
|
+
/**
|
|
44
|
+
* Use a pre-derived `CryptoKey` effect directly.
|
|
45
|
+
*
|
|
46
|
+
* @remarks
|
|
47
|
+
* The effect is resolved once per codec instance and its **success** is
|
|
48
|
+
* reused for every encrypt/decrypt operation. A failure or an interruption
|
|
49
|
+
* is not cached — the next operation resolves it again. Supply your own
|
|
50
|
+
* `Effect.retry` inside this effect to bound retries; wrap it in
|
|
51
|
+
* `Effect.cached` yourself if you want a failure to be terminal.
|
|
52
|
+
*
|
|
53
|
+
* A `throw` from it is a programmer bug and stays a defect; signal
|
|
54
|
+
* recoverable failure with `Effect.fail`.
|
|
55
|
+
*/
|
|
56
|
+
fromCryptoKey: (key) => ({
|
|
57
|
+
_tag: "CryptoKey",
|
|
58
|
+
key
|
|
59
|
+
}),
|
|
60
|
+
/**
|
|
61
|
+
* Derive a `CryptoKey` from a passphrase and salt via PBKDF2.
|
|
62
|
+
*
|
|
63
|
+
* @remarks
|
|
64
|
+
* Derivation runs lazily on the first encrypt/decrypt call. It is resolved
|
|
65
|
+
* once per codec instance and its **success** is reused for subsequent
|
|
66
|
+
* operations on that instance. A failure or an interruption is not cached —
|
|
67
|
+
* the next operation derives again.
|
|
68
|
+
*/
|
|
69
|
+
fromPassphrase: (passphrase, salt) => ({
|
|
70
|
+
_tag: "Passphrase",
|
|
71
|
+
passphrase,
|
|
72
|
+
salt
|
|
73
|
+
})
|
|
74
|
+
};
|
|
75
|
+
/** The key effect a codec instance resolves against, before memoization. */
|
|
76
|
+
const keyEffect = (keySource) => keySource._tag === "CryptoKey" ? keySource.key : Effect.mapError(deriveKey(keySource.passphrase, keySource.salt), toPublic);
|
|
77
|
+
/**
|
|
78
|
+
* Wrap any {@link (ConfigCodec:interface)} with AES-GCM encryption.
|
|
79
|
+
*
|
|
80
|
+
* @remarks
|
|
81
|
+
* `stringify` serializes with the inner codec, generates a random 12-byte IV,
|
|
82
|
+
* encrypts, prepends the IV to the ciphertext and base64-encodes the result.
|
|
83
|
+
* `parse` reverses that: the first 12 bytes of the decoded envelope are the IV,
|
|
84
|
+
* the remainder is the ciphertext, and the plaintext is handed to the inner
|
|
85
|
+
* codec's `parse`.
|
|
86
|
+
*
|
|
87
|
+
* The error channel **widens** to `E | ConfigEncryptionError` rather than
|
|
88
|
+
* flattening — the inner codec's failures stay distinguishable from
|
|
89
|
+
* cryptographic ones.
|
|
90
|
+
*
|
|
91
|
+
* @public
|
|
92
|
+
*/
|
|
93
|
+
function EncryptedCodec(inner, keySource) {
|
|
94
|
+
const name = `encrypted(${inner.name})`;
|
|
95
|
+
const [resolveKey, invalidateKey] = Effect.runSync(Effect.cachedInvalidateWithTTL(keyEffect(keySource), Duration.infinity));
|
|
96
|
+
const getKey = Effect.onExit(resolveKey, (exit) => Exit.isSuccess(exit) ? Effect.void : invalidateKey);
|
|
97
|
+
return {
|
|
98
|
+
name,
|
|
99
|
+
parse: (raw) => Effect.gen(function* () {
|
|
100
|
+
const combined = yield* Effect.mapError(fromBase64(raw), toPublic);
|
|
101
|
+
if (combined.length <= 12) return yield* Effect.fail(new ConfigEncryptionError({
|
|
102
|
+
phase: "decrypt",
|
|
103
|
+
cause: /* @__PURE__ */ new Error("Ciphertext too short to contain IV")
|
|
104
|
+
}));
|
|
105
|
+
const key = yield* getKey;
|
|
106
|
+
const iv = combined.slice(0, 12);
|
|
107
|
+
const ciphertext = combined.slice(12);
|
|
108
|
+
const plaintext = yield* Effect.mapError(decrypt(key, iv, ciphertext), toPublic);
|
|
109
|
+
return yield* inner.parse(new TextDecoder().decode(plaintext));
|
|
110
|
+
}),
|
|
111
|
+
stringify: (value) => Effect.gen(function* () {
|
|
112
|
+
const key = yield* getKey;
|
|
113
|
+
const serialized = yield* inner.stringify(value);
|
|
114
|
+
const encoded = new TextEncoder().encode(serialized);
|
|
115
|
+
const iv = randomIv();
|
|
116
|
+
const ciphertext = yield* Effect.mapError(encrypt(key, iv, encoded), toPublic);
|
|
117
|
+
return yield* Effect.mapError(toBase64(iv, ciphertext), toPublic);
|
|
118
|
+
})
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
//#endregion
|
|
123
|
+
export { ConfigEncryptionError, EncryptedCodec, EncryptedCodecKey };
|
package/JsonCodec.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ConfigCodecError } from "./ConfigCodec.js";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region src/JsonCodec.ts
|
|
5
|
+
/**
|
|
6
|
+
* A `ConfigCodec` backed by the host `JSON` global: plain JSON as
|
|
7
|
+
* configuration file content.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* The only codec that reaches no parsing engine at all — it is why this
|
|
11
|
+
* package can be depended on for JSON config alone without pulling a parser
|
|
12
|
+
* into the bundle. Both directions preserve the underlying failure
|
|
13
|
+
* structurally in `cause` — never stringified.
|
|
14
|
+
*
|
|
15
|
+
* @public
|
|
16
|
+
*/
|
|
17
|
+
const JsonCodec = {
|
|
18
|
+
name: "json",
|
|
19
|
+
parse: (raw) => Effect.try({
|
|
20
|
+
try: () => JSON.parse(raw),
|
|
21
|
+
catch: (cause) => new ConfigCodecError({
|
|
22
|
+
codec: "json",
|
|
23
|
+
operation: "parse",
|
|
24
|
+
cause
|
|
25
|
+
})
|
|
26
|
+
}),
|
|
27
|
+
stringify: (value) => Effect.try({
|
|
28
|
+
try: () => JSON.stringify(value, null, 2),
|
|
29
|
+
catch: (cause) => new ConfigCodecError({
|
|
30
|
+
codec: "json",
|
|
31
|
+
operation: "stringify",
|
|
32
|
+
cause
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
//#endregion
|
|
38
|
+
export { JsonCodec };
|
package/JsoncCodec.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ConfigCodecError } from "./ConfigCodec.js";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { Jsonc } from "@effected/jsonc";
|
|
4
|
+
|
|
5
|
+
//#region src/JsoncCodec.ts
|
|
6
|
+
/**
|
|
7
|
+
* A `ConfigCodec` backed by `@effected/jsonc`: JSON with comments and
|
|
8
|
+
* trailing commas.
|
|
9
|
+
*
|
|
10
|
+
* @remarks
|
|
11
|
+
* `@effected/jsonc` does not expose a `stringify` — its schema layer's encode
|
|
12
|
+
* direction is `JSON.stringify` (comments never survive a round-trip encode;
|
|
13
|
+
* see `Jsonc.fromString`'s remarks), so `stringify` here calls `JSON.stringify`
|
|
14
|
+
* directly and wraps a thrown defect the same way `JsonCodec` does.
|
|
15
|
+
* Both directions preserve the underlying failure structurally in `cause` —
|
|
16
|
+
* never stringified.
|
|
17
|
+
*
|
|
18
|
+
* @public
|
|
19
|
+
*/
|
|
20
|
+
const JsoncCodec = {
|
|
21
|
+
name: "jsonc",
|
|
22
|
+
parse: (raw) => Jsonc.parse(raw).pipe(Effect.mapError((cause) => new ConfigCodecError({
|
|
23
|
+
codec: "jsonc",
|
|
24
|
+
operation: "parse",
|
|
25
|
+
cause
|
|
26
|
+
}))),
|
|
27
|
+
stringify: (value) => Effect.try({
|
|
28
|
+
try: () => JSON.stringify(value, null, 2),
|
|
29
|
+
catch: (cause) => new ConfigCodecError({
|
|
30
|
+
codec: "jsonc",
|
|
31
|
+
operation: "stringify",
|
|
32
|
+
cause
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
//#endregion
|
|
38
|
+
export { JsoncCodec };
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 C. Spencer Beggs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/MergeStrategy.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { canMerge, deepMerge } from "./internal/deepMerge.js";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region 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
|
+
/**
|
|
39
|
+
* Built-in merge strategies.
|
|
40
|
+
*
|
|
41
|
+
* @remarks
|
|
42
|
+
* Renamed from v3's `ConfigWalkStrategy`, which never walked anything — the
|
|
43
|
+
* thing that walks is the `upwardWalk` resolver.
|
|
44
|
+
*
|
|
45
|
+
* @public
|
|
46
|
+
*/
|
|
47
|
+
const MergeStrategy = {
|
|
48
|
+
firstMatch,
|
|
49
|
+
layeredMerge
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
//#endregion
|
|
53
|
+
export { MergeStrategy };
|
package/README.md
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# @effected/config-file
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@effected/config-file)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://nodejs.org/)
|
|
6
|
+
[](https://www.typescriptlang.org/)
|
|
7
|
+
|
|
8
|
+
Composable config file loading for Effect. Declare a resolver chain — an explicit path, an upward walk from the cwd, the workspace or git root, `/etc` — decode every discovered file through an Effect `Schema`, and combine the results with a merge strategy. JSON, JSONC, YAML and TOML all decode with no extra install. Codecs, resolvers and merge strategies are pluggable seams, and failures arrive as tagged errors carrying structured payloads rather than prose, so "no config anywhere" is routable separately from "the config I found is broken".
|
|
9
|
+
|
|
10
|
+
> **Pre-release.** This package is part of the `@effected/*` kit, in pre-`1.0.0`
|
|
11
|
+
> development against a single pinned Effect v4 beta. Packages graduate to
|
|
12
|
+
> `1.0.0` once Effect `4.0.0` ships. To hold your own `effect` versions at
|
|
13
|
+
> exactly the ones the kit is built and tested against, install
|
|
14
|
+
> [`@effected/pnpm-plugin-effect`](https://www.npmjs.com/package/@effected/pnpm-plugin-effect).
|
|
15
|
+
>
|
|
16
|
+
> **Stability: unstable.** This package's API surface is not yet considered
|
|
17
|
+
> complete and may change across `0.x` releases. Pin an exact version — even a
|
|
18
|
+
> package marked *stable* before `1.0.0` can introduce a breaking change by
|
|
19
|
+
> accident, and an exact pin turns that into a type-check error rather than a
|
|
20
|
+
> runtime surprise. Full policy: [release strategy](https://github.com/spencerbeggs/effected#release-strategy).
|
|
21
|
+
|
|
22
|
+
## Why @effected/config-file
|
|
23
|
+
|
|
24
|
+
Config loading is where a well-typed application usually gives up: a library finds a file, parses it, validates it, and reports every one of those distinct failures as the same opaque error with a `reason` string. This package refuses that. Discovery, reading, parsing, validation and persistence each fail with their own tagged error, and each carries its cause structurally — a `ConfigValidationError` hands you the schema issue tree, not `String(ParseError)`. Resolver requirements flow into the layer's type rather than being cast away, the merge step reports every source that contributed rather than only the first, and a loaded document can be handed to `Config` accessors as a v4 `ConfigProvider` layered beneath the environment.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm install @effected/config-file effect @effect/platform-node
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pnpm add @effected/config-file effect @effect/platform-node
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Requires Node.js >=24.11.0. Every format is covered by that one install; there is no separate package to add for YAML or TOML.
|
|
37
|
+
|
|
38
|
+
`effect` v4 is a peer dependency, and so are `@effected/jsonc`, `@effected/toml`, `@effected/walker` and `@effected/yaml` — the first-party engines behind the JSONC, YAML and TOML codecs, plus the traversal primitive the `upwardWalk`, `workspaceRoot` and `gitRoot` resolvers are built on. Package managers that install peers automatically will pull them in; add them to your manifest explicitly if yours does not. The package declares no runtime dependencies of its own, so nothing it drags into your tree comes from outside `effect` and `@effected/*`.
|
|
39
|
+
|
|
40
|
+
Reading and writing files needs a `FileSystem` and a `Path` implementation, provided once at the edge — from `@effect/platform-node` on Node.
|
|
41
|
+
|
|
42
|
+
## Quick start
|
|
43
|
+
|
|
44
|
+
Declare a schema, mint a service class for it with `ConfigFile.Service`, and build its live layer with `ConfigFile.layer`. The platform layers are provided once, at the edge:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { ConfigFile, ConfigResolver, JsonCodec, MergeStrategy } from "@effected/config-file";
|
|
48
|
+
import { NodeFileSystem, NodePath } from "@effect/platform-node";
|
|
49
|
+
import { Effect, Layer, Schema } from "effect";
|
|
50
|
+
|
|
51
|
+
class AppShape extends Schema.Class<AppShape>("AppShape")({
|
|
52
|
+
port: Schema.Number,
|
|
53
|
+
host: Schema.String,
|
|
54
|
+
}) {}
|
|
55
|
+
|
|
56
|
+
class AppConfig extends ConfigFile.Service<AppConfig, AppShape>()("app/Config") {}
|
|
57
|
+
|
|
58
|
+
const AppConfigLive = ConfigFile.layer(AppConfig, {
|
|
59
|
+
schema: AppShape,
|
|
60
|
+
codec: JsonCodec,
|
|
61
|
+
resolvers: [ConfigResolver.upwardWalk({ filename: ".apprc" })],
|
|
62
|
+
strategy: MergeStrategy.firstMatch<AppShape>(),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const program = Effect.gen(function* () {
|
|
66
|
+
const config = yield* AppConfig;
|
|
67
|
+
return yield* config.load;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const PlatformLive = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer);
|
|
71
|
+
|
|
72
|
+
Effect.runPromise(program.pipe(Effect.provide(AppConfigLive), Effect.provide(PlatformLive))).then(console.log);
|
|
73
|
+
// AppShape { port: 3000, host: "localhost" }
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`ConfigFile.layer` is a layer-returning *function*, not a layer: calling it twice builds two independent service instances. Bind its result to a const, as above, and provide that const.
|
|
77
|
+
|
|
78
|
+
Resolvers are consulted in priority order, highest first. `MergeStrategy.firstMatch` takes the winner; `MergeStrategy.layeredMerge` deep-merges every source that matched, with higher-priority keys overwriting lower ones:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import { ConfigFile, ConfigResolver, MergeStrategy, YamlCodec } from "@effected/config-file";
|
|
82
|
+
import { Schema } from "effect";
|
|
83
|
+
|
|
84
|
+
class Settings extends Schema.Class<Settings>("Settings")({ port: Schema.Number }) {}
|
|
85
|
+
class SettingsConfig extends ConfigFile.Service<SettingsConfig, Settings>()("app/Settings") {}
|
|
86
|
+
|
|
87
|
+
export const SettingsLive = ConfigFile.layer(SettingsConfig, {
|
|
88
|
+
schema: Settings,
|
|
89
|
+
codec: YamlCodec,
|
|
90
|
+
resolvers: [
|
|
91
|
+
ConfigResolver.upwardWalk({ filename: ".apprc.yaml" }),
|
|
92
|
+
ConfigResolver.workspaceRoot({ filename: ".apprc.yaml" }),
|
|
93
|
+
ConfigResolver.systemEtc({ app: "myapp", filename: "config.yaml" }),
|
|
94
|
+
],
|
|
95
|
+
strategy: MergeStrategy.layeredMerge<Settings>(),
|
|
96
|
+
});
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Errors
|
|
100
|
+
|
|
101
|
+
Every failure is a tagged error you route on with `Effect.catchTag`. The tags exist so that recovery can differ:
|
|
102
|
+
|
|
103
|
+
| Tag | Means | Recovery |
|
|
104
|
+
| --- | --- | --- |
|
|
105
|
+
| `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. |
|
|
106
|
+
| `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. |
|
|
107
|
+
| `ConfigFileWriteError` | A file could not be written. Carries `path` and the structural `cause`. | Retry elsewhere, or surface to the user. |
|
|
108
|
+
| `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. |
|
|
109
|
+
| `ConfigValidationError` | The document did not satisfy the schema, or a caller-supplied `validate` rejected it. Carries the structured `issue` tree and an optional `path`. | Report the issue; do not run on config you could not validate. |
|
|
110
|
+
| `ConfigCodecError` | The codec could not parse or stringify. Carries `codec`, `operation` and the structural `cause`. | The file is corrupt. Report the path and the cause. |
|
|
111
|
+
| `ConfigMigrationError` | A versioned migration failed. Carries `version`, `name`, `phase` and the structural `cause`. | Report which step failed; the config on disk is left untouched. |
|
|
112
|
+
| `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`. |
|
|
113
|
+
|
|
114
|
+
`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:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
import type { ConfigFileShape } from "@effected/config-file";
|
|
118
|
+
import { Effect, Schema } from "effect";
|
|
119
|
+
|
|
120
|
+
class AppShape extends Schema.Class<AppShape>("AppShape")({ port: Schema.Number }) {}
|
|
121
|
+
|
|
122
|
+
const fallback = new AppShape({ port: 3000 });
|
|
123
|
+
|
|
124
|
+
// `load` fails with ConfigLoadError. Handling the not-found tag leaves
|
|
125
|
+
// ConfigReadError — the file-is-broken failures, which we let propagate.
|
|
126
|
+
export const loadOrFallback = (config: ConfigFileShape<AppShape>) =>
|
|
127
|
+
config.load.pipe(Effect.catchTag("ConfigFileNotFoundError", () => Effect.succeed(fallback)));
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Codecs
|
|
131
|
+
|
|
132
|
+
Four codecs ship in the package, each a free-standing named export:
|
|
133
|
+
|
|
134
|
+
| Codec | Format | Engine |
|
|
135
|
+
| ----- | ------ | ------ |
|
|
136
|
+
| `JsonCodec` | JSON | the host `JSON` global, no parser at all |
|
|
137
|
+
| `JsoncCodec` | JSONC | `@effected/jsonc` |
|
|
138
|
+
| `YamlCodec` | YAML | `@effected/yaml` |
|
|
139
|
+
| `TomlCodec` | TOML | `@effected/toml` |
|
|
140
|
+
|
|
141
|
+
One install covers every format, and you still pay only for the parser you name. The codecs are free-standing exports rather than properties of a namespace object, so importing `TomlCodec` never references the YAML or JSONC bindings, their parsing engines are unreachable from your entrypoint and a bundler drops them. A JSON-only application ships no parser at all.
|
|
142
|
+
|
|
143
|
+
Codecs compose. `EncryptedCodec` wraps any codec with AES-GCM, and `ConfigMigration.make` wraps any codec so parsed content is brought up to the latest version. Each *widens* the error channel rather than flattening its failures into the inner codec's error:
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
import { ConfigMigration, EncryptedCodec, EncryptedCodecKey, JsonCodec } from "@effected/config-file";
|
|
147
|
+
import { Effect } from "effect";
|
|
148
|
+
|
|
149
|
+
const migrating = ConfigMigration.make({
|
|
150
|
+
codec: JsonCodec,
|
|
151
|
+
migrations: [
|
|
152
|
+
{
|
|
153
|
+
version: 2,
|
|
154
|
+
name: "add-port",
|
|
155
|
+
up: (raw) => Effect.succeed({ ...(raw as Record<string, unknown>), port: 8080 }),
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// `parse` now fails with ConfigCodecError | ConfigMigrationError | ConfigEncryptionError.
|
|
161
|
+
export const secret = EncryptedCodec(migrating, EncryptedCodecKey.fromPassphrase("hunter2", new Uint8Array(16)));
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Features
|
|
165
|
+
|
|
166
|
+
- `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.
|
|
167
|
+
- `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.
|
|
168
|
+
- `MergeStrategy` — `firstMatch` and `layeredMerge`, combining discovered sources in priority order.
|
|
169
|
+
- `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.
|
|
170
|
+
- `ConfigCodec` / `EncryptedCodec` / `ConfigMigration` — a pluggable codec seam, generic in its error type so decorators widen rather than flatten. `ConfigCodec` is the interface: bring your own format by satisfying it.
|
|
171
|
+
- `ConfigEvents` — an opt-in `PubSub` of `ConfigEvent`, honestly zero-cost when omitted: no `events` option means no context lookup at all. Failure events carry the structured typed error, never a `reason` string.
|
|
172
|
+
- `asConfigProvider` / `layerConfigProvider` — expose a loaded, validated document as a v4 `ConfigProvider`, layered beneath the ambient one so an environment variable overrides the file it was deployed with.
|
|
173
|
+
|
|
174
|
+
## License
|
|
175
|
+
|
|
176
|
+
[MIT](LICENSE)
|
package/TomlCodec.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { ConfigCodecError } from "./ConfigCodec.js";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { Toml } from "@effected/toml";
|
|
4
|
+
|
|
5
|
+
//#region src/TomlCodec.ts
|
|
6
|
+
/**
|
|
7
|
+
* A `ConfigCodec` backed by `@effected/toml`.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* `@effected/toml`'s input hardening — a nesting-depth cap on arrays and
|
|
11
|
+
* inline tables, enforced independently on both the parse and stringify
|
|
12
|
+
* sides — fails through the typed error channel, so a hostile config file
|
|
13
|
+
* surfaces as a `ConfigCodecError` rather than crashing the process. Both
|
|
14
|
+
* directions preserve the underlying failure structurally in `cause` —
|
|
15
|
+
* never stringified.
|
|
16
|
+
*
|
|
17
|
+
* Stringify is genuinely fallible beyond hostile input: TOML has no null,
|
|
18
|
+
* so a document carrying `null` (or any other unrepresentable value, an
|
|
19
|
+
* out-of-int64-range `bigint`, a circular reference) fails with a
|
|
20
|
+
* `ConfigCodecError` whose `cause` is the structured `TomlStringifyError`.
|
|
21
|
+
*
|
|
22
|
+
* @public
|
|
23
|
+
*/
|
|
24
|
+
const TomlCodec = {
|
|
25
|
+
name: "toml",
|
|
26
|
+
parse: (raw) => Toml.parse(raw).pipe(Effect.mapError((cause) => new ConfigCodecError({
|
|
27
|
+
codec: "toml",
|
|
28
|
+
operation: "parse",
|
|
29
|
+
cause
|
|
30
|
+
}))),
|
|
31
|
+
stringify: (value) => Toml.stringify(value).pipe(Effect.mapError((cause) => new ConfigCodecError({
|
|
32
|
+
codec: "toml",
|
|
33
|
+
operation: "stringify",
|
|
34
|
+
cause
|
|
35
|
+
})))
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
//#endregion
|
|
39
|
+
export { TomlCodec };
|