solana-ruby-kit 0.1.7 → 0.1.8

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: 4a6261831e387559dd450f4da24966c5fb21a3d6d079eca64bc1c19577f1517c
4
- data.tar.gz: bb79df5d4217eb20f8fc07b9ae89e6ce070fbcb38f193b4e775d38b1cfe72998
3
+ metadata.gz: f96f7c22b28fbf9bec4186e3bf92ec52483d532b7e398da89bf143473b267878
4
+ data.tar.gz: 4a2e915aba2620726be4b5c89880c49fd7d54acf950618d4002ce19b54ac7884
5
5
  SHA512:
6
- metadata.gz: 6259e78a36520927779d1912d3cd2190690b207fdb538473ed2eabc47e153f9e16380570043087ff94aba46019ef66c5be25383f237f82ad21949cccdb6200c9
7
- data.tar.gz: 9155affe61d202d5e9847b9df5da10958ee85089410cf0bd2f856b77087710d38b3318c3c178057fa9112cfee16d5bb85f51b3d9ae36bfcfce1c01b351f04a00
6
+ metadata.gz: 8a7fef4cd3632770e3e7d67f5b4722585f66f2ad45c601687d6664fc38433a85d39de7f4d582062e6396be7df29ea3cf089ca77184a7d8c28c3cf2b3d37015ae
7
+ data.tar.gz: '0967fe5ea552384a656b22f5f03d08674e4066c373e0ee83cc31bd45598573aacade37f4295a0c24341eb0e31b7d543720be99cf347aba0ab626429f5c629085'
@@ -120,6 +120,12 @@ module Solana::Ruby::Kit
120
120
  WALLET_STANDARD__INVALID_WIRE_FORMAT = :SOLANA_ERROR__WALLET_STANDARD__INVALID_WIRE_FORMAT
121
121
  WALLET_STANDARD__SIGNATURE_VERIFICATION_FAILED = :SOLANA_ERROR__WALLET_STANDARD__SIGNATURE_VERIFICATION_FAILED
122
122
 
123
+ # ── Subscribable ──────────────────────────────────────────────────────────
124
+ SUBSCRIBABLE__RETRY_NOT_SUPPORTED = :SOLANA_ERROR__SUBSCRIBABLE__RETRY_NOT_SUPPORTED
125
+
126
+ # ── Fixed-points ──────────────────────────────────────────────────────────
127
+ FIXED_POINTS__STRICT_MODE_PRECISION_LOSS = :SOLANA_ERROR__FIXED_POINTS__STRICT_MODE_PRECISION_LOSS
128
+
123
129
  # ── Invariant violations (internal) ──────────────────────────────────────
124
130
  INVARIANT_VIOLATION__SUBSCRIPTION_ITERATOR_STATE_MISSING = :SOLANA_ERROR__INVARIANT_VIOLATION__SUBSCRIPTION_ITERATOR_STATE_MISSING
125
131
  INVARIANT_VIOLATION__SUBSCRIPTION_ITERATOR_MUST_NOT_POLL_BEFORE_RESOLVING_EXISTING_MESSAGE_PROMISE = :SOLANA_ERROR__INVARIANT_VIOLATION__SUBSCRIPTION_ITERATOR_MUST_NOT_POLL_BEFORE_RESOLVING_EXISTING_MESSAGE_PROMISE
@@ -231,6 +237,12 @@ module Solana::Ruby::Kit
231
237
  INSTRUCTION_PLANS__EMPTY_INSTRUCTION_PLAN => 'Instruction plan is empty and produced no transaction messages',
232
238
  INSTRUCTION_PLANS__FAILED_TO_EXECUTE_TRANSACTION_PLAN => 'Failed to execute transaction plan',
233
239
 
240
+ # Subscribable
241
+ SUBSCRIBABLE__RETRY_NOT_SUPPORTED => 'This reactive store does not support retry(); use create_reactive_store_from_data_publisher_factory for a retryable store',
242
+
243
+ # Fixed-points
244
+ FIXED_POINTS__STRICT_MODE_PRECISION_LOSS => 'Value has more than 9 fractional digits and cannot be represented exactly as a Sol amount',
245
+
234
246
  # Wallet Standard
235
247
  WALLET_STANDARD__INVALID_WIRE_FORMAT => 'Invalid transaction wire format: %{reason}',
