loams 0.1.0 → 0.1.2

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.
data/lib/loams/app.rb ADDED
@@ -0,0 +1,726 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "loams/descriptors"
5
+ require "loams/builders"
6
+ require "loams/batch"
7
+ require "loams/consistency"
8
+ require "loams/pagination"
9
+ require "loams/subscribe"
10
+ require "loams/values"
11
+
12
+ module Loams
13
+ # The app-building modules (SDKP §2.2–§2.5): `namespaces`, `collections`,
14
+ # `documents`, `search`, `vector` and `graph`.
15
+ #
16
+ # Each call names its RPC and builds the request's proto3 JSON; the binding
17
+ # (retry class, idempotency-key field, request and response types) is read
18
+ # from the committed descriptor set, never typed here. Requests go through
19
+ # the client's invoker, so retries, keys, token refresh, timeouts and errors
20
+ # are R1–R12 as for every other module. Answers are the response messages;
21
+ # `Loams.to_h` turns one into plain Ruby.
22
+ module App
23
+ # The RPCs behind every app call, `[module, call] => rpc`. Checked against
24
+ # `sdks/fixtures/facade-index.json` by `ruby_facade_matches_index`.
25
+ CALLS = {
26
+ %w[namespaces create] => "loams.collection.v1.NamespaceService/CreateNamespace",
27
+ %w[namespaces list] => "loams.collection.v1.NamespaceService/ListNamespaces",
28
+ %w[collections create] => "loams.collection.v1.CollectionService/CreateCollection",
29
+ %w[collections list] => "loams.collection.v1.CollectionService/ListCollections",
30
+ %w[collections get] => "loams.collection.v1.CollectionService/GetCollection",
31
+ %w[collections drop] => "loams.collection.v1.CollectionService/DropCollection",
32
+ %w[collections add_fields] => "loams.collection.v1.CollectionService/AddFields",
33
+ %w[collections list_versions] => "loams.collection.v1.CollectionService/ListVersions",
34
+ %w[collections update_aliases] => "loams.collection.v1.CollectionService/UpdateAliases",
35
+ %w[collections scan] => "loams.collection.v1.CollectionService/Scan",
36
+ %w[collections set_hot] => "loams.collection.v1.CollectionService/SetHot",
37
+ %w[collections warm] => "loams.collection.v1.CollectionService/WarmCollection",
38
+ %w[documents write] => "loams.collection.v1.DocumentService/WriteDocuments",
39
+ %w[documents get] => "loams.collection.v1.DocumentService/GetDocuments",
40
+ %w[documents scroll] => "loams.collection.v1.DocumentService/ScrollDocuments",
41
+ %w[documents count] => "loams.collection.v1.DocumentService/CountDocuments",
42
+ %w[documents delete_by_filter] => "loams.collection.v1.DocumentService/DeleteByFilter",
43
+ %w[documents patch_by_filter] => "loams.collection.v1.DocumentService/PatchByFilter",
44
+ %w[search query] => "loams.collection.v1.QueryService/Search",
45
+ %w[vector search] => "loams.collection.v1.QueryService/Search",
46
+ %w[graph create] => "loams.graph.v1.GraphAdminService/CreateGraph",
47
+ %w[graph get] => "loams.graph.v1.GraphAdminService/GetGraph",
48
+ %w[graph list] => "loams.graph.v1.GraphAdminService/ListGraphs",
49
+ %w[graph update] => "loams.graph.v1.GraphAdminService/UpdateGraph",
50
+ %w[graph delete] => "loams.graph.v1.GraphAdminService/DeleteGraph",
51
+ %w[graph schema] => "loams.graph.v1.GraphAdminService/GetSchema",
52
+ %w[graph query] => "loams.graph.v1.GraphService/Execute",
53
+ %w[graph query_batch] => "loams.graph.v1.GraphService/ExecuteBatch",
54
+ %w[graph query_stream] => "loams.graph.v1.GraphService/ExecuteStream",
55
+ %w[graph explain] => "loams.graph.v1.GraphService/Explain",
56
+ %w[sql query] => "loams.sql.v1.SqlService/Query",
57
+ %w[sql query_arrow] => "loams.sql.v1.SqlService/QueryArrow",
58
+ %w[streams create] => "loams.stream.v1.StreamService/CreateStream",
59
+ %w[streams get] => "loams.stream.v1.StreamService/DescribeStream",
60
+ %w[streams list] => "loams.stream.v1.StreamService/ListStreams",
61
+ %w[streams produce] => "loams.stream.v1.StreamService/Produce",
62
+ %w[streams fetch] => "loams.stream.v1.StreamService/Fetch",
63
+ %w[streams produce_events] => "loams.stream.v1.StreamService/ProduceCloudEvents",
64
+ %w[streams fetch_events] => "loams.stream.v1.StreamService/FetchCloudEvents",
65
+ }.freeze
66
+
67
+ # The packages whose reads take the session's token (SDKP-17).
68
+ SESSION_SCOPES = %w[loams.collection.v1].freeze
69
+
70
+ # What every module shares: the call path from proto3 JSON to an answer.
71
+ class Base
72
+ def initialize(client)
73
+ @client = client
74
+ end
75
+
76
+ private
77
+
78
+ # Sends +request+ (proto3 JSON, or a message) to +rpc+.
79
+ #
80
+ # R11: a request with a `namespace` field gets the client's default
81
+ # namespace when it leaves it empty, and with neither it is refused
82
+ # here, before any network call. R4: with `session_consistency`, a read
83
+ # that leaves `consistency` unset reads at least the session's token, and
84
+ # every token an answer carries is merged into the session.
85
+ def rpc(path, request, options = {})
86
+ binding = Descriptors.binding_for_rpc(path)
87
+ raise UnimplementedError.new("this SDK's descriptor set has no #{path}", code: :unimplemented) if binding.nil?
88
+
89
+ message = to_message(binding, request)
90
+ fill_namespace(binding, message, path)
91
+ # SQL reads collections, so it reads (and adds to) the collection token.
92
+ scope = binding.package_name == "loams.sql.v1" ? "loams.collection.v1" : binding.package_name
93
+ # SDKP-17: only collection (and SQL) reads take the session's token; a
94
+ # graph's tokens name one graph's stream.
95
+ @client.session&.apply(scope, message) if SESSION_SCOPES.include?(scope)
96
+ call_options = options.dup
97
+ if binding.server_streaming?
98
+ return @client.invoker.server_stream(binding, message, call_options)
99
+ end
100
+
101
+ answer = @client.invoker.unary(binding, message, call_options)
102
+ @client.session&.observe_message(scope, answer)
103
+ answer
104
+ end
105
+
106
+ def to_message(binding, request)
107
+ return request if request.is_a?(binding.request.msgclass)
108
+
109
+ binding.request.msgclass.decode_json(JSON.generate(request || {}))
110
+ rescue Google::Protobuf::ParseError => e
111
+ raise InvalidArgumentError.new("#{binding.rpc}: #{e.message}", code: :invalid_argument, rpc: binding.rpc)
112
+ end
113
+
114
+ def fill_namespace(binding, message, path)
115
+ field = binding.request.lookup("namespace")
116
+ return if field.nil? || field.type != :string
117
+
118
+ current = message["namespace"]
119
+ return unless current.nil? || current.empty?
120
+
121
+ default = @client.namespace
122
+ if default.nil? || default.empty?
123
+ raise InvalidArgumentError.new(
124
+ "#{path} needs a namespace: pass namespace: or give the client one",
125
+ code: :invalid_argument,
126
+ reason: :namespace_required,
127
+ rpc: path,
128
+ )
129
+ end
130
+ message["namespace"] = default
131
+ end
132
+
133
+ # `{namespace: ns}` when +ns+ is given, so the client default applies
134
+ # otherwise.
135
+ def ns(namespace)
136
+ namespace.nil? ? {} : { "namespace" => namespace.to_s }
137
+ end
138
+
139
+ def call_opts(kw)
140
+ kw.slice(:timeout, :headers, :idempotency_key, :max_retries)
141
+ end
142
+
143
+ # Every item of a paged list, following `next_page_token`.
144
+ def paged(path, request, items, options)
145
+ binding = Descriptors.binding_for_rpc(path)
146
+ PageIterator.new(
147
+ fetch: proc { |token| rpc(path, request.merge("pageToken" => token.to_s), options) },
148
+ items_field: binding.response.lookup(items),
149
+ next_page_token_field: binding.response.lookup("next_page_token"),
150
+ ).each
151
+ end
152
+ end
153
+
154
+ # Any app call by its facade name, over its request's proto3 JSON: what
155
+ # `Client#call` goes through.
156
+ class Raw < Base
157
+ def call(name, request, **opts)
158
+ mod, call = name.split(".", 2)
159
+ path = CALLS[[mod, call]] or raise ArgumentError, "no app call #{name}"
160
+ rpc(path, request, call_opts(opts))
161
+ end
162
+ end
163
+
164
+ # `namespaces`: the container every collection and stream lives in.
165
+ class Namespaces < Base
166
+ def create(namespace, **opts)
167
+ rpc(CALLS[%w[namespaces create]], { "namespace" => namespace.to_s }, call_opts(opts))
168
+ end
169
+
170
+ def list(page_size: nil, page_token: nil, **opts)
171
+ rpc(CALLS[%w[namespaces list]], Builders.compact({ "pageSize" => page_size, "pageToken" => page_token }), call_opts(opts))
172
+ end
173
+
174
+ # Every namespace, across pages, as an Enumerator.
175
+ def list_all(page_size: nil, **opts)
176
+ paged(CALLS[%w[namespaces list]], Builders.compact({ "pageSize" => page_size }), "namespaces", call_opts(opts))
177
+ end
178
+ end
179
+
180
+ # `collections`: schema, aliases, versions and the hot tier.
181
+ class Collections < Base
182
+ # +schema+ is a `Schema` or its Hash.
183
+ def create(name, schema:, partitions: nil, namespace: nil, **opts)
184
+ rpc(CALLS[%w[collections create]], ns(namespace).merge(Builders.compact({
185
+ "name" => name.to_s, "schema" => Builders.json(schema), "partitions" => partitions,
186
+ })), call_opts(opts))
187
+ end
188
+
189
+ def get(name, namespace: nil, **opts)
190
+ rpc(CALLS[%w[collections get]], ns(namespace).merge("collection" => name.to_s), call_opts(opts))
191
+ end
192
+
193
+ def list(page_size: nil, page_token: nil, namespace: nil, **opts)
194
+ rpc(CALLS[%w[collections list]], ns(namespace).merge(Builders.compact({ "pageSize" => page_size, "pageToken" => page_token })), call_opts(opts))
195
+ end
196
+
197
+ def list_all(page_size: nil, namespace: nil, **opts)
198
+ paged(CALLS[%w[collections list]], ns(namespace).merge(Builders.compact({ "pageSize" => page_size })), "collections", call_opts(opts))
199
+ end
200
+
201
+ def drop(name, namespace: nil, **opts)
202
+ rpc(CALLS[%w[collections drop]], ns(namespace).merge("collection" => name.to_s), call_opts(opts))
203
+ end
204
+
205
+ # Additive schema change: +fields+ and +vectors+ are the schema
206
+ # builder's field and vector Hashes (`Schema#to_h["fields"]`).
207
+ def add_fields(name, fields: [], vectors: [], annotations: nil, namespace: nil, **opts)
208
+ fields = Builders.json(fields)
209
+ fields = fields["fields"] if fields.is_a?(Hash)
210
+ rpc(CALLS[%w[collections add_fields]], ns(namespace).merge(Builders.compact({
211
+ "collection" => name.to_s, "fields" => fields, "vectors" => vectors, "annotations" => annotations,
212
+ })), call_opts(opts))
213
+ end
214
+
215
+ def list_versions(name, page_size: nil, page_token: nil, namespace: nil, **opts)
216
+ rpc(CALLS[%w[collections list_versions]], ns(namespace).merge(Builders.compact({
217
+ "collection" => name.to_s, "pageSize" => page_size, "pageToken" => page_token,
218
+ })), call_opts(opts))
219
+ end
220
+
221
+ # +actions+: `[{create: {alias:, collection:}}, {delete: {alias:}}]`.
222
+ def update_aliases(actions, namespace: nil, **opts)
223
+ rpc(CALLS[%w[collections update_aliases]], ns(namespace).merge("actions" => actions), call_opts(opts))
224
+ end
225
+
226
+ def scan(name, at: nil, namespace: nil, **opts)
227
+ rpc(CALLS[%w[collections scan]], ns(namespace).merge(Builders.compact({ "collection" => name.to_s, "at" => at })), call_opts(opts))
228
+ end
229
+
230
+ def set_hot(name, vectors: false, text: false, fragments: false, namespace: nil, **opts)
231
+ rpc(CALLS[%w[collections set_hot]], ns(namespace).merge(
232
+ "collection" => name.to_s, "hot" => { "vectors" => vectors, "text" => text, "fragments" => fragments },
233
+ ), call_opts(opts))
234
+ end
235
+
236
+ def warm(name, namespace: nil, **opts)
237
+ rpc(CALLS[%w[collections warm]], ns(namespace).merge("collection" => name.to_s), call_opts(opts))
238
+ end
239
+ end
240
+
241
+ # `documents`: write, get, scroll, count and the filter writes.
242
+ class Documents < Base
243
+ # Raw write ops (`WriteOp` JSON). Returns the response; its `token` is
244
+ # the write's consistency token.
245
+ def write(collection, ops, report_existence: false, namespace: nil, **opts)
246
+ rpc(CALLS[%w[documents write]], ns(namespace).merge(
247
+ "collection" => collection.to_s, "ops" => ops, "reportExistence" => report_existence,
248
+ ), call_opts(opts))
249
+ end
250
+
251
+ # +docs+: `[{id:, source:, vectors:, sparse_vectors:}]`.
252
+ def upsert(collection, docs, **kw)
253
+ write(collection, docs.map { |d| { "upsert" => Documents.document(d) } }, **kw)
254
+ end
255
+
256
+ def delete(collection, ids, **kw)
257
+ write(collection, Array(ids).map { |id| { "delete" => { "id" => Values.document_id(id) } } }, **kw)
258
+ end
259
+
260
+ # Sets the keys of +set+ and removes the keys named in +unset+.
261
+ def patch(collection, id, set: nil, unset: nil, vectors: nil, mode: nil, **kw)
262
+ patch = Builders.compact({
263
+ "id" => Values.document_id(id), "mode" => mode&.to_s, "source" => set,
264
+ "deleteKeys" => unset&.map(&:to_s), "vectors" => vectors,
265
+ })
266
+ write(collection, [{ "patch" => patch }], **kw)
267
+ end
268
+
269
+ # R13: +docs+ (any Enumerable of `{id:, source:, …}`) in chunks, one
270
+ # in flight at a time. Returns a `BatchResult` (the merged token, the
271
+ # chunks and the rows); a failed chunk raises `BatchError`.
272
+ def write_batch(collection, docs, chunk_rows: Batch::DEFAULT_CHUNK_ROWS, chunk_bytes: Batch::DEFAULT_CHUNK_BYTES, namespace: nil, **opts)
273
+ ops = docs.lazy.map { |d| { "upsert" => Documents.document(d) } }
274
+ Batch.run(ops, chunk_rows: chunk_rows, chunk_bytes: chunk_bytes) do |chunk, key|
275
+ write(collection, chunk, namespace: namespace, **opts, idempotency_key: key)
276
+ end
277
+ end
278
+
279
+ # +select+ is a projection Hash (`{source: {include: […]}}`);
280
+ # +consistency+ is `:strong`, `:eventual` or a token.
281
+ def get(collection, ids, select: nil, consistency: nil, namespace: nil, **opts)
282
+ rpc(CALLS[%w[documents get]], ns(namespace).merge(Builders.compact({
283
+ "collection" => collection.to_s, "ids" => Array(ids).map { |id| Values.document_id(id) },
284
+ "select" => select, "consistency" => Search.consistency_json(consistency),
285
+ })), call_opts(opts))
286
+ end
287
+
288
+ # One page. +where+ is a typed `Filter`; +filter+ is the server's native
289
+ # filter JSON.
290
+ def scroll(collection, where: nil, filter: nil, after: nil, limit: nil, select: nil, consistency: nil, namespace: nil, **opts)
291
+ rpc(CALLS[%w[documents scroll]], filtered(CALLS[%w[documents scroll]], ns(namespace).merge(Builders.compact({
292
+ "collection" => collection.to_s, "after" => after && Values.document_id(after),
293
+ "limit" => limit, "select" => select, "consistency" => Search.consistency_json(consistency),
294
+ })), where, filter), call_opts(opts))
295
+ end
296
+
297
+ # Every document, page after page, until the server stops answering a
298
+ # `next` cursor. An Enumerator.
299
+ def scroll_all(collection, **kw)
300
+ return to_enum(:scroll_all, collection, **kw) unless block_given?
301
+
302
+ after = kw.delete(:after)
303
+ loop do
304
+ page = scroll(collection, **kw, after: after)
305
+ page.documents.each { |doc| yield doc }
306
+ break if page.next.nil?
307
+
308
+ after = Values.to_ruby(page.next)
309
+ end
310
+ end
311
+
312
+ def count(collection, where: nil, filter: nil, consistency: nil, namespace: nil, **opts)
313
+ rpc(CALLS[%w[documents count]], filtered(CALLS[%w[documents count]], ns(namespace).merge(Builders.compact({
314
+ "collection" => collection.to_s, "consistency" => Search.consistency_json(consistency),
315
+ })), where, filter), call_opts(opts))
316
+ end
317
+
318
+ def delete_by_filter(collection, where: nil, filter: nil, max_rows: nil, allow_partial: false, cursor: nil, namespace: nil, **opts)
319
+ rpc(CALLS[%w[documents delete_by_filter]], filtered(CALLS[%w[documents delete_by_filter]], ns(namespace).merge(Builders.compact({
320
+ "collection" => collection.to_s, "maxRows" => max_rows, "allowPartial" => allow_partial, "cursor" => cursor,
321
+ })), where, filter), call_opts(opts))
322
+ end
323
+
324
+ def patch_by_filter(collection, patch, where: nil, filter: nil, max_rows: nil, allow_partial: false, cursor: nil, namespace: nil, **opts)
325
+ rpc(CALLS[%w[documents patch_by_filter]], filtered(CALLS[%w[documents patch_by_filter]], ns(namespace).merge(Builders.compact({
326
+ "collection" => collection.to_s, "patch" => patch, "maxRows" => max_rows, "allowPartial" => allow_partial, "cursor" => cursor,
327
+ })), where, filter), call_opts(opts))
328
+ end
329
+
330
+ # One document as a `WriteDocument`'s JSON.
331
+ def self.document(doc)
332
+ doc = doc.transform_keys(&:to_s)
333
+ Builders.compact({
334
+ "id" => Values.document_id(doc.fetch("id")),
335
+ "source" => doc["source"] || {},
336
+ "vectors" => doc["vectors"],
337
+ "sparseVectors" => doc["sparse_vectors"] || doc["sparseVectors"],
338
+ })
339
+ end
340
+
341
+ private
342
+
343
+ # The typed `where` goes in the request's `where` field (SDKP-3), which
344
+ # needs protos that declare it; the native `filter` JSON goes as is.
345
+ def filtered(path, request, where, filter)
346
+ unless where.nil?
347
+ binding = Descriptors.binding_for_rpc(path)
348
+ if binding.request.lookup("where").nil?
349
+ raise InvalidArgumentError.new(
350
+ "#{path} takes no typed where in this SDK's protos; pass filter: (the native filter JSON)",
351
+ code: :invalid_argument,
352
+ rpc: path,
353
+ )
354
+ end
355
+ request = request.merge("where" => Builders.json(where))
356
+ end
357
+ request = request.merge("filter" => filter) unless filter.nil?
358
+ request
359
+ end
360
+ end
361
+
362
+ # `search`: one search over one collection: text, vector, sparse or
363
+ # hybrid, with filters, fusion, highlights, `select` and pagination.
364
+ class SearchModule < Base
365
+ # +request+ is a `Search` builder or the `SearchRequest`'s JSON, checked
366
+ # (`Search#build`) and sent as built.
367
+ def query(request, **opts)
368
+ rpc(CALLS[%w[search query]], Search.wire(request), call_opts(opts))
369
+ end
370
+
371
+ # A text search: a match on one field, a multi-match on several, or a
372
+ # query string.
373
+ def text(collection, text, fields: nil, field: nil, filter: nil, limit: nil, namespace: nil, **opts)
374
+ s = Search.on(collection, namespace: namespace).text(text, fields: fields, field: field)
375
+ s.filter(filter) if filter
376
+ s.limit(limit) if limit
377
+ query(s, **opts)
378
+ end
379
+
380
+ # Text plus a vector on +field+, fused (`Fusion.rrf` by default).
381
+ def hybrid(collection, text, vector, field:, fields: nil, fusion: nil, k: nil, filter: nil, limit: nil, namespace: nil, **opts)
382
+ s = Search.on(collection, namespace: namespace)
383
+ .text(text, fields: fields, k: k)
384
+ .vector(field, vector, k: k)
385
+ .fuse(fusion || Fusion.rrf)
386
+ s.filter(filter) if filter
387
+ s.limit(limit) if limit
388
+ query(s, **opts)
389
+ end
390
+
391
+ # Every hit, page after page through `search_after`, until a page has
392
+ # fewer than `limit` hits. The request's sort must end with the `pk`
393
+ # tiebreaker, or it is refused here, before any network call.
394
+ def query_all(request, **opts)
395
+ return to_enum(:query_all, request, **opts) unless block_given?
396
+
397
+ page_request = Builders.json(request)
398
+ unless Search.tiebreak?(page_request)
399
+ raise InvalidArgumentError.new(
400
+ "query_all needs a sort that ends with the primary key, such as .sort(:score, :pk)",
401
+ code: :invalid_argument,
402
+ reason: :search_after_needs_tiebreak,
403
+ metadata: { "field" => "sort" },
404
+ )
405
+ end
406
+ limit = page_request["limit"] || Search::DEFAULT_K
407
+ loop do
408
+ page = query(page_request, **opts)
409
+ page.hits.each { |hit| yield hit }
410
+ break if page.hits.size < limit || page.hits.empty?
411
+
412
+ page_request = page_request.merge("searchAfter" => Values.to_ruby(page.hits.last.sort_values))
413
+ end
414
+ end
415
+
416
+ end
417
+
418
+ # `vector`: the same RPC as `search.query`, with the vector shape as its
419
+ # required arguments (Q613).
420
+ class VectorModule < Base
421
+ def search(collection, vector, field:, limit: nil, k: nil, filter: nil, namespace: nil, **opts)
422
+ s = Search.on(collection, namespace: namespace).vector(field, vector, k: k)
423
+ s.filter(filter) if filter
424
+ s.limit(limit) if limit
425
+ rpc(CALLS[%w[vector search]], Search.wire(s), call_opts(opts))
426
+ end
427
+ end
428
+
429
+ # `graph`: stored property graphs, queried with GQL. Experimental: the
430
+ # package is `unstable`.
431
+ class Graph < Base
432
+ LANGUAGES = {
433
+ gql: "QUERY_LANGUAGE_GQL", cypher: "QUERY_LANGUAGE_CYPHER", sql_pgq: "QUERY_LANGUAGE_SQL_PGQ",
434
+ }.freeze
435
+
436
+ def unstable?
437
+ true
438
+ end
439
+
440
+ def create(name, mapping: nil, mode: nil, limits: nil, namespace: nil, **opts)
441
+ rpc(CALLS[%w[graph create]], ns(namespace).merge(Builders.compact({
442
+ "name" => name.to_s, "mapping" => mapping, "limits" => limits,
443
+ "mode" => mode && "GRAPH_MODE_#{mode.to_s.upcase}",
444
+ })), call_opts(opts))
445
+ end
446
+
447
+ def get(name, namespace: nil, **opts)
448
+ rpc(CALLS[%w[graph get]], ns(namespace).merge("name" => name.to_s), call_opts(opts))
449
+ end
450
+
451
+ def list(page_size: nil, page_token: nil, namespace: nil, **opts)
452
+ rpc(CALLS[%w[graph list]], ns(namespace).merge(Builders.compact({ "pageSize" => page_size, "pageToken" => page_token })), call_opts(opts))
453
+ end
454
+
455
+ def list_all(page_size: nil, namespace: nil, **opts)
456
+ paged(CALLS[%w[graph list]], ns(namespace).merge(Builders.compact({ "pageSize" => page_size })), "graphs", call_opts(opts))
457
+ end
458
+
459
+ # +graph+ is the `Graph` JSON (with `namespace` and `name`), and
460
+ # +update_mask+ the paths it changes.
461
+ def update(graph, update_mask:, expected_version: nil, **opts)
462
+ graph = graph.transform_keys(&:to_s)
463
+ graph["namespace"] ||= @client.namespace
464
+ rpc(CALLS[%w[graph update]], Builders.compact({
465
+ "graph" => graph, "updateMask" => Array(update_mask).join(","),
466
+ "expectedVersion" => expected_version&.to_s,
467
+ }), call_opts(opts))
468
+ end
469
+
470
+ # Answers an `Operation`.
471
+ def delete(name, namespace: nil, **opts)
472
+ rpc(CALLS[%w[graph delete]], ns(namespace).merge("name" => name.to_s), call_opts(opts))
473
+ end
474
+
475
+ def schema(name, namespace: nil, **opts)
476
+ rpc(CALLS[%w[graph schema]], ns(namespace).merge("name" => name.to_s), call_opts(opts))
477
+ end
478
+
479
+ # One statement (GQL by default). +parameters+ are Ruby values.
480
+ def query(graph, statement, parameters: {}, read_only: false, language: :gql, consistency: nil,
481
+ max_rows: nil, namespace: nil, **opts)
482
+ rpc(CALLS[%w[graph query]], execute_request(graph, statement, parameters, read_only, language, consistency, max_rows, namespace), call_opts(opts))
483
+ end
484
+
485
+ # +statements+: `[{statement:, parameters:}]`, run in one batch.
486
+ def query_batch(graph, statements, atomic: true, language: :gql, namespace: nil, **opts)
487
+ list = statements.map do |s|
488
+ s = s.transform_keys(&:to_s)
489
+ { "statement" => s.fetch("statement"), "parameters" => params(s["parameters"] || {}) }
490
+ end
491
+ rpc(CALLS[%w[graph query_batch]], ns(namespace).merge(
492
+ "graph" => graph.to_s, "statements" => list, "atomic" => atomic, "language" => language_name(language),
493
+ ), call_opts(opts))
494
+ end
495
+
496
+ # A server stream of `ResultChunk`s (gRPC-only on Connect-unary transports).
497
+ def query_stream(graph, statement, parameters: {}, read_only: true, language: :gql, chunk_rows: nil, namespace: nil, **opts)
498
+ inner = execute_request(graph, statement, parameters, read_only, language, nil, nil, namespace || @client.namespace)
499
+ rpc(CALLS[%w[graph query_stream]], Builders.compact({ "request" => inner, "chunkRows" => chunk_rows }), call_opts(opts))
500
+ end
501
+
502
+ def explain(graph, statement, parameters: {}, profile: false, language: :gql, namespace: nil, **opts)
503
+ rpc(CALLS[%w[graph explain]], ns(namespace).merge(
504
+ "graph" => graph.to_s, "statement" => statement.to_s, "parameters" => params(parameters),
505
+ "profile" => profile, "language" => language_name(language),
506
+ ), call_opts(opts))
507
+ end
508
+
509
+ # An `ExecuteResponse`'s rows as Hashes, column name to Ruby value.
510
+ def self.records(response)
511
+ rows = response.rows
512
+ return [] if rows.nil?
513
+
514
+ columns = rows.columns.to_a
515
+ rows.rows.map { |row| columns.zip(row.values.map { |v| Values.from_graph_value(v) }).to_h }
516
+ end
517
+
518
+ private
519
+
520
+ def execute_request(graph, statement, parameters, read_only, language, consistency, max_rows, namespace)
521
+ ns(namespace).merge(Builders.compact({
522
+ "graph" => graph.to_s, "statement" => statement.to_s, "language" => language_name(language),
523
+ "parameters" => params(parameters), "readOnly" => read_only,
524
+ "consistency" => graph_consistency(consistency), "maxRows" => max_rows,
525
+ }))
526
+ end
527
+
528
+ def params(parameters)
529
+ parameters.to_h { |k, v| [k.to_s, Values.graph_value(v)] }
530
+ end
531
+
532
+ def language_name(language)
533
+ LANGUAGES.fetch(language.to_sym) { language.to_s }
534
+ end
535
+
536
+ def graph_consistency(level)
537
+ case level
538
+ when nil then nil
539
+ when :strong then { "strong" => {} }
540
+ when :eventual then { "eventual" => {} }
541
+ else { "atLeast" => level.to_s }
542
+ end
543
+ end
544
+ end
545
+ end
546
+
547
+ module App
548
+ # `sql`: one read-only statement over a namespace's collections, with
549
+ # `$1…$n` parameters. SQL reads see the session's collection writes.
550
+ class Sql < Base
551
+ # The answer's `rows` are lists in `columns` order; see `.records`.
552
+ def query(sql, params: [], max_rows: nil, consistency: nil, namespace: nil, **opts)
553
+ rpc(CALLS[%w[sql query]], request(sql, params, max_rows, consistency, namespace), call_opts(opts))
554
+ end
555
+
556
+ # The same statement, answered as Arrow IPC bytes.
557
+ def query_arrow(sql, params: [], max_rows: nil, consistency: nil, namespace: nil, **opts)
558
+ rpc(CALLS[%w[sql query_arrow]], request(sql, params, max_rows, consistency, namespace), call_opts(opts)).arrow_ipc
559
+ end
560
+
561
+ # A `QueryResponse`'s rows as Hashes, column name to value.
562
+ def self.records(response)
563
+ names = response.columns.map(&:name)
564
+ response.rows.map { |row| names.zip(Values.to_ruby(row)).to_h }
565
+ end
566
+
567
+ private
568
+
569
+ def request(sql, params, max_rows, consistency, namespace)
570
+ ns(namespace).merge(Builders.compact({
571
+ "sql" => sql.to_s, "params" => params.empty? ? nil : params,
572
+ "maxRows" => max_rows, "consistency" => Search.consistency_json(consistency),
573
+ }))
574
+ end
575
+ end
576
+
577
+ # One fetched record, decoded: `offset`, `key` and `value` (binary
578
+ # Strings, or nil), `headers` and `timestamp_ms`.
579
+ StreamRecord = Struct.new(:offset, :key, :value, :headers, :timestamp_ms, keyword_init: true) do
580
+ def json
581
+ value && JSON.parse(value)
582
+ end
583
+ end
584
+
585
+ # `streams`: a namespace's logs. Records are bytes on the wire; the module
586
+ # takes Strings (bytes as they are) or other values (sent as JSON) and
587
+ # answers bytes.
588
+ class Streams < Base
589
+ def create(name, partitions: 1, max_age_ms: nil, max_bytes: nil, namespace: nil, **opts)
590
+ retention = Builders.compact({ "maxAgeMs" => max_age_ms&.to_s, "maxBytes" => max_bytes&.to_s })
591
+ rpc(CALLS[%w[streams create]], ns(namespace).merge(Builders.compact({
592
+ "name" => name.to_s, "partitions" => partitions.zero? ? nil : partitions,
593
+ "retention" => retention.empty? ? nil : retention,
594
+ })), call_opts(opts))
595
+ end
596
+
597
+ def get(stream, namespace: nil, **opts)
598
+ rpc(CALLS[%w[streams get]], ns(namespace).merge("stream" => stream.to_s), call_opts(opts))
599
+ end
600
+
601
+ def list(page_size: nil, page_token: nil, namespace: nil, **opts)
602
+ rpc(CALLS[%w[streams list]], ns(namespace).merge(Builders.compact({ "pageSize" => page_size, "pageToken" => page_token })), call_opts(opts))
603
+ end
604
+
605
+ def list_all(page_size: nil, namespace: nil, **opts)
606
+ paged(CALLS[%w[streams list]], ns(namespace).merge(Builders.compact({ "pageSize" => page_size })), "streams", call_opts(opts))
607
+ end
608
+
609
+ # Appends `records` to one partition. A record is a value, or
610
+ # `{key:, value:, headers: {name => value}}` with an optional key
611
+ # (SDKP-25). With `producer_id` and `producer_seq` a repeat is
612
+ # deduplicated (`duplicate`).
613
+ def produce(stream, records, partition: 0, producer_id: nil, producer_seq: nil, namespace: nil, **opts)
614
+ rpc(CALLS[%w[streams produce]], ns(namespace).merge(Builders.compact({
615
+ "stream" => stream.to_s, "partition" => partition.zero? ? nil : partition,
616
+ "records" => records.map { |r| Streams.record(r) },
617
+ "producerId" => producer_id, "producerSeq" => producer_seq&.to_s,
618
+ })), call_opts(opts))
619
+ end
620
+
621
+ # Records at and after `offset`; `.records` decodes the answer.
622
+ def fetch(stream, partition: 0, offset: 0, max_records: nil, max_bytes: nil, max_wait_ms: nil, namespace: nil, **opts)
623
+ rpc(CALLS[%w[streams fetch]], ns(namespace).merge(Builders.compact({
624
+ "stream" => stream.to_s, "partition" => partition.zero? ? nil : partition,
625
+ "offset" => offset.zero? ? nil : offset.to_s, "maxBytes" => max_bytes,
626
+ "maxRecords" => max_records, "maxWaitMs" => max_wait_ms,
627
+ })), call_opts(opts))
628
+ end
629
+
630
+ # CloudEvents (Hashes) appended as one JSON batch.
631
+ def produce_events(stream, events, partition: nil, namespace: nil, **opts)
632
+ rpc(CALLS[%w[streams produce_events]], ns(namespace).merge(Builders.compact({
633
+ "stream" => stream.to_s, "partition" => partition,
634
+ "jsonBatch" => [JSON.generate(events)].pack("m0"),
635
+ })), call_opts(opts))
636
+ end
637
+
638
+ # CloudEvents at and after `offset`, decoded from the answer's JSON batch.
639
+ def fetch_events(stream, partition: 0, offset: 0, max_bytes: nil, max_wait_ms: nil, namespace: nil, **opts)
640
+ answer = rpc(CALLS[%w[streams fetch_events]], ns(namespace).merge(Builders.compact({
641
+ "stream" => stream.to_s, "partition" => partition.zero? ? nil : partition,
642
+ "offset" => offset.zero? ? nil : offset.to_s, "maxBytes" => max_bytes, "maxWaitMs" => max_wait_ms,
643
+ })), call_opts(opts))
644
+ answer.json_batch.empty? ? [] : JSON.parse(answer.json_batch)
645
+ end
646
+
647
+ # R14: every record from `from_offset` on, as a loop over `fetch`:
648
+ # enumerate it, `cancel` it, resume from `offset`.
649
+ def subscribe(stream, partition: 0, from_offset: 0, max_records: nil, max_wait_ms: nil, namespace: nil, **opts)
650
+ Subscription.new(from_offset: from_offset) do |offset|
651
+ Streams.records(fetch(stream, partition: partition, offset: offset, max_records: max_records,
652
+ max_wait_ms: max_wait_ms, namespace: namespace, **opts))
653
+ end
654
+ end
655
+
656
+ # A `FetchResponse`'s records, decoded.
657
+ def self.records(response)
658
+ response.records.map do |r|
659
+ StreamRecord.new(
660
+ offset: r.offset, key: r.key, value: r.value,
661
+ headers: r.headers.to_h { |h| [h.key, h.value] }, timestamp_ms: r.timestamp_ms,
662
+ )
663
+ end
664
+ end
665
+
666
+ # One record as a `Record`'s JSON (bytes as base64).
667
+ def self.record(record)
668
+ if record.is_a?(Hash) && (record.key?(:value) || record.key?("value") || record.key?(:key) || record.key?("key"))
669
+ r = record.transform_keys(&:to_s)
670
+ headers = (r["headers"] || {}).map { |k, v| Builders.compact({ "key" => k.to_s, "value" => v.nil? ? nil : b64(v) }) }
671
+ Builders.compact({
672
+ "key" => r["key"].nil? ? nil : b64(r["key"]),
673
+ "value" => r["value"].nil? ? nil : b64(r["value"]),
674
+ "headers" => headers.empty? ? nil : headers,
675
+ })
676
+ else
677
+ { "value" => b64(record) }
678
+ end
679
+ end
680
+
681
+ def self.b64(value)
682
+ [value.is_a?(String) ? value : JSON.generate(value)].pack("m0")
683
+ end
684
+ end
685
+ end
686
+
687
+ # `client.collection("notes")`: every document and search call with the
688
+ # collection (and namespace) filled in. It holds no state of its own.
689
+ class CollectionHandle
690
+ attr_reader :name, :namespace
691
+
692
+ def initialize(client, name, namespace: nil)
693
+ @client = client
694
+ @name = name.to_s
695
+ @namespace = namespace
696
+ end
697
+
698
+ def upsert(docs, **kw) = @client.documents.upsert(@name, docs, **with_ns(kw))
699
+ def get(ids, **kw) = @client.documents.get(@name, ids, **with_ns(kw))
700
+ def delete(ids, **kw) = @client.documents.delete(@name, ids, **with_ns(kw))
701
+ def patch(id, **kw) = @client.documents.patch(@name, id, **with_ns(kw))
702
+ def write_batch(docs, **kw) = @client.documents.write_batch(@name, docs, **with_ns(kw))
703
+ def count(**kw) = @client.documents.count(@name, **with_ns(kw))
704
+ def scroll_all(**kw, &block) = @client.documents.scroll_all(@name, **with_ns(kw), &block)
705
+ def vector_search(vector, **kw) = @client.vector.search(@name, vector, **with_ns(kw))
706
+ def drop(**kw) = @client.collections.drop(@name, **with_ns(kw))
707
+ def info(**kw) = @client.collections.get(@name, **with_ns(kw))
708
+
709
+ # +builder+ is a `Search` (its collection is set to this one), or a block
710
+ # that receives a fresh `Search.on(name)`.
711
+ def search(builder = nil, **opts)
712
+ builder ||= Search.on(@name)
713
+ builder = builder.dup
714
+ builder.collection(@name)
715
+ builder.namespace(@namespace) if @namespace && builder.namespace_name.nil?
716
+ yield builder if block_given?
717
+ @client.search.query(builder, **opts)
718
+ end
719
+
720
+ private
721
+
722
+ def with_ns(kw)
723
+ @namespace.nil? || kw.key?(:namespace) ? kw : kw.merge(namespace: @namespace)
724
+ end
725
+ end
726
+ end