igniter-ledger 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.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +481 -0
  3. data/examples/intelligent_ledger/availability_boundary_ledger.rb +1190 -0
  4. data/examples/intelligent_ledger/availability_deriver.rb +150 -0
  5. data/examples/intelligent_ledger/availability_ledger.rb +197 -0
  6. data/examples/intelligent_ledger/ledger_boundary.rb +180 -0
  7. data/examples/store_poc.rb +45 -0
  8. data/exe/igniter-ledger-server +111 -0
  9. data/exe/igniter-store-server +6 -0
  10. data/ext/igniter_store_native/Cargo.toml +28 -0
  11. data/ext/igniter_store_native/extconf.rb +6 -0
  12. data/ext/igniter_store_native/src/fact.rs +303 -0
  13. data/ext/igniter_store_native/src/fact_log.rs +180 -0
  14. data/ext/igniter_store_native/src/file_backend.rs +91 -0
  15. data/ext/igniter_store_native/src/lib.rs +55 -0
  16. data/lib/igniter/ledger.rb +7 -0
  17. data/lib/igniter/store/access_path.rb +84 -0
  18. data/lib/igniter/store/change_event.rb +65 -0
  19. data/lib/igniter/store/changefeed_buffer.rb +585 -0
  20. data/lib/igniter/store/codecs.rb +253 -0
  21. data/lib/igniter/store/contractable_receipt_sink.rb +172 -0
  22. data/lib/igniter/store/fact.rb +121 -0
  23. data/lib/igniter/store/fact_log.rb +103 -0
  24. data/lib/igniter/store/file_backend.rb +269 -0
  25. data/lib/igniter/store/http_adapter.rb +413 -0
  26. data/lib/igniter/store/igniter_store.rb +838 -0
  27. data/lib/igniter/store/mcp_adapter.rb +403 -0
  28. data/lib/igniter/store/native.rb +80 -0
  29. data/lib/igniter/store/network_backend.rb +159 -0
  30. data/lib/igniter/store/protocol/handlers/access_path_handler.rb +38 -0
  31. data/lib/igniter/store/protocol/handlers/command_handler.rb +59 -0
  32. data/lib/igniter/store/protocol/handlers/derivation_handler.rb +27 -0
  33. data/lib/igniter/store/protocol/handlers/effect_handler.rb +65 -0
  34. data/lib/igniter/store/protocol/handlers/history_handler.rb +24 -0
  35. data/lib/igniter/store/protocol/handlers/projection_handler.rb +41 -0
  36. data/lib/igniter/store/protocol/handlers/relation_handler.rb +43 -0
  37. data/lib/igniter/store/protocol/handlers/store_handler.rb +24 -0
  38. data/lib/igniter/store/protocol/handlers/subscription_handler.rb +24 -0
  39. data/lib/igniter/store/protocol/interpreter.rb +447 -0
  40. data/lib/igniter/store/protocol/receipt.rb +96 -0
  41. data/lib/igniter/store/protocol/sync_profile.rb +53 -0
  42. data/lib/igniter/store/protocol/wire_envelope.rb +214 -0
  43. data/lib/igniter/store/protocol.rb +27 -0
  44. data/lib/igniter/store/read_cache.rb +163 -0
  45. data/lib/igniter/store/schema_graph.rb +248 -0
  46. data/lib/igniter/store/segmented_file_backend.rb +699 -0
  47. data/lib/igniter/store/server_config.rb +55 -0
  48. data/lib/igniter/store/server_logger.rb +64 -0
  49. data/lib/igniter/store/server_metrics.rb +222 -0
  50. data/lib/igniter/store/store_server.rb +597 -0
  51. data/lib/igniter/store/subscription_registry.rb +73 -0
  52. data/lib/igniter/store/tbackend_adapter_descriptor.rb +307 -0
  53. data/lib/igniter/store/tcp_adapter.rb +127 -0
  54. data/lib/igniter/store/wire_protocol.rb +42 -0
  55. data/lib/igniter/store.rb +64 -0
  56. data/lib/igniter-ledger.rb +4 -0
  57. data/lib/igniter-store.rb +5 -0
  58. metadata +212 -0
