pgoutput-decoder 0.1.0 → 0.2.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: bc6535189e67a471cc685b43e61aa084fd8e5b109cbbff682494a49a936164e6
4
- data.tar.gz: 93858268fe2519951b33561b5deec1da508f32b7d2fa00f1c364731d5c6715b7
3
+ metadata.gz: 3a963632f6c43a96fee216f4f66e7ac5743f7b4374dec2a61bf5d5ab6c0111fe
4
+ data.tar.gz: 283c438cd2b5bc332483ba79a313fdbf5feda5134200f0478e8b283339fd1a68
5
5
  SHA512:
6
- metadata.gz: 1fbac46c86f50fa10ccdb623cd301ce12b12f72bd271915d0fa567e4605f758b2961cfb466661c1e59918ef89829a8bf367d3e771bcd9d55ec31dbf3d6a2bea3
7
- data.tar.gz: 3978a4c40f0939917ca97f129130995682602cadf2254b92bee9fa1fe56608baab401112efefc756ed5dc23ce9db837c6702ca9dbb5c71e403db23a1909c5bd1
6
+ metadata.gz: 05d855963feccb5872525b81318f80b58152153e344abeade63b20ede94e76e2c71932566e1ae3b8213695e2946a6b5cbb54df45e947745c0885b0d063feb7df
7
+ data.tar.gz: 07e90807168a43c177e448b47005b6e668d6bd1bee18b0f5e0665ed70f245d9734a22fee40a82f0162731b28c8f16ea38eecf34bdbec13c6a776ed3bc77bfff3
data/CHANGELOG.md CHANGED
@@ -1,17 +1,46 @@
1
1
  # Changelog
2
2
 
3
- All notable changes to this project will be documented in this file.
3
+ ## [Unreleased]
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
+ ## 0.2.0 - 2026-07-17
7
6
 
8
- ## [Unreleased]
7
+ ### Fixed
9
8
 
10
- ### Added
9
+ * Decode UPDATE and DELETE old-key tuples using relation key-column flags.
10
+ * Preserve composite and non-`id` keys while omitting non-key placeholders from
11
+ full-width PostgreSQL tuples.
12
+ * Restored SimpleCov result finalization, HTML report generation, and minimum
13
+ line/branch coverage enforcement.
14
+
15
+ ### Documentation
16
+
17
+ * Documented exact replica-key tuple behavior.
18
+
19
+ ## 0.1.1 - 2026-06-17
20
+
21
+ ### Fixed
11
22
 
12
- - Placeholder for future development.
23
+ * Curated RBS signatures shipped with the gem.
24
+ * Fixed duplicate `Pgoutput::Decoder` type declarations that caused Steep/RBS environment loading failures.
25
+ * Corrected decoder type signatures to align with actual runtime behavior.
26
+ * Improved `TypeRegistry` type annotations and value narrowing.
27
+ * Fixed Steep type-checking issues around decoder lookup, JSON decoding, UUID decoding, and numeric value decoding.
28
+ * Added missing standard library type dependencies required by Steep.
29
+ * Improved compatibility with downstream consumers using:
13
30
 
14
- ---
31
+ * `library "pgoutput-decoder"`
32
+ * `bundle exec steep check`
33
+
34
+ ### Documentation
35
+
36
+ * Refined shipped type definitions to better reflect the public API surface.
37
+
38
+ ### Internal
39
+
40
+ * No runtime behavior changes.
41
+ * No protocol decoding changes.
42
+ * No public API changes.
43
+ * This release focuses on RBS, Steep, and developer tooling correctness.
15
44
 
16
45
  ## [0.1.0] - 2026-06-01
17
46
 
@@ -32,8 +61,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
32
61
  - Added RBS signatures.
33
62
  - Added README documentation.
34
63
  - Added CI and release workflow templates.
35
-
36
- ---
37
-
38
- [Unreleased]: https://github.com/kanutocd/pgoutput-decoder/compare/v0.1.0...HEAD
39
- [0.1.0]: https://github.com/kanutocd/pgoutput-decoder/releases/tag/v0.1.0
data/README.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # pgoutput-decoder
2
2
 
