paper_trail_diff 0.7.1 → 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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +65 -0
  3. data/QUICKSTART.md +27 -2
  4. data/README.md +104 -2
  5. data/lib/paper_trail_diff/activity_boundary.rb +25 -2
  6. data/lib/paper_trail_diff/activity_history.rb +9 -6
  7. data/lib/paper_trail_diff/activity_timeline_builder.rb +12 -8
  8. data/lib/paper_trail_diff/diagnostics.rb +14 -1
  9. data/lib/paper_trail_diff/errors.rb +3 -0
  10. data/lib/paper_trail_diff/instrumentation.rb +8 -0
  11. data/lib/paper_trail_diff/paper_trail_adapter.rb +37 -14
  12. data/lib/paper_trail_diff/prepared_record_index.rb +1 -1
  13. data/lib/paper_trail_diff/root_version_selection.rb +1 -2
  14. data/lib/paper_trail_diff/support.rb +44 -0
  15. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +11 -7
  16. data/lib/paper_trail_diff/time_version_range.rb +1 -1
  17. data/lib/paper_trail_diff/timeline_snapshot_provider.rb +3 -6
  18. data/lib/paper_trail_diff/version.rb +1 -1
  19. data/lib/paper_trail_diff/version_range.rb +1 -1
  20. data/lib/paper_trail_diff/version_sequence_diagnostics.rb +82 -0
  21. data/lib/paper_trail_diff.rb +11 -6
  22. data/sig/generated/paper_trail_diff/activity_boundary.rbs +17 -2
  23. data/sig/generated/paper_trail_diff/activity_history.rbs +4 -2
  24. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +4 -2
  25. data/sig/generated/paper_trail_diff/diagnostics.rbs +3 -0
  26. data/sig/generated/paper_trail_diff/errors.rbs +4 -0
  27. data/sig/generated/paper_trail_diff/instrumentation.rbs +5 -0
  28. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +16 -8
  29. data/sig/generated/paper_trail_diff/support.rbs +23 -0
  30. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +4 -2
  31. data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +2 -2
  32. data/sig/generated/paper_trail_diff/version_sequence_diagnostics.rbs +39 -0
  33. data/sig/generated/paper_trail_diff.rbs +4 -4
  34. metadata +6 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a84116670d1dd65a6cf569a266ccc8587a9eab4e65d38cf84df485c26d33d9dc
4
- data.tar.gz: 2c8ddb0fd1b2203e644f3c50ed24da33ed25cb4c4240665eab85368cd4534f22
3
+ metadata.gz: 1ee80500dbeb48421e7b0e0766a5f5559b73aa71528360a5f1a16866f9e6ccfa
4
+ data.tar.gz: 6692d3fd2d725918e16da15b4d39312b5e1a42deba3aa8e50a269abfd9fc6bd9
5
5
  SHA512:
6
- metadata.gz: 3e32ecbedd636456388129cc664cf688b0aa909d5c05b45124eb1a6bd82dabbffa96557fe5efc946e93fe459234d3c4c5ce9af11f39228ddab55228f5a53e2f9
7
- data.tar.gz: 1b29680ab942334fd98ce5cc0a4f2bc823fd180b258282e8be2c07ba784c9c3b5aa9edc76e0e118fec3baabcfbc94344c208aef116702874c0eb4dd226cddcac
6
+ metadata.gz: f809075c65c923ca0dd828e8337477d8461dfa13b2de537a9f99548f0826f867379a33c8bf4a5061476dabd1685829adc5ab21e07052a4d40ebfff4736a44946
7
+ data.tar.gz: 6212fab6601c900b2701a9401e7e9b66c5236f12da0340788f0641efef759340a29ea6f95e7b51d86c2792d847f5553db8f30d7c96e4370e37ceec0b8a227656
data/CHANGELOG.md CHANGED
@@ -3,6 +3,71 @@
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
+
24
+ ## [0.8.0] - 2026-08-12
25
+
26
+ ### Added
27
+
28
+ - Accept `snapshots: true` on `activity_timeline` and `analyze(activity: true)`,
29
+ retaining the reconstructed states each step was compared between as
30
+ `ActivityStep#from_snapshot` and `#to_snapshot`. A diff names what changed; a
31
+ renderer that has to name an unchanged field of a changed record needed the
32
+ whole state and had no way to reach it, so consumers were rebuilding it from
33
+ the version table by hand — slower, and easy to get wrong in ways that stay
34
+ quiet. The gem already builds these states to compute each diff and discarded
35
+ them, so the option costs no extra queries. Off by default because each one
36
+ holds the whole selected graph.
37
+ - Add `examples/demo.rb`, a self-contained tour that needs no application: it
38
+ builds an in-memory database, writes a small multi-author history, and prints
39
+ an endpoint diff, a checkpoint timeline, per-person attribution across a
40
+ nested record, and one person's changes alone. It runs standalone, inside a
41
+ project, or under `bundle exec`, fetching what it needs on first run.
42
+
43
+ - Raise `PaperTrailDiff::AmbiguousVersionOrderError` when versions sharing a
44
+ timestamp have ids that cannot order them. Ordering falls back to the id when
45
+ timestamps tie, which recovers the real sequence only while ids increase with
46
+ insertion; a UUID version id does not, and a MySQL `datetime` column stores
47
+ whole seconds, so the two together are ordinary rather than exotic. The gem
48
+ previously returned a plausible-looking timeline with steps in the wrong
49
+ order and others dropped. `diagnose` reports the same condition as an error
50
+ so it can be caught before a run rather than after a wrong answer.
51
+ - Document the model and schema shapes the suite covers: single-table
52
+ inheritance (results are keyed by the base class, so build keys with
53
+ `Endpoint.identity`), non-integer primary keys, and a custom version class
54
+ via `has_paper_trail versions: { class_name: }`.
55
+
56
+ ### Changed
57
+
58
+ - Create the models it uses in the quickstart. It declared `has_paper_trail` on
59
+ an `Article` and then used `Comment` without ever generating either, so
60
+ following it in a new application failed on a missing table at the first
61
+ console step.
62
+ - Run `diagnose`'s version-order check whether or not `associations:` are
63
+ selected, so a report never answers `ok?` having inspected nothing. Document
64
+ that `ok?` means no errors among the checks that ran.
65
+
66
+ ### Fixed
67
+
68
+ - Cover `Analysis#to_h(snapshots: true)` with specs. It was shipped in 0.6.0
69
+ with no test at all.
70
+
6
71
  ## [0.7.1] - 2026-08-11
