phlex-reactive 0.13.0 → 0.13.2

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,309 @@
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; #initialize below captures it onto the job
31
+ # instance, #serialize copies it into the job's metadata, #deserialize
32
+ # restores it. So `perform`'s ARITY IS UNTOUCHED and every OTHER caller of
33
+ # the same job — a nightly sweep, a webhook — builds it with no handle, in
34
+ # which case `reactive_settle` is a NO-OP that returns nil. That is
35
+ # load-bearing: these jobs almost always have non-UI callers.
36
+ #
37
+ # (A `perform_now` INSIDE the enqueue block does carry the handle, since the
38
+ # instance is built there — so it settles synchronously. That is the right
39
+ # reading: `reply.pending` already marked the targets, and something has to
40
+ # resolve the shimmer. The settle's durable message is replayed from
41
+ # since-id 0 when the client opens the subscription, so arriving before it
42
+ # exists is safe.)
43
+ #
44
+ # ## A rolled-back action
45
+ #
46
+ # The endpoint builds the pending markers and the subscription directive only
47
+ # AFTER the action's transaction committed, so a rolled-back action leaks
48
+ # neither. Whether the ENQUEUE survives the rollback is the queue adapter's
49
+ # business, exactly as it is for a bare `perform_later` in an action — and if
50
+ # such a job does run, its settle broadcasts to a key nobody ever subscribed
51
+ # to. That is inert: the one-shot queue is reclaimed by pgbus's orphan sweep.
52
+ #
53
+ # ## Failure is never silent, and never permanent
54
+ #
55
+ # If the block raises, the target's pending markers are still cleared (the
56
+ # shimmer must not lie) and the error is RE-RAISED so the retry policy sees
57
+ # it. The shared subscription is deliberately NOT torn down on failure — a
58
+ # retry must still be able to reach the actor.
59
+ module Settles
60
+ # ActiveJob metadata key. Prefixed and spelled out — job metadata is a
61
+ # shared namespace with every other gem in the app.
62
+ SETTLE_METADATA_KEY = "phlex_reactive_settle"
63
+
64
+ # Capture the in-flight settle handle at INSTANTIATION (issue #254).
65
+ #
66
+ # `serialize` is the seam pgbus's own ActiveJob::CurrentAttributes
67
+ # integration uses, and it looked like the enqueue-time hook — but under
68
+ # Rails' `enqueue_after_transaction_commit = true` (the 7.2+ recommended
69
+ # setting) `job.enqueue` is deferred to
70
+ # ActiveRecord.after_all_transactions_commit, and the endpoint runs every
71
+ # action inside a transaction. So the deferral — and with it `serialize` —
72
+ # always fires AFTER reply.pending's `with_handle` block has exited, with
73
+ # an empty thread-local: no metadata key, a no-op `reactive_settle`, and a
74
+ # row left shimmering until someone reloads.
75
+ #
76
+ # `new` is the one moment guaranteed to be inside the block:
77
+ # `perform_later` → `job_or_instantiate` → `new` is synchronous, deferral
78
+ # or not. Pending#each_with_narrowed_handle narrows the thread-local per
79
+ # record BEFORE `perform_later`, so the `job:`/`args:` attribution
80
+ # contract is preserved.
81
+ def initialize(...)
82
+ super
83
+ @reactive_settle_handle ||= Phlex::Reactive::Pending.current_handle
84
+ end
85
+
86
+ # The same capture at ENQUEUE, for an instance built BEFORE the block and
87
+ # enqueued inside it (`job = MyJob.new(...)` … `reply.pending { job.enqueue }`).
88
+ # `enqueue` runs synchronously inside the block — it is the deferral it
89
+ # REGISTERS that runs later — so this is the last moment the thread-local
90
+ # is visible. `||=` never overwrites: a retry's `retry_job` re-enqueues a
91
+ # DESERIALIZED instance, which must keep the handle it came back with.
92
+ def enqueue(...)
93
+ @reactive_settle_handle ||= Phlex::Reactive::Pending.current_handle
94
+ super
95
+ end
96
+
97
+ # Prefer the captured handle; the thread-local fallback covers an instance
98
+ # serialized inside the block without going through either hook. A retry
99
+ # re-enqueue re-serializes the SAME instance, which keeps its handle — the
100
+ # right reading: the pending UI is still waiting on this work.
101
+ def serialize
102
+ handle = @reactive_settle_handle || Phlex::Reactive::Pending.current_handle
103
+ return super unless handle
104
+
105
+ super.merge(SETTLE_METADATA_KEY => handle.to_h_wire)
106
+ end
107
+
108
+ def deserialize(job_data)
109
+ super
110
+ @reactive_settle_handle = Phlex::Reactive::Pending::Handle.from_wire(job_data[SETTLE_METADATA_KEY])
111
+ end
112
+
113
+ # Settle the UI this job's work was enqueued for. Yields a
114
+ # Phlex::Reactive::Settle bound to the container, rebuilt from the signed
115
+ # identity the enqueue captured. Returns nil (and never runs the block)
116
+ # when this job carries no handle.
117
+ #
118
+ # `finish:` controls teardown of the SHARED one-shot subscription. All N
119
+ # settles of one reply.pending share ONE stream key (a key per record
120
+ # would mean a PGMQ table and an SSE connection per record), so tearing
121
+ # down on the first arrival would cut off the other N-1. The default
122
+ # (:auto) therefore finishes only when reply.pending marked exactly one
123
+ # target; a fan-out passes `finish: true` from whatever knows it is last —
124
+ # a Pgbus::Batch on_finish callback, or the final job of a staggered
125
+ # sequence. Until then the subscription is superseded by the container's
126
+ # next pending call, or closed when the page unloads.
127
+ def reactive_settle(finish: :auto)
128
+ handle = @reactive_settle_handle
129
+ return nil unless handle
130
+
131
+ settle = build_settle(handle)
132
+ yield settle
133
+ deliver_settle(handle, settle, finish)
134
+ settle
135
+ rescue ::StandardError
136
+ # The shimmer must resolve even when the work blew up. Clear the pending
137
+ # markers, keep the subscription (a retry still needs it), then re-raise
138
+ # so ActiveJob's retry policy gets its chance. A failure to broadcast the
139
+ # cleanup itself propagates too — there is nothing deliverable, and the
140
+ # next attempt is the only remaining hope.
141
+ broadcast_settle_cleanup(@reactive_settle_handle) if @reactive_settle_handle
142
+ raise
143
+ end
144
+
145
+ private
146
+
147
+ def build_settle(handle)
148
+ container = handle.container_class.constantize.from_identity(handle.container_payload)
149
+ Phlex::Reactive::Settle.new(container, handle)
150
+ end
151
+
152
+ # ONE durable message to the actor, carrying every stream this settle
153
+ # produced. Bundling is deliberate: the row, the count companion and the
154
+ # empty-state toggle belong to the same instant, and one message per settle
155
+ # is also why the ACTOR path needs no coalescing — there is nothing extra
156
+ # to collapse. (Coalescing applies to the PEERS path, where the aggregates
157
+ # really are separate channel calls.)
158
+ def deliver_settle(handle, settle, finish)
159
+ payload = settle.streams.join
160
+ payload += finish_streams(handle) if finish_settle?(handle, finish)
161
+ broadcast_settle_payload(handle, payload) unless payload.empty?
162
+ deliver_peers(handle, settle)
163
+ end
164
+
165
+ def finish_settle?(handle, finish)
166
+ return finish unless finish == :auto
167
+
168
+ handle.count.to_i <= 1
169
+ end
170
+
171
+ # The teardown: clear the pending markers from EVERY target this handle
172
+ # owns as well as the container, then remove the client's
173
+ # <pgbus-stream-source> by its deterministic id — its disconnectedCallback
174
+ # closes the SSE, so the subscription tears itself down with the content it
175
+ # delivered.
176
+ #
177
+ # Clearing the targets (not just the anchor) is load-bearing: a settle that
178
+ # only flashes — "could not re-execute", say — emits no row stream at all,
179
+ # so nothing swaps that row's node and its markers would otherwise sit
180
+ # there forever. Clearing an id whose node WAS replaced or removed is a
181
+ # harmless no-op (the client op resolves to nothing).
182
+ def finish_streams(handle)
183
+ clear_pending_streams(handle.target_ids + [handle.anchor]) + source_teardown(handle.anchor)
184
+ end
185
+
186
+ # One reactive:js clear per id, concatenated. html_safe by construction —
187
+ # each piece is a SafeBuffer from js_stream.
188
+ def clear_pending_streams(ids)
189
+ ids.uniq.map { clear_pending_js(it).to_s }.join.html_safe
190
+ end
191
+
192
+ def source_teardown(anchor)
193
+ target = Phlex::Reactive::Pending.source_id(anchor)
194
+ %(<turbo-stream action="remove" target="#{ERB::Util.html_escape(target)}"></turbo-stream>).html_safe
195
+ end
196
+
197
+ # reactive:js ops that strip the pending vocabulary from ONE element.
198
+ def clear_pending_js(target)
199
+ ops = Phlex::Reactive::JS.new
200
+ .remove_attr(:root, Phlex::Reactive::Pending::PENDING_ATTR)
201
+ .remove_attr(:root, "aria-busy")
202
+ .remove_attr(:root, "data-reactive-defer-pending")
203
+ Phlex::Reactive::Response.js_stream(ops, target:)
204
+ end
205
+
206
+ # The failure path: clear the pending state so the UI stops lying, WITHOUT
207
+ # tearing the subscription down (a retry must still be able to reach the
208
+ # actor).
209
+ #
210
+ # ATTRIBUTION is the constraint. A handle that owns exactly ONE target —
211
+ # which is every job the `job:`/`args:` sugar enqueues, since it narrows
212
+ # the handle per record — unambiguously identifies the row that just
213
+ # failed, so both it and the container are cleared. A handle that owns
214
+ # MANY (the block form, where the gem cannot map an arbitrary enqueue back
215
+ # to a record) cannot: clearing all of them would un-dim 176 rows that are
216
+ # still legitimately working, and clearing the container alone would claim
217
+ # the whole batch is done. So that case clears nothing and says so — the
218
+ # fan-out's own `finish: true` is what sweeps it up.
219
+ def broadcast_settle_cleanup(handle)
220
+ unless handle.target_ids.one?
221
+ warn_unattributable_failure(handle)
222
+ return
223
+ end
224
+
225
+ broadcast_settle_payload(handle, clear_pending_streams(handle.target_ids + [handle.anchor]))
226
+ end
227
+
228
+ def warn_unattributable_failure(handle)
229
+ return unless defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
230
+
231
+ ::Rails.logger.warn(
232
+ "[phlex-reactive] a settle failed for a #{handle.count}-target reply.pending — the gem " \
233
+ "cannot tell WHICH target this job owned (the enqueue used the block form), so no " \
234
+ "pending marker was cleared. Those markers clear when a settle calls " \
235
+ "reactive_settle(finish: true)."
236
+ )
237
+ end
238
+
239
+ # Durable is load-bearing: pgbus's since-id replay only covers
240
+ # PGMQ-persisted messages, and that replay is what closes the
241
+ # broadcast-before-subscribe race (the actor may still be opening the SSE
242
+ # when a fast job finishes).
243
+ def broadcast_settle_payload(handle, payload)
244
+ ::Pgbus.stream(handle.stream_key, durable: true).broadcast(payload)
245
+ end
246
+
247
+ # The peers leg (issue #248): the same collection deltas, broadcast to the
248
+ # container's stream so a second operator watching the same batch sees
249
+ # them. Sent AFTER the actor's message — the actor paid for the click and
250
+ # should not wait behind a fan-out of channel calls. `exclude:` is the
251
+ # actor's connection id, so they never get the delta twice.
252
+ # Peer delivery is BEST EFFORT and never fails the job. The actor's durable
253
+ # message has already been sent by the time we get here; re-raising would
254
+ # hand the job to the retry policy, and the retry would re-run `perform`
255
+ # and send the ACTOR's settle a second time — duplicating the pieces that
256
+ # are not idempotent (a flash, an empty-state append). A peer who missed a
257
+ # cross-tab courtesy is a far smaller problem than an actor who sees the
258
+ # flash twice, and every other broadcast in the gem is best-effort too.
259
+ def deliver_peers(handle, settle)
260
+ return if handle.peers.nil? || settle.peer_ops.empty?
261
+
262
+ keys = handle.peers.map { it.is_a?(Hash) ? GlobalID::Locator.locate(it["gid"]) : it }
263
+ container = settle.container
264
+
265
+ settle.peer_ops.each { deliver_peer_op(container, keys, handle, it) }
266
+ rescue ::StandardError => e
267
+ log_peer_failure(e)
268
+ end
269
+
270
+ # ONE peer op. A collection delta (append/prepend/remove) goes through
271
+ # broadcast_collection_to so peers get the count companion and the
272
+ # empty-state toggle too; a REPLACE moves no boundary, so it rides the
273
+ # ordinary row broadcast. A nil name means the settle handed us a built
274
+ # component, which self-targets.
275
+ def deliver_peer_op(container, keys, handle, peer_op)
276
+ name, action, model, row_kwargs = peer_op
277
+ return broadcast_peer_component(keys, handle, model) if name.nil?
278
+
279
+ if action == :replace
280
+ definition = Phlex::Reactive::Collections.definition!(container, name)
281
+ return definition.item.broadcast_to(
282
+ *keys, replace: definition.item.send(:build, model, row_kwargs || {}),
283
+ exclude: handle.connection_id
284
+ )
285
+ end
286
+
287
+ container.class.broadcast_collection_to(
288
+ *keys, container:, in: name, action => model, row: row_kwargs || {},
289
+ exclude: handle.connection_id,
290
+ coalesce: Phlex::Reactive.settle_coalesce_window_ms
291
+ )
292
+ end
293
+
294
+ def broadcast_peer_component(keys, handle, component)
295
+ component.class.broadcast_to(*keys, replace: component, exclude: handle.connection_id)
296
+ end
297
+
298
+ def log_peer_failure(error)
299
+ return unless defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
300
+
301
+ ::Rails.logger.warn(
302
+ "[phlex-reactive] a settle's PEER broadcast failed (#{error.class}: #{error.message}) — " \
303
+ "the actor's settle already landed, so the job is NOT failed: retrying it would deliver " \
304
+ "the actor's settle (and its flash) a second time."
305
+ )
306
+ end
307
+ end
308
+ end
309
+ end