3
+ [![Gem Version](https://badge.fury.io/rb/pgoutput-decoder.svg)](https://badge.fury.io/rb/pgoutput-decoder)
4
+ [![CI](https://github.com/kanutocd/pgoutput-decoder/workflows/CI/badge.svg)](https://github.com/kanutocd/pgoutput-decoder/actions)
5
+ [![Ruby Version](https://img.shields.io/badge/ruby-%3E%3D%203.4-ruby.svg)](https://www.ruby-lang.org/en/)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+
3
8
  A high-level PostgreSQL `pgoutput` logical replication value decoder for Ruby.
4
9
 
5
10
  `pgoutput-decoder` is the companion layer to [`pgoutput-parser`](https://rubygems.org/gems/pgoutput-parser). It accepts immutable protocol messages produced by `pgoutput-parser` and turns tuple payloads into application-friendly Ruby row-change events.
@@ -234,6 +239,11 @@ delete.old_values
234
239
  # => { ... } or nil
235
240
  ```
236
241
 
242
+ `old_key` contains only columns marked as replica-key columns by the preceding
243
+ `Relation` message. Composite and non-`id` identities retain their declared
244
+ column order; full-width tuples with non-key placeholders do not leak those
245
+ placeholders into the decoded key.
246
+
237
247
  ---
238
248
 
239
249
  ## Ractor Safety
@@ -257,10 +267,11 @@ The decoder instance itself is stateful and should not be shared across Ractors.
257
267
  bundle exec rake test
258
268
  ```
259
269
 
260
- With coverage:
270
+ The test task generates line and branch coverage in `coverage/` and enforces
271
+ the configured minimums:
261
272
 
262
273
  ```bash
263
- COVERAGE=true bundle exec rake test
274
+ bundle exec rake test
264
275
  ```
265
276
 
266
277
  ---
@@ -275,4 +286,4 @@ bundle exec steep check
275
286
 
276
287
  ## License
277
288
 
278
- MIT.
289
+ [MIT](LICENSE.txt).
@@ -19,11 +19,34 @@ module Pgoutput
19
19
  # @param tuple [Array<Pgoutput::Messages::TupleValue>]
20
20
  # @return [Hash<String, Object>]
21
21
  def build(relation, tuple)
22
- row = {}
22
+ build_columns(relation.columns, tuple)
23
+ end
24
+
25
+ # Build a decoded replica-key hash.
26
+ #
27
+ # PostgreSQL may encode an old-key tuple at full relation width with
28
+ # placeholders for non-key columns, while compact protocol fixtures may
29
+ # contain only key values. Relation flags determine the exact key columns
30
+ # in both representations.
31
+ #
32
+ # @param relation [Pgoutput::Messages::Relation]
33
+ # @param tuple [Array<Pgoutput::Messages::TupleValue>]
34
+ # @return [Hash<String, Object>]
35
+ def build_key(relation, tuple)
36
+ key_columns = relation.columns.select { |column| key_column?(column) }
37
+ columns = tuple.length == relation.columns.length ? relation.columns : key_columns
38
+ build_columns(columns, tuple, key_only: true)
39
+ end
40
+
41
+ private
42
+
43
+ def build_columns(columns, tuple, key_only: false)
44
+ row = {} # : Hash[String, untyped]
23
45
 
24
46
  tuple.each_with_index do |tuple_value, index|
25
- column = relation.columns[index]
47
+ column = columns[index]
26
48
  next unless column
49
+ next if key_only && !key_column?(column)
27
50
 
28
51
  normalized_value = normalize_oid(tuple_value, column.oid)
29
52
  row[column.name] = @value_decoder.decode(normalized_value)
@@ -32,7 +55,9 @@ module Pgoutput
32
55
  Ractor.make_shareable(row.freeze)
33
56
  end
34
57
 
35
- private
58
+ def key_column?(column)
59
+ column.flags.allbits?(1)
60
+ end
36
61
 
37
62
  def normalize_oid(tuple_value, oid)
38
63
  return tuple_value unless tuple_value.oid.nil?
@@ -79,7 +79,7 @@ module Pgoutput
79
79
  VARCHAR => ->(raw, _format) { raw.dup.freeze },
80
80
  FLOAT4 => ->(raw, format) { decode_float(raw, format, 4, "g") },
81
81
  FLOAT8 => ->(raw, format) { decode_float(raw, format, 8, "G") },
82
- NUMERIC => ->(raw, format) { format == :text ? BigDecimal(raw) : raw.dup.freeze },
82
+ NUMERIC => ->(raw, format) { format == :text ? Kernel.BigDecimal(raw) : raw.dup.freeze },
83
83
  JSON => ->(raw, format) { format == :text ? ::JSON.parse(raw) : raw.dup.freeze },
84
84
  JSONB => ->(raw, format) { decode_jsonb(raw, format) },
85
85
  UUID => ->(raw, format) { format == :text ? raw.dup.freeze : decode_uuid_binary(raw) },
@@ -107,7 +107,7 @@ module Pgoutput
107
107
  def decode(oid, raw, format)
108
108
  return nil if raw.nil?
109
109
 
110
- decoder = @decoders[oid]
110
+ decoder = oid ? @decoders[oid] : nil
111
111
  decoded = decoder ? decoder.call(raw, format) : raw.dup.freeze
112
112
  Ractor.make_shareable(decoded)
113
113
  end
@@ -129,24 +129,27 @@ module Pgoutput
129
129
  class << self
130
130
  private
131
131
 
132
+ # This decodes a PostgreSQL boolean value; it is not an object predicate.
133
+ # rubocop:disable Naming/PredicateMethod
132
134
  def decode_bool(raw, format)
133
135
  return raw == "t" if format == :text
134
136
 
135
137
  raw.unpack1("C") == 1
136
138
  end
139
+ # rubocop:enable Naming/PredicateMethod
137
140
 
138
141
  def decode_int(raw, format, expected_length, template)
139
142
  return raw.to_i if format == :text
140
143
  return raw.dup.freeze unless raw.bytesize == expected_length
141
144
 
142
- raw.unpack1(template)
145
+ Integer(raw.unpack1(template))
143
146
  end
144
147
 
145
148
  def decode_float(raw, format, expected_length, template)
146
149
  return Float(raw) if format == :text
147
150
  return raw.dup.freeze unless raw.bytesize == expected_length
148
151
 
149
- raw.unpack1(template)
152
+ Float(raw.unpack1(template))
150
153
  end
151
154
 
152
155
  def decode_jsonb(raw, format)
@@ -156,13 +159,13 @@ module Pgoutput
156
159
  # current on-wire format; the remaining bytes contain JSON text.
157
160
  return raw.dup.freeze unless raw.bytesize >= 2 && raw.getbyte(0) == 1
158
161
 
159
- ::JSON.parse(raw.byteslice(1..))
162
+ ::JSON.parse(raw.byteslice(1..).to_s)
160
163
  end
161
164
 
162
165
  def decode_uuid_binary(raw)
163
166
  return raw.dup.freeze unless raw.bytesize == 16
164
167
 
165
- hex = raw.unpack1("H*")
168
+ hex = raw.unpack1("H*").to_s
166
169
  "#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}".freeze
167
170
  end
168
171
  end
@@ -3,6 +3,6 @@
3
3
  module Pgoutput
4
4
  class Decoder
5
5
  # Gem version.
6
- VERSION = "0.1.0"
6
+ VERSION = "0.2.0"
7
7
  end
8
8
  end
@@ -120,7 +120,7 @@ module Pgoutput
120
120
  message.relation_id,
121
121
  relation.schema,
122
122
  relation.table,
123
- optional_row(relation, message.old_key_tuple),
123
+ optional_row(relation, message.old_key_tuple, key: true),
124
124
  optional_row(relation, message.old_tuple),
125
125
  @row_builder.build(relation, message.new_tuple)
126
126
  )
@@ -137,14 +137,15 @@ module Pgoutput
137
137
  message.relation_id,
138
138
  relation.schema,
139
139
  relation.table,
140
- optional_row(relation, message.old_key_tuple),
140
+ optional_row(relation, message.old_key_tuple, key: true),
141
141
  optional_row(relation, message.old_tuple)
142
142
  )
143
143
  )
144
144
  end
145
145
 
146
- def optional_row(relation, tuple)
146
+ def optional_row(relation, tuple, key: false)
147
147
  return nil if tuple.nil?
148
+ return @row_builder.build_key(relation, tuple) if key
148
149
 
149
150
  @row_builder.build(relation, tuple)
150
151
  end
@@ -2,6 +2,9 @@ module Pgoutput
2
2
  class Decoder
3
3
  VERSION: String
4
4
 
5
+ type parser_message = untyped
6
+ type relation = untyped
7
+ type tuple = Array[untyped]
5
8
  type event =
6
9
  Events::Begin |
7
10
  Events::Commit |
@@ -9,10 +12,31 @@ module Pgoutput
9
12
  Events::Update |
10
13
  Events::Delete
11
14
 
15
+ @type_registry: TypeRegistry
16
+ @relations: RelationCache
17
+ @row_builder: RowBuilder
18
+ @current_transaction_id: Integer?
19
+ @current_final_lsn: Integer?
20
+ @current_commit_timestamp: Integer?
21
+
12
22
  def initialize: (?type_registry: TypeRegistry) -> void
13
- def decode: (untyped message) -> event?
23
+ def decode: (parser_message message) -> event?
14
24
  def type_registry: () -> TypeRegistry
15
25
 
26
+ private
27
+
28
+ def decode_begin: (parser_message message) -> Events::Begin
29
+ def decode_commit: (parser_message message) -> Events::Commit
30
+ def decode_insert: (parser_message message) -> Events::Insert
31
+ def decode_update: (parser_message message) -> Events::Update
32
+ def decode_delete: (parser_message message) -> Events::Delete
33
+ def optional_row: (relation relation, tuple? tuple, ?key: bool) -> Hash[String, untyped]?
34
+ def relation_for: (Integer relation_id) -> relation
35
+ def require_transaction_id: () -> Integer
36
+ def clear_transaction!: () -> nil
37
+ def parser_messages: () -> untyped
38
+ def share: [T] (T object) -> T
39
+
16
40
  class Error < StandardError
17
41
  end
18
42
 
@@ -93,27 +117,52 @@ module Pgoutput
93
117
  UUID: Integer
94
118
  JSONB: Integer
95
119
 
120
+ type decoder = ^(String raw, Symbol format) -> untyped
121
+
122
+ @decoders: Hash[Integer, decoder]
123
+
96
124
  def self.default: () -> TypeRegistry
97
- def self.default_decoders: () -> Hash[Integer, Proc]
98
- def initialize: (?Hash[Integer, Proc] decoders) -> void
125
+ def self.default_decoders: () -> Hash[Integer, decoder]
126
+ def initialize: (?Hash[Integer, decoder] decoders) -> void
99
127
  def decode: (Integer? oid, String? raw, Symbol format) -> untyped?
100
128
  def with_decoder: (Integer oid) { (String raw, Symbol format) -> untyped } -> TypeRegistry
129
+
130
+ private
131
+
132
+ def self.decode_bool: (String raw, Symbol format) -> bool
133
+ def self.decode_int: (String raw, Symbol format, Integer expected_length, String template) -> (Integer | String)
134
+ def self.decode_float: (String raw, Symbol format, Integer expected_length, String template) -> (Float | String)
135
+ def self.decode_jsonb: (String raw, Symbol format) -> untyped
136
+ def self.decode_uuid_binary: (String raw) -> String
101
137
  end
102
138
 
103
139
  class ValueDecoder
140
+ @type_registry: TypeRegistry
141
+
104
142
  def initialize: (?type_registry: TypeRegistry) -> void
105
143
  def decode: (untyped tuple_value) -> untyped?
106
144
  end
107
145
 
108
146
  class RelationCache
147
+ @relations: Hash[Integer, relation]
148
+
109
149
  def initialize: () -> void
110
- def store: (untyped relation) -> untyped
111
- def fetch: (Integer relation_id) -> untyped
150
+ def store: (relation relation) -> relation
151
+ def fetch: (Integer relation_id) -> relation
112
152
  end
113
153
 
114
154
  class RowBuilder
155
+ @value_decoder: ValueDecoder
156
+
115
157
  def initialize: (?type_registry: TypeRegistry) -> void
116
- def build: (untyped relation, Array[untyped] tuple) -> Hash[String, untyped]
158
+ def build: (relation relation, tuple tuple) -> Hash[String, untyped]
159
+ def build_key: (relation relation, tuple tuple) -> Hash[String, untyped]
160
+
161
+ private
162
+
163
+ def build_columns: (Array[untyped] columns, tuple tuple, ?key_only: bool) -> Hash[String, untyped]
164
+ def key_column?: (untyped column) -> bool
165
+ def normalize_oid: (untyped tuple_value, Integer oid) -> untyped
117
166
  end
118
167
  end
119
168
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgoutput-decoder
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ken C. Demanawa
@@ -10,117 +10,33 @@ cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
- name: pgoutput-parser
13
+ name: bigdecimal
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '0.1'
18
+ version: '4.1'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '0.1'
26
- - !ruby/object:Gem::Dependency
27
- name: minitest
28
- requirement: !ruby/object:Gem::Requirement
29
- requirements:
30
- - - "~>"
31
- - !ruby/object:Gem::Version
32
- version: '5.27'
33
- type: :development
34
- prerelease: false
35
- version_requirements: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - "~>"
38
- - !ruby/object:Gem::Version
39
- version: '5.27'
40
- - !ruby/object:Gem::Dependency
41
- name: pry
42
- requirement: !ruby/object:Gem::Requirement
43
- requirements:
44
- - - "~>"
45
- - !ruby/object:Gem::Version
46
- version: 0.16.0
47
- type: :development
48
- prerelease: false
49
- version_requirements: !ruby/object:Gem::Requirement
50
- requirements:
51
- - - "~>"
52
- - !ruby/object:Gem::Version
53
- version: 0.16.0
25
+ version: '4.1'
54
26
  - !ruby/object:Gem::Dependency
55
- name: rake
56
- requirement: !ruby/object:Gem::Requirement
57
- requirements:
58
- - - "~>"
59
- - !ruby/object:Gem::Version
60
- version: '13.4'
61
- type: :development
62
- prerelease: false
63
- version_requirements: !ruby/object:Gem::Requirement
64
- requirements:
65
- - - "~>"
66
- - !ruby/object:Gem::Version
67
- version: '13.4'
68
- - !ruby/object:Gem::Dependency
69
- name: rubocop
70
- requirement: !ruby/object:Gem::Requirement
71
- requirements:
72
- - - "~>"
73
- - !ruby/object:Gem::Version
74
- version: '1.87'
75
- type: :development
76
- prerelease: false
77
- version_requirements: !ruby/object:Gem::Requirement
78
- requirements:
79
- - - "~>"
80
- - !ruby/object:Gem::Version
81
- version: '1.87'
82
- - !ruby/object:Gem::Dependency
83
- name: simplecov
84
- requirement: !ruby/object:Gem::Requirement
85
- requirements:
86
- - - "~>"
87
- - !ruby/object:Gem::Version
88
- version: 0.22.0
89
- type: :development
90
- prerelease: false
91
- version_requirements: !ruby/object:Gem::Requirement
92
- requirements:
93
- - - "~>"
94
- - !ruby/object:Gem::Version
95
- version: 0.22.0
96
- - !ruby/object:Gem::Dependency
97
- name: steep
98
- requirement: !ruby/object:Gem::Requirement
99
- requirements:
100
- - - "~>"
101
- - !ruby/object:Gem::Version
102
- version: '1.10'
103
- type: :development
104
- prerelease: false
105
- version_requirements: !ruby/object:Gem::Requirement
106
- requirements:
107
- - - "~>"
108
- - !ruby/object:Gem::Version
109
- version: '1.10'
110
- - !ruby/object:Gem::Dependency
111
- name: yard
27
+ name: pgoutput-parser
112
28
  requirement: !ruby/object:Gem::Requirement
113
29
  requirements:
114
30
  - - "~>"
115
31
  - !ruby/object:Gem::Version
116
- version: 0.9.44
117
- type: :development
32
+ version: '0.1'
33
+ type: :runtime
118
34
  prerelease: false
119
35
  version_requirements: !ruby/object:Gem::Requirement
120
36
  requirements:
121
37
  - - "~>"
122
38
  - !ruby/object:Gem::Version
123
- version: 0.9.44
39
+ version: '0.1'
124
40
  description: Decodes pgoutput-parser protocol messages into immutable Ruby row-change
125
41
  events.
126
42
  email:
@@ -141,7 +57,6 @@ files:
141
57
  - lib/pgoutput/decoder/value_decoder.rb
142
58
  - lib/pgoutput/decoder/version.rb
143
59
  - lib/pgoutput_decoder.rb
144
- - sig/pgoutput/decoder.rbs
145
60
  - sig/pgoutput_decoder.rbs
146
61
  homepage: https://github.com/kanutocd/pgoutput-decoder
147
62
  licenses:
@@ -1,6 +0,0 @@
1
- module Pgoutput
2
- module Decoder
3
- VERSION: String
4
- # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
- end
6
- end