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,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
- attr_reader :state
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
- new_state = self.class.transform_state(raw_state)
26
- joins = {}
27
- leaves = @state.reject { |k, _| new_state.key?(k) }
28
-
29
- new_state.each do |key, presences|
30
- current = @state[key] || []
31
-
32
- if current.any?
33
- current_refs = current.map { |p| p["presence_ref"] }
34
- new_refs = presences.map { |p| p["presence_ref"] }
35
- joined_presences = presences.reject { |p| current_refs.include?(p["presence_ref"]) }
36
- left_presences = current.reject { |p| new_refs.include?(p["presence_ref"]) }
37
- joins[key] = joined_presences if joined_presences.any?
38
- leaves[key] = left_presences if left_presences.any?
39
- else
40
- joins[key] = presences
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
- sync_diff_internal(joins, leaves)
45
- @on_sync_callbacks.each(&:call)
46
- @state
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
- joins = self.class.transform_state(raw_diff["joins"] || {})
53
- leaves = self.class.transform_state(raw_diff["leaves"] || {})
54
- sync_diff_internal(joins, leaves)
55
- @on_sync_callbacks.each(&:call)
56
- @state
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
- def sync_diff_internal(joins, leaves)
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
- @on_join_callbacks.each { |cb| cb.call(key, current_presences, new_presences) }
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
- @on_leave_callbacks.each { |cb| cb.call(key, remaining, left_presences) }
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 { |h| h.call(payload) }
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
@@ -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
- if @timeout
43
- f.options.timeout = @timeout
44
- f.options.open_timeout = @timeout
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
- if @timeout
74
- f.options.timeout = @timeout
75
- f.options.open_timeout = @timeout
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. Mirrors supabase-py's DownloadOptions /
59
- # TransformOptions split.
60
- def download(path, transform: nil)
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 = if transform
63
- transform.transform_keys(&:to_s).transform_values(&:to_s)
64
- else
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
- request_headers["cache-control"] = "max-age=#{cache_control}" if cache_control
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
- merged_headers = @headers.merge(headers || {})
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| URI.encode_www_form_component(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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Supabase
4
- VERSION = "3.1.1"
4
+ VERSION = "3.2.0"
5
5
  end
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.