volcano-sdk 0.31.0 → 0.32.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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +13 -7
  3. data/lib/volcano/database.rb +28 -37
  4. data/lib/volcano/durable.rb +6 -6
  5. data/lib/volcano/durable_models.rb +65 -22
  6. data/lib/volcano/function_auth.rb +9 -2
  7. data/lib/volcano/functions.rb +18 -15
  8. data/lib/volcano/immutable_request_value.rb +21 -0
  9. data/lib/volcano/logs.rb +14 -9
  10. data/lib/volcano/models.rb +55 -22
  11. data/lib/volcano/realtime/broadcast_delivery.rb +26 -2
  12. data/lib/volcano/realtime/channel_callbacks.rb +23 -15
  13. data/lib/volcano/realtime/channel_lifecycle.rb +9 -9
  14. data/lib/volcano/realtime/connection.rb +9 -5
  15. data/lib/volcano/realtime/connection_callbacks.rb +5 -1
  16. data/lib/volcano/realtime/lifecycle.rb +2 -1
  17. data/lib/volcano/realtime/postgres_batch.rb +11 -3
  18. data/lib/volcano/realtime/postgres_changes.rb +33 -16
  19. data/lib/volcano/realtime/postgres_database.rb +14 -1
  20. data/lib/volcano/realtime/postgres_delivery.rb +6 -1
  21. data/lib/volcano/realtime/postgres_expansion.rb +10 -4
  22. data/lib/volcano/realtime/presence.rb +18 -4
  23. data/lib/volcano/realtime/presence_state.rb +23 -6
  24. data/lib/volcano/realtime/protocol.rb +14 -9
  25. data/lib/volcano/realtime/protocol_dispatch.rb +25 -7
  26. data/lib/volcano/realtime/protocol_io.rb +8 -4
  27. data/lib/volcano/realtime/protocol_lifecycle.rb +2 -0
  28. data/lib/volcano/realtime/protocol_publication_dispatch.rb +12 -10
  29. data/lib/volcano/realtime/protocol_recovery.rb +7 -3
  30. data/lib/volcano/realtime/protocol_recovery_dispatch.rb +2 -0
  31. data/lib/volcano/realtime/reconnect.rb +4 -1
  32. data/lib/volcano/realtime.rb +122 -99
  33. data/lib/volcano/session.rb +14 -3
  34. data/lib/volcano/transport.rb +53 -11
  35. data/lib/volcano/user.rb +12 -7
  36. data/lib/volcano/version.rb +1 -1
  37. data/lib/volcano.rb +1 -0
  38. data/sig/auth_session_records.rbs +1 -1
  39. data/sig/client.rbs +15 -0
  40. data/sig/core_facades.rbs +81 -0
  41. data/sig/durable_records.rbs +1 -1
  42. data/sig/locks.rbs +10 -0
  43. data/sig/realtime.rbs +45 -0
  44. data/sig/realtime_postgres_change.rbs +1 -2
  45. data/sig/user.rbs +1 -1
  46. metadata +6 -1
@@ -4,6 +4,7 @@ module Volcano
4
4
  class Realtime
5
5
  # Presence behavior mixed into realtime channels.
6
6
  module Presence
7
+ # @dynamic on, emit, with_lifecycle_lock, ensure_open!, replace_presence, apply_presence_event
7
8
  def on_presence_sync(callback = nil, &)
8
9
  ensure_presence!
9
10
  on('presence_sync', callback, &)
@@ -14,7 +15,8 @@ module Volcano
14
15
  ensure_open!
15
16
  ensure_presence!
16
17
  raise ClosedError, 'realtime channel is not subscribed' unless @subscribed
17
- raise ArgumentError, 'presence state must be a hash' unless state.is_a?(Hash)
18
+
19
+ validate_presence_state(state)
18
20
 
19
21
  @tracked_state = Immutable.call(state)
20
22
  end
@@ -24,14 +26,19 @@ module Volcano
24
26
  def presence_state = @presence_state