7
72
 
8
73
  ### Fixed
data/QUICKSTART.md CHANGED
@@ -4,6 +4,18 @@ This guide gets `paper_trail_diff` running in a Rails application and shows the
4
4
  smallest useful examples. Ruby 3.1 or newer and PaperTrail 16 or 17 are
5
5
  supported.
6
6
 
7
+ Every command and console snippet below is meant to be run in order against a
8
+ scratch application, so nothing here assumes models you already have:
9
+
10
+ ```console
11
+ rails new diff-demo --minimal
12
+ cd diff-demo
13
+ ```
14
+
15
+ **Just want to see it work?** [`examples/demo.rb`](examples/demo.rb) is a single
16
+ self-contained file — no application, no migrations, nothing to undo. Download
17
+ it anywhere and run `ruby demo.rb`; it fetches what it needs on first run.
18
+
7
19
  ## 1. Install the gem
8
20
 
9
21
  From the Rails application directory:
@@ -21,6 +33,13 @@ reified state rather than a PaperTrail changeset.
21
33
 
22
34
  ## 2. Version a model
23
35
 
36
+ Create the model this guide uses, then declare it versioned:
37
+
38
+ ```console
39
+ bin/rails generate model Article title:string
40
+ bin/rails db:migrate
41
+ ```
42
+
24
43
  ```ruby
25
44
  # app/models/article.rb
26
45
  class Article < ApplicationRecord
@@ -195,8 +214,14 @@ bin/rails db:migrate
195
214
  ```
196
215
 
197
216
  The generator creates `version_associations` and enables
198
- `PaperTrail.config.track_associations`. Every model whose historical state is
199
- needed must be versioned:
217
+ `PaperTrail.config.track_associations`. This section also needs a second model:
218
+
219
+ ```console
220
+ bin/rails generate model Comment article:references body:string
221
+ bin/rails db:migrate
222
+ ```
223
+
224
+ Every model whose historical state is needed must be versioned:
200
225
 
201
226
  ```ruby
202
227
  class Article < ApplicationRecord
data/README.md CHANGED
@@ -21,7 +21,18 @@ available when
21
21
 
22
22
  Ruby 3.1 or newer and PaperTrail 16 or 17 are supported.
23
23
 
24
- New to the gem? Start with the copyable [Quickstart](QUICKSTART.md).
24
+ New to the gem? Run [`examples/demo.rb`](examples/demo.rb) to see it work in
25
+ about ten seconds — one self-contained file, no application and no migrations,
26
+ which fetches what it needs on first run:
27
+
28
+ ```console
29
+ ruby demo.rb
30
+ ```
31
+
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.
25
36
 
26
37
  ## Installation
27
38
 
@@ -83,7 +94,63 @@ for. Three consequences run through the rest of this document:
83
94
  both timeline APIs return steps rather than events.
84
95
 
85
96
  The [Quickstart](QUICKSTART.md) walks through the same idea against a real
86
- console session.
97
+ console session, creating the models it uses as it goes.
98
+
99
+ ## Which models and schemas work
100
+
101
+ Anything `has_paper_trail` tracks, including these, which are covered by the
102
+ test suite rather than assumed:
103
+
104
+ - **Single-table inheritance.** Results are keyed by the *base* class, matching
105
+ what PaperTrail writes to `item_type`. Analyzing a `Book < Publication`
106
+ produces the key `["Publication", "12"]`, so `results.fetch(["Book", ...])`
107
+ raises `KeyError`. Build keys with `PaperTrailDiff::Endpoint.identity(record)`
108
+ rather than by hand.
109
+ - **Non-integer primary keys** on the tracked model, such as a UUID column.
110
+ - **A custom version class**, via `has_paper_trail versions: { class_name: }`.
111
+ Nothing here references `PaperTrail::Version` directly.
112
+
113
+ ### Versions that cannot be ordered
114
+
115
+ Versions are ordered by `created_at`, falling back to the id when timestamps
116
+ tie. That fallback recovers the real sequence only while ids increase with
117
+ insertion. An autoincrement id does; a **UUID version id does not**.
118
+
119
+ So two conditions together are unsafe: version ids that are not sequential,
120
+ *and* versions sharing a timestamp — which is ordinary on a MySQL `datetime`
121
+ column, since it stores whole seconds. The order is then unrecoverable, and
122
+ rather than report a plausible-looking history in the wrong order the gem
123
+ raises `PaperTrailDiff::AmbiguousVersionOrderError` naming both versions.
124
+ `diagnose` reports the same condition as an error, so a caller can check before
125
+ running rather than after a surprise.
126
+
127
+ Either half alone is fine. Sequential ids order tied timestamps correctly, and
128
+ distinct timestamps never reach the fallback.
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.
87
154
 
88
155
  ## Choosing an entry point
89
156
 
@@ -549,6 +616,35 @@ boundary. Passing `to: article` is what removes the need to touch the parent
549
616
  after an ordinary versioned child mutation; current state is still never
550
617
  implicit.
551
618
 
619
+ ### Reading the state behind a step
620
+
621
+ A step's diff carries what changed. A renderer often needs what did *not* — to
622
+ say whose comment was edited, it needs the comment's author, which the diff has
623
+ no reason to mention. `snapshots: true` retains the reconstructed states each
624
+ step was compared between:
625
+
626
+ ```ruby
627
+ steps = PaperTrailDiff.activity_timeline(
628
+ article, from: :first, to: article, associations: [:comments], snapshots: true
629
+ )
630
+
631
+ step = steps.reject(&:empty?).last
632
+ step.from_snapshot.associations["comments"].records.first.attributes["author"]
633
+ ```
634
+
635
+ The gem already builds these states to compute each diff and otherwise discards
636
+ them, so asking for them costs no extra queries. Reconstructing them yourself
637
+ from the version table means reimplementing boundary ordering, and getting it
638
+ wrong is quiet rather than loud.
639
+
640
+ They are off by default because each one holds the whole selected graph, and
641
+ retaining one per step keeps the entire timeline's graph alive. A step whose
642
+ `from_boundary` is a `create` version has a `nil` `from_snapshot`: the record
643
+ did not exist yet, which is the answer rather than a missing one.
644
+
645
+ `analyze(activity: true, snapshots: true)` populates the same fields on the
646
+ `activity_timeline` it returns.
647
+
552
648
  ### Closing a destroyed root
553
649
 
554
650
  A `destroy` version is the one boundary whose following state needs no later
@@ -975,6 +1071,12 @@ report.errors.map(&:code)
975
1071
  report.warnings.map(&:code)
976
1072
  ```
