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.
- checksums.yaml +7 -0
- data/README.md +481 -0
- data/examples/intelligent_ledger/availability_boundary_ledger.rb +1190 -0
- data/examples/intelligent_ledger/availability_deriver.rb +150 -0
- data/examples/intelligent_ledger/availability_ledger.rb +197 -0
- data/examples/intelligent_ledger/ledger_boundary.rb +180 -0
- data/examples/store_poc.rb +45 -0
- data/exe/igniter-ledger-server +111 -0
- data/exe/igniter-store-server +6 -0
- data/ext/igniter_store_native/Cargo.toml +28 -0
- data/ext/igniter_store_native/extconf.rb +6 -0
- data/ext/igniter_store_native/src/fact.rs +303 -0
- data/ext/igniter_store_native/src/fact_log.rs +180 -0
- data/ext/igniter_store_native/src/file_backend.rs +91 -0
- data/ext/igniter_store_native/src/lib.rs +55 -0
- data/lib/igniter/ledger.rb +7 -0
- data/lib/igniter/store/access_path.rb +84 -0
- data/lib/igniter/store/change_event.rb +65 -0
- data/lib/igniter/store/changefeed_buffer.rb +585 -0
- data/lib/igniter/store/codecs.rb +253 -0
- data/lib/igniter/store/contractable_receipt_sink.rb +172 -0
- data/lib/igniter/store/fact.rb +121 -0
- data/lib/igniter/store/fact_log.rb +103 -0
- data/lib/igniter/store/file_backend.rb +269 -0
- data/lib/igniter/store/http_adapter.rb +413 -0
- data/lib/igniter/store/igniter_store.rb +838 -0
- data/lib/igniter/store/mcp_adapter.rb +403 -0
- data/lib/igniter/store/native.rb +80 -0
- data/lib/igniter/store/network_backend.rb +159 -0
- data/lib/igniter/store/protocol/handlers/access_path_handler.rb +38 -0
- data/lib/igniter/store/protocol/handlers/command_handler.rb +59 -0
- data/lib/igniter/store/protocol/handlers/derivation_handler.rb +27 -0
- data/lib/igniter/store/protocol/handlers/effect_handler.rb +65 -0
- data/lib/igniter/store/protocol/handlers/history_handler.rb +24 -0
- data/lib/igniter/store/protocol/handlers/projection_handler.rb +41 -0
- data/lib/igniter/store/protocol/handlers/relation_handler.rb +43 -0
- data/lib/igniter/store/protocol/handlers/store_handler.rb +24 -0
- data/lib/igniter/store/protocol/handlers/subscription_handler.rb +24 -0
- data/lib/igniter/store/protocol/interpreter.rb +447 -0
- data/lib/igniter/store/protocol/receipt.rb +96 -0
- data/lib/igniter/store/protocol/sync_profile.rb +53 -0
- data/lib/igniter/store/protocol/wire_envelope.rb +214 -0
- data/lib/igniter/store/protocol.rb +27 -0
- data/lib/igniter/store/read_cache.rb +163 -0
- data/lib/igniter/store/schema_graph.rb +248 -0
- data/lib/igniter/store/segmented_file_backend.rb +699 -0
- data/lib/igniter/store/server_config.rb +55 -0
- data/lib/igniter/store/server_logger.rb +64 -0
- data/lib/igniter/store/server_metrics.rb +222 -0
- data/lib/igniter/store/store_server.rb +597 -0
- data/lib/igniter/store/subscription_registry.rb +73 -0
- data/lib/igniter/store/tbackend_adapter_descriptor.rb +307 -0
- data/lib/igniter/store/tcp_adapter.rb +127 -0
- data/lib/igniter/store/wire_protocol.rb +42 -0
- data/lib/igniter/store.rb +64 -0
- data/lib/igniter-ledger.rb +4 -0
- data/lib/igniter-store.rb +5 -0
- 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
|