pgbus 0.8.4 → 0.9.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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/app/assets/javascripts/pgbus/stream_source_element.js +150 -5
  4. data/app/controllers/pgbus/batches_controller.rb +15 -0
  5. data/app/helpers/pgbus/application_helper.rb +12 -0
  6. data/app/views/layouts/pgbus/application.html.erb +2 -0
  7. data/app/views/pgbus/batches/_batches_table.html.erb +54 -0
  8. data/app/views/pgbus/batches/index.html.erb +8 -0
  9. data/app/views/pgbus/batches/show.html.erb +90 -0
  10. data/config/locales/da.yml +34 -0
  11. data/config/locales/de.yml +34 -0
  12. data/config/locales/en.yml +34 -0
  13. data/config/locales/es.yml +34 -0
  14. data/config/locales/fi.yml +34 -0
  15. data/config/locales/fr.yml +34 -0
  16. data/config/locales/it.yml +34 -0
  17. data/config/locales/ja.yml +34 -0
  18. data/config/locales/nb.yml +34 -0
  19. data/config/locales/nl.yml +34 -0
  20. data/config/locales/pt.yml +34 -0
  21. data/config/locales/sv.yml +34 -0
  22. data/config/routes.rb +1 -0
  23. data/lib/pgbus/client.rb +102 -4
  24. data/lib/pgbus/configuration.rb +84 -0
  25. data/lib/pgbus/execution_pools/async_pool.rb +44 -6
  26. data/lib/pgbus/process/supervisor.rb +2 -1
  27. data/lib/pgbus/process/worker.rb +38 -1
  28. data/lib/pgbus/serializer.rb +1 -1
  29. data/lib/pgbus/streams/coalescer.rb +88 -0
  30. data/lib/pgbus/streams/envelope.rb +20 -1
  31. data/lib/pgbus/streams/key.rb +35 -2
  32. data/lib/pgbus/streams/renderer.rb +67 -0
  33. data/lib/pgbus/streams.rb +150 -1
  34. data/lib/pgbus/version.rb +1 -1
  35. data/lib/pgbus/web/data_source.rb +96 -0
  36. data/lib/pgbus/web/stream_app.rb +8 -4
  37. data/lib/pgbus/web/streamer/connection.rb +15 -1
  38. data/lib/pgbus/web/streamer/falcon_connection.rb +9 -1
  39. data/lib/pgbus/web/streamer/heartbeat.rb +23 -1
  40. data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +129 -14
  41. data/lib/pgbus.rb +11 -0
  42. data/lib/tasks/pgbus_queues.rake +54 -0
  43. metadata +10 -3
@@ -44,6 +44,12 @@ module Pgbus
44
44
  # Priority queues
45
45
  attr_accessor :priority_levels, :default_priority
46
46
 
47
+ # Grouped reads (PGMQ v1.11.0+ FIFO grouping).
48
+ # nil = disabled (default read_batch behavior).
49
+ # :fifo = use read_grouped (drains oldest group first, throughput-optimized).
50
+ # :round_robin = use read_grouped_rr (fair round-robin across groups).
51
+ attr_reader :group_mode
52
+
47
53
  # Archive compaction. Only the user-facing retention window is configurable;
48
54
  # the loop interval and batch size are tuned via constants on
49
55
  # Pgbus::Process::Dispatcher.
@@ -107,6 +113,7 @@ module Pgbus
107
113
  :streams_stats_enabled, :streams_test_mode,
108
114
  :streams_orphan_sweep_interval, :streams_orphan_threshold,
109
115
  :streams_durable_patterns,
116
+ :streams_presence_patterns, :streams_presence_member,
110
117
  :streams_host, :streams_port, :streams_database_url
111
118
  attr_reader :streams_default_broadcast_mode # rubocop:disable Style/AccessorGrouping
112
119
 
@@ -146,6 +153,7 @@ module Pgbus
146
153
 
147
154
  @priority_levels = nil
148
155
  @default_priority = 1
156
+ @group_mode = nil
149
157
 
150
158
  @archive_retention = 7 * 24 * 3600 # 7 days
151
159
 
