paper_trail_diff 0.7.1 → 0.8.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 (30) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -0
  3. data/QUICKSTART.md +27 -2
  4. data/README.md +76 -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 +30 -1
  9. data/lib/paper_trail_diff/errors.rb +3 -0
  10. data/lib/paper_trail_diff/paper_trail_adapter.rb +15 -14
  11. data/lib/paper_trail_diff/prepared_record_index.rb +1 -1
  12. data/lib/paper_trail_diff/root_version_selection.rb +1 -2
  13. data/lib/paper_trail_diff/support.rb +35 -0
  14. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +11 -7
  15. data/lib/paper_trail_diff/time_version_range.rb +1 -1
  16. data/lib/paper_trail_diff/timeline_snapshot_provider.rb +3 -6
  17. data/lib/paper_trail_diff/version.rb +1 -1
  18. data/lib/paper_trail_diff/version_range.rb +1 -1
  19. data/lib/paper_trail_diff.rb +10 -6
  20. data/sig/generated/paper_trail_diff/activity_boundary.rbs +17 -2
  21. data/sig/generated/paper_trail_diff/activity_history.rbs +4 -2
  22. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +4 -2
  23. data/sig/generated/paper_trail_diff/diagnostics.rbs +9 -0
  24. data/sig/generated/paper_trail_diff/errors.rbs +4 -0
  25. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +8 -8
  26. data/sig/generated/paper_trail_diff/support.rbs +16 -0
  27. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +4 -2
  28. data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +2 -2
  29. data/sig/generated/paper_trail_diff.rbs +4 -4
  30. metadata +4 -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: 41f2b97428f5dc66c22e29bb1a01cdda0beb1ad29eafe5f86ce8809fb1c61bc0
4
+ data.tar.gz: 9f6ef5ebef159decfaf875b6df4e6341f6243c9431085b65a67987baa674191f
5
5
  SHA512:
6
- metadata.gz: 3e32ecbedd636456388129cc664cf688b0aa909d5c05b45124eb1a6bd82dabbffa96557fe5efc946e93fe459234d3c4c5ce9af11f39228ddab55228f5a53e2f9
7
- data.tar.gz: 1b29680ab942334fd98ce5cc0a4f2bc823fd180b258282e8be2c07ba784c9c3b5aa9edc76e0e118fec3baabcfbc94344c208aef116702874c0eb4dd226cddcac
6
+ metadata.gz: 28d69434ddf8c9886f1785dc665a54040c38721e1aae84738f50668f2cf677cd39f8a3f9870cc06f3627a0c9f134b935a26dceb2f50c2eff8634bd2dc52bbc8f
7
+ data.tar.gz: 704c54404a71a76a9deaa6f3e7d03b425763fdce010b240f8e24e4a293d8656c448a11bcf0e5eabcf6e3d0c2c7bb83b6d277acad83e27a7a749bdb1f0116edf5
data/CHANGELOG.md CHANGED
@@ -3,6 +3,53 @@
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.8.0] - 2026-08-12
7
+
8
+ ### Added
9
+
10
+ - Accept `snapshots: true` on `activity_timeline` and `analyze(activity: true)`,
11
+ retaining the reconstructed states each step was compared between as
12
+ `ActivityStep#from_snapshot` and `#to_snapshot`. A diff names what changed; a
13
+ renderer that has to name an unchanged field of a changed record needed the
14
+ whole state and had no way to reach it, so consumers were rebuilding it from
15
+ the version table by hand — slower, and easy to get wrong in ways that stay
16
+ quiet. The gem already builds these states to compute each diff and discarded
17
+ them, so the option costs no extra queries. Off by default because each one
18
+ holds the whole selected graph.
19
+ - Add `examples/demo.rb`, a self-contained tour that needs no application: it
20
+ builds an in-memory database, writes a small multi-author history, and prints
21
+ an endpoint diff, a checkpoint timeline, per-person attribution across a
22
+ nested record, and one person's changes alone. It runs standalone, inside a
23
+ project, or under `bundle exec`, fetching what it needs on first run.
24
+
25
+ - Raise `PaperTrailDiff::AmbiguousVersionOrderError` when versions sharing a
26
+ timestamp have ids that cannot order them. Ordering falls back to the id when
27
+ timestamps tie, which recovers the real sequence only while ids increase with
28
+ insertion; a UUID version id does not, and a MySQL `datetime` column stores
29
+ whole seconds, so the two together are ordinary rather than exotic. The gem
30
+ previously returned a plausible-looking timeline with steps in the wrong
31
+ order and others dropped. `diagnose` reports the same condition as an error
32
+ so it can be caught before a run rather than after a wrong answer.
33
+ - Document the model and schema shapes the suite covers: single-table
34
+ inheritance (results are keyed by the base class, so build keys with
35
+ `Endpoint.identity`), non-integer primary keys, and a custom version class
36
+ via `has_paper_trail versions: { class_name: }`.
37
+
38
+ ### Changed
39
+
40
+ - Create the models it uses in the quickstart. It declared `has_paper_trail` on
41
+ an `Article` and then used `Comment` without ever generating either, so
42
+ following it in a new application failed on a missing table at the first
43
+ console step.
44
+ - Run `diagnose`'s version-order check whether or not `associations:` are
45
+ selected, so a report never answers `ok?` having inspected nothing. Document
46
+ that `ok?` means no errors among the checks that ran.
47
+
48
+ ### Fixed
49
+
50
+ - Cover `Analysis#to_h(snapshots: true)` with specs. It was shipped in 0.6.0
51
+ with no test at all.
52
+
6
53
  ## [0.7.1] - 2026-08-11