25
27
  def tracked_state = @tracked_state
26
28
 
29
+ # @dynamic get_presence_state
27
30
  alias get_presence_state presence_state
28
31
 
29
32
  private
30
33
 
31
34
  def initialize_presence(type)
32
35
  @type = type
33
- @presence_state = Immutable.call({})
34
- @tracked_state = Immutable.call({})
36
+ # @type var empty_presence: Hash[String, PresenceInfo]
37
+ empty_presence = {}
38
+ # @type var empty_tracked: Hash[String, json_value]
39
+ empty_tracked = {}
40
+ @presence_state = Immutable.call(empty_presence)
41
+ @tracked_state = Immutable.call(empty_tracked)
35
42
  @presence_handler = nil
36
43
  @presence_lock = nil
37
44
  @subscription_epoch = 0
@@ -45,6 +52,10 @@ module Volcano
45
52
  raise ArgumentError, 'operation is only available for presence channels'
46
53
  end
47
54
 
55
+ def validate_presence_state(state)
56
+ raise ArgumentError, 'presence state must be a hash' unless state.is_a?(Hash)
57
+ end
58
+
48
59
  def register_presence_handler(protocol, epoch)
49
60
  return unless presence?
50
61
 
@@ -67,6 +78,8 @@ module Volcano
67
78
  next unless active_presence_epoch?(epoch)
68
79
 
69
80
  result = protocol.presence(channel: @name)
81
+ raise TypeError, 'realtime presence result must be an object' unless result.is_a?(Hash)
82
+
70
83
  replace_presence(result['presence']) if active_presence_epoch?(epoch)
71
84
  end
72
85
  end
@@ -108,7 +121,8 @@ module Volcano
108
121
  end
109
122
 
110
123
  def detach_presence_handler(protocol)
111
- protocol.off_presence(@name, @presence_handler) if @presence_handler
124
+ handler = @presence_handler
125
+ protocol.off_presence(@name, handler) if handler
112
126
  @presence_handler = nil
113
127
  end
114
128
 
@@ -4,10 +4,16 @@ module Volcano
4
4
  class Realtime
5
5
  # Maintains immutable presence state independently of protocol lifecycle.
6
6
  module PresenceState
7
+ # @dynamic presence_lock, active_presence_epoch?, presence?
8
+
7
9
  private
8
10
 
9
11
  def replace_presence(clients)
10
- clients = {} unless clients.is_a?(Hash)
12
+ unless clients.is_a?(Hash)
13
+ # @type var empty_clients: Hash[String, Object?]
14
+ empty_clients = {}
15
+ clients = empty_clients
16
+ end
11
17
  @presence_state = clients.to_h do |client_id, info|
12
18
  [client_id.to_s.freeze, presence_info(info, client_id)]
13
19
  end.freeze
@@ -32,7 +38,10 @@ module Volcano
32
38
  end
33
39
 
34
40
  def presence_info(info, fallback_client)
