mammoth 1.0.0 → 1.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fc13868a4aa6d845e63c655af36b9bbb69604be8bfb5b7ab6beda7d1a765c145
4
- data.tar.gz: 9a3c9514953f68db2d5287903e29980ffb147460691817f5de1a19a8123f1727
3
+ metadata.gz: 456cf70ebb1cb5a91e7e73a42f847298890e8d56f827ca1175ebe61b1d3bc09a
4
+ data.tar.gz: e726ba8a465f924b45f2f4055a0a6bf076b7b376681ff7db0a72355ddad241e0
5
5
  SHA512:
6
- metadata.gz: 552aa45078fa0ed209890bbfe8eb2dc0672234d4e008c8cc0edd954a284e0b40bffafe16a2a8c2df8dd0219aee3eed742ba2a38531cd3d2acd7b13b35bb48b11
7
- data.tar.gz: 69f509480ecdd46aa50a7879c3dbc1589becce9a651b1697e1f164ea0d6be781edb9f54238940a32553b59956c8cba05cd2b0caaffca57812823cb32e6fa481b
6
+ metadata.gz: 848144fb545ae6040119e249820a1fd495887b8ff2f7bb3f62d47e2fc91de4869a8ec30eaa56cb4946dd50c2063fc7071a8307ae303b25ddbc40d396c7dd45c8
7
+ data.tar.gz: fabd87ecb39c6158ca730dc9d8926a4157b6e01d8ab48a90b6f305299309d3680a86834351a59fba40e0019cfb1d81de66d34aa9ccabacd45f47a8c3b10577dd
data/CHANGELOG.md CHANGED
@@ -1,6 +1,28 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 1.1.0 - 2026-07-19
4
+
5
+ ### Added
6
+
7
+ - Added a serializer benchmark comparing explicit and deterministic fallback
8
+ event IDs for event and transaction payload projection.
9
+ - Added `webhooks-quickstart`, a one-command PostgreSQL-to-Mammoth demo with an
10
+ order application; inspectable signed webhook deliveries and retries;
11
+ `INSERT`, `UPDATE`, and `DELETE` events; multi-event payment and reversal
12
+ transactions; adaptation guidance; WAL-retention safeguards; and end-to-end
13
+ verification.
14
+
15
+ ### Fixed
16
+
17
+ - Generate deterministic fallback event and transaction-envelope IDs so retries
18
+ and post-crash redelivery preserve destination idempotency when upstream
19
+ metadata does not provide an event ID.
20
+ - Serialize `CDC::Core::ChangeEvent` column differences when complete old and
21
+ new rows are available, while avoiding false differences from key-only old
22
+ rows.
23
+ - Require pgoutput-source-adapter 0.3.1 and use its stable transaction-local
24
+ event sequence to prevent deterministic ID collisions between otherwise
25
+ identical changes while preserving replay stability.
4
26
 
5
27
  ## 1.0.0 - 2026-07-17
6
28
 
data/README.md CHANGED
@@ -38,10 +38,25 @@ API documentation:
38
38
 
39
39
  https://kanutocd.github.io/mammoth/Mammoth.html
40
40
 
41
+ ## Start Here
41
42
 
42
- ## v1.0 Release Scope
43
+ The recommended first-run experience is the
44
+ [`webhooks-quickstart`](webhooks-quickstart) stack. One Compose command starts a
45
+ demo application, logical-replication-enabled PostgreSQL, Mammoth, a signed
46
+ webhook receiver with visible retries, and Mammoth health endpoints:
43
47
 
44
- Mammoth 1.0 includes:
48
+ ```bash
49
+ cd webhooks-quickstart
50
+ docker compose up --build --wait
51
+ ```
52
+
53
+ Once the flow is visible, follow its
54
+ [`ADAPTING.md`](webhooks-quickstart/ADAPTING.md) guide to connect Mammoth to an
55
+ existing PostgreSQL application.
56
+
57
+ ## v1 Release Scope
58
+
59
+ Mammoth 1.x includes:
45
60
 
46
61
  - operator CLI for validation, bootstrap, status, delivery, observability, and
47
62
  dead-letter workflows
@@ -107,7 +122,9 @@ Human-readable CLI formatting and PostgreSQL-derived row columns are not frozen:
107
122
  scripts should rely on documented exit behavior, while receivers must tolerate
108
123
  additive fields and coordinate source schema changes. See
109
124
  [`docs/COMPATIBILITY.md`](docs/COMPATIBILITY.md) for the complete promise and
110
- major-version boundaries.
125
+ major-version boundaries. See
126
+ [`docs/WEBHOOK-PAYLOADS.md`](docs/WEBHOOK-PAYLOADS.md) for the canonical event
127
+ and transaction JSON contracts, column-change semantics, and event-ID behavior.
111
128
 
112
129
  ## Boundary
113
130
 
@@ -222,6 +239,7 @@ rather than opening SQLite directly.
222
239
  Mammoth includes local benchmarks for the product surfaces operators tune in
223
240
  production:
224
241
 
242
+ - event and transaction serialization, including deterministic fallback IDs
225
243
  - concurrent delivery runtime
226
244
  - real webhook delivery
227
245
  - multi-destination webhook fanout
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
- require "securerandom"
4
+ require "digest"
5
5
  require "time"
6
6
 
7
7
  module Mammoth
@@ -27,17 +27,17 @@ module Mammoth
27
27
  def initialize(event)
28
28
  raise ArgumentError, "event must be a CDC::Core::ChangeEvent" unless event.is_a?(CDC::Core::ChangeEvent)
29
29
 
30
- @event = event.to_h
30
+ @event = event
31
31
  end
