cdc-core 0.1.0 → 0.1.1

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: 5ace8b27c4df8285c5354599650cb450cddaad1402c913689e638fb9ec57d8b9
4
- data.tar.gz: 381f77ee16278ea147b083b677d5e9248a2fb6bf1955c40f9d2eab82055a1e49
3
+ metadata.gz: a71182bac774b200c13556d0a887754a2788dc4e3581fc84a94288764900f365
4
+ data.tar.gz: a67bb53791b19347fb0c589b59603abfc6f085504597531d7c9932da3a3d8749
5
5
  SHA512:
6
- metadata.gz: c0447c01564b04f20170e8a7f9e60cb95c12f77ef87ce54ab8a090083b7054a041224247349a9424e803f22255bbe097b33706a16c267ed1710e67fab7e6fff0
7
- data.tar.gz: 9aef7f1a3ffd777c6031e700c49f17a3aa460fa46e898d648d55b81c1a2afaf6df4e19fd700bbadad57be382d0809990909f9a97ac721ea7121fc3e327c868b7
6
+ metadata.gz: a96b523f2144fd94d0ecd633bf956fdcad791a994f3a2bf9e4559da9e4c713570f52c842e3cce15cb9a1491345190087c7cd1599612f5e4ecd5f933d22a6a9d6
7
+ data.tar.gz: 44acef005734403fea47a88e8d142cf8f9667ef032b032af24dfc25cf25c491d1c2a6caf3fab3bb06fc7790d63b8c88a8cb9984e61803dac315cd6d8118a2957
data/CHANGELOG.md CHANGED
@@ -1,15 +1,23 @@
1
- # Changelog
1
+ ## [Unreleased]
2
2
 
3
- All notable changes to this project will be documented in this file.
3
+ ## [0.1.1] - 2026-06-09
4
4
 
5
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
+ ### Added
7
6
 
8
- ## [Unreleased]
7
+ - Added `CDC::Core::OrderingScope`, `OrderingPolicy`, `OrderingKey`, and `EventPosition`.
8
+ - Added `CDC::Core::SourceAdapter` as the shared source normalization contract.
9
+ - Added `CDC::Core::Router` and `UnsupportedWorkItemError`.
10
+ - Added optional lifecycle hooks to `CDC::Core::Processor`.
11
+ - Added structured failure metadata to `CDC::Core::ProcessorResult`.
12
+ - Added status validation and serializable projection helpers to `CDC::Core::ProcessorResult`.
13
+ - Added backend-agnostic observer hooks for instrumentation.
14
+ - Added canonical metric names and tag helpers to `CDC::Core::Observer`.
9
15
 
10
- ### Added
16
+ ### Fixed
11
17
 
12
- - Placeholder for future development.
18
+ - Preserve explicit `false` metadata values when reading `EventMetadata` by symbol or string key.
19
+ - Raise `CDC::Core::InvalidOperationError` for nil operation input instead of leaking `NoMethodError`.
20
+ - Align API documentation examples with the current `#process`, `Pipeline.new(processor:)`, and `Filter#match?` contracts.
13
21
 
14
22
  ---
15
23
 
data/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # cdc-core
2
2
 
3
- Database-agnostic Change Data Capture domain primitives for Ruby.
3
+ Shared Change Data Capture vocabulary for Ruby.
4
4
 
5
- `cdc-core` provides immutable, Ractor-safe event objects and processor contracts for building CDC systems. It intentionally does not connect to databases, parse wire protocols, decode PostgreSQL OIDs, or integrate with Rails.
5
+ `cdc-core` provides immutable, Ractor-safe event objects and processor contracts for building CDC systems. It intentionally does not connect to databases, parse wire protocols, decode PostgreSQL OIDs, run schedulers, or integrate with Rails.
6
6
 
7
7
  ## Requirements
8
8
 
@@ -10,12 +10,16 @@ Database-agnostic Change Data Capture domain primitives for Ruby.
10
10
 
11
11
  ## Features
12
12
 
13
+ - SourceAdapter normalization contract
13
14
  - Immutable `ChangeEvent` objects
14
15
  - Transaction grouping via `TransactionEnvelope`
15
16
  - Column-level change objects
17
+ - Ordering vocabulary
16
18
  - Processor and composite processor contracts
17
19
  - Event filters
18
20
  - Small pipeline orchestration object
21
+ - Router for supported work item shapes
22
+ - Observer hooks and canonical metric names
19
23
  - Ractor-safe event and transaction objects
