volcano-sdk 0.31.0 → 0.32.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 36cf6c142c98f421f086d919b4d5e83034e95831d2b4246a892be57ebfaea9d5
4
- data.tar.gz: 877909af108367683c4cbb434235baa27669af9d303dd0b52307919446f8c348
3
+ metadata.gz: 551cf1235bb42130cc2c2689af7b4a68bad7582d2df5dc8345df72ee8d061925
4
+ data.tar.gz: 579af3b2c9301b7b22662e31706752c4d4098438d7ec3c25b6bb8029d17d6f4f
5
5
  SHA512:
6
- metadata.gz: 832893a1b1ba6caa88f144b81d8b8e155d2041be82fccffe4328eeb1f59f4fc8e3ce84adbb476f3b1a6b26ed719da7293f9dfb6a563270cdcb5222125661f378
7
- data.tar.gz: acfb489b98c3e8dcca7918ee1136d4e04f62fec2a7287fcc16fcd952edd3bfae2d4973b6307ef53d577a08cb4104511666917e4435c3d7722bb3cde544d7ad3d
6
+ metadata.gz: f3e37060a57939cf40d621880d6f9490af9912934b426d85485a37bd5ece0398d3a9adf0fc7034b211a21ef8b663fe6e2fd2aca6d4b7520cdfcc7373572b0d5f
7
+ data.tar.gz: f1d77ddbca839dfb7944e83cb025096dad93dd1fe846d13346e9825ec3280fede17d984b5ae14aed330ce4caa0f7a7eb0289107f985f089379da68a04095dce2
@@ -4,9 +4,16 @@ module Volcano
4
4
  class Realtime
5
5
  # Delivers broadcasts only while their subscription generation is current.
6
6
  module BroadcastDelivery
7
- PublicationContext = Data.define(:protocol, :publication, :generation, :recovered) do
7
+ # @dynamic emit, with_lifecycle_lock, broadcast?
8
+ # Captures the subscription generation alongside an immutable publication.
9
+ PublicationContext = Data.define(:protocol, :publication, :generation, :recovered)
10
+
11
+ # Freezes the publication before callbacks can observe or modify it.
12
+ class PublicationContext
13
+ # @dynamic protocol, publication, generation, recovered
8
14
  def initialize(protocol:, publication:, generation:, recovered:)
9
- super(protocol:, publication: Immutable.call(publication), generation:, recovered:)
15
+ publication = Immutable.call(publication)
16
+ super
10
17
  end
11
18
  end
12
19
  private_constant :PublicationContext
@@ -30,6 +37,14 @@ module Volcano
30
37
  PublicationContext.new(protocol:, publication:, generation:, recovered:)
31
38
  end
32
39
 
40
+ def publication_rejection(protocol, generation)
41
+ return unless broadcast?
42
+
43
+ lambda do |publication|
44
+ reject_broadcast(publication_context(protocol, publication, generation, false))
45
+ end
46
+ end
47
+
33
48
  def reject_broadcast(context)
34
49
  with_current_publication(context) do
35
50
  context.protocol.__send__(:drop_publication, @name, context.publication)
@@ -57,6 +72,15 @@ module Volcano
57
72
  @publication_protocol.__send__(:delete_recovery_state, @name)
58
73
  @publication_protocol = nil
59
74
  end
75
+
76
+ def clear_channel_recovery_state
77
+ return unless broadcast?
78
+
79
+ # @type var position: Hash[Symbol, String | Integer]
80
+ position = {}
81
+ @recovery_position = position.freeze
82
+ @recovery_lineage = nil
83
+ end
60
84
  end
61
85
  private_constant :BroadcastDelivery
62
86
  end
@@ -4,6 +4,9 @@ module Volcano
4
4
  class Realtime
5
5
  # Registers channel callbacks and connects them to protocol pushes.
6
6
  module ChannelCallbacks
