pocketbase 0.1.1
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 +7 -0
- data/CHANGELOG.md +23 -0
- data/LICENSE +21 -0
- data/README.md +275 -0
- data/lib/pocketbase/auth_store.rb +224 -0
- data/lib/pocketbase/client.rb +390 -0
- data/lib/pocketbase/client_response_error.rb +96 -0
- data/lib/pocketbase/multipart.rb +139 -0
- data/lib/pocketbase/services/backup_service.rb +54 -0
- data/lib/pocketbase/services/base_service.rb +49 -0
- data/lib/pocketbase/services/batch_service.rb +143 -0
- data/lib/pocketbase/services/collection_service.rb +52 -0
- data/lib/pocketbase/services/cron_service.rb +21 -0
- data/lib/pocketbase/services/crud_service.rb +113 -0
- data/lib/pocketbase/services/file_service.rb +39 -0
- data/lib/pocketbase/services/health_service.rb +14 -0
- data/lib/pocketbase/services/log_service.rb +42 -0
- data/lib/pocketbase/services/realtime_service.rb +379 -0
- data/lib/pocketbase/services/record_service.rb +367 -0
- data/lib/pocketbase/services/settings_service.rb +50 -0
- data/lib/pocketbase/services/sql_service.rb +14 -0
- data/lib/pocketbase/utils.rb +146 -0
- data/lib/pocketbase/version.rb +5 -0
- data/lib/pocketbase.rb +35 -0
- metadata +72 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
require "thread"
|
|
7
|
+
require "timeout"
|
|
8
|
+
|
|
9
|
+
module PocketBase
|
|
10
|
+
module Services
|
|
11
|
+
# Realtime (SSE) service (port of js-sdk RealtimeService).
|
|
12
|
+
#
|
|
13
|
+
# unsub = pb.realtime.subscribe("posts/*") { |e| puts e["action"] }
|
|
14
|
+
# # ...
|
|
15
|
+
# unsub.call
|
|
16
|
+
#
|
|
17
|
+
# The SSE stream runs in a background thread with automatic reconnect
|
|
18
|
+
# (200ms → 2s backoff, mirroring the js-sdk intervals).
|
|
19
|
+
class RealtimeService < BaseService
|
|
20
|
+
RECONNECT_INTERVALS = [0.2, 0.3, 0.5, 1.0, 1.2, 1.5, 2.0].freeze
|
|
21
|
+
CONNECT_TIMEOUT = 15
|
|
22
|
+
|
|
23
|
+
attr_reader :client_id
|
|
24
|
+
# Callback invoked on explicit disconnect or stream interruption.
|
|
25
|
+
# Receives active subscription keys after connection identity is cleared.
|
|
26
|
+
# Runs on the caller thread for disconnect, reader thread for interruption.
|
|
27
|
+
# @return [Proc, nil]
|
|
28
|
+
attr_accessor :on_disconnect
|
|
29
|
+
|
|
30
|
+
def initialize(client)
|
|
31
|
+
super
|
|
32
|
+
@client_id = ""
|
|
33
|
+
@subscriptions = {} # key => Array of procs
|
|
34
|
+
@last_sent = []
|
|
35
|
+
@mutex = Mutex.new
|
|
36
|
+
@connect_mutex = Mutex.new
|
|
37
|
+
@submit_mutex = Mutex.new
|
|
38
|
+
@connect_queue = Queue.new
|
|
39
|
+
@awaiting_initial = false
|
|
40
|
+
@reader_thread = nil
|
|
41
|
+
@stop = false
|
|
42
|
+
@connect_gen = 0
|
|
43
|
+
@on_disconnect = nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Whether the realtime SSE connection is established.
|
|
47
|
+
def connected?
|
|
48
|
+
@mutex.synchronize { !@client_id.empty? && @reader_thread&.alive? }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Registers a subscription listener for a topic.
|
|
52
|
+
# Returns an unsubscribe lambda removing only this listener.
|
|
53
|
+
# Unknown top-level options (filter, expand, fields) become query options.
|
|
54
|
+
# Failed registration removes this listener and can be retried.
|
|
55
|
+
#
|
|
56
|
+
# @param topic [String] SSE topic, e.g. "posts/*" or "@oauth2"
|
|
57
|
+
# @param options [Hash] Query/params, headers, and extra query options
|
|
58
|
+
# @yield [data] Parsed event data, delivered on the SSE reader thread
|
|
59
|
+
# @yieldparam data [Hash] Event payload
|
|
60
|
+
# @return [Proc] Callable removing only this listener
|
|
61
|
+
# @raise [ArgumentError] If topic or callback is missing
|
|
62
|
+
# @raise [ClientResponseError] If connection or subscription update fails
|
|
63
|
+
# @example Listen for record changes
|
|
64
|
+
# unsubscribe = client.realtime.subscribe("posts/*") { |event| puts event["action"] }
|
|
65
|
+
# unsubscribe.call
|
|
66
|
+
def subscribe(topic, options = {}, &callback)
|
|
67
|
+
raise ArgumentError, "topic must be set" if topic.nil? || topic.to_s.empty?
|
|
68
|
+
raise ArgumentError, "callback block required" unless callback
|
|
69
|
+
|
|
70
|
+
key = topic.to_s
|
|
71
|
+
unless options.nil? || options.empty?
|
|
72
|
+
options = send_options({}, options)
|
|
73
|
+
payload = {
|
|
74
|
+
"query" => options[:query] || options["query"],
|
|
75
|
+
"headers" => options[:headers] || options["headers"],
|
|
76
|
+
}.compact
|
|
77
|
+
key += (key.include?("?") ? "&" : "?") +
|
|
78
|
+
"options=#{Utils.encode_uri_component(JSON.generate(payload))}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
listener = ->(data) { callback.call(data) }
|
|
82
|
+
first_for_key = false
|
|
83
|
+
@mutex.synchronize do
|
|
84
|
+
@subscriptions[key] ||= []
|
|
85
|
+
first_for_key = @subscriptions[key].empty?
|
|
86
|
+
@subscriptions[key] << listener
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
begin
|
|
90
|
+
connect unless connected?
|
|
91
|
+
submit_subscriptions if first_for_key && connected?
|
|
92
|
+
rescue StandardError
|
|
93
|
+
@mutex.synchronize do
|
|
94
|
+
@subscriptions[key]&.delete(listener)
|
|
95
|
+
@subscriptions.delete(key) if @subscriptions[key]&.empty?
|
|
96
|
+
end
|
|
97
|
+
disconnect if @mutex.synchronize { @subscriptions.empty? }
|
|
98
|
+
raise
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
-> { unsubscribe_listener(topic.to_s, listener) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Unsubscribes from a topic, or from everything when topic is nil.
|
|
105
|
+
# Closes the SSE connection when no subscriptions remain.
|
|
106
|
+
def unsubscribe(topic = nil)
|
|
107
|
+
@mutex.synchronize do
|
|
108
|
+
if topic.nil?
|
|
109
|
+
@subscriptions = {}
|
|
110
|
+
else
|
|
111
|
+
matching_keys(topic.to_s).each { |k| @subscriptions.delete(k) }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
submit_subscriptions
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Unsubscribes from all subscriptions starting with the given prefix.
|
|
119
|
+
def unsubscribe_by_prefix(prefix)
|
|
120
|
+
removed = false
|
|
121
|
+
@mutex.synchronize do
|
|
122
|
+
@subscriptions.keys.each do |key|
|
|
123
|
+
if (key + "?").start_with?(prefix.to_s)
|
|
124
|
+
@subscriptions.delete(key)
|
|
125
|
+
removed = true
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
submit_subscriptions if removed
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Closes the SSE connection (keeps registered subscriptions).
|
|
134
|
+
# Stops and joins the reader, including when the stream is idle.
|
|
135
|
+
# When called from a listener, the reader exits after callback delivery.
|
|
136
|
+
# A later subscription reconnects and restores registered subscriptions.
|
|
137
|
+
#
|
|
138
|
+
# @return [nil]
|
|
139
|
+
def disconnect
|
|
140
|
+
keys = []
|
|
141
|
+
had_client = false
|
|
142
|
+
@mutex.synchronize do
|
|
143
|
+
had_client = !@client_id.empty?
|
|
144
|
+
keys = @subscriptions.keys
|
|
145
|
+
@stop = true
|
|
146
|
+
@client_id = ""
|
|
147
|
+
@last_sent = []
|
|
148
|
+
end
|
|
149
|
+
@on_disconnect&.call(keys) if had_client
|
|
150
|
+
|
|
151
|
+
thread = @mutex.synchronize { @reader_thread }
|
|
152
|
+
if thread && thread != Thread.current
|
|
153
|
+
thread.kill
|
|
154
|
+
thread.join
|
|
155
|
+
end
|
|
156
|
+
@mutex.synchronize { @reader_thread = nil if @reader_thread == thread }
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
private
|
|
161
|
+
|
|
162
|
+
def stopped?
|
|
163
|
+
@mutex.synchronize { @stop }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def connect
|
|
167
|
+
@connect_mutex.synchronize do
|
|
168
|
+
return if connected?
|
|
169
|
+
|
|
170
|
+
gen = @mutex.synchronize do
|
|
171
|
+
@connect_gen += 1
|
|
172
|
+
@awaiting_initial = true
|
|
173
|
+
@stop = false
|
|
174
|
+
if @reader_thread.nil? || !@reader_thread.alive?
|
|
175
|
+
@reader_thread = Thread.new { reader_loop }
|
|
176
|
+
end
|
|
177
|
+
@connect_gen
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
begin
|
|
181
|
+
deadline = Time.now + CONNECT_TIMEOUT
|
|
182
|
+
loop do
|
|
183
|
+
remaining = deadline - Time.now
|
|
184
|
+
if remaining <= 0
|
|
185
|
+
raise ClientResponseError.new("message" => "Realtime connect took too long.")
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
item_gen, payload = Timeout.timeout(remaining) { @connect_queue.pop }
|
|
189
|
+
next unless item_gen == gen # ignore stale signals from older connects
|
|
190
|
+
|
|
191
|
+
raise payload if payload.is_a?(Exception)
|
|
192
|
+
|
|
193
|
+
break
|
|
194
|
+
end
|
|
195
|
+
rescue Timeout::Error
|
|
196
|
+
raise ClientResponseError.new("message" => "Realtime connect took too long.")
|
|
197
|
+
ensure
|
|
198
|
+
@mutex.synchronize { @awaiting_initial = false }
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
submit_subscriptions
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def reader_loop
|
|
206
|
+
attempts = 0
|
|
207
|
+
loop do
|
|
208
|
+
break if stopped?
|
|
209
|
+
|
|
210
|
+
begin
|
|
211
|
+
stream_sse
|
|
212
|
+
rescue StandardError => e
|
|
213
|
+
gen = @mutex.synchronize { @awaiting_initial ? @connect_gen : nil }
|
|
214
|
+
if gen
|
|
215
|
+
@connect_queue << [gen, e]
|
|
216
|
+
break
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
keys = @mutex.synchronize do
|
|
220
|
+
had_client = !@client_id.empty?
|
|
221
|
+
@client_id = ""
|
|
222
|
+
@last_sent = []
|
|
223
|
+
had_client ? @subscriptions.keys : nil
|
|
224
|
+
end
|
|
225
|
+
@on_disconnect&.call(keys) if keys
|
|
226
|
+
|
|
227
|
+
break if stopped?
|
|
228
|
+
|
|
229
|
+
has_subs = @mutex.synchronize { @subscriptions.any? { |_, v| v.any? } }
|
|
230
|
+
unless has_subs
|
|
231
|
+
@mutex.synchronize { @client_id = ""; @last_sent = [] }
|
|
232
|
+
break
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
sleep RECONNECT_INTERVALS[[attempts, RECONNECT_INTERVALS.length - 1].min]
|
|
236
|
+
attempts += 1
|
|
237
|
+
break if stopped?
|
|
238
|
+
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def stream_sse
|
|
243
|
+
uri = URI.parse(@client.build_url("/api/realtime"))
|
|
244
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
|
|
245
|
+
req = Net::HTTP::Get.new(uri.request_uri)
|
|
246
|
+
req["Accept"] = "text/event-stream"
|
|
247
|
+
token = @client.auth_store.token
|
|
248
|
+
req["Authorization"] = token unless token.nil? || token.empty?
|
|
249
|
+
|
|
250
|
+
http.request(req) do |resp|
|
|
251
|
+
if resp.code.to_i >= 400
|
|
252
|
+
raise ClientResponseError.new(
|
|
253
|
+
"url" => @client.build_url("/api/realtime"),
|
|
254
|
+
"status" => resp.code.to_i,
|
|
255
|
+
"response" => (JSON.parse(resp.body) rescue {})
|
|
256
|
+
)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
buffer = +""
|
|
260
|
+
event = nil
|
|
261
|
+
data_lines = []
|
|
262
|
+
event_id = nil
|
|
263
|
+
|
|
264
|
+
resp.read_body do |chunk|
|
|
265
|
+
break if stopped?
|
|
266
|
+
|
|
267
|
+
buffer << chunk
|
|
268
|
+
while (idx = buffer.index("\n"))
|
|
269
|
+
line = buffer.slice!(0, idx + 1).chomp
|
|
270
|
+
if line.empty?
|
|
271
|
+
dispatch_event(event, data_lines.join("\n"), event_id)
|
|
272
|
+
event = nil
|
|
273
|
+
data_lines = []
|
|
274
|
+
elsif line.start_with?("event:")
|
|
275
|
+
event = line[6..].sub(/\A /, "")
|
|
276
|
+
elsif line.start_with?("data:")
|
|
277
|
+
data_lines << line[5..].sub(/\A /, "")
|
|
278
|
+
elsif line.start_with?("id:")
|
|
279
|
+
event_id = line[3..].sub(/\A /, "")
|
|
280
|
+
end
|
|
281
|
+
# ":" comment/heartbeat lines are ignored
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def dispatch_event(event, data, event_id)
|
|
289
|
+
if event == "PB_CONNECT"
|
|
290
|
+
@mutex.synchronize do
|
|
291
|
+
@client_id = event_id.to_s
|
|
292
|
+
@last_sent = []
|
|
293
|
+
end
|
|
294
|
+
gen = @mutex.synchronize { @awaiting_initial ? @connect_gen : nil }
|
|
295
|
+
@connect_queue << [gen, :connected] if gen
|
|
296
|
+
deliver("PB_CONNECT", parse_data(data))
|
|
297
|
+
unless gen
|
|
298
|
+
# Reconnect: submit here so the reader loop can back off and
|
|
299
|
+
# retry when the submit fails (a raise aborts the stream and
|
|
300
|
+
# triggers a reconnect in reader_loop).
|
|
301
|
+
#
|
|
302
|
+
# Initial connect: #connect submits synchronously after the
|
|
303
|
+
# queue pop, so subscribe() only returns once the server has
|
|
304
|
+
# confirmed the subscriptions (avoids a create-before-submit race).
|
|
305
|
+
submit_subscriptions
|
|
306
|
+
end
|
|
307
|
+
return
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
return if event.nil? || event.empty?
|
|
311
|
+
|
|
312
|
+
deliver(event, parse_data(data))
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def deliver(key, data)
|
|
316
|
+
listeners = @mutex.synchronize { (@subscriptions[key] || []).dup }
|
|
317
|
+
listeners.each do |listener|
|
|
318
|
+
begin
|
|
319
|
+
listener.call(data)
|
|
320
|
+
rescue StandardError
|
|
321
|
+
nil
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def parse_data(data)
|
|
327
|
+
JSON.parse(data)
|
|
328
|
+
rescue StandardError
|
|
329
|
+
{}
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def submit_subscriptions
|
|
333
|
+
if @mutex.synchronize { @subscriptions.empty? }
|
|
334
|
+
disconnect
|
|
335
|
+
return
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
@submit_mutex.synchronize do
|
|
339
|
+
keys = @mutex.synchronize { @subscriptions.select { |_, v| v.any? }.keys }
|
|
340
|
+
client_id = @mutex.synchronize { @client_id }
|
|
341
|
+
return if client_id.empty?
|
|
342
|
+
|
|
343
|
+
last_sent = @mutex.synchronize { @last_sent }
|
|
344
|
+
return if (keys - last_sent).empty? && (last_sent - keys).empty?
|
|
345
|
+
|
|
346
|
+
@client.send_request(
|
|
347
|
+
"/api/realtime",
|
|
348
|
+
method: "POST",
|
|
349
|
+
body: { "clientId" => client_id, "subscriptions" => keys }
|
|
350
|
+
)
|
|
351
|
+
@mutex.synchronize { @last_sent = keys if @client_id == client_id }
|
|
352
|
+
end
|
|
353
|
+
rescue ClientResponseError
|
|
354
|
+
raise
|
|
355
|
+
rescue StandardError => e
|
|
356
|
+
raise ClientResponseError.wrap(e)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def unsubscribe_listener(topic, listener)
|
|
360
|
+
@mutex.synchronize do
|
|
361
|
+
matching_keys(topic).each do |key|
|
|
362
|
+
arr = @subscriptions[key]
|
|
363
|
+
next unless arr
|
|
364
|
+
|
|
365
|
+
arr.delete(listener)
|
|
366
|
+
@subscriptions.delete(key) if arr.empty?
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
submit_subscriptions
|
|
370
|
+
nil
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def matching_keys(topic)
|
|
374
|
+
t = topic.include?("?") ? topic : topic + "?"
|
|
375
|
+
@subscriptions.keys.select { |k| (k + "?").start_with?(t) }
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
end
|