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
@@ -7,7 +7,14 @@ module Volcano
7
7
  ].freeze
8
8
  private_constant :AUTH_SESSION_ATTRIBUTES
9
9
 
10
- AuthSession = Data.define(*AUTH_SESSION_ATTRIBUTES) do
10
+ AuthSession = Data.define(*AUTH_SESSION_ATTRIBUTES)
11
+
12
+ # Reopens the generated record for checked initialization.
13
+ class AuthSession
14
+ # @dynamic id, user_id, provider, expires_at, is_active, is_current, user_agent
15
+ # @dynamic ip_address, last_ip_address, last_activity_at, session_started_at
16
+ # @dynamic created_at, updated_at, members, with, to_h, deconstruct
17
+ # @dynamic deconstruct_keys, self.[], self.members
11
18
  def initialize(**attributes)
12
19
  unknown = attributes.keys - AUTH_SESSION_ATTRIBUTES
13
20
  raise ArgumentError, "unknown keywords: #{unknown.join(', ')}" unless unknown.empty?
@@ -18,7 +25,8 @@ module Volcano
18
25
  values = AUTH_SESSION_ATTRIBUTES.to_h do |name|
19
26
  [name, freeze_session_value(attributes[name])]
20
27
  end
21
- super(**values)
28
+ attributes = values
29
+ super
22
30
  end
23
31
 
24
32
  private
@@ -28,19 +36,28 @@ module Volcano
28
36
  end
29
37
  end
30
38
 
31
- SessionPage = Data.define(:sessions, :total, :page, :limit, :total_pages) do
39
+ SessionPage = Data.define(:sessions, :total, :page, :limit, :total_pages)
40
+
41
+ # Reopens the generated record for checked initialization.
42
+ class SessionPage
43
+ # @dynamic sessions, total, page, limit, total_pages, members, with, to_h
44
+ # @dynamic deconstruct, deconstruct_keys, self.[], self.members
32
45
  def initialize(sessions:, total:, page:, limit:, total_pages:)
33
- super(sessions: sessions.to_a.dup.freeze, total: total, page: page, limit: limit,
34
- total_pages: total_pages)
46
+ sessions = sessions.to_a.dup.freeze
47
+ super
35
48
  end
36
49
  end
37
- LinkedOAuthProvider = Data.define(:provider, :linked_at, :updated_at) do
50
+ LinkedOAuthProvider = Data.define(:provider, :linked_at, :updated_at)
51
+
52
+ # Reopens the generated record for checked initialization.
53
+ class LinkedOAuthProvider
54
+ # @dynamic provider, linked_at, updated_at, members, with, to_h, deconstruct
55
+ # @dynamic deconstruct_keys, self.[], self.members
38
56
  def initialize(provider:, linked_at:, updated_at:)
39
- super(
40
- provider: immutable_value(provider),
41
- linked_at: immutable_value(linked_at),
42
- updated_at: immutable_value(updated_at)
43
- )
57
+ provider = immutable_value(provider)
58
+ linked_at = immutable_value(linked_at)
59
+ updated_at = immutable_value(updated_at)
60
+ super
44
61
  end
45
62
 
46
63
  private
@@ -49,30 +66,46 @@ module Volcano
49
66
  value.frozen? ? value : value.dup.freeze
50
67
  end
51
68
  end
52
- OAuthProviderTokenStatus = Data.define(:message, :provider, :expires_in) do
69
+ OAuthProviderTokenStatus = Data.define(:message, :provider, :expires_in)
70
+
71
+ # Reopens the generated record for checked initialization.
72
+ class OAuthProviderTokenStatus
73
+ # @dynamic message, provider, expires_in, members, with, to_h, deconstruct
74
+ # @dynamic deconstruct_keys, self.[], self.members
53
75
  def initialize(message:, provider:, expires_in:)
54
76
  values = [message, provider]
55
77
  valid = values.all? { |value| value.is_a?(String) && !value.strip.empty? }
56
78
  raise TypeError, 'Expected complete OAuth provider token status' unless valid && expires_in.is_a?(Integer)
57
79
 
58
- super(message: message.dup.freeze, provider: provider.dup.freeze, expires_in: expires_in)
80
+ message = message.dup.freeze
81
+ provider = provider.dup.freeze
82
+ super
59
83
  end
60
84
  end
61
85
  EmailChangeResult = Data.define(:message, :new_email)
62
- LockLease = Data.define(:key, :token, :expires_at, :fencing_token) do
86
+ LockLease = Data.define(:key, :token, :expires_at, :fencing_token)
87
+
88
+ # Reopens the generated record for checked initialization.
89
+ class LockLease
90
+ # @dynamic key, token, expires_at, fencing_token, members, with, to_h
91
+ # @dynamic deconstruct, deconstruct_keys, self.[], self.members
63
92
  def initialize(key:, token:, expires_at:, fencing_token:)
64
- super(
65
- key: key.dup.freeze,
66
- token: token.dup.freeze,
67
- expires_at: expires_at&.dup&.freeze,
68
- fencing_token: fencing_token
69
- )
93
+ key = key.dup.freeze
94
+ token = token.dup.freeze
95
+ expires_at = expires_at&.dup&.freeze
96
+ super
70
97
  end
71
98
  end