236
248
  WALLET_STANDARD__SIGNATURE_VERIFICATION_FAILED => 'Signature verification failed for signer %{address}',
@@ -0,0 +1,20 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Solana::Ruby::Kit
5
+ module Rpc
6
+ module Api
7
+ # Returns the estimated production time of a block.
8
+ # Mirrors TypeScript's GetBlockTimeApi.getBlockTime.
9
+ module GetBlockTime
10
+ extend T::Sig
11
+
12
+ sig { params(slot: Integer).returns(T.nilable(Integer)) }
13
+ def get_block_time(slot)
14
+ raw = transport.request('getBlockTime', [slot])
15
+ raw.nil? ? nil : Kernel.Integer(raw)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,40 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Solana::Ruby::Kit
5
+ module Rpc
6
+ module Api
7
+ # Struct returned by get_epoch_schedule.
8
+ EpochSchedule = T.let(
9
+ Struct.new(
10
+ :first_normal_epoch, # Integer — first full-length epoch after warmup
11
+ :first_normal_slot, # Integer — first slot after warmup period
12
+ :leader_schedule_slot_offset, # Integer — slots before epoch to compute leader schedule
13
+ :slots_per_epoch, # Integer — max slots per epoch
14
+ :warmup, # Boolean — whether epochs start short and grow
15
+ keyword_init: true
16
+ ),
17
+ T.untyped
18
+ )
19
+
20
+ # Returns the epoch schedule from the cluster's genesis config.
21
+ # Mirrors TypeScript's GetEpochScheduleApi.getEpochSchedule.
22
+ module GetEpochSchedule
23
+ extend T::Sig
24
+
25
+ sig { returns(T.untyped) }
26
+ def get_epoch_schedule
27
+ raw = transport.request('getEpochSchedule', [])
28
+
29
+ EpochSchedule.new(
30
+ first_normal_epoch: Kernel.Integer(raw['firstNormalEpoch']),
31
+ first_normal_slot: Kernel.Integer(raw['firstNormalSlot']),
32
+ leader_schedule_slot_offset: Kernel.Integer(raw['leaderScheduleSlotOffset']),
33
+ slots_per_epoch: Kernel.Integer(raw['slotsPerEpoch']),
34
+ warmup: raw['warmup'] == true
35
+ )
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,59 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Solana::Ruby::Kit
5
+ module Rpc
6
+ module Api
7
+ # Struct for a single entry in the getInflationReward response.
8
+ # nil entries indicate the address was not found or had no reward.
9
+ InflationReward = T.let(
10
+ Struct.new(
11
+ :amount, # Integer (Lamports) — reward credited
12
+ :commission, # Integer — vote account commission at reward time
13
+ :effective_slot, # Integer — slot in which rewards were delivered
14
+ :epoch, # Integer — epoch for which reward occurred
15
+ :post_balance, # Integer (Lamports) — post-reward account balance
16
+ keyword_init: true
17
+ ),
18
+ T.untyped
19
+ )
20
+
21
+ # Returns the inflation/staking reward for a list of addresses for an epoch.
22
+ # Mirrors TypeScript's GetInflationRewardApi.getInflationReward.
23
+ module GetInflationReward
24
+ extend T::Sig
25
+
26
+ sig do
27
+ params(
28
+ addresses: T::Array[T.untyped],
29
+ commitment: T.nilable(Symbol),
30
+ epoch: T.nilable(Integer),
31
+ min_context_slot: T.nilable(Integer)
32
+ ).returns(T::Array[T.nilable(T.untyped)])
33
+ end
34
+ def get_inflation_reward(addresses, commitment: nil, epoch: nil, min_context_slot: nil)
35
+ addr_strings = addresses.map { |a| a.respond_to?(:value) ? a.value : a.to_s }
36
+ config = {}
37
+ config['commitment'] = commitment.to_s if commitment
38
+ config['epoch'] = epoch if epoch
39
+ config['minContextSlot'] = min_context_slot if min_context_slot
40
+
41
+ params = config.empty? ? [addr_strings] : [addr_strings, config]
42
+ raw_list = transport.request('getInflationReward', params)
43
+
44
+ raw_list.map do |entry|
45
+ next nil if entry.nil?
46
+
47
+ InflationReward.new(
48
+ amount: Kernel.Integer(entry['amount']),
49
+ commission: Kernel.Integer(entry['commission']),
50
+ effective_slot: Kernel.Integer(entry['effectiveSlot']),
51
+ epoch: Kernel.Integer(entry['epoch']),
52
+ post_balance: Kernel.Integer(entry['postBalance'])
53
+ )
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -20,6 +20,9 @@ require_relative 'api/get_token_accounts_by_owner'
20
20
  require_relative 'api/get_epoch_info'