7
+ # @dynamic presence?, postgres?, broadcast?, register_presence_handler, detach_presence_handler
8
+ # @dynamic deliver_broadcast, publication_context, reject_broadcast, publication_rejection
9
+ # @dynamic dispatch_postgres_change
7
10
  def on(event, callback = nil, &block)
8
11
  raise ArgumentError, "unsupported realtime event: #{event}" unless allowed_event?(event)
9
12
 
@@ -37,21 +40,21 @@ module Volcano
37
40
  generation = @publication_generation
38
41
  @publication_protocol = protocol if broadcast?
39
42
  @publication_handler = protocol.on_publication(
40
- @name, on_rejection: publication_rejection(protocol, generation)
41
- ) do |event, data, publication, recovered:|
42
- next deliver_publication(event, data) unless broadcast?
43
-
44
- context = publication_context(protocol, publication, generation, recovered)
45
- deliver_broadcast(event, data, context)
46
- end
43
+ @name, on_rejection: publication_rejection(protocol, generation),
44
+ &publication_callback(protocol, generation)
45
+ )
47
46
  @handler_registered = true
48
47
  end
49
48
 
50
- def publication_rejection(protocol, generation)
51
- return unless broadcast?
52
-
53
- lambda do |publication|
54
- reject_broadcast(publication_context(protocol, publication, generation, false))
49
+ def publication_callback(protocol, generation)
50
+ lambda do |event, data, publication, recovered:|
51
+ # @type var recovered: bool
52
+ if broadcast?
53
+ context = publication_context(protocol, publication, generation, recovered)
54
+ deliver_broadcast(event, data, context)
55
+ else
56
+ deliver_publication(event, data)
57
+ end
55
58
  end
56
59
  end
57
60
 
@@ -67,6 +70,7 @@ module Volcano
67
70
  callbacks = @callbacks[event].dup
68
71
  return if callbacks.empty?
69
72
 
73
+ # @type var delivery: delivery
70
74
  delivery = [event, data, callbacks, before_delivery]
71
75
  return defer_callback_delivery(delivery) if callback_dispatching?
72
76
 
@@ -109,8 +113,9 @@ module Volcano
109
113
  end
110
114
 
111
115
  def detach_publication_handler(protocol)
112
- if @publication_handler
113
- protocol.off_publication(@name, @publication_handler)
116
+ handler = @publication_handler
117
+ if handler
118
+ protocol.off_publication(@name, handler)
114
119
  @publication_generation += 1 if broadcast?
115
120
  end
116
121
  detach_presence_handler(protocol)
@@ -4,6 +4,9 @@ module Volcano
4
4
  class Realtime
5
5
  # Coordinates a channel's protocol subscription and local lifecycle state.
6
6
  module ChannelLifecycle
7
+ # @dynamic end_postgres_delivery, reset_presence, detach_publication_handler, ensure_open!
8
+ # @dynamic broadcast?, next_presence_epoch, register_handlers, begin_postgres_delivery
9
+ # @dynamic invalidate_presence_subscription, clear_presence, presence?
7
10
  def mark_closed
8
11
  @closed = true
9
12
  @subscription_desired = @subscribed = false
@@ -84,14 +87,18 @@ module Volcano
84
87
  return unless broadcast?
85
88
 
86
89
  _, lineage, = @realtime.__send__(:capture_protocol_session)
87
- [lineage, recovery_position(lineage)].freeze
90
+ # @type var binding: recovery_binding
91
+ binding = [lineage, recovery_position(lineage)]
92
+ binding.freeze
88
93
  end
89
94
 
90
95
  def recovery_position(lineage)
91
96
  return @recovery_position if @recovery_lineage == lineage
92
97
 
93
98
  @recovery_lineage = lineage
94
- @recovery_position = {}.freeze
99
+ # @type var position: recovery_position
100
+ position = {}
101
+ @recovery_position = position.freeze
95
102
  end
96
103
 
97
104
  def validate_recovery_binding!(binding)
@@ -113,13 +120,6 @@ module Volcano
113
120
  @recovery_position = protocol.__send__(:position, @name) || @recovery_position
114
121
  end