7
54
 
8
55
  ### 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,15 @@ 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).
25
33
 
26
34
  ## Installation
27
35
 
@@ -83,7 +91,38 @@ for. Three consequences run through the rest of this document:
83
91
  both timeline APIs return steps rather than events.
84
92
 
85
93
  The [Quickstart](QUICKSTART.md) walks through the same idea against a real
86
- console session.
94
+ console session, creating the models it uses as it goes.
95
+
96
+ ## Which models and schemas work
97
+
98
+ Anything `has_paper_trail` tracks, including these, which are covered by the
99
+ test suite rather than assumed:
100
+
101
+ - **Single-table inheritance.** Results are keyed by the *base* class, matching
102
+ what PaperTrail writes to `item_type`. Analyzing a `Book < Publication`
103
+ produces the key `["Publication", "12"]`, so `results.fetch(["Book", ...])`
104
+ raises `KeyError`. Build keys with `PaperTrailDiff::Endpoint.identity(record)`
105
+ rather than by hand.
106
+ - **Non-integer primary keys** on the tracked model, such as a UUID column.
107
+ - **A custom version class**, via `has_paper_trail versions: { class_name: }`.
108
+ Nothing here references `PaperTrail::Version` directly.
109
+
110
+ ### Versions that cannot be ordered
111
+
112
+ Versions are ordered by `created_at`, falling back to the id when timestamps
113
+ tie. That fallback recovers the real sequence only while ids increase with
114
+ insertion. An autoincrement id does; a **UUID version id does not**.
115
+
116
+ So two conditions together are unsafe: version ids that are not sequential,
117
+ *and* versions sharing a timestamp — which is ordinary on a MySQL `datetime`
118
+ column, since it stores whole seconds. The order is then unrecoverable, and
119
+ rather than report a plausible-looking history in the wrong order the gem
120
+ raises `PaperTrailDiff::AmbiguousVersionOrderError` naming both versions.
121
+ `diagnose` reports the same condition as an error, so a caller can check before
122
+ running rather than after a surprise.
123
+
124
+ Either half alone is fine. Sequential ids order tied timestamps correctly, and
125
+ distinct timestamps never reach the fallback.
87
126
 
88
127
  ## Choosing an entry point
89
128
 
@@ -549,6 +588,35 @@ boundary. Passing `to: article` is what removes the need to touch the parent
549
588
  after an ordinary versioned child mutation; current state is still never
550
589
  implicit.
551
590
 
591
+ ### Reading the state behind a step
592
+
593
+ A step's diff carries what changed. A renderer often needs what did *not* — to
594
+ say whose comment was edited, it needs the comment's author, which the diff has
595
+ no reason to mention. `snapshots: true` retains the reconstructed states each
596
+ step was compared between:
597
+
598
+ ```ruby
599
+ steps = PaperTrailDiff.activity_timeline(
600
+ article, from: :first, to: article, associations: [:comments], snapshots: true
601
+ )
602
+
603
+ step = steps.reject(&:empty?).last
604
+ step.from_snapshot.associations["comments"].records.first.attributes["author"]
605
+ ```
606
+
607
+ The gem already builds these states to compute each diff and otherwise discards
608
+ them, so asking for them costs no extra queries. Reconstructing them yourself
609
+ from the version table means reimplementing boundary ordering, and getting it
610
+ wrong is quiet rather than loud.
611
+
612
+ They are off by default because each one holds the whole selected graph, and
613
+ retaining one per step keeps the entire timeline's graph alive. A step whose
614
+ `from_boundary` is a `create` version has a `nil` `from_snapshot`: the record
615
+ did not exist yet, which is the answer rather than a missing one.
616
+
617
+ `analyze(activity: true, snapshots: true)` populates the same fields on the
618
+ `activity_timeline` it returns.
619
+
552
620
  ### Closing a destroyed root
