wal 0.0.0 → 0.0.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.
Files changed (119) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +1 -0
  3. data/README.md +49 -0
  4. data/Rakefile +9 -1
  5. data/exe/wal +64 -0
  6. data/lib/wal/active_record_context_extension.rb +15 -0
  7. data/lib/wal/noop_watcher.rb +12 -0
  8. data/lib/wal/record_watcher.rb +389 -0
  9. data/lib/wal/replicator.rb +205 -0
  10. data/lib/wal/streaming_watcher.rb +74 -0
  11. data/lib/wal/version.rb +2 -1
  12. data/lib/wal/watcher.rb +95 -0
  13. data/lib/wal.rb +128 -1
  14. data/rbi/wal.rbi +295 -0
  15. data/sig/wal.rbs +184 -2
  16. data/sorbet/config +7 -0
  17. data/sorbet/rbi/annotations/.gitattributes +1 -0
  18. data/sorbet/rbi/annotations/activemodel.rbi +89 -0
  19. data/sorbet/rbi/annotations/activerecord.rbi +98 -0
  20. data/sorbet/rbi/annotations/activesupport.rbi +463 -0
  21. data/sorbet/rbi/annotations/minitest.rbi +119 -0
  22. data/sorbet/rbi/annotations/rainbow.rbi +269 -0
  23. data/sorbet/rbi/gems/.gitattributes +1 -0
  24. data/sorbet/rbi/gems/actioncable@8.0.2.rbi +9 -0
  25. data/sorbet/rbi/gems/actionmailbox@8.0.2.rbi +9 -0
  26. data/sorbet/rbi/gems/actionmailer@8.0.2.rbi +9 -0
  27. data/sorbet/rbi/gems/actionpack@8.0.2.rbi +21122 -0
  28. data/sorbet/rbi/gems/actiontext@8.0.2.rbi +9 -0
  29. data/sorbet/rbi/gems/actionview@8.0.2.rbi +16423 -0
  30. data/sorbet/rbi/gems/activejob@8.0.2.rbi +9 -0
  31. data/sorbet/rbi/gems/activemodel@8.0.2.rbi +6866 -0
  32. data/sorbet/rbi/gems/activerecord@8.0.2.rbi +43227 -0
  33. data/sorbet/rbi/gems/activestorage@8.0.2.rbi +9 -0
  34. data/sorbet/rbi/gems/activesupport@8.0.2.rbi +21110 -0
  35. data/sorbet/rbi/gems/ast@2.4.3.rbi +585 -0
  36. data/sorbet/rbi/gems/base64@0.3.0.rbi +545 -0
  37. data/sorbet/rbi/gems/benchmark@0.4.1.rbi +619 -0
  38. data/sorbet/rbi/gems/bigdecimal@3.2.2.rbi +78 -0
  39. data/sorbet/rbi/gems/builder@3.3.0.rbi +9 -0
  40. data/sorbet/rbi/gems/commander@5.0.0.rbi +9 -0
  41. data/sorbet/rbi/gems/concurrent-ruby@1.3.5.rbi +11657 -0
  42. data/sorbet/rbi/gems/connection_pool@2.5.3.rbi +9 -0
  43. data/sorbet/rbi/gems/crass@1.0.6.rbi +623 -0
  44. data/sorbet/rbi/gems/date@3.4.1.rbi +75 -0
  45. data/sorbet/rbi/gems/diff-lcs@1.6.2.rbi +1134 -0
  46. data/sorbet/rbi/gems/docker-api@2.4.0.rbi +1719 -0
  47. data/sorbet/rbi/gems/docopt@0.6.1.rbi +9 -0
  48. data/sorbet/rbi/gems/drb@2.2.3.rbi +1661 -0
  49. data/sorbet/rbi/gems/erubi@1.13.1.rbi +155 -0
  50. data/sorbet/rbi/gems/excon@1.2.7.rbi +1514 -0
  51. data/sorbet/rbi/gems/globalid@1.2.1.rbi +9 -0
  52. data/sorbet/rbi/gems/highline@3.0.1.rbi +9 -0
  53. data/sorbet/rbi/gems/i18n@1.14.7.rbi +2359 -0
  54. data/sorbet/rbi/gems/io-console@0.8.0.rbi +9 -0
  55. data/sorbet/rbi/gems/logger@1.7.0.rbi +963 -0
  56. data/sorbet/rbi/gems/loofah@2.24.1.rbi +1105 -0
  57. data/sorbet/rbi/gems/mail@2.8.1.rbi +9 -0
  58. data/sorbet/rbi/gems/marcel@1.0.4.rbi +9 -0
  59. data/sorbet/rbi/gems/mini_mime@1.1.5.rbi +9 -0
  60. data/sorbet/rbi/gems/minitest@5.25.5.rbi +1704 -0
  61. data/sorbet/rbi/gems/multi_json@1.15.0.rbi +268 -0
  62. data/sorbet/rbi/gems/net-imap@0.5.9.rbi +9 -0
  63. data/sorbet/rbi/gems/net-pop@0.1.2.rbi +9 -0
  64. data/sorbet/rbi/gems/net-protocol@0.2.2.rbi +292 -0
  65. data/sorbet/rbi/gems/net-smtp@0.5.1.rbi +9 -0
  66. data/sorbet/rbi/gems/netrc@0.11.0.rbi +159 -0
  67. data/sorbet/rbi/gems/nio4r@2.7.4.rbi +9 -0
  68. data/sorbet/rbi/gems/nokogiri@1.18.8.rbi +8206 -0
  69. data/sorbet/rbi/gems/ostruct@0.6.2.rbi +354 -0
  70. data/sorbet/rbi/gems/parallel@1.27.0.rbi +291 -0
  71. data/sorbet/rbi/gems/parlour@9.1.1.rbi +3071 -0
  72. data/sorbet/rbi/gems/parser@3.3.8.0.rbi +7338 -0
  73. data/sorbet/rbi/gems/pg-replication-protocol@0.0.7.rbi +633 -0
  74. data/sorbet/rbi/gems/pg@1.5.9.rbi +2806 -0
  75. data/sorbet/rbi/gems/pp@0.6.2.rbi +368 -0
  76. data/sorbet/rbi/gems/prettyprint@0.2.0.rbi +477 -0
  77. data/sorbet/rbi/gems/prism@1.4.0.rbi +41732 -0
  78. data/sorbet/rbi/gems/psych@5.2.3.rbi +2435 -0
  79. data/sorbet/rbi/gems/racc@1.8.1.rbi +160 -0
  80. data/sorbet/rbi/gems/rack-session@2.1.1.rbi +727 -0
  81. data/sorbet/rbi/gems/rack-test@2.2.0.rbi +734 -0
  82. data/sorbet/rbi/gems/rack@3.1.16.rbi +4940 -0
  83. data/sorbet/rbi/gems/rackup@2.2.1.rbi +230 -0
  84. data/sorbet/rbi/gems/rails-dom-testing@2.3.0.rbi +858 -0
  85. data/sorbet/rbi/gems/rails-html-sanitizer@1.6.2.rbi +785 -0
  86. data/sorbet/rbi/gems/rails@8.0.2.rbi +9 -0
  87. data/sorbet/rbi/gems/railties@8.0.2.rbi +3865 -0
  88. data/sorbet/rbi/gems/rainbow@3.1.1.rbi +403 -0
  89. data/sorbet/rbi/gems/rake@13.2.1.rbi +3120 -0
  90. data/sorbet/rbi/gems/rbi@0.3.6.rbi +6893 -0
  91. data/sorbet/rbi/gems/rbs@3.9.4.rbi +6978 -0
  92. data/sorbet/rbi/gems/rdoc@6.12.0.rbi +12760 -0
  93. data/sorbet/rbi/gems/reline@0.6.0.rbi +2451 -0
  94. data/sorbet/rbi/gems/rexml@3.4.1.rbi +5240 -0
  95. data/sorbet/rbi/gems/rspec-core@3.13.4.rbi +11348 -0
  96. data/sorbet/rbi/gems/rspec-expectations@3.13.5.rbi +8189 -0
  97. data/sorbet/rbi/gems/rspec-mocks@3.13.5.rbi +5350 -0
  98. data/sorbet/rbi/gems/rspec-sorbet@1.9.2.rbi +164 -0
  99. data/sorbet/rbi/gems/rspec-support@3.13.4.rbi +1630 -0
  100. data/sorbet/rbi/gems/rspec@3.13.1.rbi +83 -0
  101. data/sorbet/rbi/gems/securerandom@0.4.1.rbi +75 -0
  102. data/sorbet/rbi/gems/spoom@1.6.3.rbi +6985 -0
  103. data/sorbet/rbi/gems/stringio@3.1.5.rbi +9 -0
  104. data/sorbet/rbi/gems/tapioca@0.16.11.rbi +3628 -0
  105. data/sorbet/rbi/gems/testcontainers-core@0.2.0.rbi +1005 -0
  106. data/sorbet/rbi/gems/testcontainers-postgres@0.2.0.rbi +145 -0
  107. data/sorbet/rbi/gems/thor@1.3.2.rbi +4378 -0
  108. data/sorbet/rbi/gems/timeout@0.4.3.rbi +157 -0
  109. data/sorbet/rbi/gems/tzinfo@2.0.6.rbi +5918 -0
  110. data/sorbet/rbi/gems/uri@1.0.3.rbi +2349 -0
  111. data/sorbet/rbi/gems/useragent@0.16.11.rbi +9 -0
  112. data/sorbet/rbi/gems/websocket-driver@0.8.0.rbi +9 -0
  113. data/sorbet/rbi/gems/websocket-extensions@0.1.5.rbi +9 -0
  114. data/sorbet/rbi/gems/yard-sorbet@0.9.0.rbi +435 -0
  115. data/sorbet/rbi/gems/yard@0.9.37.rbi +18379 -0
  116. data/sorbet/rbi/gems/zeitwerk@2.7.3.rbi +9 -0
  117. data/sorbet/tapioca/config.yml +5 -0
  118. data/sorbet/tapioca/require.rb +12 -0
  119. metadata +231 -6
