pgbus 0.17.2 → 0.18.0

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -0
  3. data/README.md +55 -9
  4. data/Rakefile +26 -0
  5. data/app/assets/javascripts/pgbus/stream_source_element.js +37 -4
  6. data/app/assets/javascripts/pgbus/stream_source_element.min.js +6 -0
  7. data/app/assets/javascripts/pgbus/stream_source_element.min.js.map +1 -0
  8. data/app/controllers/pgbus/frontends_controller.rb +25 -11
  9. data/app/frontend/pgbus/application.min.js +2 -0
  10. data/app/frontend/pgbus/application.min.js.map +1 -0
  11. data/app/frontend/pgbus/modules/charts.js +24 -6
  12. data/app/frontend/pgbus/modules/charts.min.js +2 -0
  13. data/app/frontend/pgbus/modules/charts.min.js.map +1 -0
  14. data/app/frontend/pgbus/vendor/turbo.min.js +54 -0
  15. data/app/frontend/pgbus/vendor/turbo.min.js.map +1 -0
  16. data/app/views/layouts/pgbus/application.html.erb +0 -1
  17. data/config/routes.rb +1 -1
  18. data/lib/generators/pgbus/add_presence_generator.rb +2 -1
  19. data/lib/generators/pgbus/add_presence_seats_generator.rb +45 -0
  20. data/lib/generators/pgbus/templates/add_presence.rb.erb +32 -7
  21. data/lib/generators/pgbus/templates/add_presence_seats.rb.erb +38 -0
  22. data/lib/pgbus/configuration/presence_settings.rb +103 -0
  23. data/lib/pgbus/configuration/stream_authorization.rb +36 -0
  24. data/lib/pgbus/configuration.rb +16 -1
  25. data/lib/pgbus/engine.rb +13 -7
  26. data/lib/pgbus/generators/migration_detector.rb +9 -1
  27. data/lib/pgbus/streams/envelope.rb +3 -2
  28. data/lib/pgbus/streams/presence.rb +317 -81
  29. data/lib/pgbus/version.rb +1 -1
  30. data/lib/pgbus/web/stream_app.rb +43 -14
  31. data/lib/pgbus/web/streamer/connection.rb +26 -9
  32. data/lib/pgbus/web/streamer/falcon_connection.rb +23 -9
  33. data/lib/pgbus/web/streamer/heartbeat.rb +9 -3
  34. data/lib/pgbus/web/streamer/instance.rb +25 -8
  35. data/lib/pgbus/web/streamer/peer_close_watcher.rb +183 -0
  36. data/lib/pgbus/web/streamer/presence_tracker.rb +155 -0
  37. data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +26 -39
  38. data/lib/pgbus.rb +2 -0
  39. metadata +15 -1
@@ -35,26 +35,230 @@ module Pgbus
35
35
  # stream pipeline, which means it's transactional, replayable, and
36
36
  # filterable just like any other broadcast.
37
37
  #
38
- # NOT included in this v1:
39
- # - Automatic connection-driven join/leave (the application must
40
- # call join/leave explicitly).
41
- # - Automatic stale-member sweeping (call Presence.sweep!(stream)
42
- # from a cron or after a heartbeat to expire idle members).
38
+ # Seats (issue #515). A table migrated with `pgbus:add_presence_seats`
39
+ # also keeps one row per SSE connection: `connection_id` (part of the
40
+ # key), `seat` (the client's per-tab id, sent as `?seat=`) and a
41
+ # `state` jsonb for ephemeral per-tab state. Connection-driven presence
42
+ # (`streams_presence_patterns`) writes those rows, so two tabs of one
43
+ # user are two rows and the user stays present until both are gone.
44
+ # Member-keyed calls (no `connection_id:`) keep working on their own
45
+ # row (connection_id IS NULL), exactly as before.
46
+ #
47
+ # presence.join(member_id: "7", connection_id: conn_id, seat: seat)
48
+ # presence.set_state(connection_id: conn_id, state: { selected: 3 })
49
+ # presence.leave(connection_id: conn_id)
50
+ #
51
+ # Seat rows are expired by the streamer heartbeat after their stream's
52
+ # ttl (`Pgbus.configuration.presence_ttl_for`) and every join, leave
53
+ # and expiry is reported through `streams_presence_on_change`.
54
+ #
55
+ # Capability probe for a library building on seats:
56
+ # Pgbus::Streams::Presence.instance_method(:join).parameters
57
+ # .include?([:key, :connection_id]) # the gem has seats
58
+ # Pgbus::Streams::Presence.seats_supported? # the table is migrated
59
+ #
60
+ # NOT included:
43
61
  # - Built-in DOM events on the <pgbus-stream-source> element