553
621
 
554
622
  A `destroy` version is the one boundary whose following state needs no later
@@ -975,6 +1043,12 @@ report.errors.map(&:code)
975
1043
  report.warnings.map(&:code)
976
1044
  ```
977
1045
 
1046
+ `ok?` means no *errors* were found among the checks that ran, not that every
1047
+ possible hazard was ruled out. Association checks need `associations:` to have
1048
+ something to inspect; without it only the version-order check runs, which is
1049
+ why that one runs whether or not associations are selected — an unorderable
1050
+ history corrupts a scalar timeline just as thoroughly.
1051
+
978
1052
  Diagnostics are read-only guidance, not proof that arbitrary old data is
979
1053
  complete. HABTM endpoints without transaction-backed association snapshots fail
980
1054
  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_order
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,31 @@ module PaperTrailDiff
132
136
  inspect_transaction_metadata(paths)
133
137
  end
134
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
+ #: () -> 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) }
162
+ end
163
+
135
164
  #: (untyped) -> void
136
165
  def inspect_checkpoint_timestamp(model_class)
137
166
  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
 
@@ -67,8 +67,8 @@ module PaperTrailDiff
67
67
  ).build
68
68
  end
69
69
 
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
70
+ #: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Array[ActivityStep]
71
+ def activity_timeline(record, from:, to:, within:, version_scope: nil, close_on: nil, snapshots: false) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
72
72
  payload = @instrumentation_payload.merge(model_type: record.class.base_class.name.to_s)
73
73
  Instrumentation.instrument('activity_timeline', payload) do
74
74
  @traversal_preparer.call(record.class, historical: true)
@@ -76,21 +76,21 @@ module PaperTrailDiff
76
76
  reject_live_habtm_activity!(record.class) if Endpoint.record?(to) || live
77
77
  steps = activity_builder(
78
78
  record, from: from, to: to, within: within, version_scope: version_scope,
79
- live_endpoint: live
79
+ live_endpoint: live, snapshots: snapshots
80
80
  ).build
81
81
  payload[:step_count] = steps.length
82
82
  steps
83
83
  end
84
84
  end
85
85
 
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
86
+ #: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Analysis
87
+ def analyze(record, from:, to:, within:, activity: false, version_scope: nil, close_on: nil, snapshots: false) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
88
88
  @traversal_preparer.call(record.class, historical: true)
89
89
  live = live_endpoint_for(record, close_on, within)
90
90
  if activity
91
91
  return analyze_activity(
92
- record, from: from, to: to, within: within,
93
- version_scope: version_scope, live_endpoint: live
92
+ record, from: from, to: to, within: within, version_scope: version_scope,
93
+ live_endpoint: live, snapshots: snapshots
94
94
  )
95
95
  end
96
96
 
@@ -132,12 +132,12 @@ module PaperTrailDiff
132
132
  # @rbs @timeline_snapshotter: TimelineSnapshotProvider
133
133
  # @rbs @activity_snapshotter: ActivitySnapshotProvider
134
134
 
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
135
+ #: (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped, ?snapshots: bool) -> Analysis
136
+ def analyze_activity(record, from:, to:, within:, version_scope:, live_endpoint:, snapshots: false) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
137
137
  reject_live_habtm_activity!(record.class) if live_endpoint
138
138
  activity_builder(
139
- record, from: from, to: to, within: within,
140
- version_scope: version_scope, live_endpoint: live_endpoint
139
+ record, from: from, to: to, within: within, version_scope: version_scope,
140
+ live_endpoint: live_endpoint, snapshots: snapshots
141
141
  ).analyze
142
142
  end
143
143
 
@@ -212,8 +212,8 @@ module PaperTrailDiff
212
212
  )
213
213
  end
214
214
 
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
215
+ #: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped, ?snapshots: bool) -> ActivityTimelineBuilder
216
+ def activity_builder(record, from:, to:, within:, version_scope: nil, live_endpoint: nil, snapshots: false) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
217
217
  ActivityTimelineBuilder.new(
218
218
  record,
219
219
  range: TimelineRange.new(
@@ -221,7 +221,8 @@ module PaperTrailDiff
221
221
  live_endpoint: live_endpoint
222
222
  ),
223
223
  tree: @association_tree,
224
- snapshotter: @activity_snapshotter
224
+ snapshotter: @activity_snapshotter,
225
+ snapshots: snapshots
225
226
  )
226
227
  end
227
228
 
@@ -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,41 @@ 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
+ #: (untyped) -> bool
76
+ def sequential_id?(id)
77
+ id.is_a?(Integer) || id.to_s.match?(/\A\d+\z/)
78
+ end
79
+
80
+ #: (Array[untyped]) -> String
81
+ def ambiguous_message(pair)
82
+ left, right = pair
83
+ "versions #{left.id.inspect} and #{right.id.inspect} share the timestamp " \
84
+ "#{left.created_at.inspect} and have ids that do not order them, so their " \
85
+ 'sequence cannot be recovered; record versions at sub-second precision or ' \
86
+ 'with sequential ids'
87
+ end
88
+
54
89
  #: (untyped, untyped) -> Integer
55
90
  def compare_versions(left, right)
56
91
  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.8.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
@@ -169,7 +169,7 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
169
169
  # Compares adjacent root and selected-descendant activity boundaries.
170
170
  # `reload_live_endpoints:` applies only when `to:` is a current record; the
171
171
  # 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]
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?, ?snapshots: bool) -> Array[ActivityStep]
173
173
  def activity_timeline( # rubocop:disable Metrics/ParameterLists
174
174
  record,
175
175
  from: nil,
@@ -179,7 +179,8 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
179
179
  ignore: DEFAULT_IGNORED_ATTRIBUTES,
180
180
  reload_live_endpoints: true,
181
181
  version_scope: nil,
182
- close_on: nil
182
+ close_on: nil,
183
+ snapshots: false
183
184
  )
184
185
  PaperTrailAdapter.new(
185
186
  associations: associations,
@@ -191,12 +192,13 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
191
192
  to: to,
192
193
  within: within,
193
194
  version_scope: version_scope,
194
- close_on: close_on
195
+ close_on: close_on,
196
+ snapshots: snapshots
195
197
  )
196
198
  end
197
199
 
198
200
  # 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
201
+ #: (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
202
  def analyze( # rubocop:disable Metrics/ParameterLists
201
203
  record,
202
204
  from: nil,
@@ -206,7 +208,8 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
206
208
  ignore: DEFAULT_IGNORED_ATTRIBUTES,
207
209
  activity: false,
208
210
  version_scope: nil,
209
- close_on: nil
211
+ close_on: nil,
212
+ snapshots: false
210
213
  )
211
214
  PaperTrailAdapter.new(associations: associations, ignore: ignore).analyze(
212
215
  record,
@@ -215,7 +218,8 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
215
218
  within: within,
216
219
  activity: activity,
217
220
  version_scope: version_scope,
218
- close_on: close_on
221
+ close_on: close_on,
222
+ snapshots: snapshots
219
223
  )
220
224
  end
221
225
 
@@ -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,15 @@ 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
+ # : () -> void
74
+ def inspect_version_order: () -> void
75
+
76
+ # : () -> Array[untyped]
77
+ def ordered_range_versions: () -> Array[untyped]
78
+
70
79
  # : (untyped) -> void
71
80
  def inspect_checkpoint_timestamp: (untyped) -> void
72
81
 
@@ -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
@@ -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,8 @@ 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
61
 
62
62
  # `close_on:` names what ends a wall-clock window, so it is meaningless for a
63
63
  # range whose endpoints the caller already gave explicitly.
@@ -81,8 +81,8 @@ module PaperTrailDiff
81
81
  # : () -> ActivitySnapshotProvider
82
82
  def build_activity_snapshotter: () -> ActivitySnapshotProvider
83
83
 
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
84
+ # : (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped, ?snapshots: bool) -> ActivityTimelineBuilder
85
+ def activity_builder: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped, ?snapshots: bool) -> ActivityTimelineBuilder
86
86
 
87
87
  # : (untyped) -> void
88
88
  def reject_live_habtm_activity!: (untyped) -> void
@@ -15,6 +15,22 @@ 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
+ # : (untyped) -> bool
29
+ def self?.sequential_id?: (untyped) -> bool
30
+
31
+ # : (Array[untyped]) -> String
32
+ def self?.ambiguous_message: (Array[untyped]) -> String
33
+
18
34
  # : (untyped, untyped) -> Integer
19
35
  def self?.compare_versions: (untyped, untyped) -> Integer
20
36
 
@@ -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
@@ -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.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex Williams
@@ -200,11 +200,11 @@ licenses:
200
200
  metadata:
201
201
  allowed_push_host: https://rubygems.org
202
202
  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
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
205
  homepage_uri: https://github.com/aheathwilliams/paper_trail_diff
206
206
  rubygems_mfa_required: 'true'
207
- source_code_uri: https://github.com/aheathwilliams/paper_trail_diff/tree/v0.7.1
207
+ source_code_uri: https://github.com/aheathwilliams/paper_trail_diff/tree/v0.8.0
208
208
  rdoc_options: []
209
209
  require_paths:
210
210
  - lib