supabase-rb 3.1.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/lib/supabase/auth/README.md +10 -4
  3. data/lib/supabase/auth/admin_api.rb +4 -0
  4. data/lib/supabase/auth/admin_mfa_api.rb +30 -0
  5. data/lib/supabase/auth/async/admin_api.rb +4 -1
  6. data/lib/supabase/auth/async/admin_mfa_api.rb +15 -0
  7. data/lib/supabase/auth/async/client.rb +2 -4
  8. data/lib/supabase/auth/async.rb +1 -0
  9. data/lib/supabase/auth/client.rb +71 -31
  10. data/lib/supabase/auth/helpers.rb +4 -0
  11. data/lib/supabase/auth/types.rb +14 -5
  12. data/lib/supabase/auth.rb +1 -0
  13. data/lib/supabase/client.rb +103 -22
  14. data/lib/supabase/client_options.rb +1 -1
  15. data/lib/supabase/functions/README.md +99 -12
  16. data/lib/supabase/functions/client.rb +72 -31
  17. data/lib/supabase/functions/types.rb +24 -3
  18. data/lib/supabase/postgrest/async/client.rb +2 -0
  19. data/lib/supabase/postgrest/client.rb +9 -2
  20. data/lib/supabase/postgrest/errors.rb +18 -6
  21. data/lib/supabase/postgrest/request_builder.rb +5 -11
  22. data/lib/supabase/realtime/README.md +111 -0
  23. data/lib/supabase/realtime/callback_safety.rb +41 -0
  24. data/lib/supabase/realtime/channel.rb +89 -23
  25. data/lib/supabase/realtime/client.rb +130 -44
  26. data/lib/supabase/realtime/errors.rb +0 -13
  27. data/lib/supabase/realtime/message.rb +13 -3
  28. data/lib/supabase/realtime/presence.rb +84 -32
  29. data/lib/supabase/realtime/push.rb +11 -2
  30. data/lib/supabase/realtime/timer.rb +72 -0
  31. data/lib/supabase/realtime.rb +2 -1
  32. data/lib/supabase/storage/README.md +117 -0
  33. data/lib/supabase/storage/async/client.rb +7 -4
  34. data/lib/supabase/storage/client.rb +16 -4
  35. data/lib/supabase/storage/file_api.rb +36 -9
  36. data/lib/supabase/storage/request.rb +3 -1
  37. data/lib/supabase/storage/utils.rb +15 -1
  38. data/lib/supabase/version.rb +1 -1
  39. data/lib/supabase.rb +0 -7
  40. metadata +33 -16
  41. data/lib/supabase/realtime/test_socket.rb +0 -65
@@ -1,8 +1,8 @@
1
1
  # `supabase-functions`
2
2
 
