@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.
@@ -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.
@@ -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
+ [![npm](https://img.shields.io/npm/v/@effected%2Fconfig-file?label=npm&color=cb3837)](https://www.npmjs.com/package/@effected/config-file)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-4caf50.svg)](https://opensource.org/licenses/MIT)
5
+ [![Node.js %3E%3D24.11.0](https://img.shields.io/badge/Node.js-%3E%3D24.11.0-5fa04e.svg)](https://nodejs.org/)
6
+ [![TypeScript 6.0](https://img.shields.io/badge/TypeScript-6.0-3178c6.svg)](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 };