mammoth 0.1.0 → 0.2.0

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.
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mammoth
4
+ # Adapter object used by CDC::Concurrent::ProcessorPool.
5
+ #
6
+ # The processor keeps cdc-concurrent integration narrow: cdc-concurrent owns
7
+ # I/O-heavy fan-out mechanics, while DeliveryWorker owns Mammoth relay
8
+ # semantics such as retries, dead letters, and checkpoint writes.
9
+ class DeliveryProcessor
10
+ @concurrent_safe = false
11
+
12
+ class << self
13
+ # Mark this processor as safe for CDC::Concurrent::ProcessorPool.
14
+ #
15
+ # DeliveryProcessor itself is intentionally stateless after initialization;
16
+ # per-delivery retry, dead-letter, and checkpoint behavior remains owned by
17
+ # the injected DeliveryWorker.
18
+ #
19
+ # @return [true]
20
+ def concurrent_safe!
21
+ @concurrent_safe = true
22
+ end
23
+
24
+ # @return [Boolean] true when this processor has explicitly opted in to
25
+ # cdc-concurrent execution.
26
+ def concurrent_safe?
27
+ @concurrent_safe == true
28
+ end
29
+
30
+ alias concurrent_safe concurrent_safe?
31
+ end
32
+
33
+ concurrent_safe!
34
+
35
+ attr_reader :delivery_worker, :delivery_unit
36
+
37
+ # @param delivery_worker [Mammoth::DeliveryWorker] relay-aware delivery worker
38
+ # @param delivery_unit [String, Symbol] event or transaction
39
+ def initialize(delivery_worker:, delivery_unit: :event)
40
+ @delivery_worker = delivery_worker
41
+ @delivery_unit = delivery_unit.to_sym
42
+ end
43
+
44
+ # @return [Boolean] true when this processor instance is safe for concurrent execution.
45
+ def concurrent_safe?
46
+ self.class.concurrent_safe?
47
+ end
48
+
49
+ alias concurrent_safe concurrent_safe?
50
+
51
+ # Process one work item from CDC::Concurrent::ProcessorPool.
52
+ #
53
+ # @param work [Object] event or transaction envelope
54
+ # @return [Hash] delivery summary
55
+ def call(work)
56
+ process(work)
57
+ end
58
+
59
+ def process(work)
60
+ case delivery_unit
61
+ when :transaction
62
+ delivery_worker.deliver_transaction(work)
63
+ else
64
+ delivery_worker.deliver(work)
65
+ end
66
+ end
67
+ end
68
+ end
@@ -11,12 +11,13 @@ module Mammoth
11
11
  # Default source name used when an event does not provide one.
12
12
  DEFAULT_SOURCE = "postgresql"
13
13
 
14
- attr_reader :sink, :checkpoint_store, :dead_letter_store, :retry_schedule, :max_attempts, :sleeper, :source_name,
15
- :slot_name, :publication_name
14
+ attr_reader :sink, :checkpoint_store, :dead_letter_store, :delivered_envelope_store, :retry_schedule, :max_attempts,
15
+ :sleeper, :source_name, :slot_name, :publication_name
16
16
 
17
17
  # @param sink [#deliver] destination sink
18
18
  # @param checkpoint_store [Mammoth::CheckpointStore] checkpoint persistence
19
19
  # @param dead_letter_store [Mammoth::DeadLetterStore] dead letter persistence
20
+ # @param delivered_envelope_store [Mammoth::DeliveredEnvelopeStore, nil] downstream delivery ledger
20
21
  # @param source_name [String] logical source name
21
22
  # @param slot_name [String] replication slot name
22
23
  # @param publication_name [String] publication name
@@ -24,10 +25,11 @@ module Mammoth
24
25
  # @param retry_schedule [Array<Integer>] retry wait schedule in seconds
25
26
  # @param sleeper [#call] sleep strategy, injectable for tests
