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,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "zlib"
5
+ require "set"
6
+ require "fileutils"
7
+ require_relative "wire_protocol"
8
+
9
+ module Igniter
10
+ module Store
11
+ unless defined?(NATIVE) && NATIVE
12
+ # Pure-Ruby FileBackend — skipped when the Rust native extension is loaded.
13
+ #
14
+ # WAL format (v2): length-prefixed frames with CRC32 integrity check.
15
+ #
16
+ # Each frame:
17
+ # [4 bytes BE uint32: body_len][body_len bytes: JSON][4 bytes BE uint32: CRC32(body)]
18
+ #
19
+ # Snapshot format (path + ".snap"):
20
+ # [header frame: JSON { type: "snapshot_header", fact_count: N, written_at: T }]
21
+ # [fact frame 1] ... [fact frame N]
22
+ #
23
+ # On open, if a snapshot file exists: snapshot facts are loaded first, WAL
24
+ # facts whose IDs are already in the snapshot are skipped. Combined result
25
+ # is sorted by timestamp. Startup cost is O(snapshot_size + delta_wal_size)
26
+ # instead of O(total_wal_size).
27
+ class FileBackend
28
+ include WireProtocol
29
+
30
+ SNAPSHOT_SUFFIX = ".snap"
31
+
32
+ def initialize(path)
33
+ @path = path.to_s
34
+ @file = File.open(@path, "ab")
35
+ @file.sync = true
36
+ end
37
+
38
+ def write_fact(fact)
39
+ body = JSON.generate(fact.to_h)
40
+ @file.write(encode_frame(body))
41
+ end
42
+
43
+ # Combines snapshot (if present) + WAL delta into a chronologically
44
+ # ordered list of facts. Facts in the snapshot are deduplicated against
45
+ # the WAL by ID so a checkpoint never causes double-replay.
46
+ def replay
47
+ snapshot_facts, seen_ids = load_snapshot
48
+ wal_facts = read_wal_frames.reject { |f| seen_ids.include?(f.id) }
49
+ (snapshot_facts + wal_facts).sort_by(&:transaction_time)
50
+ end
51
+
52
+ # Atomically writes all +facts+ to a snapshot file (<wal_path>.snap).
53
+ # Uses a tmp file + rename so a partial write never corrupts an existing
54
+ # snapshot. The WAL file is untouched; the snapshot is a parallel read
55
+ # artefact only.
56
+ def write_snapshot(facts)
57
+ tmp = "#{snapshot_path}.tmp"
58
+ File.open(tmp, "wb") do |f|
59
+ header = JSON.generate({
60
+ type: "snapshot_header",
61
+ fact_count: facts.size,
62
+ written_at: Process.clock_gettime(Process::CLOCK_REALTIME)
63
+ })
64
+ f.write(encode_frame(header))
65
+ facts.each { |fact| f.write(encode_frame(JSON.generate(fact.to_h))) }
66
+ end
67
+ FileUtils.mv(tmp, snapshot_path)
68
+ end
69
+
70
+ # Pruning-safe barrier: atomically replace the snapshot with +facts+ AND
71
+ # truncate the WAL so that dropped facts cannot resurface on reopen.
72
+ #
73
+ # Normal checkpoint (#write_snapshot) is non-destructive — it leaves the
74
+ # WAL intact, which means any fact not present in the snapshot will still
75
+ # be loaded from the WAL on next open. For physical purge that is wrong:
76
+ # the dropped fact ids would not be in the new snapshot, so the WAL would
77
+ # replay them back into existence.
78
+ #
79
+ # This method:
80
+ # 1. Writes the new snapshot atomically (tmp → rename).
81
+ # 2. Closes the current WAL file handle.
82
+ # 3. Truncates the WAL to 0 bytes (new open in write mode).
83
+ # 4. Reopens for future appends.
84
+ #
85
+ # After a successful call, close/reopen will load only the snapshot facts.
86
+ def replace_with_snapshot!(facts)
87
+ write_snapshot(facts)
88
+ @file.close
89
+ File.open(@path, "wb") {} # truncate WAL
90
+ @file = File.open(@path, "ab")
91
+ @file.sync = true
92
+ end
93
+
94
+ def snapshot_path
95
+ @path + SNAPSHOT_SUFFIX
96
+ end
97
+
98
+ def close
99
+ @file.close
100
+ end
101
+
102
+ private
103
+
104
+ # Parses a raw JSON body into a frozen Fact. Returns nil on parse error.
105
+ def decode_fact(body)
106
+ payload = JSON.parse(body, symbolize_names: true)
107
+ Fact.from_h(payload)
108
+ rescue JSON::ParserError
109
+ nil
110
+ end
111
+
112
+ # --- WAL reading ---
113
+
114
+ def read_wal_frames
115
+ return [] unless File.exist?(@path)
116
+ facts = []
117
+ File.open(@path, "rb") do |f|
118
+ loop do
119
+ body = read_frame(f)
120
+ break unless body
121
+ fact = decode_fact(body)
122
+ facts << fact if fact
123
+ end
124
+ end
125
+ facts
126
+ end
127
+
128
+ # --- Snapshot reading ---
129
+
130
+ # Returns [Array<Fact>, Set<id>] from the snapshot file, or [[], Set[]]
131
+ # if no snapshot exists or the snapshot is corrupt.
132
+ def load_snapshot
133
+ return [[], Set.new] unless File.exist?(snapshot_path)
134
+
135
+ facts = []
136
+ File.open(snapshot_path, "rb") do |f|
137
+ header_body = read_frame(f)
138
+ return [[], Set.new] unless header_body
139
+
140
+ header = JSON.parse(header_body, symbolize_names: true)
141
+ return [[], Set.new] unless header[:type] == "snapshot_header"
142
+
143
+ loop do
144
+ body = read_frame(f)
145
+ break unless body
146
+ fact = decode_fact(body)
147
+ facts << fact if fact
148
+ end
149
+ end
150
+
151
+ [facts, Set.new(facts.map(&:id))]
152
+ rescue StandardError
153
+ [[], Set.new]
154
+ end
155
+ end
156
+ end
157
+
158
+ if defined?(NATIVE) && NATIVE
159
+ # Patch native FileBackend to add snapshot support.
160
+ # The native class exposes write_fact, replay (WAL-only), and close.
161
+ # We add: write_snapshot, snapshot_path, SNAPSHOT_SUFFIX, and a
162
+ # snapshot-aware replay that merges snapshot facts with the WAL delta.
163
+ #
164
+ # Deduplication uses the original fact id embedded in the snapshot JSON
165
+ # (not the id on the reconstructed Fact object, which is regenerated by
166
+ # Fact.build in native mode).
167
+
168
+ module NativeFileBackendSnapshotSupport
169
+ include WireProtocol
170
+
171
+ SNAPSHOT_SUFFIX = ".snap"
172
+
173
+ def snapshot_path
174
+ @_ruby_path + SNAPSHOT_SUFFIX
175
+ end
176
+
177
+ # Pruning-safe barrier for native-backed stores.
178
+ # Writes the snapshot atomically, then truncates the WAL file so that
179
+ # dropped facts cannot be replayed on reopen.
180
+ # The native write handle is still open; after truncation, the next
181
+ # native write will restart from offset 0 (append mode semantics).
182
+ def replace_with_snapshot!(facts)
183
+ write_snapshot(facts)
184
+ File.open(@_ruby_path, "wb") {} # truncate WAL
185
+ end
186
+
187
+ def write_snapshot(facts)
188
+ tmp = "#{snapshot_path}.tmp"
189
+ File.open(tmp, "wb") do |f|
190
+ header = JSON.generate({
191
+ type: "snapshot_header",
192
+ fact_count: facts.size,
193
+ written_at: Process.clock_gettime(Process::CLOCK_REALTIME)
194
+ })
195
+ f.write(encode_frame(header))
196
+ facts.each { |fact| f.write(encode_frame(JSON.generate(fact.to_h))) }
197
+ end
198
+ FileUtils.mv(tmp, snapshot_path)
199
+ end
200
+
201
+ # Merges snapshot facts (if any) with WAL facts not already in the snapshot.
202
+ # Deduplication is by original id read directly from the JSON frame, because
203
+ # Fact.from_h in native mode regenerates ids via Fact.build.
204
+ def replay
205
+ snapshot_facts, seen_ids = load_native_snapshot
206
+ wal_facts = _native_replay_wal
207
+ delta = wal_facts.reject { |f| seen_ids.include?(f.id) }
208
+ snapshot_facts + delta
209
+ end
210
+
211
+ private
212
+
213
+ def load_native_snapshot
214
+ return [[], Set.new] unless File.exist?(snapshot_path)
215
+
216
+ facts = []
217
+ seen_ids = Set.new
218
+ File.open(snapshot_path, "rb") do |f|
219
+ header_body = read_frame(f)
220
+ return [[], Set.new] unless header_body
221
+ header = JSON.parse(header_body, symbolize_names: true)
222
+ return [[], Set.new] unless header[:type] == "snapshot_header"
223
+
224
+ loop do
225
+ body = read_frame(f)
226
+ break unless body
227
+ h = JSON.parse(body, symbolize_names: true)
228
+ seen_ids.add(h[:id]) if h[:id]
229
+ fact = Fact.from_h(h)
230
+ facts << fact if fact
231
+ end
232
+ end
233
+ [facts, seen_ids]
234
+ rescue StandardError
235
+ [[], Set.new]
236
+ end
237
+ end
238
+
239
+ class FileBackend
240
+ include NativeFileBackendSnapshotSupport
241
+
242
+ SNAPSHOT_SUFFIX = NativeFileBackendSnapshotSupport::SNAPSHOT_SUFFIX
243
+
244
+ # The native extension defines `replay` (WAL-only) as a class-level method,
245
+ # which shadows the module's snapshot-aware `replay`. Save the WAL-only
246
+ # version under an alias, then override `replay` in the class body so the
247
+ # snapshot-aware path is used on store open.
248
+ alias_method :_native_replay_wal, :replay
249
+
250
+ def replay
251
+ snapshot_facts, seen_ids = load_native_snapshot
252
+ wal_facts = _native_replay_wal
253
+ delta = wal_facts.reject { |f| seen_ids.include?(f.id) }
254
+ snapshot_facts + delta
255
+ end
256
+
257
+ class << self
258
+ alias_method :_native_new, :new
259
+
260
+ def new(path)
261
+ obj = _native_new(path)
262
+ obj.instance_variable_set(:@_ruby_path, path.to_s)
263
+ obj
264
+ end
265
+ end
266
+ end
267
+ end
268
+ end
269
+ end
@@ -0,0 +1,413 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+ require "json"
5
+
6
+ module Igniter
7
+ module Store
8
+ # HTTP transport adapter for the Igniter Store Open Protocol.
9
+ #
10
+ # Exposes Protocol::Interpreter over HTTP via a Rack-compatible app.
11
+ # The canonical endpoint is POST /v1/dispatch which accepts and returns
12
+ # a WireEnvelope JSON object.
13
+ #
14
+ # Usage:
15
+ # adapter = HTTPAdapter.new(interpreter: interpreter, port: 7300)
16
+ # adapter.rack_app # → Rack-compatible, mountable in any server
17
+ # adapter.start # → foreground via Puma (dev dep)
18
+ # adapter.start_async / adapter.stop
19
+ class HTTPAdapter
20
+ module ResponseHelper
21
+ private
22
+
23
+ def json_response(status, data)
24
+ body = JSON.generate(data)
25
+ [status, { "Content-Type" => "application/json", "Content-Length" => body.bytesize.to_s }, [body]]
26
+ end
27
+
28
+ def method_not_allowed
29
+ json_response(405, { error: "Method not allowed" })
30
+ end
31
+ end
32
+
33
+ class DispatchHandler
34
+ include ResponseHelper
35
+
36
+ def initialize(interpreter)
37
+ @interpreter = interpreter
38
+ end
39
+
40
+ def call(env)
41
+ return method_not_allowed unless env["REQUEST_METHOD"] == "POST"
42
+
43
+ body = env["rack.input"].read
44
+ begin
45
+ envelope = JSON.parse(body, symbolize_names: true)
46
+ rescue JSON::ParserError => e
47
+ return json_response(400, { error: "Invalid JSON: #{e.message}" })
48
+ end
49
+
50
+ json_response(200, @interpreter.wire.dispatch(envelope))
51
+ end
52
+ end
53
+
54
+ class HealthHandler
55
+ include ResponseHelper
56
+
57
+ def initialize(health_provider: nil)
58
+ @health_provider = health_provider
59
+ end
60
+
61
+ def call(env)
62
+ return method_not_allowed unless env["REQUEST_METHOD"] == "GET"
63
+
64
+ health = @health_provider ? @health_provider.call : { protocol: :igniter_store, schema_version: 1, status: :ready }
65
+ json_response(200, health)
66
+ end
67
+ end
68
+
69
+ class MetadataHandler
70
+ include ResponseHelper
71
+
72
+ def initialize(interpreter)
73
+ @interpreter = interpreter
74
+ end
75
+
76
+ def call(env)
77
+ return method_not_allowed unless env["REQUEST_METHOD"] == "GET"
78
+
79
+ json_response(200, @interpreter.metadata_snapshot)
80
+ end
81
+ end
82
+
83
+ # Returns the canonical observability snapshot at GET /v1/status.
84
+ # When +status_provider+ is given (e.g. StoreServer#observability_snapshot),
85
+ # it is called to produce the full server+storage shape.
86
+ # Otherwise falls back to the interpreter's storage-level snapshot.
87
+ class StatusHandler
88
+ include ResponseHelper
89
+
90
+ def initialize(interpreter:, status_provider: nil)
91
+ @interpreter = interpreter
92
+ @status_provider = status_provider
93
+ end
94
+
95
+ def call(env)
96
+ return method_not_allowed unless env["REQUEST_METHOD"] == "GET"
97
+
98
+ data = @status_provider ? @status_provider.call : @interpreter.observability_snapshot
99
+ json_response(200, data)
100
+ end
101
+ end
102
+
103
+ # Readiness probe: 200 when ready to serve traffic, 503 otherwise (draining,
104
+ # stopped, or initialising). +ready_provider+ must return truthy/falsy.
105
+ class ReadyHandler
106
+ include ResponseHelper
107
+
108
+ def initialize(ready_provider: nil)
109
+ @ready_provider = ready_provider
110
+ end
111
+
112
+ def call(env)
113
+ return method_not_allowed unless env["REQUEST_METHOD"] == "GET"
114
+
115
+ ready = @ready_provider ? @ready_provider.call : true
116
+ if ready
117
+ json_response(200, { status: "ready" })
118
+ else
119
+ json_response(503, { status: "unavailable" })
120
+ end
121
+ end
122
+ end
123
+
124
+ # Returns the metrics sub-hash from the observability snapshot.
125
+ class MetricsHandler
126
+ include ResponseHelper
127
+
128
+ def initialize(metrics_provider: nil)
129
+ @metrics_provider = metrics_provider
130
+ end
131
+
132
+ def call(env)
133
+ return method_not_allowed unless env["REQUEST_METHOD"] == "GET"
134
+
135
+ data = @metrics_provider ? @metrics_provider.call : {}
136
+ json_response(200, data)
137
+ end
138
+ end
139
+
140
+ # Streaming body for SSE responses.
141
+ #
142
+ # Emits retained catch-up events from +replay_events+, then blocks on a
143
+ # live subscription to the ChangefeedBuffer until #close is called.
144
+ #
145
+ # SSE frame format per event:
146
+ # id: <sequence>
147
+ # event: fact_committed
148
+ # data: <ChangeEvent#to_h JSON>
149
+ # (blank line)
150
+ #
151
+ # #close is safe to call from any thread — it pushes a sentinel to unblock
152
+ # the live delivery loop so the subscription handle is released cleanly.
153
+ class SseBody
154
+ SSE_SENTINEL = :__sse_close
155
+
156
+ def initialize(buf, replay_events, sub_stores)
157
+ @buf = buf
158
+ @replay_events = replay_events
159
+ @sub_stores = sub_stores
160
+ @queue = nil
161
+ end
162
+
163
+ def each
164
+ @replay_events.each { |e| yield sse_frame(e) }
165
+
166
+ @queue = Queue.new
167
+ handle = @buf.subscribe(stores: @sub_stores) { |e| @queue << e }
168
+
169
+ begin
170
+ loop do
171
+ event = @queue.pop
172
+ break if event.equal?(SSE_SENTINEL)
173
+ yield sse_frame(event)
174
+ end
175
+ rescue IOError, Errno::EPIPE
176
+ nil
177
+ ensure
178
+ handle&.close
179
+ end
180
+ end
181
+
182
+ def close
183
+ @queue&.push(SSE_SENTINEL)
184
+ end
185
+
186
+ private
187
+
188
+ def sse_frame(event)
189
+ "id: #{event.cursor[:sequence]}\nevent: fact_committed\ndata: #{JSON.generate(event.to_h)}\n\n"
190
+ end
191
+ end
192
+
193
+ # GET /v1/events — Server-Sent Events transport over ChangefeedBuffer.
194
+ #
195
+ # Protocol:
196
+ # 1. Replay retained events (catch-up).
197
+ # 2. Subscribe for live events.
198
+ #
199
+ # Cursor input (both optional):
200
+ # Last-Event-ID: N → replay after sequence N (browser auto-reconnect)
201
+ # ?cursor=N → same, for simple clients / tests
202
+ #
203
+ # Store filtering (optional):
204
+ # ?store=tasks → single store
205
+ # ?stores=tasks,reminders → multiple stores
206
+ # (none) → all stores (wildcard)
207
+ #
208
+ # Error: when the requested cursor is too old (gap due to ring overflow),
209
+ # returns 409 JSON instead of starting the stream.
210
+ class SseEventsHandler
211
+ include ResponseHelper
212
+
213
+ def initialize(changefeed_provider:)
214
+ @changefeed_provider = changefeed_provider
215
+ end
216
+
217
+ def call(env)
218
+ return method_not_allowed unless env["REQUEST_METHOD"] == "GET"
219
+
220
+ buf = @changefeed_provider&.call
221
+ return json_response(503, { error: "SSE events endpoint not configured" }) unless buf
222
+
223
+ cursor = parse_sse_cursor(env)
224
+ stores = parse_sse_stores(env)
225
+
226
+ replay_result = buf.replay(
227
+ cursor: cursor,
228
+ stores: stores.empty? ? nil : stores
229
+ )
230
+
231
+ if replay_result[:status] == :cursor_too_old
232
+ return json_response(409, {
233
+ status: "cursor_too_old",
234
+ oldest_cursor: replay_result[:oldest_cursor],
235
+ newest_cursor: replay_result[:newest_cursor],
236
+ dropped_total: replay_result[:dropped_total]
237
+ })
238
+ end
239
+
240
+ body = SseBody.new(buf, replay_result[:events], stores)
241
+ [200, sse_headers, body]
242
+ end
243
+
244
+ private
245
+
246
+ def sse_headers
247
+ {
248
+ "Content-Type" => "text/event-stream; charset=utf-8",
249
+ "Cache-Control" => "no-cache",
250
+ "X-Accel-Buffering" => "no"
251
+ }
252
+ end
253
+
254
+ def parse_sse_cursor(env)
255
+ last_id = env["HTTP_LAST_EVENT_ID"]
256
+ if last_id && !last_id.empty?
257
+ seq = Integer(last_id, 10) rescue nil
258
+ return { sequence: seq } if seq
259
+ end
260
+ query = Rack::Utils.parse_query(env["QUERY_STRING"] || "")
261
+ cursor_s = query["cursor"]
262
+ if cursor_s && !cursor_s.empty?
263
+ seq = Integer(cursor_s, 10) rescue nil
264
+ return { sequence: seq } if seq
265
+ end
266
+ nil
267
+ end
268
+
269
+ def parse_sse_stores(env)
270
+ query = Rack::Utils.parse_query(env["QUERY_STRING"] || "")
271
+ stores_s = query["stores"] || query["store"] || ""
272
+ stores_s.split(",").map(&:strip).reject(&:empty?)
273
+ end
274
+ end
275
+
276
+ # GET /v1/compaction/activity — normalized compaction lifecycle activity.
277
+ #
278
+ # Query params (all optional):
279
+ # ?store=orders
280
+ # ?kind=exact_prune
281
+ # ?since=1714000000
282
+ # ?limit=50
283
+ #
284
+ # Returns same JSON shape as Protocol::Interpreter#compaction_activity.
285
+ # Non-GET → 405. Invalid numeric since/limit → 400.
286
+ class CompactionActivityHandler
287
+ include ResponseHelper
288
+
289
+ def initialize(interpreter)
290
+ @interpreter = interpreter
291
+ end
292
+
293
+ def call(env)
294
+ return method_not_allowed unless env["REQUEST_METHOD"] == "GET"
295
+
296
+ query = Rack::Utils.parse_query(env["QUERY_STRING"] || "")
297
+
298
+ store = query["store"]
299
+ kind = query["kind"]
300
+
301
+ since_raw = query["since"]
302
+ if since_raw
303
+ since = Float(since_raw) rescue nil
304
+ return json_response(400, { error: "Invalid numeric value for 'since': #{since_raw.inspect}" }) if since.nil?
305
+ end
306
+
307
+ limit_raw = query["limit"]
308
+ if limit_raw
309
+ limit = Integer(limit_raw, 10) rescue nil
310
+ return json_response(400, { error: "Invalid integer value for 'limit': #{limit_raw.inspect}" }) if limit.nil?
311
+ end
312
+
313
+ result = @interpreter.compaction_activity(
314
+ store: store,
315
+ kind: kind,
316
+ since: since,
317
+ limit: limit
318
+ )
319
+ json_response(200, result)
320
+ end
321
+ end
322
+
323
+ # Returns recent structured events from the server event ring buffer.
324
+ class EventsRecentHandler
325
+ include ResponseHelper
326
+
327
+ def initialize(events_provider: nil)
328
+ @events_provider = events_provider
329
+ end
330
+
331
+ def call(env)
332
+ return method_not_allowed unless env["REQUEST_METHOD"] == "GET"
333
+
334
+ events = @events_provider ? @events_provider.call : []
335
+ json_response(200, { events: events, count: events.size })
336
+ end
337
+ end
338
+
339
+ # ── Adapter ──────────────────────────────────────────────────────────────
340
+
341
+ def initialize(interpreter:, port: 7300, host: "0.0.0.0",
342
+ health_provider: nil, status_provider: nil,
343
+ ready_provider: nil, metrics_provider: nil, events_provider: nil,
344
+ changefeed_provider: nil)
345
+ @interpreter = interpreter
346
+ @port = port
347
+ @host = host
348
+ @health_provider = health_provider
349
+ @status_provider = status_provider
350
+ @ready_provider = ready_provider
351
+ @metrics_provider = metrics_provider
352
+ @events_provider = events_provider
353
+ @changefeed_provider = changefeed_provider
354
+ @puma = nil
355
+ @thread = nil
356
+ end
357
+
358
+ # Returns a Rack-compatible app mountable in any Rack server.
359
+ def rack_app
360
+ interp = @interpreter
361
+ hp = @health_provider
362
+ sp = @status_provider
363
+ rp = @ready_provider
364
+ mp = @metrics_provider
365
+ ep = @events_provider
366
+ cp = @changefeed_provider
367
+ not_found = ->(env) {
368
+ body = JSON.generate({ error: "Not found: #{env["REQUEST_METHOD"]} #{env["PATH_INFO"]}" })
369
+ [404, { "Content-Type" => "application/json", "Content-Length" => body.bytesize.to_s }, [body]]
370
+ }
371
+
372
+ Rack::Builder.new do
373
+ map "/v1/dispatch" do run DispatchHandler.new(interp) end
374
+ map "/v1/health" do run HealthHandler.new(health_provider: hp) end
375
+ map "/v1/status" do run StatusHandler.new(interpreter: interp, status_provider: sp) end
376
+ map "/v1/ready" do run ReadyHandler.new(ready_provider: rp) end
377
+ map "/v1/metrics" do run MetricsHandler.new(metrics_provider: mp) end
378
+ # /v1/events/recent must precede /v1/events to avoid prefix shadowing.
379
+ map "/v1/events/recent" do run EventsRecentHandler.new(events_provider: ep) end
380
+ map "/v1/events" do run SseEventsHandler.new(changefeed_provider: cp) end
381
+ map "/v1/metadata" do run MetadataHandler.new(interp) end
382
+ map "/v1/compaction/activity" do run CompactionActivityHandler.new(interp) end
383
+ run not_found
384
+ end
385
+ end
386
+
387
+ # Starts the server in the current thread (blocks). Requires puma.
388
+ def start
389
+ require "puma"
390
+ @puma = Puma::Server.new(rack_app)
391
+ @puma.add_tcp_listener(@host, @port)
392
+ @puma.run.join
393
+ end
394
+
395
+ # Starts in a background thread. Returns self.
396
+ def start_async
397
+ @thread = Thread.new { start }
398
+ sleep 0.05
399
+ self
400
+ end
401
+
402
+ def stop
403
+ @puma&.stop(true) rescue nil
404
+ @thread&.join(2) rescue nil
405
+ self
406
+ end
407
+
408
+ def bind_address
409
+ "#{@host}:#{@port}"
410
+ end
411
+ end
412
+ end
413
+ end