@@ -240,6 +248,15 @@ module Pgbus
240
248
  @streams_orphan_sweep_interval = 3600 # 1 hour
241
249
  @streams_orphan_threshold = 86_400 # 24 hours
242
250
  @streams_durable_patterns = []
251
+ # Streams matching these patterns get connection-driven presence:
252
+ # auto-join on SSE connect, auto-leave on disconnect, touch on the
253
+ # keepalive heartbeat (issue #169). Empty by default (opt-in).
254
+ @streams_presence_patterns = []
255
+ # Extracts a presence member { id:, metadata: } from a connection's
256
+ # authorize-hook context. Defaults to nil, which uses the built-in
257
+ # extractor (see #presence_member_for): a Hash with :member_id/:id,
258
+ # or an object responding to #id.
259
+ @streams_presence_member = nil
243
260
 
244
261
  # AppSignal: auto-on when the appsignal gem is loaded; probe runs in
245
262
  # the same process, so the operator can disable it independently.
@@ -300,6 +317,24 @@ module Pgbus
300
317
  @streams_default_broadcast_mode = mode
301
318
  end
302
319
 
320
+ VALID_GROUP_MODES = [nil, :fifo, :round_robin].freeze
321
+
322
+ def group_mode=(mode)
323
+ coerced = case mode
324
+ when nil then nil
325
+ when Symbol then mode
326
+ when String then mode.to_sym
327
+ else
328
+ raise ArgumentError,
329
+ "Invalid group_mode type: #{mode.class}. Must be nil, String, or Symbol"
330
+ end
331
+ unless VALID_GROUP_MODES.include?(coerced)
332
+ raise ArgumentError, "Invalid group_mode: #{coerced.inspect}. Must be nil, :fifo, or :round_robin"
333
+ end
334
+
335
+ @group_mode = coerced
336
+ end
337
+
303
338
  VALID_PGMQ_SCHEMA_MODES = %i[auto extension embedded].freeze
304
339
 
305
340
  def pgmq_schema_mode=(mode)
@@ -386,6 +421,12 @@ module Pgbus
386
421
 
387
422
  raise ArgumentError, "streams_durable_patterns must be an Array of strings/regex" unless streams_durable_patterns.is_a?(Array)
388
423
 
424
+ raise ArgumentError, "streams_presence_patterns must be an Array of strings/regex" unless streams_presence_patterns.is_a?(Array)
425
+
426
+ if !streams_presence_member.nil? && !streams_presence_member.respond_to?(:call)
427
+ raise ArgumentError, "streams_presence_member must respond to #call (a Proc/lambda) or be nil"
428
+ end
429
+
389
430
  return if streams_orphan_threshold.nil?
390
431
  return if streams_orphan_threshold.is_a?(Numeric) && streams_orphan_threshold.positive?
391
432
 
@@ -402,6 +443,35 @@ module Pgbus
402
443
  streams_default_broadcast_mode == :durable
403
444
  end
404
445
 
446
+ # Returns true if the given stream name should have connection-driven
447
+ # presence based on `streams_presence_patterns` (exact string or
448
+ # Regexp match). Presence is opt-in, so the default (no patterns) is
449
+ # false. See issue #169.
450
+ def stream_presence?(name)
451
+ patterns = streams_presence_patterns || []
452
+ patterns.any? { |p| p.is_a?(Regexp) ? p.match?(name) : p == name }
453
+ end
454
+
455
+ # Derives a presence member { id:, metadata: } from a connection's
456
+ # authorize-hook context, or nil when no member can be derived (an
457
+ # anonymous connection — presence is simply skipped). Uses
458
+ # `streams_presence_member` when configured; otherwise the built-in
459
+ # extractor handles the common shapes:
460
+ # - Hash with :member_id (or :id) and optional :metadata
461
+ # - an object responding to #id (e.g. a User model)
462
+ # The id is always coerced to a String and metadata defaults to {}.
463
+ def presence_member_for(context)
464
+ return nil if context.nil?
465
+
466
+ raw = streams_presence_member ? streams_presence_member.call(context) : default_presence_member(context)
467
+ return nil unless raw.is_a?(Hash)
468
+
469
+ id = raw[:id]
470
+ return nil if id.nil? || id.to_s.empty?
471
+
472
+ { id: id.to_s, metadata: raw[:metadata] || {} }
473
+ end
474
+
405
475
  # Set the worker capsule list. Accepts:
