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,447 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "time"
5
+
6
+ module Igniter
7
+ module Store
8
+ module Protocol
9
+ class Interpreter
10
+ HANDLERS = {
11
+ store: Handlers::StoreHandler,
12
+ history: Handlers::HistoryHandler,
13
+ access_path: Handlers::AccessPathHandler,
14
+ relation: Handlers::RelationHandler,
15
+ projection: Handlers::ProjectionHandler,
16
+ derivation: Handlers::DerivationHandler,
17
+ command: Handlers::CommandHandler,
18
+ effect: Handlers::EffectHandler,
19
+ subscription: Handlers::SubscriptionHandler,
20
+ }.freeze
21
+
22
+ # Default thresholds for storage-level alerts in observability_snapshot.
23
+ # Override per-interpreter via alert_thresholds: at construction time.
24
+ DEFAULT_STORAGE_ALERT_THRESHOLDS = {
25
+ quarantine_receipt_count: 10,
26
+ storage_byte_size: 1_073_741_824 # 1 GiB
27
+ }.freeze
28
+
29
+ def initialize(store, alert_thresholds: {})
30
+ @store = store
31
+ @registry = {} # content fingerprint → Receipt (dedup)
32
+ @alert_thresholds = DEFAULT_STORAGE_ALERT_THRESHOLDS.merge(alert_thresholds)
33
+ end
34
+
35
+ # Generic descriptor registration — dispatches by kind:.
36
+ # Returns a Receipt with status :accepted, :rejected, or :deduplicated.
37
+ def register(descriptor)
38
+ descriptor = descriptor.transform_keys(&:to_sym)
39
+ kind = descriptor[:kind]&.to_sym
40
+
41
+ return Receipt.rejection("Missing required field: kind") unless kind
42
+
43
+ handler_class = HANDLERS[kind]
44
+ return Receipt.rejection("Unknown descriptor kind: #{kind.inspect}", kind: kind) unless handler_class
45
+
46
+ fp = fingerprint(descriptor)
47
+ return Receipt.deduplicated(kind: kind, name: descriptor[:name]&.to_sym) if @registry.key?(fp)
48
+
49
+ receipt = handler_class.new(@store).call(descriptor)
50
+ @registry[fp] = receipt if receipt.accepted?
51
+ receipt
52
+ end
53
+
54
+ # Named registration helpers — vocabulary aliases for register.
55
+ def register_store(descriptor) = register(descriptor)
56
+ def register_history(descriptor) = register(descriptor)
57
+ def register_access_path(descriptor) = register(descriptor)
58
+ def register_relation(descriptor) = register(descriptor)
59
+ def register_projection(descriptor) = register(descriptor)
60
+ def register_derivation(descriptor) = register(descriptor)
61
+ def register_command(descriptor) = register(descriptor)
62
+ def register_effect(descriptor) = register(descriptor)
63
+ def register_subscription(descriptor) = register(descriptor)
64
+
65
+ # Write a fact. Returns a write Receipt carrying fact_id and value_hash.
66
+ def write(store:, key:, value:, causation: nil, valid_time: nil, term: nil,
67
+ producer: nil, derivation: nil)
68
+ fact = @store.write(
69
+ store: store.to_sym,
70
+ key: key,
71
+ value: value,
72
+ causation: causation,
73
+ valid_time: valid_time,
74
+ term: term,
75
+ producer: producer,
76
+ derivation: derivation
77
+ )
78
+ Receipt.write_accepted(store: store.to_sym, key: key, fact: fact)
79
+ end
80
+
81
+ # Append an event to a history. Returns an append Receipt carrying the
82
+ # generated fact key, fact_id, and value_hash.
83
+ def append(history:, event:, key: nil, partition_key: nil, schema_version: 1,
84
+ valid_time: nil, term: nil, producer: nil, derivation: nil)
85
+ fact = @store.append(
86
+ history: history.to_sym,
87
+ event: event,
88
+ schema_version: schema_version,
89
+ valid_time: valid_time,
90
+ term: term,
91
+ partition_key: partition_key&.to_sym,
92
+ producer: producer,
93
+ derivation: derivation
94
+ )
95
+ Receipt.append_accepted(history: history.to_sym, fact: fact, requested_key: key)
96
+ end
97
+
98
+ # Accept a full fact packet hash (kind: :fact) and write it to the store.
99
+ # Designed for wire replay, server ingestion, and protocol-native clients.
100
+ # Note: at: is recorded in the packet but cannot override the engine timestamp —
101
+ # the engine assigns monotonic timestamps on write.
102
+ def write_fact(packet)
103
+ packet = packet.transform_keys(&:to_sym)
104
+ kind = packet[:kind]&.to_sym
105
+ return Receipt.rejection("write_fact: expected kind: :fact, got #{kind.inspect}", kind: :fact) unless kind == :fact
106
+
107
+ store = packet[:store]
108
+ key = packet[:key]
109
+ value = packet[:value]
110
+ return Receipt.rejection("write_fact: missing store:", kind: :fact) unless store
111
+ return Receipt.rejection("write_fact: missing key:", kind: :fact) unless key
112
+ return Receipt.rejection("write_fact: missing value:", kind: :fact) unless value
113
+
114
+ fact = @store.write(
115
+ store: store.to_sym,
116
+ key: key.to_s,
117
+ value: value,
118
+ causation: packet[:causation],
119
+ valid_time: packet[:valid_time],
120
+ term: packet[:term],
121
+ producer: packet[:producer],
122
+ derivation: packet[:derivation]
123
+ )
124
+ Receipt.write_accepted(store: store.to_sym, key: key, fact: fact)
125
+ end
126
+
127
+ # Read the current value for a key (or nil).
128
+ def read(store:, key:, as_of: nil)
129
+ @store.read(store: store.to_sym, key: key, as_of: as_of)
130
+ end
131
+
132
+ # Query facts matching all where: conditions.
133
+ # Performs a latest-per-key scan; access paths provide introspection metadata
134
+ # but index-accelerated query planning is a future engine concern.
135
+ def query(store:, where: {}, order: nil, limit: nil, as_of: nil)
136
+ store_sym = store.to_sym
137
+ facts = @store.history(store: store_sym, as_of: as_of)
138
+
139
+ # Reduce to latest fact per key.
140
+ latest = {}
141
+ facts.each do |f|
142
+ existing = latest[f.key]
143
+ latest[f.key] = f if existing.nil? || f.transaction_time > existing.transaction_time
144
+ end
145
+
146
+ rows = latest.values
147
+
148
+ where.each do |field, val|
149
+ sym = field.to_sym
150
+ rows = rows.select { |fact| fact.value[sym] == val }
151
+ end
152
+
153
+ rows = rows.sort_by { |fact| fact.value[order.to_sym] } if order
154
+ rows = rows.first(limit) if limit
155
+ rows.map { |fact| { key: fact.key, value: fact.value } }
156
+ end
157
+
158
+ # Resolve a named relation (delegates to IgniterStore#resolve).
159
+ def resolve(relation_name, from:, as_of: nil)
160
+ @store.resolve(relation_name, from: from, as_of: as_of)
161
+ end
162
+
163
+ # Read-only provenance: compact causation chain for one store/key.
164
+ def causation_chain(store:, key:)
165
+ @store.causation_chain(store: store.to_sym, key: key)
166
+ end
167
+
168
+ # Read-only provenance: causal proof and downstream derivation metadata.
169
+ def lineage(store:, key:)
170
+ @store.lineage(store: store.to_sym, key: key)
171
+ end
172
+
173
+ # Read-only provenance: compact fact reference, without fact value.
174
+ def fact_ref(fact_id)
175
+ @store.fact_ref(fact_id)
176
+ end
177
+
178
+ # Resolve a named relation with source keys preserved for client-side
179
+ # typed record reconstruction. The value-only #resolve API remains
180
+ # stable for existing protocol callers.
181
+ def resolve_items(relation_name, from:, as_of: nil)
182
+ rule = @store.schema_graph.relation_for(name: relation_name)
183
+ raise ArgumentError, "No relation registered: #{relation_name.inspect}" unless rule
184
+
185
+ index_entry = @store.read(store: :"__rel_#{relation_name}", key: from.to_s, as_of: as_of)
186
+ return [] unless index_entry
187
+
188
+ index_entry[:keys].filter_map do |key|
189
+ value = @store.read(store: rule.source, key: key, as_of: as_of)
190
+ { key: key, value: value } if value
191
+ end
192
+ end
193
+
194
+ # OP2: unified protocol metadata snapshot.
195
+ # Combines raw descriptor registry (store/history/subscription),
196
+ # engine routing metadata (access paths), and all derived graph artifacts
197
+ # (relations, projections, derivations, scatters, retention) into one
198
+ # canonical introspection response.
199
+ # Used by Companion, StoreServer, visual tools, and compliance test kits.
200
+ def metadata_snapshot
201
+ g = @store.schema_graph
202
+ ds = g.descriptor_snapshot
203
+ snap = {
204
+ schema_version: 1,
205
+ stores: ds[:stores],
206
+ histories: ds[:histories],
207
+ access_paths: g.metadata_snapshot,
208
+ relations: g.relation_snapshot,
209
+ projections: g.projection_snapshot,
210
+ commands: g.command_snapshot,
211
+ effects: g.effect_snapshot,
212
+ derivations: g.derivation_snapshot,
213
+ scatters: g.scatter_snapshot,
214
+ subscriptions: ds[:subscriptions],
215
+ retention: g.retention_snapshot
216
+ }
217
+ stats = @store.storage_stats
218
+ snap[:storage] = stats if stats
219
+ snap
220
+ end
221
+
222
+ # Physical storage stats from the backend (SegmentedFileBackend).
223
+ # Returns nil when the backend does not support it.
224
+ def storage_stats(store: nil)
225
+ @store.storage_stats(store: store)
226
+ end
227
+
228
+ # Detailed per-segment manifest from the backend.
229
+ # Returns nil when the backend does not support it.
230
+ def segment_manifest(store: nil)
231
+ @store.segment_manifest(store: store)
232
+ end
233
+
234
+ # Raw descriptor-only snapshot (store/history/subscription).
235
+ # Use metadata_snapshot for the full picture; this is a lower-level accessor.
236
+ def descriptor_snapshot
237
+ @store.schema_graph.descriptor_snapshot
238
+ end
239
+
240
+ # OP4: generates a SyncProfile for a cold hub or incremental update.
241
+ #
242
+ # Full sync (cursor: nil): all facts + full descriptor snapshot
243
+ # Incremental (cursor: given): facts since cursor[:value] timestamp + snapshot
244
+ # stores: Array<Symbol> optional store filter (nil = all stores)
245
+ #
246
+ # The returned SyncProfile#next_cursor should be persisted by the hub and
247
+ # sent back as cursor: on the next call to receive only new facts.
248
+ def sync_hub_profile(as_of: nil, cursor: nil, stores: nil)
249
+ from = cursor&.dig(:value)
250
+
251
+ raw_facts = @store.fact_log_all(since: from, as_of: as_of)
252
+
253
+ if stores
254
+ allowed = Array(stores).map(&:to_sym).to_set
255
+ raw_facts = raw_facts.select { |f| allowed.include?(f.store) }
256
+ end
257
+
258
+ fact_packets = raw_facts.map { |f| serialize_fact(f) }
259
+
260
+ SyncProfile.new(
261
+ schema_version: 1,
262
+ kind: :sync_hub_profile,
263
+ generated_at: Process.clock_gettime(Process::CLOCK_REALTIME),
264
+ cursor: cursor,
265
+ descriptors: metadata_snapshot,
266
+ facts: fact_packets,
267
+ retention: @store.schema_graph.retention_snapshot,
268
+ compaction_receipts: compaction_receipt_summaries,
269
+ compaction_activity: compaction_activity,
270
+ subscription_checkpoints: {}
271
+ )
272
+ end
273
+
274
+ # Normalized compaction lifecycle activity.
275
+ #
276
+ # Returns a response envelope with schema_version, generated_at, filters,
277
+ # activity (normalized entries), and count.
278
+ #
279
+ # Filtering:
280
+ # store: delegate to IgniterStore#compaction_activity(store:)
281
+ # kind: filter entries by :kind
282
+ # since: keep entries where occurred_at >= since
283
+ # limit: cap result count after all other filters
284
+ def compaction_activity(store: nil, kind: nil, since: nil, limit: nil)
285
+ store_sym = store&.to_sym
286
+ entries = @store.compaction_activity(store: store_sym)
287
+
288
+ entries = entries.select { |e| e[:kind].to_s == kind.to_s } if kind
289
+ entries = entries.select { |e| e[:occurred_at] >= since.to_f } if since
290
+ entries = entries.first(limit.to_i) if limit
291
+
292
+ {
293
+ schema_version: 1,
294
+ generated_at: Time.now.iso8601(3),
295
+ filters: {
296
+ store: store&.to_s,
297
+ kind: kind&.to_s,
298
+ since: since&.to_f,
299
+ limit: limit&.to_i
300
+ },
301
+ activity: entries,
302
+ count: entries.size
303
+ }
304
+ end
305
+
306
+ # OP4: return all (or range-filtered) facts as serialized fact packets.
307
+ # Suitable for WAL replay to a cold hub or test double.
308
+ #
309
+ # Filter forms:
310
+ # { store: :name }
311
+ # { store: :name, key: "event-key" }
312
+ # { store: :name, partition_key: :tracker_id, partition_value: "sleep" }
313
+ def replay(from: nil, to: nil, filter: nil)
314
+ if filter
315
+ filter = filter.transform_keys(&:to_sym)
316
+ store_sym = filter[:store]&.to_sym
317
+
318
+ if store_sym && filter.key?(:key)
319
+ return @store.history(
320
+ store: store_sym,
321
+ key: filter[:key],
322
+ since: from,
323
+ as_of: to
324
+ ).map { |f| serialize_fact(f) }
325
+ end
326
+
327
+ if store_sym && filter[:partition_key] && filter.key?(:partition_value)
328
+ return @store.history_partition(
329
+ store: store_sym,
330
+ partition_key: filter[:partition_key].to_sym,
331
+ partition_value: filter[:partition_value],
332
+ since: from,
333
+ as_of: to
334
+ ).map { |f| serialize_fact(f) }
335
+ end
336
+ end
337
+
338
+ raw_facts = @store.fact_log_all(since: from, as_of: to)
339
+ raw_facts = raw_facts.select { |f| f.store == filter[:store]&.to_sym } if filter && filter[:store]
340
+ raw_facts.map { |f| serialize_fact(f) }
341
+ end
342
+
343
+ # OP3: returns the WireEnvelope router for this interpreter.
344
+ # Accepts process-boundary envelope hashes and returns response envelopes.
345
+ def wire
346
+ @wire ||= WireEnvelope.new(self)
347
+ end
348
+
349
+ # OP3: convenience shorthand — dispatch one wire envelope hash.
350
+ def dispatch(envelope)
351
+ wire.dispatch(envelope)
352
+ end
353
+
354
+ # ── Observability ─────────────────────────────────────────────────────
355
+
356
+ # Returns the canonical storage-level observability snapshot.
357
+ #
358
+ # Canonical shape (same top-level keys at every layer; server-only fields
359
+ # are nil at the protocol level):
360
+ # schema_version, generated_at, status, uptime_ms (nil),
361
+ # metrics (nil), alerts, storage, server (nil)
362
+ #
363
+ # storage-level alerts (quarantine_receipt_count, storage_byte_size) are
364
+ # checked against +alert_thresholds+ configured at construction time.
365
+ def observability_snapshot
366
+ storage = @store.storage_stats rescue nil
367
+ alerts = check_storage_alerts(storage)
368
+ {
369
+ schema_version: 1,
370
+ generated_at: Time.now.iso8601(3),
371
+ status: :ready,
372
+ uptime_ms: nil,
373
+ metrics: nil,
374
+ alerts: alerts,
375
+ storage: storage,
376
+ server: nil
377
+ }
378
+ end
379
+
380
+ private
381
+
382
+ def check_storage_alerts(storage)
383
+ return [] unless storage
384
+ alerts = []
385
+ stores = storage["stores"] || {}
386
+
387
+ qc = stores.values.sum { |s| s["quarantine_receipt_count"].to_i }
388
+ t = @alert_thresholds[:quarantine_receipt_count]
389
+ if t && qc > t
390
+ alerts << {
391
+ type: :quarantine_receipt_count,
392
+ threshold: t,
393
+ current_value: qc,
394
+ message: "quarantine_receipt_count exceeded threshold: #{qc} > #{t}"
395
+ }
396
+ end
397
+
398
+ bs = stores.values.sum { |s| s["byte_size"].to_i }
399
+ t = @alert_thresholds[:storage_byte_size]
400
+ if t && bs > t
401
+ alerts << {
402
+ type: :storage_byte_size,
403
+ threshold: t,
404
+ current_value: bs,
405
+ message: "storage_byte_size exceeded threshold: #{bs} > #{t}"
406
+ }
407
+ end
408
+
409
+ alerts
410
+ end
411
+
412
+ def fingerprint(descriptor)
413
+ Digest::SHA256.hexdigest(descriptor.to_a.sort_by { |k, _| k.to_s }.inspect)
414
+ end
415
+
416
+ def serialize_fact(fact)
417
+ {
418
+ schema_version: 1,
419
+ kind: :fact,
420
+ id: fact.id,
421
+ store: fact.store,
422
+ key: fact.key,
423
+ value: fact.value,
424
+ value_hash: fact.value_hash,
425
+ causation: fact.causation,
426
+ transaction_time: fact.transaction_time,
427
+ valid_time: fact.valid_time,
428
+ producer: fact.producer,
429
+ derivation: fact.derivation
430
+ }
431
+ end
432
+
433
+ def compaction_receipt_summaries
434
+ @store.compaction_receipts.map do |f|
435
+ {
436
+ id: f.id,
437
+ compacted_store: f.value[:compacted_store],
438
+ strategy: f.value[:strategy],
439
+ compacted_count: f.value[:compacted_count],
440
+ compacted_at: f.value[:compacted_at]
441
+ }
442
+ end
443
+ end
444
+ end
445
+ end
446
+ end
447
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Store
5
+ module Protocol
6
+ Receipt = Struct.new(
7
+ :schema_version,
8
+ :kind,
9
+ :status, # :accepted | :rejected | :deduplicated
10
+ :name, # descriptor name (descriptor receipts)
11
+ :store, # store name (write receipts)
12
+ :key, # record key (write receipts)
13
+ :fact_id, # fact UUID (write receipts)
14
+ :value_hash, # SHA256 of value (write receipts)
15
+ :warnings,
16
+ :errors,
17
+ :derived,
18
+ keyword_init: true
19
+ ) do
20
+ def accepted? = status == :accepted
21
+ def rejected? = status == :rejected
22
+ def deduplicated? = status == :deduplicated
23
+
24
+ def self.accepted(kind:, name:, warnings: [], derived: [])
25
+ new(schema_version: 1, kind: kind, status: :accepted,
26
+ name: name, warnings: warnings, errors: [], derived: derived)
27
+ end
28
+
29
+ def self.rejection(message, kind: nil, name: nil)
30
+ new(schema_version: 1, kind: kind, status: :rejected,
31
+ name: name, warnings: [], errors: [message], derived: [])
32
+ end
33
+
34
+ def self.deduplicated(kind:, name:)
35
+ new(schema_version: 1, kind: kind, status: :deduplicated,
36
+ name: name, warnings: ["descriptor already registered"], errors: [], derived: [])
37
+ end
38
+
39
+ def self.write_accepted(store:, key:, fact:)
40
+ new(
41
+ schema_version: 1,
42
+ kind: :receipt,
43
+ status: :accepted,
44
+ store: store,
45
+ key: key,
46
+ fact_id: fact.id,
47
+ value_hash: fact.value_hash,
48
+ warnings: [],
49
+ errors: [],
50
+ derived: []
51
+ )
52
+ end
53
+
54
+ def self.append_accepted(history:, fact:, requested_key: nil)
55
+ warnings = []
56
+ if requested_key && requested_key.to_s != fact.key.to_s
57
+ warnings << "append key is metadata only in protocol v0; generated fact key returned"
58
+ end
59
+
60
+ new(
61
+ schema_version: 1,
62
+ kind: :append_receipt,
63
+ status: :accepted,
64
+ store: history,
65
+ key: fact.key,
66
+ fact_id: fact.id,
67
+ value_hash: fact.value_hash,
68
+ warnings: warnings,
69
+ errors: [],
70
+ derived: []
71
+ )
72
+ end
73
+
74
+ def to_h
75
+ {
76
+ schema_version: schema_version,
77
+ kind: kind,
78
+ status: status,
79
+ name: name,
80
+ store: store,
81
+ key: key,
82
+ fact_id: fact_id,
83
+ value_hash: value_hash,
84
+ warnings: warnings,
85
+ errors: errors,
86
+ derived: derived
87
+ }.compact
88
+ end
89
+
90
+ def to_json(*args)
91
+ to_h.to_json(*args)
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Store
5
+ module Protocol
6
+ # OP4 — Sync Hub Profile value object.
7
+ #
8
+ # A SyncProfile is a point-in-time package that carries everything a durable
9
+ # hub needs to synchronize with the live store:
10
+ #
11
+ # descriptors — full metadata_snapshot (OP2)
12
+ # facts — serialized fact packets (full or incremental)
13
+ # retention — retention policy snapshot
14
+ # compaction_receipts — compaction history
15
+ # cursor — watermark for next incremental sync
16
+ # subscription_checkpoints — last-delivered position per subscription (OP4+)
17
+ #
18
+ # Cursor:
19
+ # nil — this is a fresh full snapshot (hub has never synced)
20
+ # { kind: :timestamp, value: Float } — resume from this timestamp
21
+ #
22
+ # A hub stores the cursor locally. On the next sync request it sends
23
+ # cursor: back and receives only facts written since that timestamp.
24
+ SyncProfile = Struct.new(
25
+ :schema_version,
26
+ :kind,
27
+ :generated_at, # Float (CLOCK_REALTIME)
28
+ :cursor, # { kind: :timestamp, value: Float } | nil
29
+ :descriptors, # Hash — from Protocol::Interpreter#metadata_snapshot
30
+ :facts, # Array<Hash> — serialized fact packets
31
+ :retention, # Hash — from SchemaGraph#retention_snapshot
32
+ :compaction_receipts, # Array<Hash> — compaction summaries
33
+ :compaction_activity, # Hash — normalized activity envelope from Interpreter#compaction_activity
34
+ :subscription_checkpoints, # Hash — subscription name → last position
35
+ keyword_init: true
36
+ ) do
37
+ def full? = cursor.nil?
38
+ def incremental? = !full?
39
+ def fact_count = facts.size
40
+
41
+ def to_json(*opts) = to_h.to_json(*opts)
42
+
43
+ # Build the cursor that a hub should send on its next sync request.
44
+ def next_cursor
45
+ return nil if facts.empty?
46
+ latest_ts = facts.max_by { |f| f[:transaction_time] || f[:timestamp] }
47
+ .then { |f| f[:transaction_time] || f[:timestamp] }
48
+ { kind: :timestamp, value: latest_ts }
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end