26
27
  def initialize(sink:, checkpoint_store:, dead_letter_store:, source_name:, slot_name:, publication_name:,
27
- max_attempts:, retry_schedule:, sleeper: Kernel.method(:sleep))
28
+ max_attempts:, retry_schedule:, delivered_envelope_store: nil, sleeper: Kernel.method(:sleep))
28
29
  @sink = sink
29
30
  @checkpoint_store = checkpoint_store
30
31
  @dead_letter_store = dead_letter_store
32
+ @delivered_envelope_store = delivered_envelope_store || DeliveredEnvelopeStore.new(checkpoint_store.sqlite_store)
31
33
  @source_name = source_name
32
34
  @slot_name = slot_name
33
35
  @publication_name = publication_name
@@ -58,30 +60,70 @@ module Mammoth
58
60
  )
59
61
  end
60
62
 
63
+ # Deliver a transaction envelope with retry, checkpoint, and DLQ handling.
64
+ #
65
+ # @param envelope [#events, #transaction_id] CDC transaction envelope
66
+ # @return [Hash] delivery summary
67
+ def deliver_transaction(envelope)
68
+ deliver_work(envelope, serializer: TransactionEnvelopeSerializer, delivery_method: :deliver_transaction)
69
+ end
70
+
61
71
  # Deliver an event with retry, checkpoint, and DLQ handling.
62
72
  #
63
73
  # @param event [Hash, #to_h] normalized event
64
74
  # @return [Hash] delivery summary
65
75
  def deliver(event)
76
+ deliver_work(event, serializer: EventSerializer, delivery_method: :deliver)
77
+ end
78
+
79
+ private
80
+
81
+ # rubocop:disable Metrics/MethodLength
82
+ def deliver_work(work, serializer:, delivery_method:)
66
83
  attempts = 0
84
+ payload = serializer.call(work)
85
+ delivery_unit = delivery_unit_for(delivery_method)
86
+ idempotency_key = idempotency_key_for(payload:, delivery_unit:)
87
+
88
+ if delivered_envelope_store.delivered?(idempotency_key)
89
+ checkpoint_payload(payload)
90
+ return {
91
+ status: "skipped",
92
+ duplicate: true,
93
+ idempotency_key: idempotency_key,
94
+ attempts: attempts,
95
+ destination: destination_name
96
+ }
97
+ end
67
98
 
68
99
  begin
69
100
  attempts += 1
70
- result = sink.deliver(event)
71
- checkpoint(event)
72
- result.merge(attempts: attempts)
101
+ result = sink.public_send(delivery_method, work)
102
+ delivered_envelope_store.record!(
103
+ idempotency_key: idempotency_key,
104
+ source_name: source_name,
105
+ slot_name: slot_name,
106
+ destination_name: destination_name,
107
+ delivery_unit: delivery_unit.to_s,
108
+ transaction_id: payload["transaction_id"],
109
+ source_position: payload["source_position"]
110
+ )
111
+ checkpoint_payload(payload)
112
+ result.merge(attempts: attempts, idempotency_key: idempotency_key)
73
113
  rescue DeliveryError => e
74
- return dead_letter(event, e, attempts) if attempts >= max_attempts
114
+ return dead_letter(work, e, attempts, serializer:) if attempts >= max_attempts
75
115
 
76
116
  wait_before_retry(attempts)
77
117
  retry
78
118
  end
79
119
  end
120
+ # rubocop:enable Metrics/MethodLength
80
121
 
81
- private
122
+ def checkpoint(work, serializer:)
123
+ checkpoint_payload(serializer.call(work))
124
+ end
82
125
 
83
- def checkpoint(event)
84
- payload = EventSerializer.call(event)
126
+ def checkpoint_payload(payload)
85
127
  checkpoint_store.write(
86
128
  source_name: source_name,
87
129
  slot_name: slot_name,
@@ -90,12 +132,32 @@ module Mammoth
90
132
  )
91
133
  end
92
134
 