406
476
  #
407
477
  # String — parsed via Pgbus::Configuration::CapsuleDSL into capsules
@@ -664,6 +734,20 @@ module Pgbus
664
734
 
665
735
  private
666
736
 
737
+ # Built-in presence-member extractor used when no custom
738
+ # `streams_presence_member` is configured. Returns a { id:, metadata: }
739
+ # Hash or nil; #presence_member_for normalizes the id/metadata.
740
+ def default_presence_member(context)
741
+ if context.is_a?(Hash)
742
+ id = context[:member_id] || context[:id]
743
+ return nil if id.nil?
744
+
745
+ { id: id, metadata: context[:metadata] || {} }
746
+ elsif context.respond_to?(:id)
747
+ { id: context.id, metadata: {} }
748
+ end
749
+ end
750
+
667
751
  # Coerce a duration setting value to a positive Numeric.
668
752
  #
669
753
  # Accepts an ActiveSupport::Duration (coerced to Integer seconds via .to_i)
@@ -5,8 +5,6 @@ module Pgbus
5
5
  class AsyncPool
6
6
  attr_reader :capacity
7
7
 
8
- IDLE_WAIT_INTERVAL = 0.01
9
-
10
8
  def initialize(capacity:, on_state_change: nil)
11
9
  @capacity = capacity
12
10
  @on_state_change = on_state_change
@@ -17,6 +15,7 @@ module Pgbus
17
15
  @fatal_error = nil
18
16
  @boot_queue = Thread::Queue.new
19
17
  @pending = Thread::Queue.new
18
+ @wake_rd, @wake_wr = IO.pipe
20
19
 
21
20
  validate_dependencies!
22
21
  @reactor_thread = start_reactor
@@ -32,6 +31,7 @@ module Pgbus
32
31
  reserve_capacity!
33
32
  reserved = true
34
33
  @pending << block
34
+ wake_reactor
35
35
  rescue StandardError
36
36
  restore_capacity if reserved
37
37
  raise
@@ -52,6 +52,7 @@ module Pgbus
52
52
 
53
53
  @shutdown_flag = true
54
54
  end
55
+ wake_reactor
55
56
  end
56
57
 
57
58
  def shutdown?
@@ -96,7 +97,18 @@ module Pgbus
96
97
  @boot_queue << :ready
97
98
 
98
99
  wait_for_executions(semaphore)
99
- wait_for_inflight
100
+ wait_for_inflight(task)
101
+ ensure
102
+ begin
103
+ @wake_rd.close
104
+ rescue IOError
105
+ nil
106
+ end
107
+ begin
108
+ @wake_wr.close
109
+ rescue IOError
110
+ nil
111
+ end
100
112
  end
101
113
  rescue Exception => e
102
114
  register_fatal_error(e)
@@ -110,10 +122,35 @@ module Pgbus
110
122
  schedule_pending(semaphore)
111
123
  break if shutdown? && @pending.empty?
112
124
 
113
- sleep(IDLE_WAIT_INTERVAL) if @pending.empty?
125
+ wait_for_wake if @pending.empty?
114
126
  end
115
127
  end
116
128
 
129
+ # Fiber-aware wait: yields the reactor fiber until the main thread
130
+ # writes a wake byte via wake_reactor. IO#wait_readable integrates
131
+ # with the Async scheduler so other fibers continue running.
132
+ def wait_for_wake
133
+ return if @wake_rd.closed?
134
+
135
+ @wake_rd.wait_readable
136
+ drain_wake_pipe
137
+ rescue IOError, Errno::EBADF
138
+ nil
139
+ end
140
+
141
+ def drain_wake_pipe
142
+ @wake_rd.read_nonblock(256)
143
+ rescue IOError, SystemCallError
144
+ nil
145
+ end
146
+
147
+ # Thread-safe: called from the main thread to wake the reactor fiber.
148
+ def wake_reactor
149
+ @wake_wr.write_nonblock(".")
150
+ rescue IO::WaitWritable, IOError, Errno::EBADF, Errno::EPIPE
151
+ nil
152
+ end
153
+
117
154
  def schedule_pending(semaphore)