20
24
  - RBS signatures
21
25
  - YARD-compatible documentation
@@ -24,22 +28,88 @@ Database-agnostic Change Data Capture domain primitives for Ruby.
24
28
  ## Ecosystem Position
25
29
 
26
30
  ```text
27
- pgoutput-client
28
-
29
-
30
- pgoutput-parser
31
-
32
-
33
- pgoutput-decoder
34
-
35
-
31
+ upstream source
32
+ |
33
+ v
34
+ source adapter
35
+ |
36
+ v
36
37
  cdc-core
37
-
38
-
39
- whodunit-chronicles
38
+ |
39
+ +--> cdc-parallel CPU-bound processing
40
+ |
41
+ +--> cdc-concurrent I/O-bound processing
42
+ |
43
+ +--> application sinks / processors
40
44
  ```
41
45
 
42
- `cdc-core` is the shared vocabulary layer. It defines what a change event, transaction, processor, and pipeline result means without caring where the event came from.
46
+ `cdc-core` is the shared vocabulary layer. It defines what a change event, transaction, processor, ordering policy, observer notification, and processor result mean without caring where the event came from or how it will be executed.
47
+
48
+ ## Boundary Summary
49
+
50
+ `cdc-core` is for vocabulary.
51
+
52
+ Runtime gems are for execution.
53
+
54
+ Sinks are for persistence or side effects.
55
+
56
+ ```text
57
+ source adapter -> cdc-core vocabulary -> runtime gem -> sink
58
+ ```
59
+
60
+ ## Source Adapters
61
+
62
+ CDC::Core::SourceAdapter defines the normalization contract used to translate source-specific payloads into cdc-core vocabulary objects.
63
+
64
+ It translates source-specific payloads into:
65
+
66
+ - `CDC::Core::ChangeEvent`
67
+ - `CDC::Core::TransactionEnvelope`
68
+ - batches of core work items
69
+
70
+ The current PostgreSQL-oriented path is:
71
+
72
+ ```text
73
+ pgoutput-client -> pgoutput-parser -> pgoutput-decoder -> source adapter -> cdc-core
74
+ ```
75
+
76
+ The `pgoutput*` family handles PostgreSQL transport, protocol parsing, and type decoding. The source-adapter boundary is where those source-specific details become generic `cdc-core` objects.
77
+
78
+ Other adapters can normalize logs, API payloads, application events, or other database streams into the same vocabulary.
79
+
80
+ ## Downstream Runtime Gems
81
+
82
+ `cdc-parallel` and `cdc-concurrent` are downstream consumers of `cdc-core` events.
83
+
84
+ ### cdc-parallel
85
+
86
+ Use `cdc-parallel` for heavy CPU-bound processing.
87
+
88
+ Examples:
89
+
90
+ - transformations
91
+ - enrichment
92
+ - encoding
93
+ - compression
94
+ - scoring
95
+ - in-memory calculations
96
+
97
+ It is the Ractor-oriented runtime path.
98
+
99
+ ### cdc-concurrent
100
+
101
+ Use `cdc-concurrent` for I/O-heavy processing.
102
+
103
+ Examples:
104
+
105
+ - HTTP calls
106
+ - webhook delivery
107
+ - Redis writes
108
+ - search indexing
109
+ - object storage writes
110
+ - database sink writes
111
+
112
+ It is the fiber-friendly runtime path.
43
113
 
44
114
  ## Installation
45
115
 
@@ -86,7 +156,7 @@ transaction = CDC::Core::TransactionEnvelope.new(
86
156
  )
87
157
  ```
88
158
 
89
- A transaction envelope is the natural unit for future parallel processing because it preserves database transaction boundaries.
159
+ A transaction envelope preserves database transaction boundaries. Runtime gems may use that boundary when they need ordering, batching, or parallel execution decisions.
90
160
 
91
161
  ## Processors
92
162
 
@@ -114,7 +184,7 @@ AnalyticsProcessor.new.ractor_safe?
114
184
  # => true
115
185
  ```
116
186
 
117
- This declares intent only. `cdc-core` does not execute processors in Ractors. A future runtime gem can use this signal.
187
+ This declares intent only. `cdc-core` does not execute processors in Ractors. `cdc-parallel` can use this signal before moving processor work across Ractors.
118
188
 
119
189
  ## Composite Processor
120
190
 
@@ -149,10 +219,26 @@ result = pipeline.process(event)
149
219
  - Parse `pgoutput`
