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,838 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "securerandom"
5
+ require "set"
6
+
7
+ module Igniter
8
+ module Store
9
+ # Thin wrapper returned from read paths when a schema coercion is registered.
10
+ # Delegates identity fields to the underlying fact; exposes the coerced value.
11
+ CoercedFact = Struct.new(:fact, :value) do
12
+ def key = fact.key
13
+ def id = fact.id
14
+ def transaction_time = fact.transaction_time
15
+ def valid_time = fact.valid_time
16
+ def schema_version = fact.schema_version
17
+ def causation = fact.causation
18
+ def value_hash = fact.value_hash
19
+ def store = fact.store
20
+ def producer = fact.producer
21
+ def derivation = fact.derivation
22
+ # Backward-compat aliases
23
+ alias_method :timestamp, :transaction_time
24
+ alias_method :term, :valid_time
25
+ end
26
+
27
+ class IgniterStore
28
+ attr_reader :schema_graph, :changefeed
29
+
30
+ # Returns a Protocol::Interpreter wrapping this store.
31
+ # External / non-Igniter clients use this surface to register descriptors,
32
+ # write facts, and query via the open protocol vocabulary.
33
+ def protocol
34
+ @protocol ||= Protocol::Interpreter.new(self)
35
+ end
36
+
37
+ # Convenience shorthand: register a protocol descriptor packet.
38
+ def register_descriptor(packet)
39
+ protocol.register(packet)
40
+ end
41
+
42
+ def initialize(backend: nil, lru_cap: ReadCache::DEFAULT_LRU_CAP, changefeed: nil)
43
+ @backend = backend
44
+ @lru_cap = lru_cap
45
+ @changefeed = changefeed
46
+ @log = FactLog.new
47
+ @cache = ReadCache.new(lru_cap: lru_cap)
48
+ @schema_graph = SchemaGraph.new
49
+ # Materialized scope index: { [store, scope] => Set<key> }
50
+ # Populated lazily on first query; maintained on every write thereafter.
51
+ # Time-travel queries (as_of: non-nil) bypass the index.
52
+ @scope_index = {}
53
+ @scope_mutex = Mutex.new
54
+ # Partition index: { [store, partition_key] => { partition_value => [fact, ...] } }
55
+ # Populated lazily on first history_partition call; maintained on every append thereafter.
56
+ # as_of/since filtering is applied at read time over the pre-grouped slice.
57
+ @partition_index = {}
58
+ @partition_mutex = Mutex.new
59
+ # Schema coercion hooks: { store_name => callable(value, schema_version) }
60
+ # Applied on every read path; raw facts remain immutable in the log and cache.
61
+ @coercions = {}
62
+ # Fact-id lookup index: { fact_id => Fact }
63
+ # Maintained on write, append, replay, and rebuild_log!.
64
+ # Reflects currently live facts only — dropped facts are removed after compaction.
65
+ @fact_id_index = {}
66
+ end
67
+
68
+ def self.open(path, lru_cap: ReadCache::DEFAULT_LRU_CAP)
69
+ backend = FileBackend.new(path)
70
+ store = new(backend: backend, lru_cap: lru_cap)
71
+ backend.replay.each { |fact| store.__send__(:replay, fact) }
72
+ store
73
+ end
74
+
75
+ def register_projection(projection_path)
76
+ @schema_graph.register_projection(projection_path)
77
+ self
78
+ end
79
+
80
+ # Register a reactive derivation rule.
81
+ # When any fact is written to +source_store+ (filtered by +source_filters+),
82
+ # +rule+ is called with the current source facts and the result is written
83
+ # to +target_store+ at +target_key+. Returning nil from +rule+ skips the write.
84
+ # Derivations do not re-trigger on derived writes (cycle-safe).
85
+ # Declare a retention policy for +store+.
86
+ # strategy: :permanent (default — never compact)
87
+ # :ephemeral — keep only the latest fact per key
88
+ # :rolling_window — keep latest per key + facts within duration seconds
89
+ # Call compact or compact(store) to execute the policy.
90
+ def set_retention(store, strategy:, duration: nil)
91
+ @schema_graph.register_retention(
92
+ store,
93
+ RetentionPolicy.new(strategy: strategy, duration: duration)
94
+ )
95
+ self
96
+ end
97
+
98
+ # Run compaction for +store+ (or all stores with registered retention policies).
99
+ # Returns an Array of result hashes: { store:, strategy:, dropped_count:,
100
+ # kept_count:, receipt_id: }. Permanent stores and stores with nothing to
101
+ # drop return dropped_count: 0 and receipt_id: nil.
102
+ def compact(store = nil)
103
+ targets = store ? [store] : @schema_graph.retention_stores
104
+ targets.filter_map do |s|
105
+ policy = @schema_graph.retention_for(store: s)
106
+ next unless policy && policy.strategy != :permanent
107
+ compact_store(s, policy)
108
+ end
109
+ end
110
+
111
+ # Facts written by compaction runs for +store+ (or all receipts when nil).
112
+ # Receipts live in the :__compaction_receipts meta-store.
113
+ def compaction_receipts(store: nil)
114
+ all = @log.facts_for(store: :__compaction_receipts)
115
+ store ? all.select { |f| f.value[:compacted_store] == store } : all
116
+ end
117
+
118
+ # Normalized compaction activity across all executors.
119
+ #
120
+ # Merges entries from:
121
+ # :__compaction_receipts — retention compaction (store.compact)
122
+ # :__fact_prune_receipts — exact fact-id prune (store.prune_fact_ids)
123
+ # backend.purge_receipts — segment purge (SegmentedFileBackend.purge!)
124
+ #
125
+ # Each entry: { kind:, executor:, store:, status:, reason:, fact_count:,
126
+ # receipt_id:, occurred_at: }
127
+ #
128
+ # Boundary-specific receipts are not included here; use
129
+ # AvailabilityBoundaryLedger#compaction_activity for the full picture.
130
+ def compaction_activity(store: nil)
131
+ entries = []
132
+
133
+ compaction_receipts(store: store).each do |f|
134
+ v = f.value
135
+ entries << {
136
+ kind: :retention_compaction,
137
+ executor: :store_compact,
138
+ store: v[:compacted_store],
139
+ status: :ok,
140
+ reason: v[:strategy],
141
+ fact_count: v[:compacted_count].to_i,
142
+ receipt_id: f.id,
143
+ occurred_at: v[:compacted_at].to_f
144
+ }
145
+ end
146
+
147
+ @log.facts_for(store: :__fact_prune_receipts).each do |f|
148
+ v = f.value
149
+ entries << {
150
+ kind: :exact_prune,
151
+ executor: :fact_prune,
152
+ store: nil,
153
+ status: :ok,
154
+ reason: v[:reason],
155
+ fact_count: v[:pruned_count].to_i,
156
+ receipt_id: f.id,
157
+ occurred_at: v[:pruned_at].to_f
158
+ }
159
+ end
160
+
161
+ if @backend.respond_to?(:purge_receipts)
162
+ @backend.purge_receipts(store: store).each do |r|
163
+ entries << {
164
+ kind: :segment_purge,
165
+ executor: :segmented_backend,
166
+ store: r["store"]&.to_sym,
167
+ status: :ok,
168
+ reason: r["purge_strategy"],
169
+ fact_count: r["fact_count"].to_i,
170
+ receipt_id: r["segment_path"],
171
+ occurred_at: r["purged_at"].to_f
172
+ }
173
+ end
174
+ end
175
+
176
+ entries.sort_by { |e| e[:occurred_at] }
177
+ end
178
+
179
+ # Removes exact facts by id from the live FactLog and all derived indexes.
180
+ #
181
+ # Requires a backend that supports +replace_with_snapshot!+ (i.e. FileBackend
182
+ # in the Ruby-path proof). Returns { status: :unsupported } for backends
183
+ # that do not support durable fact removal (e.g. SegmentedFileBackend).
184
+ # In-memory stores (backend: nil) support the operation without durability.
185
+ #
186
+ # Order of operations:
187
+ # 1. Write a prune receipt (compact refs, no full payloads) — survives the prune.
188
+ # 2. Rebuild log without the pruned facts.
189
+ # 3. Call backend.replace_with_snapshot! so dropped facts cannot resurface on reopen.
190
+ #
191
+ # Missing fact ids are reported in the result but are not fatal.
192
+ #
193
+ # Returns:
194
+ # { status: :ok, receipt_id:, pruned_count:, missing_count:,
195
+ # pruned_fact_refs:, missing_ids: }
196
+ # { status: :unsupported, reason: :backend_does_not_support_exact_prune, backend: }
197
+ def prune_fact_ids(fact_ids:, reason:, metadata: {}, receipt_store: :__fact_prune_receipts)
198
+ if @backend && !@backend.respond_to?(:replace_with_snapshot!)
199
+ return {
200
+ status: :unsupported,
201
+ reason: :backend_does_not_support_exact_prune,
202
+ backend: @backend.class.name
203
+ }
204
+ end
205
+
206
+ ids_set = Set.new(fact_ids.map(&:to_s))
207
+
208
+ pruned_refs = []
209
+ missing_ids = []
210
+ ids_set.each do |id|
211
+ fact = @fact_id_index[id]
212
+ if fact
213
+ pruned_refs << {
214
+ id: fact.id,
215
+ store: fact.store,
216
+ key: fact.key,
217
+ transaction_time: fact.transaction_time,
218
+ valid_time: fact.valid_time,
219
+ value_hash: fact.value_hash
220
+ }
221
+ else
222
+ missing_ids << id
223
+ end
224
+ end
225
+
226
+ now = Process.clock_gettime(Process::CLOCK_REALTIME)
227
+ receipt = write(
228
+ store: receipt_store,
229
+ key: SecureRandom.hex(8),
230
+ value: {
231
+ type: :fact_prune_receipt,
232
+ reason: reason,
233
+ requested_count: ids_set.size,
234
+ pruned_count: pruned_refs.size,
235
+ missing_count: missing_ids.size,
236
+ pruned_fact_refs: pruned_refs,
237
+ missing_ids: missing_ids,
238
+ metadata: metadata,
239
+ pruned_at: now
240
+ }
241
+ )
242
+
243
+ surviving = @log.all_facts.reject { |f| ids_set.include?(f.id.to_s) }
244
+ rebuild_log!(surviving)
245
+
246
+ @backend.replace_with_snapshot!(@log.all_facts) if @backend
247
+
248
+ {
249
+ status: :ok,
250
+ receipt_id: receipt.id,
251
+ pruned_count: pruned_refs.size,
252
+ missing_count: missing_ids.size,
253
+ pruned_fact_refs: pruned_refs,
254
+ missing_ids: missing_ids
255
+ }
256
+ end
257
+
258
+ # Declare a named cross-store relation backed by a materialized scatter index.
259
+ #
260
+ # When any fact is written to +source+, the value of +partition+ in that
261
+ # fact is used as a key into the index store :"__rel_<name>". The index
262
+ # entry accumulates the unique source keys that share that partition value.
263
+ #
264
+ # resolve(name, from: value) reads the index and returns the current values
265
+ # of all matching source facts (latest per key).
266
+ #
267
+ # This is a 1-N relation: one partition_key value → many source keys.
268
+ # The index is append-only (G-Set): facts are never removed from the index.
269
+ def register_relation(name, source:, partition:, target:)
270
+ rule = RelationRule.new(name: name, source: source, partition: partition, target: target)
271
+ @schema_graph.register_relation(rule)
272
+
273
+ index_store = :"__rel_#{name}"
274
+ register_scatter(
275
+ source_store: source,
276
+ partition_by: partition,
277
+ target_store: index_store,
278
+ rule: lambda { |partition_key, existing, new_fact|
279
+ keys = existing ? existing[:keys].dup : []
280
+ keys << new_fact.key unless keys.include?(new_fact.key)
281
+ { keys: keys, count: keys.size, partition_key: partition_key }
282
+ }
283
+ )
284
+ self
285
+ end
286
+
287
+ # Resolve a named relation for a given partition value.
288
+ # Returns an Array of values of all source facts whose partition field
289
+ # equals +from+. Returns [] when nothing is indexed yet.
290
+ #
291
+ # as_of: Float timestamp — when given, reads the index state AND each
292
+ # source value at that point in time (consistent point-in-time snapshot).
293
+ def resolve(relation_name, from:, as_of: nil)
294
+ rule = @schema_graph.relation_for(name: relation_name)
295
+ raise ArgumentError, "No relation registered: #{relation_name.inspect}" unless rule
296
+
297
+ index_entry = read(store: :"__rel_#{relation_name}", key: from.to_s, as_of: as_of)
298
+ return [] unless index_entry
299
+
300
+ index_entry[:keys].filter_map { |key| read(store: rule.source, key: key, as_of: as_of) }
301
+ end
302
+
303
+ # Register a scatter derivation rule.
304
+ # When a fact is written to +source_store+, the value of +partition_by+ in
305
+ # that fact's value is extracted as the target key. +rule+ is called as:
306
+ # rule.(partition_key, existing_value, new_fact) → Hash | nil
307
+ # Returning nil skips the write. Scatter writes do not re-trigger scatter
308
+ # (cycle-safe via a separate thread-local guard).
309
+ def register_scatter(source_store:, partition_by:, target_store:, rule:)
310
+ @schema_graph.register_scatter(
311
+ ScatterRule.new(
312
+ source_store: source_store,
313
+ partition_by: partition_by,
314
+ target_store: target_store,
315
+ rule: rule
316
+ )
317
+ )
318
+ self
319
+ end
320
+
321
+ def register_derivation(source_store:, source_filters: {}, target_store:, target_key:, rule:)
322
+ @schema_graph.register_derivation(
323
+ DerivationRule.new(
324
+ source_store: source_store,
325
+ source_filters: source_filters,
326
+ target_store: target_store,
327
+ target_key: target_key,
328
+ rule: rule
329
+ )
330
+ )
331
+ self
332
+ end
333
+
334
+ def register_path(path)
335
+ @schema_graph.register(path)
336
+ path.consumers.to_a.each do |consumer|
337
+ if path.scope
338
+ @cache.register_scope_consumer(path.store, path.scope, consumer)
339
+ else
340
+ @cache.register_consumer(path.store, consumer)
341
+ end
342
+ end
343
+ self
344
+ end
345
+
346
+ # Register a schema migration hook for +store_name+.
347
+ # The block receives (value, schema_version) and must return the migrated value.
348
+ # Applied on every read (point reads, scope queries, history); raw facts are
349
+ # never mutated — coercion is a read-path transform only.
350
+ def register_coercion(store_name, &block)
351
+ @coercions[store_name] = block
352
+ self
353
+ end
354
+
355
+ def write(store:, key:, value:, schema_version: 1, causation: nil, valid_time: nil, term: nil,
356
+ producer: nil, derivation: nil)
357
+ previous = @log.latest_for(store: store, key: key)
358
+ fact = Fact.build(
359
+ store: store,
360
+ key: key,
361
+ value: value,
362
+ causation: causation || previous&.id,
363
+ schema_version: schema_version,
364
+ valid_time: valid_time,
365
+ term: term,
366
+ producer: producer,
367
+ derivation: derivation
368
+ )
369
+ @log.append(fact)
370
+ @fact_id_index[fact.id] = fact
371
+ @backend&.write_fact(fact)
372
+ scope_changes = update_scope_indices(store, key, value)
373
+ @cache.invalidate(store: store, key: key, scope_changes: scope_changes)
374
+ # Emit source fact before derived/scatter writes so subscribers see
375
+ # cause before effects (source-first emission order).
376
+ @changefeed&.emit(fact)
377
+ run_derivations(store: store, source_fact: fact)
378
+ run_scatters(store: store, source_fact: fact)
379
+ fact
380
+ end
381
+
382
+ def append(history:, event:, schema_version: 1, valid_time: nil, term: nil, partition_key: nil,
383
+ producer: nil, derivation: nil)
384
+ fact = Fact.build(
385
+ store: history,
386
+ key: SecureRandom.uuid,
387
+ value: event,
388
+ schema_version: schema_version,
389
+ valid_time: valid_time,
390
+ term: term,
391
+ producer: producer,
392
+ derivation: derivation
393
+ )
394
+ @log.append(fact)
395
+ @fact_id_index[fact.id] = fact
396
+ @backend&.write_fact(fact)
397
+ if partition_key && (pv = event[partition_key])
398
+ idx_key = [history, partition_key]
399
+ @partition_mutex.synchronize do
400
+ if @partition_index.key?(idx_key)
401
+ (@partition_index[idx_key][pv] ||= []) << fact
402
+ end
403
+ end
404
+ end
405
+ @changefeed&.emit(fact)
406
+ fact
407
+ end
408
+
409
+ def read(store:, key:, as_of: nil, ttl: nil)
410
+ cached = @cache.get(store: store, key: key, as_of: as_of, ttl: ttl)
411
+ return coerce_value(store, cached) if cached
412
+
413
+ fact = @log.latest_for(store: store, key: key, as_of: as_of)
414
+ return nil unless fact
415
+
416
+ @cache.put(store: store, key: key, fact: fact, as_of: as_of)
417
+ coerce_value(store, fact)
418
+ end
419
+
420
+ def time_travel(store:, key:, at:)
421
+ read(store: store, key: key, as_of: at)
422
+ end
423
+
424
+ def query(store:, scope:, as_of: nil, ttl: nil)
425
+ path = @schema_graph.path_for(store: store, scope: scope)
426
+ raise ArgumentError, "No registered path for store=#{store.inspect} scope=#{scope.inspect}" unless path
427
+
428
+ effective_ttl = ttl || path.cache_ttl
429
+ cached = @cache.get_scope(store: store, scope: scope, as_of: as_of, ttl: effective_ttl)
430
+ return apply_coercions(store, cached) if cached
431
+
432
+ filters = path.filters || {}
433
+ facts = if as_of
434
+ # Time-travel: bypass scope index — the index reflects current state only.
435
+ @log.query_scope(store: store, filters: filters, as_of: as_of)
436
+ else
437
+ scope_key = [store, scope]
438
+ idx = @scope_mutex.synchronize { @scope_index[scope_key] }
439
+ if idx
440
+ # Index is warm: O(matched_keys) read instead of O(all_keys) scan.
441
+ idx.filter_map { |k| @log.latest_for(store: store, key: k) }
442
+ else
443
+ # First query for this scope: full scan + build index.
444
+ all_facts = @log.query_scope(store: store, filters: filters, as_of: nil)
445
+ @scope_mutex.synchronize do
446
+ @scope_index[scope_key] ||= Set.new(all_facts.map(&:key))
447
+ end
448
+ all_facts
449
+ end
450
+ end
451
+
452
+ @cache.put_scope(store: store, scope: scope, facts: facts, as_of: as_of)
453
+ apply_coercions(store, facts)
454
+ end
455
+
456
+ def history(store:, key: nil, since: nil, as_of: nil)
457
+ apply_coercions(store, @log.facts_for(store: store, key: key, since: since, as_of: as_of))
458
+ end
459
+
460
+ # Partition-filtered history query backed by a materialized index.
461
+ # First call for a (store, partition_key) pair performs a full scan and
462
+ # builds the index; subsequent calls are O(partition slice).
463
+ # as_of/since filtering is applied over the cached slice at read time.
464
+ def history_partition(store:, partition_key:, partition_value:, since: nil, as_of: nil)
465
+ idx_key = [store, partition_key]
466
+ @partition_mutex.synchronize do
467
+ unless @partition_index.key?(idx_key)
468
+ all_facts = @log.facts_for(store: store)
469
+ groups = Hash.new { |h, k| h[k] = [] }
470
+ all_facts.each do |f|
471
+ pv = f.value[partition_key]
472
+ groups[pv] << f if pv
473
+ end
474
+ @partition_index[idx_key] = groups
475
+ end
476
+
477
+ slice = (@partition_index[idx_key][partition_value] || []).dup
478
+ slice = slice.select { |f| f.transaction_time >= since } if since
479
+ slice = slice.select { |f| f.transaction_time <= as_of } if as_of
480
+ apply_coercions(store, slice)
481
+ end
482
+ end
483
+
484
+ def causation_chain(store:, key:)
485
+ history(store: store, key: key).map do |fact|
486
+ {
487
+ id: fact.id,
488
+ value_hash: fact.value_hash[0, 12],
489
+ causation: fact.causation,
490
+ transaction_time: fact.transaction_time
491
+ }
492
+ end
493
+ end
494
+
495
+ # Returns a causal proof for the given store/key: the full fact chain in
496
+ # chronological order, any registered derivation rules triggered by this
497
+ # store, and a Merkle proof hash over the chain.
498
+ #
499
+ # proof_hash: SHA256 of "id:value_hash:causation" entries joined by "|".
500
+ # Stable for the same chain; changes when any fact is added.
501
+ # nil when the chain is empty (key unknown).
502
+ #
503
+ # derived_by: derivation rules registered for this store — what downstream
504
+ # stores are affected by writes here.
505
+ def lineage(store:, key:)
506
+ chain = @log.facts_for(store: store, key: key).map do |fact|
507
+ {
508
+ id: fact.id,
509
+ store: fact.store,
510
+ key: fact.key,
511
+ causation: fact.causation,
512
+ value_hash: fact.value_hash,
513
+ transaction_time: fact.transaction_time,
514
+ valid_time: fact.valid_time,
515
+ schema_version: fact.schema_version
516
+ }
517
+ end
518
+
519
+ derived_by = @schema_graph.derivations_for_store(store: store).map do |rule|
520
+ {
521
+ target_store: rule.target_store,
522
+ target_key: rule.target_key.respond_to?(:call) ? :callable : rule.target_key,
523
+ source_filters: rule.source_filters
524
+ }
525
+ end
526
+
527
+ {
528
+ subject: { store: store, key: key },
529
+ chain: chain,
530
+ depth: chain.size,
531
+ derived_by: derived_by,
532
+ proof_hash: chain.empty? ? nil : lineage_proof_hash(chain)
533
+ }
534
+ end
535
+
536
+ # Write a snapshot of the current fact log to the backend's snapshot file.
537
+ # After a checkpoint, startup replay only replays facts written since the
538
+ # snapshot — reducing startup cost from O(total_facts) to O(delta_facts).
539
+ #
540
+ # No-op when the backend or log does not support snapshot (e.g. in-memory
541
+ # store or NATIVE FactLog without all_facts). Returns self.
542
+ def checkpoint
543
+ if @backend.respond_to?(:write_snapshot) && @log.respond_to?(:all_facts)
544
+ @backend.write_snapshot(@log.all_facts)
545
+ end
546
+ self
547
+ end
548
+
549
+ def fact_count
550
+ @log.size
551
+ end
552
+
553
+ # Returns the exact Fact object for +fact_id+ if it is live in the store, nil otherwise.
554
+ # Safe to call with nil or blank id — returns nil without raising.
555
+ # Does not apply coercion; returns the raw Fact as written.
556
+ def fact_by_id(fact_id)
557
+ return nil if fact_id.nil? || fact_id.to_s.empty?
558
+ @fact_id_index[fact_id]
559
+ end
560
+
561
+ # Returns compact metadata for +fact_id+ without exposing the full value payload.
562
+ # Returns nil when the fact is not live.
563
+ def fact_ref(fact_id)
564
+ fact = fact_by_id(fact_id)
565
+ return nil unless fact
566
+ {
567
+ id: fact.id,
568
+ store: fact.store,
569
+ key: fact.key,
570
+ transaction_time: fact.transaction_time,
571
+ valid_time: fact.valid_time,
572
+ value_hash: fact.value_hash
573
+ }
574
+ end
575
+
576
+ # Return all facts from the log, optionally bounded by time range.
577
+ # Used by the open protocol sync hub profile and replay operations.
578
+ # Returns [] when the native FactLog lacks all_facts support.
579
+ def fact_log_all(since: nil, as_of: nil)
580
+ return [] unless @log.respond_to?(:all_facts)
581
+ facts = @log.all_facts
582
+ facts = facts.select { |f| f.transaction_time >= since } if since
583
+ facts = facts.select { |f| f.transaction_time <= as_of } if as_of
584
+ facts
585
+ end
586
+
587
+ def close
588
+ @backend&.close
589
+ end
590
+
591
+ # Returns storage metadata from the backend when it supports it.
592
+ # Delegates to SegmentedFileBackend#storage_stats or returns nil for
593
+ # backends that do not expose storage metadata (in-memory, FileBackend).
594
+ def storage_stats(store: nil)
595
+ return nil unless @backend.respond_to?(:storage_stats)
596
+ @backend.storage_stats(store: store)
597
+ end
598
+
599
+ def segment_manifest(store: nil)
600
+ return nil unless @backend.respond_to?(:segment_manifest)
601
+ @backend.segment_manifest(store: store)
602
+ end
603
+
604
+ protected
605
+
606
+ def replay(fact)
607
+ @log.replay(fact)
608
+ @fact_id_index[fact.id] = fact
609
+ end
610
+
611
+ private
612
+
613
+ # Updates the materialized scope index for all scopes registered on +store+.
614
+ # Returns a Hash of { scope_name => :changed | :unchanged | :unknown } so that
615
+ # ReadCache can suppress consumer notifications for scopes whose membership
616
+ # did not change.
617
+ #
618
+ # :unknown means the index was not yet initialised (no query has run for that
619
+ # scope). ReadCache treats :unknown conservatively — it still notifies.
620
+ def update_scope_indices(store, key, new_value)
621
+ changes = {}
622
+ # Multiple paths may share the same [store, scope] key (e.g. when on_scope
623
+ # adds a consumer path alongside the register path). Process each scope
624
+ # exactly once — the shared Set must not be evaluated twice per write.
625
+ seen_scopes = Set.new
626
+ @schema_graph.paths_for(store).each do |path|
627
+ next unless path.scope
628
+ next unless seen_scopes.add?(path.scope)
629
+
630
+ scope_key = [store, path.scope]
631
+ filters = path.filters || {}
632
+ now_in = matches_filters?(new_value, filters)
633
+
634
+ @scope_mutex.synchronize do
635
+ idx = @scope_index[scope_key]
636
+ if idx.nil?
637
+ changes[path.scope] = :unknown
638
+ else
639
+ was_in = idx.include?(key)
640
+ if now_in && !was_in
641
+ idx.add(key)
642
+ changes[path.scope] = :changed
643
+ elsif !now_in && was_in
644
+ idx.delete(key)
645
+ changes[path.scope] = :changed
646
+ else
647
+ changes[path.scope] = :unchanged
648
+ end
649
+ end
650
+ end
651
+ end
652
+ changes
653
+ end
654
+
655
+ # --- Compaction internals ---
656
+
657
+ def compact_store(store, policy)
658
+ now = Process.clock_gettime(Process::CLOCK_REALTIME)
659
+ store_facts = @log.facts_for(store: store)
660
+ keep, drop = partition_compaction(store_facts, policy, now: now)
661
+
662
+ return { store: store, strategy: policy.strategy, dropped_count: 0, kept_count: keep.size, receipt_id: nil, durable: false } if drop.empty?
663
+
664
+ # Belt 7b — write receipt to meta-store before rebuilding log
665
+ receipt = write_compaction_receipt(store, drop, policy, now)
666
+
667
+ # Rebuild log: all other stores + compaction receipts + kept facts for this store
668
+ surviving = @log.all_facts.reject { |f| f.store == store }
669
+ surviving << receipt unless surviving.any? { |f| f.id == receipt.id }
670
+ new_facts = (surviving + keep).sort_by(&:transaction_time)
671
+ rebuild_log!(new_facts)
672
+
673
+ # Use the pruning-safe barrier when the backend supports it so that
674
+ # compacted facts cannot resurrect on reopen. Fall back to the
675
+ # non-destructive checkpoint when only write_snapshot is available
676
+ # (in-memory durability only), and skip entirely for in-memory stores.
677
+ durable = if @backend.respond_to?(:replace_with_snapshot!)
678
+ @backend.replace_with_snapshot!(@log.all_facts)
679
+ true
680
+ elsif @backend.respond_to?(:write_snapshot) && @log.respond_to?(:all_facts)
681
+ @backend.write_snapshot(@log.all_facts)
682
+ false
683
+ else
684
+ false
685
+ end
686
+
687
+ { store: store, strategy: policy.strategy, dropped_count: drop.size, kept_count: keep.size, receipt_id: receipt.id, durable: durable }
688
+ end
689
+
690
+ # Returns [keep_facts, drop_facts]. Latest fact per key is always kept.
691
+ def partition_compaction(facts, policy, now:)
692
+ latest_ids = facts.group_by(&:key).transform_values { |fs| fs.max_by(&:transaction_time).id }
693
+ current = Set.new(latest_ids.values)
694
+
695
+ case policy.strategy
696
+ when :ephemeral
697
+ keep = facts.select { |f| current.include?(f.id) }
698
+ drop = facts.reject { |f| current.include?(f.id) }
699
+ when :rolling_window
700
+ cutoff = now - policy.duration.to_f
701
+ keep = facts.select { |f| current.include?(f.id) || f.transaction_time >= cutoff }
702
+ drop = facts.reject { |f| current.include?(f.id) || f.transaction_time >= cutoff }
703
+ else
704
+ keep = facts
705
+ drop = []
706
+ end
707
+
708
+ [keep, drop]
709
+ end
710
+
711
+ # Write a compaction receipt fact to the :__compaction_receipts meta-store.
712
+ def write_compaction_receipt(store, dropped_facts, policy, now)
713
+ oldest = dropped_facts.min_by(&:transaction_time)
714
+ newest = dropped_facts.max_by(&:transaction_time)
715
+ write(
716
+ store: :__compaction_receipts,
717
+ key: "#{store}_#{SecureRandom.hex(4)}",
718
+ value: {
719
+ type: :compaction_receipt,
720
+ compacted_store: store,
721
+ strategy: policy.strategy,
722
+ compacted_count: dropped_facts.size,
723
+ oldest_dropped: oldest&.id,
724
+ newest_dropped: newest&.id,
725
+ oldest_ts: oldest&.transaction_time,
726
+ newest_ts: newest&.transaction_time,
727
+ compacted_at: now
728
+ }
729
+ )
730
+ end
731
+
732
+ # Replace the in-memory FactLog with a rebuilt one from +new_facts+.
733
+ # Clears all derived indices (scope index, partition index, read cache) —
734
+ # they will be rebuilt lazily on next access.
735
+ def rebuild_log!(new_facts)
736
+ new_log = FactLog.new
737
+ new_facts.each { |f| new_log.replay(f) }
738
+
739
+ # Native FactLog tracks seen stores only via the Ruby append patch;
740
+ # replay bypasses it so we backfill manually.
741
+ if defined?(Igniter::Store::NATIVE) && Igniter::Store::NATIVE
742
+ seen = new_facts.map(&:store).uniq
743
+ new_log.instance_variable_set(:@_seen_stores, seen)
744
+ end
745
+
746
+ @log = new_log
747
+ @fact_id_index = new_facts.each_with_object({}) { |f, h| h[f.id] = f }
748
+ @scope_mutex.synchronize { @scope_index.clear }
749
+ @partition_mutex.synchronize { @partition_index.clear }
750
+ @cache = ReadCache.new(lru_cap: @lru_cap)
751
+ end
752
+
753
+ def lineage_proof_hash(chain)
754
+ input = chain.map { |e| "#{e[:id]}:#{e[:value_hash]}:#{e[:causation]}" }.join("|")
755
+ Digest::SHA256.hexdigest(input)
756
+ end
757
+
758
+ # Runs all derivation rules registered for +store+ unless we are already inside
759
+ # a derivation (cycle guard via thread-local flag).
760
+ def run_derivations(store:, source_fact:)
761
+ return if Thread.current[:igniter_deriving]
762
+
763
+ rules = @schema_graph.derivations_for_store(store: store)
764
+ return if rules.empty?
765
+
766
+ Thread.current[:igniter_deriving] = true
767
+ begin
768
+ rules.each do |rule|
769
+ source_facts = @log.query_scope(store: rule.source_store, filters: rule.source_filters)
770
+ derived_value = rule.rule.call(source_facts)
771
+ next unless derived_value
772
+
773
+ tk = rule.target_key.respond_to?(:call) ? rule.target_key.call(source_facts) : rule.target_key.to_s
774
+ write(store: rule.target_store, key: tk, value: derived_value)
775
+ end
776
+ ensure
777
+ Thread.current[:igniter_deriving] = false
778
+ end
779
+ end
780
+
781
+ # Runs all scatter rules registered for +store+ unless we are already inside
782
+ # a scatter derivation (separate cycle guard from gather derivations).
783
+ # Extracts partition_by field from the triggering fact's value, reads the
784
+ # current index entry, calls rule.(partition_key, existing, new_fact), and
785
+ # writes the result when non-nil.
786
+ def run_scatters(store:, source_fact:)
787
+ return if Thread.current[:igniter_scattering]
788
+
789
+ rules = @schema_graph.scatters_for_store(store: store)
790
+ return if rules.empty?
791
+
792
+ Thread.current[:igniter_scattering] = true
793
+ begin
794
+ rules.each do |rule|
795
+ partition_value = source_fact.value[rule.partition_by]
796
+ next unless partition_value
797
+
798
+ target_key = partition_value.to_s
799
+ existing_value = read(store: rule.target_store, key: target_key)
800
+ derived_value = rule.rule.call(partition_value, existing_value, source_fact)
801
+ next unless derived_value
802
+
803
+ write(store: rule.target_store, key: target_key, value: derived_value)
804
+ end
805
+ ensure
806
+ Thread.current[:igniter_scattering] = false
807
+ end
808
+ end
809
+
810
+ def matches_filters?(value, filters)
811
+ return false unless value.is_a?(Hash)
812
+ filters.all? { |k, v| value[k] == v }
813
+ end
814
+
815
+ # Returns the coerced value for a single fact point-read.
816
+ def coerce_value(store, fact)
817
+ coercion = @coercions[store]
818
+ return fact.value unless coercion
819
+
820
+ coercion.call(fact.value, fact.schema_version)
821
+ end
822
+
823
+ # Wraps each fact in a CoercedFact when a coercion is registered for +store+.
824
+ # Returns the original array unchanged when no coercion is registered (preserves
825
+ # object identity for TTL cache equality checks).
826
+ def apply_coercions(store, facts)
827
+ coercion = @coercions[store]
828
+ return facts unless coercion
829
+
830
+ facts.map do |f|
831
+ original = f.value
832
+ coerced = coercion.call(original, f.schema_version)
833
+ coerced.equal?(original) ? f : CoercedFact.new(f, coerced)
834
+ end
835
+ end
836
+ end
837
+ end
838
+ end