Publishing an event with the previous value of an attribute with PubSub

Hey everyone!

In my app I have this resource (some parts omitted):

defmodule MyApp.Vote do
  use Ash.Resource,
    data_layer: AshPostgres.DataLayer

  actions do
    create :cast do
      argument :poll_id, :uuid, allow_nil?: false
      argument :option_id, :uuid, allow_nil?: false

      upsert? true
      upsert_identity :unique_voter_per_poll

      change manage_relationship(:poll_id, :poll, type: :append_and_remove)
      change manage_relationship(:option_id, :option, type: :append_and_remove)
      change relate_actor(:voter)
    end
  end

  relationships do
    belongs_to :poll, Poll do
      primary_key? true
      allow_nil? false
    end

    belongs_to :option, Option do
      primary_key? true
      allow_nil? false
    end

    belongs_to :voter, Voter do
      primary_key? true
      allow_nil? false
    end
  end

  identities do
    identity :unique_voter_per_poll, [:voter_id, :poll_id]
  end

  code_interface do
    define_for MyApp.Voting
    define :cast, args: [:poll_id, :option_id]
  end
end

If a voter casts their vote for another option in the same poll, this is an upsert so the new option id overwrites the old one.

Now, I want to publish updates on a pubsub because I want to display (and update) the count of votes for each option in a Live View. This means that when I cast a vote, I want to receive a vote event for the option_id, but I also want to receive an unvote event for the previous option_id.

What is the recommended way to do this?

I think what you’d need to do is handle that yourself with a custom notifier. You can write a notifier as a module with a notify/1 function and then reference it in your resource:

resource do
  simple_notifiers [YourNotifier]
end

Then you can publish a message for vote and unvote respectively. Although, thinking about it, there isn’t a good way to select old attributes when doing an upsert right now. You may need to implement a manual action and use ecto directly to effectively handle this case.

I think I’ve found a workaround: I’ve added a custom change that reads the current option.

defmodule Tabemono.Voting.Vote.Changes.StorePreviousOption do
  use Ash.Resource.Change
  require Ash.Query

  def change(changeset, _opts, ctx) do
    poll_id = Ash.Changeset.get_argument(changeset, :poll_id)
    voter_id = ctx.actor.id

    previous_vote =
      Tabemono.Voting.Vote
      |> Ash.Query.lock("FOR UPDATE NOWAIT")
      |> Ash.Query.filter(poll_id: poll_id, voter_id: voter_id)
      |> Tabemono.Voting.read_one!()

    if previous_vote do
      Ash.Changeset.put_context(changeset, :old_option_id, previous_vote.option_id)
    else
      changeset
    end
  end
end

To be extra careful, I’ve added a row lock. This way, in the (unlikely) event that the same user tries to update the vote from two different locations, one of them should fail, ensuring a single notification is sent for that old option id.

After that, it’s just a matter of reading the changeset context in the notification that gets sent. I would’ve preferred to put this information in the notification metadata instead, but I’m not sure how to control it.

Is this solutions reasonable or does it have any problem?

1 Like

Sounds like a good workaround to me :slight_smile: