scriva 1.2.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.
- checksums.yaml +7 -0
- data/README.md +371 -0
- data/lib/scriva/client.rb +648 -0
- data/lib/scriva/proto/scriva_pb.rb +83 -0
- data/lib/scriva/proto/scriva_services_pb.rb +123 -0
- data/lib/scriva/version.rb +3 -0
- data/lib/scriva.rb +2 -0
- data/scriva.gemspec +22 -0
- metadata +80 -0
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
require "grpc"
|
|
3
|
+
require "google/protobuf/well_known_types"
|
|
4
|
+
require "scriva/proto/scriva_pb"
|
|
5
|
+
require "scriva/proto/scriva_services_pb"
|
|
6
|
+
|
|
7
|
+
module Scriva
|
|
8
|
+
# Base class for ScrivaDB client errors mapped from engine gRPC status codes.
|
|
9
|
+
class Error < StandardError; end
|
|
10
|
+
|
|
11
|
+
# Raised when a keyed lookup/update/delete referenced a key that no live
|
|
12
|
+
# record holds (gRPC NOT_FOUND).
|
|
13
|
+
class NotFoundError < Error; end
|
|
14
|
+
|
|
15
|
+
# Raised when a keyed insert used a key already held by a live record
|
|
16
|
+
# (gRPC ALREADY_EXISTS).
|
|
17
|
+
class AlreadyExistsError < Error; end
|
|
18
|
+
|
|
19
|
+
# Synchronous gRPC client for ScrivaDB.
|
|
20
|
+
#
|
|
21
|
+
# Example:
|
|
22
|
+
# db = Scriva::Client.new(host: "localhost", port: 5433, api_key: "dev-key")
|
|
23
|
+
# db.create_collection("users")
|
|
24
|
+
# id = db.insert("users", { name: "Alice", age: 30 })
|
|
25
|
+
# record = db.find_by_id("users", id)
|
|
26
|
+
# db.update("users", id, { name: "Alice", age: 31 })
|
|
27
|
+
# db.delete("users", id)
|
|
28
|
+
# db.drop_collection("users")
|
|
29
|
+
# db.close
|
|
30
|
+
class Client
|
|
31
|
+
OP_MAP = {
|
|
32
|
+
"eq" => :EQ,
|
|
33
|
+
"neq" => :NEQ,
|
|
34
|
+
"gt" => :GT,
|
|
35
|
+
"gte" => :GTE,
|
|
36
|
+
"lt" => :LT,
|
|
37
|
+
"lte" => :LTE,
|
|
38
|
+
"contains" => :CONTAINS,
|
|
39
|
+
"regex" => :REGEX,
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
# Documented short aggregation names → proto AggregateOp enum symbols.
|
|
43
|
+
AGG_MAP = {
|
|
44
|
+
"count" => :AGG_COUNT,
|
|
45
|
+
"sum" => :AGG_SUM,
|
|
46
|
+
"avg" => :AGG_AVG,
|
|
47
|
+
"min" => :AGG_MIN,
|
|
48
|
+
"max" => :AGG_MAX,
|
|
49
|
+
}.freeze
|
|
50
|
+
|
|
51
|
+
WATCH_OP_NAMES = {
|
|
52
|
+
0 => "UNSPECIFIED",
|
|
53
|
+
1 => "INSERTED",
|
|
54
|
+
2 => "UPDATED",
|
|
55
|
+
3 => "DELETED",
|
|
56
|
+
4 => "OVERFLOW",
|
|
57
|
+
}.freeze
|
|
58
|
+
|
|
59
|
+
# @param host [String] server hostname
|
|
60
|
+
# @param port [Integer] gRPC port (default 5433)
|
|
61
|
+
# @param api_key [String] sent as x-api-key metadata on every call
|
|
62
|
+
# @param tls_ca_cert [String, nil] PEM CA certificate string or path to PEM file;
|
|
63
|
+
# when nil the channel uses plaintext (insecure) transport
|
|
64
|
+
def initialize(host: "localhost", port: 5433, api_key:, tls_ca_cert: nil)
|
|
65
|
+
@api_key = api_key
|
|
66
|
+
target = "#{host}:#{port}"
|
|
67
|
+
|
|
68
|
+
if tls_ca_cert
|
|
69
|
+
pem = load_pem(tls_ca_cert)
|
|
70
|
+
creds = GRPC::Core::ChannelCredentials.new(pem)
|
|
71
|
+
else
|
|
72
|
+
creds = :this_channel_is_insecure
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
@stub = Scriva::V1::Scriva::Stub.new(target, creds)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# -- lifecycle -----------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def close
|
|
81
|
+
# GRPC::ClientStub does not expose an explicit close method in all
|
|
82
|
+
# versions; the channel is GC-collected. This method exists for API
|
|
83
|
+
# symmetry with other SDKs and for use as a context manager via #tap.
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Yields self then closes — use like:
|
|
87
|
+
# Scriva::Client.open(host: ...) { |db| db.insert(...) }
|
|
88
|
+
def self.open(**kwargs, &block)
|
|
89
|
+
client = new(**kwargs)
|
|
90
|
+
if block
|
|
91
|
+
begin
|
|
92
|
+
block.call(client)
|
|
93
|
+
ensure
|
|
94
|
+
client.close
|
|
95
|
+
end
|
|
96
|
+
else
|
|
97
|
+
client
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# -- collection management -----------------------------------------------
|
|
102
|
+
|
|
103
|
+
# Create a collection. When +default_ttl_seconds+ > 0, records inserted
|
|
104
|
+
# without an explicit ttl expire that many seconds after being written;
|
|
105
|
+
# 0 inherits the server-wide default.
|
|
106
|
+
def create_collection(name, default_ttl_seconds: 0)
|
|
107
|
+
req = Scriva::V1::CreateCollectionRequest.new(
|
|
108
|
+
name: name,
|
|
109
|
+
default_ttl_seconds: default_ttl_seconds,
|
|
110
|
+
)
|
|
111
|
+
resp = @stub.create_collection(req, metadata: metadata)
|
|
112
|
+
resp.name
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def drop_collection(name)
|
|
116
|
+
req = Scriva::V1::DropCollectionRequest.new(name: name)
|
|
117
|
+
resp = @stub.drop_collection(req, metadata: metadata)
|
|
118
|
+
resp.ok
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def list_collections
|
|
122
|
+
req = Scriva::V1::ListCollectionsRequest.new
|
|
123
|
+
resp = @stub.list_collections(req, metadata: metadata)
|
|
124
|
+
resp.names.to_a
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# -- CRUD ----------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
# Insert one record. Returns the assigned integer ID.
|
|
130
|
+
#
|
|
131
|
+
# +ttl_seconds+ > 0 expires the record that long after insertion, overriding
|
|
132
|
+
# any collection default; 0 applies the collection default (if any).
|
|
133
|
+
#
|
|
134
|
+
# +key+ sets an optional caller-supplied string primary key (keyed create).
|
|
135
|
+
# When set, the record is inserted under this key and a key already held by
|
|
136
|
+
# a live record raises AlreadyExistsError. A keyed insert does not
|
|
137
|
+
# participate in transactions or per-record TTL.
|
|
138
|
+
def insert(collection, data, ttl_seconds: 0, key: "")
|
|
139
|
+
translate_errors do
|
|
140
|
+
req = Scriva::V1::InsertRequest.new(
|
|
141
|
+
collection: collection,
|
|
142
|
+
data: hash_to_struct(data),
|
|
143
|
+
ttl_seconds: ttl_seconds,
|
|
144
|
+
key: key,
|
|
145
|
+
)
|
|
146
|
+
resp = @stub.insert(req, metadata: metadata)
|
|
147
|
+
resp.id
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Insert multiple records. Returns an array of assigned IDs.
|
|
152
|
+
# +ttl_seconds+ is applied uniformly to every record in the batch.
|
|
153
|
+
def insert_many(collection, records, ttl_seconds: 0)
|
|
154
|
+
req = Scriva::V1::InsertManyRequest.new(
|
|
155
|
+
collection: collection,
|
|
156
|
+
records: records.map { |r| hash_to_struct(r) },
|
|
157
|
+
ttl_seconds: ttl_seconds,
|
|
158
|
+
)
|
|
159
|
+
resp = @stub.insert_many(req, metadata: metadata)
|
|
160
|
+
resp.ids.to_a
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Fetch a single record by ID. Returns a Hash.
|
|
164
|
+
#
|
|
165
|
+
# +fields+ is an optional projection: when non-empty, only those top-level
|
|
166
|
+
# fields are returned in +data+ (id, key and rev are always included).
|
|
167
|
+
# nil/empty returns the full record.
|
|
168
|
+
def find_by_id(collection, id, fields: nil)
|
|
169
|
+
req = Scriva::V1::FindByIdRequest.new(
|
|
170
|
+
collection: collection,
|
|
171
|
+
id: id,
|
|
172
|
+
fields: fields ? fields.map(&:to_s) : [],
|
|
173
|
+
)
|
|
174
|
+
resp = @stub.find_by_id(req, metadata: metadata)
|
|
175
|
+
record_to_hash(resp.record)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Query records. Returns an Array of Hashes.
|
|
179
|
+
# The Find RPC is server-streaming; this collects the stream for convenience.
|
|
180
|
+
# Pass a block to stream results one by one without buffering.
|
|
181
|
+
#
|
|
182
|
+
# @param filter [Hash, nil] filter hash (see filter_to_proto)
|
|
183
|
+
# @param limit [Integer] 0 = no limit
|
|
184
|
+
# @param offset [Integer]
|
|
185
|
+
# @param order_by [String, Array] a single field name (deprecated scalar
|
|
186
|
+
# sort, paired with +descending+) or an Array for a
|
|
187
|
+
# multi-field sort — each item a field-name String, a
|
|
188
|
+
# [field, desc] pair, or a {field:, desc:} Hash.
|
|
189
|
+
# @param descending [Boolean] only honoured for the scalar +order_by+ form
|
|
190
|
+
# @param fields [Array, nil] optional projection (see #find_by_id)
|
|
191
|
+
# @param page_token [String] keyset cursor from a previous #find_page
|
|
192
|
+
def find(collection, filter: nil, limit: 0, offset: 0, order_by: "",
|
|
193
|
+
descending: false, fields: nil, page_token: "")
|
|
194
|
+
req = build_find_request(
|
|
195
|
+
collection, filter, limit, offset, order_by, descending, fields, page_token
|
|
196
|
+
)
|
|
197
|
+
stream = @stub.find(req, metadata: metadata)
|
|
198
|
+
if block_given?
|
|
199
|
+
stream.each { |resp| yield record_to_hash(resp.record) }
|
|
200
|
+
nil
|
|
201
|
+
else
|
|
202
|
+
stream.map { |resp| record_to_hash(resp.record) }
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Query one keyset page, returning [records, next_page_token].
|
|
207
|
+
#
|
|
208
|
+
# Pass an ordering (+order_by+) and a +limit+, then feed the returned
|
|
209
|
+
# next-page token back as +page_token+ on the next call to walk the
|
|
210
|
+
# collection page by page in O(page) time. An empty next-page token means
|
|
211
|
+
# the last page was reached. Keep the same filter, ordering and limit on
|
|
212
|
+
# every page.
|
|
213
|
+
def find_page(collection, filter: nil, limit: 0, offset: 0, order_by: "",
|
|
214
|
+
descending: false, fields: nil, page_token: "")
|
|
215
|
+
req = build_find_request(
|
|
216
|
+
collection, filter, limit, offset, order_by, descending, fields, page_token
|
|
217
|
+
)
|
|
218
|
+
records = []
|
|
219
|
+
next_token = ""
|
|
220
|
+
@stub.find(req, metadata: metadata).each do |resp|
|
|
221
|
+
records << record_to_hash(resp.record)
|
|
222
|
+
next_token = resp.page_token unless resp.page_token.empty?
|
|
223
|
+
end
|
|
224
|
+
[records, next_token]
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Update a record. +ttl_seconds+ > 0 resets the record's expiry deadline to
|
|
228
|
+
# that long from now; 0 (the default) is sticky and leaves any existing
|
|
229
|
+
# deadline untouched.
|
|
230
|
+
def update(collection, id, data, ttl_seconds: 0)
|
|
231
|
+
req = Scriva::V1::UpdateRequest.new(
|
|
232
|
+
collection: collection,
|
|
233
|
+
id: id,
|
|
234
|
+
data: hash_to_struct(data),
|
|
235
|
+
ttl_seconds: ttl_seconds,
|
|
236
|
+
)
|
|
237
|
+
resp = @stub.update(req, metadata: metadata)
|
|
238
|
+
resp.id
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def delete(collection, id)
|
|
242
|
+
req = Scriva::V1::DeleteRequest.new(collection: collection, id: id)
|
|
243
|
+
resp = @stub.delete(req, metadata: metadata)
|
|
244
|
+
resp.ok
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# -- keyed CRUD, upsert & compare-and-swap (N1) --------------------------
|
|
248
|
+
|
|
249
|
+
# Insert +data+ under +key+, or replace the existing keyed record —
|
|
250
|
+
# atomically. If no live record carries +key+ it is inserted; otherwise the
|
|
251
|
+
# existing record's data is replaced and its +rev+ incremented. Returns the
|
|
252
|
+
# resulting record Hash (including "key" and "rev").
|
|
253
|
+
def upsert(collection, key, data)
|
|
254
|
+
req = Scriva::V1::UpsertRequest.new(
|
|
255
|
+
collection: collection,
|
|
256
|
+
key: key,
|
|
257
|
+
data: hash_to_struct(data),
|
|
258
|
+
)
|
|
259
|
+
resp = @stub.upsert(req, metadata: metadata)
|
|
260
|
+
record_to_hash(resp.record)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Fetch the record carrying +key+. Raises NotFoundError if none.
|
|
264
|
+
# +fields+ projects +data+ to those top-level fields (id/key/rev always
|
|
265
|
+
# returned).
|
|
266
|
+
def find_by_key(collection, key, fields: nil)
|
|
267
|
+
translate_errors do
|
|
268
|
+
req = Scriva::V1::FindByKeyRequest.new(
|
|
269
|
+
collection: collection,
|
|
270
|
+
key: key,
|
|
271
|
+
fields: fields ? fields.map(&:to_s) : [],
|
|
272
|
+
)
|
|
273
|
+
resp = @stub.find_by_key(req, metadata: metadata)
|
|
274
|
+
record_to_hash(resp.record)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Overwrite the record carrying +key+, preserving the key itself. Raises
|
|
279
|
+
# NotFoundError if no live record carries +key+. Returns a Hash with "id",
|
|
280
|
+
# "key", "rev" (after the write) and "date_modified".
|
|
281
|
+
def update_by_key(collection, key, data)
|
|
282
|
+
translate_errors do
|
|
283
|
+
req = Scriva::V1::UpdateByKeyRequest.new(
|
|
284
|
+
collection: collection,
|
|
285
|
+
key: key,
|
|
286
|
+
data: hash_to_struct(data),
|
|
287
|
+
)
|
|
288
|
+
resp = @stub.update_by_key(req, metadata: metadata)
|
|
289
|
+
out = { "id" => resp.id }
|
|
290
|
+
out["key"] = resp.key unless resp.key.empty?
|
|
291
|
+
out["rev"] = resp.rev unless resp.rev.zero?
|
|
292
|
+
out["date_modified"] = resp.date_modified unless resp.date_modified.empty?
|
|
293
|
+
out
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Delete the record carrying +key+. Returns true on success. Raises
|
|
298
|
+
# NotFoundError if no live record carries +key+.
|
|
299
|
+
def delete_by_key(collection, key)
|
|
300
|
+
translate_errors do
|
|
301
|
+
req = Scriva::V1::DeleteByKeyRequest.new(collection: collection, key: key)
|
|
302
|
+
resp = @stub.delete_by_key(req, metadata: metadata)
|
|
303
|
+
resp.ok
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Compare-and-swap update on +key+, conditional on +expected_rev+. The write
|
|
308
|
+
# is applied only if the record's current +rev+ equals +expected_rev+. A
|
|
309
|
+
# stale revision (or a missing key) is a clean no-op — never an error.
|
|
310
|
+
# Returns { "swapped" => Boolean, "record" => Hash | nil }; "record" is the
|
|
311
|
+
# resulting record only when "swapped" is true.
|
|
312
|
+
def update_if_rev(collection, key, expected_rev, data)
|
|
313
|
+
req = Scriva::V1::UpdateIfRevRequest.new(
|
|
314
|
+
collection: collection,
|
|
315
|
+
key: key,
|
|
316
|
+
expected_rev: expected_rev,
|
|
317
|
+
data: hash_to_struct(data),
|
|
318
|
+
)
|
|
319
|
+
resp = @stub.update_if_rev(req, metadata: metadata)
|
|
320
|
+
out = { "swapped" => resp.swapped, "record" => nil }
|
|
321
|
+
out["record"] = record_to_hash(resp.record) if resp.swapped && resp.has_record?
|
|
322
|
+
out
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# -- secondary indexes ---------------------------------------------------
|
|
326
|
+
|
|
327
|
+
def ensure_index(collection, field)
|
|
328
|
+
req = Scriva::V1::EnsureIndexRequest.new(collection: collection, field: field)
|
|
329
|
+
@stub.ensure_index(req, metadata: metadata)
|
|
330
|
+
nil
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def drop_index(collection, field)
|
|
334
|
+
req = Scriva::V1::DropIndexRequest.new(collection: collection, field: field)
|
|
335
|
+
resp = @stub.drop_index(req, metadata: metadata)
|
|
336
|
+
resp.ok
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def list_indexes(collection)
|
|
340
|
+
req = Scriva::V1::ListIndexesRequest.new(collection: collection)
|
|
341
|
+
resp = @stub.list_indexes(req, metadata: metadata)
|
|
342
|
+
resp.fields.to_a
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# -- transactions --------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
def begin_tx(collection)
|
|
348
|
+
req = Scriva::V1::BeginTxRequest.new(collection: collection)
|
|
349
|
+
resp = @stub.begin_tx(req, metadata: metadata)
|
|
350
|
+
resp.tx_id
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def commit_tx(tx_id)
|
|
354
|
+
req = Scriva::V1::CommitTxRequest.new(tx_id: tx_id)
|
|
355
|
+
resp = @stub.commit_tx(req, metadata: metadata)
|
|
356
|
+
resp.ok
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def rollback_tx(tx_id)
|
|
360
|
+
req = Scriva::V1::RollbackTxRequest.new(tx_id: tx_id)
|
|
361
|
+
resp = @stub.rollback_tx(req, metadata: metadata)
|
|
362
|
+
resp.ok
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# -- watch ---------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
# Subscribe to change events on a collection.
|
|
368
|
+
#
|
|
369
|
+
# Without a block, returns an Enumerator that yields event Hashes:
|
|
370
|
+
# { op: "INSERTED", collection: "users", record: {...}, ts: "2026-..." }
|
|
371
|
+
#
|
|
372
|
+
# With a block, iterates until the server closes the stream or the block
|
|
373
|
+
# raises StopIteration / the caller breaks.
|
|
374
|
+
#
|
|
375
|
+
# @param filter [Hash, nil] optional filter
|
|
376
|
+
def watch(collection, filter: nil)
|
|
377
|
+
req = Scriva::V1::WatchRequest.new(
|
|
378
|
+
collection: collection,
|
|
379
|
+
filter: filter_to_proto(filter),
|
|
380
|
+
)
|
|
381
|
+
stream = @stub.watch(req, metadata: metadata)
|
|
382
|
+
enum = Enumerator.new do |yielder|
|
|
383
|
+
stream.each do |event|
|
|
384
|
+
out = {
|
|
385
|
+
op: WATCH_OP_NAMES[event.op] || "UNSPECIFIED",
|
|
386
|
+
collection: event.collection,
|
|
387
|
+
record: record_to_hash(event.record),
|
|
388
|
+
}
|
|
389
|
+
out[:ts] = event.ts.to_time.utc.iso8601(9) if event.has_ts?
|
|
390
|
+
yielder << out
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
if block_given?
|
|
394
|
+
enum.each { |ev| yield ev }
|
|
395
|
+
nil
|
|
396
|
+
else
|
|
397
|
+
enum
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# -- aggregations (N4) ---------------------------------------------------
|
|
402
|
+
|
|
403
|
+
# Compute count + numeric aggregations over the filtered live records.
|
|
404
|
+
#
|
|
405
|
+
# The Aggregate RPC is server-streaming — one message per group; this
|
|
406
|
+
# collects them into an Array. Each result Hash carries "group" (the
|
|
407
|
+
# group-by value, nil for the whole-set group), "count", "numeric", and —
|
|
408
|
+
# when the group had at least one numeric +field+ value — "sum"/"avg"/
|
|
409
|
+
# "min"/"max".
|
|
410
|
+
#
|
|
411
|
+
# @param aggregations [Array, nil] which numeric aggregations to compute —
|
|
412
|
+
# any of "count", "sum", "avg", "min", "max". count is always
|
|
413
|
+
# returned; sum/avg/min/max require +field+.
|
|
414
|
+
# @param field [String] numeric field for sum/avg/min/max.
|
|
415
|
+
# @param group_by [String] optional group-by field — one result per value.
|
|
416
|
+
# @param filter [Hash, nil] the same plain-hash filter as #find.
|
|
417
|
+
def aggregate(collection, aggregations: nil, field: "", group_by: "", filter: nil)
|
|
418
|
+
agg_ops = (aggregations || []).map do |name|
|
|
419
|
+
key = name.to_s.downcase
|
|
420
|
+
AGG_MAP[key] or raise ArgumentError,
|
|
421
|
+
"unknown aggregation #{name.inspect}; expected one of #{AGG_MAP.keys.sort.join(", ")}"
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
req = Scriva::V1::AggregateRequest.new(
|
|
425
|
+
collection: collection,
|
|
426
|
+
filter: filter_to_proto(filter),
|
|
427
|
+
group_by: group_by,
|
|
428
|
+
field: field,
|
|
429
|
+
aggregations: agg_ops,
|
|
430
|
+
)
|
|
431
|
+
@stub.aggregate(req, metadata: metadata).map do |resp|
|
|
432
|
+
g = {
|
|
433
|
+
"group" => resp.has_group_value? ? resp.group_value.to_ruby : nil,
|
|
434
|
+
"count" => resp.count,
|
|
435
|
+
"numeric" => resp.numeric,
|
|
436
|
+
}
|
|
437
|
+
if resp.numeric
|
|
438
|
+
g["sum"] = resp.sum
|
|
439
|
+
g["avg"] = resp.avg
|
|
440
|
+
g["min"] = resp.min
|
|
441
|
+
g["max"] = resp.max
|
|
442
|
+
end
|
|
443
|
+
g
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# Count the live records matching +filter+ (or all records).
|
|
448
|
+
def count(collection, filter: nil)
|
|
449
|
+
groups = aggregate(collection, filter: filter)
|
|
450
|
+
groups.empty? ? 0 : groups.first["count"]
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# Group live records by +field+ and aggregate each group. Convenience
|
|
454
|
+
# wrapper over #aggregate. +field+ is the group-by field; +metric+ is the
|
|
455
|
+
# numeric field for sum/avg/min/max (pass those in +aggregations+). Returns
|
|
456
|
+
# one Hash per distinct group value; see #aggregate for the Hash shape.
|
|
457
|
+
def group_by(collection, field, aggregations: nil, metric: "", filter: nil)
|
|
458
|
+
aggregate(
|
|
459
|
+
collection,
|
|
460
|
+
aggregations: aggregations,
|
|
461
|
+
field: metric,
|
|
462
|
+
group_by: field,
|
|
463
|
+
filter: filter,
|
|
464
|
+
)
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# -- stats ---------------------------------------------------------------
|
|
468
|
+
|
|
469
|
+
def stats(collection)
|
|
470
|
+
req = Scriva::V1::CollectionStatsRequest.new(collection: collection)
|
|
471
|
+
resp = @stub.collection_stats(req, metadata: metadata)
|
|
472
|
+
{
|
|
473
|
+
collection: resp.collection,
|
|
474
|
+
record_count: resp.record_count,
|
|
475
|
+
segment_count: resp.segment_count,
|
|
476
|
+
dirty_entries: resp.dirty_entries,
|
|
477
|
+
size_bytes: resp.size_bytes,
|
|
478
|
+
}
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# -- maintenance ---------------------------------------------------------
|
|
482
|
+
|
|
483
|
+
# Force a synchronous compaction of a collection — merges dirty segments and
|
|
484
|
+
# reclaims space from deleted/overwritten records. Returns only once it is
|
|
485
|
+
# done. Returns true on success.
|
|
486
|
+
def compact(collection)
|
|
487
|
+
req = Scriva::V1::CompactRequest.new(collection: collection)
|
|
488
|
+
resp = @stub.compact(req, metadata: metadata)
|
|
489
|
+
resp.ok
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# Stream a consistent, gzip-compressed tar snapshot of the whole database.
|
|
493
|
+
#
|
|
494
|
+
# Without a block, returns an Enumerator yielding binary String chunks.
|
|
495
|
+
# With a block, yields each chunk in turn. Concatenate the chunks in order
|
|
496
|
+
# to reconstruct the archive; restore with `tar xzf backup.tar.gz`.
|
|
497
|
+
def snapshot(&block)
|
|
498
|
+
req = Scriva::V1::SnapshotRequest.new
|
|
499
|
+
stream = @stub.snapshot(req, metadata: metadata)
|
|
500
|
+
enum = Enumerator.new { |y| stream.each { |chunk| y << chunk.data } }
|
|
501
|
+
if block
|
|
502
|
+
enum.each(&block)
|
|
503
|
+
nil
|
|
504
|
+
else
|
|
505
|
+
enum
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# Stream a whole-database snapshot straight to a file (binary). Returns the
|
|
510
|
+
# number of bytes written.
|
|
511
|
+
def snapshot_to_file(path)
|
|
512
|
+
total = 0
|
|
513
|
+
File.open(path, "wb") do |f|
|
|
514
|
+
snapshot { |chunk| total += f.write(chunk) }
|
|
515
|
+
end
|
|
516
|
+
total
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
private
|
|
520
|
+
|
|
521
|
+
def metadata
|
|
522
|
+
{ "x-api-key" => @api_key }
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
# Map gRPC NOT_FOUND / ALREADY_EXISTS onto typed exceptions; other status
|
|
526
|
+
# codes propagate as the original GRPC::BadStatus.
|
|
527
|
+
def translate_errors
|
|
528
|
+
yield
|
|
529
|
+
rescue GRPC::NotFound => e
|
|
530
|
+
raise NotFoundError, e.details
|
|
531
|
+
rescue GRPC::AlreadyExists => e
|
|
532
|
+
raise AlreadyExistsError, e.details
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
def load_pem(tls_ca_cert)
|
|
536
|
+
return tls_ca_cert if tls_ca_cert.include?("BEGIN CERTIFICATE")
|
|
537
|
+
File.read(tls_ca_cert)
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
# Convert a plain Ruby Hash into a google.protobuf.Struct message.
|
|
541
|
+
def hash_to_struct(hash)
|
|
542
|
+
Google::Protobuf::Struct.from_hash(stringify_keys(hash))
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
def stringify_keys(hash)
|
|
546
|
+
hash.transform_keys(&:to_s)
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# Convert a proto Record into a plain Ruby Hash. The caller-supplied "key"
|
|
550
|
+
# (when set) and the per-record "rev" (present on every live record,
|
|
551
|
+
# starting at 1) are surfaced alongside "id", "data" and the timestamps.
|
|
552
|
+
def record_to_hash(record)
|
|
553
|
+
return {} if record.nil?
|
|
554
|
+
out = {
|
|
555
|
+
"id" => record.id,
|
|
556
|
+
"data" => record.has_data? ? record.data.to_h : {},
|
|
557
|
+
}
|
|
558
|
+
out["key"] = record.key unless record.key.empty?
|
|
559
|
+
out["rev"] = record.rev unless record.rev.zero?
|
|
560
|
+
out["date_added"] = record.date_added.to_time.utc.iso8601(9) if record.has_date_added?
|
|
561
|
+
out["date_modified"] = record.date_modified.to_time.utc.iso8601(9) if record.has_date_modified?
|
|
562
|
+
out
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
# Build a FindRequest shared by #find and #find_page, wiring projection,
|
|
566
|
+
# keyset pagination and either the multi-field or the deprecated scalar sort.
|
|
567
|
+
def build_find_request(collection, filter, limit, offset, order_by, descending, fields, page_token)
|
|
568
|
+
req = Scriva::V1::FindRequest.new(
|
|
569
|
+
collection: collection,
|
|
570
|
+
filter: filter_to_proto(filter),
|
|
571
|
+
limit: limit,
|
|
572
|
+
offset: offset,
|
|
573
|
+
fields: fields ? fields.map(&:to_s) : [],
|
|
574
|
+
page_token: page_token,
|
|
575
|
+
)
|
|
576
|
+
specs = order_by_to_proto(order_by)
|
|
577
|
+
if specs
|
|
578
|
+
specs.each { |o| req.order_by_fields.push(o) }
|
|
579
|
+
elsif order_by.is_a?(String) && !order_by.empty?
|
|
580
|
+
# Deprecated single-field path — honoured only when order_by_fields is
|
|
581
|
+
# empty (server-side).
|
|
582
|
+
req.order_by = order_by
|
|
583
|
+
req.descending = descending
|
|
584
|
+
end
|
|
585
|
+
req
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# Convert a multi-field +order_by+ argument to an Array of OrderBy specs.
|
|
589
|
+
# Returns nil when +order_by+ is empty or a plain String (the deprecated
|
|
590
|
+
# single-field path, handled by the caller). Otherwise accepts an Array
|
|
591
|
+
# whose items are field-name Strings, [field, desc] pairs, or
|
|
592
|
+
# {field:, desc:} Hashes.
|
|
593
|
+
def order_by_to_proto(order_by)
|
|
594
|
+
return nil if order_by.nil? || order_by.is_a?(String)
|
|
595
|
+
order_by.map do |item|
|
|
596
|
+
case item
|
|
597
|
+
when String
|
|
598
|
+
Scriva::V1::OrderBy.new(field: item, desc: false)
|
|
599
|
+
when Hash
|
|
600
|
+
h = item.transform_keys(&:to_s)
|
|
601
|
+
desc = h.fetch("desc", h.fetch("descending", false))
|
|
602
|
+
Scriva::V1::OrderBy.new(field: h["field"].to_s, desc: !!desc)
|
|
603
|
+
when Array
|
|
604
|
+
desc = item.length > 1 ? !!item[1] : false
|
|
605
|
+
Scriva::V1::OrderBy.new(field: item[0].to_s, desc: desc)
|
|
606
|
+
else
|
|
607
|
+
raise ArgumentError,
|
|
608
|
+
"order_by items must be a field-name String, a [field, desc] pair, " \
|
|
609
|
+
"or a {field:, desc:} Hash"
|
|
610
|
+
end
|
|
611
|
+
end
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
# Convert a plain Hash filter into a proto Filter message.
|
|
615
|
+
#
|
|
616
|
+
# Accepted shapes:
|
|
617
|
+
# { field: "age", op: "gt", value: "30" }
|
|
618
|
+
# { and: [ <filter>, ... ] }
|
|
619
|
+
# { or: [ <filter>, ... ] }
|
|
620
|
+
def filter_to_proto(hash)
|
|
621
|
+
return nil if hash.nil?
|
|
622
|
+
|
|
623
|
+
h = hash.transform_keys(&:to_s)
|
|
624
|
+
|
|
625
|
+
if h.key?("and")
|
|
626
|
+
children = h["and"].map { |c| filter_to_proto(c) }
|
|
627
|
+
return Scriva::V1::Filter.new(and: Scriva::V1::AndFilter.new(filters: children))
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
if h.key?("or")
|
|
631
|
+
children = h["or"].map { |c| filter_to_proto(c) }
|
|
632
|
+
return Scriva::V1::Filter.new(or: Scriva::V1::OrFilter.new(filters: children))
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
op_name = h["op"].to_s.downcase
|
|
636
|
+
op_val = OP_MAP[op_name] or raise ArgumentError,
|
|
637
|
+
"unknown filter op #{h["op"].inspect}; expected one of #{OP_MAP.keys.sort.join(", ")}"
|
|
638
|
+
|
|
639
|
+
Scriva::V1::Filter.new(
|
|
640
|
+
field: Scriva::V1::FieldFilter.new(
|
|
641
|
+
field: h["field"].to_s,
|
|
642
|
+
op: Scriva::V1::FilterOp.const_get(op_val),
|
|
643
|
+
value: h["value"].to_s,
|
|
644
|
+
)
|
|
645
|
+
)
|
|
646
|
+
end
|
|
647
|
+
end
|
|
648
|
+
end
|