93
- def dead_letter(event, error, attempts)
135
+ def idempotency_key_for(payload:, delivery_unit:)
136
+ [
137
+ source_name,
138
+ slot_name,
139
+ destination_name,
140
+ delivery_unit,
141
+ payload["transaction_id"] || payload["event_id"],
142
+ payload["source_position"]
143
+ ].compact.join(":")
144
+ end
145
+
146
+ def delivery_unit_for(delivery_method)
147
+ delivery_method == :deliver_transaction ? :transaction : :event
148
+ end
149
+
150
+ def destination_name
151
+ sink.respond_to?(:name) ? sink.name : sink.class.name
152
+ end
153
+
154
+ def dead_letter(event, error, attempts, serializer:)
94
155
  id = dead_letter_store.write(
95
156
  event: event,
96
157
  destination_name: sink.name,
97
158
  error: error,
98
- retry_count: attempts
159
+ retry_count: attempts,
160
+ serializer: serializer
99
161
  )
100
162
  { status: "dead_lettered", dead_letter_id: id, attempts: attempts }
101
163
  end
@@ -5,14 +5,17 @@ module Mammoth
5
5
  #
6
6
  # ReplicationConsumer is intentionally upstream-agnostic. It does not know
7
7
  # which upstream system produced the work. Its
8
- # only job is to consume CDC Ecosystem work, flatten CDC transaction
9
- # envelopes, and yield individual change events to the delivery pipeline.
8
+ # job is to consume CDC Ecosystem work and yield either individual change
9
+ # events or transaction envelopes depending on Mammoth's configured delivery
10
+ # unit.
10
11
  class ReplicationConsumer
11
- attr_reader :source
12
+ attr_reader :source, :delivery_unit
12
13
 
13
14
  # @param source [#each, nil] injectable CDC work stream
14
- def initialize(source: nil)
15
+ # @param delivery_unit [String, Symbol] :event or :transaction
16
+ def initialize(source: nil, delivery_unit: :event)
15
17
  @source = source
18
+ @delivery_unit = delivery_unit.to_sym
16
19
  end
17
20
 
18
21
  # Consume normalized CDC work from the configured source.
@@ -46,16 +49,45 @@ module Mammoth
46
49
 
47
50
  def flatten_cdc_work(work)
48
51
  return [] if work.nil?
52
+ return validate_transaction_envelope(work) if transaction_envelope?(work) && transaction_delivery?
49
53
  return validate_events(work.events) if transaction_envelope?(work)
50
54
  return work.flat_map { |item| flatten_cdc_work(item) } if work.is_a?(Array)
55
+ return [synthetic_transaction_envelope(work)] if transaction_delivery?
51
56
 
52
57
  validate_events([work])
53
58
  end
54
59
 
60
+ def transaction_delivery?
61
+ delivery_unit == :transaction
62
+ end
63
+
64
+ def validate_transaction_envelope(envelope)
65
+ validate_events(envelope.events)
66
+ [envelope]
67
+ end
68
+
55
69
  def validate_events(events)
56
70
  events.each { |event| validate_cdc_event!(event) }
57
71
  end
58
72
 
73
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
74
+ def synthetic_transaction_envelope(event)
75
+ validate_cdc_event!(event)
76
+
77
+ event_hash = event.to_h
78
+ SyntheticTransactionEnvelope.new(
79
+ [event],
80
+ event_hash["transaction_id"] || event_hash[:transaction_id] ||
81
+ event_hash["xid"] || event_hash[:xid] || event_hash["event_id"] || event_hash[:event_id],
82
+ event_hash["commit_lsn"] || event_hash[:commit_lsn] ||
83
+ event_hash["source_position"] || event_hash[:source_position],
84
+ event_hash["committed_at"] || event_hash[:committed_at] ||
85
+ event_hash["occurred_at"] || event_hash[:occurred_at],
86
+ event_hash["metadata"] || event_hash[:metadata] || {}
87
+ )
88
+ end
89
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
90
+
59
91
  def validate_cdc_event!(event)
60
92
  return event if event.respond_to?(:to_h) && cdc_event_hash?(event.to_h)
61
93
 
@@ -74,5 +106,8 @@ module Mammoth
74
106
  def transaction_envelope?(work)