21
21
  require_relative 'api/get_vote_accounts'
22
22
  require_relative 'api/simulate_transaction'
23
+ require_relative 'api/get_block_time'
24
+ require_relative 'api/get_epoch_schedule'
25
+ require_relative 'api/get_inflation_reward'
23
26
 
24
27
  module Solana::Ruby::Kit
25
28
  module Rpc
@@ -56,6 +59,9 @@ module Solana::Ruby::Kit
56
59
  include Api::GetEpochInfo
57
60
  include Api::GetVoteAccounts
58
61
  include Api::SimulateTransaction
62
+ include Api::GetBlockTime
63
+ include Api::GetEpochSchedule
64
+ include Api::GetInflationReward
59
65
 
60
66
  sig { returns(Transport) }
61
67
  attr_reader :transport
@@ -0,0 +1,102 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'bigdecimal'
5
+ require_relative '../errors'
6
+ require_relative 'lamports'
7
+
8
+ module Solana::Ruby::Kit
9
+ module RpcTypes
10
+ # SOL amounts expressed as a decimal fixed-point value with 9 decimal places.
11
+ # 1 SOL == 1_000_000_000 lamports, so Sol#raw is the exact Lamports count.
12
+ #
13
+ # Mirrors TypeScript's Sol (DecimalFixedPoint<'unsigned', 64, 9>).
14
+ class Sol
15
+ extend T::Sig
16
+
17
+ # The Lamports count as an unsigned integer (u64 range).
18
+ sig { returns(Integer) }
19
+ attr_reader :raw
20
+
21
+ sig { params(raw: Integer).void }
22
+ def initialize(raw)
23
+ @raw = T.let(raw, Integer)
24
+ freeze
25
+ end
26
+
27
+ # Returns the decimal string representation (e.g. "1.5", "0.000000001").
28
+ sig { returns(String) }
29
+ def to_s
30
+ whole = @raw / 10**9
31
+ fraction = @raw % 10**9
32
+ return whole.to_s if fraction.zero?
33
+
34
+ frac_str = fraction.to_s.rjust(9, '0').sub(/0+\z/, '')
35
+ "#{whole}.#{frac_str}"
36
+ end
37
+
38
+ sig { returns(String) }
39
+ def inspect = "#<#{self.class} #{self}>"
40
+
41
+ sig { params(other: T.untyped).returns(T::Boolean) }
42
+ def ==(other)
43
+ other.is_a?(Sol) && @raw == other.raw
44
+ end
45
+
46
+ sig { returns(Integer) }
47
+ def hash = @raw.hash
48
+
49
+ alias eql? ==
50
+ end
51
+
52
+ SOL_DECIMALS = T.let(9, Integer)
53
+ SOL_SCALE = T.let(10**9, Integer)
54
+ SOL_RAW_MAX = T.let(2**64 - 1, Integer)
55
+
56
+ module_function
57
+
58
+ # Parses a decimal string and returns a Sol value.
59
+ # Raises FIXED_POINTS__STRICT_MODE_PRECISION_LOSS if the string has more
60
+ # than 9 fractional digits (mirrors TypeScript's default 'strict' mode).
61
+ #
62
+ # Examples:
63
+ # sol('1.5') # => Sol(raw: 1_500_000_000)
64
+ # sol('0.000000001') # => Sol(raw: 1) — one lamport
65
+ #
66
+ # Mirrors TypeScript's sol(value, rounding?).
67
+ sig { params(value: String).returns(Sol) }
68
+ def sol(value)
69
+ bd = BigDecimal(value)
70
+ Kernel.raise SolanaError.new(SolanaError::FIXED_POINTS__STRICT_MODE_PRECISION_LOSS) if bd < 0
71
+
72
+ scaled = bd * SOL_SCALE
73
+ raw = scaled.to_i
74
+
75
+ unless scaled == BigDecimal(raw.to_s)
76
+ Kernel.raise SolanaError.new(SolanaError::FIXED_POINTS__STRICT_MODE_PRECISION_LOSS)
77
+ end
78
+
79
+ Kernel.raise SolanaError.new(SolanaError::SOLANA_ERROR__LAMPORTS_OUT_OF_RANGE) if raw > SOL_RAW_MAX
80
+
81
+ Sol.new(raw)
82
+ end
83
+
84
+ # Converts a Sol value to its equivalent Lamports integer.
85
+ # The conversion is exact — Sol#raw is the Lamports count.
86
+ #
87
+ # Mirrors TypeScript's solToLamports(value).
88
+ sig { params(value: Sol).returns(Lamports) }
89
+ def sol_to_lamports(value)
90
+ value.raw
91
+ end
92
+
93
+ # Converts a Lamports integer to its equivalent Sol value.
94
+ # The conversion is exact.
95
+ #
96
+ # Mirrors TypeScript's lamportsToSol(value).
97
+ sig { params(value: Lamports).returns(Sol) }
98
+ def lamports_to_sol(value)
99
+ Sol.new(value)
100
+ end
101
+ end
102
+ end
@@ -4,5 +4,6 @@
4
4
  # Core RPC type definitions — mirrors @solana/rpc-types.
