paper_trail_diff 0.8.0 → 0.9.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: 41f2b97428f5dc66c22e29bb1a01cdda0beb1ad29eafe5f86ce8809fb1c61bc0
4
- data.tar.gz: 9f6ef5ebef159decfaf875b6df4e6341f6243c9431085b65a67987baa674191f
3
+ metadata.gz: 1ee80500dbeb48421e7b0e0766a5f5559b73aa71528360a5f1a16866f9e6ccfa
4
+ data.tar.gz: 6692d3fd2d725918e16da15b4d39312b5e1a42deba3aa8e50a269abfd9fc6bd9
5
5
  SHA512:
6
- metadata.gz: 28d69434ddf8c9886f1785dc665a54040c38721e1aae84738f50668f2cf677cd39f8a3f9870cc06f3627a0c9f134b935a26dceb2f50c2eff8634bd2dc52bbc8f
7
- data.tar.gz: 704c54404a71a76a9deaa6f3e7d03b425763fdce010b240f8e24e4a293d8656c448a11bcf0e5eabcf6e3d0c2c7bb83b6d277acad83e27a7a749bdb1f0116edf5
6
+ metadata.gz: f809075c65c923ca0dd828e8337477d8461dfa13b2de537a9f99548f0826f867379a33c8bf4a5061476dabd1685829adc5ab21e07052a4d40ebfff4736a44946
7
+ data.tar.gz: 6212fab6601c900b2701a9401e7e9b66c5236f12da0340788f0641efef759340a29ea6f95e7b51d86c2792d847f5553db8f30d7c96e4370e37ceec0b8a227656
data/CHANGELOG.md CHANGED
@@ -3,6 +3,24 @@
3
3
  All notable changes to this project will be documented in this file. The
