supabase-rb 2.0.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 (74) hide show
  1. checksums.yaml +7 -0
  2. data/lib/supabase/README.md +90 -0
  3. data/lib/supabase/auth/README.md +172 -0
  4. data/lib/supabase/auth/admin_api.rb +218 -0
  5. data/lib/supabase/auth/admin_oauth_api.rb +51 -0
  6. data/lib/supabase/auth/api.rb +125 -0
  7. data/lib/supabase/auth/async/admin_api.rb +36 -0
  8. data/lib/supabase/auth/async/admin_oauth_api.rb +15 -0
  9. data/lib/supabase/auth/async/api.rb +32 -0
  10. data/lib/supabase/auth/async/client.rb +33 -0
  11. data/lib/supabase/auth/async.rb +14 -0
  12. data/lib/supabase/auth/client.rb +1217 -0
  13. data/lib/supabase/auth/constants.rb +32 -0
  14. data/lib/supabase/auth/errors.rb +207 -0
  15. data/lib/supabase/auth/helpers.rb +222 -0
  16. data/lib/supabase/auth/memory_storage.rb +25 -0
  17. data/lib/supabase/auth/storage.rb +19 -0
  18. data/lib/supabase/auth/timer.rb +40 -0
  19. data/lib/supabase/auth/types.rb +517 -0
  20. data/lib/supabase/auth/version.rb +7 -0
  21. data/lib/supabase/auth.rb +19 -0
  22. data/lib/supabase/client.rb +200 -0
  23. data/lib/supabase/client_options.rb +82 -0
  24. data/lib/supabase/functions/README.md +71 -0
  25. data/lib/supabase/functions/async/client.rb +45 -0
  26. data/lib/supabase/functions/async.rb +8 -0
  27. data/lib/supabase/functions/client.rb +174 -0
  28. data/lib/supabase/functions/errors.rb +38 -0
  29. data/lib/supabase/functions/types.rb +37 -0
  30. data/lib/supabase/functions/version.rb +7 -0
  31. data/lib/supabase/functions.rb +11 -0
  32. data/lib/supabase/postgrest/README.md +84 -0
  33. data/lib/supabase/postgrest/async/client.rb +50 -0
  34. data/lib/supabase/postgrest/async.rb +8 -0
  35. data/lib/supabase/postgrest/client.rb +136 -0
  36. data/lib/supabase/postgrest/errors.rb +49 -0
  37. data/lib/supabase/postgrest/request_builder.rb +657 -0
  38. data/lib/supabase/postgrest/types.rb +60 -0
  39. data/lib/supabase/postgrest/utils.rb +24 -0
  40. data/lib/supabase/postgrest/version.rb +7 -0
  41. data/lib/supabase/postgrest.rb +13 -0
  42. data/lib/supabase/realtime/README.md +90 -0
  43. data/lib/supabase/realtime/channel.rb +274 -0
  44. data/lib/supabase/realtime/client.rb +182 -0
  45. data/lib/supabase/realtime/errors.rb +19 -0
  46. data/lib/supabase/realtime/message.rb +38 -0
  47. data/lib/supabase/realtime/presence.rb +136 -0
  48. data/lib/supabase/realtime/push.rb +48 -0
  49. data/lib/supabase/realtime/socket.rb +40 -0
  50. data/lib/supabase/realtime/sockets/async_websocket.rb +175 -0
  51. data/lib/supabase/realtime/sockets/websocket_client_simple.rb +94 -0
  52. data/lib/supabase/realtime/test_socket.rb +65 -0
  53. data/lib/supabase/realtime/transformers.rb +26 -0
  54. data/lib/supabase/realtime/types.rb +70 -0
  55. data/lib/supabase/realtime/version.rb +7 -0
  56. data/lib/supabase/realtime.rb +18 -0
  57. data/lib/supabase/storage/README.md +108 -0
  58. data/lib/supabase/storage/analytics.rb +69 -0
  59. data/lib/supabase/storage/async/client.rb +52 -0
  60. data/lib/supabase/storage/async.rb +8 -0
  61. data/lib/supabase/storage/bucket_api.rb +65 -0
  62. data/lib/supabase/storage/client.rb +80 -0
  63. data/lib/supabase/storage/errors.rb +32 -0
  64. data/lib/supabase/storage/file_api.rb +281 -0
  65. data/lib/supabase/storage/request.rb +63 -0
  66. data/lib/supabase/storage/types.rb +236 -0
  67. data/lib/supabase/storage/utils.rb +35 -0
  68. data/lib/supabase/storage/vectors.rb +189 -0
  69. data/lib/supabase/storage/version.rb +7 -0
  70. data/lib/supabase/storage.rb +17 -0
  71. data/lib/supabase/version.rb +5 -0
  72. data/lib/supabase-auth.rb +3 -0
  73. data/lib/supabase.rb +63 -0
  74. metadata +272 -0
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "errors"
6
+
7
+ module Supabase
8
+ module Realtime
9
+ # A Phoenix Channel frame: { event, topic, payload, ref, join_ref }.
10
+ # Used both for outbound pushes and parsed inbound messages.
11
+ Message = Struct.new(:event, :topic, :payload, :ref, :join_ref, keyword_init: true) do
12
+ def to_json(*)
13
+ JSON.generate(
14
+ "event" => event,
15
+ "topic" => topic,
16
+ "payload" => payload,
17
+ "ref" => ref,
18
+ "join_ref" => join_ref
19
+ )
20
+ end
21
+
22
+ # Parse a raw JSON frame received on the WebSocket into a Message. Raises
23
+ # ProtocolError if the frame isn't well-formed JSON.
24
+ def self.parse(raw)
25
+ json = JSON.parse(raw)
26
+ new(
27
+ event: json["event"],
28
+ topic: json["topic"],
29
+ payload: json["payload"] || {},
30
+ ref: json["ref"],
31
+ join_ref: json["join_ref"]
32
+ )
33
+ rescue JSON::ParserError => e
34
+ raise Errors::ProtocolError, "Malformed Phoenix frame: #{e.message}"
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supabase
4
+ module Realtime
5
+ # Tracks presence state for one channel and implements the Phoenix Presence
6
+ # sync algorithm: presence_state replaces the local snapshot, presence_diff
7
+ # applies joins/leaves on top of it.
8
+ #
9
+ # The algorithm mirrors phoenix.js's Presence.syncState / Presence.syncDiff so
10
+ # callers porting from JS/Python see identical behavior.
11
+ class Presence
12
+ attr_reader :state
13
+
14
+ def initialize
15
+ @state = {}
16
+ @on_sync_callbacks = []
17
+ @on_join_callbacks = []
18
+ @on_leave_callbacks = []
19
+ end
20
+
21
+ # The first presence_state message after joining sends the full state. Any
22
+ # local metas we already have for a key but the server doesn't are emitted
23
+ # as leaves; anything new is emitted as a join.
24
+ def sync_state(new_state)
25
+ joins = {}
26
+ leaves = {}
27
+
28
+ @state.each do |key, presence|
29
+ leaves[key] = presence unless new_state.key?(key)
30
+ end
31
+
32
+ new_state.each do |key, new_presence|
33
+ current = @state[key]
34
+ if current
35
+ joined = []
36
+ left = []
37
+ current_refs = metas(current).map { |m| m["phx_ref"] }
38
+ new_refs = metas(new_presence).map { |m| m["phx_ref"] }
39
+ joined = metas(new_presence).reject { |m| current_refs.include?(m["phx_ref"]) }
40
+ left = metas(current).reject { |m| new_refs.include?(m["phx_ref"]) }
41
+ joins[key] = { "metas" => joined } unless joined.empty?
42
+ leaves[key] = { "metas" => left } unless left.empty?
43
+ else
44
+ joins[key] = new_presence
45
+ end
46
+ end
47
+
48
+ @state = deep_copy(new_state)
49
+ emit_joins(joins)
50
+ emit_leaves(leaves)
51
+ @on_sync_callbacks.each(&:call)
52
+ @state
53
+ end
54
+
55
+ # Subsequent presence_diff messages carry only joins/leaves to apply.
56
+ def sync_diff(diff)
57
+ joins = diff["joins"] || {}
58
+ leaves = diff["leaves"] || {}
59
+
60
+ joins.each do |key, presence|
61
+ if @state[key]
62
+ existing_refs = metas(@state[key]).map { |m| m["phx_ref"] }
63
+ new_metas = metas(presence).reject { |m| existing_refs.include?(m["phx_ref"]) }
64
+ @state[key] = { "metas" => metas(@state[key]) + new_metas }
65
+ else
66
+ @state[key] = presence
67
+ end
68
+ end
69
+
70
+ leaves.each do |key, presence|
71
+ next unless @state[key]
72
+
73
+ leaving_refs = metas(presence).map { |m| m["phx_ref"] }
74
+ remaining = metas(@state[key]).reject { |m| leaving_refs.include?(m["phx_ref"]) }
75
+ if remaining.empty?
76
+ @state.delete(key)
77
+ else
78
+ @state[key] = { "metas" => remaining }
79
+ end
80
+ end
81
+
82
+ emit_joins(joins)
83
+ emit_leaves(leaves)
84
+ @on_sync_callbacks.each(&:call)
85
+ @state
86
+ end
87
+
88
+ # List every meta currently tracked, flat. Useful when callers don't care
89
+ # about the per-key grouping.
90
+ def list
91
+ @state.values.flat_map { |presence| metas(presence) }
92
+ end
93
+
94
+ def on_sync(&block)
95
+ @on_sync_callbacks << block
96
+ self
97
+ end
98
+
99
+ def on_join(&block)
100
+ @on_join_callbacks << block
101
+ self
102
+ end
103
+
104
+ def on_leave(&block)
105
+ @on_leave_callbacks << block
106
+ self
107
+ end
108
+
109
+ def any_callbacks?
110
+ [@on_sync_callbacks, @on_join_callbacks, @on_leave_callbacks].any? { |list| !list.empty? }
111
+ end
112
+
113
+ private
114
+
115
+ def metas(presence)
116
+ Array(presence && presence["metas"])
117
+ end
118
+
119
+ def emit_joins(joins)
120
+ joins.each do |key, presence|
121
+ @on_join_callbacks.each { |cb| cb.call(key, presence) }
122
+ end
123
+ end
124
+
125
+ def emit_leaves(leaves)
126
+ leaves.each do |key, presence|
127
+ @on_leave_callbacks.each { |cb| cb.call(key, presence) }
128
+ end
129
+ end
130
+
131
+ def deep_copy(obj)
132
+ Marshal.load(Marshal.dump(obj))
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "types"
4
+
5
+ module Supabase
6
+ module Realtime
7
+ # One outbound Phoenix push, awaiting a reply. The channel matches incoming
8
+ # phx_reply messages to pushes by `ref` and fires the appropriate handler.
9
+ #
10
+ # `receive(:ok / :error / :timeout) { |payload| ... }` registers handlers
11
+ # before the push is sent, mirroring phoenix.js's Push API.
12
+ class Push
13
+ attr_reader :ref, :event, :payload, :received_status
14
+
15
+ def initialize(channel, event, payload = {}, ref: nil)
16
+ @channel = channel
17
+ @event = event
18
+ @payload = payload
19
+ @ref = ref
20
+ @handlers = Hash.new { |h, k| h[k] = [] }
21
+ @received_status = nil
22
+ @received_payload = nil
23
+ end
24
+
25
+ def receive(status, &block)
26
+ if @received_status == status
27
+ # Reply already arrived before this handler was attached — fire immediately.
28
+ block.call(@received_payload)
29
+ else
30
+ @handlers[status] << block
31
+ end
32
+ self
33
+ end
34
+
35
+ # Called by the Channel when a phx_reply with matching ref arrives.
36
+ def resolve(status:, payload:)
37
+ @received_status = status
38
+ @received_payload = payload
39
+ @handlers[status].each { |h| h.call(payload) }
40
+ end
41
+
42
+ # Called by the Channel if no reply arrives within the timeout window.
43
+ def time_out
44
+ resolve(status: Types::AckStatus::TIMEOUT, payload: {})
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supabase
4
+ module Realtime
5
+ # Transport interface that {Client} talks to. A real implementation wraps a
6
+ # WebSocket; {TestSocket} stays in-memory for specs.
7
+ #
8
+ # Implementations must provide:
9
+ #
10
+ # - `connect` — open the underlying transport
11
+ # - `close` — tear it down
12
+ # - `send(payload)` — push one raw JSON frame to the server
13
+ # - `connected?` — boolean state predicate
14
+ # - `on_message(&blk)` — register an inbound-frame callback (raw JSON string)
15
+ # - `on_open(&blk)` — register an on-open callback
16
+ # - `on_close(&blk)` — register an on-close callback
17
+ #
18
+ # The Client never assumes a specific WebSocket library; bring your own
19
+ # (websocket-client-simple for sync, async-websocket for async, etc.) by
20
+ # implementing this interface.
21
+ module Socket
22
+ # The minimum surface. Including it gives default no-op implementations so
23
+ # subclasses can override piecemeal.
24
+ def connect; raise NotImplementedError; end
25
+ def close; raise NotImplementedError; end
26
+ def send(_payload); raise NotImplementedError; end
27
+ def connected?; raise NotImplementedError; end
28
+
29
+ def on_message(&blk); message_callbacks << blk; end
30
+ def on_open(&blk); open_callbacks << blk; end
31
+ def on_close(&blk); close_callbacks << blk; end
32
+ def on_error(&blk); error_callbacks << blk; end
33
+
34
+ def message_callbacks; @message_callbacks ||= []; end
35
+ def open_callbacks; @open_callbacks ||= []; end
36
+ def close_callbacks; @close_callbacks ||= []; end
37
+ def error_callbacks; @error_callbacks ||= []; end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "async/http/endpoint"
5
+ require "async/websocket/client"
6
+ require "protocol/websocket/message"
7
+
8
+ require_relative "../errors"
9
+ require_relative "../socket"
10
+
11
+ module Supabase
12
+ module Realtime
13
+ module Sockets
14
+ # {Socket} implementation backed by the async-websocket gem (socketry/async).
15
+ #
16
+ # Unlike {WebsocketClientSimple} — which spawns a background OS thread for
17
+ # the read loop — this adapter runs entirely inside the calling fiber's
18
+ # Async reactor. Pick it when:
19
+ #
20
+ # - your app already runs on the socketry/async stack (Falcon, or
21
+ # supabase-rb's own async REST clients via async-http-faraday), so
22
+ # a single reactor owns all I/O;
23
+ # - you want cooperative concurrency without cross-thread callback
24
+ # hops or mutexes on listener state.
25
+ #
26
+ # require "async"
27
+ # require "supabase/realtime"
28
+ # require "supabase/realtime/sockets/async_websocket"
29
+ #
30
+ # Async do
31
+ # socket = Supabase::Realtime::Sockets::AsyncWebsocket.new(url: ws_url)
32
+ # client = Supabase::Realtime::Client.new(url: ws_url, socket: socket)
33
+ # client.connect
34
+ #
35
+ # channel = client.channel("realtime:public:users")
36
+ # channel.on_postgres_changes("INSERT", schema: "public", table: "users") { |p| puts p }
37
+ # channel.subscribe
38
+ # end
39
+ #
40
+ # All callbacks (on_open / on_message / on_close / on_error, and every
41
+ # downstream channel listener) run inside the Async reactor on the same
42
+ # fiber tree as the caller — no thread hops, no mutexes required for
43
+ # state owned by the reactor.
44
+ class AsyncWebsocket
45
+ include Socket
46
+
47
+ # @param url [String] ws(s):// URL including query params
48
+ # @param headers [Hash] extra HTTP headers sent on the upgrade request
49
+ # @param parent [Async::Task, nil] reactor task to attach the session
50
+ # to. Defaults to {Async::Task.current?} resolved at connect-time, so
51
+ # callers must be inside an `Async { ... }` block.
52
+ # @param connector [#connect] dependency-injection seam — defaults to
53
+ # {Async::WebSocket::Client}. Tests pass a fake that returns a stub
54
+ # connection so no real socket is opened.
55
+ def initialize(url:, headers: {}, parent: nil, connector: ::Async::WebSocket::Client)
56
+ @url = url
57
+ @headers = headers
58
+ @parent = parent
59
+ @connector = connector
60
+ @connection = nil
61
+ @session = nil
62
+ end
63
+
64
+ def connect
65
+ return if connected?
66
+
67
+ parent = @parent || ::Async::Task.current?
68
+ unless parent
69
+ raise Errors::RealtimeError,
70
+ "Supabase::Realtime::Sockets::AsyncWebsocket#connect must run inside an Async { ... } block " \
71
+ "(or be constructed with parent:)"
72
+ end
73
+
74
+ endpoint = ::Async::HTTP::Endpoint.parse(@url)
75
+ ready = ::Async::Promise.new
76
+
77
+ @session = parent.async do
78
+ @connector.connect(endpoint, headers: header_pairs) do |connection|
79
+ @connection = connection
80
+ fire_open
81
+ ready.resolve(true)
82
+
83
+ read_loop(connection)
84
+ end
85
+ rescue => err
86
+ fire_error(err)
87
+ ready.reject(err) unless ready.resolved?
88
+ ensure
89
+ fire_close
90
+ end
91
+
92
+ # Cooperative wait — Promise buffers the resolution, so this returns
93
+ # immediately whether the session task got there first or not. After
94
+ # this, callers can rely on connected?.
95
+ ready.wait
96
+ nil
97
+ end
98
+
99
+ def close
100
+ conn = @connection
101
+ session = @session
102
+ @connection = nil
103
+ @session = nil
104
+
105
+ # Closing the connection makes #read return nil → read_loop exits →
106
+ # the session task terminates naturally. This is more reliable than
107
+ # task.stop, which doesn't always interrupt a fiber blocked on a
108
+ # non-IO suspend point (queues, notifications).
109
+ begin
110
+ conn&.close
111
+ rescue StandardError
112
+ # Connection may already be torn down — ignore.
113
+ end
114
+
115
+ # Belt-and-braces: if the connection didn't unblock the read, stop
116
+ # the task as a fallback. No-op if it already finished.
117
+ session&.stop
118
+ end
119
+
120
+ def send(payload)
121
+ conn = @connection
122
+ return unless conn
123
+
124
+ # Connection#write auto-wraps UTF-8 strings in a text frame, which is
125
+ # what the Phoenix protocol expects.
126
+ conn.write(payload)
127
+ conn.flush
128
+ end
129
+
130
+ def connected?
131
+ !@connection.nil?
132
+ end
133
+
134
+ # ----- Internal callback fan-outs (public so the read task can reach them) -----
135
+
136
+ def fire_open
137
+ open_callbacks.each(&:call)
138
+ end
139
+
140
+ def fire_message(payload)
141
+ message_callbacks.each { |cb| cb.call(payload) }
142
+ end
143
+
144
+ def fire_close
145
+ return if @connection.nil? && close_callbacks.empty?
146
+
147
+ @connection = nil
148
+ close_callbacks.each(&:call)
149
+ end
150
+
151
+ def fire_error(err)
152
+ error_callbacks.each { |cb| cb.call(err) }
153
+ end
154
+
155
+ private
156
+
157
+ def header_pairs
158
+ @headers.map { |k, v| [k.to_s, v.to_s] }
159
+ end
160
+
161
+ def read_loop(connection)
162
+ while (message = connection.read)
163
+ next unless message.is_a?(::Protocol::WebSocket::TextMessage)
164
+
165
+ fire_message(message.buffer.to_s)
166
+ end
167
+ rescue ::Async::Stop
168
+ # graceful shutdown — initiated by #close
169
+ rescue => err
170
+ fire_error(err)
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "websocket-client-simple"
4
+
5
+ require_relative "../socket"
6
+
7
+ module Supabase
8
+ module Realtime
9
+ module Sockets
10
+ # {Socket} implementation backed by the websocket-client-simple gem.
11
+ #
12
+ # The underlying gem spawns a background thread to read frames from the
13
+ # server, so every callback this adapter fires (on_open / on_message /
14
+ # on_close / on_error — and downstream, every user listener registered
15
+ # on a Channel) runs on **that background thread**. Callers are
16
+ # responsible for thread-safety in their listeners.
17
+ #
18
+ # require "supabase/realtime"
19
+ # require "supabase/realtime/sockets/websocket_client_simple"
20
+ #
21
+ # socket = Supabase::Realtime::Sockets::WebsocketClientSimple.new(url: ws_url)
22
+ # client = Supabase::Realtime::Client.new(url: ws_url, socket: socket)
23
+ # client.connect
24
+ class WebsocketClientSimple
25
+ include Socket
26
+
27
+ # @param url [String] full ws(s):// URL including query params
28
+ # @param headers [Hash] extra HTTP headers sent on the upgrade request
29
+ # @param connector [#connect] dependency injection seam — defaults to
30
+ # ::WebSocket::Client::Simple. Tests pass a fake that returns a stub
31
+ # WS client so we never open a real socket.
32
+ def initialize(url:, headers: {}, connector: ::WebSocket::Client::Simple)
33
+ @url = url
34
+ @headers = headers
35
+ @connector = connector
36
+ @ws = nil
37
+ end
38
+
39
+ def connect
40
+ return if connected?
41
+
42
+ self_ref = self
43
+ @ws = @connector.connect(@url, headers: @headers) do |client|
44
+ client.on(:open) { self_ref.fire_open }
45
+ client.on(:message) { |msg| self_ref.fire_message(msg) }
46
+ client.on(:close) { |reason| self_ref.fire_close(reason) }
47
+ client.on(:error) { |err| self_ref.fire_error(err) }
48
+ end
49
+ nil
50
+ end
51
+
52
+ def close
53
+ @ws&.close
54
+ @ws = nil
55
+ end
56
+
57
+ def send(payload)
58
+ @ws&.send(payload)
59
+ end
60
+
61
+ def connected?
62
+ !@ws.nil? && @ws.open?
63
+ end
64
+
65
+ # ----- Internal callback shims (called by the WS background thread) -----
66
+ # Public so the connect block can reach them, not part of the Socket
67
+ # contract callers should use.
68
+
69
+ def fire_open
70
+ open_callbacks.each(&:call)
71
+ end
72
+
73
+ # websocket-client-simple yields a Frame::Incoming object whose #data
74
+ # holds the payload and whose #type is :text / :binary / :ping / etc.
75
+ # We only forward text frames — Phoenix doesn't use binary.
76
+ def fire_message(msg)
77
+ return unless msg.respond_to?(:type) ? msg.type == :text : true
78
+
79
+ payload = msg.respond_to?(:data) ? msg.data : msg.to_s
80
+ message_callbacks.each { |cb| cb.call(payload) }
81
+ end
82
+
83
+ def fire_close(_reason = nil)
84
+ @ws = nil
85
+ close_callbacks.each(&:call)
86
+ end
87
+
88
+ def fire_error(err)
89
+ error_callbacks.each { |cb| cb.call(err) }
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "socket"
4
+
5
+ module Supabase
6
+ module Realtime
7
+ # In-memory {Socket} implementation for specs and local prototyping. Captures
8
+ # every frame the client sends in `sent_frames`, and exposes `inject(frame)`
9
+ # so a test can simulate a server response.
10
+ #
11
+ # Not intended for production use — bring a real WebSocket adapter for that.
12
+ class TestSocket
13
+ include Socket
14
+
15
+ attr_reader :sent_frames
16
+
17
+ def initialize
18
+ @connected = false
19
+ @sent_frames = []
20
+ end
21
+
22
+ def connect
23
+ @connected = true
24
+ open_callbacks.each(&:call)
25
+ end
26
+
27
+ def close
28
+ @connected = false
29
+ close_callbacks.each(&:call)
30
+ end
31
+
32
+ def send(payload)
33
+ @sent_frames << payload
34
+ end
35
+
36
+ def connected?
37
+ @connected
38
+ end
39
+
40
+ # ----- Test helpers -----
41
+
42
+ # Push a JSON frame as if it came from the server. Accepts a JSON String
43
+ # or a Hash (which gets JSON-encoded for you).
44
+ def inject(frame)
45
+ raw = frame.is_a?(String) ? frame : JSON.generate(frame)
46
+ message_callbacks.each { |cb| cb.call(raw) }
47
+ end
48
+
49
+ # Convenience: the last frame the client pushed, parsed back to a Hash.
50
+ def last_sent_frame
51
+ return nil if @sent_frames.empty?
52
+
53
+ JSON.parse(@sent_frames.last)
54
+ end
55
+
56
+ def sent_events
57
+ @sent_frames.map { |f| JSON.parse(f)["event"] }
58
+ end
59
+
60
+ def reset_sent_frames
61
+ @sent_frames = []
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supabase
4
+ module Realtime
5
+ # Mirrors realtime-py's `realtime.transformers`. Today this is a single
6
+ # helper used to derive the HTTP endpoint from the WebSocket URL so callers
7
+ # can hit the realtime REST surface (e.g. /api/broadcast, /connections).
8
+ module Transformers
9
+ module_function
10
+
11
+ # Converts a realtime socket URL into its HTTP equivalent.
12
+ #
13
+ # http_endpoint_url("wss://x.supabase.co/realtime/v1/websocket")
14
+ # # => "https://x.supabase.co/realtime/v1"
15
+ #
16
+ # Replaces the leading `ws`/`wss` scheme with `http`/`https`, strips any
17
+ # `/socket/websocket`, `/socket`, or `/websocket` suffix, and trims
18
+ # trailing slashes. Mirrors py's regex chain verbatim.
19
+ def http_endpoint_url(socket_url)
20
+ url = socket_url.to_s.sub(/\Aws/i, "http")
21
+ url = url.sub(%r{(/socket/websocket|/socket|/websocket)/?\z}i, "")
22
+ url.sub(%r{/+\z}, "")
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supabase
4
+ module Realtime
5
+ module Types
6
+ # Phoenix protocol version this client speaks. Matches supabase-py.
7
+ VSN = "1.0.0"
8
+
9
+ DEFAULT_TIMEOUT_SECONDS = 10
10
+ DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 25
11
+
12
+ # The special topic the Phoenix server uses for heartbeats and connection-level
13
+ # control messages.
14
+ PHOENIX_TOPIC = "phoenix"
15
+
16
+ # Phoenix event names — mirror supabase-py's ChannelEvents enum 1:1 so docs
17
+ # and message dumps line up.
18
+ module ChannelEvents
19
+ CLOSE = "phx_close"
20
+ ERROR = "phx_error"
21
+ JOIN = "phx_join"
22
+ REPLY = "phx_reply"
23
+ LEAVE = "phx_leave"
24
+ HEARTBEAT = "heartbeat"
25
+ ACCESS_TOKEN = "access_token"
26
+ BROADCAST = "broadcast"
27
+ PRESENCE = "presence"
28
+ PRESENCE_STATE = "presence_state"
29
+ PRESENCE_DIFF = "presence_diff"
30
+ SYSTEM = "system"
31
+ POSTGRES_CHANGES = "postgres_changes"
32
+ end
33
+
34
+ # Channel lifecycle states.
35
+ module ChannelStates
36
+ CLOSED = :closed
37
+ ERRORED = :errored
38
+ JOINED = :joined
39
+ JOINING = :joining
40
+ LEAVING = :leaving
41
+ ALL = [CLOSED, ERRORED, JOINED, JOINING, LEAVING].freeze
42
+ end
43
+
44
+ # States passed to Channel#subscribe's callback so callers know whether the
45
+ # join succeeded.
46
+ module SubscribeStates
47
+ SUBSCRIBED = "SUBSCRIBED"
48
+ TIMED_OUT = "TIMED_OUT"
49
+ CLOSED = "CLOSED"
50
+ CHANNEL_ERROR = "CHANNEL_ERROR"
51
+ end
52
+
53
+ # Server replies to a phx_join push with one of these statuses.
54
+ module AckStatus
55
+ OK = "ok"
56
+ ERROR = "error"
57
+ TIMEOUT = "timeout"
58
+ end
59
+
60
+ # Postgres-change event filters callers pass to Channel#on_postgres_changes.
61
+ # "*" subscribes to all three events.
62
+ module PostgresChangesEvent
63
+ ALL = "*"
64
+ INSERT = "INSERT"
65
+ UPDATE = "UPDATE"
66
+ DELETE = "DELETE"
67
+ end
68
+ end
69
+ end
70
+ end