150
220
  - Decode PostgreSQL values
151
221
  - Manage replication slots
222
+ - Implement concrete source adapters
152
223
  - Run Ractor pools
224
+ - Run fiber schedulers
153
225
  - Persist audit records
154
226
  - Integrate with ActiveRecord
155
- - Publish to Kafka, Redis, or HTTP sinks
227
+ - Publish to Kafka, Redis, HTTP, or other sinks
228
+
229
+ ## Documentation
230
+
231
+ The YARD documentation uses `docs/index.md` as its readme and includes the Markdown files under `docs/`.
232
+
233
+ ```text
234
+ --title "cdc-core API Documentation"
235
+ --readme docs/index.md
236
+ --markup markdown
237
+ --output-dir doc
238
+ lib/**/*.rb
239
+ -
240
+ docs/**/*.md
241
+ ```
156
242
 
157
243
  ## Development
158
244
 
@@ -163,4 +249,4 @@ bundle exec steep check
163
249
 
164
250
  ## License
165
251
 
166
- MIT.
252
+ [MIT](./LICENSE.txt).
@@ -10,15 +10,17 @@ module CDC
10
10
  class CompositeProcessor < Processor
11
11
  # @return [Array<Processor>] processors executed for each event
12
12
  # @return [Boolean] whether processing stops on the first failure
13
- attr_reader :processors, :fail_fast
13
+ # @return [Observer] observer notified of dispatch events
14
+ attr_reader :processors, :fail_fast, :observer
14
15
 
15
16
  # Build a composite processor.
16
17
  #
17
18
  # @param processors [Array<#process>] processors to execute
18
19
  # @param fail_fast [Boolean] whether to stop after the first failure
19
- def initialize(processors, fail_fast: true) # rubocop:disable Lint/MissingSuper
20
+ def initialize(processors, fail_fast: true, observer: NullObserver::INSTANCE) # rubocop:disable Lint/MissingSuper
20
21
  @processors = processors.freeze
21
22
  @fail_fast = fail_fast
23
+ @observer = observer || NullObserver::INSTANCE
22
24
  end
23
25
 
24
26
  # Process an event through each configured processor.
@@ -26,18 +28,12 @@ module CDC
26
28
  # @param event [ChangeEvent] event to process
27
29
  # @return [Array<ProcessorResult>] result from each attempted processor
28
30
  def process(event)
29
- results = [] # : Array[ProcessorResult]
30
- processors.each do |processor|
31
- result = normalize_result(processor.process(event), event)
32
- results << result
33
- break if fail_fast && result.failure?
34
- rescue StandardError => e
35
- result = ProcessorResult.failure(e, event:)
36
- results << result
37
- break if fail_fast
38
- end
39
- Ractor.make_shareable(results.freeze) if results.none?(&:failure?)
40
- results.freeze
31
+ observer.dispatch_started(event)
32
+ results = collect_results(event)
33
+ final_results = results.freeze
34
+ observe_results(final_results)
35
+ Ractor.make_shareable(final_results) if results.none?(&:failure?)
36
+ final_results
41
37
  end
42
38
 
43
39
  # Processors that declared Ractor safety.
@@ -66,6 +62,35 @@ module CDC
66
62
 
67
63
  result ? ProcessorResult.success(event) : ProcessorResult.skipped(event)
68
64
  end
65
+
66
+ def collect_results(event)
67
+ results = [] # : Array[ProcessorResult]
68
+ processors.each do |processor|
69
+ result = process_with(processor, event)
70
+ results << result
71
+ break if fail_fast && result.failure?
72
+ end
73
+ results
74
+ end
75
+
76
+ def process_with(processor, event)
77
+ normalize_result(processor.process(event), event)
78
+ rescue StandardError => e
79
+ ProcessorResult.failure(e, event:, processor: processor.class.name)
80
+ end
81
+
82
+ def observe_results(results)
83
+ results.each do |result|
84
+ case result.status
85
+ when :success
86
+ observer.dispatch_succeeded(result)
87
+ when :failure
88
+ observer.dispatch_failed(result)
89
+ when :skipped
90
+ observer.dispatch_skipped(result)
91
+ end
92
+ end
93
+ end
69
94
  end
70
95
  end
71
96
  end
@@ -8,6 +8,15 @@ module CDC
8
8
  # Raised when an operation cannot be normalized to a supported CDC action.