977
1073
 
1074
+ `ok?` means no *errors* were found among the checks that ran, not that every
1075
+ possible hazard was ruled out. Association checks need `associations:` to have
1076
+ something to inspect; without it only the version-order check runs, which is
1077
+ why that one runs whether or not associations are selected — an unorderable
1078
+ history corrupts a scalar timeline just as thoroughly.
1079
+
978
1080
  Diagnostics are read-only guidance, not proof that arbitrary old data is
979
1081
  complete. HABTM endpoints without transaction-backed association snapshots fail
980
1082
  loudly with `PaperTrailDiff::IncompleteAssociationHistoryError` during normal
@@ -108,12 +108,35 @@ module PaperTrailDiff
108
108
  attr_reader :from_boundary #: ActivityBoundary
109
109
  attr_reader :to_boundary #: ActivityBoundary
110
110
  attr_reader :diff #: Diff
111
+ # The reconstructed states this step was compared between, present only when
112
+ # a caller asked for them. A diff carries what changed; a renderer that has
113
+ # to name an unchanged field of a changed record needs the whole state, and
114
+ # rebuilding it from the version table by hand is both slower and easy to
115
+ # get wrong.
116
+ attr_reader :from_snapshot #: RecordSnapshot?
117
+ attr_reader :to_snapshot #: RecordSnapshot?
111
118
 
112
- #: (from_boundary: ActivityBoundary, to_boundary: ActivityBoundary, diff: Diff) -> void
113
- def initialize(from_boundary:, to_boundary:, diff:)
119
+ # Compares two reconstructed states and keeps them only when asked, which is
120
+ # every caller's shape: the diff always comes from the pair, the pair itself
121
+ # is retained on request.
122
+ #: (from_boundary: ActivityBoundary, to_boundary: ActivityBoundary, from_snapshot: RecordSnapshot?, to_snapshot: RecordSnapshot?, retain: bool) -> ActivityStep
123
+ def self.between(from_boundary:, to_boundary:, from_snapshot:, to_snapshot:, retain:)
124
+ new(
125
+ from_boundary: from_boundary,
126
+ to_boundary: to_boundary,
127
+ diff: Engine.compare(from_snapshot, to_snapshot),
128
+ from_snapshot: (from_snapshot if retain),
129
+ to_snapshot: (to_snapshot if retain)
130
+ )
131
+ end
132
+
133
+ #: (from_boundary: ActivityBoundary, to_boundary: ActivityBoundary, diff: Diff, ?from_snapshot: RecordSnapshot?, ?to_snapshot: RecordSnapshot?) -> void
134
+ def initialize(from_boundary:, to_boundary:, diff:, from_snapshot: nil, to_snapshot: nil)
114
135
  @from_boundary = from_boundary
115
136
  @to_boundary = to_boundary
116
137
  @diff = diff
138
+ @from_snapshot = from_snapshot
139
+ @to_snapshot = to_snapshot
117
140
  freeze
118
141
  end
119
142
 
@@ -26,13 +26,16 @@ module PaperTrailDiff
26
26
 
27
27
  # Builds activity steps while retaining root snapshots for combined analysis.
28
28
  class ActivityHistoryBuilder
29
- #: (Array[untyped], Array[ActivityEvent], untyped, ?current: untyped, ?include_step: untyped) -> void
30
- def initialize(root_versions, events, snapshotter, current: nil, include_step: nil)
29
+ #: (Array[untyped], Array[ActivityEvent], untyped, ?current: untyped, ?include_step: untyped, ?snapshots: bool) -> void
30
+ def initialize( # rubocop:disable Metrics/ParameterLists
31
+ root_versions, events, snapshotter, current: nil, include_step: nil, snapshots: false
32
+ )
31
33
  @root_versions = root_versions
32
34
  @events = events
33
35
  @snapshotter = snapshotter
34
36
  @current = current
35
37
  @include_step = include_step
38
+ @snapshots = snapshots
36
39
  end
37
40
 
38
41
  #: () -> ActivityHistory
@@ -54,6 +57,7 @@ module PaperTrailDiff
54
57
  # @rbs @snapshotter: untyped
55
58
  # @rbs @current: untyped
56
59
  # @rbs @include_step: untyped
60
+ # @rbs @snapshots: bool
57
61
  # @rbs @steps: Array[ActivityStep]
58
62
  # @rbs @root_snapshots: Hash[Array[untyped], RecordSnapshot?]
