@effected/config-file 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ConfigFile.js CHANGED
@@ -120,6 +120,13 @@ const withCodecPath = (error, target) => error instanceof ConfigCodecError && er
120
120
  cause: error.cause,
121
121
  path: target
122
122
  }) : error;
123
+ /**
124
+ * Put `header` in front of `document`, separated by exactly one newline.
125
+ *
126
+ * A header that already ends in a newline is not given another — a caller who
127
+ * built the line with a trailing `"\n"` and one who did not get the same file.
128
+ */
129
+ const prependHeader = (document, header) => header === void 0 ? document : header.endsWith("\n") ? `${header}${document}` : `${header}\n${document}`;
123
130
  const makeImpl = (options, fs, path, resolverEnv) => {
124
131
  /**
125
132
  * Publish one event, or nothing at all.
@@ -252,24 +259,46 @@ const makeImpl = (options, fs, path, resolverEnv) => {
252
259
  return yield* runValidate(decoded);
253
260
  });
254
261
  /**
255
- * Shared by `write` and `save`. Not an `Effect.fn`: it is internal, and the
256
- * public boundaries that call it already open a span.
262
+ * Schema-encode, stringify, and prepend the header. Shared by `encode`,
263
+ * `write` and `save`. Not an `Effect.fn`: it is internal, and the public
264
+ * boundaries that call it already open a span.
265
+ *
266
+ * `target` is the file this text is destined for, when there is one. It is
267
+ * only ever used to NAME the file in an error — `encode` passes `none` and
268
+ * its errors honestly carry no path.
257
269
  */
258
- const encodeAndWrite = (value, target) => Effect.gen(function* () {
270
+ const encodeTo = (value, target, encodeOptions) => Effect.gen(function* () {
259
271
  const encoded = yield* Schema.encodeEffect(options.schema)(value).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(new ConfigValidationError({
260
- path: Option.some(target),
272
+ path: target,
261
273
  issue: error.issue
262
274
  }))));
263
- const serialized = yield* options.codec.stringify(encoded).pipe(Effect.mapError((error) => withCodecPath(error, target)), Effect.tapError((error) => emit({
275
+ const serialized = yield* options.codec.stringify(encoded).pipe(Effect.mapError((error) => Option.match(target, {
276
+ onNone: () => error,
277
+ onSome: (file) => withCodecPath(error, file)
278
+ })));
279
+ return prependHeader(serialized, encodeOptions?.header);
280
+ });
281
+ /**
282
+ * `encodeTo` a known file, then write it. Shared by `write` and `save`.
283
+ *
284
+ * `StringifyFailed` is emitted HERE, not in `encodeTo`: it is a write-path
285
+ * event, and `encode` promises to publish nothing. A subscriber counting
286
+ * failed writes must not see a dry run's codec failure.
287
+ */
288
+ const encodeAndWrite = (value, target, encodeOptions) => Effect.gen(function* () {
289
+ const serialized = yield* encodeTo(value, Option.some(target), encodeOptions).pipe(Effect.tapError((error) => error._tag === "ConfigCodecError" ? emit({
264
290
  _tag: "StringifyFailed",
265
291
  codec: options.codec.name,
266
292
  error
267
- })));
293
+ }) : Effect.void));
268
294
  yield* fs.writeFileString(target, serialized).pipe(Effect.mapError((cause) => new ConfigFileWriteError({
269
295
  path: target,
270
296
  cause
271
297
  })));
272
298
  });
299
+ const encode = Effect.fn("ConfigFile.encode")(function* (value, encodeOptions) {
300
+ return yield* encodeTo(value, Option.none(), encodeOptions);
301
+ });
273
302
  /**
274
303
  * Resolve `defaultPath`, `mkdir -p` its parent and write. Shared by `save`
275
304
  * and `update`, and — crucially — emits nothing.
@@ -290,8 +319,8 @@ const makeImpl = (options, fs, path, resolverEnv) => {
290
319
  yield* encodeAndWrite(value, target);
291
320
  return target;
292
321
  });
293
- const write = Effect.fn("ConfigFile.write")(function* (value, target) {
294
- yield* encodeAndWrite(value, target);
322
+ const write = Effect.fn("ConfigFile.write")(function* (value, target, encodeOptions) {
323
+ yield* encodeAndWrite(value, target, encodeOptions);
295
324
  yield* emit({
296
325
  _tag: "Written",
297
326
  path: target
@@ -335,6 +364,7 @@ const makeImpl = (options, fs, path, resolverEnv) => {
335
364
  discover: discover(),
336
365
  loadOrDefault,
337
366
  validate,
367
+ encode,
338
368
  write,
339
369
  save,
340
370
  update
package/README.md CHANGED
@@ -158,7 +158,7 @@ Every failure is a tagged error you route on with `Effect.catchTag`. The tags ex
158
158
  | `ConfigMigrationError` | A versioned migration failed. Carries `version`, `name`, `phase` and the structural `cause`. | Report which step failed; the config on disk is left untouched. |
159
159
  | `ConfigEncryptionError` | An encrypt, decrypt, key-derivation or base64 step failed. Carries `phase` and the structural `cause`. | A wrong passphrase and a corrupt envelope both land here; inspect `phase`. |
160
160
 
161
- `ConfigLoadError`, `ConfigReadError`, `ConfigWriteError`, `ConfigSaveError` and `ConfigUpdateError` are exported unions naming exactly the failures each method can produce. Catching one tag narrows the union, leaving the rest to propagate:
161
+ `ConfigLoadError`, `ConfigReadError`, `ConfigEncodeError`, `ConfigWriteError`, `ConfigSaveError` and `ConfigUpdateError` are exported unions naming exactly the failures each method can produce — `ConfigEncodeError` is `ConfigWriteError` minus `ConfigFileWriteError`, because `encode` never touches the disk. Catching one tag narrows the union, leaving the rest to propagate:
162
162
 
163
163
  ```ts
164
164
  import type { ConfigFileShape } from "@effected/config-file";
@@ -213,6 +213,7 @@ export const secret = EncryptedCodec(migrating, EncryptedCodecKey.fromPassphrase
213
213
  - `ConfigFile.Service` / `ConfigFile.layer` / `ConfigFile.testLayer` — a per-schema service class and its layers. `testLayer` seeds files into a temp directory and wires the *real* implementation over them, so tests exercise the actual pipeline rather than a stub that can drift from it.
214
214
  - `parseOptions` — decode options threaded into every decode, on the layer and on `read`. `onExcessProperty: "error"` is the only way to report a typo'd section or a field the schema deliberately removed.
215
215
  - `ConfigFile.read` — the one-shot escape from the service: read, decode and validate one explicit path, schema and codec named per call, with no resolver chain and no write path.
216
+ - `encode` / `write` / `save` / `update` — the write path. `encode(value, options?)` returns the serialized text a `write` would put on disk without writing, emitting an event or naming a path in its errors: the primitive behind a `--dry-run` flag. `write` takes an explicit path the caller vouches for; `save` and `update` resolve the layer's `defaultPath` and create its parent directory. `encode` and `write` accept `{ header }`, text prepended verbatim above the document with exactly one newline between — a `#:schema https://…` directive atop a TOML file, say. The caller owns its validity in the target format; JSON has no comment syntax, so a header on `JsonCodec` yields an unparseable file by construction.
216
217
  - `ConfigResolver` — `explicitPath`, `staticDir`, `upwardWalk`, `workspaceRoot`, `gitRoot` and `systemEtc`. A resolver's error channel is `never` by contract: every filesystem failure becomes `Option.none()`, so one unreadable tier never aborts the chain.
217
218
  - `MergeStrategy` — `firstMatch` and `layeredMerge`, combining discovered sources in priority order.
218
219
  - `JsonCodec`, `JsoncCodec`, `YamlCodec`, `TomlCodec` — JSON, JSONC, YAML and TOML in the box, exported free-standing so an unused format's engine is tree-shaken away.
package/index.d.ts CHANGED
@@ -600,6 +600,38 @@ type ConfigReadError = ConfigFileReadError | ConfigCodecError | ConfigValidation
600
600
  * @public
601
601
  */
602
602
  type ConfigWriteError = ConfigFileWriteError | ConfigCodecError | ConfigValidationError;
603
+ /**
604
+ * The failure modes of {@link ConfigFileShape.encode}: everything on the write
605
+ * path except the write itself.
606
+ *
607
+ * @remarks
608
+ * Deliberately excludes {@link ConfigFileWriteError} — nothing touches the
609
+ * filesystem — so a `--dry-run` caller's error channel is honest about that.
610
+ *
611
+ * @public
612
+ */
613
+ type ConfigEncodeError = ConfigCodecError | ConfigValidationError;
614
+ /**
615
+ * Options shared by {@link ConfigFileShape.encode} and {@link ConfigFileShape.write}.
616
+ *
617
+ * @public
618
+ */
619
+ interface ConfigEncodeOptions {
620
+ /**
621
+ * Text emitted **verbatim** in front of the serialized document, separated
622
+ * from it by exactly one newline. If `header` already ends in `"\n"`, no
623
+ * further newline is added.
624
+ *
625
+ * @remarks
626
+ * The caller owns the header's validity in the target format: `#` comment
627
+ * lines for TOML and YAML, `//` for JSONC. JSON has no comment syntax, so a
628
+ * header on a JSON codec yields an unparseable file by construction — the
629
+ * service does not check. The motivating case is a schema directive such as
630
+ * `#:schema https://example.com/config.schema.json` at the top of a TOML
631
+ * file, which editors read for completion and validation.
632
+ */
633
+ readonly header?: string;
634
+ }
603
635
  /**
604
636
  * The failure modes of {@link ConfigFileShape.save}.
605
637
  *
@@ -646,15 +678,27 @@ interface ConfigFileShape<A> {
646
678
  readonly loadOrDefault: (defaultValue: A) => Effect.Effect<A, ConfigReadError>;
647
679
  /** Decode and validate an in-memory value. */
648
680
  readonly validate: (value: unknown) => Effect.Effect<A, ConfigValidationError>;
681
+ /**
682
+ * Encode `value` to its serialized text without writing anything.
683
+ *
684
+ * @remarks
685
+ * Produces byte-for-byte what `write(value, path, options)` puts on disk —
686
+ * the primitive for `--dry-run` and "show me the file" callers. Emits no
687
+ * event, because nothing was written. A codec failure here carries no
688
+ * `path`, and a validation failure's `path` is `Option.none()`: there is no
689
+ * file to name.
690
+ */
691
+ readonly encode: (value: A, options?: ConfigEncodeOptions) => Effect.Effect<string, ConfigEncodeError>;
649
692
  /**
650
693
  * Encode `value` and write it to an explicit `path`.
651
694
  *
652
695
  * @remarks
653
696
  * Does **not** create the parent directory — that is
654
697
  * {@link ConfigFileShape.save}'s job, and the distinction is load-bearing:
655
- * `write` targets a path the caller already vouched for.
698
+ * `write` targets a path the caller already vouched for. `options` are the
699
+ * same as {@link ConfigFileShape.encode}'s, so the two stay in lockstep.
656
700
  */
657
- readonly write: (value: A, path: string) => Effect.Effect<void, ConfigWriteError>;
701
+ readonly write: (value: A, path: string, options?: ConfigEncodeOptions) => Effect.Effect<void, ConfigWriteError>;
658
702
  /**
659
703
  * Resolve `defaultPath`, `mkdir -p` its parent, encode `value` into it, and
660
704
  * return the path written.
@@ -1231,5 +1275,5 @@ export declare const TomlCodec: ConfigCodec;
1231
1275
  */
1232
1276
  export declare const YamlCodec: ConfigCodec;
1233
1277
  //#endregion
1234
- export type { ConfigCodec, ConfigEventsShape, ConfigFileMigration, ConfigFileOptions, ConfigFileShape, ConfigFileTestOptions, ConfigLoadError, ConfigMatch, ConfigMigrationOptions, ConfigReadError, ConfigReadOptions, ConfigSaveError, ConfigSource, ConfigUpdateError, ConfigWriteError, LayerConfigProviderOptions, NonEmptySources, UpwardWalkOptions };
1278
+ export type { ConfigCodec, ConfigEncodeError, ConfigEncodeOptions, ConfigEventsShape, ConfigFileMigration, ConfigFileOptions, ConfigFileShape, ConfigFileTestOptions, ConfigLoadError, ConfigMatch, ConfigMigrationOptions, ConfigReadError, ConfigReadOptions, ConfigSaveError, ConfigSource, ConfigUpdateError, ConfigWriteError, LayerConfigProviderOptions, NonEmptySources, UpwardWalkOptions };
1235
1279
  //# sourceMappingURL=index.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/config-file",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "private": false,
5
5
  "description": "Composable config file loading for Effect: JSON, JSONC, YAML and TOML codecs, resolution strategies, and merge behaviors.",
6
6
  "keywords": [
@@ -45,8 +45,8 @@
45
45
  "peerDependencies": {
46
46
  "@effected/jsonc": "^0.11.0",
47
47
  "@effected/toml": "^0.7.0",
48
- "@effected/walker": "^0.8.0",
49
- "@effected/yaml": "^0.15.0",
48
+ "@effected/walker": "^0.9.0",
49
+ "@effected/yaml": "^0.15.1",
50
50
  "effect": "4.0.0-rc.115"
51
51
  },
52
52
  "engines": {