75
107
  work.respond_to?(:events) && work.respond_to?(:transaction_id)
76
108
  end
109
+
110
+ SyntheticTransactionEnvelope = Data.define(:events, :transaction_id, :commit_lsn, :commit_time, :metadata)
111
+ private_constant :SyntheticTransactionEnvelope
77
112
  end
78
113
  end
@@ -12,6 +12,7 @@ module Mammoth
12
12
  # This class may mention pgoutput implementation details because it is the
13
13
  # concrete PostgreSQL source adapter used by Mammoth. The rest of Mammoth
14
14
  # should remain source-agnostic and consume only the work yielded here.
15
+ # rubocop:disable Metrics/ClassLength
15
16
  class Postgres
16
17
  # @return [Mammoth::Configuration] loaded Mammoth configuration
17
18
  attr_reader :config
@@ -23,6 +24,8 @@ module Mammoth
23
24
  attr_reader :decoder
24
25
  # @return [Object, nil] injected CDC source adapter
25
26
  attr_reader :adapter
27
+ # @return [Mammoth::CheckpointStore, nil] checkpoint store used for restart resume
28
+ attr_reader :checkpoint_store
26
29
 
27
30
  # Build a PostgreSQL CDC source.
28
31
  #
@@ -31,12 +34,14 @@ module Mammoth
31
34
  # @param parser [Object, nil] injected pgoutput parser or relation tracker
32
35
  # @param decoder [Object, nil] injected pgoutput decoder
33
36
  # @param adapter [Object, nil] injected source adapter
34
- def initialize(config, runner: nil, parser: nil, decoder: nil, adapter: nil)
37
+ # @param checkpoint_store [Mammoth::CheckpointStore, nil] persisted checkpoints for restart resume
38
+ def initialize(config, runner: nil, parser: nil, decoder: nil, adapter: nil, checkpoint_store: nil)
35
39
  @config = config
36
40
  @runner = runner
37
41
  @parser = parser
38
42
  @decoder = decoder
39
43
  @adapter = adapter
44
+ @checkpoint_store = checkpoint_store
40
45
  end
41
46
 
42
47
  # Stream CDC::Core-shaped work from PostgreSQL logical replication.
@@ -67,9 +72,39 @@ module Mammoth
67
72
  def process_payload(payload, metadata, &block)
68
73
  parsed = parse_payload(payload)
69
74
  decoded = decode_message(parsed, metadata)
70
- normalize_decoded(decoded).each { |work| block.call(work) if work }
75
+ process_decoded(decoded, metadata, &block)
71
76
  end
72
77
 
78
+ # rubocop:disable Metrics/MethodLength
79
+ def process_decoded(decoded, metadata, &block)
80
+ return if decoded.nil?
81
+
82
+ if decoded.is_a?(Array)
83
+ decoded.each { |item| process_decoded(item, metadata, &block) }
84
+ return
85
+ end
86
+
87
+ if begin_message?(decoded)
88
+ start_transaction_buffer(decoded)
89
+ return
90
+ end
91
+
92
+ if commit_message?(decoded)
93
+ emit_transaction_buffer(decoded, metadata, &block)
94
+ return
95
+ end
96
+
97
+ normalize_decoded(decoded).each do |work|
98
+ work = enrich_work_position(work, metadata, decoded)
99
+ if transaction_buffer_active?
100
+ Array(@transaction_events) << work
101
+ else
102
+ block.call(work)
103
+ end
104
+ end
105
+ end
106
+ # rubocop:enable Metrics/MethodLength
107
+
73
108
  def parse_payload(payload)
74
109
  parser = effective_parser
75
110
  return parser.process(payload) if parser.respond_to?(:process)
@@ -107,20 +142,129 @@ module Mammoth
107
142
  result.is_a?(Array) ? result.compact : [result].compact
108
143
  end
109
144
 