59
63
  # @rbs @first_snapshot: RecordSnapshot?
@@ -110,10 +114,9 @@ module PaperTrailDiff
110
114
  previous_boundary = @previous_boundary
111
115
  return unless previous_boundary
112
116
 
113
- @steps << ActivityStep.new(
114
- from_boundary: previous_boundary,
115
- to_boundary: boundary,
116
- diff: Engine.compare(@previous_snapshot, snapshot)
117
+ @steps << ActivityStep.between(
118
+ from_boundary: previous_boundary, to_boundary: boundary,
119
+ from_snapshot: @previous_snapshot, to_snapshot: snapshot, retain: @snapshots
117
120
  )
118
121
  @selected_last_snapshot = snapshot
119
122
  end
@@ -4,8 +4,9 @@
4
4
  module PaperTrailDiff
5
5
  # Compares adjacent root and selected-descendant activity boundaries.
6
6
  class ActivityTimelineBuilder
7
- #: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped) -> void
8
- def initialize(record, range:, tree:, snapshotter:)
7
+ #: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool) -> void
8
+ def initialize(record, range:, tree:, snapshotter:, snapshots: false)
9
+ @snapshots = snapshots
9
10
  @record = record
10
11
  @from = range.from
11
12
  @to = range.to
@@ -49,6 +50,7 @@ module PaperTrailDiff
49
50
  # @rbs @range: TimelineRange
50
51
  # @rbs @tree: AssociationTree
51
52
  # @rbs @snapshotter: untyped
53
+ # @rbs @snapshots: bool
52
54
 
53
55
  #: () -> Array[ActivityStep]
54
56
  def no_steps
@@ -150,10 +152,10 @@ module PaperTrailDiff
150
152
  previous_event = events.last
151
153
  previous_boundary = ActivityBoundary.from_version(previous_event.version) if previous_event
152
154
  if final_boundary && previous_boundary
153
- steps << ActivityStep.new(
154
- from_boundary: previous_boundary,
155
- to_boundary: final_boundary,
156
- diff: Engine.compare(history.last_snapshot, final_snapshot)
155
+ steps << ActivityStep.between(
156
+ from_boundary: previous_boundary, to_boundary: final_boundary,
157
+ from_snapshot: history.last_snapshot, to_snapshot: final_snapshot,
158
+ retain: @snapshots
157
159
  )
158
160
  end
159
161
  steps.freeze
@@ -175,7 +177,8 @@ module PaperTrailDiff
175
177
  root_versions,
176
178
  events,
177
179
  @snapshotter,
178
- current: current
180
+ current: current,
181
+ snapshots: @snapshots
179
182
  ).call
180
183
  end
181
184
 
@@ -201,7 +204,8 @@ module PaperTrailDiff
201
204
  @record,
202
205
  range: @range,
203
206
  tree: @tree,
204
- snapshotter: @snapshotter
207
+ snapshotter: @snapshotter,
208
+ snapshots: @snapshots
205
209
  )
206
210
  end
207
211
  end
@@ -76,7 +76,11 @@ module PaperTrailDiff
76
76
  #: () -> DiagnosticReport
77
77
  def call
78
78
  model_class = validated_model_class
79
- return DiagnosticReport.new(issues: []) if @tree.empty?
79
+ # Runs whether or not associations are selected: unorderable versions
80
+ # corrupt a scalar timeline just as surely, and a report that inspected
81
+ # nothing has no business answering `ok?`.
82
+ inspect_version_sequence
83
+ return DiagnosticReport.new(issues: @issues) if @tree.empty?
80
84
 
81
85
  unless association_tracking_available?
82
86
  add_error(:association_tracking_unavailable, tracking_unavailable_message)
@@ -132,6 +136,15 @@ module PaperTrailDiff
132
136
  inspect_transaction_metadata(paths)
133
137
  end
134
138
 
139
+ #: () -> void
140
+ def inspect_version_sequence
141
+ @issues.concat(
142
+ VersionSequenceDiagnostics.new(
143
+ @from_version, @to_version, associations_selected: !@tree.empty?
144
+ ).call
145
+ )
146
+ end
147
+
135
148
  #: (untyped) -> void
136
149
  def inspect_checkpoint_timestamp(model_class)
137
150
  return if synchronized_timestamp_disabled?(model_class)
@@ -29,6 +29,9 @@ module PaperTrailDiff
29
29
  # Raised when an in-range mutation has no later root boundary for reconstruction.
30
30
  class IncompleteTimeRangeError < InvalidTimelineRangeError; end
31
31
 
32
+ # Raised when versions sharing a timestamp cannot be ordered by their ids.
33
+ class AmbiguousVersionOrderError < Error; end
34
+
32
35
  # Raised when a requested ActiveRecord association does not exist.
33
36
  class UnknownAssociationError < Error; end
34
37
 
@@ -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
@@ -67,8 +68,8 @@ module PaperTrailDiff
67
68
  ).build
68
69
  end
69
70
 
70
- #: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?) -> Array[ActivityStep]
71
- def activity_timeline(record, from:, to:, within:, version_scope: nil, close_on: nil) # rubocop:disable Metrics/ParameterLists
71
+ #: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Array[ActivityStep]
72
+ def activity_timeline(record, from:, to:, within:, version_scope: nil, close_on: nil, snapshots: false) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
72
73
  payload = @instrumentation_payload.merge(model_type: record.class.base_class.name.to_s)
73
74
  Instrumentation.instrument('activity_timeline', payload) do
74
75
  @traversal_preparer.call(record.class, historical: true)
@@ -76,21 +77,21 @@ module PaperTrailDiff
76
77
  reject_live_habtm_activity!(record.class) if Endpoint.record?(to) || live