5
5
  require_relative 'rpc_types/commitment'
6
6
  require_relative 'rpc_types/lamports'
7
+ require_relative 'rpc_types/sol'
7
8
  require_relative 'rpc_types/cluster_url'
8
9
  require_relative 'rpc_types/account_info'
@@ -0,0 +1,207 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Solana::Ruby::Kit
5
+ module Subscribable
6
+ extend T::Sig
7
+
8
+ # Lifecycle status of a ReactiveActionStore.
9
+ # Mirrors TypeScript's ReactiveActionStatus.
10
+ ReactiveActionStatus = T.type_alias { String } # 'idle' | 'running' | 'success' | 'error'
11
+
12
+ # Lifecycle snapshot of a ReactiveActionStore.
13
+ # `data` persists across running/error states so callers can render stale
14
+ # content while a retry is in flight; only reset() clears it.
15
+ #
16
+ # Mirrors TypeScript's ReactiveActionState<TResult>.
17
+ class ReactiveActionState
18
+ extend T::Sig
19
+
20
+ sig { returns(String) }
21
+ attr_reader :status
22
+
23
+ sig { returns(T.untyped) }
24
+ attr_reader :data
25
+
26
+ sig { returns(T.untyped) }
27
+ attr_reader :error
28
+
29
+ sig { params(status: String, data: T.untyped, error: T.untyped).void }
30
+ def initialize(status:, data: nil, error: nil)
31
+ @status = T.let(status, String)
32
+ @data = T.let(data, T.untyped)
33
+ @error = T.let(error, T.untyped)
34
+ freeze
35
+ end
36
+
37
+ sig { params(other: T.untyped).returns(T::Boolean) }
38
+ def ==(other)
39
+ other.is_a?(ReactiveActionState) &&
40
+ @status == other.status &&
41
+ @data.equal?(other.data) &&
42
+ @error.equal?(other.error)
43
+ end
44
+ end
45
+
46
+ REACTIVE_ACTION_IDLE_STATE = T.let(
47
+ ReactiveActionState.new(status: 'idle').freeze,
48
+ ReactiveActionState
49
+ )
50
+
51
+ # A thread-safe state machine wrapping a callable. Exposes
52
+ # dispatch / dispatch_async / get_state / subscribe / reset
53
+ # so that observers can react to in-flight, succeeded, or failed calls.
54
+ #
55
+ # dispatch — fire-and-forget; runs the callable in a background thread.
56
+ # dispatch_async — blocking; runs the callable in the current thread and
57
+ # returns the result (or raises on failure / supersession).
58
+ #
59
+ # Only the most recent dispatch can mutate state — superseded calls silently
60
+ # drop their result via a generation counter.
61
+ #
62
+ # Mirrors TypeScript's ReactiveActionStore<TArgs, TResult>.
63
+ class ReactiveActionStore
64
+ extend T::Sig
65
+
66
+ sig { params(fn: T.proc.params(args: T.untyped).returns(T.untyped)).void }
67
+ def initialize(fn)
68
+ @fn = T.let(fn, T.proc.params(args: T.untyped).returns(T.untyped))
69
+ @state = T.let(REACTIVE_ACTION_IDLE_STATE, ReactiveActionState)
70
+ @listeners = T.let([], T::Array[T.proc.void])
71
+ @mutex = T.let(Mutex.new, Mutex)
72
+ @current_gen = T.let(0, Integer)
73
+ end
74
+
75
+ # Returns the current lifecycle snapshot.
76
+ sig { returns(ReactiveActionState) }
77
+ def get_state
78
+ @mutex.synchronize { @state }
79
+ end
80
+
81
+ # Fire-and-forget dispatch. Runs fn in a background thread. Only the
82
+ # most recent call's result updates state; superseded threads are ignored.
83
+ sig { params(args: T.untyped).void }
84
+ def dispatch(*args)
85
+ gen = nil
86
+ prev_data = nil
87
+ @mutex.synchronize do
88
+ @current_gen += 1
89
+ gen = @current_gen
90
+ prev_data = @state.data
91
+ @state = ReactiveActionState.new(status: 'running', data: prev_data)
92
+ end
93
+ _notify
94
+
95
+ Thread.new do
96
+ begin
97
+ result = @fn.call(*args)
98
+ active = @mutex.synchronize do
99
+ if @current_gen == gen
100
+ @state = ReactiveActionState.new(status: 'success', data: result)
101
+ true
102
+ else
103
+ false
104
+ end
105
+ end
106
+ _notify if active
107
+ rescue => e
108
+ active = @mutex.synchronize do
109
+ if @current_gen == gen
110
+ @state = ReactiveActionState.new(status: 'error', data: prev_data, error: e)
111
+ true
112
+ else
113
+ false
114
+ end
115
+ end
116
+ _notify if active
117
+ end
118
+ end
119
+
120
+ nil
121
+ end
122
+
123
+ # Blocking dispatch. Runs fn in the current thread. Returns the result on
124
+ # success. Raises the error on failure. Raises RuntimeError if superseded
125
+ # by a concurrent dispatch or reset().
126
+ sig { params(args: T.untyped).returns(T.untyped) }
127
+ def dispatch_async(*args)
128
+ gen = nil
129
+ prev_data = nil
130
+ @mutex.synchronize do
131
+ @current_gen += 1
132
+ gen = @current_gen
133
+ prev_data = @state.data
134
+ @state = ReactiveActionState.new(status: 'running', data: prev_data)
135
+ end
136
+ _notify
137
+
138
+ result = @fn.call(*args)
139
+
140
+ active = @mutex.synchronize do
141
+ if @current_gen == gen
142
+ @state = ReactiveActionState.new(status: 'success', data: result)
143
+ true
144
+ else
145
+ false
146
+ end
147
+ end
148
+ _notify if active
149
+ Kernel.raise 'ReactiveActionStore: call was superseded' unless active
150
+ result
151
+ rescue RuntimeError
152
+ Kernel.raise
153
+ rescue => e
154
+ active = @mutex.synchronize do
155
+ if @current_gen == gen
156
+ @state = ReactiveActionState.new(status: 'error', data: prev_data, error: e)
157
+ true
158
+ else
159
+ false
160
+ end
161
+ end
162
+ _notify if active
163
+ Kernel.raise e
164
+ end
165
+
166
+ # Aborts any in-flight background dispatch (by incrementing generation)
167
+ # and resets state to idle.
168
+ sig { void }
169
+ def reset
170
+ @mutex.synchronize do
171
+ @current_gen += 1
172
+ @state = REACTIVE_ACTION_IDLE_STATE
173
+ end
174
+ _notify
175
+ end
176
+
177
+ # Registers a listener called on every state change.
178
+ # Returns an unsubscribe lambda.
179
+ sig { params(listener: T.proc.void).returns(T.proc.void) }
180
+ def subscribe(&listener)
181
+ @mutex.synchronize { @listeners << listener }
182
+ lambda { @mutex.synchronize { @listeners.delete(listener) } }
183
+ end
184
+
185
+ private
186
+
187
+ sig { void }
188
+ def _notify
189
+ subs = @mutex.synchronize { @listeners.dup }
190
+ subs.each(&:call)
191
+ end
192
+ end
193
+
194
+ module_function
195
+
196
+ # Wraps a callable in a ReactiveActionStore.
197
+ # fn receives the arguments passed to dispatch/dispatch_async.
198
+ #
199
+ # Mirrors TypeScript's createReactiveActionStore.
200
+ sig do
201
+ params(fn: T.proc.params(args: T.untyped).returns(T.untyped)).returns(ReactiveActionStore)
202
+ end
203
+ def create_reactive_action_store(&fn)
204
+ ReactiveActionStore.new(T.let(fn, T.proc.params(args: T.untyped).returns(T.untyped)))
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,255 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../errors'
5
+ require_relative 'data_publisher'
6
+
7
+ module Solana::Ruby::Kit
8
+ module Subscribable
9
+ extend T::Sig
10
+
11
+ # Lifecycle snapshot of a ReactiveStreamStore — a single frozen value
12
+ # capturing status, last-known data, and any error.
13
+ #
14
+ # status values:
15
+ # 'loading' — waiting for first value; data is nil
16
+ # 'loaded' — value has arrived, no active error
17
+ # 'error' — stream failed; data is last known value (may be nil)
18
+ # 'retrying' — retry in progress after error; data preserved
19
+ #
20
+ # Mirrors TypeScript's ReactiveState<T>.
21
+ class ReactiveState
22
+ extend T::Sig
23
+
24
+ sig { returns(String) }
25
+ attr_reader :status
26
+
27
+ sig { returns(T.untyped) }
28
+ attr_reader :data
29
+
30
+ sig { returns(T.untyped) }
31
+ attr_reader :error
32
+
33
+ sig { params(status: String, data: T.untyped, error: T.untyped).void }
34
+ def initialize(status:, data: nil, error: nil)
35
+ @status = T.let(status, String)
36
+ @data = T.let(data, T.untyped)
37
+ @error = T.let(error, T.untyped)
38
+ freeze
39
+ end
40
+
41
+ sig { params(other: T.untyped).returns(T::Boolean) }
42
+ def ==(other)
43
+ other.is_a?(ReactiveState) &&
44
+ @status == other.status &&
45
+ @data.equal?(other.data) &&
46
+ @error.equal?(other.error)
47
+ end
48
+ end
49
+
50
+ REACTIVE_LOADING_STATE = T.let(
51
+ ReactiveState.new(status: 'loading').freeze,
52
+ ReactiveState
53
+ )
54
+
55
+ # Thread-safe store that tracks the latest value published to a data channel
56
+ # and notifies subscribers on every state change.
57
+ #
58
+ # Compatible with any observer pattern that expects a
59
+ # { subscribe, get_unified_state } contract.
60
+ #
61
+ # Mirrors TypeScript's ReactiveStreamStore<T>.
62
+ class ReactiveStreamStore
63
+ extend T::Sig
64
+
65
+ sig { void }
66
+ def initialize
67
+ @state = T.let(REACTIVE_LOADING_STATE, ReactiveState)
68
+ @subscribers = T.let([], T::Array[T.proc.void])
69
+ @mutex = T.let(Mutex.new, Mutex)
70
+ end
71
+
72
+ # Returns the current lifecycle snapshot.
73
+ sig { returns(ReactiveState) }
74
+ def get_unified_state
75
+ @mutex.synchronize { @state }
76
+ end
77
+
78
+ # Returns the most recent data value, or nil if no value has arrived yet.
79
+ # @deprecated Use get_unified_state instead.
80
+ sig { returns(T.untyped) }
81
+ def get_state
82
+ @mutex.synchronize { @state.data }
83
+ end
84
+
85
+ # Returns the current error, or nil if no error has occurred.
86
+ # @deprecated Use get_unified_state instead.
87
+ sig { returns(T.untyped) }
88
+ def get_error
89
+ @mutex.synchronize { @state.error }
90
+ end
91
+
92
+ # Re-opens the stream after an error. Raises RETRY_NOT_SUPPORTED on
93
+ # DataPublisher-backed stores — use create_reactive_store_from_data_publisher_factory
94
+ # for a retryable store.
95
+ sig { void }
96
+ def retry
97
+ Kernel.raise SolanaError.new(SolanaError::SUBSCRIBABLE__RETRY_NOT_SUPPORTED)
98
+ end
99
+
100
+ # Registers a callback invoked on every state change.
101
+ # Returns an unsubscribe lambda.
102
+ sig { params(callback: T.proc.void).returns(T.proc.void) }
103
+ def subscribe(&callback)
104
+ @mutex.synchronize { @subscribers << callback }
105
+ lambda { @mutex.synchronize { @subscribers.delete(callback) } }
106
+ end
107
+
108
+ # Internal: transition state and notify all subscribers.
109
+ sig { params(new_state: ReactiveState).void }
110
+ def _set_state(new_state)
111
+ subs = nil
112
+ @mutex.synchronize do
113
+ return if @state.equal?(new_state)
114
+ @state = new_state
115
+ subs = @subscribers.dup
116
+ end
117
+ subs&.each(&:call)
118
+ end
119
+ end
120
+
121
+ # Deprecated alias — use ReactiveStreamStore.
122
+ ReactiveStore = ReactiveStreamStore
123
+
124
+ # A ReactiveStreamStore that supports retry() by re-invoking a factory proc
125
+ # to get a fresh DataPublisher on each connection attempt.
126
+ # Mirrors TypeScript's createReactiveStoreFromDataPublisherFactory result.
127
+ class RetryableReactiveStreamStore < ReactiveStreamStore
128
+ extend T::Sig
129
+
130
+ sig do
131
+ params(
132
+ data_channel: T.untyped,
133
+ error_channel: T.untyped,
134
+ create_publisher: T.proc.returns(DataPublisher),
135
+ signal: T.nilable(T.proc.void)
136
+ ).void
137
+ end
138
+ def initialize(data_channel:, error_channel:, create_publisher:, signal: nil)
139
+ super()
140
+ @data_channel = T.let(data_channel, T.untyped)
141
+ @error_channel = T.let(error_channel, T.untyped)
142
+ @outer_signal = T.let(signal, T.nilable(T.proc.void))
143
+ @create_publisher = T.let(create_publisher, T.proc.returns(DataPublisher))
144
+ @stopped = T.let(false, T::Boolean)
145
+ # Per-connection active flag shared with subscriber lambdas via closure.
146
+ @conn_active = T.let([false], T::Array[T::Boolean])
147
+ _connect
148
+ end
149
+
150
+ sig { override.void }
151
+ def retry
152
+ stopped = @mutex.synchronize { @stopped }
153
+ return if stopped
154
+ return unless get_unified_state.status == 'error'
155
+
156
+ stale_data = get_unified_state.data
157
+ # Deactivate old connection's subscribers
158
+ @mutex.synchronize { @conn_active[0] = false }
159
+ _set_state(ReactiveState.new(status: 'retrying', data: stale_data))
160
+ _connect
161
+ end
162
+
163
+ private
164
+
165
+ sig { void }
166
+ def _connect
167
+ return if @mutex.synchronize { @stopped }
168
+
169
+ # Fresh active flag for this connection window
170
+ active = [true]
171
+ @mutex.synchronize { @conn_active = active }
172
+
173
+ # inner_signal fires (raises) once this connection is superseded or errors out,
174
+ # which causes DataPublisher to prune those subscribers automatically.
175
+ inner_signal = lambda do
176
+ @outer_signal&.call
177
+ Kernel.raise 'connection_inactive' unless active[0]
178
+ end
179
+
180
+ begin
181
+ publisher = @create_publisher.call
182
+ rescue => e
183
+ active[0] = false
184
+ stale_data = get_unified_state.data
185
+ _set_state(ReactiveState.new(status: 'error', data: stale_data, error: e))
186
+ return
187
+ end
188
+
189
+ publisher.on(@data_channel, signal: inner_signal) do |data|
190
+ _set_state(ReactiveState.new(status: 'loaded', data: data))
191
+ end
192
+
193
+ publisher.on(@error_channel, signal: inner_signal) do |err|
194
+ next unless active[0] && get_unified_state.status != 'error'
195
+ active[0] = false
196
+ stale_data = get_unified_state.data
197
+ _set_state(ReactiveState.new(status: 'error', data: stale_data, error: err))
198
+ end
199
+ end
200
+ end
201
+
202
+ module_function
203
+
204
+ # Creates a ReactiveStreamStore backed by a ready-made DataPublisher.
205
+ # retry() is not supported — see create_reactive_store_from_data_publisher_factory.
206
+ #
207
+ # Mirrors TypeScript's createReactiveStoreFromDataPublisher (deprecated in TS).
208
+ sig do
209
+ params(
210
+ publisher: DataPublisher,
211
+ data_channel: T.untyped,
212
+ error_channel: T.untyped,
213
+ signal: T.nilable(T.proc.void)
214
+ ).returns(ReactiveStreamStore)
215
+ end
216
+ def create_reactive_store_from_data_publisher(publisher, data_channel:, error_channel:, signal: nil)
217
+ store = ReactiveStreamStore.new
218
+
219
+ publisher.on(data_channel, signal: signal) do |data|
220
+ store._set_state(ReactiveState.new(status: 'loaded', data: data))
221
+ end
222
+
223
+ publisher.on(error_channel, signal: signal) do |err|
224
+ unified = store.get_unified_state
225
+ next if unified.status == 'error'
226
+ store._set_state(ReactiveState.new(status: 'error', data: unified.data, error: err))
227
+ end
228
+
229
+ store
230
+ end
231
+
232
+ # Creates a retryable ReactiveStreamStore from a DataPublisher factory proc.
233
+ # The factory is called once immediately and again on each retry().
234
+ #
235
+ # Mirrors TypeScript's createReactiveStoreFromDataPublisherFactory.
236
+ sig do
237
+ params(
238
+ data_channel: T.untyped,
239
+ error_channel: T.untyped,
240
+ signal: T.nilable(T.proc.void),
241
+ blk: T.proc.returns(DataPublisher)
242
+ ).returns(ReactiveStreamStore)
243
+ end
244
+ def create_reactive_store_from_data_publisher_factory(
245
+ data_channel:, error_channel:, signal: nil, &blk
246
+ )
247
+ RetryableReactiveStreamStore.new(
248
+ data_channel: data_channel,
249
+ error_channel: error_channel,
250
+ create_publisher: T.let(blk, T.proc.returns(DataPublisher)),
251
+ signal: signal
252
+ )
253
+ end
254
+ end
255
+ end
@@ -6,6 +6,8 @@ require 'timeout'
6
6
  # Mirrors @solana/subscribable.
