Presence and Conflicts
A bounty board that several visitors edit at once. Each row shows who else has it open and a simple conflict-solving mechanism: keep mine, or take theirs. The conflict is found on the server, by comparing two copies of the row, and the page needs no JavaScript of its own.
Bounty board
Finding you a name…
Shared with every visitor. Put back each night.
# frozen_string_literal: true
# The bounty board. Which row is open belongs to this tab, and so does the
# copy of the bounty its form was filled from. The bounties, and who has
# which one open, belong to everyone: any change to either pings every tab,
# and each tab reads both again.
class Recipes::Presence::BoardChannel < ApplicationCable::Channel
include Hibiki::Rails::Channel
Bounty = Recipes::Presence::Bounty
Visitor = Recipes::Presence::Visitor
BountyForm = Recipes::Presence::BountyForm
VIEWS = "app/recipes/presence"
# Only a server that was killed leaves a visitor behind. This keeps the
# living ones apart from those.
periodically :beat, every: 30.seconds
def build_graph
@version = Hibiki::State.new(0)
@editing_id = Hibiki::State.new(nil)
# The line of the board the open form was filled from.
@base = Hibiki::State.new(nil)
# Part of the form's ids. A new number makes the next render put in new
# fields, where a morph would leave what was typed in the old ones.
@draft = Hibiki::State.new(0)
@form = BountyForm.new
# Plain values, so a ping that changes none of them sends nothing.
rows = Hibiki::Derived.new do
@version.value
Bounty.rows
end
editors = Hibiki::Derived.new do
@version.value
Visitor.editing(except: cid)
end
others = Hibiki::Derived.new do
@version.value
Visitor.others(except: cid)
end
# The conflict: the open bounty as it is now, when that is newer than
# the copy the form was filled from. Nobody sets it. It appears when
# the board is read again, and goes when the form is filled again.
@theirs = Hibiki::Derived.new do
base = @base.value
row = rows.value.find { it.id == @editing_id.value }
row if base && row && row.lock_version != base.lock_version
end
# The fields this visitor has changed. Names only, so typing changes
# it once per field and not once per letter.
@mine = Hibiki::Derived.new do
base = @base.value
base ? fields.select { @form.public_send(it) != base.public_send(it) } : []
end
# The whole board is one fragment. The open row's fields are read
# without subscribing to them, so typing renders nothing; `@mine` is
# followed only while there is a conflict to explain.
Hibiki::Effect.new do
theirs = @theirs.value
locals = { rows: rows.value, editors: editors.value, editing_id: @editing_id.value, draft: @draft.value,
form: @form, base: @base.value, theirs:, mine: theirs ? @mine.value : [] }
@form.errors
Hibiki.untrack do
broadcast_morph target: "presence_rows", partial: "#{VIEWS}/rows", locals:
end
end
Hibiki::Effect.new do
broadcast_morph target: "presence_who", partial: "#{VIEWS}/who", locals: { me: @me, others: others.value }
end
end
def edit(data)
bounty = Bounty.find_by(id: data["id"])
return unless bounty
fill(bounty)
@editing_id.value = bounty.id
Visitor.open(cid, bounty.id)
end
def cancel(_data) = close
# Keeps the form up to date with the fields, so a render caused by
# someone else puts back what was typed.
def set_field(data)
return unless @editing_id.value
assign(data)
end
# While there is a conflict the row offers Keep mine and Take theirs, and
# a plain save waits for one of them.
def save(data)
return unless @editing_id.value
assign(data)
commit unless @theirs.value
end
# Start again from the bounty as it is now.
def take_theirs(_data)
bounty = open_bounty
fill(bounty) if bounty
end
# Their save, with this visitor's changes on top: the form is filled from
# the bounty as it is now, and only the fields that were changed here are
# put back before it is saved.
def keep_mine(_data)
bounty = open_bounty
return unless bounty
kept = @mine.value.index_with { @form.public_send(it) }
fill(bounty)
kept.each { |name, value| @form.public_send(:"#{name}=", value) }
commit
end
private
def subscribed
@me = Visitor.join(cid) if cid.present?
super
return if subscription_rejected?
# A bounty saved in another tab, or a row opened or closed there. The
# block runs on a cable thread, so it hops onto the graph thread before
# touching a signal.
stream_from Bounty::CHANGED, coder: ActiveSupport::JSON do |ping|
graph_actor&.post { hear(ping) }
end
end
def unsubscribed
Visitor.leave(cid) if @me
super
end
def beat = Visitor.beat(cid)
# A ping this tab caused is about something it already knows.
def hear(ping)
reread unless ping["from"] == cid
end
def reread = @version.value += 1
def fields = BountyForm.hibiki_attributes
def open_bounty = Bounty.find_by(id: @editing_id.value)
def fill(bounty)
bounty.saved_by = cid
@form.hydrate(bounty)
@base.value = bounty.row
@draft.value += 1
end
def close(ping: true)
@editing_id.value = nil
@base.value = nil
Visitor.open(cid, nil, ping:)
end
# A save the model turns down leaves its messages on the form, and the
# row stays open. A save from a copy that is no longer the newest is the
# same conflict caught late: the ping has not been heard yet, so the
# board is read now.
#
# The save and the closed row are one transaction, so the bounty's ping
# goes out after both and is the only one the others need.
def commit
Bounty.transaction do
next unless @form.commit
close(ping: false)
reread
end
rescue ActiveRecord::StaleObjectError
reread
end
# Only the form's own attributes are taken from what the browser sent.
def assign(data)
fields.each do |name|
value = data[name.to_s]
@form.public_send(:"#{name}=", value.first(60)) if value.is_a?(String)
end
end
end
# frozen_string_literal: true
# The form in an open row of the Presence and Conflicts recipe. Each field
# is a signal. It has no rules: a save that the model turns down leaves the
# model's messages in `errors`.
class Recipes::Presence::BountyForm
include Hibiki::Rails::ReactiveForm
reactive_attributes "Recipes::Presence::Bounty", :target, :reward, :last_seen
end
# frozen_string_literal: true
# A notice on the bounty board of the Presence and Conflicts recipe. There
# is one board, and every visitor edits it, so what a visitor types is
# public. Recipes::Reset puts the notices back each night.
#
# The table has a `lock_version` column, so a save from a copy that is no
# longer the newest raises ActiveRecord::StaleObjectError.
class Recipes::Presence::Bounty < ApplicationRecord
self.table_name = "recipes_presence_bounties"
# A line of the board: a plain copy of a row, compared by what it says.
Row = Data.define(:id, :target, :reward, :last_seen, :lock_version)
# What the conflict notice calls each field a visitor can change.
LABELS = { target: "Target", reward: "Reward", last_seen: "Last seen" }.freeze
MAX_REWARD = 100_000
SEED_FILE = Rails.root.join("db/seeds/bounties.yml")
# Every open board listens here: for a bounty saved by someone else, and
# for someone opening or closing a row.
CHANGED = "recipes:presence:bounties"
# The tab that is saving this copy, when a channel is. Its ping carries
# the name, so that tab does not read again what it has just written.
attr_accessor :saved_by
after_commit { self.class.announce(from: saved_by) }
validates :target, presence: { message: "A bounty needs a target." },
length: { maximum: 40, message: "Forty letters is all the notice has room for." }
validates :reward, numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: MAX_REWARD,
message: "A reward is 0 to #{MAX_REWARD.to_fs(:delimited)} gp." }
validates :last_seen, length: { maximum: 40, message: "Forty letters is all the notice has room for." }
def self.rows = order(:id).map(&:row)
def self.gp(amount) = Recipes::Totals::Order.gp(amount)
# A ping that says only which tab caused it, if one did. Whoever else
# hears it reads the board again.
def self.announce(from: nil) = ActionCable.server.broadcast(CHANGED, { from: })
# Loads the board from db/seeds/bounties.yml, over whatever is there. Safe
# to run again.
def self.seed!
upsert_all(YAML.load_file(SEED_FILE), unique_by: :slug)
end
# `upsert_all` runs no callback and leaves `lock_version` alone, so both
# are done by hand: a form left open over the reset is told its bounty
# changed.
def self.reset!
seed!
update_all("lock_version = lock_version + 1")
announce
end
def row = Row.new(id:, target:, reward:, last_seen:, lock_version:)
end
# frozen_string_literal: true
# Who has the bounty board open, one row per open page. The channel adds a
# row when a page subscribes and deletes it when the page goes, and writes
# `bounty_id` while that page has a bounty open as a form.
#
# Presence is a table so that every server process sees the same visitors,
# and so that a change to it is news like any other: it pings the stream
# the board already listens on.
class Recipes::Presence::Visitor < ApplicationRecord
self.table_name = "recipes_presence_visitors"
Bounty = Recipes::Presence::Bounty
# A page that has not been heard from for this long is gone. Only a
# server that was killed leaves such a row; a closed tab deletes its own.
LIVE = 90.seconds
scope :live, -> { where(seen_at: LIVE.ago..) }
scope :except_for, ->(cid) { where.not(cid:) }
# Adds a page and gives it the lowest number nobody holds. The unique
# index settles two pages that pick the same one.
def self.join(cid)
where(cid:).or(where(seen_at: ...LIVE.ago)).delete_all
visitor = begin
create!(cid:, number: free_number, seen_at: Time.current)
rescue ActiveRecord::RecordNotUnique
retry
end
Bounty.announce(from: cid)
visitor
end
# `delete_all` and `update_all` run no callback, so the ping is sent by
# hand. Each ping names the page that caused it.
def self.leave(cid)
where(cid:).delete_all
Bounty.announce(from: cid)
end
# Which bounty the page has open; nil when it has none. A caller whose
# own save is about to ping asks for no second one.
def self.open(cid, bounty_id, ping: true)
where(cid:).update_all(bounty_id:)
Bounty.announce(from: cid) if ping
end
# Nothing anyone sees changes, so no ping.
def self.beat(cid) = where(cid:).update_all(seen_at: Time.current)
# Who else has which bounty open: { bounty id => [numbers] }.
def self.editing(except:)
live.except_for(except).where.not(bounty_id: nil).order(:number).pluck(:bounty_id, :number)
.group_by(&:first).transform_values { it.map(&:last) }
end
def self.others(except:) = live.except_for(except).count
# "Adventurer 3 is editing", "Adventurers 2 and 3 are editing".
def self.editing_line(numbers)
return "Adventurer #{numbers.first} is editing" if numbers.one?
"Adventurers #{numbers.to_sentence} are editing"
end
def self.free_number
taken = pluck(:number)
(1..taken.size + 1).find { !taken.include?(it) }
end
private_class_method :free_number
def label = "Adventurer #{number}"
end
<%# The page reads the same rows and the same visitors the channel does. No
row is open yet, and this tab has no number until it subscribes. %>
<%= island Recipes::Presence::BoardChannel, class: "panel mx-auto w-full max-w-2xl p-4 sm:p-5" do %>
<header class="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<h2 class="flex items-baseline gap-2 text-xl font-bold">
Bounty board
<span class="loading loading-spinner loading-xs hidden in-data-hibiki-busy:inline-block" aria-hidden="true"></span>
</h2>
<%= render "app/recipes/presence/who" %>
</header>
<p class="text-sm opacity-70">Shared with every visitor. Put back each night.</p>
<%= render "app/recipes/presence/rows", rows: Recipes::Presence::Bounty.rows,
editors: Recipes::Presence::Visitor.editing(except: nil) %>
<% end %>
<%# locals: (me: nil, others: 0) -%>
<%# Who this tab is, and how many other tabs have the board open. %>
<p id="presence_who" class="text-sm">
<% if me %>
You are <b><%= me.label %></b>.
<%= others.zero? ? "Nobody else is here." : "#{pluralize(others, 'other')} #{others == 1 ? 'is' : 'are'} here." %>
<% else %>
<span class="opacity-70">Finding you a name…</span>
<% end %>
</p>
<%# locals: (rows:, editors: {}, editing_id: nil, draft: 0, form: nil, base: nil, theirs: nil, mine: []) -%>
<%# Morphed by the channel. Each row keeps its id whether it is open or not,
so the rows that did not change are left as they are. `editors` is who
else has which row open. %>
<div id="presence_rows" class="mt-3">
<ul id="presence_list">
<% rows.each do |row| %>
<li id="presence_row_<%= row.id %>" class="border-b-2 border-base-300">
<% if row.id == editing_id %>
<%= render "app/recipes/presence/form", form:, draft:, base:, theirs:, mine:, editing: editors[row.id] %>
<% else %>
<%= render "app/recipes/presence/row", row:, editing: editors[row.id] %>
<% end %>
</li>
<% end %>
</ul>
</div>
<%# locals: (row:, editing: nil) -%>
<%# A closed row is one button, so the keyboard can open it too. It stays a
button while someone else has the row open: the badge warns, and
nothing is locked. %>
<%= tag.button type: "button", id: "presence_show_#{row.id}",
class: "group flex w-full cursor-pointer flex-wrap items-center gap-x-3 gap-y-1 px-2 py-2 text-left hover:bg-base-200",
**on(:edit, with: { id: row.id }) do %>
<span class="min-w-0 flex-1 basis-48">
<span class="block font-semibold"><%= row.target %></span>
<span class="block text-xs opacity-70">Last seen: <%= row.last_seen.presence || "nowhere yet" %></span>
</span>
<%= render "app/recipes/presence/editing", editing: %>
<span class="text-xs opacity-0 group-hover:opacity-60 group-focus-visible:opacity-60">edit</span>
<b class="shrink-0 text-right text-sm whitespace-nowrap tabular-nums sm:w-24"><%= Recipes::Presence::Bounty.gp(row.reward) %></b>
<% end %>
<%# locals: (editing: nil) -%>
<%# Who else has this row open, as the numbers of their tabs. %>
<% if editing.present? %>
<span class="badge badge-sm badge-warning whitespace-nowrap"><%= Recipes::Presence::Visitor.editing_line(editing) %></span>
<% end %>
<%# locals: (form:, draft:, base: nil, theirs: nil, mine: [], editing: nil) -%>
<%# An open row. Enter saves, Escape cancels, and `focus` puts the cursor in
the target when this tab opens the row, as in Inline Edit. The ids
carry `draft`, so a form filled again is made of new fields. When the
bounty has changed underneath, the conflict notice takes the place of
Save. %>
<% id = "#{form.record.id}_#{draft}" %>
<% field = on(:set_field, event: :input) %>
<%= tag.div id: "presence_edit_#{id}", class: "bg-base-200 px-2 py-2", **on(:cancel, event: "keydown.esc") do %>
<%= tag.form id: "presence_form_#{id}", **on(:save, event: :submit, reset: false) do %>
<div class="flex flex-wrap items-center gap-2">
<%= tag.input type: "text", name: "target", id: "presence_#{id}_target", value: form.target, maxlength: 60,
autocomplete: "off", aria: { label: "Target" }, class: "input input-sm min-w-0 flex-1 basis-48 text-sm",
**field.deep_merge(focus(select: true)) %>
<label class="input input-sm w-32">
<%= tag.input type: "number", name: "reward", id: "presence_#{id}_reward", value: form.reward, min: 0,
inputmode: "numeric", aria: { label: "Reward" }, class: "text-sm tabular-nums", **field %>
<span class="opacity-70">gp</span>
</label>
<label class="input input-sm min-w-0 flex-1 basis-full">
<span class="opacity-70">Last seen</span>
<%= tag.input type: "text", name: "last_seen", id: "presence_#{id}_last_seen", value: form.last_seen,
maxlength: 60, autocomplete: "off", class: "text-sm", **field %>
</label>
</div>
<% form.errors.each_value do |messages| %>
<p class="mt-1 text-sm text-error"><%= messages.first %></p>
<% end %>
<% if theirs %>
<%= render "app/recipes/presence/conflict", base:, theirs:, mine: %>
<% end %>
<div class="mt-2 flex flex-wrap items-center gap-2">
<% if theirs %>
<%= tag.button "Keep mine", type: "button", class: "btn btn-primary btn-sm", **on(:keep_mine) %>
<%= tag.button "Take theirs", type: "button", class: "btn btn-sm", **on(:take_theirs) %>
<% else %>
<%= tag.button "Save", type: "submit", class: "btn btn-primary btn-sm" %>
<% end %>
<%= tag.button "Cancel", type: "button", class: "btn btn-sm", **on(:cancel) %>
<%= render "app/recipes/presence/editing", editing: %>
</div>
<% end %>
<% end %>
<%# locals: (base:, theirs:, mine:) -%>
<%# What the other save changed, from the copy this form was filled from
(`base`) to the bounty as it is now (`theirs`). `mine` names the fields
this visitor changed too. %>
<% bounty = Recipes::Presence::Bounty %>
<% changed = bounty::LABELS.reject { |name, _| theirs.public_send(name) == base.public_send(name) } %>
<div id="presence_conflict" role="alert" class="alert alert-warning alert-soft mt-2 block text-sm text-base-content">
<p class="font-semibold">This bounty was saved by someone else while you had it open.</p>
<ul class="mt-1 list-disc pl-5">
<% changed.each do |name, label| %>
<% value = theirs.public_send(name) %>
<li>
<b><%= label %></b> is now
<% if name == :reward %><%= bounty.gp(value) %><% else %><q><%= value.presence || "blank" %></q><% end %><% if mine.include?(name) %>, and you changed it too<% end %>.
</li>
<% end %>
<% if changed.empty? %>
<li>It reads as it did when you opened it.</li>
<% end %>
</ul>
<p class="mt-1">
<b>Keep mine</b> saves the fields you changed over theirs and leaves the rest as they saved it.
<b>Take theirs</b> drops what you typed.
</p>
</div>
The panel above is a bounty board: each bounty with a target, a reward and where it was last seen. A shared form like this has a natural problem: two people can edit the same row, and whoever saves last overwrites a change made by others. Try opening this page in a second window and put the two side by side: each window is an adventurer with a number. Try editing the same row in both, followed by a save in one of them. The other window is told at once what changed, and its Save button becomes two: Keep mine and Take theirs. Knowing who is here usually takes a channel and JavaScript of its own, and telling a visitor what changed under their form takes even more JavaScript. Here presence is a table the channel already writes to, and the conflict is a value worked out from two copies of the row.
The board is shared by every visitor: anyone can edit it, and it is reset every night.
How it fits together
The board lives in a channel: the server side of an Action
Cable connection, an object that stays alive while the page is open, one
for each tab. A state holds a value and remembers who read it.
Here @editing_id is a state holding the id of the row this
tab has open, and @base holds a copy of that bounty as it
was when the form was filled. The open row's fields are a reactive
form, BountyForm, with a state for each column, and
the channel's public methods are its actions, the methods the
page may call.
The bounties, and who has which one open, belong to everyone, so both
are tables. A derived is a value worked out from other values,
and worked out again when one of them changes. Here rows is
a derived that reads the bounties, and editors one that
reads who else has which row open, both as plain values. Each reads a
state first, @version, a number that goes up whenever
another tab saves a bounty or opens or closes a row, so each asks the
database again.
The conflict is a derived too. @theirs is the bounty this
tab has open, as the board has it now, when its version is no longer the
one in @base; the rest of the time it is nil.
An effect is a block of code that runs once, and runs again
whenever a value it read has changed. One renders the whole board, with
the badges, the open row and the notice drawn from those values, and
sends it to the page, where it is morphed into place. This is what
happens when another visitor saves the bounty you have open:
-
Their save writes the bounty, and its
lock_versiongoes up by one. After the commit the model pings every tab. -
Your channel hears the ping and adds one to
@version.rowsread it, so the bounties are read again, and your open row now carries the new version. -
@theirsreadrows, so it is worked out again, and it stops beingnil. The effect read@theirs, so it renders the board again: the notice sits in your open row, and Keep mine and Take theirs stand where Save was. Your fields are left as you have them.
The channel never says that the notice depends on a save in another tab. Whatever the effect read on its last run is what it depends on.
Who has a row open
Who has which row open is the one fact here that no single tab knows. Each tab has its own channel and its own states, so the fact has to live where every tab can read it. A table does that, and it works the same with several server processes:
# The channel. A page is a visitor from the moment it subscribes until its
# socket closes, however that happens.
def subscribed
@me = Visitor.join(cid) if cid.present?
super
return if subscription_rejected?
stream_from Bounty::CHANGED, coder: ActiveSupport::JSON do |ping|
graph_actor&.post { hear(ping) }
end
end
# A ping says only which tab caused it. That tab already knows.
def hear(ping)
reread unless ping["from"] == cid
end
def unsubscribed
Visitor.leave(cid) if @me
super
end
# Opening and closing a row write one column.
def edit(data)
# ...
Visitor.open(cid, bounty.id)
end
# Who else has which row open, read again on every ping. It is a plain
# hash, so a ping that changes nobody's row renders nothing.
editors = Hibiki::Derived.new do
@version.value
Visitor.editing(except: cid)
end
A page is a visitor from the moment it subscribes until its socket closes, and opening a row writes the bounty's id to its visitor. A change to that table pings the same stream a saved bounty does, naming the tab that caused it, so that tab does not read again what it already knows.
The badge is a warning and not a lock. A lock that a visitor forgets shuts everyone else out of the row, and a lock with a timer closes the form of a visitor who stopped to think. Here an open form stays open as long as its page does, and a closed tab takes its row away with it.
A badge that appears in a row is a render of the whole board, into forms other visitors are typing in. They keep what was typed, because each keystroke is sent to the form's states and the effect draws the fields from the form. (The open row itself, with its cursor, Enter and Escape, is the in-place form of the Inline Edit recipe.)
A change under an open form
Presence makes a conflict rare, and it does not prevent one. The save
that overwrites is often made when nobody else is editing: you open a
bounty, someone else opens it, saves and leaves, and then you save. So
the check has to be on the record. Active Record has one: a
lock_version column that every save counts up, so that a
save from a copy whose number is behind is refused. The channel keeps
the copy the form was filled from, and compares its number with the
board's:
# The line of the board the open form was filled from.
@base = Hibiki::State.new(nil)
def fill(bounty)
bounty.saved_by = cid
@form.hydrate(bounty)
@base.value = bounty.row
@draft.value += 1
end
# The conflict is derived, not set. `rows` is read again on every ping, so
# this turns from nil to their copy when someone else's save arrives.
@theirs = Hibiki::Derived.new do
base = @base.value
row = rows.value.find { it.id == @editing_id.value }
row if base && row && row.lock_version != base.lock_version
end
# A save made before the ping was heard. Active Record refuses it, and the
# answer is to read the board now: `@theirs` does the rest.
def commit
Bounty.transaction do
next unless @form.commit
close(ping: false)
reread
end
rescue ActiveRecord::StaleObjectError
reread
end
fill runs whenever the form is filled from a bounty, and
keeps a plain copy of the row in @base for
@theirs to compare with. A save can still leave before the
ping arrives. Then Active Record refuses it, and the channel answers by
reading the board, which is all the ping would have done. Found early or
late, the conflict is the same derived and the same notice.
Keep mine and Take theirs
A notice that says only "someone else saved this" leaves you to find out
what. This one lists each field the other save changed, and says which
of them you changed too. It can, because the channel holds three copies
of the bounty: the line the form was filled from, the line as the board
has it now, and the form. The fields you changed are a derived of names,
@mine, read only while the notice is up, so typing draws
the board once when a field joins the list and not once for each letter:
# The fields this visitor has changed, by name.
@mine = Hibiki::Derived.new do
base = @base.value
base ? fields.select { @form.public_send(it) != base.public_send(it) } : []
end
# Start again from the bounty as it is now.
def take_theirs(_data)
bounty = open_bounty
fill(bounty) if bounty
end
# Fill the form from the bounty as it is now, put back only the fields
# that were changed here, and save.
def keep_mine(_data)
bounty = open_bounty
return unless bounty
kept = @mine.value.index_with { @form.public_send(it) }
fill(bounty)
kept.each { |name, value| @form.public_send(:"#{name}=", value) }
commit
end
Take theirs fills the form from the bounty as it is now, and what
you typed is gone. A morph leaves a field you have typed in alone, so
the form's ids carry a number that fill counts up, and the
next render puts in new fields.
Keep mine is not "save anyway". Saving the whole form would write your old copy of the fields you never touched over what they saved. So it fills the form from the newest bounty, puts back only the fields you changed, and saves that. Change the target in one window and the reward in the other, and both changes survive.
The same feature in stock Rails
Stock Rails has the staleness check: it is the same
lock_version column. The rest is built by hand. Here is a
sketch of it. It is not run here.
# A sketch of the stock Rails version. It is not part of this app.
class BountiesController < ApplicationController
before_action :set_bounty, only: %i[show edit update]
def index
@bounties = Bounty.order(:id)
end
# Cancel. The row's frame asks for the closed row again.
def show
render partial: "bounties/bounty", locals: { bounty: @bounty }
end
# Opening a row, and Take theirs: the frame asks for a fresh form.
def edit
end
# The form carries the `lock_version` it was rendered with, so a save
# from an older copy raises.
def update
keep_mine if params[:keep_mine]
if @bounty.update(bounty_params)
render partial: "bounties/bounty", locals: { bounty: @bounty }
else
render :edit, status: :unprocessable_entity
end
rescue ActiveRecord::StaleObjectError
# Their values for the notice, the visitor's own for the fields.
@theirs = Bounty.find(@bounty.id)
render :edit, status: :conflict
end
private
def set_bounty = @bounty = Bounty.find(params[:id])
def bounty_params = params.expect(bounty: %i[target reward last_seen lock_version])
# The form also sends each field as it was first rendered. Only a field
# that differs from that was changed by this visitor; the rest are
# dropped, so the reloaded record keeps what the other save wrote.
def keep_mine
was = params.expect(was: %i[target reward last_seen])
params[:bounty].delete_if { |name, value| was[name] == value }
params[:bounty].delete(:lock_version)
end
end
class Bounty < ApplicationRecord
# The `lock_version` column turns optimistic locking on.
validates :target, presence: true, length: { maximum: 40 }
validates :reward, numericality: { only_integer: true, in: 0..100_000 }
validates :last_seen, length: { maximum: 40 }
has_many :visitors
after_update_commit do
# Closed rows. A visitor who has the row open has no such line.
broadcast_replace_to "bounties", target: dom_id(self, :line),
partial: "bounties/line", locals: { bounty: self }
# Open rows. Only a form has this element. Every open form gets the
# same notice, the saver's own included for a moment, and it cannot say
# which fields the reader changed: the server does not know what each
# form holds. The Stimulus controller works that out.
broadcast_update_to "bounties", target: dom_id(self, :notice),
partial: "bounties/notice", locals: { theirs: self }
end
end
# Who has the page open, and which row.
class Visitor < ApplicationRecord
belongs_to :bounty, optional: true
after_commit :broadcast_badges, :broadcast_count
private
# The row that was left and the row that was entered. The badge goes to
# everyone, the visitor it names included; the Stimulus controller hides
# a visitor's own.
def broadcast_badges
Bounty.where(id: [ bounty_id, bounty_id_before_last_save ].compact).each do |bounty|
broadcast_replace_to "bounties", target: dom_id(bounty, :editing),
partial: "bounties/editing", locals: { bounty: }
end
end
def broadcast_count
broadcast_update_to "bounties", target: "visitor_count", html: Visitor.count
end
end
# Turbo Streams carry HTML down. Knowing who is here needs a channel of its
# own, for the page to subscribe to and to send the open row up.
class PresenceChannel < ApplicationCable::Channel
def subscribed
@visitor = Visitor.create!(number: Visitor.free_number)
transmit({ number: @visitor.number })
end
def unsubscribed
@visitor&.destroy
end
def open(data)
@visitor.update!(bounty_id: data["id"])
end
end
<%= turbo_stream_from "bounties" %>
<div data-controller="presence">
<p>
You are Adventurer <span data-presence-target="me"></span>.
<span id="visitor_count"><%= Visitor.count %></span> here.
</p>
<ul>
<%= render partial: "bounties/bounty", collection: @bounties %>
</ul>
</div>
<%# A closed row: a frame, and in it a link to the form. %>
<li>
<%= turbo_frame_tag bounty, data: { action: "turbo:frame-load->presence#frame" } do %>
<%= link_to edit_bounty_path(bounty) do %>
<%= render "bounties/line", bounty: %>
<% end %>
<%= render "bounties/editing", bounty: %>
<% end %>
</li>
<%# What the broadcast replaces for visitors whose row is closed. %>
<span id="<%= dom_id(bounty, :line) %>">
<%= bounty.target %>, last seen: <%= bounty.last_seen %>
<b><%= number_with_delimiter(bounty.reward) %> gp</b>
</span>
<%# Who has the row open. Each name is marked with its number so that the
Stimulus controller can hide the reader's own. %>
<span id="<%= dom_id(bounty, :editing) %>">
<% bounty.visitors.order(:number).each do |visitor| %>
<span data-presence-target="badge" data-number="<%= visitor.number %>">
Adventurer <%= visitor.number %> is editing
</span>
<% end %>
</span>
<%# The same frame id as the closed row. Rendered again with a 422 for a
save the model turns down, and with a 409 for a stale one. %>
<%= turbo_frame_tag @bounty, data: { action: "turbo:frame-load->presence#frame" } do %>
<%= form_with model: @bounty, data: { presence_target: "form", bounty_id: @bounty.id,
controller: "conflict", action: "input->conflict#mark" } do |form| %>
<%# What the form was rendered from, for the staleness check and for
telling which fields this visitor changed. %>
<%= form.hidden_field :lock_version %>
<% %i[target reward last_seen].each do |name| %>
<%= hidden_field_tag "was[#{name}]", @bounty.attribute_in_database(name),
data: { conflict_target: "was", field: name } %>
<% end %>
<%= form.text_field :target, autofocus: true, data: { conflict_target: "field" } %>
<%= form.number_field :reward, min: 0, data: { conflict_target: "field" } %>
<%= form.text_field :last_seen, data: { conflict_target: "field" } %>
<% @bounty.errors.each do |error| %>
<p><%= error.full_message %></p>
<% end %>
<%# Empty until someone else saves. The model's broadcast fills it for a
form that is only open; a refused save fills it here. %>
<div id="<%= dom_id(@bounty, :notice) %>" data-conflict-target="notice">
<%= render "bounties/notice", theirs: @theirs if @theirs %>
</div>
<%# The controller swaps these when the notice fills. %>
<%= form.submit "Save", data: { conflict_target: "save" } %>
<%= form.submit "Keep mine", name: "keep_mine", hidden: true, data: { conflict_target: "resolve" } %>
<%= link_to "Take theirs", edit_bounty_path(@bounty), hidden: true, data: { conflict_target: "resolve" } %>
<%= link_to "Cancel", bounty_path(@bounty) %>
<%= render "bounties/editing", bounty: @bounty %>
<% end %>
<% end %>
<%# Their values, each marked with its field so the Stimulus controller can
drop the ones that did not change and mark the ones the reader changed. %>
<p>This bounty was saved by someone else while you had it open.</p>
<ul>
<% { target: "Target", reward: "Reward", last_seen: "Last seen" }.each do |name, label| %>
<li data-conflict-target="line" data-field="<%= name %>" data-value="<%= theirs[name] %>">
<b><%= label %></b> is now <%= theirs[name] %><span data-conflict-target="both" hidden>, and you changed it too</span>.
</li>
<% end %>
</ul>
import { Controller } from "@hotwired/stimulus"
import consumer from "channels/consumer"
// Tells the server which row this visitor has open, and hides the visitor's
// own name from the badges, which are broadcast to everyone alike.
export default class extends Controller {
static targets = ["me", "form", "badge"]
connect() {
this.subscription = consumer.subscriptions.create("PresenceChannel", {
received: ({ number }) => {
this.number = String(number)
this.meTarget.textContent = number
this.badgeTargets.forEach((badge) => this.badgeTargetConnected(badge))
}
})
}
disconnect() {
this.subscription.unsubscribe()
}
// A frame finished loading: a row opened, closed or was saved. A page
// has at most one form only if nothing else opened a second frame.
frame() {
const form = this.hasFormTarget ? this.formTarget : null
this.subscription.perform("open", { id: form ? form.dataset.bountyId : null })
}
badgeTargetConnected(badge) {
badge.hidden = badge.dataset.number === this.number
}
}
import { Controller } from "@hotwired/stimulus"
// On each open form. The notice arrives as the same HTML for everyone, so
// what it means for this form is worked out here: which of their fields
// differ from what the form was rendered from, and which of those the
// visitor changed too.
export default class extends Controller {
static targets = ["was", "field", "notice", "line", "both", "save", "resolve"]
// A line of the notice arrived, by broadcast or with a refused save.
lineTargetConnected() {
this.mark()
this.saveTarget.hidden = true
this.resolveTargets.forEach((button) => (button.hidden = false))
}
mark() {
this.lineTargets.forEach((line) => {
const was = this.wasTargets.find((input) => input.dataset.field === line.dataset.field).value
const field = this.fieldTargets.find((input) => input.name === `bounty[${line.dataset.field}]`)
line.hidden = line.dataset.value === was
line.querySelector("[data-conflict-target='both']").hidden = field.value === was
})
}
}
resources :bounties, only: %i[index show edit update]
The difficulty is the same in every part of it. A broadcast is one piece of HTML for everyone, and here every visitor needs something different: the badges without their own name, and the notice measured against what their own form holds. The server does not know either, so the page sends them up in hidden fields or works them out in JavaScript. The table lists each behaviour of the demo above and where it comes from.
| Stock Rails | Hibiki | |
|---|---|---|
| A stale save is refused | A lock_version column and a hidden field carrying it |
The same column; the channel holds the record |
| Who is here | A table, a channel of your own and a Stimulus controller that subscribes to it | A table, written from the channel the page already has |
| Which row a visitor has open | The Stimulus controller watches the frames load and tells the channel | Nothing; edit and cancel already run on the server |
| The badge on a row | A callback that broadcasts a partial for the row left and the row entered | A derived the effect reads |
| Not showing a visitor their own badge | The Stimulus controller hides it; the broadcast is the same for everyone | except: cid in the query; each tab draws its own board |
| Told when the record changes under your form | A second broadcast, aimed at an element only an open form has | A derived comparing two versions |
| What they changed, and what you changed too | Each field's first value in a hidden input, compared in a Stimulus controller | Compared in the partial; the channel has all three copies |
| A save that left before the news arrived | Rescue the error and render the form again with their values | Rescue the error and read the board; the same derived shows it |
| Keep mine | Send the first values back and drop the params that match them | One action: fill the form again, put back the changed fields, save |
| Take theirs | A link that fetches the form again | One action: fill the form again |
| Swapping Save for the two buttons | The Stimulus controller, when the notice arrives | An if in the form's partial |
| JavaScript you write | Two Stimulus controllers | None |
| What a visitor arriving costs | One insert, and a broadcast to every page | One insert, and three small queries in every other open tab |
The last row is the honest cost. Every ping makes every other open tab read the bounties and the visitors again, where a broadcast renders once for all. A save is one ping, since the bounty and the closed row are written in one transaction. For a board of this size the reads are inexpensive; for a long list, consider reducing queries on each ping.