72
- LockState = Data.define(:held, :expires_at, :fencing_token) do
99
+ LockState = Data.define(:held, :expires_at, :fencing_token)
100
+
101
+ # Reopens the generated record for checked initialization.
102
+ class LockState
103
+ # @dynamic held, expires_at, fencing_token, members, with, to_h, deconstruct
104
+ # @dynamic deconstruct_keys, self.[], self.members
73
105
  def initialize(held:, expires_at:, fencing_token:)
74
106
  immutable_expiry = expires_at&.then { |value| value.frozen? ? value : value.dup.freeze }
75
- super(held: held, expires_at: immutable_expiry, fencing_token: fencing_token)
107
+ expires_at = immutable_expiry
108
+ super
76
109
  end
77
110
  end
78
111
  end
@@ -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,10 +4,16 @@ 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
 
10
- @callbacks[event] << (callback || block || raise(ArgumentError, 'callback or block is required'))
13
+ handler = callback || block || raise(ArgumentError, 'callback or block is required')
14
+ raise ArgumentError, 'callback must respond to call' unless handler.respond_to?(:call)
15
+
16
+ @callbacks[event] << handler
11
17
  self
12
18
  end
13
19
 
@@ -37,21 +43,21 @@ module Volcano
37
43
  generation = @publication_generation
38
44
  @publication_protocol = protocol if broadcast?
39
45
  @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
46
+ @name, on_rejection: publication_rejection(protocol, generation),
47
+ &publication_callback(protocol, generation)
48
+ )
47
49
  @handler_registered = true
48
50
  end
49
51
 
50
- def publication_rejection(protocol, generation)
51
- return unless broadcast?
52
-
53
- lambda do |publication|
54
- reject_broadcast(publication_context(protocol, publication, generation, false))
52
+ def publication_callback(protocol, generation)
53
+ lambda do |event, data, publication, recovered:|
54
+ # @type var recovered: bool
55
+ if broadcast?
56
+ context = publication_context(protocol, publication, generation, recovered)
57
+ deliver_broadcast(event, data, context)
58
+ else
59
+ deliver_publication(event, data)
60
+ end
55
61
  end
56
62
  end
57
63
 
@@ -67,6 +73,7 @@ module Volcano
67
73
  callbacks = @callbacks[event].dup
68
74
  return if callbacks.empty?
69
75
 
76
+ # @type var delivery: delivery
70
77
  delivery = [event, data, callbacks, before_delivery]
71
78
  return defer_callback_delivery(delivery) if callback_dispatching?
72
79
 
@@ -109,8 +116,9 @@ module Volcano
109
116
  end
110
117
 
111
118
  def detach_publication_handler(protocol)
112
- if @publication_handler
113
- protocol.off_publication(@name, @publication_handler)
119
+ handler = @publication_handler
120
+ if handler
121
+ protocol.off_publication(@name, handler)
114
122
  @publication_generation += 1 if broadcast?
115
123
  end
116
124
  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
 
@@ -28,6 +39,8 @@ module Volcano
28
39
  ensure_postgres!
29
40
  event = postgres_event(event)
30
41
  handler = callback || block || raise(ArgumentError, 'callback or block is required')
42
+ raise ArgumentError, 'callback must respond to call' unless handler.respond_to?(:call)
43
+
31
44
  register_postgres_listener(event, schema.to_s, table.to_s, handler)
32
45
  end
33
46
 
@@ -41,7 +54,7 @@ module Volcano
41
54
  end
42
55
 
43
56
  def postgres_change(data)
44
- return unless valid_postgres_change?(data)
57
+ return unless data.is_a?(Hash) && valid_postgres_change?(data)
45
58
 
46
59
  PostgresChange.new(
47
60
  type: data.fetch('type'), schema: data.fetch('schema'), table: data.fetch('table'),
@@ -58,7 +71,10 @@ module Volcano
58
71
  end
59
72
 
60
73
  def register_postgres_listener(event, schema, table, handler)
61
- filtered = proc { |change| handler.call(change) if change.schema == schema && change.table == table }
74
+ # @type var filtered: ^(Object?) -> void
75
+ filtered = lambda do |change|
76
+ handler.call(change) if change.is_a?(PostgresChange) && change.schema == schema && change.table == table
77
+ end
62
78
  @postgres_filters[filtered] = [event, schema, table]
63
79
  on(event, filtered)
64
80
  end
@@ -77,7 +93,7 @@ module Volcano
77
93
  end
78
94
 
79
95
  def valid_postgres_change?(data)
80
- valid_postgres_identity?(data) && valid_postgres_payload?(data)
96
+ data.is_a?(Hash) && valid_postgres_identity?(data) && valid_postgres_payload?(data)
81
97
  end
82
98
 
83
99
  def valid_postgres_identity?(data)
@@ -87,8 +103,9 @@ module Volcano
87
103
  end
88
104
 
89
105
  def valid_postgres_payload?(data)
106
+ mode = data['mode']
90
107
  optional_hash?(data['record']) && optional_hash?(data['old_record']) &&
91
- optional_array?(data['columns']) && [nil, 'lightweight'].include?(data['mode'])
108
+ optional_array?(data['columns']) && (mode.nil? || mode == 'lightweight')
92
109
  end
93
110
 
94
111
  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