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,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "zlib"
5
+ require "digest"
6
+ require "securerandom"
7
+ require "msgpack"
8
+ require_relative "wire_protocol"
9
+
10
+ module Igniter
11
+ module Store
12
+ # Pluggable per-segment codec system for SegmentedFileBackend.
13
+ #
14
+ # Each codec owns the write side (how a Fact becomes bytes in a segment)
15
+ # and the read side (how segment bytes become Facts on replay).
16
+ #
17
+ # Codec lifecycle per segment:
18
+ #
19
+ # codec = Codecs.build(:compact_delta)
20
+ # codec.start_segment(io, store: "readings") # optional header frame
21
+ # codec.encode_fact(io, fact) # returns bytes written
22
+ # ...
23
+ # codec.flush(io) # flush any buffered data
24
+ # ── seal ──
25
+ #
26
+ # codec2 = Codecs.build(:compact_delta)
27
+ # facts = codec2.decode(io) # reads whole segment
28
+ #
29
+ # Codec instances are stateful and single-use per segment.
30
+ module Codecs
31
+ # Build a fresh codec instance by name.
32
+ def self.build(name)
33
+ case name.to_sym
34
+ when :json_crc32
35
+ JsonCrc32.new
36
+ when :compact_delta, :"compact_delta_zlib"
37
+ CompactDelta.new
38
+ else
39
+ raise ArgumentError, "Unknown codec: #{name.inspect}"
40
+ end
41
+ end
42
+
43
+ # ── JsonCrc32 ───────────────────────────────────────────────────────────
44
+ #
45
+ # One CRC32-framed JSON frame per fact. Matches the pure-Ruby FileBackend
46
+ # format — readable without any extra dependencies.
47
+ class JsonCrc32
48
+ include WireProtocol
49
+
50
+ NAME = "json_crc32"
51
+
52
+ def name = NAME
53
+
54
+ def start_segment(_io, store: nil) = 0 # no header needed
55
+
56
+ def encode_fact(io, fact)
57
+ frame = encode_frame(JSON.generate(fact.to_h))
58
+ io.write(frame)
59
+ end
60
+
61
+ def flush(_io) = 0 # stateless, nothing buffered
62
+
63
+ def buffered_count = 0
64
+
65
+ def decode(io)
66
+ facts = []
67
+ loop do
68
+ body = read_frame(io)
69
+ break unless body
70
+ fact = Fact.from_h(JSON.parse(body, symbolize_names: true)) rescue nil
71
+ facts << fact if fact
72
+ end
73
+ facts
74
+ end
75
+ end
76
+
77
+ # ── CompactDelta ────────────────────────────────────────────────────────
78
+ #
79
+ # Structural compression optimised for high-frequency History stores
80
+ # (sensor readings, GPS tracks, telemetry).
81
+ #
82
+ # What is removed vs the full Fact representation:
83
+ # id → not stored; synthetic id assigned on decode
84
+ # store → in segment header (once per segment)
85
+ # value_hash → not stored; recomputed from value on decode
86
+ # causation → always nil for History — omitted
87
+ # term → in segment header
88
+ # schema_version→ in segment header
89
+ # value keys → field index from per-segment dictionary (header frame)
90
+ # key string → per-segment key dictionary index (delta updates per batch)
91
+ # timestamp → absolute ms for first entry; signed delta-ms thereafter
92
+ #
93
+ # Segment layout (all frames use WireProtocol CRC32 framing):
94
+ # [header_frame] MessagePack { store, fields:[...], term, schema_version }
95
+ # [batch_frame] MessagePack { km:{idx=>key,...}, e:[[ki,Δms,[v0,v1…]],…] }
96
+ # compressed with Zlib before framing
97
+ # ...
98
+ #
99
+ # The key map (km) in each batch carries only NEW keys added since the
100
+ # previous batch, so readers accumulate it incrementally.
101
+ #
102
+ # Benchmark result (GPS stream, 5 k facts):
103
+ # json_crc32 → 380 bytes/fact
104
+ # compact_delta→ 23 bytes/fact (16x smaller)
105
+ #
106
+ # Limitation (native mode): decoded Facts receive synthetic ids and, in
107
+ # the Rust native extension, timestamps are reset to Time.now — the same
108
+ # known gap as json_crc32 in native mode. Pure-Ruby mode restores
109
+ # timestamps correctly.
110
+ class CompactDelta
111
+ include WireProtocol
112
+
113
+ NAME = "compact_delta_zlib"
114
+ BATCH_SIZE = 64
115
+
116
+ def name = NAME
117
+
118
+ # ── Write side ──────────────────────────────────────────────────────
119
+
120
+ def initialize
121
+ @fields = nil
122
+ @key_map = {} # key_string → Integer index
123
+ @km_flushed = 0 # keys already sent to disk
124
+ @last_ts_ms = nil
125
+ @batch_buf = []
126
+ @header_written = false
127
+ @store = nil
128
+ end
129
+
130
+ def start_segment(_io, store: nil)
131
+ @store = store.to_s
132
+ 0 # header is written lazily on first encode_fact call
133
+ end
134
+
135
+ # Returns bytes written to io (0 while batch is buffered).
136
+ def encode_fact(io, fact)
137
+ unless @header_written
138
+ @fields = (fact.value || {}).keys.map(&:to_s).sort
139
+ header = { store: fact.store.to_s, fields: @fields,
140
+ valid_time: fact.valid_time, schema_version: fact.schema_version }
141
+ body = MessagePack.pack(stringify(header))
142
+ io.write(encode_frame(body))
143
+ @header_written = true
144
+ end
145
+
146
+ @batch_buf << fact
147
+ @batch_buf.size >= BATCH_SIZE ? write_batch(io) : 0
148
+ end
149
+
150
+ # Flush any remaining buffered facts. Returns bytes written.
151
+ def flush(io)
152
+ @batch_buf.empty? ? 0 : write_batch(io)
153
+ end
154
+
155
+ def buffered_count = @batch_buf.size
156
+
157
+ # ── Read side ────────────────────────────────────────────────────────
158
+
159
+ def decode(io)
160
+ header_body = read_frame(io)
161
+ return [] unless header_body
162
+ header = MessagePack.unpack(header_body)
163
+ fields = header["fields"] || []
164
+ store = (header["store"] || "").to_sym
165
+ valid_time = (header["valid_time"] || header["term"])&.to_f
166
+ sv = (header["schema_version"] || 1).to_i
167
+
168
+ key_map = {} # Integer index → key_string
169
+ last_ts_ms = nil
170
+ facts = []
171
+
172
+ while (body = read_frame(io))
173
+ _count = body[0, 4].unpack1("N")
174
+ raw = Zlib::Inflate.inflate(body[4..])
175
+ batch = MessagePack.unpack(raw)
176
+
177
+ (batch["km"] || {}).each { |idx, key| key_map[idx.to_i] = key }
178
+
179
+ (batch["e"] || []).each do |key_idx, delta_ms, vals|
180
+ ts_ms = last_ts_ms ? last_ts_ms + delta_ms : delta_ms
181
+ last_ts_ms = ts_ms
182
+
183
+ value = fields.each_with_index.to_h { |fn, i| [fn.to_sym, vals[i]] }
184
+ vh = Digest::SHA256.hexdigest(JSON.generate(stable_sort(value)))
185
+
186
+ fact_hash = {
187
+ id: SecureRandom.uuid,
188
+ store: store,
189
+ key: key_map[key_idx.to_i],
190
+ value: value,
191
+ value_hash: vh,
192
+ causation: nil,
193
+ transaction_time: ts_ms / 1_000.0,
194
+ valid_time: valid_time,
195
+ schema_version: sv
196
+ }
197
+ fact = Fact.from_h(fact_hash) rescue nil
198
+ facts << fact if fact
199
+ end
200
+ end
201
+
202
+ facts
203
+ end
204
+
205
+ private
206
+
207
+ def write_batch(io)
208
+ entries = @batch_buf.map do |f|
209
+ key = f.key.to_s
210
+ @key_map[key] ||= @key_map.size
211
+
212
+ ts_ms = (f.transaction_time.to_f * 1_000).round
213
+ delta = @last_ts_ms ? ts_ms - @last_ts_ms : ts_ms
214
+ @last_ts_ms = ts_ms
215
+
216
+ vals = @fields.map { |fn|
217
+ v = f.value.key?(fn.to_sym) ? f.value[fn.to_sym] : f.value[fn]
218
+ v.is_a?(Symbol) ? v.to_s : v
219
+ }
220
+ [@key_map[key], delta, vals]
221
+ end
222
+
223
+ new_keys = @key_map.select { |_, idx| idx >= @km_flushed }
224
+ .invert
225
+ .transform_keys(&:to_s)
226
+ @km_flushed = @key_map.size
227
+
228
+ raw = MessagePack.pack(stringify({ km: new_keys, e: entries }))
229
+ body = [@batch_buf.size].pack("N") + Zlib::Deflate.deflate(raw, Zlib::BEST_COMPRESSION)
230
+ @batch_buf.clear
231
+ io.write(encode_frame(body))
232
+ end
233
+
234
+ def stringify(v)
235
+ case v
236
+ when Symbol then v.to_s
237
+ when Hash then v.transform_keys(&:to_s).transform_values { |x| stringify(x) }
238
+ when Array then v.map { |x| stringify(x) }
239
+ else v
240
+ end
241
+ end
242
+
243
+ def stable_sort(v)
244
+ case v
245
+ when Hash then v.sort_by { |k, _| k.to_s }.to_h { |k, x| [k.to_s, stable_sort(x)] }
246
+ when Array then v.map { |x| stable_sort(x) }
247
+ else v
248
+ end
249
+ end
250
+ end
251
+ end
252
+ end
253
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Store
5
+ # Durable sink for contractable observation/event receipts emitted by
6
+ # igniter-embed's contractable runner.
7
+ #
8
+ # Implements the record_observation / record_event store adapter protocol
9
+ # so it can be passed directly as the `store:` option to any contractable.
10
+ #
11
+ # Idempotency policy:
12
+ # record_observation — keyed by observation_id; same id overwrites the
13
+ # current fact and creates a causation chain entry. Safe to retry.
14
+ # record_event — append-only history; retries produce duplicate entries.
15
+ # Callers should deduplicate at the source if needed.
16
+ class ContractableReceiptSink
17
+ REQUIRED_OBSERVATION_FIELDS = %i[observation_id receipt_kind].freeze
18
+ REQUIRED_EVENT_FIELDS = %i[event_id receipt_kind observation_id].freeze
19
+
20
+ attr_reader :store, :client, :observations_store, :events_store, :producer
21
+
22
+ def initialize(
23
+ store: nil,
24
+ client: nil,
25
+ observations_store: :contractable_observations,
26
+ events_store: :contractable_events,
27
+ producer: { type: :embed, name: :contractable_receipt_sink }
28
+ )
29
+ raise ArgumentError, "ContractableReceiptSink requires store: or client:" unless store || client
30
+
31
+ @store = store
32
+ @client = client
33
+ @observations_store = observations_store.to_sym
34
+ @events_store = events_store.to_sym
35
+ @producer = producer
36
+ register_descriptors
37
+ end
38
+
39
+ def record_observation(receipt)
40
+ validate_receipt!(receipt, REQUIRED_OBSERVATION_FIELDS, :contractable_observation)
41
+ target.write(
42
+ store: observations_store,
43
+ key: receipt[:observation_id].to_s,
44
+ value: receipt,
45
+ producer: producer
46
+ )
47
+ end
48
+
49
+ def record_event(receipt)
50
+ validate_receipt!(receipt, REQUIRED_EVENT_FIELDS, :contractable_event)
51
+ target.append(
52
+ history: events_store,
53
+ event: receipt,
54
+ partition_key: :observation_id,
55
+ producer: producer
56
+ )
57
+ end
58
+
59
+ def observation(observation_id)
60
+ normalize_read_result(target.read(store: observations_store, key: observation_id.to_s))
61
+ end
62
+
63
+ def events_for(observation_id)
64
+ history_partition_values(
65
+ store: events_store,
66
+ partition_key: :observation_id,
67
+ partition_value: observation_id.to_s
68
+ )
69
+ end
70
+
71
+ def observations(status: nil, limit: nil)
72
+ all_facts = history_facts(store: observations_store)
73
+ by_key = {}
74
+ all_facts.each { |f| by_key[fact_key(f)] = f }
75
+ results = by_key.values.sort_by { |f| fact_transaction_time(f) }.map { |f| fact_value(f) }
76
+ results = results.select { |r| r[:status] == status } if status
77
+ limit ? results.take(limit) : results
78
+ end
79
+
80
+ def error_events(limit: nil)
81
+ results = history_facts(store: events_store).map { |f| fact_value(f) }.select { |r| r[:severity] == :error }
82
+ limit ? results.take(limit) : results
83
+ end
84
+
85
+ private
86
+
87
+ def target
88
+ client || store
89
+ end
90
+
91
+ def validate_receipt!(receipt, required_fields, expected_kind)
92
+ missing = required_fields.select { |f| receipt[f].nil? }
93
+ raise ArgumentError, "contractable receipt missing required fields: #{missing.join(", ")}" if missing.any?
94
+
95
+ actual_kind = receipt[:receipt_kind]
96
+ return if actual_kind == expected_kind
97
+
98
+ raise ArgumentError, "expected receipt_kind #{expected_kind.inspect}, got #{actual_kind.inspect}"
99
+ end
100
+
101
+ def register_descriptors
102
+ target.register_descriptor(
103
+ kind: :store,
104
+ name: observations_store,
105
+ key: :observation_id,
106
+ fields: %i[observation_id name role stage status sampled async started_at finished_at duration_ms redaction],
107
+ producer: { system: :igniter_embed }
108
+ )
109
+ target.register_descriptor(
110
+ kind: :history,
111
+ name: events_store,
112
+ key: :event_id,
113
+ partition_key: :observation_id,
114
+ fields: %i[event_id observation_id event severity summary occurred_at]
115
+ )
116
+ end
117
+
118
+ def normalize_read_result(result)
119
+ return result.value if result.respond_to?(:value) && result.respond_to?(:found?)
120
+
121
+ if result.is_a?(Hash) && result.key?(:value)
122
+ result[:value]
123
+ else
124
+ result
125
+ end
126
+ end
127
+
128
+ def history_partition_values(store:, partition_key:, partition_value:)
129
+ if target.respond_to?(:history_partition)
130
+ return target.history_partition(
131
+ store: store,
132
+ partition_key: partition_key,
133
+ partition_value: partition_value
134
+ ).map(&:value)
135
+ end
136
+
137
+ history_facts(store: store)
138
+ .map { |f| fact_value(f) }
139
+ .select { |value| value[partition_key] == partition_value }
140
+ end
141
+
142
+ def history_facts(store:)
143
+ return target.history(store: store) if target.respond_to?(:history)
144
+
145
+ replay_result = target.replay(store: store)
146
+ return replay_result.facts if replay_result.respond_to?(:facts)
147
+
148
+ if replay_result.is_a?(Hash) && replay_result.key?(:facts)
149
+ replay_result[:facts]
150
+ else
151
+ Array(replay_result)
152
+ end
153
+ end
154
+
155
+ def fact_key(fact)
156
+ fact.is_a?(Hash) ? fact[:key] : fact.key
157
+ end
158
+
159
+ def fact_value(fact)
160
+ fact.is_a?(Hash) ? fact[:value] : fact.value
161
+ end
162
+
163
+ def fact_transaction_time(fact)
164
+ if fact.is_a?(Hash)
165
+ fact[:transaction_time] || fact[:timestamp] || 0
166
+ else
167
+ fact.transaction_time
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "securerandom"
6
+
7
+ module Igniter
8
+ module Store
9
+ unless defined?(NATIVE) && NATIVE
10
+ # Pure-Ruby Fact Struct — skipped when the Rust native extension is loaded.
11
+ # The native extension provides its own Fact class with :build and reader methods.
12
+ Fact = Struct.new(
13
+ :id,
14
+ :store,
15
+ :key,
16
+ :value,
17
+ :value_hash,
18
+ :causation,
19
+ :transaction_time,
20
+ :valid_time,
21
+ :schema_version,
22
+ :producer,
23
+ :derivation,
24
+ keyword_init: true
25
+ ) do
26
+ # Canonical build entry point.
27
+ # valid_time: domain time (writer-supplied, nullable Float).
28
+ # term: backward-compat alias for valid_time — accepted but deprecated.
29
+ def self.build(store:, key:, value:, causation: nil, valid_time: nil, term: nil,
30
+ schema_version: 1, producer: nil, derivation: nil)
31
+ vt = valid_time.nil? ? (term ? term.to_f : nil) : valid_time.to_f
32
+ serialized = JSON.generate(stable_sort(value))
33
+ new(
34
+ id: SecureRandom.uuid,
35
+ store: store,
36
+ key: key,
37
+ value: deep_freeze(value),
38
+ value_hash: Digest::SHA256.hexdigest(serialized),
39
+ causation: causation,
40
+ transaction_time: Process.clock_gettime(Process::CLOCK_REALTIME),
41
+ valid_time: vt,
42
+ schema_version: schema_version,
43
+ producer: producer ? deep_freeze(producer) : nil,
44
+ derivation: derivation ? deep_freeze(derivation) : nil
45
+ ).freeze
46
+ end
47
+
48
+ private_class_method def self.stable_sort(value)
49
+ case value
50
+ when Hash
51
+ value.sort_by { |key, _entry| key.to_s }.to_h do |key, entry|
52
+ [key.to_s, stable_sort(entry)]
53
+ end
54
+ when Array
55
+ value.map { |entry| stable_sort(entry) }
56
+ else
57
+ value
58
+ end
59
+ end
60
+
61
+ private_class_method def self.deep_freeze(value)
62
+ case value
63
+ when Hash
64
+ value.transform_values { |entry| deep_freeze(entry) }.freeze
65
+ when Array
66
+ value.map { |entry| deep_freeze(entry) }.freeze
67
+ else
68
+ value.frozen? ? value : value.dup.freeze
69
+ end
70
+ end
71
+
72
+ # Backward-compat: callers that read fact.timestamp still work.
73
+ alias_method :timestamp, :transaction_time
74
+ # Backward-compat: callers that read fact.term still work.
75
+ alias_method :term, :valid_time
76
+ end
77
+ end
78
+
79
+ # Reopen Fact (Ruby Struct or native class) and add from_h + normalizations.
80
+ class Fact
81
+ if defined?(Igniter::Store::NATIVE) && Igniter::Store::NATIVE
82
+ # Native extension stores `store` as a Rust String; normalize to Symbol
83
+ # to match the Ruby Struct fallback behaviour.
84
+ alias_method :_native_store_str, :store
85
+ def store = _native_store_str.to_sym
86
+ end
87
+
88
+ # Reconstruct a Fact from a wire-deserialized hash.
89
+ # Accepts both old field names (timestamp, term) and new names
90
+ # (transaction_time, valid_time) for smooth transition.
91
+ #
92
+ # In native mode: Fact.new is unavailable (no Ruby allocator), so we call
93
+ # Fact.build which recomputes id and transaction_time. This is a known
94
+ # Phase 2 gap for time-travel fidelity over the network.
95
+ def self.from_h(h)
96
+ h = h.transform_keys(&:to_sym)
97
+ h[:store] = h.fetch(:store).to_sym
98
+ # Accept both old (timestamp) and new (transaction_time) key names.
99
+ h[:transaction_time] = (h[:transaction_time] || h[:timestamp])&.to_f || 0.0
100
+ h[:valid_time] = (h[:valid_time] || h[:term])&.to_f
101
+
102
+ if defined?(Igniter::Store::NATIVE) && Igniter::Store::NATIVE
103
+ build(
104
+ store: h[:store],
105
+ key: h[:key],
106
+ value: h[:value],
107
+ causation: h[:causation],
108
+ valid_time: h[:valid_time],
109
+ schema_version: h.fetch(:schema_version, 1),
110
+ producer: h[:producer],
111
+ derivation: h[:derivation]
112
+ )
113
+ else
114
+ new(**h.slice(:id, :store, :key, :value, :value_hash, :causation,
115
+ :transaction_time, :valid_time, :schema_version,
116
+ :producer, :derivation)).freeze
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module Igniter
6
+ module Store
7
+ unless defined?(NATIVE) && NATIVE
8
+ # Pure-Ruby FactLog — skipped when the Rust native extension is loaded.
9
+ class FactLog
10
+ include MonitorMixin
11
+
12
+ def initialize
13
+ super()
14
+ @log = []
15
+ @by_id = {}
16
+ @by_key = Hash.new { |hash, key| hash[key] = [] }
17
+ end
18
+
19
+ def append(fact)
20
+ synchronize do
21
+ @log << fact
22
+ @by_id[fact.id] = fact
23
+ @by_key[[fact.store, fact.key]] << fact
24
+ end
25
+ fact
26
+ end
27
+
28
+ def replay(fact)
29
+ synchronize do
30
+ @log << fact
31
+ @by_id[fact.id] = fact
32
+ @by_key[[fact.store, fact.key]] << fact
33
+ end
34
+ end
35
+
36
+ def latest_for(store:, key:, as_of: nil)
37
+ facts = synchronize { @by_key[[store, key]].dup }
38
+ facts = facts.select { |fact| fact.transaction_time <= as_of } if as_of
39
+ facts.last
40
+ end
41
+
42
+ def facts_for(store:, key: nil, since: nil, as_of: nil)
43
+ synchronize do
44
+ facts = key ? @by_key[[store, key]].dup : @log.select { |fact| fact.store == store }
45
+ facts = facts.select { |fact| fact.transaction_time >= since } if since
46
+ facts = facts.select { |fact| fact.transaction_time <= as_of } if as_of
47
+ facts
48
+ end
49
+ end
50
+
51
+ def query_scope(store:, filters:, as_of: nil)
52
+ synchronize do
53
+ seen = {}
54
+ @by_key.each do |(s, k), facts|
55
+ next unless s == store
56
+ candidates = as_of ? facts.select { |f| f.transaction_time <= as_of } : facts
57
+ latest = candidates.last
58
+ next unless latest
59
+ seen[k] = latest if matches_filters?(latest.value, filters)
60
+ end
61
+ seen.values
62
+ end
63
+ end
64
+
65
+ def all_facts
66
+ synchronize { @log.dup }
67
+ end
68
+
69
+ def size
70
+ synchronize { @log.size }
71
+ end
72
+
73
+ private
74
+
75
+ def matches_filters?(value, filters)
76
+ return false unless value.is_a?(Hash)
77
+ filters.all? { |k, v| value[k] == v }
78
+ end
79
+ end
80
+ end
81
+
82
+ if defined?(NATIVE) && NATIVE
83
+ # Patch the Rust-native FactLog to expose all_facts.
84
+ # The native append is intercepted to track which stores have been written;
85
+ # all_facts then collects via facts_for(store:) per known store.
86
+ class FactLog
87
+ alias_method :_native_append_unwrapped, :append
88
+
89
+ def append(fact)
90
+ @_seen_stores ||= []
91
+ s = fact.store
92
+ @_seen_stores << s unless @_seen_stores.include?(s)
93
+ _native_append_unwrapped(fact)
94
+ end
95
+
96
+ def all_facts
97
+ @_seen_stores ||= []
98
+ @_seen_stores.flat_map { |s| facts_for(store: s) }.sort_by(&:transaction_time)
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end