44
62
  # (the application's broadcast block decides the HTML).
63
+ # - Expiry of member-keyed rows (call #sweep! from a cron for those).
45
64
  class Presence
65
+ RECORD_COLUMNS = "member_id, metadata, joined_at, last_seen_at"
66
+ SEAT_RECORD_COLUMNS = "member_id, metadata, joined_at, last_seen_at, connection_id, seat, state"
67
+
68
+ # How long a "no seats" answer is trusted before the schema is probed
69
+ # again, so a table migrated under a running process is picked up.
70
+ SCHEMA_RECHECK_SECONDS = 30
71
+
72
+ @schema_mutex = Mutex.new
73
+ @seats_supported = nil
74
+ @probed_at = nil
75
+
76
+ class << self
77
+ # True when pgbus_presence_members has the seat columns (the
78
+ # add_presence_seats migration ran). A true answer is kept for the
79
+ # life of the process; a false one for SCHEMA_RECHECK_SECONDS.
80
+ def seats_supported?
81
+ return @seats_supported if schema_answer_fresh?
82
+
83
+ @schema_mutex.synchronize do
84
+ next @seats_supported if schema_answer_fresh?
85
+
86
+ rows = connection.exec_params(<<~SQL, [])
87
+ SELECT 1 AS present FROM information_schema.columns
88
+ WHERE table_name = 'pgbus_presence_members' AND column_name = 'connection_id'
89
+ AND table_schema = ANY (current_schemas(false))
90
+ SQL
91
+ @probed_at = monotonic_now
92
+ @seats_supported = !rows.to_a.empty?
93
+ end
94
+ end
95
+
96
+ # seats_supported?, but a cached "no seats" is probed again first.
97
+ # For the destructive member-keyed statements: on a table migrated
98
+ # under this process their SQL without `connection_id IS NULL`
99
+ # succeeds silently and deletes seat rows.
100
+ def seats_supported_now?
101
+ return true if seats_supported?
102
+
103
+ reset_schema_cache!
104
+ seats_supported?
105
+ end
106
+
107
+ def reset_schema_cache!
108
+ @schema_mutex.synchronize do
109
+ @probed_at = nil
110
+ @seats_supported = nil
111
+ end
112
+ end
113
+
114
+ # Deletes every seat row (connection_id IS NOT NULL) that has not
115
+ # been touched within its stream's ttl and returns the deleted
116
+ # records, each with its "stream_name". Member-keyed rows are left
117
+ # alone. `ttl_for` maps a stream name to seconds; `min_ttl` is the
118
+ # smallest ttl configured, used to narrow the candidates in SQL.
119
+ # The DELETE re-checks the cutoff (a touch between the two
120
+ # statements keeps the row) and claims each row atomically, so
121
+ # concurrent sweepers in other processes never report a row twice.
122
+ def expire_seats!(ttl_for:, min_ttl:)
123
+ return [] unless seats_supported?
124
+
125
+ candidates = connection.exec_params(<<~SQL, [min_ttl])
126
+ SELECT stream_name, connection_id FROM pgbus_presence_members
127
+ WHERE connection_id IS NOT NULL AND last_seen_at < NOW() - make_interval(secs => $1)
128
+ SQL
129
+ candidates.to_a.group_by { |row| row["stream_name"] }.flat_map do |stream_name, rows|
130
+ expire_stream_seats(stream_name, rows.map { |row| row["connection_id"] }, ttl_for.call(stream_name))
131
+ end
132
+ end
133
+
134
+ def connection
135
+ # Respect multi-database setups (connects_to). When
136
+ # Pgbus.configuration.connects_to is set, the pgbus tables
137
+ # live in a separate database accessed via Pgbus::BusRecord;
138
+ # the default path uses ActiveRecord::Base. Matches the
139
+ # canonical pattern in Pgbus::Process::QueueLock#connection
140
+ # and Pgbus::Configuration's connection probe.
141
+ unless defined?(::ActiveRecord::Base)
142
+ raise Pgbus::ConfigurationError,
143
+ "Pgbus::Streams::Presence requires ActiveRecord (no AR connection available)"
144
+ end
145
+
146
+ if Pgbus.configuration.connects_to
147
+ Pgbus::BusRecord.connection.raw_connection
148
+ else
149
+ ::ActiveRecord::Base.connection.raw_connection
150
+ end
151
+ end
152
+
153
+ # Reports a seat join/leave to streams_presence_on_change (issue #515).
154
+ def report_change(stream_name, type, record)
155
+ Pgbus.configuration.notify_presence_change(
156
+ stream_name,
157
+ { type: type, connection_id: record["connection_id"], seat: record["seat"], member_id: record["id"] }
158
+ )
159
+ end
160
+
161
+ # A Ruby Array of Strings as a Postgres text[] literal.
162
+ def text_array(values)
163
+ "{#{values.map { |v| %("#{v.to_s.gsub(/["\\]/) { |c| "\\#{c}" }}") }.join(",")}}"
164
+ end
165
+
166
+ def monotonic_now
167
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
168
+ end
169
+
170
+ private
171
+
172
+ # Reads each ivar once: a concurrent reset_schema_cache! may nil them.
173
+ def schema_answer_fresh?
174
+ supported = @seats_supported
175
+ probed_at = @probed_at
176
+ return false if supported.nil? || probed_at.nil?
177
+
178
+ supported || monotonic_now - probed_at < SCHEMA_RECHECK_SECONDS
179
+ end
180
+
181
+ def expire_stream_seats(stream_name, connection_ids, ttl)
182
+ rows = connection.exec_params(<<~SQL, [stream_name, text_array(connection_ids), ttl])
183
+ DELETE FROM pgbus_presence_members
184
+ WHERE stream_name = $1 AND connection_id = ANY($2::text[])
185
+ AND last_seen_at < NOW() - make_interval(secs => $3)
186
+ RETURNING #{SEAT_RECORD_COLUMNS}
187
+ SQL
188
+ rows.to_a.map do |row|
189
+ record = Record.build(row)
190
+ report_change(stream_name, :leave, record)
191
+ record.merge("stream_name" => stream_name)
192
+ end
193
+ end
194
+ end
195
+
196
+ # Turns a pgbus_presence_members row into the public record Hash.
197
+ # Seat columns appear only when the row has them.
198
+ module Record
199
+ def self.build(row)
200
+ record = {
201
+ "id" => row["member_id"],
202
+ "metadata" => parse_json(row["metadata"]),
203
+ "joined_at" => row["joined_at"],
204
+ "last_seen_at" => row["last_seen_at"]
205
+ }
206
+ return record unless row.key?("connection_id")
207
+
208
+ record.merge("connection_id" => row["connection_id"], "seat" => row["seat"],
209
+ "state" => parse_json(row["state"]))
210
+ end
211
+
212
+ def self.parse_json(value)
213
+ return value if value.is_a?(Hash)
214
+ return {} if value.nil? || value.empty?
215
+
216
+ JSON.parse(value)
217
+ rescue JSON::ParserError => e
218
+ # Resilience fallback: return an empty hash rather than
219
+ # crashing the presence member list on one corrupt row. Debug
220
+ # log so operators can still find the corruption without
221
+ # impacting production throughput (debug is off by default).
222
+ # Truncate the value so a huge metadata payload doesn't bloat
223
+ # the log line.
224
+ truncated = value.to_s[0, 200]
225
+ Pgbus.logger&.debug do
226
+ "[Pgbus::Streams::Presence] parse_metadata failed (#{e.class}: #{e.message}); raw: #{truncated.inspect}"
227
+ end
228
+ {}
229
+ end
230
+ end
231
+
46
232
  def initialize(stream)