118
155
  while (block = next_pending)
119
156
  semaphore.async do
@@ -160,6 +197,7 @@ module Pgbus
160
197
  @available_capacity += 1
161
198
  @available_capacity.positive?
162
199
  end
200
+ wake_reactor
163
201
  @on_state_change&.call if should_notify
164
202
  end
165
203
 
@@ -174,8 +212,8 @@ module Pgbus
174
212
  raise error if error
175
213
  end
176
214
 
177
- def wait_for_inflight
178
- sleep(IDLE_WAIT_INTERVAL) while inflight?
215
+ def wait_for_inflight(task)
216
+ task.sleep(0.01) while inflight?
179
217
  end
180
218
 
181
219
  def inflight?
@@ -69,6 +69,7 @@ module Pgbus
69
69
  single_active = worker_config[:single_active_consumer] || worker_config["single_active_consumer"] || false
70
70
  priority = worker_config[:consumer_priority] || worker_config["consumer_priority"] || 0
71
71
  exec_mode = config.execution_mode_for(worker_config)
72
+ grp_mode = worker_config[:group_mode] || worker_config["group_mode"] || config.group_mode
72
73
 
73
74
  pid = fork do
74
75
  restore_signals
@@ -78,7 +79,7 @@ module Pgbus
78
79
  worker = Worker.new(
79
80
  queues: queues, threads: threads, config: config,
80
81
  single_active_consumer: single_active, consumer_priority: priority,
81
- execution_mode: exec_mode
82
+ execution_mode: exec_mode, group_mode: grp_mode
82
83
  )
83
84
  worker.run
84
85
  end
@@ -11,12 +11,24 @@ module Pgbus
11
11
 
12
12
  def initialize(queues:, threads: 5, config: Pgbus.configuration,
13
13
  single_active_consumer: false, consumer_priority: 0,
14
- execution_mode: :threads)
14
+ execution_mode: :threads, group_mode: nil)
15
15
  @queues = Array(queues)
16
16
  @wildcard = @queues.include?("*")
17
17
  @threads = threads
18
18
  @config = config
19
19
  @execution_mode = ExecutionPools.normalize_mode(execution_mode)
20
+ @group_mode = case group_mode
21
+ when nil then nil
22
+ when Symbol then group_mode
23
+ when String then group_mode.to_sym
24
+ else
25
+ raise ArgumentError,
26
+ "Invalid group_mode type: #{group_mode.class}. Must be nil, String, or Symbol"
27
+ end
28
+ unless Pgbus::Configuration::VALID_GROUP_MODES.include?(@group_mode)
29
+ raise ArgumentError,
30
+ "Invalid group_mode: #{@group_mode.inspect}. Must be nil, :fifo, or :round_robin"
31
+ end
20
32
  @single_active_consumer = single_active_consumer
21
33
  @consumer_priority = consumer_priority
22
34
  @lifecycle = Lifecycle.new
@@ -141,6 +153,8 @@ module Pgbus
141
153
 
142
154
  if priority_enabled?
143
155
  fetch_prioritized(active_queues, qty)
156
+ elsif @group_mode
157
+ fetch_grouped(active_queues, qty)
144
158
  elsif active_queues.size == 1
145
159
  queue = active_queues.first
146
160
  messages = Pgbus.client.read_batch(queue, qty: qty) || []
@@ -210,6 +224,29 @@ module Pgbus
210
224
  end
211
225
  end
212
226
 
227
+ # Use grouped reads for fair or throughput-optimized multi-tenant processing.
228
+ # Each queue is read independently with the configured group strategy.
229
+ def fetch_grouped(active_queues, qty)
230
+ remaining = qty
231
+ results = []
232
+
233
+ active_queues.each do |queue|
234
+ break if remaining <= 0
235
+
236
+ messages = case @group_mode
237
+ when :round_robin
238
+ Pgbus.client.read_grouped_rr(queue, qty: remaining) || []
239
+ else # :fifo
240
+ Pgbus.client.read_grouped(queue, qty: remaining) || []
241
+ end
242
+
243
+ messages.each { |m| results << [queue, m] }
244
+ remaining -= messages.size
245
+ end
246
+
247
+ results
248
+ end
249
+
213
250
  def priority_enabled?