9
9
  class InvalidOperationError < Error; end
10
10
 
11
+ # Raised when an ordering scope cannot be normalized to a supported value.
12
+ class InvalidOrderingScopeError < Error; end
13
+
14
+ # Raised when an ordering position cannot be normalized to a supported value.
15
+ class InvalidOrderingPositionError < Error; end
16
+
17
+ # Raised when a router receives an unsupported CDC work item.
18
+ class UnsupportedWorkItemError < Error; end
19
+
11
20
  # Raised by processors when a processor-specific failure needs wrapping.
12
21
  class ProcessorError < Error; end
13
22
  end
@@ -24,7 +24,10 @@ module CDC
24
24
  # @param key [String, Symbol] metadata key
25
25
  # @return [Object, nil]
26
26
  def [](key)
27
- data[key] || data[key.to_s] || data[key.to_sym]
27
+ string_key = key.to_s
28
+ return data[string_key] if data.key?(string_key)
29
+
30
+ data[key]
28
31
  end
29
32
 
30
33
  # Return the normalized Ractor-shareable hash.
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CDC
4
+ module Core
5
+ # Immutable representation of an event's position metadata.
6
+ #
7
+ # EventPosition is intentionally small and transport-agnostic. It captures
8
+ # the position strategy plus the event fields that a runtime may use to
9
+ # preserve ordering guarantees.
10
+ class EventPosition
11
+ # @return [Symbol] position strategy
12
+ # @return [Object, nil] primary position value for the chosen strategy
13
+ # @return [Object, nil] transaction identifier associated with the event
14
+ # @return [Integer, nil] sequence number within a transaction or stream
15
+ # @return [Time, nil] timestamp associated with the event
16
+ attr_reader :strategy, :value, :transaction_id, :sequence_number, :occurred_at
17
+
18
+ # Build an event position.
19
+ #
20
+ # @param strategy [#to_sym] position strategy
21
+ # @param value [Object, nil] primary position value
22
+ # @param transaction_id [Object, nil] transaction identifier
23
+ # @param sequence_number [Integer, nil] sequence number
24
+ # @param occurred_at [Time, nil] event timestamp
25
+ def initialize(strategy:, value:, transaction_id: nil, sequence_number: nil, occurred_at: nil)
26
+ @strategy = strategy.to_sym
27
+ @value = value
28
+ @transaction_id = transaction_id
29
+ @sequence_number = sequence_number
30
+ @occurred_at = occurred_at
31
+ Ractor.make_shareable(self)
32
+ end
33
+
34
+ # Convert the position into a Ractor-shareable hash.
35
+ #
36
+ # @return [Hash{String=>Object,nil}]
37
+ def to_h
38
+ Ractor.make_shareable({
39
+ 'strategy' => strategy,
40
+ 'value' => value,
41
+ 'transaction_id' => transaction_id,
42
+ 'sequence_number' => sequence_number,
43
+ 'occurred_at' => occurred_at
44
+ }.freeze)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CDC
4
+ module Core
5
+ # No-op observer for callers that do not need instrumentation.
6
+ class NullObserver < Observer
7
+ INSTANCE = new
8
+
9
+ private_class_method :new
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CDC
4
+ module Core
5
+ # Observer interface for CDC runtime instrumentation.
6
+ #
7
+ # Observers receive lifecycle and result notifications from core runtime
8
+ # objects. The default implementation is a no-op so callers can opt in
9
+ # without taking a dependency on a metrics backend.
10
+ class Observer
11
+ # Canonical metric names emitted by core runtime hooks.
12
+ METRIC_NAMES = Ractor.make_shareable({
13
+ dispatch_started: 'cdc_core.dispatch.started',
14
+ dispatch_succeeded: 'cdc_core.dispatch.succeeded',
15
+ dispatch_failed: 'cdc_core.dispatch.failed',
16
+ dispatch_skipped: 'cdc_core.dispatch.skipped'
17
+ }.freeze)
18
+
19
+ # Build a canonical metric tag set for a CDC work item or result.
20
+ #
21
+ # @param payload [ChangeEvent, TransactionEnvelope, ProcessorResult, Array]
22
+ # @return [Hash{String=>Object}]
23
+ def self.metric_tags(payload)
24
+ tags = {} # : Hash[String, untyped]
25
+ case payload
26
+ when ChangeEvent
27
+ tags.merge!(change_event_metric_tags(payload))
28
+ when TransactionEnvelope
29
+ tags.merge!(transaction_envelope_metric_tags(payload))
30
+ when ProcessorResult
31
+ tags.merge!(processor_result_metric_tags(payload))
32
+ when Array
33
+ tags.merge!(batch_metric_tags(payload))
34
+ else
35
+ tags['kind'] = payload.class.name
36
+ end
37
+
38
+ Ractor.make_shareable(tags.freeze)
39
+ end
40
+
41
+ # Canonical metric name for the start hook.
42
+ #
43
+ # @return [String]
44
+ def self.started_metric_name = METRIC_NAMES.fetch(:dispatch_started)
45
+
46
+ # Canonical metric name for the success hook.
47
+ #
48
+ # @return [String]
49
+ def self.succeeded_metric_name = METRIC_NAMES.fetch(:dispatch_succeeded)
50
+
51
+ # Canonical metric name for the failure hook.
52
+ #
53
+ # @return [String]
54
+ def self.failed_metric_name = METRIC_NAMES.fetch(:dispatch_failed)
55
+
56
+ # Canonical metric name for the skip hook.
57
+ #
58
+ # @return [String]
59
+ def self.skipped_metric_name = METRIC_NAMES.fetch(:dispatch_skipped)
60
+
61
+ # Called before a work item is dispatched.
62
+ #
63
+ # @param _event [ChangeEvent, TransactionEnvelope, Array]
64
+ # @return [void]
65
+ def dispatch_started(_event); end
66
+
67
+ # Called after a work item is processed successfully.
68
+ #
69
+ # @param _result [ProcessorResult, Array<ProcessorResult>]
70
+ # @return [void]
71
+ def dispatch_succeeded(_result); end
72
+
73
+ # Called after a work item fails.
74
+ #
75
+ # @param _result [ProcessorResult]
76
+ # @return [void]
77
+ def dispatch_failed(_result); end
78
+
79
+ # Called when a work item is filtered or skipped.
80
+ #
81
+ # @param _result [ProcessorResult]
82
+ # @return [void]
83
+ def dispatch_skipped(_result); end
84
+
85
+ private_class_method def self.change_event_metric_tags(event)
86
+ {
87
+ 'kind' => 'change_event',
88
+ 'operation' => event.operation,
89
+ 'schema' => event.schema,
90
+ 'table' => event.table
91
+ }.tap do |tags|
92
+ tags['transaction_id'] = event.transaction_id if event.transaction_id
93
+ end
94
+ end
95
+
96
+ private_class_method def self.transaction_envelope_metric_tags(transaction)
97
+ {
98
+ 'kind' => 'transaction_envelope',
99
+ 'transaction_id' => transaction.transaction_id,
100
+ 'size' => transaction.size
101
+ }
102
+ end
103
+
104
+ private_class_method def self.processor_result_metric_tags(result)
105
+ {
106
+ 'kind' => 'processor_result',
107
+ 'status' => result.status,
108
+ 'retryable' => result.retryable?
109
+ }.tap do |tags|
110
+ tags['processor'] = result.processor_name if result.processor_name
111
+ tags['failure_reason'] = result.failure_reason if result.failure?
112
+ end
113
+ end
114
+
115
+ private_class_method def self.batch_metric_tags(batch)
116
+ {
117
+ 'kind' => 'batch',
118
+ 'size' => batch.size
119
+ }
120
+ end
121
+ end
122
+ end
123
+ end
@@ -34,6 +34,8 @@ module CDC
34
34
  value = operation.to_sym