@@ -0,0 +1,205 @@
1
+ # typed: false
2
+
3
+ require "ostruct"
4
+
5
+ module Wal
6
+ # Responsible to hook into a Postgres logical replication slot and stream the changes to a specific `Watcher`.
7
+ # Also it supports inject "contexts" into the replication events.
8
+ class Replicator
9
+ extend T::Sig
10
+ include PG::Replication::Protocol
11
+
12
+ sig do
13
+ params(
14
+ replication_slot: String,
15
+ use_temporary_slot: T::Boolean,
16
+ db_config: T::Hash[Symbol, T.untyped],
17
+ ).void
18
+ end
19
+ def initialize(
20
+ replication_slot:,
21
+ use_temporary_slot: false,
22
+ db_config: ActiveRecord::Base.configurations.configs_for(name: "primary").configuration_hash
23
+ )
24
+ @db_config = db_config
25
+ @replication_slot = replication_slot
26
+ @use_temporary_slot = use_temporary_slot
27
+ end
28
+
29
+ sig { params(watcher: Watcher, publications: T::Array[String]).void }
30
+ def replicate_forever(watcher, publications:)
31
+ replication = replicate(watcher, publications:)
32
+ loop { replication.next }
33
+ rescue StopIteration
34
+ nil
35
+ end
36
+
37
+ sig { params(watcher: Watcher, publications: T::Array[String]).returns(T::Enumerator::Lazy[Event]) }
38
+ def replicate(watcher, publications:)
39
+ watch_conn = PG.connect(
40
+ dbname: @db_config[:database],
41
+ host: @db_config[:host],
42
+ user: @db_config[:username],
43
+ password: @db_config[:password].presence,
44
+ port: @db_config[:port].presence,
45
+ replication: "database",
46
+ )
47
+
48
+ begin
49
+ watch_conn.query(<<~SQL)
50
+ CREATE_REPLICATION_SLOT #{@replication_slot} #{@use_temporary_slot ? "TEMPORARY" : ""} LOGICAL "pgoutput"
51
+ SQL
52
+ rescue PG::DuplicateObject
53
+ # We are fine, we already have created this slot in a previous run
54
+ end
55
+
56
+ tables = {}
57
+ context = {}
58
+ transaction_id = nil
59
+
60
+ watch_conn.start_pgoutput_replication_slot(@replication_slot, publications, messages: true).filter_map do |msg|
61
+ case msg
62
+ in XLogData(data: PG::Replication::PGOutput::Relation(oid:, name:, columns:))
63
+ tables[oid] = Table.new(
64
+ # TODO: for now we are forcing an id column here, but that is not really correct
65
+ primary_key_colums: columns.any? { |col| col.name == "id" } ? ["id"] : [],
66
+ name:,
67
+ columns: columns.map do |col|
68
+ Column.new(
69
+ name: col.name,
70
+ decoder: ActiveRecord::Base.connection.lookup_cast_type_from_column(
71
+ # We have to create this OpenStruct because weird AR API reasons...
72
+ # And the `sql_type` param luckly doesn't really matter for our use case
73
+ ::OpenStruct.new(oid: col.oid, fmod: col.modifier, sql_type: "")
74
+ ),
75
+ )
76
+ end
77
+ )
78
+ next
79
+
80
+ in XLogData(lsn:, data: PG::Replication::PGOutput::Begin(xid:, timestamp:, final_lsn:))
81
+ transaction_id = xid
82
+ context = {}
83
+ BeginTransactionEvent.new(
84
+ transaction_id:,
85
+ lsn:,
86
+ final_lsn:,
87
+ timestamp:,
88
+ ).tap { |event| watcher.on_event(event) }
89
+
90
+ in XLogData(lsn:, data: PG::Replication::PGOutput::Commit(timestamp:))
91
+ CommitTransactionEvent.new(
92
+ transaction_id:,
93
+ lsn:,
94
+ context:,
95
+ timestamp:,
96
+ ).tap do |event|
97
+ watcher.on_event(event)
98
+ watch_conn.standby_status_update(write_lsn: lsn)
99
+ end
100
+
101
+ in XLogData(data: PG::Replication::PGOutput::Message(prefix:, content:)) if watcher.valid_context_prefix? prefix
102
+ begin
103
+ context = JSON.parse(content).presence || {}
104
+ next
105
+ rescue JSON::ParserError
106
+ # Invalid context received, just ignore
107
+ end
108
+
109
+ in XLogData(lsn:, data: PG::Replication::PGOutput::Insert(oid:, new:))
110
+ table = tables[oid]
111
+ next unless watcher.should_watch_table? table.name
112
+ new_data = table.decode_row(new)
113
+ record_id = table.primary_key(new_data)
114
+ next unless record_id
115
+
116
+ InsertEvent.new(
117
+ transaction_id:,
118
+ lsn:,
119
+ context:,
120
+ table: table.name,
121
+ primary_key: record_id,
122
+ new: new_data,
123
+ ).tap { |event| watcher.on_event(event) }
124
+
125
+ in XLogData(lsn:, data: PG::Replication::PGOutput::Update(oid:, new:, old:))
126
+ table = tables[oid]
127
+ next unless watcher.should_watch_table? table.name
128
+ old_data = table.decode_row(old)
129
+ new_data = table.decode_row(new)
130
+ record_id = table.primary_key(new_data)
131
+ next unless record_id
132
+
133
+ UpdateEvent.new(
134
+ transaction_id:,
135
+ lsn:,
136
+ context:,
137
+ table: table.name,
138
+ primary_key: record_id,
139
+ old: old_data,
140
+ new: new_data,
141
+ ).tap { |event| watcher.on_event(event) }
142
+
143
+ in XLogData(lsn:, data: PG::Replication::PGOutput::Delete(oid:, old:, key:))
144
+ table = tables[oid]
145
+ next unless watcher.should_watch_table? table.name
146
+ old_data = table.decode_row(old.presence || key)
147
+ record_id = table.primary_key(old_data)
148
+ next unless record_id
149
+
150
+ DeleteEvent.new(
151
+ transaction_id:,
152
+ lsn:,
153
+ context:,
154
+ table: table.name,
155
+ primary_key: record_id,
156
+ old: old_data,
157
+ ).tap { |event| watcher.on_event(event) }
158
+
159
+ else
160
+ next
161
+ end
162
+ end
163
+ end
164
+
165
+ class Column < T::Struct
166
+ const :name, String
167
+ const :decoder, T.untyped
168
+
169
+ def decode(value)
170
+ decoder.deserialize(value)
171
+ end
172
+ end
173
+
174
+ class Table < T::Struct
175
+ const :name, String
176
+ const :primary_key_colums, T::Array[String]
177
+ const :columns, T::Array[Column]
178
+
179
+ def primary_key(decoded_row)
180
+ case primary_key_colums
181
+ in [key]
182
+ case decoded_row[key]
183
+ in Integer => id
184
+ id
185
+ in String => id
186
+ id
187
+ else
188
+ # Only supporting string and integer primary keys for now
189
+ nil
190
+ end
191
+ else
192
+ # Not supporting coumpound primary keys
193
+ nil
194
+ end
195
+ end
196
+
197
+ def decode_row(values)
198
+ values
199
+ .zip(columns)
200
+ .map { |tuple, col| [col.name, col.decode(tuple.data)] }
201
+ .to_h
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,74 @@
1
+ # typed: true
2
+
3
+ module Wal
4
+ # A watcher that streams all the events of each WAL transaction on a separate thread.
5
+ #
6
+ # Useful to improve the throughput, as it will allow you to process events while fetching for more in parallel.
7
+ #
8
+ # Example:
9
+ #
10
+ # Watcher that persists all delete events as it arrives using a single database transaction, and without waiting
11
+ # for the full WAL log transaction to be finished.
12
+ #
13
+ # ```ruby
14
+ # class RegisterDeletesWalWatcher < Wal::StreamingWalWatcher
15
+ # sig { override.params(events: T::Enumerator[Event]).void }
16
+ # def on_transaction_events(events)
17
+ # DeletedApplicationRecord.transaction do
18
+ # events
19
+ # .lazy
20
+ # .filter { |event| event.is_a? DeleteEvent }
21
+ # .each { |event| DeletedApplicationRecord.create_from_event(event) }
22
+ # end
23
+ # end
24
+ # end
25
+ # ```
26
+ class StreamingWatcher
27
+ extend T::Sig
28
+ extend T::Helpers
29
+ include Wal::Watcher
30
+ abstract!
31
+
32
+ sig { abstract.params(events: T::Enumerator[Event]).void }
33
+ def on_transaction_events(events); end
34
+
35
+ sig { params(event: BeginTransactionEvent).returns(Integer) }
36
+ def queue_size(event)
37
+ 5_000
38
+ end
39
+
40
+ sig { override.params(event: Event).void }
41
+ def on_event(event)
42
+ case event
43
+ when BeginTransactionEvent
44
+ @queue = SizedQueue.new(queue_size(event))
45
+
46
+ event_stream = Enumerator.new do |y|
47
+ while (item = @queue.pop)
48
+ case item
49
+ when CommitTransactionEvent
50
+ y << item
51
+ break
52
+ else
53
+ y << item
54
+ end
55
+ end
56
+ end
57
+ @worker = Thread.new { on_transaction_events(event_stream) }
58
+
59
+ @queue << event
60
+
61
+ when CommitTransactionEvent
62
+ @queue << event
63
+ @worker.join
64
+
65
+ # We are cleaning this up to hint to Ruby GC that this can be freed before the next begin transaction arrives
66
+ @queue.clear
67
+ @queue = nil
68
+
69
+ else
70
+ @queue << event
71
+ end
72
+ end
73
+ end
74
+ end
data/lib/wal/version.rb CHANGED
@@ -1,5 +1,6 @@
1
+ # typed: strict
1
2
  # frozen_string_literal: true
