pcrd 0.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.
Files changed (72) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +24 -0
  3. data/LICENSE +21 -0
  4. data/README.md +614 -0
  5. data/bin/pcrd +7 -0
  6. data/lib/pcrd/advisory_lock.rb +50 -0
  7. data/lib/pcrd/apply/engine.rb +184 -0
  8. data/lib/pcrd/apply/worker.rb +97 -0
  9. data/lib/pcrd/backfill/batch.rb +158 -0
  10. data/lib/pcrd/backfill/engine.rb +153 -0
  11. data/lib/pcrd/checkpoint/store.rb +217 -0
  12. data/lib/pcrd/cli.rb +274 -0
  13. data/lib/pcrd/commands/analyze.rb +125 -0
  14. data/lib/pcrd/commands/cleanup.rb +112 -0
  15. data/lib/pcrd/commands/demo.rb +152 -0
  16. data/lib/pcrd/commands/readiness.rb +30 -0
  17. data/lib/pcrd/commands/status.rb +129 -0
  18. data/lib/pcrd/commands/verify.rb +172 -0
  19. data/lib/pcrd/config/add_column.rb +7 -0
  20. data/lib/pcrd/config/analyze_config.rb +8 -0
  21. data/lib/pcrd/config/column_spec.rb +10 -0
  22. data/lib/pcrd/config/connection.rb +7 -0
  23. data/lib/pcrd/config/cutover_config.rb +7 -0
  24. data/lib/pcrd/config/load_error.rb +7 -0
  25. data/lib/pcrd/config/loader.rb +158 -0
  26. data/lib/pcrd/config/migrate_config.rb +21 -0
  27. data/lib/pcrd/config/root.rb +9 -0
  28. data/lib/pcrd/config/schema.rb +62 -0
  29. data/lib/pcrd/config/table.rb +9 -0
  30. data/lib/pcrd/config/verify_config.rb +7 -0
  31. data/lib/pcrd/config.rb +7 -0
  32. data/lib/pcrd/connection/client.rb +129 -0
  33. data/lib/pcrd/connection/error.rb +7 -0
  34. data/lib/pcrd/connection/replication.rb +108 -0
  35. data/lib/pcrd/cutover/orchestrator.rb +108 -0
  36. data/lib/pcrd/cutover/sequences.rb +138 -0
  37. data/lib/pcrd/demo/generator.rb +214 -0
  38. data/lib/pcrd/demo/schema.rb +154 -0
  39. data/lib/pcrd/error.rb +12 -0
  40. data/lib/pcrd/migration/orchestrator.rb +272 -0
  41. data/lib/pcrd/monitor/lag.rb +107 -0
  42. data/lib/pcrd/options.rb +15 -0
  43. data/lib/pcrd/output/analyze_printer.rb +173 -0
  44. data/lib/pcrd/output/cutover_printer.rb +128 -0
  45. data/lib/pcrd/output/preflight_printer.rb +119 -0
  46. data/lib/pcrd/output/readiness_printer.rb +72 -0
  47. data/lib/pcrd/preflight.rb +331 -0
  48. data/lib/pcrd/readiness/manifest.rb +201 -0
  49. data/lib/pcrd/replication/consumer.rb +235 -0
  50. data/lib/pcrd/replication/error.rb +10 -0
  51. data/lib/pcrd/replication/pgoutput/messages.rb +68 -0
  52. data/lib/pcrd/replication/pgoutput/parser.rb +316 -0
  53. data/lib/pcrd/reporter/console.rb +46 -0
  54. data/lib/pcrd/reporter/null.rb +14 -0
  55. data/lib/pcrd/schema/column.rb +59 -0
  56. data/lib/pcrd/schema/ddl.rb +71 -0
  57. data/lib/pcrd/schema/diff_entry.rb +36 -0
  58. data/lib/pcrd/schema/differ.rb +175 -0
  59. data/lib/pcrd/schema/object_reader.rb +187 -0
  60. data/lib/pcrd/schema/packer.rb +90 -0
  61. data/lib/pcrd/schema/reader.rb +118 -0
  62. data/lib/pcrd/schema/setup.rb +143 -0
  63. data/lib/pcrd/schema/setup_error.rb +9 -0
  64. data/lib/pcrd/schema/table_not_found.rb +8 -0
  65. data/lib/pcrd/schema/type_registry.rb +116 -0
  66. data/lib/pcrd/sql.rb +55 -0
  67. data/lib/pcrd/transform/row_transformer.rb +69 -0
  68. data/lib/pcrd/transform/type_map.rb +209 -0
  69. data/lib/pcrd/transform/validator.rb +106 -0
  70. data/lib/pcrd/version.rb +5 -0
  71. data/lib/pcrd.rb +11 -0
  72. metadata +231 -0
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Pcrd
6
+ module Readiness
7
+ # Builds the target-readiness manifest: for each migrated table, which
8
+ # secondary objects exist on the source, whether the target already has
9
+ # them, and runnable DDL to create the missing ones before cutover.
10
+ #
11
+ # The load DDL (Schema::DDL) creates only table + primary key; indexes,
12
+ # constraints, grants, etc. are deferred so the bulk load is fast. This
13
+ # turns that deferral from tribal knowledge into an explicit checklist.
14
+ #
15
+ # Rename/drop aware: an object referencing a dropped column is reported as
16
+ # not-recreatable; one referencing a renamed column is flagged for manual
17
+ # regeneration (its source DDL is shown commented out) rather than emitting
18
+ # silently-wrong SQL.
19
+ #
20
+ # Sequences/identity are reported as informational — they are restored
21
+ # automatically by `pcrd cutover` (Cutover::Sequences), so the manifest does
22
+ # not emit competing DDL for them.
23
+ class Manifest
24
+ # status: :missing (DDL provided) | :present | :needs_review | :info
25
+ Entry = Data.define(:category, :name, :status, :detail, :ddl)
26
+ Table = Data.define(:table_name, :entries)
27
+ Result = Data.define(:tables)
28
+
29
+ KIND_LABEL = { "f" => "foreign key", "u" => "unique constraint", "c" => "check constraint" }.freeze
30
+
31
+ def initialize(source_pool:, target_pool:, config:)
32
+ @source = source_pool
33
+ @target = target_pool
34
+ @config = config
35
+ end
36
+
37
+ def build
38
+ src = Schema::ObjectReader.new(@source)
39
+ tgt = Schema::ObjectReader.new(@target)
40
+
41
+ tables = (@config.migrate&.tables || []).map do |table_config|
42
+ Table.new(table_name: table_config.name, entries: entries_for(table_config, src, tgt))
43
+ end
44
+
45
+ Result.new(tables: tables)
46
+ end
47
+
48
+ private
49
+
50
+ def entries_for(table_config, src, tgt)
51
+ name = table_config.name
52
+ drops, renames = change_maps(table_config)
53
+ present_idx = tgt.indexes(name).map(&:name).to_set
54
+ present_con = tgt.constraints(name).map(&:name).to_set
55
+
56
+ index_entries(name, src.indexes(name), present_idx, drops, renames) +
57
+ constraint_entries(name, src.constraints(name), present_con, drops, renames) +
58
+ sequence_entries(src.identity_columns(name)) +
59
+ owner_entries(name, src, tgt) +
60
+ grant_entries(name, src, tgt) +
61
+ comment_entries(name, src, tgt, drops, renames)
62
+ end
63
+
64
+ def index_entries(table, indexes, present, drops, renames)
65
+ indexes.map do |ix|
66
+ review = review_reason(ix.columns, drops, renames)
67
+ if present.include?(ix.name)
68
+ entry("index", ix.name, :present, "already on target", nil)
69
+ elsif review
70
+ entry("index", ix.name, :needs_review, review, "-- #{review}\n-- #{ix.definition};")
71
+ else
72
+ entry("index", ix.name, :missing, ix.unique ? "unique index" : "index",
73
+ "#{concurrently(ix.definition)};")
74
+ end
75
+ end
76
+ end
77
+
78
+ def constraint_entries(table, constraints, present, drops, renames)
79
+ constraints.map do |c|
80
+ label = KIND_LABEL[c.kind]
81
+ review = review_reason(c.columns, drops, renames)
82
+ if present.include?(c.name)
83
+ entry("constraint", c.name, :present, "already on target", nil)
84
+ elsif review
85
+ entry("constraint", c.name, :needs_review, review,
86
+ "-- #{review}\n-- ALTER TABLE #{Sql.quote_table(table)} " \
87
+ "ADD CONSTRAINT #{Sql.quote_ident(c.name)} #{c.definition};")
88
+ else
89
+ fk_note = c.kind == "f" ? " -- run after all referenced tables are loaded" : ""
90
+ ddl = "ALTER TABLE #{Sql.quote_table(table)} " \
91
+ "ADD CONSTRAINT #{Sql.quote_ident(c.name)} #{c.definition};#{fk_note}"
92
+ entry("constraint", c.name, :missing, label, ddl)
93
+ end
94
+ end
95
+ end
96
+
97
+ def sequence_entries(identity_columns)
98
+ identity_columns.map do |col|
99
+ entry("sequence", col.column, :info,
100
+ "#{col.kind} column — restored automatically by `pcrd cutover`", nil)
101
+ end
102
+ end
103
+
104
+ def owner_entries(table, src, tgt)
105
+ source_owner = src.owner(table)
106
+ return [] unless source_owner
107
+
108
+ if source_owner == tgt.owner(table)
109
+ [entry("owner", source_owner, :present, "owner already #{source_owner}", nil)]
110
+ else
111
+ [entry("owner", source_owner, :missing, "set owner to #{source_owner}",
112
+ "ALTER TABLE #{Sql.quote_table(table)} OWNER TO #{Sql.quote_ident(source_owner)};")]
113
+ end
114
+ end
115
+
116
+ def grant_entries(table, src, tgt)
117
+ target = tgt.grants(table).to_h { |g| [g.grantee, g.privileges] }
118
+
119
+ src.grants(table).map do |g|
120
+ have = target[g.grantee] || []
121
+ if (g.privileges - have).empty?
122
+ entry("grant", g.grantee, :present, g.privileges.join(", "), nil)
123
+ else
124
+ grantee_sql = g.grantee == "PUBLIC" ? "PUBLIC" : Sql.quote_ident(g.grantee)
125
+ entry("grant", g.grantee, :missing, g.privileges.join(", "),
126
+ "GRANT #{g.privileges.join(', ')} ON #{Sql.quote_table(table)} TO #{grantee_sql};")
127
+ end
128
+ end
129
+ end
130
+
131
+ # Comments are rename-safe: only the column identifier changes, so a
132
+ # renamed column's comment is re-emitted against its target name; dropped
133
+ # columns are skipped.
134
+ def comment_entries(table, src, tgt, drops, renames)
135
+ entries = []
136
+
137
+ source_table_comment = src.table_comment(table)
138
+ if source_table_comment
139
+ status = source_table_comment == tgt.table_comment(table) ? :present : :missing
140
+ ddl = status == :missing ? "COMMENT ON TABLE #{Sql.quote_table(table)} IS #{quote_literal(source_table_comment)};" : nil
141
+ entries << entry("comment", "(table)", status, truncate(source_table_comment), ddl)
142
+ end
143
+
144
+ target_comments = tgt.column_comments(table)
145
+ src.column_comments(table).each do |col, comment|
146
+ next if drops.include?(col)
147
+
148
+ target_col = renames[col] || col
149
+ if target_comments[target_col] == comment
150
+ entries << entry("comment", target_col, :present, truncate(comment), nil)
151
+ else
152
+ entries << entry("comment", target_col, :missing, truncate(comment),
153
+ "COMMENT ON COLUMN #{Sql.quote_table(table)}.#{Sql.quote_ident(target_col)} " \
154
+ "IS #{quote_literal(comment)};")
155
+ end
156
+ end
157
+
158
+ entries
159
+ end
160
+
161
+ def quote_literal(str)
162
+ "'#{str.gsub("'", "''")}'"
163
+ end
164
+
165
+ def truncate(str)
166
+ str.length > 40 ? "#{str[0, 37]}..." : str
167
+ end
168
+
169
+ # Injects CONCURRENTLY so the index build does not lock out writes.
170
+ def concurrently(definition)
171
+ definition.sub(/\ACREATE (UNIQUE )?INDEX /, 'CREATE \1INDEX CONCURRENTLY ')
172
+ end
173
+
174
+ def review_reason(columns, drops, renames)
175
+ dropped = columns & drops
176
+ return "references dropped column(s): #{dropped.join(', ')} — not recreated" if dropped.any?
177
+
178
+ renamed = columns & renames.keys
179
+ if renamed.any?
180
+ pairs = renamed.map { |c| "#{c}->#{renames[c]}" }.join(", ")
181
+ return "references renamed column(s): #{pairs} — regenerate manually"
182
+ end
183
+
184
+ nil
185
+ end
186
+
187
+ def change_maps(table_config)
188
+ cols = table_config.columns || {}
189
+ drops = cols.select { |_, spec| spec.drop }.keys.map(&:to_s)
190
+ renames = cols.each_with_object({}) do |(src, spec), h|
191
+ h[src.to_s] = spec.rename if spec.rename
192
+ end
193
+ [drops, renames]
194
+ end
195
+
196
+ def entry(category, name, status, detail, ddl)
197
+ Entry.new(category: category, name: name, status: status, detail: detail, ddl: ddl)
198
+ end
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pcrd
4
+ module Replication
5
+ # Streams WAL messages from a pgoutput logical replication slot and
6
+ # buffers complete transactions onto a Thread::Queue for the apply engine.
7
+ #
8
+ # Protocol (inside each raw message from the server):
9
+ # 0x77 ('w') — XLogData: 1 type + 8 wal_start + 8 wal_end + 8 ts + payload
10
+ # 0x6B ('k') — Primary keepalive: 1 type + 8 wal_end + 8 ts + 1 reply_flag
11
+ #
12
+ # The consumer must respond to keepalives with a StandbyStatusUpdate ('r')
13
+ # within wal_sender_timeout (default 60s) or the server drops the connection.
14
+ #
15
+ # Thread model:
16
+ # start — launches a background thread that drives the stream loop
17
+ # stop — signals the thread to exit cleanly after the current poll
18
+ # queue — Thread::Queue; pop from the apply engine side
19
+ # advance_lsn — call from apply engine after each applied transaction
20
+ class Consumer
21
+ # A complete buffered transaction ready for the apply engine.
22
+ Transaction = Data.define(:begin_msg, :events, :commit_lsn)
23
+
24
+ XLOG_DATA = 0x77
25
+ KEEPALIVE = 0x6B
26
+ PG_EPOCH_OFFSET_US = 946_684_800 * 1_000_000
27
+ KEEPALIVE_INTERVAL = 10 # seconds between proactive keepalives to server
28
+ WAIT_TIMEOUT = 1 # max seconds per wait_readable; limits stop latency
29
+
30
+ # Backpressure cap. The queue holds at most this many buffered
31
+ # transactions; once full, the stream loop stops reading new WAL (the
32
+ # server's flow control kicks in) until the apply side drains it. This
33
+ # bounds memory during a long backfill instead of letting the queue grow
34
+ # without limit. Tuned via :max_queue.
35
+ DEFAULT_MAX_QUEUE = 10_000
36
+ FULL_QUEUE_BACKOFF = 0.05 # seconds to wait between retries when full
37
+
38
+ def initialize(repl_conn:, parser:, slot_name:, pub_name:, start_lsn: "0/0",
39
+ max_queue: DEFAULT_MAX_QUEUE)
40
+ @repl = repl_conn
41
+ @parser = parser
42
+ @slot_name = slot_name
43
+ @pub_name = pub_name
44
+ @start_lsn = start_lsn
45
+ @queue = SizedQueue.new(max_queue)
46
+ @stop = false
47
+ @mutex = Mutex.new
48
+ @conf_lsn = 0 # last applied LSN (int64); advanced by apply engine
49
+ @last_received_lsn = nil # commit LSN of the most recently buffered txn
50
+ @thread = nil
51
+ end
52
+
53
+ attr_reader :queue, :parser, :last_error
54
+
55
+ # Commit LSN of the most recent transaction buffered onto the queue, for
56
+ # observability ("how far has streaming read?"). nil until the first txn.
57
+ def last_received_lsn
58
+ @mutex.synchronize { @last_received_lsn }
59
+ end
60
+
61
+ # Number of buffered transactions waiting to be applied (backpressure gauge).
62
+ def queue_depth
63
+ @queue.size
64
+ end
65
+
66
+ # Opens the replication connection and starts the background thread.
67
+ def start
68
+ @repl.open
69
+ @repl.start_replication(slot_name: @slot_name, pub_name: @pub_name, start_lsn: @start_lsn)
70
+ @thread = Thread.new { stream_loop }
71
+ self
72
+ end
73
+
74
+ # Signals the consumer to stop after the current poll cycle.
75
+ def stop
76
+ @mutex.synchronize { @stop = true }
77
+ @thread&.join(5)
78
+ @repl.close
79
+ end
80
+
81
+ def stopped?
82
+ @mutex.synchronize { @stop }
83
+ end
84
+
85
+ # True if the streaming thread exited because of an error. The apply
86
+ # side polls this when the queue drains empty so a dead consumer
87
+ # surfaces as a failure instead of looking like "caught up and idle".
88
+ def failed?
89
+ @mutex.synchronize { !@last_error.nil? }
90
+ end
91
+
92
+ # Called by the apply engine after a transaction has been applied.
93
+ # Updates the LSN we report back to the server (WAL reclaim point).
94
+ def advance_lsn(lsn_string)
95
+ int = lsn_to_int(lsn_string)
96
+ @mutex.synchronize { @conf_lsn = [@conf_lsn, int].max }
97
+ end
98
+
99
+ private
100
+
101
+ def stream_loop
102
+ @current_begin = nil
103
+ @current_events = []
104
+ last_keepalive = monotonic
105
+
106
+ loop do
107
+ # Short wait so stop! is noticed within WAIT_TIMEOUT seconds.
108
+ break if stopped?
109
+
110
+ if @repl.wait_readable(WAIT_TIMEOUT)
111
+ # Drain all messages available right now in one pass.
112
+ loop do
113
+ raw = @repl.get_copy_data
114
+ break if raw.nil? || raw == false
115
+ dispatch(raw)
116
+ end
117
+ end
118
+
119
+ # Send proactive keepalive if nothing has been received recently.
120
+ if monotonic - last_keepalive >= KEEPALIVE_INTERVAL
121
+ send_status
122
+ last_keepalive = monotonic
123
+ end
124
+ end
125
+ rescue => e
126
+ # Record the failure and let the thread exit. We deliberately do NOT
127
+ # enqueue anything: a malformed transaction here would be applied as a
128
+ # no-op and its sentinel "LSN" checkpointed, hiding the failure. The
129
+ # apply loop detects the dead consumer via #failed? once the queue
130
+ # drains and raises Replication::Error.
131
+ @mutex.synchronize { @last_error = e }
132
+ end
133
+
134
+ def dispatch(raw)
135
+ tag = raw.getbyte(0)
136
+
137
+ case tag
138
+ when XLOG_DATA
139
+ # Header: 1 tag + 8 wal_start + 8 wal_end + 8 ts = 25 bytes
140
+ payload = raw.b[25..]
141
+ handle_pgoutput(@parser.parse(payload))
142
+
143
+ when KEEPALIVE
144
+ # Bytes 1-8: wal_end; byte 17: reply_requested
145
+ reply_needed = raw.getbyte(17) == 1
146
+ send_status if reply_needed
147
+ end
148
+ end
149
+
150
+ def handle_pgoutput(msg)
151
+ case msg
152
+ when Pgoutput::Messages::Begin
153
+ @current_begin = msg
154
+ @current_events = []
155
+
156
+ when Pgoutput::Messages::Insert,
157
+ Pgoutput::Messages::Update,
158
+ Pgoutput::Messages::Delete
159
+ @current_events << msg
160
+
161
+ when Pgoutput::Messages::Commit
162
+ unless @current_events.empty?
163
+ enqueue(Transaction.new(
164
+ begin_msg: @current_begin,
165
+ events: @current_events.dup,
166
+ commit_lsn: msg.lsn
167
+ ))
168
+ end
169
+ @current_begin = nil
170
+ @current_events = []
171
+
172
+ when Pgoutput::Messages::Truncate
173
+ reject_truncate(msg)
174
+
175
+ # Relation and Type are cached by the parser; no action needed here.
176
+ end
177
+ end
178
+
179
+ # pcrd does not replicate TRUNCATE: silently ignoring it would leave the
180
+ # target diverged from the source with no signal. Halt loudly so the
181
+ # operator can truncate the target deliberately and resume. The
182
+ # publication only covers migrated tables, so any TRUNCATE is relevant.
183
+ def reject_truncate(msg)
184
+ names = msg.relation_ids.map do |id|
185
+ (r = @parser.relation(id)) ? "#{r.namespace}.#{r.name}" : "oid:#{id}"
186
+ end
187
+ raise Error,
188
+ "TRUNCATE received for #{names.join(", ")}. pcrd does not replicate " \
189
+ "TRUNCATE; the target would silently diverge. Migration halted — " \
190
+ "truncate the target manually if intended, then resume."
191
+ end
192
+
193
+ # Pushes a transaction onto the bounded queue. If the queue is full the
194
+ # apply side is behind, so we wait — but keep answering the server with
195
+ # keepalives so it does not drop us past wal_sender_timeout while we
196
+ # apply backpressure. Returns early if stop is requested.
197
+ def enqueue(txn)
198
+ loop do
199
+ return if stopped?
200
+
201
+ begin
202
+ @queue.push(txn, true) # non-blocking; raises ThreadError when full
203
+ @mutex.synchronize { @last_received_lsn = txn.commit_lsn }
204
+ return
205
+ rescue ThreadError
206
+ send_status
207
+ sleep FULL_QUEUE_BACKOFF
208
+ end
209
+ end
210
+ end
211
+
212
+ # Sends a StandbyStatusUpdate to keep the connection alive and
213
+ # tell the server which LSN we have confirmed applying.
214
+ # 'r' tag + write_lsn (8) + flush_lsn (8) + apply_lsn (8) + ts (8) + reply (1)
215
+ def send_status
216
+ lsn = @mutex.synchronize { @conf_lsn }
217
+ now = ((Time.now.to_f * 1_000_000).to_i - PG_EPOCH_OFFSET_US)
218
+ msg = "r".b + [lsn, lsn, lsn, now, 0].pack("Q>Q>Q>q>C")
219
+ @repl.put_copy_data(msg)
220
+ rescue Connection::Error
221
+ nil # connection may be closing; ignore
222
+ end
223
+
224
+ def lsn_to_int(str)
225
+ return 0 unless str&.include?("/")
226
+ high, low = str.split("/").map { _1.to_i(16) }
227
+ (high << 32) | low
228
+ end
229
+
230
+ def monotonic
231
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
232
+ end
233
+ end
234
+ end
235
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pcrd
4
+ module Replication
5
+ # Raised when the WAL streaming consumer stops unexpectedly (e.g. the
6
+ # replication connection drops or pgoutput parsing fails). Carries the
7
+ # original error as #cause when available.
8
+ class Error < Pcrd::Error; end
9
+ end
10
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pcrd
4
+ module Replication
5
+ module Pgoutput
6
+ # Immutable structs for each pgoutput message type.
7
+ # Produced by Parser#parse; consumed by the WAL consumer (Phase 9).
8
+ #
9
+ # Tuple data (new_tuple / old_tuple) is a Hash<column_name, value> where:
10
+ # value = String — column value in text format
11
+ # value = nil — SQL NULL
12
+ # value = :toast — unchanged TOASTed value (not re-sent by server)
13
+ module Messages
14
+ # One column description inside a Relation message.
15
+ RelationColumn = Data.define(
16
+ :flags, # Integer: bit 0 set = part of replica identity key
17
+ :name, # String: column name
18
+ :type_id, # Integer: OID of the column data type
19
+ :type_modifier # Integer: atttypmod (-1 means no modifier)
20
+ )
21
+
22
+ # B — transaction begin
23
+ # lsn: String "X/Y" — final LSN of the transaction
24
+ # commit_time: Time (UTC)
25
+ # xid: Integer — transaction ID
26
+ Begin = Data.define(:lsn, :commit_time, :xid)
27
+
28
+ # C — transaction commit
29
+ # lsn: String "X/Y" — commit LSN
30
+ # end_lsn: String "X/Y" — LSN after the end of the transaction record
31
+ # commit_time: Time (UTC)
32
+ Commit = Data.define(:flags, :lsn, :end_lsn, :commit_time)
33
+
34
+ # R — relation (table schema snapshot)
35
+ # id: Integer — relation OID
36
+ # namespace: String — schema name (empty for pg_catalog)
37
+ # name: String — table name
38
+ # replica_identity: String — one of 'd', 'n', 'f', 'i'
39
+ # columns: Array<RelationColumn>
40
+ Relation = Data.define(:id, :namespace, :name, :replica_identity, :columns)
41
+
42
+ # T — data type
43
+ Type = Data.define(:id, :namespace, :name)
44
+
45
+ # I — INSERT
46
+ Insert = Data.define(:relation_id, :new_tuple)
47
+
48
+ # U — UPDATE
49
+ # old_tuple is nil unless REPLICA IDENTITY is FULL or INDEX
50
+ Update = Data.define(:relation_id, :old_tuple, :new_tuple)
51
+
52
+ # D — DELETE
53
+ # old_tuple contains either the key columns or all columns depending on REPLICA IDENTITY
54
+ Delete = Data.define(:relation_id, :old_tuple)
55
+
56
+ # O — origin (the replication origin this transaction came from)
57
+ Origin = Data.define(:lsn, :name)
58
+
59
+ # A — TRUNCATE (PG 11+)
60
+ # option_bits: Integer — 1 = CASCADE, 2 = RESTART IDENTITY
61
+ Truncate = Data.define(:option_bits, :relation_ids)
62
+
63
+ # M — logical decoding message (PG 14+)
64
+ LogicalMessage = Data.define(:flags, :lsn, :prefix, :content)
65
+ end
66
+ end
67
+ end
68
+ end