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,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "date"
5
+
6
+ module Igniter
7
+ module Store
8
+ module IntelligentLedger
9
+ # Pure computation: derives an AvailabilitySnapshot from base facts.
10
+ # No store dependency — all IO lives in AvailabilityLedger.
11
+ #
12
+ # Template format (value Hash):
13
+ # { "weekly_schedule" => { "1" => [["09:00","17:00"]], "3" => [...], ... } }
14
+ # Keys are wday strings ("0"=Sun … "6"=Sat); values are arrays of [start_time, end_time].
15
+ #
16
+ # Override/reservation value:
17
+ # { "start" => <unix float>, "end" => <unix float>, "type" => "block"|"reserve"|"cancel",
18
+ # "order_id" => "..." (optional) }
19
+ class AvailabilityDeriver
20
+ DERIVATION_NAME = "availability_snapshot"
21
+ DERIVATION_VERSION = "1.0"
22
+
23
+ # Returns a value Hash suitable for storing as an AvailabilitySnapshotFact.
24
+ #
25
+ # base_facts:
26
+ # :template — Fact or nil (weekly schedule)
27
+ # :overrides — Array<Fact> (explicit time blocks)
28
+ # :active_reservations — Array<Fact> (reserved order slots)
29
+ #
30
+ # horizon_start — Date (inclusive, start of window)
31
+ # horizon_days — Integer (number of days to expand)
32
+ # source_fact_ids — Array<String> (all fact IDs that contributed)
33
+ # source_fact_refs — Array<Hash> (structured refs: id/store/role; optional)
34
+ def derive(base_facts:, horizon_start:, horizon_days:, source_fact_ids:, source_fact_refs: nil)
35
+ template_value = base_facts[:template]&.value || {}
36
+ override_facts = base_facts[:overrides] || []
37
+ reservation_facts = base_facts[:active_reservations] || []
38
+
39
+ # 1. Expand template into intervals over the horizon window
40
+ available = expand_template(template_value, horizon_start, horizon_days)
41
+
42
+ # 2. Collect blocking intervals (overrides + reservations)
43
+ blocked = collect_blocked(override_facts, reservation_facts)
44
+
45
+ # 3. Subtract blocked from available
46
+ blocked.each { |b| available = subtract_interval(available, b) }
47
+
48
+ # 4. Compute total available seconds
49
+ available_seconds = available.sum { |s, e| e - s }
50
+
51
+ refs = (source_fact_refs || []).uniq { |r| r["id"] || r[:id] }
52
+
53
+ {
54
+ "available_slots" => available.map { |s, e| { "start" => s, "end" => e } },
55
+ "blocked_intervals" => blocked.map { |s, e| { "start" => s, "end" => e } },
56
+ "available_seconds" => available_seconds.round,
57
+ "derived_from_fact_ids" => source_fact_ids.uniq,
58
+ "derived_from_fact_refs" => refs,
59
+ "derivation" => {
60
+ "name" => DERIVATION_NAME,
61
+ "version" => DERIVATION_VERSION
62
+ },
63
+ "computed_at" => Time.now.iso8601(3),
64
+ "horizon_start" => horizon_start.iso8601,
65
+ "horizon_days" => horizon_days
66
+ }
67
+ end
68
+
69
+ private
70
+
71
+ # Expands the weekly_schedule template into concrete [start_ts, end_ts] pairs
72
+ # over [horizon_start, horizon_start + horizon_days).
73
+ # Fact values have symbol keys (native extension normalises all keys to symbols).
74
+ def expand_template(template_value, horizon_start, horizon_days)
75
+ schedule = template_value[:weekly_schedule] || {}
76
+ intervals = []
77
+
78
+ horizon_days.times do |offset|
79
+ day = horizon_start + offset
80
+ wday_sym = day.wday.to_s.to_sym
81
+ wday_slots = schedule[wday_sym] || []
82
+ wday_slots.each do |slot|
83
+ start_ts = day_time_to_unix(day, slot[0].to_s)
84
+ end_ts = day_time_to_unix(day, slot[1].to_s)
85
+ intervals << [start_ts, end_ts] if end_ts > start_ts
86
+ end
87
+ end
88
+
89
+ intervals.sort_by(&:first)
90
+ end
91
+
92
+ # Gathers blocking intervals from override and reservation facts.
93
+ # Ignores facts whose type is "cancel" (cancellations restore availability).
94
+ def collect_blocked(override_facts, reservation_facts)
95
+ blocked = []
96
+
97
+ override_facts.each do |f|
98
+ v = f.value
99
+ next if v[:type].to_s == "cancel"
100
+ s = v[:start].to_f
101
+ e = v[:end].to_f
102
+ blocked << [s, e] if e > s
103
+ end
104
+
105
+ reservation_facts.each do |f|
106
+ v = f.value
107
+ next if v[:type].to_s == "cancel"
108
+ s = v[:start].to_f
109
+ e = v[:end].to_f
110
+ blocked << [s, e] if e > s
111
+ end
112
+
113
+ blocked.sort_by(&:first)
114
+ end
115
+
116
+ # Subtracts one [block_start, block_end] interval from a list of [start, end] pairs.
117
+ # Handles: no-overlap, full-containment, left-trim, right-trim, split.
118
+ def subtract_interval(intervals, block)
119
+ bs, be = block
120
+ result = []
121
+ intervals.each do |s, e|
122
+ if be <= s || bs >= e
123
+ # no overlap — keep as-is
124
+ result << [s, e]
125
+ elsif bs <= s && be >= e
126
+ # block fully covers slot — drop
127
+ elsif bs > s && be < e
128
+ # block splits slot — keep left and right fragments
129
+ result << [s, bs]
130
+ result << [be, e]
131
+ elsif bs <= s
132
+ # block trims left side
133
+ result << [be, e] if be < e
134
+ else
135
+ # block trims right side
136
+ result << [s, bs] if bs > s
137
+ end
138
+ end
139
+ result
140
+ end
141
+
142
+ # Converts a Date + "HH:MM" string to a Unix timestamp (Float).
143
+ def day_time_to_unix(date, time_str)
144
+ h, m = time_str.split(":").map(&:to_i)
145
+ Time.utc(date.year, date.month, date.day, h, m, 0).to_f
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "availability_deriver"
4
+
5
+ module Igniter
6
+ module Store
7
+ module IntelligentLedger
8
+ # Store-backed orchestrator for the AvailabilitySnapshot derivation.
9
+ #
10
+ # Reads base facts from an IgniterStore, invokes AvailabilityDeriver,
11
+ # then persists the snapshot fact and a derivation receipt.
12
+ #
13
+ # Store layout:
14
+ # :availability_templates — key: technician_id
15
+ # :availability_overrides — key: "technician_id/override_id"
16
+ # :order_events — key: order_id (value has "type": "reserved"/"cancelled")
17
+ # :availability_snapshots — key: "technician_id/horizon_bucket"
18
+ # :derivation_receipts — key: snapshot_fact_id
19
+ class AvailabilityLedger
20
+ PRODUCER = { "system" => "availability_ledger", "version" => AvailabilityDeriver::DERIVATION_VERSION }.freeze
21
+
22
+ def initialize(store:)
23
+ @store = store
24
+ @deriver = AvailabilityDeriver.new
25
+ end
26
+
27
+ # Writes a template fact for a technician.
28
+ # weekly_schedule: { "1" => [["09:00","17:00"]], ... }
29
+ def write_template(technician_id:, weekly_schedule:)
30
+ @store.write(
31
+ store: :availability_templates,
32
+ key: technician_id.to_s,
33
+ value: { "weekly_schedule" => weekly_schedule }
34
+ )
35
+ end
36
+
37
+ # Writes an override fact (block a specific interval).
38
+ # type: "block" (default) or "cancel"
39
+ def write_override(technician_id:, override_id:, start_time:, end_time:, type: "block")
40
+ @store.write(
41
+ store: :availability_overrides,
42
+ key: "#{technician_id}/#{override_id}",
43
+ value: {
44
+ "technician_id" => technician_id.to_s,
45
+ "start" => start_time.to_f,
46
+ "end" => end_time.to_f,
47
+ "type" => type
48
+ }
49
+ )
50
+ end
51
+
52
+ # Writes an order event fact.
53
+ # type: "reserved" or "cancelled"
54
+ def write_order_event(order_id:, technician_id:, start_time:, end_time:, type: "reserved")
55
+ @store.write(
56
+ store: :order_events,
57
+ key: order_id.to_s,
58
+ value: {
59
+ "order_id" => order_id.to_s,
60
+ "technician_id" => technician_id.to_s,
61
+ "start" => start_time.to_f,
62
+ "end" => end_time.to_f,
63
+ "type" => type
64
+ }
65
+ )
66
+ end
67
+
68
+ # Derives and persists an availability snapshot for a technician over a horizon window.
69
+ #
70
+ # horizon_start — Date (inclusive)
71
+ # horizon_days — Integer (window length)
72
+ #
73
+ # Returns { snapshot_fact:, receipt_fact: }.
74
+ def compute_snapshot(technician_id:, horizon_start:, horizon_days:)
75
+ tid = technician_id.to_s
76
+
77
+ # --- gather base facts ---
78
+ template_fact = @store.history(store: :availability_templates, key: tid).last
79
+
80
+ override_facts = @store.history(store: :availability_overrides).select do |f|
81
+ f.key.start_with?("#{tid}/")
82
+ end
83
+
84
+ order_facts = @store.history(store: :order_events).select do |f|
85
+ f.value[:technician_id].to_s == tid
86
+ end
87
+
88
+ # Active reservations = latest event per order_id where type != "cancelled"
89
+ active_reservations = active_order_facts(order_facts)
90
+
91
+ # Collect source fact IDs and structured refs (id + store + role).
92
+ source_ids = []
93
+ source_refs = []
94
+
95
+ if template_fact
96
+ source_ids << template_fact.id
97
+ source_refs << { "id" => template_fact.id, "store" => "availability_templates",
98
+ "role" => "template", "key" => template_fact.key }
99
+ end
100
+
101
+ override_facts.each do |f|
102
+ source_ids << f.id
103
+ source_refs << { "id" => f.id, "store" => "availability_overrides",
104
+ "role" => "override", "key" => f.key }
105
+ end
106
+
107
+ order_facts.each do |f|
108
+ source_ids << f.id
109
+ source_refs << { "id" => f.id, "store" => "order_events",
110
+ "role" => "order_event", "key" => f.key }
111
+ end
112
+
113
+ base_facts = {
114
+ template: template_fact,
115
+ overrides: override_facts,
116
+ active_reservations: active_reservations
117
+ }
118
+
119
+ # --- derive ---
120
+ snapshot_value = @deriver.derive(
121
+ base_facts: base_facts,
122
+ horizon_start: horizon_start,
123
+ horizon_days: horizon_days,
124
+ source_fact_ids: source_ids,
125
+ source_fact_refs: source_refs
126
+ )
127
+
128
+ # --- persist snapshot ---
129
+ bucket = "#{horizon_start.iso8601}/#{horizon_days}d"
130
+ snapshot_fact = @store.write(
131
+ store: :availability_snapshots,
132
+ key: "#{tid}/#{bucket}",
133
+ value: snapshot_value,
134
+ producer: PRODUCER,
135
+ derivation: snapshot_derivation_metadata(snapshot_value)
136
+ )
137
+
138
+ # --- persist receipt ---
139
+ receipt_value = {
140
+ "snapshot_fact_id" => snapshot_fact.id,
141
+ "technician_id" => tid,
142
+ "horizon_start" => horizon_start.iso8601,
143
+ "horizon_days" => horizon_days,
144
+ "derivation_name" => AvailabilityDeriver::DERIVATION_NAME,
145
+ "derivation_version" => AvailabilityDeriver::DERIVATION_VERSION,
146
+ "source_fact_ids" => source_ids.uniq,
147
+ "source_fact_refs" => source_refs.uniq { |r| r["id"] },
148
+ "derived_at" => Time.now.iso8601(3)
149
+ }
150
+
151
+ receipt_fact = @store.write(
152
+ store: :derivation_receipts,
153
+ key: snapshot_fact.id,
154
+ value: receipt_value,
155
+ producer: PRODUCER
156
+ )
157
+
158
+ { snapshot_fact: snapshot_fact, receipt_fact: receipt_fact }
159
+ end
160
+
161
+ # Reads the latest snapshot Fact for a technician/horizon combination.
162
+ # Returns nil if none exists.
163
+ def read_snapshot(technician_id:, horizon_start:, horizon_days:)
164
+ bucket = "#{horizon_start.iso8601}/#{horizon_days}d"
165
+ @store.history(store: :availability_snapshots, key: "#{technician_id}/#{bucket}").last
166
+ end
167
+
168
+ # Reads the derivation receipt Fact for a given snapshot_fact_id.
169
+ def read_receipt(snapshot_fact_id)
170
+ @store.history(store: :derivation_receipts, key: snapshot_fact_id).last
171
+ end
172
+
173
+ private
174
+
175
+ # From a list of order event facts, return only the facts that represent
176
+ # the latest state per order_id and are NOT cancelled.
177
+ def active_order_facts(order_facts)
178
+ by_order = order_facts.group_by { |f| f.key }
179
+ by_order.filter_map do |_order_id, facts|
180
+ latest = facts.max_by(&:transaction_time)
181
+ latest if latest&.value&.fetch(:type, nil).to_s != "cancelled"
182
+ end
183
+ end
184
+
185
+ def snapshot_derivation_metadata(snapshot_value)
186
+ raw = snapshot_value.fetch("derivation")
187
+ {
188
+ name: raw.fetch("name"),
189
+ version: raw.fetch("version"),
190
+ source_fact_ids: snapshot_value.fetch("derived_from_fact_ids"),
191
+ source_fact_refs: snapshot_value.fetch("derived_from_fact_refs", [])
192
+ }
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "time"
5
+
6
+ module Igniter
7
+ module Store
8
+ module IntelligentLedger
9
+ # A closed semantic boundary over facts for a technician availability day.
10
+ #
11
+ # Lifecycle: open -> closed -> settled -> compacted
12
+ #
13
+ # Once closed, result_hash and output_fact_id are immutable. Settlement
14
+ # materialises useful long-lived memory (summary, metrics, receipt) before
15
+ # compaction. Compaction requires settlement first, then marks internal
16
+ # detail as purged while preserving output and result_hash.
17
+ class LedgerBoundary
18
+ POLICY_NAME = "technician_day"
19
+ RULE_VERSION = "1.0"
20
+
21
+ attr_reader :boundary_key, :subject, :status, :result_hash,
22
+ :source_fact_ids, :source_fact_refs,
23
+ :output_fact_id, :output_value,
24
+ :receipt_fact_id, :detail_status, :closed_at,
25
+ :compacted_at, :compaction_receipt_id,
26
+ :settlement_status, :settlement_receipt_id
27
+
28
+ def initialize(subject:, rule_version: RULE_VERSION)
29
+ @subject = subject.freeze
30
+ @rule_version = rule_version
31
+ @boundary_key = build_boundary_key
32
+ @status = :open
33
+ @detail_status = :full
34
+ @source_fact_ids = [].freeze
35
+ @source_fact_refs = [].freeze
36
+ @output_fact_id = nil
37
+ @output_value = nil
38
+ @receipt_fact_id = nil
39
+ @result_hash = nil
40
+ @closed_at = nil
41
+ @compacted_at = nil
42
+ @compaction_receipt_id = nil
43
+ @settlement_status = :unsettled
44
+ @settlement_receipt_id = nil
45
+ end
46
+
47
+ def id = @boundary_key
48
+
49
+ def open? = @status == :open
50
+ def closed? = @status == :closed || @status == :compacted
51
+ def compacted? = @status == :compacted
52
+ def settled? = @settlement_status == :settled
53
+
54
+ # Transitions open -> closed.
55
+ # output_fact, receipt_fact, result_hash are immutable after this point.
56
+ # source_fact_refs — structured refs (id/store/role); optional, defaults to [].
57
+ # Refs are normalized to string keys for consistency across store round-trips.
58
+ def close!(output_fact:, receipt_fact:, source_fact_ids:, source_fact_refs: [])
59
+ raise "boundary already closed" unless @status == :open
60
+
61
+ @output_fact_id = output_fact.id
62
+ @output_value = output_fact.value
63
+ @receipt_fact_id = receipt_fact.id
64
+ @source_fact_ids = source_fact_ids.uniq.freeze
65
+ @source_fact_refs = source_fact_refs
66
+ .map { |r| r.transform_keys(&:to_s) }
67
+ .uniq { |r| r["id"] }
68
+ .freeze
69
+ @result_hash = compute_result_hash(@output_value, @source_fact_ids)
70
+ @status = :closed
71
+ @closed_at = Time.now
72
+ end
73
+
74
+ # Transitions settlement_status: :unsettled -> :settled.
75
+ # Boundary must be closed first. settlement_receipt_id is immutable after this.
76
+ def settle!(settlement_receipt_id:)
77
+ raise "boundary must be closed before settlement" unless @status == :closed
78
+ raise "boundary already settled" if @settlement_status == :settled
79
+
80
+ @settlement_receipt_id = settlement_receipt_id
81
+ @settlement_status = :settled
82
+ end
83
+
84
+ # Transitions closed -> compacted.
85
+ # Requires settlement first. Internal detail is marked purged;
86
+ # output, result_hash, and settlement outputs remain intact.
87
+ def compact!(compaction_receipt_id:)
88
+ raise "boundary must be closed before compaction" unless @status == :closed
89
+ raise "boundary must be settled before compaction" unless @settlement_status == :settled
90
+
91
+ @compaction_receipt_id = compaction_receipt_id
92
+ @detail_status = :purged
93
+ @status = :compacted
94
+ @compacted_at = Time.now
95
+ end
96
+
97
+ # Returns a class-level deterministic key without instantiating a full boundary.
98
+ def self.key_for(company_id:, technician_id:, date:, rule_version: RULE_VERSION)
99
+ "#{POLICY_NAME}/company=#{company_id}/technician=#{technician_id}/date=#{date}/version=#{rule_version}"
100
+ end
101
+
102
+ # Rebuilds a LedgerBoundary from persisted store data after a process restart.
103
+ #
104
+ # boundary_record — value hash from :ledger_boundaries (symbol keys from store)
105
+ # output_value — value hash recovered from :availability_snapshots, or nil
106
+ # settlement_receipt_id — fact ID from :ledger_settlement_receipts, nil if unsettled
107
+ # compaction_receipt_id — fact ID from :ledger_cleanup_receipts, nil if not compacted
108
+ # compacted_at — parsed Time from cleanup receipt, nil if not compacted
109
+ #
110
+ # Status is authoritative from receipt evidence: if compaction_receipt_id is present
111
+ # the status is :compacted regardless of what the boundary record says, because the
112
+ # boundary record in :ledger_boundaries is written at close time and never updated.
113
+ def self.from_persisted(boundary_record:, output_value: nil,
114
+ settlement_receipt_id: nil,
115
+ compaction_receipt_id: nil,
116
+ compacted_at: nil)
117
+ obj = allocate
118
+ obj.__send__(:restore_from_record!,
119
+ boundary_record: boundary_record,
120
+ output_value: output_value,
121
+ settlement_receipt_id: settlement_receipt_id,
122
+ compaction_receipt_id: compaction_receipt_id,
123
+ compacted_at: compacted_at)
124
+ obj
125
+ end
126
+
127
+ private
128
+
129
+ def restore_from_record!(boundary_record:, output_value:,
130
+ settlement_receipt_id:, compaction_receipt_id:, compacted_at:)
131
+ @boundary_key = boundary_record[:boundary_key]
132
+ @rule_version = boundary_record[:rule_version] || RULE_VERSION
133
+ @subject = boundary_record[:subject].freeze
134
+
135
+ # Status: cleanup receipt evidence overrides the stored boundary status
136
+ # (boundary record is written at close time and never updated on compact)
137
+ @status = compaction_receipt_id ? :compacted : :closed
138
+ @detail_status = compaction_receipt_id ? :purged : (boundary_record[:detail_status]&.to_sym || :full)
139
+
140
+ @output_fact_id = boundary_record[:output_fact_id]
141
+ @output_value = output_value
142
+ @receipt_fact_id = boundary_record[:receipt_fact_id]
143
+ @result_hash = boundary_record[:result_hash]
144
+ @source_fact_ids = Array(boundary_record[:source_fact_ids]).freeze
145
+ @source_fact_refs = Array(boundary_record[:source_fact_refs] || [])
146
+ .map { |r| r.transform_keys(&:to_s) }
147
+ .freeze
148
+
149
+ @closed_at = parse_time_safe(boundary_record[:closed_at])
150
+ @compacted_at = compacted_at
151
+ @compaction_receipt_id = compaction_receipt_id
152
+
153
+ @settlement_status = settlement_receipt_id ? :settled : :unsettled
154
+ @settlement_receipt_id = settlement_receipt_id
155
+ end
156
+
157
+ def parse_time_safe(val)
158
+ val ? Time.parse(val.to_s) : nil
159
+ rescue ArgumentError, TypeError
160
+ nil
161
+ end
162
+
163
+ def build_boundary_key
164
+ s = @subject
165
+ self.class.key_for(
166
+ company_id: s[:company_id],
167
+ technician_id: s[:technician_id],
168
+ date: s[:date],
169
+ rule_version: @rule_version
170
+ )
171
+ end
172
+
173
+ def compute_result_hash(output_value, source_fact_ids)
174
+ content = output_value.to_s + source_fact_ids.sort.join(",") + @rule_version
175
+ Digest::SHA256.hexdigest(content)
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+ require "igniter-ledger"
5
+
6
+ store = Igniter::Store.memory
7
+
8
+ invalidations = []
9
+ store.register_path(
10
+ Igniter::Store.access_path(
11
+ store: :reminders,
12
+ lookup: :primary_key,
13
+ scope: nil,
14
+ filters: nil,
15
+ cache_ttl: 60,
16
+ consumers: [->(store_name, key) { invalidations << [store_name, key] }]
17
+ )
18
+ )
19
+
20
+ t_before = Process.clock_gettime(Process::CLOCK_REALTIME)
21
+ first = store.write(store: :reminders, key: "r1", value: { title: "Buy milk", status: :open })
22
+ sleep 0.01
23
+ t_mid = Process.clock_gettime(Process::CLOCK_REALTIME)
24
+ second = store.write(store: :reminders, key: "r1", value: { title: "Buy milk", status: :closed })
25
+
26
+ store.append(history: :reminder_logs, event: { reminder_id: "r1", action: :created, at: t_before })
27
+ store.append(history: :reminder_logs, event: { reminder_id: "r1", action: :closed, at: Time.now.to_f })
28
+
29
+ wal_path = File.join(Dir.tmpdir, "igniter_ledger_package_poc_#{Process.pid}.jsonl")
30
+ begin
31
+ file_store = Igniter::Store.open(wal_path)
32
+ file_store.write(store: :tasks, key: "t1", value: { title: "Package POC", done: false })
33
+ file_store.write(store: :tasks, key: "t1", value: { title: "Package POC", done: true })
34
+ replayed = Igniter::Store.open(wal_path)
35
+
36
+ puts "access_paths=#{store.schema_graph.paths_for(:reminders).length}"
37
+ puts "chain_intact=#{second.causation == first.id}"
38
+ puts "current_status=#{store.read(store: :reminders, key: "r1").fetch(:status)}"
39
+ puts "status_at_mid=#{store.time_travel(store: :reminders, key: "r1", at: t_mid).fetch(:status)}"
40
+ puts "invalidations=#{invalidations.inspect}"
41
+ puts "history_count=#{store.history(store: :reminder_logs).length}"
42
+ puts "wal_replay_done=#{replayed.read(store: :tasks, key: "t1").fetch(:done)}"
43
+ ensure
44
+ File.delete(wal_path) if File.exist?(wal_path)
45
+ end
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ #
5
+ # igniter-ledger-server — standalone LedgerServer process
6
+ #
7
+ # Usage:
8
+ # igniter-ledger-server [options]
9
+ #
10
+ # Examples:
11
+ # igniter-ledger-server
12
+ # igniter-ledger-server --port 7400 --backend memory
13
+ # igniter-ledger-server --backend file --path /var/lib/igniter/store.wal --pid-file /run/igniter-ledger.pid
14
+ # igniter-ledger-server --host 0.0.0.0 --port 7400 --log-level debug
15
+
16
+ require "optparse"
17
+ require_relative "../lib/igniter-ledger"
18
+
19
+ opts = {}
20
+ cf_opts = {}
21
+ cf_thresholds = {}
22
+
23
+ parser = OptionParser.new do |o|
24
+ o.banner = "Usage: igniter-ledger-server [options]"
25
+ o.separator ""
26
+ o.separator "Network:"
27
+ o.on("--host HOST", "Bind host (default: 127.0.0.1)") { |v| opts[:host] = v }
28
+ o.on("--port PORT", Integer, "Bind port (default: 7400)") { |v| opts[:port] = v }
29
+ o.on("--transport TYPE", "tcp or unix (default: tcp)") { |v| opts[:transport] = v.to_sym }
30
+
31
+ o.separator ""
32
+ o.separator "Storage:"
33
+ o.on("--backend BACKEND", "memory or file (default: memory)") { |v| opts[:backend] = v.to_sym }
34
+ o.on("--path PATH", "WAL file path (required for --backend file)") { |v| opts[:path] = v }
35
+
36
+ o.separator ""
37
+ o.separator "Operations:"
38
+ o.on("--log-level LEVEL", "debug|info|warn|error (default: info)") { |v| opts[:log_level] = v.to_sym }
39
+ o.on("--pid-file PATH", "Write PID to file on startup") { |v| opts[:pid_file] = v }
40
+ o.on("--drain-timeout SECS", Integer, "Graceful shutdown drain timeout (default: 5)") { |v| opts[:drain_timeout] = v }
41
+
42
+ o.separator ""
43
+ o.separator "Protocol API (envelope dispatch):"
44
+ o.on("--http-port PORT", Integer, "HTTP adapter port (default: disabled)") { |v| opts[:http_port] = v }
45
+ o.on("--tcp-port PORT", Integer, "TCP adapter port (default: disabled)") { |v| opts[:tcp_port] = v }
46
+
47
+ o.separator ""
48
+ o.separator "Changefeed:"
49
+ o.on("--changefeed-max-size N", Integer, "Event ring size (default: 1000)") { |v| cf_opts[:max_size] = v }
50
+ o.on("--changefeed-subscriber-queue-size N", Integer, "Per-subscriber queue size (default: 100)") { |v| cf_opts[:subscriber_queue_size] = v }
51
+ o.on("--changefeed-overflow POLICY", "drop_oldest|drop_newest (default: drop_oldest)") { |v| cf_opts[:overflow] = v.to_sym }
52
+ o.on("--changefeed-close-policy POLICY", "drain|discard (default: drain)") { |v| cf_opts[:close_policy] = v.to_sym }
53
+ o.on("--changefeed-diagnostic-ring-size N", Integer, "Diagnostic ring size (default: 100)") { |v| cf_opts[:diagnostic_ring_size] = v }
54
+ o.on("--changefeed-alert-total-queued N", Integer, "Alert: total_queued >= N") { |v| cf_thresholds[:total_queued] = v }
55
+ o.on("--changefeed-alert-overflow-dropped-total N", Integer, "Alert: overflow_dropped_total >= N") { |v| cf_thresholds[:overflow_dropped_total] = v }
56
+ o.on("--changefeed-alert-failed-total N", Integer, "Alert: failed_total >= N") { |v| cf_thresholds[:failed_total] = v }
57
+ o.on("--changefeed-alert-queue-pressure-ratio FLOAT", Float, "Alert: queue_pressure_ratio >= FLOAT") { |v| cf_thresholds[:queue_pressure_ratio] = v }
58
+
59
+ o.separator ""
60
+ o.on("-h", "--help", "Show this help and exit") do
61
+ puts o
62
+ exit 0
63
+ end
64
+ o.on("-v", "--version", "Show version and exit") do
65
+ version = begin
66
+ require "igniter/version"
67
+ Igniter::VERSION
68
+ rescue LoadError
69
+ begin
70
+ require_relative "../../../lib/igniter/version"
71
+ Igniter::VERSION
72
+ rescue LoadError
73
+ Gem.loaded_specs["igniter-ledger"]&.version&.to_s || "unknown"
74
+ end
75
+ end
76
+ puts "igniter-ledger #{version}"
77
+ exit 0
78
+ end
79
+ end
80
+
81
+ begin
82
+ parser.parse!
83
+ rescue OptionParser::InvalidOption, OptionParser::InvalidArgument => e
84
+ warn "Error: #{e.message}"
85
+ warn parser
86
+ exit 1
87
+ end
88
+
89
+ # Validate --backend file requires --path
90
+ if opts[:backend] == :file && opts[:path].nil?
91
+ warn "Error: --backend file requires --path PATH"
92
+ exit 1
93
+ end
94
+
95
+ http_port = opts.delete(:http_port)
96
+ tcp_port = opts.delete(:tcp_port)
97
+
98
+ cf_opts[:alert_thresholds] = cf_thresholds unless cf_thresholds.empty?
99
+ opts[:changefeed] = cf_opts unless cf_opts.empty?
100
+
101
+ config = Igniter::Ledger::ServerConfig.new(**opts)
102
+
103
+ $stdout.sync = true
104
+ $stderr.sync = true
105
+
106
+ server = Igniter::Ledger::LedgerServer.new(config: config)
107
+ if http_port || tcp_port
108
+ server.start_with_adapters(http_port: http_port, tcp_port: tcp_port)
109
+ else
110
+ server.start_foreground
111
+ end