35
- data = info['conn_info'].is_a?(Hash) ? info.fetch('conn_info') : {}
41
+ # @type var empty_data: Hash[String, json_value]
42
+ empty_data = {}
43
+ raw_data = info['conn_info']
44
+ data = raw_data.is_a?(Hash) ? raw_data : empty_data
36
45
  PresenceInfo.new(
37
46
  client: info.fetch('client', fallback_client).to_s,
38
47
  user: info['user']&.to_s,
@@ -44,16 +53,24 @@ module Volcano
44
53
  return unless presence?
45
54
 
46
55
  presence_lock.acquire do
47
- @presence_state = Immutable.call({})
48
- @tracked_state = Immutable.call({})
56
+ # @type var empty_presence: Hash[String, PresenceInfo]
57
+ empty_presence = {}
58
+ # @type var empty_tracked: Hash[String, json_value]
59
+ empty_tracked = {}
60
+ @presence_state = Immutable.call(empty_presence)
61
+ @tracked_state = Immutable.call(empty_tracked)
49
62
  @presence_state
50
63
  end
51
64
  end
52
65
 
53
66
  def reset_presence
54
67
  @subscription_epoch += 1
55
- @presence_state = Immutable.call({})
56
- @tracked_state = Immutable.call({})
68
+ # @type var empty_presence: Hash[String, PresenceInfo]
69
+ empty_presence = {}
70
+ # @type var empty_tracked: Hash[String, json_value]
71
+ empty_tracked = {}
72
+ @presence_state = Immutable.call(empty_presence)
73
+ @tracked_state = Immutable.call(empty_tracked)
57
74
  end
58
75
  end
59
76
  private_constant :PresenceState
@@ -14,6 +14,8 @@ module Volcano
14
14
  class ServerError < StandardError
15
15
  attr_reader :code
16
16
 
17
+ # @dynamic code
18
+
17
19
  def initialize(message, code: nil)
18
20
  super(message)
19
21
  @code = code.is_a?(String) ? code.dup.freeze : code
@@ -46,12 +48,10 @@ module Volcano
46
48
  def self.connect(id:, token:) = { 'id' => id, 'connect' => { 'token' => token } }
47
49
 
48
50
  def self.subscribe(id:, channel:, recoverable: false, join_leave: false, recovery: nil)
51
+ # @type var options: Hash[String, Object]
49
52
  options = { 'channel' => channel }
50
- if recovery
51
- add_recovery_options(options, recovery)
52
- elsif recoverable
53
- options['recoverable'] = true
54
- end
53
+ add_recovery_options(options, recovery) if recovery
54
+ options['recoverable'] = true if !recovery && recoverable
55
55
  options['join_leave'] = true if join_leave
56
56
  { 'id' => id, 'subscribe' => options }
57
57
  end
@@ -88,8 +88,9 @@ module Volcano
88
88
  def connect(token:)
89
89
  result = request('connect') { |id| self.class.connect(id: id, token: token) }
90
90
  ensure_open!
91
- @connected = true
92
- result
91
+ raise TypeError, 'realtime connect result must be an object' unless result.is_a?(Hash)
92
+
93
+ result.tap { @connected = true }
93
94
  end
94
95
 
95
96
  def subscribe(channel:, recoverable: false, join_leave: false, recovery: nil)
@@ -111,7 +112,11 @@ module Volcano
111
112
  def unsubscribe(channel:)
112
113
  @subscription_lock.acquire do
113
114
  ensure_open!
114
- next {} unless @subscriptions.include?(channel)
115
+ unless @subscriptions.include?(channel)
116
+ # @type var empty_reply: Hash[String, Object]
117
+ empty_reply = {}
118
+ next empty_reply
119
+ end
115
120
 
116
121
  result = request('unsubscribe') { |id| self.class.unsubscribe(id: id, channel: channel) }
117
122
  @subscriptions.delete(channel)
@@ -146,7 +151,7 @@ module Volcano
146
151
  end
147
152
 
148
153
  def ensure_open!
149
- raise @closed_error if @closed
154
+ raise(@closed_error || ClosedError.new('realtime connection closed')) if @closed
150
155
 
151
156
  self
152
157
  end
@@ -4,6 +4,9 @@ module Volcano
4
4
  class Realtime
5
5
  # Dispatches protocol replies and publications to their waiting consumers.
6
6
  module ProtocolDispatch
7
+ # @dynamic write_frame, dispatch_publication, dispatch_queued_callback, close_with, closed_error
8
+ PresenceDelivery = Data.define(:handlers, :event, :data)
9
+
7
10
  private
8
11
 
9
12
  def process(frame)
@@ -12,7 +15,10 @@ module Volcano
12
15
  elsif frame.key?('id')
13
16
  dispatch_reply(frame)
14
17
  elsif frame.key?('push')
15
- dispatch_push(frame.fetch('push'))
18
+ push = frame.fetch('push')
19
+ raise TypeError, 'realtime push must be an object' unless push.is_a?(Hash)
20
+
21
+ dispatch_push(push)
16
22
  end
17
23
  end
18
24
 
@@ -27,7 +33,10 @@ module Volcano
27
33
  end
28
34
 
29
35
  def dispatch_reply(frame)
30
- pending = @pending[frame.fetch('id')]
36
+ id = frame.fetch('id')
37
+ return unless id.is_a?(Integer)
38
+
39
+ pending = @pending.fetch(id, nil)
31
40
  return unless pending
32
41
 
33
42
  reply = reply_value(frame, pending.reply_key)
@@ -45,12 +54,18 @@ module Volcano
45
54
 
46
55
  def reply_value(frame, reply_key)
47
56
  error = frame['error']
48
- return frame.fetch(reply_key) { frame.fetch('result') { frame.except('id') } } unless error
57
+ return successful_reply(frame, reply_key) unless error
58
+
59
+ raise TypeError, 'realtime error must be an object' unless error.is_a?(Hash)
49
60
 
50
61
  message = error['message'] || 'realtime command failed'
51
62
  Protocol::Failure.new(error: ServerError.new(message, code: error['code']))
52
63
  end
53
64
 
65
+ def successful_reply(frame, reply_key)
66
+ frame.fetch(reply_key) { frame.fetch('result') { frame.except('id') } }
67
+ end
68
+
54
69
  def dispatch_presence(push, event)
55
70
  channel = matching_channel(@presence_handlers, push['channel'].to_s)
56
71
  info = push.dig(event, 'info')
@@ -61,7 +76,8 @@ module Volcano
61
76
  return
62
77
  end
63
78
 
64
- @callback_queue.enqueue([@presence_handlers.fetch(channel).dup, event, info])
79
+ delivery = PresenceDelivery.new(handlers: @presence_handlers.fetch(channel).dup, event:, data: info)
80
+ @callback_queue.enqueue(delivery)
65
81
  end
66
82
 
67
83
  def matching_channel(handlers, channel)
@@ -93,7 +109,7 @@ module Volcano
93
109
 
94
110
  def dispatch_callback_delivery(handlers, event, data)
95
111
  handlers.each do |handler|
96
- break if @callback_stopping
112
+ break handlers if @callback_stopping
97
113
 
98
114
  handler.call(event, data)
99
115
  rescue StandardError
@@ -105,9 +121,11 @@ module Volcano
105
121
  return if @callback_queue.size >= @max_callback_queue
106
122
 
107
123
  channel, handlers = @pending_presence_resyncs.shift
108
- return unless channel
124
+ return unless channel && handlers
109
125
 
110
- @callback_queue.enqueue([handlers, 'sync_required', {}])
126
+ # @type var empty_info: Hash[String, Object?]
127
+ empty_info = {}
128
+ @callback_queue.enqueue(PresenceDelivery.new(handlers:, event: 'sync_required', data: empty_info))
111
129
  end
112
130
  end
113
131
  end
@@ -6,12 +6,15 @@ module Volcano
6
6
  class Protocol
7
7
  # Coordinates protocol requests and the socket reader loop.
8
8
  module IO
9
+ # @dynamic ensure_open!, close_with, process
10
+
9
11
  private
10
12
 
11
13
  def request(reply_key, on_reply: nil)
14
+ # @type var registered_id: Integer?
12
15
  ensure_open!
13
- id, reply_queue = register_request(reply_key, on_reply)
14
- write_frame(yield(id))
16
+ registered_id, reply_queue = register_request(reply_key, on_reply)
17
+ write_frame(yield(registered_id || raise(TypeError, 'invalid realtime request id')))
15
18
  reply = await_reply(reply_queue)
16
19
  raise reply.error if reply.is_a?(Failure)
17
20
 
@@ -19,7 +22,7 @@ module Volcano
19
22
  rescue StandardError => e
20
23
  raise Redaction.exception(e, secrets: @secrets), cause: nil
21
24
  ensure
22
- @pending.delete(id)
25
+ @pending.delete(registered_id) if registered_id
23
26
  end
24
27
 
25
28
  def register_request(reply_key, on_reply)
@@ -83,7 +86,8 @@ module Volcano
83
86
 
84
87
  def closed_error(error)
85
88
  ClosedError.new(Redaction.message(error.message, secrets: @secrets)).tap do |closed|
86
- closed.set_backtrace(error.backtrace)
89
+ trace = error.backtrace
90
+ closed.set_backtrace(trace) if trace
87
91
  end
88
92
  end
89
93
  end
@@ -5,6 +5,8 @@ module Volcano
5
5
  class Protocol
6
6
  # Exposes connection state and manages publication handlers.
7
7
  module Lifecycle
8
+ # @dynamic ensure_open!, initialize_recovery_positions, initialize_publication_producer, dispatch_callbacks
9
+ # @dynamic dispatch_publication_batches, read_loop
8
10
  def connected? = @connected && !@closed
9
11
 
10
12
  def on_publication(channel, on_rejection: nil, &handler)
@@ -4,6 +4,8 @@ module Volcano
4
4
  class Realtime
5
5
  # Normalizes publications and admits live deliveries without blocking the reader.
6
6
  module ProtocolPublicationDispatch
7
+ # @dynamic enqueue_live_publication, matching_channel, drop_publication, mark_unknown_gap
8
+ # @dynamic dispatch_callback_delivery
7
9
  Registration = Data.define(:handler, :on_rejection)
8
10
  PublicationDelivery = Data.define(
9
11
  :channel, :handlers, :event, :data, :publication, :recovered, :on_rejection, :rejection_key
@@ -24,12 +26,15 @@ module Volcano
24
26
  def publication_delivery(channel, publication, recovered:)
25
27
  matched_channel = matching_publication_channel(channel)
26
28
  return unless matched_channel
27
- return reject_invalid_publication(matched_channel, publication) unless valid_publication?(publication)
29
+ return reject_invalid_publication(matched_channel, publication) unless publication.is_a?(Hash)
30
+
31
+ data = publication['data']
32
+ return reject_invalid_publication(matched_channel, publication) unless data.is_a?(Hash)
28
33
  unless @publication_handlers.key?(matched_channel)
29
34
  return drop_handlerless_publication(matched_channel, publication)
30
35
  end
31
36
 
32
- build_publication_delivery(matched_channel, publication, recovered: recovered)
37
+ build_publication_delivery(matched_channel, publication, data, recovered: recovered)
33
38
  end
34
39
 
35
40
  def matching_publication_channel(channel)
@@ -38,10 +43,6 @@ module Volcano
38
43
  [handler_channel, recovery_channel].compact.max_by(&:length)
39
44
  end
40
45
 
41
- def valid_publication?(publication)
42
- publication.is_a?(Hash) && publication['data'].is_a?(Hash)
43
- end
44
-
45
46
  def reject_invalid_publication(channel, publication)
46
47
  if publication.is_a?(Hash)
47
48
  drop_publication(channel, publication)
@@ -56,9 +57,8 @@ module Volcano
56
57
  nil
57
58
  end
58
59
 
59
- def build_publication_delivery(channel, publication, recovered:)
60
+ def build_publication_delivery(channel, publication, data, recovered:)
60
61
  registrations = @publication_handlers.fetch(channel).dup.freeze
61
- data = publication.fetch('data')
62
62
  PublicationDelivery.new(
63
63
  channel: channel.dup.freeze,
64
64
  handlers: registrations.map(&:handler).freeze,
@@ -98,14 +98,16 @@ module Volcano
98
98
  def dispatch_queued_callback(delivery)
99
99
  if delivery.is_a?(PublicationDelivery)
100
100
  dispatch_publication_callbacks(delivery)
101
+ elsif delivery.is_a?(ProtocolDispatch::PresenceDelivery)
102
+ dispatch_callback_delivery(delivery.handlers, delivery.event, delivery.data)
101
103
  else
102
- dispatch_callback_delivery(*delivery)
104
+ raise TypeError, 'invalid realtime callback delivery'
103
105
  end
104
106
  end
105
107
 
106
108
  def dispatch_publication_callbacks(delivery)
107
109
  delivery.handlers.each do |handler|
108
- break if @callback_stopping
110
+ break delivery.handlers if @callback_stopping
109
111
 
110
112
  handler.call(
111
113
  delivery.event, delivery.data, delivery.publication, recovered: delivery.recovered
@@ -4,6 +4,10 @@ module Volcano
4
4
  class Realtime
5
5
  # Validates recovery subscribe results before updating stream positions.
6
6
  module ProtocolRecovery
7
+ # @dynamic class, request, dispatch_recovered_publications, recovery_position
8
+ # @dynamic delete_recovery_state, set_recovery_position, mark_unknown_gap
9
+ # @dynamic mark_publication_gap, same_epoch?
10
+
7
11
  private
8
12
 
9
13
  def subscribe_request(channel:, recoverable:, join_leave:, recovery:)
@@ -28,7 +32,7 @@ module Volcano
28
32
 
29
33
  def parse_recovery_result(channel:, recovery:, result:)
30
34
  result_position, publications = validated_recovery_result(result)
31
- return block_malformed_recovery_result(channel, recovery) unless result_position
35
+ return block_malformed_recovery_result(channel, recovery) unless result_position && publications
32
36
 
33
37
  requested_position = requested_recovery_position(recovery)
34
38
  return block_recovery_result(channel, requested_position) if unrecovered_empty_result?(
@@ -50,7 +54,7 @@ module Volcano
50
54
  end
51
55
 
52
56
  def unrecovered_empty_result?(result, requested_position, publications)
53
- requested_position && result['recovered'] == false && publications.empty?
57
+ requested_position && result.is_a?(Hash) && result['recovered'] == false && publications.empty?
54
58
  end
55
59
 
56
60
  def validated_recovery_result(result)
@@ -68,7 +72,7 @@ module Volcano
68
72
  validated = publications.map { |publication| validated_recovery_publication(publication, result_position) }
69
73
  return [nil, nil] if validated.any?(&:nil?)
70
74
 
71
- [result_position, validated]
75
+ [result_position, validated.compact]
72
76
  end
73
77
 
74
78
  def validated_recovery_publication(publication, fallback_position)
@@ -4,6 +4,8 @@ module Volcano
4
4
  class Realtime
5
5
  # Preserves publication order while recovered deliveries wait for callback capacity.
6
6
  module ProtocolRecoveryDispatch
7
+ # @dynamic publication_delivery, reject_publication_delivery, admit_live_publication
8
+ # @dynamic close_with, closed_error
7
9
  PublicationBatch = Data.define(:channel, :deliveries, :rejection_key)
8
10
  private_constant :PublicationBatch
9
11
 
@@ -4,6 +4,7 @@ module Volcano
4
4
  class Realtime
5
5
  # Reconnects unexpected transport losses and restores subscription intent.
6
6
  module Reconnect
7
+ # @dynamic channel_lock, protocol_lock, connect_protocol, report_channel_error
7
8
  RECONNECT_MIN_DELAY = 0.1
8
9
  RECONNECT_MAX_DELAY = 20.0
9
10
  MAX_BACKOFF_EXPONENT = 8
@@ -19,7 +20,9 @@ module Volcano
19
20
  def stop_reconnect
20
21
  task = @reconnect_task
21
22
  @reconnect_task = nil
22
- task&.stop unless task.equal?(Async::Task.current)
23
+ return unless task
24
+
25
+ task.stop unless task.equal?(Async::Task.current)
23
26
  end
24
27
 
25
28
  def reconnect_loop