214
251
  config.priority_levels && config.priority_levels > 1
215
252
  end
@@ -58,7 +58,7 @@ module Pgbus
58
58
  raise ArgumentError, "Invalid GlobalID: #{gid_string.inspect}" unless gid
59
59
 
60
60
  allowed = Pgbus.configuration.allowed_global_id_models
61
- if allowed&.empty?
61
+ if allowed && allowed.empty?
62
62
  raise ArgumentError,
63
63
  "GlobalID deserialization is disabled (allowed_global_id_models is empty). " \
64
64
  "Set to nil to allow all models, or add permitted classes."
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+
5
+ module Pgbus
6
+ module Streams
7
+ # Publish-side coalescing for high-frequency broadcasts (issue #171).
8
+ #
9
+ # A chatty reactive component — a live cursor, a typing indicator, a
10
+ # progress bar — can fan out many small broadcasts per second. For
11
+ # per-keystroke / per-frame updates that's wasteful: every frame
12
+ # becomes a PGMQ insert (or a NOTIFY) and a fan-out to every connection.
13
+ #
14
+ # The Coalescer batches per (stream, target) within a short window and
15
+ # flushes only the *latest* payload, so superseded frames never hit the
16
+ # bus at all. This is last-write-wins and is only safe for idempotent
17
+ # actions (replace / update of a stable target) — which is exactly the
18
+ # high-frequency case. It is strictly opt-in (`coalesce:` on broadcast).
19
+ #
20
+ # Debounce semantics: the FIRST submit for a (stream, target) schedules
21
+ # a flush `window_ms` later; subsequent submits within that window only
22
+ # overwrite the buffered payload. So latency is bounded to one window
23
+ # and a continuous stream of updates can't starve the flush (it is a
24
+ # trailing-edge-with-max-wait debounce, not a resettable one).
25
+ #
26
+ # Thread-safe: many request threads may submit concurrently. The buffer
27
+ # and the per-key pending-flush set are guarded by a single mutex; the
28
+ # flush itself runs off the mutex on the scheduler's thread.
29
+ class Coalescer
30
+ # Default coalescing window when `coalesce: true` is passed without an
31
+ # explicit millisecond value.
32
+ DEFAULT_WINDOW_MS = 50
33
+
34
+ Entry = Struct.new(:payload, :opts)
35
+
36
+ # scheduler: responds to `schedule(delay_seconds) { ... }`. Defaults
37
+ # to a Concurrent::ScheduledTask-backed scheduler.
38
+ # flush: ->(stream_name:, target:, payload:, opts:) called once per
39
+ # window per key with the latest buffered frame.
40
+ def initialize(flush:, scheduler: nil)
41
+ @flush = flush
42
+ @scheduler = scheduler || ScheduledTaskScheduler.new
43
+ @mutex = Mutex.new
44
+ @buffer = {} # key => Entry (latest)
45
+ @pending = {} # key => true while a flush is scheduled
46
+ end
47
+
48
+ # Buffers a frame for (stream_name, target). Overwrites any frame
49
+ # already buffered for the same key within the current window. The
50
+ # first submit per window schedules the flush.
51
+ def submit(stream_name:, target:, payload:, opts:, window_ms: DEFAULT_WINDOW_MS)
52
+ key = [stream_name, target]
53
+ schedule = false
54
+
55
+ @mutex.synchronize do
56
+ @buffer[key] = Entry.new(payload, opts)
57
+ unless @pending[key]
58
+ @pending[key] = true
59
+ schedule = true
60
+ end
61
+ end
62
+
63
+ @scheduler.schedule(window_ms / 1000.0) { flush_key(key, stream_name, target) } if schedule
64
+ end
65
+
66
+ private
67
+
68
+ def flush_key(key, stream_name, target)
69
+ entry = @mutex.synchronize do
70
+ @pending.delete(key)
71
+ @buffer.delete(key)
72
+ end
73
+ return unless entry
74
+
75
+ @flush.call(stream_name: stream_name, target: target, payload: entry.payload, opts: entry.opts)
76
+ end
77
+
78
+ # Default scheduler backed by Concurrent::ScheduledTask. Kept as a
79
+ # tiny adapter so the Coalescer can be unit-tested with a synchronous
80
+ # fake scheduler (no real timers, no sleeps).
81
+ class ScheduledTaskScheduler
82
+ def schedule(delay_seconds, &)
83
+ Concurrent::ScheduledTask.execute(delay_seconds, &)
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module Pgbus
4
6
  module Streams
