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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b6f3e676fb9f80387385b5339aff40fcdc8b584b9c8e66f92072f2ac9d7ba356
4
- data.tar.gz: f9972703bfd0828183e1a600c5cad893c4102c728b913384919b41da9e4deb06
3
+ metadata.gz: '0168cd0cbeebbe6a2e1132d54ba1592b6a1f1f43d0c3f7d9374b52d16394f0a0'
4
+ data.tar.gz: 92b7a6b274189d5036f4975a7b1866510b6b09b6be0ea1d0360dd45d156d913b
5
5
  SHA512:
6
- metadata.gz: 39871694f25f18c296f358f6f85325f18f31a1752e2bb79dc96c6ce0551f5d0c10a72c5994a5302761e6e4df170eadae058dc69fa04dac5764db2568198d1b1a
7
- data.tar.gz: aa76e506b180682651570e8b8a3c1a091901655a5d95ac5d642b962dc33dfd09f2c77d131117eb70c42a1163c63cd341d8941d1db3064ce7504943e75e771f35
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.
@@ -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
@@ -25,6 +25,7 @@ module Supabase
25
25
 
26
26
  def build_connection
27
27
  Faraday.new(url: @url, ssl: { verify: @verify }, proxy: @proxy) do |f|
28
+ f.response :follow_redirects
28
29
  f.response :raise_error
29
30
  if @timeout
30
31
  f.options.timeout = @timeout
@@ -18,6 +18,7 @@ module Supabase
18
18
 
19
19
  def build_connection
20
20
  Faraday.new(url: @url, ssl: { verify: @verify }, proxy: @proxy) do |f|
21
+ f.response :follow_redirects
21
22
  f.response :raise_error
22
23
  if @timeout
23
24
  f.options.timeout = @timeout
@@ -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 - verify via JWKS using the jwt gem's decode
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(token, nil, true, { algorithms: [header["alg"]], jwks: jwk_set })
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
 
@@ -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
- global_headers = options[:global]&.dig(:headers) || options.dig("global", "headers") || {}
45
- configured_auth = global_headers["Authorization"] || global_headers[:Authorization]
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
- options.is_a?(Hash) &&
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" => @supabase_key },
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
- @headers["Authorization"] = "Bearer #{token || @supabase_key}"
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
@@ -34,6 +34,7 @@ module Supabase
34
34
 
35
35
  def build_session
36
36
  Faraday.new(url: @base_url, ssl: { verify: @verify }, proxy: @proxy) do |f|
37
+ f.response :follow_redirects
37
38
  f.options.timeout = @timeout
38
39
  f.options.open_timeout = @timeout
39
40
  f.adapter :async_http
@@ -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
- relay = response.headers["x-relay-header"] || response.headers["X-Relay-Header"]
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
- parse_json_safe(body) || body
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
- `Client#connect` сразу, без внутреннего ретрая. Поведение совпадает с
164
- py: `await connect()` тоже бросает на постоянной ошибке. Бэкграунд-цикл
165
- и `on_reconnect_failed` относятся ИСКЛЮЧИТЕЛЬНО к ситуации
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`, сервер недоступен | `raise` (как в py) |
174
- | Установленный сокет, сервер дропнул | бэкграунд-реконнект до `max_retries` |
175
- | Все `max_retries` исчерпаны | `on_reconnect_failed.(last_error)` |
176
- | Явный `disconnect` во время бэкоффа | колбэк НЕ вызывается |
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
- @join_push.instance_variable_set(:@ref, @socket&.next_ref)
89
- # Make subscribe a one-call entry point: if the caller hasn't already
90
- # connected the underlying transport, open it now so the join frame
91
- # actually reaches the wire instead of being held forever in the
92
- # Client#send_buffer. Matches supabase-py's `channel.subscribe()` ergonomics.
93
- @socket.connect if @socket && !@socket.connected?
94
- send_push(@join_push, register_pending: true)
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
- @join_push.instance_variable_set(:@ref, @socket&.next_ref)
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
- @system_callbacks.each do |cb|
261
- CallbackSafety.safe(logger, "system") { cb.call(message.payload) }
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
- @state = Types::ChannelStates::CLOSED
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
- @state = Types::ChannelStates::ERRORED
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. Finally, pulls the
296
- # current socket access_token onto config.access_token so RLS sees the
297
- # caller's JWT private channels reject the join otherwise. The token
298
- # source is identical to what set_auth rotates (single source of truth).
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
- config["access_token"] = @socket&.access_token
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
- # Server-side binding-id routing: once on_join_ok has recorded the
389
- # server-assigned :id, an inbound frame's payload.ids tells us which
390
- # bindings the server intended to fire. This is how two bindings on
391
- # the same (schema, table) but different :filter get demultiplexed —
392
- # without it both would fire on every change. Before the join-ack
393
- # (no :id yet) we fall through and the legacy event/schema/table
394
- # filtering remains the sole gate.
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 { |push, register_pending| send_push(push, register_pending: register_pending) }
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/access_token)
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
- @socket.connect
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
- @socket&.close if @channels.empty?
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`. The ACCESS_TOKEN frame fan-out
184
- # only runs once the socket is actually connected.
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
- if connected?
190
- @channels.each do |channel|
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
- # Swallow a transient send error shouldn't kill the heartbeat loop.
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
- query = { "vsn" => Types::VSN }.merge(params.transform_keys(&:to_s)) if params && !params.empty?
408
- query ||= { "vsn" => Types::VSN }
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
- ready = ::Async::Promise.new
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.reject(err) unless ready.resolved?
93
+ ready.resolve(err) unless ready.resolved?
88
94
  ensure
89
95
  fire_close
90
96
  end
91
97
 
92
- # Cooperative wait — Promise buffers the resolution, so this returns
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
- service_key = @headers["apiKey"]
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
- # Treat as raw bytes/text, not a path call sites that want path semantics
294
- # pass a Pathname or open the File themselves (matches storage3's bytes/IO contract).
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
- json = with_metadata("next_token" => next_token, "max_results" => max_results, "prefix" => prefix)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Supabase
4
- VERSION = "3.2.0"
4
+ VERSION = "3.2.1"
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: 3.2.0
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://github.com/supabase-ruby/supabase-rb
293
+ homepage: https://supabase-ruby.dev
265
294
  licenses:
266
295
  - MIT
267
296
  metadata:
268
- homepage_uri: https://github.com/supabase-ruby/supabase-rb
297
+ homepage_uri: https://supabase-ruby.dev
269
298
  source_code_uri: https://github.com/supabase-ruby/supabase-rb
270
- documentation_uri: https://github.com/supabase-ruby/supabase-rb/blob/master/lib/supabase/README.md
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.0.0
309
+ version: 3.1.0
280
310
  required_rubygems_version: !ruby/object:Gem::Requirement
281
311
  requirements:
282
312
  - - ">="