47
233
  @stream = stream
48
234
  end
49
235
 
236
+ # True when the table carries seats (see .seats_supported?).
237
+ def seats?
238
+ self.class.seats_supported?
239
+ end
240
+
241
+ # True when the table carries seats, re-probing a cached "no".
242
+ def seats_now?
243
+ self.class.seats_supported_now?
244
+ end
245
+
50
246
  # Adds (or refreshes) a member on this stream. Idempotent: calling
51
247
  # join twice with the same member_id updates last_seen_at and
52
248
  # metadata without creating a duplicate row. Yields the member
53
249
  # hash to the optional block so the caller can render an
54
250
  # `<turbo-stream action="append">` to broadcast the join.
55
- def join(member_id:, metadata: {})
251
+ #
252
+ # With `connection_id:` (seat tables only) the row is the
253
+ # connection's: one per SSE connection, carrying the client's `seat`.
254
+ def join(member_id:, metadata: {}, connection_id: nil, seat: nil)
56
255
  member_id = member_id.to_s
57
- record = upsert_member(member_id, metadata)
256
+ record = if connection_id
257
+ upsert_seat(member_id, metadata, connection_id.to_s, seat&.to_s)
258
+ else
259
+ upsert_member(member_id, metadata)
260
+ end
261
+ self.class.report_change(@stream.name, :join, record) if connection_id
58
262
 
