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,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
|