7
7
  require_relative 'subscribable/data_publisher'
8
8
  require_relative 'subscribable/async_iterable'
9
+ require_relative 'subscribable/reactive_stream_store'
10
+ require_relative 'subscribable/reactive_action_store'
9
11
 
10
12
  module Solana::Ruby::Kit
11
13
  module Subscribable
@@ -4,7 +4,7 @@
4
4
  module Solana
5
5
  module Ruby
6
6
  module Kit
7
- VERSION = '0.1.7'
7
+ VERSION = '0.1.8'
8
8
  end
9
9
  end
10
10
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solana-ruby-kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.7
4
+ version: 0.1.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Paul Zupan, Idhra Inc.
@@ -239,7 +239,10 @@ files:
239
239
  - lib/solana/ruby/kit/rpc/api/get_account_info.rb
240
240
  - lib/solana/ruby/kit/rpc/api/get_balance.rb
241
241
  - lib/solana/ruby/kit/rpc/api/get_block_height.rb
242
+ - lib/solana/ruby/kit/rpc/api/get_block_time.rb
242
243
  - lib/solana/ruby/kit/rpc/api/get_epoch_info.rb
244
+ - lib/solana/ruby/kit/rpc/api/get_epoch_schedule.rb
245
+ - lib/solana/ruby/kit/rpc/api/get_inflation_reward.rb
243
246
  - lib/solana/ruby/kit/rpc/api/get_latest_blockhash.rb