4
4
  project follows [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [0.9.0] - 2026-08-14
7
+
8
+ ### Added
9
+
10
+ - Report versions that share a timestamp, which hides any association change
11
+ between them: PT-AT records membership per version but resolves it by
12
+ timestamp, so such a pair is indistinguishable even when sequential ids order
13
+ it correctly. `diagnose` reports `:tied_version_timestamps` as a warning when
14
+ `associations:` are selected, and a comparison in that state emits
15
+ `ambiguous_association_boundary.paper_trail_diff`. It warns rather than
16
+ raising because the result may be perfectly correct — if nothing associated
17
+ changed, nothing was lost — and the gem cannot distinguish the cases, since
18
+ not seeing the change is the symptom.
19
+
20
+ ### Changed
21
+
22
+ - Link the demo application from the README now that it is public.
23
+
6
24
  ## [0.8.0] - 2026-08-12
7
25
 
8
26
  ### Added
data/README.md CHANGED
@@ -29,7 +29,10 @@ which fetches what it needs on first run:
29
29
  ruby demo.rb
30
30
  ```
31
31
 
32
- Then start with the copyable [Quickstart](QUICKSTART.md).
32
+ Then start with the copyable [Quickstart](QUICKSTART.md), or browse
33
+ [the demo application](https://github.com/aheathwilliams/paper_trail_summary_test_app)
34
+ — a small Rails app that installs a published release and exercises scalar,
35
+ nested, through-association, and HABTM history against it.
33
36
 
34
37
  ## Installation
35
38
 
@@ -124,6 +127,31 @@ running rather than after a surprise.
124
127
  Either half alone is fine. Sequential ids order tied timestamps correctly, and
125
128
  distinct timestamps never reach the fallback.
126
129
 
130
+ ### Association changes across a shared timestamp
131
+
132
+ Sequential ids recover the *order* of tied versions, but not their association
133
+ state. PT-AT records association membership per version and resolves it by
134
+ timestamp, so two versions sharing one are indistinguishable for that purpose:
135
+ **an association change between them is invisible.** It is not reported as
136
+ absent — the association simply does not appear in the diff.
137
+
138
+ Unlike an unorderable sequence this is not always wrong. If nothing associated
139
+ changed between the two versions, the result is correct, and the gem cannot
140
+ tell the two cases apart, because not seeing the change is the symptom. So it
141
+ does not raise. Instead:
142
+
143
+ - `diagnose` reports `:tied_version_timestamps` as a **warning** whenever
144
+ `associations:` are selected and versions in range share a timestamp.
145
+ - a comparison in that state emits
146
+ `ambiguous_association_boundary.paper_trail_diff` through ActiveSupport
147
+ notifications, carrying the two version ids and the shared timestamp. Nothing
148
+ is logged; subscribe if you want it surfaced.
149
+
150
+ Writing several versions inside one clock tick is easy to do — a service object
151
+ that touches a record and its children in quick succession will manage it — so
152
+ this is worth checking before trusting an association history. Recording
153
+ versions at sub-second precision separates them.
154
+
127
155
  ## Choosing an entry point
128
156
 
129
157
  | You need | Call |
@@ -79,7 +79,7 @@ module PaperTrailDiff
79
79
  # Runs whether or not associations are selected: unorderable versions
80
80
  # corrupt a scalar timeline just as surely, and a report that inspected
81
81
  # nothing has no business answering `ok?`.
82
- inspect_version_order
82
+ inspect_version_sequence
83
83
  return DiagnosticReport.new(issues: @issues) if @tree.empty?
84
84
 
85
85
  unless association_tracking_available?
@@ -136,29 +136,13 @@ module PaperTrailDiff
136
136
  inspect_transaction_metadata(paths)
137
137
  end
138
138
 
139
- # Ordering falls back to the id when timestamps tie, which only recovers the
140
- # real sequence for ids that increase with insertion. Reported before a run
141
- # rather than after a wrong answer.
142
139
  #: () -> void
143
- def inspect_version_order
144
- pair = Support.ambiguous_pair(ordered_range_versions)
145
- return unless pair
146
-
147
- add_error(:ambiguous_version_order, Support.ambiguous_message(pair), nil, pair.first.id)
148
- rescue StandardError
149
- nil
150
- end
151
-
152
- #: () -> Array[untyped]
153
- def ordered_range_versions
154
- bounds = [@from_version.created_at, @to_version.created_at].compact.sort
155
- return [] unless bounds.length == 2
156
-
157
- @from_version.class
158
- .where(item_type: @from_version.item_type, item_id: @from_version.item_id)
159
- .where(created_at: bounds.first..bounds.last)
160
- .to_a
161
- .sort_by { |version| Support.chronological_version_key(version) }
140
+ def inspect_version_sequence
141
+ @issues.concat(
142
+ VersionSequenceDiagnostics.new(
143
+ @from_version, @to_version, associations_selected: !@tree.empty?
144
+ ).call
145
+ )
162
146
  end
163
147
 
164
148
  #: (untyped) -> void
@@ -19,6 +19,14 @@ module PaperTrailDiff
19
19
  )
20
20
  end
21
21
 
22
+ # Reports a condition rather than timing work: nothing failed, but the
23
+ # result may be incomplete and only the application can judge that.
24
+ #: (String | Symbol, Hash[Symbol, untyped]) -> void
25
+ def notify(event, payload)
26
+ instrument(event, payload) { nil }
27
+ nil
28
+ end
29
+
22
30
  #: (association_paths: Array[String], reload_live_endpoints: bool) -> Hash[Symbol, untyped]
23
31
  def comparison_payload(association_paths:, reload_live_endpoints:)
24
32
  {
@@ -33,6 +33,7 @@ module PaperTrailDiff
33
33
  payload = @instrumentation_payload.merge(comparison_count: 1)
34
34
  Instrumentation.instrument('compare', payload) do
35
35
  Endpoint.validate_pair!(from_endpoint, to_endpoint)
36
+ notify_ambiguous_association_boundary(from_endpoint, to_endpoint)
36
37
  Engine.compare(snapshot_for_endpoint(from_endpoint), snapshot_for_endpoint(to_endpoint))
37
38
  end
38
39
  end
@@ -141,6 +142,27 @@ module PaperTrailDiff
141
142
  ).analyze
142
143
  end
143
144
 
145
+ # Association membership is resolved by timestamp, so endpoints sharing one
146
+ # cannot be told apart and any association change between them is invisible.
147
+ # The result may still be correct -- nothing associated may have changed --
148
+ # and the gem cannot tell which, since not seeing the change is the symptom.
149
+ # So it reports the condition and leaves the judgement to the application.
150
+ #: (untyped, untyped) -> void
151
+ def notify_ambiguous_association_boundary(from_endpoint, to_endpoint)
152
+ return if @association_tree.empty?
153
+ return unless Endpoint.version?(from_endpoint) && Endpoint.version?(to_endpoint)
154
+ return unless from_endpoint.created_at == to_endpoint.created_at
155
+
156
+ Instrumentation.notify(
157
+ 'ambiguous_association_boundary',
158
+ @instrumentation_payload.merge(
159
+ item_type: from_endpoint.item_type.to_s,
160
+ version_ids: [from_endpoint.id, to_endpoint.id].freeze,
161
+ recorded_at: from_endpoint.created_at
162
+ )
163
+ )
164
+ end
165
+
144
166
  # `close_on:` names what ends a wall-clock window, so it is meaningless for a
145
167
  # range whose endpoints the caller already gave explicitly.
146
168
  #: (Symbol?, untyped) -> bool
@@ -72,6 +72,15 @@ module PaperTrailDiff
72
72
  end
73
73
  end
74
74
 
75
+ # Versions sharing a timestamp, whether or not their ids order them. PT-AT
76
+ # indexes association membership per version but resolves it by timestamp,
77
+ # so association state cannot be told apart across such a pair even when the
78
+ # scalar sequence is perfectly recoverable.
79
+ #: (Array[untyped]) -> Array[untyped]?
80
+ def tied_timestamp_pair(versions)
81
+ versions.each_cons(2).find { |left, right| left.created_at == right.created_at }
82
+ end
83
+
75
84
  #: (untyped) -> bool
76
85
  def sequential_id?(id)
77
86
  id.is_a?(Integer) || id.to_s.match?(/\A\d+\z/)
@@ -2,5 +2,5 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module PaperTrailDiff
5
- VERSION = '0.8.0'
5
+ VERSION = '0.9.0'
6
6
  end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Checks the recorded sequence itself, before any question of association
6
+ # setup: whether the versions can be ordered at all, and whether timestamps
7
+ # they share hide changes that ordering alone cannot recover.
8
+ class VersionSequenceDiagnostics
9
+ #: (untyped, untyped, ?associations_selected: bool) -> void
10
+ def initialize(from_version, to_version, associations_selected: false)
11
+ @from_version = from_version
12
+ @to_version = to_version
13
+ @associations_selected = associations_selected
14
+ end
15
+
16
+ #: () -> Array[DiagnosticIssue]
17
+ def call
18
+ versions = ordered_range_versions
19
+ issues = [] #: Array[DiagnosticIssue?]
20
+ issues << unorderable(versions)
21
+ issues << tied_timestamps(versions) if @associations_selected
22
+ issues.compact
23
+ rescue StandardError
24
+ []
25
+ end
26
+
27
+ private
28
+
29
+ # @rbs @from_version: untyped
30
+ # @rbs @to_version: untyped
31
+ # @rbs @associations_selected: bool
32
+
33
+ # Ordering falls back to the id when timestamps tie, which only recovers the
34
+ # real sequence for ids that increase with insertion. Reported before a run
35
+ # rather than after a wrong answer.
36
+ #: (Array[untyped]) -> DiagnosticIssue?
37
+ def unorderable(versions)
38
+ pair = Support.ambiguous_pair(versions)
39
+ return unless pair
40
+
41
+ DiagnosticIssue.new(
42
+ severity: :error,
43
+ code: :ambiguous_version_order,
44
+ message: Support.ambiguous_message(pair),
45
+ version_id: pair.first.id
46
+ )
47
+ end
48
+
49
+ # Association membership is recorded per version but resolved by timestamp,
50
+ # so a tie hides any association change across that pair. Unlike an
51
+ # unorderable sequence this is not always wrong: if nothing associated
52
+ # changed between them the result is correct, and the gem cannot tell which
53
+ # it is, because not seeing the change is the symptom. So it warns.
54
+ #: (Array[untyped]) -> DiagnosticIssue?
55
+ def tied_timestamps(versions)
56
+ pair = Support.tied_timestamp_pair(versions)
57
+ return unless pair
58
+
59
+ left, right = pair
60
+ DiagnosticIssue.new(
61
+ severity: :warning,
62
+ code: :tied_version_timestamps,
63
+ message: "versions #{left.id.inspect} and #{right.id.inspect} share the timestamp " \
64
+ "#{left.created_at.inspect}, so association changes between them cannot " \
65
+ 'be detected; record versions at sub-second precision to separate them',
66
+ version_id: left.id
67
+ )
68
+ end
69
+
70
+ #: () -> Array[untyped]
71
+ def ordered_range_versions
72
+ bounds = [@from_version.created_at, @to_version.created_at].compact.sort
73
+ return [] unless bounds.length == 2
74
+
75
+ @from_version.class
76
+ .where(item_type: @from_version.item_type, item_id: @from_version.item_id)
77
+ .where(created_at: bounds.first..bounds.last)
78
+ .to_a
79
+ .sort_by { |version| Support.chronological_version_key(version) }
80
+ end
81
+ end
82
+ end
@@ -65,6 +65,7 @@ require_relative 'paper_trail_diff/activity_root_steps'
65
65
  require_relative 'paper_trail_diff/analysis_batch'
66
66
  require_relative 'paper_trail_diff/batched_root_analyzer'
67
67
  require_relative 'paper_trail_diff/version_range'
68
+ require_relative 'paper_trail_diff/version_sequence_diagnostics'
68
69
  require_relative 'paper_trail_diff/time_range'
69
70
  require_relative 'paper_trail_diff/time_version_range'
70
71
  require_relative 'paper_trail_diff/timeline_range'
@@ -67,14 +67,8 @@ module PaperTrailDiff
67
67
  # : (Array[Array[untyped]]) -> void
68
68
  def inspect_habtm: (Array[Array[untyped]]) -> void
69
69
 
70
- # Ordering falls back to the id when timestamps tie, which only recovers the
71
- # real sequence for ids that increase with insertion. Reported before a run
72
- # rather than after a wrong answer.
73
70
  # : () -> void
74
- def inspect_version_order: () -> void
75
-
76
- # : () -> Array[untyped]
77
- def ordered_range_versions: () -> Array[untyped]
71
+ def inspect_version_sequence: () -> void
78
72
 
79
73
  # : (untyped) -> void
80
74
  def inspect_checkpoint_timestamp: (untyped) -> void
@@ -6,6 +6,11 @@ module PaperTrailDiff
6
6
  # : (String | Symbol, Hash[Symbol, untyped]) { () -> untyped } -> untyped
7
7
  def self?.instrument: (String | Symbol, Hash[Symbol, untyped]) { () -> untyped } -> untyped
8
8
 
9
+ # Reports a condition rather than timing work: nothing failed, but the
10
+ # result may be incomplete and only the application can judge that.
11
+ # : (String | Symbol, Hash[Symbol, untyped]) -> void
12
+ def self?.notify: (String | Symbol, Hash[Symbol, untyped]) -> void
13
+
9
14
  # : (association_paths: Array[String], reload_live_endpoints: bool) -> Hash[Symbol, untyped]
10
15
  def self?.comparison_payload: (association_paths: Array[String], reload_live_endpoints: bool) -> Hash[Symbol, untyped]
11
16
  end
@@ -59,6 +59,14 @@ module PaperTrailDiff
59
59
  # : (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped, ?snapshots: bool) -> Analysis
60
60
  def analyze_activity: (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped, ?snapshots: bool) -> Analysis
61
61
 
62
+ # Association membership is resolved by timestamp, so endpoints sharing one
63
+ # cannot be told apart and any association change between them is invisible.
64
+ # The result may still be correct -- nothing associated may have changed --
65
+ # and the gem cannot tell which, since not seeing the change is the symptom.
66
+ # So it reports the condition and leaves the judgement to the application.
67
+ # : (untyped, untyped) -> void
68
+ def notify_ambiguous_association_boundary: (untyped, untyped) -> void
69
+
62
70
  # `close_on:` names what ends a wall-clock window, so it is meaningless for a
63
71
  # range whose endpoints the caller already gave explicitly.
64
72
  # : (Symbol?, untyped) -> bool
@@ -25,6 +25,13 @@ module PaperTrailDiff
25
25
  # : (Array[untyped]) -> Array[untyped]?
26
26
  def self?.ambiguous_pair: (Array[untyped]) -> Array[untyped]?
27
27
 
28
+ # Versions sharing a timestamp, whether or not their ids order them. PT-AT
29
+ # indexes association membership per version but resolves it by timestamp,
30
+ # so association state cannot be told apart across such a pair even when the
31
+ # scalar sequence is perfectly recoverable.
32
+ # : (Array[untyped]) -> Array[untyped]?
33
+ def self?.tied_timestamp_pair: (Array[untyped]) -> Array[untyped]?
34
+
28
35
  # : (untyped) -> bool
29
36
  def self?.sequential_id?: (untyped) -> bool
30
37
 
@@ -0,0 +1,39 @@
1
+ # Generated from lib/paper_trail_diff/version_sequence_diagnostics.rb with RBS::Inline
2
+
3
+ module PaperTrailDiff
4
+ # Checks the recorded sequence itself, before any question of association
5
+ # setup: whether the versions can be ordered at all, and whether timestamps
6
+ # they share hide changes that ordering alone cannot recover.
7
+ class VersionSequenceDiagnostics
8
+ # : (untyped, untyped, ?associations_selected: bool) -> void
9
+ def initialize: (untyped, untyped, ?associations_selected: bool) -> void
10
+
11
+ # : () -> Array[DiagnosticIssue]
12
+ def call: () -> Array[DiagnosticIssue]
13
+
14
+ private
15
+
16
+ @associations_selected: bool
17
+
18
+ @from_version: untyped
19
+
20
+ @to_version: untyped
21
+
22
+ # Ordering falls back to the id when timestamps tie, which only recovers the
23
+ # real sequence for ids that increase with insertion. Reported before a run
24
+ # rather than after a wrong answer.
25
+ # : (Array[untyped]) -> DiagnosticIssue?
26
+ def unorderable: (Array[untyped]) -> DiagnosticIssue?
27
+
28
+ # Association membership is recorded per version but resolved by timestamp,
29
+ # so a tie hides any association change across that pair. Unlike an
30
+ # unorderable sequence this is not always wrong: if nothing associated
31
+ # changed between them the result is correct, and the gem cannot tell which
32
+ # it is, because not seeing the change is the symptom. So it warns.
33
+ # : (Array[untyped]) -> DiagnosticIssue?
34
+ def tied_timestamps: (Array[untyped]) -> DiagnosticIssue?
35
+
36
+ # : () -> Array[untyped]
37
+ def ordered_range_versions: () -> Array[untyped]
38
+ end
39
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: paper_trail_diff
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex Williams
@@ -118,6 +118,7 @@ files:
118
118
  - lib/paper_trail_diff/version_association_candidate_scope.rb
119
119
  - lib/paper_trail_diff/version_range.rb
120
120
  - lib/paper_trail_diff/version_scope_filter.rb
121
+ - lib/paper_trail_diff/version_sequence_diagnostics.rb
121
122
  - sig/generated/paper_trail_diff.rbs
122
123
  - sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs
123
124
  - sig/generated/paper_trail_diff/activity_boundary.rbs
@@ -194,17 +195,18 @@ files:
194
195
  - sig/generated/paper_trail_diff/version_association_candidate_scope.rbs
195
196
  - sig/generated/paper_trail_diff/version_range.rbs
196
197
  - sig/generated/paper_trail_diff/version_scope_filter.rbs
198
+ - sig/generated/paper_trail_diff/version_sequence_diagnostics.rbs
197
199
  homepage: https://github.com/aheathwilliams/paper_trail_diff
198
200
  licenses:
199
201
  - MIT
200
202
  metadata:
201
203
  allowed_push_host: https://rubygems.org
202
204
  bug_tracker_uri: https://github.com/aheathwilliams/paper_trail_diff/issues
203
- changelog_uri: https://github.com/aheathwilliams/paper_trail_diff/blob/v0.8.0/CHANGELOG.md
204
- documentation_uri: https://github.com/aheathwilliams/paper_trail_diff/blob/v0.8.0/README.md
205
+ changelog_uri: https://github.com/aheathwilliams/paper_trail_diff/blob/v0.9.0/CHANGELOG.md
206
+ documentation_uri: https://github.com/aheathwilliams/paper_trail_diff/blob/v0.9.0/README.md
205
207
  homepage_uri: https://github.com/aheathwilliams/paper_trail_diff
206
208
  rubygems_mfa_required: 'true'
207
- source_code_uri: https://github.com/aheathwilliams/paper_trail_diff/tree/v0.8.0
209
+ source_code_uri: https://github.com/aheathwilliams/paper_trail_diff/tree/v0.9.0
208
210
  rdoc_options: []
209
211
  require_paths:
210
212
  - lib