statelydb 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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