145
+ def begin_message?(decoded)
146
+ message_kind(decoded).include?("begin")
147
+ end
148
+
149
+ def commit_message?(decoded)
150
+ message_kind(decoded).include?("commit")
151
+ end
152
+
153
+ def message_kind(decoded)
154
+ (value_from(decoded, :message_type, :type, :kind) || decoded.class.name.to_s.split("::").last).to_s.downcase
155
+ end
156
+
157
+ def start_transaction_buffer(decoded)
158
+ @transaction_events = []
159
+ @transaction_id = value_from(decoded, :transaction_id, :xid, :final_lsn)
160
+ @transaction_metadata = value_hash(decoded, :metadata) || {}
161
+ end
162
+
163
+ def emit_transaction_buffer(decoded, metadata, &block)
164
+ return unless transaction_buffer_active?
165
+
166
+ block.call(
167
+ TransactionEnvelope.new(
168
+ @transaction_events,
169
+ transaction_id_for(decoded),
170
+ commit_lsn_for(decoded, metadata),
171
+ value_from(decoded, :commit_time, :committed_at, :timestamp),
172
+ @transaction_metadata
173
+ )
174
+ )
175
+ ensure
176
+ clear_transaction_buffer
177
+ end
178
+
179
+ def transaction_id_for(decoded)
180
+ value_from(decoded, :transaction_id, :xid) || @transaction_id || first_event_value(:transaction_id, :xid)
181
+ end
182
+
183
+ def commit_lsn_for(decoded, metadata)
184
+ value_from(decoded, :commit_lsn, :source_position, :lsn, :end_lsn, :final_lsn) ||
185
+ value_from(metadata, :commit_lsn, :source_position, :lsn, :end_lsn, :final_lsn) ||
186
+ first_event_value(:commit_lsn, :source_position, :lsn)
187
+ end
188
+
189
+ def transaction_buffer_active?
190
+ !@transaction_events.nil?
191
+ end
192
+
193
+ def clear_transaction_buffer
194
+ @transaction_events = nil
195
+ @transaction_id = nil
196
+ @transaction_metadata = nil
197
+ end
198
+
199
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
200
+ def enrich_work_position(work, metadata, decoded)
201
+ position = value_from(work, :source_position, :commit_lsn) ||
202
+ value_from(metadata, :source_position, :commit_lsn, :lsn) ||
203
+ value_from(decoded, :source_position, :commit_lsn, :lsn)
204
+ return work unless position
205
+
206
+ work_hash = work.respond_to?(:to_h) ? work.to_h : work
207
+ return work unless work_hash.is_a?(Hash)
208
+
209
+ key_style = work_hash.key?("operation") ? :string : :symbol
210
+ source_position_key = key_style == :string ? "source_position" : :source_position
211
+ commit_lsn_key = key_style == :string ? "commit_lsn" : :commit_lsn
212
+ return work if work_hash[source_position_key] || work_hash[commit_lsn_key]
213
+
214
+ work_hash.merge(source_position_key => position, commit_lsn_key => position)
215
+ end
216
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
217
+
218
+ def first_event_value(*keys)
219
+ event = Array(@transaction_events).find do |event|
220
+ keys.any? { |key| value_from(event, key) }
221
+ end
222
+ value_from(event, *keys) if event
223
+ end
224
+
225
+ def value_from(object, *keys)
226
+ return nil if object.nil?
227
+
228
+ keys.each do |key|
229
+ return object.public_send(key) if object.respond_to?(key)
230
+
231
+ hash = object.respond_to?(:to_h) ? object.to_h : object
232
+ next unless hash.respond_to?(:key?)
233
+
234
+ return hash[key] if hash.key?(key)
235
+ return hash[key.to_s] if hash.key?(key.to_s)
236
+ end
237
+
238
+ nil
239
+ end
240
+
241
+ def value_hash(object, key)
242
+ value = value_from(object, key)
243
+ value.is_a?(Hash) ? value : nil
244
+ end
245
+
110
246
  def effective_runner
111
- runner || (@effective_runner ||= build_runner)
247
+ runner || @effective_runner || begin
248
+ @effective_runner = build_runner
249
+ end
112
250
  end
