k9o 1.2.0-aarch64-linux → 1.5.0-aarch64-linux

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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +115 -1
  3. data/README.md +1575 -180
  4. data/k9o.gemspec +2 -2
  5. data/lib/k9o/_ffi/kibo_kms_rest_ffi.rb +928 -25
  6. data/lib/k9o/_ffi/libkibo_kms_rest_ffi.so +0 -0
  7. data/lib/k9o/ai.rb +15 -0
  8. data/lib/k9o/analytics.rb +403 -0
  9. data/lib/k9o/approvals.rb +253 -0
  10. data/lib/k9o/audit.rb +162 -14
  11. data/lib/k9o/client.rb +28 -9
  12. data/lib/k9o/compute.rb +32 -15
  13. data/lib/k9o/crypto.rb +471 -0
  14. data/lib/k9o/custody.rb +28 -0
  15. data/lib/k9o/data.rb +125 -5
  16. data/lib/k9o/ed25519.rb +182 -0
  17. data/lib/k9o/end_users.rb +93 -0
  18. data/lib/k9o/envelope.rb +20 -5
  19. data/lib/k9o/functions.rb +127 -9
  20. data/lib/k9o/gate.rb +52 -22
  21. data/lib/k9o/http_transport.rb +78 -16
  22. data/lib/k9o/jcs.rb +117 -0
  23. data/lib/k9o/kerror.rb +57 -5
  24. data/lib/k9o/keys.rb +131 -0
  25. data/lib/k9o/kibo.rb +3 -3
  26. data/lib/k9o/kibo_client_core.rb +385 -0
  27. data/lib/k9o/kibo_ffi_core.rb +178 -0
  28. data/lib/k9o/locate.rb +118 -0
  29. data/lib/k9o/me.rb +27 -87
  30. data/lib/k9o/metadata.rb +342 -0
  31. data/lib/k9o/moat.rb +443 -0
  32. data/lib/k9o/org.rb +81 -68
  33. data/lib/k9o/orgs.rb +1 -0
  34. data/lib/k9o/policies.rb +25 -6
  35. data/lib/k9o/projects.rb +43 -8
  36. data/lib/k9o/query.rb +174 -0
  37. data/lib/k9o/rampart.rb +273 -0
  38. data/lib/k9o/realtime.rb +261 -32
  39. data/lib/k9o/region.rb +20 -18
  40. data/lib/k9o/schedules.rb +109 -0
  41. data/lib/k9o/schema.rb +35 -0
  42. data/lib/k9o/streams.rb +94 -0
  43. data/lib/k9o/studio.rb +21 -11
  44. data/lib/k9o/url_grammar.rb +46 -20
  45. data/lib/k9o/vault.rb +79 -84
  46. data/lib/k9o/vectors.rb +124 -0
  47. data/lib/k9o/version.rb +1 -1
  48. data/lib/k9o.rb +17 -0
  49. data/sig/k9o/ai.rbs +4 -0
  50. data/sig/k9o/approvals.rbs +71 -0
  51. data/sig/k9o/datasets.rbs +125 -0
  52. data/sig/k9o/gate.rbs +9 -6
  53. data/sig/k9o/kerror.rbs +24 -0
  54. data/sig/k9o/kibo_client_core.rbs +25 -0
  55. data/sig/k9o/metadata.rbs +54 -0
  56. data/sig/k9o/moat.rbs +29 -0
  57. data/sig/k9o/query.rbs +26 -0
  58. data/sig/k9o/rampart.rbs +59 -0
  59. data/sig/k9o/realtime.rbs +34 -0
  60. data/sig/k9o/region.rbs +14 -3
  61. data/sig/k9o/workload.rbs +64 -0
  62. metadata +33 -4
