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.
@@ -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:, effect: nil)
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
- def with_pgbus_broadcast_opts(exclude:, visible_to:)
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.13.0"
5
+ VERSION = "0.13.1"
6
6
  end
7
7
  end