@@ -0,0 +1,403 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "securerandom"
6
+ require "set"
7
+ require "uri"
8
+
9
+ module Igniter
10
+ module Store
11
+ # Read-only MCP adapter over Store Open Protocol.
12
+ #
13
+ # Every tool call lowers to a Protocol::Interpreter method or a named
14
+ # protocol metadata view. The adapter never touches backends directly,
15
+ # never executes Igniter contracts, and never evaluates Ruby DSL.
16
+ #
17
+ # Usage — embedded (local store):
18
+ #
19
+ # store = Igniter::Store.segmented(root_dir)
20
+ # adapter = Igniter::Store::MCPAdapter.new(store)
21
+ # result = adapter.call_tool(:query, store: "tasks", where: {}, limit: 50)
22
+ #
23
+ # Usage — wrap an existing Interpreter:
24
+ #
25
+ # adapter = Igniter::Store::MCPAdapter.new(proto) # proto = Protocol::Interpreter
26
+ #
27
+ # Usage — remote StoreServer /v1/dispatch:
28
+ #
29
+ # adapter = Igniter::Store::MCPAdapter.remote("http://127.0.0.1:7300/v1/dispatch")
30
+ #
31
+ # Every response includes:
32
+ # schema_version, request_id, source_protocol_op, status, result | error
33
+ #
34
+ # Mutating tools (write_fact, register_descriptor, compact, checkpoint) are
35
+ # disabled by default and require an explicit :enabled_tools list.
36
+ class MCPAdapter
37
+ SCHEMA_VERSION = 1
38
+
39
+ READ_TOOLS = %i[
40
+ metadata_snapshot
41
+ descriptor_snapshot
42
+ observability_snapshot
43
+ read
44
+ query
45
+ resolve
46
+ causation_chain
47
+ lineage
48
+ fact_ref
49
+ replay
50
+ sync_profile
51
+ storage_stats
52
+ segment_manifest
53
+ compaction_activity
54
+ ].freeze
55
+
56
+ TOOL_TO_OP = {
57
+ metadata_snapshot: :metadata_snapshot,
58
+ descriptor_snapshot: :descriptor_snapshot,
59
+ observability_snapshot: :observability_snapshot,
60
+ read: :read,
61
+ query: :query,
62
+ resolve: :resolve,
63
+ causation_chain: :causation_chain,
64
+ lineage: :lineage,
65
+ fact_ref: :fact_ref,
66
+ replay: :replay,
67
+ sync_profile: :sync_hub_profile,
68
+ storage_stats: :storage_stats,
69
+ segment_manifest: :segment_manifest,
70
+ compaction_activity: :compaction_activity
71
+ }.freeze
72
+
73
+ class RemoteDispatch
74
+ def initialize(endpoint)
75
+ @uri = normalize_endpoint(endpoint)
76
+ end
77
+
78
+ def dispatch(op:, packet:, request_id:)
79
+ envelope = {
80
+ protocol: :igniter_store,
81
+ schema_version: SCHEMA_VERSION,
82
+ request_id: request_id,
83
+ op: op,
84
+ packet: packet
85
+ }
86
+
87
+ request = Net::HTTP::Post.new(@uri)
88
+ request["Content-Type"] = "application/json"
89
+ request.body = JSON.generate(envelope)
90
+
91
+ http = Net::HTTP.new(@uri.host, @uri.port)
92
+ http.use_ssl = @uri.scheme == "https"
93
+ response = http.request(request)
94
+ unless response.code.to_i.between?(200, 299)
95
+ raise "HTTP #{@uri} returned #{response.code}"
96
+ end
97
+
98
+ JSON.parse(response.body, symbolize_names: true)
99
+ end
100
+
101
+ private
102
+
103
+ def normalize_endpoint(endpoint)
104
+ uri = URI(endpoint.to_s)
105
+ uri.path = "/v1/dispatch" if uri.path.nil? || uri.path.empty? || uri.path == "/"
106
+ uri
107
+ end
108
+ end
109
+
110
+ def self.remote(endpoint, enabled_tools: READ_TOOLS)
111
+ new(RemoteDispatch.new(endpoint), enabled_tools: enabled_tools)
112
+ end
113
+
114
+ # +interpreter_or_store+ — Protocol::Interpreter, IgniterStore, or a store
115
+ # returned by Igniter::Store.segmented / Igniter::Store.memory.
116
+ # +enabled_tools+ — Array of tool name Symbols. Defaults to READ_TOOLS.
117
+ def initialize(interpreter_or_store, enabled_tools: READ_TOOLS)
118
+ @interpreter = case interpreter_or_store
119
+ when Protocol::Interpreter
120
+ interpreter_or_store
121
+ when IgniterStore
122
+ Protocol::Interpreter.new(interpreter_or_store)
123
+ when RemoteDispatch
124
+ interpreter_or_store
125
+ else
126
+ raise ArgumentError,
127
+ "MCPAdapter expects a Protocol::Interpreter, IgniterStore, or RemoteDispatch, " \
128
+ "got #{interpreter_or_store.class}"
129
+ end
130
+ @remote = interpreter_or_store.is_a?(RemoteDispatch)
131
+ @enabled = enabled_tools.map(&:to_sym).to_set
132
+ end
133
+
134
+ # Returns an Array of tool schema Hashes (name + description + input_schema).
135
+ def tool_list
136
+ READ_TOOLS.select { |t| @enabled.include?(t) }.map { |t| tool_schema(t) }
137
+ end
138
+
139
+ # Call a named tool with an arguments Hash (symbol or string keys).
140
+ # Returns a response Hash with schema_version, request_id, status, etc.
141
+ # Never raises — errors are captured into the response envelope.
142
+ def call_tool(name, arguments = {})
143
+ tool = nil
144
+ req = nil
145
+ tool = name.to_sym
146
+ args = arguments.transform_keys(&:to_sym)
147
+ req = args.delete(:request_id) || generate_request_id
148
+
149
+ unless @enabled.include?(tool)
150
+ return error_response(tool, req, "Tool #{tool.inspect} is not enabled")
151
+ end
152
+
153
+ result = dispatch(tool, args, request_id: req)
154
+ ok_response(tool, req, result)
155
+ rescue ArgumentError => e
156
+ error_response(tool, req || generate_request_id, e.message)
157
+ rescue StandardError => e
158
+ error_response(tool, req || generate_request_id, "Internal error: #{e.message}")
159
+ end
160
+
161
+ private
162
+
163
+ def dispatch(name, args, request_id:)
164
+ return remote_dispatch(name, args, request_id: request_id) if @remote
165
+
166
+ case name
167
+ when :metadata_snapshot
168
+ @interpreter.metadata_snapshot
169
+
170
+ when :descriptor_snapshot
171
+ @interpreter.descriptor_snapshot
172
+
173
+ when :observability_snapshot
174
+ @interpreter.observability_snapshot
175
+
176
+ when :read
177
+ @interpreter.read(
178
+ store: args.fetch(:store),
179
+ key: args.fetch(:key),
180
+ as_of: args[:as_of]
181
+ )
182
+
183
+ when :query
184
+ raise ArgumentError, "query: requires limit:" unless args.key?(:limit)
185
+ items = @interpreter.query(
186
+ store: args.fetch(:store),
187
+ where: args.fetch(:where, {}),
188
+ order: args[:order],
189
+ limit: args[:limit].to_i,
190
+ as_of: args[:as_of]
191
+ )
192
+ items.map { |item| item[:value] }
193
+
194
+ when :resolve
195
+ @interpreter.resolve(
196
+ args.fetch(:relation).to_sym,
197
+ from: args.fetch(:from),
198
+ as_of: args[:as_of]
199
+ )
200
+
201
+ when :causation_chain
202
+ chain = @interpreter.causation_chain(
203
+ store: args.fetch(:store),
204
+ key: args.fetch(:key)
205
+ )
206
+ { chain: chain, count: chain.size }
207
+
208
+ when :lineage
209
+ @interpreter.lineage(
210
+ store: args.fetch(:store),
211
+ key: args.fetch(:key)
212
+ )
213
+
214
+ when :fact_ref
215
+ ref = @interpreter.fact_ref(args.fetch(:fact_id))
216
+ { found: !ref.nil?, ref: ref }
217
+
218
+ when :replay
219
+ unless args[:limit] || args[:store] || args[:from]
220
+ raise ArgumentError, "replay: requires at least one bounding argument (limit:, store:, or from:)"
221
+ end
222
+ filter = args[:store] ? { store: args[:store] } : args[:filter]
223
+ facts = @interpreter.replay(from: args[:from], to: args[:to], filter: filter)
224
+ facts = facts.first(args[:limit].to_i) if args[:limit]
225
+ { facts: facts, count: facts.size }
226
+
227
+ when :sync_profile
228
+ @interpreter.sync_hub_profile(
229
+ as_of: args[:as_of],
230
+ cursor: args[:cursor],
231
+ stores: args[:stores]
232
+ )
233
+
234
+ when :storage_stats
235
+ @interpreter.storage_stats(store: args[:store])
236
+
237
+ when :segment_manifest
238
+ @interpreter.segment_manifest(store: args[:store])
239
+
240
+ when :compaction_activity
241
+ @interpreter.compaction_activity(
242
+ store: args[:store],
243
+ kind: args[:kind],
244
+ since: args[:since],
245
+ limit: args[:limit]
246
+ )
247
+ end
248
+ end
249
+
250
+ def remote_dispatch(name, args, request_id:)
251
+ packet = packet_for(name, args)
252
+ op = TOOL_TO_OP.fetch(name)
253
+ response = @interpreter.dispatch(op: op, packet: packet, request_id: request_id)
254
+ status = response[:status]&.to_sym
255
+ raise "remote dispatch #{op.inspect} failed: #{response[:error]}" unless status == :ok
256
+
257
+ normalize_wire_result(name, response[:result])
258
+ end
259
+
260
+ def packet_for(name, args)
261
+ case name
262
+ when :metadata_snapshot, :descriptor_snapshot, :observability_snapshot
263
+ {}
264
+ when :read
265
+ { store: args.fetch(:store), key: args.fetch(:key), as_of: args[:as_of] }
266
+ when :query
267
+ raise ArgumentError, "query: requires limit:" unless args.key?(:limit)
268
+ { store: args.fetch(:store), where: args.fetch(:where, {}),
269
+ order: args[:order], limit: args[:limit].to_i, as_of: args[:as_of] }
270
+ when :resolve
271
+ { relation: args.fetch(:relation), from: args.fetch(:from), as_of: args[:as_of] }
272
+ when :causation_chain, :lineage
273
+ { store: args.fetch(:store), key: args.fetch(:key) }
274
+ when :fact_ref
275
+ { fact_id: args.fetch(:fact_id) }
276
+ when :replay
277
+ unless args[:limit] || args[:store] || args[:from]
278
+ raise ArgumentError, "replay: requires at least one bounding argument (limit:, store:, or from:)"
279
+ end
280
+ packet = { from: args[:from], to: args[:to], filter: args[:filter] }
281
+ packet[:filter] = { store: args[:store] } if args[:store]
282
+ packet[:limit] = args[:limit].to_i if args[:limit]
283
+ packet
284
+ when :sync_profile
285
+ { as_of: args[:as_of], cursor: args[:cursor], stores: args[:stores] }
286
+ when :storage_stats, :segment_manifest
287
+ { store: args[:store] }
288
+ when :compaction_activity
289
+ { store: args[:store], kind: args[:kind], since: args[:since], limit: args[:limit] }
290
+ else
291
+ {}
292
+ end.compact
293
+ end
294
+
295
+ def normalize_wire_result(name, result)
296
+ case name
297
+ when :read
298
+ result[:value]
299
+ when :query, :resolve
300
+ result[:results]
301
+ when :replay
302
+ facts = result[:facts]
303
+ count = result[:count]
304
+ { facts: facts, count: count }
305
+ else
306
+ result
307
+ end
308
+ end
309
+
310
+ def ok_response(tool, request_id, result)
311
+ {
312
+ schema_version: SCHEMA_VERSION,
313
+ request_id: request_id,
314
+ source_protocol_op: TOOL_TO_OP[tool],
315
+ status: :ok,
316
+ result: result
317
+ }
318
+ end
319
+
320
+ def error_response(tool, request_id, message)
321
+ {
322
+ schema_version: SCHEMA_VERSION,
323
+ request_id: request_id,
324
+ source_protocol_op: TOOL_TO_OP[tool],
325
+ status: :error,
326
+ error: message
327
+ }
328
+ end
329
+
330
+ def generate_request_id
331
+ "mcp_#{SecureRandom.hex(8)}"
332
+ end
333
+
334
+ def tool_schema(name)
335
+ {
336
+ name: name.to_s,
337
+ description: tool_description(name),
338
+ input_schema: tool_input_schema(name)
339
+ }
340
+ end
341
+
342
+ def tool_description(name)
343
+ {
344
+ metadata_snapshot: "Return the full protocol registry metadata snapshot.",
345
+ descriptor_snapshot: "Return registered descriptors grouped by kind.",
346
+ observability_snapshot: "Return the canonical observability snapshot: status, alerts, storage.",
347
+ read: "Read the current (or as_of) value for one key.",
348
+ query: "Query a bounded store view with optional where/order/limit/as_of.",
349
+ resolve: "Resolve a registered relation from a source key.",
350
+ causation_chain: "Return the compact causation chain for a store/key.",
351
+ lineage: "Return read-only lineage proof metadata for a store/key.",
352
+ fact_ref: "Return compact metadata for a fact id without exposing fact value.",
353
+ replay: "Replay bounded facts by store, time range, or limit.",
354
+ sync_profile: "Return a sync hub profile (facts + descriptors + cursor).",
355
+ storage_stats: "Return aggregate storage statistics for one or all stores.",
356
+ segment_manifest: "Return per-segment storage manifest for one or all stores.",
357
+ compaction_activity: "Return normalized compaction lifecycle activity (retention compact, exact prune, segment purge)."
358
+ }.fetch(name, name.to_s)
359
+ end
360
+
361
+ def tool_input_schema(name)
362
+ case name
363
+ when :read
364
+ { type: "object", required: ["store", "key"],
365
+ properties: { store: { type: "string" }, key: { type: "string" },
366
+ as_of: { type: "number" } } }
367
+ when :query
368
+ { type: "object", required: ["store", "limit"],
369
+ properties: { store: { type: "string" }, where: { type: "object" },
370
+ order: { type: "string" }, limit: { type: "integer" },
371
+ as_of: { type: "number" } } }
372
+ when :resolve
373
+ { type: "object", required: ["relation", "from"],
374
+ properties: { relation: { type: "string" }, from: { type: "string" },
375
+ as_of: { type: "number" } } }
376
+ when :causation_chain, :lineage
377
+ { type: "object", required: ["store", "key"],
378
+ properties: { store: { type: "string" }, key: { type: "string" } } }
379
+ when :fact_ref
380
+ { type: "object", required: ["fact_id"],
381
+ properties: { fact_id: { type: "string" } } }
382
+ when :replay
383
+ { type: "object",
384
+ properties: { store: { type: "string" }, from: { type: "number" },
385
+ to: { type: "number" }, limit: { type: "integer" } } }
386
+ when :storage_stats, :segment_manifest
387
+ { type: "object",
388
+ properties: { store: { type: "string" } } }
389
+ when :sync_profile
390
+ { type: "object",
391
+ properties: { stores: { type: "array" }, cursor: { type: "object" },
392
+ as_of: { type: "number" } } }
393
+ when :compaction_activity
394
+ { type: "object",
395
+ properties: { store: { type: "string" }, kind: { type: "string" },
396
+ since: { type: "number" }, limit: { type: "integer" } } }
397
+ else
398
+ { type: "object", properties: {} }
399
+ end
400
+ end
401
+ end
402
+ end
403
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Try to load the compiled Rust extension.
4
+ # Falls back silently to pure-Ruby implementations if not compiled.
5
+ begin
6
+ # The compiled bundle lives in the same directory as this file.
7
+ $LOAD_PATH.unshift(__dir__) unless $LOAD_PATH.include?(__dir__)
8
+ require "igniter_store_native"
9
+
10
+ # ── Ruby wrappers on top of Rust-defined Fact ─────────────────────────────
11
+ # Translates keyword args to the positional _native_build method (8-arg form).
12
+ class Igniter::Store::Fact
13
+ def self.build(store:, key:, value:, causation: nil, valid_time: nil, term: nil,
14
+ schema_version: 1, producer: nil, derivation: nil)
15
+ # term: is a deprecated alias for valid_time — accepted for compat.
16
+ vt = valid_time.nil? ? (term ? term.to_f : nil) : valid_time.to_f
17
+ _native_build(
18
+ store.to_s,
19
+ key.to_s,
20
+ value,
21
+ causation,
22
+ vt,
23
+ schema_version.to_i,
24
+ producer,
25
+ derivation
26
+ )
27
+ end
28
+
29
+ alias_method :_native_value, :value
30
+ alias_method :_native_producer, :producer
31
+ alias_method :_native_derivation, :derivation
32
+
33
+ def value = self.class.deep_freeze_native_value(_native_value)
34
+
35
+ def producer = self.class.deep_freeze_native_value(_native_producer)
36
+
37
+ def derivation = self.class.deep_freeze_native_value(_native_derivation)
38
+
39
+ def self.deep_freeze_native_value(value)
40
+ case value
41
+ when Hash
42
+ value.transform_values { |entry| deep_freeze_native_value(entry) }.freeze
43
+ when Array
44
+ value.map { |entry| deep_freeze_native_value(entry) }.freeze
45
+ else
46
+ value.frozen? ? value : value.dup.freeze
47
+ end
48
+ end
49
+ end
50
+
51
+ # ── Ruby wrappers on top of Rust-defined FactLog ──────────────────────────
52
+ # Translates keyword args to positional native methods.
53
+ class Igniter::Store::FactLog
54
+ def initialize(backend: nil)
55
+ # backend is handled by IgniterStore, not stored here
56
+ end
57
+
58
+ def append(fact)
59
+ _native_append(fact)
60
+ fact
61
+ end
62
+
63
+ def latest_for(store:, key:, as_of: nil)
64
+ latest_for_native(store.to_s, key.to_s, as_of&.to_f)
65
+ end
66
+
67
+ def facts_for(store:, key: nil, since: nil, as_of: nil)
68
+ facts_for_native(store.to_s, key&.to_s, since&.to_f, as_of&.to_f)
69
+ end
70
+
71
+ def query_scope(store:, filters:, as_of: nil)
72
+ query_scope_native(store.to_s, filters, as_of&.to_f)
73
+ end
74
+ end
75
+
76
+ Igniter::Store.send(:remove_const, :NATIVE) if Igniter::Store.const_defined?(:NATIVE)
77
+ Igniter::Store::NATIVE = true
78
+ rescue LoadError
79
+ # NATIVE already set to false by store.rb
80
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "json"
5
+ require_relative "wire_protocol"
6
+
7
+ module Igniter
8
+ module Store
9
+ # NetworkBackend — client-side backend that proxies write_fact / replay /
10
+ # write_snapshot over a TCP or Unix socket connection to a StoreServer.
11
+ #
12
+ # The wire protocol is CRC32-framed JSON (same framing as the WAL file format).
13
+ # Each request is a single frame; the server replies with a single frame.
14
+ #
15
+ # Usage (via Companion::Store):
16
+ # store = Igniter::Companion::Store.new(
17
+ # backend: :network,
18
+ # address: "127.0.0.1:7400",
19
+ # transport: :tcp # default; or :unix for Unix domain sockets
20
+ # )
21
+ #
22
+ # Direct usage:
23
+ # nb = Igniter::Store::NetworkBackend.new(address: "127.0.0.1:7400")
24
+ #
25
+ # Reactive push subscription (separate connection, background thread):
26
+ # handle = nb.subscribe(stores: [:tasks]) { |fact| puts fact.key }
27
+ # handle.close # unsubscribes cleanly
28
+ class NetworkBackend
29
+ include WireProtocol
30
+
31
+ class NetworkError < StandardError; end
32
+
33
+ # Handle returned by #subscribe. Call #close to unsubscribe.
34
+ class Subscription
35
+ include WireProtocol
36
+
37
+ def initialize(socket, thread)
38
+ @socket = socket
39
+ @thread = thread
40
+ end
41
+
42
+ def close
43
+ begin
44
+ @socket.write(encode_frame(JSON.generate({ op: "close" })))
45
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET
46
+ nil
47
+ end
48
+ @socket.close rescue nil
49
+ @thread.kill rescue nil
50
+ end
51
+ end
52
+
53
+ def initialize(address:, transport: :tcp)
54
+ @address = address
55
+ @transport = transport
56
+ @mutex = Mutex.new
57
+ @socket = connect
58
+ end
59
+
60
+ def write_fact(fact)
61
+ rpc("write_fact", fact: fact.to_h)
62
+ nil
63
+ end
64
+
65
+ # Returns an Array<Fact> from the server's durable store.
66
+ def replay
67
+ response = rpc("replay")
68
+ (response[:facts] || []).map { |h| decode_fact(h) }
69
+ end
70
+
71
+ # Sends all +facts+ to the server for snapshot storage.
72
+ # No-op on the server side if the server backend does not support snapshots.
73
+ def write_snapshot(facts)
74
+ rpc("write_snapshot", facts: facts.map(&:to_h))
75
+ nil
76
+ end
77
+
78
+ # Opens a dedicated second connection for push events and registers a handler.
79
+ # The main RPC connection is unaffected.
80
+ # Returns a Subscription handle; call handle.close to unsubscribe.
81
+ def subscribe(stores:, &callback)
82
+ raise ArgumentError, "subscribe requires a block" unless callback
83
+
84
+ sub_socket = connect
85
+ stores_s = Array(stores).map(&:to_s)
86
+ sub_socket.write(encode_frame(JSON.generate({ op: "subscribe", stores: stores_s })))
87
+
88
+ body = read_frame(sub_socket)
89
+ raise NetworkError, "Subscribe: server closed connection" unless body
90
+ resp = JSON.parse(body, symbolize_names: true)
91
+ raise NetworkError, resp[:error] unless resp[:ok]
92
+
93
+ thread = Thread.new(sub_socket) do |sock|
94
+ Thread.current.abort_on_exception = false
95
+ loop do
96
+ body = read_frame(sock)
97
+ break unless body
98
+ event = JSON.parse(body, symbolize_names: true)
99
+ next unless event[:event] == "fact_written"
100
+ fact = decode_fact(event[:fact])
101
+ callback.call(fact) rescue nil
102
+ end
103
+ rescue IOError, Errno::ECONNRESET, Errno::EPIPE, Errno::EBADF
104
+ nil
105
+ ensure
106
+ sock.close rescue nil
107
+ end
108
+
109
+ Subscription.new(sub_socket, thread)
110
+ end
111
+
112
+ def close
113
+ @mutex.synchronize do
114
+ send_frame({ op: "close" })
115
+ read_frame(@socket) # drain the server's { ok: true } so socket can close cleanly (FIN not RST)
116
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET
117
+ nil
118
+ ensure
119
+ @socket.close rescue nil
120
+ end
121
+ end
122
+
123
+ private
124
+
125
+ def connect
126
+ case @transport
127
+ when :tcp
128
+ host, port = @address.split(":")
129
+ s = TCPSocket.new(host, Integer(port))
130
+ s.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, true)
131
+ s
132
+ when :unix
133
+ UNIXSocket.new(@address)
134
+ else
135
+ raise ArgumentError, "Unknown transport: #{@transport.inspect}. Use :tcp or :unix"
136
+ end
137
+ end
138
+
139
+ def rpc(op, **params)
140
+ @mutex.synchronize do
141
+ send_frame(params.merge(op: op))
142
+ body = read_frame(@socket)
143
+ raise NetworkError, "Connection closed by server" unless body
144
+ response = JSON.parse(body, symbolize_names: true)
145
+ raise NetworkError, response[:error] unless response[:ok]
146
+ response
147
+ end
148
+ end
149
+
150
+ def send_frame(payload)
151
+ @socket.write(encode_frame(JSON.generate(payload)))
152
+ end
153
+
154
+ def decode_fact(h)
155
+ Fact.from_h(h)
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Store
5
+ module Protocol
6
+ module Handlers
7
+ class AccessPathHandler
8
+ REQUIRED = %i[name store fields].freeze
9
+
10
+ def initialize(store) = @store = store
11
+
12
+ def call(descriptor)
13
+ missing = REQUIRED.select { |f| descriptor[f].nil? }
14
+ return Receipt.rejection("Missing required fields: #{missing.join(", ")}", kind: :access_path) if missing.any?
15
+
16
+ name = descriptor[:name].to_sym
17
+ store_name = descriptor[:store].to_sym
18
+ unique = descriptor.fetch(:unique, true)
19
+
20
+ @store.register_path(
21
+ AccessPath.new(
22
+ store: store_name,
23
+ lookup: :primary_key,
24
+ scope: name,
25
+ filters: {},
26
+ cache_ttl: descriptor[:cache_ttl],
27
+ consumers: []
28
+ )
29
+ )
30
+
31
+ warnings = unique ? [] : ["unique: false — non-unique access paths are recorded but not enforced by the engine"]
32
+ Receipt.accepted(kind: :access_path, name: name, warnings: warnings)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end