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
Binary file
data/lib/k9o/ai.rb CHANGED
@@ -7,6 +7,8 @@ module K9o
7
7
  # Wire (project-plane):
8
8
  # POST /@<slug>/ai/embeddings body { model?, input: String | [String] }
9
9
  # -> { data: { embeddings: [[Float]], model:, dimensions: }, meta }
10
+ # POST /@<slug>/ai/generate body { prompt:, model?, maxTokens?, temperature?, usageType? }
11
+ # -> { data: { modelId:, content:, finishReason:, usage:, usageType: }, meta }
10
12
  #
11
13
  # Bounds are enforced by the gateway, not here. The SDK sends `input`
12
14
  # exactly as given and never puts it in an error.
@@ -24,5 +26,18 @@ module K9o
24
26
  HttpTransport.request(config: @config, method: 'POST',
25
27
  path: UrlGrammar.project_path(slug, 'ai/embeddings'), body: body)
26
28
  end
29
+
30
+ # POST /@<slug>/ai/generate — one synchronous text generation (verb sugar over compute.llms.invoke).
31
+ # Only the fields given are sent; without `model` the gateway uses the org's default LLM.
32
+ def generate(prompt, model: nil, max_tokens: nil, temperature: nil, usage_type: nil)
33
+ slug = Studio.require_project_slug(@config, 'ai')
34
+ body = { 'prompt' => prompt }
35
+ body['model'] = model unless model.nil?
36
+ body['maxTokens'] = max_tokens unless max_tokens.nil?
37
+ body['temperature'] = temperature unless temperature.nil?
38
+ body['usageType'] = usage_type unless usage_type.nil?
39
+ HttpTransport.request(config: @config, method: 'POST',
40
+ path: UrlGrammar.project_path(slug, 'ai/generate'), body: body)
41
+ end
27
42
  end
28
43
  end
