supabase-rb 2.0.0 → 3.0.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1640366f383a39fbba5df993fe27d204981f99672377fc6982403b4259f6cd81
4
- data.tar.gz: d332d7b5a8c3bd0f698263988a8c949c132dbbe51908007b7bf5321d8e53a22b
3
+ metadata.gz: 2589d2d48421b3667b8db24781d8f09d1d3919846e8c08512561c6b9fecd69b7
4
+ data.tar.gz: 227b035cb0da6d059a5c43886c8d31801b26ce88b16d3178357c166ceb8c1288
5
5
  SHA512:
6
- metadata.gz: 11f501920c972f3dde5fee01049df4f95fd2aaff1ff6bc2e29c093e8f4c61671900e9caab52e9b870fa86a430a05d0cdbfaf5c4e957e418a389397cfd4ec941f
7
- data.tar.gz: 582991118f05d8171764efa5788d97234c013f8f7eacc84eefe903959068f20a7d083eaf09ead84a4dc76d225a318c6d69e70b96654f97e0254d184a13bbec39
6
+ metadata.gz: be23970a53045e60aa4ec5dc8de14731013ddbf08f38c1a0a43694337aefe0d380339c877b773a9e93cbfc4713334abe47da12f4b51948b48f1ea0a0c2355072
7
+ data.tar.gz: d533a8d6ad8ef5563ead20e71cf9dfeabeb0d7403a4032330b602135145c440d5c841482ca8c5dba934ed73adb6ccaafdd66c4945c58c30613d12c8051480632
@@ -335,6 +335,8 @@ module Supabase
335
335
  session = response.session
336
336
  else
337
337
  user_response = get_user(access_token)