115
122
 
116
- def clear_channel_recovery_state
117
- return unless broadcast?
118
-
119
- @recovery_position = {}.freeze
120
- @recovery_lineage = nil
121
- end
122
-
123
123
  def mark_removed
124
124
  @callbacks.each_value(&:clear)
125
125
  @handler_registered = @subscription_desired = @subscribed = false
@@ -4,6 +4,9 @@ module Volcano
4
4
  class Realtime
5
5
  # Opens the WebSocket transport and maps its failures to public errors.
6
6
  module Connection
7
+ # @dynamic protocol_connected, protocol_error, protocol_closed, protocol_error_started
8
+ # @dynamic protocol_failed, stop_reconnect
9
+
7
10
  private
8
11
 
9
12
  def connect_protocol
@@ -71,9 +74,9 @@ module Volcano
71
74
  socket: socket,
72
75
  secrets: realtime_secrets,
73
76
  events: Protocol::Events.new(
74
- on_close: method(:protocol_closed),
75
- on_error: method(:protocol_error_started),
76
- on_failure: method(:protocol_failed)
77
+ on_close: ->(error) { protocol_closed(error) },
78
+ on_error: ->(error) { protocol_error_started(error) },
79
+ on_failure: ->(error, disconnected) { protocol_failed(error, disconnected) }
77
80
  )
78
81
  )
79
82
  end
@@ -87,7 +90,7 @@ module Volcano
87
90
  def address
88
91
  uri = URI(@api_url)
89
92
  uri.scheme = uri.scheme == 'https' ? 'wss' : 'ws'
90
- uri.path = "#{uri.path.delete_suffix('/')}/realtime/v1/websocket"
93
+ uri.path = "#{uri.path.to_s.delete_suffix('/')}/realtime/v1/websocket"
91
94
  encoded_key = URI.encode_www_form_component(@client.anon_token).gsub('+', '%20')
92
95
  uri.query = "apikey=#{encoded_key}"
93
96
  uri.to_s
@@ -106,7 +109,8 @@ module Volcano
106
109
  return redacted unless Transport::NETWORK_ERRORS.any? { |type| error.is_a?(type) }
107
110
 
108
111
  transport_error = Error::TransportError.new(redacted.message)
109
- transport_error.set_backtrace(redacted.backtrace)
112
+ trace = redacted.backtrace
113
+ transport_error.set_backtrace(trace) if trace
110
114
  transport_error
111
115
  end
112
116
  end
@@ -4,6 +4,7 @@ module Volcano
4
4
  class Realtime
5
5
  # Registers and dispatches public connection lifecycle callbacks.
6
6
  module ConnectionCallbacks
7
+ # @dynamic start_reconnect
7
8
  def on_connect(callback = nil, &block) = register_connection_callback(:connect, callback, block)
8
9
  def on_disconnect(callback = nil, &block) = register_connection_callback(:disconnect, callback, block)
9
10
  def on_error(callback = nil, &block) = register_connection_callback(:error, callback, block)
@@ -76,6 +77,7 @@ module Volcano
76
77
 
77
78
  def protocol_failed(error, disconnected)
78
79
  reset_after_protocol_loss if disconnected
80
+ # @type var events: Array[[connection_event, connection_context]]
79
81
  events = [[:error, error_context(error)]]
80
82
  events << [:disconnect, disconnect_context(error)] if disconnected
81
83
  emit_connection_events(events)
@@ -84,8 +86,10 @@ module Volcano
84
86
 
85
87
  def error_context(error)
86
88
  snapshot = immutable_exception(error)
89
+ code = snapshot.respond_to?(:code) ? snapshot.method(:code).call : nil
90
+ code = nil unless code.is_a?(String) || code.is_a?(Integer)
87
91
  ErrorContext.new(
88
- code: snapshot.respond_to?(:code) ? snapshot.code : nil,
92
+ code: code,
89
93
  message: immutable_string(snapshot.message), error: snapshot
90
94
  )
91
95
  end
