supabase-rb 3.2.0 → 3.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/LICENSE +21 -0
- data/lib/supabase/auth/api.rb +14 -0
- data/lib/supabase/auth/async/admin_api.rb +1 -0
- data/lib/supabase/auth/async/api.rb +1 -0
- data/lib/supabase/auth/client.rb +14 -2
- data/lib/supabase/auth/helpers.rb +2 -0
- data/lib/supabase/client.rb +61 -7
- data/lib/supabase/functions/async/client.rb +1 -0
- data/lib/supabase/functions/client.rb +15 -2
- data/lib/supabase/postgrest/async/client.rb +1 -0
- data/lib/supabase/postgrest/client.rb +4 -0
- data/lib/supabase/realtime/README.md +17 -12
- data/lib/supabase/realtime/channel.rb +146 -42
- data/lib/supabase/realtime/client.rb +107 -26
- data/lib/supabase/realtime/sockets/async_websocket.rb +12 -4
- data/lib/supabase/storage/analytics.rb +5 -1
- data/lib/supabase/storage/file_api.rb +8 -2
- data/lib/supabase/storage/vectors.rb +6 -1
- data/lib/supabase/version.rb +1 -1
- metadata +35 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '0168cd0cbeebbe6a2e1132d54ba1592b6a1f1f43d0c3f7d9374b52d16394f0a0'
|
|
4
|
+
data.tar.gz: 92b7a6b274189d5036f4975a7b1866510b6b09b6be0ea1d0360dd45d156d913b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b426ffddbbc4c9f86ee32453a4b58d478c7fc7643b418205662901cb759f7603161cb8e96f536aa947214a5dbb8e83408f164dd3add2354bdf204e68b2aae46d
|
|
7
|
+
data.tar.gz: 533f4f7a15bc5a1571f7554d3e7e54737f7aee788c07a6d81cad08f6ab2d4e39f7a6404fe2657cae679977ed9136d189a260b6936e893b564f55d808d5d03991
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2020 Supabase
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/lib/supabase/auth/api.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "faraday"
|
|
4
|
+
require "faraday/follow_redirects"
|
|
4
5
|
require "json"
|
|
5
6
|
|
|
6
7
|
module Supabase
|
|
@@ -59,6 +60,15 @@ module Supabase
|
|
|
59
60
|
result = no_resolve_json ? response : parse_response(response)
|
|
60
61
|
|
|
61
62
|
xform ? xform.call(result) : result
|
|
63
|
+
rescue Errors::AuthError
|
|
64
|
+
# A domain error raised inside the request — typically by an `xform`
|
|
65
|
+
# (e.g. JWKS parsing raising AuthInvalidJwtError) or response parsing —
|
|
66
|
+
# is already the correct exception. Re-raise it unchanged. Without this
|
|
67
|
+
# clause the blanket `rescue StandardError` below funnels it through
|
|
68
|
+
# handle_exception, which masks every non-Faraday error as
|
|
69
|
+
# AuthRetryableError(status: 0) — so callers of get_claims would see a
|
|
70
|
+
# spurious "retryable" error instead of the real AuthInvalidJwtError.
|
|
71
|
+
raise
|
|
62
72
|
rescue Faraday::Error => e
|
|
63
73
|
raise Helpers.handle_exception(e)
|
|
64
74
|
rescue StandardError => e
|
|
@@ -99,6 +109,10 @@ module Supabase
|
|
|
99
109
|
|
|
100
110
|
def build_connection
|
|
101
111
|
Faraday.new(url: @url, ssl: { verify: @verify }, proxy: @proxy) do |f|
|
|
112
|
+
# Follow 3xx (httpx `follow_redirects=True` in supabase-py) before
|
|
113
|
+
# raise_error inspects the status, so a redirect isn't turned into an
|
|
114
|
+
# error.
|
|
115
|
+
f.response :follow_redirects
|
|
102
116
|
f.response :raise_error
|
|
103
117
|
if @timeout
|
|
104
118
|
f.options.timeout = @timeout
|
data/lib/supabase/auth/client.rb
CHANGED
|
@@ -13,6 +13,7 @@ module Supabase
|
|
|
13
13
|
STORAGE_KEY = "supabase.auth.token"
|
|
14
14
|
EXPIRY_MARGIN = 10
|
|
15
15
|
JWKS_TTL = 600 # 10 minutes
|
|
16
|
+
|
|
16
17
|
# Explicit asymmetric algorithm-to-digest mapping (reference table).
|
|
17
18
|
ALG_TO_DIGEST = {
|
|
18
19
|
"RS256" => "SHA256", "RS384" => "SHA384", "RS512" => "SHA512",
|
|
@@ -712,12 +713,23 @@ module Supabase
|
|
|
712
713
|
# same message py would raise.
|
|
713
714
|
raise Errors::AuthInvalidJwtError, "Algorithm not supported" unless SUPPORTED_ALGORITHMS.include?(header["alg"])
|
|
714
715
|
|
|
715
|
-
# Asymmetric JWT
|
|
716
|
+
# Asymmetric JWT — signature verification ONLY, matching py exactly:
|
|
717
|
+
# py calls algorithm.verify() (gotrue_client.py:1272-1282) after the
|
|
718
|
+
# manual validate_exp above, so no claim validation (exp/nbf) happens
|
|
719
|
+
# here — the jwt gem's defaults are explicitly switched off.
|
|
716
720
|
jwk_data = _fetch_jwks(header["kid"], jwks || { "keys" => [] })
|
|
717
721
|
jwk_set = JWT::JWK::Set.new({ "keys" => [jwk_data] })
|
|
718
722
|
|
|
719
723
|
begin
|
|
720
|
-
JWT.decode(
|
|
724
|
+
JWT.decode(
|
|
725
|
+
token, nil, true,
|
|
726
|
+
{
|
|
727
|
+
algorithms: [header["alg"]],
|
|
728
|
+
jwks: jwk_set,
|
|
729
|
+
verify_expiration: false,
|
|
730
|
+
verify_not_before: false
|
|
731
|
+
}
|
|
732
|
+
)
|
|
721
733
|
rescue JWT::DecodeError => e
|
|
722
734
|
raise Errors::AuthInvalidJwtError, "Invalid JWT signature: #{e.message}"
|
|
723
735
|
end
|
|
@@ -90,6 +90,8 @@ module Supabase
|
|
|
90
90
|
/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i.match?(value)
|
|
91
91
|
end
|
|
92
92
|
|
|
93
|
+
# Mirrors py `validate_exp` (helpers.py:286-292): no clock-skew leeway —
|
|
94
|
+
# a token is rejected the moment `exp <= now`.
|
|
93
95
|
def validate_exp(exp)
|
|
94
96
|
raise Errors::AuthInvalidJwtError, "JWT has no expiration time" if exp.nil? || exp == 0
|
|
95
97
|
|
data/lib/supabase/client.rb
CHANGED
|
@@ -41,8 +41,9 @@ module Supabase
|
|
|
41
41
|
if options.is_a?(Supabase::ClientOptions)
|
|
42
42
|
configured_auth = options.headers["Authorization"] || options.headers[:Authorization]
|
|
43
43
|
elsif options.is_a?(Hash)
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
configured_headers = options[:global]&.dig(:headers) || options.dig("global", "headers") ||
|
|
45
|
+
options[:headers] || options["headers"] || {}
|
|
46
|
+
configured_auth = configured_headers["Authorization"] || configured_headers[:Authorization]
|
|
46
47
|
end
|
|
47
48
|
|
|
48
49
|
client = new(supabase_url: supabase_url, supabase_key: supabase_key,
|
|
@@ -77,9 +78,8 @@ module Supabase
|
|
|
77
78
|
# is kept as a raw Hash so existing callers don't break — anything
|
|
78
79
|
# else is canonicalized into a ClientOptions struct so the per-sub-
|
|
79
80
|
# client kwargs derivation has one code path.
|
|
80
|
-
legacy_hash_shape =
|
|
81
|
-
|
|
82
|
-
options.keys.any? { |k| %i[auth postgrest storage functions realtime global].include?(k.to_sym) }
|
|
81
|
+
legacy_hash_shape = options.is_a?(Hash) && legacy_options_hash?(options)
|
|
82
|
+
warn_stray_legacy_keys(options) if legacy_hash_shape
|
|
83
83
|
|
|
84
84
|
@options =
|
|
85
85
|
if options.is_a?(Hash) && !legacy_hash_shape
|
|
@@ -109,6 +109,14 @@ module Supabase
|
|
|
109
109
|
"apikey" => @supabase_key,
|
|
110
110
|
"Authorization" => "Bearer #{@supabase_key}"
|
|
111
111
|
}.merge(configured_headers || {})
|
|
112
|
+
|
|
113
|
+
# Current access token used to authorize the data-plane sub-clients
|
|
114
|
+
# (postgrest/storage/functions) and the realtime socket. Starts as the
|
|
115
|
+
# anon key and is rotated by #apply_auth on sign-in / token refresh. Held
|
|
116
|
+
# explicitly (rather than re-derived from @headers) so that a realtime
|
|
117
|
+
# client built LAZILY after a sign-in still picks up the session token
|
|
118
|
+
# instead of the anon key — see #realtime.
|
|
119
|
+
@access_token = @supabase_key
|
|
112
120
|
end
|
|
113
121
|
|
|
114
122
|
def async?
|
|
@@ -143,9 +151,13 @@ module Supabase
|
|
|
143
151
|
end
|
|
144
152
|
|
|
145
153
|
def realtime
|
|
154
|
+
# Use the current access token (@access_token), not the anon key: if the
|
|
155
|
+
# caller signed in before this lazy accessor first ran, apply_auth could
|
|
156
|
+
# not push the token to a not-yet-built realtime client, so we must seed
|
|
157
|
+
# the join auth from the rotated token here. apikey stays the anon key.
|
|
146
158
|
@realtime ||= Realtime::Client.new(
|
|
147
159
|
url: realtime_url,
|
|
148
|
-
params: { "apikey" => @supabase_key, "access_token" => @
|
|
160
|
+
params: { "apikey" => @supabase_key, "access_token" => @access_token },
|
|
149
161
|
**sub_options(:realtime)
|
|
150
162
|
)
|
|
151
163
|
end
|
|
@@ -226,6 +238,47 @@ module Supabase
|
|
|
226
238
|
|
|
227
239
|
private
|
|
228
240
|
|
|
241
|
+
# Keys that only occur in the legacy nested options shape — none of them
|
|
242
|
+
# is a ClientOptions field, so their presence is an unambiguous marker.
|
|
243
|
+
# `:storage` and `:realtime` are deliberately NOT in this list: both are
|
|
244
|
+
# also ClientOptions fields and need value-based disambiguation below.
|
|
245
|
+
LEGACY_ONLY_OPTION_KEYS = %i[auth postgrest functions global].freeze
|
|
246
|
+
# Every key the legacy nested shape consumes; anything else passed
|
|
247
|
+
# alongside one of these is silently invisible to the sub-clients.
|
|
248
|
+
LEGACY_OPTION_KEYS = (LEGACY_ONLY_OPTION_KEYS + %i[storage realtime]).freeze
|
|
249
|
+
private_constant :LEGACY_ONLY_OPTION_KEYS, :LEGACY_OPTION_KEYS
|
|
250
|
+
|
|
251
|
+
def legacy_options_hash?(options)
|
|
252
|
+
return true if options.keys.any? { |k| LEGACY_ONLY_OPTION_KEYS.include?(k.to_sym) }
|
|
253
|
+
|
|
254
|
+
# `:storage` exists in both shapes. A Hash can only be the legacy
|
|
255
|
+
# per-sub-client kwargs — the ClientOptions field holds a session
|
|
256
|
+
# storage *object* (get_item/set_item duck type), never a Hash.
|
|
257
|
+
#
|
|
258
|
+
# `:realtime` is a kwargs Hash in both shapes and both code paths hand
|
|
259
|
+
# it to Realtime::Client unchanged, so on its own it is not a legacy
|
|
260
|
+
# marker — routing it through ClientOptions keeps sibling fields like
|
|
261
|
+
# `:schema` from being silently dropped.
|
|
262
|
+
option_value(options, :storage).is_a?(Hash)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def option_value(options, key)
|
|
266
|
+
options.key?(key) ? options[key] : options[key.to_s]
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# The legacy shape only routes its known nested keys; flat ClientOptions
|
|
270
|
+
# fields mixed in (e.g. `{ schema: "x", auth: {...} }`) never reach any
|
|
271
|
+
# sub-client. Losing them silently was the original failure mode of the
|
|
272
|
+
# shape detector, so make the remaining ambiguous case loud.
|
|
273
|
+
def warn_stray_legacy_keys(options)
|
|
274
|
+
stray = options.keys.map(&:to_sym) - LEGACY_OPTION_KEYS
|
|
275
|
+
return if stray.empty?
|
|
276
|
+
|
|
277
|
+
warn "Supabase::Client: options #{stray.inspect} are ignored when combined with the " \
|
|
278
|
+
"legacy nested options shape (#{LEGACY_OPTION_KEYS.inspect} keys). Pass a flat " \
|
|
279
|
+
"ClientOptions-style hash or a Supabase::ClientOptions instance to use them."
|
|
280
|
+
end
|
|
281
|
+
|
|
229
282
|
# Single internal path shared by the public `#set_auth` and the
|
|
230
283
|
# `on_auth_state_change` listener installed on `#auth`. Refreshes the
|
|
231
284
|
# Authorization header used by every non-auth sub-client and resets their
|
|
@@ -238,7 +291,8 @@ module Supabase
|
|
|
238
291
|
# waiting for every joined channel's `Socket#send` to drain — see
|
|
239
292
|
# spec/async/apply_auth_non_blocking_spec.rb (US-047 / US-048).
|
|
240
293
|
def apply_auth(token)
|
|
241
|
-
@
|
|
294
|
+
@access_token = token || @supabase_key
|
|
295
|
+
@headers["Authorization"] = "Bearer #{@access_token}"
|
|
242
296
|
@storage = @functions = @postgrest = nil
|
|
243
297
|
dispatch_realtime { @realtime&.set_auth(token) }
|
|
244
298
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "faraday"
|
|
4
|
+
require "faraday/follow_redirects"
|
|
4
5
|
require "json"
|
|
5
6
|
require "uri"
|
|
6
7
|
|
|
@@ -134,6 +135,7 @@ module Supabase
|
|
|
134
135
|
|
|
135
136
|
def build_session
|
|
136
137
|
Faraday.new(url: @base_url, ssl: { verify: @verify }, proxy: @proxy) do |f|
|
|
138
|
+
f.response :follow_redirects
|
|
137
139
|
f.options.timeout = @timeout
|
|
138
140
|
f.options.open_timeout = @timeout
|
|
139
141
|
f.adapter Faraday.default_adapter
|
|
@@ -170,7 +172,13 @@ module Supabase
|
|
|
170
172
|
def raise_for_relay!(response)
|
|
171
173
|
# The relay layer signals its own errors via this response header (set to
|
|
172
174
|
# "true"). The function itself doesn't set this — only the relay.
|
|
173
|
-
|
|
175
|
+
#
|
|
176
|
+
# DIVERGES FROM PY (intentional): supabase-py reads `x-relay-header`,
|
|
177
|
+
# which is a long-standing bug — the actual relay error header is
|
|
178
|
+
# `x-relay-error` (see @supabase/functions-js: `headers.get('x-relay-error')`).
|
|
179
|
+
# We follow supabase-js (the canonical client) so relay errors are
|
|
180
|
+
# detected against a real Supabase deployment.
|
|
181
|
+
relay = response.headers["x-relay-error"] || response.headers["X-Relay-Error"]
|
|
174
182
|
return unless relay == "true"
|
|
175
183
|
|
|
176
184
|
parsed = parse_json_safe(response.body) || {}
|
|
@@ -193,7 +201,12 @@ module Supabase
|
|
|
193
201
|
when "json"
|
|
194
202
|
return body if body.empty?
|
|
195
203
|
|
|
196
|
-
|
|
204
|
+
# The caller explicitly asked for JSON, so a body that doesn't parse
|
|
205
|
+
# is a contract violation and must surface — not be silently handed
|
|
206
|
+
# back as a raw String (which the old `parse_json_safe(body) || body`
|
|
207
|
+
# did, leaving callers to discover the wrong type at runtime). Mirrors
|
|
208
|
+
# supabase-py's `response.json()`, which raises on invalid JSON.
|
|
209
|
+
JSON.parse(body)
|
|
197
210
|
when "binary"
|
|
198
211
|
# Byte-for-byte copy with BINARY (ASCII-8BIT) encoding. Faraday may
|
|
199
212
|
# hand us the body tagged as UTF-8 even when it's raw bytes; force
|
|
@@ -39,6 +39,7 @@ module Supabase
|
|
|
39
39
|
Faraday.new(url: @base_url, ssl: { verify: @verify }, proxy: @proxy) do |f|
|
|
40
40
|
f.request :url_encoded
|
|
41
41
|
f.options.params_encoder = Faraday::FlatParamsEncoder
|
|
42
|
+
f.response :follow_redirects
|
|
42
43
|
if @timeout
|
|
43
44
|
f.options.timeout = @timeout
|
|
44
45
|
f.options.open_timeout = @timeout
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "faraday"
|
|
4
|
+
require "faraday/follow_redirects"
|
|
4
5
|
|
|
5
6
|
require_relative "request_builder"
|
|
6
7
|
require_relative "version"
|
|
@@ -174,6 +175,9 @@ module Supabase
|
|
|
174
175
|
Faraday.new(url: @base_url, ssl: { verify: @verify }, proxy: @proxy) do |f|
|
|
175
176
|
f.request :url_encoded
|
|
176
177
|
f.options.params_encoder = Faraday::FlatParamsEncoder
|
|
178
|
+
# Follow 3xx like supabase-py's httpx `follow_redirects=True`, so a
|
|
179
|
+
# PostgREST/proxy redirect doesn't surface as an APIError.
|
|
180
|
+
f.response :follow_redirects
|
|
177
181
|
if @timeout
|
|
178
182
|
f.options.timeout = @timeout
|
|
179
183
|
f.options.open_timeout = @timeout
|
|
@@ -159,21 +159,26 @@ shared state нет в принципе (asyncio однопоточен), но
|
|
|
159
159
|
- **Явный `connect` к недоступному серверу не «успешен молча».** Первичный
|
|
160
160
|
`client.connect` ходит на транспорт синхронно: если `Socket#connect`
|
|
161
161
|
бросает (типично `Errno::ECONNREFUSED` / `SocketError` от
|
|
162
|
-
`websocket-client-simple`),
|
|
163
|
-
`
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
162
|
+
`websocket-client-simple`), `Client#connect` ретраит с экспоненциальным
|
|
163
|
+
бэкоффом как в py (`initial_backoff * 2^(n-1)`, кап 60 с) до `max_retries`
|
|
164
|
+
попыток суммарно и затем пробрасывает последнюю ошибку. При
|
|
165
|
+
`auto_reconnect: false` первая же ошибка пробрасывается сразу — тоже
|
|
166
|
+
паритет с py. Бэкграунд-цикл и `on_reconnect_failed` относятся к
|
|
167
|
+
ситуациям «соединение установилось и потом упало» и «транспорт сообщил
|
|
168
|
+
об ошибке асинхронно» (некоторые адаптеры открывают сокет в фоне и не
|
|
169
|
+
бросают из `connect` синхронно — тогда первичный сбой тоже уходит в
|
|
170
|
+
бэкграунд-цикл).
|
|
168
171
|
|
|
169
172
|
Сводка контракта:
|
|
170
173
|
|
|
171
|
-
| Сценарий
|
|
172
|
-
|
|
173
|
-
| Первичный `connect`, сервер недоступен
|
|
174
|
-
|
|
|
175
|
-
|
|
|
176
|
-
|
|
|
174
|
+
| Сценарий | Поведение |
|
|
175
|
+
|------------------------------------------------|----------------------------------------------------|
|
|
176
|
+
| Первичный `connect`, сервер недоступен (sync) | ретраи с бэкоффом, затем `raise` (как в py) |
|
|
177
|
+
| То же при `auto_reconnect: false` | `raise` сразу, без ретраев (как в py) |
|
|
178
|
+
| Транспорт сигналит сбой асинхронно | бэкграунд-реконнект → `on_reconnect_failed` |
|
|
179
|
+
| Установленный сокет, сервер дропнул | бэкграунд-реконнект до `max_retries` |
|
|
180
|
+
| Все `max_retries` исчерпаны (бэкграунд) | `on_reconnect_failed.(last_error)` |
|
|
181
|
+
| Явный `disconnect` во время бэкоффа | ретраи прекращаются, колбэк НЕ вызывается |
|
|
177
182
|
|
|
178
183
|
## Testing
|
|
179
184
|
|
|
@@ -85,26 +85,31 @@ module Supabase
|
|
|
85
85
|
@state = Types::ChannelStates::JOINING
|
|
86
86
|
|
|
87
87
|
inject_postgres_changes_bindings
|
|
88
|
-
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
|
|
94
|
-
|
|
88
|
+
# Make subscribe a one-call entry point. If the socket is already open,
|
|
89
|
+
# send the join now. If it isn't, just (idempotently) open it — the
|
|
90
|
+
# client's rejoin_channels fires on socket-open and (re)sends the join
|
|
91
|
+
# exactly once. The previous version sent/buffered the join here AND let
|
|
92
|
+
# rejoin_channels re-send it on open, so a subscribe-before-open issued
|
|
93
|
+
# a DUPLICATE join; the server phx_closed the extra one, which (with the
|
|
94
|
+
# registry-removal fix) tore the channel down and broke delivery. Caught
|
|
95
|
+
# by the live integration suite, not the mocked specs.
|
|
96
|
+
if @socket && !@socket.connected?
|
|
97
|
+
@socket.connect
|
|
98
|
+
else
|
|
99
|
+
send_join_push
|
|
100
|
+
end
|
|
95
101
|
self
|
|
96
102
|
end
|
|
97
103
|
|
|
98
104
|
# Re-issue the join push without resetting @joined_once. Used by the
|
|
99
|
-
# client after a socket reconnect to restore channel subscriptions
|
|
105
|
+
# client after a socket reconnect to restore channel subscriptions, and by
|
|
106
|
+
# the rejoin timer after a join error.
|
|
100
107
|
def rejoin
|
|
101
108
|
return unless @joined_once
|
|
102
109
|
|
|
103
110
|
@state = Types::ChannelStates::JOINING
|
|
104
111
|
inject_postgres_changes_bindings
|
|
105
|
-
|
|
106
|
-
@join_push.instance_variable_set(:@received_status, nil)
|
|
107
|
-
send_push(@join_push, register_pending: true)
|
|
112
|
+
send_join_push
|
|
108
113
|
self
|
|
109
114
|
end
|
|
110
115
|
|
|
@@ -221,6 +226,26 @@ module Supabase
|
|
|
221
226
|
self
|
|
222
227
|
end
|
|
223
228
|
|
|
229
|
+
# Push the rotated access_token to the server for this channel. Called by
|
|
230
|
+
# {Client#set_auth} for every joined channel, mirroring supabase-py
|
|
231
|
+
# (client.py:335-337): `await channel.push(ChannelEvents.access_token,
|
|
232
|
+
# {"access_token": token})`. Routing through the normal push path means the
|
|
233
|
+
# frame is buffered (not dropped) when the socket is momentarily offline,
|
|
234
|
+
# matching py's `channel.push` buffering — the prior version only sent when
|
|
235
|
+
# `connected?` and silently lost the rotation otherwise.
|
|
236
|
+
def push_access_token(token)
|
|
237
|
+
return unless @joined_once
|
|
238
|
+
|
|
239
|
+
ref = @socket&.next_ref
|
|
240
|
+
push = Push.new(self,
|
|
241
|
+
Types::ChannelEvents::ACCESS_TOKEN,
|
|
242
|
+
{ "access_token" => token },
|
|
243
|
+
ref: ref,
|
|
244
|
+
timeout: Types::DEFAULT_TIMEOUT_SECONDS)
|
|
245
|
+
send_push(push, register_pending: true)
|
|
246
|
+
self
|
|
247
|
+
end
|
|
248
|
+
|
|
224
249
|
# Public low-level push for arbitrary Phoenix events. Mirrors
|
|
225
250
|
# `supabase-py`'s `channel.push(event, payload, timeout)`. Returns the
|
|
226
251
|
# {Push} instance so callers can attach receive() handlers and observe the
|
|
@@ -257,19 +282,21 @@ module Supabase
|
|
|
257
282
|
when Types::ChannelEvents::PRESENCE_DIFF
|
|
258
283
|
@presence.sync_diff(message.payload)
|
|
259
284
|
when Types::ChannelEvents::SYSTEM
|
|
260
|
-
|
|
261
|
-
|
|
285
|
+
# supabase-py routes system frames by status (channel.py:520-525): a
|
|
286
|
+
# `status: "ok"` payload reaches the on_system callbacks; anything else
|
|
287
|
+
# (e.g. a postgres_changes subscription failure reported via `system`)
|
|
288
|
+
# is treated as a channel error → ERRORED + rejoin scheduled.
|
|
289
|
+
if message.payload.is_a?(Hash) && message.payload["status"] == "error"
|
|
290
|
+
trigger_channel_error(message.payload)
|
|
291
|
+
else
|
|
292
|
+
@system_callbacks.each do |cb|
|
|
293
|
+
CallbackSafety.safe(logger, "system") { cb.call(message.payload) }
|
|
294
|
+
end
|
|
262
295
|
end
|
|
263
296
|
when Types::ChannelEvents::CLOSE
|
|
264
|
-
|
|
265
|
-
@close_callbacks.each do |cb|
|
|
266
|
-
CallbackSafety.safe(logger, "phx_close") { cb.call(message.payload) }
|
|
267
|
-
end
|
|
297
|
+
handle_channel_close(message.payload)
|
|
268
298
|
when Types::ChannelEvents::ERROR
|
|
269
|
-
|
|
270
|
-
@error_callbacks.each do |cb|
|
|
271
|
-
CallbackSafety.safe(logger, "phx_error") { cb.call(message.payload) }
|
|
272
|
-
end
|
|
299
|
+
trigger_channel_error(message.payload)
|
|
273
300
|
end
|
|
274
301
|
|
|
275
302
|
true
|
|
@@ -292,10 +319,23 @@ module Supabase
|
|
|
292
319
|
# so the server filters before sending, instead of shipping every change
|
|
293
320
|
# for the topic and forcing the client to drop most of them. Also flips
|
|
294
321
|
# config.presence.enabled when any presence callback is attached, so the
|
|
295
|
-
# server starts emitting presence_state/diff frames.
|
|
296
|
-
#
|
|
297
|
-
#
|
|
298
|
-
#
|
|
322
|
+
# server starts emitting presence_state/diff frames.
|
|
323
|
+
#
|
|
324
|
+
# The access_token is placed at the ROOT of the join payload (a sibling of
|
|
325
|
+
# "config"), and only when a token is actually present — matching
|
|
326
|
+
# supabase-py's `channel.py` exactly:
|
|
327
|
+
#
|
|
328
|
+
# config_payload = { "config": { ... } }
|
|
329
|
+
# if self.socket.access_token:
|
|
330
|
+
# config_payload["access_token"] = self.socket.access_token
|
|
331
|
+
#
|
|
332
|
+
# The server / Phoenix gateway reads `payload.access_token`, NOT
|
|
333
|
+
# `payload.config.access_token`. Nesting it under config (as a prior
|
|
334
|
+
# version of this port did) meant the caller's JWT never reached RLS and
|
|
335
|
+
# private channels authorized with the URL apikey only. The token source is
|
|
336
|
+
# the same field set_auth rotates (Client#access_token) — single source of
|
|
337
|
+
# truth. On rejoin the payload hash is reused, so an explicitly-cleared
|
|
338
|
+
# token must delete the stale key rather than leave it behind.
|
|
299
339
|
def inject_postgres_changes_bindings
|
|
300
340
|
config = (@join_push.payload["config"] ||= {})
|
|
301
341
|
config["postgres_changes"] = @postgres_changes_callbacks.map do |binding|
|
|
@@ -309,7 +349,12 @@ module Supabase
|
|
|
309
349
|
presence_cfg = (config["presence"] ||= {})
|
|
310
350
|
presence_cfg["enabled"] = true if @presence.any_callbacks?
|
|
311
351
|
|
|
312
|
-
|
|
352
|
+
token = @socket&.access_token
|
|
353
|
+
if token
|
|
354
|
+
@join_push.payload["access_token"] = token
|
|
355
|
+
else
|
|
356
|
+
@join_push.payload.delete("access_token")
|
|
357
|
+
end
|
|
313
358
|
end
|
|
314
359
|
|
|
315
360
|
# If a presence callback is added after the channel is already joined,
|
|
@@ -324,6 +369,20 @@ module Supabase
|
|
|
324
369
|
subscribe(&@subscribe_callback)
|
|
325
370
|
end
|
|
326
371
|
|
|
372
|
+
# Put the join push on the wire — but only when the socket is actually
|
|
373
|
+
# open. When it isn't, the join is intentionally NOT sent or buffered here:
|
|
374
|
+
# the client's rejoin_channels re-sends it the moment the socket opens, and
|
|
375
|
+
# sending/buffering it here too would duplicate the join (the server then
|
|
376
|
+
# phx_closes the extra one). Assigns a fresh ref and clears any prior reply
|
|
377
|
+
# status so a rejoin is matched to its own phx_reply.
|
|
378
|
+
def send_join_push
|
|
379
|
+
return unless @socket&.connected?
|
|
380
|
+
|
|
381
|
+
@join_push.instance_variable_set(:@ref, @socket.next_ref)
|
|
382
|
+
@join_push.instance_variable_set(:@received_status, nil)
|
|
383
|
+
send_push(@join_push, register_pending: true)
|
|
384
|
+
end
|
|
385
|
+
|
|
327
386
|
def send_push(push, register_pending:)
|
|
328
387
|
message = Message.new(
|
|
329
388
|
event: push.event,
|
|
@@ -333,14 +392,15 @@ module Supabase
|
|
|
333
392
|
join_ref: @join_push.ref
|
|
334
393
|
)
|
|
335
394
|
|
|
395
|
+
if register_pending && push.ref
|
|
396
|
+
@pending_pushes[push.ref] = push
|
|
397
|
+
# Arm the timeout when the push is queued, not when it hits the wire
|
|
398
|
+
# (py parity, channel.py:318-323): a push buffered on a channel that
|
|
399
|
+
# never reaches JOINED must resolve TIMEOUT, not hang forever.
|
|
400
|
+
push.start_timeout
|
|
401
|
+
end
|
|
402
|
+
|
|
336
403
|
if can_send?(push)
|
|
337
|
-
if register_pending && push.ref
|
|
338
|
-
@pending_pushes[push.ref] = push
|
|
339
|
-
# Arm the timeout only once the push is actually on the wire — if it
|
|
340
|
-
# gets buffered (channel not yet joined) we leave it untimed until
|
|
341
|
-
# the buffer is flushed.
|
|
342
|
-
push.start_timeout
|
|
343
|
-
end
|
|
344
404
|
@socket&.push(message)
|
|
345
405
|
else
|
|
346
406
|
@push_buffer << [push, register_pending]
|
|
@@ -385,13 +445,18 @@ module Supabase
|
|
|
385
445
|
next unless binding[:event] == change_type || binding[:event] == "*"
|
|
386
446
|
next if binding[:schema] && binding[:schema] != schema
|
|
387
447
|
next if binding[:table] && binding[:table] != table
|
|
388
|
-
#
|
|
389
|
-
#
|
|
390
|
-
#
|
|
391
|
-
#
|
|
392
|
-
#
|
|
393
|
-
#
|
|
394
|
-
#
|
|
448
|
+
# DIVERGES FROM PY/JS (intentional — see docs/PARITY.md D7): supabase-py
|
|
449
|
+
# AND realtime-js demultiplex postgres_changes *solely* by the
|
|
450
|
+
# server-assigned binding id (`id && ids.include?(id)`), so a binding
|
|
451
|
+
# with no id fires for nothing. We instead filter client-side on
|
|
452
|
+
# event/schema/table (above) and use the server id only as an
|
|
453
|
+
# additional demux when present. This is more robust — events still
|
|
454
|
+
# route correctly even if the server omits ids — at the cost of one
|
|
455
|
+
# narrow edge case: two bindings on the SAME (schema, table, event)
|
|
456
|
+
# differing only by `:filter`, while neither has a server id yet, will
|
|
457
|
+
# both fire (we don't evaluate PostgREST `:filter` client-side). In the
|
|
458
|
+
# normal flow on_join_ok records the ids on the join-ack, after which
|
|
459
|
+
# this gate demuxes them correctly.
|
|
395
460
|
next if binding[:id] && ids.is_a?(Array) && !ids.include?(binding[:id])
|
|
396
461
|
|
|
397
462
|
CallbackSafety.safe(logger, "postgres_changes:#{binding[:event]}") do
|
|
@@ -480,13 +545,52 @@ module Supabase
|
|
|
480
545
|
def flush_push_buffer
|
|
481
546
|
buffered = @push_buffer
|
|
482
547
|
@push_buffer = []
|
|
483
|
-
buffered.each
|
|
548
|
+
buffered.each do |push, register_pending|
|
|
549
|
+
# A push that resolved while buffered (timed out waiting for the join)
|
|
550
|
+
# must not go on the wire late — its caller already saw TIMEOUT.
|
|
551
|
+
next if push.received_status
|
|
552
|
+
|
|
553
|
+
send_push(push, register_pending: register_pending)
|
|
554
|
+
end
|
|
484
555
|
end
|
|
485
556
|
|
|
486
557
|
def on_leave_ack
|
|
558
|
+
handle_channel_close({})
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
# Channel teardown — mirrors supabase-py `channel.on_close`
|
|
562
|
+
# (channel.py:134-138): cancel any pending rejoin, mark CLOSED, fire the
|
|
563
|
+
# registered close listeners, and remove the channel from the owning
|
|
564
|
+
# client's registry so a CLOSED channel no longer receives dispatched
|
|
565
|
+
# frames and doesn't leak across a subscribe/unsubscribe churn cycle. The
|
|
566
|
+
# registry removal is the fix for the prior leak where unsubscribed
|
|
567
|
+
# channels stayed in `client.channels` forever and kept running
|
|
568
|
+
# presence/broadcast dispatch.
|
|
569
|
+
#
|
|
570
|
+
# (Named distinctly from the public {#on_close} listener registrar, which
|
|
571
|
+
# is an rb-only convenience with no py counterpart.)
|
|
572
|
+
def handle_channel_close(payload = {})
|
|
573
|
+
@rejoin_timer.reset
|
|
487
574
|
@state = Types::ChannelStates::CLOSED
|
|
488
575
|
@close_callbacks.each do |cb|
|
|
489
|
-
CallbackSafety.safe(logger, "phx_close") { cb.call(
|
|
576
|
+
CallbackSafety.safe(logger, "phx_close") { cb.call(payload) }
|
|
577
|
+
end
|
|
578
|
+
@socket._remove_channel(self) if @socket.respond_to?(:_remove_channel)
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
# Mirrors supabase-py `channel.on_error` (channel.py:140-146): a phx_error
|
|
582
|
+
# frame, or a `system` frame with status "error", errors the channel and
|
|
583
|
+
# schedules a rejoin with exponential backoff so a transient server-side
|
|
584
|
+
# channel crash self-heals instead of staying dead until the whole socket
|
|
585
|
+
# drops. No-op while LEAVING/CLOSED so a phx_error racing an unsubscribe
|
|
586
|
+
# can't flip the channel back to ERRORED.
|
|
587
|
+
def trigger_channel_error(payload)
|
|
588
|
+
return if leaving? || closed?
|
|
589
|
+
|
|
590
|
+
@state = Types::ChannelStates::ERRORED
|
|
591
|
+
@rejoin_timer.schedule_timeout
|
|
592
|
+
@error_callbacks.each do |cb|
|
|
593
|
+
CallbackSafety.safe(logger, "phx_error") { cb.call(payload) }
|
|
490
594
|
end
|
|
491
595
|
end
|
|
492
596
|
end
|
|
@@ -36,7 +36,9 @@ module Supabase
|
|
|
36
36
|
:logger
|
|
37
37
|
|
|
38
38
|
# @param url [String] WebSocket endpoint (ws:// or wss://). Plain http(s) are upgraded.
|
|
39
|
-
# @param params [Hash] query-string params merged onto the URL (e.g. apikey
|
|
39
|
+
# @param params [Hash] query-string params merged onto the URL (e.g. apikey).
|
|
40
|
+
# `access_token` is accepted here but is NOT serialized into the URL —
|
|
41
|
+
# it is carried in join payloads / access_token pushes instead.
|
|
40
42
|
# @param transport [Socket, nil] inject your own transport. If nil, the production
|
|
41
43
|
# websocket-client-simple adapter is constructed from URL+params.
|
|
42
44
|
# @param socket [Socket, nil] deprecated alias for `transport:` — kept for back compat.
|
|
@@ -75,6 +77,7 @@ module Supabase
|
|
|
75
77
|
@logger = logger
|
|
76
78
|
@heartbeat_thread = nil
|
|
77
79
|
@reconnect_thread = nil
|
|
80
|
+
@connecting = false
|
|
78
81
|
@intentionally_closed = false
|
|
79
82
|
@send_buffer = [] # frames queued while no socket / not connected
|
|
80
83
|
@send_buffer_mutex = Mutex.new
|
|
@@ -111,14 +114,49 @@ module Supabase
|
|
|
111
114
|
self
|
|
112
115
|
end
|
|
113
116
|
|
|
117
|
+
# Establish the WebSocket connection. Mirrors supabase-py's `connect()`
|
|
118
|
+
# (client.py:141-193): synchronous transport failures are retried with
|
|
119
|
+
# exponential backoff — `initial_backoff * 2^(n-1)` seconds, capped at
|
|
120
|
+
# 60s — for up to `max_retries` total attempts, then the last error is
|
|
121
|
+
# re-raised to the caller. With `auto_reconnect: false` the first
|
|
122
|
+
# failure raises immediately, as in py.
|
|
123
|
+
#
|
|
124
|
+
# Only failures that `Socket#connect` raises *synchronously* are retried
|
|
125
|
+
# here. Transports that report failure asynchronously (on_error/on_close
|
|
126
|
+
# after connect returns) are recovered by the background reconnect loop
|
|
127
|
+
# (schedule_reconnect → on_reconnect_failed) — same contract, different
|
|
128
|
+
# signal path. A concurrent `disconnect` aborts the retry loop quietly.
|
|
114
129
|
def connect
|
|
115
130
|
unless @socket
|
|
116
131
|
@socket = build_default_transport
|
|
117
132
|
attach_socket
|
|
118
133
|
end
|
|
119
134
|
|
|
135
|
+
# Idempotent: if a connection is already open or in flight, don't kick
|
|
136
|
+
# off a second transport.connect. A duplicate connect can produce a
|
|
137
|
+
# second on_open, which would fire rejoin_channels twice and send a
|
|
138
|
+
# duplicate join per channel (the server then phx_closes the extra one).
|
|
139
|
+
# This matters because Channel#subscribe calls connect when the socket
|
|
140
|
+
# isn't open yet, and the caller may have already called connect.
|
|
141
|
+
return self if connected? || @connecting
|
|
142
|
+
|
|
120
143
|
@intentionally_closed = false
|
|
121
|
-
|
|
144
|
+
attempts = 0
|
|
145
|
+
begin
|
|
146
|
+
@connecting = true
|
|
147
|
+
@socket.connect
|
|
148
|
+
rescue StandardError
|
|
149
|
+
# Reset the in-flight flag so the retry (and any later connect call)
|
|
150
|
+
# isn't short-circuited by the idempotency guard above.
|
|
151
|
+
@connecting = false
|
|
152
|
+
attempts += 1
|
|
153
|
+
raise if !@auto_reconnect || attempts >= @max_retries
|
|
154
|
+
|
|
155
|
+
sleep [@initial_backoff * (2**(attempts - 1)), 60.0].min
|
|
156
|
+
return self if @intentionally_closed
|
|
157
|
+
|
|
158
|
+
retry
|
|
159
|
+
end
|
|
122
160
|
self
|
|
123
161
|
end
|
|
124
162
|
|
|
@@ -161,7 +199,24 @@ module Supabase
|
|
|
161
199
|
def remove_channel(channel)
|
|
162
200
|
channel.unsubscribe
|
|
163
201
|
@channels.delete(channel)
|
|
164
|
-
|
|
202
|
+
# Close the socket once the registry empties — mirrors supabase-py's
|
|
203
|
+
# `remove_channel` (which calls `self.close()` when `len(channels) == 0`).
|
|
204
|
+
# Use the intentional-close path (`disconnect`), not a bare
|
|
205
|
+
# `@socket.close`: the latter fires on_close → schedule_reconnect and the
|
|
206
|
+
# socket would immediately come back up.
|
|
207
|
+
disconnect if @channels.empty?
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Internal: drop a channel from the registry without unsubscribing it.
|
|
211
|
+
# Called by {Channel#on_close} when a channel reaches CLOSED on its own
|
|
212
|
+
# (leave-ack or a server phx_close), mirroring supabase-py's
|
|
213
|
+
# `socket._remove_channel` (client.py:294-295). Distinct from the public
|
|
214
|
+
# {#remove_channel}, which actively unsubscribes. Removes the specific
|
|
215
|
+
# channel object (topics can repeat in the flat registry). Does not close
|
|
216
|
+
# the socket — that auto-close only happens via the explicit
|
|
217
|
+
# remove_channel/remove_all_channels paths, matching py.
|
|
218
|
+
def _remove_channel(channel)
|
|
219
|
+
@channels.delete(channel)
|
|
165
220
|
end
|
|
166
221
|
|
|
167
222
|
# Unsubscribe every tracked channel and clear the registry. Iterates over a
|
|
@@ -172,6 +227,9 @@ module Supabase
|
|
|
172
227
|
def remove_all_channels
|
|
173
228
|
@channels.dup.each { |ch| ch.unsubscribe }
|
|
174
229
|
@channels.clear
|
|
230
|
+
# supabase-py's remove_all_channels unsubscribes every channel and then
|
|
231
|
+
# `await self.close()`. Match that — intentional close, no reconnect.
|
|
232
|
+
disconnect
|
|
175
233
|
self
|
|
176
234
|
end
|
|
177
235
|
|
|
@@ -180,30 +238,19 @@ module Supabase
|
|
|
180
238
|
#
|
|
181
239
|
# Safe to call before `connect`: the token is always written to
|
|
182
240
|
# `@access_token` / `@params` so the next subscribe picks it up via
|
|
183
|
-
# `Channel#inject_postgres_changes_bindings`.
|
|
184
|
-
#
|
|
241
|
+
# `Channel#inject_postgres_changes_bindings`.
|
|
242
|
+
#
|
|
243
|
+
# The fan-out mirrors supabase-py (client.py:333-337): for every joined
|
|
244
|
+
# channel, `channel.push(access_token, {access_token: token})`. Routing
|
|
245
|
+
# through the channel's push path (rather than sending a raw frame only
|
|
246
|
+
# when `connected?`) means a rotation issued while the socket is briefly
|
|
247
|
+
# offline is buffered and replayed on reconnect, not silently dropped.
|
|
185
248
|
def set_auth(token)
|
|
186
249
|
@access_token = token
|
|
187
250
|
@params["access_token"] = token if @params.is_a?(Hash)
|
|
188
251
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
next unless channel.joined?
|
|
192
|
-
|
|
193
|
-
msg = Message.new(
|
|
194
|
-
event: Types::ChannelEvents::ACCESS_TOKEN,
|
|
195
|
-
topic: channel.topic,
|
|
196
|
-
payload: { "access_token" => token },
|
|
197
|
-
ref: next_ref
|
|
198
|
-
)
|
|
199
|
-
@socket.send(JSON.generate(
|
|
200
|
-
"event" => msg.event,
|
|
201
|
-
"topic" => msg.topic,
|
|
202
|
-
"payload" => msg.payload,
|
|
203
|
-
"ref" => msg.ref,
|
|
204
|
-
"join_ref" => nil
|
|
205
|
-
))
|
|
206
|
-
end
|
|
252
|
+
@channels.each do |channel|
|
|
253
|
+
channel.push_access_token(token) if channel.joined?
|
|
207
254
|
end
|
|
208
255
|
end
|
|
209
256
|
|
|
@@ -265,9 +312,25 @@ module Supabase
|
|
|
265
312
|
@socket.on_message { |raw| handle_inbound(raw) }
|
|
266
313
|
@socket.on_open { handle_socket_open }
|
|
267
314
|
@socket.on_close { handle_socket_close }
|
|
315
|
+
@socket.on_error { |err| handle_socket_error(err) }
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# A transport-level error (failed write, protocol error) means the
|
|
319
|
+
# connection is effectively dead. supabase-py funnels both heartbeat-send
|
|
320
|
+
# failures and socket errors into `_on_connect_error` → `_reconnect`
|
|
321
|
+
# (client.py:212-221). Some transports surface an abrupt drop only as an
|
|
322
|
+
# error and never fire on_close, so wiring this here is what keeps a
|
|
323
|
+
# half-dead connection from silently never reconnecting. Honors the same
|
|
324
|
+
# intentional-close / auto_reconnect gating as handle_socket_close.
|
|
325
|
+
def handle_socket_error(_err = nil)
|
|
326
|
+
stop_heartbeat
|
|
327
|
+
return if @intentionally_closed || !@auto_reconnect
|
|
328
|
+
|
|
329
|
+
schedule_reconnect
|
|
268
330
|
end
|
|
269
331
|
|
|
270
332
|
def handle_socket_open
|
|
333
|
+
@connecting = false
|
|
271
334
|
flush_send_buffer
|
|
272
335
|
start_heartbeat
|
|
273
336
|
rejoin_channels
|
|
@@ -291,6 +354,7 @@ module Supabase
|
|
|
291
354
|
end
|
|
292
355
|
|
|
293
356
|
def handle_socket_close
|
|
357
|
+
@connecting = false
|
|
294
358
|
stop_heartbeat
|
|
295
359
|
return if @intentionally_closed || !@auto_reconnect
|
|
296
360
|
|
|
@@ -313,7 +377,14 @@ module Supabase
|
|
|
313
377
|
begin
|
|
314
378
|
send_heartbeat
|
|
315
379
|
rescue StandardError
|
|
316
|
-
#
|
|
380
|
+
# A heartbeat write failure means the connection is dead. Mirror
|
|
381
|
+
# supabase-py (client.py:270-271), which routes the failure into
|
|
382
|
+
# the reconnect sequence rather than swallowing it — otherwise a
|
|
383
|
+
# half-dead socket that errors on write but never fires on_close
|
|
384
|
+
# would never recover. Break the loop; handle_socket_error
|
|
385
|
+
# (re)starts heartbeat after a successful reconnect.
|
|
386
|
+
handle_socket_error
|
|
387
|
+
break
|
|
317
388
|
end
|
|
318
389
|
end
|
|
319
390
|
end
|
|
@@ -404,8 +475,18 @@ module Supabase
|
|
|
404
475
|
normalized.sub!(%r{\Ahttps://}, "wss://")
|
|
405
476
|
normalized = "#{normalized}/websocket" unless normalized.end_with?("/websocket")
|
|
406
477
|
|
|
407
|
-
|
|
408
|
-
query
|
|
478
|
+
# The user JWT must never appear in the URL: supabase-py puts only
|
|
479
|
+
# `apikey` in the query string (client.py:78-79) and carries the access
|
|
480
|
+
# token in join payloads / access_token pushes. URLs are logged by
|
|
481
|
+
# proxies and servers, so serializing the token here would leak it.
|
|
482
|
+
# `access_token` stays available via @access_token for joins/set_auth.
|
|
483
|
+
query = { "vsn" => Types::VSN }
|
|
484
|
+
if params && !params.empty?
|
|
485
|
+
url_params = params.transform_keys(&:to_s)
|
|
486
|
+
url_params.delete("access_token")
|
|
487
|
+
url_params.compact!
|
|
488
|
+
query = query.merge(url_params)
|
|
489
|
+
end
|
|
409
490
|
|
|
410
491
|
separator = normalized.include?("?") ? "&" : "?"
|
|
411
492
|
"#{normalized}#{separator}#{URI.encode_www_form(query)}"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "async"
|
|
4
|
+
require "async/variable"
|
|
4
5
|
require "async/http/endpoint"
|
|
5
6
|
require "async/websocket/client"
|
|
6
7
|
require "protocol/websocket/message"
|
|
@@ -72,7 +73,12 @@ module Supabase
|
|
|
72
73
|
end
|
|
73
74
|
|
|
74
75
|
endpoint = ::Async::HTTP::Endpoint.parse(@url)
|
|
75
|
-
|
|
76
|
+
# Async::Variable, not Async::Promise: Promise only exists in newer
|
|
77
|
+
# async releases (Ruby >= 3.2 resolutions), while Variable is
|
|
78
|
+
# available across all async 2.x — and 3.1 resolves async 2.24.
|
|
79
|
+
# Variable has no #reject, so a connect error is resolved as a value
|
|
80
|
+
# and re-raised by the waiting side below.
|
|
81
|
+
ready = ::Async::Variable.new
|
|
76
82
|
|
|
77
83
|
@session = parent.async do
|
|
78
84
|
@connector.connect(endpoint, headers: header_pairs) do |connection|
|
|
@@ -84,15 +90,17 @@ module Supabase
|
|
|
84
90
|
end
|
|
85
91
|
rescue => err
|
|
86
92
|
fire_error(err)
|
|
87
|
-
ready.
|
|
93
|
+
ready.resolve(err) unless ready.resolved?
|
|
88
94
|
ensure
|
|
89
95
|
fire_close
|
|
90
96
|
end
|
|
91
97
|
|
|
92
|
-
# Cooperative wait —
|
|
98
|
+
# Cooperative wait — Variable buffers the resolution, so this returns
|
|
93
99
|
# immediately whether the session task got there first or not. After
|
|
94
100
|
# this, callers can rely on connected?.
|
|
95
|
-
ready.wait
|
|
101
|
+
outcome = ready.wait
|
|
102
|
+
raise outcome if outcome.is_a?(Exception)
|
|
103
|
+
|
|
96
104
|
nil
|
|
97
105
|
end
|
|
98
106
|
|
|
@@ -49,7 +49,11 @@ module Supabase
|
|
|
49
49
|
# a plain Hash that a downstream iceberg-ruby (when one exists) can consume.
|
|
50
50
|
# Keeping the public method present mirrors the API surface.
|
|
51
51
|
def catalog(catalog_name, access_key_id:, secret_access_key:)
|
|
52
|
-
|
|
52
|
+
# py reads the header through case-insensitive `httpx.Headers`; the
|
|
53
|
+
# umbrella Supabase::Client sends "apikey" (lowercase), so match any
|
|
54
|
+
# casing (exact "apiKey" wins if both spellings are present).
|
|
55
|
+
service_key = @headers["apiKey"] ||
|
|
56
|
+
@headers.find { |k, _| k.to_s.casecmp?("apikey") }&.last
|
|
53
57
|
raise Errors::StorageApiError.new("apiKey must be passed in the headers.") if service_key.to_s.empty?
|
|
54
58
|
|
|
55
59
|
s3_endpoint = @base_url.sub(%r{iceberg/?\z}, "s3")
|
|
@@ -290,8 +290,14 @@ module Supabase
|
|
|
290
290
|
def build_upload_io(file, filename, content_type)
|
|
291
291
|
case file
|
|
292
292
|
when String
|
|
293
|
-
#
|
|
294
|
-
#
|
|
293
|
+
# DIVERGES FROM PY (intentional): supabase-py treats a `str` as a
|
|
294
|
+
# filesystem PATH and opens it; raw bytes must be passed as
|
|
295
|
+
# bytes/BufferedReader. We treat a String as raw bytes/text and a
|
|
296
|
+
# Pathname (or any IO) as the file source. Rationale: this matches
|
|
297
|
+
# supabase-js (which takes Blob/Buffer/File, never a path string) and
|
|
298
|
+
# avoids the silent footgun where `upload("a.png", "./a.png")` would
|
|
299
|
+
# upload the literal path string. Callers that want path semantics
|
|
300
|
+
# pass `Pathname("./a.png")` or an opened File.
|
|
295
301
|
Faraday::Multipart::FilePart.new(StringIO.new(file), content_type, filename)
|
|
296
302
|
when Pathname
|
|
297
303
|
Faraday::Multipart::FilePart.new(file.to_s, content_type, filename)
|
|
@@ -90,7 +90,12 @@ module Supabase
|
|
|
90
90
|
end
|
|
91
91
|
|
|
92
92
|
def list_indexes(next_token: nil, max_results: nil, prefix: nil)
|
|
93
|
-
|
|
93
|
+
# DIVERGES FROM PY (intentional): supabase-py sends snake_case body keys
|
|
94
|
+
# here (`next_token`/`max_results`) while every other vectors action —
|
|
95
|
+
# list_buckets, list — uses camelCase (`nextToken`/`maxResults`). That
|
|
96
|
+
# inconsistency is a py bug; we send camelCase to match the sibling
|
|
97
|
+
# actions and the rest of the storage/vector API.
|
|
98
|
+
json = with_metadata("nextToken" => next_token, "maxResults" => max_results, "prefix" => prefix)
|
|
94
99
|
body = @client.send_action(path: "ListIndexes", json: json)
|
|
95
100
|
Types::ListVectorIndexesResponse.from_hash(body)
|
|
96
101
|
end
|
data/lib/supabase/version.rb
CHANGED
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: 3.2.
|
|
4
|
+
version: 3.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Supabase
|
|
@@ -107,6 +107,20 @@ dependencies:
|
|
|
107
107
|
- - "~>"
|
|
108
108
|
- !ruby/object:Gem::Version
|
|
109
109
|
version: '0.22'
|
|
110
|
+
- !ruby/object:Gem::Dependency
|
|
111
|
+
name: simplecov-lcov
|
|
112
|
+
requirement: !ruby/object:Gem::Requirement
|
|
113
|
+
requirements:
|
|
114
|
+
- - "~>"
|
|
115
|
+
- !ruby/object:Gem::Version
|
|
116
|
+
version: '0.8'
|
|
117
|
+
type: :development
|
|
118
|
+
prerelease: false
|
|
119
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
120
|
+
requirements:
|
|
121
|
+
- - "~>"
|
|
122
|
+
- !ruby/object:Gem::Version
|
|
123
|
+
version: '0.8'
|
|
110
124
|
- !ruby/object:Gem::Dependency
|
|
111
125
|
name: webmock
|
|
112
126
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -177,6 +191,20 @@ dependencies:
|
|
|
177
191
|
- - "~>"
|
|
178
192
|
- !ruby/object:Gem::Version
|
|
179
193
|
version: '0.30'
|
|
194
|
+
- !ruby/object:Gem::Dependency
|
|
195
|
+
name: rbnacl
|
|
196
|
+
requirement: !ruby/object:Gem::Requirement
|
|
197
|
+
requirements:
|
|
198
|
+
- - "~>"
|
|
199
|
+
- !ruby/object:Gem::Version
|
|
200
|
+
version: '7.1'
|
|
201
|
+
type: :development
|
|
202
|
+
prerelease: false
|
|
203
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
204
|
+
requirements:
|
|
205
|
+
- - "~>"
|
|
206
|
+
- !ruby/object:Gem::Version
|
|
207
|
+
version: '7.1'
|
|
180
208
|
description: 'Ruby client for Supabase: Auth, PostgREST, Storage, Edge Functions,
|
|
181
209
|
and Realtime exposed through a single Supabase.create_client(supabase_url:, supabase_key:)
|
|
182
210
|
factory, mirroring supabase-py''s create_client().'
|
|
@@ -186,6 +214,7 @@ executables: []
|
|
|
186
214
|
extensions: []
|
|
187
215
|
extra_rdoc_files: []
|
|
188
216
|
files:
|
|
217
|
+
- LICENSE
|
|
189
218
|
- lib/supabase-auth.rb
|
|
190
219
|
- lib/supabase.rb
|
|
191
220
|
- lib/supabase/README.md
|
|
@@ -261,14 +290,15 @@ files:
|
|
|
261
290
|
- lib/supabase/storage/vectors.rb
|
|
262
291
|
- lib/supabase/storage/version.rb
|
|
263
292
|
- lib/supabase/version.rb
|
|
264
|
-
homepage: https://
|
|
293
|
+
homepage: https://supabase-ruby.dev
|
|
265
294
|
licenses:
|
|
266
295
|
- MIT
|
|
267
296
|
metadata:
|
|
268
|
-
homepage_uri: https://
|
|
297
|
+
homepage_uri: https://supabase-ruby.dev
|
|
269
298
|
source_code_uri: https://github.com/supabase-ruby/supabase-rb
|
|
270
|
-
documentation_uri: https://
|
|
299
|
+
documentation_uri: https://supabase-ruby.dev/reference
|
|
271
300
|
changelog_uri: https://github.com/supabase-ruby/supabase-rb/blob/master/CHANGELOG.md
|
|
301
|
+
bug_tracker_uri: https://github.com/supabase-ruby/supabase-rb/issues
|
|
272
302
|
rdoc_options: []
|
|
273
303
|
require_paths:
|
|
274
304
|
- lib
|
|
@@ -276,7 +306,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
276
306
|
requirements:
|
|
277
307
|
- - ">="
|
|
278
308
|
- !ruby/object:Gem::Version
|
|
279
|
-
version: 3.
|
|
309
|
+
version: 3.1.0
|
|
280
310
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
281
311
|
requirements:
|
|
282
312
|
- - ">="
|