59
263
  if block_given?
60
264
  html = yield(record)
@@ -67,11 +271,19 @@ module Pgbus
67
271
  # Removes a member. Yields the (former) member hash to the
68
272
  # optional block so the caller can broadcast a `remove` action.
69
273
  # Returns the deleted record, or nil if the member wasn't present.
70
- def leave(member_id:)
71
- member_id = member_id.to_s
72
- record = delete_member(member_id)
274
+ # Pass `connection_id:` instead to remove one connection's seat row.
275
+ def leave(member_id: nil, connection_id: nil)
276
+ record = if connection_id && member_id.nil?
277
+ delete_seat(connection_id.to_s)
278
+ elsif member_id && connection_id.nil?
279
+ delete_member(member_id.to_s)
280
+ else
281
+ raise ArgumentError, "leave takes exactly one of member_id: or connection_id:"
282
+ end
73
283
  return nil unless record
74
284
 
285
+ self.class.report_change(@stream.name, :leave, record) if connection_id
286
+
75
287
  if block_given?
76
288
  html = yield(record)
77
289
  @stream.broadcast(html) if html.is_a?(String) && !html.empty?
@@ -84,32 +296,53 @@ module Pgbus
84
296
  # Used as a heartbeat mechanism: clients ping touch periodically
85
297
  # to stay in the member list, and a sweeper expires anyone who
86
298
  # hasn't pinged in N seconds.
87
- def touch(member_id:)
299
+ #
300
+ # With `connection_id:` (a String or an Array of them) it refreshes
301
+ # those connections' seat rows in one statement and returns the ids
302
+ # it found — a missing id means the row was expired while the
303
+ # connection was still alive, and the caller can re-join it.
304
+ def touch(member_id: nil, connection_id: nil)
305
+ raise ArgumentError, "touch takes exactly one of member_id: or connection_id:" unless member_id.nil? ^ connection_id.nil?
306
+
307
+ return touch_seats(Array(connection_id)) if connection_id
308
+
88
309
  member_id = member_id.to_s
89
310
  connection.exec_params(<<~SQL, [@stream.name, member_id])
90
311
  UPDATE pgbus_presence_members
91
312
  SET last_seen_at = NOW()
92
- WHERE stream_name = $1 AND member_id = $2
313
+ WHERE stream_name = $1 AND member_id = $2#{" AND connection_id IS NULL" if seats_now?}
93
314
  SQL
94
315
  end
95
316
 
317
+ # Merges `state` into one connection's seat row (keys not given keep
318
+ # their value). Returns the updated record, or nil when the
319
+ # connection has no row on this stream. Does not broadcast: the
320
+ # caller renders whatever its viewers should see.
321
+ def set_state(connection_id:, state:)
322
+ raise ArgumentError, "set_state takes a Hash (got #{state.class})" unless state.is_a?(Hash)
323
+
324
+ rows = connection.exec_params(<<~SQL, [@stream.name, connection_id.to_s, JSON.generate(state)])
325
+ UPDATE pgbus_presence_members
326
+ SET state = state || $3::jsonb, last_seen_at = NOW()
327
+ WHERE stream_name = $1 AND connection_id = $2
328
+ RETURNING #{SEAT_RECORD_COLUMNS}
329
+ SQL
330
+ row = rows.first
331
+ row && Record.build(row)
332
+ end
333
+
96
334
  # Returns the current list of members on this stream as an
