k9o 1.3.0 → 1.4.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fddde5f475c121ba44d0e069107845e30e0104613b717edb809813d152e2882a
4
- data.tar.gz: af7564a794aa6c40150a09bf38800dad60e092afb26dfdf8e918524124b4a68c
3
+ metadata.gz: eaf2bcfb047371f56983f311783294b81d482812171a893b3e57ea5361d9a5e1
4
+ data.tar.gz: 1afe1668983a800d3c061f11e5f6117bda5eff6459b1f7aab210b091f7f8eaaa
5
5
  SHA512:
6
- metadata.gz: c3735d8f97f9a0eacc9fa02dcf897ac0182056c606179b7cdcc104cc32ebba9ddb14b433c7522e30e4a10deadbd8b13022de1db262f4abccb2912b9edb05ac4a
7
- data.tar.gz: f4f833edfa3f6fc6ea2d75efccfb85f09c902e0e2fb79cc43c8edad7db02bcd3817832087227005944fb4714f198ac4b0dd6f88134248698c9d2b4e3674aa1a0
6
+ metadata.gz: d4595a0da65edfee744aac3adac7f8f96b4aa67352bc013dca3c3b8bcb534182992f83387bd554e17c9da50c07275a0172fb8ab4488dab6a23b81570fbcefaf2
7
+ data.tar.gz: 9686288bd9ea43dbabb7148e6033d78a45124b16f72774856ac26d893638f1794dc64948e4778fb27237d9a310c62c20f17de4786bc4aa528a3c727e807be45a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog — k9o (Ruby)
2
2
 