244
247
  - lib/solana/ruby/kit/rpc/api/get_minimum_balance_for_rent_exemption.rb
245
248
  - lib/solana/ruby/kit/rpc/api/get_multiple_accounts.rb
@@ -278,11 +281,14 @@ files:
278
281
  - lib/solana/ruby/kit/rpc_types/cluster_url.rb
279
282
  - lib/solana/ruby/kit/rpc_types/commitment.rb
280
283
  - lib/solana/ruby/kit/rpc_types/lamports.rb
284
+ - lib/solana/ruby/kit/rpc_types/sol.rb
281
285
  - lib/solana/ruby/kit/signers.rb
282
286
  - lib/solana/ruby/kit/signers/keypair_signer.rb
283
287
  - lib/solana/ruby/kit/subscribable.rb
284
288
  - lib/solana/ruby/kit/subscribable/async_iterable.rb
285
289
  - lib/solana/ruby/kit/subscribable/data_publisher.rb
290
+ - lib/solana/ruby/kit/subscribable/reactive_action_store.rb
291
+ - lib/solana/ruby/kit/subscribable/reactive_stream_store.rb
286
292
  - lib/solana/ruby/kit/sysvars.rb
287
293
  - lib/solana/ruby/kit/sysvars/addresses.rb
288
294
  - lib/solana/ruby/kit/sysvars/clock.rb