@@ -0,0 +1,403 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+ require 'stringio'
6
+
7
+ module K9o
8
+ # Shared plumbing for the dataset namespaces.
9
+ module DatasetSupport
10
+ REATTEST_DAYS = [7, 30, 90, 365].freeze
11
+ ON_ERROR = %w[skip reject].freeze
12
+ UPLOAD_FORMATS = %w[csv json].freeze
13
+ MAX_UPLOAD_BYTES = 25 * 1024 * 1024
14
+ RESIDENCY = %w[per-subject shared].freeze
15
+
16
+ private
17
+
18
+ def invalid(message, context = {})
19
+ ApprovalChecks.invalid(message, context)
20
+ end
21
+
22
+ def seg(value, what)
23
+ ApprovalChecks.id_segment(value, what)
24
+ end
25
+
26
+ def project
27
+ Studio.require_project_slug(@config, 'datasets')
28
+ end
29
+
30
+ def route(tail)
31
+ UrlGrammar.project_path(project, tail)
32
+ end
33
+
34
+ def call(method, tail, body: nil, query: nil, **extra)
35
+ HttpTransport.request(config: @config, method: method, path: route(tail), body: body, query: query, **extra)
36
+ end
37
+
38
+ def stringify(value)
39
+ case value
40
+ when Hash then value.each_with_object({}) { |(k, v), out| out[k.to_s] = stringify(v) }
41
+ when Array then value.map { |v| stringify(v) }
42
+ when Symbol then value.to_s
43
+ else value
44
+ end
45
+ end
46
+
47
+ def reattest_days!(value)
48
+ return nil if value.nil?
49
+ return value if REATTEST_DAYS.include?(value)
50
+
51
+ raise invalid("reattest_interval_days must be one of #{REATTEST_DAYS.join(', ')}", reattest_interval_days: value)
52
+ end
53
+ end
54
+
55
+ # client.analytics: datasets. Routes under /@<slug>/analytics.
56
+ #
57
+ # list GET analytics
58
+ # query(sql, datasets) POST analytics/query
59
+ # create(slug, display_name:, columns:, residency_class:, ...) POST analytics/<slug>
60
+ # get(slug) / delete(slug) GET / DELETE analytics/<slug>
61
+ # materialize(slug, from_collection:|from_query:) POST analytics/<slug>/materialize
62
+ # read(slug, partition_filter:, cursor:, limit:) POST analytics/<slug>/read
63
+ # write(slug, rows, on_error:) POST analytics/<slug>/write
64
+ # upload(slug, data, format:, ...) POST analytics/<slug>/upload
65
+ # download_original(slug) GET analytics/<slug>/upload/original (bytes)
66
+ # rollups / schedules see AnalyticsRollups and AnalyticsSchedules
67
+ #
68
+ # Every call needs project membership with the matching datasets permission; service, workload and extension
69
+ # tokens are refused. While the region's analytics storage is not provisioned, queries and writes answer 503
70
+ # K9O_ANALYTICS_UNAVAILABLE (raised as K9o::KError).
71
+ class Analytics
72
+ include DatasetSupport
73
+
74
+ attr_reader :rollups, :schedules
75
+
76
+ def initialize(config)
77
+ @config = config
78
+ @rollups = AnalyticsRollups.new(config)
79
+ @schedules = AnalyticsSchedules.new(config)
80
+ end
81
+
82
+ # The project's active datasets, newest first.
83
+ def list
84
+ call('GET', 'analytics')
85
+ end
86
+
87
+ # Run one read-only SQL SELECT over datasets. Reference each as +dataset.<slug>+ and name it in +datasets+ (1 to
88
+ # 64 slugs). +sql+ is at most 100,000 characters. +data+ holds the rows.
89
+ def query(sql, datasets)
90
+ raise invalid('sql must be a non-empty String') unless sql.is_a?(String) && !sql.strip.empty?
91
+ raise invalid('sql must be at most 100000 characters', length: sql.length) if sql.length > 100_000
92
+ unless datasets.is_a?(Array) && !datasets.empty? && datasets.size <= 64 &&
93
+ datasets.all? { |d| d.is_a?(String) && !d.empty? }
94
+ raise invalid('datasets must be an Array of 1 to 64 dataset slugs')
95
+ end
96
+
97
+ call('POST', 'analytics/query', body: { 'sql' => sql, 'datasets' => datasets })
98
+ end
99
+
100
+ # Create a dataset at +slug+. +columns+ is an Array of { name:, type: } (1 to 512; types string, int32, int64,
101
+ # float64, bool, date, timestamp, json, bytes, decimal(p,s)). +residency_class+ is "per-subject" or "shared".
102
+ # Optional +partition_by+ (up to 16 keys; a timestamp key needs a transform such as "created_at:day") and
103
+ # +storage_target+ ({ kind: "managed" } or { kind: "byos", binding_id: ... } spelled { kind:, bindingId: }).
104
+ def create(slug, display_name:, columns:, residency_class:, partition_by: nil, storage_target: nil)
105
+ unless display_name.is_a?(String) && !display_name.empty? && display_name.length <= 200
106
+ raise invalid('display_name must be a String of 1 to 200 characters')
107
+ end
108
+ unless columns.is_a?(Array) && !columns.empty? && columns.size <= 512 && columns.all? { |c| c.is_a?(Hash) }
109
+ raise invalid('columns must be an Array of 1 to 512 { name:, type: } Hashes')
110
+ end
111
+
112
+ cls = residency_class.to_s
113
+ raise invalid("residency_class must be one of #{RESIDENCY.join(', ')}", residency_class: residency_class) unless RESIDENCY.include?(cls)
114
+
115
+ body = { 'displayName' => display_name, 'columns' => stringify(columns), 'residencyClass' => cls }
116
+ unless partition_by.nil?
117
+ raise invalid('partition_by must be an Array of at most 16 strings') unless partition_by.is_a?(Array) && partition_by.size <= 16
118
+
119
+ body['partitionBy'] = partition_by
120
+ end
121
+ body['storageTarget'] = stringify(storage_target) unless storage_target.nil?
122
+ call('POST', "analytics/#{seg(slug, 'dataset')}", body: body)
123
+ end
124
+
125
+ def get(slug)
126
+ call('GET', "analytics/#{seg(slug, 'dataset')}")
127
+ end
128
+
129
+ # Retire the dataset: it reads as absent from then on and its slug is never reissued.
130
+ def delete(slug)
131
+ call('DELETE', "analytics/#{seg(slug, 'dataset')}")
132
+ end
133
+
134
+ # Copy rows in from exactly one source: +from_collection+ (a collection name) or +from_query+ (a saved Studio
135
+ # query id that reads one collection). +data+ has rowsRead, ingested and rejected.
136
+ def materialize(slug, from_collection: nil, from_query: nil)
137
+ if from_collection.nil? == from_query.nil?
138
+ raise invalid('give exactly one of from_collection or from_query')
139
+ end
140
+
141
+ body = {}
142
+ body['fromCollection'] = from_collection unless from_collection.nil?
143
+ body['fromQuery'] = from_query unless from_query.nil?
144
+ call('POST', "analytics/#{seg(slug, 'dataset')}/materialize", body: body)
145
+ end
146
+
147
+ # One page of rows. Each row has a stable +_k9o_row_id+; +meta.cursor.next+ is the +cursor:+ of the next page and
148
+ # nil on the last. The first page carries +meta.schema+.
149
+ def read(slug, partition_filter: nil, cursor: nil, limit: nil)
150
+ body = {}
151
+ body['partitionFilter'] = stringify(partition_filter) unless partition_filter.nil?
152
+ body['cursor'] = cursor unless cursor.nil?
153
+ unless limit.nil?
154
+ raise invalid('limit must be a whole number of 1 or more', limit: limit) unless limit.is_a?(Integer) && limit >= 1
155
+
156
+ body['limit'] = limit
157
+ end
158
+ call('POST', "analytics/#{seg(slug, 'dataset')}/read", body: body)
159
+ end
160
+
161
+ # Append rows. +rows+ is an Array of Hashes (sent as a JSON array) or a String of newline-delimited JSON. A bad
162
+ # row is skipped and reported (+on_error: "skip"+, the default) or refuses the whole write ("reject").
163
+ # +data+ has ingested, skipped, skippedDetails, the partitions touched and the bytes written.
164
+ def write(slug, rows, on_error: nil)
165
+ query = on_error_query(on_error)
166
+ path = "analytics/#{seg(slug, 'dataset')}/write"
167
+ if rows.is_a?(String)
168
+ call('POST', path, query: query, raw_body: rows, headers: { 'content-type' => 'application/x-ndjson' })
169
+ elsif rows.is_a?(Array) && rows.all? { |r| r.is_a?(Hash) }
170
+ call('POST', path, query: query, body: stringify(rows))
171
+ else
172
+ raise invalid('rows must be an Array of Hashes or a String of newline-delimited JSON')
173
+ end
174
+ end
175
+
176
+ # Upload a CSV or JSON file (a String of bytes or an IO; up to 25 MiB). +format+ is "csv" or "json" (required).
177
+ # +dry_run: true+ returns the detected columns and a 50-row preview without writing. +manifest+ (a Hash, same
178
+ # fields as #create) on a slug that does not exist yet creates the dataset and loads the file in one call; the
179
+ # file then goes as multipart with +filename+. CSV options: +delimiter+, +encoding+, +has_header+.
180
+ def upload(slug, data, format:, filename: 'upload', manifest: nil, dry_run: nil, on_error: nil, delimiter: nil,
181
+ encoding: nil, has_header: nil)
182
+ fmt = format.to_s
183
+ raise invalid("format must be one of #{UPLOAD_FORMATS.join(', ')}", format: format) unless UPLOAD_FORMATS.include?(fmt)
184
+
185
+ bytes = data.respond_to?(:read) ? data.read : data
186
+ raise invalid('data must be a String or an IO') unless bytes.is_a?(String)
187
+ raise invalid("the file is over #{MAX_UPLOAD_BYTES} bytes", size: bytes.bytesize) if bytes.bytesize > MAX_UPLOAD_BYTES
188
+
189
+ query = { 'format' => fmt }
190
+ query['dryRun'] = dry_run.to_s unless dry_run.nil?
191
+ query.merge!(on_error_query(on_error))
192
+ query['delimiter'] = delimiter unless delimiter.nil?
193
+ query['encoding'] = encoding unless encoding.nil?
194
+ query['hasHeader'] = has_header.to_s unless has_header.nil?
195
+ path = "analytics/#{seg(slug, 'dataset')}/upload"
196
+ mime = fmt == 'csv' ? 'text/csv' : 'application/json'
197
+ if manifest.nil?
198
+ call('POST', path, query: query, raw_body: bytes, headers: { 'content-type' => mime })
199
+ else
200
+ raise invalid('manifest must be a Hash') unless manifest.is_a?(Hash)
201
+
202
+ parts = [{ name: 'file', value: bytes, filename: filename, content_type: mime },
203
+ { name: 'manifest', value: JSON.dump(stringify(manifest)), content_type: 'application/json' }]
204
+ call('POST', path, query: query, multipart: parts)
205
+ end
206
+ end
207
+
208
+ # The original file of the dataset's stored upload. Answers a K9o::Download (+body+ bytes, +content_type+),
209
+ # not an Envelope; 404 when none is stored.
210
+ def download_original(slug)
211
+ call('GET', "analytics/#{seg(slug, 'dataset')}/upload/original", binary: true)
212
+ end
213
+
214
+ private
215
+
216
+ def on_error_query(on_error)
217
+ return {} if on_error.nil?
218
+
219
+ { 'onError' => ApprovalChecks.enum!(on_error, ON_ERROR, 'on_error') }
220
+ end
221
+ end
222
+
223
+ # client.analytics.rollups: continuous rollups that fold a stream into a dataset over fixed windows.
224
+ #
225
+ # A rollup keeps running only while a person re-attests it within +reattest_interval_days+ (7, 30, 90 or 365;
226
+ # 90 by default). Without #reattest it pauses when the interval ends (it is paused, not deleted).
227
+ class AnalyticsRollups
228
+ include DatasetSupport
229
+
230
+ AGGS = %w[count last sum avg min max count_distinct p50 p95 p99].freeze
231
+
232
+ def initialize(config)
233
+ @config = config
234
+ end
235
+
236
+ # Create a rollup on +dataset_slug+. +slug+ names it, +source+ is the stream it folds, +measures+ is an Array of
237
+ # { name:, agg:, column: } (up to 64; +agg+ is count, last, sum, avg, min, max, count_distinct, p50, p95 or p99;
238
+ # +column+ is not used by count), +dimensions+ up to 16 column names, +window_seconds+ at least 60. Give exactly
239
+ # one of +subject_field+ (per-subject data, suppressed below the cohort threshold) or +non_personal: true+.
240
+ def create(dataset_slug, slug:, source:, measures:, window_seconds:, dimensions: nil, subject_field: nil,
241
+ non_personal: nil, reattest_interval_days: nil)
242
+ raise invalid('slug must be a String of 1 to 63 characters') unless slug.is_a?(String) && !slug.empty? && slug.length <= 63
243
+ raise invalid('source must be a non-empty String') unless source.is_a?(String) && !source.empty?
244
+ unless measures.is_a?(Array) && !measures.empty? && measures.size <= 64 && measures.all? { |m| m.is_a?(Hash) }
245
+ raise invalid('measures must be an Array of 1 to 64 { name:, agg:, column: } Hashes')
246
+ end
247
+ unless window_seconds.is_a?(Integer) && window_seconds >= 60
248
+ raise invalid('window_seconds must be a whole number of 60 or more', window_seconds: window_seconds)
249
+ end
250
+ if dimensions && !(dimensions.is_a?(Array) && dimensions.size <= 16)
251
+ raise invalid('dimensions must be an Array of at most 16 column names')
252
+ end
253
+ if subject_field.nil? == (non_personal != true)
254
+ raise invalid('give exactly one of subject_field or non_personal: true')
255
+ end
256
+
257
+ body = { 'slug' => slug, 'source' => source, 'measures' => stringify(measures), 'windowSeconds' => window_seconds }
258
+ body['dimensions'] = dimensions unless dimensions.nil?
259
+ body['subjectField'] = subject_field unless subject_field.nil?
260
+ body['nonPersonal'] = true if non_personal == true
261
+ days = reattest_days!(reattest_interval_days)
262
+ body['reattestIntervalDays'] = days unless days.nil?
263
+ call('POST', "analytics/#{seg(dataset_slug, 'dataset')}/rollups", body: body)
264
+ end
265
+
266
+ # The dataset's rollups with attestation state: +attestedAt+, +expiresAt+ and +attestationStatus+ ("active",
267
+ # "expiring_soon" or "expired").
268
+ def list(dataset_slug)
269
+ call('GET', "analytics/#{seg(dataset_slug, 'dataset')}/rollups")
270
+ end
271
+
272
+ def delete(dataset_slug, rollup_id)
273
+ call('DELETE', "analytics/#{seg(dataset_slug, 'dataset')}/rollups/#{seg(rollup_id, 'rollup')}")
274
+ end
275
+
276
+ # Confirm the rollup is still wanted: resets its attestation to now, optionally with a new interval, and resumes
277
+ # an expired rollup. Only a person can do this: a service, workload or extension token gets 403 K9O_RBAC_DENY
278
+ # with context reason "human_only" (raised as K9o::KError); a personal access token acts as its person.
279
+ def reattest(dataset_slug, rollup_id, reattest_interval_days: nil)
280
+ body = {}
281
+ days = reattest_days!(reattest_interval_days)
282
+ body['reattestIntervalDays'] = days unless days.nil?
283
+ call('POST', "analytics/#{seg(dataset_slug, 'dataset')}/rollups/#{seg(rollup_id, 'rollup')}/reattest", body: body)
284
+ end
285
+ end
286
+
287
+ # client.analytics.schedules: recurring materialization from a collection into a dataset. A schedule keeps running
288
+ # only while a person re-attests it within +reattest_interval_days+; see #reattest.
289
+ class AnalyticsSchedules
290
+ include DatasetSupport
291
+
292
+ def initialize(config)
293
+ @config = config
294
+ end
295
+
296
+ # +interval_seconds+ is at least 60. Each run reads the collection with its creator's current access.
297
+ def create(dataset_slug, from_collection:, interval_seconds:, reattest_interval_days: nil)
298
+ unless from_collection.is_a?(String) && !from_collection.empty?
299
+ raise invalid('from_collection must be a non-empty String')
300
+ end
301
+ unless interval_seconds.is_a?(Integer) && interval_seconds >= 60
302
+ raise invalid('interval_seconds must be a whole number of 60 or more', interval_seconds: interval_seconds)
303
+ end
304
+
305
+ body = { 'fromCollection' => from_collection, 'intervalSeconds' => interval_seconds }
306
+ days = reattest_days!(reattest_interval_days)
307
+ body['reattestIntervalDays'] = days unless days.nil?
308
+ call('POST', "analytics/#{seg(dataset_slug, 'dataset')}/schedules", body: body)
309
+ end
310
+
311
+ # The dataset's schedules with +lastRunAt+, +lastRunStatus+, +nextRunAt+ and attestation state.
312
+ def list(dataset_slug)
313
+ call('GET', "analytics/#{seg(dataset_slug, 'dataset')}/schedules")
314
+ end
315
+
316
+ def delete(dataset_slug, schedule_id)
317
+ call('DELETE', "analytics/#{seg(dataset_slug, 'dataset')}/schedules/#{seg(schedule_id, 'schedule')}")
318
+ end
319
+
320
+ # Confirm the schedule is still wanted (a paused schedule resumes). Only a person can do this: a service,
321
+ # workload or extension token gets 403 K9O_RBAC_DENY with context reason "human_only" (raised as K9o::KError).
322
+ def reattest(dataset_slug, schedule_id, reattest_interval_days: nil)
323
+ body = {}
324
+ days = reattest_days!(reattest_interval_days)
325
+ body['reattestIntervalDays'] = days unless days.nil?
326
+ call('POST', "analytics/#{seg(dataset_slug, 'dataset')}/schedules/#{seg(schedule_id, 'schedule')}/reattest", body: body)
327
+ end
328
+ end
329
+
330
+ # client.rollups: read the project's rollups.
331
+ #
332
+ # list GET /@<slug>/rollups
333
+ # read(rollup_slug, from:, to:, dims:) GET /@<slug>/rollups/<slug>/read
334
+ # subscribe(rollup_slug, from_sequence:, on_resync:) GET .../subscribe (server-sent events)
335
+ class Rollups
336
+ include DatasetSupport
337
+
338
+ def initialize(config)
339
+ @config = config
340
+ end
341
+
342
+ # Every rollup in the project whose dataset you can read, with its read and subscribe endpoints.
343
+ def list
344
+ call('GET', 'rollups')
345
+ end
346
+
347
+ # The rollup's cells for a time range. +from+ and +to+ are ISO 8601 strings or Times (the gateway defaults to the
348
+ # last 24 hours; at most 50,000 cells). +dims+ is an Array or comma-separated String projecting dimensions (only
349
+ # count, sum, min and max measures combine). A cell below the cohort threshold is { "status" => "suppressed",
350
+ # "reason" => "cohort_below_k" }; +coverage+ says whether the range is complete.
351
+ def read(rollup_slug, from: nil, to: nil, dims: nil)
352
+ query = {}
353
+ query['from'] = time_text(from) unless from.nil?
354
+ query['to'] = time_text(to) unless to.nil?
355
+ query['dims'] = dims.is_a?(Array) ? dims.join(',') : dims unless dims.nil?
356
+ call('GET', "rollups/#{seg(rollup_slug, 'rollup')}/read", query: query)
357
+ end
358
+
359
+ # An Enumerator of closed cells, one per +cell+ frame. A frame wraps the cell as its +payload+; each cell
360
+ # yielded is { "cellKey", "bucket", "dims", "final", "measures", "suppressed", "eventId" }. A cell below the cohort
361
+ # threshold has no values: it is { "cellKey", "bucket", "status" => "suppressed", "reason" => "cohort_below_k",
362
+ # "suppressed" => true }; for every other cell +suppressed+ is false. +eventId+ is the publisher's id for the frame.
363
+ #
364
+ # It reconnects with +Last-Event-ID+ (the frame's SSE +id+, the resume cursor), drops cells it already delivered
365
+ # by +eventId+ (a resume re-sends the last 5 seconds), ignores the +:hb+ heartbeat (every 15 seconds) and reopens
366
+ # when the gateway ends the connection (after at most 300 seconds). On +event: resync+ (action +reread-source+:
367
+ # the position is lost, so re-read the rollup with #read) it calls +on_resync+ with { "reason", "action" } and
368
+ # continues from a fresh connection. +from_sequence+ starts from a known position on the first connection. The
369
+ # stream ends when the access token expires (the next connection is then refused 401). Stop with +break+ or
370
+ # +first(n)+.
371
+ def subscribe(rollup_slug, from_sequence: nil, on_resync: nil)
372
+ unless from_sequence.nil? || (from_sequence.is_a?(Integer) && from_sequence >= 0)
373
+ raise invalid('from_sequence must be a whole number, 0 or more', from_sequence: from_sequence)
374
+ end
375
+
376
+ path = route("rollups/#{seg(rollup_slug, 'rollup')}/subscribe")
377
+ decode = lambda do |data, frame|
378
+ next nil unless data.is_a?(Hash) && data['payload'].is_a?(Hash)
379
+ next nil unless frame[:event].nil? || frame[:event] == 'cell' || data['eventType'] == 'cell'
380
+
381
+ cell = data['payload'].dup
382
+ cell['suppressed'] = cell['status'] == 'suppressed'
383
+ cell['eventId'] = data['eventId'] if data['eventId'].is_a?(String)
384
+ cell
385
+ end
386
+ keys = lambda do |cell, frame|
387
+ id = cell['eventId'].is_a?(String) ? cell['eventId'] : (frame && frame[:id])
388
+ id ? [id] : []
389
+ end
390
+ cursor = lambda do |cell, frame|
391
+ (frame && frame[:id]) || cell['eventId']
392
+ end
393
+ Realtime.new(@config).feed(path, {}, from_sequence.nil? ? nil : { 'fromSequence' => from_sequence }, on_resync,
394
+ decode: decode, keys: keys, cursor: cursor)
395
+ end
396
+
397
+ private
398
+
399
+ def time_text(value)
400
+ value.respond_to?(:iso8601) ? value.iso8601 : value.to_s
401
+ end
402
+ end
403
+ end