Trying to undertand brian-watkins/elm-procedure?

I was asking about ports with a request/response pattern here:

Which led to looking at this package:

https://package.elm-lang.org/packages/brian-watkins/elm-procedure/1.1.0/

@brian-watkins I really like the idea of this package. Particularly its Procedure abstraction which is monadic with its map and andThen functions. Its a really nice way of expressing a state machine that processes a sequence of events, whilst also unifying the Elm event stream accross Cmd, Subscription and Task. Just not sure it actually does what I want it to? Has anyone experience of using it?

Its the docs for filter that are throwing me off a bit. Procedure.Channel - elm-procedure 1.1.0

“”
Filter messages received by whatever subscription is listening on this channel.

For example, you might need to send a command over a port and then wait for a response via a port subscription. You could accomplish that like so:

Channel.open (\key -> myPortCommand key)
  |> Channel.connect myPortSubscription
  |> Channel.filter (\key data -> data.key == key)
  |> Channel.acceptOne
  |> Procedure.run ProcedureTagger DataTagger

In this example, we pass the channel key through the port and use it to filter incoming subscription messages. This allows us to associate the messages we receive with a particular channel (and procedure), in case multiple procedures with channels that utilize this subscription are running simultaneously.
“”

Looking through the sources I didn’t find a Dict key (Value -> msg). The idea being that multiple requests could be in flight at the same time with different keys, and when they complete, a function that was set up at the start of the request is then used to build the response message from what comes back in the port.

Applying a filter suggests to me that what comes back in the subscritpion port will hit a filter and potentially be stopped at that point. I don’t see how the request-response pair is being bound to its key, in such a way that multiple request-response pairs can run concurrently.

The sample code doesn’t help me understand this any better:

  app.ports.syncPort.subscribe(function(word) {
    app.ports.portSubscription.send(`Thanks for the message: ${word}!`)
  })

There is no ‘correlation id’ passed through the output and input ports. I want a pair of ports like this:

  app.ports.syncPort.subscribe(function(args) {
    var id = args[0];
    var words = args[1];

    app.ports.portSubscription.send([id, `Thanks for the message: ${word}!`]);
  })

The idea being that the correlation id can be used to match up the request-response pair (as the key).

Here’s an example that (I think) fits the case you have in mind.

Suppose you have a port command and a port subscription like so:

import Procedure.Channel as Channel exposing (ChannelKey)

type alias SaveRequest =
  { id: ChannelKey
  , data: String
  }

type alias SaveResult =
  { id: ChannelKey
  , data: String
  , success: Bool
  }

port localStorageSaveResult : (SaveResult -> msg) -> Sub msg
port saveToLocalStorage : SaveRequest -> Cmd msg

And on the JS side, you wire up these ports like so:

app.ports.saveToLocalStorage.subscribe(function(request) {
  // actually save to local storage ... then ...
  app.ports.localStorageSaveResult.send({
    id: request.id,
    data: request.data,
    success: true
  })
})

Now, you could have a variety of Procedures that make use of these ports in your app.

For example:

saveWordProcedure : String -> Cmd Msg
saveWordProcedure word =
  Channel.open (\channelKey -> saveToLocalStorage { id = channelKey, data = word } )
    |> Channel.connect localStorageSaveResult
    |> Channel.filter (\channelKey result -> result.id == channelKey)
    |> Channel.acceptOne
    |> Procedure.map .data
    |> Procedure.run ProcMsg SavedWord


saveNumberProcedure : Int -> Cmd Msg
saveNumberProcedure number =
  Channel.open (\channelKey -> saveToLocalStorage { id = channelKey, data = String.fromInt number } )
    |> Channel.connect localStorageSaveResult
    |> Channel.filter (\channelKey result -> result.id == channelKey)
    |> Channel.acceptOne
    |> Procedure.map (\saveResult -> String.toInt saveResult.data |> Maybe.withDefault -1)
    |> Procedure.run ProcMsg SavedNumber

Notice that in both cases the Procedure uses the ChannelKey (which identifies a particular running instance of the procedure) to build the record that is sent through the saveToLocalStorage port. On the JS side, the channel key is passed back as the id attribute of the SaveResult record received by the localStorageSaveResult subscription. This allows each procedure to filter on the messages received from localStorageSaveResult so that it only operates on the ones it cares about.

Consider the case when you have the commands generated by saveWordProcedure and saveNumberProcedure in flight at the same time. When you add Procedure.Program.subscriptions to your app’s subscriptions function, it will subscribe to the localStorageSaveResult port and each procedure will receive any messages received on that port – since that’s just how Elm works. By using Channel.filter we can specify which of those messages should be handled by a particular procedure. That particular procedure will ignore other messages, but another procedure may filter differently and process those messages. The same thing will happen if you have multiple instances of (for example) saveWordProcedure in flight at once – the ChannelKey individuates different procedures and different executions of the same procedure.

So, elm-procedure provides a ChannelKey to individuate procedure instances, which you can use to filter subscription messages. Elm-Procedure is unopinonated about how you pass that piece of data back and forth across the port boundary. And note too that, if it doesn’t make sense for your use case, it’s not necessary to send around the ChannelKey at all.

I’ve updated the sample code in the repo and added an integration test that describes this case.

Hope this helps!

5 Likes

Thanks for the reply and updating the sample code.

I think what was confusing me is the way filter is a (ChannelKey -> a -> Bool) function. I was thinking, if there are multiple subscriptions in play at once, can the message come in on the wrong one, then hit the filter and get blocked there, and never processed? I also did not realize that if you create >1 subscription on the same port, the incoming message get delivered to ALL subscribers.

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.