@@ -4,6 +4,7 @@ module Volcano
4
4
  class Realtime
5
5
  # Channel collection and connection-state lifecycle operations.
6
6
  module Lifecycle
7
+ # @dynamic channel_lock, normalize_channel_type
7
8
  def connected? = !@closed && (@protocol&.connected? || false)
8
9
 
9
10
  def remove_channel(name, type: :broadcast)
@@ -26,7 +27,7 @@ module Volcano
26
27
  private
27
28
 
28
29
  def remove_channel_locked(name)
29
- channel = @channels[name]
30
+ channel = @channels.fetch(name, nil)
30
31
  return unless channel
31
32
 
32
33
  channel.__send__(:remove)
@@ -2,9 +2,14 @@
2
2
 
3
3
  module Volcano
4
4
  class Realtime
5
+ # Immutable limits for one channel's automatic row-fetch queue.
5
6
  PostgresBatchConfig = Data.define(
6
7
  :auto_fetch, :batch_window_ms, :max_batch_size
7
- ) do
8
+ )
9
+
10
+ # Validates the queue limits supplied to a Postgres channel.
11
+ class PostgresBatchConfig
12
+ # @dynamic auto_fetch, batch_window_ms, max_batch_size
8
13
  def initialize(auto_fetch:, batch_window_ms:, max_batch_size:)
9
14
  unless batch_window_ms.is_a?(Integer) && batch_window_ms.positive?
10
15
  raise ArgumentError, 'fetch_batch_window_ms must be a positive integer'
@@ -20,6 +25,8 @@ module Volcano
20
25
 
21
26
  # Collects compatible row lookups and preserves their callback order.
22
27
  module PostgresBatch
28
+ # @dynamic postgres_stop?, current_postgres_request?, expanded_postgres_changes, emit
29
+
23
30
  private
24
31
 
25
32
  def collect_postgres_batch(first)
@@ -35,6 +42,7 @@ module Volcano
35
42
  while requests.length < @postgres_batch_config.max_batch_size
36
43
  request = dequeue_postgres_before(deadline)
37
44
  return unless request
45
+ return request unless request.is_a?(PostgresDeliveryRequest)
38
46
  return request unless compatible_postgres_request?(requests.first, request)
39
47
 
40
48
  requests << request
@@ -72,8 +80,8 @@ module Volcano
72
80
  return unless current_postgres_request?(requests.first)
73
81
 
74
82
  changes = expanded_postgres_changes(requests)
75
- requests.zip(changes).each do |request, change|
76
- deliver_expanded_postgres_change(request, change)
83
+ requests.each_with_index do |request, index|
84
+ deliver_expanded_postgres_change(request, changes.fetch(index))
77
85
  end
78
86
  end
79
87
 
@@ -4,23 +4,34 @@ module Volcano
4
4
  class Realtime
5
5
  PostgresChange = Data.define(
6
6
  :type, :schema, :table, :record, :old_record, :columns, :timestamp, :id, :mode
7
- ) do
8
- def initialize(
9
- type:, schema:, table:, timestamp:, record: nil, old_record: nil, columns: nil,
10
- id: nil, mode: nil
11
- )
12
- super(
13
- type: Immutable.call(type.to_s), schema: Immutable.call(schema.to_s),
14
- table: Immutable.call(table.to_s), record: Immutable.optional(record),
15
- old_record: Immutable.optional(old_record),
16
- columns: Immutable.optional(columns), timestamp: Immutable.call(timestamp.to_s),
17
- id: Immutable.optional(id), mode: mode && Immutable.call(mode.to_s)
18
- )
7
+ )
8
+
9
+ # Immutable snapshot of a Postgres change publication.
10
+ class PostgresChange
11
+ # @dynamic type, schema, table, record, old_record, columns, timestamp, id, mode
12
+ # @dynamic members, with, to_h, deconstruct, deconstruct_keys, self.[], self.members
13
+ def initialize(type:, schema:, table:, timestamp:, **payload)
14
+ type = Immutable.call(type.to_s)
15
+ schema = Immutable.call(schema.to_s)
16
+ table = Immutable.call(table.to_s)
17
+ timestamp = Immutable.call(timestamp.to_s)
18
+ payload = snapshot_payload(payload)
19
+ super
20
+ end
21
+
22
+ private
23
+
24
+ def snapshot_payload(payload)
25
+ payload = { record: nil, old_record: nil, columns: nil, id: nil, mode: nil }.merge(payload)
26
+ %i[record old_record columns id].each { |field| payload[field] = Immutable.optional(payload[field]) }
27
+ payload[:mode] &&= Immutable.call(payload[:mode].to_s)
28
+ payload
19
29
  end
