statelydb 0.1.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.
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "error"
4
+ require "grpc"
5
+
6
+ module StatelyDB
7
+ module Common
8
+ # GRPC interceptor to convert errors to StatelyDB::Error
9
+ class ErrorInterceptor < GRPC::ClientInterceptor
10
+ # client unary interceptor
11
+ def request_response(request:, call:, method:, metadata:) # rubocop:disable Lint/UnusedMethodArgument
12
+ yield
13
+ rescue Exception => e
14
+ raise StatelyDB::Error.from(e)
15
+ end
16
+
17
+ # client streaming interceptor
18
+ def client_streamer(requests:, call:, method:, metadata:) # rubocop:disable Lint/UnusedMethodArgument
19
+ yield
20
+ rescue Exception => e
21
+ raise StatelyDB::Error.from(e)
22
+ end
23
+
24
+ # server streaming interceptor
25
+ def server_streamer(request:, call:, method:, metadata:) # rubocop:disable Lint/UnusedMethodArgument
26
+ yield
27
+ rescue Exception => e
28
+ raise StatelyDB::Error.from(e)
29
+ end
30
+
31
+ # bidirectional streaming interceptor
32
+ def bidi_streamer(requests:, call:, method:, metadata:) # rubocop:disable Lint/UnusedMethodArgument
33
+ yield
34
+ rescue Exception => e
35
+ raise StatelyDB::Error.from(e)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "grpc"
4
+ require "uri"
5
+
6
+ module StatelyDB
7
+ module Common
8
+ # A module for Stately Cloud networking code
9
+ module Net
10
+ # Create a new gRPC channel
11
+ # @param [String] endpoint The endpoint to connect to
12
+ # @return [GRPC::Core::Channel] The new channel
13
+ def self.new_channel(endpoint: "https://api.stately.cloud")
14
+ endpoint_uri = URI(endpoint)
15
+ creds = GRPC::Core::ChannelCredentials.new
16
+ call_creds = GRPC::Core::CallCredentials.new(proc {})
17
+ creds = if endpoint_uri.scheme == "http"
18
+ :this_channel_is_insecure
19
+ else
20
+ creds.compose(call_creds)
21
+ end
22
+ GRPC::Core::Channel.new(endpoint_uri.authority, {}, creds)
23
+ end
24
+ end
25
+ end
26
+ end
data/lib/error.rb ADDED
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Add the pb dir to the LOAD_PATH because generated proto imports are not relative and
4
+ # we don't want the protos polluting the main namespace.
5
+ # Tracking here: https://github.com/grpc/grpc/issues/6164
6
+ $LOAD_PATH.unshift "#{File.dirname(__FILE__)}/api"
7
+
8
+ require "api/errors/error_details_pb"
9
+
10
+ module StatelyDB
11
+ # The Error class contains common StatelyDB error types.
12
+ class Error < StandardError
13
+ # The gRPC/Connect Code for this error.
14
+ attr_reader :code
15
+ # The more fine-grained Stately error code, which is a human-readable string.
16
+ attr_reader :stately_code
17
+ # The upstream cause of the error, if available.
18
+ attr_reader :cause
19
+
20
+ # @param [String] message
21
+ # @param [Integer] code
22
+ # @param [String] stately_code
23
+ # @param [Exception] cause
24
+ def initialize(message, code: nil, stately_code: nil, cause: nil)
25
+ # Turn a gRPC status code into a human-readable string. e.g. 3 -> "InvalidArgument"
26
+ code_str = if code > 0
27
+ GRPC::Core::StatusCodes.constants.find do |c|
28
+ GRPC::Core::StatusCodes.const_get(c) === code
29
+ end.to_s.split("_").collect(&:capitalize).join
30
+ else
31
+ "Unknown"
32
+ end
33
+
34
+ super("(#{code_str}/#{stately_code}): #{message}")
35
+ @code = code
36
+ @stately_code = stately_code
37
+ @cause = cause
38
+ end
39
+
40
+ # Convert any exception into a StatelyDB::Error.
41
+ # @param [Exception] error
42
+ # @return [StatelyDB::Error]
43
+ def self.from(error)
44
+ return error if error.is_a?(StatelyDB::Error)
45
+
46
+ if error.is_a?(GRPC::BadStatus)
47
+ status = error.to_rpc_status
48
+
49
+ unless status.nil? || status.details.empty?
50
+ raw_detail = status.details[0]
51
+ if raw_detail.type_url == "type.googleapis.com/stately.errors.StatelyErrorDetails"
52
+ error_details = Stately::Errors::StatelyErrorDetails.decode(raw_detail.value)
53
+ return new(error_details.message, code: error.code, stately_code: error_details.stately_code,
54
+ cause: error_details.upstream_cause)
55
+ end
56
+ end
57
+ end
58
+
59
+ new(error.message, code: GRPC::Codes::StatusCodes::Unknown, stately_code: "Unknown", cause: error)
60
+ end
61
+ end
62
+ end
data/lib/key_path.rb ADDED
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StatelyDB
4
+ # KeyPath is a helper class for constructing key paths.
5
+ class KeyPath
6
+ def initialize
7
+ super
8
+ @path = []
9
+ end
10
+
11
+ # Appends a new path segment.
12
+ # @param [String] namespace
13
+ # @param [String] identifier
14
+ # @return [KeyPath]
15
+ def with(namespace, identifier = nil)
16
+ if identifier.nil?
17
+ @path << namespace
18
+ return self
19
+ end
20
+ @path << "#{namespace}-#{self.class.to_key_id(identifier)}"
21
+ self
22
+ end
23
+
24
+ # @return [String]
25
+ def to_str
26
+ "/".dup.concat(@path.join("/"))
27
+ end
28
+
29
+ # @return [String]
30
+ def inspect
31
+ to_str
32
+ end
33
+
34
+ # @return [String]
35
+ def to_s
36
+ to_str
37
+ end
38
+
39
+ # Appends a new path segment.
40
+ # @param [String] namespace
41
+ # @param [String] identifier
42
+ # @return [KeyPath]
43
+ #
44
+ # @example
45
+ # keypath = KeyPath.with("genres", "rock").with("artists", "the-beatles")
46
+ def self.with(namespace, identifier = nil)
47
+ new.with(namespace, identifier)
48
+ end
49
+
50
+ # If the value is a binary string, encode it as a url-safe base64 string with padding removed.
51
+ # Note that we also prepend the value with the ~ sigil to indicate that it is a base64 string.
52
+ #
53
+ # @param [String, StatelyDB::UUID, #to_s] value The value to convert to a key id.
54
+ # @return [String]
55
+ def self.to_key_id(value)
56
+ if value.is_a?(StatelyDB::UUID)
57
+ "~#{value.to_base64}"
58
+ elsif value.is_a?(String) && value.encoding == Encoding::BINARY
59
+ b64_value = [value].pack("m0").tr("=", "").tr("+/", "-_")
60
+ "~#{b64_value}"
61
+ else
62
+ # Any other value is just converted to a string
63
+ value.to_s
64
+ end
65
+ end
66
+ end
67
+ end
data/lib/statelydb.rb ADDED
@@ -0,0 +1,333 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Add the pb dir to the LOAD_PATH because generated proto imports are not relative and
4
+ # we don't want the protos polluting the main namespace.
5
+ # Tracking here: https://github.com/grpc/grpc/issues/6164
6
+ $LOAD_PATH.unshift "#{File.dirname(__FILE__)}/api"
7
+
8
+ require "api/db/service_services_pb"
9
+ require "common/auth/auth0_token_provider"
10
+ require "common/auth/interceptor"
11
+ require "common/net/conn"
12
+ require "common/error_interceptor"
13
+ require "grpc"
14
+ require "json"
15
+ require "net/http"
16
+
17
+ require "transaction/transaction"
18
+ require "transaction/queue"
19
+ require "error"
20
+ require "key_path"
21
+ require "token"
22
+ require "uuid"
23
+
24
+ module StatelyDB
25
+ # Client is a client for interacting with the Stately Cloud API.
26
+ class Client
27
+ # Initialize a new StatelyDB Client
28
+ #
29
+ # @param store_id [Integer] the StatelyDB to use for all operations with this client.
30
+ # @param schema [Module] the schema module to use for mapping StatelyDB Items.
31
+ # @param token_provider [Common::Auth::TokenProvider] the token provider to use for authentication.
32
+ # @param channel [GRPC::Core::Channel] the gRPC channel to use for communication.
33
+ def initialize(store_id: nil,
34
+ schema: StatelyDB::Types,
35
+ token_provider: Common::Auth::Auth0TokenProvider.new,
36
+ channel: Common::Net.new_channel)
37
+ raise "store_id is required" if store_id.nil?
38
+ raise "schema is required" if schema.nil?
39
+
40
+ auth_interceptor = Common::Auth::Interceptor.new(token_provider:)
41
+ error_interceptor = Common::ErrorInterceptor.new
42
+
43
+ @stub = Stately::Db::DatabaseService::Stub.new(nil, nil, channel_override: channel,
44
+ interceptors: [error_interceptor, auth_interceptor])
45
+ @store_id = store_id.to_i
46
+ @schema = schema
47
+ @allow_stale = false
48
+ end
49
+
50
+ # Set whether to allow stale results for all operations with this client. This produces a new client
51
+ # with the allow_stale flag set.
52
+ # @param allow_stale [Boolean] whether to allow stale results
53
+ # @return [StatelyDB::Client] a new client with the allow_stale flag set
54
+ # @example
55
+ # client.with_allow_stale(true).get("/ItemType-identifier")
56
+ def with_allow_stale(allow_stale)
57
+ new_client = clone
58
+ new_client.instance_variable_set(:@allow_stale, allow_stale)
59
+ new_client
60
+ end
61
+
62
+ # Fetch a single Item from a StatelyDB Store at the given key_path.
63
+ #
64
+ # @param key_path [String] the path to the item
65
+ # @return [StatelyDB::Item, NilClass] the Item or nil if not found
66
+ # @raise [StatelyDB::Error] if the parameters are invalid or if the item is not found
67
+ #
68
+ # @example
69
+ # client.get("/ItemType-identifier")
70
+ def get(key_path)
71
+ resp = get_batch(key_path)
72
+
73
+ # Always return a single Item.
74
+ resp.first
75
+ end
76
+
77
+ # Fetch a batch of Items from a StatelyDB Store at the given key_paths.
78
+ #
79
+ # @param key_paths [String, Array<String>] the paths to the items
80
+ # @return [Array<StatelyDB::Item>, NilClass] the items or nil if not found
81
+ # @raise [StatelyDB::Error] if the parameters are invalid or if the item is not found
82
+ #
83
+ # @example
84
+ # client.data.get_batch("/ItemType-identifier", "/ItemType-identifier2")
85
+ def get_batch(*key_paths)
86
+ key_paths = Array(key_paths).flatten
87
+ req = Stately::Db::GetRequest.new(
88
+ store_id: @store_id,
89
+ gets:
90
+ key_paths.map { |key_path| Stately::Db::GetItem.new(key_path: String(key_path)) },
91
+ allow_stale: @allow_stale
92
+ )
93
+
94
+ resp = @stub.get(req)
95
+ resp.items.map do |result|
96
+ @schema.unmarshal_item(stately_item: result)
97
+ end
98
+ end
99
+
100
+ # Begin listing Items from a StatelyDB Store at the given prefix.
101
+ #
102
+ # @param prefix [String] the prefix to list
103
+ # @param limit [Integer] the maximum number of items to return
104
+ # @param sort_property [String] the property to sort by
105
+ # @param sort_direction [Symbol] the direction to sort by (:ascending or :descending)
106
+ # @return [Array<StatelyDB::Item>, StatelyDB::Token] the list of Items and the token
107
+ #
108
+ # @example
109
+ # client.data.begin_list("/ItemType-identifier", limit: 10, sort_direction: :ascending)
110
+ def begin_list(prefix,
111
+ limit: 100,
112
+ sort_property: nil,
113
+ sort_direction: :ascending)
114
+ sort_direction = sort_direction == :ascending ? 0 : 1
115
+
116
+ req = Stately::Db::BeginListRequest.new(
117
+ store_id: @store_id,
118
+ key_path_prefix: String(prefix),
119
+ limit:,
120
+ sort_property:,
121
+ sort_direction:,
122
+ allow_stale: @allow_stale
123
+ )
124
+ resp = @stub.begin_list(req)
125
+ process_list_response(resp)
126
+ end
127
+
128
+ # Continue listing Items from a StatelyDB Store using a token.
129
+ #
130
+ # @param token [StatelyDB::Token] the token to continue from
131
+ # @return [Array<StatelyDB::Item>, StatelyDB::Token] the list of Items and the token
132
+ #
133
+ # @example
134
+ # (items, token) = client.data.begin_list("/ItemType-identifier")
135
+ # client.data.continue_list(token)
136
+ def continue_list(token)
137
+ req = Stately::Db::ContinueListRequest.new(
138
+ token_data: token.token_data
139
+ )
140
+ resp = @stub.continue_list(req)
141
+ process_list_response(resp)
142
+ end
143
+
144
+ # Sync a list of Items from a StatelyDB Store.
145
+ #
146
+ # @param token [StatelyDB::Token] the token to sync from
147
+ # @return [StatelyDB::SyncResult] the result of the sync operation
148
+ #
149
+ # @example
150
+ # (items, token) = client.data.begin_list("/ItemType-identifier")
151
+ # client.data.sync_list(token)
152
+ def sync_list(token)
153
+ req = Stately::Db::SyncListRequest.new(
154
+ token_data: token.token_data
155
+ )
156
+ resp = @stub.sync_list(req)
157
+ process_sync_response(resp)
158
+ end
159
+
160
+ # Put an Item into a StatelyDB Store at the given key_path.
161
+ #
162
+ # @param item [StatelyDB::Item] a StatelyDB Item
163
+ # @return [StatelyDB::Item] the item that was stored
164
+ #
165
+ # @example
166
+ # client.data.put(my_item)
167
+ def put(item)
168
+ resp = put_batch(item)
169
+
170
+ # Always return a single Item.
171
+ resp.first
172
+ end
173
+
174
+ # Put a batch of Items into a StatelyDB Store.
175
+ #
176
+ # @param items [StatelyDB::Item, Array<StatelyDB::Item>] the items to store
177
+ # @return [Array<StatelyDB::Item>] the items that were stored
178
+ #
179
+ # @example
180
+ # client.data.put_batch(item1, item2)
181
+ def put_batch(*items)
182
+ items = Array(items).flatten
183
+ req = Stately::Db::PutRequest.new(
184
+ store_id: @store_id,
185
+ puts: items.map do |item|
186
+ Stately::Db::PutItem.new(
187
+ item: item.send("marshal_stately")
188
+ )
189
+ end
190
+ )
191
+ resp = @stub.put(req)
192
+
193
+ resp.items.map do |result|
194
+ @schema.unmarshal_item(stately_item: result)
195
+ end
196
+ end
197
+
198
+ # Delete one or more Items from a StatelyDB Store at the given key_paths.
199
+ #
200
+ # @param key_paths [String, Array<String>] the paths to the items
201
+ # @raise [StatelyDB::Error::InvalidParameters] if the parameters are invalid
202
+ # @raise [StatelyDB::Error::NotFound] if the item is not found
203
+ # @return [void] nil
204
+ #
205
+ # @example
206
+ # client.data.delete("/ItemType-identifier", "/ItemType-identifier2")
207
+ def delete(*key_paths)
208
+ key_paths = Array(key_paths).flatten
209
+ req = Stately::Db::DeleteRequest.new(
210
+ store_id: @store_id,
211
+ deletes: key_paths.map { |key_path| Stately::Db::DeleteItem.new(key_path: String(key_path)) }
212
+ )
213
+ @stub.delete(req)
214
+ nil
215
+ end
216
+
217
+ # Transaction takes a block and executes the block within a transaction.
218
+ # If the block raises an exception, the transaction is rolled back.
219
+ # If the block completes successfully, the transaction is committed.
220
+ #
221
+ # @return [StatelyDB::Transaction::Transaction::Result] the result of the transaction
222
+ # @raise [StatelyDB::Error::InvalidParameters] if the parameters are invalid
223
+ # @raise [StatelyDB::Error::NotFound] if the item is not found
224
+ # @raise [Exception] if any other exception is raised
225
+ #
226
+ # @example
227
+ # client.data.transaction do |txn|
228
+ # txn.put(item: my_item)
229
+ # txn.put(item: another_item)
230
+ # end
231
+ def transaction
232
+ txn = StatelyDB::Transaction::Transaction.new(stub: @stub, store_id: @store_id, schema: @schema)
233
+ txn.begin
234
+ yield txn
235
+ txn.commit
236
+ rescue StatelyDB::Error
237
+ raise
238
+ # Handle any other exceptions and abort the transaction. We're rescuing Exception here
239
+ # because we want to catch all exceptions, including those that don't inherit from StandardError.
240
+ rescue Exception => e
241
+ txn.abort
242
+
243
+ # Calling raise with no parameters re-raises the original exception
244
+ raise StatelyDB::Error.from(e)
245
+ end
246
+
247
+ private
248
+
249
+ # Process a list response from begin_list or continue_list
250
+ #
251
+ # @param resp [Stately::Db::ListResponse] the response to process
252
+ # @return [(Array<StatelyDB::Item>, StatelyDB::Token)] the list of Items and the token
253
+ # @api private
254
+ # @!visibility private
255
+ def process_list_response(resp)
256
+ items = []
257
+ token = nil
258
+ resp.each do |r|
259
+ case r.response
260
+ when :result
261
+ r.result.items.map do |result|
262
+ items << @schema.unmarshal_item(stately_item: result)
263
+ end
264
+ when :finished
265
+ raw_token = r.finished.token
266
+ token = StatelyDB::Token.new(token_data: raw_token.token_data,
267
+ can_continue: raw_token.can_continue,
268
+ can_sync: raw_token.can_sync)
269
+ end
270
+ end
271
+ [items, token]
272
+ end
273
+
274
+ # Process a sync response from sync_list
275
+ #
276
+ # @param resp [Stately::Db::SyncResponse] the response to process
277
+ # @return [StatelyDB::SyncResult] the result of the sync operation
278
+ # @api private
279
+ # @!visibility private
280
+ def process_sync_response(resp)
281
+ changed_items = []
282
+ deleted_item_paths = []
283
+ token = nil
284
+ is_reset = false
285
+ resp.each do |r|
286
+ case r.response
287
+ when :result
288
+ r.result.changed_items.each do |item|
289
+ changed_items << @schema.unmarshal_item(stately_item: item)
290
+ end
291
+ r.result.deleted_items.each do |item|
292
+ deleted_item_paths << item.key_path
293
+ end
294
+ when :reset
295
+ is_reset = true
296
+ when :finished
297
+ raw_token = r.finished.token
298
+ token = StatelyDB::Token.new(token_data: raw_token.token_data,
299
+ can_continue: raw_token.can_continue,
300
+ can_sync: raw_token.can_sync)
301
+ end
302
+ end
303
+ SyncResult.new(changed_items:, deleted_item_paths:, is_reset:, token:)
304
+ end
305
+ end
306
+
307
+ # SyncResult represents the results of a sync operation.
308
+ #
309
+ # @attr_reader changed_items [Array<StatelyDB::Item>] the items that were changed
310
+ # @attr_reader deleted_item_paths [Array<String>] the key paths that were deleted
311
+ # @attr_reader is_reset [Boolean] whether the sync operation reset the token
312
+ # @attr_reader token [StatelyDB::Token] the token to continue from
313
+ class SyncResult
314
+ attr_reader :changed_items, :deleted_item_paths, :is_reset, :token
315
+
316
+ # @param changed_items [Array<StatelyDB::Item>] the items that were changed
317
+ # @param deleted_item_paths [Array<String>] the key paths that were deleted
318
+ # @param is_reset [Boolean] whether the sync operation reset the token
319
+ # @param token [StatelyDB::Token] the token to continue from
320
+ def initialize(changed_items:, deleted_item_paths:, is_reset:, token:)
321
+ @changed_items = changed_items
322
+ @deleted_item_paths = deleted_item_paths
323
+ @is_reset = is_reset
324
+ @token = token
325
+ end
326
+ end
327
+
328
+ # StatelyDB::Item is a base class for all StatelyDB Items. This class is provided in documentation
329
+ # to show the expected interface for a StatelyDB Item, but in practice the SDK will return a subclass
330
+ # of this class that is generated from the schema.
331
+ class Item < Object
332
+ end
333
+ end
data/lib/token.rb ADDED
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StatelyDB
4
+ # The Token type contains a continuation token for list and sync operations along with metadata about the ability
5
+ # to sync or continue listing based on the last operation performed.
6
+ #
7
+ # Ths StatelyDB SDK vends this Token type for list and sync operations. Consumers should not need to construct this
8
+ # type directly.
9
+ class Token
10
+ # @!visibility private
11
+ attr_accessor :token_data
12
+
13
+ # @param [String] token_data
14
+ # @param [Boolean] can_continue
15
+ # @param [Boolean] can_sync
16
+ def initialize(token_data:, can_continue:, can_sync:)
17
+ @token_data = token_data
18
+ @can_continue = can_continue
19
+ @can_sync = can_sync
20
+ end
21
+
22
+ # Returns true if the list operation can be continued, otherwise false.
23
+ # @return [Boolean]
24
+ def can_continue?
25
+ !!@can_continue
26
+ end
27
+
28
+ # Returns true if the sync operation can be continued, otherwise false.
29
+ # @return [Boolean]
30
+ def can_sync?
31
+ !!@can_sync
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StatelyDB
4
+ module Transaction
5
+ # TransactionQueue is a wrapper around Thread::Queue that implements Enumerable
6
+ class Queue < Thread::Queue
7
+ # @!attribute [r] last_message_id
8
+ # @return [Integer, nil] The ID of the last message, or nil if there is no message.
9
+ attr_reader :last_message_id
10
+
11
+ def initialize
12
+ super
13
+ @last_message_id = 0
14
+ end
15
+
16
+ # next_message_id returns the next message ID, which is the current size of the queue + 1.
17
+ # This value is consumed by the StatelyDB transaction as a monotonically increasing MessageID.
18
+ # @return [Integer]
19
+ def next_message_id
20
+ @last_message_id += 1
21
+ end
22
+
23
+ # Iterates over each element in the queue, yielding each element to the given block.
24
+ #
25
+ # @yield [Object] Gives each element in the queue to the block.
26
+ # @return [void]
27
+ def each
28
+ loop do
29
+ yield pop
30
+ end
31
+ end
32
+
33
+ # Iterates over each item in the queue, yielding each item to the given block.
34
+ #
35
+ # @yield [Object] Gives each item in the queue to the block.
36
+ # @return [void]
37
+ def each_item
38
+ loop do
39
+ yield pop
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end