77
78
  steps = activity_builder(
78
79
  record, from: from, to: to, within: within, version_scope: version_scope,
79
- live_endpoint: live
80
+ live_endpoint: live, snapshots: snapshots
80
81
  ).build
81
82
  payload[:step_count] = steps.length
82
83
  steps
83
84
  end
84
85
  end
85
86
 
86
- #: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Analysis
87
- def analyze(record, from:, to:, within:, activity: false, version_scope: nil, close_on: nil) # rubocop:disable Metrics/ParameterLists
87
+ #: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Analysis
88
+ def analyze(record, from:, to:, within:, activity: false, version_scope: nil, close_on: nil, snapshots: false) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
88
89
  @traversal_preparer.call(record.class, historical: true)
89
90
  live = live_endpoint_for(record, close_on, within)
90
91
  if activity
91
92
  return analyze_activity(
92
- record, from: from, to: to, within: within,
93
- version_scope: version_scope, live_endpoint: live
93
+ record, from: from, to: to, within: within, version_scope: version_scope,
94
+ live_endpoint: live, snapshots: snapshots
94
95
  )
95
96
  end
96
97
 
@@ -132,15 +133,36 @@ module PaperTrailDiff
132
133
  # @rbs @timeline_snapshotter: TimelineSnapshotProvider
133
134
  # @rbs @activity_snapshotter: ActivitySnapshotProvider
134
135
 
135
- #: (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped) -> Analysis
136
- def analyze_activity(record, from:, to:, within:, version_scope:, live_endpoint:) # rubocop:disable Metrics/ParameterLists
136
+ #: (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped, ?snapshots: bool) -> Analysis
137
+ def analyze_activity(record, from:, to:, within:, version_scope:, live_endpoint:, snapshots: false) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
137
138
  reject_live_habtm_activity!(record.class) if live_endpoint
138
139
  activity_builder(
139
- record, from: from, to: to, within: within,
140
- version_scope: version_scope, live_endpoint: live_endpoint
140
+ record, from: from, to: to, within: within, version_scope: version_scope,
141
+ live_endpoint: live_endpoint, snapshots: snapshots
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
@@ -212,8 +234,8 @@ module PaperTrailDiff
212
234
  )
213
235
  end
214
236
 
215
- #: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped) -> ActivityTimelineBuilder
216
- def activity_builder(record, from:, to:, within:, version_scope: nil, live_endpoint: nil) # rubocop:disable Metrics/ParameterLists
237
+ #: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped, ?snapshots: bool) -> ActivityTimelineBuilder
238
+ def activity_builder(record, from:, to:, within:, version_scope: nil, live_endpoint: nil, snapshots: false) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
217
239
  ActivityTimelineBuilder.new(
218
240
  record,
219
241
  range: TimelineRange.new(
@@ -221,7 +243,8 @@ module PaperTrailDiff
221
243
  live_endpoint: live_endpoint
222
244
  ),
223
245
  tree: @association_tree,
224
- snapshotter: @activity_snapshotter
246
+ snapshotter: @activity_snapshotter,
247
+ snapshots: snapshots
225
248
  )
226
249
  end
227
250
 
@@ -109,7 +109,7 @@ module PaperTrailDiff
109
109
 
110
110
  #: (versions: Array[untyped], live: PreparedRecordState?, ?state_loader: PreparedVersionStateLoader) -> void
111
111
  def initialize(versions:, live:, state_loader: PreparedVersionStateLoader.new)
112
- @versions = versions.sort_by { |version| Support.chronological_version_key(version) }.freeze
112
+ @versions = Support.chronological_sort(versions).freeze
113
113
  @version_positions = @versions.each_with_index.to_h do |version, index|
114
114
  [version.id.to_s, index]
115
115
  end.freeze
@@ -134,8 +134,7 @@ module PaperTrailDiff
134
134
 
135
135
  #: (Array[untyped]) -> Array[untyped]
136
136
  def chronological(versions)
137
- versions.uniq { |version| [version.class.name, version.id] }
138
- .sort_by { |version| Support.chronological_version_key(version) }
137
+ Support.chronological_sort(versions.uniq { |version| [version.class.name, version.id] })
139
138
  end
140
139
 
141
140
  #: (untyped) -> untyped
@@ -51,6 +51,50 @@ module PaperTrailDiff
51
51
  [version.created_at, version.id.to_s.rjust(32, '0')]
52
52
  end
53
53
 
54
+ # Ordering falls back to the id whenever timestamps tie, which recovers the
55
+ # real order only while ids increase with insertion. An autoincrement id
56
+ # does; a UUID does not, so a tie between UUID-keyed versions is genuinely
57
+ # unorderable and any timeline built from it would be fiction.
58
+ #: (Array[untyped]) -> Array[untyped]
59
+ def chronological_sort(versions)
60
+ sorted = versions.sort_by { |version| chronological_version_key(version) }
61
+ ambiguous = ambiguous_pair(sorted)
62
+ return sorted unless ambiguous
63
+
64
+ raise AmbiguousVersionOrderError, ambiguous_message(ambiguous)
65
+ end
66
+
67
+ #: (Array[untyped]) -> Array[untyped]?
68
+ def ambiguous_pair(sorted)
69
+ sorted.each_cons(2).find do |left, right|
70
+ left.created_at == right.created_at &&
71
+ !(sequential_id?(left.id) && sequential_id?(right.id))
72
+ end
73
+ end
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
+
84
+ #: (untyped) -> bool
85
+ def sequential_id?(id)
86
+ id.is_a?(Integer) || id.to_s.match?(/\A\d+\z/)
87
+ end
88
+
89
+ #: (Array[untyped]) -> String
90
+ def ambiguous_message(pair)
91
+ left, right = pair
92
+ "versions #{left.id.inspect} and #{right.id.inspect} share the timestamp " \
93
+ "#{left.created_at.inspect} and have ids that do not order them, so their " \
94
+ 'sequence cannot be recovered; record versions at sub-second precision or ' \
95
+ 'with sequential ids'
96
+ end
97
+
54
98
  #: (untyped, untyped) -> Integer
