paper_trail_diff 0.8.0 → 0.10.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 +4 -4
- data/CHANGELOG.md +40 -0
- data/README.md +78 -1
- data/lib/paper_trail_diff/diagnostics.rb +10 -24
- data/lib/paper_trail_diff/errors.rb +4 -0
- data/lib/paper_trail_diff/instrumentation.rb +8 -0
- data/lib/paper_trail_diff/paper_trail_adapter.rb +22 -0
- data/lib/paper_trail_diff/support.rb +9 -0
- data/lib/paper_trail_diff/traversal_preparer.rb +25 -0
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_sequence_diagnostics.rb +82 -0
- data/lib/paper_trail_diff.rb +1 -0
- data/sig/generated/paper_trail_diff/diagnostics.rbs +1 -7
- data/sig/generated/paper_trail_diff/errors.rbs +5 -0
- data/sig/generated/paper_trail_diff/instrumentation.rbs +5 -0
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +8 -0
- data/sig/generated/paper_trail_diff/support.rbs +7 -0
- data/sig/generated/paper_trail_diff/traversal_preparer.rbs +10 -0
- data/sig/generated/paper_trail_diff/version_sequence_diagnostics.rbs +39 -0
- metadata +6 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 00ac8585544b9421fab5fa7cf677a5eddaae2867058beb2c7958ff0e8f917da5
|
|
4
|
+
data.tar.gz: 52e079e4f84b77ffd804fd452eaf4acc03618d2e7e685969faddcfe2f8c21eba
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 400361a0bd6d3d55b3637a5fdcb42febf04ff62a81b75aba3848f70c62d1865f7983016c31bb34347dc50df5b2f66ccb0e44306e2a31990c3db5c04f3806813e
|
|
7
|
+
data.tar.gz: 0b8497f1dcb9ecd6e12b09e28b7a69fe17363d251308abffb824e8d1d0033572c4bee71351785ed425dc5e46c656d092612c7cb4c1ff33e69ad270d8e2e7260b
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,46 @@
|
|
|
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.10.0] - 2026-08-15
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- Add `rake release:preflight`, which runs the full gate and then checks
|
|
11
|
+
everything that must hold before a tag: a clean working tree, a HEAD that
|
|
12
|
+
matches its upstream, an unused tag, a dated changelog section for the
|
|
13
|
+
version, and that the version is not already on RubyGems. It reports and
|
|
14
|
+
never tags, pushes, or publishes. An unreachable RubyGems is reported as
|
|
15
|
+
unknown rather than treated as "not published".
|
|
16
|
+
- Raise `PaperTrailDiff::UnversionedAssociationError` when a selected
|
|
17
|
+
association's target is not versioned and history is being reconstructed.
|
|
18
|
+
Such a comparison could only ever answer "nothing changed", which is a wrong
|
|
19
|
+
answer rather than an empty one. Live-to-live comparison reads current state
|
|
20
|
+
and is unaffected. `diagnose` now reports the same condition as an error
|
|
21
|
+
rather than a warning, so `ok?` no longer stays true for a comparison that
|
|
22
|
+
will raise.
|
|
23
|
+
- Document ActiveStorage attachments, which this reaches through:
|
|
24
|
+
`has_one_attached` points at models Rails owns and PaperTrail never versions,
|
|
25
|
+
so attachments are audited through a versioned model of your own that mirrors
|
|
26
|
+
the metadata onto ordinary columns.
|
|
27
|
+
|
|
28
|
+
## [0.9.0] - 2026-08-14
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- Report versions that share a timestamp, which hides any association change
|
|
33
|
+
between them: PT-AT records membership per version but resolves it by
|
|
34
|
+
timestamp, so such a pair is indistinguishable even when sequential ids order
|
|
35
|
+
it correctly. `diagnose` reports `:tied_version_timestamps` as a warning when
|
|
36
|
+
`associations:` are selected, and a comparison in that state emits
|
|
37
|
+
`ambiguous_association_boundary.paper_trail_diff`. It warns rather than
|
|
38
|
+
raising because the result may be perfectly correct — if nothing associated
|
|
39
|
+
changed, nothing was lost — and the gem cannot distinguish the cases, since
|
|
40
|
+
not seeing the change is the symptom.
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
- Link the demo application from the README now that it is public.
|
|
45
|
+
|
|
6
46
|
## [0.8.0] - 2026-08-12
|
|
7
47
|
|
|
8
48
|
### 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,80 @@ 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
|
+
|
|
155
|
+
### ActiveStorage attachments
|
|
156
|
+
|
|
157
|
+
`has_one_attached` and `has_many_attached` point at `ActiveStorage::Attachment`
|
|
158
|
+
and `ActiveStorage::Blob`, which Rails owns and PaperTrail does not version.
|
|
159
|
+
There is no history behind them, so a historical comparison over those paths
|
|
160
|
+
raises `PaperTrailDiff::UnversionedAssociationError` rather than reporting that
|
|
161
|
+
nothing changed. Adding `has_paper_trail` to Rails' own models does not rescue
|
|
162
|
+
it either: `has_one_attached` is a scoped `has_one`, and reifying it from
|
|
163
|
+
history is ambiguous.
|
|
164
|
+
|
|
165
|
+
Audit the attachment through a model you own instead. Give it the facts worth
|
|
166
|
+
auditing as ordinary columns, and version it:
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
class DocumentRevision < ApplicationRecord
|
|
170
|
+
belongs_to :attachable, polymorphic: true
|
|
171
|
+
has_one_attached :file
|
|
172
|
+
has_paper_trail
|
|
173
|
+
|
|
174
|
+
# Attaching a file writes to ActiveStorage's tables, not to this record, so
|
|
175
|
+
# nothing would be versioned without copying the facts across.
|
|
176
|
+
after_save :record_file_metadata, if: -> { file.attached? }
|
|
177
|
+
|
|
178
|
+
def record_file_metadata
|
|
179
|
+
blob = file.blob
|
|
180
|
+
return if filename == blob.filename.to_s && checksum == blob.checksum
|
|
181
|
+
|
|
182
|
+
update_columns(
|
|
183
|
+
filename: blob.filename.to_s, content_type: blob.content_type,
|
|
184
|
+
byte_size: blob.byte_size, checksum: blob.checksum
|
|
185
|
+
)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
class Article < ApplicationRecord
|
|
192
|
+
has_many :document_revisions, as: :attachable
|
|
193
|
+
has_paper_trail
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
PaperTrailDiff.compare(before, after, associations: [:document_revisions])
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The bytes stay in ActiveStorage; the *auditable facts* live where PaperTrail
|
|
200
|
+
can see them. Nothing about the column names matters to this gem — it reports
|
|
201
|
+
whichever columns changed. What matters is that replacing a file writes to a
|
|
202
|
+
versioned record, because attaching one on its own does not.
|
|
203
|
+
|
|
127
204
|
## Choosing an entry point
|
|
128
205
|
|
|
129
206
|
| 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
|
-
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
@@ -191,7 +175,9 @@ module PaperTrailDiff
|
|
|
191
175
|
def inspect_versioned_model(model_class, path)
|
|
192
176
|
return if model_versioned?(model_class)
|
|
193
177
|
|
|
194
|
-
|
|
178
|
+
# An error rather than a warning: `ok?` must not stay true for a
|
|
179
|
+
# comparison that cannot work, and this one raises at runtime.
|
|
180
|
+
add_error(
|
|
195
181
|
:unversioned_association_target,
|
|
196
182
|
"#{model_class.name} does not appear to have PaperTrail enabled",
|
|
197
183
|
path
|
|
@@ -35,6 +35,10 @@ module PaperTrailDiff
|
|
|
35
35
|
# Raised when a requested ActiveRecord association does not exist.
|
|
36
36
|
class UnknownAssociationError < Error; end
|
|
37
37
|
|
|
38
|
+
# Raised when a selected association's target is not versioned, so its history
|
|
39
|
+
# cannot be reconstructed at all.
|
|
40
|
+
class UnversionedAssociationError < Error; end
|
|
41
|
+
|
|
38
42
|
# Raised when a requested association macro is not supported.
|
|
39
43
|
class UnsupportedAssociationError < Error; end
|
|
40
44
|
|
|
@@ -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/)
|
|
@@ -17,6 +17,7 @@ module PaperTrailDiff
|
|
|
17
17
|
|
|
18
18
|
ensure_association_tracking! if historical
|
|
19
19
|
@traversal.validate!(model_class)
|
|
20
|
+
ensure_versioned_targets!(model_class) if historical
|
|
20
21
|
end
|
|
21
22
|
|
|
22
23
|
private
|
|
@@ -24,6 +25,30 @@ module PaperTrailDiff
|
|
|
24
25
|
# @rbs @tree: AssociationTree
|
|
25
26
|
# @rbs @traversal: AssociationTraversal
|
|
26
27
|
|
|
28
|
+
# A model PaperTrail never versioned has no history to reconstruct, so a
|
|
29
|
+
# comparison over it can only ever answer "nothing changed" -- which is a
|
|
30
|
+
# wrong answer rather than an empty one. Live-to-live comparison reads
|
|
31
|
+
# current state and is unaffected, so this applies to historical work only.
|
|
32
|
+
#: (untyped) -> void
|
|
33
|
+
def ensure_versioned_targets!(model_class)
|
|
34
|
+
@traversal.selected_reflections(model_class).each do |path, reflection|
|
|
35
|
+
# A polymorphic target is not known until a row names it; `diagnose`
|
|
36
|
+
# reports that separately rather than guessing here.
|
|
37
|
+
next if reflection.polymorphic?
|
|
38
|
+
next if versioned?(reflection.klass)
|
|
39
|
+
|
|
40
|
+
raise UnversionedAssociationError,
|
|
41
|
+
"association #{path} cannot be compared historically: " \
|
|
42
|
+
"#{reflection.klass.name} is not versioned. Add `has_paper_trail` to it, " \
|
|
43
|
+
'or mirror the fields you need onto a model that has it.'
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
#: (untyped) -> bool
|
|
48
|
+
def versioned?(model_class)
|
|
49
|
+
model_class.respond_to?(:paper_trail_options) && !model_class.paper_trail_options.nil?
|
|
50
|
+
end
|
|
51
|
+
|
|
27
52
|
#: () -> void
|
|
28
53
|
def ensure_association_tracking!
|
|
29
54
|
paper_trail = Object.const_get(:PaperTrail) #: untyped
|
|
@@ -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
|
data/lib/paper_trail_diff.rb
CHANGED
|
@@ -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
|
|
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
|
|
@@ -45,6 +45,11 @@ module PaperTrailDiff
|
|
|
45
45
|
class UnknownAssociationError < Error
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
+
# Raised when a selected association's target is not versioned, so its history
|
|
49
|
+
# cannot be reconstructed at all.
|
|
50
|
+
class UnversionedAssociationError < Error
|
|
51
|
+
end
|
|
52
|
+
|
|
48
53
|
# Raised when a requested association macro is not supported.
|
|
49
54
|
class UnsupportedAssociationError < Error
|
|
50
55
|
end
|
|
@@ -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
|
|
|
@@ -16,6 +16,16 @@ module PaperTrailDiff
|
|
|
16
16
|
|
|
17
17
|
@tree: AssociationTree
|
|
18
18
|
|
|
19
|
+
# A model PaperTrail never versioned has no history to reconstruct, so a
|
|
20
|
+
# comparison over it can only ever answer "nothing changed" -- which is a
|
|
21
|
+
# wrong answer rather than an empty one. Live-to-live comparison reads
|
|
22
|
+
# current state and is unaffected, so this applies to historical work only.
|
|
23
|
+
# : (untyped) -> void
|
|
24
|
+
def ensure_versioned_targets!: (untyped) -> void
|
|
25
|
+
|
|
26
|
+
# : (untyped) -> bool
|
|
27
|
+
def versioned?: (untyped) -> bool
|
|
28
|
+
|
|
19
29
|
# : () -> void
|
|
20
30
|
def ensure_association_tracking!: () -> void
|
|
21
31
|
end
|
|
@@ -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.
|
|
4
|
+
version: 0.10.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.
|
|
204
|
-
documentation_uri: https://github.com/aheathwilliams/paper_trail_diff/blob/v0.
|
|
205
|
+
changelog_uri: https://github.com/aheathwilliams/paper_trail_diff/blob/v0.10.0/CHANGELOG.md
|
|
206
|
+
documentation_uri: https://github.com/aheathwilliams/paper_trail_diff/blob/v0.10.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.
|
|
209
|
+
source_code_uri: https://github.com/aheathwilliams/paper_trail_diff/tree/v0.10.0
|
|
208
210
|
rdoc_options: []
|
|
209
211
|
require_paths:
|
|
210
212
|
- lib
|