32
32
 
33
33
  # Return the webhook payload.
34
34
  #
35
35
  # @return [Hash] webhook payload
36
36
  def call
37
- event_hash = stringify_keys(@event)
37
+ event_hash = stringify_keys(@event.to_h)
38
38
  metadata = stringify_keys(event_hash["metadata"] || {})
39
39
  {
40
- "event_id" => event_id(metadata),
40
+ "event_id" => event_id(event_hash, metadata),
41
41
  "source" => source(metadata),
42
42
  "operation" => normalize_operation(event_hash.fetch("operation")),
43
43
  "namespace" => event_hash["schema"],
@@ -47,7 +47,7 @@ module Mammoth
47
47
  "transaction_id" => event_hash["transaction_id"],
48
48
  "occurred_at" => occurred_at(event_hash),
49
49
  "data" => event_data(event_hash),
50
- "changes" => metadata["changes"] || [],
50
+ "changes" => serialized_changes(event_hash, metadata),
51
51
  "metadata" => metadata
52
52
  }
53
53
  end
@@ -69,8 +69,25 @@ module Mammoth
69
69
  operation.to_s
70
70
  end
71
71
 
72
- def event_id(metadata)
73
- metadata["event_id"] || SecureRandom.uuid
72
+ def event_id(event_hash, metadata)
73
+ metadata["event_id"] || deterministic_event_id(event_hash, metadata)
74
+ end
75
+
76
+ def deterministic_event_id(event_hash, metadata)
77
+ identity = {
78
+ source: source(metadata),
79
+ transaction_id: event_hash["transaction_id"],
80
+ source_position: event_hash["commit_lsn"],
81
+ sequence_number: event_hash["sequence_number"],
82
+ occurred_at: event_hash["occurred_at"],
83
+ namespace: event_hash["schema"],
84
+ entity: event_hash["table"],
85
+ operation: normalize_operation(event_hash.fetch("operation")),
86
+ primary_key: event_hash["primary_key"],
87
+ old_values: event_hash["old_values"],
88
+ new_values: event_hash["new_values"]
89
+ }
90
+ "evt_#{Digest::SHA256.hexdigest(JSON.generate(identity))}"
74
91
  end
75
92
 
76
93
  def source(metadata)
@@ -87,5 +104,20 @@ module Mammoth
87
104
  def event_data(event_hash)
88
105
  event_hash["data"] || event_hash["new_values"] || event_hash["old_values"] || {}
89
106
  end
107
+
108
+ def serialized_changes(event_hash, metadata)
109
+ return metadata["changes"] || [] if metadata.key?("changes")
110
+ return [] unless changes_available?(event_hash)
111
+
112
+ @event.changes.map(&:to_h)
113
+ end
114
+
115
+ def changes_available?(event_hash)
116
+ return true unless normalize_operation(event_hash.fetch("operation")) == "update"
117
+
118
+ old_values = event_hash["old_values"]
119
+ new_values = event_hash["new_values"]
120
+ old_values.is_a?(Hash) && new_values.is_a?(Hash) && old_values.keys.sort == new_values.keys.sort
121
+ end
90
122
  end
91
123
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
- require "securerandom"
4
+ require "digest"
5
5
  require "time"
6
6
 
7
7
  module Mammoth
@@ -38,7 +38,7 @@ module Mammoth
38
38
  def call
39
39
  event_payloads = envelope.events.map { |event| EventSerializer.call(event) }
40
40
  {
41
- "event_id" => envelope_metadata["event_id"] || SecureRandom.uuid,
41
+ "event_id" => envelope_metadata["event_id"] || deterministic_event_id(event_payloads),
42
42
  "type" => PAYLOAD_TYPE,
43
43
  "source" => first_event_value(event_payloads, "source") || EventSerializer::DEFAULT_SOURCE,
44
44
  "transaction_id" => envelope.transaction_id,
@@ -70,6 +70,16 @@ module Mammoth
70
70
  @envelope_metadata ||= stringify_keys(envelope.metadata)
71
71
  end
72
72
 
73
+ def deterministic_event_id(event_payloads)
74
+ identity = {
75
+ source: first_event_value(event_payloads, "source") || EventSerializer::DEFAULT_SOURCE,
76
+ transaction_id: envelope.transaction_id,
77
+ source_position: source_position(event_payloads),
78
+ event_ids: event_payloads.map { |payload| payload.fetch("event_id") }
79
+ }
80
+ "txn_#{Digest::SHA256.hexdigest(JSON.generate(identity))}"
81
+ end
82
+
73
83
  def source_position(event_payloads)
74
84
  envelope.commit_lsn ||
75
85
  first_event_value(event_payloads.reverse, "source_position")
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Mammoth
4
4
  # Current Mammoth gem version.
5
- VERSION = "1.0.0"
5
+ VERSION = "1.1.0"
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mammoth
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ken C. Demanawa
@@ -122,7 +122,7 @@ dependencies:
122
122
  version: '0.3'
123
123
  - - ">="
124
124
  - !ruby/object:Gem::Version
125
- version: 0.3.0
125
+ version: 0.3.1
126
126
  type: :runtime
127
127
  prerelease: false
128
128
  version_requirements: !ruby/object:Gem::Requirement
@@ -132,7 +132,7 @@ dependencies:
132
132
  version: '0.3'
133
133
  - - ">="
134
134
  - !ruby/object:Gem::Version
135
- version: 0.3.0
135
+ version: 0.3.1
136
136
  - !ruby/object:Gem::Dependency
137
137
  name: sqlite3
138
138
  requirement: !ruby/object:Gem::Requirement