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,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ warn "igniter-store-server is deprecated; use igniter-ledger-server instead"
5
+
6
+ load File.expand_path("igniter-ledger-server", __dir__)
@@ -0,0 +1,28 @@
1
+ [package]
2
+ name = "igniter_store_native"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+ authors = ["Alexander <alexander.s.fokin@gmail.com>"]
6
+
7
+ [lib]
8
+ name = "igniter_store_native"
9
+ crate-type = ["cdylib"]
10
+
11
+ [dependencies]
12
+ magnus = "0.7"
13
+ blake3 = "1.5"
14
+ parking_lot = "0.12"
15
+ serde = { version = "1", features = ["derive"] }
16
+ serde_json = "1"
17
+ rmp-serde = "1.3"
18
+ crc32fast = "1"
19
+ uuid = { version = "1", features = ["v4", "fast-rng"] }
20
+
21
+ [profile.release]
22
+ lto = true
23
+ codegen-units = 1
24
+ opt-level = 3
25
+
26
+ [profile.dev]
27
+ opt-level = 0
28
+ debug = true
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mkmf"
4
+ require "rb_sys/mkmf"
5
+
6
+ create_rust_makefile("igniter_store_native")
@@ -0,0 +1,303 @@
1
+ use magnus::{
2
+ r_hash::ForEach, prelude::*, Error, Float as RbFloat, IntoValue, Integer as RbInteger,
3
+ RArray, RHash, Ruby, Symbol, Value,
4
+ };
5
+ use serde::{Deserialize, Serialize};
6
+ use std::collections::BTreeMap;
7
+
8
+ /// Pure-Rust representation of an immutable fact.
9
+ #[derive(Debug, Clone, Serialize, Deserialize)]
10
+ pub struct FactData {
11
+ pub id: String,
12
+ pub store: String,
13
+ pub key: String,
14
+ /// Stable-sorted JSON with symbol-tagged strings (":foo" = Ruby :foo).
15
+ pub value: serde_json::Value,
16
+ pub value_hash: String,
17
+ pub causation: Option<String>,
18
+ /// Wall-clock epoch seconds when the fact was committed (auto-set by store).
19
+ pub transaction_time: f64,
20
+ /// Domain time: when the event is asserted to be true in the world (writer-supplied, nullable).
21
+ pub valid_time: Option<f64>,
22
+ pub schema_version: i64,
23
+ /// Typed producer reference: { type:, name:, ... } or nil.
24
+ pub producer: Option<serde_json::Value>,
25
+ /// Inline provenance for derived facts: { name:, version:, source_fact_ids:, ... } or nil.
26
+ pub derivation: Option<serde_json::Value>,
27
+ }
28
+
29
+ /// Ruby-visible Fact class backed by Rust.
30
+ #[magnus::wrap(class = "Igniter::Store::Fact", free_immediately, size)]
31
+ pub struct Fact(pub FactData);
32
+
33
+ // ── Class method ─────────────────────────────────────────────────────────────
34
+
35
+ /// Build a Fact from Ruby arguments (8-arg form used by native.rb build wrapper).
36
+ /// valid_time and schema_version are positional; producer and derivation are Value
37
+ /// so they can accept Ruby nil without a wrapper type.
38
+ pub fn rb_build(
39
+ store: String,
40
+ key: String,
41
+ rb_value: RHash,
42
+ causation: Option<String>,
43
+ valid_time_val: Value,
44
+ schema_version: i64,
45
+ producer_val: Value,
46
+ derivation_val: Value,
47
+ ) -> Result<Fact, Error> {
48
+ let json_val = ruby_hash_to_json_sorted(rb_value.as_value());
49
+ let json_str = serde_json::to_string(&json_val)
50
+ .map_err(|e| Error::new(magnus::exception::runtime_error(), e.to_string()))?;
51
+ let value_hash = blake3::hash(json_str.as_bytes()).to_hex().to_string();
52
+
53
+ let valid_time = if valid_time_val.is_nil() {
54
+ None
55
+ } else {
56
+ RbFloat::from_value(valid_time_val)
57
+ .map(|f| Some(f.to_f64()))
58
+ .unwrap_or_else(|| {
59
+ RbInteger::from_value(valid_time_val)
60
+ .and_then(|i| i.to_i64().ok())
61
+ .map(|i| i as f64)
62
+ })
63
+ };
64
+
65
+ let producer = if producer_val.is_nil() {
66
+ None
67
+ } else {
68
+ Some(ruby_hash_to_json_sorted(producer_val))
69
+ };
70
+
71
+ let derivation = if derivation_val.is_nil() {
72
+ None
73
+ } else {
74
+ Some(ruby_hash_to_json_sorted(derivation_val))
75
+ };
76
+
77
+ Ok(Fact(FactData {
78
+ id: uuid::Uuid::new_v4().to_string(),
79
+ store,
80
+ key,
81
+ value: json_val,
82
+ value_hash,
83
+ causation,
84
+ transaction_time: current_time(),
85
+ valid_time,
86
+ schema_version,
87
+ producer,
88
+ derivation,
89
+ }))
90
+ }
91
+
92
+ // ── Instance methods ──────────────────────────────────────────────────────────
93
+
94
+ impl Fact {
95
+ pub fn rb_id(&self) -> String { self.0.id.clone() }
96
+ pub fn rb_store(&self) -> String { self.0.store.clone() }
97
+ pub fn rb_key(&self) -> String { self.0.key.clone() }
98
+
99
+ pub fn rb_value(&self) -> Value {
100
+ let ruby = unsafe { Ruby::get_unchecked() };
101
+ json_to_ruby_value(&ruby, &self.0.value)
102
+ }
103
+
104
+ pub fn rb_value_hash(&self) -> String { self.0.value_hash.clone() }
105
+
106
+ pub fn rb_causation(&self) -> Value {
107
+ let ruby = unsafe { Ruby::get_unchecked() };
108
+ match &self.0.causation {
109
+ Some(s) => s.as_str().into_value_with(&ruby),
110
+ None => ruby.qnil().as_value(),
111
+ }
112
+ }
113
+
114
+ /// Canonical name for when the fact was committed.
115
+ pub fn rb_transaction_time(&self) -> f64 { self.0.transaction_time }
116
+
117
+ /// Canonical name for the domain valid time (nullable).
118
+ pub fn rb_valid_time(&self) -> Value {
119
+ let ruby = unsafe { Ruby::get_unchecked() };
120
+ match self.0.valid_time {
121
+ Some(v) => v.into_value_with(&ruby),
122
+ None => ruby.qnil().as_value(),
123
+ }
124
+ }
125
+
126
+ /// Backward-compat alias for transaction_time.
127
+ pub fn rb_timestamp(&self) -> f64 { self.0.transaction_time }
128
+
129
+ /// Backward-compat alias for valid_time (returns 0 when nil, matching old term: 0 default).
130
+ pub fn rb_term(&self) -> f64 { self.0.valid_time.unwrap_or(0.0) }
131
+
132
+ pub fn rb_schema_version(&self) -> i64 { self.0.schema_version }
133
+
134
+ pub fn rb_producer(&self) -> Value {
135
+ let ruby = unsafe { Ruby::get_unchecked() };
136
+ match &self.0.producer {
137
+ Some(v) => json_to_ruby_value(&ruby, v),
138
+ None => ruby.qnil().as_value(),
139
+ }
140
+ }
141
+
142
+ pub fn rb_derivation(&self) -> Value {
143
+ let ruby = unsafe { Ruby::get_unchecked() };
144
+ match &self.0.derivation {
145
+ Some(v) => json_to_ruby_value(&ruby, v),
146
+ None => ruby.qnil().as_value(),
147
+ }
148
+ }
149
+
150
+ pub fn rb_frozen(&self) -> bool { true }
151
+
152
+ pub fn rb_to_h(&self) -> Result<RHash, Error> {
153
+ let ruby = unsafe { Ruby::get_unchecked() };
154
+ let h = RHash::new();
155
+ h.aset(Symbol::new("id"), self.0.id.as_str())?;
156
+ h.aset(Symbol::new("store"), Symbol::new(self.0.store.as_str()))?;
157
+ h.aset(Symbol::new("key"), self.0.key.as_str())?;
158
+ h.aset(Symbol::new("value"), json_to_ruby_value(&ruby, &self.0.value))?;
159
+ h.aset(Symbol::new("value_hash"), self.0.value_hash.as_str())?;
160
+ match &self.0.causation {
161
+ Some(s) => h.aset(Symbol::new("causation"), s.as_str())?,
162
+ None => h.aset(Symbol::new("causation"), ruby.qnil())?,
163
+ }
164
+ h.aset(Symbol::new("transaction_time"), self.0.transaction_time)?;
165
+ match self.0.valid_time {
166
+ Some(v) => h.aset(Symbol::new("valid_time"), v)?,
167
+ None => h.aset(Symbol::new("valid_time"), ruby.qnil())?,
168
+ }
169
+ h.aset(Symbol::new("schema_version"), self.0.schema_version)?;
170
+ match &self.0.producer {
171
+ Some(v) => h.aset(Symbol::new("producer"), json_to_ruby_value(&ruby, v))?,
172
+ None => h.aset(Symbol::new("producer"), ruby.qnil())?,
173
+ }
174
+ match &self.0.derivation {
175
+ Some(v) => h.aset(Symbol::new("derivation"), json_to_ruby_value(&ruby, v))?,
176
+ None => h.aset(Symbol::new("derivation"), ruby.qnil())?,
177
+ }
178
+ Ok(h)
179
+ }
180
+
181
+ pub fn rb_inspect(&self) -> String {
182
+ format!(
183
+ "#<Igniter::Store::Fact store={:?} key={:?} hash={}>",
184
+ self.0.store,
185
+ self.0.key,
186
+ &self.0.value_hash[..12]
187
+ )
188
+ }
189
+ }
190
+
191
+ // ── Helpers ───────────────────────────────────────────────────────────────────
192
+
193
+ pub fn ruby_hash_to_json_sorted(val: Value) -> serde_json::Value {
194
+ ruby_to_json_inner(val)
195
+ }
196
+
197
+ fn ruby_to_json_inner(val: Value) -> serde_json::Value {
198
+ if val.is_nil() {
199
+ return serde_json::Value::Null;
200
+ }
201
+ // Symbol :foo → tagged string ":foo" (preserves round-trip identity)
202
+ if let Some(sym) = Symbol::from_value(val) {
203
+ let name = sym.name().unwrap_or_default();
204
+ return serde_json::Value::String(format!(":{name}"));
205
+ }
206
+ // Array
207
+ if let Some(arr) = RArray::from_value(val) {
208
+ let len = arr.len();
209
+ let items: Vec<serde_json::Value> = (0..len)
210
+ .map(|i| {
211
+ arr.entry(i as isize)
212
+ .map(ruby_to_json_inner)
213
+ .unwrap_or(serde_json::Value::Null)
214
+ })
215
+ .collect();
216
+ return serde_json::Value::Array(items);
217
+ }
218
+ // Hash — keys sorted via BTreeMap for stable hashing
219
+ if let Some(hash) = RHash::from_value(val) {
220
+ let mut map: BTreeMap<String, serde_json::Value> = BTreeMap::new();
221
+ let _ = hash.foreach(|k: Value, v: Value| {
222
+ let key = if let Some(sym) = Symbol::from_value(k) {
223
+ sym.name().unwrap_or_default().to_string()
224
+ } else if let Ok(s) = String::try_convert(k) {
225
+ s
226
+ } else {
227
+ k.inspect()
228
+ };
229
+ map.insert(key, ruby_to_json_inner(v));
230
+ Ok(ForEach::Continue)
231
+ });
232
+ return serde_json::Value::Object(map.into_iter().collect());
233
+ }
234
+ // Integer — exact Ruby type check to avoid coercing Float 7.0 → 7
235
+ if let Some(int) = RbInteger::from_value(val) {
236
+ if let Ok(n) = int.to_i64() {
237
+ return serde_json::json!(n);
238
+ }
239
+ }
240
+ // Float — exact Ruby type check
241
+ if let Some(flt) = RbFloat::from_value(val) {
242
+ return serde_json::json!(flt.to_f64());
243
+ }
244
+ // String
245
+ if let Ok(s) = String::try_convert(val) {
246
+ return serde_json::Value::String(s);
247
+ }
248
+ // Boolean fallback via inspect
249
+ match val.inspect().as_str() {
250
+ "true" => serde_json::Value::Bool(true),
251
+ "false" => serde_json::Value::Bool(false),
252
+ other => serde_json::Value::String(other.to_string()),
253
+ }
254
+ }
255
+
256
+ /// serde_json::Value → Ruby Value.
257
+ /// Strings prefixed with ":" are restored as Ruby Symbols.
258
+ pub fn json_to_ruby_value(ruby: &Ruby, val: &serde_json::Value) -> Value {
259
+ match val {
260
+ serde_json::Value::Null => ruby.qnil().as_value(),
261
+ serde_json::Value::Bool(b) => {
262
+ if *b { ruby.qtrue().as_value() } else { ruby.qfalse().as_value() }
263
+ }
264
+ serde_json::Value::Number(n) => {
265
+ if let Some(i) = n.as_i64() {
266
+ i.into_value_with(ruby)
267
+ } else if let Some(f) = n.as_f64() {
268
+ f.into_value_with(ruby)
269
+ } else {
270
+ ruby.qnil().as_value()
271
+ }
272
+ }
273
+ serde_json::Value::String(s) => {
274
+ if s.starts_with(':') {
275
+ Symbol::new(&s[1..]).as_value()
276
+ } else {
277
+ s.as_str().into_value_with(ruby)
278
+ }
279
+ }
280
+ serde_json::Value::Array(arr) => {
281
+ let rb_arr = RArray::new();
282
+ for item in arr {
283
+ let _ = rb_arr.push(json_to_ruby_value(ruby, item));
284
+ }
285
+ rb_arr.as_value()
286
+ }
287
+ serde_json::Value::Object(obj) => {
288
+ let rb_hash = RHash::new();
289
+ for (k, v) in obj {
290
+ let key = Symbol::new(k.as_str()).as_value();
291
+ let _ = rb_hash.aset(key, json_to_ruby_value(ruby, v));
292
+ }
293
+ rb_hash.as_value()
294
+ }
295
+ }
296
+ }
297
+
298
+ fn current_time() -> f64 {
299
+ std::time::SystemTime::now()
300
+ .duration_since(std::time::UNIX_EPOCH)
301
+ .unwrap_or_default()
302
+ .as_secs_f64()
303
+ }
@@ -0,0 +1,180 @@
1
+ use magnus::{prelude::*, Error, IntoValue, RArray, RHash, Ruby, Value};
2
+ use parking_lot::RwLock;
3
+ use std::collections::HashMap;
4
+
5
+ use crate::fact::{ruby_hash_to_json_sorted, Fact, FactData};
6
+
7
+ struct FactLogInner {
8
+ log: Vec<FactData>,
9
+ by_id: HashMap<String, usize>,
10
+ /// (store, key) → insertion-ordered indices into `log`
11
+ by_key: HashMap<(String, String), Vec<usize>>,
12
+ }
13
+
14
+ impl FactLogInner {
15
+ fn push(&mut self, data: FactData) {
16
+ let idx = self.log.len();
17
+ self.by_id.insert(data.id.clone(), idx);
18
+ self.by_key
19
+ .entry((data.store.clone(), data.key.clone()))
20
+ .or_default()
21
+ .push(idx);
22
+ self.log.push(data);
23
+ }
24
+ }
25
+
26
+ #[magnus::wrap(class = "Igniter::Store::FactLog", free_immediately, size)]
27
+ pub struct FactLog(RwLock<FactLogInner>);
28
+
29
+ impl FactLog {
30
+ pub fn rb_new() -> Self {
31
+ FactLog(RwLock::new(FactLogInner {
32
+ log: Vec::new(),
33
+ by_id: HashMap::new(),
34
+ by_key: HashMap::new(),
35
+ }))
36
+ }
37
+
38
+ /// Appends a fact. Returns nil; Ruby wrapper returns the original Fact arg.
39
+ pub fn rb_append(&self, rb_fact: &Fact) -> Value {
40
+ self.0.write().push(rb_fact.0.clone());
41
+ let ruby = unsafe { Ruby::get_unchecked() };
42
+ ruby.qnil().as_value()
43
+ }
44
+
45
+ /// Replays a fact during WAL restore (no backend write).
46
+ pub fn rb_replay_fact(&self, rb_fact: &Fact) {
47
+ self.0.write().push(rb_fact.0.clone());
48
+ }
49
+
50
+ pub fn rb_latest_for_native(
51
+ &self,
52
+ store: String,
53
+ key: String,
54
+ as_of: Option<f64>,
55
+ ) -> Value {
56
+ let ruby = unsafe { Ruby::get_unchecked() };
57
+ let inner = self.0.read();
58
+ let k = (store, key);
59
+ let indices = match inner.by_key.get(&k) {
60
+ Some(v) => v,
61
+ None => return ruby.qnil().as_value(),
62
+ };
63
+
64
+ let latest_idx = if let Some(as_of) = as_of {
65
+ indices
66
+ .iter()
67
+ .rev()
68
+ .find(|&&i| inner.log[i].transaction_time <= as_of)
69
+ .copied()
70
+ } else {
71
+ indices.last().copied()
72
+ };
73
+
74
+ match latest_idx {
75
+ Some(idx) => {
76
+ let data = inner.log[idx].clone();
77
+ drop(inner);
78
+ Fact(data).into_value_with(&ruby)
79
+ }
80
+ None => ruby.qnil().as_value(),
81
+ }
82
+ }
83
+
84
+ pub fn rb_facts_for_native(
85
+ &self,
86
+ store: String,
87
+ key: Option<String>,
88
+ since: Option<f64>,
89
+ as_of: Option<f64>,
90
+ ) -> Result<RArray, Error> {
91
+ let ruby = unsafe { Ruby::get_unchecked() };
92
+ let inner = self.0.read();
93
+
94
+ let indices: Vec<usize> = if let Some(ref k) = key {
95
+ inner
96
+ .by_key
97
+ .get(&(store.clone(), k.clone()))
98
+ .cloned()
99
+ .unwrap_or_default()
100
+ } else {
101
+ (0..inner.log.len())
102
+ .filter(|&i| inner.log[i].store == store)
103
+ .collect()
104
+ };
105
+
106
+ let filtered: Vec<FactData> = indices
107
+ .into_iter()
108
+ .filter(|&i| {
109
+ let t = inner.log[i].transaction_time;
110
+ since.map_or(true, |s| t >= s) && as_of.map_or(true, |a| t <= a)
111
+ })
112
+ .map(|i| inner.log[i].clone())
113
+ .collect();
114
+
115
+ drop(inner);
116
+
117
+ let arr = RArray::new();
118
+ for data in filtered {
119
+ arr.push(Fact(data).into_value_with(&ruby))?;
120
+ }
121
+ Ok(arr)
122
+ }
123
+
124
+ pub fn rb_size(&self) -> usize {
125
+ self.0.read().log.len()
126
+ }
127
+
128
+ /// Returns latest fact per key in `store` whose `value` matches all `filters`.
129
+ /// `filters` is a Ruby Hash like `{ status: :pending }`.
130
+ /// Returns the latest fact per key (optionally as of a timestamp).
131
+ pub fn rb_query_scope_native(
132
+ &self,
133
+ store: String,
134
+ filters: RHash,
135
+ as_of: Option<f64>,
136
+ ) -> Result<RArray, Error> {
137
+ let ruby = unsafe { Ruby::get_unchecked() };
138
+ let filter_json = ruby_hash_to_json_sorted(filters.as_value());
139
+
140
+ let inner = self.0.read();
141
+
142
+ let mut results: Vec<FactData> = Vec::new();
143
+
144
+ for ((s, _k), indices) in &inner.by_key {
145
+ if s != &store {
146
+ continue;
147
+ }
148
+ let latest_idx = if let Some(as_of) = as_of {
149
+ indices
150
+ .iter()
151
+ .rev()
152
+ .find(|&&i| inner.log[i].transaction_time <= as_of)
153
+ .copied()
154
+ } else {
155
+ indices.last().copied()
156
+ };
157
+ if let Some(idx) = latest_idx {
158
+ if matches_filters(&inner.log[idx].value, &filter_json) {
159
+ results.push(inner.log[idx].clone());
160
+ }
161
+ }
162
+ }
163
+ drop(inner);
164
+
165
+ let arr = RArray::new();
166
+ for data in results {
167
+ arr.push(Fact(data).into_value_with(&ruby))?;
168
+ }
169
+ Ok(arr)
170
+ }
171
+ }
172
+
173
+ fn matches_filters(value: &serde_json::Value, filters: &serde_json::Value) -> bool {
174
+ match (value, filters) {
175
+ (serde_json::Value::Object(v), serde_json::Value::Object(f)) => {
176
+ f.iter().all(|(k, fv)| v.get(k).map_or(false, |vv| vv == fv))
177
+ }
178
+ _ => false,
179
+ }
180
+ }
@@ -0,0 +1,91 @@
1
+ /// Binary framed WAL.
2
+ ///
3
+ /// Record layout:
4
+ /// [4 bytes BE u32: body_len][body_len bytes: rmp-serde body][4 bytes BE u32: CRC32 of body]
5
+ use magnus::{Error, IntoValue, RArray, Ruby};
6
+ use std::cell::RefCell;
7
+ use std::fs::{File, OpenOptions};
8
+ use std::io::{BufWriter, Read, Seek, SeekFrom, Write};
9
+
10
+ use crate::fact::{Fact, FactData};
11
+
12
+ struct FileBackendInner {
13
+ path: String,
14
+ writer: BufWriter<File>,
15
+ }
16
+
17
+ #[magnus::wrap(class = "Igniter::Store::FileBackend", free_immediately, size)]
18
+ pub struct FileBackend(RefCell<FileBackendInner>);
19
+
20
+ impl FileBackend {
21
+ pub fn rb_new(path: String) -> Result<Self, Error> {
22
+ let file = OpenOptions::new()
23
+ .create(true)
24
+ .append(true)
25
+ .open(&path)
26
+ .map_err(|e| Error::new(magnus::exception::runtime_error(), e.to_string()))?;
27
+ Ok(FileBackend(RefCell::new(FileBackendInner {
28
+ path,
29
+ writer: BufWriter::new(file),
30
+ })))
31
+ }
32
+
33
+ pub fn rb_write_fact(&self, rb_fact: &Fact) -> Result<(), Error> {
34
+ let body = rmp_serde::to_vec_named(&rb_fact.0)
35
+ .map_err(|e| Error::new(magnus::exception::runtime_error(), e.to_string()))?;
36
+ let crc = crc32fast::hash(&body);
37
+ let mut inner = self.0.borrow_mut();
38
+ inner.writer.write_all(&(body.len() as u32).to_be_bytes())
39
+ .and_then(|_| inner.writer.write_all(&body))
40
+ .and_then(|_| inner.writer.write_all(&crc.to_be_bytes()))
41
+ .and_then(|_| inner.writer.flush())
42
+ .map_err(|e| Error::new(magnus::exception::runtime_error(), e.to_string()))
43
+ }
44
+
45
+ pub fn rb_replay(&self) -> Result<RArray, Error> {
46
+ let ruby = unsafe { Ruby::get_unchecked() };
47
+ let path = self.0.borrow().path.clone();
48
+
49
+ let mut file = File::open(&path)
50
+ .map_err(|e| Error::new(magnus::exception::runtime_error(), e.to_string()))?;
51
+ file.seek(SeekFrom::Start(0))
52
+ .map_err(|e| Error::new(magnus::exception::runtime_error(), e.to_string()))?;
53
+
54
+ let arr = RArray::new();
55
+ loop {
56
+ let mut len_buf = [0u8; 4];
57
+ match file.read_exact(&mut len_buf) {
58
+ Ok(_) => {}
59
+ Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
60
+ Err(e) => return Err(Error::new(magnus::exception::runtime_error(), e.to_string())),
61
+ }
62
+ let body_len = u32::from_be_bytes(len_buf) as usize;
63
+
64
+ let mut body = vec![0u8; body_len];
65
+ if file.read_exact(&mut body).is_err() {
66
+ break; // truncated record
67
+ }
68
+
69
+ let mut crc_buf = [0u8; 4];
70
+ if file.read_exact(&mut crc_buf).is_err() {
71
+ break;
72
+ }
73
+ if u32::from_be_bytes(crc_buf) != crc32fast::hash(&body) {
74
+ break; // corrupted frame
75
+ }
76
+
77
+ let data: FactData = match rmp_serde::from_slice(&body) {
78
+ Ok(d) => d,
79
+ Err(_) => continue,
80
+ };
81
+
82
+ arr.push(Fact(data).into_value_with(&ruby))?;
83
+ }
84
+ Ok(arr)
85
+ }
86
+
87
+ pub fn rb_close(&self) -> Result<(), Error> {
88
+ self.0.borrow_mut().writer.flush()
89
+ .map_err(|e| Error::new(magnus::exception::runtime_error(), e.to_string()))
90
+ }
91
+ }
@@ -0,0 +1,55 @@
1
+ mod fact;
2
+ mod fact_log;
3
+ mod file_backend;
4
+
5
+ use magnus::{function, method, prelude::*, Error, Module, Ruby};
6
+
7
+ use fact::Fact;
8
+ use fact_log::FactLog;
9
+ use file_backend::FileBackend;
10
+
11
+ #[magnus::init]
12
+ fn init(ruby: &Ruby) -> Result<(), Error> {
13
+ let igniter = ruby.define_module("Igniter")?;
14
+ let store_mod = igniter.define_module("Store")?;
15
+
16
+ // ── Fact ──────────────────────────────────────────────────────────────────
17
+ let fact_class = store_mod.define_class("Fact", ruby.class_object())?;
18
+ fact_class.define_singleton_method("_native_build", function!(fact::rb_build, 8))?;
19
+ fact_class.define_method("id", method!(Fact::rb_id, 0))?;
20
+ fact_class.define_method("store", method!(Fact::rb_store, 0))?;
21
+ fact_class.define_method("key", method!(Fact::rb_key, 0))?;
22
+ fact_class.define_method("value", method!(Fact::rb_value, 0))?;
23
+ fact_class.define_method("value_hash", method!(Fact::rb_value_hash, 0))?;
24
+ fact_class.define_method("causation", method!(Fact::rb_causation, 0))?;
25
+ fact_class.define_method("transaction_time", method!(Fact::rb_transaction_time, 0))?;
26
+ fact_class.define_method("valid_time", method!(Fact::rb_valid_time, 0))?;
27
+ fact_class.define_method("producer", method!(Fact::rb_producer, 0))?;
28
+ fact_class.define_method("derivation", method!(Fact::rb_derivation, 0))?;
29
+ fact_class.define_method("schema_version", method!(Fact::rb_schema_version, 0))?;
30
+ fact_class.define_method("to_h", method!(Fact::rb_to_h, 0))?;
31
+ fact_class.define_method("inspect", method!(Fact::rb_inspect, 0))?;
32
+ fact_class.define_method("frozen?", method!(Fact::rb_frozen, 0))?;
33
+ // Backward-compat aliases (removed after callers migrate)
34
+ fact_class.define_method("timestamp", method!(Fact::rb_timestamp, 0))?;
35
+ fact_class.define_method("term", method!(Fact::rb_term, 0))?;
36
+
37
+ // ── FactLog ───────────────────────────────────────────────────────────────
38
+ let log_class = store_mod.define_class("FactLog", ruby.class_object())?;
39
+ log_class.define_singleton_method("new", function!(FactLog::rb_new, 0))?;
40
+ log_class.define_method("_native_append", method!(FactLog::rb_append, 1))?;
41
+ log_class.define_method("replay", method!(FactLog::rb_replay_fact, 1))?;
42
+ log_class.define_method("latest_for_native", method!(FactLog::rb_latest_for_native, 3))?;
43
+ log_class.define_method("facts_for_native", method!(FactLog::rb_facts_for_native, 4))?;
44
+ log_class.define_method("query_scope_native", method!(FactLog::rb_query_scope_native, 3))?;
45
+ log_class.define_method("size", method!(FactLog::rb_size, 0))?;
46
+
47
+ // ── FileBackend ───────────────────────────────────────────────────────────
48
+ let fb_class = store_mod.define_class("FileBackend", ruby.class_object())?;
49
+ fb_class.define_singleton_method("new", function!(FileBackend::rb_new, 1))?;
50
+ fb_class.define_method("write_fact", method!(FileBackend::rb_write_fact, 1))?;
51
+ fb_class.define_method("replay", method!(FileBackend::rb_replay, 0))?;
52
+ fb_class.define_method("close", method!(FileBackend::rb_close, 0))?;
53
+
54
+ Ok(())
55
+ }
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "store"
4
+
5
+ module Igniter
6
+ Ledger = Store unless const_defined?(:Ledger)
7
+ end