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,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "callback_safety"
|
|
4
|
+
|
|
3
5
|
module Supabase
|
|
4
6
|
module Realtime
|
|
5
7
|
# Tracks presence state for one channel and implements the Phoenix Presence
|
|
@@ -9,56 +11,79 @@ module Supabase
|
|
|
9
11
|
# shape before being stored or emitted, so listener callbacks receive
|
|
10
12
|
# `(key, current_presences, new_presences)` with `presence_ref` keys.
|
|
11
13
|
class Presence
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def initialize
|
|
14
|
+
def initialize(logger: nil)
|
|
15
15
|
@state = {}
|
|
16
|
+
# Guards every read/write of @state so a reader thread iterating over
|
|
17
|
+
# `presence_state` cannot collide with the realtime read-thread
|
|
18
|
+
# applying inbound presence_state / presence_diff frames. US-007 stress
|
|
19
|
+
# spec demonstrates the bare-Hash version raises "can't add a new key
|
|
20
|
+
# into hash during iteration" under load; with the mutex + snapshot
|
|
21
|
+
# accessor the same scenario stays clean. Callbacks are fanned out
|
|
22
|
+
# AFTER the mutex is released to avoid user code reentering `state`
|
|
23
|
+
# under the same lock.
|
|
24
|
+
@mutex = Mutex.new
|
|
16
25
|
@on_sync_callbacks = []
|
|
17
26
|
@on_join_callbacks = []
|
|
18
27
|
@on_leave_callbacks = []
|
|
28
|
+
@logger = logger
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Snapshot of the current presence state. Returns a shallow dup of the
|
|
32
|
+
# internal hash so callers can iterate safely while the read-thread
|
|
33
|
+
# continues to apply inbound diffs (US-007 thread safety AC).
|
|
34
|
+
def state
|
|
35
|
+
@mutex.synchronize { @state.dup }
|
|
19
36
|
end
|
|
20
37
|
|
|
21
38
|
# First snapshot after joining: diff against the (possibly empty) local
|
|
22
39
|
# state and apply the joins/leaves through the same code path as
|
|
23
40
|
# `sync_diff`.
|
|
24
41
|
def sync_state(raw_state)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
42
|
+
events = nil
|
|
43
|
+
@mutex.synchronize do
|
|
44
|
+
new_state = self.class.transform_state(raw_state)
|
|
45
|
+
joins = {}
|
|
46
|
+
leaves = @state.reject { |k, _| new_state.key?(k) }
|
|
47
|
+
|
|
48
|
+
new_state.each do |key, presences|
|
|
49
|
+
current = @state[key] || []
|
|
50
|
+
|
|
51
|
+
if current.any?
|
|
52
|
+
current_refs = current.map { |p| p["presence_ref"] }
|
|
53
|
+
new_refs = presences.map { |p| p["presence_ref"] }
|
|
54
|
+
joined_presences = presences.reject { |p| current_refs.include?(p["presence_ref"]) }
|
|
55
|
+
left_presences = current.reject { |p| new_refs.include?(p["presence_ref"]) }
|
|
56
|
+
joins[key] = joined_presences if joined_presences.any?
|
|
57
|
+
leaves[key] = left_presences if left_presences.any?
|
|
58
|
+
else
|
|
59
|
+
joins[key] = presences
|
|
60
|
+
end
|
|
41
61
|
end
|
|
42
|
-
end
|
|
43
62
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
63
|
+
events = apply_sync_diff_locked(joins, leaves)
|
|
64
|
+
end
|
|
65
|
+
fire_events(events)
|
|
66
|
+
fire_sync_callbacks
|
|
67
|
+
state
|
|
47
68
|
end
|
|
48
69
|
|
|
49
70
|
# Subsequent presence_diff messages: apply joins/leaves to the local state.
|
|
50
71
|
# Raw input is transformed before being applied.
|
|
51
72
|
def sync_diff(raw_diff)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
73
|
+
events = nil
|
|
74
|
+
@mutex.synchronize do
|
|
75
|
+
joins = self.class.transform_state(raw_diff["joins"] || {})
|
|
76
|
+
leaves = self.class.transform_state(raw_diff["leaves"] || {})
|
|
77
|
+
events = apply_sync_diff_locked(joins, leaves)
|
|
78
|
+
end
|
|
79
|
+
fire_events(events)
|
|
80
|
+
fire_sync_callbacks
|
|
81
|
+
state
|
|
57
82
|
end
|
|
58
83
|
|
|
59
84
|
# Flat list of every presence currently tracked.
|
|
60
85
|
def list
|
|
61
|
-
@state.values.flatten
|
|
86
|
+
@mutex.synchronize { @state.values.flatten }
|
|
62
87
|
end
|
|
63
88
|
|
|
64
89
|
def on_sync(&block)
|
|
@@ -108,7 +133,14 @@ module Supabase
|
|
|
108
133
|
|
|
109
134
|
private
|
|
110
135
|
|
|
111
|
-
|
|
136
|
+
# Mutates @state and returns the join/leave events that should be fanned
|
|
137
|
+
# out to user callbacks after the mutex is released. Order matches the
|
|
138
|
+
# py reference (`AsyncRealtimePresence._sync_diff`): joins applied first,
|
|
139
|
+
# then leaves; for leaves, the key is removed from @state once empty so
|
|
140
|
+
# the next sync sees it as gone.
|
|
141
|
+
def apply_sync_diff_locked(joins, leaves)
|
|
142
|
+
events = []
|
|
143
|
+
|
|
112
144
|
joins.each do |key, new_presences|
|
|
113
145
|
current_presences = @state[key] || []
|
|
114
146
|
@state[key] = new_presences
|
|
@@ -119,7 +151,7 @@ module Supabase
|
|
|
119
151
|
@state[key] = keep_from_current + @state[key]
|
|
120
152
|
end
|
|
121
153
|
|
|
122
|
-
|
|
154
|
+
events << [:join, key, current_presences, new_presences]
|
|
123
155
|
end
|
|
124
156
|
|
|
125
157
|
leaves.each do |key, left_presences|
|
|
@@ -130,10 +162,30 @@ module Supabase
|
|
|
130
162
|
remaining = current_presences.reject { |p| remove_refs.include?(p["presence_ref"]) }
|
|
131
163
|
@state[key] = remaining
|
|
132
164
|
|
|
133
|
-
|
|
165
|
+
events << [:leave, key, remaining, left_presences]
|
|
134
166
|
|
|
135
167
|
@state.delete(key) if remaining.empty?
|
|
136
168
|
end
|
|
169
|
+
|
|
170
|
+
events
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def fire_events(events)
|
|
174
|
+
events.each do |kind, key, current_or_remaining, new_or_left|
|
|
175
|
+
callbacks = kind == :join ? @on_join_callbacks : @on_leave_callbacks
|
|
176
|
+
label = kind == :join ? "presence_join" : "presence_leave"
|
|
177
|
+
callbacks.each do |cb|
|
|
178
|
+
CallbackSafety.safe(@logger, label) do
|
|
179
|
+
cb.call(key, current_or_remaining, new_or_left)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def fire_sync_callbacks
|
|
186
|
+
@on_sync_callbacks.each do |cb|
|
|
187
|
+
CallbackSafety.safe(@logger, "presence_sync") { cb.call }
|
|
188
|
+
end
|
|
137
189
|
end
|
|
138
190
|
end
|
|
139
191
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "callback_safety"
|
|
3
4
|
require_relative "types"
|
|
4
5
|
|
|
5
6
|
module Supabase
|
|
@@ -32,7 +33,7 @@ module Supabase
|
|
|
32
33
|
def receive(status, &block)
|
|
33
34
|
if @received_status == status
|
|
34
35
|
# Reply already arrived before this handler was attached — fire immediately.
|
|
35
|
-
block.call(@received_payload)
|
|
36
|
+
CallbackSafety.safe(logger, "push_receive:#{status}") { block.call(@received_payload) }
|
|
36
37
|
else
|
|
37
38
|
@handlers[status] << block
|
|
38
39
|
end
|
|
@@ -50,7 +51,9 @@ module Supabase
|
|
|
50
51
|
@received_payload = payload
|
|
51
52
|
end
|
|
52
53
|
cancel_timeout
|
|
53
|
-
@handlers[status].each
|
|
54
|
+
@handlers[status].each do |h|
|
|
55
|
+
CallbackSafety.safe(logger, "push_receive:#{status}") { h.call(payload) }
|
|
56
|
+
end
|
|
54
57
|
end
|
|
55
58
|
|
|
56
59
|
# Schedule a TIMEOUT resolution if no reply arrives within `seconds`.
|
|
@@ -85,6 +88,12 @@ module Supabase
|
|
|
85
88
|
thread&.kill if thread && thread != Thread.current
|
|
86
89
|
end
|
|
87
90
|
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def logger
|
|
95
|
+
@channel.respond_to?(:logger) ? @channel.logger : nil
|
|
96
|
+
end
|
|
88
97
|
end
|
|
89
98
|
end
|
|
90
99
|
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Supabase
|
|
4
|
+
module Realtime
|
|
5
|
+
# Reschedulable timer with caller-controlled backoff. Ports supabase-py's
|
|
6
|
+
# AsyncTimer to Ruby threads (`realtime/_async/timer.py:24-29`): each
|
|
7
|
+
# `schedule_timeout` cancels the previous tick, increments `tries`, and
|
|
8
|
+
# schedules a fresh tick after `backoff.call(tries + 1)` seconds. So the
|
|
9
|
+
# first call advances `tries` 0→1 and waits `backoff.call(2)`; the second
|
|
10
|
+
# call advances `tries` 1→2 and waits `backoff.call(3)`; and so on. `reset`
|
|
11
|
+
# cancels any pending tick and zeroes the counter.
|
|
12
|
+
#
|
|
13
|
+
# The `tries + 1` offset matches py 1:1 (US-006). For example, with the
|
|
14
|
+
# channel's `lambda tries: 2**tries` the delay curve is 4, 8, 16, 32, 64 s
|
|
15
|
+
# for the first five attempts — identical to py's rejoin timer.
|
|
16
|
+
#
|
|
17
|
+
# Used by Realtime's reconnect/rejoin loops to apply exponential backoff
|
|
18
|
+
# without re-implementing thread bookkeeping at every call site.
|
|
19
|
+
class Timer
|
|
20
|
+
attr_reader :tries
|
|
21
|
+
|
|
22
|
+
# @param callback [#call] invoked after each successful tick.
|
|
23
|
+
# @param backoff [#call] receives `tries + 1` (1-indexed at first call)
|
|
24
|
+
# and returns the delay in seconds for the next tick. Matches py's
|
|
25
|
+
# `timer_calc(self.tries + 1)` contract.
|
|
26
|
+
def initialize(callback:, backoff:)
|
|
27
|
+
@callback = callback
|
|
28
|
+
@backoff = backoff
|
|
29
|
+
@tries = 0
|
|
30
|
+
@thread = nil
|
|
31
|
+
@mutex = Mutex.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Cancel any pending tick and schedule a new one. Mirrors py
|
|
35
|
+
# (`timer.py:24-29`): bump `tries` first, then sleep for
|
|
36
|
+
# `backoff.call(tries + 1)` seconds before invoking `callback`. On the
|
|
37
|
+
# first call this yields `tries = 1` BEFORE the sleep, so by the time
|
|
38
|
+
# the callback fires the counter already reflects the attempt number.
|
|
39
|
+
def schedule_timeout
|
|
40
|
+
delay = nil
|
|
41
|
+
@mutex.synchronize do
|
|
42
|
+
kill_thread
|
|
43
|
+
@tries += 1
|
|
44
|
+
delay = @backoff.call(@tries + 1)
|
|
45
|
+
@thread = Thread.new do
|
|
46
|
+
Thread.current.report_on_exception = false
|
|
47
|
+
sleep(delay)
|
|
48
|
+
@callback.call
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
self
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Cancel the pending tick and reset the retry counter to zero. Idempotent.
|
|
55
|
+
def reset
|
|
56
|
+
@mutex.synchronize do
|
|
57
|
+
kill_thread
|
|
58
|
+
@tries = 0
|
|
59
|
+
end
|
|
60
|
+
self
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def kill_thread
|
|
66
|
+
thread = @thread
|
|
67
|
+
@thread = nil
|
|
68
|
+
thread.kill if thread && thread != Thread.current
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/supabase/realtime.rb
CHANGED
|
@@ -4,13 +4,14 @@ require_relative "realtime/version"
|
|
|
4
4
|
require_relative "realtime/errors"
|
|
5
5
|
require_relative "realtime/types"
|
|
6
6
|
require_relative "realtime/transformers"
|
|
7
|
+
require_relative "realtime/callback_safety"
|
|
7
8
|
require_relative "realtime/message"
|
|
8
9
|
require_relative "realtime/presence"
|
|
9
10
|
require_relative "realtime/push"
|
|
11
|
+
require_relative "realtime/timer"
|
|
10
12
|
require_relative "realtime/socket"
|
|
11
13
|
require_relative "realtime/channel"
|
|
12
14
|
require_relative "realtime/client"
|
|
13
|
-
require_relative "realtime/test_socket"
|
|
14
15
|
|
|
15
16
|
module Supabase
|
|
16
17
|
module Realtime
|
|
@@ -58,6 +58,37 @@ Upload accepts `String` (raw bytes), any `IO`, `StringIO`, or `Pathname`.
|
|
|
58
58
|
Multipart encoding is handled by `faraday-multipart`. Metadata Hashes are
|
|
59
59
|
base64-encoded into the `x-metadata` header automatically.
|
|
60
60
|
|
|
61
|
+
### Storage upload
|
|
62
|
+
|
|
63
|
+
`bucket.upload(path, file)` interprets its `file` argument by **class**, not
|
|
64
|
+
by content:
|
|
65
|
+
|
|
66
|
+
> **String = raw bytes; Pathname = file path.**
|
|
67
|
+
|
|
68
|
+
To avoid the most common porting mistake from supabase-py/storage3:
|
|
69
|
+
|
|
70
|
+
- **`String` = raw bytes** — the value is uploaded verbatim. Even if the string
|
|
71
|
+
*looks* like a file path (`"user1.png"`), nothing is read from disk. This
|
|
72
|
+
matches storage3's `bytes`/`IO` contract.
|
|
73
|
+
- **`Pathname` = file path** — the file at that location on disk is opened
|
|
74
|
+
and streamed.
|
|
75
|
+
- `IO` / `StringIO` (or any `#read`-able) — streamed as-is.
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
# String → raw bytes (the literal characters "hello" are uploaded)
|
|
79
|
+
bucket.upload("greeting.txt", "hello")
|
|
80
|
+
|
|
81
|
+
# String holding bytes read from disk → uploaded as those bytes
|
|
82
|
+
bucket.upload("user1.png", File.binread("user1.png"), content_type: "image/png")
|
|
83
|
+
|
|
84
|
+
# Pathname → file on disk is opened and streamed
|
|
85
|
+
bucket.upload("user1.png", Pathname.new("user1.png"), content_type: "image/png")
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
If you pass a `String` expecting "upload the file at this path", you will
|
|
89
|
+
upload the path string itself. Wrap it in `Pathname.new(...)` or pass
|
|
90
|
+
`File.binread(...)` / `File.open(...)`.
|
|
91
|
+
|
|
61
92
|
### Signed URLs
|
|
62
93
|
|
|
63
94
|
```ruby
|
|
@@ -106,3 +137,89 @@ Async do
|
|
|
106
137
|
data = bucket.download("user1.png")
|
|
107
138
|
end
|
|
108
139
|
```
|
|
140
|
+
|
|
141
|
+
## Differences from supabase-py
|
|
142
|
+
|
|
143
|
+
### `timeout:`, `verify:`, `proxy:` are active constructor parameters
|
|
144
|
+
|
|
145
|
+
In `supabase-py` (`storage3`) these three kwargs on `SyncStorageClient.__init__`
|
|
146
|
+
are **deprecated**: passing any of them emits a `DeprecationWarning` and the
|
|
147
|
+
guidance is to configure the underlying `httpx.Client` instead
|
|
148
|
+
(see `storage3/_sync/client.py`).
|
|
149
|
+
|
|
150
|
+
In `supabase-rb` they are **active and have well-defined effects** on the
|
|
151
|
+
default Faraday session:
|
|
152
|
+
|
|
153
|
+
| Kwarg | Type | Default | Effect |
|
|
154
|
+
|-----------|-----------------|---------|---------------------------------------------------------------------------|
|
|
155
|
+
| `timeout` | `Numeric, nil` | `20` | Sets both `Faraday::Connection#options.timeout` and `.open_timeout` (sec).|
|
|
156
|
+
| `verify` | `Boolean` | `true` | Becomes `ssl: { verify: ... }` on the Faraday connection (TLS cert check).|
|
|
157
|
+
| `proxy` | `String, nil` | `nil` | Passed through as Faraday's `proxy:` option. |
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
storage = Supabase::Storage::Client.new(
|
|
161
|
+
base_url: "https://project.supabase.co/storage/v1",
|
|
162
|
+
headers: { "apikey" => key, "Authorization" => "Bearer #{token}" },
|
|
163
|
+
timeout: 30,
|
|
164
|
+
verify: true,
|
|
165
|
+
proxy: "http://corporate-proxy.local:3128"
|
|
166
|
+
)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The 20-second default mirrors `storage3`'s `DEFAULT_TIMEOUT`. If you pass
|
|
170
|
+
your own `http_client:` (a pre-built `Faraday::Connection`),
|
|
171
|
+
`timeout`/`verify`/`proxy` are ignored — your Faraday is used as-is.
|
|
172
|
+
|
|
173
|
+
### Retry — opt-in via Faraday middleware
|
|
174
|
+
|
|
175
|
+
Neither `supabase-py` (`storage3`) nor `supabase-rb` retries storage
|
|
176
|
+
requests automatically. In Ruby, the idiomatic way to add retries is to
|
|
177
|
+
inject a Faraday connection with the [`faraday-retry`][faraday-retry]
|
|
178
|
+
middleware:
|
|
179
|
+
|
|
180
|
+
```ruby
|
|
181
|
+
require "faraday"
|
|
182
|
+
require "faraday/retry"
|
|
183
|
+
require "faraday/follow_redirects"
|
|
184
|
+
require "faraday/multipart"
|
|
185
|
+
require "supabase/storage"
|
|
186
|
+
|
|
187
|
+
http = Faraday.new(url: "https://project.supabase.co/storage/v1/") do |f|
|
|
188
|
+
f.request :retry,
|
|
189
|
+
max: 2,
|
|
190
|
+
interval: 0.5,
|
|
191
|
+
backoff_factor: 2,
|
|
192
|
+
retry_statuses: [429, 500, 502, 503, 504],
|
|
193
|
+
# Defaults cover Faraday::TimeoutError + Errno::ETIMEDOUT +
|
|
194
|
+
# Faraday::RetriableResponse — listing them explicitly keeps the
|
|
195
|
+
# set intact when we also want ConnectionFailed.
|
|
196
|
+
exceptions: [Faraday::ConnectionFailed, Faraday::TimeoutError,
|
|
197
|
+
Errno::ETIMEDOUT, Faraday::RetriableResponse]
|
|
198
|
+
# Keep the middleware stack the built-in Storage client wires up:
|
|
199
|
+
f.request :multipart # bucket.upload(...)
|
|
200
|
+
f.response :follow_redirects # signed-URL / presigned-upload 30x flow
|
|
201
|
+
f.options.timeout = 30
|
|
202
|
+
f.options.open_timeout = 30
|
|
203
|
+
f.adapter Faraday.default_adapter
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
storage = Supabase::Storage::Client.new(
|
|
207
|
+
base_url: "https://project.supabase.co/storage/v1",
|
|
208
|
+
headers: { "apikey" => key, "Authorization" => "Bearer #{token}" },
|
|
209
|
+
http_client: http
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
storage.list_buckets # automatically retried on 5xx / network errors
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
`faraday-retry` is not a runtime dependency of `supabase-rb`; add
|
|
216
|
+
`gem "faraday-retry"` to your `Gemfile` if you want this pattern.
|
|
217
|
+
|
|
218
|
+
By default `faraday-retry` only retries idempotent methods (`%i[delete
|
|
219
|
+
get head options put]`), which is the right policy for storage: `GET`
|
|
220
|
+
list/download, `PUT` upload-overwrite, and `DELETE` remove are safe to
|
|
221
|
+
replay. `POST` (`bucket.upload` to a fresh object, `create_bucket`,
|
|
222
|
+
`empty_bucket`) is **not** retried by default — opt in via `methods:` if
|
|
223
|
+
you understand the duplicate-write tradeoff.
|
|
224
|
+
|
|
225
|
+
[faraday-retry]: https://github.com/lostisland/faraday-retry
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "async/http/faraday"
|
|
4
|
+
require "faraday/follow_redirects"
|
|
4
5
|
require_relative "../client"
|
|
5
6
|
|
|
6
7
|
module Supabase
|
|
@@ -36,13 +37,15 @@ module Supabase
|
|
|
36
37
|
class Client < Supabase::Storage::Client
|
|
37
38
|
private
|
|
38
39
|
|
|
40
|
+
# Mirrors {Supabase::Storage::Client#build_session} (default timeout=20,
|
|
41
|
+
# follow_redirects middleware, no HTTP/2 — see that method's docstring
|
|
42
|
+
# for the parity rationale) and only swaps the adapter for async_http.
|
|
39
43
|
def build_session(base_url)
|
|
40
44
|
Faraday.new(url: base_url, ssl: { verify: @verify }, proxy: @proxy) do |f|
|
|
41
45
|
f.request :multipart
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
end
|
|
46
|
+
f.response :follow_redirects
|
|
47
|
+
f.options.timeout = @timeout || 20
|
|
48
|
+
f.options.open_timeout = @timeout || 20
|
|
46
49
|
f.adapter :async_http
|
|
47
50
|
end
|
|
48
51
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "faraday"
|
|
4
|
+
require "faraday/follow_redirects"
|
|
4
5
|
require "faraday/multipart"
|
|
5
6
|
|
|
6
7
|
require_relative "bucket_api"
|
|
@@ -67,13 +68,24 @@ module Supabase
|
|
|
67
68
|
|
|
68
69
|
private
|
|
69
70
|
|
|
71
|
+
# Faraday session for Storage.
|
|
72
|
+
#
|
|
73
|
+
# Parity notes vs storage3 / supabase-py:
|
|
74
|
+
# * Default timeout is 20s (matches storage3's `DEFAULT_TIMEOUT`) when the
|
|
75
|
+
# caller doesn't pass one. Explicit `timeout:` still wins.
|
|
76
|
+
# * `faraday-follow_redirects` is wired in so signed-URL / presigned-upload
|
|
77
|
+
# flows that 30x to S3 work the same as `httpx.follow_redirects=True`.
|
|
78
|
+
# * HTTP/2 is intentionally NOT enabled here: Ruby's stdlib `Net::HTTP`
|
|
79
|
+
# (Faraday's default adapter) is HTTP/1.1 only, and switching to an
|
|
80
|
+
# HTTP/2-capable adapter is out of scope for storage parity. This is a
|
|
81
|
+
# deliberate, documented divergence from storage3 (which uses httpx and
|
|
82
|
+
# negotiates h2 transparently).
|
|
70
83
|
def build_session(base_url)
|
|
71
84
|
Faraday.new(url: base_url, ssl: { verify: @verify }, proxy: @proxy) do |f|
|
|
72
85
|
f.request :multipart
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
end
|
|
86
|
+
f.response :follow_redirects
|
|
87
|
+
f.options.timeout = @timeout || 20
|
|
88
|
+
f.options.open_timeout = @timeout || 20
|
|
77
89
|
f.adapter Faraday.default_adapter
|
|
78
90
|
end
|
|
79
91
|
end
|
|
@@ -21,6 +21,12 @@ module Supabase
|
|
|
21
21
|
|
|
22
22
|
attr_reader :id
|
|
23
23
|
|
|
24
|
+
# Mirrors storage3's `TypedDict` `TransformOptions` (height, width, resize,
|
|
25
|
+
# format, quality). PyJWT-style: we don't error on unknown keys at runtime —
|
|
26
|
+
# the user-facing API quietly drops them — but Ruby has no TypedDict, so
|
|
27
|
+
# we warn through `Kernel#warn` to flag typos like `:hieght` or stale keys.
|
|
28
|
+
KNOWN_TRANSFORM_KEYS = %i[height width resize format quality].freeze
|
|
29
|
+
|
|
24
30
|
def initialize(id, base_url, headers, session)
|
|
25
31
|
@id = id
|
|
26
32
|
@base_url = base_url.end_with?("/") ? base_url : "#{base_url}/"
|
|
@@ -55,15 +61,15 @@ module Supabase
|
|
|
55
61
|
|
|
56
62
|
# When `transform:` is provided, the request is routed through the image
|
|
57
63
|
# rendering endpoint (`render/image/authenticated`) and the transform opts
|
|
58
|
-
# are passed as query params.
|
|
59
|
-
#
|
|
60
|
-
|
|
64
|
+
# are passed as query params. `query_params:` adds arbitrary query params
|
|
65
|
+
# on top (merged after transform — explicit query_params win on conflict).
|
|
66
|
+
# Mirrors supabase-py's DownloadOptions / TransformOptions split.
|
|
67
|
+
def download(path, transform: nil, query_params: nil)
|
|
68
|
+
warn_unknown_transform_keys(transform) if transform
|
|
61
69
|
render_path = transform ? %w[render image authenticated] : %w[object]
|
|
62
|
-
query =
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
{}
|
|
66
|
-
end
|
|
70
|
+
query = {}
|
|
71
|
+
query.merge!(transform.transform_keys(&:to_s).transform_values(&:to_s)) if transform
|
|
72
|
+
query.merge!(query_params.transform_keys(&:to_s).transform_values(&:to_s)) if query_params
|
|
67
73
|
|
|
68
74
|
parts = Utils.relative_path_to_parts(path)
|
|
69
75
|
response = _request(:get, [*render_path, @id, *parts], raw_response: true, query: query)
|
|
@@ -143,6 +149,7 @@ module Supabase
|
|
|
143
149
|
# original filename, a String to override the filename, or nil to leave inline
|
|
144
150
|
# @return [Hash] { "signedURL" => "...", "signedUrl" => "..." }
|
|
145
151
|
def create_signed_url(path, expires_in:, download: nil, transform: nil)
|
|
152
|
+
warn_unknown_transform_keys(transform) if transform
|
|
146
153
|
json = { "expiresIn" => expires_in.to_s }
|
|
147
154
|
download_query = {}
|
|
148
155
|
if download
|
|
@@ -177,6 +184,7 @@ module Supabase
|
|
|
177
184
|
end
|
|
178
185
|
|
|
179
186
|
def get_public_url(path, download: nil, transform: nil)
|
|
187
|
+
warn_unknown_transform_keys(transform) if transform
|
|
180
188
|
download_query = {}
|
|
181
189
|
if download
|
|
182
190
|
download_query["download"] = download == true ? "" : download
|
|
@@ -213,6 +221,23 @@ module Supabase
|
|
|
213
221
|
|
|
214
222
|
private
|
|
215
223
|
|
|
224
|
+
# Emit a one-line `Kernel#warn` per call when `transform:` carries any key
|
|
225
|
+
# outside {KNOWN_TRANSFORM_KEYS}. We do not raise: the key is still passed
|
|
226
|
+
# through to the storage render endpoint as a query param, so a typo or
|
|
227
|
+
# server-only flag remains observable (just no longer silent).
|
|
228
|
+
def warn_unknown_transform_keys(transform)
|
|
229
|
+
return unless transform.respond_to?(:keys)
|
|
230
|
+
|
|
231
|
+
unknown = transform.keys.map(&:to_sym) - KNOWN_TRANSFORM_KEYS
|
|
232
|
+
return if unknown.empty?
|
|
233
|
+
|
|
234
|
+
Kernel.warn(
|
|
235
|
+
"[Supabase::Storage] unknown transform option(s): " \
|
|
236
|
+
"#{unknown.map(&:inspect).join(', ')}. " \
|
|
237
|
+
"Known keys: #{KNOWN_TRANSFORM_KEYS.map(&:inspect).join(', ')}."
|
|
238
|
+
)
|
|
239
|
+
end
|
|
240
|
+
|
|
216
241
|
def upload_or_update(method, path, file, content_type:, cache_control:, upsert:, metadata:, headers:, omit_upsert: false)
|
|
217
242
|
parts = Utils.relative_path_to_parts(path)
|
|
218
243
|
send_multipart(method, ["object", @id, *parts],
|
|
@@ -225,7 +250,9 @@ module Supabase
|
|
|
225
250
|
|
|
226
251
|
def send_multipart(method, segments, file:, filename:, content_type:, cache_control:, upsert:, metadata:, extra_headers:, query: nil, relative_path: nil)
|
|
227
252
|
request_headers = {}
|
|
228
|
-
|
|
253
|
+
# py parity: when no explicit cache_control, fall back to DEFAULT_FILE_OPTIONS["cache-control"]
|
|
254
|
+
# ("3600" — raw, no max-age wrapper). When explicit, wrap as max-age=<n>.
|
|
255
|
+
request_headers["cache-control"] = cache_control ? "max-age=#{cache_control}" : Types::DEFAULT_FILE_OPTIONS["cache-control"]
|
|
229
256
|
request_headers["x-upsert"] = upsert.to_s unless upsert.nil?
|
|
230
257
|
request_headers.merge!(extra_headers) if extra_headers
|
|
231
258
|
|
|
@@ -17,7 +17,9 @@ module Supabase
|
|
|
17
17
|
|
|
18
18
|
def _request(method, segments, json: nil, headers: nil, query: nil, body: nil, raw_response: false)
|
|
19
19
|
url = Utils.join_url(@base_url, segments, query)
|
|
20
|
-
|
|
20
|
+
# py parity (storage3 file_api._request): per-call headers are the base; client
|
|
21
|
+
# @headers always wins on collision. Mirrors `headers.update(self._headers)`.
|
|
22
|
+
merged_headers = (headers || {}).merge(@headers)
|
|
21
23
|
|
|
22
24
|
response = @session.run_request(method.to_s.downcase.to_sym, url, nil, merged_headers) do |req|
|
|
23
25
|
if json
|
|
@@ -7,6 +7,12 @@ module Supabase
|
|
|
7
7
|
module Utils
|
|
8
8
|
module_function
|
|
9
9
|
|
|
10
|
+
# RFC 3986 unreserved set: ALPHA / DIGIT / "-" / "." / "_" / "~"
|
|
11
|
+
# Anything else in a path segment gets percent-encoded byte-by-byte (UTF-8).
|
|
12
|
+
# This matches yarl's path-segment encoding used by storage3 / supabase-py.
|
|
13
|
+
RFC3986_UNRESERVED = /[^A-Za-z0-9\-._~]/n.freeze
|
|
14
|
+
private_constant :RFC3986_UNRESERVED
|
|
15
|
+
|
|
10
16
|
# Splits a relative storage path into its path segments, dropping a leading
|
|
11
17
|
# `/` if the caller supplied one. Mirrors storage3.utils.relative_path_to_parts.
|
|
12
18
|
#
|
|
@@ -16,9 +22,17 @@ module Supabase
|
|
|
16
22
|
path.to_s.split("/").reject(&:empty?)
|
|
17
23
|
end
|
|
18
24
|
|
|
25
|
+
# Percent-encode a single path segment per RFC 3986 (unreserved set only).
|
|
26
|
+
# Notably: space → "%20" (not "+"), "+" → "%2B", "/" → "%2F".
|
|
27
|
+
# `URI.encode_www_form_component` cannot be used here — it follows
|
|
28
|
+
# application/x-www-form-urlencoded, which mis-encodes spaces and "+".
|
|
29
|
+
def rfc3986_encode_segment(segment)
|
|
30
|
+
segment.to_s.b.gsub(RFC3986_UNRESERVED) { |b| format("%%%02X", b.unpack1("C")) }
|
|
31
|
+
end
|
|
32
|
+
|
|
19
33
|
# URL-encode each path segment so user-supplied filenames don't break the URL.
|
|
20
34
|
def encode_segments(parts)
|
|
21
|
-
parts.map { |p|
|
|
35
|
+
parts.map { |p| rfc3986_encode_segment(p) }
|
|
22
36
|
end
|
|
23
37
|
|
|
24
38
|
# Join the (already-trailing-slashed) base URL with the given path segments and
|
data/lib/supabase/version.rb
CHANGED
data/lib/supabase.rb
CHANGED
|
@@ -12,7 +12,6 @@ module Supabase
|
|
|
12
12
|
# rescue Supabase::StorageException => e # storage
|
|
13
13
|
# rescue Supabase::AuthApiError => e # auth
|
|
14
14
|
# rescue Supabase::FunctionsHttpError => e # functions
|
|
15
|
-
# rescue Supabase::AuthorizationError => e # realtime
|
|
16
15
|
#
|
|
17
16
|
# The actual classes live in their sub-namespaces; these are aliases.
|
|
18
17
|
|
|
@@ -43,12 +42,6 @@ module Supabase
|
|
|
43
42
|
FunctionsRelayError = Functions::Errors::FunctionsRelayError if defined?(Functions::Errors::FunctionsRelayError)
|
|
44
43
|
end
|
|
45
44
|
|
|
46
|
-
# Realtime
|
|
47
|
-
if defined?(Realtime::Errors)
|
|
48
|
-
AuthorizationError = Realtime::Errors::AuthorizationError if defined?(Realtime::Errors::AuthorizationError)
|
|
49
|
-
NotConnectedError = Realtime::Errors::NotConnectedError if defined?(Realtime::Errors::NotConnectedError)
|
|
50
|
-
end
|
|
51
|
-
|
|
52
45
|
# Raised by {Supabase.create_client} on a missing url/key. Mirrors py's
|
|
53
46
|
# `SupabaseException`. We don't inherit from a sub-library error because the
|
|
54
47
|
# umbrella factory predates choosing any of them.
|