@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 ADDED
@@ -0,0 +1,27 @@
1
+ import { Schema } from "effect";
2
+
3
+ //#region src/ConfigCodec.ts
4
+ /**
5
+ * Indicates that a codec failed to parse or stringify configuration content.
6
+ *
7
+ * @remarks
8
+ * The underlying failure is preserved structurally in `cause` — it is never
9
+ * stringified. Route on the `"ConfigCodecError"` tag with `Effect.catchTag`.
10
+ *
11
+ * @public
12
+ */
13
+ var ConfigCodecError = class extends Schema.TaggedErrorClass()("ConfigCodecError", {
14
+ /** The codec that failed, e.g. `"json"`. */
15
+ codec: Schema.String,
16
+ /** Which direction failed. */
17
+ operation: Schema.Literals(["parse", "stringify"]),
18
+ /** The underlying failure, preserved structurally. */
19
+ cause: Schema.Defect()
20
+ }) {
21
+ get message() {
22
+ return `${this.codec} ${this.operation} failed`;
23
+ }
24
+ };
25
+
26
+ //#endregion
27
+ export { ConfigCodecError };
package/ConfigEvent.js ADDED
@@ -0,0 +1,119 @@
1
+ import { Context, Effect, Layer, PubSub, Schema } from "effect";
2
+
3
+ //#region src/ConfigEvent.ts
4
+ /**
5
+ * A reference to one configuration source that contributed to a load.
6
+ *
7
+ * @remarks
8
+ * Carries the resolver's name alongside the path so a subscriber can tell
9
+ * `/etc/app/.apprc` found by `systemEtc` from the same path passed explicitly.
10
+ *
11
+ * @public
12
+ */
13
+ const ConfigSourceRef = Schema.Struct({
14
+ /** The filesystem path the value was read from. */
15
+ path: Schema.String,
16
+ /** The name of the resolver that found it. */
17
+ resolver: Schema.String
18
+ });
19
+ /**
20
+ * Every event published during config discovery, parsing, validation and
21
+ * persistence.
22
+ *
23
+ * @remarks
24
+ * v3's `Stringified` and `ResolutionFailed` variants were declared and never
25
+ * emitted; they are not ported. `DiscoveryFailed` goes with them: under this
26
+ * package's resolver-absorption contract a resolver's error channel is `never`
27
+ * — every filesystem failure becomes `Option.none()` — so the pipeline can
28
+ * never observe a discovery failure to report one.
29
+ *
30
+ * The failure variants carry the **structured** typed error in `error`, not a
31
+ * `reason` string. v3 stringified them, destroying every field a subscriber
32
+ * might branch on; a subscriber that wants prose can read `error.message`.
33
+ *
34
+ * @public
35
+ */
36
+ const ConfigEventPayload = Schema.Union([
37
+ Schema.TaggedStruct("Discovered", {
38
+ path: Schema.String,
39
+ resolver: Schema.String
40
+ }),
41
+ Schema.TaggedStruct("NotFound", {}),
42
+ Schema.TaggedStruct("Parsed", {
43
+ path: Schema.String,
44
+ codec: Schema.String
45
+ }),
46
+ Schema.TaggedStruct("ParseFailed", {
47
+ path: Schema.String,
48
+ codec: Schema.String,
49
+ error: Schema.Defect()
50
+ }),
51
+ Schema.TaggedStruct("Validated", { path: Schema.String }),
52
+ Schema.TaggedStruct("ValidationFailed", {
53
+ path: Schema.String,
54
+ error: Schema.Defect()
55
+ }),
56
+ Schema.TaggedStruct("Resolved", {
57
+ sources: Schema.Array(ConfigSourceRef),
58
+ strategy: Schema.String
59
+ }),
60
+ Schema.TaggedStruct("Loaded", { sources: Schema.Array(ConfigSourceRef) }),
61
+ Schema.TaggedStruct("StringifyFailed", {
62
+ codec: Schema.String,
63
+ error: Schema.Defect()
64
+ }),
65
+ Schema.TaggedStruct("Written", { path: Schema.String }),
66
+ Schema.TaggedStruct("Saved", { path: Schema.String }),
67
+ Schema.TaggedStruct("Updated", { path: Schema.String })
68
+ ]);
69
+ /**
70
+ * A published event: the payload plus the instant it occurred.
71
+ *
72
+ * @public
73
+ */
74
+ var ConfigEvent = class extends Schema.Class("ConfigEvent")({
75
+ /** When the event occurred. */
76
+ timestamp: Schema.DateTimeUtc,
77
+ /** What happened. */
78
+ event: ConfigEventPayload
79
+ }) {};
80
+ /**
81
+ * The opt-in event hook: a PubSub of {@link ConfigEvent} that consumers
82
+ * subscribe to.
83
+ *
84
+ * @remarks
85
+ * Opt-in and honestly zero-cost: when `ConfigFileOptions.events` is omitted the
86
+ * pipeline's `emit` is `Effect.void` and never even looks the service up.
87
+ *
88
+ * Events are a **consumer-facing hook**, not the package's observability
89
+ * channel — every public fallible method is also an `Effect.fn` named span, and
90
+ * the library stays telemetry-agnostic.
91
+ *
92
+ * @example
93
+ * ```ts
94
+ * const events = ConfigEvents.layer;
95
+ * const AppLayer = Layer.mergeAll(
96
+ * events,
97
+ * ConfigFile.layer(AppConfig, { schema, codec, resolvers, strategy, events: ConfigEvents }),
98
+ * );
99
+ * ```
100
+ *
101
+ * @public
102
+ */
103
+ var ConfigEvents = class ConfigEvents extends Context.Service()("@effected/config-file/ConfigEvents") {
104
+ /**
105
+ * An unbounded PubSub of config events.
106
+ *
107
+ * @remarks
108
+ * Unbounded on purpose: a slow subscriber must never apply backpressure to a
109
+ * config load. Bind this to a const and provide that const — building it
110
+ * twice mints two hubs, and the subscriber would watch the one `emit` does
111
+ * not publish to.
112
+ */
113
+ static layer = Layer.effect(ConfigEvents, Effect.gen(function* () {
114
+ return { events: yield* PubSub.unbounded() };
115
+ }));
116
+ };
117
+
118
+ //#endregion
119
+ export { ConfigEvent, ConfigEventPayload, ConfigEvents, ConfigSourceRef };
package/ConfigFile.js ADDED
@@ -0,0 +1,421 @@
1
+ import { ConfigEvent } from "./ConfigEvent.js";
2
+ import { ConfigResolver } from "./ConfigResolver.js";
3
+ import { Context, DateTime, Effect, FileSystem, Layer, Option, Path, PubSub, Schema, Semaphore } from "effect";
4
+
5
+ //#region src/ConfigFile.ts
6
+ /**
7
+ * Indicates that the resolver chain produced no configuration source.
8
+ *
9
+ * @remarks
10
+ * Its own tag, so "no config anywhere" is routable with `Effect.catchTag`
11
+ * separately from "the config I found is broken" — the single most important
12
+ * distinction the v3 mega-error could not express.
13
+ *
14
+ * @public
15
+ */
16
+ var ConfigFileNotFoundError = class extends Schema.TaggedErrorClass()("ConfigFileNotFoundError", {
17
+ /** The names of the resolvers that were probed, in order. */
18
+ searched: Schema.Array(Schema.String) }) {
19
+ get message() {
20
+ return `No config file found (searched: ${this.searched.join(", ")})`;
21
+ }
22
+ };
23
+ /**
24
+ * Indicates that a config file could not be read from the filesystem.
25
+ *
26
+ * @remarks
27
+ * `cause` preserves the underlying filesystem failure structurally. v3 flattened
28
+ * it to `reason: String(e)`.
29
+ *
30
+ * @public
31
+ */
32
+ var ConfigFileReadError = class extends Schema.TaggedErrorClass()("ConfigFileReadError", {
33
+ /** The path that could not be read. */
34
+ path: Schema.String,
35
+ /** The underlying failure, preserved structurally. */
36
+ cause: Schema.Defect()
37
+ }) {
38
+ get message() {
39
+ return `Failed to read config file at "${this.path}"`;
40
+ }
41
+ };
42
+ /**
43
+ * Indicates that a config file could not be written to the filesystem.
44
+ *
45
+ * @public
46
+ */
47
+ var ConfigFileWriteError = class extends Schema.TaggedErrorClass()("ConfigFileWriteError", {
48
+ /** The path that could not be written. */
49
+ path: Schema.String,
50
+ /** The underlying failure, preserved structurally. */
51
+ cause: Schema.Defect()
52
+ }) {
53
+ get message() {
54
+ return `Failed to write config file at "${this.path}"`;
55
+ }
56
+ };
57
+ /**
58
+ * Indicates that {@link ConfigFileShape.save} or {@link ConfigFileShape.update}
59
+ * was called on a service configured without a `defaultPath`.
60
+ *
61
+ * @remarks
62
+ * v3 reported this as a generic `ConfigError` carrying `operation: "save"` and
63
+ * `reason: "no default path configured"` — indistinguishable by tag from a real
64
+ * write failure.
65
+ *
66
+ * It carries no `path` field on purpose. The whole point of this failure is
67
+ * that there is no path; a {@link ConfigFileWriteError} with a fabricated path
68
+ * would be a lie, and lying error payloads are what this port exists to undo.
69
+ *
70
+ * @public
71
+ */
72
+ var ConfigDefaultPathMissingError = class extends Schema.TaggedErrorClass()("ConfigDefaultPathMissingError", {}) {
73
+ get message() {
74
+ return "No `defaultPath` configured: `save` and `update` require ConfigFileOptions.defaultPath";
75
+ }
76
+ };
77
+ /**
78
+ * Indicates that parsed config content did not satisfy the schema, or that a
79
+ * caller-supplied `validate` rejected it.
80
+ *
81
+ * @remarks
82
+ * `issue` carries the **structured** schema failure — at runtime a
83
+ * `SchemaIssue.Issue` tree, reachable through `_tag` and nested `issues`. v3
84
+ * flattened this to `String(ParseError)`, destroying every field a caller might
85
+ * branch on. It is typed `unknown` because v4 exposes no `Schema` for `Issue`;
86
+ * narrow it with the `SchemaIssue` module.
87
+ *
88
+ * @public
89
+ */
90
+ var ConfigValidationError = class extends Schema.TaggedErrorClass()("ConfigValidationError", {
91
+ /** The offending file, absent when `validate` was called on an in-memory value. */
92
+ path: Schema.Option(Schema.String),
93
+ /** The structured schema issue. Never a string. */
94
+ issue: Schema.Defect()
95
+ }) {
96
+ get message() {
97
+ return `Config validation failed${Option.match(this.path, {
98
+ onNone: () => "",
99
+ onSome: (p) => ` at "${p}"`
100
+ })}`;
101
+ }
102
+ };
103
+ /**
104
+ * Create a uniquely-keyed service class for one config schema.
105
+ *
106
+ * @example
107
+ * ```ts
108
+ * class AppConfig extends ConfigFile.Service<AppConfig, AppShape>()("app/Config") {}
109
+ * ```
110
+ *
111
+ * @public
112
+ */
113
+ const Service = () => (id) => Context.Service()(id);
114
+ const makeImpl = (options, fs, path, resolverEnv) => {
115
+ /**
116
+ * Publish one event, or nothing at all.
117
+ *
118
+ * @remarks
119
+ * Three properties hold, in order of how easy they are to lose:
120
+ *
121
+ * 1. **Zero-cost when absent.** No `events` option means `Effect.void` — no
122
+ * context lookup, no `DateTime.now`.
123
+ * 2. **Never a requirement.** `Effect.serviceOption` reads the ambient context
124
+ * without adding to `R`, so wiring events cannot change a layer's type.
125
+ * 3. **Never fatal.** A subscriber's hub is consumer-supplied code. It cannot
126
+ * FAIL — `PubSub.publish` has no error channel — but it CAN throw, and
127
+ * `catchDefect` absorbs that; interruption is deliberately left to
128
+ * propagate, because a config load that is being interrupted must stay
129
+ * interrupted.
130
+ */
131
+ const emit = (payload) => options.events === void 0 ? Effect.void : Effect.serviceOption(options.events).pipe(Effect.flatMap(Option.match({
132
+ onNone: () => Effect.void,
133
+ onSome: (svc) => Effect.gen(function* () {
134
+ const timestamp = yield* DateTime.now;
135
+ yield* PubSub.publish(svc.events, new ConfigEvent({
136
+ timestamp,
137
+ event: payload
138
+ }));
139
+ })
140
+ })), Effect.catchDefect((defect) => Effect.logDebug("ConfigEvents.emit: consumer hub raised a defect", defect)));
141
+ /** The public, path-and-resolver view of the sources that fed a load. */
142
+ const sourceRefs = (sources) => sources.map((s) => ({
143
+ path: s.path,
144
+ resolver: s.resolver
145
+ }));
146
+ const decode = (parsed, at) => Schema.decodeUnknownEffect(options.schema)(parsed).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(new ConfigValidationError({
147
+ path: at,
148
+ issue: error.issue
149
+ }))));
150
+ const runValidate = (value) => options.validate ? options.validate(value) : Effect.succeed(value);
151
+ const loadFrom = Effect.fn("ConfigFile.loadFrom")(function* (target) {
152
+ const raw = yield* fs.readFileString(target).pipe(Effect.mapError((cause) => new ConfigFileReadError({
153
+ path: target,
154
+ cause
155
+ })));
156
+ const parsed = yield* options.codec.parse(raw).pipe(Effect.tapError((error) => emit({
157
+ _tag: "ParseFailed",
158
+ path: target,
159
+ codec: options.codec.name,
160
+ error
161
+ })));
162
+ yield* emit({
163
+ _tag: "Parsed",
164
+ path: target,
165
+ codec: options.codec.name
166
+ });
167
+ const validated = yield* Effect.gen(function* () {
168
+ const decoded = yield* decode(parsed, Option.some(target));
169
+ return yield* runValidate(decoded);
170
+ }).pipe(Effect.tapError((error) => emit({
171
+ _tag: "ValidationFailed",
172
+ path: target,
173
+ error
174
+ })));
175
+ yield* emit({
176
+ _tag: "Validated",
177
+ path: target
178
+ });
179
+ return validated;
180
+ });
181
+ const discover = Effect.fn("ConfigFile.discover")(function* () {
182
+ const sources = [];
183
+ for (const resolver of options.resolvers) {
184
+ const found = yield* Effect.provide(resolver.resolve, resolverEnv);
185
+ if (Option.isSome(found)) {
186
+ const target = found.value;
187
+ yield* emit({
188
+ _tag: "Discovered",
189
+ path: target,
190
+ resolver: resolver.name
191
+ });
192
+ sources.push({
193
+ path: target,
194
+ resolver: resolver.name,
195
+ value: yield* loadFrom(target)
196
+ });
197
+ }
198
+ }
199
+ return sources;
200
+ });
201
+ const searched = options.resolvers.map((r) => r.name);
202
+ /**
203
+ * Merge the discovered sources and announce the result. Shared by `load` and
204
+ * `loadOrDefault`, which in v3 each re-inlined it and drifted apart.
205
+ *
206
+ * Both events carry EVERY contributing source. v3 reported `sources[0].path`,
207
+ * which is wrong under `layeredMerge`, where all of them contributed.
208
+ */
209
+ const mergeAndEmit = (sources) => Effect.gen(function* () {
210
+ const value = yield* options.strategy.resolve(sources);
211
+ const refs = sourceRefs(sources);
212
+ yield* emit({
213
+ _tag: "Resolved",
214
+ sources: refs,
215
+ strategy: options.strategy.name
216
+ });
217
+ yield* emit({
218
+ _tag: "Loaded",
219
+ sources: refs
220
+ });
221
+ return value;
222
+ });
223
+ const load = Effect.fn("ConfigFile.load")(function* () {
224
+ const sources = yield* discover();
225
+ if (sources.length === 0) {
226
+ yield* emit({ _tag: "NotFound" });
227
+ return yield* Effect.fail(new ConfigFileNotFoundError({ searched }));
228
+ }
229
+ return yield* mergeAndEmit(sources);
230
+ });
231
+ const loadOrDefault = Effect.fn("ConfigFile.loadOrDefault")(function* (defaultValue) {
232
+ const sources = yield* discover();
233
+ if (sources.length === 0) {
234
+ yield* emit({ _tag: "NotFound" });
235
+ return defaultValue;
236
+ }
237
+ return yield* mergeAndEmit(sources);
238
+ });
239
+ const validate = Effect.fn("ConfigFile.validate")(function* (value) {
240
+ const decoded = yield* decode(value, Option.none());
241
+ return yield* runValidate(decoded);
242
+ });
243
+ /**
244
+ * Shared by `write` and `save`. Not an `Effect.fn`: it is internal, and the
245
+ * public boundaries that call it already open a span.
246
+ */
247
+ const encodeAndWrite = (value, target) => Effect.gen(function* () {
248
+ const encoded = yield* Schema.encodeEffect(options.schema)(value).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(new ConfigValidationError({
249
+ path: Option.some(target),
250
+ issue: error.issue
251
+ }))));
252
+ const serialized = yield* options.codec.stringify(encoded).pipe(Effect.tapError((error) => emit({
253
+ _tag: "StringifyFailed",
254
+ codec: options.codec.name,
255
+ error
256
+ })));
257
+ yield* fs.writeFileString(target, serialized).pipe(Effect.mapError((cause) => new ConfigFileWriteError({
258
+ path: target,
259
+ cause
260
+ })));
261
+ });
262
+ /**
263
+ * Resolve `defaultPath`, `mkdir -p` its parent and write. Shared by `save`
264
+ * and `update`, and — crucially — emits nothing.
265
+ *
266
+ * v3's `update` called the public `save`, so a single `update` published
267
+ * `Written` + `Saved` + `Updated`; v3 documented this as a known smell. Event
268
+ * granularity is per-operation, so the emitting boundary must sit in the
269
+ * public method, never in the shared internals it delegates to.
270
+ */
271
+ const saveTo = (value) => Effect.gen(function* () {
272
+ const configured = options.defaultPath;
273
+ if (configured === void 0) return yield* Effect.fail(new ConfigDefaultPathMissingError({}));
274
+ const target = yield* Effect.provide(configured, resolverEnv);
275
+ yield* fs.makeDirectory(path.dirname(target), { recursive: true }).pipe(Effect.mapError((cause) => new ConfigFileWriteError({
276
+ path: target,
277
+ cause
278
+ })));
279
+ yield* encodeAndWrite(value, target);
280
+ return target;
281
+ });
282
+ const write = Effect.fn("ConfigFile.write")(function* (value, target) {
283
+ yield* encodeAndWrite(value, target);
284
+ yield* emit({
285
+ _tag: "Written",
286
+ path: target
287
+ });
288
+ });
289
+ const save = Effect.fn("ConfigFile.save")(function* (value) {
290
+ const target = yield* saveTo(value);
291
+ yield* emit({
292
+ _tag: "Saved",
293
+ path: target
294
+ });
295
+ return target;
296
+ });
297
+ /**
298
+ * Serializes `update`'s read-modify-write. One permit, one service instance.
299
+ *
300
+ * @remarks
301
+ * `update` is load → transform → save. Without a lock, two concurrent calls
302
+ * both read the old document across the read's async boundary and both write
303
+ * their own transform of it, so one caller's change is silently lost. The
304
+ * lock covers the whole critical section, not just the write.
305
+ *
306
+ * This guards a single service instance in a single process. It is not a file
307
+ * lock: another process writing the same path concurrently can still clobber.
308
+ */
309
+ const updateLock = Semaphore.makeUnsafe(1);
310
+ const update = Effect.fn("ConfigFile.update")(function* (fn, defaultValue) {
311
+ return yield* updateLock.withPermits(1)(Effect.gen(function* () {
312
+ const updated = fn(defaultValue !== void 0 ? yield* loadOrDefault(defaultValue) : yield* load());
313
+ const target = yield* saveTo(updated);
314
+ yield* emit({
315
+ _tag: "Updated",
316
+ path: target
317
+ });
318
+ return updated;
319
+ }));
320
+ });
321
+ return {
322
+ load: load(),
323
+ loadFrom,
324
+ discover: discover(),
325
+ loadOrDefault,
326
+ validate,
327
+ write,
328
+ save,
329
+ update
330
+ };
331
+ };
332
+ /**
333
+ * Build the live layer for a config service class.
334
+ *
335
+ * @remarks
336
+ * Resolver requirements flow into the layer's `R` type. v3 cast them away with
337
+ * `as Effect.Effect<Option<string>>`, making `Layer<Service, never, FileSystem>`
338
+ * a claim rather than a proof.
339
+ *
340
+ * `ConfigFile.layer` is a layer-RETURNING function, not a layer: calling it
341
+ * twice builds two independent service instances. Bind its result to a const
342
+ * and provide that const, per the memoization discipline — do not call
343
+ * `ConfigFile.layer(...)` inline at each provide site.
344
+ *
345
+ * @public
346
+ */
347
+ const layer = (tag, options) => Layer.effect(tag, Effect.gen(function* () {
348
+ const fs = yield* FileSystem.FileSystem;
349
+ const path = yield* Path.Path;
350
+ const resolverEnv = yield* Effect.context();
351
+ return makeImpl(options, fs, path, resolverEnv);
352
+ }));
353
+ /**
354
+ * A scoped layer that seeds `files` into a temp directory, wires the **real**
355
+ * live implementation over them, and removes the directory when the scope
356
+ * closes.
357
+ *
358
+ * @remarks
359
+ * Deliberately not a mock. It delegates to the very same `makeImpl` that
360
+ * {@link ConfigFile.layer} uses, so tests exercise the actual codec, resolver
361
+ * and merge pipeline rather than a parallel implementation that can drift from
362
+ * it. A stubbed test layer would make every downstream test a claim about the
363
+ * stub instead of about the code under test.
364
+ *
365
+ * Platform-agnostic: the consumer supplies the `FileSystem` layer, and the temp
366
+ * directory is created through `FileSystem.makeTempDirectory` rather than
367
+ * `node:fs`.
368
+ *
369
+ * `Layer.scoped` does not exist in v4. `Layer.effect` types its layer as
370
+ * `Layer<I, E, Exclude<R, Scope>>`, so an `Effect.addFinalizer` inside it binds
371
+ * to the layer's own scope and runs on release without surfacing `Scope` in the
372
+ * layer's requirements.
373
+ *
374
+ * @example
375
+ * ```ts
376
+ * const TestConfig = ConfigFile.testLayer(AppConfig, {
377
+ * schema: AppShape,
378
+ * codec: JsonCodec,
379
+ * strategy: MergeStrategy.firstMatch<AppShape>(),
380
+ * files: { ".apprc": `{"port":4242}` },
381
+ * }).pipe(Layer.provide(NodeServices.layer));
382
+ * ```
383
+ *
384
+ * @public
385
+ */
386
+ const testLayer = (tag, options) => Layer.effect(tag, Effect.gen(function* () {
387
+ const fs = yield* FileSystem.FileSystem;
388
+ const path = yield* Path.Path;
389
+ const resolverEnv = yield* Effect.context();
390
+ const dir = yield* fs.makeTempDirectory({ prefix: "effected-config-file-" }).pipe(Effect.orDie);
391
+ yield* Effect.addFinalizer(() => fs.remove(dir, { recursive: true }).pipe(Effect.orDie));
392
+ for (const [name, content] of Object.entries(options.files)) {
393
+ const target = path.join(dir, name);
394
+ yield* fs.makeDirectory(path.dirname(target), { recursive: true }).pipe(Effect.orDie);
395
+ yield* fs.writeFileString(target, content).pipe(Effect.orDie);
396
+ }
397
+ const resolvers = Object.keys(options.files).map((name) => ConfigResolver.staticDir({
398
+ dir,
399
+ filename: name
400
+ }));
401
+ return makeImpl({
402
+ schema: options.schema,
403
+ codec: options.codec,
404
+ strategy: options.strategy,
405
+ resolvers,
406
+ ...options.validate !== void 0 && { validate: options.validate }
407
+ }, fs, path, resolverEnv);
408
+ }));
409
+ /**
410
+ * The config file service: a per-schema service factory and its layers.
411
+ *
412
+ * @public
413
+ */
414
+ const ConfigFile = {
415
+ Service,
416
+ layer,
417
+ testLayer
418
+ };
419
+
420
+ //#endregion
421
+ export { ConfigDefaultPathMissingError, ConfigFile, ConfigFileNotFoundError, ConfigFileReadError, ConfigFileWriteError, ConfigValidationError };
@@ -0,0 +1,97 @@
1
+ import { Effect, Schema } from "effect";
2
+
3
+ //#region src/ConfigMigration.ts
4
+ /**
5
+ * Indicates that a versioned config migration failed.
6
+ *
7
+ * @remarks
8
+ * `phase` says where: reading the current version, applying a step, or writing
9
+ * the new version back. `cause` preserves the underlying failure by identity
10
+ * when the failing step signals recoverable failure with `Effect.fail`. v3
11
+ * assembled all three into a prose `reason` string.
12
+ *
13
+ * @public
14
+ */
15
+ var ConfigMigrationError = class extends Schema.TaggedErrorClass()("ConfigMigrationError", {
16
+ /** The target version of the step that failed. `0` when reading the version failed. */
17
+ version: Schema.Number,
18
+ /** The name of the step that failed; empty when reading the version failed. */
19
+ name: Schema.String,
20
+ /** Which stage of a migration step failed. */
21
+ phase: Schema.Literals([
22
+ "read-version",
23
+ "apply",
24
+ "write-version"
25
+ ]),
26
+ /** The underlying failure, preserved structurally. */
27
+ cause: Schema.Defect()
28
+ }) {
29
+ get message() {
30
+ return this.phase === "read-version" ? "Failed to read the config version" : `Migration "${this.name}" (v${this.version}) failed during ${this.phase}`;
31
+ }
32
+ };
33
+ const defaultVersionAccess = {
34
+ get: (raw) => {
35
+ if (typeof raw !== "object" || raw === null) return Effect.fail(/* @__PURE__ */ new Error("config is not an object"));
36
+ const version = raw.version;
37
+ return typeof version === "number" ? Effect.succeed(version) : Effect.fail(/* @__PURE__ */ new Error("version field is missing or not a number"));
38
+ },
39
+ set: (raw, version) => Effect.succeed({
40
+ ...raw,
41
+ version
42
+ })
43
+ };
44
+ /** Reads and writes a top-level `version` field. @public */
45
+ const VersionAccess = { default: defaultVersionAccess };
46
+ /**
47
+ * Runs one migration phase, mapping its declared failure into a
48
+ * {@link ConfigMigrationError}.
49
+ *
50
+ * @remarks
51
+ * `up` and {@link (VersionAccess:interface)} are caller-supplied code with a declared error
52
+ * channel: they signal failure with `Effect.fail`. A `throw` from one of them is
53
+ * a contract violation — a programmer bug, not a data condition — and stays a
54
+ * defect so a consumer's `catchTag("ConfigMigrationError")` cannot silently
55
+ * swallow it. `Effect.suspend` ensures a throw raised while constructing the
56
+ * effect dies exactly like a throw raised while running it.
57
+ */
58
+ const runPhase = (phase, version, name, run) => Effect.suspend(run).pipe(Effect.mapError((cause) => new ConfigMigrationError({
59
+ version,
60
+ name,
61
+ phase,
62
+ cause
63
+ })));
64
+ /**
65
+ * Wrap a codec so that parsed content is brought up to the latest version.
66
+ *
67
+ * @remarks
68
+ * The returned codec's error channel **widens** to include
69
+ * {@link ConfigMigrationError} rather than flattening migration failures into
70
+ * the inner codec's error — the reason the {@link (ConfigCodec:interface)} seam is generic
71
+ * in its error type.
72
+ *
73
+ * @public
74
+ */
75
+ const make = (options) => {
76
+ const access = options.versionAccess ?? VersionAccess.default;
77
+ const sorted = [...options.migrations].sort((a, b) => a.version - b.version);
78
+ return {
79
+ name: options.codec.name,
80
+ stringify: options.codec.stringify,
81
+ parse: (raw) => Effect.gen(function* () {
82
+ let parsed = yield* options.codec.parse(raw);
83
+ if (sorted.length === 0) return parsed;
84
+ const current = yield* runPhase("read-version", 0, "", () => access.get(parsed));
85
+ for (const migration of sorted.filter((m) => m.version > current)) {
86
+ parsed = yield* runPhase("apply", migration.version, migration.name, () => migration.up(parsed));
87
+ parsed = yield* runPhase("write-version", migration.version, migration.name, () => access.set(parsed, migration.version));
88
+ }
89
+ return parsed;
90
+ })
91
+ };
92
+ };
93
+ /** Versioned migration support for config codecs. @public */
94
+ const ConfigMigration = { make };
95
+
96
+ //#endregion
97
+ export { ConfigMigration, ConfigMigrationError, VersionAccess };