97
- # array of hashes (id, metadata, joined_at, last_seen_at).
335
+ # array of hashes (id, metadata, joined_at, last_seen_at). On a seat
336
+ # table each row is one seat and also carries connection_id, seat
337
+ # and state; group by "id" for one entry per member.
98
338
  def members
99
339
  rows = connection.exec_params(<<~SQL, [@stream.name])
100
- SELECT member_id, metadata, joined_at, last_seen_at
340
+ SELECT #{seats? ? SEAT_RECORD_COLUMNS : RECORD_COLUMNS}
101
341
  FROM pgbus_presence_members
102
342
  WHERE stream_name = $1
103
343
  ORDER BY joined_at
104
344
  SQL
105
- rows.to_a.map do |row|
106
- {
107
- "id" => row["member_id"],
108
- "metadata" => parse_metadata(row["metadata"]),
109
- "joined_at" => row["joined_at"],
110
- "last_seen_at" => row["last_seen_at"]
111
- }
112
- end
345
+ rows.to_a.map { |row| Record.build(row) }
113
346
  end
114
347
 
115
348
  # Returns the count of current members. Faster than members.size
@@ -128,88 +361,91 @@ module Pgbus
128
361
  #
129
362
  # Atomically claims the deletion via DELETE RETURNING so multiple
130
363
  # workers running the sweep concurrently won't double-emit leave
131
- # events.
364
+ # events. Yields each expired record when a block is given.
132
365
  def sweep!(older_than:)
133
366
  rows = connection.exec_params(<<~SQL, [@stream.name, older_than])
134
367
  DELETE FROM pgbus_presence_members
135
- WHERE stream_name = $1 AND last_seen_at < $2
136
- RETURNING member_id, metadata, joined_at, last_seen_at
368
+ WHERE stream_name = $1 AND last_seen_at < $2#{" AND connection_id IS NULL" if seats_now?}
369
+ RETURNING #{RECORD_COLUMNS}
137
370
  SQL
138
- rows.to_a.size
371
+ rows = rows.to_a
372
+ rows.each { |row| yield Record.build(row) } if block_given?
373
+ rows.size
139
374
  end
140
375
 
141
376
  private
142
377
 
143
- def upsert_member(member_id, metadata)
378
+ # A process that last probed the pre-seat schema sends the old conflict
379
+ # target, which Postgres rejects once add_presence_seats has replaced
380
+ # the unique index with a partial one. Re-probe and retry once.
381
+ def upsert_member(member_id, metadata, retried: false)
382
+ upsert_member_once(member_id, metadata)
383
+ rescue PG::InvalidColumnReference
384
+ raise if retried
385
+
386
+ self.class.reset_schema_cache!
387
+ upsert_member(member_id, metadata, retried: true)
388
+ end
389
+
390
+ def upsert_member_once(member_id, metadata)
391
+ conflict = seats? ? "(stream_name, member_id) WHERE connection_id IS NULL" : "(stream_name, member_id)"
144
392
  rows = connection.exec_params(<<~SQL, [@stream.name, member_id, JSON.generate(metadata)])
145
393
  INSERT INTO pgbus_presence_members (stream_name, member_id, metadata, joined_at, last_seen_at)
146
394
  VALUES ($1, $2, $3::jsonb, NOW(), NOW())
147
- ON CONFLICT (stream_name, member_id)
395
+ ON CONFLICT #{conflict}
148
396
  DO UPDATE SET metadata = EXCLUDED.metadata, last_seen_at = NOW()
149
- RETURNING member_id, metadata, joined_at, last_seen_at
397
+ RETURNING #{RECORD_COLUMNS}
150
398
  SQL
