@effected/config-file 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ConfigCodec.js +27 -0
- package/ConfigEvent.js +119 -0
- package/ConfigFile.js +421 -0
- package/ConfigMigration.js +97 -0
- package/ConfigProvider.js +87 -0
- package/ConfigResolver.js +95 -0
- package/EncryptedCodec.js +123 -0
- package/JsonCodec.js +38 -0
- package/JsoncCodec.js +38 -0
- package/LICENSE +21 -0
- package/MergeStrategy.js +53 -0
- package/README.md +176 -0
- package/TomlCodec.js +39 -0
- package/YamlCodec.js +34 -0
- package/index.d.ts +932 -0
- package/index.js +14 -0
- package/internal/crypto.js +115 -0
- package/internal/deepMerge.js +80 -0
- package/package.json +54 -0
- package/tsdoc-metadata.json +11 -0
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 };
|