55
99
  def compare_versions(left, right)
56
100
  chronological_version_key(left) <=> chronological_version_key(right) ||
@@ -4,8 +4,9 @@
4
4
  module PaperTrailDiff
5
5
  # Builds activity views for mutations selected by a wall-clock range.
6
6
  class TimeActivityTimelineBuilder
7
- #: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped) -> void
8
- def initialize(record, range:, tree:, snapshotter:)
7
+ #: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool) -> void
8
+ def initialize(record, range:, tree:, snapshotter:, snapshots: false)
9
+ @snapshots = snapshots
9
10
  @record = record
10
11
  @range = range
11
12
  @tree = tree
@@ -40,6 +41,7 @@ module PaperTrailDiff
40
41
  # @rbs @range: TimelineRange
41
42
  # @rbs @tree: AssociationTree
42
43
  # @rbs @snapshotter: untyped
44
+ # @rbs @snapshots: bool
43
45
 
44
46
  #: () -> [ActivityHistory, RootVersionPlan, ActivityStep?, RecordSnapshot?, untyped]
45
47
  def history_and_versions
@@ -89,7 +91,8 @@ module PaperTrailDiff
89
91
  root_versions,
90
92
  events,
91
93
  @snapshotter,
92
- include_step: ->(event) { @range.include?(event.version) }
94
+ include_step: ->(event) { @range.include?(event.version) },
95
+ snapshots: @snapshots
93
96
  ).call
94
97
  end
95
98
 
@@ -129,10 +132,11 @@ module PaperTrailDiff
129
132
  #: (ActivityHistory, ActivityEvent) -> ActivityStep
130
133
  def destroyed_step(history, event)
131
134
  version = event.version
