1import { describe, it } from "@effect/vitest"
2import { deepStrictEqual } from "@effect/vitest/utils"
3import { ConfigProvider, Effect, FileSystem, Layer, Path, PlatformError, Result } from "effect"
4
5const notFound = (method: string): PlatformError.PlatformError =>
6 PlatformError.systemError({
7 module: "FileSystem",
8 _tag: "NotFound",
9 method
10 })
11
12async function assertSuccess(
13 provider: ConfigProvider.ConfigProvider,
14 path: ConfigProvider.Path,
15 expected: ConfigProvider.Node
16) {
17 const r = Effect.result(provider.load(path))
18 deepStrictEqual(await Effect.runPromise(r), Result.succeed(expected))
19}
20
21async function assertMissing(
22 provider: ConfigProvider.ConfigProvider,
23 path: ConfigProvider.Path
24) {
25 const r = Effect.result(provider.load(path))
26 deepStrictEqual(await Effect.runPromise(r), Result.succeed(undefined))
27}
28
29async function assertFailure(
30 provider: ConfigProvider.ConfigProvider,
31 path: ConfigProvider.Path,
32 expected: ConfigProvider.SourceError
33) {
34 const r = Effect.result(provider.load(path))
35 deepStrictEqual(await Effect.runPromise(r), Result.fail(expected))
36}
37
38describe("ConfigProvider", () => {
39 describe("make", () => {
40 it.effect("exposes lookup absence and input transformation as provider behavior", () =>
41 Effect.gen(function*() {
42 const provider = ConfigProvider.fromUnknown({
43 APP: {
44 PORT: "3000"
45 }
46 })
47 const nested = provider.mapInput((path) => ["APP", ...path])
48
49 deepStrictEqual(
50 yield* nested.load(["PORT"]),
51 ConfigProvider.makeValue("3000")
52 )
53 deepStrictEqual(yield* nested.load(["MISSING"]), undefined)
54 }))
55
56 it("creates a provider from a lookup function", async () => {
57 const provider = ConfigProvider.make((path) =>
58 Effect.succeed(
59 path.join(".") === "A.B"
60 ? ConfigProvider.makeValue("value")
61 : undefined
62 )
63 )
64
65 await assertSuccess(provider, ["A", "B"], ConfigProvider.makeValue("value"))
66 await assertMissing(provider, ["missing"])
67 })
68
69 it("preserves SourceError failures", async () => {
70 const error = new ConfigProvider.SourceError({ message: "source unavailable" })
71 const provider = ConfigProvider.make(() => Effect.fail(error))
72
73 await assertFailure(provider, ["A"], error)
74 })
75 })
76
77 describe("combinators", () => {
78 describe("orElse", () => {
79 it("uses the fallback when the primary provider is missing the path", async () => {
80 const primary = ConfigProvider.fromEnv({
81 env: {
82 "A": "value1"
83 }
84 })
85 const fallback = ConfigProvider.fromEnv({
86 env: {
87 "B": "value2"
88 }
89 })
90 const provider = ConfigProvider.orElse(primary, fallback)
91 await assertSuccess(provider, ["A"], ConfigProvider.makeValue("value1"))
92 await assertSuccess(provider, ["B"], ConfigProvider.makeValue("value2"))
93 })
94
95 it("does not use the fallback after a SourceError", async () => {
96 const error = new ConfigProvider.SourceError({ message: "io down" })
97 const primary = ConfigProvider.make(() => Effect.fail(error))
98 const fallback = ConfigProvider.fromEnv({ env: { KEY: "fallback" } })
99 const provider = primary.pipe(ConfigProvider.orElse(fallback))
100 await assertFailure(provider, ["KEY"], error)
101 })
102
103 it("does not evaluate the fallback after the primary provider finds a node", async () => {
104 const primary = ConfigProvider.fromUnknown({ KEY: "primary" })
105 const fallback = ConfigProvider.make(() =>
106 Effect.fail(new ConfigProvider.SourceError({ message: "fallback evaluated" }))
107 )
108 const provider = primary.pipe(ConfigProvider.orElse(fallback))
109
110 await assertSuccess(provider, ["KEY"], ConfigProvider.makeValue("primary"))
111 })
112
113 it("preserves each operand's transformations", async () => {
114 const primary = ConfigProvider.fromEnv({
115 env: {
116 "DATABASE_HOST": "from-env"
117 }
118 }).pipe(ConfigProvider.constantCase)
119 const fallback = ConfigProvider.fromEnv({
120 env: {
121 "APP_PORT": "3000"
122 }
123 }).pipe(ConfigProvider.nested("APP"))
124 const provider = primary.pipe(ConfigProvider.orElse(fallback))
125 await assertSuccess(provider, ["databaseHost"], ConfigProvider.makeValue("from-env"))
126 await assertSuccess(provider, ["PORT"], ConfigProvider.makeValue("3000"))
127 })
128
129 it("distributes mapInput over both operands", async () => {
130 const appendSuffix = ConfigProvider.mapInput((path) =>
131 path.map((seg) => typeof seg === "string" ? `${seg}_SUFFIX` : seg)
132 )
133 const primary = ConfigProvider.fromEnv({
134 env: {
135 "prefix_SUFFIX_KEY_SUFFIX": "primary"
136 }
137 }).pipe(ConfigProvider.nested("prefix"))
138 const fallback = ConfigProvider.fromEnv({
139 env: {
140 "fallback_SUFFIX_KEY_SUFFIX": "fallback",
141 "fallback_SUFFIX_OTHER_SUFFIX": "fallback"
142 }
143 }).pipe(ConfigProvider.nested("fallback"))
144 const provider = primary.pipe(ConfigProvider.orElse(fallback), appendSuffix)
145 await assertSuccess(provider, ["KEY"], ConfigProvider.makeValue("primary"))
146 await assertSuccess(provider, ["OTHER"], ConfigProvider.makeValue("fallback"))
147 })
148
149 it("distributes nested over both operands", async () => {
150 const primary = ConfigProvider.fromEnv({
151 env: {
152 "app_DATABASE_HOST": "primary"
153 }
154 }).pipe(ConfigProvider.constantCase)
155 const fallback = ConfigProvider.fromEnv({
156 env: {
157 "app_PORT": "fallback"
158 }
159 })
160 const provider = primary.pipe(ConfigProvider.orElse(fallback), ConfigProvider.nested("app"))
161 await assertSuccess(provider, ["databaseHost"], ConfigProvider.makeValue("primary"))
162 await assertSuccess(provider, ["PORT"], ConfigProvider.makeValue("fallback"))
163 })
164
165 it("uses the fallback when the primary value is an empty string", async () => {
166 const primary = ConfigProvider.fromEnv({
167 env: {
168 "KEY": ""
169 }
170 })
171 const fallback = ConfigProvider.fromUnknown({ KEY: "fallback" })
172 const provider = primary.pipe(ConfigProvider.orElse(fallback))
173 await assertSuccess(provider, ["KEY"], ConfigProvider.makeValue("fallback"))
174 })
175
176 it("does not merge a fallback container into an existing primary container", async () => {
177 const primary = ConfigProvider.fromUnknown({ primary: "value1" })
178 const fallback = ConfigProvider.fromUnknown({ fallback: "value2" })
179 const provider = primary.pipe(ConfigProvider.orElse(fallback))
180
181 await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set(["primary"])))
182 await assertSuccess(provider, ["fallback"], ConfigProvider.makeValue("value2"))
183 })
184 })
185
186 describe("mapInput", () => {
187 it("composes transformations in application order", async () => {
188 const appendB = ConfigProvider.mapInput((path) => path.map((sn) => typeof sn === "string" ? sn + "_B" : sn))
189 const provider = ConfigProvider.mapInput(
190 ConfigProvider.fromEnv({
191 env: {
192 "KEY_A_B": "value"
193 }
194 }),
195 (path) => path.map((sn) => typeof sn === "string" ? sn + "_A" : sn)
196 ).pipe(appendB)
197 await assertSuccess(provider, ["KEY"], ConfigProvider.makeValue("value"))
198 })
199 })
200
201 describe("constantCase", () => {
202 it("converts string path segments to config case", async () => {
203 const provider = ConfigProvider.constantCase(ConfigProvider.fromEnv({
204 env: {
205 "CONSTANT_CASE": "value1"
206 }
207 }))
208 await assertSuccess(provider, ["constant.case"], ConfigProvider.makeValue("value1"))
209 })
210
211 it("preserves numeric word groups", async () => {
212 const provider = ConfigProvider.constantCase(ConfigProvider.fromEnv({
213 env: {
214 "API_V2_XML": "value1",
215 "FIELD2_VALUE": "value2"
216 }
217 }))
218 await assertSuccess(provider, ["api-v2 xml"], ConfigProvider.makeValue("value1"))
219 await assertSuccess(provider, ["field2Value"], ConfigProvider.makeValue("value2"))
220 })
221
222 it("leaves numeric path segments unchanged", async () => {
223 const provider = ConfigProvider.fromUnknown({
224 ITEMS: ["value"]
225 }).pipe(ConfigProvider.constantCase)
226
227 await assertSuccess(provider, ["items", 0], ConfigProvider.makeValue("value"))
228 })
229 })
230
231 describe("nested", () => {
232 it("adds a string prefix to the path", async () => {
233 const provider = ConfigProvider.fromEnv({
234 env: {
235 "prefix_A": "value"
236 }
237 }).pipe(ConfigProvider.nested("prefix"))
238 await assertSuccess(provider, ["A"], ConfigProvider.makeValue("value"))
239 })
240
241 it("adds a Path prefix with string and numeric segments", async () => {
242 const provider = ConfigProvider.nested(
243 ConfigProvider.fromUnknown({
244 apps: [{ PORT: "3000" }]
245 }),
246 ["apps", 0]
247 )
248
249 await assertSuccess(provider, ["PORT"], ConfigProvider.makeValue("3000"))
250 })
251
252 it("preserves order when constantCase is applied before nested", async () => {
253 const provider = ConfigProvider.fromEnv({
254 env: {
255 "prefix_KEY_WITH_DOTS": "value"
256 }
257 }).pipe(ConfigProvider.constantCase, ConfigProvider.nested("prefix"))
258 await assertSuccess(provider, ["key.with.dots"], ConfigProvider.makeValue("value"))
259 })
260
261 it("preserves order when constantCase is applied after nested", async () => {
262 const provider = ConfigProvider.fromEnv({
263 env: {
264 "PREFIX_WITH_DOTS_KEY_WITH_DOTS": "value"
265 }
266 }).pipe(ConfigProvider.nested("prefix.with.dots"), ConfigProvider.constantCase)
267 await assertSuccess(provider, ["key.with.dots"], ConfigProvider.makeValue("value"))
268 })
269
270 it("composes multiple prefixes as wrappers", async () => {
271 const provider = ConfigProvider.fromEnv({
272 env: {
273 "b_a_KEY": "value"
274 }
275 }).pipe(ConfigProvider.nested("a"), ConfigProvider.nested("b"))
276 await assertSuccess(provider, ["KEY"], ConfigProvider.makeValue("value"))
277 })
278
279 it("allows a later mapInput to transform the prefixed path", async () => {
280 const appendLeaf = ConfigProvider.mapInput((path) => [...path, "leaf"])
281 const provider = ConfigProvider.fromEnv({
282 env: {
283 "app_KEY_leaf": "value"
284 }
285 }).pipe(ConfigProvider.nested("app"), appendLeaf)
286 await assertSuccess(provider, ["KEY"], ConfigProvider.makeValue("value"))
287 })
288 })
289 })
290
291 describe("fromEnvRecord", () => {
292 it("reads defined values and skips undefined values", async () => {
293 const env: Record<string, string | undefined> = {
294 DEFINED: "value",
295 UNDEFINED: undefined
296 }
297 const provider = ConfigProvider.fromEnvRecord(env)
298
299 await assertSuccess(provider, ["DEFINED"], ConfigProvider.makeValue("value"))
300 await assertMissing(provider, ["UNDEFINED"])
301 await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set(["DEFINED"])))
302 })
303
304 it("treats empty strings as missing by default", async () => {
305 await assertMissing(ConfigProvider.fromEnvRecord({ EMPTY: "" }), ["EMPTY"])
306 })
307
308 it("preserves empty strings when requested", async () => {
309 const provider = ConfigProvider.fromEnvRecord(
310 { EMPTY: "" },
311 { preserveEmptyStrings: true }
312 )
313
314 await assertSuccess(provider, ["EMPTY"], ConfigProvider.makeValue(""))
315 })
316 })
317
318 describe("fromEnv", () => {
319 it("env without an underscore", async () => {
320 const env = { A: "value1" }
321 const provider = ConfigProvider.fromEnv({ env })
322 await assertSuccess(provider, ["A"], ConfigProvider.makeValue("value1"))
323 })
324
325 it("missing key returns None", async () => {
326 const env = { A: "value1" }
327 const provider = ConfigProvider.fromEnv({ env })
328 await assertMissing(provider, ["missing"])
329 })
330
331 it("does not read inherited environment properties", async () => {
332 const provider = ConfigProvider.fromEnv({ env: {} })
333 await assertMissing(provider, ["constructor"])
334 })
335
336 it("does not traverse inherited trie properties", async () => {
337 delete (Object as any).children
338 const provider = ConfigProvider.fromEnv({ env: { constructor_X: "value" } })
339 const polluted = Object.hasOwn(Object, "children")
340 delete (Object as any).children
341
342 deepStrictEqual(polluted, false)
343 await assertSuccess(provider, ["constructor", "X"], ConfigProvider.makeValue("value"))
344 })
345
346 it("treats empty string values as missing while preserving structure by default", async () => {
347 const env = { A: "", B: "value1" }
348 const provider = ConfigProvider.fromEnv({ env })
349
350 await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set(["A", "B"])))
351 await assertMissing(provider, ["A"])
352 await assertSuccess(provider, ["B"], ConfigProvider.makeValue("value1"))
353 })
354
355 it("preserves empty strings when requested", async () => {
356 const env = { A: "" }
357 const provider = ConfigProvider.fromEnv({ env, preserveEmptyStrings: true })
358
359 await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set(["A"])))
360 await assertSuccess(provider, ["A"], ConfigProvider.makeValue(""))
361 })
362
363 it("treats empty co-located record values as missing and preserves children", async () => {
364 const env = { A: "", A_B: "value1" }
365 const provider = ConfigProvider.fromEnv({ env })
366
367 await assertSuccess(provider, ["A"], ConfigProvider.makeRecord(new Set(["B"])))
368 await assertSuccess(provider, ["A", "B"], ConfigProvider.makeValue("value1"))
369 })
370
371 it("preserves empty co-located record values when requested", async () => {
372 const env = { A: "", A_B: "value1" }
373 const provider = ConfigProvider.fromEnv({ env, preserveEmptyStrings: true })
374
375 await assertSuccess(provider, ["A"], ConfigProvider.makeRecord(new Set(["B"]), ""))
376 await assertSuccess(provider, ["A", "B"], ConfigProvider.makeValue("value1"))
377 })
378
379 it("treats empty co-located array values as missing and preserves children", async () => {
380 const env = { A: "", A_0: "value1" }
381 const provider = ConfigProvider.fromEnv({ env })
382
383 await assertSuccess(provider, ["A"], ConfigProvider.makeArray(1))
384 await assertSuccess(provider, ["A", 0], ConfigProvider.makeValue("value1"))
385 })
386
387 it("preserves empty array children structurally while treating their values as missing", async () => {
388 const env = { A_0: "", A_1: "value1" }
389 const provider = ConfigProvider.fromEnv({ env })
390
391 await assertSuccess(provider, ["A"], ConfigProvider.makeArray(2))
392 await assertMissing(provider, ["A", 0])
393 await assertSuccess(provider, ["A", 1], ConfigProvider.makeValue("value1"))
394 })
395
396 it("direct lookup of a key containing underscores", async () => {
397 const env = { NODE_ENV: "value1" }
398 const provider = ConfigProvider.fromEnv({ env })
399 await assertSuccess(provider, ["NODE_ENV"], ConfigProvider.makeValue("value1"))
400 })
401
402 it("single underscore creates nesting", async () => {
403 const env = { NODE_ENV: "value1" }
404 const provider = ConfigProvider.fromEnv({ env })
405
406 await assertSuccess(provider, ["NODE"], ConfigProvider.makeRecord(new Set(["ENV"])))
407 await assertSuccess(provider, ["NODE", "ENV"], ConfigProvider.makeValue("value1"))
408 })
409
410 it("prefix can be both a value and a container", async () => {
411 const env = { NODE: "value1", NODE_ENV: "value2" }
412 const provider = ConfigProvider.fromEnv({ env })
413
414 await assertSuccess(provider, ["NODE"], ConfigProvider.makeRecord(new Set(["ENV"]), "value1"))
415 await assertSuccess(provider, ["NODE", "ENV"], ConfigProvider.makeValue("value2"))
416 await assertSuccess(provider, ["NODE_ENV"], ConfigProvider.makeValue("value2"))
417 })
418
419 it("multiple nested children under the same prefix", async () => {
420 const env = { NODE: "value1", NODE_ENV: "value2", NODE_PATH: "value3" }
421 const provider = ConfigProvider.fromEnv({ env })
422
423 await assertSuccess(provider, ["NODE"], ConfigProvider.makeRecord(new Set(["ENV", "PATH"]), "value1"))
424 await assertSuccess(provider, ["NODE", "ENV"], ConfigProvider.makeValue("value2"))
425 await assertSuccess(provider, ["NODE", "PATH"], ConfigProvider.makeValue("value3"))
426 await assertSuccess(provider, ["NODE_PATH"], ConfigProvider.makeValue("value3"))
427 })
428
429 it("nested depth > 2 works", async () => {
430 const env = { A_B_C: "value1" }
431 const provider = ConfigProvider.fromEnv({ env })
432
433 await assertSuccess(provider, ["A"], ConfigProvider.makeRecord(new Set(["B"])))
434 await assertSuccess(provider, ["A", "B"], ConfigProvider.makeRecord(new Set(["C"])))
435 await assertSuccess(provider, ["A", "B", "C"], ConfigProvider.makeValue("value1"))
436 })
437
438 it("array: dense numeric children yield an Array node", async () => {
439 const env = { A_0: "value1", A_1: "value2" }
440 const provider = ConfigProvider.fromEnv({ env })
441
442 await assertSuccess(provider, ["A"], ConfigProvider.makeArray(2))
443 await assertSuccess(provider, ["A", 0], ConfigProvider.makeValue("value1"))
444 await assertSuccess(provider, ["A", 1], ConfigProvider.makeValue("value2"))
445 })
446
447 it("array: non-dense numeric children still yield an Array node", async () => {
448 const env = { A: "root", A_0: "value1", A_2: "value3" }
449 const provider = ConfigProvider.fromEnv({ env })
450
451 // max index is 2 => length 3 (sparse is allowed)
452 await assertSuccess(provider, ["A"], ConfigProvider.makeArray(3, "root"))
453 await assertSuccess(provider, ["A", 0], ConfigProvider.makeValue("value1"))
454 await assertMissing(provider, ["A", 1])
455 await assertSuccess(provider, ["A", 2], ConfigProvider.makeValue("value3"))
456 })
457
458 it("array: parent can have a co-located value", async () => {
459 const env = { A: "root", A_0: "value1", A_1: "value2" }
460 const provider = ConfigProvider.fromEnv({ env })
461
462 await assertSuccess(provider, ["A"], ConfigProvider.makeArray(2, "root"))
463 await assertSuccess(provider, ["A", 0], ConfigProvider.makeValue("value1"))
464 await assertSuccess(provider, ["A", 1], ConfigProvider.makeValue("value2"))
465 })
466
467 it("mixed children (numeric + non-numeric) yields Record", async () => {
468 const env = { A_0: "value1", A_B: "value2" }
469 const provider = ConfigProvider.fromEnv({ env })
470
471 await assertSuccess(provider, ["A"], ConfigProvider.makeRecord(new Set(["0", "B"])))
472 await assertSuccess(provider, ["A", 0], ConfigProvider.makeValue("value1"))
473 await assertSuccess(provider, ["A", "B"], ConfigProvider.makeValue("value2"))
474 })
475
476 it("double underscore produces an empty segment (no special handling)", async () => {
477 const env = { A__B: "value1" }
478 const provider = ConfigProvider.fromEnv({ env })
479
480 await assertSuccess(provider, ["A"], ConfigProvider.makeRecord(new Set([""])))
481 await assertSuccess(provider, ["A", ""], ConfigProvider.makeRecord(new Set(["B"])))
482 await assertSuccess(provider, ["A", "", "B"], ConfigProvider.makeValue("value1"))
483 await assertSuccess(provider, ["A__B"], ConfigProvider.makeValue("value1"))
484 })
485
486 it("empty segment node can be both a value and a container", async () => {
487 const env = { A_: "value1", A__B: "value2" }
488 const provider = ConfigProvider.fromEnv({ env })
489
490 await assertSuccess(provider, ["A", ""], ConfigProvider.makeRecord(new Set(["B"]), "value1"))
491 await assertSuccess(provider, ["A", "", "B"], ConfigProvider.makeValue("value2"))
492 })
493
494 it("leading underscore creates an empty first segment", async () => {
495 const env = { _A: "value1" }
496 const provider = ConfigProvider.fromEnv({ env })
497
498 await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set([""])))
499 await assertSuccess(provider, [""], ConfigProvider.makeRecord(new Set(["A"])))
500 await assertSuccess(provider, ["", "A"], ConfigProvider.makeValue("value1"))
501 await assertSuccess(provider, ["_A"], ConfigProvider.makeValue("value1"))
502 })
503
504 it("trailing underscore creates an empty last segment", async () => {
505 const env = { A_: "value1" }
506 const provider = ConfigProvider.fromEnv({ env })
507
508 await assertSuccess(provider, ["A"], ConfigProvider.makeRecord(new Set([""])))
509 await assertSuccess(provider, ["A", ""], ConfigProvider.makeValue("value1"))
510 await assertSuccess(provider, ["A_"], ConfigProvider.makeValue("value1"))
511 })
512
513 it("cannot descend past a leaf (A exists, but A_B does not)", async () => {
514 const env = { A: "value1" }
515 const provider = ConfigProvider.fromEnv({ env })
516
517 await assertSuccess(provider, ["A"], ConfigProvider.makeValue("value1"))
518 await assertMissing(provider, ["A", "B"])
519 await assertMissing(provider, ["A_B"])
520 })
521
522 it("direct lookup and nested lookup both work for the same env var", async () => {
523 const env = { A_B: "value1" }
524 const provider = ConfigProvider.fromEnv({ env })
525
526 await assertSuccess(provider, ["A_B"], ConfigProvider.makeValue("value1"))
527 await assertSuccess(provider, ["A"], ConfigProvider.makeRecord(new Set(["B"])))
528 await assertSuccess(provider, ["A", "B"], ConfigProvider.makeValue("value1"))
529 })
530
531 it("prefix value is preserved when there are multiple nested children", async () => {
532 const env = { A: "root", A_B: "value1", A_C: "value2" }
533 const provider = ConfigProvider.fromEnv({ env })
534
535 await assertSuccess(provider, ["A"], ConfigProvider.makeRecord(new Set(["B", "C"]), "root"))
536 await assertSuccess(provider, ["A", "B"], ConfigProvider.makeValue("value1"))
537 await assertSuccess(provider, ["A", "C"], ConfigProvider.makeValue("value2"))
538 })
539
540 it("intermediate node can be both a value and a container (A_B and A_B_C)", async () => {
541 const env = { A_B: "value1", A_B_C: "value2" }
542 const provider = ConfigProvider.fromEnv({ env })
543
544 await assertSuccess(provider, ["A_B"], ConfigProvider.makeValue("value1"))
545 await assertSuccess(provider, ["A"], ConfigProvider.makeRecord(new Set(["B"])))
546 await assertSuccess(provider, ["A", "B"], ConfigProvider.makeRecord(new Set(["C"]), "value1"))
547 await assertSuccess(provider, ["A", "B", "C"], ConfigProvider.makeValue("value2"))
548 })
549
550 it("numeric index: string and number segments should resolve the same", async () => {
551 const env = { A_0: "value1" }
552 const provider = ConfigProvider.fromEnv({ env })
553
554 await assertSuccess(provider, ["A", 0], ConfigProvider.makeValue("value1"))
555 await assertSuccess(provider, ["A", "0"], ConfigProvider.makeValue("value1"))
556 })
557
558 it("array: leading-zero indices are not numeric, so it yields a Record", async () => {
559 const env = { A_01: "value1", A_1: "value2" }
560 const provider = ConfigProvider.fromEnv({ env })
561
562 await assertSuccess(provider, ["A"], ConfigProvider.makeRecord(new Set(["01", "1"])))
563 await assertSuccess(provider, ["A", "01"], ConfigProvider.makeValue("value1"))
564 await assertSuccess(provider, ["A", 1], ConfigProvider.makeValue("value2"))
565 })
566
567 it("array: values outside the JavaScript array index range yield a Record", async () => {
568 const env = { A_4294967295: "value1" }
569 const provider = ConfigProvider.fromEnv({ env })
570
571 await assertSuccess(provider, ["A"], ConfigProvider.makeRecord(new Set(["4294967295"])))
572 await assertSuccess(provider, ["A", "4294967295"], ConfigProvider.makeValue("value1"))
573 })
574
575 it("root path exposes top-level keys", async () => {
576 const env = { A: "value1", B_C: "value2" }
577 const provider = ConfigProvider.fromEnv({ env })
578
579 await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set(["A", "B"])))
580 await assertSuccess(provider, ["A"], ConfigProvider.makeValue("value1"))
581 await assertSuccess(provider, ["B"], ConfigProvider.makeRecord(new Set(["C"])))
582 })
583
584 it("underscore-only key creates empty segments", async () => {
585 const env = { "_": "value1" }
586 const provider = ConfigProvider.fromEnv({ env })
587
588 await assertSuccess(provider, ["_"], ConfigProvider.makeValue("value1"))
589 await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set([""])))
590 await assertSuccess(provider, [""], ConfigProvider.makeRecord(new Set([""])))
591 await assertSuccess(provider, ["", ""], ConfigProvider.makeValue("value1"))
592 })
593
594 it("array: non-integer indices do not yield an Array node", async () => {
595 const env = { "A_-1": "value1", "A_1.5": "value2" }
596 const provider = ConfigProvider.fromEnv({ env })
597
598 await assertSuccess(provider, ["A"], ConfigProvider.makeRecord(new Set(["-1", "1.5"])))
599 })
600 })
601
602 describe("fromUnknown", () => {
603 const provider = ConfigProvider.fromUnknown({
604 string: "value1",
605 record: {
606 key1: "value2",
607 key2: {
608 key3: "value3"
609 }
610 },
611 array: ["value4", {
612 key4: "value5"
613 }, ["value6"]],
614 null: null,
615 number: 42,
616 boolean: true,
617 bigint: 0n,
618 undefined,
619 unknown: Symbol("unknown")
620 })
621
622 it("Root node", async () => {
623 await assertSuccess(
624 provider,
625 [],
626 ConfigProvider.makeRecord(
627 new Set(["string", "record", "array", "null", "number", "boolean", "bigint", "undefined", "unknown"])
628 )
629 )
630 })
631
632 it("Exact leaf resolution", async () => {
633 await assertSuccess(provider, ["string"], ConfigProvider.makeValue("value1"))
634 await assertSuccess(provider, ["record", "key1"], ConfigProvider.makeValue("value2"))
635 await assertSuccess(provider, ["array", 0], ConfigProvider.makeValue("value4"))
636 await assertSuccess(provider, ["array", 1, "key4"], ConfigProvider.makeValue("value5"))
637 await assertSuccess(provider, ["array", 2, 0], ConfigProvider.makeValue("value6"))
638 })
639
640 it("treats empty string leaves as missing by default", async () => {
641 const provider = ConfigProvider.fromUnknown({
642 a: "",
643 b: "value1",
644 record: {
645 key: ""
646 },
647 array: [""]
648 })
649
650 await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set(["a", "b", "record", "array"])))
651 await assertMissing(provider, ["a"])
652 await assertSuccess(provider, ["b"], ConfigProvider.makeValue("value1"))
653 await assertSuccess(provider, ["record"], ConfigProvider.makeRecord(new Set(["key"])))
654 await assertMissing(provider, ["record", "key"])
655 await assertSuccess(provider, ["array"], ConfigProvider.makeArray(1))
656 await assertMissing(provider, ["array", 0])
657 })
658
659 it("preserves empty string leaves when requested", async () => {
660 const provider = ConfigProvider.fromUnknown(
661 {
662 a: "",
663 array: [""]
664 },
665 { preserveEmptyStrings: true }
666 )
667
668 await assertSuccess(provider, ["a"], ConfigProvider.makeValue(""))
669 await assertSuccess(provider, ["array", 0], ConfigProvider.makeValue(""))
670 })
671
672 it("Object detection", async () => {
673 await assertSuccess(provider, ["record"], ConfigProvider.makeRecord(new Set(["key1", "key2"])))
674 await assertSuccess(provider, ["record", "key2"], ConfigProvider.makeRecord(new Set(["key3"])))
675 })
676
677 it("Array detection", async () => {
678 await assertSuccess(provider, ["array"], ConfigProvider.makeArray(3))
679 await assertSuccess(provider, ["array", 2], ConfigProvider.makeArray(1))
680 })
681
682 it("should return None on non-existing paths", async () => {
683 await assertMissing(provider, ["string", "non-existing"])
684 await assertMissing(provider, ["record", "non-existing"])
685 await assertMissing(provider, ["array", 3, "non-existing"])
686 })
687
688 it("does not read inherited object properties", async () => {
689 const root = Object.assign(Object.create({ inherited: "value1" }), {
690 own: "value2"
691 })
692 const provider = ConfigProvider.fromUnknown(root)
693
694 await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set(["own"])))
695 await assertMissing(provider, ["inherited"])
696 await assertSuccess(provider, ["own"], ConfigProvider.makeValue("value2"))
697 })
698
699 it("null values", async () => {
700 await assertMissing(provider, ["null"])
701 })
702
703 it("number values", async () => {
704 await assertSuccess(provider, ["number"], ConfigProvider.makeValue("42"))
705 })
706
707 it("boolean values", async () => {
708 await assertSuccess(provider, ["boolean"], ConfigProvider.makeValue("true"))
709 })
710
711 it("bigint values", async () => {
712 await assertSuccess(provider, ["bigint"], ConfigProvider.makeValue("0"))
713 })
714
715 it("undefined values", async () => {
716 await assertMissing(provider, ["undefined"])
717 })
718
719 it("unknown values", async () => {
720 await assertSuccess(provider, ["unknown"], ConfigProvider.makeValue("Symbol(unknown)"))
721 })
722 })
723
724 describe("fromDotEnvContents", () => {
725 it("comments are ignored", async () => {
726 const provider = ConfigProvider.fromDotEnvContents(`
727# comments are ignored
728API_URL=https://api.example.com
729`)
730 await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set(["API"])))
731 await assertSuccess(provider, ["API_URL"], ConfigProvider.makeValue("https://api.example.com"))
732 })
733
734 it("export is allowed", async () => {
735 const provider = ConfigProvider.fromDotEnvContents(`
736export NODE_ENV=production
737`)
738 await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set(["NODE"])))
739 await assertSuccess(provider, ["NODE_ENV"], ConfigProvider.makeValue("production"))
740 })
741
742 it("supports single, double, and backtick quoted values", async () => {
743 const provider = ConfigProvider.fromDotEnvContents(`
744SINGLE='value # one'
745DOUBLE="line 1\\nline 2"
746BACKTICK=\`value # two\`
747`)
748 await assertSuccess(provider, ["SINGLE"], ConfigProvider.makeValue("value # one"))
749 await assertSuccess(provider, ["DOUBLE"], ConfigProvider.makeValue("line 1\nline 2"))
750 await assertSuccess(provider, ["BACKTICK"], ConfigProvider.makeValue("value # two"))
751 })
752
753 it("removes inline comments from unquoted values", async () => {
754 const provider = ConfigProvider.fromDotEnvContents("VALUE=value # comment")
755
756 await assertSuccess(provider, ["VALUE"], ConfigProvider.makeValue("value"))
757 })
758
759 it("objects are supported", async () => {
760 const provider = ConfigProvider.fromDotEnvContents(`
761OBJECT_key1=value1
762OBJECT_key2=value2
763`)
764 await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set(["OBJECT"])))
765 await assertSuccess(provider, ["OBJECT"], ConfigProvider.makeRecord(new Set(["key1", "key2"])))
766 await assertSuccess(provider, ["OBJECT", "key1"], ConfigProvider.makeValue("value1"))
767 await assertSuccess(provider, ["OBJECT", "key2"], ConfigProvider.makeValue("value2"))
768 })
769
770 it("treats empty string values as missing while preserving structure by default", async () => {
771 const provider = ConfigProvider.fromDotEnvContents(`
772A=
773B=value1
774`)
775 await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set(["A", "B"])))
776 await assertMissing(provider, ["A"])
777 await assertSuccess(provider, ["B"], ConfigProvider.makeValue("value1"))
778 })
779
780 it("preserves empty strings when requested", async () => {
781 const provider = ConfigProvider.fromDotEnvContents(
782 `
783A=
784`,
785 { preserveEmptyStrings: true }
786 )
787 await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set(["A"])))
788 await assertSuccess(provider, ["A"], ConfigProvider.makeValue(""))
789 })
790
791 it("a node may be both leaf and object", async () => {
792 const provider = ConfigProvider.fromDotEnvContents(`
793OBJECT=value1
794OBJECT_key1=value2
795OBJECT_key2=value3
796`)
797 await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set(["OBJECT"])))
798 await assertSuccess(provider, ["OBJECT"], ConfigProvider.makeRecord(new Set(["key1", "key2"]), "value1"))
799 await assertSuccess(provider, ["OBJECT", "key1"], ConfigProvider.makeValue("value2"))
800 await assertSuccess(provider, ["OBJECT", "key2"], ConfigProvider.makeValue("value3"))
801 })
802
803 it("a node may be both leaf and array", async () => {
804 const provider = ConfigProvider.fromDotEnvContents(`
805ARRAY=value1
806ARRAY_0=value2
807ARRAY_1=value3
808`)
809 await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set(["ARRAY"])))
810 await assertSuccess(provider, ["ARRAY"], ConfigProvider.makeArray(2, "value1"))
811 await assertSuccess(provider, ["ARRAY", 0], ConfigProvider.makeValue("value2"))
812 await assertSuccess(provider, ["ARRAY", 1], ConfigProvider.makeValue("value3"))
813 })
814
815 it("arrays are supported", async () => {
816 const provider = ConfigProvider.fromDotEnvContents(`
817ARRAY_0=value1
818ARRAY_1=value2
819`)
820 await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set(["ARRAY"])))
821 await assertSuccess(provider, ["ARRAY"], ConfigProvider.makeArray(2))
822 await assertSuccess(provider, ["ARRAY", 0], ConfigProvider.makeValue("value1"))
823 await assertSuccess(provider, ["ARRAY", 1], ConfigProvider.makeValue("value2"))
824 })
825
826 it("expansion of environment variables is off by default", async () => {
827 const provider = ConfigProvider.fromDotEnvContents(`
828PASSWORD="value"
829DB_PASS=$PASSWORD
830`)
831 await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set(["PASSWORD", "DB"])))
832 await assertSuccess(provider, ["PASSWORD"], ConfigProvider.makeValue("value"))
833 await assertSuccess(provider, ["DB_PASS"], ConfigProvider.makeValue("$PASSWORD"))
834 })
835
836 it("expansion of environment variables is supported", async () => {
837 const provider = ConfigProvider.fromDotEnvContents(
838 `
839PASSWORD="value"
840DB_PASS=\${PASSWORD}
841`,
842 { expandVariables: true }
843 )
844 await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set(["PASSWORD", "DB"])))
845 await assertSuccess(provider, ["PASSWORD"], ConfigProvider.makeValue("value"))
846 await assertSuccess(provider, ["DB_PASS"], ConfigProvider.makeValue("value"))
847 })
848
849 it("expands referenced values as literal data", async () => {
850 const provider = ConfigProvider.fromDotEnvContents(
851 `
852SOURCE=a$&b$'c$\`d
853TARGET=\${SOURCE}
854`,
855 { expandVariables: true }
856 )
857 await assertSuccess(provider, ["TARGET"], ConfigProvider.makeValue("a$&b$'c$`d"))
858 })
859
860 it("expansion defaults are used only for empty or unset variables", async () => {
861 const provider = ConfigProvider.fromDotEnvContents(
862 `
863SET=actual
864EMPTY=
865FROM_SET=\${SET:-fallback}
866FROM_EMPTY=\${EMPTY:-fallback}
867FROM_UNSET=\${UNSET:-fallback}
868`,
869 { expandVariables: true }
870 )
871 await assertSuccess(provider, ["FROM_SET"], ConfigProvider.makeValue("actual"))
872 await assertSuccess(provider, ["FROM_EMPTY"], ConfigProvider.makeValue("fallback"))
873 await assertSuccess(provider, ["FROM_UNSET"], ConfigProvider.makeValue("fallback"))
874 })
875
876 it("does not expand missing inherited variables", async () => {
877 const provider = ConfigProvider.fromDotEnvContents("VALUE=$constructor", {
878 expandVariables: true
879 })
880 await assertMissing(provider, ["VALUE"])
881 })
882 })
883
884 describe("fromDotEnv", () => {
885 it("should load configuration from .env file", async () => {
886 const provider = await Effect.runPromise(
887 ConfigProvider.fromDotEnv().pipe(
888 Effect.provide(FileSystem.layerNoop({
889 readFileString: (path) =>
890 Effect.succeed(`PATH=${path}
891A=1`)
892 }))
893 )
894 )
895
896 await assertSuccess(provider, ["PATH"], ConfigProvider.makeValue(".env"))
897 await assertSuccess(provider, ["A"], ConfigProvider.makeValue("1"))
898 })
899
900 it("should support `path` option to specify the path to the .env file", async () => {
901 const provider = await Effect.runPromise(
902 ConfigProvider.fromDotEnv({ path: "custom.env" }).pipe(
903 Effect.provide(FileSystem.layerNoop({
904 readFileString: (path) =>
905 Effect.succeed(`CUSTOM_PATH=${path}
906A=1`)
907 }))
908 )
909 )
910
911 await assertSuccess(provider, ["CUSTOM_PATH"], ConfigProvider.makeValue("custom.env"))
912 await assertSuccess(provider, ["A"], ConfigProvider.makeValue("1"))
913 })
914
915 it("should support `expandVariables` option", async () => {
916 const provider = await Effect.runPromise(
917 ConfigProvider.fromDotEnv({ expandVariables: true }).pipe(
918 Effect.provide(FileSystem.layerNoop({
919 readFileString: () =>
920 Effect.succeed(`PASSWORD=value
921DB_PASS=$PASSWORD`)
922 }))
923 )
924 )
925
926 await assertSuccess(provider, ["DB_PASS"], ConfigProvider.makeValue("value"))
927 })
928
929 it("should treat empty string values as missing while preserving structure by default", async () => {
930 const provider = await Effect.runPromise(
931 ConfigProvider.fromDotEnv().pipe(
932 Effect.provide(FileSystem.layerNoop({
933 readFileString: () => Effect.succeed("A=")
934 }))
935 )
936 )
937
938 await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set(["A"])))
939 await assertMissing(provider, ["A"])
940 })
941
942 it("should support `preserveEmptyStrings` option", async () => {
943 const provider = await Effect.runPromise(
944 ConfigProvider.fromDotEnv({ preserveEmptyStrings: true }).pipe(
945 Effect.provide(FileSystem.layerNoop({
946 readFileString: () => Effect.succeed("A=")
947 }))
948 )
949 )
950
951 await assertSuccess(provider, ["A"], ConfigProvider.makeValue(""))
952 })
953
954 it("fails with the FileSystem error when the file cannot be read", async () => {
955 const error = PlatformError.systemError({
956 module: "FileSystem",
957 _tag: "PermissionDenied",
958 method: "readFileString"
959 })
960 const result = await Effect.runPromise(
961 ConfigProvider.fromDotEnv().pipe(
962 Effect.provide(FileSystem.layerNoop({
963 readFileString: () => Effect.fail(error)
964 })),
965 Effect.result
966 )
967 )
968
969 deepStrictEqual(result, Result.fail(error))
970 })
971 })
972
973 describe("fromDir", () => {
974 const files: Record<string, string> = {
975 "/secret": "keepitsafe\n",
976 "/SHOUTING": "value",
977 "/integer": "123",
978 "/nested/config": "hello"
979 }
980 const Fs = FileSystem.layerNoop({
981 readFileString(path) {
982 if (path in files) {
983 return Effect.succeed(files[path])
984 }
985 return Effect.fail(notFound("readFileString"))
986 },
987 readDirectory(_path) {
988 return Effect.fail(notFound("readDirectory"))
989 }
990 })
991 const Platform = Layer.mergeAll(Fs, Path.layer)
992
993 it("reads trimmed file contents at root and nested paths", async () => {
994 const provider = await Effect.runPromise(
995 ConfigProvider.fromDir({ rootPath: "/" }).pipe(Effect.provide(Platform))
996 )
997
998 await assertSuccess(provider, ["secret"], ConfigProvider.makeValue("keepitsafe"))
999 await assertSuccess(provider, ["SHOUTING"], ConfigProvider.makeValue("value"))
1000 await assertSuccess(provider, ["integer"], ConfigProvider.makeValue("123"))
1001 await assertSuccess(provider, ["nested", "config"], ConfigProvider.makeValue("hello"))
1002 await assertMissing(provider, ["missing"])
1003 })
1004
1005 it("treats empty files as missing by default", async () => {
1006 const Fs = FileSystem.layerNoop({
1007 readFileString(path) {
1008 return path === "/empty"
1009 ? Effect.succeed("")
1010 : Effect.fail(notFound("readFileString"))
1011 },
1012 readDirectory(path) {
1013 return path === "/"
1014 ? Effect.succeed(["empty"])
1015 : Effect.fail(notFound("readDirectory"))
1016 }
1017 })
1018 const provider = await Effect.runPromise(
1019 ConfigProvider.fromDir({ rootPath: "/" }).pipe(Effect.provide(Layer.mergeAll(Fs, Path.layer)))
1020 )
1021
1022 await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set(["empty"])))
1023 await assertMissing(provider, ["empty"])
1024 })
1025
1026 it("preserves empty files when requested", async () => {
1027 const Fs = FileSystem.layerNoop({
1028 readFileString(path) {
1029 return path === "/empty"
1030 ? Effect.succeed("\n")
1031 : Effect.fail(notFound("readFileString"))
1032 },
1033 readDirectory() {
1034 return Effect.fail(notFound("readDirectory"))
1035 }
1036 })
1037 const provider = await Effect.runPromise(
1038 ConfigProvider.fromDir({ rootPath: "/", preserveEmptyStrings: true }).pipe(
1039 Effect.provide(Layer.mergeAll(Fs, Path.layer))
1040 )
1041 )
1042
1043 await assertSuccess(provider, ["empty"], ConfigProvider.makeValue(""))
1044 })
1045
1046 it("reads directory entries as a record", async () => {
1047 const dirs: Record<string, ReadonlyArray<string>> = {
1048 "/app": ["host", "port"]
1049 }
1050 const Fs = FileSystem.layerNoop({
1051 readFileString() {
1052 return Effect.fail(notFound("readFileString"))
1053 },
1054 readDirectory(path) {
1055 const entries = dirs[path]
1056 return entries
1057 ? Effect.succeed([...entries])
1058 : Effect.fail(notFound("readDirectory"))
1059 }
1060 })
1061 const provider = await Effect.runPromise(
1062 ConfigProvider.fromDir({ rootPath: "/" }).pipe(Effect.provide(Layer.mergeAll(Fs, Path.layer)))
1063 )
1064 await assertSuccess(provider, ["app"], ConfigProvider.makeRecord(new Set(["host", "port"])))
1065 })
1066
1067 it("wraps non-NotFound failures in SourceError", async () => {
1068 const cause = PlatformError.systemError({
1069 module: "FileSystem",
1070 _tag: "PermissionDenied",
1071 method: "readFileString"
1072 })
1073 const Fs = FileSystem.layerNoop({
1074 readFileString: () => Effect.fail(cause),
1075 readDirectory: () => Effect.fail(notFound("readDirectory"))
1076 })
1077 const provider = await Effect.runPromise(
1078 ConfigProvider.fromDir({ rootPath: "/config" }).pipe(
1079 Effect.provide(Layer.mergeAll(Fs, Path.layer))
1080 )
1081 )
1082
1083 await assertFailure(
1084 provider,
1085 ["secret"],
1086 new ConfigProvider.SourceError({
1087 message: "Failed to read file at /config/secret",
1088 cause
1089 })
1090 )
1091 })
1092 })
1093
1094 describe("layers", () => {
1095 const loadFromContext = (path: ConfigProvider.Path) =>
1096 Effect.gen(function*() {
1097 const provider = yield* ConfigProvider.ConfigProvider
1098 return yield* provider.load(path)
1099 })
1100
1101 it("layer installs a provider", async () => {
1102 const result = await Effect.runPromise(
1103 loadFromContext(["KEY"]).pipe(
1104 Effect.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({ KEY: "value" })))
1105 )
1106 )
1107
1108 deepStrictEqual(result, ConfigProvider.makeValue("value"))
1109 })
1110
1111 it("layer accepts an Effect that produces a provider", async () => {
1112 const result = await Effect.runPromise(
1113 loadFromContext(["KEY"]).pipe(
1114 Effect.provide(ConfigProvider.layer(Effect.succeed(ConfigProvider.fromUnknown({ KEY: "value" }))))
1115 )
1116 )
1117
1118 deepStrictEqual(result, ConfigProvider.makeValue("value"))
1119 })
1120
1121 it("layerAdd adds an Effect-produced provider as fallback", async () => {
1122 const current = ConfigProvider.fromUnknown({
1123 CURRENT: "current",
1124 SHARED: "current"
1125 })
1126 const fallback = ConfigProvider.fromUnknown({
1127 FALLBACK: "fallback",
1128 SHARED: "fallback"
1129 })
1130 const Added = ConfigProvider.layerAdd(Effect.succeed(fallback)).pipe(
1131 Layer.provide(ConfigProvider.layer(current))
1132 )
1133
1134 const result = await Effect.runPromise(
1135 Effect.all({
1136 current: loadFromContext(["CURRENT"]),
1137 fallback: loadFromContext(["FALLBACK"]),
1138 shared: loadFromContext(["SHARED"])
1139 }).pipe(Effect.provide(Added))
1140 )
1141
1142 deepStrictEqual(result, {
1143 current: ConfigProvider.makeValue("current"),
1144 fallback: ConfigProvider.makeValue("fallback"),
1145 shared: ConfigProvider.makeValue("current")
1146 })
1147 })
1148
1149 it("layerAdd can install the added provider as primary", async () => {
1150 const current = ConfigProvider.fromUnknown({
1151 CURRENT: "current",
1152 SHARED: "current"
1153 })
1154 const primary = ConfigProvider.fromUnknown({
1155 PRIMARY: "primary",
1156 SHARED: "primary"
1157 })
1158 const Added = ConfigProvider.layerAdd(primary, { asPrimary: true }).pipe(
1159 Layer.provide(ConfigProvider.layer(current))
1160 )
1161
1162 const result = await Effect.runPromise(
1163 Effect.all({
1164 current: loadFromContext(["CURRENT"]),
1165 primary: loadFromContext(["PRIMARY"]),
1166 shared: loadFromContext(["SHARED"])
1167 }).pipe(Effect.provide(Added))
1168 )
1169
1170 deepStrictEqual(result, {
1171 current: ConfigProvider.makeValue("current"),
1172 primary: ConfigProvider.makeValue("primary"),
1173 shared: ConfigProvider.makeValue("primary")
1174 })
1175 })
1176 })
1177})