5
7
  # Encodes Server-Sent Events frames per https://html.spec.whatwg.org/multipage/server-sent-events.html.
@@ -29,13 +31,30 @@ module Pgbus
29
31
  raise ArgumentError, "id is required" if id.nil?
30
32
  raise ArgumentError, "event is required" if event.nil? || event.to_s.empty?
31
33
 
32
- "id: #{id}\nevent: #{event}\ndata: #{strip_newlines(data.to_s)}\n\n"
34
+ # Strip newlines from BOTH event and data, not just data: each is
35
+ # interpolated into its own SSE field line, so an unescaped \r/\n in
36
+ # either would terminate the field early and let a crafted value
37
+ # inject extra SSE fields (a forged id:/data:) into the frame.
38
+ "id: #{id}\nevent: #{strip_newlines(event.to_s)}\ndata: #{strip_newlines(data.to_s)}\n\n"
33
39
  end
34
40
 
35
41
  def self.comment(text)
36
42
  ": #{strip_newlines(text.to_s)}\n\n"
37
43
  end
38
44
 
45
+ # Emits a `pgbus:connected` frame carrying the server-minted
46
+ # connection id as JSON. Sent once, right after the open handshake,
47
+ # so the page can read its own connection id and send it back as
48
+ # `X-Pgbus-Connection` on action requests (actor-echo suppression,
49
+ # issue #165). Deliberately omits an `id:` line: this is connection
50
+ # metadata, not a broadcast, and giving it a cursor id would corrupt
51
+ # the client's Last-Event-ID replay position on reconnect.
52
+ def self.connected(id:)
53
+ raise ArgumentError, "id is required" if id.nil? || id.to_s.empty?
54
+
55
+ "event: pgbus:connected\ndata: #{JSON.generate({ connectionId: id.to_s })}\n\n"
56
+ end
57
+
39
58
  def self.retry_directive(milliseconds)
40
59
  unless milliseconds.is_a?(Integer) && !milliseconds.negative?
41
60
  raise ArgumentError, "retry must be a non-negative integer (got #{milliseconds.inspect})"
@@ -63,9 +63,42 @@ module Pgbus
63
63
  # `to_stream_key`/`to_gid_param` implementation that forgot to
64
64
  # sanitize) raise an ArgumentError at the call site.
65
65
  def stream_key(*parts, digest_bits: DEFAULT_DIGEST_BITS)
66
- fragments = Array(parts).flatten.map { |part| normalize(part, digest_bits: digest_bits) }
66
+ flattened = Array(parts).flatten
67
+
68
+ # Idempotency for an already-built key: a single String argument
69
+ # is treated as a pre-built pgbus stream key and returned
70
+ # unchanged (after the budget check). This lets a consumer hold
71
+ # one `stream_key` value and pass it to both `turbo_stream_from`
72
+ # and the broadcaster without the colon separator guard raising
73
+ # on the second call. The guard only protects against ambiguous
74
+ # *joins* (`stream_key('a:b', :c)` vs `stream_key('a', 'b:c')`),
75
+ # and there is no second fragment here to collapse against, so the
76
+ # hazard cannot arise. Symbols and records are NOT keys — a colon
77
+ # in those never came from `stream_key` and stays a mistake.
78
+ return stream_key!(flattened.first) if flattened.length == 1 && flattened.first.is_a?(String)
79
+
80
+ fragments = flattened.map { |part| normalize(part, digest_bits: digest_bits) }
67
81
  fragments.each { |fragment| reject_colons!(fragment) }