132
- ActivityStep.new(
135
+ ActivityStep.between(
133
136
  from_boundary: ActivityBoundary.from_version(version),
134
137
  to_boundary: ActivityBoundary.destroyed(version),
135
- diff: Engine.compare(history.root_snapshots[ActivityRootSteps.version_key(version)], nil)
138
+ from_snapshot: history.root_snapshots[ActivityRootSteps.version_key(version)],
139
+ to_snapshot: nil, retain: @snapshots
136
140
  )
137
141
  end
138
142
 
@@ -144,10 +148,10 @@ module PaperTrailDiff
144
148
  previous = history.steps.last&.to_boundary
145
149
  return unless previous
146
150
 
147
- ActivityStep.new(
151
+ ActivityStep.between(
148
152
  from_boundary: previous,
149
153
  to_boundary: ActivityBoundary.current(record, captured_at: captured_at),
150
- diff: Engine.compare(history.last_snapshot, snapshot)
154
+ from_snapshot: history.last_snapshot, to_snapshot: snapshot, retain: @snapshots
151
155
  )
152
156
  end
153
157
 
@@ -56,7 +56,7 @@ module PaperTrailDiff
56
56
 
57
57
  #: (Array[untyped]) -> Array[untyped]
58
58
  def ordered(versions)
59
- versions.sort_by { |version| Support.chronological_version_key(version) }
59
+ Support.chronological_sort(versions)
60
60
  end
61
61
  end
62
62
  end
@@ -4,8 +4,8 @@
4
4
  module PaperTrailDiff
5
5
  # Gives TimelineBuilder one-argument access to a range-prepared snapshot store.
6
6
  class TimelineSnapshotProvider
7
- #: (HistoricalSnapshotStore, ?live_snapshotter: untyped) -> void
8
- def initialize(store, live_snapshotter: nil)
7
+ #: (HistoricalSnapshotStore, live_snapshotter: untyped) -> void
8
+ def initialize(store, live_snapshotter:)
9
9
  @store = store
10
10
  @live_snapshotter = live_snapshotter
11
11
  end
@@ -28,10 +28,7 @@ module PaperTrailDiff
28
28
 
29
29
  #: (untyped) -> RecordSnapshot?
30
30
  def live_snapshot(record)
31
- snapshotter = @live_snapshotter
32
- raise InvalidTimelineRangeError, 'live endpoints are unavailable here' unless snapshotter
33
-
34
- snapshotter.call(record)
31
+ @live_snapshotter.call(record)
35
32
  end
36
33
 
37
34
  # @rbs @store: HistoricalSnapshotStore
@@ -2,5 +2,5 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module PaperTrailDiff
5
- VERSION = '0.7.1'
5
+ VERSION = '0.9.0'
6
6
  end
@@ -96,7 +96,7 @@ module PaperTrailDiff
96
96
 
97
97
  #: (Array[untyped]) -> Array[untyped]
98
98
  def ordered(versions)
99
- versions.sort_by { |version| Support.chronological_version_key(version) }
99
+ Support.chronological_sort(versions)
100
100
  end
101
101
 
102
102
  #: (untyped, untyped, boundary: Symbol) -> void
@@ -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'
@@ -169,7 +170,7 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
169
170
  # Compares adjacent root and selected-descendant activity boundaries.
170
171
  # `reload_live_endpoints:` applies only when `to:` is a current record; the
171
172
  # other range forms never read live state.
172
- #: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Array[ActivityStep]
173
+ #: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Array[ActivityStep]
173
174
  def activity_timeline( # rubocop:disable Metrics/ParameterLists
174
175
  record,
175
176
  from: nil,
@@ -179,7 +180,8 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
179
180
  ignore: DEFAULT_IGNORED_ATTRIBUTES,
180
181
  reload_live_endpoints: true,
181
182
  version_scope: nil,
182
- close_on: nil
183
+ close_on: nil,
184
+ snapshots: false
183
185
  )
184
186
  PaperTrailAdapter.new(
185
187
  associations: associations,
@@ -191,12 +193,13 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
191
193
  to: to,
192
194
  within: within,
193
195
  version_scope: version_scope,
194
- close_on: close_on
196
+ close_on: close_on,
197
+ snapshots: snapshots
195
198
  )
196
199
  end
197
200
 
198
201
  # Builds an endpoint diff and root-checkpoint timeline while normalizing each version once.
199
- #: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Analysis
202
+ #: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Analysis
200
203
  def analyze( # rubocop:disable Metrics/ParameterLists
201
204
  record,
202
205
  from: nil,
@@ -206,7 +209,8 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
206
209
  ignore: DEFAULT_IGNORED_ATTRIBUTES,
207
210
  activity: false,
208
211
  version_scope: nil,
209
- close_on: nil
212
+ close_on: nil,
213
+ snapshots: false
210
214
  )
211
215
  PaperTrailAdapter.new(associations: associations, ignore: ignore).analyze(
212
216
  record,
@@ -215,7 +219,8 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
215
219
  within: within,
216
220
  activity: activity,
217
221
  version_scope: version_scope,
218
- close_on: close_on
222
+ close_on: close_on,
223
+ snapshots: snapshots
219
224
  )
220
225
  end
221
226
 
@@ -55,8 +55,23 @@ module PaperTrailDiff
55
55
 
56
56
  attr_reader diff: Diff
57
57
 
58
- # : (from_boundary: ActivityBoundary, to_boundary: ActivityBoundary, diff: Diff) -> void
59
- def initialize: (from_boundary: ActivityBoundary, to_boundary: ActivityBoundary, diff: Diff) -> void
58
+ # The reconstructed states this step was compared between, present only when
59
+ # a caller asked for them. A diff carries what changed; a renderer that has
60
+ # to name an unchanged field of a changed record needs the whole state, and
61
+ # rebuilding it from the version table by hand is both slower and easy to
62
+ # get wrong.
63
+ attr_reader from_snapshot: RecordSnapshot?
64
+
65
+ attr_reader to_snapshot: RecordSnapshot?
66
+
67
+ # Compares two reconstructed states and keeps them only when asked, which is
68
+ # every caller's shape: the diff always comes from the pair, the pair itself
69
+ # is retained on request.
70
+ # : (from_boundary: ActivityBoundary, to_boundary: ActivityBoundary, from_snapshot: RecordSnapshot?, to_snapshot: RecordSnapshot?, retain: bool) -> ActivityStep
71
+ def self.between: (from_boundary: ActivityBoundary, to_boundary: ActivityBoundary, from_snapshot: RecordSnapshot?, to_snapshot: RecordSnapshot?, retain: bool) -> ActivityStep
72
+
73
+ # : (from_boundary: ActivityBoundary, to_boundary: ActivityBoundary, diff: Diff, ?from_snapshot: RecordSnapshot?, ?to_snapshot: RecordSnapshot?) -> void
74
+ def initialize: (from_boundary: ActivityBoundary, to_boundary: ActivityBoundary, diff: Diff, ?from_snapshot: RecordSnapshot?, ?to_snapshot: RecordSnapshot?) -> void
60
75
 
61
76
  # : () -> bool
62
77
  def empty?: () -> bool
@@ -20,8 +20,8 @@ module PaperTrailDiff
20
20
 
21
21
  # Builds activity steps while retaining root snapshots for combined analysis.
22
22
  class ActivityHistoryBuilder
23
- # : (Array[untyped], Array[ActivityEvent], untyped, ?current: untyped, ?include_step: untyped) -> void
24
- def initialize: (Array[untyped], Array[ActivityEvent], untyped, ?current: untyped, ?include_step: untyped) -> void
23
+ # : (Array[untyped], Array[ActivityEvent], untyped, ?current: untyped, ?include_step: untyped, ?snapshots: bool) -> void
24
+ def initialize: (Array[untyped], Array[ActivityEvent], untyped, ?current: untyped, ?include_step: untyped, ?snapshots: bool) -> void
25
25
 
26
26
  # : () -> ActivityHistory
27
27
  def call: () -> ActivityHistory
@@ -48,6 +48,8 @@ module PaperTrailDiff
48
48
 
49
49
  @selected_last_snapshot: RecordSnapshot?
50
50
 
51
+ @snapshots: bool
52
+
51
53
  @snapshotter: untyped
52
54
 
53
55
  @steps: Array[ActivityStep]
@@ -3,8 +3,8 @@
3
3
  module PaperTrailDiff
4
4
  # Compares adjacent root and selected-descendant activity boundaries.
5
5
  class ActivityTimelineBuilder
6
- # : (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped) -> void
7
- def initialize: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped) -> void
6
+ # : (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool) -> void
7
+ def initialize: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool) -> void
8
8
 
9
9
  # : () -> Array[ActivityStep]
10
10
  def build: () -> Array[ActivityStep]
@@ -21,6 +21,8 @@ module PaperTrailDiff
21
21
 
22
22
  @record: untyped
23
23
 
24
+ @snapshots: bool
25
+
24
26
  @snapshotter: untyped
25
27
 
26
28
  @to: untyped
@@ -67,6 +67,9 @@ module PaperTrailDiff
67
67
  # : (Array[Array[untyped]]) -> void
68
68
  def inspect_habtm: (Array[Array[untyped]]) -> void
69
69
 
70
+ # : () -> void
71
+ def inspect_version_sequence: () -> void
72
+
70
73
  # : (untyped) -> void
71
74
  def inspect_checkpoint_timestamp: (untyped) -> void
72
75
 
@@ -37,6 +37,10 @@ module PaperTrailDiff
37
37
  class IncompleteTimeRangeError < InvalidTimelineRangeError
38
38
  end
39
39
 