3
+ ## 1.4.0 (unreleased)
4
+
5
+ ### Added
6
+
7
+ - `query(sql, params)`: one read-only SQL statement; an array binds `$1, $2, ...`, a hash binds `:name` placeholders.
8
+ - `search(collection, column:, q:)`: full-text search over one column, plus `text_search` and `vector_search` on `select`.
9
+ - `ai.generate`: text generation from a prompt (verb sugar over `compute.llms.invoke`; `model` defaults to the org's default LLM).
10
+ - `streams` namespace for user data streams: `create`, `list`, `get`, `delete`, `publish`, `replay` and `subscribe`.
11
+ `subscribe` tails live events over SSE and reconnects from the last sequence when the server closes the connection.
12
+
13
+ - `approvals` namespace for the pending-change queue: `list`, `get`, `approve`, `reject` and `bulk`.
14
+ - `approval_policies` namespace: `list`, `create` and `delete`.
15
+ - `schema.collections.list`: the project's collections with row counts and last migration, paged by cursor.
16
+
17
+ ### Changed
18
+
19
+ - The README description now covers streams and realtime, and the platform overview moved to an "About Kilimanjaro" appendix.
20
+ - Function deploy examples use the sandbox tier.
21
+
3
22
  ## 1.3.0 — 2026-10-26
4
23
 
5
24
  Every method now matches the gateway.
@@ -37,7 +56,7 @@ Every method now matches the gateway.
37
56
 
38
57
  ## 1.2.0 — 2026-10-25
39
58
 
40
- - The README now introduces the Kilimanjaro products (Keep, Pulse, Gate, Kibo). No change to the public API.
59
+ - The README now introduces the Kilimanjaro building blocks (Keep, Pulse, Gate, Kibo). No change to the public API.
41
60
 
42
61
  ## 1.1.1 — 2026-10-25
43
62
 
data/README.md CHANGED
@@ -1,14 +1,7 @@
1
1
  # k9o
2
2
 
3
- The Kilimanjaro platform SDK for Ruby.
4
-
5
- Kilimanjaro is the AI-native foundation for building critical applications.
6
- Its products are:
7
-
8
- - Keep: AI Security and Governance
9
- - Pulse: Data Intelligence
10
- - Gate: Customer Identity and Access Management
11
- - Kibo: Keys and Secrets Management
3
+ Client for Kilimanjaro. Read and write your data, store files, manage policies and migrations,
4
+ deploy and invoke agents or functions, and publish to and subscribe to streams and realtime events.
12
5
 
13
6
  ## Contents
14
7
 
@@ -17,8 +10,15 @@ Its products are:
17
10
  - [Quick start](#quick-start)
18
11
  - [Authentication and project context](#authentication-and-project-context)
19
12
  - [Running queries](#running-queries)
13
+ - [Query with SQL](#query-with-sql)
14
+ - [Search](#search)
15
+ - [Streams](#streams)
16
+ - [Approvals](#approvals)
17
+ - [Approval policies](#approval-policies)
18
+ - [Schema collections](#schema-collections)
20
19
  - [Handling errors](#handling-errors)
21
20
  - [Appendix](#appendix)
21
+ - [About Kilimanjaro](#about-kilimanjaro)
22
22
  - [License](#license)
23
23
 
24
24
  ## Links
@@ -31,13 +31,13 @@ Its products are:
31
31
  ## Install
32
32
 
33
33
  ```bash
34
- gem install k9o
34
+ gem install k9o -v 1.4.0
35
35
  ```
36
36
 
37
37
  Or add it to your `Gemfile`:
38
38
 
39
39
  ```ruby
40
- gem "k9o"
40
+ gem "k9o", "1.4.0"
41
41
  ```
42
42
 
43
43
  Requires Ruby 3.2 or newer. See [requirements](#requirements) for details.
@@ -63,11 +63,11 @@ k9o = K9o.create_client(
63
63
  ```ruby
64
64
  new_task = { "title" => "Write the README", "done" => false }
65
65
  created = k9o.from("tasks").insert(new_task)
66
- puts "created: #{created.data['title']}"
66
+ puts "Output: #{created.data['title']}"
67
67
  ```
68
68
 
69
69
  ```text
70
- created: Write the README
70
+ Output: Write the README
71
71
  ```
72
72
 
73
73
  ### List tasks
@@ -75,12 +75,12 @@ created: Write the README
75
75
  ```ruby
76
76
  response = k9o.from("tasks").select.eq("done", false).limit(10).execute
77
77
  response.data.each do |task|
78
- puts "#{task['title']} #{task['done']}"
78
+ puts "Output: #{task['title']} #{task['done']}"
79
79
  end
80
80
  ```
81
81
 
82
82
  ```text
83
- Write the README false
83
+ Output: Write the README false
84
84
  ```
85
85
 
86
86
  ### Update and delete a task
@@ -143,13 +143,193 @@ A sub-object the server did not send is `nil`, so use `&.` when it may be absent
143
143
  ```ruby
144
144
  request_id = response.meta.context.request_id
145
145
  total = response.meta.cursor.total_count
146
- puts "#{request_id} #{total}"
146
+ puts "Output: #{request_id} #{total}"
147
+ ```
148
+
149
+ ```text
150
+ Output: req_01H... 1
151
+ ```
152
+
153
+ ## Query with SQL
154
+
155
+ `k9o.query(sql, params)` runs one read-only SQL statement and returns the rows in `data`.
156
+ It is read-only (a write is refused with `K9O_RAW_SQL_READ_ONLY`; change data through `from`), takes one statement per call,
157
+ applies row-level security, accepts at most 100000 characters of SQL and 1000 parameters, and needs the project editor role.
158
+ A reference to `dataset.<name>` reads an analytics dataset.
159
+
160
+ Pass an array to bind `$1`, `$2`, and so on, or a hash to bind `:name` placeholders. Values are bound by the database and can be a string, number, boolean or `nil`; serialise anything else (for example as JSON text) first.
161
+
162
+ ```ruby
163
+ rows = k9o.query("SELECT data->>'title' AS title FROM posts WHERE data->>'status' = :status", status: "published")
164
+ puts "Output: #{rows.data.first['title']}"
165
+
166
+ same = k9o.query("SELECT count(*) AS n FROM posts WHERE data->>'status' = $1", ["published"])
167
+ puts "Output: #{same.data.first['n']}"
168
+ ```
169
+
170
+ ```text
171
+ Output: Hello
172
+ Output: 12
173
+ ```
174
+
175
+ A `:name` with no key in the hash raises `K9o::KError` with code `K9O_INVALID_FIELD_VALUE` before any request is sent.
176
+
177
+ ## Search
178
+
179
+ `k9o.search(collection, column:, q:)` runs a full-text search over one text column, best matches first.
180
+ `language:` defaults to `english`; `type:` is `plain` (default), `phrase` or `websearch`. `limit:` and `cursor:` page the result.
181
+
182
+ ```ruby
183
+ found = k9o.search("articles", column: "body", q: "climate resilience", limit: 5)
184
+ puts "Output: #{found.data.length} #{found.data.first['title']}"
185
+ ```
186
+
187
+ ```text
188
+ Output: 5 Adapting to heat
189
+ ```
190
+
191
+ The same search is available on a query as `text_search(column, query, language: "english", type: "plain")`.
192
+ `vector_search(column, embedding, metric: "cosine", limit: nil)` finds the closest rows to an embedding, closest first;
193
+ `metric:` is `cosine`, `euclidean` or `dot`, and `limit:` is 1 to 1000.
194
+
195
+ ```ruby
196
+ vector = k9o.ai.embed(input: "heat waves").data["embeddings"].first
197
+ near = k9o.from("articles").select.vector_search("embedding", vector, limit: 3).execute
198
+ puts "Output: #{near.data.length}"
199
+ ```
200
+
201
+ ```text
202
+ Output: 3
203
+ ```
204
+
205
+ ## Streams
206
+
207
+ Publish events to a stream and tail them live. Publishing needs the project admin role; subscribing and replaying need admin or auditor.
208
+ `subscribe` reconnects from the last sequence when the server closes the connection, so no event is repeated or skipped.
209
+
210
+ ```ruby
211
+ stream = k9o.streams.create("Orders feed", 1_073_741_824)
212
+ slug = stream.data["slug"]
213
+ puts "Output: #{slug}"
214
+ k9o.streams.publish(slug, { "order" => 1001 }, subject: "eu.new")
215
+
216
+ k9o.streams.subscribe(slug).each do |event|
217
+ puts "Output: #{event["sequence"]} #{event["data"]["order"]}"
218
+ break
219
+ end
147
220
  ```
148
221
 
149
222
  ```text
150
- req_01H... 1
223
+ Output: orders-feed
224
+ Output: 1 1001
151
225
  ```
152
226
 
227
+ `replay` reads a bounded range of past events.
228
+ Failures raise `K9o::KError`: `K9O_STREAM_UNAVAILABLE` while streams are not available, and `K9O_STREAM_INVALID` for a bad subject.
229
+
230
+ ## Approvals
231
+
232
+ Writes that need approval wait in a queue for a second person. `k9o.approvals` reads that queue and decides its changes.
233
+ Project admins and auditors see every change; other members see only the ones they proposed.
234
+
235
+ ```ruby
236
+ queue = k9o.approvals.list(status: "pending_approval", limit: 20)
237
+ change = queue.data.first
238
+
239
+ puts "Output: #{change["id"]} #{change["operation"]} #{change["collection"]}"
240
+
241
+ decided = k9o.approvals.approve(change["id"], reason: "checked the amount")
242
+
243
+ puts "Output: #{decided.data["status"]}"
244
+ ```
245
+
246
+ ```text
247
+ Output: apr_01j9x3 update orders
248
+ Output: redeemed
249
+ ```
250
+
251
+ - A change is `pending_approval`, `approved`, `rejected`, `expired` or `redeemed` (applied). An `expired` change can still read `pending_approval` until someone decides it.
252
+ - `approve` can answer 200 with `status` still `pending_approval`. That means your approval is recorded and more approvers are needed; the change is not applied yet.
253
+ - `reject(id, reason:)` needs a non-empty `reason`; the SDK checks it before sending.
254
+ - `list` is not paginated: it returns the newest `limit` changes (1 to 200, default 50). `get(id)` reads one.
255
+ - Deciding needs the queue-admin role (project admin or auditor).
256
+
257
+ Decide many at once with `bulk`. `criteria` is a predicate over each pending change (see [Approval policies](#approval-policies)):
258
+
259
+ ```ruby
260
+ run = k9o.approvals.bulk(
261
+ action: "reject",
262
+ criteria: { "eq" => ["change.table", "orders"] },
263
+ reason: "stale"
264
+ )
265
+
266
+ puts "Output: #{run.data["affectedCount"]} #{run.data["affectedIds"].join(",")}"
267
+ ```
268
+
269
+ ```text
270
+ Output: 2 apr_01j9x3,apr_01j9x4
271
+ ```
272
+
273
+ - A row that fails (a conflict, an apply error) is skipped silently and left out of `affectedIds`. Compare `affectedIds` with the set you intended.
274
+ - `reject` needs a `reason`; `criteria` must be a predicate hash. `attestationId` identifies the batch, even when nothing matched.
275
+ - It considers the newest 10000 pending changes. Bulk approve is not guaranteed to apply every matched row.
276
+
277
+ ## Approval policies
278
+
279
+ A policy decides what happens to writes on one collection that match a predicate: `queue` them for approval, or `auto_approve` them.
280
+ For a write the first matching policy, oldest first, decides. Policies are evaluated for writes sent with the `requireApproval=true` query parameter.
281
+
282
+ ```ruby
283
+ policy = k9o.approval_policies.create(
284
+ collection: "orders",
285
+ operation: "update",
286
+ action: "queue",
287
+ predicate: { "gt" => ["change.row.amount", 1000] }
288
+ )
289
+
290
+ puts "Output: #{policy.data["id"]} #{policy.data["action"]}"
291
+
292
+ removed = k9o.approval_policies.delete(policy.data["id"])
293
+
294
+ puts "Output: #{removed.data["id"]}"
295
+ ```
296
+
297
+ ```text
298
+ Output: apo_01j9x5 queue
299
+ Output: apo_01j9x5
300
+ ```
301
+
302
+ - `operation` is `insert`, `update` or `delete`; `action` is `auto_approve` or `queue`.
303
+ - `collection` matches `^[a-zA-Z_][a-zA-Z0-9_]{0,62}$` and `predicate` is a non-null hash. The SDK checks both before sending.
304
+ - A predicate has one operator key: `and`, `or`, `not`, `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `not_in`, `in_cidr`, `not_in_cidr` or `contains`.
305
+ `and` and `or` take an array of predicates, `not` takes one, and a comparison takes `[left, right]`.
306
+ - Operands start with `maker.` or `change.` (for example `change.row.amount`, `change.table`, `change.operation`, `maker.id`).
307
+ - `list` returns all policies. Identical policies are not de-duplicated. Creating and deleting need project admin or author.
308
+
309
+ ## Schema collections
310
+
311
+ `k9o.schema.collections.list` lists the project's collections by name, with their size and last migration.
312
+
313
+ ```ruby
314
+ page = k9o.schema.collections.list(limit: 2)
315
+
316
+ page.data.each do |collection|
317
+ puts "Output: #{collection["name"]} #{collection["rowCount"]} rows, #{collection["columnCount"]} columns"
318
+ end
319
+ puts "Output: next #{page.meta.cursor.next}"
320
+ ```
321
+
322
+ ```text
323
+ Output: orders 120 rows, 8 columns
324
+ Output: tasks 14 rows, 4 columns
325
+ Output: next dGFza3M
326
+ ```
327
+
328
+ - Pass `meta.cursor.next` as `cursor` for the next page; it is `nil` on the last page. `limit` is 1 to 200 (default 50).
329
+ - A row is `name`, `rowCount`, `rowCountIsExact`, `columnCount`, `indexCount`, `lastMigrationSlug` and `lastMigrationAppliedAt`.
330
+ - `rowCount` is a live count that skips soft-deleted rows and ignores row-level security. Without an `org_id` claim on the token, `meta.warnings` says so.
331
+ - Project admins, auditors, editors, members and viewers can read it.
332
+
153
333
  ## Handling errors
154
334
 
155
335
  Failures raise `K9o::KError`, a subclass of `StandardError`. Branch on `code`.
@@ -158,14 +338,14 @@ Failures raise `K9o::KError`, a subclass of `StandardError`. Branch on `code`.
158
338
  begin
159
339
  k9o.from("missing").select.execute
160
340
  rescue K9o::KError => error
161
- puts "#{error.code} #{error.status} #{error.request_id}"
162
- puts error.message
341
+ puts "Output: #{error.code} #{error.status} #{error.request_id}"
342
+ puts "Output: #{error.message}"
163
343
  end
164
344
  ```
165
345
 
166
346
  ```text
167
- K9O_NOT_FOUND 404 req_01H...
168
- Collection not found
347
+ Output: K9O_NOT_FOUND 404 req_01H...
348
+ Output: Collection not found
169
349
  ```
170
350
 
171
351
  - See [error attributes](#error-attributes) for every field.
@@ -235,6 +415,8 @@ Chain filters before `execute`. Values are matched against one column.
235
415
  | `limit(n)` | Maximum rows to return. |
236
416
  | `cursor(token)` | Continue from `meta.cursor.next`. |
237
417
  | `count(mode)` | Row count mode: `none`, `exact` or `estimated`. |
418
+ | `text_search(column, query, language: "english", type: "plain")` | Full-text search over one column. |
419
+ | `vector_search(column, embedding, metric: "cosine", limit: nil)` | Nearest rows to an embedding, closest first. |
238
420
 
239
421
  #### Projects
240
422
 
@@ -249,11 +431,11 @@ Chain filters before `execute`. Values are matched against one column.
249
431
 
250
432
  ```ruby
251
433
  project = k9o.projects.create(display_name: "Task app", slug: "task-app")
252
- puts project.data["slug"]
434
+ puts "Output: #{project.data["slug"]}"
253
435
  ```
254
436
 
255
437
  ```text
256
- task-app
438
+ Output: task-app
257
439
  ```
258
440
 
259
441
  #### Gate (end-user sign-in)
@@ -271,11 +453,11 @@ task-app
271
453
  ```ruby
272
454
  session = k9o.gate.sign_in(email: "ada@example.com", password: "correct-horse")
273
455
  user = session.data["user"]
274
- puts user["email"]
456
+ puts "Output: #{user["email"]}"
275
457
  ```
276
458
 
277
459
  ```text
278
- ada@example.com
460
+ Output: ada@example.com
279
461
  ```
280
462
 
281
463
  #### Storage
@@ -324,24 +506,24 @@ signed = avatars.create_signed_url("ada.png", expires_in: 300)
324
506
  | `k9o.functions.delete` | Remove a function. |
325
507
  | `k9o.functions.invoke` | Run a function. The `payload` argument is sent as `{payload}`. |
326
508
 
327
- `tier:` is `"gateway"` (WebAssembly, `"wasm"`) or `"sandbox"` (OCI image archive, `"tar"`).
509
+ `tier:` is `"sandbox"` (an OCI image archive, `"tar"`, up to 256 MiB). The `gateway` tier is for functions owned by platform operators and is not available to most projects. Sandbox functions run in a [Firelock](https://github.com/firelock-sandbox/firelock) microVM sandbox.
328
510
 
329
511
  ```ruby
330
- artifact = k9o.functions.upload_artifact(File.binread("hello.wasm"), "wasm")
512
+ artifact = k9o.functions.upload_artifact(File.binread("hello.tar"), "tar")
331
513
 
332
514
  k9o.functions.deploy(
333
515
  name: "hello",
334
516
  runtime: "bun",
335
- tier: "gateway",
517
+ tier: "sandbox",
336
518
  artifact_key: artifact.data["artifactKey"],
337
519
  version: "1.0.0"
338
520
  )
339
521
  result = k9o.functions.invoke("hello", { "name" => "Ada" })
340
- puts result.data["greeting"]
522
+ puts "Output: #{result.data["greeting"]}"
341
523
  ```
342
524
 
343
525
  ```text
344
- Hello, Ada
526
+ Output: Hello, Ada
345
527
  ```
346
528
 
347
529
  #### Migrations
@@ -391,6 +573,21 @@ Hello, Ada
391
573
  | Method | What it does |
392
574
  |---|---|
393
575
  | `k9o.ai.embed(input:, model: nil)` | Embed one string or a list of strings. |
576
+ | `k9o.ai.generate(prompt, model: nil, max_tokens: nil, temperature: nil, usage_type: nil)` | Generate text from a prompt. |
577
+
578
+ ##### Generate text
579
+
580
+ `model` is optional: without it the org's default model is used. Models from outside providers answer 501 until built.
581
+ The result `data` is the same as `compute.llms.invoke`.
582
+
583
+ ```ruby
584
+ result = k9o.ai.generate("Say hi in three words", max_tokens: 20)
585
+ puts "Output: #{result.data['content']}"
586
+ ```
587
+
588
+ ```text
589
+ Output: Hi there, friend!
590
+ ```
394
591
 
395
592
  #### Language models
396
593
 
@@ -407,11 +604,11 @@ Pass settings in `params:` (`max_tokens`, `temperature`, `usage_type`). The resu
407
604
 
408
605
  ```ruby
409
606
  reply = k9o.compute.llms.invoke("llama-3-8b", prompt: "Say hi", params: { max_tokens: 20 })
410
- puts "#{reply.data['content']} #{reply.data['usage']['totalTokens']}"
607
+ puts "Output: #{reply.data['content']} #{reply.data['usage']['totalTokens']}"
411
608
  ```
412
609
 
413
610
  ```text
414
- Hi there! 14
611
+ Output: Hi there! 14
415
612
  ```
416
613
 
417
614
  #### Realtime
@@ -424,6 +621,48 @@ Hi there! 14
424
621
 
425
622
  `K9o::Realtime::Topic` builds topic names, for example `Topic.collection("tasks")`.
426
623
 
624
+ #### Streams
625
+
626
+ `k9o.streams` publishes to and subscribes to the project's user streams. It needs `project_slug`.
627
+
628
+ | Method | What it does |
629
+ |---|---|
630
+ | `k9o.streams.create(display_name, max_bytes, retention_seconds: nil)` | Create a stream. The slug comes from the name. |
631
+ | `k9o.streams.list` | List the project's streams. |
632
+ | `k9o.streams.get(slug)` | Read one stream. |
633
+ | `k9o.streams.delete(slug)` | Retire a stream. |
634
+ | `k9o.streams.publish(slug, data, subject: nil)` | Append an event. `subject` is a dot-separated routing suffix. |
635
+ | `k9o.streams.replay(slug, from_sequence: nil, until_sequence: nil, limit: nil)` | Read past events: `events`, `nextCursor`, `hasMore`. |
636
+ | `k9o.streams.subscribe(slug, from_sequence: nil)` | Tail live events over SSE (an `Enumerator`). Reconnects from the last sequence. |
637
+
638
+ #### Approvals
639
+
640
+ `k9o.approvals` reads and decides the queue of changes waiting for approval. It needs `project_slug`.
641
+
642
+ | Method | What it does |
643
+ |---|---|
644
+ | `k9o.approvals.list(status: nil, limit: nil)` | Newest pending changes first. Not paginated; `limit` is 1 to 200. |
645
+ | `k9o.approvals.get(id)` | Read one change. |
646
+ | `k9o.approvals.approve(id, reason: nil)` | Approve. `status` can stay `pending_approval` while more approvers are needed. |
647
+ | `k9o.approvals.reject(id, reason:)` | Reject. `reason` is required. |
648
+ | `k9o.approvals.bulk(action:, criteria:, reason: nil)` | Approve or reject every change matching a predicate. `reason` is required to reject. Failed rows are skipped. |
649
+
650
+ #### Approval policies
651
+
652
+ `k9o.approval_policies` holds the rules that decide which writes are queued. It needs `project_slug`.
653
+
654
+ | Method | What it does |
655
+ |---|---|
656
+ | `k9o.approval_policies.list` | All policies, oldest first. |
657
+ | `k9o.approval_policies.create(collection:, operation:, action:, predicate:)` | Create a policy. `action` is `auto_approve` or `queue`. |
658
+ | `k9o.approval_policies.delete(id)` | Delete a policy. |
659
+
660
+ #### Schema
661
+
662
+ | Method | What it does |
663
+ |---|---|
664
+ | `k9o.schema.collections.list(limit: nil, cursor: nil)` | List the project's collections by name, with `meta.cursor.next` paging. |
665
+
427
666
  #### Studio
428
667
 
429
668
  `k9o.studio` manages workflows and dashboards.
@@ -584,6 +823,20 @@ The server may send codes not listed here, so keep a fallback branch.
584
823
  | `@k9o/core` | TypeScript SDK |
585
824
  | `@k9o/cli` | Command-line tool |
586
825
 
826
+ ### About Kilimanjaro
827
+
828
+ Kilimanjaro is a modular developer platform for building secure enterprise applications.
829
+ You define AI agents, functions and data pipelines on the platform.
830
+ The SDKs let you deploy and invoke them, read and write your data, publish to and subscribe to streams,
831
+ sign users in, and encrypt with keys you hold.
832
+
833
+ It provides four building blocks:
834
+
835
+ - **Keep**: AI security and governance. Guardrails and policy for the agents you run.
836
+ - **Pulse**: data intelligence and real-time stream processing. Streams, analytics and live feeds.
837
+ - **Gate**: customer identity and access management. Sign-in and sessions, and automatic data scoping so a signed-in user reaches only their own data.
838
+ - **Kibo**: cryptographic keys and secrets management. Client-side encryption with keys you hold, and a secrets vault.
839
+
587
840
  ## License
588
841
 
589
842
  Apache-2.0 © Formelo Inc. See `LICENSE` and `NOTICE`.
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,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module K9o
4
+ # Shared argument checks for the approvals, approval policy and schema namespaces. A bad value raises
5
+ # K9o::KError K9O_INVALID_FIELD_VALUE before any request is sent.
6
+ module ApprovalChecks
7
+ module_function
8
+
9
+ def invalid(message, context = {})
10
+ KError.new(code: 'K9O_INVALID_FIELD_VALUE', message: message, context: context)
11
+ end
12
+
13
+ def check_limit(limit)
14
+ return if limit.nil?
15
+ return if limit.is_a?(Integer) && limit >= 1 && limit <= 200
16
+
17
+ raise invalid('limit must be a whole number from 1 to 200', limit: limit)
18
+ end
19
+
20
+ def id_segment(value, what)
21
+ raise invalid("#{what} id must be a non-empty string") unless value.is_a?(String) && !value.empty?
22
+
23
+ ::URI.encode_www_form_component(value).gsub('+', '%20')
24
+ end
25
+
26
+ def reason!(reason)
27
+ raise invalid('reason is required and must be a non-empty string') unless reason.is_a?(String) && !reason.strip.empty?
28
+
29
+ reason
30
+ end
31
+
32
+ def predicate!(value, field)
33
+ raise invalid("#{field} must be a predicate object") unless value.is_a?(Hash)
34
+
35
+ value
36
+ end
37
+
38
+ def enum!(value, allowed, field)
39
+ text = value.is_a?(Symbol) ? value.to_s : value
40
+ return text if allowed.include?(text)
41
+
42
+ raise invalid("#{field} must be #{allowed.map { |a| "\"#{a}\"" }.join(', ')}", field.to_sym => value)
43
+ end
44
+ end
45
+
46
+ # Pending-change queue: k9o.approvals.*
47
+ #
48
+ # list(status:, limit:) GET /@<slug>/pending-approvals
49
+ # get(id) GET /@<slug>/pending-approvals/<id>
50
+ # approve(id, reason:) POST /@<slug>/pending-approvals/<id>/approve
51
+ # reject(id, reason:) POST /@<slug>/pending-approvals/<id>/reject
52
+ # bulk(action:, criteria:, reason:) POST /@<slug>/pending-approvals/bulk
53
+ #
54
+ # +list+ is not paginated: it returns the newest +limit+ changes (1 to 200, default 50). +approve+ can answer 200
55
+ # with +status+ still +pending_approval+: the approval is recorded and more approvers are needed. +bulk+ skips rows
56
+ # that fail without a report, so compare +affectedIds+ with what you intended.
57
+ class Approvals
58
+ def initialize(config)
59
+ @config = config
60
+ end
61
+
62
+ # Pending changes, newest first. Only +limit+ caps the result.
63
+ def list(status: nil, limit: nil)
64
+ ApprovalChecks.check_limit(limit)
65
+ query = {}
66
+ query['status'] = status.to_s unless status.nil?
67
+ query['limit'] = limit unless limit.nil?
68
+ call('GET', path, query: query)
69
+ end
70
+
71
+ # One pending change. Members who are not queue admins can read only their own.
72
+ def get(id)
73
+ call('GET', path("/#{ApprovalChecks.id_segment(id, 'approval')}"))
74
+ end
75
+
76
+ # Approve a pending change. Read +status+ on the answer: +pending_approval+ means more approvers are needed.
77
+ def approve(id, reason: nil)
78
+ seg = ApprovalChecks.id_segment(id, 'approval')
79
+ call('POST', path("/#{seg}/approve"), body: reason.nil? ? {} : { 'reason' => reason })
80
+ end
81
+
82
+ # Reject a pending change. +reason+ is required and must not be empty.
83
+ def reject(id, reason:)
84
+ seg = ApprovalChecks.id_segment(id, 'approval')
85
+ call('POST', path("/#{seg}/reject"), body: { 'reason' => ApprovalChecks.reason!(reason) })
86
+ end
87
+
88
+ # Approve or reject every pending change matching +criteria+ (a predicate Hash). +reason+ is required to reject.
89
+ # Rows that fail are skipped without a report: compare +affectedIds+ with what you intended.
90
+ def bulk(action:, criteria:, reason: nil)
91
+ act = ApprovalChecks.enum!(action, %w[approve reject], 'action')
92
+ body = { 'action' => act, 'criteria' => ApprovalChecks.predicate!(criteria, 'criteria') }
93
+ if act == 'reject'
94
+ body['reason'] = ApprovalChecks.reason!(reason)
95
+ elsif !reason.nil?
96
+ body['reason'] = reason
97
+ end
98
+ call('POST', path('/bulk'), body: body)
99
+ end
100
+
101
+ private
102
+
103
+ def call(method, route, body: nil, query: nil)
104
+ HttpTransport.request(config: @config, method: method, path: route, body: body, query: query)
105
+ end
106
+
107
+ def path(tail = '')
108
+ UrlGrammar.project_path(Studio.require_project_slug(@config, 'approvals'), "pending-approvals#{tail}")
109
+ end
110
+ end
111
+
112
+ # Approval policies: k9o.approval_policies.*
113
+ #
114
+ # list GET /@<slug>/approval-policies
115
+ # create(collection:, operation:, action:, predicate:) POST /@<slug>/approval-policies
116
+ # delete(id) DELETE /@<slug>/approval-policies/<id>
117
+ class ApprovalPolicies
118
+ COLLECTION_RE = /\A[a-zA-Z_][a-zA-Z0-9_]{0,62}\z/.freeze
119
+
120
+ def initialize(config)
121
+ @config = config
122
+ end
123
+
124
+ # All policies, oldest first. The first matching policy for a write decides.
125
+ def list
126
+ call('GET', path)
127
+ end
128
+
129
+ # Create a policy. Identical policies are not de-duplicated.
130
+ def create(collection:, operation:, action:, predicate:)
131
+ unless collection.is_a?(String) && COLLECTION_RE.match?(collection)
132
+ raise ApprovalChecks.invalid('collection must match ^[a-zA-Z_][a-zA-Z0-9_]{0,62}$', collection: collection)
133
+ end
134
+
135
+ body = {
136
+ 'collection' => collection,
137
+ 'operation' => ApprovalChecks.enum!(operation, %w[insert update delete], 'operation'),
138
+ 'action' => ApprovalChecks.enum!(action, %w[auto_approve queue], 'action'),
139
+ 'predicate' => ApprovalChecks.predicate!(predicate, 'predicate')
140
+ }
141
+ call('POST', path, body: body)
142
+ end
143
+
144
+ # Delete a policy; the answer is { 'id' => ... }.
145
+ def delete(id)
146
+ call('DELETE', path("/#{ApprovalChecks.id_segment(id, 'policy')}"))
147
+ end
148
+
149
+ private
150
+
151
+ def call(method, route, body: nil)
152
+ HttpTransport.request(config: @config, method: method, path: route, body: body)
153
+ end
154
+
155
+ def path(tail = '')
156
+ UrlGrammar.project_path(Studio.require_project_slug(@config, 'approval_policies'), "approval-policies#{tail}")
157
+ end
158
+ end
159
+ end
data/lib/k9o/client.rb CHANGED
@@ -3,8 +3,10 @@
3
3
  module K9o
4
4
  # The customer-facing client returned by K9o.create_client.
5
5
  class K9oClient
6
- attr_reader :config, :projects, :gate, :studio, :compute, :realtime, :org,
7
- :orgs, :migrations, :functions, :policies, :audit, :me, :kibo, :ai, :storage
6
+ include QueryMethods
7
+
8
+ attr_reader :config, :projects, :gate, :studio, :compute, :realtime, :streams, :org,
9
+ :approvals, :approval_policies, :schema, :orgs, :migrations, :functions, :policies, :audit, :me, :kibo, :ai, :storage
8
10
 
9
11
  def initialize(config)
10
12
  @config = config
@@ -13,6 +15,10 @@ module K9o
13
15
  @studio = Studio.new(config)
14
16
  @compute = Compute.new(config)
15
17
  @realtime = Realtime.new(config)
18
+ @streams = Streams.new(config)
19
+ @approvals = Approvals.new(config)
20
+ @approval_policies = ApprovalPolicies.new(config)
21
+ @schema = Schema.new(config)
16
22
  @org = Org.new(config)
17
23
  @orgs = Orgs.new(config)
18
24
  @migrations = Migrations.new(config)
data/lib/k9o/data.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+
3
5
  module K9o
4
6
  # Data-plane DSL.
5
7
  #
@@ -113,13 +115,77 @@ module K9o
113
115
  end
114
116
 
115
117
  class SelectBuilder < BaseBuilder
118
+ SEARCH_COLUMN = /\A[a-zA-Z_][a-zA-Z0-9_]{0,62}\z/.freeze
119
+ SEARCH_LANGUAGE = /\A[a-zA-Z_]+\z/.freeze
120
+ TEXT_TYPES = %w[plain phrase websearch].freeze
121
+ VECTOR_METRICS = %w[cosine euclidean dot].freeze
122
+
123
+ def initialize(config, collection)
124
+ super
125
+ @search = {}
126
+ end
127
+
128
+ # Full-text search over one text column. +type+ is "plain", "phrase" or "websearch".
129
+ def text_search(column, query, language: 'english', type: 'plain')
130
+ check_search_column(column)
131
+ raise search_error('text search query must be a non-empty string') unless query.is_a?(String) && !query.empty?
132
+ unless language.is_a?(String) && language.match?(SEARCH_LANGUAGE)
133
+ raise search_error('text search language must be letters and underscores only')
134
+ end
135
+
136
+ type = type.to_s
137
+ raise search_error("text search type must be one of #{TEXT_TYPES.join(', ')}") unless TEXT_TYPES.include?(type)
138
+
139
+ @search.delete('textSearchLanguage')
140
+ @search.delete('textSearchType')
141
+ @search['textSearch'] = "#{column}:#{query}"
142
+ @search['textSearchLanguage'] = language unless language == 'english'
143
+ @search['textSearchType'] = type unless type == 'plain'
144
+ self
145
+ end
146
+
147
+ # Nearest-neighbour search over one vector column; results come back closest first.
148
+ # +metric+ is "cosine", "euclidean" or "dot"; +limit+ is 1 to 1000 (server default 10).
149
+ def vector_search(column, embedding, metric: 'cosine', limit: nil)
150
+ check_search_column(column)
151
+ unless embedding.is_a?(Array) && !embedding.empty? &&
152
+ embedding.all? { |v| v.is_a?(Integer) || (v.is_a?(Float) && v.finite?) }
153
+ raise search_error('embedding must be a non-empty array of finite numbers')
154
+ end
155
+
156
+ metric = metric.to_s
157
+ raise search_error("vector metric must be one of #{VECTOR_METRICS.join(', ')}") unless VECTOR_METRICS.include?(metric)
158
+ unless limit.nil? || (limit.is_a?(Integer) && limit >= 1 && limit <= 1000)
159
+ raise search_error('vector limit must be an integer from 1 to 1000')
160
+ end
161
+
162
+ @search.delete('vectorMetric')
163
+ @search.delete('vectorLimit')
164
+ @search['vectorSearch'] = "#{column}:#{JSON.generate(embedding)}"
165
+ @search['vectorMetric'] = metric unless metric == 'cosine'
166
+ @search['vectorLimit'] = limit.to_s unless limit.nil?
167
+ self
168
+ end
169
+
116
170
  def execute
117
171
  HttpTransport.request(
118
172
  config: @config, method: 'GET',
119
173
  path: FromBuilder.collection_path(@config, @collection),
120
- query: to_query
174
+ query: to_query.merge(@search)
121
175
  )
122
176
  end
177
+
178
+ private
179
+
180
+ def search_error(message)
181
+ KError.new(code: 'K9O_INVALID_FIELD_VALUE', message: message, context: { collection: @collection })
182
+ end
183
+
184
+ def check_search_column(column)
185
+ return if column.is_a?(String) && column.match?(SEARCH_COLUMN)
186
+
187
+ raise search_error('search column must be a column name: letters, digits and underscores, starting with a letter or underscore')
188
+ end
123
189
  end
124
190
 
125
191
  class UpdateBuilder < BaseBuilder
data/lib/k9o/query.rb ADDED
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module K9o
4
+ # `client.query(sql, params)` and `client.search(collection, column:, q:)`.
5
+ #
6
+ # Wire (project-plane):
7
+ # POST /@<slug>/query body { sql:, params? } -> { data: [row, ...], meta }
8
+ # GET /@<slug>/data/<collection>?textSearch=<column>:<q>... (the select builder's +text_search+)
9
+ #
10
+ # +params+ is an Array (positional, bound to $1, $2, ...; the SQL is sent as written) or a Hash of named values
11
+ # (+:name+ placeholders are rewritten to $n here and the values become the positional list). Values are bound by
12
+ # the database, never interpolated into the SQL text.
13
+ module Query
14
+ IDENT_START = /[A-Za-z_]/.freeze
15
+ IDENT_CHAR = /[A-Za-z0-9_]/.freeze
16
+ DOLLAR_TAG = /\A\$([A-Za-z_][A-Za-z0-9_]*)?\$/.freeze
17
+
18
+ module_function
19
+
20
+ def invalid(message, context = {})
21
+ KError.new(code: 'K9O_INVALID_FIELD_VALUE', message: message, context: context)
22
+ end
23
+
24
+ def check_value(value, where)
25
+ return value if value.nil? || value == true || value == false || value.is_a?(String)
26
+ return value if value.is_a?(Integer)
27
+ return value if value.is_a?(Float) && value.finite?
28
+
29
+ raise invalid("#{where} must be a string, number, boolean or nil; serialise anything else (for example as JSON text) first")
30
+ end
31
+
32
+ def lookup(named, name)
33
+ return [true, named[name]] if named.key?(name)
34
+ return [true, named[name.to_sym]] if named.key?(name.to_sym)
35
+
36
+ [false, nil]
37
+ end
38
+
39
+ # Rewrite +:name+ placeholders to $n. Skips quoted text, comments, dollar-quoted strings and +::+ casts.
40
+ # Returns [sql, params].
41
+ def translate_named_params(sql, named)
42
+ order = []
43
+ out = +''
44
+ i = 0
45
+ n = sql.length
46
+ while i < n
47
+ c = sql[i]
48
+ nxt = i + 1 < n ? sql[i + 1] : ''
49
+ if c == "'" || ((c == 'E' || c == 'e') && nxt == "'" && !(i > 0 && sql[i - 1] =~ IDENT_CHAR))
50
+ escapes = c != "'"
51
+ j = escapes ? i + 2 : i + 1
52
+ while j < n
53
+ if escapes && sql[j] == '\\'
54
+ j += 2
55
+ next
56
+ end
57
+ if sql[j] == "'"
58
+ if sql[j + 1] == "'"
59
+ j += 2
60
+ next
61
+ end
62
+ break
63
+ end
64
+ j += 1
65
+ end
66
+ out << sql[i...[j + 1, n].min]
67
+ i = j + 1
68
+ elsif c == '"'
69
+ j = i + 1
70
+ while j < n
71
+ if sql[j] == '"'
72
+ if sql[j + 1] == '"'
73
+ j += 2
74
+ next
75
+ end
76
+ break
77
+ end
78
+ j += 1
79
+ end
80
+ out << sql[i...[j + 1, n].min]
81
+ i = j + 1
82
+ elsif c == '-' && nxt == '-'
83
+ j = sql.index("\n", i) || n
84
+ out << sql[i...j]
85
+ i = j
86
+ elsif c == '/' && nxt == '*'
87
+ depth = 1
88
+ j = i + 2
89
+ while j < n && depth > 0
90
+ if sql[j, 2] == '/*'
91
+ depth += 1
92
+ j += 2
93
+ elsif sql[j, 2] == '*/'
94
+ depth -= 1
95
+ j += 2
96
+ else
97
+ j += 1
98
+ end
99
+ end
100
+ out << sql[i...j]
101
+ i = j
102
+ elsif c == '$'
103
+ m = DOLLAR_TAG.match(sql[i..-1])
104
+ if m
105
+ close = sql.index(m[0], i + m[0].length)
106
+ stop = close.nil? ? n : close + m[0].length
107
+ out << sql[i...stop]
108
+ i = stop
109
+ else
110
+ out << c
111
+ i += 1
112
+ end
113
+ elsif c == ':' && nxt == ':'
114
+ out << '::'
115
+ i += 2
116
+ elsif c == ':' && nxt != '' && nxt =~ IDENT_START && !(i > 0 && sql[i - 1] =~ IDENT_CHAR)
117
+ j = i + 2
118
+ j += 1 while j < n && sql[j] =~ IDENT_CHAR
119
+ name = sql[(i + 1)...j]
120
+ unless order.include?(name)
121
+ found, = lookup(named, name)
122
+ raise invalid("no value for :#{name}", name: name) unless found
123
+
124
+ order << name
125
+ end
126
+ out << "$#{order.index(name) + 1}"
127
+ i = j
128
+ else
129
+ out << c
130
+ i += 1
131
+ end
132
+ end
133
+ values = order.map { |name| check_value(lookup(named, name)[1], "the value for :#{name}") }
134
+ [out, values]
135
+ end
136
+
137
+ # Builds [path, body] for POST /@<slug>/query.
138
+ def prepare(config, sql, params)
139
+ slug = Studio.require_project_slug(config, 'query')
140
+ text = sql
141
+ values = []
142
+ if params.is_a?(Hash)
143
+ text, values = translate_named_params(sql, params)
144
+ elsif !params.nil?
145
+ values = params.each_with_index.map { |v, i| check_value(v, "params[#{i}]") }
146
+ end
147
+ body = { 'sql' => text }
148
+ body['params'] = values unless values.empty?
149
+ [UrlGrammar.project_path(slug, 'query'), body]
150
+ end
151
+ end
152
+
153
+ # Client-root methods mixed into K9oClient.
154
+ module QueryMethods
155
+ # POST /@<slug>/query — run one read-only SQL statement; +data+ is the array of rows.
156
+ # An Array binds positionally to $1, $2, ...; a Hash binds +:name+ placeholders.
157
+ def query(sql, params = nil)
158
+ path, body = Query.prepare(@config, sql, params)
159
+ HttpTransport.request(config: @config, method: 'POST', path: path, body: body)
160
+ end
161
+
162
+ # Full-text search over one text column of a collection, best matches first. Sugar over
163
+ # +from(collection).select.text_search(column, q, ...)+; +limit+ and +cursor+ page the result.
164
+ def search(collection, column:, q:, language: nil, type: nil, limit: nil, cursor: nil)
165
+ opts = {}
166
+ opts[:language] = language unless language.nil?
167
+ opts[:type] = type unless type.nil?
168
+ builder = from(collection).select.text_search(column, q, **opts)
169
+ builder.limit(limit) unless limit.nil?
170
+ builder.cursor(cursor) unless cursor.nil?
171
+ builder.execute
172
+ end
173
+ end
174
+ end
data/lib/k9o/schema.rb ADDED
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module K9o
4
+ # Schema: k9o.schema.*
5
+ #
6
+ # collections.list(limit:, cursor:) GET /@<slug>/schema/collections
7
+ #
8
+ # The list is cursor-paginated and ordered by collection name: pass +meta.cursor.next+ of one page as +cursor+ of
9
+ # the next, until it is nil.
10
+ class Schema
11
+ attr_reader :collections
12
+
13
+ def initialize(config)
14
+ @collections = SchemaCollections.new(config)
15
+ end
16
+ end
17
+
18
+ class SchemaCollections
19
+ def initialize(config)
20
+ @config = config
21
+ end
22
+
23
+ # The project's collections by name, one page at a time. +limit+ is 1 to 200 (default 50).
24
+ def list(limit: nil, cursor: nil)
25
+ route = UrlGrammar.project_path(Studio.require_project_slug(@config, 'schema'), 'schema/collections')
26
+ ApprovalChecks.check_limit(limit)
27
+ raise ApprovalChecks.invalid('cursor must be a string', cursor: cursor) unless cursor.nil? || cursor.is_a?(String)
28
+
29
+ query = {}
30
+ query['limit'] = limit unless limit.nil?
31
+ query['cursor'] = cursor unless cursor.nil?
32
+ HttpTransport.request(config: @config, method: 'GET', path: route, query: query)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module K9o
4
+ # User data streams namespace: k9o.streams.*.
5
+ #
6
+ # Routes under /@<slug>/data-streams/user. Publish needs project admin; subscribe and replay need admin or
7
+ # auditor. While the stream data plane is unavailable the gateway answers K9O_STREAM_UNAVAILABLE (503); a bad
8
+ # subject answers K9O_STREAM_INVALID. Both raise K9o::KError.
9
+ class Streams
10
+ @reconnect_delay = 0.25
11
+
12
+ class << self
13
+ # Seconds to wait before reconnecting after the server closes a subscribe connection.
14
+ attr_accessor :reconnect_delay
15
+ end
16
+
17
+ def initialize(config)
18
+ @config = config
19
+ end
20
+
21
+ # POST /@<slug>/data-streams/user — +max_bytes+ is the storage ceiling; the slug is derived from +display_name+.
22
+ def create(display_name, max_bytes, retention_seconds: nil)
23
+ body = { 'displayName' => display_name, 'maxBytes' => max_bytes }
24
+ body['retentionSeconds'] = retention_seconds unless retention_seconds.nil?
25
+ call('POST', path, body)
26
+ end
27
+
28
+ # GET /@<slug>/data-streams/user — the project's active user streams.
29
+ def list
30
+ call('GET', path)
31
+ end
32
+
33
+ # GET /@<slug>/data-streams/user/<slug>
34
+ def get(slug)
35
+ call('GET', path(slug))
36
+ end
37
+
38
+ # DELETE /@<slug>/data-streams/user/<slug> — retire the stream.
39
+ def delete(slug)
40
+ call('DELETE', path(slug))
41
+ end
42
+
43
+ # POST .../<slug>/publish — +data+ is any JSON value; +subject+ is an optional dot-separated routing suffix.
44
+ def publish(slug, data, subject: nil)
45
+ body = { 'data' => data }
46
+ body['subject'] = subject unless subject.nil?
47
+ call('POST', path(slug, 'publish'), body)
48
+ end
49
+
50
+ # POST .../<slug>/replay — a bounded range of past events: data['events'], data['nextCursor'], data['hasMore'].
51
+ def replay(slug, from_sequence: nil, until_sequence: nil, limit: nil)
52
+ body = {}
53
+ body['fromSequence'] = from_sequence unless from_sequence.nil?
54
+ body['untilSequence'] = until_sequence unless until_sequence.nil?
55
+ body['limit'] = limit unless limit.nil?
56
+ call('POST', path(slug, 'replay'), body)
57
+ end
58
+
59
+ # GET .../<slug>/subscribe — an Enumerator tailing live events { 'sequence', 'subject', 'data', 'publishedAt' }.
60
+ # The gateway bounds each connection and closes it; the enumerator reconnects from the last sequence it yielded.
61
+ # Stop by leaving the +each+ block (break) or by taking a finite number (+first(n)+).
62
+ def subscribe(slug, from_sequence: nil)
63
+ route = path(slug, 'subscribe')
64
+ Enumerator.new do |yielder|
65
+ cursor = from_sequence
66
+ loop do
67
+ query = cursor.nil? ? nil : { 'fromSequence' => cursor.to_s }
68
+ HttpTransport.stream_sse(config: @config, method: 'GET', path: route, query: query).each do |event|
69
+ next unless event.is_a?(Hash) && event.key?('sequence')
70
+
71
+ cursor = event['sequence']
72
+ yielder << event
73
+ end
74
+ delay = self.class.reconnect_delay
75
+ sleep(delay) if delay.positive?
76
+ end
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def call(method, route, body = nil)
83
+ HttpTransport.request(config: @config, method: method, path: route, body: body)
84
+ end
85
+
86
+ def path(stream_slug = nil, suffix = nil)
87
+ project = Studio.require_project_slug(@config, 'streams')
88
+ sub = +'data-streams/user'
89
+ sub << "/#{::URI.encode_www_form_component(stream_slug)}" if stream_slug
90
+ sub << "/#{suffix}" if suffix
91
+ UrlGrammar.project_path(project, sub)
92
+ end
93
+ end
94
+ end
data/lib/k9o/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module K9o
4
- VERSION = '1.3.0'
4
+ VERSION = '1.4.0'
5
5
  end
data/lib/k9o.rb CHANGED
@@ -29,6 +29,7 @@ require_relative 'k9o/url_grammar'
29
29
  require_relative 'k9o/region'
30
30
  require_relative 'k9o/http_transport'
31
31
  require_relative 'k9o/data'
32
+ require_relative 'k9o/query'
32
33
  require_relative 'k9o/projects'
33
34
  require_relative 'k9o/gate'
34
35
  require_relative 'k9o/studio'
@@ -36,6 +37,9 @@ require_relative 'k9o/compute'
36
37
  require_relative 'k9o/ai'
37
38
  require_relative 'k9o/storage'
38
39
  require_relative 'k9o/realtime'
40
+ require_relative 'k9o/streams'
41
+ require_relative 'k9o/approvals'
42
+ require_relative 'k9o/schema'
39
43
  require_relative 'k9o/migrations'
40
44
  require_relative 'k9o/functions'
41
45
  require_relative 'k9o/policies'
data/sig/k9o/ai.rbs CHANGED
@@ -8,6 +8,10 @@ module K9o
8
8
  # { "embeddings" => Array[Array[Float]], "model" => String, "dimensions" => Integer }.
9
9
  def initialize: (untyped config) -> void
10
10
  def embed: (input: (String | Array[String]), ?model: String?) -> Envelope
11
+
12
+ # Only the fields given are sent. Returns an Envelope whose `data` is
13
+ # { "modelId" => String, "content" => String, "finishReason" => String, "usage" => Hash, "usageType" => String }.
14
+ def generate: (String prompt, ?model: String?, ?max_tokens: Integer?, ?temperature: Numeric?, ?usage_type: String?) -> Envelope
11
15
  end
12
16
 
13
17
  class K9oClient
@@ -0,0 +1,49 @@
1
+ # RBS signatures for `client.approvals`, `client.approval_policies` and `client.schema`.
2
+
3
+ module K9o
4
+ module ApprovalChecks
5
+ def self.invalid: (String message, ?Hash[Symbol, untyped] context) -> KError
6
+ def self.check_limit: (untyped limit) -> void
7
+ def self.id_segment: (untyped value, String what) -> String
8
+ def self.reason!: (untyped reason) -> String
9
+ def self.predicate!: (untyped value, String field) -> Hash[untyped, untyped]
10
+ def self.enum!: (untyped value, Array[String] allowed, String field) -> String
11
+ end
12
+
13
+ class Approvals
14
+ def initialize: (untyped config) -> void
15
+ # Not paginated: only `limit` (1 to 200, default 50) caps the result.
16
+ def list: (?status: (String | Symbol)?, ?limit: Integer?) -> Envelope
17
+ def get: (String id) -> Envelope
18
+ # `status` on the answer stays "pending_approval" while more approvers are needed.
19
+ def approve: (String id, ?reason: String?) -> Envelope
20
+ def reject: (String id, reason: String) -> Envelope
21
+ # Failed rows are skipped without a report: compare `affectedIds` with what you intended.
22
+ def bulk: (action: (String | Symbol), criteria: Hash[(String | Symbol), untyped], ?reason: String?) -> Envelope
23
+ end
24
+
25
+ class ApprovalPolicies
26
+ COLLECTION_RE: Regexp
27
+
28
+ def initialize: (untyped config) -> void
29
+ def list: () -> Envelope
30
+ def create: (collection: String, operation: (String | Symbol), action: (String | Symbol), predicate: Hash[(String | Symbol), untyped]) -> Envelope
31
+ def delete: (String id) -> Envelope
32
+ end
33
+
34
+ class SchemaCollections
35
+ def initialize: (untyped config) -> void
36
+ def list: (?limit: Integer?, ?cursor: String?) -> Envelope
37
+ end
38
+
39
+ class Schema
40
+ attr_reader collections: SchemaCollections
41
+ def initialize: (untyped config) -> void
42
+ end
43
+
44
+ class K9oClient
45
+ attr_reader approvals: Approvals
46
+ attr_reader approval_policies: ApprovalPolicies
47
+ attr_reader schema: Schema
48
+ end
49
+ end
data/sig/k9o/query.rbs ADDED
@@ -0,0 +1,26 @@
1
+ # RBS signatures for `client.query`, `client.search` and the select builder's search methods.
2
+
3
+ module K9o
4
+ module Query
5
+ type value = String | Integer | Float | bool | nil
6
+
7
+ # Rewrites :name placeholders to $n; returns [sql, positional values].
8
+ def self.translate_named_params: (String sql, Hash[(String | Symbol), untyped] named) -> [String, Array[value]]
9
+ end
10
+
11
+ module QueryMethods
12
+ # An Array binds positionally to $1, $2, ...; a Hash binds :name placeholders. `data` is the array of rows.
13
+ def query: (String sql, ?(Array[untyped] | Hash[(String | Symbol), untyped])? params) -> Envelope
14
+
15
+ def search: (String collection, column: String, q: String, ?language: String?, ?type: String?, ?limit: Integer?, ?cursor: String?) -> Envelope
16
+ end
17
+
18
+ class K9oClient
19
+ include QueryMethods
20
+ end
21
+
22
+ class SelectBuilder
23
+ def text_search: (String column, String query, ?language: String, ?type: (String | Symbol)) -> self
24
+ def vector_search: (String column, Array[Numeric] embedding, ?metric: (String | Symbol), ?limit: Integer?) -> self
25
+ end
26
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: k9o
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kilimanjaro
@@ -39,6 +39,7 @@ files:
39
39
  - k9o.gemspec
40
40
  - lib/k9o.rb
41
41
  - lib/k9o/ai.rb
42
+ - lib/k9o/approvals.rb
42
43
  - lib/k9o/audit.rb
43
44
  - lib/k9o/barbican.rb
44
45
  - lib/k9o/client.rb
@@ -57,16 +58,21 @@ files:
57
58
  - lib/k9o/orgs.rb
58
59
  - lib/k9o/policies.rb
59
60
  - lib/k9o/projects.rb
61
+ - lib/k9o/query.rb
60
62
  - lib/k9o/realtime.rb
61
63
  - lib/k9o/region.rb
64
+ - lib/k9o/schema.rb
62
65
  - lib/k9o/storage.rb
66
+ - lib/k9o/streams.rb
63
67
  - lib/k9o/studio.rb
64
68
  - lib/k9o/url_grammar.rb
65
69
  - lib/k9o/vault.rb
66
70
  - lib/k9o/version.rb
67
71
  - sig/k9o/ai.rbs
72
+ - sig/k9o/approvals.rbs
68
73
  - sig/k9o/gate.rbs
69
74
  - sig/k9o/migrations.rbs
75
+ - sig/k9o/query.rbs
70
76
  - sig/k9o/region.rbs
71
77
  - sig/k9o/storage.rbs
72
78
  homepage: https://kilimanjaro.dev