113
251
 
114
252
  def effective_parser
115
- parser || (@effective_parser ||= build_parser)
253
+ parser || @effective_parser || begin
254
+ @effective_parser = build_parser
255
+ end
116
256
  end
117
257
 
118
258
  def effective_decoder
119
- decoder || (@effective_decoder ||= build_decoder)
259
+ decoder || @effective_decoder || begin
260
+ @effective_decoder = build_decoder
261
+ end
120
262
  end
121
263
 
122
264
  def effective_adapter
123
- adapter || (@effective_adapter ||= build_adapter)
265
+ adapter || @effective_adapter || begin
266
+ @effective_adapter = build_adapter
267
+ end
124
268
  end
125
269
 
126
270
  def build_runner
@@ -152,15 +296,47 @@ module Mammoth
152
296
  database_url: database_url,
153
297
  slot_name: required_config("replication", "slot"),
154
298
  publication_names: required_publications,
155
- start_lsn: config.dig("replication", "start_lsn"),
156
- auto_create_slot: !config.dig("replication", "auto_create_slot").nil?,
157
- temporary_slot: !config.dig("replication", "temporary_slot").nil?
299
+ start_lsn: replication_start_lsn,
300
+ auto_create_slot: config.dig("replication", "auto_create_slot") == true,
301
+ temporary_slot: config.dig("replication", "temporary_slot") == true
158
302
  }.tap do |options|
159
303
  feedback_interval = config.dig("replication", "feedback_interval")
160
304
  options[:feedback_interval] = feedback_interval unless feedback_interval.nil?
161
305
  end
162
306
  end
163
307
 
308
+ def replication_start_lsn
309
+ configured = config.dig("replication", "start_lsn")
310
+ return configured unless blank?(configured)
311
+
312
+ checkpoint_lsn
313
+ end
314
+
315
+ def checkpoint_lsn
316
+ return nil unless checkpoint_store
317
+
318
+ row = checkpoint_store.fetch(source_name: source_name, slot_name: required_config("replication", "slot"))
319
+ normalize_lsn(row&.fetch("last_lsn", nil))
320
+ end
321
+
322
+ def source_name
323
+ required_config("mammoth", "name")
324
+ end
325
+
326
+ def normalize_lsn(value)
327
+ return nil if blank?(value)
328
+
329
+ lsn = value.to_s
330
+ return lsn if lsn.include?("/")
331
+ return "0/#{lsn.to_i.to_s(16).upcase}" if lsn.match?(/\A\d+\z/)
332
+
333
+ lsn
334
+ end
335
+
336
+ def blank?(value)
337
+ value.nil? || value == ""
338
+ end
339
+
164
340
  def required_publications
165
341
  publications = required_config("replication", "publications")
166
342
  unless publications.is_a?(Array) && publications.any? &&
@@ -198,6 +374,9 @@ module Mammoth
198
374
  rescue LoadError => e
199
375
  raise ReplicationError, "#{gem_name} is required for PostgreSQL CDC source integration: #{e.message}"
200
376
  end
377
+ TransactionEnvelope = Data.define(:events, :transaction_id, :commit_lsn, :commit_time, :metadata)
378
+ private_constant :TransactionEnvelope
201
379
  end
380
+ # rubocop:enable Metrics/ClassLength
202
381
  end
203
382
  end
@@ -53,3 +53,27 @@ ON dead_letters(namespace, entity);
53
53
 
54
54
  CREATE INDEX IF NOT EXISTS idx_dead_letters_failed_at
55
55
  ON dead_letters(failed_at);