2
3
 
3
4
  module Wal
4
- VERSION = "0.0.0"
5
+ VERSION = "0.0.1"
5
6
  end
@@ -0,0 +1,95 @@
1
+ # typed: strict
2
+
3
+ module Wal
4
+ # Watcher is the core API used to hook into Postgres WAL log.
5
+ # The only required method on the API is the `on_event`, which will receive a WAL entry of the following events:
6
+ # - Transaction started: `Wal::BeginTransactionEvent`
7
+ # - Row inserted: `Wal::InsertEvent`
8
+ # - Row updated: `Wal::UpdateEvent`
9
+ # - Row deleted: `Wal::DeleteEvent`
10
+ # - Transaction committed: `Wal::CommitTransactionEvent`
11
+ #
12
+ # The `on_event` method will be called without any buffering, so it is up to implementators to aggregate them if
13
+ # desired. In practice, it is rarelly useful to implement this module directly for application level business logic,
14
+ # and instead it is more recomended using more specific ones, such as the `RecordWatcher` and `StreamingWalWatcher`.
15
+ module Watcher
16
+ extend T::Sig
17
+ extend T::Helpers
18
+ include Wal
19
+ abstract!
20
+
21
+ sig { abstract.params(event: Event).void }
22
+ def on_event(event); end
23
+
24
+ # Allows dropping the processing of any table
25
+ sig { params(table: String).returns(T::Boolean) }
26
+ def should_watch_table?(table)
27
+ true
28
+ end
29
+
30
+ # Check if the given context prefix should be allowed for this watcher
31
+ sig { params(prefix: String).returns(T::Boolean) }
32
+ def valid_context_prefix?(prefix)
33
+ true
34
+ end
35
+
36
+ # Include this module if you prefer to work with each event having its own method.
37
+ # This might be useful when you always want to process each type of event in a different way.
38
+ #
39
+ # Example:
40
+ #
41
+ # Watcher that calculates how much time passed between the begin and commit of a WAL transaction.
42
+ #
43
+ # ```ruby
44
+ # class MeasureTransactionTimeWatcher
45
+ # extend T::Sig
46
+ # include Wal::Watcher
47
+ # include Wal::Watcher::SeparatedEvents
48
+ #
49
+ # sig { params(event: BeginTransactionEvent).void }
50
+ # def on_begin(event)
51
+ # @start_time = Time.current
52
+ # end
53
+ #
54
+ # sig { params(event: CommitTransactionEvent).void }
55
+ # def on_commit(event)
56
+ # puts "Transaction processing time: #{Time.current - @start_time}"
57
+ # end
58
+ # end
59
+ # ```
60
+ module SeparatedEvents
61
+ extend T::Sig
62
+
63
+ sig { params(event: Event).void }
64
+ def on_event(event)
65
+ case event
66
+ when BeginTransactionEvent
67
+ on_begin(event)
68
+ when CommitTransactionEvent
69
+ on_commit(event)
70
+ when InsertEvent
71
+ on_insert(event)
72
+ when UpdateEvent
73
+ on_update(event)
74
+ when DeleteEvent
75
+ on_delete(event)
76
+ end
77
+ end
78
+
79
+ sig { params(event: BeginTransactionEvent).void }
80
+ def on_begin(event); end
81
+
82
+ sig { params(event: InsertEvent).void }
83
+ def on_insert(event); end
84
+
85
+ sig { params(event: UpdateEvent).void }
86
+ def on_update(event); end
87
+
88
+ sig { params(event: DeleteEvent).void }
89
+ def on_delete(event); end
90
+
91
+ sig { params(event: CommitTransactionEvent).void }
92
+ def on_commit(event); end
93
+ end
94
+ end
95
+ end
data/lib/wal.rb CHANGED
@@ -1,6 +1,133 @@
1
- # frozen_string_literal: true
1
+ # typed: strict
2
2
 