3
3
  Ruby client for [Supabase Edge Functions](https://supabase.com/docs/guides/functions).
4
- Per-call control over body, headers, HTTP method, region routing, and
5
- response parsing. Mirrors the public surface of
4
+ Per-call control over body, headers, region routing, and response parsing.
5
+ Mirrors the public surface of
6
6
  [`supabase_functions`](https://github.com/supabase/supabase-py/tree/main/src/functions)
7
7
  in Python.
8
8
 
@@ -27,27 +27,36 @@ functions = Supabase::Functions::Client.new(
27
27
  )
28
28
 
29
29
  # Simple invoke (POST + JSON body)
30
- response = functions.invoke("hello", body: { name: "Ada" })
31
- response.data # => parsed JSON or raw bytes
32
- response.status
33
- response.headers
30
+ raw = functions.invoke("hello", body: { name: "Ada" })
31
+ # => the raw response body as a String (default). Parsing is opt-in:
32
+
33
+ data = functions.invoke("hello", body: { name: "Ada" }, response_type: :json)
34
+ # => parsed JSON (Hash / Array / scalar). Same shape as supabase-py.
35
+
36
+ # For the legacy wrapper carrying status + headers, pass
37
+ # `return_response: true` — note: that path is deprecated.
34
38
  ```
35
39
 
36
- ### Custom method / headers / query / region
40
+ ### Custom headers / region
37
41
 
38
42
  ```ruby
39
43
  functions.invoke(
40
44
  "ingest",
41
- method: "PUT",
42
45
  headers: { "X-Trace-Id" => "abc" },
43
- query: { tenant: "x" },
44
46
  region: Supabase::Functions::Types::FunctionRegion::US_EAST_1,
45
47
  body: payload_hash
46
48
  )
47
49
  ```
48
50
 
49
- `response.data` is auto-parsed when the response `Content-Type` is JSON,
50
- otherwise the raw body. Force parsing with `response_type: :json`.
51
+ `#invoke` is always a POST there is no `method:` kwarg. There is no
52
+ `query:` kwarg either (the only query-string consumer is region routing,
53
+ which is wired up internally). Both kwargs existed historically to mirror
54
+ the supabase-js surface and were dropped in US-030 for parity with
55
+ supabase-py.
56
+
57
+ The return value is the raw response body unless you opt in with
58
+ `response_type: :json` — Content-Type is intentionally ignored (parity with
59
+ supabase-py, deliberately different from supabase-js).
51
60
 
52
61
  ### Errors
53
62
 
@@ -66,6 +75,84 @@ async = Supabase::Functions::Async::Client.new(
66
75
  )
67
76
 
68
77
  Async do
69
- response = async.invoke("hello", body: { name: "Ada" })
78
+ data = async.invoke("hello", body: { name: "Ada" })
79
+ end
80
+ ```
81
+
82
+ ## Differences from supabase-py
83
+
84
+ ### `timeout:`, `verify:`, `proxy:` are active constructor parameters
85
+
86
+ In `supabase-py` these three kwargs on `SyncFunctionsClient.__init__` are
87
+ **deprecated**: passing any of them emits a `DeprecationWarning` and the
88
+ guidance is to configure the underlying `httpx.Client` instead
89
+ (see `functions_client.py`).
90
+
91
+ In `supabase-rb` they are **active and have well-defined effects** on the
92
+ default Faraday session:
93
+
94
+ | Kwarg | Type | Default | Effect |
95
+ |-----------|-----------------|---------|---------------------------------------------------------------------------|
96
+ | `timeout` | `Numeric, nil` | `60` | Sets both `Faraday::Connection#options.timeout` and `.open_timeout` (sec).|
97
+ | `verify` | `Boolean` | `true` | Becomes `ssl: { verify: ... }` on the Faraday connection (TLS cert check).|
98
+ | `proxy` | `String, nil` | `nil` | Passed through as Faraday's `proxy:` option. |
99
+
100
+ ```ruby
101
+ functions = Supabase::Functions::Client.new(
102
+ base_url: "https://project.supabase.co/functions/v1",
103
+ headers: { "Authorization" => "Bearer #{key}" },
104
+ timeout: 30,
105
+ verify: true,
106
+ proxy: "http://corporate-proxy.local:3128"
107
+ )
108
+ ```
109
+
110
+ If you pass your own `http_client:` (a pre-built `Faraday::Connection`),
111
+ `timeout`/`verify`/`proxy` are ignored — your Faraday is used as-is.
112
+
113
+ ### Retry — opt-in via Faraday middleware
114
+
115
+ Neither `supabase-py` nor `supabase-rb` retries Edge Function calls
116
+ automatically. In Ruby, the idiomatic way to add retries is to inject a
117
+ Faraday connection with the [`faraday-retry`][faraday-retry] middleware:
118
+
119
+ ```ruby
120
+ require "faraday"
121
+ require "faraday/retry"
122
+ require "supabase/functions"
123
+
124
+ http = Faraday.new(url: "https://project.supabase.co/functions/v1") do |f|
125
+ f.request :retry,
126
+ max: 2,
127
+ interval: 0.5,
128
+ backoff_factor: 2,
129
+ retry_statuses: [429, 500, 502, 503, 504],
130
+ # `invoke` always POSTs, so opt POST into the retried set.
131
+ methods: %i[get head options put delete post],
132
+ # Defaults cover Faraday::TimeoutError + Errno::ETIMEDOUT +
133
+ # Faraday::RetriableResponse — listing them explicitly keeps the
134
+ # set intact when we also want ConnectionFailed.
135
+ exceptions: [Faraday::ConnectionFailed, Faraday::TimeoutError,
136
+ Errno::ETIMEDOUT, Faraday::RetriableResponse]
137
+ f.options.timeout = 60
138
+ f.options.open_timeout = 60
139
+ f.adapter Faraday.default_adapter
70
140
  end
141
+
142
+ functions = Supabase::Functions::Client.new(
143
+ base_url: "https://project.supabase.co/functions/v1",
144
+ headers: { "Authorization" => "Bearer #{key}" },
145
+ http_client: http
146
+ )
71
147
  ```
148
+
149
+ `faraday-retry` is not a runtime dependency of `supabase-rb`; add
150
+ `gem "faraday-retry"` to your `Gemfile` if you want this pattern.
151
+
152
+ Be mindful that `invoke` is always a POST — by default `faraday-retry`
153
+ only retries idempotent methods (`%i[delete get head options put]`), so
154
+ you must opt POST in via `methods:` above if you want POST retries.
155
+ Functions whose side effects are not idempotent should leave POST out
156
+ of `methods:` to avoid double-execution.
157
+
158
+ [faraday-retry]: https://github.com/lostisland/faraday-retry
@@ -17,13 +17,19 @@ module Supabase
17
17
  # headers: { "Authorization" => "Bearer #{key}" }
18
18
  # )
19
19
  #
20
- # response = functions.invoke("hello-world", body: { name: "Ada" })
21
- # response.data # => parsed JSON or raw bytes
22
- # response.status # => 200
23
- # response.headers # => { "content-type" => "application/json", ... }
20
+ # raw = functions.invoke("hello-world", body: { name: "Ada" })
21
+ # # => Ruby returns String; encoding depends on response_type
22
+ # # (:text UTF-8, :binary → ASCII-8BIT, :json → parsed object).
23
+ # data = functions.invoke("hello-world", body: { name: "Ada" }, response_type: :json)
24
+ # # => parsed JSON Hash / Array / scalar.
25
+ #
26
+ # JSON parsing is opt-in via `response_type: :json` — Content-Type is not
27
+ # consulted (deliberately different from supabase-js).
28
+ #
29
+ # For the legacy `Types::Response` wrapper (data + status + headers), pass
30
+ # `return_response: true` — note that `Types::Response` is deprecated and
31
+ # will be removed in a future release.
24
32
  class Client
25
- VALID_METHODS = %w[GET OPTIONS HEAD POST PUT PATCH DELETE].freeze
26
-
27
33
  attr_reader :base_url, :headers
28
34
 
29
35
  # @param base_url [String] full URL to the Edge Functions endpoint
@@ -54,24 +60,38 @@ module Supabase
54
60
 
55
61
  # Invoke an Edge Function by name.
56
62
  #
63
+ # Always POSTs. The `method:` and `query:` kwargs were dropped in US-030
64
+ # — they had no analogue in supabase-py and only existed to mirror the
65
+ # supabase-js surface (see Open Question §9.4). Region routing still
66
+ # appends `forceFunctionRegion` to the URL via the region branch below.
67
+ #
57
68
  # @param function_name [String]
58
69
  # @param body [Hash, String, nil] JSON-encoded if Hash, sent as-is if String
59
70
  # @param headers [Hash] per-invocation headers (merged over the client defaults)
60
- # @param method [String, Symbol] HTTP method, defaults to "POST"
61
71
  # @param region [String, nil] one of {Types::FunctionRegion}::ALL
62
- # @param response_type [Symbol, String] :json to parse JSON, anything else returns raw bytes
63
- # @param query [Hash, nil] extra query-string params
64
- # @return [Types::Response]
65
- def invoke(function_name, body: nil, headers: {}, method: "POST", region: nil, response_type: :text, query: nil)
72
+ # @param response_type [Symbol, String] controls how the response body
73
+ # is returned. Ruby always returns a `String` (unlike supabase-py, which
74
+ # returns `bytes` for binary). Supported values:
75
+ # * `:json` — parse the body as JSON; returns Hash / Array / scalar.
76
+ # * `:text` — return a `String` with `Encoding::UTF_8` (default).
77
+ # * `:binary` — return a `String` with `Encoding::BINARY`
78
+ # (`ASCII-8BIT`), byte-for-byte equal to the HTTP response body.
79
+ # Parsing/encoding is opt-in by the caller, never inferred from the
80
+ # response Content-Type.
81
+ # @param return_response [Boolean] when true, return the deprecated
82
+ # {Types::Response} wrapper (data + status + headers) instead of the
83
+ # bare parsed body. Default `false` (US-026). The wrapper is scheduled
84
+ # for removal — prefer reading the data directly.
85
+ # @return [Object, Types::Response] parsed body (Hash / String / Array /
86
+ # nil) by default; the deprecated `Types::Response` struct when
87
+ # `return_response: true` is passed.
88
+ def invoke(function_name, body: nil, headers: {}, region: nil, response_type: :text,
89
+ return_response: false)
66
90
  validate_function_name!(function_name)
67
-
68
- http_method = method.to_s.upcase
69
- unless VALID_METHODS.include?(http_method)
70
- raise ArgumentError, "method must be one of #{VALID_METHODS.join(', ')}"
71
- end
91
+ validate_region!(region)
72
92
 
73
93
  merged_headers = @headers.merge(headers)
74
- merged_query = (query || {}).transform_keys(&:to_s)
94
+ merged_query = {}
75
95
 
76
96
  if region && region != Types::FunctionRegion::ANY
77
97
  merged_headers["x-region"] = region
@@ -93,7 +113,7 @@ module Supabase
93
113
  end
94
114
 
95
115
  response = @session.run_request(
96
- http_method.downcase.to_sym,
116
+ :post,
97
117
  "#{@base_url}/#{function_name}",
98
118
  encoded_body,
99
119
  merged_headers
@@ -101,14 +121,13 @@ module Supabase
101
121
  req.params.update(merged_query) unless merged_query.empty?
102
122
  end
103
123
 
104
- raise_for_relay!(response)
105
124
  raise_for_status!(response)
125
+ raise_for_relay!(response)
126
+
127
+ data = parse_body(response, response_type)
128
+ return data unless return_response
106
129
 
107
- Types::Response.new(
108
- data: parse_body(response, response_type),
109
- status: response.status,
110
- headers: response.headers
111
- )
130
+ Types::Response.new(data: data, status: response.status, headers: response.headers)
112
131
  end
113
132
 
114
133
  private
@@ -134,6 +153,20 @@ module Supabase
134
153
  raise ArgumentError, "function_name must be a non-empty String"
135
154
  end
136
155
 
156
+ # Reject regions that aren't in {Types::FunctionRegion::ALL}. Nil is fine
157
+ # (means "let the server pick"); FunctionRegion::ANY is explicitly allowed
158
+ # — the AC's `|| region == FunctionRegion::ANY` clause is redundant with
159
+ # the `ALL` check (ANY is already in ALL) but kept here in spirit so
160
+ # callers passing the sentinel string `"any"` also pass.
161
+ def validate_region!(region)
162
+ return if region.nil?
163
+ return if Types::FunctionRegion::ALL.include?(region)
164
+
165
+ raise ArgumentError,
166
+ "region must be one of Supabase::Functions::Types::FunctionRegion::ALL " \
167
+ "(got #{region.inspect})"
168
+ end
169
+
137
170
  def raise_for_relay!(response)
138
171
  # The relay layer signals its own errors via this response header (set to
139
172
  # "true"). The function itself doesn't set this — only the relay.
@@ -153,14 +186,22 @@ module Supabase
153
186
  end
154
187
 
155
188
  def parse_body(response, response_type)
156
- return response.body if response.body.nil? || response.body.empty?
157
-
158
- if response_type.to_s == "json"
159
- parse_json_safe(response.body) || response.body
189
+ body = response.body
190
+ return body if body.nil?
191
+
192
+ case response_type.to_s
193
+ when "json"
194
+ return body if body.empty?
195
+
196
+ parse_json_safe(body) || body
197
+ when "binary"
198
+ # Byte-for-byte copy with BINARY (ASCII-8BIT) encoding. Faraday may
199
+ # hand us the body tagged as UTF-8 even when it's raw bytes; force
200
+ # the encoding so callers get a stable, lossless String.
201
+ body.dup.force_encoding(Encoding::BINARY)
160
202
  else
161
- # Auto-detect JSON: if the server says application/json, parse it.
162
- content_type = response.headers["content-type"] || response.headers["Content-Type"] || ""
163
- content_type.include?("application/json") ? (parse_json_safe(response.body) || response.body) : response.body
203
+ # :text (default) return a UTF-8 String.
204
+ body.dup.force_encoding(Encoding::UTF_8)
164
205
  end
165
206
  end
166
207
 
@@ -3,9 +3,30 @@
3
3
  module Supabase
4
4
  module Functions
5
5
  module Types
6
- # Returned by Client#invoke. `data` is parsed JSON when response_type: :json
7
- # (or auto-detected from a JSON Content-Type), otherwise the raw response body.
8
- Response = Struct.new(:data, :status, :headers, keyword_init: true)
6
+ # @deprecated US-026: `Client#invoke` now returns parsed body directly.
7
+ # Pass `return_response: true` to {Supabase::Functions::Client#invoke}
8
+ # to keep building this wrapper temporarily — both paths emit a
9
+ # one-time deprecation warning. Slated for removal in a future
10
+ # release. Read `data` directly from `invoke`'s return value instead.
11
+ class Response < Struct.new(:data, :status, :headers, keyword_init: true)
12
+ DEPRECATION_MESSAGE =
13
+ "[DEPRECATION] Supabase::Functions::Types::Response is deprecated " \
14
+ "(US-026): Supabase::Functions::Client#invoke now returns the parsed " \
15
+ "body directly. Pass `return_response: true` only as a temporary " \
16
+ "compatibility shim — this struct will be removed in a future release."
17
+
18
+ class << self
19
+ attr_accessor :_deprecation_warned
20
+ end
21
+
22
+ def self.new(*args, **kwargs)
23
+ unless _deprecation_warned
24
+ Kernel.warn(DEPRECATION_MESSAGE)
25
+ self._deprecation_warned = true
26
+ end
27
+ super
28
+ end
29
+ end
9
30
 
10
31
  # Supabase Edge Function regions. Use FunctionRegion::US_EAST_1 etc., or pass
11
32
  # the bare string ("us-east-1") to Client#invoke — both are accepted.
@@ -37,6 +37,8 @@ module Supabase
37
37
 
38
38
  def build_session
39
39
  Faraday.new(url: @base_url, ssl: { verify: @verify }, proxy: @proxy) do |f|
40
+ f.request :url_encoded
41
+ f.options.params_encoder = Faraday::FlatParamsEncoder
40
42
  if @timeout
41
43
  f.options.timeout = @timeout
42
44
  f.options.open_timeout = @timeout
@@ -12,6 +12,10 @@ module Supabase
12
12
  "Content-Type" => "application/json"
13
13
  }.freeze
14
14
 
15
+ # Per-request HTTP timeout (seconds) applied when callers don't supply
16
+ # one. Mirrors supabase-py's `DEFAULT_POSTGREST_CLIENT_TIMEOUT = 120`.
17
+ DEFAULT_POSTGREST_TIMEOUT = 120
18
+
15
19
  # Sync PostgREST client. Constructed once per project; reused across requests.
16
20
  #
17
21
  # ```ruby
@@ -42,7 +46,7 @@ module Supabase
42
46
  @http_client = http_client
43
47
  @verify = verify
44
48
  @proxy = proxy
45
- @timeout = timeout
49
+ @timeout = timeout.nil? ? DEFAULT_POSTGREST_TIMEOUT : timeout
46
50
  end
47
51
 
48
52
  # Set the Authorization header to either Bearer (token) or Basic
@@ -132,8 +136,9 @@ module Supabase
132
136
  "POST"
133
137
  end
134
138
 
135
- headers = @headers.dup
139
+ headers = {}
136
140
  headers["Prefer"] = "count=#{count}" if count
141
+ headers.merge!(@headers)
137
142
 
138
143
  if %w[HEAD GET].include?(method)
139
144
  query = stringify_keys(params)
@@ -167,6 +172,8 @@ module Supabase
167
172
 
168
173
  def build_session
169
174
  Faraday.new(url: @base_url, ssl: { verify: @verify }, proxy: @proxy) do |f|
175
+ f.request :url_encoded
176
+ f.options.params_encoder = Faraday::FlatParamsEncoder
170
177
  if @timeout
171
178
  f.options.timeout = @timeout
172
179
  f.options.open_timeout = @timeout
@@ -7,18 +7,30 @@ module Supabase
7
7
  # Mirrors supabase-py's APIError — exposes :message, :code, :hint, :details
8
8
  # plus the raw error hash via {#raw}.
9
9
  class APIError < StandardError
10
- attr_reader :raw, :message, :code, :hint, :details
10
+ attr_reader :raw, :code, :hint, :details
11
11
 
12
12
  # @param error [Hash] parsed JSON body from a PostgREST error response
13
13
  def initialize(error = {})
14
- @raw = error || {}
15
- @message = @raw["message"] || @raw[:message]
16
- @code = @raw["code"] || @raw[:code]
17
- @hint = @raw["hint"] || @raw[:hint]
18
- @details = @raw["details"] || @raw[:details]
14
+ if error.is_a?(Hash)
15
+ @raw = error
16
+ @message = @raw["message"] || @raw[:message]
17
+ @code = @raw["code"] || @raw[:code]
18
+ @hint = @raw["hint"] || @raw[:hint]
19
+ @details = @raw["details"] || @raw[:details]
20
+ elsif error.nil?
21
+ @raw = {}
22
+ else
23
+ @raw = error
24
+ @message = "PostgREST returned non-hash error: #{error.inspect}"
25
+ end
19
26
  super(to_s)
20
27
  end
21
28
 
29
+ # Override StandardError#message so the field-level value is non-nil.
30
+ def message
31
+ @message.to_s
32
+ end
33
+
22
34
  def to_s
23
35
  parts = []
24
36
  parts << "Error #{@code}:" if @code
@@ -338,18 +338,12 @@ module Supabase
338
338
 
339
339
  private
340
340
 
341
- # PostgREST allows the same query key to appear multiple times (e.g. multiple
342
- # `order=` or repeated filter columns). Ruby Hash collapses by key, so we
343
- # store repeats as Arrays — Faraday emits them as multiple query params.
344
341
  def add_param(params, key, value)
345
- existing = params[key]
346
- params[key] = if existing.is_a?(Array)
347
- existing + [value]
348
- elsif existing
349
- [existing, value]
350
- else
351
- value
352
- end
342
+ if params.key?(key)
343
+ params[key] = Array(params[key]) << value
344
+ else
345
+ params[key] = value
346
+ end
353
347
  params
354
348
  end
355
349
  end
@@ -31,6 +31,13 @@ websocket-client-simple adapter runs the read loop on a background thread,
31
31
  which means listener callbacks fire on that thread — bring your own
32
32
  thread-safety to anything they touch.
33
33
 
34
+ The same caveat applies to the channel rejoin timer: after a join error or
35
+ timeout, the channel schedules a retry via `Supabase::Realtime::Timer`, which
36
+ runs the rejoin on a background thread once the backoff delay elapses.
37
+ Anything the rejoin path mutates (state shared with listener callbacks, the
38
+ underlying `Socket`, etc.) must tolerate being touched from an arbitrary
39
+ thread — consistent with the existing listener-thread model.
40
+
34
41
  ## Usage
35
42
 
36
43
  ```ruby
@@ -64,6 +71,110 @@ channel.presence.on_join { |key, presence| ... }
64
71
  channel.presence.on_leave { |key, presence| ... }
65
72
  ```
66
73
 
74
+ ## Модель конкурентности
75
+
76
+ Все пользовательские колбэки realtime исполняются на **read-треде websocket-
77
+ гема** — том же, который читает фреймы из сокета. Транспорт по умолчанию
78
+ (`Sockets::WebsocketClientSimple`) построен на
79
+ [`websocket-client-simple`](https://github.com/shokai/websocket-client-simple),
80
+ который спавнит этот тред сам в `connect`-блоке. Если вы инжектите свой
81
+ адаптер (`include Supabase::Realtime::Socket`), правила те же — `Client`
82
+ дёргает `message_callbacks` синхронно из `fire_message`, поэтому колбэк
83
+ исполняется на том же треде, который вы пришлёте.
84
+
85
+ **Где какие колбэки исполняются:**
86
+
87
+ | Колбэк | Тред |
88
+ |-----------------------------------------------------------------------------------|----------------------------------------------------------------------------|
89
+ | `channel.on_broadcast`, `on_postgres_changes`, `on_system`, `on_close`, `on_error`| read-тред транспорта |
90
+ | `presence.on_sync`, `on_join`, `on_leave` | read-тред транспорта (но фанятся ПОСЛЕ освобождения внутреннего mutex'а) |
91
+ | Блок `channel.subscribe { \|status, err\| ... }` | read-тред транспорта |
92
+ | `push.receive(:ok / :error) { ... }` | read-тред (если ответ пришёл) ИЛИ push-timeout-тред (если сработал watchdog) |
93
+ | `client.on_reconnect_failed { \|err\| ... }` | reconnect-тред (см. секцию ниже) |
94
+
95
+ **Что от вас требуется внутри колбэка:**
96
+
97
+ 1. **Не блокировать.** Любая длительная операция в колбэке (синхронный HTTP,
98
+ тяжёлый БД-запрос, `sleep`, `Mutex#synchronize` на чужом локе) задерживает
99
+ обработку следующих фреймов на том же сокете. Если задержка превысит
100
+ `heartbeat_interval` — heartbeat не отправится, сервер дропнет соединение,
101
+ и стартует фоновый реконнект. Правильный паттерн — внутри колбэка только
102
+ декодинг payload + пушок в очередь / Sidekiq / собственный пул тредов;
103
+ тяжёлая работа — снаружи.
104
+
105
+ 2. **Тред-безопасность общего состояния.** Колбэк работает на read-треде, а
106
+ ваш основной код (Rails-контроллер, Sidekiq-воркер, ActiveRecord-
107
+ соединение) — на других. Любое разделяемое состояние, к которому вы
108
+ обращаетесь и из колбэка, и снаружи, должно быть защищено вами — мьютексом,
109
+ `Concurrent::Map`, атомиком, очередью с потокобезопасной семантикой и т.п.
110
+ Внутреннее состояние гема уже синхронизировано: `presence.state` возвращает
111
+ shallow-копию снимка (US-007), `Push` разруливает гонку «timeout vs reply»
112
+ под собственным мьютексом, `Channel#join_state` обновляется только из read-
113
+ треда. Всё, что лежит ВНЕ гема, — на пользователе.
114
+
115
+ 3. **Исключения уже изолированы.** `raise StandardError` из любого
116
+ пользовательского колбэка ловится `CallbackSafety` (US-002), логируется в
117
+ `Realtime::Client.new(logger:)` (или `$stderr` через `Kernel#warn`, если
118
+ логгер не задан) и **не убивает read-тред** — соседние колбэки и
119
+ следующие фреймы продолжают работать. Это страховка, а не штатный канал
120
+ ошибок: пользовательский код всё равно должен ловить свои ошибки явно,
121
+ иначе лог быстро превратится в шум.
122
+
123
+ **Отличие от `supabase-py`.** Python-клиент построен на `asyncio`: read-loop
124
+ там — это `await`-цикл внутри одной корутины, и колбэки (`async def`)
125
+ дёргаются `await callback(payload)` в том же event-loop'е. Конкуренции по
126
+ shared state нет в принципе (asyncio однопоточен), но блокирующий колбэк
127
+ блокирует весь loop, а не «один тред из пула». В rb read-loop — это
128
+ настоящий `Thread`, поэтому правила противоположные: блокировка локальна
129
+ (страдает только один сокет), но shared state требует реальной
130
+ синхронизации.
131
+
132
+ ## Realtime reconnect: отличие от supabase-py
133
+
134
+ `Realtime::Client` отличается от `supabase-py` (`realtime/_async/client.py:141-193`)
135
+ тем, как сообщает о реконнекте — это намеренное отклонение, продиктованное
136
+ разницей моделей конкурентности (треды vs `asyncio`):
137
+
138
+ - **Реконнект всегда фоновый.** Когда сервер закрывает сокет, rb запускает
139
+ отдельный тред с экспоненциальным бэкоффом (`initial_backoff` ×
140
+ `2^(n-1)`, капается на 60 с) и пытается переподключиться до `max_retries`
141
+ раз. В py та же логика «живёт» внутри корутины `connect()` — она
142
+ возвращает управление либо когда сокет встал, либо когда был исчерпан
143
+ бюджет ретраев (через `raise`).
144
+ - **Окончательная неудача доходит через колбэк, а не исключение.** После
145
+ исчерпания `max_retries` rb вызывает каждый зарегистрированный колбэк
146
+ `on_reconnect_failed { |last_error| ... }` ровно один раз, с последним
147
+ пойманным исключением транспорта. Колбэки оборачиваются `CallbackSafety`
148
+ — раис в одном пользовательском блоке не блокирует остальные:
149
+
150
+ ```ruby
151
+ client.on_reconnect_failed do |err|
152
+ logger.error("realtime down for good: #{err.class}: #{err.message}")
153
+ notify_oncall!
154
+ end
155
+ ```
156
+
157
+ Если `disconnect` был вызван явно — колбэк не дёргается (это была
158
+ намеренная остановка, не сбой).
159
+ - **Явный `connect` к недоступному серверу не «успешен молча».** Первичный
160
+ `client.connect` ходит на транспорт синхронно: если `Socket#connect`
161
+ бросает (типично `Errno::ECONNREFUSED` / `SocketError` от
162
+ `websocket-client-simple`), это исключение пробрасывается из
163
+ `Client#connect` сразу, без внутреннего ретрая. Поведение совпадает с
164
+ py: `await connect()` тоже бросает на постоянной ошибке. Бэкграунд-цикл
165
+ и `on_reconnect_failed` относятся ИСКЛЮЧИТЕЛЬНО к ситуации
166
+ «соединение установилось и потом упало», а не «никогда не поднималось
167
+ первый раз».
168
+
169
+ Сводка контракта:
170
+
171
+ | Сценарий | Поведение |
172
+ |-------------------------------------------|--------------------------------------|
173
+ | Первичный `connect`, сервер недоступен | `raise` (как в py) |
174
+ | Установленный сокет, сервер дропнул | бэкграунд-реконнект до `max_retries` |
175
+ | Все `max_retries` исчерпаны | `on_reconnect_failed.(last_error)` |
176
+ | Явный `disconnect` во время бэкоффа | колбэк НЕ вызывается |
177
+
67
178
  ## Testing
68
179
 
69
180
  For unit testing, use `Supabase::Realtime::TestSocket` — an in-memory Socket
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supabase
4
+ module Realtime
5
+ # Helper for invoking user-supplied callbacks (channel/presence/push hooks)
6
+ # from the read-thread without letting a bad block tear down dispatch.
7
+ #
8
+ # supabase-py uses Python's exception-on-task semantics where one task's
9
+ # exception doesn't kill the listener (`realtime/_async/client.py:113-117`
10
+ # catches at the validation step; user-callback exceptions surface through
11
+ # `asyncio` task results). The rb port runs callbacks on the websocket-gem
12
+ # read-thread synchronously, so an unguarded exception kills the loop and
13
+ # silently stops delivery to *every* channel on the client. This helper
14
+ # rescues `StandardError`, logs a `warn` with the event name and the
15
+ # exception class/message, and lets the read-thread continue.
16
+ #
17
+ # @see US-002 acceptance criteria — "колбэк, бросающий исключение, не мешает
18
+ # доставке следующего сообщения" / "исключение в колбэке одного канала не
19
+ # мешает доставке сообщений в соседний".
20
+ module CallbackSafety
21
+ module_function
22
+
23
+ # Invoke `block` and swallow any `StandardError`, logging it via `logger`
24
+ # at `warn` level. When `logger` is nil or doesn't respond to `warn`,
25
+ # falls back to `Kernel#warn` ($stderr) so an unconfigured client still
26
+ # leaves a trace instead of failing silently.
27
+ def safe(logger, event_name)
28
+ yield
29
+ rescue StandardError => e
30
+ msg = "[Supabase::Realtime] callback for '#{event_name}' raised " \
31
+ "#{e.class}: #{e.message}"
32
+ if logger.respond_to?(:warn)
33
+ logger.warn(msg)
34
+ else
35
+ Kernel.warn(msg)
36
+ end
37
+ nil
38
+ end
39
+ end
40
+ end
41
+ end