151
- row = rows.first
152
- {
153
- "id" => row["member_id"],
154
- "metadata" => parse_metadata(row["metadata"]),
155
- "joined_at" => row["joined_at"],
156
- "last_seen_at" => row["last_seen_at"]
157
- }
399
+ Record.build(rows.first)
400
+ end
401
+
402
+ def upsert_seat(member_id, metadata, connection_id, seat)
403
+ rows = connection.exec_params(<<~SQL, [@stream.name, member_id, JSON.generate(metadata), connection_id, seat])
404
+ INSERT INTO pgbus_presence_members
405
+ (stream_name, member_id, metadata, connection_id, seat, joined_at, last_seen_at)
406
+ VALUES ($1, $2, $3::jsonb, $4, $5, NOW(), NOW())
407
+ ON CONFLICT (stream_name, connection_id) WHERE connection_id IS NOT NULL
408
+ DO UPDATE SET member_id = EXCLUDED.member_id, metadata = EXCLUDED.metadata,
409
+ seat = EXCLUDED.seat, last_seen_at = NOW()
410
+ RETURNING #{SEAT_RECORD_COLUMNS}
411
+ SQL
412
+ Record.build(rows.first)
158
413
  end
159
414
 
160
415
  def delete_member(member_id)
161
416
  rows = connection.exec_params(<<~SQL, [@stream.name, member_id])
162
417
  DELETE FROM pgbus_presence_members
163
- WHERE stream_name = $1 AND member_id = $2
164
- RETURNING member_id, metadata, joined_at, last_seen_at
418
+ WHERE stream_name = $1 AND member_id = $2#{" AND connection_id IS NULL" if seats_now?}
419
+ RETURNING #{RECORD_COLUMNS}
165
420
  SQL
166
421
  row = rows.first
167
- return nil unless row
168
-
169
- {
170
- "id" => row["member_id"],
171
- "metadata" => parse_metadata(row["metadata"]),
172
- "joined_at" => row["joined_at"],
173
- "last_seen_at" => row["last_seen_at"]
174
- }
175
- end
176
-
177
- def parse_metadata(value)
178
- return value if value.is_a?(Hash)
179
- return {} if value.nil? || value.empty?
180
-
181
- JSON.parse(value)
182
- rescue JSON::ParserError => e
183
- # Resilience fallback: return an empty hash rather than
184
- # crashing the presence member list on one corrupt row. Debug
185
- # log so operators can still find the corruption without
186
- # impacting production throughput (debug is off by default).
187
- # Truncate the value so a huge metadata payload doesn't bloat
188
- # the log line.
189
- truncated = value.to_s[0, 200]
190
- Pgbus.logger&.debug do
191
- "[Pgbus::Streams::Presence] parse_metadata failed (#{e.class}: #{e.message}); raw: #{truncated.inspect}"
192
- end
193
- {}
422
+ row && Record.build(row)
194
423
  end
195
424
 
196
- def connection
197
- # Respect multi-database setups (connects_to). When
198
- # Pgbus.configuration.connects_to is set, the pgbus tables
199
- # live in a separate database accessed via Pgbus::BusRecord;
200
- # the default path uses ActiveRecord::Base. Matches the
201
- # canonical pattern in Pgbus::Process::QueueLock#connection
202
- # and Pgbus::Configuration's connection probe.
203
- unless defined?(::ActiveRecord::Base)
204
- raise Pgbus::ConfigurationError,
205
- "Pgbus::Streams::Presence requires ActiveRecord (no AR connection available)"
206
- end
425
+ def delete_seat(connection_id)
426
+ rows = connection.exec_params(<<~SQL, [@stream.name, connection_id])
427
+ DELETE FROM pgbus_presence_members
428
+ WHERE stream_name = $1 AND connection_id = $2
429
+ RETURNING #{SEAT_RECORD_COLUMNS}
430
+ SQL
431
+ row = rows.first
432
+ row && Record.build(row)
433
+ end
207
434
 
208
- if Pgbus.configuration.connects_to
209
- Pgbus::BusRecord.connection.raw_connection
210
- else
211
- ::ActiveRecord::Base.connection.raw_connection
212
- end
435
+ def touch_seats(connection_ids)
436
+ return [] if connection_ids.empty?
437
+
438
+ rows = connection.exec_params(<<~SQL, [@stream.name, self.class.text_array(connection_ids)])
439
+ UPDATE pgbus_presence_members
440
+ SET last_seen_at = NOW()
441
+ WHERE stream_name = $1 AND connection_id = ANY($2::text[])
442
+ RETURNING connection_id
443
+ SQL
444
+ rows.to_a.map { |row| row["connection_id"] }
445
+ end
446
+
447
+ def connection
448
+ self.class.connection
213
449
  end