56
+
57
+
58
+ CREATE TABLE IF NOT EXISTS delivered_envelopes (
59
+ id INTEGER PRIMARY KEY,
60
+ idempotency_key TEXT NOT NULL,
61
+ source_name TEXT NOT NULL,
62
+ slot_name TEXT NOT NULL,
63
+ destination_name TEXT NOT NULL,
64
+ delivery_unit TEXT NOT NULL,
65
+ transaction_id TEXT,
66
+ source_position TEXT,
67
+ delivered_at TEXT NOT NULL,
68
+
69
+ UNIQUE (idempotency_key)
70
+ );
71
+
72
+ CREATE INDEX IF NOT EXISTS idx_delivered_envelopes_source
73
+ ON delivered_envelopes(source_name, slot_name);
74
+
75
+ CREATE INDEX IF NOT EXISTS idx_delivered_envelopes_destination
76
+ ON delivered_envelopes(destination_name);
77
+
78
+ CREATE INDEX IF NOT EXISTS idx_delivered_envelopes_source_position
79
+ ON delivered_envelopes(source_position);
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "time"
6
+
7
+ module Mammoth
8
+ # Serializes CDC transaction envelopes into webhook payloads.
9
+ #
10
+ # Mammoth uses transaction envelopes as the safest delivery and checkpointing
11
+ # boundary for concurrent delivery. A transaction payload preserves the commit
12
+ # position and groups the row-level changes that belong to the same database
13
+ # transaction.
14
+ class TransactionEnvelopeSerializer
15
+ # Default payload type for transaction webhook delivery.
16
+ PAYLOAD_TYPE = "transaction.committed"
17
+
18
+ # Serialize a CDC::Core::TransactionEnvelope-like object into a Hash.
19
+ #
20
+ # @param envelope [#events, #transaction_id] transaction envelope
21
+ # @return [Hash] webhook-ready transaction payload
22
+ def self.call(envelope)
23
+ new(envelope).call
24
+ end
25
+
26
+ # @param envelope [#events, #transaction_id] transaction envelope
27
+ def initialize(envelope)
28
+ @envelope = envelope
29
+ end
30
+
31
+ # Return the webhook payload.
32
+ #
33
+ # @return [Hash] transaction webhook payload
34
+ def call
35
+ event_payloads = envelope.events.map { |event| EventSerializer.call(event) }
36
+ {
37
+ "event_id" => envelope_value("event_id") || SecureRandom.uuid,
38
+ "type" => PAYLOAD_TYPE,
39
+ "source" => first_event_value(event_payloads, "source") || EventSerializer::DEFAULT_SOURCE,
40
+ "transaction_id" => envelope.transaction_id,
41
+ "source_position" => source_position(event_payloads),
42
+ "commit_lsn" => source_position(event_payloads),
43
+ "committed_at" => committed_at,
44
+ "event_count" => event_payloads.length,
45
+ "events" => event_payloads,
46
+ "metadata" => envelope_value("metadata") || {}
47
+ }
48
+ end
49
+
50
+ # Return JSON representation of the transaction payload.
51
+ #
52
+ # @return [String] JSON representation
53
+ def to_json(*_args)
54
+ JSON.generate(call)
55
+ end
56
+
57
+ private
58
+
59
+ attr_reader :envelope
60
+
61
+ def envelope_hash
62
+ @envelope_hash ||= envelope.respond_to?(:to_h) ? stringify_keys(envelope.to_h) : {}
63
+ end
64
+
65
+ def stringify_keys(hash)
66
+ hash.to_h.transform_keys(&:to_s)
67
+ end
68
+
69
+ def envelope_value(key)
70
+ return envelope.public_send(key) if envelope.respond_to?(key)
71
+
72
+ envelope_hash[key]
73
+ end
74
+
75
+ def source_position(event_payloads)
76
+ envelope_value("source_position") || envelope_value("commit_lsn") ||
77
+ first_event_value(event_payloads.reverse, "source_position")
78
+ end
79
+
80
+ def committed_at
81
+ value = envelope_value("committed_at") || envelope_value("commit_time")
82
+ return value.iso8601 if value.respond_to?(:iso8601)
83
+
84
+ value || Time.now.utc.iso8601
85
+ end
86
+
87
+ def first_event_value(event_payloads, key)
88
+ event_payloads.find { |payload| payload[key] }&.fetch(key)
89
+ end
90
+ end
91
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Mammoth
4
4
  # Current Mammoth gem version.
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end