35
35
  return value if SUPPORTED.include?(value)
36
36
 
37
+ raise InvalidOperationError, "unsupported CDC operation: #{operation.inspect}"
38
+ rescue NoMethodError
37
39
  raise InvalidOperationError, "unsupported CDC operation: #{operation.inspect}"
38
40
  end
39
41
 
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CDC
4
+ module Core
5
+ # Immutable grouping key for ordering-related dispatch.
6
+ #
7
+ # OrderingKey captures the scope plus the components that define a
8
+ # particular ordered lane. It does not choose an execution strategy.
9
+ class OrderingKey
10
+ # @return [Symbol] ordering scope
11
+ # @return [Hash{String=>Object}] normalized key components
12
+ attr_reader :scope, :components
13
+
14
+ # Build an ordering key.
15
+ #
16
+ # @param scope [#to_sym] ordering scope
17
+ # @param components [Hash] key components
18
+ def initialize(scope:, components: {})
19
+ @scope = OrderingScope.normalize(scope)
20
+ @components = EventMetadata.new(components).to_h
21
+ Ractor.make_shareable(self)
22
+ end
23
+
24
+ # Whether the key has no components.
25
+ #
26
+ # @return [Boolean]
27
+ def empty? = components.empty?
28
+
29
+ # Convert the key into a Ractor-shareable hash.
30
+ #
31
+ # @return [Hash{String=>Object}]
32
+ def to_h
33
+ Ractor.make_shareable({
34
+ 'scope' => scope,
35
+ 'components' => components
36
+ }.freeze)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CDC
4
+ module Core
5
+ # Immutable description of how ordered CDC work should be grouped.
6
+ #
7
+ # OrderingPolicy defines the vocabulary for ordering guarantees without
8
+ # performing any scheduling itself. Runtime layers can consume the policy to
9
+ # route events into ordered lanes.
10
+ class OrderingPolicy
11
+ # Position strategies supported by the core contract.
12
+ SUPPORTED_POSITIONS = Ractor.make_shareable(
13
+ %i[commit_lsn transaction_id sequence_number occurred_at].freeze
14
+ )
15
+
16
+ # @return [Symbol] ordering scope
17
+ # @return [Symbol] position strategy
18
+ # @return [Boolean] whether transaction boundaries should be preserved
19
+ attr_reader :scope, :position, :transaction_aware
20
+
21
+ # Build an ordering policy.
22
+ #
23
+ # @param scope [#to_sym] ordering scope
24
+ # @param position [#to_sym] position strategy
25
+ # @param transaction_aware [Boolean] whether transaction boundaries matter
26
+ def initialize(scope:, position: :commit_lsn, transaction_aware: true)
27
+ @scope = OrderingScope.normalize(scope)
28
+ @position = normalize_position(position)
29
+ @transaction_aware = transaction_aware == true
30
+ Ractor.make_shareable(self)
31
+ end
32
+
33
+ # Whether transaction boundaries should be preserved.
34
+ #
35
+ # @return [Boolean]
36
+ def transaction_aware? = transaction_aware
37
+
38
+ # Derive an ordering key for an event.
39
+ #
40
+ # @param event [ChangeEvent] event to classify
41
+ # @return [OrderingKey, nil]
42
+ def key_for(event)
43
+ return nil if scope == OrderingScope::NONE
44
+
45
+ OrderingKey.new(scope: scope, components: key_components(event))
46
+ end
47
+
48
+ # Derive an event position for an event.
49
+ #
50
+ # @param event [ChangeEvent] event to classify
51
+ # @return [EventPosition]
52
+ def position_for(event)
53
+ EventPosition.new(
54
+ strategy: position,
55
+ value: event.public_send(position),
56
+ transaction_id: event.transaction_id,
57
+ sequence_number: event.sequence_number,
58
+ occurred_at: event.occurred_at
59
+ )
60
+ end
61
+
62
+ # Convert the policy into a Ractor-shareable hash.
63
+ #
64
+ # @return [Hash{String=>Object}]
65
+ def to_h
66
+ Ractor.make_shareable({
67
+ 'scope' => scope,
68
+ 'position' => position,
69
+ 'transaction_aware' => transaction_aware
70
+ }.freeze)
71
+ end
72
+
73
+ private
74
+
75
+ # Normalize the position strategy.
76
+ #
77
+ # @param position [#to_sym] position strategy
78
+ # @return [Symbol]
79
+ def normalize_position(position)
80
+ value = position.to_sym
81
+ return value if SUPPORTED_POSITIONS.include?(value)
82
+
83
+ raise InvalidOrderingPositionError, "unsupported CDC ordering position: #{position.inspect}"
84
+ rescue NoMethodError
85
+ raise InvalidOrderingPositionError, "unsupported CDC ordering position: #{position.inspect}"
86
+ end
87
+
88
+ # Build the components for the current scope.
89
+ #
90
+ # @param event [ChangeEvent]
91
+ # @return [Hash]
92
+ def key_components(event)
93
+ case scope
94
+ when OrderingScope::GLOBAL
95
+ {}
96
+ when OrderingScope::TRANSACTION
97
+ { transaction_id: event.transaction_id }
98
+ when OrderingScope::RELATION
99
+ { schema: event.schema, table: event.table }
100
+ when OrderingScope::PRIMARY_KEY
101
+ { schema: event.schema, table: event.table, primary_key: event.primary_key }
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end