214
450
  end
215
451
  end
data/lib/pgbus/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.17.2"
4
+ VERSION = "0.18.0"
5
5
  end
@@ -16,13 +16,16 @@ module Pgbus
16
16
  # Call flow (happy path, Puma 6.1+ hijack):
17
17
  # 1. Parse the signed name from PATH_INFO
18
18
  # 2. Verify it via Pgbus::Streams::SignedName (raises → 404)
19
- # 3. Run the authorize hook (raises → 403)
19
+ # 3. Run the authorize hook: authorize:, else config.streams_authorize
20
+ # (falsy → 403)
20
21
  # 4. Parse the cursor from ?since= or Last-Event-ID
21
22
  # 5. Check streams_max_connections per worker (over → 503)
22
23
  # 6. Check rack.hijack? (missing → 501)
23
24
  # 7. Hijack, write the HTTP response line + SSE headers + opening
24
25
  # comment directly to the socket
25
- # 8. Build a Connection, hand to Streamer.current.register(...)
26
+ # 8. Build a Connection, hand to Streamer.current.register(...). The
27
+ # Dispatcher writes the pgbus:connected frame once the connection
28
+ # is registered and its presence seat joined (issue #521)
26
29
  # 9. Return [-1, {}, []] (Puma's async protocol)
27
30
  #
28
31
  # On errors, returns a normal [status, headers, body] response so
@@ -31,15 +34,22 @@ module Pgbus
31
34
  # full request lifecycle.
32
35
  class StreamApp
33
36
  PATH_PREFIX = "/pgbus/streams"
37
+ # The client's per-tab presence seat (?seat=, issue #515): an opaque id
38
+ # minted by <pgbus-stream-source presence>. Anything else is dropped.
39
+ SEAT_FORMAT = /\A[A-Za-z0-9_-]{1,64}\z/
34
40
  # EventSource reconnect delay handed to the streams_test_mode stub (24h).
35
41
  TEST_MODE_RETRY_MS = 86_400_000
36
42
  private_constant :PATH_PREFIX
37
43
 
44
+ # Used when neither authorize: nor config.streams_authorize is set.
45
+ ALLOW_ALL = ->(_env, _stream_name) { true }
46
+ private_constant :ALLOW_ALL
47
+
38
48
  def initialize(streamer: nil, config: nil, logger: nil, authorize: nil)
39
49
  @streamer_override = streamer
40
50
  @config_override = config
41
51
  @logger_override = logger
42
- @authorize = authorize || ->(_env, _stream_name) { true }
52
+ @authorize = authorize
43
53
  end
44
54
 
45
55
  def call(env)
@@ -52,7 +62,7 @@ module Pgbus
52
62
  stream_name = verify!(signed_name)
53
63
  return not_found("invalid signed stream name") if stream_name.nil?
54
64
 
55
- authorize_result = @authorize.call(env, stream_name)
65
+ authorize_result = authorize_hook.call(env, stream_name)
56
66
  return forbidden unless authorize_result
57
67
 
58
68
  # If authorize returned a non-boolean value (e.g. a User model),
@@ -64,14 +74,16 @@ module Pgbus
64
74
  cursor = parse_cursor(env, request)
65
75
  return bad_request("invalid cursor: #{cursor}") if cursor.is_a?(String)
66
76
 
77
+ seat = parse_seat(request)
78
+
67
79
  return test_mode_stub if config.streams_test_mode
68
80
 
69
81
  return over_capacity if streamer.registry.size >= config.streams_max_connections
70
82
 
71
83
  if config.streams_falcon_streaming_body
72
- streaming_body_response(stream_name: stream_name, since_id: cursor, context: context)
84
+ streaming_body_response(stream_name: stream_name, since_id: cursor, context: context, seat: seat)
73
85
  elsif env["rack.hijack?"]
74
- hijack_and_register(env, stream_name: stream_name, since_id: cursor, context: context)
86
+ hijack_and_register(env, stream_name: stream_name, since_id: cursor, context: context, seat: seat)
75
87
  else