338
+ raise Errors::UserDoesntExist, access_token if user_response.nil?
339
+
338
340
  session = Types::Session.new(
339
341
  access_token: access_token,
340
342
  refresh_token: refresh_token,
@@ -1021,6 +1023,8 @@ module Supabase
1021
1023
  expires_at = time_now + expires_in
1022
1024
 
1023
1025
  user_response = get_user(access_token)
1026
+ raise Errors::UserDoesntExist, access_token if user_response.nil?
1027
+
1024
1028
  session = Types::Session.new(
1025
1029
  provider_token: provider_token,
1026
1030
  provider_refresh_token: provider_refresh_token,
@@ -112,6 +112,16 @@ module Supabase
112
112
  end
113
113
  end
114
114
 
115
+ # Raised when an access token references a user that no longer exists.
116
+ class UserDoesntExist < StandardError
117
+ attr_reader :access_token
118
+
119
+ def initialize(access_token)
120
+ super("User from access_token does not exist")
121
+ @access_token = access_token
122
+ end
123
+ end
124
+
115
125
  # Alias for AuthSessionMissing (matches Python's AuthSessionMissingError)
116
126
  AuthSessionMissingError = AuthSessionMissing
117
127
  # Alias for AuthWeakPassword (matches Python's AuthWeakPasswordError)
@@ -105,7 +105,7 @@ module Supabase
105
105
  response = exception.response
106
106
  status = response[:status]
107
107
 
108
- if [502, 503, 504].include?(status)
108
+ if [502, 503, 504, 520, 521, 522, 523, 524, 530].include?(status)
109
109
  return Errors::AuthRetryableError.new(exception.message, status: status)
110
110
  end
111
111
 
@@ -39,7 +39,7 @@ module Supabase
39
39
  @subscribe_callback = nil
40
40
 
41
41
  @join_push
42
- .receive(Types::AckStatus::OK) { |_| on_join_ok }
42
+ .receive(Types::AckStatus::OK) { |p| on_join_ok(p) }
43
43
  .receive(Types::AckStatus::ERROR) { |p| on_join_error(p) }
44
44
  .receive(Types::AckStatus::TIMEOUT) { |_| on_join_timeout }
45
45
  end
@@ -63,18 +63,42 @@ module Supabase
63
63
  @subscribe_callback = block
64
64
  @state = Types::ChannelStates::JOINING
65
65
 
66
+ inject_postgres_changes_bindings
66
67
  @join_push.instance_variable_set(:@ref, @socket&.next_ref)
67
68
  send_push(@join_push, register_pending: true)
68
69
  self
69
70
  end
70
71
 
71
- # Tear down the subscription with a phx_leave push.
72
+ # Re-issue the join push without resetting @joined_once. Used by the
73
+ # client after a socket reconnect to restore channel subscriptions.
74
+ def rejoin
75
+ return unless @joined_once
76
+
77
+ @state = Types::ChannelStates::JOINING
78
+ inject_postgres_changes_bindings
79
+ @join_push.instance_variable_set(:@ref, @socket&.next_ref)
80
+ @join_push.instance_variable_set(:@received_status, nil)
81
+ send_push(@join_push, register_pending: true)
82
+ self
83
+ end
84
+
85
+ # Tear down the subscription with a phx_leave push. State stays in LEAVING
86
+ # until the server acks (or errors / times out) — mirrors phoenix.js and
87
+ # supabase-py so a fast unsubscribe→resubscribe cycle doesn't race with the
88
+ # server's reply for the previous join.
72
89
  def unsubscribe
90
+ return self if closed?
91
+
73
92
  @state = Types::ChannelStates::LEAVING
74
93
  ref = @socket&.next_ref
75
94
  leave_push = Push.new(self, Types::ChannelEvents::LEAVE, {}, ref: ref)
76
- send_push(leave_push, register_pending: false)
77
- @state = Types::ChannelStates::CLOSED
95
+
96
+ leave_push
97
+ .receive(Types::AckStatus::OK) { |_| on_leave_ack }
98
+ .receive(Types::AckStatus::ERROR) { |_| on_leave_ack }
99
+ .receive(Types::AckStatus::TIMEOUT) { |_| on_leave_ack }
100
+
101
+ send_push(leave_push, register_pending: true)
78
102
  self
79
103
  end
80
104
 
@@ -187,6 +211,21 @@ module Supabase
187
211
  }
188
212
  end
189
213
 
214
+ # Mirrors phoenix.js / supabase-py: every registered on_postgres_changes
215
+ # listener is serialized into config.postgres_changes on the join payload
216
+ # so the server filters before sending, instead of shipping every change
217
+ # for the topic and forcing the client to drop most of them.
218
+ def inject_postgres_changes_bindings
219
+ config = (@join_push.payload["config"] ||= {})
220
+ config["postgres_changes"] = @postgres_changes_callbacks.map do |binding|
221
+ entry = { "event" => binding[:event] }
222
+ entry["schema"] = binding[:schema] if binding[:schema]
223
+ entry["table"] = binding[:table] if binding[:table]
224
+ entry["filter"] = binding[:filter] if binding[:filter]
225
+ entry
226
+ end
227
+ end
228
+
190
229
  def send_push(push, register_pending:)
191
230
  message = Message.new(
192
231
  event: push.event,
@@ -197,13 +236,23 @@ module Supabase
197
236
  )
198
237
 
199
238
  if can_send?
200
- @pending_pushes[push.ref] = push if register_pending && push.ref
239
+ if register_pending && push.ref
240
+ @pending_pushes[push.ref] = push
241
+ # Arm the timeout only once the push is actually on the wire — if it
242
+ # gets buffered (channel not yet joined) we leave it untimed until
243
+ # the buffer is flushed.
244
+ push.start_timeout
245
+ end
201
246
  @socket&.push(message)
202
247
  else
203
248
  @push_buffer << [push, register_pending]
204
249
  end
205
250
  end
206
251
 
252
+ def remove_pending(ref)
253
+ @pending_pushes.delete(ref)
254
+ end
255
+
207
256
  def can_send?
208
257
  # The join push flushes while joining; the leave push flushes while leaving.
209
258
  # Everything else (broadcasts, presence, custom pushes) only sends once joined.
@@ -248,7 +297,46 @@ module Supabase
248
297
  end
249
298
  end
250
299
 
251
- def on_join_ok
300
+ def on_join_ok(payload = nil)
301
+ # phoenix replies for postgres_changes echo back the bindings the server
302
+ # actually registered. Compare them index-wise with our local callbacks:
303
+ # if any client binding doesn't match the server's, the subscription is
304
+ # silently going to miss events — abort the subscription and surface a
305
+ # CHANNEL_ERROR so the caller can react instead of waiting forever for
306
+ # rows that will never arrive.
307
+ server_postgres_changes = payload.is_a?(Hash) ? payload["postgres_changes"] : nil
308
+
309
+ if server_postgres_changes && !@postgres_changes_callbacks.empty?
310
+ new_bindings = []
311
+ mismatch = false
312
+
313
+ @postgres_changes_callbacks.each_with_index do |binding, i|
314
+ server_binding = server_postgres_changes[i]
315
+
316
+ if server_binding &&
317
+ server_binding["event"] == binding[:event] &&
318
+ server_binding["schema"] == binding[:schema] &&
319
+ server_binding["table"] == binding[:table] &&
320
+ server_binding["filter"] == binding[:filter]
321
+ new_bindings << binding.merge(id: server_binding["id"])
322
+ else
323
+ mismatch = true
324
+ break
325
+ end
326
+ end
327
+
328
+ if mismatch
329
+ unsubscribe
330
+ err = Errors::RealtimeError.new(
331
+ "mismatch between server and client bindings for postgres changes"
332
+ )
333
+ @subscribe_callback&.call(Types::SubscribeStates::CHANNEL_ERROR, err)
334
+ return
335
+ end
336
+
337
+ @postgres_changes_callbacks = new_bindings
338
+ end
339
+
252
340
  @state = Types::ChannelStates::JOINED
253
341
  flush_push_buffer
254
342
  @subscribe_callback&.call(Types::SubscribeStates::SUBSCRIBED, nil)
@@ -269,6 +357,11 @@ module Supabase
269
357
  @push_buffer = []
270
358
  buffered.each { |push, register_pending| send_push(push, register_pending: register_pending) }
271
359
  end
360
+
361
+ def on_leave_ack
362
+ @state = Types::ChannelStates::CLOSED
363
+ @close_callbacks.each { |cb| cb.call({}) }
364
+ end
272
365
  end
273
366
  end
274
367
  end
@@ -28,13 +28,21 @@ module Supabase
28
28
  # channel.on_postgres_changes("*", schema: "public", table: "users") { |p| puts p }
29
29
  # channel.subscribe
30
30
  class Client
31
- attr_reader :url, :params, :access_token, :channels, :socket, :timeout
31
+ attr_reader :url, :params, :access_token, :channels, :socket, :timeout,
32
+ :heartbeat_interval, :auto_reconnect, :max_retries, :initial_backoff
32
33
 
33
34
  # @param url [String] WebSocket endpoint (ws:// or wss://). Plain http(s) are upgraded.
34
35
  # @param params [Hash] query-string params merged onto the URL (e.g. apikey/access_token)
35
36
  # @param socket [Socket, nil] inject your own transport (defaults to nil — caller wires it up)
36
37
  # @param timeout [Numeric] default per-push timeout (seconds)
37
- def initialize(url:, params: {}, socket: nil, timeout: Types::DEFAULT_TIMEOUT_SECONDS)
38
+ # @param heartbeat_interval [Numeric] seconds between automatic heartbeat pushes (0 disables)
39
+ # @param auto_reconnect [Boolean] reconnect on unexpected socket close
40
+ # @param max_retries [Integer] maximum reconnect attempts before giving up
41
+ # @param initial_backoff [Numeric] seconds of delay before the first reconnect attempt;
42
+ # doubles each attempt up to a 60s cap (matches supabase-py)
43
+ def initialize(url:, params: {}, socket: nil, timeout: Types::DEFAULT_TIMEOUT_SECONDS,
44
+ heartbeat_interval: Types::DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
45
+ auto_reconnect: true, max_retries: 5, initial_backoff: 1.0)
38
46
  @url = normalize_url(url, params)
39
47
  @params = params
40
48
  @access_token = params[:access_token] || params["access_token"]
@@ -43,6 +51,16 @@ module Supabase
43
51
  @timeout = timeout
44
52
  @ref = 0
45
53
 
54
+ @heartbeat_interval = heartbeat_interval
55
+ @auto_reconnect = auto_reconnect
56
+ @max_retries = max_retries
57
+ @initial_backoff = initial_backoff
58
+ @heartbeat_thread = nil
59
+ @reconnect_thread = nil
60
+ @intentionally_closed = false
61
+ @send_buffer = [] # frames queued while no socket / not connected
62
+ @send_buffer_mutex = Mutex.new
63
+
46
64
  attach_socket if @socket
47
65
  end
48
66
 
@@ -56,11 +74,15 @@ module Supabase
56
74
  def connect
57
75
  raise Errors::RealtimeError, "no socket attached — call #use_socket(socket) first" unless @socket
58
76
 
77
+ @intentionally_closed = false
59
78
  @socket.connect
60
79
  self
61
80
  end
62
81
 
63
82
  def disconnect
83
+ @intentionally_closed = true
84
+ stop_reconnect
85
+ stop_heartbeat
64
86
  @socket&.close
65
87
  @channels.each_value { |ch| ch.instance_variable_set(:@state, Types::ChannelStates::CLOSED) }
66
88
  self
@@ -135,24 +157,127 @@ module Supabase
135
157
  @ref.to_s
136
158
  end
137
159
 
138
- # Used by Channel#send_push.
160
+ # Used by Channel#send_push. If the socket isn't connected yet, the frame
161
+ # is buffered and flushed automatically when the socket opens — matches
162
+ # supabase-py's send_buffer so offline pushes aren't silently dropped.
139
163
  def push(message)
140
- return unless @socket
141
-
142
- @socket.send(JSON.generate(
164
+ frame = JSON.generate(
143
165
  "event" => message.event,
144
166
  "topic" => message.topic,
145
167
  "payload" => message.payload,
146
168
  "ref" => message.ref,
147
169
  "join_ref" => message.join_ref
148
- ))
170
+ )
171
+
172
+ if connected?
173
+ @socket.send(frame)
174
+ else
175
+ @send_buffer_mutex.synchronize { @send_buffer << frame }
176
+ end
149
177
  end
150
178
 
151
179
  private
152
180
 
153
181
  def attach_socket
154
- @socket.on_message do |raw|
155
- handle_inbound(raw)
182
+ @socket.on_message { |raw| handle_inbound(raw) }
183
+ @socket.on_open { handle_socket_open }
184
+ @socket.on_close { handle_socket_close }
185
+ end
186
+
187
+ def handle_socket_open
188
+ flush_send_buffer
189
+ start_heartbeat
190
+ rejoin_channels
191
+ end
192
+
193
+ def flush_send_buffer
194
+ buffered = @send_buffer_mutex.synchronize do
195
+ frames = @send_buffer
196
+ @send_buffer = []
197
+ frames
198
+ end
199
+ buffered.each do |frame|
200
+ begin
201
+ @socket.send(frame)
202
+ rescue StandardError
203
+ # Drop on send-error — re-queueing would risk a tight loop if the
204
+ # socket closes immediately. The push's own timeout will surface
205
+ # the failure to the caller.
206
+ end
207
+ end
208
+ end
209
+
210
+ def handle_socket_close
211
+ stop_heartbeat
212
+ return if @intentionally_closed || !@auto_reconnect
213
+
214
+ schedule_reconnect
215
+ end
216
+
217
+ def start_heartbeat
218
+ return if @heartbeat_interval.nil? || @heartbeat_interval <= 0
219
+ return if @heartbeat_thread&.alive?
220
+
221
+ interval = @heartbeat_interval
222
+ @heartbeat_thread = Thread.new do
223
+ Thread.current.report_on_exception = false
224
+ loop do
225
+ sleep interval
226
+ break unless connected?
227
+
228
+ begin
229
+ send_heartbeat
230
+ rescue StandardError
231
+ # Swallow — a transient send error shouldn't kill the heartbeat loop.
232
+ end
233
+ end
234
+ end
235
+ end
236
+
237
+ def stop_heartbeat
238
+ thread = @heartbeat_thread
239
+ @heartbeat_thread = nil
240
+ thread.kill if thread && thread != Thread.current
241
+ end
242
+
243
+ def schedule_reconnect
244
+ return if @reconnect_thread&.alive?
245
+
246
+ initial = @initial_backoff
247
+ max_tries = @max_retries
248
+
249
+ @reconnect_thread = Thread.new do
250
+ Thread.current.report_on_exception = false
251
+ retries = 0
252
+ while retries < max_tries
253
+ retries += 1
254
+ wait = [initial * (2**(retries - 1)), 60.0].min
255
+ sleep wait
256
+ break if @intentionally_closed
257
+
258
+ begin
259
+ @socket.connect
260
+ break # on_open will fire and restart heartbeat + rejoin channels
261
+ rescue StandardError
262
+ # Try again until max_retries is hit.
263
+ end
264
+ end
265
+ @reconnect_thread = nil
266
+ end
267
+ end
268
+
269
+ def stop_reconnect
270
+ thread = @reconnect_thread
271
+ @reconnect_thread = nil
272
+ thread.kill if thread && thread != Thread.current
273
+ end
274
+
275
+ def rejoin_channels
276
+ @channels.each_value do |channel|
277
+ next unless channel.instance_variable_get(:@joined_once)
278
+ next if channel.joining?
279
+
280
+ channel.rejoin
156
281
  end
157
282
  end
158
283
 
@@ -3,11 +3,11 @@
3
3
  module Supabase
4
4
  module Realtime
5
5
  # Tracks presence state for one channel and implements the Phoenix Presence
6
- # sync algorithm: presence_state replaces the local snapshot, presence_diff
7
- # applies joins/leaves on top of it.
8
- #
9
- # The algorithm mirrors phoenix.js's Presence.syncState / Presence.syncDiff so
10
- # callers porting from JS/Python see identical behavior.
6
+ # sync algorithm. Mirrors supabase-py's AsyncRealtimePresence: raw
7
+ # `{ key => { "metas" => [{ "phx_ref" => ..., ... }] } }` wire payloads are
8
+ # transformed to a flat `{ key => [{ "presence_ref" => ..., ... }, ...] }`
9
+ # shape before being stored or emitted, so listener callbacks receive
10
+ # `(key, current_presences, new_presences)` with `presence_ref` keys.
11
11
  class Presence
12
12
  attr_reader :state
13
13
 
@@ -18,77 +18,47 @@ module Supabase
18
18
  @on_leave_callbacks = []
19
19
  end
20
20
 
21
- # The first presence_state message after joining sends the full state. Any
22
- # local metas we already have for a key but the server doesn't are emitted
23
- # as leaves; anything new is emitted as a join.
24
- def sync_state(new_state)
25
- joins = {}
26
- leaves = {}
27
-
28
- @state.each do |key, presence|
29
- leaves[key] = presence unless new_state.key?(key)
30
- end
31
-
32
- new_state.each do |key, new_presence|
33
- current = @state[key]
34
- if current
35
- joined = []
36
- left = []
37
- current_refs = metas(current).map { |m| m["phx_ref"] }
38
- new_refs = metas(new_presence).map { |m| m["phx_ref"] }
39
- joined = metas(new_presence).reject { |m| current_refs.include?(m["phx_ref"]) }
40
- left = metas(current).reject { |m| new_refs.include?(m["phx_ref"]) }
41
- joins[key] = { "metas" => joined } unless joined.empty?
42
- leaves[key] = { "metas" => left } unless left.empty?
21
+ # First snapshot after joining: diff against the (possibly empty) local
22
+ # state and apply the joins/leaves through the same code path as
23
+ # `sync_diff`.
24
+ def sync_state(raw_state)
25
+ new_state = self.class.transform_state(raw_state)
26
+ joins = {}
27
+ leaves = @state.reject { |k, _| new_state.key?(k) }
28
+
29
+ new_state.each do |key, presences|
30
+ current = @state[key] || []
31
+
32
+ if current.any?
33
+ current_refs = current.map { |p| p["presence_ref"] }
34
+ new_refs = presences.map { |p| p["presence_ref"] }
35
+ joined_presences = presences.reject { |p| current_refs.include?(p["presence_ref"]) }
36
+ left_presences = current.reject { |p| new_refs.include?(p["presence_ref"]) }
37
+ joins[key] = joined_presences if joined_presences.any?
38
+ leaves[key] = left_presences if left_presences.any?
43
39
  else
44
- joins[key] = new_presence
40
+ joins[key] = presences
45
41
  end
46
42
  end
47
43
 
48
- @state = deep_copy(new_state)
49
- emit_joins(joins)
50
- emit_leaves(leaves)
44
+ sync_diff_internal(joins, leaves)
51
45
  @on_sync_callbacks.each(&:call)
52
46
  @state
53
47
  end
54
48
 
55
- # Subsequent presence_diff messages carry only joins/leaves to apply.
56
- def sync_diff(diff)
57
- joins = diff["joins"] || {}
58
- leaves = diff["leaves"] || {}
59
-
60
- joins.each do |key, presence|
61
- if @state[key]
62
- existing_refs = metas(@state[key]).map { |m| m["phx_ref"] }
63
- new_metas = metas(presence).reject { |m| existing_refs.include?(m["phx_ref"]) }
64
- @state[key] = { "metas" => metas(@state[key]) + new_metas }
65
- else
66
- @state[key] = presence
67
- end
68
- end
69
-
70
- leaves.each do |key, presence|
71
- next unless @state[key]
72
-
73
- leaving_refs = metas(presence).map { |m| m["phx_ref"] }
74
- remaining = metas(@state[key]).reject { |m| leaving_refs.include?(m["phx_ref"]) }
75
- if remaining.empty?
76
- @state.delete(key)
77
- else
78
- @state[key] = { "metas" => remaining }
79
- end
80
- end
81
-
82
- emit_joins(joins)
83
- emit_leaves(leaves)
49
+ # Subsequent presence_diff messages: apply joins/leaves to the local state.
50
+ # Raw input is transformed before being applied.
51
+ def sync_diff(raw_diff)
52
+ joins = self.class.transform_state(raw_diff["joins"] || {})
53
+ leaves = self.class.transform_state(raw_diff["leaves"] || {})
54
+ sync_diff_internal(joins, leaves)
84
55
  @on_sync_callbacks.each(&:call)
85
56
  @state
86
57
  end
87
58
 
88
- # List every meta currently tracked, flat. Useful when callers don't care
89
- # about the per-key grouping.
59
+ # Flat list of every presence currently tracked.
90
60
  def list
91
- @state.values.flat_map { |presence| metas(presence) }
61
+ @state.values.flatten
92
62
  end
93
63
 
94
64
  def on_sync(&block)
@@ -110,26 +80,60 @@ module Supabase
110
80
  [@on_sync_callbacks, @on_join_callbacks, @on_leave_callbacks].any? { |list| !list.empty? }
111
81
  end
112
82
 
113
- private
114
-
115
- def metas(presence)
116
- Array(presence && presence["metas"])
83
+ # Convert raw Phoenix wire format `{ key => { "metas" => [{phx_ref, ...}] } }`
84
+ # to flat `{ key => [{presence_ref, ...}, ...] }`. Idempotent on already
85
+ # transformed input.
86
+ def self.transform_state(state)
87
+ new_state = {}
88
+ (state || {}).each do |key, presences|
89
+ new_state[key] = if presences.is_a?(Hash) && presences.key?("metas")
90
+ presences["metas"].map { |meta| transform_meta(meta) }
91
+ else
92
+ Array(presences).map { |meta| transform_meta(meta) }
93
+ end
94
+ end
95
+ new_state
117
96
  end
118
97
 
119
- def emit_joins(joins)
120
- joins.each do |key, presence|
121
- @on_join_callbacks.each { |cb| cb.call(key, presence) }
98
+ def self.transform_meta(meta)
99
+ meta = meta.dup
100
+ meta.delete("phx_ref_prev")
101
+ if meta.key?("phx_ref")
102
+ ref = meta.delete("phx_ref")
103
+ { "presence_ref" => ref }.merge(meta)
104
+ else
105
+ meta
122
106
  end
123
107
  end
124
108
 
125
- def emit_leaves(leaves)
126
- leaves.each do |key, presence|
127
- @on_leave_callbacks.each { |cb| cb.call(key, presence) }
109
+ private
110
+
111
+ def sync_diff_internal(joins, leaves)
112
+ joins.each do |key, new_presences|
113
+ current_presences = @state[key] || []
114
+ @state[key] = new_presences
115
+
116
+ if current_presences.any?
117
+ joined_refs = new_presences.map { |p| p["presence_ref"] }
118
+ keep_from_current = current_presences.reject { |p| joined_refs.include?(p["presence_ref"]) }
119
+ @state[key] = keep_from_current + @state[key]
120
+ end
121
+
122
+ @on_join_callbacks.each { |cb| cb.call(key, current_presences, new_presences) }
128
123
  end
129
- end
130
124
 
131
- def deep_copy(obj)
132
- Marshal.load(Marshal.dump(obj))
125
+ leaves.each do |key, left_presences|
126
+ current_presences = @state[key] || []
127
+ next if current_presences.empty?
128
+
129
+ remove_refs = left_presences.map { |p| p["presence_ref"] }
130
+ remaining = current_presences.reject { |p| remove_refs.include?(p["presence_ref"]) }
131
+ @state[key] = remaining
132
+
133
+ @on_leave_callbacks.each { |cb| cb.call(key, remaining, left_presences) }
134
+
135
+ @state.delete(key) if remaining.empty?
136
+ end
133
137
  end
134
138
  end
135
139
  end
@@ -9,17 +9,24 @@ module Supabase
9
9
  #
10
10
  # `receive(:ok / :error / :timeout) { |payload| ... }` registers handlers
11
11
  # before the push is sent, mirroring phoenix.js's Push API.
12
+ #
13
+ # Pushes can be given a timeout via `start_timeout(seconds)`; if no reply is
14
+ # received within that window the push resolves with AckStatus::TIMEOUT and
15
+ # is removed from the channel's pending_pushes registry.
12
16
  class Push
13
17
  attr_reader :ref, :event, :payload, :received_status
14
18
 
15
- def initialize(channel, event, payload = {}, ref: nil)
19
+ def initialize(channel, event, payload = {}, ref: nil, timeout: Types::DEFAULT_TIMEOUT_SECONDS)
16
20
  @channel = channel
17
21
  @event = event
18
22
  @payload = payload
19
23
  @ref = ref
24
+ @timeout = timeout
20
25
  @handlers = Hash.new { |h, k| h[k] = [] }
21
26
  @received_status = nil
22
27
  @received_payload = nil
28
+ @timeout_thread = nil
29
+ @mutex = Mutex.new
23
30
  end
24
31
 
25
32
  def receive(status, &block)
@@ -34,14 +41,49 @@ module Supabase
34
41
 
35
42
  # Called by the Channel when a phx_reply with matching ref arrives.
36
43
  def resolve(status:, payload:)
37
- @received_status = status
38
- @received_payload = payload
44
+ @mutex.synchronize do
45
+ # Idempotent: a late timeout firing after a real ack must not fire
46
+ # callbacks twice. First resolution wins.
47
+ return if @received_status
48
+
49
+ @received_status = status
50
+ @received_payload = payload
51
+ end
52
+ cancel_timeout
39
53
  @handlers[status].each { |h| h.call(payload) }
40
54
  end
41
55
 
42
- # Called by the Channel if no reply arrives within the timeout window.
56
+ # Schedule a TIMEOUT resolution if no reply arrives within `seconds`.
57
+ # Safe to call multiple times — only the first call schedules.
58
+ def start_timeout(seconds = @timeout)
59
+ @mutex.synchronize do
60
+ return if @timeout_thread
61
+ return if @received_status
62
+
63
+ @timeout_thread = Thread.new do
64
+ sleep(seconds)
65
+ time_out
66
+ end
67
+ end
68
+ self
69
+ end
70
+
71
+ # Called when no reply arrives in time, or as an explicit forced-timeout
72
+ # entry point.
43
73
  def time_out
44
74
  resolve(status: Types::AckStatus::TIMEOUT, payload: {})
75
+ # Pending registry lives on the channel — clean up so a late reply
76
+ # doesn't reach a push we've already given up on.
77
+ @channel.send(:remove_pending, @ref) if @channel.respond_to?(:remove_pending, true) && @ref
78
+ end
79
+
80
+ # Cancel the pending timeout (no-op if not started or already resolved).
81
+ def cancel_timeout
82
+ @mutex.synchronize do
83
+ thread = @timeout_thread
84
+ @timeout_thread = nil
85
+ thread&.kill if thread && thread != Thread.current
86
+ end
45
87
  end
46
88
  end
47
89
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Supabase
4
- VERSION = "2.0.0"
4
+ VERSION = "3.0.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: supabase-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 3.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Supabase