git.iko.soy/rowtype-yoga/purescript-yoga-om-layer
on main help sign in
Remove unsafeCoerce from wireLayers, use Om.expand/widenCtx with open row constraints
commit7605e0b9fd4858e21b971122a56a0c5b09941b9f authorMark <mark.eibes@gmail.com> dateSat 14 Feb 2026, 01:03 (8 months ago) parent1b0d89f
12 files changed+1406−759
.spec-results+226 spago.lock+323−3 spago.yaml+3−1 test/Test/Main.purs+6−755 test/Test/Yoga/Om/Layer/CompositionSpec.purs+66 test/Test/Yoga/Om/Layer/ErrorChannelSpec.purs+167 test/Test/Yoga/Om/Layer/InstancesSpec.purs+70 test/Test/Yoga/Om/Layer/MemoizationSpec.purs+114 test/Test/Yoga/Om/Layer/RecoveringSpec.purs+128 test/Test/Yoga/Om/Layer/RepeatingSpec.purs+49 test/Test/Yoga/Om/Layer/ScopedSpec.purs+146 test/Test/Yoga/Om/Layer/WireLayersSpec.purs+108
.spec-resultsnew file+226
@@ -0,0 +1,226 @@
1+[
2+ [
3+ "Automatic Memoization builds the same layer only once when used in two branches",
4+ {
5+ "timestamp": "1771027873496.0",
6+ "success": true
7+ }
8+ ],
9+ [
10+ "Automatic Memoization memoized bracketLayer acquires and releases only once",
11+ {
12+ "timestamp": "1771027873496.0",
13+ "success": true
14+ }
15+ ],
16+ [
17+ "Automatic Memoization memoized scoped layer runs finalizer only once",
18+ {
19+ "timestamp": "1771027873496.0",
20+ "success": true
21+ }
22+ ],
23+ [
24+ "Composition proves Row.Nub deduplicates shared requirements at type level",
25+ {
26+ "timestamp": "1771027873496.0",
27+ "success": true
28+ }
29+ ],
30+ [
31+ "Composition proves deduplication at runtime via shared Ref",
32+ {
33+ "timestamp": "1771027873496.0",
34+ "success": true
35+ }
36+ ],
37+ [
38+ "Error Channel composes layers with different error types",
39+ {
40+ "timestamp": "1771027873496.0",
41+ "success": true
42+ }
43+ ],
44+ [
45+ "Error Channel propagates errors from the first layer",
46+ {
47+ "timestamp": "1771027873496.0",
48+ "success": true
49+ }
50+ ],
51+ [
52+ "Error Channel propagates errors from the second layer",
53+ {
54+ "timestamp": "1771027873496.0",
55+ "success": true
56+ }
57+ ],
58+ [
59+ "Error Channel propagates errors through vertical composition",
60+ {
61+ "timestamp": "1771027873496.0",
62+ "success": true
63+ }
64+ ],
65+ [
66+ "Error Channel scoped layer error triggers finalizers",
67+ {
68+ "timestamp": "1771027873496.0",
69+ "success": true
70+ }
71+ ],
72+ [
73+ "Error Channel scoped layers with errors run via runScopedWith",
74+ {
75+ "timestamp": "1771027873496.0",
76+ "success": true
77+ }
78+ ],
79+ [
80+ "Functor / Apply / Bind applies two layers",
81+ {
82+ "timestamp": "1771027873496.0",
83+ "success": true
84+ }
85+ ],
86+ [
87+ "Functor / Apply / Bind binds layers sequentially",
88+ {
89+ "timestamp": "1771027873496.0",
90+ "success": true
91+ }
92+ ],
93+ [
94+ "Functor / Apply / Bind maps over layer output",
95+ {
96+ "timestamp": "1771027873496.0",
97+ "success": true
98+ }
99+ ],
100+ [
101+ "Layer recovering does not retry on non-matching errors",
102+ {
103+ "timestamp": "1771027873496.0",
104+ "success": true
105+ }
106+ ],
107+ [
108+ "Layer recovering only retries the specified error in a multi-error layer",
109+ {
110+ "timestamp": "1771027873496.0",
111+ "success": true
112+ }
113+ ],
114+ [
115+ "Layer recovering preserves the error when retries are exhausted",
116+ {
117+ "timestamp": "1771027873496.0",
118+ "success": true
119+ }
120+ ],
121+ [
122+ "Layer recovering retries layer construction on matching errors",
123+ {
124+ "timestamp": "1771027873496.0",
125+ "success": true
126+ }
127+ ],
128+ [
129+ "Layer recovering uses RetryStatus in the check",
130+ {
131+ "timestamp": "1771027873496.0",
132+ "success": true
133+ }
134+ ],
135+ [
136+ "Layer repeating repeats layer construction while condition holds",
137+ {
138+ "timestamp": "1771027873496.0",
139+ "success": true
140+ }
141+ ],
142+ [
143+ "Layer repeating stops when policy exhausted",
144+ {
145+ "timestamp": "1771027873496.0",
146+ "success": true
147+ }
148+ ],
149+ [
150+ "Parallel runs layers in parallel via parApply",
151+ {
152+ "timestamp": "1771027873496.0",
153+ "success": true
154+ }
155+ ],
156+ [
157+ "Scoped Layers combines scoped and non-scoped layers",
158+ {
159+ "timestamp": "1771027873496.0",
160+ "success": true
161+ }
162+ ],
163+ [
164+ "Scoped Layers fresh layer builds twice even when same base layer",
165+ {
166+ "timestamp": "1771027873496.0",
167+ "success": true
168+ }
169+ ],
170+ [
171+ "Scoped Layers runs finalizers in reverse order on success",
172+ {
173+ "timestamp": "1771027873496.0",
174+ "success": true
175+ }
176+ ],
177+ [
178+ "Scoped Layers runs finalizers when the callback throws",
179+ {
180+ "timestamp": "1771027873496.0",
181+ "success": true
182+ }
183+ ],
184+ [
185+ "Scoped Layers threads finalizers through vertical composition (provide)",
186+ {
187+ "timestamp": "1771027873496.0",
188+ "success": true
189+ }
190+ ],
191+ [
192+ "Scoped Layers works with bracketLayer",
193+ {
194+ "timestamp": "1771027873496.0",
195+ "success": true
196+ }
197+ ],
198+ [
199+ "wireLayers memoizes shared dependencies within wireLayers",
200+ {
201+ "timestamp": "1771027873496.0",
202+ "success": true
203+ }
204+ ],
205+ [
206+ "wireLayers wires a diamond dependency",
207+ {
208+ "timestamp": "1771027873496.0",
209+ "success": true
210+ }
211+ ],
212+ [
213+ "wireLayers wires a simple linear chain (A -> B -> C)",
214+ {
215+ "timestamp": "1771027873496.0",
216+ "success": true
217+ }
218+ ],
219+ [
220+ "wireLayers wires independent layers with no inter-dependencies",
221+ {
222+ "timestamp": "1771027873496.0",
223+ "success": true
224+ }
225+ ]
226+]
spago.lock+323−3
@@ -67,7 +67,13 @@
6767 "refs": ">=6.0.0 <7.0.0"
6868 },
6969 {
70− "spec": ">=7.0.0 <8.0.0"
70+ "spec": ">=7.0.0 <9.0.0"
71+ },
72+ {
73+ "spec-discovery": ">=8.0.0 <9.0.0"
74+ },
75+ {
76+ "spec-node": ">=0.0.3 <1.0.0"
7177 }
7278 ]
7379 }
@@ -127,6 +133,53 @@
127133 "prelude"
128134 ]
129135 },
136+ "argonaut-codecs": {
137+ "type": "registry",
138+ "version": "9.1.0",
139+ "integrity": "sha256-K910SBrmYallESsm8pxJYThs7lig6hxX3M4PW33Cgew=",
140+ "dependencies": [
141+ "argonaut-core",
142+ "arrays",
143+ "bifunctors",
144+ "either",
145+ "foldable-traversable",
146+ "foreign-object",
147+ "identity",
148+ "integers",
149+ "lists",
150+ "maybe",
151+ "nonempty",
152+ "ordered-collections",
153+ "prelude",
154+ "record",
155+ "strings",
156+ "tuples"
157+ ]
158+ },
159+ "argonaut-core": {
160+ "type": "registry",
161+ "version": "7.0.0",
162+ "integrity": "sha256-gYZihtBIKthVuXXoiPphMookSf8M5aHyFYkcIWqlxTM=",
163+ "dependencies": [
164+ "arrays",
165+ "control",
166+ "either",
167+ "foreign-object",
168+ "functions",
169+ "gen",
170+ "maybe",
171+ "nonempty",
172+ "prelude",
173+ "strings",
174+ "tailrec"
175+ ]
176+ },
177+ "arraybuffer-types": {
178+ "type": "registry",
179+ "version": "3.0.2",
180+ "integrity": "sha256-p05cJnSkyeoB7VHzMyc2Eb1RwUaB7Nl0sQSqEGNEUD8=",
181+ "dependencies": []
182+ },
130183 "arrays": {
131184 "type": "registry",
132185 "version": "7.3.0",
@@ -318,6 +371,16 @@
318371 "unsafe-coerce"
319372 ]
320373 },
374+ "exitcodes": {
375+ "type": "registry",
376+ "version": "4.0.0",
377+ "integrity": "sha256-xBl4aFSEQ05Owp1ZvO6OxxvLK3LVzU35RSbj79SsePQ=",
378+ "dependencies": [
379+ "enums",
380+ "maybe",
381+ "prelude"
382+ ]
383+ },
321384 "foldable-traversable": {
322385 "type": "registry",
323386 "version": "6.0.0",
@@ -336,6 +399,42 @@
336399 "tuples"
337400 ]
338401 },
402+ "foreign": {
403+ "type": "registry",
404+ "version": "7.0.0",
405+ "integrity": "sha256-jiDRMVQfovGsbbA/FzbzYf6iWk9i2b5gNrIjz+gFfsI=",
406+ "dependencies": [
407+ "either",
408+ "functions",
409+ "integers",
410+ "lists",
411+ "maybe",
412+ "prelude",
413+ "strings",
414+ "transformers",
415+ "unsafe-coerce"
416+ ]
417+ },
418+ "foreign-object": {
419+ "type": "registry",
420+ "version": "4.1.0",
421+ "integrity": "sha256-x/Q7r80z/vmHRKvPwhYmHP/DnJciPtsyGfRTMzayKIU=",
422+ "dependencies": [
423+ "arrays",
424+ "foldable-traversable",
425+ "functions",
426+ "gen",
427+ "lists",
428+ "maybe",
429+ "prelude",
430+ "st",
431+ "tailrec",
432+ "tuples",
433+ "typelevel-prelude",
434+ "unfoldable",
435+ "unsafe-coerce"
436+ ]
437+ },
339438 "fork": {
340439 "type": "registry",
341440 "version": "6.0.0",
@@ -456,6 +555,21 @@
456555 "prelude"
457556 ]
458557 },
558+ "js-date": {
559+ "type": "registry",
560+ "version": "8.0.0",
561+ "integrity": "sha256-DNQrp4xYc8o80jtxo0aOkUeXGi6h0Xidkk2ZQ8Y4l5Y=",
562+ "dependencies": [
563+ "datetime",
564+ "effect",
565+ "enums",
566+ "foreign",
567+ "functions",
568+ "integers",
569+ "maybe",
570+ "prelude"
571+ ]
572+ },
459573 "lazy": {
460574 "type": "registry",
461575 "version": "6.0.0",
@@ -523,6 +637,107 @@
523637 "safe-coerce"
524638 ]
525639 },
640+ "node-buffer": {
641+ "type": "registry",
642+ "version": "9.0.0",
643+ "integrity": "sha256-RdQjilmEHqsT4fwpCr8Mavw8KTafYrS3ZM6zeAAVAl8=",
644+ "dependencies": [
645+ "arraybuffer-types",
646+ "effect",
647+ "functions",
648+ "maybe",
649+ "nullable",
650+ "partial",
651+ "prelude",
652+ "st",
653+ "unsafe-coerce"
654+ ]
655+ },
656+ "node-event-emitter": {
657+ "type": "registry",
658+ "version": "3.0.0",
659+ "integrity": "sha256-wodx71NxJBPXOaawFn01V1ByYB2vT+I3RuV2giOVKMg=",
660+ "dependencies": [
661+ "effect",
662+ "either",
663+ "functions",
664+ "maybe",
665+ "nullable",
666+ "prelude",
667+ "unsafe-coerce"
668+ ]
669+ },
670+ "node-fs": {
671+ "type": "registry",
672+ "version": "9.2.0",
673+ "integrity": "sha256-fxioTSm9y47WuYs9F/7nxCh/RVDCl3Kr3Hg779J4SJA=",
674+ "dependencies": [
675+ "aff",
676+ "datetime",
677+ "effect",
678+ "either",
679+ "enums",
680+ "exceptions",
681+ "functions",
682+ "integers",
683+ "js-date",
684+ "maybe",
685+ "node-buffer",
686+ "node-path",
687+ "node-streams",
688+ "nullable",
689+ "partial",
690+ "prelude",
691+ "strings"
692+ ]
693+ },
694+ "node-path": {
695+ "type": "registry",
696+ "version": "5.0.1",
697+ "integrity": "sha256-j7n/SmpVz0FOMJl4XZop3ofJ+MeIPkMNqUEUKHEL6Us=",
698+ "dependencies": [
699+ "effect"
700+ ]
701+ },
702+ "node-process": {
703+ "type": "registry",
704+ "version": "11.2.0",
705+ "integrity": "sha256-onzKeNmaeYfN3HSDPqSFTrrP2Yl9tnVsfKKhnf2plxM=",
706+ "dependencies": [
707+ "effect",
708+ "exceptions",
709+ "foreign",
710+ "foreign-object",
711+ "maybe",
712+ "node-event-emitter",
713+ "node-streams",
714+ "nullable",
715+ "posix-types",
716+ "prelude",
717+ "strings"
718+ ]
719+ },
720+ "node-streams": {
721+ "type": "registry",
722+ "version": "9.0.1",
723+ "integrity": "sha256-yuVGm/R/tBr4Nphi+a1a984Z0fkmN9Q5wWtSmbmJTpA=",
724+ "dependencies": [
725+ "aff",
726+ "arrays",
727+ "effect",
728+ "either",
729+ "exceptions",
730+ "maybe",
731+ "node-buffer",
732+ "node-event-emitter",
733+ "nullable",
734+ "prelude",
735+ "refs",
736+ "st",
737+ "tailrec",
738+ "unsafe-coerce"
739+ ]
740+ },
526741 "nonempty": {
527742 "type": "registry",
528743 "version": "7.0.0",
@@ -566,6 +781,56 @@
566781 "prelude"
567782 ]
568783 },
784+ "open-memoize": {
785+ "type": "registry",
786+ "version": "6.2.0",
787+ "integrity": "sha256-yJLuVYX8FocHIJ+6q7efH/Pj4S+e1Y57DZ5Dn7qf4ww=",
788+ "dependencies": [
789+ "either",
790+ "integers",
791+ "lazy",
792+ "lists",
793+ "maybe",
794+ "partial",
795+ "prelude",
796+ "strings",
797+ "tuples"
798+ ]
799+ },
800+ "optparse": {
801+ "type": "registry",
802+ "version": "5.0.1",
803+ "integrity": "sha256-2btDjHPRR0uujUQJuZAN9AzvVoXe7yG6IwnY5evERhQ=",
804+ "dependencies": [
805+ "arrays",
806+ "bifunctors",
807+ "control",
808+ "effect",
809+ "either",
810+ "enums",
811+ "exists",
812+ "exitcodes",
813+ "foldable-traversable",
814+ "free",
815+ "integers",
816+ "lazy",
817+ "lists",
818+ "maybe",
819+ "newtype",
820+ "node-buffer",
821+ "node-process",
822+ "node-streams",
823+ "nonempty",
824+ "numbers",
825+ "open-memoize",
826+ "partial",
827+ "prelude",
828+ "strings",
829+ "tailrec",
830+ "transformers",
831+ "tuples"
832+ ]
833+ },
569834 "ordered-collections": {
570835 "type": "registry",
571836 "version": "3.2.0",
@@ -641,6 +906,16 @@
641906 "tuples"
642907 ]
643908 },
909+ "posix-types": {
910+ "type": "registry",
911+ "version": "6.0.0",
912+ "integrity": "sha256-Je5jG439F/SFO/IvVevZnWVxDIonrehlKuJkefP2lHE=",
913+ "dependencies": [
914+ "maybe",
915+ "newtype",
916+ "prelude"
917+ ]
918+ },
644919 "prelude": {
645920 "type": "registry",
646921 "version": "6.0.2",
@@ -714,8 +989,8 @@
714989 },
715990 "spec": {
716991 "type": "registry",
717− "version": "7.6.1",
718− "integrity": "sha256-5GysDOS1gVbbGX75YXUdhww3h2S246Ck0sS6yt+ig+0=",
992+ "version": "8.1.1",
993+ "integrity": "sha256-b6qjDb7lUJGLV+WEoywLu6BCXC4zGpfbN43mTL+vBeI=",
719994 "dependencies": [
720995 "aff",
721996 "ansi",
@@ -746,6 +1021,51 @@
7461021 "tuples"
7471022 ]
7481023 },
1024+ "spec-discovery": {
1025+ "type": "registry",
1026+ "version": "8.4.0",
1027+ "integrity": "sha256-aFp8bllT/wrxQu8hMlDchQQY08dQAz+Xdq2hZi5z3yY=",
1028+ "dependencies": [
1029+ "aff",
1030+ "effect",
1031+ "foldable-traversable",
1032+ "prelude",
1033+ "spec",
1034+ "spec-node"
1035+ ]
1036+ },
1037+ "spec-node": {
1038+ "type": "registry",
1039+ "version": "0.0.3",
1040+ "integrity": "sha256-8/9+g0obbamV0YtqzmttbWs8HaWF7CEeW+eDtqzuAAo=",
1041+ "dependencies": [
1042+ "aff",
1043+ "argonaut-codecs",
1044+ "argonaut-core",
1045+ "arrays",
1046+ "control",
1047+ "datetime",
1048+ "effect",
1049+ "either",
1050+ "foldable-traversable",
1051+ "identity",
1052+ "integers",
1053+ "maybe",
1054+ "newtype",
1055+ "node-buffer",
1056+ "node-fs",
1057+ "node-process",
1058+ "now",
1059+ "numbers",
1060+ "optparse",
1061+ "ordered-collections",
1062+ "partial",
1063+ "prelude",
1064+ "spec",
1065+ "strings",
1066+ "tuples"
1067+ ]
1068+ },
7491069 "st": {
7501070 "type": "registry",
7511071 "version": "6.2.0",
spago.yaml+3−1
@@ -29,6 +29,8 @@ package:
2929 test:
3030 main: Test.Main
3131 dependencies:
32− - spec: ">=7.0.0 <8.0.0"
32+ - spec: ">=7.0.0 <9.0.0"
33+ - spec-discovery: ">=8.0.0 <9.0.0"
34+ - spec-node: ">=0.0.3 <1.0.0"
3335 - console: ">=6.0.0 <7.0.0"
3436 - refs: ">=6.0.0 <7.0.0"
test/Test/Main.purs+6−755
@@ -2,763 +2,14 @@ module Test.Main where
22
33import Prelude
44
5−import Control.Parallel (parApply)
65import Effect (Effect)
7−import Effect.Aff (launchAff_, throwError, try)
8−import Effect.Aff.Class (liftAff)
9−import Effect.Exception (error)
6+import Effect.Aff (launchAff_)
107import Effect.Class (liftEffect)
11−import Effect.Class.Console as Console
12−import Effect.Ref as Ref
13−import Test.Spec (describe, it)
14−import Test.Spec.Assertions (shouldEqual)
8+import Test.Spec.Discovery (discover)
159import Test.Spec.Reporter (consoleReporter)
16−import Test.Spec.Runner (runSpec)
17−import Data.Either (Either(..))
18−import Data.Variant (match)
19−import Yoga.Om as Om
20−import Data.Time.Duration (Milliseconds(..))
21−import Effect.Aff.Retry (RetryStatus(..), limitRetries, constantDelay)
22−import Yoga.Om.Layer (OmLayer, Scope, makeLayer, makeScopedLayer, bracketLayer, fresh, combineRequirements, runLayer, runScoped, runScopedWith, withScoped, provide, recovering, repeating, wireLayers)
23−
24−-- Example types
25−type Config = { port :: Int, host :: String }
26−type Logger = { log :: String -> Effect Unit }
27−type Database = { query :: String -> Effect (Array String) }
10+import Test.Spec.Runner.Node (runSpecAndExitProcess)
2811
2912main :: Effect Unit
30−main = launchAff_ $ runSpec [ consoleReporter ] do
31− describe "OmLayer - Proof of Concept: Type-Level Deduplication" do
32−
33− it "proves Row.Nub deduplicates shared requirements at type level" do
34− let
35− loggerLayer :: OmLayer (config :: Config) () { logger :: Logger }
36− loggerLayer = makeLayer do
37− { config } <- Om.ask
38− pure { logger: { log: \msg -> Console.log $ "[" <> config.host <> "] " <> msg } }
39−
40− databaseLayer :: OmLayer (config :: Config) () { database :: Database }
41− databaseLayer = makeLayer do
42− { config } <- Om.ask
43− pure { database: { query: \q -> pure [ "Result from " <> config.host ] } }
44−
45− let
46− _proofOfDeduplication :: OmLayer (config :: Config) _ _
47− _proofOfDeduplication = combineRequirements loggerLayer databaseLayer
48−
49− Console.log "Type-level deduplication works: (config, config) -> (config)"
50− pure unit
51−
52− it "proves deduplication at runtime via shared Ref" do
53− accessLog <- liftEffect $ Ref.new ""
54− let
55− combinedLayer :: OmLayer (config :: Config, accessLog :: Ref.Ref String) () { logger :: Logger, database :: Database }
56− combinedLayer = makeLayer do
57− { config, accessLog: log } <- Om.ask
58− liftEffect $ Ref.modify_ (_ <> "config-accessed-by-logger ") log
59− let logger = { log: \msg -> Console.log $ "[" <> config.host <> "] " <> msg }
60− liftEffect $ Ref.modify_ (_ <> "config-accessed-by-database ") log
61− let database = { query: \q -> pure [ "Result from " <> config.host ] }
62− pure { logger, database }
63−
64− let
65− ctx =
66− { config: { port: 5432, host: "localhost" }
67− , accessLog: accessLog
68− }
69− result <- runLayer ctx combinedLayer
70− # Om.runOm ctx
71− { exception: \_ -> pure { logger: { log: \_ -> pure unit }, database: { query: \_ -> pure [] } } }
72−
73− finalLog <- liftEffect $ Ref.read accessLog
74− finalLog `shouldEqual` "config-accessed-by-logger config-accessed-by-database "
75−
76− liftEffect $ result.logger.log "Testing logger from combined layer"
77− queryResult <- liftEffect $ result.database.query "SELECT *"
78− queryResult `shouldEqual` [ "Result from localhost" ]
79−
80− describe "Scoped Layers" do
81−
82− it "runs finalizers in reverse order on success" do
83− log <- liftEffect $ Ref.new []
84− let
85− layerA :: OmLayer (scope :: Scope) () { a :: String }
86− layerA = makeScopedLayer
87− (pure { a: "A" })
88− (\_ -> liftEffect $ Ref.modify_ (_ <> [ "release-A" ]) log)
89−
90− layerB :: OmLayer (scope :: Scope) () { b :: String }
91− layerB = makeScopedLayer
92− (pure { b: "B" })
93− (\_ -> liftEffect $ Ref.modify_ (_ <> [ "release-B" ]) log)
94−
95− layerC :: OmLayer (scope :: Scope) () { c :: String }
96− layerC = makeScopedLayer
97− (pure { c: "C" })
98− (\_ -> liftEffect $ Ref.modify_ (_ <> [ "release-C" ]) log)
99−
100− layerBC :: OmLayer (scope :: Scope) () { b :: String, c :: String }
101− layerBC = combineRequirements layerB layerC
102−
103− combined = combineRequirements layerA layerBC
104−
105− _ <- liftAff $ runScoped combined
106− finalLog <- liftEffect $ Ref.read log
107− finalLog `shouldEqual` [ "release-C", "release-B", "release-A" ]
108−
109− it "runs finalizers when the callback throws" do
110− log <- liftEffect $ Ref.new []
111− let
112− layer :: OmLayer (scope :: Scope) () { value :: String }
113− layer = makeScopedLayer
114− (pure { value: "acquired" })
115− (\_ -> liftEffect $ Ref.modify_ (_ <> [ "released" ]) log)
116−
117− _ <- liftAff $ try $ withScoped layer \_ ->
118− throwError (error "callback failed")
119− finalLog <- liftEffect $ Ref.read log
120− finalLog `shouldEqual` [ "released" ]
121−
122− it "threads finalizers through vertical composition (provide)" do
123− log <- liftEffect $ Ref.new []
124− let
125− baseLayer :: OmLayer (scope :: Scope) () { base :: String }
126− baseLayer = makeScopedLayer
127− (pure { base: "base-value" })
128− (\_ -> liftEffect $ Ref.modify_ (_ <> [ "release-base" ]) log)
129−
130− upperLayer :: OmLayer (scope :: Scope, base :: String) () { upper :: String }
131− upperLayer = makeScopedLayer
132− ( do
133− { base } <- Om.ask
134− pure { upper: base <> "-extended" }
135− )
136− (\_ -> liftEffect $ Ref.modify_ (_ <> [ "release-upper" ]) log)
137−
138− composed = upperLayer `provide` baseLayer
139−
140− result <- liftAff $ runScoped composed
141− result.upper `shouldEqual` "base-value-extended"
142− finalLog <- liftEffect $ Ref.read log
143− finalLog `shouldEqual` [ "release-upper", "release-base" ]
144−
145− it "works with bracketLayer" do
146− log <- liftEffect $ Ref.new []
147− let
148− layer = bracketLayer
149− ( do
150− liftEffect $ Ref.modify_ (_ <> [ "acquire" ]) log
151− pure 42
152− )
153− (\_ -> liftEffect $ Ref.modify_ (_ <> [ "release" ]) log)
154− (\n -> pure { value: n })
155−
156− result <- liftAff $ runScoped layer
157− result.value `shouldEqual` 42
158− finalLog <- liftEffect $ Ref.read log
159− finalLog `shouldEqual` [ "acquire", "release" ]
160−
161− it "fresh layer builds twice even when same base layer" do
162− counter <- liftEffect $ Ref.new 0
163− let
164− baseLayer :: OmLayer (scope :: Scope) () { value :: String }
165− baseLayer = makeLayer do
166− liftEffect $ Ref.modify_ (_ + 1) counter
167− pure { value: "built" }
168−
169− branch1 :: OmLayer (scope :: Scope) () { out1 :: String }
170− branch1 = consumer `provide` fresh baseLayer
171− where
172− consumer :: OmLayer (scope :: Scope, value :: String) () { out1 :: String }
173− consumer = makeLayer do
174− { value } <- Om.ask
175− pure { out1: value <> "-1" }
176−
177− branch2 :: OmLayer (scope :: Scope) () { out2 :: String }
178− branch2 = consumer `provide` fresh baseLayer
179− where
180− consumer :: OmLayer (scope :: Scope, value :: String) () { out2 :: String }
181− consumer = makeLayer do
182− { value } <- Om.ask
183− pure { out2: value <> "-2" }
184−
185− app = combineRequirements branch1 branch2
186−
187− result <- liftAff $ runScoped app
188− result.out1 `shouldEqual` "built-1"
189− result.out2 `shouldEqual` "built-2"
190− count <- liftEffect $ Ref.read counter
191− count `shouldEqual` 2
192−
193− it "combines scoped and non-scoped layers" do
194− log <- liftEffect $ Ref.new []
195− let
196− scopedLayer :: OmLayer (scope :: Scope) () { scoped :: String }
197− scopedLayer = makeScopedLayer
198− (pure { scoped: "yes" })
199− (\_ -> liftEffect $ Ref.modify_ (_ <> [ "release-scoped" ]) log)
200−
201− plainLayer :: OmLayer () () { plain :: String }
202− plainLayer = makeLayer (pure { plain: "no-finalizer" })
203−
204− combined = combineRequirements scopedLayer plainLayer
205−
206− result <- liftAff $ runScoped combined
207− result.scoped `shouldEqual` "yes"
208− result.plain `shouldEqual` "no-finalizer"
209− finalLog <- liftEffect $ Ref.read log
210− finalLog `shouldEqual` [ "release-scoped" ]
211−
212− describe "Automatic Memoization" do
213−
214− it "builds the same layer only once when used in two branches" do
215− counter <- liftEffect $ Ref.new 0
216− let
217− expensiveLayer :: OmLayer (scope :: Scope) () { value :: String }
218− expensiveLayer = makeLayer do
219− liftEffect $ Ref.modify_ (_ + 1) counter
220− pure { value: "built" }
221−
222− branch1 :: OmLayer (scope :: Scope) () { out1 :: String }
223− branch1 = consumer `provide` expensiveLayer
224− where
225− consumer :: OmLayer (scope :: Scope, value :: String) () { out1 :: String }
226− consumer = makeLayer do
227− { value } <- Om.ask
228− pure { out1: value <> "-1" }
229−
230− branch2 :: OmLayer (scope :: Scope) () { out2 :: String }
231− branch2 = consumer `provide` expensiveLayer
232− where
233− consumer :: OmLayer (scope :: Scope, value :: String) () { out2 :: String }
234− consumer = makeLayer do
235− { value } <- Om.ask
236− pure { out2: value <> "-2" }
237−
238− app = combineRequirements branch1 branch2
239−
240− result <- liftAff $ runScoped app
241− result.out1 `shouldEqual` "built-1"
242− result.out2 `shouldEqual` "built-2"
243− count <- liftEffect $ Ref.read counter
244− count `shouldEqual` 1
245−
246− it "memoized scoped layer runs finalizer only once" do
247− log <- liftEffect $ Ref.new []
248− let
249− dbLayer :: OmLayer (scope :: Scope) () { db :: String }
250− dbLayer = makeScopedLayer
251− (pure { db: "connected" })
252− (\_ -> liftEffect $ Ref.modify_ (_ <> [ "db-closed" ]) log)
253−
254− repoBranch :: OmLayer (scope :: Scope) () { repo :: String }
255− repoBranch = repo `provide` dbLayer
256− where
257− repo :: OmLayer (scope :: Scope, db :: String) () { repo :: String }
258− repo = makeLayer do
259− { db } <- Om.ask
260− pure { repo: db <> "-repo" }
261−
262− analyticsBranch :: OmLayer (scope :: Scope) () { analytics :: String }
263− analyticsBranch = analytics `provide` dbLayer
264− where
265− analytics :: OmLayer (scope :: Scope, db :: String) () { analytics :: String }
266− analytics = makeLayer do
267− { db } <- Om.ask
268− pure { analytics: db <> "-analytics" }
269−
270− app = combineRequirements repoBranch analyticsBranch
271−
272− result <- liftAff $ runScoped app
273− result.repo `shouldEqual` "connected-repo"
274− result.analytics `shouldEqual` "connected-analytics"
275− finalLog <- liftEffect $ Ref.read log
276− finalLog `shouldEqual` [ "db-closed" ]
277−
278− it "memoized bracketLayer acquires and releases only once" do
279− log <- liftEffect $ Ref.new []
280− let
281− connLayer :: OmLayer (scope :: Scope) () { conn :: Int }
282− connLayer = bracketLayer
283− ( do
284− liftEffect $ Ref.modify_ (_ <> [ "acquire" ]) log
285− pure 42
286− )
287− (\_ -> liftEffect $ Ref.modify_ (_ <> [ "release" ]) log)
288− (\n -> pure { conn: n })
289−
290− branch1 :: OmLayer (scope :: Scope) () { x :: String }
291− branch1 = upper `provide` connLayer
292− where
293− upper :: OmLayer (scope :: Scope, conn :: Int) () { x :: String }
294− upper = makeLayer do
295− { conn } <- Om.ask
296− pure { x: show conn }
297−
298− branch2 :: OmLayer (scope :: Scope) () { y :: String }
299− branch2 = upper `provide` connLayer
300− where
301− upper :: OmLayer (scope :: Scope, conn :: Int) () { y :: String }
302− upper = makeLayer do
303− { conn } <- Om.ask
304− pure { y: show (conn + 1) }
305−
306− app = combineRequirements branch1 branch2
307−
308− result <- liftAff $ runScoped app
309− result.x `shouldEqual` "42"
310− result.y `shouldEqual` "43"
311− finalLog <- liftEffect $ Ref.read log
312− finalLog `shouldEqual` [ "acquire", "release" ]
313−
314− describe "Error Channel" do
315−
316− it "composes layers with different error types" do
317− let
318− dbLayer :: OmLayer () (dbError :: String) { db :: String }
319− dbLayer = makeLayer do
320− pure { db: "connected" }
321−
322− cacheLayer :: OmLayer () (cacheError :: String) { cache :: String }
323− cacheLayer = makeLayer do
324− pure { cache: "ready" }
325−
326− combined :: OmLayer () (dbError :: String, cacheError :: String) { db :: String, cache :: String }
327− combined = combineRequirements dbLayer cacheLayer
328−
329− result <- Om.runOm {}
330− { exception: \_ -> pure { db: "", cache: "" }
331− , dbError: \_ -> pure { db: "", cache: "" }
332− , cacheError: \_ -> pure { db: "", cache: "" }
333− }
334− (runLayer {} combined)
335− result.db `shouldEqual` "connected"
336− result.cache `shouldEqual` "ready"
337−
338− it "propagates errors from the first layer" do
339− let
340− failingLayer :: OmLayer () (dbError :: String) { db :: String }
341− failingLayer = makeLayer do
342− Om.throw { dbError: "connection refused" }
343−
344− okLayer :: OmLayer () () { cache :: String }
345− okLayer = makeLayer do
346− pure { cache: "ready" }
347−
348− combined = combineRequirements failingLayer okLayer
349−
350− result <- Om.runReader {} (runLayer {} combined)
351− # liftAff
352− case result of
353− Left err -> do
354− let
355− msg = err # match
356− { exception: \_ -> "exception"
357− , dbError: \e -> e
358− }
359− msg `shouldEqual` "connection refused"
360− Right _ -> "should have failed" `shouldEqual` "but succeeded"
361−
362− it "propagates errors from the second layer" do
363− let
364− okLayer :: OmLayer () () { db :: String }
365− okLayer = makeLayer do
366− pure { db: "connected" }
367−
368− failingLayer :: OmLayer () (cacheError :: String) { cache :: String }
369− failingLayer = makeLayer do
370− Om.throw { cacheError: "cache unavailable" }
371−
372− combined = combineRequirements okLayer failingLayer
373−
374− result <- Om.runReader {} (runLayer {} combined)
375− # liftAff
376− case result of
377− Left err -> do
378− let
379− msg = err # match
380− { exception: \_ -> "exception"
381− , cacheError: \e -> e
382− }
383− msg `shouldEqual` "cache unavailable"
384− Right _ -> "should have failed" `shouldEqual` "but succeeded"
385−
386− it "propagates errors through vertical composition" do
387− let
388− baseLayer :: OmLayer () (baseError :: String) { base :: String }
389− baseLayer = makeLayer do
390− Om.throw { baseError: "base failed" }
391−
392− upperLayer :: OmLayer (base :: String) (upperError :: String) { upper :: String }
393− upperLayer = makeLayer do
394− { base } <- Om.ask
395− pure { upper: base <> "-extended" }
396−
397− composed = upperLayer `provide` baseLayer
398−
399− result <- Om.runReader {} (runLayer {} composed)
400− # liftAff
401− case result of
402− Left err -> do
403− let
404− msg = err # match
405− { exception: \_ -> "exception"
406− , baseError: \e -> e
407− , upperError: \_ -> "upper"
408− }
409− msg `shouldEqual` "base failed"
410− Right _ -> "should have failed" `shouldEqual` "but succeeded"
411−
412− it "scoped layers with errors run via runScopedWith" do
413− log <- liftEffect $ Ref.new []
414− let
415− dbLayer :: OmLayer (scope :: Scope) (dbError :: String) { db :: String }
416− dbLayer = makeScopedLayer
417− (pure { db: "connected" })
418− (\_ -> liftEffect $ Ref.modify_ (_ <> [ "db-closed" ]) log)
419−
420− cacheLayer :: OmLayer (scope :: Scope) (cacheError :: String) { cache :: String }
421− cacheLayer = makeScopedLayer
422− (pure { cache: "ready" })
423− (\_ -> liftEffect $ Ref.modify_ (_ <> [ "cache-closed" ]) log)
424−
425− combined = combineRequirements dbLayer cacheLayer
426−
427− let
428− handlers =
429− { exception: \_ -> pure { db: "", cache: "" }
430− , dbError: \_ -> pure { db: "", cache: "" }
431− , cacheError: \_ -> pure { db: "", cache: "" }
432− }
433− result <- liftAff $ runScopedWith handlers combined
434− result.db `shouldEqual` "connected"
435− result.cache `shouldEqual` "ready"
436− finalLog <- liftEffect $ Ref.read log
437− finalLog `shouldEqual` [ "cache-closed", "db-closed" ]
438−
439− it "scoped layer error triggers finalizers" do
440− log <- liftEffect $ Ref.new []
441− let
442− goodLayer :: OmLayer (scope :: Scope) () { good :: String }
443− goodLayer = makeScopedLayer
444− (pure { good: "ok" })
445− (\_ -> liftEffect $ Ref.modify_ (_ <> [ "good-closed" ]) log)
446−
447− failLayer :: OmLayer (scope :: Scope) (buildError :: String) { bad :: String }
448− failLayer = makeScopedLayer
449− (Om.throw { buildError: "build failed" })
450− (\_ -> liftEffect $ Ref.modify_ (_ <> [ "bad-closed" ]) log)
451−
452− combined = combineRequirements goodLayer failLayer
453−
454− let
455− handlers =
456− { exception: \_ -> pure { good: "", bad: "" }
457− , buildError: \_ -> pure { good: "", bad: "" }
458− }
459− result <- liftAff $ try $ runScopedWith handlers combined
460− case result of
461− Right r -> r.good `shouldEqual` ""
462− Left _ -> pure unit
463− finalLog <- liftEffect $ Ref.read log
464− finalLog `shouldEqual` [ "good-closed" ]
465−
466− describe "Functor / Apply / Bind" do
467−
468− it "maps over layer output" do
469− let
470− layer :: OmLayer () () { value :: Int }
471− layer = makeLayer (pure { value: 10 })
472−
473− mapped = layer <#> \r -> { doubled: r.value * 2 }
474−
475− result <- Om.runOm {} { exception: \_ -> pure { doubled: 0 } } (runLayer {} mapped)
476− result.doubled `shouldEqual` 20
477−
478− it "applies two layers" do
479− let
480− fLayer :: OmLayer () () ({ a :: Int } -> { a :: Int, b :: String })
481− fLayer = makeLayer (pure \r -> { a: r.a, b: show r.a })
482−
483− aLayer :: OmLayer () () { a :: Int }
484− aLayer = makeLayer (pure { a: 42 })
485−
486− result <- Om.runOm {} { exception: \_ -> pure { a: 0, b: "" } } (runLayer {} (fLayer <*> aLayer))
487− result.a `shouldEqual` 42
488− result.b `shouldEqual` "42"
489−
490− it "binds layers sequentially" do
491− let
492− layer = do
493− r <- makeLayer (pure { x: 5 }) :: OmLayer () () { x :: Int }
494− makeLayer (pure { result: r.x + 10 })
495−
496− result <- Om.runOm {} { exception: \_ -> pure { result: 0 } } (runLayer {} layer)
497− result.result `shouldEqual` 15
498−
499− describe "Parallel" do
500−
501− it "runs layers in parallel via parApply" do
502− log <- liftEffect $ Ref.new []
503− let
504− layerA :: OmLayer () () { a :: String }
505− layerA = makeLayer do
506− liftEffect $ Ref.modify_ (_ <> [ "a" ]) log
507− pure { a: "A" }
508−
509− layerB :: OmLayer () () { b :: String }
510− layerB = makeLayer do
511− liftEffect $ Ref.modify_ (_ <> [ "b" ]) log
512− pure { b: "B" }
513−
514− combined = parApply (map (\ra rb -> { a: ra.a, b: rb.b }) layerA) layerB
515−
516− result <- Om.runOm {} { exception: \_ -> pure { a: "", b: "" } } (runLayer {} combined)
517− result.a `shouldEqual` "A"
518− result.b `shouldEqual` "B"
519− finalLog <- liftEffect $ Ref.read log
520− finalLog `shouldEqual` [ "a", "b" ]
521−
522− describe "Layer recovering" do
523−
524− it "retries layer construction on matching errors" do
525− attemptsRef <- liftEffect $ Ref.new 0
526− let
527− layer :: OmLayer () (dbError :: String) { db :: String }
528− layer = makeLayer do
529− attempts <- Ref.modify (_ + 1) attemptsRef # liftEffect
530− when (attempts < 3) do
531− Om.throw { dbError: "connection refused" }
532− pure { db: "connected" }
533−
534− retried = layer
535− # recovering (constantDelay (Milliseconds 0.0) <> limitRetries 5)
536− \_ -> { dbError: \_ -> pure true }
537−
538− result <- Om.runOm {}
539− { exception: \_ -> pure { db: "" }, dbError: \_ -> pure { db: "" } }
540− (runLayer {} retried)
541− result.db `shouldEqual` "connected"
542− attempts <- liftEffect $ Ref.read attemptsRef
543− attempts `shouldEqual` 3
544−
545− it "does not retry on non-matching errors" do
546− attemptsRef <- liftEffect $ Ref.new 0
547− let
548− layer :: OmLayer () (dbError :: String, authError :: String) { db :: String }
549− layer = makeLayer do
550− Ref.modify_ (_ + 1) attemptsRef # liftEffect
551− Om.throw { authError: "unauthorized" }
552−
553− retried = layer
554− # recovering (constantDelay (Milliseconds 0.0) <> limitRetries 3)
555− \_ -> { dbError: \_ -> pure true }
556−
557− _ <- Om.runOm {}
558− { exception: \_ -> pure { db: "" }
559− , dbError: \_ -> pure { db: "" }
560− , authError: \_ -> pure { db: "" }
561− }
562− (runLayer {} retried)
563− attempts <- liftEffect $ Ref.read attemptsRef
564− attempts `shouldEqual` 1
565−
566− it "preserves the error when retries are exhausted" do
567− let
568− layer :: OmLayer () (dbError :: String) { db :: String }
569− layer = makeLayer do
570− Om.throw { dbError: "connection refused" }
571−
572− retried = layer
573− # recovering (constantDelay (Milliseconds 0.0) <> limitRetries 2)
574− \_ -> { dbError: \_ -> pure true }
575−
576− result <- Om.runReader {} (runLayer {} retried)
577− # liftAff
578− case result of
579− Left err -> do
580− let
581− msg = err # match
582− { exception: \_ -> "exception"
583− , dbError: \e -> e
584− }
585− msg `shouldEqual` "connection refused"
586− Right _ -> "should have failed" `shouldEqual` "but succeeded"
587−
588− it "only retries the specified error in a multi-error layer" do
589− attemptsRef <- liftEffect $ Ref.new 0
590− let
591− layer :: OmLayer () (dbError :: String, cacheError :: String) { db :: String }
592− layer = makeLayer do
593− attempts <- Ref.modify (_ + 1) attemptsRef # liftEffect
594− if attempts <= 2 then Om.throw { dbError: "db down" }
595− else Om.throw { cacheError: "cache miss" }
596−
597− retried = layer
598− # recovering (constantDelay (Milliseconds 0.0) <> limitRetries 5)
599− \_ -> { dbError: \_ -> pure true }
600−
601− result <- Om.runReader {} (runLayer {} retried)
602− # liftAff
603− case result of
604− Left err -> do
605− let
606− msg = err # match
607− { exception: \_ -> "exception"
608− , dbError: \e -> e
609− , cacheError: \e -> e
610− }
611− msg `shouldEqual` "cache miss"
612− Right _ -> "should have failed" `shouldEqual` "but succeeded"
613− attempts <- liftEffect $ Ref.read attemptsRef
614− attempts `shouldEqual` 3
615−
616− it "uses RetryStatus in the check" do
617− attemptsRef <- liftEffect $ Ref.new 0
618− let
619− layer :: OmLayer () (dbError :: String) { db :: String }
620− layer = makeLayer do
621− Ref.modify_ (_ + 1) attemptsRef # liftEffect
622− Om.throw { dbError: "fail" }
623−
624− retried = layer
625− # recovering (constantDelay (Milliseconds 0.0) <> limitRetries 10)
626− \(RetryStatus s) -> { dbError: \_ -> pure (s.iterNumber < 2) }
627−
628− _ <- Om.runOm {}
629− { exception: \_ -> pure { db: "" }, dbError: \_ -> pure { db: "" } }
630− (runLayer {} retried)
631− attempts <- liftEffect $ Ref.read attemptsRef
632− attempts `shouldEqual` 3
633−
634− describe "Layer repeating" do
635−
636− it "repeats layer construction while condition holds" do
637− counterRef <- liftEffect $ Ref.new 0
638− let
639− layer :: OmLayer () () { count :: Int }
640− layer = makeLayer do
641− count <- Ref.modify (_ + 1) counterRef # liftEffect
642− pure { count }
643−
644− repeated = layer
645− # repeating (constantDelay (Milliseconds 0.0) <> limitRetries 10)
646− \_ r -> pure (r.count < 5)
647−
648− result <- Om.runOm {}
649− { exception: \_ -> pure { count: 0 } }
650− (runLayer {} repeated)
651− result.count `shouldEqual` 5
652−
653− it "stops when policy exhausted" do
654− counterRef <- liftEffect $ Ref.new 0
655− let
656− layer :: OmLayer () () { count :: Int }
657− layer = makeLayer do
658− count <- Ref.modify (_ + 1) counterRef # liftEffect
659− pure { count }
660−
661− repeated = layer
662− # repeating (constantDelay (Milliseconds 0.0) <> limitRetries 3)
663− \_ _ -> pure true
664−
665− result <- Om.runOm {}
666− { exception: \_ -> pure { count: 0 } }
667− (runLayer {} repeated)
668− result.count `shouldEqual` 4
669−
670− describe "wireLayers" do
671−
672− it "wires a simple linear chain (A -> B -> C)" do
673− let
674− a_base :: OmLayer (scope :: Scope) () { base :: String }
675− a_base = makeLayer (pure { base: "foundation" })
676−
677− b_mid :: OmLayer (scope :: Scope, base :: String) () { mid :: String }
678− b_mid = makeLayer do
679− { base } <- Om.ask
680− pure { mid: base <> "-middle" }
681−
682− c_top :: OmLayer (scope :: Scope, mid :: String) () { top :: String }
683− c_top = makeLayer do
684− { mid } <- Om.ask
685− pure { top: mid <> "-top" }
686−
687− app = wireLayers { a_base, b_mid, c_top }
688−
689− result <- liftAff $ runScoped app
690− result.base `shouldEqual` "foundation"
691− result.mid `shouldEqual` "foundation-middle"
692− result.top `shouldEqual` "foundation-middle-top"
693−
694− it "wires independent layers with no inter-dependencies" do
695− let
696− a_x :: OmLayer (scope :: Scope) () { x :: Int }
697− a_x = makeLayer (pure { x: 1 })
698−
699− b_y :: OmLayer (scope :: Scope) () { y :: Int }
700− b_y = makeLayer (pure { y: 2 })
701−
702− c_z :: OmLayer (scope :: Scope) () { z :: Int }
703− c_z = makeLayer (pure { z: 3 })
704−
705− app = wireLayers { a_x, b_y, c_z }
706−
707− result <- liftAff $ runScoped app
708− result.x `shouldEqual` 1
709− result.y `shouldEqual` 2
710− result.z `shouldEqual` 3
711−
712− it "wires a diamond dependency" do
713− let
714− a_db :: OmLayer (scope :: Scope) () { db :: String }
715− a_db = makeLayer (pure { db: "postgres" })
716−
717− b_cache :: OmLayer (scope :: Scope, db :: String) () { cache :: String }
718− b_cache = makeLayer do
719− { db } <- Om.ask
720− pure { cache: db <> "-cache" }
721−
722− b_repo :: OmLayer (scope :: Scope, db :: String) () { repo :: String }
723− b_repo = makeLayer do
724− { db } <- Om.ask
725− pure { repo: db <> "-repo" }
726−
727− d_app :: OmLayer (scope :: Scope, cache :: String, repo :: String) () { app :: String }
728− d_app = makeLayer do
729− { cache, repo } <- Om.ask
730− pure { app: cache <> "+" <> repo }
731−
732− app = wireLayers { a_db, b_cache, b_repo, d_app }
733−
734− result <- liftAff $ runScoped app
735− result.db `shouldEqual` "postgres"
736− result.cache `shouldEqual` "postgres-cache"
737− result.repo `shouldEqual` "postgres-repo"
738− result.app `shouldEqual` "postgres-cache+postgres-repo"
739−
740− it "memoizes shared dependencies within wireLayers" do
741− counter <- liftEffect $ Ref.new 0
742− let
743− a_db :: OmLayer (scope :: Scope) () { db :: String }
744− a_db = makeLayer do
745− liftEffect $ Ref.modify_ (_ + 1) counter
746− pure { db: "connected" }
747−
748− b_cache :: OmLayer (scope :: Scope, db :: String) () { cache :: String }
749− b_cache = makeLayer do
750− { db } <- Om.ask
751− pure { cache: db <> "-cache" }
752−
753− b_repo :: OmLayer (scope :: Scope, db :: String) () { repo :: String }
754− b_repo = makeLayer do
755− { db } <- Om.ask
756− pure { repo: db <> "-repo" }
757−
758− app = wireLayers { a_db, b_cache, b_repo }
759−
760− result <- liftAff $ runScoped app
761− result.cache `shouldEqual` "connected-cache"
762− result.repo `shouldEqual` "connected-repo"
763− count <- liftEffect $ Ref.read counter
764− count `shouldEqual` 1
13+main = launchAff_ do
14+ specs <- discover "Test\\.Yoga\\.Om\\.Layer\\..*Spec"
15+ liftEffect $ runSpecAndExitProcess [ consoleReporter ] specs
test/Test/Yoga/Om/Layer/CompositionSpec.pursnew file+66
@@ -0,0 +1,66 @@
1+module Test.Yoga.Om.Layer.CompositionSpec where
2+
3+import Prelude
4+
5+import Effect (Effect)
6+import Effect.Class (liftEffect)
7+import Effect.Class.Console as Console
8+import Effect.Ref as Ref
9+import Test.Spec (Spec, describe, it)
10+import Test.Spec.Assertions (shouldEqual)
11+import Yoga.Om as Om
12+import Yoga.Om.Layer (OmLayer, makeLayer, combineRequirements, runLayer)
13+
14+type Config = { port :: Int, host :: String }
15+type Logger = { log :: String -> Effect Unit }
16+type Database = { query :: String -> Effect (Array String) }
17+
18+spec :: Spec Unit
19+spec = describe "Composition" do
20+
21+ it "proves Row.Nub deduplicates shared requirements at type level" do
22+ let
23+ loggerLayer :: OmLayer (config :: Config) () { logger :: Logger }
24+ loggerLayer = makeLayer do
25+ { config } <- Om.ask
26+ pure { logger: { log: \msg -> Console.log $ "[" <> config.host <> "] " <> msg } }
27+
28+ databaseLayer :: OmLayer (config :: Config) () { database :: Database }
29+ databaseLayer = makeLayer do
30+ { config } <- Om.ask
31+ pure { database: { query: \_ -> pure [ "Result from " <> config.host ] } }
32+
33+ let
34+ _proofOfDeduplication :: OmLayer (config :: Config) _ _
35+ _proofOfDeduplication = combineRequirements loggerLayer databaseLayer
36+
37+ Console.log "Type-level deduplication works: (config, config) -> (config)"
38+ pure unit
39+
40+ it "proves deduplication at runtime via shared Ref" do
41+ accessLog <- liftEffect $ Ref.new ""
42+ let
43+ combinedLayer :: OmLayer (config :: Config, accessLog :: Ref.Ref String) () { logger :: Logger, database :: Database }
44+ combinedLayer = makeLayer do
45+ { config, accessLog: log } <- Om.ask
46+ liftEffect $ Ref.modify_ (_ <> "config-accessed-by-logger ") log
47+ let logger = { log: \msg -> Console.log $ "[" <> config.host <> "] " <> msg }
48+ liftEffect $ Ref.modify_ (_ <> "config-accessed-by-database ") log
49+ let database = { query: \_ -> pure [ "Result from " <> config.host ] }
50+ pure { logger, database }
51+
52+ let
53+ ctx =
54+ { config: { port: 5432, host: "localhost" }
55+ , accessLog: accessLog
56+ }
57+ result <- runLayer ctx combinedLayer
58+ # Om.runOm ctx
59+ { exception: \_ -> pure { logger: { log: \_ -> pure unit }, database: { query: \_ -> pure [] } } }
60+
61+ finalLog <- liftEffect $ Ref.read accessLog
62+ finalLog `shouldEqual` "config-accessed-by-logger config-accessed-by-database "
63+
64+ liftEffect $ result.logger.log "Testing logger from combined layer"
65+ queryResult <- liftEffect $ result.database.query "SELECT *"
66+ queryResult `shouldEqual` [ "Result from localhost" ]
test/Test/Yoga/Om/Layer/ErrorChannelSpec.pursnew file+167
@@ -0,0 +1,167 @@
1+module Test.Yoga.Om.Layer.ErrorChannelSpec where
2+
3+import Prelude
4+
5+import Effect.Aff (try)
6+import Effect.Aff.Class (liftAff)
7+import Effect.Class (liftEffect)
8+import Effect.Ref as Ref
9+import Data.Either (Either(..))
10+import Data.Variant (match)
11+import Test.Spec (Spec, describe, it)
12+import Test.Spec.Assertions (shouldEqual)
13+import Yoga.Om as Om
14+import Yoga.Om.Layer (OmLayer, Scope, makeLayer, makeScopedLayer, combineRequirements, runLayer, runScopedWith, provide)
15+
16+spec :: Spec Unit
17+spec = describe "Error Channel" do
18+
19+ it "composes layers with different error types" do
20+ let
21+ dbLayer :: OmLayer () (dbError :: String) { db :: String }
22+ dbLayer = makeLayer do
23+ pure { db: "connected" }
24+
25+ cacheLayer :: OmLayer () (cacheError :: String) { cache :: String }
26+ cacheLayer = makeLayer do
27+ pure { cache: "ready" }
28+
29+ combined :: OmLayer () (dbError :: String, cacheError :: String) { db :: String, cache :: String }
30+ combined = combineRequirements dbLayer cacheLayer
31+
32+ result <- Om.runOm {}
33+ { exception: \_ -> pure { db: "", cache: "" }
34+ , dbError: \_ -> pure { db: "", cache: "" }
35+ , cacheError: \_ -> pure { db: "", cache: "" }
36+ }
37+ (runLayer {} combined)
38+ result.db `shouldEqual` "connected"
39+ result.cache `shouldEqual` "ready"
40+
41+ it "propagates errors from the first layer" do
42+ let
43+ failingLayer :: OmLayer () (dbError :: String) { db :: String }
44+ failingLayer = makeLayer do
45+ Om.throw { dbError: "connection refused" }
46+
47+ okLayer :: OmLayer () () { cache :: String }
48+ okLayer = makeLayer do
49+ pure { cache: "ready" }
50+
51+ combined = combineRequirements failingLayer okLayer
52+
53+ result <- Om.runReader {} (runLayer {} combined)
54+ # liftAff
55+ case result of
56+ Left err -> do
57+ let
58+ msg = err # match
59+ { exception: \_ -> "exception"
60+ , dbError: \e -> e
61+ }
62+ msg `shouldEqual` "connection refused"
63+ Right _ -> "should have failed" `shouldEqual` "but succeeded"
64+
65+ it "propagates errors from the second layer" do
66+ let
67+ okLayer :: OmLayer () () { db :: String }
68+ okLayer = makeLayer do
69+ pure { db: "connected" }
70+
71+ failingLayer :: OmLayer () (cacheError :: String) { cache :: String }
72+ failingLayer = makeLayer do
73+ Om.throw { cacheError: "cache unavailable" }
74+
75+ combined = combineRequirements okLayer failingLayer
76+
77+ result <- Om.runReader {} (runLayer {} combined)
78+ # liftAff
79+ case result of
80+ Left err -> do
81+ let
82+ msg = err # match
83+ { exception: \_ -> "exception"
84+ , cacheError: \e -> e
85+ }
86+ msg `shouldEqual` "cache unavailable"
87+ Right _ -> "should have failed" `shouldEqual` "but succeeded"
88+
89+ it "propagates errors through vertical composition" do
90+ let
91+ baseLayer :: OmLayer () (baseError :: String) { base :: String }
92+ baseLayer = makeLayer do
93+ Om.throw { baseError: "base failed" }
94+
95+ upperLayer :: OmLayer (base :: String) (upperError :: String) { upper :: String }
96+ upperLayer = makeLayer do
97+ { base } <- Om.ask
98+ pure { upper: base <> "-extended" }
99+
100+ composed = upperLayer `provide` baseLayer
101+
102+ result <- Om.runReader {} (runLayer {} composed)
103+ # liftAff
104+ case result of
105+ Left err -> do
106+ let
107+ msg = err # match
108+ { exception: \_ -> "exception"
109+ , baseError: \e -> e
110+ , upperError: \_ -> "upper"
111+ }
112+ msg `shouldEqual` "base failed"
113+ Right _ -> "should have failed" `shouldEqual` "but succeeded"
114+
115+ it "scoped layers with errors run via runScopedWith" do
116+ log <- liftEffect $ Ref.new []
117+ let
118+ dbLayer :: OmLayer (scope :: Scope) (dbError :: String) { db :: String }
119+ dbLayer = makeScopedLayer
120+ (pure { db: "connected" })
121+ (\_ -> liftEffect $ Ref.modify_ (_ <> [ "db-closed" ]) log)
122+
123+ cacheLayer :: OmLayer (scope :: Scope) (cacheError :: String) { cache :: String }
124+ cacheLayer = makeScopedLayer
125+ (pure { cache: "ready" })
126+ (\_ -> liftEffect $ Ref.modify_ (_ <> [ "cache-closed" ]) log)
127+
128+ combined = combineRequirements dbLayer cacheLayer
129+
130+ let
131+ handlers =
132+ { exception: \_ -> pure { db: "", cache: "" }
133+ , dbError: \_ -> pure { db: "", cache: "" }
134+ , cacheError: \_ -> pure { db: "", cache: "" }
135+ }
136+ result <- liftAff $ runScopedWith handlers combined
137+ result.db `shouldEqual` "connected"
138+ result.cache `shouldEqual` "ready"
139+ finalLog <- liftEffect $ Ref.read log
140+ finalLog `shouldEqual` [ "cache-closed", "db-closed" ]
141+
142+ it "scoped layer error triggers finalizers" do
143+ log <- liftEffect $ Ref.new []
144+ let
145+ goodLayer :: OmLayer (scope :: Scope) () { good :: String }
146+ goodLayer = makeScopedLayer
147+ (pure { good: "ok" })
148+ (\_ -> liftEffect $ Ref.modify_ (_ <> [ "good-closed" ]) log)
149+
150+ failLayer :: OmLayer (scope :: Scope) (buildError :: String) { bad :: String }
151+ failLayer = makeScopedLayer
152+ (Om.throw { buildError: "build failed" })
153+ (\_ -> liftEffect $ Ref.modify_ (_ <> [ "bad-closed" ]) log)
154+
155+ combined = combineRequirements goodLayer failLayer
156+
157+ let
158+ handlers =
159+ { exception: \_ -> pure { good: "", bad: "" }
160+ , buildError: \_ -> pure { good: "", bad: "" }
161+ }
162+ result <- liftAff $ try $ runScopedWith handlers combined
163+ case result of
164+ Right r -> r.good `shouldEqual` ""
165+ Left _ -> pure unit
166+ finalLog <- liftEffect $ Ref.read log
167+ finalLog `shouldEqual` [ "good-closed" ]
test/Test/Yoga/Om/Layer/InstancesSpec.pursnew file+70
@@ -0,0 +1,70 @@
1+module Test.Yoga.Om.Layer.InstancesSpec where
2+
3+import Prelude
4+
5+import Control.Parallel (parApply)
6+import Effect.Class (liftEffect)
7+import Effect.Ref as Ref
8+import Test.Spec (Spec, describe, it)
9+import Test.Spec.Assertions (shouldEqual)
10+import Yoga.Om as Om
11+import Yoga.Om.Layer (OmLayer, makeLayer, runLayer)
12+
13+spec :: Spec Unit
14+spec = do
15+
16+ describe "Functor / Apply / Bind" do
17+
18+ it "maps over layer output" do
19+ let
20+ layer :: OmLayer () () { value :: Int }
21+ layer = makeLayer (pure { value: 10 })
22+
23+ mapped = layer <#> \r -> { doubled: r.value * 2 }
24+
25+ result <- Om.runOm {} { exception: \_ -> pure { doubled: 0 } } (runLayer {} mapped)
26+ result.doubled `shouldEqual` 20
27+
28+ it "applies two layers" do
29+ let
30+ fLayer :: OmLayer () () ({ a :: Int } -> { a :: Int, b :: String })
31+ fLayer = makeLayer (pure \r -> { a: r.a, b: show r.a })
32+
33+ aLayer :: OmLayer () () { a :: Int }
34+ aLayer = makeLayer (pure { a: 42 })
35+
36+ result <- Om.runOm {} { exception: \_ -> pure { a: 0, b: "" } } (runLayer {} (fLayer <*> aLayer))
37+ result.a `shouldEqual` 42
38+ result.b `shouldEqual` "42"
39+
40+ it "binds layers sequentially" do
41+ let
42+ layer = do
43+ r <- makeLayer (pure { x: 5 }) :: OmLayer () () { x :: Int }
44+ makeLayer (pure { result: r.x + 10 })
45+
46+ result <- Om.runOm {} { exception: \_ -> pure { result: 0 } } (runLayer {} layer)
47+ result.result `shouldEqual` 15
48+
49+ describe "Parallel" do
50+
51+ it "runs layers in parallel via parApply" do
52+ log <- liftEffect $ Ref.new []
53+ let
54+ layerA :: OmLayer () () { a :: String }
55+ layerA = makeLayer do
56+ liftEffect $ Ref.modify_ (_ <> [ "a" ]) log
57+ pure { a: "A" }
58+
59+ layerB :: OmLayer () () { b :: String }
60+ layerB = makeLayer do
61+ liftEffect $ Ref.modify_ (_ <> [ "b" ]) log
62+ pure { b: "B" }
63+
64+ combined = parApply (map (\ra rb -> { a: ra.a, b: rb.b }) layerA) layerB
65+
66+ result <- Om.runOm {} { exception: \_ -> pure { a: "", b: "" } } (runLayer {} combined)
67+ result.a `shouldEqual` "A"
68+ result.b `shouldEqual` "B"
69+ finalLog <- liftEffect $ Ref.read log
70+ finalLog `shouldEqual` [ "a", "b" ]
test/Test/Yoga/Om/Layer/MemoizationSpec.pursnew file+114
@@ -0,0 +1,114 @@
1+module Test.Yoga.Om.Layer.MemoizationSpec where
2+
3+import Prelude
4+
5+import Effect.Aff.Class (liftAff)
6+import Effect.Class (liftEffect)
7+import Effect.Ref as Ref
8+import Test.Spec (Spec, describe, it)
9+import Test.Spec.Assertions (shouldEqual)
10+import Yoga.Om as Om
11+import Yoga.Om.Layer (OmLayer, Scope, makeLayer, makeScopedLayer, bracketLayer, combineRequirements, runScoped, provide)
12+
13+spec :: Spec Unit
14+spec = describe "Automatic Memoization" do
15+
16+ it "builds the same layer only once when used in two branches" do
17+ counter <- liftEffect $ Ref.new 0
18+ let
19+ expensiveLayer :: OmLayer (scope :: Scope) () { value :: String }
20+ expensiveLayer = makeLayer do
21+ liftEffect $ Ref.modify_ (_ + 1) counter
22+ pure { value: "built" }
23+
24+ branch1 :: OmLayer (scope :: Scope) () { out1 :: String }
25+ branch1 = consumer `provide` expensiveLayer
26+ where
27+ consumer :: OmLayer (scope :: Scope, value :: String) () { out1 :: String }
28+ consumer = makeLayer do
29+ { value } <- Om.ask
30+ pure { out1: value <> "-1" }
31+
32+ branch2 :: OmLayer (scope :: Scope) () { out2 :: String }
33+ branch2 = consumer `provide` expensiveLayer
34+ where
35+ consumer :: OmLayer (scope :: Scope, value :: String) () { out2 :: String }
36+ consumer = makeLayer do
37+ { value } <- Om.ask
38+ pure { out2: value <> "-2" }
39+
40+ app = combineRequirements branch1 branch2
41+
42+ result <- liftAff $ runScoped app
43+ result.out1 `shouldEqual` "built-1"
44+ result.out2 `shouldEqual` "built-2"
45+ count <- liftEffect $ Ref.read counter
46+ count `shouldEqual` 1
47+
48+ it "memoized scoped layer runs finalizer only once" do
49+ log <- liftEffect $ Ref.new []
50+ let
51+ dbLayer :: OmLayer (scope :: Scope) () { db :: String }
52+ dbLayer = makeScopedLayer
53+ (pure { db: "connected" })
54+ (\_ -> liftEffect $ Ref.modify_ (_ <> [ "db-closed" ]) log)
55+
56+ repoBranch :: OmLayer (scope :: Scope) () { repo :: String }
57+ repoBranch = repo `provide` dbLayer
58+ where
59+ repo :: OmLayer (scope :: Scope, db :: String) () { repo :: String }
60+ repo = makeLayer do
61+ { db } <- Om.ask
62+ pure { repo: db <> "-repo" }
63+
64+ analyticsBranch :: OmLayer (scope :: Scope) () { analytics :: String }
65+ analyticsBranch = analytics `provide` dbLayer
66+ where
67+ analytics :: OmLayer (scope :: Scope, db :: String) () { analytics :: String }
68+ analytics = makeLayer do
69+ { db } <- Om.ask
70+ pure { analytics: db <> "-analytics" }
71+
72+ app = combineRequirements repoBranch analyticsBranch
73+
74+ result <- liftAff $ runScoped app
75+ result.repo `shouldEqual` "connected-repo"
76+ result.analytics `shouldEqual` "connected-analytics"
77+ finalLog <- liftEffect $ Ref.read log
78+ finalLog `shouldEqual` [ "db-closed" ]
79+
80+ it "memoized bracketLayer acquires and releases only once" do
81+ log <- liftEffect $ Ref.new []
82+ let
83+ connLayer :: OmLayer (scope :: Scope) () { conn :: Int }
84+ connLayer = bracketLayer
85+ ( do
86+ liftEffect $ Ref.modify_ (_ <> [ "acquire" ]) log
87+ pure 42
88+ )
89+ (\_ -> liftEffect $ Ref.modify_ (_ <> [ "release" ]) log)
90+ (\n -> pure { conn: n })
91+
92+ branch1 :: OmLayer (scope :: Scope) () { x :: String }
93+ branch1 = upper `provide` connLayer
94+ where
95+ upper :: OmLayer (scope :: Scope, conn :: Int) () { x :: String }
96+ upper = makeLayer do
97+ { conn } <- Om.ask
98+ pure { x: show conn }
99+
100+ branch2 :: OmLayer (scope :: Scope) () { y :: String }
101+ branch2 = upper `provide` connLayer
102+ where
103+ upper :: OmLayer (scope :: Scope, conn :: Int) () { y :: String }
104+ upper = makeLayer do
105+ { conn } <- Om.ask
106+ pure { y: show (conn + 1) }
107+
108+ app = combineRequirements branch1 branch2
109+
110+ result <- liftAff $ runScoped app
111+ result.x `shouldEqual` "42"
112+ result.y `shouldEqual` "43"
113+ finalLog <- liftEffect $ Ref.read log
114+ finalLog `shouldEqual` [ "acquire", "release" ]
test/Test/Yoga/Om/Layer/RecoveringSpec.pursnew file+128
@@ -0,0 +1,128 @@
1+module Test.Yoga.Om.Layer.RecoveringSpec where
2+
3+import Prelude
4+
5+import Effect.Aff.Class (liftAff)
6+import Effect.Class (liftEffect)
7+import Effect.Ref as Ref
8+import Data.Either (Either(..))
9+import Data.Variant (match)
10+import Data.Time.Duration (Milliseconds(..))
11+import Effect.Aff.Retry (RetryStatus(..), limitRetries, constantDelay)
12+import Test.Spec (Spec, describe, it)
13+import Test.Spec.Assertions (shouldEqual)
14+import Yoga.Om as Om
15+import Yoga.Om.Layer (OmLayer, makeLayer, runLayer, recovering)
16+
17+spec :: Spec Unit
18+spec = describe "Layer recovering" do
19+
20+ it "retries layer construction on matching errors" do
21+ attemptsRef <- liftEffect $ Ref.new 0
22+ let
23+ layer :: OmLayer () (dbError :: String) { db :: String }
24+ layer = makeLayer do
25+ attempts <- Ref.modify (_ + 1) attemptsRef # liftEffect
26+ when (attempts < 3) do
27+ Om.throw { dbError: "connection refused" }
28+ pure { db: "connected" }
29+
30+ retried = layer
31+ # recovering (constantDelay (Milliseconds 0.0) <> limitRetries 5)
32+ \_ -> { dbError: \_ -> pure true }
33+
34+ result <- Om.runOm {}
35+ { exception: \_ -> pure { db: "" }, dbError: \_ -> pure { db: "" } }
36+ (runLayer {} retried)
37+ result.db `shouldEqual` "connected"
38+ attempts <- liftEffect $ Ref.read attemptsRef
39+ attempts `shouldEqual` 3
40+
41+ it "does not retry on non-matching errors" do
42+ attemptsRef <- liftEffect $ Ref.new 0
43+ let
44+ layer :: OmLayer () (dbError :: String, authError :: String) { db :: String }
45+ layer = makeLayer do
46+ Ref.modify_ (_ + 1) attemptsRef # liftEffect
47+ Om.throw { authError: "unauthorized" }
48+
49+ retried = layer
50+ # recovering (constantDelay (Milliseconds 0.0) <> limitRetries 3)
51+ \_ -> { dbError: \_ -> pure true }
52+
53+ _ <- Om.runOm {}
54+ { exception: \_ -> pure { db: "" }
55+ , dbError: \_ -> pure { db: "" }
56+ , authError: \_ -> pure { db: "" }
57+ }
58+ (runLayer {} retried)
59+ attempts <- liftEffect $ Ref.read attemptsRef
60+ attempts `shouldEqual` 1
61+
62+ it "preserves the error when retries are exhausted" do
63+ let
64+ layer :: OmLayer () (dbError :: String) { db :: String }
65+ layer = makeLayer do
66+ Om.throw { dbError: "connection refused" }
67+
68+ retried = layer
69+ # recovering (constantDelay (Milliseconds 0.0) <> limitRetries 2)
70+ \_ -> { dbError: \_ -> pure true }
71+
72+ result <- Om.runReader {} (runLayer {} retried)
73+ # liftAff
74+ case result of
75+ Left err -> do
76+ let
77+ msg = err # match
78+ { exception: \_ -> "exception"
79+ , dbError: \e -> e
80+ }
81+ msg `shouldEqual` "connection refused"
82+ Right _ -> "should have failed" `shouldEqual` "but succeeded"
83+
84+ it "only retries the specified error in a multi-error layer" do
85+ attemptsRef <- liftEffect $ Ref.new 0
86+ let
87+ layer :: OmLayer () (dbError :: String, cacheError :: String) { db :: String }
88+ layer = makeLayer do
89+ attempts <- Ref.modify (_ + 1) attemptsRef # liftEffect
90+ if attempts <= 2 then Om.throw { dbError: "db down" }
91+ else Om.throw { cacheError: "cache miss" }
92+
93+ retried = layer
94+ # recovering (constantDelay (Milliseconds 0.0) <> limitRetries 5)
95+ \_ -> { dbError: \_ -> pure true }
96+
97+ result <- Om.runReader {} (runLayer {} retried)
98+ # liftAff
99+ case result of
100+ Left err -> do
101+ let
102+ msg = err # match
103+ { exception: \_ -> "exception"
104+ , dbError: \e -> e
105+ , cacheError: \e -> e
106+ }
107+ msg `shouldEqual` "cache miss"
108+ Right _ -> "should have failed" `shouldEqual` "but succeeded"
109+ attempts <- liftEffect $ Ref.read attemptsRef
110+ attempts `shouldEqual` 3
111+
112+ it "uses RetryStatus in the check" do
113+ attemptsRef <- liftEffect $ Ref.new 0
114+ let
115+ layer :: OmLayer () (dbError :: String) { db :: String }
116+ layer = makeLayer do
117+ Ref.modify_ (_ + 1) attemptsRef # liftEffect
118+ Om.throw { dbError: "fail" }
119+
120+ retried = layer
121+ # recovering (constantDelay (Milliseconds 0.0) <> limitRetries 10)
122+ \(RetryStatus s) -> { dbError: \_ -> pure (s.iterNumber < 2) }
123+
124+ _ <- Om.runOm {}
125+ { exception: \_ -> pure { db: "" }, dbError: \_ -> pure { db: "" } }
126+ (runLayer {} retried)
127+ attempts <- liftEffect $ Ref.read attemptsRef
128+ attempts `shouldEqual` 3
test/Test/Yoga/Om/Layer/RepeatingSpec.pursnew file+49
@@ -0,0 +1,49 @@
1+module Test.Yoga.Om.Layer.RepeatingSpec where
2+
3+import Prelude
4+
5+import Effect.Class (liftEffect)
6+import Effect.Ref as Ref
7+import Data.Time.Duration (Milliseconds(..))
8+import Effect.Aff.Retry (limitRetries, constantDelay)
9+import Test.Spec (Spec, describe, it)
10+import Test.Spec.Assertions (shouldEqual)
11+import Yoga.Om as Om
12+import Yoga.Om.Layer (OmLayer, makeLayer, runLayer, repeating)
13+
14+spec :: Spec Unit
15+spec = describe "Layer repeating" do
16+
17+ it "repeats layer construction while condition holds" do
18+ counterRef <- liftEffect $ Ref.new 0
19+ let
20+ layer :: OmLayer () () { count :: Int }
21+ layer = makeLayer do
22+ count <- Ref.modify (_ + 1) counterRef # liftEffect
23+ pure { count }
24+
25+ repeated = layer
26+ # repeating (constantDelay (Milliseconds 0.0) <> limitRetries 10)
27+ \_ r -> pure (r.count < 5)
28+
29+ result <- Om.runOm {}
30+ { exception: \_ -> pure { count: 0 } }
31+ (runLayer {} repeated)
32+ result.count `shouldEqual` 5
33+
34+ it "stops when policy exhausted" do
35+ counterRef <- liftEffect $ Ref.new 0
36+ let
37+ layer :: OmLayer () () { count :: Int }
38+ layer = makeLayer do
39+ count <- Ref.modify (_ + 1) counterRef # liftEffect
40+ pure { count }
41+
42+ repeated = layer
43+ # repeating (constantDelay (Milliseconds 0.0) <> limitRetries 3)
44+ \_ _ -> pure true
45+
46+ result <- Om.runOm {}
47+ { exception: \_ -> pure { count: 0 } }
48+ (runLayer {} repeated)
49+ result.count `shouldEqual` 4
test/Test/Yoga/Om/Layer/ScopedSpec.pursnew file+146
@@ -0,0 +1,146 @@
1+module Test.Yoga.Om.Layer.ScopedSpec where
2+
3+import Prelude
4+
5+import Effect.Aff (throwError, try)
6+import Effect.Aff.Class (liftAff)
7+import Effect.Exception (error)
8+import Effect.Class (liftEffect)
9+import Effect.Ref as Ref
10+import Test.Spec (Spec, describe, it)
11+import Test.Spec.Assertions (shouldEqual)
12+import Yoga.Om as Om
13+import Yoga.Om.Layer (OmLayer, Scope, makeLayer, makeScopedLayer, bracketLayer, fresh, combineRequirements, runScoped, withScoped, provide)
14+
15+spec :: Spec Unit
16+spec = describe "Scoped Layers" do
17+
18+ it "runs finalizers in reverse order on success" do
19+ log <- liftEffect $ Ref.new []
20+ let
21+ layerA :: OmLayer (scope :: Scope) () { a :: String }
22+ layerA = makeScopedLayer
23+ (pure { a: "A" })
24+ (\_ -> liftEffect $ Ref.modify_ (_ <> [ "release-A" ]) log)
25+
26+ layerB :: OmLayer (scope :: Scope) () { b :: String }
27+ layerB = makeScopedLayer
28+ (pure { b: "B" })
29+ (\_ -> liftEffect $ Ref.modify_ (_ <> [ "release-B" ]) log)
30+
31+ layerC :: OmLayer (scope :: Scope) () { c :: String }
32+ layerC = makeScopedLayer
33+ (pure { c: "C" })
34+ (\_ -> liftEffect $ Ref.modify_ (_ <> [ "release-C" ]) log)
35+
36+ layerBC :: OmLayer (scope :: Scope) () { b :: String, c :: String }
37+ layerBC = combineRequirements layerB layerC
38+
39+ combined = combineRequirements layerA layerBC
40+
41+ _ <- liftAff $ runScoped combined
42+ finalLog <- liftEffect $ Ref.read log
43+ finalLog `shouldEqual` [ "release-C", "release-B", "release-A" ]
44+
45+ it "runs finalizers when the callback throws" do
46+ log <- liftEffect $ Ref.new []
47+ let
48+ layer :: OmLayer (scope :: Scope) () { value :: String }
49+ layer = makeScopedLayer
50+ (pure { value: "acquired" })
51+ (\_ -> liftEffect $ Ref.modify_ (_ <> [ "released" ]) log)
52+
53+ _ <- liftAff $ try $ withScoped layer \_ ->
54+ throwError (error "callback failed")
55+ finalLog <- liftEffect $ Ref.read log
56+ finalLog `shouldEqual` [ "released" ]
57+
58+ it "threads finalizers through vertical composition (provide)" do
59+ log <- liftEffect $ Ref.new []
60+ let
61+ baseLayer :: OmLayer (scope :: Scope) () { base :: String }
62+ baseLayer = makeScopedLayer
63+ (pure { base: "base-value" })
64+ (\_ -> liftEffect $ Ref.modify_ (_ <> [ "release-base" ]) log)
65+
66+ upperLayer :: OmLayer (scope :: Scope, base :: String) () { upper :: String }
67+ upperLayer = makeScopedLayer
68+ ( do
69+ { base } <- Om.ask
70+ pure { upper: base <> "-extended" }
71+ )
72+ (\_ -> liftEffect $ Ref.modify_ (_ <> [ "release-upper" ]) log)
73+
74+ composed = upperLayer `provide` baseLayer
75+
76+ result <- liftAff $ runScoped composed
77+ result.upper `shouldEqual` "base-value-extended"
78+ finalLog <- liftEffect $ Ref.read log
79+ finalLog `shouldEqual` [ "release-upper", "release-base" ]
80+
81+ it "works with bracketLayer" do
82+ log <- liftEffect $ Ref.new []
83+ let
84+ layer = bracketLayer
85+ ( do
86+ liftEffect $ Ref.modify_ (_ <> [ "acquire" ]) log
87+ pure 42
88+ )
89+ (\_ -> liftEffect $ Ref.modify_ (_ <> [ "release" ]) log)
90+ (\n -> pure { value: n })
91+
92+ result <- liftAff $ runScoped layer
93+ result.value `shouldEqual` 42
94+ finalLog <- liftEffect $ Ref.read log
95+ finalLog `shouldEqual` [ "acquire", "release" ]
96+
97+ it "fresh layer builds twice even when same base layer" do
98+ counter <- liftEffect $ Ref.new 0
99+ let
100+ baseLayer :: OmLayer (scope :: Scope) () { value :: String }
101+ baseLayer = makeLayer do
102+ liftEffect $ Ref.modify_ (_ + 1) counter
103+ pure { value: "built" }
104+
105+ branch1 :: OmLayer (scope :: Scope) () { out1 :: String }
106+ branch1 = consumer `provide` fresh baseLayer
107+ where
108+ consumer :: OmLayer (scope :: Scope, value :: String) () { out1 :: String }
109+ consumer = makeLayer do
110+ { value } <- Om.ask
111+ pure { out1: value <> "-1" }
112+
113+ branch2 :: OmLayer (scope :: Scope) () { out2 :: String }
114+ branch2 = consumer `provide` fresh baseLayer
115+ where
116+ consumer :: OmLayer (scope :: Scope, value :: String) () { out2 :: String }
117+ consumer = makeLayer do
118+ { value } <- Om.ask
119+ pure { out2: value <> "-2" }
120+
121+ app = combineRequirements branch1 branch2
122+
123+ result <- liftAff $ runScoped app
124+ result.out1 `shouldEqual` "built-1"
125+ result.out2 `shouldEqual` "built-2"
126+ count <- liftEffect $ Ref.read counter
127+ count `shouldEqual` 2
128+
129+ it "combines scoped and non-scoped layers" do
130+ log <- liftEffect $ Ref.new []
131+ let
132+ scopedLayer :: OmLayer (scope :: Scope) () { scoped :: String }
133+ scopedLayer = makeScopedLayer
134+ (pure { scoped: "yes" })
135+ (\_ -> liftEffect $ Ref.modify_ (_ <> [ "release-scoped" ]) log)
136+
137+ plainLayer :: OmLayer () () { plain :: String }
138+ plainLayer = makeLayer (pure { plain: "no-finalizer" })
139+
140+ combined = combineRequirements scopedLayer plainLayer
141+
142+ result <- liftAff $ runScoped combined
143+ result.scoped `shouldEqual` "yes"
144+ result.plain `shouldEqual` "no-finalizer"
145+ finalLog <- liftEffect $ Ref.read log
146+ finalLog `shouldEqual` [ "release-scoped" ]
test/Test/Yoga/Om/Layer/WireLayersSpec.pursnew file+108
@@ -0,0 +1,108 @@
1+module Test.Yoga.Om.Layer.WireLayersSpec where
2+
3+import Prelude
4+
5+import Effect.Aff.Class (liftAff)
6+import Effect.Class (liftEffect)
7+import Effect.Ref as Ref
8+import Test.Spec (Spec, describe, it)
9+import Test.Spec.Assertions (shouldEqual)
10+import Yoga.Om as Om
11+import Yoga.Om.Layer (OmLayer, Scope, makeLayer, runScoped, wireLayers)
12+
13+spec :: Spec Unit
14+spec = describe "wireLayers" do
15+
16+ it "wires a simple linear chain (A -> B -> C)" do
17+ let
18+ a_base :: OmLayer (scope :: Scope) () { base :: String }
19+ a_base = makeLayer (pure { base: "foundation" })
20+
21+ b_mid :: OmLayer (scope :: Scope, base :: String) () { mid :: String }
22+ b_mid = makeLayer do
23+ { base } <- Om.ask
24+ pure { mid: base <> "-middle" }
25+
26+ c_top :: OmLayer (scope :: Scope, mid :: String) () { top :: String }
27+ c_top = makeLayer do
28+ { mid } <- Om.ask
29+ pure { top: mid <> "-top" }
30+
31+ app = wireLayers { a_base, b_mid, c_top }
32+
33+ result <- liftAff $ runScoped app
34+ result.base `shouldEqual` "foundation"
35+ result.mid `shouldEqual` "foundation-middle"
36+ result.top `shouldEqual` "foundation-middle-top"
37+
38+ it "wires independent layers with no inter-dependencies" do
39+ let
40+ a_x :: OmLayer (scope :: Scope) () { x :: Int }
41+ a_x = makeLayer (pure { x: 1 })
42+
43+ b_y :: OmLayer (scope :: Scope) () { y :: Int }
44+ b_y = makeLayer (pure { y: 2 })
45+
46+ c_z :: OmLayer (scope :: Scope) () { z :: Int }
47+ c_z = makeLayer (pure { z: 3 })
48+
49+ app = wireLayers { a_x, b_y, c_z }
50+
51+ result <- liftAff $ runScoped app
52+ result.x `shouldEqual` 1
53+ result.y `shouldEqual` 2
54+ result.z `shouldEqual` 3
55+
56+ it "wires a diamond dependency" do
57+ let
58+ a_db :: OmLayer (scope :: Scope) () { db :: String }
59+ a_db = makeLayer (pure { db: "postgres" })
60+
61+ b_cache :: OmLayer (scope :: Scope, db :: String) () { cache :: String }
62+ b_cache = makeLayer do
63+ { db } <- Om.ask
64+ pure { cache: db <> "-cache" }
65+
66+ b_repo :: OmLayer (scope :: Scope, db :: String) () { repo :: String }
67+ b_repo = makeLayer do
68+ { db } <- Om.ask
69+ pure { repo: db <> "-repo" }
70+
71+ d_app :: OmLayer (scope :: Scope, cache :: String, repo :: String) () { app :: String }
72+ d_app = makeLayer do
73+ { cache, repo } <- Om.ask
74+ pure { app: cache <> "+" <> repo }
75+
76+ app = wireLayers { a_db, b_cache, b_repo, d_app }
77+
78+ result <- liftAff $ runScoped app
79+ result.db `shouldEqual` "postgres"
80+ result.cache `shouldEqual` "postgres-cache"
81+ result.repo `shouldEqual` "postgres-repo"
82+ result.app `shouldEqual` "postgres-cache+postgres-repo"
83+
84+ it "memoizes shared dependencies within wireLayers" do
85+ counter <- liftEffect $ Ref.new 0
86+ let
87+ a_db :: OmLayer (scope :: Scope) () { db :: String }
88+ a_db = makeLayer do
89+ liftEffect $ Ref.modify_ (_ + 1) counter
90+ pure { db: "connected" }
91+
92+ b_cache :: OmLayer (scope :: Scope, db :: String) () { cache :: String }
93+ b_cache = makeLayer do
94+ { db } <- Om.ask
95+ pure { cache: db <> "-cache" }
96+
97+ b_repo :: OmLayer (scope :: Scope, db :: String) () { repo :: String }
98+ b_repo = makeLayer do
99+ { db } <- Om.ask
100+ pure { repo: db <> "-repo" }
101+
102+ app = wireLayers { a_db, b_cache, b_repo }
103+
104+ result <- liftAff $ runScoped app
105+ result.cache `shouldEqual` "connected-cache"
106+ result.repo `shouldEqual` "connected-repo"
107+ count <- liftEffect $ Ref.read counter
108+ count `shouldEqual` 1