flexiq 2.1.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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +302 -0
  4. data/lib/flexiq/backoff.rb +21 -0
  5. data/lib/flexiq/batch_item_result.rb +21 -0
  6. data/lib/flexiq/cbor/decoder.rb +169 -0
  7. data/lib/flexiq/cbor/encoder.rb +112 -0
  8. data/lib/flexiq/client.rb +196 -0
  9. data/lib/flexiq/deadline.rb +24 -0
  10. data/lib/flexiq/debounce.rb +20 -0
  11. data/lib/flexiq/edge_condition.rb +24 -0
  12. data/lib/flexiq/enqueue_options.rb +38 -0
  13. data/lib/flexiq/enqueue_request.rb +25 -0
  14. data/lib/flexiq/enqueue_result.rb +13 -0
  15. data/lib/flexiq/errors.rb +21 -0
  16. data/lib/flexiq/job.rb +60 -0
  17. data/lib/flexiq/job_page.rb +19 -0
  18. data/lib/flexiq/job_status.rb +41 -0
  19. data/lib/flexiq/job_transition.rb +25 -0
  20. data/lib/flexiq/payload.rb +55 -0
  21. data/lib/flexiq/queue_stats.rb +12 -0
  22. data/lib/flexiq/reason.rb +52 -0
  23. data/lib/flexiq/rpc_error.rb +117 -0
  24. data/lib/flexiq/task_error.rb +50 -0
  25. data/lib/flexiq/transition_kind.rb +27 -0
  26. data/lib/flexiq/transport.rb +216 -0
  27. data/lib/flexiq/version.rb +6 -0
  28. data/lib/flexiq/watch/by_ids.rb +33 -0
  29. data/lib/flexiq/watch/by_queue.rb +33 -0
  30. data/lib/flexiq/watch.rb +132 -0
  31. data/lib/flexiq/watch_items.rb +13 -0
  32. data/lib/flexiq/wire/bytes.rb +23 -0
  33. data/lib/flexiq/wire/duration.rb +41 -0
  34. data/lib/flexiq/wire/int64.rb +20 -0
  35. data/lib/flexiq/wire/path.rb +20 -0
  36. data/lib/flexiq/wire/sse.rb +91 -0
  37. data/lib/flexiq/wire/timestamp.rb +27 -0
  38. data/lib/flexiq/workflow_constructs.rb +66 -0
  39. data/lib/flexiq/workflow_edge.rb +21 -0
  40. data/lib/flexiq/workflow_graph.rb +50 -0
  41. data/lib/flexiq/workflow_node.rb +54 -0
  42. data/lib/flexiq/workflow_node_state.rb +17 -0
  43. data/lib/flexiq/workflow_node_status.rb +31 -0
  44. data/lib/flexiq/workflow_run.rb +24 -0
  45. data/lib/flexiq/workflow_run_view.rb +19 -0
  46. data/lib/flexiq/workflow_state.rb +31 -0
  47. data/lib/flexiq.rb +53 -0
  48. metadata +92 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9e98266383975c9d410b23e1691b7494c4f1a25c91288d5aabf0a51969da59ed
