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 +4 -4
- data/lib/supabase/auth/client.rb +4 -0
- data/lib/supabase/auth/errors.rb +10 -0
- data/lib/supabase/auth/helpers.rb +1 -1
- data/lib/supabase/realtime/channel.rb +99 -6
- data/lib/supabase/realtime/client.rb +134 -9
- data/lib/supabase/realtime/presence.rb +80 -76
- data/lib/supabase/realtime/push.rb +46 -4
- data/lib/supabase/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2589d2d48421b3667b8db24781d8f09d1d3919846e8c08512561c6b9fecd69b7
|
|
4
|
+
data.tar.gz: 227b035cb0da6d059a5c43886c8d31801b26ce88b16d3178357c166ceb8c1288
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: be23970a53045e60aa4ec5dc8de14731013ddbf08f38c1a0a43694337aefe0d380339c877b773a9e93cbfc4713334abe47da12f4b51948b48f1ea0a0c2355072
|
|
7
|
+
data.tar.gz: d533a8d6ad8ef5563ead20e71cf9dfeabeb0d7403a4032330b602135145c440d5c841482ca8c5dba934ed73adb6ccaafdd66c4945c58c30613d12c8051480632
|
data/lib/supabase/auth/client.rb
CHANGED
|
@@ -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,
|
data/lib/supabase/auth/errors.rb
CHANGED
|
@@ -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) { |
|
|
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
|
-
#
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
155
|
-
|
|
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
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
def sync_state(
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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] =
|
|
40
|
+
joins[key] = presences
|
|
45
41
|
end
|
|
46
42
|
end
|
|
47
43
|
|
|
48
|
-
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
joins
|
|
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
|
-
#
|
|
89
|
-
# about the per-key grouping.
|
|
59
|
+
# Flat list of every presence currently tracked.
|
|
90
60
|
def list
|
|
91
|
-
@state.values.
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
@
|
|
38
|
-
|
|
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
|
-
#
|
|
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
|
data/lib/supabase/version.rb
CHANGED