76
88
  unsupported_server
77
89
  end
@@ -114,7 +126,12 @@ module Pgbus
114
126
  e.message
115
127
  end
116
128
 
117
- def hijack_and_register(env, stream_name:, since_id:, context: nil)
129
+ def parse_seat(request)
130
+ seat = request.params["seat"]
131
+ seat if seat.is_a?(String) && seat.ascii_only? && SEAT_FORMAT.match?(seat)
132
+ end
133
+
134
+ def hijack_and_register(env, stream_name:, since_id:, context: nil, seat: nil)
118
135
  # Rack hijack is a one-time transition per the Rack spec. Call
119
136
  # once, use the return value, and fall back to the side-effect
120
137
  # `rack.hijack_io` variable only if the return was nil (some
@@ -124,7 +141,7 @@ module Pgbus
124
141
  io = hijack_result || env["rack.hijack_io"]
125
142
 
126
143
  connection_id = SecureRandom.hex(8)
127
- write_headers(io, stream_name: stream_name, since_id: since_id, connection_id: connection_id)
144
+ write_headers(io, stream_name: stream_name, since_id: since_id)
128
145
 
129
146
  connection = Pgbus::Web::Streamer::Connection.new(
130
147
  id: connection_id,
@@ -133,14 +150,15 @@ module Pgbus
133
150
  since_id: since_id,
134
151
  writer: Pgbus::Web::Streamer::IoWriter,
135
152
  write_deadline_ms: config.streams_write_deadline_ms,
136
- context: context
153
+ context: context,
154
+ seat: seat
137
155
  )
138
156
  streamer.register(connection)
139
157
 
140
158
  [-1, {}, []]
141
159
  end
142
160
 
143
- def streaming_body_response(stream_name:, since_id:, context: nil)
161
+ def streaming_body_response(stream_name:, since_id:, context: nil, seat: nil)
144
162
  require "protocol/http/body/writable" unless defined?(::Protocol::HTTP::Body::Writable)
145
163
  require_relative "streamer/falcon_connection" unless defined?(Pgbus::Web::Streamer::FalconConnection)
146
164
 
@@ -151,7 +169,6 @@ module Pgbus
151
169
  body.write(Pgbus::Streams::Envelope.comment(
152
170
  "pgbus stream open since_id=#{since_id} stream=#{stream_name}"
153
171
  ))
154
- body.write(Pgbus::Streams::Envelope.connected(id: connection_id))
155
172
 
156
173
  connection = Pgbus::Web::Streamer::FalconConnection.new(
157
174
  id: connection_id,
@@ -159,7 +176,8 @@ module Pgbus
159
176
  body: body,
160
177
  since_id: since_id,
161
178
  write_deadline_ms: config.streams_write_deadline_ms,
162
- context: context
179
+ context: context,
180
+ seat: seat
163
181
  )
164
182
  streamer.register(connection)
165
183
 
@@ -174,11 +192,22 @@ module Pgbus
174
192
  }
175
193
  end
176
194
 
177
- def write_headers(io, stream_name:, since_id:, connection_id:)
195
+ def write_headers(io, stream_name:, since_id:)
178
196
  io.write(Pgbus::Streams::Envelope.http_response_headers)
179
197
  io.write(Pgbus::Streams::Envelope.retry_directive(2_000))
180
198
  io.write(Pgbus::Streams::Envelope.comment("pgbus stream open since_id=#{since_id} stream=#{stream_name}"))
181
- io.write(Pgbus::Streams::Envelope.connected(id: connection_id))
199
+ end
200
+
201
+ # An explicit authorize: wins; otherwise config.streams_authorize, read
202
+ # per request so the engine's mount (built when routes load) sees a hook
203
+ # set in an initializer that runs later (issue #522). Only nil means
204
+ # "unset": any other non-callable (false included) must reach #call and
205
+ # fail closed with a 500, never fall through to ALLOW_ALL.
206
+ def authorize_hook
207
+ return @authorize unless @authorize.nil?
208
+
209
+ hook = config.streams_authorize
210
+ hook.nil? ? ALLOW_ALL : hook
182
211
  end
183
212
 
184
213
  def streamer