4
+ data.tar.gz: e95eba4c46c659286fb6aa58f3a4524004e5c2d995db6b1b168ff74195bbcea8
5
+ SHA512:
6
+ metadata.gz: 388d485f608c7bf541bb4463f5568f5be19fa70dfafbe173206c11d00cad1199e8a4ff51b1f4d45ea7a9706517aa8e2d2baa75e9b7e00a4db59cbd5a74a63a86
7
+ data.tar.gz: 0d26e5abe9642fb86c45a525695ae4bd17dd0d291f0fa8c757c27797144bacbdd429fab627297bf2641e59598cbb1b3c8a178bdb4a4468a0991d4eafaa088da8
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pratyush Sharma
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,302 @@
1
+ # FlexiQ Ruby client
2
+
3
+ A Ruby client for the **producer door** of a running
4
+ [`flexiq-server`](../../crates/flexiq-server): submit work, read it back, wait for it, cancel it,
5
+ count it, and submit static workflows.
6
+ No database credential, no native extension, no gRPC toolchain — it speaks the server's JSON
7
+ facade with the Ruby standard library alone.
8
+
9
+ It is a producer client, not an SDK: it **cannot execute tasks**. It submits work that
10
+ somebody else's workers drain. To run tasks in Ruby, use the companion
11
+ [`flexiq-executor`](../ruby-executor) gem, which attaches to the executor door over gRPC.
12
+
13
+ ## Install
14
+
15
+ Ruby 3.3 or newer. The gem is not on RubyGems yet; take it from this repository with Bundler:
16
+
17
+ ```ruby
18
+ # Gemfile
19
+ gem "flexiq", git: "https://github.com/ByteVeda/flexiq", glob: "sdks/ruby/*.gemspec"
20
+ ```
21
+
22
+ ## Quickstart
23
+
24
+ ```ruby
25
+ require "flexiq"
26
+
27
+ client = FlexiQ::Client.new("https://flexiq.internal:50051", token: ENV.fetch("FLEXIQ_TOKEN"))
28
+
29
+ result = client.enqueue("orders.send_receipt",
30
+ args: [{ "order_id" => "ord-0001" }],
31
+ queue: "emails",
32
+ unique_key: "receipt-ord-0001")
33
+ job = client.get_job(result.job.id, include_result: true)
34
+ job.status # => :pending, :running, :complete, :failed, :dead or :cancelled
35
+ job.terminal? # => false until it finishes for good
36
+
37
+ client.cancel_job(job.id)
38
+ client.queue_stats("emails").pending
39
+ ```
40
+
41
+ The URL is the address the server's `FLEXIQ_GRPC_LISTEN` binds (these examples use port `50051`);
42
+ the JSON facade shares that listener.
43
+
44
+ ## Surface
45
+
46
+ | Method | Route | Notes |
47
+ |---|---|---|
48
+ | `enqueue(task, args:, kwargs:, **options)` | `POST /v1/jobs` | Options are `FlexiQ::EnqueueOptions` fields. |
49
+ | `enqueue_batch(requests)` | `POST /v1/jobs:batchEnqueue` | One `BatchItemResult` per item. Not atomic. |
50
+ | `get_job(id, include_payload:, include_result:)` | `GET /v1/jobs/{id}` | Payload and result only on request. |
51
+ | `list_jobs(status:, queue:, task_name:, page_size:, page_token:)` | `GET /v1/jobs` | One `JobPage`, newest first. See [Listing](#listing). |
52
+ | `each_job(status:, queue:, task_name:, page_size:)` | `GET /v1/jobs` | An `Enumerator` over every page. |
53
+ | `watch_jobs(ids)` | `GET /v1/jobs:watch` (SSE) | Yields each job's transitions until all are finished. See [Waiting](#waiting). |
54
+ | `watch_queue(queue = nil, resume_cursor:)` | `GET /v1/jobs:watch` (SSE) | Yields a queue's live transitions until you break. |
55
+ | `wait(id, timeout:)` | `GET /v1/jobs:watch`, then `GET /v1/jobs/{id}` | The finished `Job`, with its result. |
56
+ | `cancel_job(id)` | `POST /v1/jobs/{id}:cancel` | Idempotent. |
57
+ | `queue_stats(queue = nil)` | `GET /v1/queues/{queue}/stats`, `GET /v1/stats` | `nil` counts the whole namespace. |
58
+ | `submit_workflow(name, graph, params_json:)` | `POST /v1/workflows` | The run id. Static graphs only; not idempotent. See [Workflows](#workflows). |
59
+ | `get_workflow_run(run_id)` | `GET /v1/workflows/{run_id}` | A `WorkflowRunView`: the run and every node. |
60
+
61
+ Enqueue options: `queue`, `priority`, `max_retries`, `scheduled_at` (`Time`), `timeout`
62
+ (seconds), `unique_key`, `metadata`, `notes`, `depends_on`, `expires_at` (`Time`),
63
+ `result_ttl` (seconds), `debounce` (`FlexiQ::Debounce` or a Hash of its fields).
64
+
65
+ The namespace is the token's, fixed when an operator minted it. No call can name another, and a
66
+ job in another namespace reads as `JOB_NOT_FOUND`. If enqueues succeed and nothing ever runs
67
+ them, check the namespace first.
68
+
69
+ ## Listing
70
+
71
+ `list_jobs` reads one page, newest first. Every filter is optional, and `status:` takes the
72
+ `FlexiQ::JobStatus` symbols (`:pending`, `:failed`, …). Rows never carry payload or result; read
73
+ one job with `get_job` for those.
74
+
75
+ ```ruby
76
+ page = client.list_jobs(queue: "emails", status: :failed, page_size: 100)
77
+ page.jobs # => [FlexiQ::Job, ...]
78
+ page.next_page_token # => pass back as page_token: for the next page; nil on the last
79
+
80
+ client.each_job(queue: "emails", status: :failed).first(500) # follows the cursor for you
81
+ ```
82
+
83
+ `next_page_token` is opaque. Pass it back unchanged and read nothing out of it; a page token the
84
+ server did not issue is refused with `RPCError` reason `INVALID_REQUEST`. `page_size` defaults to
85
+ the server's choice (50) and is capped (500), so trust `last_page?`, not the row count.
86
+
87
+ A credential whose `produce` or `read` grant is narrowed must name what the grant narrows: a
88
+ `queue:` it reaches for a queue-narrowed grant, a `task_name:` it reaches for a task-narrowed one,
89
+ and both when the grant narrows both. Otherwise `list_jobs` is refused with `SCOPE_DENIED`. A
90
+ listing is never filtered down to the rows the credential can see.
91
+
92
+ ## Waiting
93
+
94
+ Watch a job instead of polling `get_job`. `wait` blocks until the job is finished and reads it
95
+ back with its result:
96
+
97
+ ```ruby
98
+ job = client.wait(job_id, timeout: 60) # raises FlexiQ::WaitTimeoutError after 60 s; nil = no limit
99
+ job.status # => :complete, :dead or :cancelled
100
+ job.decode_result
101
+ ```
102
+
103
+ `watch_jobs` yields every step on the way. Each id opens with a `:snapshot` of its current
104
+ state (or a `FlexiQ::JobNotFound`), then live transitions follow, and the call returns once
105
+ every job is finished:
106
+
107
+ ```ruby
108
+ client.watch_jobs([a, b]) do |item|
109
+ case item
110
+ in FlexiQ::JobTransition => t then puts "#{t.job_id} #{t.kind} -> #{t.status}"
111
+ in FlexiQ::JobNotFound => n then puts "#{n.job_id} not visible to this token"
112
+ end
113
+ end
114
+ ```
115
+
116
+ Decide that a job is finished by `JobTransition#terminal?`, never by its status: `:failed` is
117
+ not final while a retry or a dead-letter is still to come.
118
+
119
+ `watch_queue` follows every job in a queue until you `break`. It has no snapshot. It opens with a
120
+ `FlexiQ::WatchCheckpoint`, and then yields one `JobTransition` per live change. Every item
121
+ carries a `cursor`; keep the last one, checkpoints included, and pass it back as
122
+ `resume_cursor:` to replay what you missed:
123
+
124
+ ```ruby
125
+ client.watch_queue("emails", resume_cursor: saved) do |item|
126
+ case item
127
+ in FlexiQ::WatchGap then reconcile_with(client.list_jobs(queue: "emails"))
128
+ in FlexiQ::JobTransition => t then handle(t)
129
+ else nil
130
+ end
131
+ saved = item.cursor if item.respond_to?(:cursor)
132
+ end
133
+ ```
134
+
135
+ Watches keep themselves alive:
136
+
137
+ - **Dropped connection, `WATCH_OVERFLOW` or `SHUTTING_DOWN`:** the watch reopens after a
138
+ backoff. An id watch reopens on the jobs that are not finished yet, each with a fresh
139
+ snapshot. A queue watch resumes from its last cursor.
140
+ - **Expired cursor** (`WATCH_CURSOR_EXPIRED`): the server keeps a bounded window, per process.
141
+ The queue watch yields a `FlexiQ::WatchGap` and starts again from now. The transitions in the
142
+ gap are gone; `list_jobs` reads current state, not history.
143
+ - **Failure before the first item:** it raises. This covers a wrong URL, a missing `read`
144
+ scope, and a token already at its cap of concurrent watches (`WATCH_LIMIT`;
145
+ `RPCError#watch_limit` is the cap).
146
+
147
+ A queue watch only sees transitions that the server process it reached handles itself. Each
148
+ watch holds a connection of its own, so other calls on the same client are never blocked.
149
+ `watch_read_timeout:` (default 60 s) is how long a watch may stay silent before it counts as
150
+ dropped. The server sends a keepalive every 15 s.
151
+
152
+ ## Workflows
153
+
154
+ A workflow is a graph of steps. Submitting one enqueues a job per node, chained by the edges, and
155
+ any worker with workflow tracking enabled advances the run, not only whoever submitted it.
156
+
157
+ ```ruby
158
+ graph = FlexiQ::WorkflowGraph.new(
159
+ nodes: [
160
+ FlexiQ::WorkflowNode.new(name: "charge", task_name: "orders.charge", args: [order], queue: "payments"),
161
+ FlexiQ::WorkflowNode.new(name: "ship", task_name: "orders.ship", args: [order],
162
+ condition: :on_success, timeout: 30, compensate: "orders.unship")
163
+ ],
164
+ edges: [%w[charge ship]] # or FlexiQ::WorkflowEdge.new(from: "charge", to: "ship")
165
+ )
166
+ run_id = client.submit_workflow("checkout", graph, params_json: '{"order":"ord-0001"}')
167
+
168
+ view = client.get_workflow_run(run_id)
169
+ view.run.state # => :pending, :running, :completed, :failed, … (FlexiQ::WorkflowState)
170
+ view.run.terminal?
171
+ view.node("ship").status # => :pending, :running, :completed, … (FlexiQ::WorkflowNodeStatus)
172
+ view.node("ship").job_id # read it with get_job
173
+ ```
174
+
175
+ A node takes the arguments `enqueue` does and sends the same `raw` bytes, plus `queue`,
176
+ `max_retries`, `timeout` (seconds), `priority`, `condition` (`:on_success`, `:on_failure`,
177
+ `:always`) and `compensate`. A Hash of a node's fields works wherever a node does. The graph is
178
+ checked before the call: at least one node, unique names, and no edge to an undeclared node.
179
+
180
+ **This door advances static graphs only.** A node setting `gate`, `cache`, `fan_out`, `fan_in`
181
+ or `sub_workflow` is refused before anything is written; only a live SDK process can drive those.
182
+ The refusal names one node, so a graph with several clears them one call at a time:
183
+
184
+ ```ruby
185
+ begin
186
+ client.submit_workflow("review", graph)
187
+ rescue FlexiQ::RPCError => e
188
+ raise unless e.reason == FlexiQ::Reason::WORKFLOW_CONSTRUCT_UNSUPPORTED
189
+
190
+ e.workflow_construct # => { node: "review", field: "gate" }
191
+ end
192
+ ```
193
+
194
+ Every submission is version 1 of `name`. Resubmitting a name with a different graph is refused
195
+ with `INVALID_ARGUMENT`; submit a changed graph under a new name.
196
+
197
+ **`submit_workflow` is not idempotent**, and a workflow has no `unique_key`. A run cannot be found
198
+ by name, so a call that raised `TransportError` or an ambiguous `RPCError` (`UNAVAILABLE`,
199
+ `DEADLINE_EXCEEDED`, `CANCELLED`) may have submitted a run whose id is lost. A retry submits a
200
+ second run. Never retry it blind.
201
+
202
+ A narrowed `produce` grant is checked on every node, as `enqueue` would check it, with an empty
203
+ queue counted as `default`. The first node outside the grant refuses the whole graph:
204
+ `SCOPE_DENIED`, with `RPCError#node` naming that node. A node's `compensate` task must be reached
205
+ on every queue. `get_workflow_run` answers `NOT_FOUND` unless the grant reaches every node's job.
206
+
207
+ ## Credentials and TLS
208
+
209
+ Every call sends `Authorization: Bearer <token>`. The token is opaque; the client never parses it.
210
+
211
+ `https://` is the default and verifies the server's certificate. Point at a private CA, or
212
+ present a client certificate for mTLS, with `tls:`:
213
+
214
+ ```ruby
215
+ FlexiQ::Client.new(url, token: token, tls: {
216
+ ca_file: "/etc/flexiq/ca.pem",
217
+ cert: OpenSSL::X509::Certificate.new(File.read("client.pem")),
218
+ key: OpenSSL::PKey.read(File.read("client.key"))
219
+ })
220
+ ```
221
+
222
+ `http://` is refused unless you pass `insecure: true`, which is for a loopback listener you
223
+ trust. A bearer token on a plaintext network hop can be replayed by anything that sees it.
224
+
225
+ Other keywords: `open_timeout`, `read_timeout`, `write_timeout`, `watch_read_timeout` (seconds)
226
+ and `user_agent`.
227
+
228
+ ## Errors
229
+
230
+ Every error is a `FlexiQ::Error`:
231
+
232
+ | Class | Means |
233
+ |---|---|
234
+ | `FlexiQ::RPCError` | The server refused. Branch on `#reason` (`FlexiQ::Reason::QUEUE_FULL`, …), never on the message. |
235
+ | `FlexiQ::TransportError` | No answer arrived: connect, TLS, timeout, or a body that was not FlexiQ's. |
236
+ | `FlexiQ::ConfigurationError` | The client was built with settings it will not honour. |
237
+ | `FlexiQ::CodecError` | A value could not be encoded into, or decoded from, a payload. |
238
+ | `FlexiQ::WaitTimeoutError` | `wait` ran out of time. The job is untouched. |
239
+
240
+ ```ruby
241
+ begin
242
+ client.enqueue("orders.charge", args: [order], queue: "payments", unique_key: order["id"])
243
+ rescue FlexiQ::RPCError => e
244
+ case e.reason
245
+ when FlexiQ::Reason::QUEUE_FULL then sleep(e.retry_after || 1) # e.queue_full => {queue:, pending:, cap:}
246
+ when FlexiQ::Reason::SCOPE_DENIED then raise "token lacks #{e.scope}"
247
+ else raise
248
+ end
249
+ end
250
+ ```
251
+
252
+ `RPCError#retryable?` says whether the condition clears on its own. It never says a write is
253
+ safe to resend.
254
+
255
+ ## Retries
256
+
257
+ The client never retries a call; only watches reopen themselves, since a watch writes nothing.
258
+ A write that raised `TransportError`, or `RPCError` with code
259
+ `UNAVAILABLE`, `DEADLINE_EXCEEDED` or `CANCELLED`, may have landed before the connection dropped,
260
+ and nothing on the wire says which. Resend a write only with `unique_key` set, reusing the same
261
+ value. `unique_key` dedupes against the *active* job only — once that job finishes the key is
262
+ free — so keep the total retry window shorter than the job's own life.
263
+
264
+ ## Payloads
265
+
266
+ Arguments travel as the `raw` CBOR envelope this gem writes itself — tag byte `0x02`, then
267
+ `[args, kwargs]` — held to the contract's three rules: definite-length containers,
268
+ shortest-form integers and 64-bit floats. The bytes are therefore the same ones any other FlexiQ
269
+ runtime writes for the same call, which is what keeps cross-runtime `auto:` idempotency keys
270
+ equal. Hash keys keep their insertion order.
271
+
272
+ | Ruby | CBOR |
273
+ |---|---|
274
+ | `nil`, `true`, `false` | simple values |
275
+ | `Integer` (64-bit range) | integer |
276
+ | `Float` | 64-bit float |
277
+ | `String` | text — or a byte string when its encoding is `Encoding::BINARY` |
278
+ | `Symbol` | text |
279
+ | `Array`, `Hash` | array, map |
280
+
281
+ Prefer one Hash argument (`args: [{...}]`): it binds cleanly in every runtime's handlers.
282
+
283
+ Reading back: `job.decode_payload` returns `[args, kwargs]`, `job.decode_result` the task's
284
+ return value, and `job.task_error` a `FlexiQ::TaskError` — structured when the failure carried the
285
+ canonical JSON, otherwise the raw text verbatim.
286
+
287
+ ## Development
288
+
289
+ ```bash
290
+ bin/setup # bundle install
291
+ bundle exec rake # rubocop + unit suite (includes the wire-vector conformance test)
292
+ bundle exec rake e2e # against a real server; build one first:
293
+ # cargo build -p flexiq-server --features grpc
294
+ ```
295
+
296
+ The e2e suite runs `FLEXIQ_SERVER_BIN`, or else `target/{debug,release}/flexiq-server`.
297
+
298
+ ## Contract
299
+
300
+ [`contracts/REMOTE_SDK_CONTRACT.md`](../../contracts/REMOTE_SDK_CONTRACT.md) is the rule this
301
+ client implements, and [`contracts/wire-vectors.json`](../../contracts/wire-vectors.json) is the
302
+ conformance bar its suite asserts.
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlexiQ
4
+ # Retry delay: 0.25 s doubling to 5 s; `reset` after a success.
5
+ class Backoff
6
+ START = 0.25
7
+ CAP = 5.0
8
+
9
+ def initialize = reset
10
+
11
+ def reset
12
+ @delay = START
13
+ end
14
+
15
+ # Sleeps the current delay, cut short by `deadline` (Deadline) when given.
16
+ def pause(deadline = nil)
17
+ sleep(deadline ? deadline.cap(@delay) : @delay)
18
+ @delay = [@delay * 2, CAP].min
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlexiQ
4
+ # One item of an EnqueueBatch: either `enqueued` (an EnqueueResult, durable) or `error`
5
+ # (an unraised RPCError — that item alone did not land). A batch is never atomic.
6
+ #
7
+ # Both nil means a newer server answered with an outcome this build does not know.
8
+ BatchItemResult = Data.define(:index, :enqueued, :error) do
9
+ def self.from_json(json, index)
10
+ new(
11
+ index: index,
12
+ enqueued: json["enqueued"] && EnqueueResult.from_json(json["enqueued"]),
13
+ error: json["error"] && RPCError.from_status(json["error"])
14
+ )
15
+ end
16
+
17
+ def enqueued? = !enqueued.nil?
18
+
19
+ def failed? = !error.nil?
20
+ end
21
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlexiQ
4
+ module CBOR
5
+ # A CBOR tag this client has no meaning for, kept so the value is not lost.
6
+ Tagged = Data.define(:tag, :value)
7
+
8
+ # RFC 8949 reader. Accepts every form the encoder is forbidden to write —
9
+ # indefinite lengths, non-shortest integers, half and single floats — because a
10
+ # payload written before those rules, or by another writer, still has to run.
11
+ class Decoder
12
+ # Nesting cap, so hostile input cannot exhaust the stack.
13
+ MAX_DEPTH = 512
14
+ BREAK = Object.new.freeze
15
+ private_constant :BREAK
16
+
17
+ # Decodes exactly one CBOR item; trailing bytes are an error.
18
+ def self.decode(bytes)
19
+ decoder = new(bytes)
20
+ value = decoder.read_value(0)
21
+ decoder.finish
22
+ value
23
+ end
24
+
25
+ def initialize(bytes)
26
+ @bytes = bytes.b
27
+ @pos = 0
28
+ end
29
+
30
+ def finish
31
+ return if @pos == @bytes.bytesize
32
+
33
+ raise CodecError, "#{@bytes.bytesize - @pos} trailing byte(s) after the CBOR item"
34
+ end
35
+
36
+ # One complete item; a break code is only legal where an indefinite container reads it.
37
+ def read_value(depth)
38
+ value = read_item(depth)
39
+ raise CodecError, "unexpected CBOR break code" if value.equal?(BREAK)
40
+
41
+ value
42
+ end
43
+
44
+ private
45
+
46
+ def read_item(depth)
47
+ raise CodecError, "CBOR nesting deeper than #{MAX_DEPTH}" if depth > MAX_DEPTH
48
+
49
+ initial = take_byte
50
+ major = initial >> 5
51
+ info = initial & 0x1F
52
+ case major
53
+ when 0 then read_argument(info)
54
+ when 1 then -1 - read_argument(info)
55
+ when 2 then read_string(info, Encoding::BINARY, depth)
56
+ when 3 then read_string(info, Encoding::UTF_8, depth)
57
+ when 4 then read_array(info, depth)
58
+ when 5 then read_map(info, depth)
59
+ when 6 then Tagged.new(tag: read_argument(info), value: read_value(depth + 1))
60
+ else read_simple(info)
61
+ end
62
+ end
63
+
64
+ def take_byte
65
+ raise CodecError, "CBOR input ended early" if @pos >= @bytes.bytesize
66
+
67
+ byte = @bytes.getbyte(@pos)
68
+ @pos += 1
69
+ byte
70
+ end
71
+
72
+ def take(count)
73
+ raise CodecError, "CBOR input ended early" if @pos + count > @bytes.bytesize
74
+
75
+ slice = @bytes.byteslice(@pos, count)
76
+ @pos += count
77
+ slice
78
+ end
79
+
80
+ def read_argument(info)
81
+ case info
82
+ when 0..23 then info
83
+ when 24 then take(1).unpack1("C")
84
+ when 25 then take(2).unpack1("n")
85
+ when 26 then take(4).unpack1("N")
86
+ when 27 then take(8).unpack1("Q>")
87
+ else raise CodecError, "invalid CBOR argument encoding #{info}"
88
+ end
89
+ end
90
+
91
+ def indefinite?(info) = info == 31
92
+
93
+ def read_string(info, encoding, depth)
94
+ raw = indefinite?(info) ? read_chunks(encoding, depth) : take(read_argument(info))
95
+ text = raw.dup.force_encoding(encoding)
96
+ raise CodecError, "CBOR text string is not valid UTF-8" unless text.valid_encoding?
97
+
98
+ text
99
+ end
100
+
101
+ # An indefinite string is a run of definite chunks of the same major type.
102
+ def read_chunks(encoding, depth)
103
+ out = +"".b
104
+ loop do
105
+ chunk = read_item(depth + 1)
106
+ break if chunk.equal?(BREAK)
107
+ raise CodecError, "indefinite CBOR string holds a chunk of another type" unless chunk.is_a?(String)
108
+ raise CodecError, "indefinite CBOR string mixes text and bytes" unless chunk.encoding == encoding
109
+
110
+ out << chunk.b
111
+ end
112
+ out
113
+ end
114
+
115
+ def read_array(info, depth)
116
+ return Array.new(read_argument(info)) { read_value(depth + 1) } unless indefinite?(info)
117
+
118
+ items = []
119
+ loop do
120
+ item = read_item(depth + 1)
121
+ break if item.equal?(BREAK)
122
+
123
+ items << item
124
+ end
125
+ items
126
+ end
127
+
128
+ def read_map(info, depth)
129
+ map = {}
130
+ if indefinite?(info)
131
+ loop do
132
+ key = read_item(depth + 1)
133
+ break if key.equal?(BREAK)
134
+
135
+ map[key] = read_value(depth + 1)
136
+ end
137
+ else
138
+ read_argument(info).times { map[read_value(depth + 1)] = read_value(depth + 1) }
139
+ end
140
+ map
141
+ end
142
+
143
+ def read_simple(info)
144
+ case info
145
+ when 20 then false
146
+ when 21 then true
147
+ when 22, 23 then nil
148
+ when 25 then half_float(take(2).unpack1("n"))
149
+ when 26 then take(4).unpack1("g")
150
+ when 27 then take(8).unpack1("G")
151
+ when 31 then BREAK
152
+ else raise CodecError, "unsupported CBOR simple value #{info}"
153
+ end
154
+ end
155
+
156
+ # IEEE 754 binary16 has no pack directive; RFC 8949 appendix D spells the decode.
157
+ def half_float(bits)
158
+ exponent = (bits >> 10) & 0x1F
159
+ mantissa = bits & 0x3FF
160
+ magnitude =
161
+ if exponent.zero? then Math.ldexp(mantissa, -24)
162
+ elsif exponent == 31 then mantissa.zero? ? Float::INFINITY : Float::NAN
163
+ else Math.ldexp(mantissa + 1024, exponent - 25)
164
+ end
165
+ bits.anybits?(0x8000) ? -magnitude : magnitude
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlexiQ
4
+ module CBOR
5
+ # RFC 8949 writer held to the three rules the wire contract pins:
6
+ # definite-length containers, shortest-form integers, 64-bit finite floats.
7
+ #
8
+ # Each rule moves bytes without moving meaning, and the `auto:` idempotency key
9
+ # hashes those bytes — so a drift here silently stops cross-runtime dedupe.
10
+ module Encoder
11
+ UINT64_MAX = (2**64) - 1
12
+ NEGATIVE_MIN = -(2**64)
13
+
14
+ # Preferred serialization for the non-finite floats; the contract leaves their width free.
15
+ POSITIVE_INFINITY = "\xF9\x7C\x00".b.freeze
16
+ NEGATIVE_INFINITY = "\xF9\xFC\x00".b.freeze
17
+ NAN = "\xF9\x7E\x00".b.freeze
18
+
19
+ module_function
20
+
21
+ # Encodes one Ruby value into a binary String.
22
+ #
23
+ # A String tagged `Encoding::BINARY` is a CBOR byte string; any other String is text.
24
+ # Symbols are text. Hash keys keep insertion order: the vectors pin unsorted keys.
25
+ def encode(value)
26
+ write(+"".b, value)
27
+ end
28
+
29
+ def write(out, value)
30
+ case value
31
+ when nil then out << "\xF6".b
32
+ when true then out << "\xF5".b
33
+ when false then out << "\xF4".b
34
+ when Integer then write_integer(out, value)
35
+ when Float then write_float(out, value)
36
+ when Symbol then write_text(out, value.name)
37
+ when String then write_string(out, value)
38
+ when Array then write_array(out, value)
39
+ when Hash then write_map(out, value)
40
+ else raise CodecError, "cannot encode #{value.class} as CBOR"
41
+ end
42
+ end
43
+
44
+ def write_integer(out, value)
45
+ unless value.between?(NEGATIVE_MIN, UINT64_MAX)
46
+ raise CodecError, "integer #{value} is outside CBOR's 64-bit range"
47
+ end
48
+
49
+ value.negative? ? write_head(out, 1, -1 - value) : write_head(out, 0, value)
50
+ end
51
+
52
+ def write_float(out, value)
53
+ return out << NAN if value.nan?
54
+ return out << (value.positive? ? POSITIVE_INFINITY : NEGATIVE_INFINITY) if value.infinite?
55
+
56
+ out << "\xFB".b << [value].pack("G")
57
+ end
58
+
59
+ def write_string(out, value)
60
+ return write_bytes(out, value) if value.encoding == Encoding::BINARY
61
+
62
+ write_text(out, value)
63
+ end
64
+
65
+ def write_bytes(out, value)
66
+ write_head(out, 2, value.bytesize)
67
+ out << value
68
+ end
69
+
70
+ def write_text(out, value)
71
+ text = value.encoding == Encoding::UTF_8 ? value : value.encode(Encoding::UTF_8)
72
+ raise CodecError, "text is not valid UTF-8: #{text.inspect}" unless text.valid_encoding?
73
+
74
+ write_head(out, 3, text.bytesize)
75
+ out << text.b
76
+ rescue EncodingError => e
77
+ raise CodecError, "text cannot be converted to UTF-8: #{e.message}"
78
+ end
79
+
80
+ def write_array(out, value)
81
+ write_head(out, 4, value.length)
82
+ value.each { |item| write(out, item) }
83
+ out
84
+ end
85
+
86
+ def write_map(out, value)
87
+ write_head(out, 5, value.length)
88
+ value.each do |key, item|
89
+ write(out, key)
90
+ write(out, item)
91
+ end
92
+ out
93
+ end
94
+
95
+ # Major type in the top three bits, then the argument in its shortest form.
96
+ def write_head(out, major, argument)
97
+ prefix = major << 5
98
+ if argument < 24
99
+ out << (prefix | argument).chr
100
+ elsif argument <= 0xFF
101
+ out << (prefix | 24).chr << [argument].pack("C")
102
+ elsif argument <= 0xFFFF
103
+ out << (prefix | 25).chr << [argument].pack("n")
104
+ elsif argument <= 0xFFFF_FFFF
105
+ out << (prefix | 26).chr << [argument].pack("N")
106
+ else
107
+ out << (prefix | 27).chr << [argument].pack("Q>")
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end