40
+ # Raised when versions sharing a timestamp cannot be ordered by their ids.
41
+ class AmbiguousVersionOrderError < Error
42
+ end
43
+
40
44
  # Raised when a requested ActiveRecord association does not exist.
41
45
  class UnknownAssociationError < Error
42
46
  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
@@ -22,11 +22,11 @@ module PaperTrailDiff
22
22
  # : (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?) -> Array[Step]
23
23
  def timeline: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?) -> Array[Step]
24
24
 
25
- # : (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?) -> Array[ActivityStep]
26
- def activity_timeline: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?) -> Array[ActivityStep]
25
+ # : (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Array[ActivityStep]
26
+ def activity_timeline: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Array[ActivityStep]
27
27
 
28
- # : (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Analysis
29
- def analyze: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Analysis
28
+ # : (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Analysis
29
+ def analyze: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Analysis
30
30
 
31
31
  # Analyzes many roots over one shared range, preparing their history once.
32
32
  # : (Array[untyped], within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Hash[identity, Analysis]
@@ -56,8 +56,16 @@ module PaperTrailDiff
56
56
 
57
57
  @traversal_preparer: TraversalPreparer
58
58
 
59
- # : (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped) -> Analysis
60
- def analyze_activity: (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped) -> Analysis
59
+ # : (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped, ?snapshots: bool) -> Analysis
60
+ def analyze_activity: (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped, ?snapshots: bool) -> Analysis
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
61
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.
@@ -81,8 +89,8 @@ module PaperTrailDiff
81
89
  # : () -> ActivitySnapshotProvider
82
90
  def build_activity_snapshotter: () -> ActivitySnapshotProvider
83
91
 
84
- # : (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped) -> ActivityTimelineBuilder
85
- def activity_builder: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped) -> ActivityTimelineBuilder
92
+ # : (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped, ?snapshots: bool) -> ActivityTimelineBuilder
93
+ def activity_builder: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped, ?snapshots: bool) -> ActivityTimelineBuilder
86
94
 
87
95
  # : (untyped) -> void
88
96
  def reject_live_habtm_activity!: (untyped) -> void
@@ -15,6 +15,29 @@ module PaperTrailDiff
15
15
  # : (untyped) -> Array[untyped]
16
16
  def self?.chronological_version_key: (untyped) -> Array[untyped]
17
17
 
18
+ # Ordering falls back to the id whenever timestamps tie, which recovers the
19
+ # real order only while ids increase with insertion. An autoincrement id
20
+ # does; a UUID does not, so a tie between UUID-keyed versions is genuinely
21
+ # unorderable and any timeline built from it would be fiction.
22
+ # : (Array[untyped]) -> Array[untyped]
23
+ def self?.chronological_sort: (Array[untyped]) -> Array[untyped]
24
+
25
+ # : (Array[untyped]) -> Array[untyped]?
26
+ def self?.ambiguous_pair: (Array[untyped]) -> Array[untyped]?
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
+
35
+ # : (untyped) -> bool
36
+ def self?.sequential_id?: (untyped) -> bool
37
+
38
+ # : (Array[untyped]) -> String
39
+ def self?.ambiguous_message: (Array[untyped]) -> String
40
+
18
41
  # : (untyped, untyped) -> Integer
19
42
  def self?.compare_versions: (untyped, untyped) -> Integer
20
43
 
@@ -3,8 +3,8 @@
3
3
  module PaperTrailDiff
4
4
  # Builds activity views for mutations selected by a wall-clock range.
5
5
  class TimeActivityTimelineBuilder
6
- # : (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped) -> void
7
- def initialize: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped) -> void
6
+ # : (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool) -> void
7
+ def initialize: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool) -> void
8
8
 
9
9
  # : () -> Array[ActivityStep]
10
10
  def build: () -> Array[ActivityStep]
@@ -18,6 +18,8 @@ module PaperTrailDiff
18
18
 
19
19
  @record: untyped
20
20
 
21
+ @snapshots: bool
22
+
21
23
  @snapshotter: untyped
22
24
 
23
25
  @tree: AssociationTree
@@ -3,8 +3,8 @@
3
3
  module PaperTrailDiff
4
4
  # Gives TimelineBuilder one-argument access to a range-prepared snapshot store.
5
5
  class TimelineSnapshotProvider
6
- # : (HistoricalSnapshotStore, ?live_snapshotter: untyped) -> void
7
- def initialize: (HistoricalSnapshotStore, ?live_snapshotter: untyped) -> void
6
+ # : (HistoricalSnapshotStore, live_snapshotter: untyped) -> void
7
+ def initialize: (HistoricalSnapshotStore, live_snapshotter: untyped) -> void
8
8
 
9
9
  # : (untyped, Array[untyped]) -> void
10
10
  def prepare: (untyped, Array[untyped]) -> void
@@ -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
@@ -23,12 +23,12 @@ module PaperTrailDiff
23
23
  # Compares adjacent root and selected-descendant activity boundaries.
24
24
  # `reload_live_endpoints:` applies only when `to:` is a current record; the
25
25
  # other range forms never read live state.
26
- # : (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Array[ActivityStep]
27
- def self.activity_timeline: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Array[ActivityStep]
26
+ # : (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Array[ActivityStep]
27
+ def self.activity_timeline: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Array[ActivityStep]
28
28
 
29
29
  # Builds an endpoint diff and root-checkpoint timeline while normalizing each version once.
30
- # : (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Analysis
31
- def self.analyze: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Analysis
30
+ # : (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Analysis
31
+ def self.analyze: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Analysis
32
32
 
33
33
  # Analyzes many roots over one shared time window, preparing their selected
34
34
  # history once for the batch instead of once per record. Roots with no
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.7.1
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.7.1/CHANGELOG.md
204
- documentation_uri: https://github.com/aheathwilliams/paper_trail_diff/blob/v0.7.1/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.7.1
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