igniter-ledger-client 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c4fe38120d406045031a3f7bfaae0c29e576b30c539c6632d7b3dc2a1b1d4b85
4
+ data.tar.gz: eea5c501aa9c2c30a0c512426bd855321de3a4ca8788b4bdc5183bd7755179ee
5
+ SHA512:
6
+ metadata.gz: 4e32fca6b33da4045e7339df9ff2abf2c863941cbb287bba4758c0b6cc17e60105e509469f588eec33a9b695f68bd9817c4717b609f58ad6999e3794cf378fa7
7
+ data.tar.gz: 70f56f887565bc4871173b4749a0d3ce99259608148dba12bdef33e0a42fa995b7c76fa263ad60fddf2d312848ae32987d101c49bbfe6184a87876d0814b8745
data/README.md ADDED
@@ -0,0 +1,173 @@
1
+ # igniter-ledger-client
2
+
3
+ Protocol-first client package for Igniter Ledger / Ledger Open Protocol.
4
+
5
+ Status: pre-v1 skeleton. This package owns the client boundary, not the storage
6
+ engine.
7
+
8
+ ## Purpose
9
+
10
+ `igniter-ledger-client` is the shared client layer for packages and host apps
11
+ that need to talk to a Ledger/Store endpoint without depending on
12
+ `igniter-ledger` internals.
13
+
14
+ ```text
15
+ Embed / Companion / Web / MCP / Spark adapters
16
+ -> Igniter::LedgerClient
17
+ -> Ledger Open Protocol envelope
18
+ -> local dispatch | remote HTTP | future TCP/pool/outbox transport
19
+ ```
20
+
21
+ The package deliberately has no runtime dependency on `igniter-ledger`.
22
+
23
+ ## Owns
24
+
25
+ - request/response envelope helpers
26
+ - client error semantics
27
+ - stable Ruby method surface for common Ledger operations
28
+ - transport adapters such as object dispatch and remote HTTP
29
+ - future pooling, timeout, retry, and backpressure policy seams
30
+
31
+ ## Does Not Own
32
+
33
+ - fact storage engine
34
+ - WAL, segments, compaction, or changefeed internals
35
+ - contract execution
36
+ - Rails, Sidekiq, ActiveRecord, or Spark-specific code
37
+ - Store-to-Ledger package rename
38
+
39
+ ## Example
40
+
41
+ ```ruby
42
+ require "igniter-ledger-client"
43
+
44
+ client = Igniter::LedgerClient.remote_http(
45
+ "http://127.0.0.1:7300/v1/dispatch",
46
+ open_timeout: 1.0,
47
+ read_timeout: 2.0
48
+ )
49
+
50
+ client.write(
51
+ store: :orders,
52
+ key: "order-1",
53
+ value: { status: :open },
54
+ producer: { type: :app, name: :spark }
55
+ )
56
+
57
+ client.append(
58
+ history: :order_events,
59
+ event: { event_id: "evt-1", order_id: "order-1", event: :opened },
60
+ partition_key: :order_id,
61
+ producer: { type: :app, name: :spark }
62
+ )
63
+
64
+ client.read(store: :orders, key: "order-1")
65
+ ```
66
+
67
+ For local/integration tests, wrap any object exposing `dispatch(envelope)` or
68
+ `wire.dispatch(envelope)`:
69
+
70
+ ```ruby
71
+ client = Igniter::LedgerClient.wrap(protocol_interpreter.wire)
72
+ client.metadata_snapshot
73
+ ```
74
+
75
+ When `igniter-ledger` is present in the same process, wrapping
76
+ `LedgerStore#protocol` is the local adoption path:
77
+
78
+ ```ruby
79
+ ledger = Igniter::Ledger::LedgerStore.new
80
+ client = Igniter::LedgerClient.wrap(ledger.protocol)
81
+ ```
82
+
83
+ Package-level adapters should prefer accepting a `client:` argument over
84
+ reaching into Ledger internals. For example, `ContractableReceiptSink` can be
85
+ constructed with `client: client` and still use the same protocol envelope path
86
+ as a remote HTTP client.
87
+
88
+ ## v0 Surface
89
+
90
+ ```ruby
91
+ client.register_descriptor(...)
92
+ client.write(store:, key:, value:, **metadata)
93
+ client.append(history:, event:, key: nil, partition_key: nil, **metadata)
94
+ client.read(store:, key:, as_of: nil)
95
+ client.query(store:, where:, limit: nil, as_of: nil, order: nil)
96
+ client.replay(
97
+ store: nil,
98
+ from: nil,
99
+ to: nil,
100
+ key: nil,
101
+ partition_key: nil,
102
+ partition_value: nil,
103
+ filter: nil
104
+ )
105
+ client.resolve(relation:, from:, as_of: nil)
106
+ client.causation_chain(store:, key:)
107
+ client.lineage(store:, key:)
108
+ client.fact_ref(fact_id)
109
+ client.subscribe(stores:, cursor: nil) { |event| ... }
110
+ client.metadata_snapshot
111
+ client.descriptor_snapshot
112
+ client.observability_snapshot
113
+ client.compaction_activity(store: nil, kind: nil, since: nil, limit: nil)
114
+ client.close
115
+ ```
116
+
117
+ `append` dispatches the Ledger Open Protocol `append` op and keeps append-only
118
+ history semantics distinct from keyed record writes. `key:` may be sent as
119
+ client metadata for future idempotency work, but protocol v0 returns the
120
+ generated fact key and does not treat `key:` as a stable idempotency guarantee.
121
+
122
+ `replay` accepts either an explicit protocol `filter:` or convenience arguments
123
+ for store, key, and partition replay. Partition replay sends
124
+ `filter: { store:, partition_key:, partition_value: }` and uses Ledger
125
+ partition indexes when the endpoint is backed by a Ledger protocol interpreter.
126
+
127
+ `subscribe` returns an idempotently closeable handle and yields
128
+ `Igniter::LedgerClient::Results::ChangeEventResult` objects. Remote HTTP
129
+ subscriptions read SSE from `/v1/events`; `events_url:` can be passed explicitly,
130
+ otherwise it is derived from `/v1/dispatch`. Cursor resume uses the `?cursor=`
131
+ query parameter.
132
+
133
+ ## Docs
134
+
135
+ - [docs/tracks/ledger-client-protocol-v0.md](docs/tracks/ledger-client-protocol-v0.md)
136
+ — current implementation/convergence track for this package.
137
+
138
+ ## Error Policy
139
+
140
+ The client raises `Igniter::LedgerClient::Error` for protocol error envelopes
141
+ and `Igniter::LedgerClient::TransportError` for transport failures.
142
+
143
+ Successful mutation/read calls return small result objects:
144
+
145
+ - `write` -> `Igniter::LedgerClient::Results::WriteResult`
146
+ - `append` -> `Igniter::LedgerClient::Results::AppendResult`
147
+ - `register_descriptor` -> `Igniter::LedgerClient::Results::ReceiptResult`
148
+ - `read` -> `Igniter::LedgerClient::Results::ReadResult`
149
+ - `query` -> `Igniter::LedgerClient::Results::QueryResult`
150
+ - `resolve` -> `Igniter::LedgerClient::Results::ResolveResult`
151
+ - `replay` -> `Igniter::LedgerClient::Results::ReplayResult`
152
+ - `causation_chain` -> `Igniter::LedgerClient::Results::CausationChainResult`
153
+ - `lineage` -> `Igniter::LedgerClient::Results::LineageResult`
154
+ - `fact_ref` -> `Igniter::LedgerClient::Results::FactRefResult`
155
+ - `subscribe` events -> `Igniter::LedgerClient::Results::ChangeEventResult`
156
+
157
+ Result objects expose named readers, `to_h`, and transitional `[]` access.
158
+ `QueryResult#items` is the canonical row shape for query consumers and includes
159
+ `{ key:, value: }` entries; `QueryResult#results` remains the backward-compatible
160
+ value-only list. `ResolveResult` follows the same `items`/`results` convention
161
+ so typed clients can preserve source record keys.
162
+ `causation_chain`, `lineage`, and `fact_ref` are read-only provenance
163
+ introspection calls. `fact_ref` returns compact metadata only; arbitrary
164
+ `fact_by_id` value reads remain outside the public client surface.
165
+ Snapshot-style methods such as `metadata_snapshot` and `observability_snapshot`
166
+ still return raw protocol hashes in v0.
167
+
168
+ ## Package Boundary
169
+
170
+ `igniter-embed` should not own Store connections, pools, retries, or
171
+ backpressure. Embed emits receipts to an adapter protocol. The adapter can then
172
+ use `igniter-ledger-client` to deliver those receipts locally, remotely, or
173
+ through a host outbox.
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "envelope"
4
+ require_relative "error"
5
+ require_relative "results"
6
+ require_relative "subscription"
7
+
8
+ module Igniter
9
+ module LedgerClient
10
+ class Client
11
+ attr_reader :transport
12
+
13
+ def initialize(transport:)
14
+ @transport = transport
15
+ end
16
+
17
+ def register_descriptor(descriptor = nil, **fields)
18
+ Results.wrap(:register_descriptor, dispatch(:register_descriptor, (descriptor || {}).merge(fields)))
19
+ end
20
+
21
+ def write(store:, key:, value:, **metadata)
22
+ Results.wrap(:write, dispatch(:write, metadata.merge(store: store, key: key, value: value)))
23
+ end
24
+
25
+ def append(history:, event:, key: nil, partition_key: nil, **metadata)
26
+ packet = metadata.merge(history: history, event: event)
27
+ packet[:key] = key if key
28
+ packet[:partition_key] = partition_key if partition_key
29
+ Results.wrap(:append, dispatch(:append, packet))
30
+ end
31
+
32
+ def read(store:, key:, as_of: nil)
33
+ packet = { store: store, key: key }
34
+ packet[:as_of] = as_of if as_of
35
+ Results.wrap(:read, dispatch(:read, packet))
36
+ end
37
+
38
+ def query(store:, where:, limit: nil, as_of: nil, order: nil)
39
+ packet = { store: store, where: where }
40
+ packet[:limit] = limit if limit
41
+ packet[:as_of] = as_of if as_of
42
+ packet[:order] = order if order
43
+ Results.wrap(:query, dispatch(:query, packet))
44
+ end
45
+
46
+ def replay(store: nil, from: nil, to: nil, key: nil, partition_key: nil, partition_value: nil, filter: nil)
47
+ packet = {}
48
+ packet[:from] = from if from
49
+ packet[:to] = to if to
50
+ filter_packet = replay_filter(
51
+ store: store,
52
+ key: key,
53
+ partition_key: partition_key,
54
+ partition_value: partition_value,
55
+ filter: filter
56
+ )
57
+ packet[:filter] = filter_packet if filter_packet
58
+ Results.wrap(:replay, dispatch(:replay, packet))
59
+ end
60
+
61
+ def resolve(relation:, from:, as_of: nil)
62
+ packet = { relation: relation, from: from }
63
+ packet[:as_of] = as_of if as_of
64
+ Results.wrap(:resolve, dispatch(:resolve, packet))
65
+ end
66
+
67
+ def causation_chain(store:, key:)
68
+ Results.wrap(:causation_chain, dispatch(:causation_chain, { store: store, key: key }))
69
+ end
70
+
71
+ def lineage(store:, key:)
72
+ Results.wrap(:lineage, dispatch(:lineage, { store: store, key: key }))
73
+ end
74
+
75
+ def fact_ref(fact_id)
76
+ Results.wrap(:fact_ref, dispatch(:fact_ref, { fact_id: fact_id }))
77
+ end
78
+
79
+ def metadata_snapshot = dispatch(:metadata_snapshot)
80
+
81
+ def descriptor_snapshot = dispatch(:descriptor_snapshot)
82
+
83
+ def observability_snapshot = dispatch(:observability_snapshot)
84
+
85
+ def compaction_activity(store: nil, kind: nil, since: nil, limit: nil)
86
+ packet = {}
87
+ packet[:store] = store if store
88
+ packet[:kind] = kind if kind
89
+ packet[:since] = since if since
90
+ packet[:limit] = limit if limit
91
+ dispatch(:compaction_activity, packet)
92
+ end
93
+
94
+ def subscribe(stores:, cursor: nil, &block)
95
+ raise ArgumentError, "subscribe requires a block" unless block
96
+
97
+ raise NotImplementedError, "ledger client transport does not support subscriptions" unless transport.respond_to?(:subscribe)
98
+
99
+ transport.subscribe(stores: stores, cursor: cursor) do |event|
100
+ block.call(Results::ChangeEventResult.new(event))
101
+ end
102
+ end
103
+
104
+ def dispatch(operation, packet = {}, request_id: nil)
105
+ request = Envelope.request(operation: operation, packet: packet, request_id: request_id)
106
+ Envelope.result_or_raise(transport.dispatch(request))
107
+ rescue Error
108
+ raise
109
+ rescue StandardError => e
110
+ raise TransportError.new(e.message, request_id: request&.fetch(:request_id, nil))
111
+ end
112
+
113
+ def close
114
+ transport.close if transport.respond_to?(:close)
115
+ end
116
+
117
+ private
118
+
119
+ def replay_filter(store:, key:, partition_key:, partition_value:, filter:)
120
+ convenience_filter = {}
121
+ convenience_filter[:store] = store if store
122
+ convenience_filter[:key] = key if key
123
+ convenience_filter[:partition_key] = partition_key if partition_key
124
+ convenience_filter[:partition_value] = partition_value if partition_value
125
+
126
+ raise ArgumentError, "replay filter: cannot be combined with store/key/partition convenience arguments" if filter && !convenience_filter.empty?
127
+
128
+ return filter if filter
129
+ return nil if convenience_filter.empty?
130
+
131
+ convenience_filter
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Igniter
6
+ module LedgerClient
7
+ module Envelope
8
+ PROTOCOL = :igniter_store
9
+ SCHEMA_VERSION = 1
10
+
11
+ OPERATIONS = %i[
12
+ register_descriptor
13
+ write
14
+ append
15
+ write_fact
16
+ read
17
+ query
18
+ resolve
19
+ causation_chain
20
+ lineage
21
+ fact_ref
22
+ metadata_snapshot
23
+ descriptor_snapshot
24
+ observability_snapshot
25
+ sync_hub_profile
26
+ replay
27
+ storage_stats
28
+ segment_manifest
29
+ compaction_activity
30
+ ].freeze
31
+
32
+ module_function
33
+
34
+ def request(operation:, packet: {}, request_id: nil)
35
+ operation = operation.to_sym
36
+ raise ArgumentError, "unknown ledger op: #{operation.inspect}" unless OPERATIONS.include?(operation)
37
+
38
+ {
39
+ protocol: PROTOCOL,
40
+ schema_version: SCHEMA_VERSION,
41
+ request_id: request_id || generate_request_id,
42
+ op: operation,
43
+ packet: packet || {}
44
+ }
45
+ end
46
+
47
+ def ok?(response)
48
+ normalize(response)[:status]&.to_sym == :ok
49
+ end
50
+
51
+ def result_or_raise(response)
52
+ response = normalize(response)
53
+ return response[:result] if ok?(response)
54
+
55
+ raise Error.new(
56
+ response[:error] || "ledger client request failed",
57
+ response: response,
58
+ request_id: response[:request_id]
59
+ )
60
+ end
61
+
62
+ def normalize(hash)
63
+ hash.to_h.transform_keys(&:to_sym)
64
+ end
65
+
66
+ def generate_request_id
67
+ "req_#{SecureRandom.hex(12)}"
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module LedgerClient
5
+ class Error < StandardError
6
+ attr_reader :response, :request_id
7
+
8
+ def initialize(message, response: nil, request_id: nil)
9
+ super(message)
10
+ @response = response
11
+ @request_id = request_id
12
+ end
13
+ end
14
+
15
+ class TransportError < Error; end
16
+ end
17
+ end
@@ -0,0 +1,406 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module LedgerClient
5
+ module Results
6
+ module HashAccess
7
+ def [](key)
8
+ to_h[key.to_sym]
9
+ end
10
+ end
11
+
12
+ class ReceiptResult
13
+ include HashAccess
14
+
15
+ FIELDS = %i[
16
+ schema_version
17
+ kind
18
+ status
19
+ name
20
+ store
21
+ key
22
+ fact_id
23
+ value_hash
24
+ warnings
25
+ errors
26
+ derived
27
+ ].freeze
28
+
29
+ attr_reader(*FIELDS)
30
+
31
+ def initialize(raw = {})
32
+ data = self.class.normalize(raw)
33
+ @schema_version = data[:schema_version]
34
+ @kind = token(data[:kind])
35
+ @status = token(data[:status])
36
+ @name = token(data[:name])
37
+ @store = token(data[:store])
38
+ @key = data[:key]
39
+ @fact_id = data[:fact_id]
40
+ @value_hash = data[:value_hash]
41
+ @warnings = Array(data[:warnings]).freeze
42
+ @errors = Array(data[:errors]).freeze
43
+ @derived = Array(data[:derived]).freeze
44
+ freeze
45
+ end
46
+
47
+ def accepted? = status == :accepted
48
+
49
+ def rejected? = status == :rejected
50
+
51
+ def deduplicated? = status == :deduplicated
52
+
53
+ def to_h
54
+ {
55
+ schema_version: schema_version,
56
+ kind: kind,
57
+ status: status,
58
+ name: name,
59
+ store: store,
60
+ key: key,
61
+ fact_id: fact_id,
62
+ value_hash: value_hash,
63
+ warnings: warnings,
64
+ errors: errors,
65
+ derived: derived
66
+ }.compact
67
+ end
68
+
69
+ def self.normalize(raw)
70
+ hash = if raw.respond_to?(:to_h)
71
+ raw.to_h
72
+ else
73
+ FIELDS.each_with_object({}) do |field, acc|
74
+ acc[field] = raw.public_send(field) if raw.respond_to?(field)
75
+ end
76
+ end
77
+
78
+ hash.to_h.transform_keys(&:to_sym)
79
+ end
80
+
81
+ private
82
+
83
+ def token(value)
84
+ value.is_a?(String) ? value.to_sym : value
85
+ end
86
+ end
87
+
88
+ class WriteResult < ReceiptResult
89
+ end
90
+
91
+ class AppendResult < ReceiptResult
92
+ end
93
+
94
+ class ReadResult
95
+ include HashAccess
96
+
97
+ attr_reader :value
98
+
99
+ def initialize(raw = {})
100
+ data = normalize(raw)
101
+ @value = data[:value]
102
+ @found = data.key?(:found) ? boolean(data[:found]) : !value.nil?
103
+ freeze
104
+ end
105
+
106
+ def found? = @found
107
+
108
+ def to_h
109
+ { value: value, found: found? }
110
+ end
111
+
112
+ private
113
+
114
+ def normalize(raw)
115
+ hash = raw.respond_to?(:to_h) ? raw.to_h : {}
116
+ hash.each_with_object({}) { |(key, value), acc| acc[key.to_sym] = value }
117
+ end
118
+
119
+ def boolean(value)
120
+ value ? true : false
121
+ end
122
+ end
123
+
124
+ class QueryResult
125
+ include HashAccess
126
+
127
+ attr_reader :items, :results, :count
128
+
129
+ def initialize(raw = {})
130
+ data = normalize(raw)
131
+ @items = normalize_items(data[:items]).freeze
132
+ @results = Array(data[:results] || items.map { |item| item[:value] }).freeze
133
+ @count = data.key?(:count) ? data[:count].to_i : [items.size, results.size].max
134
+ freeze
135
+ end
136
+
137
+ def to_h
138
+ { items: items, results: results, count: count }
139
+ end
140
+
141
+ private
142
+
143
+ def normalize(raw)
144
+ hash = raw.respond_to?(:to_h) ? raw.to_h : {}
145
+ hash.each_with_object({}) { |(key, value), acc| acc[key.to_sym] = value }
146
+ end
147
+
148
+ def normalize_items(raw_items)
149
+ Array(raw_items).map do |item|
150
+ data = item.to_h.transform_keys(&:to_sym)
151
+ { key: data[:key], value: normalize_value(data[:value] || {}) }
152
+ end
153
+ end
154
+
155
+ def normalize_value(value)
156
+ return value unless value.is_a?(Hash)
157
+
158
+ value.each_with_object({}) { |(key, entry), acc| acc[key.to_sym] = entry }
159
+ end
160
+ end
161
+
162
+ class ResolveResult
163
+ include HashAccess
164
+
165
+ attr_reader :items, :results, :count
166
+
167
+ def initialize(raw = {})
168
+ data = normalize(raw)
169
+ @items = normalize_items(data[:items]).freeze
170
+ @results = Array(data[:results] || items.map { |item| item[:value] }).freeze
171
+ @count = data.key?(:count) ? data[:count].to_i : [items.size, results.size].max
172
+ freeze
173
+ end
174
+
175
+ def to_h
176
+ { items: items, results: results, count: count }
177
+ end
178
+
179
+ private
180
+
181
+ def normalize(raw)
182
+ return { results: raw } if raw.is_a?(Array)
183
+
184
+ hash = raw.respond_to?(:to_h) ? raw.to_h : {}
185
+ hash.each_with_object({}) { |(key, value), acc| acc[key.to_sym] = value }
186
+ end
187
+
188
+ def normalize_items(raw_items)
189
+ Array(raw_items).map do |item|
190
+ data = item.to_h.transform_keys(&:to_sym)
191
+ { key: data[:key], value: normalize_value(data[:value] || {}) }
192
+ end
193
+ end
194
+
195
+ def normalize_value(value)
196
+ return value unless value.is_a?(Hash)
197
+
198
+ value.each_with_object({}) { |(key, entry), acc| acc[key.to_sym] = entry }
199
+ end
200
+ end
201
+
202
+ class ReplayResult
203
+ include HashAccess
204
+
205
+ attr_reader :facts, :count
206
+
207
+ def initialize(raw = {})
208
+ data = normalize(raw)
209
+ @facts = Array(data[:facts]).freeze
210
+ @count = data.key?(:count) ? data[:count].to_i : facts.size
211
+ freeze
212
+ end
213
+
214
+ def to_h
215
+ { facts: facts, count: count }
216
+ end
217
+
218
+ private
219
+
220
+ def normalize(raw)
221
+ hash = raw.respond_to?(:to_h) ? raw.to_h : {}
222
+ hash.each_with_object({}) { |(key, value), acc| acc[key.to_sym] = value }
223
+ end
224
+ end
225
+
226
+ class CausationChainResult
227
+ include HashAccess
228
+
229
+ attr_reader :chain, :count
230
+
231
+ def initialize(raw = {})
232
+ data = normalize(raw)
233
+ @chain = Array(data[:chain]).map { |entry| normalize_hash(entry) }.freeze
234
+ @count = data.key?(:count) ? data[:count].to_i : chain.size
235
+ freeze
236
+ end
237
+
238
+ def to_h
239
+ { chain: chain, count: count }
240
+ end
241
+
242
+ private
243
+
244
+ def normalize(raw)
245
+ return { chain: raw } if raw.is_a?(Array)
246
+
247
+ normalize_hash(raw)
248
+ end
249
+
250
+ def normalize_hash(raw)
251
+ raw.respond_to?(:to_h) ? raw.to_h.transform_keys(&:to_sym) : {}
252
+ end
253
+ end
254
+
255
+ class LineageResult
256
+ include HashAccess
257
+
258
+ attr_reader :subject, :chain, :depth, :derived_by, :proof_hash
259
+
260
+ def initialize(raw = {})
261
+ data = normalize_hash(raw)
262
+ @subject = normalize_hash(data[:subject]).freeze
263
+ @chain = Array(data[:chain]).map { |entry| normalize_hash(entry) }.freeze
264
+ @depth = data.key?(:depth) ? data[:depth].to_i : chain.size
265
+ @derived_by = Array(data[:derived_by]).map { |entry| normalize_hash(entry) }.freeze
266
+ @proof_hash = data[:proof_hash]
267
+ freeze
268
+ end
269
+
270
+ def to_h
271
+ {
272
+ subject: subject,
273
+ chain: chain,
274
+ depth: depth,
275
+ derived_by: derived_by,
276
+ proof_hash: proof_hash
277
+ }
278
+ end
279
+
280
+ private
281
+
282
+ def normalize_hash(raw)
283
+ raw.respond_to?(:to_h) ? raw.to_h.transform_keys(&:to_sym) : {}
284
+ end
285
+ end
286
+
287
+ class FactRefResult
288
+ include HashAccess
289
+
290
+ attr_reader :ref
291
+
292
+ def initialize(raw = {})
293
+ data = normalize_hash(raw)
294
+ @ref = data[:ref] ? normalize_hash(data[:ref]).freeze : nil
295
+ @found = if data.key?(:found)
296
+ data[:found] ? true : false
297
+ else
298
+ !ref.nil?
299
+ end
300
+ freeze
301
+ end
302
+
303
+ def found? = @found
304
+
305
+ def to_h
306
+ { found: found?, ref: ref }
307
+ end
308
+
309
+ private
310
+
311
+ def normalize_hash(raw)
312
+ raw.respond_to?(:to_h) ? raw.to_h.transform_keys(&:to_sym) : {}
313
+ end
314
+ end
315
+
316
+ class ChangeEventResult
317
+ include HashAccess
318
+
319
+ attr_reader :sequence, :store, :key, :fact_id, :value_hash, :cursor, :raw
320
+
321
+ def initialize(raw = {})
322
+ @raw = raw
323
+ data = normalize(raw)
324
+ @cursor = normalize_cursor(data[:cursor])
325
+ @sequence = (data[:sequence] || cursor[:sequence])&.to_i
326
+ @store = token(data[:store])
327
+ @key = data[:key]
328
+ @fact_id = data[:fact_id]
329
+ @value_hash = data[:value_hash] || fact_value_hash(raw)
330
+ freeze
331
+ end
332
+
333
+ def to_h
334
+ {
335
+ sequence: sequence,
336
+ store: store,
337
+ key: key,
338
+ fact_id: fact_id,
339
+ value_hash: value_hash,
340
+ cursor: cursor,
341
+ raw: raw
342
+ }.compact
343
+ end
344
+
345
+ private
346
+
347
+ def normalize(raw)
348
+ hash = if raw.respond_to?(:to_h)
349
+ raw.to_h
350
+ else
351
+ %i[sequence store key fact_id value_hash cursor].each_with_object({}) do |field, acc|
352
+ acc[field] = raw.public_send(field) if raw.respond_to?(field)
353
+ end
354
+ end
355
+
356
+ hash.to_h.transform_keys(&:to_sym)
357
+ end
358
+
359
+ def normalize_cursor(cursor)
360
+ return {} unless cursor
361
+
362
+ cursor.to_h.transform_keys(&:to_sym).freeze
363
+ end
364
+
365
+ def fact_value_hash(raw)
366
+ return raw.fact.value_hash if raw.respond_to?(:fact) && raw.fact.respond_to?(:value_hash)
367
+
368
+ nil
369
+ end
370
+
371
+ def token(value)
372
+ value.is_a?(String) ? value.to_sym : value
373
+ end
374
+ end
375
+
376
+ module_function
377
+
378
+ def wrap(operation, raw)
379
+ case operation.to_sym
380
+ when :register_descriptor
381
+ ReceiptResult.new(raw)
382
+ when :write
383
+ WriteResult.new(raw)
384
+ when :append
385
+ AppendResult.new(raw)
386
+ when :read
387
+ ReadResult.new(raw)
388
+ when :query
389
+ QueryResult.new(raw)
390
+ when :resolve
391
+ ResolveResult.new(raw)
392
+ when :replay
393
+ ReplayResult.new(raw)
394
+ when :causation_chain
395
+ CausationChainResult.new(raw)
396
+ when :lineage
397
+ LineageResult.new(raw)
398
+ when :fact_ref
399
+ FactRefResult.new(raw)
400
+ else
401
+ raw
402
+ end
403
+ end
404
+ end
405
+ end
406
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module LedgerClient
5
+ class Subscription
6
+ attr_accessor :error
7
+
8
+ def initialize(&close_proc)
9
+ @close_proc = close_proc
10
+ @closed = false
11
+ @mutex = Mutex.new
12
+ end
13
+
14
+ def close
15
+ close_proc = nil
16
+ @mutex.synchronize do
17
+ return self if @closed
18
+
19
+ @closed = true
20
+ close_proc = @close_proc
21
+ end
22
+
23
+ close_proc&.call
24
+ self
25
+ end
26
+
27
+ def closed?
28
+ @mutex.synchronize { @closed }
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../subscription"
4
+
5
+ module Igniter
6
+ module LedgerClient
7
+ module Transports
8
+ class ObjectDispatch
9
+ def initialize(target)
10
+ @target = target
11
+ end
12
+
13
+ def dispatch(envelope)
14
+ if @target.respond_to?(:dispatch)
15
+ @target.dispatch(envelope)
16
+ elsif @target.respond_to?(:wire)
17
+ @target.wire.dispatch(envelope)
18
+ elsif @target.respond_to?(:protocol) && @target.protocol.respond_to?(:wire)
19
+ @target.protocol.wire.dispatch(envelope)
20
+ else
21
+ raise ArgumentError, "object does not expose dispatch(envelope), wire.dispatch(envelope), or protocol.wire.dispatch(envelope)"
22
+ end
23
+ end
24
+
25
+ def subscribe(stores:, cursor: nil, &block)
26
+ raise ArgumentError, "subscribe requires a block" unless block
27
+
28
+ feed = changefeed_source
29
+ raise NotImplementedError, "object dispatch target does not expose changefeed.subscribe" unless feed.respond_to?(:subscribe)
30
+
31
+ replay(feed, stores: stores, cursor: cursor, handler: block) if cursor
32
+ handle = feed.subscribe(stores: stores) { |event| block.call(event) }
33
+ Subscription.new { handle.close }
34
+ end
35
+
36
+ def close
37
+ @target.close if @target.respond_to?(:close)
38
+ end
39
+
40
+ private
41
+
42
+ def changefeed_source
43
+ @target.changefeed if @target.respond_to?(:changefeed)
44
+ end
45
+
46
+ def replay(feed, stores:, cursor:, handler:)
47
+ return unless feed.respond_to?(:replay)
48
+
49
+ store_filter = Array(stores).empty? ? nil : stores
50
+ result = feed.replay(cursor: normalize_cursor(cursor), stores: store_filter)
51
+ raise TransportError, "changefeed cursor is too old" if result[:status].to_sym == :cursor_too_old
52
+
53
+ Array(result[:events]).each { |event| handler.call(event) }
54
+ end
55
+
56
+ def normalize_cursor(cursor)
57
+ return cursor if cursor.is_a?(Hash)
58
+
59
+ { sequence: cursor.to_i }
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+ require_relative "../subscription"
7
+
8
+ module Igniter
9
+ module LedgerClient
10
+ module Transports
11
+ class RemoteHTTP
12
+ attr_reader :uri, :events_uri, :open_timeout, :read_timeout
13
+
14
+ def initialize(endpoint, events_url: nil, open_timeout: 1.0, read_timeout: 2.0, write_timeout: nil, headers: {})
15
+ @uri = normalize_endpoint(endpoint)
16
+ @events_uri = normalize_events_endpoint(events_url)
17
+ @open_timeout = open_timeout
18
+ @read_timeout = read_timeout
19
+ @write_timeout = write_timeout
20
+ @headers = headers
21
+ end
22
+
23
+ def dispatch(envelope)
24
+ request = Net::HTTP::Post.new(uri)
25
+ request["Content-Type"] = "application/json"
26
+ @headers.each { |key, value| request[key.to_s] = value }
27
+ request.body = JSON.generate(envelope)
28
+
29
+ response = http.request(request)
30
+ raise TransportError, "ledger HTTP #{uri} returned #{response.code}" unless response.code.to_i.between?(200, 299)
31
+
32
+ JSON.parse(response.body, symbolize_names: true)
33
+ rescue JSON::ParserError => e
34
+ raise TransportError, "invalid ledger HTTP response: #{e.message}"
35
+ end
36
+
37
+ def subscribe(stores:, cursor: nil, &block)
38
+ raise ArgumentError, "subscribe requires a block" unless block
39
+
40
+ http_client = nil
41
+ thread = nil
42
+ subscription = Subscription.new do
43
+ http_client&.finish if http_client&.started?
44
+ thread&.join(1) unless Thread.current.equal?(thread)
45
+ rescue IOError, SystemCallError
46
+ nil
47
+ end
48
+
49
+ thread = Thread.new do
50
+ stream_uri = events_stream_uri(stores: stores, cursor: cursor)
51
+ request = Net::HTTP::Get.new(stream_uri)
52
+ request["Accept"] = "text/event-stream"
53
+ @headers.each { |key, value| request[key.to_s] = value }
54
+ http_client = http_for(stream_uri)
55
+ http_client.request(request) do |response|
56
+ raise TransportError, "ledger SSE #{stream_uri} returned #{response.code}" unless response.code.to_i.between?(200, 299)
57
+
58
+ read_sse(response, subscription, &block)
59
+ end
60
+ rescue StandardError => e
61
+ subscription.error = e unless subscription&.closed?
62
+ ensure
63
+ subscription&.close unless subscription&.closed?
64
+ end
65
+ subscription
66
+ end
67
+
68
+ private
69
+
70
+ def http
71
+ http_for(uri)
72
+ end
73
+
74
+ def http_for(target_uri)
75
+ Net::HTTP.new(target_uri.host, target_uri.port).tap do |client|
76
+ client.use_ssl = target_uri.scheme == "https"
77
+ client.open_timeout = open_timeout if open_timeout
78
+ client.read_timeout = read_timeout if read_timeout
79
+ client.write_timeout = @write_timeout if @write_timeout && client.respond_to?(:write_timeout=)
80
+ end
81
+ end
82
+
83
+ def normalize_endpoint(endpoint)
84
+ parsed = URI(endpoint.to_s)
85
+ parsed.path = "/v1/dispatch" if parsed.path.nil? || parsed.path.empty? || parsed.path == "/"
86
+ parsed
87
+ end
88
+
89
+ def normalize_events_endpoint(events_url)
90
+ return derive_events_uri unless events_url
91
+
92
+ parsed = URI(events_url.to_s)
93
+ parsed.path = "/v1/events" if parsed.path.nil? || parsed.path.empty? || parsed.path == "/"
94
+ parsed
95
+ end
96
+
97
+ def derive_events_uri
98
+ uri.dup.tap do |parsed|
99
+ parsed.path = parsed.path.end_with?("/v1/dispatch") ? parsed.path.sub(%r{/v1/dispatch\z}, "/v1/events") : "/v1/events"
100
+ end
101
+ end
102
+
103
+ def events_stream_uri(stores:, cursor:)
104
+ events_uri.dup.tap do |parsed|
105
+ params = URI.decode_www_form(parsed.query.to_s)
106
+ store_names = Array(stores).map(&:to_s).reject(&:empty?)
107
+ params << ["stores", store_names.join(",")] unless store_names.empty?
108
+ sequence = cursor_sequence(cursor)
109
+ params << ["cursor", sequence.to_s] if sequence
110
+ parsed.query = params.empty? ? nil : URI.encode_www_form(params)
111
+ end
112
+ end
113
+
114
+ def cursor_sequence(cursor)
115
+ return nil unless cursor
116
+
117
+ data = cursor.respond_to?(:to_h) ? cursor.to_h.transform_keys(&:to_sym) : { sequence: cursor }
118
+ data[:sequence]
119
+ end
120
+
121
+ def read_sse(response, subscription, &block)
122
+ buffer = +""
123
+ response.read_body do |chunk|
124
+ break if subscription&.closed?
125
+
126
+ buffer << chunk
127
+ while (frame = next_sse_frame(buffer))
128
+ event = parse_sse_frame(frame)
129
+ block.call(event) if event
130
+ end
131
+ end
132
+ end
133
+
134
+ def next_sse_frame(buffer)
135
+ idx = buffer.index("\n\n")
136
+ sep_len = 2
137
+ unless idx
138
+ idx = buffer.index("\r\n\r\n")
139
+ sep_len = 4
140
+ end
141
+ return nil unless idx
142
+
143
+ buffer.slice!(0, idx + sep_len)
144
+ end
145
+
146
+ def parse_sse_frame(frame)
147
+ event_id = nil
148
+ data_lines = []
149
+
150
+ frame.each_line do |line|
151
+ line = line.chomp
152
+ event_id = line.sub("id: ", "") if line.start_with?("id: ")
153
+ data_lines << line.sub("data: ", "") if line.start_with?("data: ")
154
+ end
155
+ return nil if data_lines.empty?
156
+
157
+ data = JSON.parse(data_lines.join("\n"), symbolize_names: true)
158
+ data[:sequence] ||= event_id.to_i if event_id
159
+ data
160
+ rescue JSON::ParserError => e
161
+ raise TransportError, "invalid ledger SSE event: #{e.message}"
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ledger_client/envelope"
4
+ require_relative "ledger_client/error"
5
+ require_relative "ledger_client/results"
6
+ require_relative "ledger_client/subscription"
7
+ require_relative "ledger_client/client"
8
+ require_relative "ledger_client/transports/object_dispatch"
9
+ require_relative "ledger_client/transports/remote_http"
10
+
11
+ module Igniter
12
+ module LedgerClient
13
+ def self.wrap(target)
14
+ return target if target.is_a?(Client)
15
+
16
+ Client.new(transport: Transports::ObjectDispatch.new(target))
17
+ end
18
+
19
+ def self.remote_http(endpoint, **options)
20
+ Client.new(transport: Transports::RemoteHTTP.new(endpoint, **options))
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "igniter/ledger_client"
metadata ADDED
@@ -0,0 +1,52 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: igniter-ledger-client
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.5.2
5
+ platform: ruby
6
+ authors:
7
+ - Alexander
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: 'Small client-side package for the Igniter Ledger/Open Protocol boundary:
13
+ envelopes, transports, errors, and stable client methods without embedding the storage
14
+ engine.'
15
+ email:
16
+ - alexander.s.fokin@gmail.com
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - README.md
22
+ - lib/igniter-ledger-client.rb
23
+ - lib/igniter/ledger_client.rb
24
+ - lib/igniter/ledger_client/client.rb
25
+ - lib/igniter/ledger_client/envelope.rb
26
+ - lib/igniter/ledger_client/error.rb
27
+ - lib/igniter/ledger_client/results.rb
28
+ - lib/igniter/ledger_client/subscription.rb
29
+ - lib/igniter/ledger_client/transports/object_dispatch.rb
30
+ - lib/igniter/ledger_client/transports/remote_http.rb
31
+ homepage: https://github.com/alexander-s-f/igniter
32
+ licenses:
33
+ - MIT
34
+ metadata: {}
35
+ rdoc_options: []
36
+ require_paths:
37
+ - lib
38
+ required_ruby_version: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: 3.1.0
43
+ required_rubygems_version: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ requirements: []
49
+ rubygems_version: 4.0.10
50
+ specification_version: 4
51
+ summary: Protocol-first Ledger client package for Igniter
52
+ test_files: []