20
30
  end
21
31
 
22
32
  # Registers filtered callbacks for Postgres change publications.
23
33
  module PostgresChanges
34
+ # @dynamic on, enqueue_postgres_delivery
24
35
  CHANGE_EVENTS = %w[INSERT UPDATE DELETE].freeze
25
36
  EVENTS = [*CHANGE_EVENTS, '*'].freeze
26
37
 
@@ -41,7 +52,7 @@ module Volcano
41
52
  end
42
53
 
43
54
  def postgres_change(data)
44
- return unless valid_postgres_change?(data)
55
+ return unless data.is_a?(Hash) && valid_postgres_change?(data)
45
56
 
46
57
  PostgresChange.new(
47
58
  type: data.fetch('type'), schema: data.fetch('schema'), table: data.fetch('table'),
@@ -58,7 +69,10 @@ module Volcano
58
69
  end
59
70
 
60
71
  def register_postgres_listener(event, schema, table, handler)
61
- filtered = proc { |change| handler.call(change) if change.schema == schema && change.table == table }
72
+ # @type var filtered: ^(Object?) -> void
73
+ filtered = lambda do |change|
74
+ handler.call(change) if change.is_a?(PostgresChange) && change.schema == schema && change.table == table
75
+ end
62
76
  @postgres_filters[filtered] = [event, schema, table]
63
77
  on(event, filtered)
64
78
  end
@@ -77,7 +91,7 @@ module Volcano
77
91
  end
78
92
 
79
93
  def valid_postgres_change?(data)
80
- valid_postgres_identity?(data) && valid_postgres_payload?(data)
94
+ data.is_a?(Hash) && valid_postgres_identity?(data) && valid_postgres_payload?(data)
81
95
  end
82
96
 
83
97
  def valid_postgres_identity?(data)
@@ -87,8 +101,9 @@ module Volcano
87
101
  end
88
102
 
89
103
  def valid_postgres_payload?(data)
104
+ mode = data['mode']
90
105
  optional_hash?(data['record']) && optional_hash?(data['old_record']) &&
91
- optional_array?(data['columns']) && [nil, 'lightweight'].include?(data['mode'])
106
+ optional_array?(data['columns']) && (mode.nil? || mode == 'lightweight')
92
107
  end
93
108
 
94
109
  def optional_hash?(value) = value.nil? || value.is_a?(Hash)
@@ -10,6 +10,8 @@ module Volcano
10
10
 
11
11
  attr_reader :database_name
12
12
 
13
+ # @dynamic database_name
14
+
13
15
  def database_name=(name)
14
16
  unless valid_database_name?(name)
15
17
  raise ArgumentError,
@@ -48,12 +50,23 @@ module Volcano
48
50
 
49
51
  def fetch_postgres_rows(change, ids, database_name, access_token)
50
52
  table = change.schema == 'public' ? change.table : "#{change.schema}.#{change.table}"
51
- postgres_fetch_semaphore.acquire do
53
+ rows = postgres_fetch_semaphore.acquire do
52
54
  BlockingCall.call do
53
55
  @client.__send__(:database_with_token, database_name, access_token)
54
56
  .from(table).select('*').in('id', ids).execute
55
57
  end
56
58
  end
59
+ validate_postgres_rows(rows)
60
+ end
61
+
62
+ def validate_postgres_rows(rows)
63
+ raise TypeError, 'Postgres row lookup must return an array' unless rows.is_a?(Array)
64
+
65
+ rows.map do |row|
66
+ raise TypeError, 'Postgres row lookup must return objects' unless row.is_a?(Hash)
67
+
68
+ row
69
+ end
57
70
  end
58
71
 
59
72
  def postgres_fetch_semaphore
@@ -9,6 +9,7 @@ module Volcano
9
9
 
10
10
  # Serializes Postgres publications and expands lightweight row identities.
11
11
  module PostgresDelivery
12
+ # @dynamic postgres?, collect_postgres_batch, deliver_postgres_batch
12
13
  QUEUE_LIMIT = 128
13
14
  STOP = Object.new.freeze
14
15
  private_constant :QUEUE_LIMIT, :STOP
@@ -18,7 +19,9 @@ module Volcano
18
19
  def initialize_postgres_delivery(batch_config)
19
20
  require 'async/queue'
20
21
  @postgres_batch_config = batch_config
21
- @postgres_filters = {}
22
+ # @type var empty_filters: Hash[^(Object?) -> void, [String, String, String]]
23
+ empty_filters = {}
24
+ @postgres_filters = empty_filters
22
25
  @postgres_queue = Async::LimitedQueue.new(QUEUE_LIMIT)
23
26
  @postgres_worker = nil
24
27
  @postgres_epoch = 0
@@ -88,10 +91,12 @@ module Volcano
88
91
  end
89
92
 
90
93
  def run_postgres_worker
94
+ # @type var pending: Object?
91
95
  pending = nil
92
96
  loop do
93
97
  request = pending || @postgres_queue.dequeue
94
98
  break if postgres_stop?(request)
99
+ raise TypeError, 'invalid Postgres delivery request' unless request.is_a?(PostgresDeliveryRequest)
95
100
 
96
101
  requests, pending = collect_postgres_batch(request)
97
102
  deliver_postgres_batch(requests)
@@ -4,6 +4,8 @@ module Volcano
4
4
  class Realtime
5
5
  # Expands lightweight Postgres identities into callback-ready changes.
6
6
  module PostgresExpansion
7
+ # @dynamic current_postgres_request?, postgres_batch_key
8
+
7
9
  private
8
10
 
9
11
  def expanded_postgres_changes(requests)
@@ -26,12 +28,16 @@ module Volcano
26
28
 
27
29
  def fetch_postgres_records(requests)
28
30
  request = requests.first
31
+ database_name = request.database_name
32
+ lineage = request.session_lineage
33
+ raise TypeError, 'invalid Postgres fetch request' unless database_name && lineage.is_a?(SessionOperations)
34
+
29
35
  access_token = @realtime.__send__(
30
- :access_token_for_protocol_lineage, request.session_lineage
36
+ :access_token_for_protocol_lineage, lineage
31
37
  )
32
38
  rows = @realtime.__send__(
33
39
  :fetch_postgres_rows, request.change, postgres_ids(requests),
34
- request.database_name, access_token
40
+ database_name, access_token
35
41
  )
36
42
  rows.to_h { |row| [row.fetch('id').to_s, row] }
37
43
  end
@@ -41,7 +47,7 @@ module Volcano
41
47
  end
42
48
 
43
49
  def expanded_postgres_record(request, records)
44
- record = records[request.change.id.to_s]
50
+ record = records.fetch(request.change.id.to_s, nil)
45
51
  unless record
46
52
  report_postgres_fetch_error(request, Error::NotFoundError.new('Postgres row not found'))
47
53
  return request.change
@@ -55,7 +61,7 @@ module Volcano
55
61
  end
56
62
 
57
63
  def delete_postgres_change(change)
58
- old_record = change.old_record || (change.id && { 'id' => change.id })
64
+ old_record = change.old_record || (change.id ? { 'id' => change.id } : nil)
59
65
  change.with(old_record:, id: nil, mode: nil)
60
66
  end
61
67
 
@@ -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