@effected/config-file 0.18.0 → 0.19.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/ConfigEvent.js CHANGED
@@ -30,6 +30,13 @@ const ConfigSourceRef = Schema.Struct({
30
30
  * `reason` string, so every field a subscriber might branch on survives; a
31
31
  * subscriber that wants prose can read `error.message`.
32
32
  *
33
+ * `error` is typed `unknown` (`Schema.Defect()`) on purpose. `ParseFailed` and
34
+ * `StringifyFailed` carry whatever the service's codec failed with, and under
35
+ * a decorated codec that is not always a `ConfigCodecError`: an
36
+ * `EncryptedCodec` failure arrives as `ConfigEncryptionError`, a migration
37
+ * failure as `ConfigMigrationError`. Narrow by `_tag` or `instanceof`, never
38
+ * by assuming the codec error.
39
+ *
33
40
  * @public
34
41
  */
35
42
  const ConfigEventPayload = Schema.Union([
package/ConfigFile.js CHANGED
@@ -292,8 +292,13 @@ const makeImpl = (options, fs, path, resolverEnv) => {
292
292
  * `target` is the file this text is destined for, when there is one. It is
293
293
  * only ever used to NAME the file in an error — `encode` passes `none` and
294
294
  * its errors honestly carry no path.
295
+ *
296
+ * `onCodecFailure` observes a failure of the codec's `stringify` step and
297
+ * nothing else. The codec's error type is the caller's `CE`, which may be a
298
+ * decorator's widened union, so a codec failure cannot be recognized by tag
299
+ * after the fact — it is observed where it happens.
295
300
  */
296
- const encodeTo = (value, target, encodeOptions) => Effect.gen(function* () {
301
+ const encodeTo = (value, target, encodeOptions, onCodecFailure) => Effect.gen(function* () {
297
302
  const encoded = yield* Schema.encodeEffect(options.schema)(value).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(new ConfigValidationError({
298
303
  path: target,
299
304
  issue: error.issue
@@ -301,7 +306,7 @@ const makeImpl = (options, fs, path, resolverEnv) => {
301
306
  const serialized = yield* options.codec.stringify(encoded).pipe(Effect.mapError((error) => Option.match(target, {
302
307
  onNone: () => error,
303
308
  onSome: (file) => withCodecPath(error, file)
304
- })));
309
+ })), Effect.tapError(onCodecFailure));
305
310
  return prependHeader(serialized, encodeOptions?.header);
306
311
  });
307
312
  /**
@@ -312,18 +317,18 @@ const makeImpl = (options, fs, path, resolverEnv) => {
312
317
  * failed writes must not see a dry run's codec failure.
313
318
  */
314
319
  const encodeAndWrite = (value, target, encodeOptions) => Effect.gen(function* () {
315
- const serialized = yield* encodeTo(value, Option.some(target), encodeOptions).pipe(Effect.tapError((error) => error._tag === "ConfigCodecError" ? emit({
320
+ const serialized = yield* encodeTo(value, Option.some(target), encodeOptions, (error) => emit({
316
321
  _tag: "StringifyFailed",
317
322
  codec: options.codec.name,
318
323
  error
319
- }) : Effect.void));
324
+ }));
320
325
  yield* fs.writeFileString(target, serialized).pipe(Effect.mapError((cause) => new ConfigFileWriteError({
321
326
  path: target,
322
327
  cause
323
328
  })));
324
329
  });
325
330
  const encode = Effect.fn("ConfigFile.encode")(function* (value, encodeOptions) {
326
- return yield* encodeTo(value, Option.none(), encodeOptions);
331
+ return yield* encodeTo(value, Option.none(), encodeOptions, () => Effect.void);
327
332
  });
328
333
  /**
329
334
  * Resolve `defaultPath`, `mkdir -p` its parent and write. Shared by `save`
@@ -447,9 +452,23 @@ var ConfigFile = class {
447
452
  /**
448
453
  * Create a uniquely-keyed service class for one config schema.
449
454
  *
455
+ * @remarks
456
+ * The optional third type argument is the codec's error channel. Omit it
457
+ * for the built-in codecs. A service over a decorated codec names the
458
+ * decorator's widened error, because the key is declared before the codec
459
+ * is chosen and cannot infer it: wiring `EncryptedCodec(JsonCodec, key)`
460
+ * into a key declared without it is a compile error, since that service's
461
+ * methods would fail with a `ConfigEncryptionError` its type never mentions.
462
+ *
450
463
  * @example
451
464
  * ```ts
452
465
  * class AppConfig extends ConfigFile.Service<AppConfig, AppShape>()("app/Config") {}
466
+ *
467
+ * class Secrets extends ConfigFile.Service<
468
+ * Secrets,
469
+ * SecretsShape,
470
+ * ConfigCodecError | ConfigEncryptionError
471
+ * >()("app/Secrets") {}
453
472
  * ```
454
473
  */
455
474
  static Service = Service;
@@ -88,7 +88,11 @@ var ConfigMigration = class {
88
88
  * The returned codec's error channel **widens** to include
89
89
  * {@link ConfigMigrationError} rather than flattening migration failures into
90
90
  * the inner codec's error — the reason the {@link (ConfigCodec:interface)} seam is generic
91
- * in its error type.
91
+ * in its error type. The inner codec's own error type is kept as-is, so
92
+ * wrapping an `EncryptedCodec` yields
93
+ * `ConfigCodec<ConfigCodecError | ConfigEncryptionError | ConfigMigrationError>`,
94
+ * and the result goes straight into `ConfigFile.layer`, `testLayer` or
95
+ * `read`.
92
96
  *
93
97
  * @example
94
98
  * ```ts
package/ConfigProvider.js CHANGED
@@ -42,6 +42,9 @@ import { ConfigProvider, Effect } from "effect";
42
42
  * Descent by own property is also why a prototype getter and `__proto__` are
43
43
  * both unreachable through the returned provider.
44
44
  *
45
+ * `CE` is the service's codec error, so a service over a decorated codec
46
+ * keeps its widened failure in this effect's error channel.
47
+ *
45
48
  * @example
46
49
  * ```ts
47
50
  * import { asConfigProvider } from "@effected/config-file";
@@ -73,7 +76,8 @@ const asConfigProvider = (service) => Effect.map(service.load, (value) => Config
73
76
  *
74
77
  * The load happens when the layer is built, and a {@link ConfigFileNotFoundError}
75
78
  * surfaces in the layer's error channel rather than degrading to an empty
76
- * provider — the same honesty {@link asConfigProvider} keeps.
79
+ * provider — the same honesty {@link asConfigProvider} keeps. A service over a
80
+ * decorated codec carries its widened codec error `CE` there too.
77
81
  *
78
82
  * @example
79
83
  * ```ts
package/ConfigResolver.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Effect, FileSystem, Option, Path } from "effect";
1
+ import { Effect, FileSystem, Option, Path, Schema } from "effect";
2
2
  import { Walker } from "@effected/walker";
3
3
 
4
4
  // src/ConfigResolver.ts
@@ -104,18 +104,25 @@ const isGitRoot = (dir) => Effect.gen(function* () {
104
104
  const path = yield* Path.Path;
105
105
  return yield* fs.exists(path.join(dir, ".git"));
106
106
  });
107
+ /**
108
+ * Decode a `package.json` text to `Some` exactly when it is a JSON object
109
+ * carrying a `workspaces` key, whatever that key's value (`null` included).
110
+ *
111
+ * @remarks
112
+ * Field presence is all a root check needs, so the struct is
113
+ * `{ workspaces: Unknown }` rather than a `package-json` dependency. It is
114
+ * built once at module scope; `decodeUnknownOption` never throws, which is
115
+ * what keeps a malformed manifest from leaking a defect through a predicate
116
+ * whose error channel is `PlatformError`.
117
+ */
118
+ const decodeWorkspacesManifest = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Struct({ workspaces: Schema.Unknown })));
107
119
  /** A workspace root is marked by `pnpm-workspace.yaml`, or a `package.json` with a `workspaces` field. */
108
120
  const isWorkspaceRoot = (dir) => Effect.gen(function* () {
109
121
  const fs = yield* FileSystem.FileSystem;
110
122
  const path = yield* Path.Path;
111
123
  if (yield* fs.exists(path.join(dir, "pnpm-workspace.yaml"))) return true;
112
124
  const pkgPath = path.join(dir, "package.json");
113
- if (yield* fs.exists(pkgPath)) {
114
- const content = yield* fs.readFileString(pkgPath);
115
- try {
116
- if ("workspaces" in JSON.parse(content)) return true;
117
- } catch {}
118
- }
125
+ if (yield* fs.exists(pkgPath)) return Option.isSome(decodeWorkspacesManifest(yield* fs.readFileString(pkgPath)));
119
126
  return false;
120
127
  });
121
128
  const gitRoot = (options) => rootAnchored("git", isGitRoot, options);
package/EncryptedCodec.js CHANGED
@@ -83,9 +83,17 @@ const keyEffect = (keySource) => keySource._tag === "CryptoKey" ? keySource.key
83
83
  * the remainder is the ciphertext, and the plaintext is handed to the inner
84
84
  * codec's `parse`.
85
85
  *
86
+ * The envelope is decoded with core's `effect/encoding/Base64`. Whitespace
87
+ * around it (a trailing newline, a BOM) is tolerated; whitespace inside it, or
88
+ * missing padding, fails with phase `"encoding"`. `stringify` never produces
89
+ * either.
90
+ *
86
91
  * The error channel **widens** to `E | ConfigEncryptionError` rather than
87
92
  * flattening — the inner codec's failures stay distinguishable from
88
- * cryptographic ones.
93
+ * cryptographic ones. The result plugs straight into `ConfigFile.layer`,
94
+ * `ConfigFile.testLayer` and `ConfigFile.read`, whose error unions widen with
95
+ * it; a `ConfigFile.Service` key names the widened error as its third type
96
+ * argument.
89
97
  *
90
98
  * @example
91
99
  * ```ts
@@ -119,9 +127,9 @@ function EncryptedCodec(inner, keySource) {
119
127
  const key = yield* getKey;
120
128
  const serialized = yield* inner.stringify(value);
121
129
  const encoded = new TextEncoder().encode(serialized);
122
- const iv = randomIv();
130
+ const iv = yield* Effect.mapError(randomIv, toPublic);
123
131
  const ciphertext = yield* Effect.mapError(encrypt(key, iv, encoded), toPublic);
124
- return yield* Effect.mapError(toBase64(iv, ciphertext), toPublic);
132
+ return toBase64(iv, ciphertext);
125
133
  })
126
134
  };
127
135
  }
package/README.md CHANGED
@@ -187,11 +187,20 @@ Four codecs ship in the package, each a free-standing named export:
187
187
 
188
188
  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.
189
189
 
190
- 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:
190
+ 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, and a decorated codec plugs into `ConfigFile.layer`, `ConfigFile.testLayer` and `ConfigFile.read` like any other: their error unions widen with it. A service key names the widened error as its third type argument:
191
191
 
192
192
  ```ts
193
- import { ConfigMigration, EncryptedCodec, EncryptedCodecKey, JsonCodec } from "@effected/config-file";
194
- import { Effect } from "effect";
193
+ import type { ConfigCodecError, ConfigEncryptionError, ConfigMigrationError } from "@effected/config-file";
194
+ import {
195
+ ConfigFile,
196
+ ConfigMigration,
197
+ ConfigResolver,
198
+ EncryptedCodec,
199
+ EncryptedCodecKey,
200
+ JsonCodec,
201
+ MergeStrategy,
202
+ } from "@effected/config-file";
203
+ import { Effect, Schema } from "effect";
195
204
 
196
205
  const migrating = ConfigMigration.make({
197
206
  codec: JsonCodec,
@@ -206,8 +215,24 @@ const migrating = ConfigMigration.make({
206
215
 
207
216
  // `parse` now fails with ConfigCodecError | ConfigMigrationError | ConfigEncryptionError.
208
217
  export const secret = EncryptedCodec(migrating, EncryptedCodecKey.fromPassphrase("hunter2", new Uint8Array(16)));
218
+
219
+ const Settings = Schema.Struct({ port: Schema.Number });
220
+ type SecretError = ConfigCodecError | ConfigMigrationError | ConfigEncryptionError;
221
+
222
+ class SecretSettings extends ConfigFile.Service<SecretSettings, typeof Settings.Type, SecretError>()(
223
+ "app/SecretSettings",
224
+ ) {}
225
+
226
+ export const SecretSettingsLive = ConfigFile.layer(SecretSettings, {
227
+ schema: Settings,
228
+ codec: secret,
229
+ resolvers: [ConfigResolver.explicitPath("./settings.enc")],
230
+ strategy: MergeStrategy.firstMatch(),
231
+ });
209
232
  ```
210
233
 
234
+ Leave the third type argument off and the `ConfigFile.layer` call is a compile error: that service's methods would fail with a `ConfigEncryptionError` its type never names. The envelope's base64 is decoded by core's `effect/encoding/Base64`, so whitespace around it is tolerated while whitespace inside it, or missing padding, fails with `ConfigEncryptionError` phase `"encoding"`.
235
+
211
236
  ## Features
212
237
 
213
238
  - `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.
package/index.d.ts CHANGED
@@ -95,6 +95,13 @@ export declare const ConfigSourceRef: Schema.Struct<{
95
95
  * `reason` string, so every field a subscriber might branch on survives; a
96
96
  * subscriber that wants prose can read `error.message`.
97
97
  *
98
+ * `error` is typed `unknown` (`Schema.Defect()`) on purpose. `ParseFailed` and
99
+ * `StringifyFailed` carry whatever the service's codec failed with, and under
100
+ * a decorated codec that is not always a `ConfigCodecError`: an
101
+ * `EncryptedCodec` failure arrives as `ConfigEncryptionError`, a migration
102
+ * failure as `ConfigMigrationError`. Narrow by `_tag` or `instanceof`, never
103
+ * by assuming the codec error.
104
+ *
98
105
  * @public
99
106
  */
100
107
  export declare const ConfigEventPayload: Schema.Union<readonly [Schema.TaggedStruct<"Discovered", {
@@ -637,30 +644,39 @@ export declare class ConfigValidationError extends ConfigValidationError_base {
637
644
  /**
638
645
  * The failure modes of the full discovery-and-load path.
639
646
  *
647
+ * @remarks
648
+ * `CE` is the codec's error channel. It defaults to {@link ConfigCodecError},
649
+ * the error of the four built-in codecs; a decorated codec widens it. Under
650
+ * `EncryptedCodec(JsonCodec, key)` it is
651
+ * `ConfigCodecError | ConfigEncryptionError`, and the union widens with it
652
+ * rather than flattening the decorator's failure into a codec error.
653
+ *
640
654
  * @public
641
655
  */
642
- type ConfigLoadError = ConfigFileNotFoundError | ConfigFileReadError | ConfigCodecError | ConfigValidationError;
656
+ type ConfigLoadError<CE = ConfigCodecError> = ConfigFileNotFoundError | ConfigFileReadError | CE | ConfigValidationError;
643
657
  /**
644
658
  * The failure modes of reading one known path.
645
659
  *
646
660
  * @remarks
647
661
  * Deliberately excludes {@link ConfigFileNotFoundError}: every method typed with
648
662
  * this union either takes an explicit path or treats "nothing found" as success.
663
+ * `CE` is the codec's error channel, as on {@link ConfigLoadError}.
649
664
  *
650
665
  * @public
651
666
  */
652
- type ConfigReadError = ConfigFileReadError | ConfigCodecError | ConfigValidationError;
667
+ type ConfigReadError<CE = ConfigCodecError> = ConfigFileReadError | CE | ConfigValidationError;
653
668
  /**
654
669
  * The failure modes of encoding and writing one known path.
655
670
  *
656
671
  * @remarks
657
672
  * Deliberately excludes {@link ConfigFileNotFoundError} — the path is explicit,
658
673
  * so there is nothing to discover — and {@link ConfigDefaultPathMissingError},
659
- * because no default path is consulted.
674
+ * because no default path is consulted. `CE` is the codec's error channel, as
675
+ * on {@link ConfigLoadError}.
660
676
  *
661
677
  * @public
662
678
  */
663
- type ConfigWriteError = ConfigFileWriteError | ConfigCodecError | ConfigValidationError;
679
+ type ConfigWriteError<CE = ConfigCodecError> = ConfigFileWriteError | CE | ConfigValidationError;
664
680
  /**
665
681
  * The failure modes of {@link ConfigFileShape.encode}: everything on the write
666
682
  * path except the write itself.
@@ -668,10 +684,11 @@ type ConfigWriteError = ConfigFileWriteError | ConfigCodecError | ConfigValidati
668
684
  * @remarks
669
685
  * Deliberately excludes {@link ConfigFileWriteError} — nothing touches the
670
686
  * filesystem — so a `--dry-run` caller's error channel is honest about that.
687
+ * `CE` is the codec's error channel, as on {@link ConfigLoadError}.
671
688
  *
672
689
  * @public
673
690
  */
674
- type ConfigEncodeError = ConfigCodecError | ConfigValidationError;
691
+ type ConfigEncodeError<CE = ConfigCodecError> = CE | ConfigValidationError;
675
692
  /**
676
693
  * Options shared by {@link ConfigFileShape.encode} and {@link ConfigFileShape.write}.
677
694
  *
@@ -694,32 +711,40 @@ interface ConfigEncodeOptions {
694
711
  readonly header?: string;
695
712
  }
696
713
  /**
697
- * The failure modes of {@link ConfigFileShape.save}.
714
+ * The failure modes of {@link ConfigFileShape.save}. `CE` is the codec's error
715
+ * channel, as on {@link ConfigLoadError}.
698
716
  *
699
717
  * @public
700
718
  */
701
- type ConfigSaveError = ConfigWriteError | ConfigDefaultPathMissingError;
719
+ type ConfigSaveError<CE = ConfigCodecError> = ConfigWriteError<CE> | ConfigDefaultPathMissingError;
702
720
  /**
703
- * The failure modes of {@link ConfigFileShape.update}, which loads and then saves.
721
+ * The failure modes of {@link ConfigFileShape.update}, which loads and then
722
+ * saves. `CE` is the codec's error channel, as on {@link ConfigLoadError}.
704
723
  *
705
724
  * @public
706
725
  */
707
- type ConfigUpdateError = ConfigLoadError | ConfigFileWriteError | ConfigDefaultPathMissingError;
726
+ type ConfigUpdateError<CE = ConfigCodecError> = ConfigLoadError<CE> | ConfigFileWriteError | ConfigDefaultPathMissingError;
708
727
  /**
709
- * The config file service, generic over the decoded config type `A`.
728
+ * The config file service, generic over the decoded config type `A` and the
729
+ * codec's error channel `CE`.
710
730
  *
711
731
  * @remarks
712
732
  * Error unions are narrowed per method: `loadOrDefault` cannot fail with
713
733
  * {@link ConfigFileNotFoundError} because that is the branch it handles, and
714
734
  * `discover` treats an empty result as success.
715
735
  *
736
+ * `CE` defaults to {@link ConfigCodecError}, so a service over a built-in
737
+ * codec never names it. A service over a decorated codec —
738
+ * `EncryptedCodec(...)`, `ConfigMigration.make(...)` — names the decorator's
739
+ * widened error here, and every codec-facing method carries it.
740
+ *
716
741
  * @public
717
742
  */
718
- interface ConfigFileShape<A> {
743
+ interface ConfigFileShape<A, CE = ConfigCodecError> {
719
744
  /** Discover, decode and merge the highest-priority config source. */
720
- readonly load: Effect.Effect<A, ConfigLoadError>;
745
+ readonly load: Effect.Effect<A, ConfigLoadError<CE>>;
721
746
  /** Read, decode and validate one explicit path. */
722
- readonly loadFrom: (path: string) => Effect.Effect<A, ConfigReadError>;
747
+ readonly loadFrom: (path: string) => Effect.Effect<A, ConfigReadError<CE>>;
723
748
  /**
724
749
  * Every source the resolver chain found, in priority order. Empty is success.
725
750
  *
@@ -728,7 +753,7 @@ interface ConfigFileShape<A> {
728
753
  * being silently skipped: silently skipping a corrupt file would mean running
729
754
  * on the wrong config. This is deliberate.
730
755
  */
731
- readonly discover: Effect.Effect<ReadonlyArray<ConfigSource<A>>, ConfigReadError>;
756
+ readonly discover: Effect.Effect<ReadonlyArray<ConfigSource<A>>, ConfigReadError<CE>>;
732
757
  /**
733
758
  * Like {@link ConfigFileShape.load}, but yields `defaultValue` when nothing is found.
734
759
  *
@@ -736,7 +761,7 @@ interface ConfigFileShape<A> {
736
761
  * `defaultValue` is returned as-is: neither the schema nor `options.validate`
737
762
  * is applied to it. It is trusted caller input, not a discovered document.
738
763
  */
739
- readonly loadOrDefault: (defaultValue: A) => Effect.Effect<A, ConfigReadError>;
764
+ readonly loadOrDefault: (defaultValue: A) => Effect.Effect<A, ConfigReadError<CE>>;
740
765
  /** Decode and validate an in-memory value. */
741
766
  readonly validate: (value: unknown) => Effect.Effect<A, ConfigValidationError>;
742
767
  /**
@@ -749,7 +774,7 @@ interface ConfigFileShape<A> {
749
774
  * `path`, and a validation failure's `path` is `Option.none()`: there is no
750
775
  * file to name.
751
776
  */
752
- readonly encode: (value: A, options?: ConfigEncodeOptions) => Effect.Effect<string, ConfigEncodeError>;
777
+ readonly encode: (value: A, options?: ConfigEncodeOptions) => Effect.Effect<string, ConfigEncodeError<CE>>;
753
778
  /**
754
779
  * Encode `value` and write it to an explicit `path`.
755
780
  *
@@ -759,12 +784,12 @@ interface ConfigFileShape<A> {
759
784
  * `write` targets a path the caller already vouched for. `options` are the
760
785
  * same as {@link ConfigFileShape.encode}'s, so the two stay in lockstep.
761
786
  */
762
- readonly write: (value: A, path: string, options?: ConfigEncodeOptions) => Effect.Effect<void, ConfigWriteError>;
787
+ readonly write: (value: A, path: string, options?: ConfigEncodeOptions) => Effect.Effect<void, ConfigWriteError<CE>>;
763
788
  /**
764
789
  * Resolve `defaultPath`, `mkdir -p` its parent, encode `value` into it, and
765
790
  * return the path written.
766
791
  */
767
- readonly save: (value: A) => Effect.Effect<string, ConfigSaveError>;
792
+ readonly save: (value: A) => Effect.Effect<string, ConfigSaveError<CE>>;
768
793
  /**
769
794
  * Load the current value, apply `fn`, {@link ConfigFileShape.save} the result
770
795
  * and return it.
@@ -773,7 +798,7 @@ interface ConfigFileShape<A> {
773
798
  * With `defaultValue` the load cannot fail with
774
799
  * {@link ConfigFileNotFoundError}; without it, it can.
775
800
  */
776
- readonly update: (fn: (current: A) => A, defaultValue?: A) => Effect.Effect<A, ConfigUpdateError>;
801
+ readonly update: (fn: (current: A) => A, defaultValue?: A) => Effect.Effect<A, ConfigUpdateError<CE>>;
777
802
  }
778
803
  /**
779
804
  * Options for {@link ConfigFile.layer}.
@@ -782,9 +807,13 @@ interface ConfigFileShape<A> {
782
807
  * `RR` is the union of the resolvers' requirements. It flows into the layer's
783
808
  * `R` rather than being cast away.
784
809
  *
810
+ * `CE` is the codec's error channel, inferred from `codec`. It defaults to
811
+ * {@link ConfigCodecError}; a decorated codec widens it, and the service's
812
+ * error unions widen with it.
813
+ *
785
814
  * @public
786
815
  */
787
- interface ConfigFileOptions<A, I, RR> {
816
+ interface ConfigFileOptions<A, I, RR, CE = ConfigCodecError> {
788
817
  /**
789
818
  * The schema every discovered document is decoded through.
790
819
  *
@@ -795,8 +824,15 @@ interface ConfigFileOptions<A, I, RR> {
795
824
  * requirements.
796
825
  */
797
826
  readonly schema: Schema.Codec<A, I>;
798
- /** How file content becomes an unknown document, and back. */
799
- readonly codec: ConfigCodec;
827
+ /**
828
+ * How file content becomes an unknown document, and back.
829
+ *
830
+ * @remarks
831
+ * Any `ConfigCodec<CE>`, decorated ones included: an
832
+ * `EncryptedCodec(JsonCodec, key)` or a `ConfigMigration.make(...)` codec
833
+ * goes here directly.
834
+ */
835
+ readonly codec: ConfigCodec<CE>;
800
836
  /** The resolver chain, in priority order. */
801
837
  readonly resolvers: ReadonlyArray<ConfigResolver<RR>>;
802
838
  /** How several discovered sources become one value. */
@@ -863,13 +899,21 @@ interface ConfigFileOptions<A, I, RR> {
863
899
  * {@link ConfigDefaultPathMissingError} under this layer — the honest answer.
864
900
  * Exercise the write path with {@link ConfigFile.layer} instead.
865
901
  *
902
+ * `CE` is the codec's error channel, as on {@link ConfigFileOptions}.
903
+ *
866
904
  * @public
867
905
  */
868
- interface ConfigFileTestOptions<A, I> {
906
+ interface ConfigFileTestOptions<A, I, CE = ConfigCodecError> {
869
907
  /** The schema every seeded document is decoded through. */
870
908
  readonly schema: Schema.Codec<A, I>;
871
- /** How file content becomes an unknown document, and back. */
872
- readonly codec: ConfigCodec;
909
+ /**
910
+ * How file content becomes an unknown document, and back.
911
+ *
912
+ * @remarks
913
+ * Any `ConfigCodec<CE>`, decorated ones included, exactly as on
914
+ * {@link ConfigFileOptions.codec}.
915
+ */
916
+ readonly codec: ConfigCodec<CE>;
873
917
  /** How several discovered sources become one value. */
874
918
  readonly strategy: MergeStrategy<A>;
875
919
  /**
@@ -886,9 +930,13 @@ interface ConfigFileTestOptions<A, I> {
886
930
  /**
887
931
  * Options for {@link ConfigFile.read}.
888
932
  *
933
+ * @remarks
934
+ * `CE` is the codec's error channel, inferred from `codec`, as on
935
+ * {@link ConfigFileOptions}.
936
+ *
889
937
  * @public
890
938
  */
891
- interface ConfigReadOptions<A, I> {
939
+ interface ConfigReadOptions<A, I, CE = ConfigCodecError> {
892
940
  /** The schema the document is decoded through. */
893
941
  readonly schema: Schema.Codec<A, I>;
894
942
  /**
@@ -900,8 +948,11 @@ interface ConfigReadOptions<A, I> {
900
948
  * free-standing-codec tree-shaking guarantee: a consumer that only ever
901
949
  * passes `JsonCodec` never references the JSONC, YAML or TOML modules, so
902
950
  * their engines stay out of the bundle.
951
+ *
952
+ * A decorated codec (`EncryptedCodec`, `ConfigMigration.make`) is accepted,
953
+ * and `read`'s error channel widens to its error.
903
954
  */
904
- readonly codec: ConfigCodec;
955
+ readonly codec: ConfigCodec<CE>;
905
956
  /**
906
957
  * Parse options for the decode, chiefly `onExcessProperty`.
907
958
  *
@@ -922,12 +973,26 @@ export declare class ConfigFile {
922
973
  /**
923
974
  * Create a uniquely-keyed service class for one config schema.
924
975
  *
976
+ * @remarks
977
+ * The optional third type argument is the codec's error channel. Omit it
978
+ * for the built-in codecs. A service over a decorated codec names the
979
+ * decorator's widened error, because the key is declared before the codec
980
+ * is chosen and cannot infer it: wiring `EncryptedCodec(JsonCodec, key)`
981
+ * into a key declared without it is a compile error, since that service's
982
+ * methods would fail with a `ConfigEncryptionError` its type never mentions.
983
+ *
925
984
  * @example
926
985
  * ```ts
927
986
  * class AppConfig extends ConfigFile.Service<AppConfig, AppShape>()("app/Config") {}
987
+ *
988
+ * class Secrets extends ConfigFile.Service<
989
+ * Secrets,
990
+ * SecretsShape,
991
+ * ConfigCodecError | ConfigEncryptionError
992
+ * >()("app/Secrets") {}
928
993
  * ```
929
994
  */
930
- static readonly Service: <Self, A>() => <const Id extends string>(id: Id) => Context.ServiceClass<Self, Id, ConfigFileShape<A>>;
995
+ static readonly Service: <Self, A, CE = ConfigCodecError>() => <const Id extends string>(id: Id) => Context.ServiceClass<Self, Id, ConfigFileShape<A, CE>>;
931
996
  /**
932
997
  * Build the live layer for a config service class.
933
998
  *
@@ -965,7 +1030,7 @@ export declare class ConfigFile {
965
1030
  * });
966
1031
  * ```
967
1032
  */
968
- static readonly layer: <Self, A, I, RR = never, S extends ConfigFileShape<A> = ConfigFileShape<A>>(tag: Context.Key<Self, S> & Context.Key<Self, ConfigFileShape<A>> & ([ConfigFileShape<NoInfer<A>>] extends [S] ? unknown : never), options: ConfigFileOptions<A, I, RR>) => Layer.Layer<Self, never, FileSystem.FileSystem | Path.Path | RR>;
1033
+ static readonly layer: <Self, A, I, RR = never, CE = ConfigCodecError, S extends ConfigFileShape<A, CE> = ConfigFileShape<A, CE>>(tag: Context.Key<Self, S> & Context.Key<Self, ConfigFileShape<A, CE>> & ([ConfigFileShape<NoInfer<A>, NoInfer<CE>>] extends [S] ? unknown : never), options: ConfigFileOptions<A, I, RR, CE>) => Layer.Layer<Self, never, FileSystem.FileSystem | Path.Path | RR>;
969
1034
  /**
970
1035
  * A scoped layer that seeds `files` into a temp directory, wires the **real**
971
1036
  * live implementation over them, and removes the directory when the scope
@@ -999,7 +1064,7 @@ export declare class ConfigFile {
999
1064
  * }).pipe(Layer.provide(NodeServices.layer));
1000
1065
  * ```
1001
1066
  */
1002
- static readonly testLayer: <Self, A, I, S extends ConfigFileShape<A> = ConfigFileShape<A>>(tag: Context.Key<Self, S> & Context.Key<Self, ConfigFileShape<A>> & ([ConfigFileShape<NoInfer<A>>] extends [S] ? unknown : never), options: ConfigFileTestOptions<A, I>) => Layer.Layer<Self, never, FileSystem.FileSystem | Path.Path>;
1067
+ static readonly testLayer: <Self, A, I, CE = ConfigCodecError, S extends ConfigFileShape<A, CE> = ConfigFileShape<A, CE>>(tag: Context.Key<Self, S> & Context.Key<Self, ConfigFileShape<A, CE>> & ([ConfigFileShape<NoInfer<A>, NoInfer<CE>>] extends [S] ? unknown : never), options: ConfigFileTestOptions<A, I, CE>) => Layer.Layer<Self, never, FileSystem.FileSystem | Path.Path>;
1003
1068
  /**
1004
1069
  * Read, decode and validate one explicit path — no service, no layer, no tag.
1005
1070
  *
@@ -1037,7 +1102,7 @@ export declare class ConfigFile {
1037
1102
  * });
1038
1103
  * ```
1039
1104
  */
1040
- static readonly read: <A, I>(path: string, options: ConfigReadOptions<A, I>) => Effect.Effect<A, ConfigReadError, FileSystem.FileSystem>;
1105
+ static readonly read: <A, I, CE = ConfigCodecError>(path: string, options: ConfigReadOptions<A, I, CE>) => Effect.Effect<A, ConfigReadError<CE>, FileSystem.FileSystem>;
1041
1106
  }
1042
1107
  declare const ConfigMigrationError_base: Schema.Class<ConfigMigrationError, Schema.TaggedStruct<"ConfigMigrationError", {
1043
1108
  /** The target version of the step that failed. `0` when reading the version failed. */
@@ -1092,10 +1157,19 @@ export interface VersionAccess {
1092
1157
  export declare const VersionAccess: {
1093
1158
  readonly default: VersionAccess;
1094
1159
  };
1095
- /** Options for {@link ConfigMigration.make}. @public */
1096
- interface ConfigMigrationOptions {
1097
- /** The codec being wrapped. */
1098
- readonly codec: ConfigCodec;
1160
+ /**
1161
+ * Options for {@link ConfigMigration.make}.
1162
+ *
1163
+ * @remarks
1164
+ * `CE` is the wrapped codec's error channel, inferred from `codec`. It
1165
+ * defaults to `ConfigCodecError`; wrapping an already-decorated codec carries
1166
+ * its widened error through.
1167
+ *
1168
+ * @public
1169
+ */
1170
+ interface ConfigMigrationOptions<CE = ConfigCodecError> {
1171
+ /** The codec being wrapped — any `ConfigCodec<CE>`, decorated ones included. */
1172
+ readonly codec: ConfigCodec<CE>;
1099
1173
  /** The migration steps; they run in ascending `version` order. */
1100
1174
  readonly migrations: ReadonlyArray<ConfigFileMigration>;
1101
1175
  /** How the version is read and written; defaults to {@link (VersionAccess:variable).default}, a top-level `version` field. */
@@ -1111,7 +1185,11 @@ export declare class ConfigMigration {
1111
1185
  * The returned codec's error channel **widens** to include
1112
1186
  * {@link ConfigMigrationError} rather than flattening migration failures into
1113
1187
  * the inner codec's error — the reason the {@link (ConfigCodec:interface)} seam is generic
1114
- * in its error type.
1188
+ * in its error type. The inner codec's own error type is kept as-is, so
1189
+ * wrapping an `EncryptedCodec` yields
1190
+ * `ConfigCodec<ConfigCodecError | ConfigEncryptionError | ConfigMigrationError>`,
1191
+ * and the result goes straight into `ConfigFile.layer`, `testLayer` or
1192
+ * `read`.
1115
1193
  *
1116
1194
  * @example
1117
1195
  * ```ts
@@ -1130,7 +1208,7 @@ export declare class ConfigMigration {
1130
1208
  * });
1131
1209
  * ```
1132
1210
  */
1133
- static readonly make: (options: ConfigMigrationOptions) => ConfigCodec<ConfigCodecError | ConfigMigrationError>;
1211
+ static readonly make: <CE = ConfigCodecError>(options: ConfigMigrationOptions<CE>) => ConfigCodec<CE | ConfigMigrationError>;
1134
1212
  }
1135
1213
  /**
1136
1214
  * Expose a loaded, merged, schema-validated document as a `ConfigProvider`,
@@ -1173,6 +1251,9 @@ export declare class ConfigMigration {
1173
1251
  * Descent by own property is also why a prototype getter and `__proto__` are
1174
1252
  * both unreachable through the returned provider.
1175
1253
  *
1254
+ * `CE` is the service's codec error, so a service over a decorated codec
1255
+ * keeps its widened failure in this effect's error channel.
1256
+ *
1176
1257
  * @example
1177
1258
  * ```ts
1178
1259
  * import { asConfigProvider } from "@effected/config-file";
@@ -1187,7 +1268,7 @@ export declare class ConfigMigration {
1187
1268
  *
1188
1269
  * @public
1189
1270
  */
1190
- export declare const asConfigProvider: <A>(service: ConfigFileShape<A>) => Effect.Effect<ConfigProvider.ConfigProvider, ConfigLoadError>;
1271
+ export declare const asConfigProvider: <A, CE = ConfigCodecError>(service: ConfigFileShape<A, CE>) => Effect.Effect<ConfigProvider.ConfigProvider, ConfigLoadError<CE>>;
1191
1272
  /**
1192
1273
  * Options for {@link layerConfigProvider}.
1193
1274
  *
@@ -1220,7 +1301,8 @@ interface LayerConfigProviderOptions {
1220
1301
  *
1221
1302
  * The load happens when the layer is built, and a {@link ConfigFileNotFoundError}
1222
1303
  * surfaces in the layer's error channel rather than degrading to an empty
1223
- * provider — the same honesty {@link asConfigProvider} keeps.
1304
+ * provider — the same honesty {@link asConfigProvider} keeps. A service over a
1305
+ * decorated codec carries its widened codec error `CE` there too.
1224
1306
  *
1225
1307
  * @example
1226
1308
  * ```ts
@@ -1229,7 +1311,7 @@ interface LayerConfigProviderOptions {
1229
1311
  *
1230
1312
  * @public
1231
1313
  */
1232
- export declare const layerConfigProvider: <Self, A>(tag: Context.Key<Self, ConfigFileShape<A>>, options?: LayerConfigProviderOptions) => Layer.Layer<never, ConfigLoadError, Self>;
1314
+ export declare const layerConfigProvider: <Self, A, CE = ConfigCodecError>(tag: Context.Key<Self, ConfigFileShape<A, CE>>, options?: LayerConfigProviderOptions) => Layer.Layer<never, ConfigLoadError<CE>, Self>;
1233
1315
  declare const ConfigEncryptionError_base: Schema.Class<ConfigEncryptionError, Schema.TaggedStruct<"ConfigEncryptionError", {
1234
1316
  /** Which cryptographic stage failed. */
1235
1317
  readonly phase: Schema.Literals<readonly ["key-derivation", "encrypt", "decrypt", "encoding"]>;
@@ -1309,9 +1391,17 @@ export declare const EncryptedCodecKey: {
1309
1391
  * the remainder is the ciphertext, and the plaintext is handed to the inner
1310
1392
  * codec's `parse`.
1311
1393
  *
1394
+ * The envelope is decoded with core's `effect/encoding/Base64`. Whitespace
1395
+ * around it (a trailing newline, a BOM) is tolerated; whitespace inside it, or
1396
+ * missing padding, fails with phase `"encoding"`. `stringify` never produces
1397
+ * either.
1398
+ *
1312
1399
  * The error channel **widens** to `E | ConfigEncryptionError` rather than
1313
1400
  * flattening — the inner codec's failures stay distinguishable from
1314
- * cryptographic ones.
1401
+ * cryptographic ones. The result plugs straight into `ConfigFile.layer`,
1402
+ * `ConfigFile.testLayer` and `ConfigFile.read`, whose error unions widen with
1403
+ * it; a `ConfigFile.Service` key names the widened error as its third type
1404
+ * argument.
1315
1405
  *
1316
1406
  * @example
1317
1407
  * ```ts
@@ -1,8 +1,10 @@
1
1
  import { Effect } from "effect";
2
+ import * as Base64 from "effect/encoding/Base64";
2
3
 
3
4
  // src/internal/crypto.ts
4
5
  /**
5
- * AES-GCM primitives over WebCrypto.
6
+ * AES-GCM primitives over WebCrypto, with base64 from core's
7
+ * `effect/encoding/Base64`.
6
8
  *
7
9
  * @remarks
8
10
  * This module imports nothing from the rest of the package. `EncryptedCodec.ts`
@@ -59,39 +61,57 @@ const deriveKey = (passphrase, salt) => Effect.tryPromise({
59
61
  catch: fail("key-derivation")
60
62
  });
61
63
  /**
62
- * Decode base64 into bytes.
64
+ * Decode the base64 envelope into bytes.
63
65
  *
64
66
  * @remarks
65
- * `atob` is available in all modern environments (Node 20+, Bun, Deno).
67
+ * Delegates to core's `effect/encoding/Base64` rather than hand-rolling
68
+ * `atob` and a char-code loop. Core is stricter than `atob`: it strips only
69
+ * CR and LF, so an embedded space or tab, or missing padding, fails here with
70
+ * phase `"encoding"`. The text is trimmed first, so whitespace AROUND the
71
+ * envelope — the realistic residue of a hand edit or an editor's trailing
72
+ * newline — is still tolerated, and `trim` also drops a leading BOM or NBSP,
73
+ * which `atob` rejected. `EncryptedCodec` itself always writes padded,
74
+ * whitespace-free output, so being strict inside the text costs nothing.
75
+ *
76
+ * The decoded view is backed by a plain `ArrayBuffer`; callers that hand a
77
+ * slice of it to WebCrypto get an `ArrayBuffer`-backed view from `slice`.
66
78
  *
67
79
  * @internal
68
80
  */
69
- const fromBase64 = (raw) => Effect.try({
70
- try: () => {
71
- const binary = atob(raw);
72
- const buf = new ArrayBuffer(binary.length);
73
- const bytes = new Uint8Array(buf);
74
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
75
- return bytes;
76
- },
77
- catch: fail("encoding")
78
- });
81
+ const fromBase64 = (raw) => Effect.mapError(Effect.fromResult(Base64.decode(raw.trim())), (error) => fail("encoding")(withoutInput(error)));
82
+ /**
83
+ * Rebuild core's `EncodingError` as a plain `Error` that keeps only its message.
84
+ *
85
+ * @remarks
86
+ * Core's `EncodingError` stores the whole stripped input as `input`, and the
87
+ * public `ConfigEncryptionError` keeps its cause by identity. Passed through
88
+ * as is, a PLAINTEXT config handed to an encrypted codec (a file that was
89
+ * never encrypted, or a resolver pointing at the wrong file) would carry
90
+ * every secret in it into the typed error, the `ParseFailed` event payload,
91
+ * and any log that inspects or serializes the error. Core's messages name at
92
+ * most one offending character, never the input.
93
+ */
94
+ const withoutInput = (error) => {
95
+ const cause = new Error(error.message);
96
+ cause.name = error._tag;
97
+ return cause;
98
+ };
79
99
  /**
80
100
  * Prepend the IV to the ciphertext and base64-encode the envelope.
81
101
  *
102
+ * @remarks
103
+ * Synchronous and total: encoding bytes to base64 cannot fail, so there is no
104
+ * error channel to carry.
105
+ *
82
106
  * @internal
83
107
  */
84
- const toBase64 = (iv, ciphertext) => Effect.try({
85
- try: () => {
86
- const ciphertextBytes = new Uint8Array(ciphertext);
87
- const resultBuf = new ArrayBuffer(12 + ciphertextBytes.length);
88
- const result = new Uint8Array(resultBuf);
89
- result.set(iv, 0);
90
- result.set(ciphertextBytes, 12);
91
- return btoa(Array.from(result, (b) => String.fromCharCode(b)).join(""));
92
- },
93
- catch: fail("encoding")
94
- });
108
+ const toBase64 = (iv, ciphertext) => {
109
+ const ciphertextBytes = new Uint8Array(ciphertext);
110
+ const result = new Uint8Array(12 + ciphertextBytes.length);
111
+ result.set(iv, 0);
112
+ result.set(ciphertextBytes, 12);
113
+ return Base64.encode(result);
114
+ };
95
115
  /** Decrypt AES-GCM ciphertext under `iv`. @internal */
96
116
  const decrypt = (key, iv, ciphertext) => Effect.tryPromise({
97
117
  try: () => globalThis.crypto.subtle.decrypt({
@@ -108,7 +128,28 @@ const encrypt = (key, iv, plaintext) => Effect.tryPromise({
108
128
  }, key, plaintext),
109
129
  catch: fail("encrypt")
110
130
  });
111
- /** A fresh cryptographically random 12-byte IV. @internal */
112
- const randomIv = () => globalThis.crypto.getRandomValues(new Uint8Array(/* @__PURE__ */ new ArrayBuffer(12)));
131
+ /**
132
+ * A fresh cryptographically random 12-byte IV, drawn from WebCrypto.
133
+ *
134
+ * @remarks
135
+ * Deliberately NOT core's `Crypto.randomBytes` in `R`, which is otherwise the
136
+ * kit's rule for any contract core declares. The IV is an AES-GCM nonce:
137
+ * repeating one under the same key destroys GCM's confidentiality and
138
+ * integrity. A swappable random source makes that a wiring accident away —
139
+ * `Crypto.make`'s own documentation example returns zero-filled bytes, and a
140
+ * seeded `TestCrypto` layer that reached production would repeat IVs. The
141
+ * `ConfigCodec` seam also has no `R` channel to carry the service. This is a
142
+ * recorded, security-motivated exception, not a precedent for other
143
+ * randomness.
144
+ *
145
+ * A runtime without `globalThis.crypto` fails typed with phase `"encrypt"`,
146
+ * like the neighbouring WebCrypto steps, rather than escaping as a defect.
147
+ *
148
+ * @internal
149
+ */
150
+ const randomIv = Effect.try({
151
+ try: () => globalThis.crypto.getRandomValues(new Uint8Array(/* @__PURE__ */ new ArrayBuffer(12))),
152
+ catch: fail("encrypt")
153
+ });
113
154
 
114
155
  export { decrypt, deriveKey, encrypt, fromBase64, randomIv, toArrayBufferView, toBase64 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/config-file",
3
- "version": "0.18.0",
3
+ "version": "0.19.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": [
@@ -44,10 +44,10 @@
44
44
  },
45
45
  "peerDependencies": {
46
46
  "@effected/glob": "^0.14.0",
47
- "@effected/jsonc": "^0.19.0",
48
- "@effected/toml": "^0.15.0",
47
+ "@effected/jsonc": "^0.20.0",
48
+ "@effected/toml": "^0.16.0",
49
49
  "@effected/walker": "^0.19.0",
50
- "@effected/yaml": "^0.23.0",
50
+ "@effected/yaml": "^0.24.0",
51
51
  "effect": "^4.0.0"
52
52
  },
53
53
  "engines": {