volcano-sdk 0.30.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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/lib/volcano/auth.rb +4 -48
  3. data/lib/volcano/auth_session_construction.rb +67 -0
  4. data/lib/volcano/auth_sign_up.rb +2 -0
  5. data/lib/volcano/realtime/broadcast_delivery.rb +26 -2
  6. data/lib/volcano/realtime/channel_callbacks.rb +19 -14
  7. data/lib/volcano/realtime/channel_lifecycle.rb +9 -9
  8. data/lib/volcano/realtime/connection.rb +9 -5
  9. data/lib/volcano/realtime/connection_callbacks.rb +5 -1
  10. data/lib/volcano/realtime/lifecycle.rb +2 -1
  11. data/lib/volcano/realtime/postgres_batch.rb +11 -3
  12. data/lib/volcano/realtime/postgres_changes.rb +31 -16
  13. data/lib/volcano/realtime/postgres_database.rb +14 -1
  14. data/lib/volcano/realtime/postgres_delivery.rb +6 -1
  15. data/lib/volcano/realtime/postgres_expansion.rb +10 -4
  16. data/lib/volcano/realtime/presence.rb +18 -4
  17. data/lib/volcano/realtime/presence_state.rb +23 -6
  18. data/lib/volcano/realtime/protocol.rb +14 -9
  19. data/lib/volcano/realtime/protocol_dispatch.rb +25 -7
  20. data/lib/volcano/realtime/protocol_io.rb +8 -4
  21. data/lib/volcano/realtime/protocol_lifecycle.rb +2 -0
  22. data/lib/volcano/realtime/protocol_publication_dispatch.rb +12 -10
  23. data/lib/volcano/realtime/protocol_recovery.rb +7 -3
  24. data/lib/volcano/realtime/protocol_recovery_dispatch.rb +2 -0
  25. data/lib/volcano/realtime/reconnect.rb +4 -1
  26. data/lib/volcano/realtime.rb +122 -99
  27. data/lib/volcano/storage.rb +18 -34
  28. data/lib/volcano/storage_models.rb +18 -5
  29. data/lib/volcano/storage_mutations.rb +8 -3
  30. data/lib/volcano/storage_object_response.rb +88 -0
  31. data/lib/volcano/storage_response.rb +92 -0
  32. data/lib/volcano/storage_resumable_upload.rb +13 -2
  33. data/lib/volcano/storage_session_adapter.rb +61 -0
  34. data/lib/volcano/storage_transport_adapter.rb +73 -0
  35. data/lib/volcano/storage_upload_models.rb +51 -27
  36. data/lib/volcano/storage_upload_sessions.rb +1 -35
  37. data/lib/volcano/version.rb +1 -1
  38. data/lib/volcano.rb +6 -1
  39. data/sig/auth_credentials.rbs +15 -0
  40. data/sig/realtime.rbs +46 -0
  41. data/sig/realtime_postgres_change.rbs +1 -2
  42. data/sig/storage.rbs +29 -0
  43. data/sig/storage_records.rbs +3 -1
  44. data/sig/upload_session_status.rbs +3 -1
  45. metadata +9 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 296cdb524e3552873235adccfe2d5c98b39a3da451c06777020b59d45c6a4be0
4
- data.tar.gz: 9ca783889458304d91b40601f673a4f5015f117ee4f30f90c23891e351cafe3a
3
+ metadata.gz: 551cf1235bb42130cc2c2689af7b4a68bad7582d2df5dc8345df72ee8d061925
4
+ data.tar.gz: 579af3b2c9301b7b22662e31706752c4d4098438d7ec3c25b6bb8029d17d6f4f
5
5
  SHA512:
6
- metadata.gz: 81067b14e84482e5dfebf935d27051c109ff14b87d144814a36db6ef5a7de6003e0cde0fe4ed4c54c1e06dcd9db79d8f5088c1aa21fbd4bbc3419da9a585f44e
7
- data.tar.gz: 4346575e985ab233b5afd723f36e1044b4e9e001b1eefa461a2514977f10a240a88a73978f30d88b2b74a3ec2a6bd4565d1e1ab92968e1988069492cbae9c739
6
+ metadata.gz: f3e37060a57939cf40d621880d6f9490af9912934b426d85485a37bd5ece0398d3a9adf0fc7034b211a21ef8b663fe6e2fd2aca6d4b7520cdfcc7373572b0d5f
7
+ data.tar.gz: f1d77ddbca839dfb7944e83cb025096dad93dd1fe846d13346e9825ec3280fede17d984b5ae14aed330ce4caa0f7a7eb0289107f985f089379da68a04095dce2
data/lib/volcano/auth.rb CHANGED
@@ -9,9 +9,7 @@ module Volcano
9
9
  include AuthUpdateUser
10
10
  include AuthEmailChange
11
11
  include AuthSessions
12
-
13
- INCOMPLETE_SESSION = 'Expected a complete Volcano::Session'
14
- private_constant :INCOMPLETE_SESSION
12
+ include AuthSessionConstruction
15
13
 
16
14
  def initialize(client, transport, api_url:)
17
15
  @client = client
@@ -27,10 +25,10 @@ module Volcano
27
25
  @client.store_session(owned_complete_session(session), event: nil)