68
- key = fragments.join(":")
82
+ validate_budget!(fragments.join(":"))
83
+ end
84
+
85
+ # Accepts an already-built stream key verbatim, skipping the
86
+ # per-fragment colon guard (a pre-built key legitimately contains
87
+ # ':' separators). Still enforces the queue-name budget so an
88
+ # oversized key fails at the call site rather than deep inside
89
+ # Client#ensure_stream_queue. Use this when you hold a key string
90
+ # and want to be explicit that no re-keying should happen — e.g.
91
+ # passing the same value to `turbo_stream_from` and a broadcaster.
92
+ def stream_key!(key)
93
+ raise ArgumentError, "stream_key! key must be a String, got #{key.class}" unless key.is_a?(String)
94
+
95
+ validate_budget!(key)
96
+ end
97
+
98
+ # Returns the key when it fits the pgbus queue-name budget; raises
99
+ # ArgumentError with an actionable message otherwise. Shared by
100
+ # `stream_key` and `stream_key!` so both paths fail identically.
101
+ def validate_budget!(key)
69
102
  budget = queue_name_budget
70
103
  return key if key.length <= budget
71
104
 
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ module Pgbus
6
+ module Streams
7
+ # Turns a renderable into a complete `<turbo-stream>` action tag,
8
+ # ready to hand to Stream#broadcast. This centralises the off-request
9
+ # render + tag-building that every consumer would otherwise stitch
10
+ # together by hand (the #1 footgun in server-driven UI: rendering a
11
+ # component outside a request without a usable view context).
12
+ #
13
+ # pgbus deliberately has no hard dependency on turbo-rails, ActionView,
14
+ # or Phlex, so this builder is self-contained and matches Turbo's wire
15
+ # format directly. The browser's Turbo runtime consumes the tag; the
16
+ # exact string is the contract, not any particular Ruby library.
17
+ #
18
+ # Renderable resolution (first match wins):
19
+ # - String → used verbatim (already-rendered markup)
20
+ # - responds to :call → Phlex::HTML#call (the issue's example shape)
21
+ # - responds to :render_in → ViewComponent / phlex-rails
22
+ # (`render_in(view_context)`; a nil context is passed because
23
+ # off-request there is no controller — components that need URL
24
+ # helpers should be rendered by the app and the string passed in)
25
+ # - else → to_s
26
+ #
27
+ # Tag format mirrors Turbo::Streams::TagBuilder:
28
+ # - content actions wrap the markup in a <template>
29
+ # - content-less actions (remove) emit no <template>
30
+ module Renderer
31
+ # Turbo stream actions that carry no content (no <template> wrapper).
32
+ CONTENTLESS_ACTIONS = %w[remove].freeze
33
+
34
+ module_function
35
+
36
+ # Builds a `<turbo-stream action target><template>...</template></turbo-stream>`
37
+ # string. `renderable` may be nil for content-less actions.
38
+ def turbo_stream_tag(action:, target:, renderable: nil)
39
+ raise ArgumentError, "target is required" if target.nil? || target.to_s.empty?
40
+
41
+ action = action.to_s
42
+ attrs = %(action="#{escape(action)}" target="#{escape(target)}")
43
+
44
+ return "<turbo-stream #{attrs}></turbo-stream>" if CONTENTLESS_ACTIONS.include?(action)
45
+
46
+ content = render(renderable)
47
+ "<turbo-stream #{attrs}><template>#{content}</template></turbo-stream>"
48
+ end
49
+
50
+ # Resolves a renderable to an HTML string. See module docs for the
51
+ # resolution order. Returns "" for nil (a content action with no
52
+ # renderable still emits an empty <template>).
53
+ def render(renderable)
54
+ return "" if renderable.nil?
55
+ return renderable if renderable.is_a?(String)
56
+ return renderable.call.to_s if renderable.respond_to?(:call)
57
+ return renderable.render_in(nil).to_s if renderable.respond_to?(:render_in)
58
+
59
+ renderable.to_s
60
+ end
61
+
62
+ def escape(value)
63
+ CGI.escape_html(value.to_s)
64
+ end
65
+ end
66
+ end
67
+ end