mammoth 0.0.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,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mammoth
4
+ # Base error for all Mammoth failures.
5
+ class Error < StandardError; end
6
+
7
+ # Raised when configuration cannot be loaded or validated.
8
+ class ConfigurationError < Error; end
9
+
10
+ # Raised when local SQLite operational state cannot be initialized.
11
+ class StoreError < Error; end
12
+
13
+ # Raised when webhook delivery fails.
14
+ class DeliveryError < Error; end
15
+
16
+ # Raised when replication consumption is not available or fails.
17
+ class ReplicationError < Error; end
18
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "time"
6
+
7
+ module Mammoth
8
+ # Serializes CDC-core change events into webhook payloads.
9
+ #
10
+ # The serializer projects Mammoth's sink payload from CDC vocabulary rather
11
+ # than pgoutput protocol vocabulary. That keeps webhook delivery independent
12
+ # from PostgreSQL-specific message shapes while preserving source metadata such
13
+ # as commit LSN and transaction identity when available.
14
+ class EventSerializer
15
+ DEFAULT_SOURCE = "postgresql"
16
+
17
+ # Serialize an event-like object into a webhook-ready Hash.
18
+ #
19
+ # @param event [Hash, #to_h] normalized CDC event
20
+ # @return [Hash] webhook payload
21
+ def self.call(event)
22
+ new(event).call
23
+ end
24
+
25
+ # @param event [Hash, #to_h] normalized CDC event
26
+ def initialize(event)
27
+ @event = event.respond_to?(:to_h) ? event.to_h : event
28
+ end
29
+
30
+ # Return the webhook payload.
31
+ #
32
+ # @return [Hash] webhook payload
33
+ def call
34
+ event_hash = stringify_keys(@event)
35
+ {
36
+ "event_id" => event_hash["event_id"] || SecureRandom.uuid,
37
+ "source" => event_hash["source"] || DEFAULT_SOURCE,
38
+ "operation" => normalize_operation(event_hash.fetch("operation")),
39
+ "namespace" => event_hash["namespace"] || event_hash["schema"],
40
+ "entity" => event_hash["entity"] || event_hash["table"],
41
+ "identity" => event_hash["identity"] || event_hash["primary_key"],
42
+ "source_position" => event_hash["source_position"] || event_hash["commit_lsn"],
43
+ "transaction_id" => event_hash["transaction_id"],
44
+ "occurred_at" => occurred_at(event_hash),
45
+ "data" => event_data(event_hash),
46
+ "changes" => event_hash["changes"] || [],
47
+ "metadata" => event_hash["metadata"] || {}
48
+ }
49
+ end
50
+
51
+ # Return JSON representation of the webhook payload.
52
+ #
53
+ # @return [String] JSON representation of the payload
54
+ def to_json(*_args)
55
+ JSON.generate(call)
56
+ end
57
+
58
+ private
59
+
60
+ def stringify_keys(hash)
61
+ hash.to_h.transform_keys(&:to_s)
62
+ end
63
+
64
+ def normalize_operation(operation)
65
+ operation.to_s
66
+ end
67
+
68
+ def occurred_at(event_hash)
69
+ value = event_hash["occurred_at"]
70
+ return value.iso8601 if value.respond_to?(:iso8601)
71
+
72
+ value || Time.now.utc.iso8601
73
+ end
74
+
75
+ def event_data(event_hash)
76
+ event_hash["data"] || event_hash["new_values"] || event_hash["old_values"] || {}
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mammoth
4
+ # Streams PostgreSQL logical replication through the CDC Ecosystem boundary.
5
+ #
6
+ # PgoutputSource is Mammoth's upstream integration point. It composes the
7
+ # standalone pgoutput transport, parser, decoder, and source-adapter gems so
8
+ # the rest of Mammoth only receives CDC-core domain objects. Transport
9
+ # resiliency remains owned by pgoutput-client; Mammoth owns delivery.
10
+ class PgoutputSource
11
+ # @return [Mammoth::Configuration] loaded Mammoth configuration
12
+ attr_reader :config
13
+ # @return [Object, nil] pgoutput-client compatible runner
14
+ attr_reader :runner
15
+ # @return [Object, nil] pgoutput-parser compatible parser
16
+ attr_reader :parser
17
+ # @return [Object, nil] pgoutput-decoder compatible decoder
18
+ attr_reader :decoder
19
+ # @return [Object, nil] CDC source adapter
20
+ attr_reader :source_adapter
21
+
22
+ # Build the pgoutput integration source.
23
+ #
24
+ # @param config [Mammoth::Configuration] loaded configuration
25
+ # @param runner [Object, nil] injectable pgoutput-client runner
26
+ # @param parser [Object, nil] injectable pgoutput parser
27
+ # @param decoder [Object, nil] injectable pgoutput decoder
28
+ # @param source_adapter [Object, nil] injectable CDC source adapter
29
+ def initialize(config, runner: nil, parser: nil, decoder: nil, source_adapter: nil)
30
+ @config = config
31
+ @runner = runner
32
+ @parser = parser
33
+ @decoder = decoder
34
+ @source_adapter = source_adapter
35
+ end
36
+
37
+ # Stream CDC-core objects from PostgreSQL.
38
+ #
39
+ # @yieldparam work [Object] CDC::Core::ChangeEvent or TransactionEnvelope
40
+ # @return [void]
41
+ # @raise [Mammoth::ReplicationError] when required CDC components are unavailable
42
+ def each
43
+ return enum_for(:each) unless block_given?
44
+
45
+ effective_runner.start do |payload, metadata|
46
+ normalized_items(payload, metadata).each { |item| yield item }
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def normalized_items(payload, metadata)
53
+ decoded = effective_decoder ? invoke_component(effective_decoder, parsed_payload(payload), metadata) : parsed_payload(payload)
54
+ normalized = invoke_source_adapter(decoded, metadata)
55
+ Array(normalized).flatten
56
+ end
57
+
58
+ def parsed_payload(payload)
59
+ return payload unless effective_parser
60
+
61
+ invoke_component(effective_parser, payload)
62
+ end
63
+
64
+ def invoke_source_adapter(decoded, metadata)
65
+ adapter = effective_source_adapter
66
+ if adapter.respond_to?(:normalize)
67
+ adapter.normalize(decoded)
68
+ elsif adapter.respond_to?(:call)
69
+ adapter.call(decoded, metadata)
70
+ else
71
+ raise ReplicationError, "pgoutput source adapter must respond to #normalize or #call"
72
+ end
73
+ end
74
+
75
+ def invoke_component(component, *args)
76
+ if component.respond_to?(:call)
77
+ component.call(*args)
78
+ elsif component.respond_to?(:parse)
79
+ component.parse(*args)
80
+ elsif component.respond_to?(:decode)
81
+ component.decode(*args)
82
+ else
83
+ raise ReplicationError, "#{component.class} must respond to #call, #parse, or #decode"
84
+ end
85
+ end
86
+
87
+ def effective_runner
88
+ @runner ||= build_runner
89
+ end
90
+
91
+ def effective_parser
92
+ @parser ||= build_parser
93
+ end
94
+
95
+ def effective_decoder
96
+ @decoder ||= build_decoder
97
+ end
98
+
99
+ def effective_source_adapter
100
+ @source_adapter ||= build_source_adapter
101
+ end
102
+
103
+ def build_runner
104
+ require_optional!("pgoutput_client", "pgoutput-client")
105
+ Pgoutput::Client::Runner.new(
106
+ database_url: database_url,
107
+ slot_name: config.dig("replication", "slot"),
108
+ publication_names: [config.dig("replication", "publication")],
109
+ start_lsn: config.dig("replication", "start_lsn"),
110
+ auto_create_slot: config.dig("replication", "auto_create_slot") || false
111
+ )
112
+ end
113
+
114
+ def build_parser
115
+ require_any!(["pgoutput_parser", "pgoutput/parser"], "pgoutput-parser")
116
+ constant_or_nil("Pgoutput::Parser") || constant_or_nil("Pgoutput::Parser::Parser")
117
+ end
118
+
119
+ def build_decoder
120
+ require_any!(["pgoutput_decoder", "pgoutput/decoder"], "pgoutput-decoder")
121
+ constant_or_nil("Pgoutput::Decoder") || constant_or_nil("Pgoutput::Decoder::ValueDecoder")
122
+ end
123
+
124
+ def build_source_adapter
125
+ require_optional!("cdc_core", "cdc-core")
126
+ require_any!(["pgoutput_source_adapter", "pgoutput/source_adapter/cdc"], "pgoutput-source-adapter")
127
+
128
+ adapter_class = constant_or_nil("Pgoutput::SourceAdapter::Cdc")
129
+ raise ReplicationError, "Pgoutput::SourceAdapter::Cdc is unavailable" unless adapter_class
130
+
131
+ adapter_class.new
132
+ end
133
+
134
+ def database_url
135
+ password = ENV.fetch(config.dig("postgres", "password_env"), "")
136
+ user = config.dig("postgres", "username")
137
+ host = config.dig("postgres", "host")
138
+ port = config.dig("postgres", "port")
139
+ database = config.dig("postgres", "database")
140
+ "postgres://#{user}:#{password}@#{host}:#{port}/#{database}"
141
+ end
142
+
143
+ def require_optional!(feature, gem_name)
144
+ require feature
145
+ rescue LoadError => e
146
+ raise ReplicationError, "#{gem_name} is required for live pgoutput replication: #{e.message}"
147
+ end
148
+
149
+ def require_any!(features, gem_name)
150
+ errors = []
151
+ features.each do |feature|
152
+ require feature
153
+ return true
154
+ rescue LoadError => e
155
+ errors << e.message
156
+ end
157
+ raise ReplicationError, "#{gem_name} is required for live pgoutput replication: #{errors.join("; ")}"
158
+ end
159
+
160
+ def constant_or_nil(name)
161
+ name.split("::").reduce(Object) { |scope, const_name| scope.const_get(const_name, false) }
162
+ rescue NameError
163
+ nil
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mammoth
4
+ # Consumes CDC-core work items from Mammoth's configured replication source.
5
+ #
6
+ # ReplicationConsumer is the boundary between upstream CDC ingestion and
7
+ # sink delivery. Live PostgreSQL ingestion is delegated to {PgoutputSource};
8
+ # injected sources remain available for unit tests, demos, and e2e fixtures.
9
+ class ReplicationConsumer
10
+ attr_reader :config, :source, :adapter
11
+
12
+ # @param config [Mammoth::Configuration] loaded configuration
13
+ # @param source [#each, nil] injectable CDC work stream
14
+ # @param adapter [#call, nil] optional adapter for injected raw events
15
+ def initialize(config, source: nil, adapter: nil)
16
+ @config = config
17
+ @source = source
18
+ @adapter = adapter
19
+ end
20
+
21
+ # Return the configured replication slot.
22
+ #
23
+ # @return [String]
24
+ def slot
25
+ config.dig("replication", "slot")
26
+ end
27
+
28
+ # Return the configured publication.
29
+ #
30
+ # @return [String]
31
+ def publication
32
+ config.dig("replication", "publication")
33
+ end
34
+
35
+ # Consume normalized CDC work from the configured source.
36
+ #
37
+ # @yieldparam event [Object] CDC::Core::ChangeEvent-compatible event
38
+ # @return [Integer] number of consumed events
39
+ def start
40
+ return enum_for(:start) unless block_given?
41
+
42
+ count = 0
43
+ each_event do |event|
44
+ yield event
45
+ count += 1
46
+ end
47
+ count
48
+ end
49
+
50
+ private
51
+
52
+ def each_event
53
+ effective_source.each do |raw_work|
54
+ normalize(raw_work).each { |event| yield event }
55
+ end
56
+ end
57
+
58
+ def effective_source
59
+ source || PgoutputSource.new(config)
60
+ end
61
+
62
+ def normalize(raw_work)
63
+ adapted = adapter ? adapter.call(raw_work) : raw_work
64
+ flatten_cdc_work(adapted)
65
+ end
66
+
67
+ def flatten_cdc_work(work)
68
+ if transaction_envelope?(work)
69
+ work.events
70
+ else
71
+ Array(work).flat_map { |item| transaction_envelope?(item) ? item.events : item }
72
+ end
73
+ end
74
+
75
+ def transaction_envelope?(work)
76
+ work.respond_to?(:events) && work.respond_to?(:transaction_id)
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,55 @@
1
+ CREATE TABLE IF NOT EXISTS schema_migrations (
2
+ version TEXT PRIMARY KEY,
3
+ applied_at TEXT NOT NULL
4
+ );
5
+
6
+ CREATE TABLE IF NOT EXISTS checkpoints (
7
+ id INTEGER PRIMARY KEY,
8
+ source_name TEXT NOT NULL,
9
+ slot_name TEXT NOT NULL,
10
+ publication_name TEXT NOT NULL,
11
+ last_lsn TEXT,
12
+ updated_at TEXT NOT NULL,
13
+
14
+ UNIQUE (source_name, slot_name)
15
+ );
16
+
17
+ CREATE TABLE IF NOT EXISTS dead_letters (
18
+ id INTEGER PRIMARY KEY,
19
+ event_id TEXT NOT NULL,
20
+ source_name TEXT NOT NULL,
21
+ destination_name TEXT NOT NULL,
22
+ operation TEXT NOT NULL,
23
+
24
+ namespace TEXT,
25
+ entity TEXT,
26
+ source_position TEXT,
27
+
28
+ payload_json TEXT NOT NULL,
29
+
30
+ error_class TEXT,
31
+ error_message TEXT,
32
+ retry_count INTEGER NOT NULL DEFAULT 0,
33
+
34
+ status TEXT NOT NULL DEFAULT 'pending',
35
+ failed_at TEXT NOT NULL,
36
+ updated_at TEXT NOT NULL,
37
+
38
+ CHECK (status IN ('pending', 'resolved', 'ignored')),
39
+ CHECK (retry_count >= 0)
40
+ );
41
+
42
+ CREATE INDEX IF NOT EXISTS idx_dead_letters_status
43
+ ON dead_letters(status);
44
+
45
+ CREATE INDEX IF NOT EXISTS idx_dead_letters_destination
46
+ ON dead_letters(destination_name);
47
+
48
+ CREATE INDEX IF NOT EXISTS idx_dead_letters_source_position
49
+ ON dead_letters(source_position);
50
+
51
+ CREATE INDEX IF NOT EXISTS idx_dead_letters_entity
52
+ ON dead_letters(namespace, entity);
53
+
54
+ CREATE INDEX IF NOT EXISTS idx_dead_letters_failed_at
55
+ ON dead_letters(failed_at);
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "sqlite3"
5
+ require "time"
6
+
7
+ module Mammoth
8
+ # Owns Mammoth's local SQLite operational database.
9
+ #
10
+ # The SQLite database is Mammoth's operational memory. It stores only boring,
11
+ # inspectable state required for reliability: schema versions, checkpoints,
12
+ # and dead letters.
13
+ class SQLiteStore
14
+ DEFAULT_DB_PATH = File.expand_path("../../.sqlite3/mammoth.db", __dir__)
15
+ MIGRATION_DIR = File.expand_path("sql", __dir__)
16
+ BOOTSTRAP_FILE = "__bootstrap__.sql"
17
+ BOOTSTRAP_VERSION = "__bootstrap__"
18
+ MIGRATIONS_TABLE = "schema_migrations"
19
+
20
+ attr_reader :path
21
+
22
+ # Open and return a connected store.
23
+ #
24
+ # @param path [String] SQLite database path
25
+ # @return [Mammoth::SQLiteStore]
26
+ def self.connect(path = DEFAULT_DB_PATH)
27
+ new(path).connect
28
+ end
29
+
30
+ # @param path [String] SQLite database path
31
+ def initialize(path = DEFAULT_DB_PATH)
32
+ @path = path
33
+ @database = nil
34
+ end
35
+
36
+ # Open the SQLite database and configure conservative operational pragmas.
37
+ #
38
+ # @return [Mammoth::SQLiteStore] self
39
+ def connect
40
+ FileUtils.mkdir_p(File.dirname(path))
41
+ @database = SQLite3::Database.new(path)
42
+ @database.results_as_hash = true
43
+ execute_pragma("journal_mode", "WAL")
44
+ execute_pragma("foreign_keys", "ON")
45
+ execute_pragma("busy_timeout", 5000)
46
+ self
47
+ rescue SQLite3::Exception => e
48
+ raise StoreError, "failed to open SQLite database #{path}: #{e.message}"
49
+ end
50
+
51
+ # @return [SQLite3::Database] connected database
52
+ def database
53
+ @database || connect.database
54
+ end
55
+
56
+ # Apply the initial schema if it has not yet been applied.
57
+ #
58
+ # @return [Mammoth::SQLiteStore] self
59
+ def bootstrap!
60
+ return self if bootstrapped?
61
+
62
+ apply_migration!(BOOTSTRAP_FILE, BOOTSTRAP_VERSION)
63
+ self
64
+ end
65
+
66
+ # Apply a migration file unless its version already exists.
67
+ #
68
+ # @param sql_file [String] SQL file name under lib/mammoth/sql
69
+ # @return [Mammoth::SQLiteStore] self
70
+ def migrate!(sql_file)
71
+ bootstrap!
72
+ version_name = File.basename(sql_file, ".sql")
73
+ return self if version_exists?(version_name)
74
+
75
+ apply_migration!(sql_file, version_name)
76
+ self
77
+ end
78
+
79
+ # @return [Boolean] true when the initial schema is already applied
80
+ def bootstrapped?
81
+ table_exists?(MIGRATIONS_TABLE) && version_exists?(BOOTSTRAP_VERSION)
82
+ end
83
+
84
+ # @param table_name [String] table name
85
+ # @return [Boolean] true when a table exists
86
+ def table_exists?(table_name)
87
+ !database.execute(
88
+ "SELECT 1 FROM sqlite_schema WHERE type = ? AND name = ? LIMIT 1",
89
+ ["table", table_name]
90
+ ).empty?
91
+ end
92
+
93
+ # @param version_name [String] migration version
94
+ # @return [Boolean] true when a migration version exists
95
+ def version_exists?(version_name)
96
+ return false unless table_exists?(MIGRATIONS_TABLE)
97
+
98
+ !database.execute(
99
+ "SELECT 1 FROM schema_migrations WHERE version = ? LIMIT 1",
100
+ [version_name]
101
+ ).empty?
102
+ end
103
+
104
+ # Return table names in the operational database.
105
+ #
106
+ # @return [Array<String>] table names
107
+ def tables
108
+ database.execute(
109
+ "SELECT name FROM sqlite_schema WHERE type = 'table' ORDER BY name"
110
+ ).map { |row| row.fetch("name") }
111
+ end
112
+
113
+ private
114
+
115
+ def apply_migration!(sql_file, version_name)
116
+ sql_path = File.expand_path(sql_file, MIGRATION_DIR)
117
+ raise StoreError, "migration file not found: #{sql_path}" unless File.file?(sql_path)
118
+
119
+ database.transaction do
120
+ database.execute_batch(File.read(sql_path))
121
+ database.execute(
122
+ "INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)",
123
+ [version_name, Time.now.utc.iso8601]
124
+ )
125
+ end
126
+ rescue SQLite3::Exception => e
127
+ raise StoreError, "failed to apply migration #{sql_file}: #{e.message}"
128
+ end
129
+
130
+ def execute_pragma(name, value)
131
+ database.execute("PRAGMA #{name} = #{value}")
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mammoth
4
+ # Builds and prints a boring operational status snapshot.
5
+ class Status
6
+ attr_reader :config, :sqlite_store
7
+
8
+ # Print status for a configuration and optional SQLite store.
9
+ #
10
+ # @param config [Mammoth::Configuration] loaded configuration
11
+ # @param sqlite_store [Mammoth::SQLiteStore, nil] operational store
12
+ # @return [void]
13
+ def self.call(config, sqlite_store: nil)
14
+ new(config, sqlite_store: sqlite_store).call
15
+ end
16
+
17
+ # @param config [Mammoth::Configuration] loaded configuration
18
+ # @param sqlite_store [Mammoth::SQLiteStore, nil] operational store
19
+ def initialize(config, sqlite_store: nil)
20
+ @config = config
21
+ @sqlite_store = sqlite_store
22
+ end
23
+
24
+ # Print the status snapshot.
25
+ #
26
+ # @return [void]
27
+ def call
28
+ puts "Mammoth: #{config.dig("mammoth", "name")}"
29
+ puts "Runtime: not started"
30
+ puts "SQLite: #{sqlite_path}"
31
+ puts "Webhook: #{config.dig("webhook", "name")}"
32
+ print_store_state if sqlite_store
33
+ end
34
+
35
+ private
36
+
37
+ def sqlite_path
38
+ config.dig("sqlite", "path")
39
+ end
40
+
41
+ def print_store_state
42
+ store = sqlite_store.bootstrap!
43
+ puts "Tables: #{store.tables.join(", ")}"
44
+ puts "Checkpoints: #{CheckpointStore.new(store).count}"
45
+ puts "Dead Letters: #{DeadLetterStore.new(store).count}"
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mammoth
4
+ VERSION = "0.0.0"
5
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Mammoth
8
+ # Delivers normalized Mammoth events to a webhook endpoint.
9
+ class WebhookSink
10
+ SUCCESS_RANGE = 200..299
11
+
12
+ attr_reader :name, :url, :timeout_seconds
13
+
14
+ # @param name [String] destination name
15
+ # @param url [String] webhook endpoint URL
16
+ # @param timeout_seconds [Integer] HTTP open/read timeout in seconds
17
+ def initialize(name:, url:, timeout_seconds: 5)
18
+ @name = name
19
+ @url = URI(url)
20
+ @timeout_seconds = timeout_seconds
21
+ end
22
+
23
+ # Build a sink from Mammoth configuration.
24
+ #
25
+ # @param config [Mammoth::Configuration] loaded configuration
26
+ # @return [Mammoth::WebhookSink]
27
+ def self.from_config(config)
28
+ new(
29
+ name: config.dig("webhook", "name"),
30
+ url: config.dig("webhook", "url"),
31
+ timeout_seconds: config.dig("webhook", "timeout_seconds")
32
+ )
33
+ end
34
+
35
+ # Deliver an event to the webhook endpoint.
36
+ #
37
+ # @param event [Hash, #to_h] normalized event
38
+ # @return [Hash] delivery result
39
+ # @raise [Mammoth::DeliveryError] when delivery fails
40
+ def deliver(event)
41
+ payload = EventSerializer.call(event)
42
+ response = perform_request(payload)
43
+ return delivery_result(payload, response) if SUCCESS_RANGE.cover?(response.code.to_i)
44
+
45
+ raise DeliveryError, "webhook #{name} returned HTTP #{response.code}"
46
+ rescue Timeout::Error, SystemCallError, SocketError, JSON::GeneratorError => e
47
+ raise DeliveryError, "webhook #{name} delivery failed: #{e.message}"
48
+ end
49
+
50
+ private
51
+
52
+ def perform_request(payload)
53
+ Net::HTTP.start(url.host, url.port, use_ssl: url.scheme == "https", open_timeout: timeout_seconds,
54
+ read_timeout: timeout_seconds) do |http|
55
+ http.request(build_request(payload))
56
+ end
57
+ end
58
+
59
+ def build_request(payload)
60
+ Net::HTTP::Post.new(url.request_uri).tap do |request|
61
+ request["Content-Type"] = "application/json"
62
+ request.body = JSON.generate(payload)
63
+ end
64
+ end
65
+
66
+ def delivery_result(payload, response)
67
+ {
68
+ event_id: payload.fetch("event_id"),
69
+ destination: name,
70
+ status: "delivered",
71
+ http_status: response.code.to_i
72
+ }
73
+ end
74
+ end
75
+ end
data/lib/mammoth.rb ADDED
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mammoth/version"
4
+ require_relative "mammoth/errors"
5
+ require_relative "mammoth/configuration"
6
+ require_relative "mammoth/status"
7
+ require_relative "mammoth/sqlite_store"
8
+ require_relative "mammoth/checkpoint_store"
9
+ require_relative "mammoth/dead_letter_store"
10
+ require_relative "mammoth/event_serializer"
11
+ require_relative "mammoth/webhook_sink"
12
+ require_relative "mammoth/delivery_worker"
13
+ require_relative "mammoth/pgoutput_source"
14
+ require_relative "mammoth/replication_consumer"
15
+ require_relative "mammoth/application"
16
+ require_relative "mammoth/cli"
17
+
18
+ # Mammoth is a self-hosted PostgreSQL event relay.
19
+ #
20
+ # Mammoth v0.1.0 focuses on a deliberately small, boring product slice:
21
+ # PostgreSQL change events are normalized, persisted through local operational
22
+ # state, and delivered to webhook destinations.
23
+ module Mammoth
24
+ end