28
26
  end
29
27
 
30
- def on_auth_state_change(&callback)
31
- raise ArgumentError, 'callback block required' unless callback
28
+ def on_auth_state_change(&)
29
+ raise ArgumentError, 'callback block required' unless block_given?
32
30
 
33
- @client.subscribe_auth_state_change(&callback)
31
+ @client.subscribe_auth_state_change(&)
34
32
  end
35
33
 
36
34
  def sign_in(email:, password:)
@@ -77,47 +75,5 @@ module Volcano
77
75
  )
78
76
  end
79
77
  end
80
-
81
- def build_session(payload)
82
- owned_session(
83
- access_token: payload.fetch('access_token'),
84
- refresh_token: payload.fetch('refresh_token'),
85
- user_id: payload.fetch('user').fetch('id'),
86
- user: payload.fetch('user')
87
- )
88
- end
89
-
90
- def parse_refresh_session(payload)
91
- owned_complete_session(build_session(payload))
92
- rescue KeyError, TypeError, NoMethodError, ArgumentError => e
93
- raise Error::TransportError, INCOMPLETE_SESSION, cause: e
94
- end
95
-
96
- def complete_session?(session)
97
- return false unless session.is_a?(Session)
98
-
99
- values = [session.access_token, session.refresh_token, session.user_id]
100
- values.all? { |value| value.is_a?(String) && !value.strip.empty? } &&
101
- matching_session_user?(session)
102
- end
103
-
104
- def matching_session_user?(session)
105
- session.user.nil? || (session.user.is_a?(Hash) && session.user['id'] == session.user_id)
106
- end
107
-
108
- def owned_complete_session(session)
109
- raise ArgumentError, INCOMPLETE_SESSION unless complete_session?(session)
110
-
111
- owned_session(**session.to_h)
112
- end
113
-
114
- def owned_session(access_token:, refresh_token:, user_id:, user: nil)
115
- Session.new(
116
- access_token: access_token.dup.freeze,
117
- refresh_token: refresh_token.dup.freeze,
118
- user_id: user_id.dup.freeze,
119
- user: user
120
- )
121
- end
122
78
  end
123
79
  end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Volcano
4
+ # Builds and validates owned credential snapshots for the auth facade.
5
+ module AuthSessionConstruction
6
+ INCOMPLETE_SESSION = 'Expected a complete Volcano::Session'
7
+ private_constant :INCOMPLETE_SESSION
8
+
9
+ private
10
+
11
+ def build_session(payload)
12
+ raise TypeError, INCOMPLETE_SESSION unless payload.is_a?(Hash)
13
+
14
+ user = payload.fetch('user')
15
+ raise TypeError, INCOMPLETE_SESSION unless user.is_a?(Hash)
16
+
17
+ owned_session(
18
+ access_token: required_session_string(payload.fetch('access_token')),
19
+ refresh_token: required_session_string(payload.fetch('refresh_token')),
20
+ user_id: required_session_string(user.fetch('id')),
21
+ user: user
22
+ )
23
+ end
24
+
25
+ def parse_refresh_session(payload)
26
+ owned_complete_session(build_session(payload))
27
+ rescue KeyError, TypeError, NoMethodError, ArgumentError => e
28
+ raise Error::TransportError, INCOMPLETE_SESSION, cause: e
29
+ end
30
+
31
+ def complete_session?(session)
32
+ values = [session.access_token, session.refresh_token, session.user_id]
33
+ values.all? { |value| value.is_a?(String) && !value.strip.empty? } &&
34
+ matching_session_user?(session)
35
+ end
36
+
37
+ def matching_session_user?(session)
38
+ session.user.nil? || (session.user.is_a?(Hash) && session.user['id'] == session.user_id)
39
+ end
40
+
41
+ def owned_complete_session(session)
42
+ raise ArgumentError, INCOMPLETE_SESSION unless session.is_a?(Session) && complete_session?(session)
43
+
44
+ owned_session(
45
+ access_token: required_session_string(session.access_token),
46
+ refresh_token: required_session_string(session.refresh_token),
47
+ user_id: required_session_string(session.user_id),
48
+ user: session.user
49
+ )
50
+ end
51
+
52
+ def required_session_string(value)
53
+ return value if value.is_a?(String) && !value.strip.empty?
54
+
55
+ raise ArgumentError, INCOMPLETE_SESSION
56
+ end
57
+
58
+ def owned_session(access_token:, refresh_token:, user_id:, user: nil)
59
+ Session.new(
60
+ access_token: access_token.dup.freeze,
61
+ refresh_token: refresh_token.dup.freeze,
62
+ user_id: user_id.dup.freeze,
63
+ user: user
64
+ )
65
+ end
66
+ end
67
+ end
@@ -29,6 +29,8 @@ module Volcano
29
29
  end
30
30
 
31
31
  def build_sign_up_result(payload)
32
+ raise TypeError, INVALID_SIGN_UP_RESULT unless payload.is_a?(Hash)
33
+
32
34
  confirmation_required = payload.fetch('confirmation_required')
33
35
  message = payload.fetch('message')
34
36
  valid = [true, false].include?(confirmation_required) && message.is_a?(String)
@@ -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