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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f7fe3bf8a241d5fb390cf97969b4628b5d1ae71d41571f1adac1d4d1290c8c5b
|
|
4
|
+
data.tar.gz: 3c3d681ed7c1f0e876342612ddcc10354ce96408b373be316d5bf90586e8a5e2
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 39399f7a318387a540ff29686a45228deaa841cb5f4806a3b1b504290ffd2a417b5083ab99827781c7f126428ae31d0c46ca3b81d164022f579b545907495047
|
|
7
|
+
data.tar.gz: 934955e9542e2ea17f116dab206ac764dbf0049ab0fbb3e5c3d33dc0a0dbefeb6156ac7d0f46b4d419fff657941ed55e76059fb28e874124bb186be7f6fe9ea5
|
data/README.md
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
# Scriva Ruby Client
|
|
2
|
+
|
|
3
|
+
Idiomatic Ruby gRPC client for [ScrivaDB](https://github.com/srjn45/scriva).
|
|
4
|
+
|
|
5
|
+
**Ruby 3.1+ · gem `scriva`**
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
gem install scriva
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Or add to your `Gemfile`:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
gem "scriva", "~> 0.7"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
require "scriva"
|
|
27
|
+
|
|
28
|
+
db = Scriva::Client.new(host: "localhost", port: 5433, api_key: "dev-key")
|
|
29
|
+
|
|
30
|
+
db.create_collection("users")
|
|
31
|
+
|
|
32
|
+
id = db.insert("users", { name: "Alice", age: 30, role: "admin" })
|
|
33
|
+
|
|
34
|
+
record = db.find_by_id("users", id)
|
|
35
|
+
# => { "id" => 1, "data" => { "name" => "Alice", "age" => 30, ... }, "date_added" => "..." }
|
|
36
|
+
|
|
37
|
+
admins = db.find("users", filter: { field: "role", op: "eq", value: "admin" })
|
|
38
|
+
|
|
39
|
+
db.update("users", id, { name: "Alice", age: 31 })
|
|
40
|
+
db.delete("users", id)
|
|
41
|
+
db.drop_collection("users")
|
|
42
|
+
db.close
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Use `.open` for automatic close on block exit:
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
Scriva::Client.open(host: "localhost", port: 5433, api_key: "dev-key") do |db|
|
|
49
|
+
db.create_collection("orders")
|
|
50
|
+
# ...
|
|
51
|
+
end
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Connection options
|
|
57
|
+
|
|
58
|
+
| Option | Type | Default | Description |
|
|
59
|
+
|---|---|---|---|
|
|
60
|
+
| `host` | String | `"localhost"` | Server hostname |
|
|
61
|
+
| `port` | Integer | `5433` | gRPC port |
|
|
62
|
+
| `api_key` | String | *(required)* | Sent as `x-api-key` metadata on every call |
|
|
63
|
+
| `tls_ca_cert` | String / nil | `nil` | PEM CA cert string or path to PEM file; enables TLS |
|
|
64
|
+
|
|
65
|
+
### TLS
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
# Path to CA cert file
|
|
69
|
+
db = Scriva::Client.new(
|
|
70
|
+
host: "myserver.example.com",
|
|
71
|
+
port: 5433,
|
|
72
|
+
api_key: "my-key",
|
|
73
|
+
tls_ca_cert: "/path/to/ca.crt"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Or inline PEM string
|
|
77
|
+
pem = File.read("/path/to/ca.crt")
|
|
78
|
+
db = Scriva::Client.new(host: "...", port: 5433, api_key: "...", tls_ca_cert: pem)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## API reference
|
|
84
|
+
|
|
85
|
+
### Collection management
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
db.create_collection("users") # => "users"
|
|
89
|
+
db.drop_collection("users") # => true
|
|
90
|
+
db.list_collections # => ["users", "orders"]
|
|
91
|
+
|
|
92
|
+
# Give the collection a default per-record TTL (seconds). Records inserted
|
|
93
|
+
# without an explicit ttl then expire this many seconds after being written.
|
|
94
|
+
db.create_collection("sessions", default_ttl_seconds: 3600)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### CRUD
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
# Insert one record — returns integer ID
|
|
101
|
+
id = db.insert("users", { name: "Alice", age: 30 })
|
|
102
|
+
|
|
103
|
+
# Insert many — returns array of IDs
|
|
104
|
+
ids = db.insert_many("users", [
|
|
105
|
+
{ name: "Bob", age: 25 },
|
|
106
|
+
{ name: "Carol", age: 28 },
|
|
107
|
+
])
|
|
108
|
+
|
|
109
|
+
# Find by ID — returns Hash
|
|
110
|
+
record = db.find_by_id("users", id)
|
|
111
|
+
# { "id" => 1, "data" => { "name" => "Alice", ... }, "date_added" => "2026-..." }
|
|
112
|
+
|
|
113
|
+
# Find (server-streaming, collected into Array by default)
|
|
114
|
+
all = db.find("users")
|
|
115
|
+
filtered = db.find("users",
|
|
116
|
+
filter: { field: "age", op: "gte", value: "25" },
|
|
117
|
+
limit: 10,
|
|
118
|
+
offset: 0,
|
|
119
|
+
order_by: "name",
|
|
120
|
+
descending: false
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Streaming find via block — no buffering
|
|
124
|
+
db.find("users") { |record| puts record["data"]["name"] }
|
|
125
|
+
|
|
126
|
+
# Update
|
|
127
|
+
db.update("users", id, { name: "Alice", age: 31 }) # => id
|
|
128
|
+
|
|
129
|
+
# Delete
|
|
130
|
+
db.delete("users", id) # => true
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Records carry `"key"` and `"rev"` when the server set them (see
|
|
134
|
+
[Keyed CRUD](#keyed-crud-upsert--compare-and-swap)); `"rev"` is the monotonic
|
|
135
|
+
per-record revision, starting at 1 and bumped on every write.
|
|
136
|
+
|
|
137
|
+
### Keyed CRUD, upsert & compare-and-swap
|
|
138
|
+
|
|
139
|
+
Every record has an optional caller-supplied string **key** and a monotonic
|
|
140
|
+
**rev**. These let you address records by your own identifier and do
|
|
141
|
+
optimistic-concurrency updates.
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
# upsert: insert under a key, or atomically replace an existing keyed record
|
|
145
|
+
# (bumping its rev). Returns the resulting record Hash.
|
|
146
|
+
rec = db.upsert("users", "user:alice", { name: "Alice", age: 30 })
|
|
147
|
+
# => { "id" => 1, "data" => {...}, "key" => "user:alice", "rev" => 1, ... }
|
|
148
|
+
|
|
149
|
+
# Keyed create via insert(key:) — a key already held by a live record raises
|
|
150
|
+
# Scriva::AlreadyExistsError.
|
|
151
|
+
db.insert("users", { name: "Bob" }, key: "user:bob")
|
|
152
|
+
|
|
153
|
+
# Fetch / overwrite / delete by key. A missing key raises Scriva::NotFoundError.
|
|
154
|
+
db.find_by_key("users", "user:alice") # => record Hash
|
|
155
|
+
db.update_by_key("users", "user:alice", { name: "Alice", age: 31 })
|
|
156
|
+
# => { "id" => 1, "key" => "user:alice", "rev" => 2, "date_modified" => "..." }
|
|
157
|
+
db.delete_by_key("users", "user:bob") # => true
|
|
158
|
+
|
|
159
|
+
# Compare-and-swap: the write applies only if the record's current rev matches
|
|
160
|
+
# expected_rev. A stale rev (or a missing key) is a clean no-op — never an error.
|
|
161
|
+
res = db.update_if_rev("users", "user:alice", 2, { name: "Alice", age: 32 })
|
|
162
|
+
# => { "swapped" => true, "record" => { ... "rev" => 3 } } when rev matched
|
|
163
|
+
# => { "swapped" => false, "record" => nil } when rev was stale
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Typed errors (`Scriva::NotFoundError`, `Scriva::AlreadyExistsError`) both
|
|
167
|
+
subclass `Scriva::Error`; other gRPC failures propagate as `GRPC::BadStatus`.
|
|
168
|
+
|
|
169
|
+
### Field projection
|
|
170
|
+
|
|
171
|
+
`find`, `find_by_id` and `find_by_key` accept a `fields:` list. When non-empty,
|
|
172
|
+
only those top-level fields are returned in each record's `data`; `id`, `key`
|
|
173
|
+
and `rev` are always included.
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
db.find_by_id("users", id, fields: ["name", "email"])
|
|
177
|
+
db.find_by_key("users", "user:alice", fields: ["name"])
|
|
178
|
+
db.find("users", filter: { field: "role", op: "eq", value: "admin" }, fields: ["name"])
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Keyset pagination & multi-field sort
|
|
182
|
+
|
|
183
|
+
`order_by:` also accepts an **Array** for a multi-field, per-field-directional
|
|
184
|
+
sort — each item a field name, a `[field, desc]` pair, or a `{ field:, desc: }`
|
|
185
|
+
Hash. `find_page` returns `[records, next_page_token]`; feed the token back as
|
|
186
|
+
`page_token:` to walk the collection page by page in O(page) time. An empty
|
|
187
|
+
token means the last page was reached — keep the same filter, ordering and limit
|
|
188
|
+
on every page.
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
ordering = [{ field: "dept", desc: false }, ["age", true]] # dept ↑, then age ↓
|
|
192
|
+
|
|
193
|
+
page1, token = db.find_page("users", order_by: ordering, limit: 50)
|
|
194
|
+
page2, token = db.find_page("users", order_by: ordering, limit: 50, page_token: token) unless token.empty?
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### Aggregations
|
|
198
|
+
|
|
199
|
+
Compute count and numeric aggregations (`sum`/`avg`/`min`/`max`) entirely in the
|
|
200
|
+
engine — the collection is never materialised on the client. All three honour
|
|
201
|
+
the same filter as `find`.
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
# Count matching (or all) live records
|
|
205
|
+
db.count("orders") # => 128
|
|
206
|
+
db.count("orders", filter: { field: "status", op: "eq", value: "paid" })
|
|
207
|
+
|
|
208
|
+
# Group by a field, aggregating a numeric metric per group
|
|
209
|
+
db.group_by("orders", "region", aggregations: ["sum", "avg", "min", "max"], metric: "total")
|
|
210
|
+
# => [ { "group" => "us", "count" => 40, "numeric" => true,
|
|
211
|
+
# "sum" => 9000.0, "avg" => 225.0, "min" => 5.0, "max" => 999.0 }, ... ]
|
|
212
|
+
|
|
213
|
+
# Or the general form: optional group_by, optional numeric field
|
|
214
|
+
db.aggregate("orders", aggregations: ["sum"], field: "total")
|
|
215
|
+
# => [ { "group" => nil, "count" => 128, "numeric" => true, "sum" => 30000.0, ... } ]
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Each result Hash carries `"group"` (the group-by value, `nil` for the whole-set
|
|
219
|
+
group), `"count"`, `"numeric"`, and — when the group had at least one numeric
|
|
220
|
+
`field` value — `"sum"`, `"avg"`, `"min"`, `"max"`.
|
|
221
|
+
|
|
222
|
+
#### Per-record TTL
|
|
223
|
+
|
|
224
|
+
`insert`, `insert_many`, and `update` each take a `ttl_seconds:` keyword:
|
|
225
|
+
|
|
226
|
+
```ruby
|
|
227
|
+
# Expire this record 60 seconds from now, regardless of the collection default.
|
|
228
|
+
db.insert("sessions", { token: "abc" }, ttl_seconds: 60)
|
|
229
|
+
|
|
230
|
+
# Same TTL applied to every record in the batch.
|
|
231
|
+
db.insert_many("sessions", [{ token: "a" }, { token: "b" }], ttl_seconds: 60)
|
|
232
|
+
|
|
233
|
+
# On update, ttl_seconds > 0 resets the expiry; ttl_seconds: 0 (the default) is
|
|
234
|
+
# sticky and leaves the existing deadline untouched.
|
|
235
|
+
db.update("sessions", id, { token: "abc", seen: true }, ttl_seconds: 120)
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
`ttl_seconds: 0` (the default) inherits the collection's default TTL on insert;
|
|
239
|
+
a value greater than 0 overrides it. Negative values are rejected by the server.
|
|
240
|
+
|
|
241
|
+
### Filter syntax
|
|
242
|
+
|
|
243
|
+
```ruby
|
|
244
|
+
# Field filter
|
|
245
|
+
{ field: "age", op: "gt", value: "18" }
|
|
246
|
+
{ field: "name", op: "contains", value: "ali" }
|
|
247
|
+
{ field: "email",op: "regex", value: ".*@gmail\\.com" }
|
|
248
|
+
|
|
249
|
+
# AND composite
|
|
250
|
+
{ and: [
|
|
251
|
+
{ field: "role", op: "eq", value: "admin" },
|
|
252
|
+
{ field: "age", op: "gte", value: "25" },
|
|
253
|
+
] }
|
|
254
|
+
|
|
255
|
+
# OR composite
|
|
256
|
+
{ or: [
|
|
257
|
+
{ field: "status", op: "eq", value: "active" },
|
|
258
|
+
{ field: "role", op: "eq", value: "superuser" },
|
|
259
|
+
] }
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Supported `op` values: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains`, `regex`
|
|
263
|
+
|
|
264
|
+
### Secondary indexes
|
|
265
|
+
|
|
266
|
+
```ruby
|
|
267
|
+
db.ensure_index("users", "email")
|
|
268
|
+
db.list_indexes("users") # => ["email"]
|
|
269
|
+
db.drop_index("users", "email") # => true
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### Transactions
|
|
273
|
+
|
|
274
|
+
```ruby
|
|
275
|
+
tx = db.begin_tx("orders")
|
|
276
|
+
# ... perform inserts / updates in transaction context ...
|
|
277
|
+
db.commit_tx(tx) # => true
|
|
278
|
+
# or
|
|
279
|
+
db.rollback_tx(tx) # => true
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
### Watch (server-streaming change feed)
|
|
283
|
+
|
|
284
|
+
```ruby
|
|
285
|
+
# Enumerator — iterate manually
|
|
286
|
+
enum = db.watch("users")
|
|
287
|
+
enum.each do |event|
|
|
288
|
+
puts "#{event[:op]}: #{event[:record]["data"].inspect}"
|
|
289
|
+
break if done?
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Block form — cleaner for long-running subscriptions
|
|
293
|
+
db.watch("users", filter: { field: "role", op: "eq", value: "admin" }) do |event|
|
|
294
|
+
puts event.inspect
|
|
295
|
+
end
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Each event is a Hash:
|
|
299
|
+
|
|
300
|
+
```ruby
|
|
301
|
+
{
|
|
302
|
+
op: "INSERTED", # "INSERTED" | "UPDATED" | "DELETED" | "OVERFLOW"
|
|
303
|
+
collection: "users",
|
|
304
|
+
record: { "id" => 1, "data" => { ... }, "date_added" => "..." },
|
|
305
|
+
ts: "2026-06-29T12:00:00.000000000Z",
|
|
306
|
+
}
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
### Stats
|
|
310
|
+
|
|
311
|
+
```ruby
|
|
312
|
+
s = db.stats("users")
|
|
313
|
+
# {
|
|
314
|
+
# collection: "users",
|
|
315
|
+
# record_count: 42,
|
|
316
|
+
# segment_count: 3,
|
|
317
|
+
# dirty_entries: 0,
|
|
318
|
+
# size_bytes: 8192,
|
|
319
|
+
# }
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
### Maintenance
|
|
323
|
+
|
|
324
|
+
```ruby
|
|
325
|
+
# Force a synchronous compaction of a collection — merges dirty segments and
|
|
326
|
+
# reclaims space from deleted/overwritten records. Returns true on success.
|
|
327
|
+
db.compact("users")
|
|
328
|
+
|
|
329
|
+
# Stream a consistent gzip-compressed tar snapshot of the whole database
|
|
330
|
+
# straight to a file. Returns the number of bytes written; restore with
|
|
331
|
+
# `tar xzf backup.tar.gz`.
|
|
332
|
+
bytes = db.snapshot_to_file("backup.tar.gz")
|
|
333
|
+
|
|
334
|
+
# Or consume the raw archive chunks yourself (Snapshot is server-streaming):
|
|
335
|
+
db.snapshot { |chunk| out.write(chunk) } # chunk is a binary String
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
---
|
|
339
|
+
|
|
340
|
+
## Regenerate proto stubs
|
|
341
|
+
|
|
342
|
+
The stubs under `lib/scriva/proto/` are pre-generated and committed. To
|
|
343
|
+
regenerate after a proto change:
|
|
344
|
+
|
|
345
|
+
```bash
|
|
346
|
+
gem install grpc-tools
|
|
347
|
+
cd clients/ruby
|
|
348
|
+
./generate.sh
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
---
|
|
352
|
+
|
|
353
|
+
## Run the examples
|
|
354
|
+
|
|
355
|
+
Start the server (`make run` from repo root), then:
|
|
356
|
+
|
|
357
|
+
```bash
|
|
358
|
+
cd clients/ruby
|
|
359
|
+
bundle install
|
|
360
|
+
bundle exec ruby examples/test_basic.rb
|
|
361
|
+
bundle exec ruby examples/test_watch.rb
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
---
|
|
365
|
+
|
|
366
|
+
## Publish to RubyGems
|
|
367
|
+
|
|
368
|
+
```bash
|
|
369
|
+
gem build scriva.gemspec
|
|
370
|
+
gem push scriva-0.7.0.gem
|
|
371
|
+
```
|