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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a6181c7ab8900e42562ecb8b82e8c52a30af5b643eadd89b045c60d1b48c7e87
4
- data.tar.gz: cfa42f5aa9230785cb451cfcefc0c280d5d51d0c648d5bf797911807a59291b4
3
+ metadata.gz: '049bef601e70264a857668de2ad7766f9b4edae020ded0844d2f5efacdc0607d'
4
+ data.tar.gz: 1293bfd5f14ef03fa1889444c662846d4f1129155e1b9ea458b48529a7644b3b
5
5
  SHA512:
6
- metadata.gz: 01744edf54e1a720c5365413ad4f70334b88d5351685d7476ec1a782f146e9c0e97cdbe66d536d36da6fcf52e31b8dee0e9320019fca5313282dea3ce0a01cc7
7
- data.tar.gz: dbaf5238767322e2ce1f0ca4bd44d93f3712f56189050984bcc3f612fa4f650d994b5813afd52fd163973543f346a47d6eca75627e73d77008a0ce83cfe9d3f4
6
+ metadata.gz: d3fe07e736d776bdcdc594ef9d4cb3e91aea4c0cf81d6bf9c0f703d5de744e2d60394b79c4bad02c32b98ac04fc99d3b1a6746c88ff8e377e94e259765ddbfec
7
+ data.tar.gz: eb3c04c9cea640d1c6662e602a77b1b49e4e5e7204cb6c587916440c5695064a84219348127b7282ee83b9dd6e6fa57dced2ca9dd79fd7eba1cb1e33e56d6419
data/CHANGELOG.md CHANGED
@@ -6,16 +6,77 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
- ### Fixed
10
-
11
- - **`rake release` re-locks the root `Gemfile.lock` too.** Since #246 the gem
12
- root's lockfile is committed, and it pins `phlex-reactive` by local path — so
13
- the version bump left it stale and the Release workflow's frozen
14
- `bundle install` refused it (v0.13.0's first attempt). The release task now
15
- re-locks and commits every tracked lockfile that pins the gem (root + docs).
16
9
 
17
10
  ### Added
18
11
 
12
+ - **`bin/release` — the release front door, ported from pgbus.** Works out the
13
+ next version (`patch` by default, `minor`, `major`, or an explicit `X.Y.Z`
14
+ with an optional `v`), prints the commits since the last tag, and hands off
15
+ to `rake release[X.Y.Z]` after a y/N confirm. `list` and `--dry-run` are
16
+ read-only. It refuses to run off anything but a clean, up-to-date `main` —
17
+ the rake task pushes `origin main`, so starting anywhere else either fails
18
+ the push or ships whatever local commits happen to be sitting there — and
19
+ refuses an existing tag unless `--force` (which maps to `release[X.Y.Z,force]`).
20
+ It also warns when `version.rb` and the newest `v*` tag disagree.
21
+
22
+ - **Async-action lifecycle: `reply.pending` + `reactive_settle` (#248).** An
23
+ action that *enqueues* work can now reply truthfully. The endpoint renders the
24
+ reply inside the action's transaction while the queue publishes on commit, so
25
+ any `reply.morph` after an enqueue is guaranteed to draw the pre-job world —
26
+ rows still present, buttons still live — beside the "Queued 177" flash the
27
+ same reply emitted. Apps worked around it with a `queued:` kwarg threaded into
28
+ every row component plus a second render branch; ~60 lines per screen, and the
29
+ page still never learned the outcome.
30
+
31
+ `reply.pending(records, in: :collection, job:, args:)` — or the block form,
32
+ `reply.pending(records, in: :collection) { MyService.call(...) }`, which every
33
+ ActiveJob enqueued inside captures — marks the targets with
34
+ `data-reactive-pending` + `aria-busy` (style it in one CSS rule), opens ONE
35
+ shared durable subscription anchored on the container, and hands the
36
+ fulfilment to your job. The job includes `Phlex::Reactive::Settles` and calls
37
+ `reactive_settle { |s| s.remove(record) }` (also `replace` / `append` /
38
+ `prepend` / `move(from:, to:)` / `count` / `flash` / `js` / `streams!`), which
39
+ emits the row **plus** the count companion **plus** the 0↔1 empty-state
40
+ toggle. `peers: true` broadcasts the same delta to the container's record
41
+ stream for a second operator watching the batch.
42
+
43
+ The handle rides **ActiveJob metadata**, so `perform`'s arity is untouched and
44
+ every other caller of the same job — a nightly sweep, a webhook — runs
45
+ unchanged with `reactive_settle` as a no-op. The `job:`/`args:` form narrows
46
+ each job's handle to its own record, so a job that raises clears exactly that
47
+ row's markers before re-raising for the retry policy. No client changes: the
48
+ markers ride the existing `reactive:js` op lane and the subscription is the
49
+ `reactive:defer` push-lane wire from #165.
50
+
51
+ - **`Phlex::Reactive::Collections` — the collection bookkeeping is now a public
52
+ module (#248).** The count companion and the 0↔1 empty-state boundary used to
53
+ live in `Response`'s privates, reachable only from `reply.*`, so a job or a
54
+ broadcast had to re-derive them by hand and got the boundary subtly wrong.
55
+ `Response.build_collection_*` now delegates, and the settle and broadcast
56
+ paths read the same `count_refresh` / `empty_toggle` decisions — they cannot
57
+ drift. No behavior change for existing `reply.append` / `reply.remove` calls,
58
+ except that each delta now resolves the `size:` proc **once** instead of twice
59
+ (one fewer query per add/remove, and the count companion can no longer
60
+ disagree with the empty-state toggle it ships beside when a concurrent write
61
+ lands between the two reads).
62
+
63
+ - **`Container.broadcast_collection_to(*keys, container:, in:, append:/prepend:/remove:)`
64
+ (#248).** The broadcast-side counterpart of `reply.append` / `reply.remove`:
65
+ the row **plus** the count companion **plus** the empty-state toggle, instead
66
+ of the bare row `broadcast_to(append:)` emits. `coalesce:` applies to the
67
+ aggregate streams only (idempotent replaces of stable targets), so a 177-row
68
+ fan-out collapses to a handful of count refreshes; the row stream is never
69
+ coalesced. Needs pgbus with zoolutions/pgbus#465 — a thread-local an older
70
+ pgbus simply ignores, so it degrades to "chattier, equally correct" rather
71
+ than breaking.
72
+
73
+ - **`Phlex::Reactive.settle_coalesce_window_ms` (50) and `.settle_capable?`
74
+ (#248).** The window governs the aggregate (count / empty-state) streams on
75
+ the peers path. `settle_capable?` reports whether `reply.pending` has a lane
76
+ at all; without one it degrades to a plain enqueue (no markers, no lie) with a
77
+ one-time warning. There is deliberately no `settle_token_ttl` — a settle has
78
+ no pull lane for a token to govern.
79
+
19
80
  - **`reactive_persist` drafts rich editors (#241).** A named `lexxy-editor`,
20
81
  `trix-editor` or bare `[contenteditable]` inside a `reactive_persist` root is
21
82
  now drafted and restored through its **own** value surface — the editor's
@@ -380,6 +441,33 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
380
441
 
381
442
  ### Fixed
382
443
 
444
+ - **`rake release` bumps the pin in every tracked lockfile (#247, #253).** Since
445
+ #246 the gem root's `Gemfile.lock` is committed alongside `docs/Gemfile.lock`,
446
+ and both pin `phlex-reactive` by local path — so a version bump that left them
447
+ alone shipped a stale lockfile, and the Release workflow's frozen
448
+ `bundle install` refused it (v0.13.0's first attempt).
449
+
450
+ The task now bumps that pin **with a text edit**, not `bundle lock`. #247's
451
+ first cut used `bundle lock --local`, which is a full re-resolve — and a
452
+ re-resolve trips over constraints that have nothing to do with this gem:
453
+ `docs/Gemfile.lock` declares Linux platforms for the Kamal deploy, and
454
+ resolving `thruster` for those against a Mac's installed gems fails ("Could
455
+ not find gems matching 'thruster' valid for all resolution platforms"), which
456
+ aborted v0.13.1's first attempt mid-release with `version.rb` already bumped.
457
+ A re-resolve also silently folds unrelated dependency drift into the release
458
+ commit the moment a Gemfile is out of sync with its lock. The only line a
459
+ version bump changes is the path-gem pin, so the task edits exactly that (the
460
+ `PATH` spec + the `CHECKSUMS` entry) in place — deterministic on any machine,
461
+ no network, no installed gems, the same 2-line diff bundler produced. A
462
+ lockfile that names no pin at all now **aborts** the release rather than
463
+ reporting itself already current — and that check runs in a PREFLIGHT, before
464
+ the task does anything destructive or irreversible: before the `force`
465
+ cleanup that deletes the GitHub release and its tag, and before `version.rb`
466
+ or any lockfile is written. So an abort leaves the release intact and the tree
467
+ clean, rather than a deleted release or a half-bumped tree the clean-tree
468
+ guard would then block on retry.
469
+ pgbus's release task made the same call after the same failure.
470
+
383
471
  - **`Component::Action` renamed `ActionDefinition` — it shadowed a host kit
384
472
  component named `Action` under dev autoloading (#233).** The mixin sits in
385
473
  every reactive component's ancestry, so its bare `Action` constant satisfied
data/README.md CHANGED
@@ -406,6 +406,8 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
406
406
  | `reactive_collection :name, item:, container:, count:, empty:, size:` | Declare an add/remove-row list once; actions call `reply.append`/`prepend`/`remove`. See [Reactive collections](#reactive-collections-addremove-rows--count--empty-state). |
407
407
  | `reply.replace` / `.morph` / `.update` / `.remove` / `.redirect(url)` / `.with(*)` / `.js(ops)` | Return from an action to control the reply (flash, remove, redirect, multi-stream, server-pushed client ops). See [Controlling the action's reply](#reply--controlling-the-actions-reply). |
408
408
  | `reply.append(name, model)` / `.prepend(...)` / `.remove(name, model)` | Add/remove a row in a declared `reactive_collection` (row + count + empty-state in one reply). |
409
+ | `reply.pending(records, in:, job:) { … }` + `include Phlex::Reactive::Settles` / `reactive_settle` | For an action that **enqueues** work: mark targets pending, let your job settle them (row + count + empty-state) when it finishes. See [Async actions](#async-actions-replypending--reactive_settle). |
410
+ | `Container.broadcast_collection_to(*keys, container:, in:, append:/prepend:/remove:)` | The broadcast-side `reply.append`/`reply.remove`: the row **plus** the count companion **plus** the empty-state toggle, to peers. |
409
411
 
410
412
  Param types: `:string` (default), `:integer`, `:float`, `:boolean`, `:file`,
411
413
  `:date`, `:datetime`, `:decimal`. Anything not in the schema is dropped before
@@ -1884,6 +1886,7 @@ def update(quantity:, price:) = (@item.update!(quantity:, price:); reply.streams
1884
1886
  | `reply.streams(*streams)` | **partial update** — emit exactly these streams (no full-self replace) + a tiny token-only refresh, so live inputs survive; for per-field grid editing (issue #30) |
1885
1887
  | `.js(ops, target: …)` | also push **client DOM ops** (focus, dispatch, class/attr toggles) over a `reactive:js` stream, applied AFTER the render — `reply.morph.js(js.focus("[name=next]"))` focuses the morphed field (issue #97) |
1886
1888
  | `.defer(component, placeholder:, morph:)` | take an **expensive segment off the actor's critical path** (issue #165) — the reply returns immediately and the real render streams to the SAME actor when ready; see [Deferred segments](#deferred-segments-replydefer--reactive_lazy) |
1889
+ | `reply.pending(records, in:, job:, args:, peers:) { … }` | the action **enqueues** the work: mark the targets pending now and let **your own job** settle them when the work is actually done (issue #248); see [Async actions](#async-actions-replypending--reactive_settle) |
1887
1890
  | `reply.with(*streams)` / `#stream(*more)` | multi-stream (self re-render still injected for the token) |
1888
1891
 
1889
1892
  `.flash`/`.stream`/`.also` are additive on a self-replace, so the component's
@@ -2449,11 +2452,240 @@ end
2449
2452
  - **`remove` takes the record or its `dom_id` string** — a just-destroyed
2450
2453
  ActiveRecord still answers `dom_id` correctly, so `reply.remove(todo, from: :items)`
2451
2454
  works; pass the raw id only if your row `#id` matches `ActiveRecord::RecordIdentifier`.
2452
- - **Reply governs the actor's HTTP response only.** For a *cross-tab* live list
2453
- (other viewers see the row appear) keep broadcasting the row with
2454
- `NotificationRow.broadcast_to(..., append: model, exclude: reactive_connection_id)` —
2455
- `reactive_collection` is the per-actor add/remove + count + empty-state wrapper,
2456
- not a replacement for the broadcast.
2455
+ - **Reply governs the actor's HTTP response only.** For a *cross-tab* live list,
2456
+ broadcast the delta with `NotificationsList.broadcast_collection_to(*keys,
2457
+ container: self, in: :notifications, append: model, exclude: reactive_connection_id)`
2458
+ — that emits the row **plus** the count companion **plus** the empty-state
2459
+ toggle, through the same decisions the reply path uses. (Plain
2460
+ `broadcast_to(append:)` still works and still emits the **bare row**: it has no
2461
+ container instance, so it cannot resolve the declaration or run `size:`.)
2462
+ - **When the work is asynchronous**, an action that merely enqueues it cannot
2463
+ reply with a delta at all — the reply renders before the job touches the
2464
+ database. Use [`reply.pending`](#async-actions-replypending--reactive_settle)
2465
+ and let the job settle the row.
2466
+
2467
+ ### Async actions (`reply.pending` + `reactive_settle`)
2468
+
2469
+ An action that **does** the work can reply honestly. An action that
2470
+ **enqueues** the work cannot — and until issue #248 every app worked around it
2471
+ the same way.
2472
+
2473
+ The endpoint runs your action inside a transaction and renders the reply
2474
+ *there*, while the queue adapter publishes on **commit**. So any `reply.morph`
2475
+ after an enqueue renders from a database the job has not touched yet, and is
2476
+ *guaranteed* to draw the pre-job world — rows still present, buttons still live,
2477
+ counts unchanged — sitting right beside the "Queued 177 transfers" flash the
2478
+ same reply emitted:
2479
+
2480
+ ```ruby
2481
+ def restore_all
2482
+ count = BatchRestoreService.call(bulk_payment: @bulk_payment) # fans out N jobs
2483
+ reply.morph.flash(:notice, "Putting #{count} back…") # renders the PRE-job world
2484
+ end
2485
+ ```
2486
+
2487
+ The usual fix is a `queued:` kwarg threaded into every row component with a
2488
+ second render branch, a `@queued_*` flag on the container, and the header
2489
+ button's count forced to `0` — ~60 lines of identical bookkeeping per screen.
2490
+ And it still never tells the operator the **outcome**: the page says "Queued"
2491
+ forever until someone reloads.
2492
+
2493
+ `reply.pending` replaces all of it:
2494
+
2495
+ ```ruby
2496
+ class ReconcileQueue < ApplicationComponent
2497
+ include Phlex::Reactive::Component
2498
+
2499
+ reactive_collection :unreconcilable,
2500
+ item: TransferRow, container: "unreconcilable",
2501
+ count: "unreconcilable-count", empty: NothingToReconcile,
2502
+ size: -> { @bulk_payment.transfers.unreconcilable.count }
2503
+
2504
+ action :re_execute, params: {transfer_id: :integer}
2505
+ action :restore_all
2506
+
2507
+ # ONE record — the job settles it, and the subscription tears itself down.
2508
+ def re_execute(transfer_id:)
2509
+ transfer = @bulk_payment.transfers.re_executable.find(transfer_id)
2510
+ authorize! transfer, :update?
2511
+ reply.pending(transfer, in: :unreconcilable, job: ReExecuteJob, args: [transfer.id])
2512
+ end
2513
+
2514
+ # A FAN-OUT — the enqueue lives in the block, so the service object's own
2515
+ # perform_later calls capture the settle handle too.
2516
+ def restore_all
2517
+ authorize! @bulk_payment, :update?
2518
+ restorable = @bulk_payment.transfers.restorable.to_a
2519
+ reply.pending(restorable, in: :declined, peers: true) do
2520
+ BatchRestoreService.call(bulk_payment: @bulk_payment)
2521
+ end.flash(:notice, "Putting #{restorable.size} back…")
2522
+ end
2523
+ end
2524
+ ```
2525
+
2526
+ and the job settles it when the work is **actually** done:
2527
+
2528
+ ```ruby
2529
+ class ReExecuteJob < ApplicationJob
2530
+ include Phlex::Reactive::Settles
2531
+
2532
+ def perform(transfer_id) # signature UNCHANGED
2533
+ transfer = Transfer.find(transfer_id)
2534
+ result = Transfers::ReExecuteService.call(transfer:)
2535
+
2536
+ reactive_settle do |s|
2537
+ if result.success?
2538
+ s.remove(transfer) # row + count + empty-state
2539
+ else
2540
+ s.replace(transfer) # back to actionable
2541
+ s.flash(:alert, result.error) # the operator learns the outcome
2542
+ end
2543
+ end
2544
+ end
2545
+ end
2546
+ ```
2547
+
2548
+ and the case that motivated the whole thing — work that moves a record between
2549
+ two lists — is one call:
2550
+
2551
+ ```ruby
2552
+ reactive_settle { |s| s.move(transfer, from: :declined, to: :unreconcilable) }
2553
+ ```
2554
+
2555
+ #### What the reply actually does
2556
+
2557
+ `reply.pending` deliberately does **NOT** re-render the container (that is the
2558
+ bug). It emits:
2559
+
2560
+ 1. a `data-reactive-pending="true"` + `aria-busy="true"` marker on every target,
2561
+ over the existing `reactive:js` op lane — style it in one CSS rule:
2562
+
2563
+ ```css
2564
+ [data-reactive-pending] { opacity: .5; pointer-events: none; }
2565
+ ```
2566
+
2567
+ 2. **one** subscription directive, anchored on the container, opening a
2568
+ single durable one-shot stream that **all N settles share**;
2569
+ 3. an inert `reactive:token` refresh, so the container's signed token rolls
2570
+ forward and the list is not act-once-only.
2571
+
2572
+ | `reply.pending(...)` | |
2573
+ |---|---|
2574
+ | `records` | one record, an enumerable of records, or built Streamable components |
2575
+ | `in: :name` | the `reactive_collection` the records live in — how their row DOM ids *and* the count/empty-state bookkeeping are resolved (required for records) |
2576
+ | a block | your enqueue. **Anything ActiveJob-enqueued inside it captures the settle handle**, including from a service object |
2577
+ | `job:` / `args:` | sugar for the common case. `args:` is an Array (one record) or a Proc called per record (`args: ->(r) { [r.id] }`); omitted means `perform_later(record)` |
2578
+ | `peers: true` | also broadcast each settle to the container's record stream, so a second operator watching the same batch sees it. **Default is actor-only**, matching `reply.defer` |
2579
+
2580
+ | `reactive_settle` yields a settle builder | |
2581
+ |---|---|
2582
+ | `s.replace(record)` | re-render the row in place (no count churn — a replace moves no boundary) |
2583
+ | `s.remove(record, from: :name)` | row + count + empty-state restore. `from:` defaults to the collection `reply.pending` named |
2584
+ | `s.append(record, to: :name)` / `s.prepend` | row + count + empty-state clear |
2585
+ | `s.move(record, from:, to:)` | ordered remove-then-append between two collections |
2586
+ | `s.count(:name)` | refresh only the count companion |
2587
+ | `s.flash(level, content)` | tell the operator the outcome |
2588
+ | `s.js(ops)` / `s.streams!(*raw)` | the `reply.js` / `reply.streams` escape hatches |
2589
+
2590
+ #### The rules that make it safe
2591
+
2592
+ - **The handle rides ActiveJob metadata, not `perform`'s arity.** `reply.pending`
2593
+ installs it in a thread-local and runs your enqueue inside it;
2594
+ `Settles#serialize` copies it into the job's metadata. So **every other caller
2595
+ of the same job — a nightly sweep, a webhook — keeps working unchanged**, and
2596
+ `reactive_settle` is simply a no-op there. That is load-bearing: these jobs
2597
+ almost always have non-UI callers.
2598
+ - **A job that raises still clears the pending state — when it can attribute
2599
+ it.** The `job:`/`args:` form enqueues one job per record and narrows each
2600
+ job's handle to *that record's* target, so a failure clears exactly its row
2601
+ and the container, then re-raises for your retry policy. The **block** form
2602
+ cannot be narrowed (the gem cannot map an arbitrary enqueue back to a record),
2603
+ so a failure there clears nothing and logs why — un-dimming 176 rows that are
2604
+ still working would be a worse lie. Those markers clear at `finish: true`.
2605
+ The subscription is deliberately **not** torn down on failure — a retry must
2606
+ still be able to reach the actor.
2607
+ - **Finishing clears every target, not just the container.** A settle that only
2608
+ flashes emits no row stream, so nothing swaps that row's node — the finish
2609
+ sweep is what removes its markers.
2610
+ - **ONE stream key per `reply.pending` call.** A durable broadcast to a
2611
+ never-seen key creates a real PGMQ table (reclaimed by pgbus's hourly orphan
2612
+ sweep at a 24h threshold), so a key per record would leave 177 tables sitting
2613
+ for a day and open 177 SSE connections. All N settles share one key and one
2614
+ subscription.
2615
+ - **Teardown is therefore explicit.** Because the key is shared, tearing down on
2616
+ the *first* arrival would cut off the other N−1. `reactive_settle` finishes
2617
+ automatically when `reply.pending` marked exactly **one** target; a fan-out
2618
+ passes `finish: true` from whatever knows it is last (a `Pgbus::Batch`
2619
+ `on_finish` callback, or the final job of a staggered sequence). Until then
2620
+ the subscription is superseded by the container's next `reply.pending` or
2621
+ closed when the page unloads.
2622
+ - **`reply.pending` needs the defer PUSH lane**
2623
+ (`Phlex::Reactive.settle_capable?` — pgbus reactive Streams + `SignedName` +
2624
+ ActiveJob, and `defer_transport` not forced to `:fetch`). A settle has no pull
2625
+ fallback: the client cannot poll "is the job done yet". **Without it,
2626
+ `reply.pending` degrades rather than breaks** — your enqueue still runs, no
2627
+ handle is installed, and **no pending markers are emitted**, so the UI shows
2628
+ the pre-job world (today's behavior) instead of a shimmer that could never
2629
+ resolve. A one-time warning says so.
2630
+ - **The pgbus CLIENT must be on the page too.** The server picks the push lane on
2631
+ *server-side* capability alone. If the browser has no `<pgbus-stream-source>`
2632
+ custom element registered, `reply.defer` degrades to its fetch token — but a
2633
+ settle has no fetch lane, so the subscription never opens and the pending
2634
+ markers would sit there. The client logs a loud console error naming the cause.
2635
+ Load pgbus's client wherever you use `reply.pending`; it is the same
2636
+ prerequisite the defer push lane already has.
2637
+ - **Authorization is still yours.** `reply.pending` signs the *container's*
2638
+ identity so the job can rebuild it; that is not permission to act. `authorize!`
2639
+ in the action, exactly as everywhere else.
2640
+ - **One `reply.pending` per container, per reply.** Every pending segment emits a
2641
+ directive targeting the container's id, and the client keys subscriptions by
2642
+ target — so a second call would supersede the first and orphan its jobs. The
2643
+ second call raises. Mark every target in one call; the settle names its own
2644
+ collection (`s.remove(record, from: :name)`).
2645
+ - **Peer delivery is best effort.** If a peer broadcast fails after the actor's
2646
+ message already went out, the job is *not* failed — retrying it would re-run
2647
+ `perform` and send the actor's settle (and its flash) a second time. The
2648
+ failure is logged instead.
2649
+
2650
+ #### Broadcasting a collection delta to peers
2651
+
2652
+ `broadcast_to(append:)` emits the **bare row** — it has no container instance,
2653
+ so it cannot resolve the declaration or run the size resolver. Its collection
2654
+ counterpart does:
2655
+
2656
+ ```ruby
2657
+ ReconcileQueue.broadcast_collection_to(@bulk_payment, :transfers,
2658
+ container: self, in: :unreconcilable, remove: transfer,
2659
+ exclude: reactive_connection_id)
2660
+ ```
2661
+
2662
+ `row:` carries the row component's extra init kwargs — pass the same ones the
2663
+ actor got, or a peer whose row has a required kwarg raises instead of rendering.
2664
+ A `remove:` may be a record or an already-built dom-id string, matching
2665
+ `reply.remove(id, from:)`.
2666
+
2667
+ Row **plus** count companion **plus** empty-state toggle, through the same
2668
+ `Phlex::Reactive::Collections` decisions the reply path uses — so the two can
2669
+ never drift. `coalesce:` (default `Phlex::Reactive.settle_coalesce_window_ms`,
2670
+ 50 ms) applies to the **aggregate** streams only: the count and empty-state are
2671
+ idempotent replaces of stable targets, so a 177-row fan-out collapses to a
2672
+ handful of them. The **row** stream is never coalesced (an append is not
2673
+ idempotent). Coalescing needs pgbus with
2674
+ [zoolutions/pgbus#465](https://github.com/zoolutions/pgbus/issues/465); on
2675
+ anything older the window is ignored and every aggregate goes out — chattier,
2676
+ equally correct.
2677
+
2678
+ #### Settle configuration
2679
+
2680
+ | Setting | Default | Purpose |
2681
+ |---|---|---|
2682
+ | `Phlex::Reactive.settle_coalesce_window_ms` | `50` | Window for the aggregate (count / empty-state) streams on the peers path. |
2683
+
2684
+ There is deliberately **no** `settle_token_ttl`. A settle has no pull lane for a
2685
+ token to govern — the client cannot poll "is the job done yet", and redeeming
2686
+ such a token at the defer endpoint would render the *pre-job* component, i.e.
2687
+ the exact bug `reply.pending` fixes. The settle's wait is bounded by the job,
2688
+ not by a TTL.
2457
2689
 
2458
2690
  ### Effects — animate enter/exit/update (opt-in)
2459
2691
 
@@ -403,7 +403,21 @@ module Phlex
403
403
  end
404
404
  end
405
405
 
406
- append_deferred_streams(streams, result)
406
+ append_pending_streams(append_deferred_streams(streams, result), result)
407
+ end
408
+
409
+ # Pending segments (issue #248) ride LAST, alongside the deferred ones and
410
+ # for the same reason: this runs AFTER run_action returned, i.e. after the
411
+ # action's transaction COMMITTED — a rolled-back action takes the rescue
412
+ # paths, so no pending marker and no subscription directive can ever
413
+ # outlive a mutation that did not happen. (The ENQUEUE itself already ran
414
+ # inside the action, exactly like the app's own perform_later would have;
415
+ # its transactional behaviour is the queue adapter's, unchanged.) The
416
+ # common non-pending reply pays one empty? check.
417
+ def append_pending_streams(streams, result)
418
+ return streams unless result.pending?
419
+
420
+ [*streams, *result.pending_segments.flat_map { Phlex::Reactive::Pending.streams_for(it) }]
407
421
  end
408
422
 
409
423
  # Deferred segments (issue #165) ride LAST — after every render stream and
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ # The reactive_collection bookkeeping (issue #35), extracted from Response's
6
+ # privates so MORE THAN ONE caller can run it (issue #248).
7
+ #
8
+ # A collection row is never just a row: adding one must also refresh the
9
+ # count companion and clear the empty-state at the 0->1 boundary; removing
10
+ # one must refresh the count and restore the empty-state at the 1->0
11
+ # boundary. That arithmetic used to live inside Response, reachable only
12
+ # from `reply.*` — so a JOB that finished background work had to re-derive
13
+ # it by hand and got the boundary subtly wrong.
14
+ #
15
+ # Three callers now share this module:
16
+ #
17
+ # * Response.build_collection_{append,prepend,remove} — the actor's reply
18
+ # * Phlex::Reactive::Settle — the job-side settle (issue #248)
19
+ # * Streamable.broadcast_collection_to — the peers' broadcast (issue #248)
20
+ #
21
+ # The reply/settle paths build <turbo-stream> STRINGS; the broadcast path
22
+ # hands pieces to Turbo::StreamsChannel, which builds its own tags. They
23
+ # therefore cannot share the rendering — so what they share is the layer
24
+ # that actually drifts: the DECISIONS (#count_refresh and #empty_toggle,
25
+ # the 0<->1 boundary). Every renderer below reads those two.
26
+ #
27
+ # The module is stateless: every method takes the CollectionDefinition plus
28
+ # the bound container instance (the size resolver is `instance_exec`d
29
+ # against it, so it reads the container's ivars/association).
30
+ module Collections
31
+ class << self
32
+ # Resolve a declared collection off the container's class. A typo'd name
33
+ # must fail LOUDLY here, not silently emit an empty stream list.
34
+ def definition!(container, name)
35
+ container.class.reactive_collections[name.to_sym] ||
36
+ raise(Phlex::Reactive::Error,
37
+ "undeclared reactive_collection :#{name} on #{container.class}")
38
+ end
39
+
40
+ # --- The two shared DECISIONS -------------------------------------
41
+
42
+ # The collection's LIVE size, resolved ONCE per delta. Both decisions
43
+ # below read it, and both renderers pass the same value down — the
44
+ # resolver is usually a DB count, so evaluating it twice per delta is
45
+ # both an extra query AND a correctness hazard: a concurrent write
46
+ # landing between the two reads would emit a count companion that
47
+ # disagrees with the empty-state toggle it ships beside.
48
+ # `:__unresolved` is the "not computed yet" sentinel, distinct from a
49
+ # legitimately nil size (no size: declared).
50
+ def size_of(definition, container) = definition.size_for(container)
51
+
52
+ # [count_target, size_string] when a count companion AND a size resolver
53
+ # are both declared and the resolver returned a number; nil otherwise
54
+ # (the count stream is simply omitted — a list with just rows works).
55
+ # `size:` may be passed in by a caller that already resolved it.
56
+ def count_refresh(definition, container, size = :__unresolved)
57
+ return nil unless definition.count
58
+
59
+ size = size_of(definition, container) if size == :__unresolved
60
+ return nil if size.nil?
61
+
62
+ [definition.count, size.to_s]
63
+ end
64
+
65
+ # What the empty-state must do for this delta, or nil for "nothing":
66
+ # :clear — the list just crossed 0->1, remove the empty-state
67
+ # :restore — the list just emptied, append the empty-state back
68
+ # Both are edge-triggered off the LIVE size (the resolver runs after the
69
+ # mutation), never off a client-side increment.
70
+ def empty_toggle(definition, container, delta, size = :__unresolved)
71
+ return nil unless definition.empty
72
+
73
+ size = size_of(definition, container) if size == :__unresolved
74
+ case delta
75
+ when :add then :clear if size == 1
76
+ else :restore if size&.zero?
77
+ end
78
+ end
79
+
80
+ # --- The reply/settle renderer (turbo-stream strings) --------------
81
+
82
+ # Row add (append/prepend) + count + empty-state clear.
83
+ #
84
+ # row_kwargs (issue #186) thread to the row component's init via the
85
+ # class stream builder's **options passthrough (ItemRow.new(model:,
86
+ # **row_kwargs)). `effect:` (issue #215) stamps the ROW stream only —
87
+ # the count companion and the empty-state toggle are bookkeeping, not
88
+ # the thing entering/leaving.
89
+ def add_streams(definition, container, model, action, row_kwargs = {}, effect: nil)
90
+ size = size_of(definition, container)
91
+ streams = [
92
+ definition.item.public_send(action, target: definition.container, model:, effect:, **row_kwargs)
93
+ ]
94
+ streams.concat(count_streams(definition, container, size))
95
+ streams << definition.empty.new.to_stream_remove if empty_toggle(definition, container, :add, size) == :clear
96
+ streams
97
+ end
98
+
99
+ # Row remove + count + empty-state restore. The empty-state is appended
100
+ # back INTO the container (not its own id) when the list just emptied —
101
+ # restoring "No items yet" after the last row went. model: nil builds it
102
+ # argument-free (an empty-state is a static view).
103
+ def remove_streams(definition, container, model, effect: nil)
104
+ size = size_of(definition, container)
105
+ streams = [row_remove_stream(definition, model, effect)]
106
+ streams.concat(count_streams(definition, container, size))
107
+ if empty_toggle(definition, container, :remove, size) == :restore
108
+ streams << definition.empty.append(target: definition.container, model: nil)
109
+ end
110
+ streams
111
+ end
112
+
113
+ # The count companion's update stream, or [] when there is nothing to
114
+ # refresh. Its own method (rather than an inline append) because the
115
+ # settle path refreshes the count WITHOUT a row delta — a job that
116
+ # neither added nor removed a row can still have changed the size.
117
+ def count_streams(definition, container, size = :__unresolved)
118
+ target, resolved = count_refresh(definition, container, size)
119
+ return [] unless target
120
+
121
+ [Phlex::Reactive::Response.update_stream(target, resolved)]
122
+ end
123
+
124
+ # Remove the row by its DOM id. Accepts the record (so dom_id is
125
+ # derived) or an already-built dom-id string (e.g. the value the row
126
+ # used as its #id).
127
+ def row_remove_stream(definition, model, effect = nil)
128
+ if model.is_a?(String)
129
+ Phlex::Reactive::Effects.annotate(Phlex::Reactive.stream_builder.remove(model), effect)
130
+ else
131
+ definition.item.remove(model, effect:)
132
+ end
133
+ end
134
+
135
+ # Re-render ONE row in place (a settle that neither added nor removed —
136
+ # the work ran and the row is simply different now). No count/empty
137
+ # streams: a replace cannot change the size.
138
+ def replace_streams(definition, model, effect: nil, **row_kwargs)
139
+ [definition.item.replace(model, effect:, **row_kwargs)]
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end