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 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
+ ```