phlex-reactive 0.13.0 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +95 -7
- data/README.md +237 -5
- data/app/controllers/phlex/reactive/actions_controller.rb +15 -1
- data/lib/phlex/reactive/collections.rb +144 -0
- data/lib/phlex/reactive/pending.rb +337 -0
- data/lib/phlex/reactive/reply.rb +37 -0
- data/lib/phlex/reactive/response.rb +109 -68
- data/lib/phlex/reactive/settle.rb +170 -0
- data/lib/phlex/reactive/settles.rb +272 -0
- data/lib/phlex/reactive/streamable.rb +146 -3
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +35 -0
- metadata +5 -1
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
# The job-side reply builder (issue #248) — what `reactive_settle` yields.
|
|
6
|
+
#
|
|
7
|
+
# reactive_settle do |s|
|
|
8
|
+
# if result.success?
|
|
9
|
+
# s.remove(transfer, from: :unreconcilable)
|
|
10
|
+
# else
|
|
11
|
+
# s.replace(transfer)
|
|
12
|
+
# s.flash(:alert, result.error)
|
|
13
|
+
# end
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# The verbs mirror `reply.*` and, critically, route through the SAME
|
|
17
|
+
# Phlex::Reactive::Collections decisions — so a job-side append emits the
|
|
18
|
+
# row AND the count companion AND the 0<->1 empty-state toggle, instead of
|
|
19
|
+
# the bare row `broadcast_to(append:)` gives you. That shared bookkeeping is
|
|
20
|
+
# the whole point: it is what the app can no longer get subtly wrong.
|
|
21
|
+
#
|
|
22
|
+
# Unlike Response, Settle is a MUTABLE accumulator, not a value object: a
|
|
23
|
+
# settle block is imperative (branch on the work's result, add a flash) and
|
|
24
|
+
# every verb returns self so the calls can also be chained.
|
|
25
|
+
#
|
|
26
|
+
# It collects streams; Phlex::Reactive::Settles owns the delivery — one
|
|
27
|
+
# durable message to the actor's shared one-shot stream, plus the optional
|
|
28
|
+
# peers broadcast.
|
|
29
|
+
class Settle
|
|
30
|
+
attr_reader :streams
|
|
31
|
+
|
|
32
|
+
# The rebuilt container — Settles reads it for the peers broadcast (it
|
|
33
|
+
# carries the collection declaration and the size resolver).
|
|
34
|
+
attr_reader :container
|
|
35
|
+
|
|
36
|
+
# `container` is the rebuilt container component (from_identity, off the
|
|
37
|
+
# request thread); `handle` is the Pending::Handle the enqueue captured.
|
|
38
|
+
def initialize(container, handle)
|
|
39
|
+
@container = container
|
|
40
|
+
@handle = handle
|
|
41
|
+
@streams = []
|
|
42
|
+
@peer_ops = []
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Re-render ONE row in place. The work ran and the row is simply different
|
|
46
|
+
# now (a failed re-execution going back to actionable, say). No count or
|
|
47
|
+
# empty-state stream: a replace cannot change the size.
|
|
48
|
+
#
|
|
49
|
+
# Accepts a record (resolved through the collection's row component) or a
|
|
50
|
+
# built Streamable component (its own #id is the target).
|
|
51
|
+
def replace(model, morph: false, effect: nil, **row_kwargs)
|
|
52
|
+
if model.is_a?(Phlex::Reactive::Streamable)
|
|
53
|
+
@streams << model.to_stream_replace(morph:, effect:)
|
|
54
|
+
# A built component carries its own identity, so peers can be handed
|
|
55
|
+
# the same instance — no definition needed.
|
|
56
|
+
@peer_ops << [nil, :replace, model, {}] if @handle.peers
|
|
57
|
+
return self
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
definition = definition!(nil)
|
|
61
|
+
@streams.concat(Phlex::Reactive::Collections.replace_streams(definition, model, effect:, **row_kwargs))
|
|
62
|
+
# A replace is NOT a collection delta (it moves no boundary), but peers
|
|
63
|
+
# still need it: without this, a failed re-execution goes back to
|
|
64
|
+
# actionable for the actor while every other operator keeps the stale
|
|
65
|
+
# row. It rides the ordinary row broadcast, not broadcast_collection_to.
|
|
66
|
+
peer(definition, :replace, model, row_kwargs)
|
|
67
|
+
self
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Remove a row: the row + the count companion + the empty-state restore at
|
|
71
|
+
# the 1->0 boundary. `from:` defaults to the collection reply.pending
|
|
72
|
+
# named, so the common case reads `s.remove(transfer)`.
|
|
73
|
+
def remove(model, from: nil, effect: nil)
|
|
74
|
+
definition = definition!(from)
|
|
75
|
+
@streams.concat(Phlex::Reactive::Collections.remove_streams(definition, @container, model, effect:))
|
|
76
|
+
peer(definition, :remove, model, {})
|
|
77
|
+
self
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Add a row: the row + the count companion + the empty-state clear at the
|
|
81
|
+
# 0->1 boundary.
|
|
82
|
+
def append(model, to: nil, effect: nil, **row_kwargs)
|
|
83
|
+
add(:append, model, to, effect, row_kwargs)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def prepend(model, to: nil, effect: nil, **row_kwargs)
|
|
87
|
+
add(:prepend, model, to, effect, row_kwargs)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The case that motivated the whole issue: the work moved a record between
|
|
91
|
+
# two lists. Ordered remove-then-append so the size resolvers run against
|
|
92
|
+
# the post-move world in the order a reader expects, and so a row can
|
|
93
|
+
# never be momentarily present in both containers.
|
|
94
|
+
def move(model, from:, to:, effect: nil, **row_kwargs)
|
|
95
|
+
remove(model, from:, effect:)
|
|
96
|
+
append(model, to:, effect:, **row_kwargs)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Refresh ONLY the collection's count companion — for a settle that
|
|
100
|
+
# changed the size without adding or removing a visible row. `name`
|
|
101
|
+
# defaults to the collection reply.pending named.
|
|
102
|
+
def count(name = nil)
|
|
103
|
+
@streams.concat(Phlex::Reactive::Collections.count_streams(definition!(name), @container))
|
|
104
|
+
self
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Tell the operator what happened. The job is where the OUTCOME is known,
|
|
108
|
+
# so this is how the page stops saying "Queued" forever.
|
|
109
|
+
def flash(level, content, target: Phlex::Reactive.flash_target, dismiss_after: nil)
|
|
110
|
+
@streams << Phlex::Reactive::Response.send(:flash_stream, level, content, target:, dismiss_after:)
|
|
111
|
+
self
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Server-pushed client DOM ops, same vocabulary as reply.js. Defaults to
|
|
115
|
+
# the container's id so self-scoped ops just work.
|
|
116
|
+
def js(ops, target: :__default)
|
|
117
|
+
resolved = target == :__default ? @handle.anchor : target
|
|
118
|
+
@streams << Phlex::Reactive::Response.js_stream(ops, target: resolved)
|
|
119
|
+
self
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Escape hatch: raw <turbo-stream> strings, exactly like reply.streams.
|
|
123
|
+
def streams!(*more)
|
|
124
|
+
@streams.concat(more.flatten)
|
|
125
|
+
self
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The peer deltas recorded alongside the actor's streams (issue #248).
|
|
129
|
+
# Collected rather than broadcast inline so Settles can send them AFTER
|
|
130
|
+
# the actor's message — the actor paid for the click and should not wait
|
|
131
|
+
# behind a fan-out of channel calls.
|
|
132
|
+
attr_reader :peer_ops
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def add(action, model, name, effect, row_kwargs)
|
|
137
|
+
definition = definition!(name)
|
|
138
|
+
@streams.concat(
|
|
139
|
+
Phlex::Reactive::Collections.add_streams(definition, @container, model, action, row_kwargs, effect:)
|
|
140
|
+
)
|
|
141
|
+
peer(definition, action, model, row_kwargs)
|
|
142
|
+
self
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Resolve the collection: the explicit keyword, else the one reply.pending
|
|
146
|
+
# named. A settle with neither is a call-site mistake — fail loudly rather
|
|
147
|
+
# than silently emit nothing.
|
|
148
|
+
def definition!(name)
|
|
149
|
+
resolved = name || @handle.collection
|
|
150
|
+
unless resolved
|
|
151
|
+
raise Phlex::Reactive::Error,
|
|
152
|
+
"this settle has no collection to work on — name it (s.remove(record, from: :items)) " \
|
|
153
|
+
"or pass in: to reply.pending so the settle inherits it"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
Phlex::Reactive::Collections.definition!(@container, resolved)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Record a peer delta (only when reply.pending asked for peers:). The row
|
|
160
|
+
# kwargs ride along: a peer whose row component has a required init kwarg
|
|
161
|
+
# would otherwise raise, and one with an optional kwarg would render
|
|
162
|
+
# different markup than the actor got.
|
|
163
|
+
def peer(definition, action, model, row_kwargs)
|
|
164
|
+
return unless @handle.peers
|
|
165
|
+
|
|
166
|
+
@peer_ops << [definition.name, action, model, row_kwargs]
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
# The job half of the async-action lifecycle (issue #248). Include it in a
|
|
6
|
+
# job that does work an action merely ENQUEUED, and settle the UI when the
|
|
7
|
+
# work is actually done:
|
|
8
|
+
#
|
|
9
|
+
# class ReExecuteJob < ApplicationJob
|
|
10
|
+
# include Phlex::Reactive::Settles
|
|
11
|
+
#
|
|
12
|
+
# def perform(transfer_id) # signature UNCHANGED
|
|
13
|
+
# transfer = Transfer.find(transfer_id)
|
|
14
|
+
# result = Transfers::ReExecuteService.call(transfer:)
|
|
15
|
+
#
|
|
16
|
+
# reactive_settle do |s|
|
|
17
|
+
# if result.success?
|
|
18
|
+
# s.remove(transfer, from: :unreconcilable)
|
|
19
|
+
# else
|
|
20
|
+
# s.replace(transfer)
|
|
21
|
+
# s.flash(:alert, result.error)
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# ## The handle rides ActiveJob metadata
|
|
28
|
+
#
|
|
29
|
+
# `reply.pending` installs a Pending::Handle in a thread-local and runs the
|
|
30
|
+
# caller's enqueue inside it; #serialize below copies it into the job's
|
|
31
|
+
# metadata, #deserialize restores it. So `perform`'s ARITY IS UNTOUCHED and
|
|
32
|
+
# every OTHER caller of the same job — a nightly sweep, a webhook — enqueues
|
|
33
|
+
# it with no handle, in which case `reactive_settle` is a NO-OP that returns
|
|
34
|
+
# nil. That is load-bearing: these jobs almost always have non-UI callers.
|
|
35
|
+
#
|
|
36
|
+
# (`perform_now` does not round-trip through serialize/deserialize, so it
|
|
37
|
+
# carries no handle either — which is the correct reading: a synchronous
|
|
38
|
+
# call has no pending UI waiting on it.)
|
|
39
|
+
#
|
|
40
|
+
# ## A rolled-back action
|
|
41
|
+
#
|
|
42
|
+
# The endpoint builds the pending markers and the subscription directive only
|
|
43
|
+
# AFTER the action's transaction committed, so a rolled-back action leaks
|
|
44
|
+
# neither. Whether the ENQUEUE survives the rollback is the queue adapter's
|
|
45
|
+
# business, exactly as it is for a bare `perform_later` in an action — and if
|
|
46
|
+
# such a job does run, its settle broadcasts to a key nobody ever subscribed
|
|
47
|
+
# to. That is inert: the one-shot queue is reclaimed by pgbus's orphan sweep.
|
|
48
|
+
#
|
|
49
|
+
# ## Failure is never silent, and never permanent
|
|
50
|
+
#
|
|
51
|
+
# If the block raises, the target's pending markers are still cleared (the
|
|
52
|
+
# shimmer must not lie) and the error is RE-RAISED so the retry policy sees
|
|
53
|
+
# it. The shared subscription is deliberately NOT torn down on failure — a
|
|
54
|
+
# retry must still be able to reach the actor.
|
|
55
|
+
module Settles
|
|
56
|
+
# ActiveJob metadata key. Prefixed and spelled out — job metadata is a
|
|
57
|
+
# shared namespace with every other gem in the app.
|
|
58
|
+
SETTLE_METADATA_KEY = "phlex_reactive_settle"
|
|
59
|
+
|
|
60
|
+
# Capture the in-flight settle handle at ENQUEUE time. This is the same
|
|
61
|
+
# seam pgbus's own ActiveJob::CurrentAttributes integration uses, and it
|
|
62
|
+
# is adapter-agnostic: it works under :async, :test and :inline as well as
|
|
63
|
+
# a real backend, which is what app specs need.
|
|
64
|
+
def serialize
|
|
65
|
+
handle = Phlex::Reactive::Pending.current_handle
|
|
66
|
+
return super unless handle
|
|
67
|
+
|
|
68
|
+
super.merge(SETTLE_METADATA_KEY => handle.to_h_wire)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def deserialize(job_data)
|
|
72
|
+
super
|
|
73
|
+
@reactive_settle_handle = Phlex::Reactive::Pending::Handle.from_wire(job_data[SETTLE_METADATA_KEY])
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Settle the UI this job's work was enqueued for. Yields a
|
|
77
|
+
# Phlex::Reactive::Settle bound to the container, rebuilt from the signed
|
|
78
|
+
# identity the enqueue captured. Returns nil (and never runs the block)
|
|
79
|
+
# when this job carries no handle.
|
|
80
|
+
#
|
|
81
|
+
# `finish:` controls teardown of the SHARED one-shot subscription. All N
|
|
82
|
+
# settles of one reply.pending share ONE stream key (a key per record
|
|
83
|
+
# would mean a PGMQ table and an SSE connection per record), so tearing
|
|
84
|
+
# down on the first arrival would cut off the other N-1. The default
|
|
85
|
+
# (:auto) therefore finishes only when reply.pending marked exactly one
|
|
86
|
+
# target; a fan-out passes `finish: true` from whatever knows it is last —
|
|
87
|
+
# a Pgbus::Batch on_finish callback, or the final job of a staggered
|
|
88
|
+
# sequence. Until then the subscription is superseded by the container's
|
|
89
|
+
# next pending call, or closed when the page unloads.
|
|
90
|
+
def reactive_settle(finish: :auto)
|
|
91
|
+
handle = @reactive_settle_handle
|
|
92
|
+
return nil unless handle
|
|
93
|
+
|
|
94
|
+
settle = build_settle(handle)
|
|
95
|
+
yield settle
|
|
96
|
+
deliver_settle(handle, settle, finish)
|
|
97
|
+
settle
|
|
98
|
+
rescue ::StandardError
|
|
99
|
+
# The shimmer must resolve even when the work blew up. Clear the pending
|
|
100
|
+
# markers, keep the subscription (a retry still needs it), then re-raise
|
|
101
|
+
# so ActiveJob's retry policy gets its chance. A failure to broadcast the
|
|
102
|
+
# cleanup itself propagates too — there is nothing deliverable, and the
|
|
103
|
+
# next attempt is the only remaining hope.
|
|
104
|
+
broadcast_settle_cleanup(@reactive_settle_handle) if @reactive_settle_handle
|
|
105
|
+
raise
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def build_settle(handle)
|
|
111
|
+
container = handle.container_class.constantize.from_identity(handle.container_payload)
|
|
112
|
+
Phlex::Reactive::Settle.new(container, handle)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# ONE durable message to the actor, carrying every stream this settle
|
|
116
|
+
# produced. Bundling is deliberate: the row, the count companion and the
|
|
117
|
+
# empty-state toggle belong to the same instant, and one message per settle
|
|
118
|
+
# is also why the ACTOR path needs no coalescing — there is nothing extra
|
|
119
|
+
# to collapse. (Coalescing applies to the PEERS path, where the aggregates
|
|
120
|
+
# really are separate channel calls.)
|
|
121
|
+
def deliver_settle(handle, settle, finish)
|
|
122
|
+
payload = settle.streams.join
|
|
123
|
+
payload += finish_streams(handle) if finish_settle?(handle, finish)
|
|
124
|
+
broadcast_settle_payload(handle, payload) unless payload.empty?
|
|
125
|
+
deliver_peers(handle, settle)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def finish_settle?(handle, finish)
|
|
129
|
+
return finish unless finish == :auto
|
|
130
|
+
|
|
131
|
+
handle.count.to_i <= 1
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The teardown: clear the pending markers from EVERY target this handle
|
|
135
|
+
# owns as well as the container, then remove the client's
|
|
136
|
+
# <pgbus-stream-source> by its deterministic id — its disconnectedCallback
|
|
137
|
+
# closes the SSE, so the subscription tears itself down with the content it
|
|
138
|
+
# delivered.
|
|
139
|
+
#
|
|
140
|
+
# Clearing the targets (not just the anchor) is load-bearing: a settle that
|
|
141
|
+
# only flashes — "could not re-execute", say — emits no row stream at all,
|
|
142
|
+
# so nothing swaps that row's node and its markers would otherwise sit
|
|
143
|
+
# there forever. Clearing an id whose node WAS replaced or removed is a
|
|
144
|
+
# harmless no-op (the client op resolves to nothing).
|
|
145
|
+
def finish_streams(handle)
|
|
146
|
+
clear_pending_streams(handle.target_ids + [handle.anchor]) + source_teardown(handle.anchor)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# One reactive:js clear per id, concatenated. html_safe by construction —
|
|
150
|
+
# each piece is a SafeBuffer from js_stream.
|
|
151
|
+
def clear_pending_streams(ids)
|
|
152
|
+
ids.uniq.map { clear_pending_js(it).to_s }.join.html_safe
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def source_teardown(anchor)
|
|
156
|
+
target = Phlex::Reactive::Pending.source_id(anchor)
|
|
157
|
+
%(<turbo-stream action="remove" target="#{ERB::Util.html_escape(target)}"></turbo-stream>).html_safe
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# reactive:js ops that strip the pending vocabulary from ONE element.
|
|
161
|
+
def clear_pending_js(target)
|
|
162
|
+
ops = Phlex::Reactive::JS.new
|
|
163
|
+
.remove_attr(:root, Phlex::Reactive::Pending::PENDING_ATTR)
|
|
164
|
+
.remove_attr(:root, "aria-busy")
|
|
165
|
+
.remove_attr(:root, "data-reactive-defer-pending")
|
|
166
|
+
Phlex::Reactive::Response.js_stream(ops, target:)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# The failure path: clear the pending state so the UI stops lying, WITHOUT
|
|
170
|
+
# tearing the subscription down (a retry must still be able to reach the
|
|
171
|
+
# actor).
|
|
172
|
+
#
|
|
173
|
+
# ATTRIBUTION is the constraint. A handle that owns exactly ONE target —
|
|
174
|
+
# which is every job the `job:`/`args:` sugar enqueues, since it narrows
|
|
175
|
+
# the handle per record — unambiguously identifies the row that just
|
|
176
|
+
# failed, so both it and the container are cleared. A handle that owns
|
|
177
|
+
# MANY (the block form, where the gem cannot map an arbitrary enqueue back
|
|
178
|
+
# to a record) cannot: clearing all of them would un-dim 176 rows that are
|
|
179
|
+
# still legitimately working, and clearing the container alone would claim
|
|
180
|
+
# the whole batch is done. So that case clears nothing and says so — the
|
|
181
|
+
# fan-out's own `finish: true` is what sweeps it up.
|
|
182
|
+
def broadcast_settle_cleanup(handle)
|
|
183
|
+
unless handle.target_ids.one?
|
|
184
|
+
warn_unattributable_failure(handle)
|
|
185
|
+
return
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
broadcast_settle_payload(handle, clear_pending_streams(handle.target_ids + [handle.anchor]))
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def warn_unattributable_failure(handle)
|
|
192
|
+
return unless defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
|
|
193
|
+
|
|
194
|
+
::Rails.logger.warn(
|
|
195
|
+
"[phlex-reactive] a settle failed for a #{handle.count}-target reply.pending — the gem " \
|
|
196
|
+
"cannot tell WHICH target this job owned (the enqueue used the block form), so no " \
|
|
197
|
+
"pending marker was cleared. Those markers clear when a settle calls " \
|
|
198
|
+
"reactive_settle(finish: true)."
|
|
199
|
+
)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Durable is load-bearing: pgbus's since-id replay only covers
|
|
203
|
+
# PGMQ-persisted messages, and that replay is what closes the
|
|
204
|
+
# broadcast-before-subscribe race (the actor may still be opening the SSE
|
|
205
|
+
# when a fast job finishes).
|
|
206
|
+
def broadcast_settle_payload(handle, payload)
|
|
207
|
+
::Pgbus.stream(handle.stream_key, durable: true).broadcast(payload)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# The peers leg (issue #248): the same collection deltas, broadcast to the
|
|
211
|
+
# container's stream so a second operator watching the same batch sees
|
|
212
|
+
# them. Sent AFTER the actor's message — the actor paid for the click and
|
|
213
|
+
# should not wait behind a fan-out of channel calls. `exclude:` is the
|
|
214
|
+
# actor's connection id, so they never get the delta twice.
|
|
215
|
+
# Peer delivery is BEST EFFORT and never fails the job. The actor's durable
|
|
216
|
+
# message has already been sent by the time we get here; re-raising would
|
|
217
|
+
# hand the job to the retry policy, and the retry would re-run `perform`
|
|
218
|
+
# and send the ACTOR's settle a second time — duplicating the pieces that
|
|
219
|
+
# are not idempotent (a flash, an empty-state append). A peer who missed a
|
|
220
|
+
# cross-tab courtesy is a far smaller problem than an actor who sees the
|
|
221
|
+
# flash twice, and every other broadcast in the gem is best-effort too.
|
|
222
|
+
def deliver_peers(handle, settle)
|
|
223
|
+
return if handle.peers.nil? || settle.peer_ops.empty?
|
|
224
|
+
|
|
225
|
+
keys = handle.peers.map { it.is_a?(Hash) ? GlobalID::Locator.locate(it["gid"]) : it }
|
|
226
|
+
container = settle.container
|
|
227
|
+
|
|
228
|
+
settle.peer_ops.each { deliver_peer_op(container, keys, handle, it) }
|
|
229
|
+
rescue ::StandardError => e
|
|
230
|
+
log_peer_failure(e)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# ONE peer op. A collection delta (append/prepend/remove) goes through
|
|
234
|
+
# broadcast_collection_to so peers get the count companion and the
|
|
235
|
+
# empty-state toggle too; a REPLACE moves no boundary, so it rides the
|
|
236
|
+
# ordinary row broadcast. A nil name means the settle handed us a built
|
|
237
|
+
# component, which self-targets.
|
|
238
|
+
def deliver_peer_op(container, keys, handle, peer_op)
|
|
239
|
+
name, action, model, row_kwargs = peer_op
|
|
240
|
+
return broadcast_peer_component(keys, handle, model) if name.nil?
|
|
241
|
+
|
|
242
|
+
if action == :replace
|
|
243
|
+
definition = Phlex::Reactive::Collections.definition!(container, name)
|
|
244
|
+
return definition.item.broadcast_to(
|
|
245
|
+
*keys, replace: definition.item.send(:build, model, row_kwargs || {}),
|
|
246
|
+
exclude: handle.connection_id
|
|
247
|
+
)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
container.class.broadcast_collection_to(
|
|
251
|
+
*keys, container:, in: name, action => model, row: row_kwargs || {},
|
|
252
|
+
exclude: handle.connection_id,
|
|
253
|
+
coalesce: Phlex::Reactive.settle_coalesce_window_ms
|
|
254
|
+
)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def broadcast_peer_component(keys, handle, component)
|
|
258
|
+
component.class.broadcast_to(*keys, replace: component, exclude: handle.connection_id)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def log_peer_failure(error)
|
|
262
|
+
return unless defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
|
|
263
|
+
|
|
264
|
+
::Rails.logger.warn(
|
|
265
|
+
"[phlex-reactive] a settle's PEER broadcast failed (#{error.class}: #{error.message}) — " \
|
|
266
|
+
"the actor's settle already landed, so the job is NOT failed: retrying it would deliver " \
|
|
267
|
+
"the actor's settle (and its flash) a second time."
|
|
268
|
+
)
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
@@ -50,6 +50,10 @@ module Phlex
|
|
|
50
50
|
prepend: "prepend", remove: "remove", js: "reactive:js"
|
|
51
51
|
}.freeze
|
|
52
52
|
BROADCAST_SELF_TARGETING = %i[replace remove].freeze
|
|
53
|
+
# The broadcast_collection_to verbs (issue #248) — a collection DELTA, so
|
|
54
|
+
# only the three that change the size. (A `replace:` row is an ordinary
|
|
55
|
+
# broadcast_to: it moves no boundary and needs no count refresh.)
|
|
56
|
+
COLLECTION_VERBS = %i[append prepend remove].freeze
|
|
53
57
|
BROADCAST_CONTAINER = %i[update append prepend].freeze
|
|
54
58
|
BROADCAST_MORPHABLE = %i[replace update].freeze
|
|
55
59
|
|
|
@@ -118,7 +122,8 @@ module Phlex
|
|
|
118
122
|
# thread-local path (so exclude:/visible_to: reach pgbus and Action Cable
|
|
119
123
|
# no-ops). Self-targeting verbs derive the target from the component's #id
|
|
120
124
|
# and REQUIRE a Streamable payload; container verbs need an explicit target.
|
|
121
|
-
def broadcast_component(owner, verb, payload, component, keys, morph:, target:, exclude:, visible_to:,
|
|
125
|
+
def broadcast_component(owner, verb, payload, component, keys, morph:, target:, exclude:, visible_to:,
|
|
126
|
+
effect: nil, coalesce: nil)
|
|
122
127
|
if verb == :js && !effect.nil?
|
|
123
128
|
raise ArgumentError,
|
|
124
129
|
"broadcast_to js: takes no effect: — effects animate element streams (replace/update/" \
|
|
@@ -136,7 +141,7 @@ module Phlex
|
|
|
136
141
|
Phlex::Reactive.instrument(
|
|
137
142
|
"broadcast", { component: component_name, stream_action: BROADCAST_VERBS[verb], streamables: keys.size }
|
|
138
143
|
) do
|
|
139
|
-
with_pgbus_broadcast_opts(exclude:, visible_to:) do
|
|
144
|
+
with_pgbus_broadcast_opts(exclude:, visible_to:, coalesce:) do
|
|
140
145
|
# A broadcast render NEVER inherits the actor's url_options (issue
|
|
141
146
|
# #232): this call may run inside an action request (where the
|
|
142
147
|
# endpoint threaded the actor's host), but subscribers can be on
|
|
@@ -150,6 +155,21 @@ module Phlex
|
|
|
150
155
|
end
|
|
151
156
|
end
|
|
152
157
|
|
|
158
|
+
# Broadcast ALREADY-RENDERED html (issue #248) — no component to build or
|
|
159
|
+
# render. The count companion is a plain number, not a component, so it
|
|
160
|
+
# has no render leg; everything else (instrumentation, the pgbus
|
|
161
|
+
# thread-locals, the per-key dispatch) is identical to
|
|
162
|
+
# broadcast_component.
|
|
163
|
+
def broadcast_raw(owner, verb, target, html, keys, exclude: nil, visible_to: nil, coalesce: nil)
|
|
164
|
+
Phlex::Reactive.instrument(
|
|
165
|
+
"broadcast", { component: owner.name, stream_action: BROADCAST_VERBS[verb], streamables: keys.size }
|
|
166
|
+
) do
|
|
167
|
+
with_pgbus_broadcast_opts(exclude:, visible_to:, coalesce:) do
|
|
168
|
+
keys.each { dispatch_broadcast(verb, it, target, html, nil, false, nil) }
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
153
173
|
# Validate + serialize broadcast ops: reject actor-only ops (focus steals
|
|
154
174
|
# focus in every tab; submit force-submits every subscriber's form;
|
|
155
175
|
# paste_into reads every subscriber's clipboard) and an empty chain (a
|
|
@@ -189,18 +209,27 @@ module Phlex
|
|
|
189
209
|
# instrument_broadcast uses (issue #185/#187). Duplicated at module level so
|
|
190
210
|
# the shared broadcast_component owns its transport threading. On Action
|
|
191
211
|
# Cable / old pgbus this is a pure `yield`.
|
|
192
|
-
|
|
212
|
+
# `coalesce:` (issue #248) rides the SAME thread-local convention. Unlike
|
|
213
|
+
# a kwarg, an unknown thread-local is silently IGNORED by a pgbus that
|
|
214
|
+
# does not forward it (pre-zoolutions/pgbus#465) — so setting it is
|
|
215
|
+
# always safe: an old pgbus just does not coalesce (more messages, same
|
|
216
|
+
# correctness), a new one does. That is why there is no capability gate
|
|
217
|
+
# here beyond the existing pgbus_streams? one.
|
|
218
|
+
def with_pgbus_broadcast_opts(exclude:, visible_to:, coalesce: nil)
|
|
193
219
|
return yield unless Phlex::Reactive.pgbus_streams?
|
|
194
220
|
|
|
195
221
|
prev_exclude = Thread.current[:pgbus_broadcast_exclude]
|
|
196
222
|
prev_visible = Thread.current[:pgbus_broadcast_visible_to]
|
|
223
|
+
prev_coalesce = Thread.current[:pgbus_broadcast_coalesce]
|
|
197
224
|
Thread.current[:pgbus_broadcast_exclude] = exclude
|
|
198
225
|
Thread.current[:pgbus_broadcast_visible_to] = visible_to
|
|
226
|
+
Thread.current[:pgbus_broadcast_coalesce] = coalesce
|
|
199
227
|
yield
|
|
200
228
|
ensure
|
|
201
229
|
if Phlex::Reactive.pgbus_streams?
|
|
202
230
|
Thread.current[:pgbus_broadcast_exclude] = prev_exclude
|
|
203
231
|
Thread.current[:pgbus_broadcast_visible_to] = prev_visible
|
|
232
|
+
Thread.current[:pgbus_broadcast_coalesce] = prev_coalesce
|
|
204
233
|
end
|
|
205
234
|
end
|
|
206
235
|
|
|
@@ -494,6 +523,49 @@ module Phlex
|
|
|
494
523
|
)
|
|
495
524
|
end
|
|
496
525
|
|
|
526
|
+
# Broadcast a collection DELTA — the row PLUS the count companion PLUS
|
|
527
|
+
# the 0<->1 empty-state toggle (issue #248), the broadcast-side
|
|
528
|
+
# counterpart of reply.append / reply.remove.
|
|
529
|
+
#
|
|
530
|
+
# Container.broadcast_collection_to(@list, :todos,
|
|
531
|
+
# container: @list_component, append: todo, in: :todos,
|
|
532
|
+
# exclude: reactive_connection_id)
|
|
533
|
+
#
|
|
534
|
+
# broadcast_to(append:) deliberately emits the BARE row: it has no
|
|
535
|
+
# container instance, so it cannot resolve the declaration or run the
|
|
536
|
+
# size resolver. This verb takes that instance as `container:` and runs
|
|
537
|
+
# the SAME Phlex::Reactive::Collections decisions the reply path runs,
|
|
538
|
+
# so a peer's list stays as correct as the actor's.
|
|
539
|
+
#
|
|
540
|
+
# `in:` names the declared reactive_collection; the verb is `append:`,
|
|
541
|
+
# `prepend:` or `remove:` (exactly one). `exclude:`/`visible_to:` thread
|
|
542
|
+
# to pgbus as everywhere else.
|
|
543
|
+
#
|
|
544
|
+
# `row:` is the row component's extra init kwargs (issue #186's row_kwargs
|
|
545
|
+
# on the reply side) — pass the SAME ones the actor got, or a peer whose
|
|
546
|
+
# row component has a required kwarg raises instead of rendering.
|
|
547
|
+
#
|
|
548
|
+
# `coalesce:` (a window in ms, or true) applies to the AGGREGATE streams
|
|
549
|
+
# only — the count companion and the empty-state toggle are idempotent
|
|
550
|
+
# replaces of stable targets, so a 177-row fan-out collapses to a
|
|
551
|
+
# handful of count refreshes. The ROW stream is NEVER coalesced: an
|
|
552
|
+
# append is not idempotent and each row is a distinct target. Needs
|
|
553
|
+
# pgbus with zoolutions/pgbus#465; on anything older the thread-local is
|
|
554
|
+
# ignored and every aggregate stream simply goes out (correct, chattier).
|
|
555
|
+
# (`in:` is a Ruby keyword, so it cannot be a named parameter — it is
|
|
556
|
+
# pulled out of **opts, which then holds exactly the verb kwarg.)
|
|
557
|
+
def broadcast_collection_to(*streamables, container:, exclude: nil, visible_to: nil,
|
|
558
|
+
coalesce: nil, effect: nil, row: {}, **opts)
|
|
559
|
+
name = opts.delete(:in) ||
|
|
560
|
+
raise(ArgumentError, "broadcast_collection_to needs in: :collection_name")
|
|
561
|
+
action, model = extract_collection_verb(opts)
|
|
562
|
+
definition = Phlex::Reactive::Collections.definition!(container, name)
|
|
563
|
+
keys = [streamables]
|
|
564
|
+
|
|
565
|
+
broadcast_collection_row(definition, action, model, keys, exclude:, visible_to:, effect:, row:)
|
|
566
|
+
broadcast_collection_aggregates(definition, container, action, keys, exclude:, visible_to:, coalesce:)
|
|
567
|
+
end
|
|
568
|
+
|
|
497
569
|
# Define the guided-error stub for each removed broadcast method (issue
|
|
498
570
|
# #185). `verb` is referenced in define_method AND the message, so the outer
|
|
499
571
|
# block param must be named — `it` is illegal here.
|
|
@@ -519,6 +591,77 @@ module Phlex
|
|
|
519
591
|
verb.first
|
|
520
592
|
end
|
|
521
593
|
|
|
594
|
+
# The collection verb split — append:/prepend:/remove:, exactly one.
|
|
595
|
+
def extract_collection_verb(verb)
|
|
596
|
+
unless verb.size == 1 && COLLECTION_VERBS.include?(verb.keys.first)
|
|
597
|
+
raise ArgumentError,
|
|
598
|
+
"broadcast_collection_to needs exactly ONE verb kwarg " \
|
|
599
|
+
"(#{COLLECTION_VERBS.join("/")}), got #{verb.keys.inspect}"
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
verb.first
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
# The ROW leg: routed through the ordinary broadcast_to so the row is
|
|
606
|
+
# built, rendered and instrumented exactly like every other broadcast.
|
|
607
|
+
# Never coalesced (distinct targets; an append is not idempotent).
|
|
608
|
+
#
|
|
609
|
+
# A STRING model is an already-built dom id, the same form
|
|
610
|
+
# reply.remove(id, from:) and Collections.row_remove_stream accept.
|
|
611
|
+
# Building a row component from it would hand the id to the component's
|
|
612
|
+
# initializer as if it were a record — so it short-circuits to a raw
|
|
613
|
+
# remove of that target instead.
|
|
614
|
+
def broadcast_collection_row(definition, action, model, keys, exclude:, visible_to:, effect:, row: {})
|
|
615
|
+
if action == :remove
|
|
616
|
+
if model.is_a?(::String)
|
|
617
|
+
return Phlex::Reactive::Streamable.broadcast_raw(
|
|
618
|
+
definition.item, :remove, model, nil, keys, exclude:, visible_to:
|
|
619
|
+
)
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
return Phlex::Reactive::Streamable.broadcast_component(
|
|
623
|
+
definition.item, :remove, model, definition.item.send(:build, model, row), keys,
|
|
624
|
+
morph: false, target: nil, exclude:, visible_to:, effect:
|
|
625
|
+
)
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
Phlex::Reactive::Streamable.broadcast_component(
|
|
629
|
+
definition.item, action, model, definition.item.send(:build, model, row), keys,
|
|
630
|
+
morph: false, target: definition.container, exclude:, visible_to:, effect:
|
|
631
|
+
)
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
# The AGGREGATE leg: the count companion and the empty-state toggle,
|
|
635
|
+
# both idempotent replaces of stable targets — so both carry `coalesce:`.
|
|
636
|
+
def broadcast_collection_aggregates(definition, container, action, keys, exclude:, visible_to:, coalesce:)
|
|
637
|
+
delta = action == :remove ? :remove : :add
|
|
638
|
+
# Resolve the size ONCE (it is usually a DB count) and hand the same
|
|
639
|
+
# value to both decisions — see Collections.size_of.
|
|
640
|
+
size = Phlex::Reactive::Collections.size_of(definition, container)
|
|
641
|
+
|
|
642
|
+
if (refresh = Phlex::Reactive::Collections.count_refresh(definition, container, size))
|
|
643
|
+
# NOT `target, size = refresh` — that would rebind `size` to the
|
|
644
|
+
# count's STRING form and hand a String to empty_toggle below.
|
|
645
|
+
count_target, count_html = refresh
|
|
646
|
+
Phlex::Reactive::Streamable.broadcast_raw(
|
|
647
|
+
self, :update, count_target, count_html, keys, exclude:, visible_to:, coalesce:
|
|
648
|
+
)
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
case Phlex::Reactive::Collections.empty_toggle(definition, container, delta, size)
|
|
652
|
+
when :clear
|
|
653
|
+
Phlex::Reactive::Streamable.broadcast_component(
|
|
654
|
+
definition.empty, :remove, nil, definition.empty.new, keys,
|
|
655
|
+
morph: false, target: nil, exclude:, visible_to:, coalesce:
|
|
656
|
+
)
|
|
657
|
+
when :restore
|
|
658
|
+
Phlex::Reactive::Streamable.broadcast_component(
|
|
659
|
+
definition.empty, :append, nil, definition.empty.new, keys,
|
|
660
|
+
morph: false, target: definition.container, exclude:, visible_to:, coalesce:
|
|
661
|
+
)
|
|
662
|
+
end
|
|
663
|
+
end
|
|
664
|
+
|
|
522
665
|
# Coerce a verb payload into a built component: a Phlex component passes
|
|
523
666
|
# through (issue #185 — built payloads); a Hash is init kwargs verbatim (no
|
|
524
667
|
# **options collision); anything else is the record built via
|