@@ -0,0 +1,342 @@
1
+ # frozen_string_literal: true
2
+
3
+ module K9o
4
+ # Metadata: k9o.metadata.* filter, search and follow columns you declare on a collection (or on another data
5
+ # surface) through one API, with the same access rules as the data itself.
6
+ #
7
+ # filter(surfaces:, where:, limit:, cursor:) POST /@<slug>/metadata/query
8
+ # set(collection, id, columns) PATCH /@<slug>/metadata/collection/<collection>/<id>
9
+ # search(surface:, field:, q:, limit:) POST /@<slug>/metadata/search
10
+ # vectors.nearest(surface:, column:, embedding:, k:, ...) POST /@<slug>/metadata/vectors/nearest
11
+ # subscribe(surface, from_sequence:, on_resync:) GET /@<slug>/metadata/subscribe?surface= (SSE)
12
+ # index.get(collection) GET /@<slug>/metadata/index/<collection>
13
+ # index.declare(collection, columns) PUT /@<slug>/metadata/index/<collection>
14
+ #
15
+ # Declare the columns first (+index.declare+). +set+ writes declared columns only; +filter+, +search+ and
16
+ # +vectors.nearest+ read declared columns only. Column names given to +filter+ (+where[].field+), +search+ (+field+)
17
+ # and +vectors.nearest+ (+column+) are sent as +metadata.<name>+; the prefix is added when you leave it out. Rows
18
+ # come back with bare column names under +metadata+.
19
+ #
20
+ # Every method takes its options as keyword arguments or as one Hash (with or without braces); keys may be
21
+ # symbols or strings. A bad argument raises K9o::KError (+K9O_INVALID_FIELD_VALUE+, or +K9O_UNKNOWN_SURFACE+ for a
22
+ # surface) before anything is sent. Gateway answers arrive as K9o::KError with the gateway's code:
23
+ #
24
+ # * 400 +K9O_UNKNOWN_SURFACE+: the surface is not +collection:<name>+, +telemetry:<event>+ or +storage:<bucket>+.
25
+ # * 400 +K9O_METADATA_FIELD_NOT_INDEXED+: the column is not declared (+context["field"]+).
26
+ # * 400 +K9O_ENCRYPTED_COLUMN_OPERATION_UNSUPPORTED+: an encrypted column supports only +eq+ (not null) and +in+.
27
+ # * 400 +K9O_INVALID_FIELD_VALUE+, +K9O_INVALID_CURSOR+: a bad body field or cursor.
28
+ # * 403 +K9O_ACCESS_DENIED+, +K9O_EXTENSION_PERMISSION_DENIED+: no access to the surface.
29
+ # * 404 +K9O_COLLECTION_NOT_FOUND+ (declare on a missing collection), +K9O_NOT_FOUND+ (set on a missing record).
30
+ # * 413 +K9O_QUERY_RESULT_TOO_LARGE+: more than 10000 rows across the surfaces.
31
+ # * 501 +K9O_NOT_IMPLEMENTED+: +storage:+ surfaces are accepted by the SDK, and the gateway answers 501 for them.
32
+ class Metadata
33
+ # A page of rows: an Envelope whose +data+ is the Array of rows and whose +cursor+ is the cursor of the next
34
+ # page, or +nil+ when there is none. Each row is { "surface", "scope", "recordId" (collection) or "eventId"
35
+ # (telemetry), "metadata" }.
36
+ class Page < Envelope
37
+ def cursor
38
+ meta.cursor&.next
39
+ end
40
+ end
41
+
42
+ # Shared checks and the request helper.
43
+ module Support
44
+ OPS = %w[eq neq gt gte lt lte contains startsWith endsWith in notIn and or not].freeze
45
+ METRICS = %w[cosine euclidean dot].freeze
46
+ SLICES = %w[collection telemetry storage].freeze
47
+ SCOPE = /\A[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\z/.freeze
48
+ IDENT = /\A[a-zA-Z_][a-zA-Z0-9_]{0,62}\z/.freeze
49
+ PREFIX = 'metadata.'
50
+ MAX_COLUMNS = 64
51
+ MAX_LIMIT = 1000
52
+ # On a telemetry surface these where fields are bare, never prefixed.
53
+ TELEMETRY_FIELDS = %w[createdAt actorId].freeze
54
+
55
+ private
56
+
57
+ def invalid(message, context = {})
58
+ ApprovalChecks.invalid(message, context)
59
+ end
60
+
61
+ def route(tail)
62
+ UrlGrammar.project_path(Studio.require_project_slug(@config, 'metadata'), "metadata/#{tail}")
63
+ end
64
+
65
+ def segment(value)
66
+ ::URI.encode_www_form_component(value).gsub('+', '%20')
67
+ end
68
+
69
+ def call(method, path, body = nil)
70
+ HttpTransport.request(config: @config, method: method, path: path, body: body)
71
+ end
72
+
73
+ def page(envelope)
74
+ Page.new(data: envelope.data, meta: envelope.meta)
75
+ end
76
+
77
+ # The options as a Hash with string keys, from keyword arguments, one Hash, or both.
78
+ def options(input, extra, allowed)
79
+ raise invalid('options must be a Hash') unless input.nil? || input.is_a?(Hash)
80
+
81
+ opts = {}
82
+ (input || {}).merge(extra).each { |key, value| opts[key.to_s] = value }
83
+ unknown = opts.keys - allowed
84
+ raise invalid("unknown option: #{unknown.join(', ')}", options: unknown) unless unknown.empty?
85
+
86
+ opts
87
+ end
88
+
89
+ def slice_of(surface)
90
+ surface[0, surface.index(':')]
91
+ end
92
+
93
+ def check_surface(surface, name)
94
+ raise invalid("#{name} must be a string such as collection:posts", surface: surface) unless surface.is_a?(String)
95
+
96
+ at = surface.index(':')
97
+ slice = at ? surface[0, at] : ''
98
+ scope = at ? surface[(at + 1)..] : ''
99
+ unless SLICES.include?(slice) && SCOPE.match?(scope) && (slice != 'collection' || IDENT.match?(scope))
100
+ raise KError.new(
101
+ code: 'K9O_UNKNOWN_SURFACE',
102
+ message: "#{name} \"#{surface}\" is not a surface: use collection:<name>, telemetry:<event> or storage:<bucket>",
103
+ context: { surface: surface }
104
+ )
105
+ end
106
+ surface
107
+ end
108
+
109
+ def check_collection_surface(surface, name)
110
+ check_surface(surface, name)
111
+ raise invalid("#{name} must be a collection surface (collection:<name>)", surface: surface) unless slice_of(surface) == 'collection'
112
+
113
+ surface
114
+ end
115
+
116
+ def check_limit(value, name)
117
+ return if value.nil?
118
+ return if value.is_a?(Integer) && value >= 1 && value <= MAX_LIMIT
119
+
120
+ raise invalid("#{name} must be a whole number from 1 to #{MAX_LIMIT}", name.to_sym => value)
121
+ end
122
+
123
+ # The name on the wire: metadata.<name>, adding the prefix when it is missing.
124
+ def prefixed(name, label)
125
+ name = name.to_s if name.is_a?(Symbol)
126
+ raise invalid("#{label} must be a column name", label.to_sym => name) if !name.is_a?(String) || name.empty? || name == PREFIX
127
+
128
+ name.start_with?(PREFIX) ? name : "#{PREFIX}#{name}"
129
+ end
130
+
131
+ def wire_where(where, telemetry)
132
+ return nil if where.nil?
133
+ raise invalid('where must be an array of { field, op, value }') unless where.is_a?(Array)
134
+
135
+ where.each_with_index.map do |clause, index|
136
+ raise invalid("where[#{index}] must be a Hash") unless clause.is_a?(Hash)
137
+
138
+ c = clause.each_with_object({}) { |(key, value), out| out[key.to_s] = value }
139
+ c['op'] = c['op'].to_s if c['op'].is_a?(Symbol)
140
+ unless c['op'].is_a?(String) && OPS.include?(c['op'])
141
+ raise invalid("where[#{index}].op must be one of: #{OPS.join(', ')}", op: c['op'])
142
+ end
143
+
144
+ field = c['field'].is_a?(Symbol) ? c['field'].to_s : c['field']
145
+ bare = telemetry && field.is_a?(String) && TELEMETRY_FIELDS.include?(field)
146
+ c['field'] = bare ? field : prefixed(field, "where[#{index}].field")
147
+ c
148
+ end
149
+ end
150
+
151
+ def collection_name(collection)
152
+ collection = collection.to_s if collection.is_a?(Symbol)
153
+ raise invalid('collection must be a collection name', collection: collection) unless collection.is_a?(String) && IDENT.match?(collection)
154
+
155
+ collection
156
+ end
157
+
158
+ def finite?(value)
159
+ value.is_a?(Numeric) && !value.is_a?(Complex) && (!value.respond_to?(:finite?) || value.finite?)
160
+ end
161
+ end
162
+
163
+ include Support
164
+
165
+ attr_reader :vectors, :index
166
+
167
+ def initialize(config)
168
+ @config = config
169
+ @vectors = MetadataVectors.new(config)
170
+ @index = MetadataIndex.new(config)
171
+ end
172
+
173
+ # Rows of one or more surfaces whose declared columns meet +where+, in request order. +surfaces+ is a non-empty
174
+ # Array of +collection:<name>+, +telemetry:<event-prefix>+ or +storage:<bucket>+. +where+ is an Array of
175
+ # { field:, op:, value: } applied to every surface (+op+ is one of eq, neq, gt, gte, lt, lte, contains, startsWith,
176
+ # endsWith, in, notIn; and, or and not are part of the grammar and refused by the gateway). On a telemetry surface
177
+ # +createdAt+ (gt, gte, lt, lte) and +actorId+ (eq) are sent as they are. +limit+ is 1 to 1000 per surface (the
178
+ # gateway defaults to 100). +cursor+ is the +cursor+ of the previous page and is allowed with exactly one surface.
179
+ # More than 10000 rows in all answers 413 K9O_QUERY_RESULT_TOO_LARGE; a +storage:+ surface answers 501
180
+ # K9O_NOT_IMPLEMENTED. Returns a Page.
181
+ def filter(input = nil, **extra)
182
+ path = route('query')
183
+ opts = options(input, extra, %w[surfaces where limit cursor])
184
+ surfaces = opts['surfaces']
185
+ raise invalid('surfaces must be a non-empty array') unless surfaces.is_a?(Array) && !surfaces.empty?
186
+
187
+ surfaces = surfaces.each_with_index.map { |s, i| check_surface(s, "surfaces[#{i}]") }
188
+ check_limit(opts['limit'], 'limit')
189
+ unless opts['cursor'].nil?
190
+ raise invalid('cursor must be the cursor of a previous page') unless opts['cursor'].is_a?(String) && !opts['cursor'].empty?
191
+ raise invalid('cursor is allowed only with exactly one surface', field: 'cursor') unless surfaces.size == 1
192
+ end
193
+ where = wire_where(opts['where'], surfaces.any? { |s| slice_of(s) == 'telemetry' })
194
+ body = { 'surfaces' => surfaces }
195
+ body['where'] = where unless where.nil?
196
+ body['limit'] = opts['limit'] unless opts['limit'].nil?
197
+ body['cursor'] = opts['cursor'] unless opts['cursor'].nil?
198
+ page(call('POST', path, body))
199
+ end
200
+
201
+ # Write declared columns of one record: <tt>set("posts", "p2", title: "New", priority: 9)</tt> or with a Hash.
202
+ # Keys are bare column names (a leading +metadata.+ is dropped); a column that is not declared answers 400
203
+ # K9O_METADATA_FIELD_NOT_INDEXED. +data+ is the whole updated record. The write runs through the record API's
204
+ # rules, so a write that needs approval is queued as it is there.
205
+ def set(collection, id, columns = nil, **extra)
206
+ name = collection_name(collection)
207
+ raise invalid('id must be a record id', id: id) unless id.is_a?(String) && !id.empty?
208
+ raise invalid('columns must be a Hash with at least one column') unless columns.nil? || columns.is_a?(Hash)
209
+
210
+ merged = (columns || {}).merge(extra)
211
+ raise invalid('columns must be a Hash with at least one column') if merged.empty?
212
+
213
+ bare = merged.each_with_object({}) do |(key, value), out|
214
+ key = key.to_s
215
+ out[key.start_with?(PREFIX) ? key[PREFIX.size..] : key] = value
216
+ end
217
+ call('PATCH', route("collection/#{segment(name)}/#{segment(id)}"), 'metadata' => bare)
218
+ end
219
+
220
+ # Full-text search over one declared text column of a collection: +surface:+ (+collection:<name>+), +field:+
221
+ # (+body+ or +metadata.body+), +q:+ (the words to look for) and optional +limit:+ (1 to 1000, the gateway
222
+ # defaults to 50). Matching is by word stem, so "finance" does not match "financial". Returns the first page
223
+ # only (there is no cursor input) and no rank. Returns a Page.
224
+ def search(input = nil, **extra)
225
+ path = route('search')
226
+ opts = options(input, extra, %w[surface field q limit])
227
+ surface = check_collection_surface(opts['surface'], 'surface')
228
+ field = prefixed(opts['field'], 'field')
229
+ raise invalid('q must be a non-empty string') unless opts['q'].is_a?(String) && !opts['q'].empty?
230
+
231
+ check_limit(opts['limit'], 'limit')
232
+ body = { 'surface' => surface, 'field' => field, 'q' => opts['q'] }
233
+ body['limit'] = opts['limit'] unless opts['limit'].nil?
234
+ page(call('POST', path, body))
235
+ end
236
+
237
+ # Follow changes of a +collection:<name>+ surface live. Returns an Enumerator of events whose +eventType+ is
238
+ # +row_inserted+, +row_updated+ or +row_deleted+ and whose +payload+ is { "collection", "id" } only, so read the
239
+ # record by id for its values. It is the reader behind Realtime#subscribe: it reconnects with +Last-Event-ID+,
240
+ # drops events it already delivered and calls +on_resync+ (a callable, may be nil) when the position is lost.
241
+ # +from_sequence+ (a whole number, 0 or more) starts from a known feed position; it applies only to the first
242
+ # connection. Changes to undeclared columns are reported too.
243
+ def subscribe(surface, input = nil, **extra)
244
+ path = route('subscribe')
245
+ opts = {}
246
+ raise invalid('options must be a Hash') unless input.nil? || input.is_a?(Hash)
247
+
248
+ (input || {}).merge(extra).each { |key, value| opts[key.to_s] = value }
249
+ from = opts.key?('from_sequence') ? opts['from_sequence'] : opts['fromSequence']
250
+ unknown = opts.keys - %w[from_sequence fromSequence on_resync]
251
+ raise invalid("unknown option: #{unknown.join(', ')}", options: unknown) unless unknown.empty?
252
+
253
+ target = check_collection_surface(surface, 'surface')
254
+ unless from.nil? || (from.is_a?(Integer) && from >= 0)
255
+ raise invalid('from_sequence must be a whole number, 0 or more', from_sequence: from)
256
+ end
257
+ Realtime.new(@config).feed(path, { 'surface' => target }, from.nil? ? nil : { 'fromSequence' => from }, opts['on_resync'])
258
+ end
259
+ end
260
+
261
+ # client.metadata.vectors
262
+ class MetadataVectors
263
+ include Metadata::Support
264
+
265
+ def initialize(config)
266
+ @config = config
267
+ end
268
+
269
+ # The rows whose declared vector column is nearest to +embedding+, closest first: +surface:+ (+collection:<name>+),
270
+ # +column:+ (+embedding+ or +metadata.embedding+), +embedding:+ (a non-empty Array of finite numbers), optional
271
+ # +k:+ (1 to 1000, the gateway defaults to 10), +metric:+ (+cosine+ default, +euclidean+, +dot+) and +where:+
272
+ # (conditions in the grammar of Metadata#filter). Rows carry no score. At most 50 rows come back whatever +k+
273
+ # is, until the gateway forwards a larger page size. Returns a Metadata::Page.
274
+ def nearest(input = nil, **extra)
275
+ path = route('vectors/nearest')
276
+ opts = options(input, extra, %w[surface column embedding k metric where])
277
+ surface = check_collection_surface(opts['surface'], 'surface')
278
+ column = prefixed(opts['column'], 'column')
279
+ embedding = opts['embedding']
280
+ raise invalid('embedding must be a non-empty array of numbers') unless embedding.is_a?(Array) && !embedding.empty?
281
+ raise invalid('embedding must contain only finite numbers') unless embedding.all? { |n| finite?(n) }
282
+
283
+ check_limit(opts['k'], 'k')
284
+ metric = opts['metric']
285
+ metric = metric.to_s if metric.is_a?(Symbol)
286
+ raise invalid("metric must be one of: #{METRICS.join(', ')}", metric: metric) unless metric.nil? || METRICS.include?(metric)
287
+
288
+ where = wire_where(opts['where'], false)
289
+ body = { 'surface' => surface, 'column' => column, 'embedding' => embedding }
290
+ body['k'] = opts['k'] unless opts['k'].nil?
291
+ body['metric'] = metric unless metric.nil?
292
+ body['where'] = where unless where.nil?
293
+ page(call('POST', path, body))
294
+ end
295
+ end
296
+
297
+ # client.metadata.index
298
+ class MetadataIndex
299
+ include Metadata::Support
300
+
301
+ def initialize(config)
302
+ @config = config
303
+ end
304
+
305
+ # The columns declared on a collection, ordered by name. +data+ is { "collection", "columns" => [{ "column",
306
+ # "indexed" }] }.
307
+ def get(collection)
308
+ name = collection_name(collection)
309
+ call('GET', route("index/#{segment(name)}"))
310
+ end
311
+
312
+ # Declare the columns of a collection that metadata may use: an Array of { column:, indexed: } (+indexed+
313
+ # defaults to false). The columns must already exist on the collection. The list replaces the previous declaration
314
+ # (an empty list clears it); at most 64 columns, no +id+, no duplicates. Needs the project-admin role and the
315
+ # migrations permission.
316
+ def declare(collection, columns)
317
+ name = collection_name(collection)
318
+ raise invalid('columns must be an array of { column, indexed? }') unless columns.is_a?(Array)
319
+ raise invalid("at most #{MAX_COLUMNS} columns can be declared", count: columns.size) if columns.size > MAX_COLUMNS
320
+
321
+ seen = {}
322
+ wire = columns.each_with_index.map do |entry, index|
323
+ raise invalid("columns[#{index}] must be a Hash") unless entry.is_a?(Hash)
324
+
325
+ c = entry.each_with_object({}) { |(key, value), out| out[key.to_s] = value }
326
+ column = c['column'].is_a?(Symbol) ? c['column'].to_s : c['column']
327
+ unless column.is_a?(String) && IDENT.match?(column) && column != 'id'
328
+ raise invalid("columns[#{index}].column must be a column name other than id", column: c['column'])
329
+ end
330
+ unless c['indexed'].nil? || c['indexed'] == true || c['indexed'] == false
331
+ raise invalid("columns[#{index}].indexed must be true or false")
332
+ end
333
+ raise invalid("column \"#{column}\" is listed twice", column: column) if seen[column]
334
+
335
+ seen[column] = true
336
+ c['column'] = column
337
+ c
338
+ end
339
+ call('PUT', route("index/#{segment(name)}"), 'columns' => wire)
340
+ end
341
+ end
342
+ end