3
+ require "pg"
4
+ require "pg/replication"
5
+ require "active_support"
6
+ require "active_record"
7
+ require_relative "wal/watcher"
8
+ require_relative "wal/noop_watcher"
9
+ require_relative "wal/record_watcher"
10
+ require_relative "wal/streaming_watcher"
11
+ require_relative "wal/replicator"
12
+ require_relative "wal/active_record_context_extension"
3
13
  require_relative "wal/version"
4
14
 
5
15
  module Wal
16
+ Event = T.type_alias do
17
+ T.any(
18
+ BeginTransactionEvent,
19
+ CommitTransactionEvent,
20
+ InsertEvent,
21
+ UpdateEvent,
22
+ DeleteEvent,
23
+ )
24
+ end
25
+
26
+ class BeginTransactionEvent < T::Struct
27
+ extend T::Sig
28
+
29
+ const :transaction_id, Integer
30
+ const :lsn, Integer
31
+ const :final_lsn, Integer
32
+ const :timestamp, Time
33
+
34
+ sig { returns(Integer) }
35
+ def estimated_size
36
+ final_lsn - lsn
37
+ end
38
+ end
39
+
40
+ class CommitTransactionEvent < T::Struct
41
+ const :transaction_id, Integer
42
+ const :lsn, Integer
43
+ const :context, T::Hash[String, T.untyped]
44
+ const :timestamp, Time
45
+ end
46
+
47
+ module DiffedEvent
48
+ extend T::Sig
49
+
50
+ sig { returns(T::Hash[String, [T.untyped, T.untyped]]) }
51
+ def diff
52
+ {}
53
+ end
54
+
55
+ sig { params(attribute: T.any(Symbol, String)).returns(T::Boolean) }
56
+ def changed_attribute?(attribute)
57
+ diff.key? attribute.to_s
58
+ end
59
+
60
+ sig { params(attribute: T.any(Symbol, String)).returns(T.untyped) }
61
+ def attribute(attribute)
62
+ if (changes = diff[attribute.to_s])
63
+ changes[1]
64
+ end
65
+ end
66
+
67
+ sig { params(attribute: T.any(Symbol, String)).returns(T.nilable([T.untyped, T.untyped])) }
68
+ def attribute_changes(attribute)
69
+ diff[attribute.to_s]
70
+ end
71
+
72
+ sig { params(attribute: T.any(Symbol, String)).returns(T.untyped) }
73
+ def attribute_was(attribute)
74
+ if (changes = diff[attribute.to_s])
75
+ changes[0]
76
+ end
77
+ end
78
+ end
79
+
80
+ class InsertEvent < T::Struct
81
+ extend T::Sig
82
+ include DiffedEvent
83
+
84
+ const :transaction_id, Integer
85
+ const :lsn, Integer
86
+ const :context, T::Hash[String, T.untyped]
87
+ const :table, String
88
+ const :primary_key, T.untyped
89
+ const :new, T::Hash[String, T.untyped]
90
+
91
+ sig { returns(T::Hash[String, [T.untyped, T.untyped]]) }
92
+ def diff
93
+ new.transform_values { |val| [nil, val] }
94
+ end
95
+ end
96
+
97
+ class UpdateEvent < T::Struct
98
+ extend T::Sig
99
+ include DiffedEvent
100
+
101
+ const :transaction_id, Integer
102
+ const :lsn, Integer
103
+ const :context, T::Hash[String, T.untyped]
104
+ const :table, String
105
+ const :primary_key, T.untyped
106
+ const :old, T::Hash[String, T.untyped]
107
+ const :new, T::Hash[String, T.untyped]
108
+
109
+ sig { returns(T::Hash[String, [T.untyped, T.untyped]]) }
110
+ def diff
111
+ (old.keys | new.keys).reduce({}) do |diff, key|
112
+ old[key] != new[key] ? diff.merge(key => [old[key], new[key]]) : diff
113
+ end
114
+ end
115
+ end
116
+
117
+ class DeleteEvent < T::Struct
118
+ extend T::Sig
119
+ include DiffedEvent
120
+
121
+ const :transaction_id, Integer
122
+ const :lsn, Integer
123
+ const :context, T::Hash[String, T.untyped]
124
+ const :table, String
125
+ const :primary_key, T.untyped
126
+ const :old, T::Hash[String, T.untyped]
127
+
128
+ sig { returns(T::Hash[String, [T.untyped, T.untyped]]) }
129
+ def diff
130
+ old.transform_values { |val| [val, nil] }
131
+ end
132
+ end
6
133
  end