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.
- checksums.yaml +4 -4
- data/lib/supabase/auth/README.md +10 -4
- data/lib/supabase/auth/admin_api.rb +4 -0
- data/lib/supabase/auth/admin_mfa_api.rb +30 -0
- data/lib/supabase/auth/async/admin_api.rb +4 -1
- data/lib/supabase/auth/async/admin_mfa_api.rb +15 -0
- data/lib/supabase/auth/async/client.rb +2 -4
- data/lib/supabase/auth/async.rb +1 -0
- data/lib/supabase/auth/client.rb +71 -31
- data/lib/supabase/auth/helpers.rb +4 -0
- data/lib/supabase/auth/types.rb +14 -5
- data/lib/supabase/auth.rb +1 -0
- data/lib/supabase/client.rb +103 -22
- data/lib/supabase/client_options.rb +1 -1
- data/lib/supabase/functions/README.md +99 -12
- data/lib/supabase/functions/client.rb +72 -31
- data/lib/supabase/functions/types.rb +24 -3
- data/lib/supabase/postgrest/async/client.rb +2 -0
- data/lib/supabase/postgrest/client.rb +9 -2
- data/lib/supabase/postgrest/errors.rb +18 -6
- data/lib/supabase/postgrest/request_builder.rb +5 -11
- data/lib/supabase/realtime/README.md +111 -0
- data/lib/supabase/realtime/callback_safety.rb +41 -0
- data/lib/supabase/realtime/channel.rb +89 -23
- data/lib/supabase/realtime/client.rb +130 -44
- data/lib/supabase/realtime/errors.rb +0 -13
- data/lib/supabase/realtime/message.rb +13 -3
- data/lib/supabase/realtime/presence.rb +84 -32
- data/lib/supabase/realtime/push.rb +11 -2
- data/lib/supabase/realtime/timer.rb +72 -0
- data/lib/supabase/realtime.rb +2 -1
- data/lib/supabase/storage/README.md +117 -0
- data/lib/supabase/storage/async/client.rb +7 -4
- data/lib/supabase/storage/client.rb +16 -4
- data/lib/supabase/storage/file_api.rb +36 -9
- data/lib/supabase/storage/request.rb +3 -1
- data/lib/supabase/storage/utils.rb +15 -1
- data/lib/supabase/version.rb +1 -1
- data/lib/supabase.rb +0 -7
- metadata +33 -16
- 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,
|
|
5
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
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
|
-
`
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
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]
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
#
|
|
162
|
-
|
|
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
|
-
#
|
|
7
|
-
#
|
|
8
|
-
|
|
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 =
|
|
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, :
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|