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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +113 -7
- data/README.md +244 -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 +309 -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
|
@@ -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
|
data/lib/phlex/reactive.rb
CHANGED
|
@@ -712,6 +712,41 @@ module Phlex
|
|
|
712
712
|
defined?(::ActiveJob::Base) ? true : false
|
|
713
713
|
end
|
|
714
714
|
|
|
715
|
+
# --- Async-action lifecycle / settles (issue #248) -----------------
|
|
716
|
+
|
|
717
|
+
# NOTE: there is deliberately NO settle_token_ttl. Issue #248's sketch had
|
|
718
|
+
# one for a fallback PULL token, but a settle has no pull lane at all — the
|
|
719
|
+
# client cannot poll "is the job done yet", and redeeming such a token at
|
|
720
|
+
# the defer endpoint would render the PRE-JOB component, which is the exact
|
|
721
|
+
# bug reply.pending exists to fix. A setting that cannot change behavior is
|
|
722
|
+
# worse than no setting: it tells an operator they can extend a wait window
|
|
723
|
+
# that is not governed by a token in the first place. The settle's wait is
|
|
724
|
+
# bounded by the JOB, not by a TTL.
|
|
725
|
+
|
|
726
|
+
# Window (ms) the AGGREGATE settle streams coalesce on — the count
|
|
727
|
+
# companion, the empty-state toggle, any companion refresh. They are
|
|
728
|
+
# idempotent replaces of stable targets, so a 177-row fan-out collapses to
|
|
729
|
+
# a handful of them instead of 177. The ROW streams are never coalesced.
|
|
730
|
+
# Needs pgbus with zoolutions/pgbus#465 on the peers path; without it the
|
|
731
|
+
# window is simply ignored. nil resets to the default.
|
|
732
|
+
attr_writer :settle_coalesce_window_ms
|
|
733
|
+
|
|
734
|
+
def settle_coalesce_window_ms
|
|
735
|
+
@settle_coalesce_window_ms ||= 50
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
# Can reply.pending mint a settle handle at all? A settle has NO pull
|
|
739
|
+
# fallback — the client cannot poll "is the job done yet" — so it needs
|
|
740
|
+
# the defer PUSH lane (durable pgbus one-shot stream + ActiveJob). A
|
|
741
|
+
# forced defer_transport of :fetch is therefore also a no.
|
|
742
|
+
#
|
|
743
|
+
# False does NOT break anything: reply.pending degrades to a plain
|
|
744
|
+
# enqueue (no pending markers, no handle, reactive_settle no-ops in the
|
|
745
|
+
# job) — today's behavior, never a permanently pending row.
|
|
746
|
+
def settle_capable?
|
|
747
|
+
defer_push_capable? && defer_transport != :fetch
|
|
748
|
+
end
|
|
749
|
+
|
|
715
750
|
# DOM id of the host-app container a Response#flash appends into.
|
|
716
751
|
# Default "flash"; override to match your layout's flash region.
|
|
717
752
|
def flash_target
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: phlex-reactive
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.13.
|
|
4
|
+
version: 0.13.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mikael Henriksson
|
|
@@ -153,6 +153,7 @@ files:
|
|
|
153
153
|
- lib/phlex/reactive/authorization.rb
|
|
154
154
|
- lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md
|
|
155
155
|
- lib/phlex/reactive/client_bindings.rb
|
|
156
|
+
- lib/phlex/reactive/collections.rb
|
|
156
157
|
- lib/phlex/reactive/component.rb
|
|
157
158
|
- lib/phlex/reactive/component/dsl.rb
|
|
158
159
|
- lib/phlex/reactive/component/helpers.rb
|
|
@@ -178,8 +179,11 @@ files:
|
|
|
178
179
|
- lib/phlex/reactive/mcp/tools/doctor_tool.rb
|
|
179
180
|
- lib/phlex/reactive/mcp/tools/find_tool.rb
|
|
180
181
|
- lib/phlex/reactive/param_schema.rb
|
|
182
|
+
- lib/phlex/reactive/pending.rb
|
|
181
183
|
- lib/phlex/reactive/reply.rb
|
|
182
184
|
- lib/phlex/reactive/response.rb
|
|
185
|
+
- lib/phlex/reactive/settle.rb
|
|
186
|
+
- lib/phlex/reactive/settles.rb
|
|
183
187
|
- lib/phlex/reactive/show_conditions.rb
|
|
184
188
|
- lib/phlex/reactive/stream.rb
|
|
185
189
|
- lib/phlex/reactive/streamable.rb
|