paper_trail_diff 0.4.0 → 0.6.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -0
  3. data/README.md +120 -3
  4. data/lib/paper_trail_diff/activity_root_steps.rb +9 -7
  5. data/lib/paper_trail_diff/activity_timeline_builder.rb +15 -8
  6. data/lib/paper_trail_diff/analysis.rb +19 -5
  7. data/lib/paper_trail_diff/analysis_batch.rb +92 -0
  8. data/lib/paper_trail_diff/batch_boundary_resolver.rb +137 -0
  9. data/lib/paper_trail_diff/batched_root_analyzer.rb +50 -0
  10. data/lib/paper_trail_diff/batched_root_versions.rb +149 -0
  11. data/lib/paper_trail_diff/comparison_batch.rb +37 -13
  12. data/lib/paper_trail_diff/historical_snapshot_store.rb +3 -7
  13. data/lib/paper_trail_diff/paper_trail_adapter.rb +70 -47
  14. data/lib/paper_trail_diff/root_version_plan.rb +86 -0
  15. data/lib/paper_trail_diff/root_version_selection.rb +131 -0
  16. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +25 -13
  17. data/lib/paper_trail_diff/time_version_range.rb +19 -24
  18. data/lib/paper_trail_diff/timeline_builder.rb +21 -18
  19. data/lib/paper_trail_diff/timeline_range.rb +37 -6
  20. data/lib/paper_trail_diff/traversal_preparer.rb +40 -0
  21. data/lib/paper_trail_diff/version.rb +1 -1
  22. data/lib/paper_trail_diff/version_range.rb +45 -5
  23. data/lib/paper_trail_diff/version_scope_filter.rb +37 -0
  24. data/lib/paper_trail_diff.rb +54 -12
  25. data/sig/generated/paper_trail_diff/activity_root_steps.rbs +2 -2
  26. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +6 -2
  27. data/sig/generated/paper_trail_diff/analysis.rbs +13 -4
  28. data/sig/generated/paper_trail_diff/analysis_batch.rbs +49 -0
  29. data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +62 -0
  30. data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +28 -0
  31. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +69 -0
  32. data/sig/generated/paper_trail_diff/comparison_batch.rbs +15 -0
  33. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +0 -1
  34. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +24 -15
  35. data/sig/generated/paper_trail_diff/root_version_plan.rbs +56 -0
  36. data/sig/generated/paper_trail_diff/root_version_selection.rbs +77 -0
  37. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +9 -4
  38. data/sig/generated/paper_trail_diff/time_version_range.rbs +7 -9
  39. data/sig/generated/paper_trail_diff/timeline_builder.rbs +6 -6
  40. data/sig/generated/paper_trail_diff/timeline_range.rbs +15 -2
  41. data/sig/generated/paper_trail_diff/traversal_preparer.rbs +22 -0
  42. data/sig/generated/paper_trail_diff/version_range.rbs +17 -2
  43. data/sig/generated/paper_trail_diff/version_scope_filter.rbs +22 -0
  44. data/sig/generated/paper_trail_diff.rbs +17 -7
  45. metadata +20 -4
@@ -14,17 +14,19 @@ module PaperTrailDiff
14
14
 
15
15
  #: () -> Array[ActivityStep]
16
16
  def build
17
- history, _root_versions, closing = history_and_versions
17
+ history, _plan, closing = history_and_versions
18
18
  activity_steps(history, closing)
19
19
  end
20
20
 
21
21
  #: () -> Analysis
22
22
  def analyze
23
- history, root_versions, closing = history_and_versions
23
+ history, plan, closing = history_and_versions
24
24
  Analysis.new(
25
25
  diff: Engine.compare(history.first_snapshot, history.last_snapshot),
26
- timeline: ActivityRootSteps.call(root_versions, history.root_snapshots),
27
- activity_timeline: activity_steps(history, closing)
26
+ timeline: ActivityRootSteps.call(plan, history.root_snapshots),
27
+ activity_timeline: activity_steps(history, closing),
28
+ from_snapshot: history.first_snapshot,
29
+ to_snapshot: history.last_snapshot
28
30
  )
29
31
  end
30
32
 
@@ -35,18 +37,19 @@ module PaperTrailDiff
35
37
  # @rbs @tree: AssociationTree
36
38
  # @rbs @snapshotter: untyped
37
39
 
38
- #: () -> [ActivityHistory, Array[untyped], ActivityStep?]
40
+ #: () -> [ActivityHistory, RootVersionPlan, ActivityStep?]
39
41
  def history_and_versions
40
- root_versions = @range.select(context_required: !@tree.empty?)
41
- return [ActivityHistory.empty, root_versions, nil] if root_versions.empty?
42
+ plan = @range.select_plan(context_required: !@tree.empty?)
43
+ root_versions = plan.reconstruction_versions
44
+ return [ActivityHistory.empty, plan, nil] if root_versions.empty?
42
45
 
43
46
  prepare_history(root_versions)
44
47
  events = collect_events(root_versions)
45
- selected = selected_events(events)
46
- return [ActivityHistory.empty, root_versions, nil] unless time_events?(selected, events)
48
+ selected = selected_events(events, plan)
49
+ return [ActivityHistory.empty, plan, nil] unless time_events?(selected, events)
47
50
 
48
51
  history = build_history(root_versions, events)
49
- [history, root_versions, closing_step(history, selected.last)]
52
+ [history, plan, closing_step(history, selected.last)]
50
53
  end
51
54
 
52
55
  #: (Array[untyped], Array[ActivityEvent]) -> ActivityHistory
@@ -66,9 +69,18 @@ module PaperTrailDiff
66
69
  (history.steps + [closing]).freeze
67
70
  end
68
71
 
69
- #: (Array[ActivityEvent]) -> Array[ActivityEvent]
70
- def selected_events(events)
71
- events.select { |event| @range.include?(event.version) }
72
+ # Root versions inside the window that the plan does not report are context
73
+ # rather than mutations: the version appended to reveal the last selected
74
+ # change, and anything a filter excluded but the replay still walks. Neither
75
+ # may be counted as a selected mutation. Descendant events are not filtered,
76
+ # so window membership is the whole test for them.
77
+ #: (Array[ActivityEvent], RootVersionPlan) -> Array[ActivityEvent]
78
+ def selected_events(events, plan)
79
+ events.select do |event|
80
+ next false unless @range.include?(event.version)
81
+
82
+ !event.root? || plan.mutation?(event.version)
83
+ end
72
84
  end
73
85
 
74
86
  # The window's last selected mutation is the root's own destruction, so the
@@ -4,34 +4,38 @@
4
4
  module PaperTrailDiff
5
5
  # Selects in-range root versions plus one later reconstruction boundary.
6
6
  class TimeVersionRange
7
- #: (untyped, time_range: TimeRange) -> void
8
- def initialize(record, time_range:)
7
+ #: (untyped, time_range: TimeRange, ?version_scope: untyped) -> void
8
+ def initialize(record, time_range:, version_scope: nil)
9
9
  @record = record
10
10
  @time_range = time_range
11
+ @version_scope = version_scope
11
12
  end
12
13
 
13
14
  #: (?context_required: bool) -> Array[untyped]
14
15
  def select(context_required: false)
15
- relation = versions_relation
16
- selected = ordered(@time_range.scope(relation).to_a)
17
- empty = [] #: Array[untyped]
18
- return empty.freeze if selected.empty? && !context_required
19
-
20
- trailing = trailing_version(relation)
21
- unless trailing
22
- return selected.freeze if terminal_destroy?(selected)
23
-
24
- message = 'time range requires a later root version to reconstruct its final change'
25
- raise IncompleteTimeRangeError, message
26
- end
16
+ select_plan(context_required: context_required).versions
17
+ end
27
18
 
28
- (selected + [trailing]).freeze
19
+ #: (?context_required: bool) -> RootVersionPlan
20
+ def select_plan(context_required: false)
21
+ relation = versions_relation
22
+ in_range = ordered(@time_range.scope(relation).to_a)
23
+ RootVersionSelection.new(
24
+ in_range: in_range,
25
+ selected: VersionScopeFilter.new(@version_scope).call(@time_range.scope(relation),
26
+ in_range),
27
+ after_range: trailing_version(relation),
28
+ windowed: true,
29
+ context_required: context_required,
30
+ filtered: !@version_scope.nil?
31
+ ).call
29
32
  end
30
33
 
31
34
  private
32
35
 
33
36
  # @rbs @record: untyped
34
37
  # @rbs @time_range: TimeRange
38
+ # @rbs @version_scope: untyped
35
39
 
36
40
  #: () -> untyped
37
41
  def versions_relation
@@ -42,15 +46,6 @@ module PaperTrailDiff
42
46
  raise InvalidTimelineRangeError, message, cause: e
43
47
  end
44
48
 
45
- # A window closing on the record's own destruction needs no later version:
46
- # the destroy reveals the preceding mutation and nothing can follow it, so
47
- # demanding a checkpoint that can never be written would reject the range
48
- # permanently.
49
- #: (Array[untyped]) -> bool
50
- def terminal_destroy?(versions)
51
- versions.last&.event.to_s == 'destroy'
52
- end
53
-
54
49
  #: (untyped) -> untyped
55
50
  def trailing_version(relation)
56
51
  @time_range.trailing_scope(relation).reorder(created_at: :asc, id: :asc).first
@@ -4,10 +4,15 @@
4
4
  module PaperTrailDiff
5
5
  # Selects and compares a chronological slice of a record's version history.
6
6
  class TimelineBuilder
7
- #: (untyped, from: untyped, to: untyped, snapshotter: untyped, ?within: untyped) -> void
8
- def initialize(record, from:, to:, snapshotter:, within: nil)
7
+ #: (untyped, from: untyped, to: untyped, snapshotter: untyped, ?within: untyped, ?versions: Array[untyped]?, ?version_scope: untyped, ?plan: RootVersionPlan?) -> void
8
+ def initialize( # rubocop:disable Metrics/ParameterLists
9
+ record, from:, to:, snapshotter:, within: nil, versions: nil, version_scope: nil, plan: nil
10
+ )
9
11
  @record = record
10
- @range = TimelineRange.new(record, from: from, to: to, within: within)
12
+ @range = TimelineRange.new(
13
+ record, from: from, to: to, within: within,
14
+ versions: versions, version_scope: version_scope, plan: plan
15
+ )
11
16
  @snapshotter = snapshotter
12
17
  end
13
18
 
@@ -22,7 +27,9 @@ module PaperTrailDiff
22
27
  steps, first_snapshot, last_snapshot = compare_history(selected_versions)
23
28
  Analysis.new(
24
29
  diff: Engine.compare(first_snapshot, last_snapshot),
25
- timeline: steps
30
+ timeline: steps,
31
+ from_snapshot: first_snapshot,
32
+ to_snapshot: last_snapshot
26
33
  )
27
34
  end
28
35
 
@@ -32,24 +39,20 @@ module PaperTrailDiff
32
39
  # @rbs @range: TimelineRange
33
40
  # @rbs @snapshotter: untyped
34
41
 
35
- #: (Array[untyped]) -> [Array[Step], RecordSnapshot?, RecordSnapshot?]
36
- def compare_history(versions)
37
- return empty_history if versions.empty?
42
+ #: (RootVersionPlan) -> [Array[Step], RecordSnapshot?, RecordSnapshot?]
43
+ def compare_history(plan)
44
+ return empty_history if plan.empty?
38
45
 
46
+ versions = plan.versions
39
47
  @snapshotter.prepare(@record, versions) if @snapshotter.respond_to?(:prepare)
40
- first_snapshot = @snapshotter.call(versions.first)
41
- previous_snapshot = first_snapshot
42
- steps = versions.each_cons(2).map do |from_version, to_version|
43
- current_snapshot = @snapshotter.call(to_version)
44
- step = Step.new(
48
+ steps = plan.steps.map do |from_version, to_version|
49
+ Step.new(
45
50
  from_version: from_version,
46
51
  to_version: to_version,
47
- diff: Engine.compare(previous_snapshot, current_snapshot)
52
+ diff: Engine.compare(@snapshotter.call(from_version), @snapshotter.call(to_version))
48
53
  )
49
- previous_snapshot = current_snapshot
50
- step
51
54
  end.freeze
52
- [steps, first_snapshot, previous_snapshot]
55
+ [steps, @snapshotter.call(versions.first), @snapshotter.call(versions.last)]
53
56
  end
54
57
 
55
58
  #: () -> [Array[Step], nil, nil]
@@ -58,9 +61,9 @@ module PaperTrailDiff
58
61
  [steps.freeze, nil, nil]
59
62
  end
60
63
 
61
- #: () -> Array[untyped]
64
+ #: () -> RootVersionPlan
62
65
  def selected_versions
63
- @range.select
66
+ @range.select_plan
64
67
  end
65
68
  end
66
69
  end
@@ -10,9 +10,12 @@ module PaperTrailDiff
10
10
  attr_reader :to #: untyped
11
11
  attr_reader :time_range #: TimeRange?
12
12
 
13
- #: (untyped, from: untyped, to: untyped, within: untyped) -> void
14
- def initialize(record, from:, to:, within:)
13
+ #: (untyped, from: untyped, to: untyped, within: untyped, ?versions: Array[untyped]?, ?version_scope: untyped, ?plan: RootVersionPlan?) -> void
14
+ def initialize(record, from:, to:, within:, versions: nil, version_scope: nil, plan: nil) # rubocop:disable Metrics/ParameterLists
15
15
  @record = record
16
+ @plan = plan
17
+ @versions = (plan ? plan.versions : versions)&.freeze
18
+ @version_scope = version_scope
16
19
  @requested_from = from
17
20
  @requested_to = to
18
21
  @time_range = build_time_range(within)
@@ -29,17 +32,42 @@ module PaperTrailDiff
29
32
  (symbolic?(@requested_from) && @from.nil?) || (symbolic?(@requested_to) && @to.nil?)
30
33
  end
31
34
 
35
+ # A batch may have selected these versions already, in which case reselecting
36
+ # them per record would undo the batching.
37
+ # The plan says which pairs of versions become steps, which a filter can
38
+ # make different from adjacent pairs of the selected versions.
39
+ #: (?context_required: bool) -> RootVersionPlan
40
+ def select_plan(context_required: false)
41
+ preselected = @plan
42
+ return preselected if preselected
43
+
44
+ range = time_range
45
+ if range
46
+ return TimeVersionRange.new(
47
+ @record, time_range: range, version_scope: @version_scope
48
+ ).select_plan(context_required: context_required)
49
+ end
50
+ return RootVersionPlan.empty if unresolved?
51
+
52
+ VersionRange.new(
53
+ @record, from: @from, to: @to, version_scope: @version_scope
54
+ ).select_plan_for_range
55
+ end
56
+
32
57
  #: (?context_required: bool) -> Array[untyped]
33
58
  def select(context_required: false)
59
+ preselected = @versions
60
+ return preselected if preselected
61
+
34
62
  range = time_range
35
63
  if range
36
- return TimeVersionRange.new(@record, time_range: range).select(
37
- context_required: context_required
38
- )
64
+ return TimeVersionRange.new(
65
+ @record, time_range: range, version_scope: @version_scope
66
+ ).select(context_required: context_required)
39
67
  end
40
68
  return empty_versions if unresolved?
41
69
 
42
- VersionRange.new(@record, from: @from, to: @to).select
70
+ VersionRange.new(@record, from: @from, to: @to, version_scope: @version_scope).select
43
71
  end
44
72
 
45
73
  #: () -> bool
@@ -61,6 +89,9 @@ module PaperTrailDiff
61
89
  private
62
90
 
63
91
  # @rbs @record: untyped
92
+ # @rbs @versions: Array[untyped]?
93
+ # @rbs @plan: RootVersionPlan?
94
+ # @rbs @version_scope: untyped
64
95
  # @rbs @requested_from: untyped
65
96
  # @rbs @requested_to: untyped
66
97
  # @rbs @from: untyped
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Checks that a model can be traversed before any reconstruction starts, and
6
+ # that association tracking is actually available when history is involved.
7
+ class TraversalPreparer
8
+ #: (tree: AssociationTree, traversal: AssociationTraversal) -> void
9
+ def initialize(tree:, traversal:)
10
+ @tree = tree
11
+ @traversal = traversal
12
+ end
13
+
14
+ #: (untyped, historical: bool) -> void
15
+ def call(model_class, historical:)
16
+ return if @tree.empty?
17
+
18
+ ensure_association_tracking! if historical
19
+ @traversal.validate!(model_class)
20
+ end
21
+
22
+ private
23
+
24
+ # @rbs @tree: AssociationTree
25
+ # @rbs @traversal: AssociationTraversal
26
+
27
+ #: () -> void
28
+ def ensure_association_tracking!
29
+ paper_trail = Object.const_get(:PaperTrail) #: untyped
30
+ config = paper_trail.config #: untyped
31
+ available = defined?(::PaperTrailAssociationTracking) &&
32
+ config.respond_to?(:track_associations?) &&
33
+ config.track_associations?
34
+ return if available
35
+
36
+ message = 'association tracking must be loaded and enabled to compare historical associations'
37
+ raise AssociationTrackingUnavailableError, message
38
+ end
39
+ end
40
+ end
@@ -2,5 +2,5 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module PaperTrailDiff
5
- VERSION = '0.4.0'
5
+ VERSION = '0.6.0'
6
6
  end
@@ -4,11 +4,34 @@
4
4
  module PaperTrailDiff
5
5
  # Selects an inclusive, chronological range from a record's root versions.
6
6
  class VersionRange
7
- #: (untyped, from: untyped, to: untyped) -> void
8
- def initialize(record, from:, to:)
7
+ #: (untyped, from: untyped, to: untyped, ?version_scope: untyped) -> void
8
+ def initialize(record, from:, to:, version_scope: nil)
9
9
  @record = record
10
10
  @from = from
11
11
  @to = to
12
+ @version_scope = version_scope
13
+ end
14
+
15
+ # Same selection as `select`, but keeping the step pairs a filter implies.
16
+ #: () -> RootVersionPlan
17
+ def select_plan_for_range
18
+ relation = validated_relation
19
+ select_plan(
20
+ relation.where(created_at: @from.created_at..@to.created_at),
21
+ through: @to
22
+ )
23
+ end
24
+
25
+ #: () -> untyped
26
+ def validated_relation
27
+ relation = versions_relation
28
+ validate_boundary!(@from, relation, boundary: :from)
29
+ validate_boundary!(@to, relation, boundary: :to)
30
+ if Support.compare_versions(@from, @to).positive?
31
+ raise InvalidTimelineRangeError, '`from` version must not follow `to` version'
32
+ end
33
+
34
+ relation
12
35
  end
13
36
 
14
37
  #: () -> Array[untyped]
@@ -38,6 +61,7 @@ module PaperTrailDiff
38
61
  # @rbs @record: untyped
39
62
  # @rbs @from: untyped
40
63
  # @rbs @to: untyped
64
+ # @rbs @version_scope: untyped
41
65
 
42
66
  #: () -> untyped
43
67
  def versions_relation
@@ -50,13 +74,29 @@ module PaperTrailDiff
50
74
 
51
75
  #: (untyped, ?through: untyped) -> Array[untyped]
52
76
  def select_relation(relation, through: nil)
53
- selected = relation.to_a.select do |version|
77
+ select_plan(relation, through: through).versions
78
+ end
79
+
80
+ #: (untyped, ?through: untyped) -> RootVersionPlan
81
+ def select_plan(relation, through: nil)
82
+ in_range = ordered(relation.to_a.select do |version|
54
83
  next false if Support.compare_versions(@from, version).positive?
55
84
  next true unless through
56
85
 
57
86
  Support.compare_versions(version, through) <= 0
58
- end
59
- selected.sort_by { |version| Support.chronological_version_key(version) }
87
+ end)
88
+ RootVersionSelection.new(
89
+ in_range: in_range,
90
+ selected: VersionScopeFilter.new(@version_scope).call(relation, in_range),
91
+ after_range: nil,
92
+ windowed: false,
93
+ filtered: !@version_scope.nil?
94
+ ).call
95
+ end
96
+
97
+ #: (Array[untyped]) -> Array[untyped]
98
+ def ordered(versions)
99
+ versions.sort_by { |version| Support.chronological_version_key(version) }
60
100
  end
61
101
 
62
102
  #: (untyped, untyped, boundary: Symbol) -> void
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Applies a caller's version filter to a range, naming which of its versions
6
+ # count as selected mutations. The unfiltered versions stay available to the
7
+ # caller, because the one that reveals the last selected mutation is drawn
8
+ # from them.
9
+ class VersionScopeFilter
10
+ #: (untyped) -> void
11
+ def initialize(scope)
12
+ @scope = validated(scope)
13
+ end
14
+
15
+ #: (untyped, Array[untyped]) -> Array[untyped]
16
+ def call(relation, in_range)
17
+ scope = @scope
18
+ return in_range unless scope
19
+
20
+ # A narrowed relation is the expected return. Active Support also gives
21
+ # `pluck` to plain enumerables, so an array of versions works too.
22
+ chosen = Set.new(scope.call(relation).pluck(:id))
23
+ in_range.select { |version| chosen.include?(version.id) }
24
+ end
25
+
26
+ private
27
+
28
+ # @rbs @scope: untyped
29
+
30
+ #: (untyped) -> untyped
31
+ def validated(scope)
32
+ return scope if scope.nil? || scope.respond_to?(:call)
33
+
34
+ raise ConfigurationError, 'version_scope: must respond to call'
35
+ end
36
+ end
37
+ end
@@ -10,6 +10,7 @@ require_relative 'paper_trail_diff/errors'
10
10
  require_relative 'paper_trail_diff/configuration'
11
11
  require_relative 'paper_trail_diff/endpoint'
12
12
  require_relative 'paper_trail_diff/association_traversal'
13
+ require_relative 'paper_trail_diff/traversal_preparer'
13
14
  require_relative 'paper_trail_diff/association_discovery'
14
15
  require_relative 'paper_trail_diff/diagnostics'
15
16
  require_relative 'paper_trail_diff/collection_identity_index'
@@ -36,7 +37,12 @@ require_relative 'paper_trail_diff/live_endpoint_batch_loader'
36
37
  require_relative 'paper_trail_diff/preloaded_endpoint_batch_loader'
37
38
  require_relative 'paper_trail_diff/live_endpoint_provider'
38
39
  require_relative 'paper_trail_diff/live_graph_collector'
40
+ require_relative 'paper_trail_diff/batch_boundary_resolver'
39
41
  require_relative 'paper_trail_diff/comparison_batch'
42
+ require_relative 'paper_trail_diff/root_version_plan'
43
+ require_relative 'paper_trail_diff/version_scope_filter'
44
+ require_relative 'paper_trail_diff/root_version_selection'
45
+ require_relative 'paper_trail_diff/batched_root_versions'
40
46
  require_relative 'paper_trail_diff/snapshot_normalizer'
41
47
  require_relative 'paper_trail_diff/historical_snapshot_store'
42
48
  require_relative 'paper_trail_diff/timeline_snapshot_provider'
@@ -56,6 +62,8 @@ require_relative 'paper_trail_diff/activity_boundary'
56
62
  require_relative 'paper_trail_diff/step'
57
63
  require_relative 'paper_trail_diff/analysis'
58
64
  require_relative 'paper_trail_diff/activity_root_steps'
65
+ require_relative 'paper_trail_diff/analysis_batch'
66
+ require_relative 'paper_trail_diff/batched_root_analyzer'
59
67
  require_relative 'paper_trail_diff/version_range'
60
68
  require_relative 'paper_trail_diff/time_range'
61
69
  require_relative 'paper_trail_diff/time_version_range'
@@ -90,8 +98,9 @@ require_relative 'paper_trail_diff/paper_trail_adapter'
90
98
  # type traversal_record_path = Array[RecordReference]
91
99
  # end
92
100
 
93
- # Structured version comparison for PaperTrail.
94
- module PaperTrailDiff
101
+ # Structured version comparison for PaperTrail. Each method is a thin,
102
+ # documented entry point, so this reads as an API listing rather than logic.
103
+ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
95
104
  DEFAULT_IGNORED_ATTRIBUTES = ['updated_at'].freeze
96
105
  SUPPORTED_ASSOCIATION_MACROS = %i[
97
106
  belongs_to
@@ -136,43 +145,54 @@ module PaperTrailDiff
136
145
  end
137
146
 
138
147
  # Compares every adjacent reconstructed state in an inclusive version range.
139
- #: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option) -> Array[Step]
148
+ #: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?version_scope: untyped) -> Array[Step]
140
149
  def timeline( # rubocop:disable Metrics/ParameterLists
141
150
  record,
142
151
  from: nil,
143
152
  to: nil,
144
153
  within: nil,
145
154
  associations: [],
146
- ignore: DEFAULT_IGNORED_ATTRIBUTES
155
+ ignore: DEFAULT_IGNORED_ATTRIBUTES,
156
+ version_scope: nil
147
157
  )
148
158
  PaperTrailAdapter.new(associations: associations, ignore: ignore).timeline(
149
159
  record,
150
160
  from: from,
151
161
  to: to,
152
- within: within
162
+ within: within,
163
+ version_scope: version_scope
153
164
  )
154
165
  end
155
166
 
156
167
  # Compares adjacent root and selected-descendant activity boundaries.
157
- #: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option) -> Array[ActivityStep]
168
+ # `reload_live_endpoints:` applies only when `to:` is a current record; the
169
+ # other range forms never read live state.
170
+ #: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool, ?version_scope: untyped) -> Array[ActivityStep]
158
171
  def activity_timeline( # rubocop:disable Metrics/ParameterLists
159
172
  record,
160
173
  from: nil,
161
174
  to: nil,
162
175
  within: nil,
163
176
  associations: [],
164
- ignore: DEFAULT_IGNORED_ATTRIBUTES
177
+ ignore: DEFAULT_IGNORED_ATTRIBUTES,
178
+ reload_live_endpoints: true,
179
+ version_scope: nil
165
180
  )
166
- PaperTrailAdapter.new(associations: associations, ignore: ignore).activity_timeline(
181
+ PaperTrailAdapter.new(
182
+ associations: associations,
183
+ ignore: ignore,
184
+ reload_live_endpoints: reload_live_endpoints
185
+ ).activity_timeline(
167
186
  record,
168
187
  from: from,
169
188
  to: to,
170
- within: within
189
+ within: within,
190
+ version_scope: version_scope
171
191
  )
172
192
  end
173
193
 
174
194
  # Builds an endpoint diff and root-checkpoint timeline while normalizing each version once.
175
- #: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool) -> Analysis
195
+ #: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped) -> Analysis
176
196
  def analyze( # rubocop:disable Metrics/ParameterLists
177
197
  record,
178
198
  from: nil,
@@ -180,14 +200,36 @@ module PaperTrailDiff
180
200
  within: nil,
181
201
  associations: [],
182
202
  ignore: DEFAULT_IGNORED_ATTRIBUTES,
183
- activity: false
203
+ activity: false,
204
+ version_scope: nil
184
205
  )
185
206
  PaperTrailAdapter.new(associations: associations, ignore: ignore).analyze(
186
207
  record,
187
208
  from: from,
188
209
  to: to,
189
210
  within: within,
190
- activity: activity
211
+ activity: activity,
212
+ version_scope: version_scope
213
+ )
214
+ end
215
+
216
+ # Analyzes many roots over one shared time window, preparing their selected
217
+ # history once for the batch instead of once per record. Roots with no
218
+ # versions in the window return an empty `Analysis`.
219
+ #: (Array[untyped], ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped) -> Hash[identity, Analysis]
220
+ def analyze_many( # rubocop:disable Metrics/ParameterLists
221
+ records,
222
+ within: nil,
223
+ associations: [],
224
+ ignore: DEFAULT_IGNORED_ATTRIBUTES,
225
+ activity: false,
226
+ version_scope: nil
227
+ )
228
+ PaperTrailAdapter.new(associations: associations, ignore: ignore).analyze_many(
229
+ records,
230
+ within: within,
231
+ activity: activity,
232
+ version_scope: version_scope
191
233
  )
192
234
  end
193
235
 
@@ -4,8 +4,8 @@ module PaperTrailDiff
4
4
  # Root checkpoint steps recovered from the snapshots an activity pass retained,
5
5
  # so a combined result does not reconstruct the same boundaries twice.
6
6
  module ActivityRootSteps
7
- # : (Array[untyped], Hash[Array[untyped], RecordSnapshot?]) -> Array[Step]
8
- def self?.call: (Array[untyped], Hash[Array[untyped], RecordSnapshot?]) -> Array[Step]
7
+ # : (RootVersionPlan, Hash[Array[untyped], RecordSnapshot?]) -> Array[Step]
8
+ def self?.call: (RootVersionPlan, Hash[Array[untyped], RecordSnapshot?]) -> Array[Step]
9
9
 
10
10
  # : (untyped) -> Array[untyped]
11
11
  def self?.version_key: (untyped) -> Array[untyped]
@@ -30,6 +30,10 @@ module PaperTrailDiff
30
30
  # : () -> Array[ActivityStep]
31
31
  def no_steps: () -> Array[ActivityStep]
32
32
 
33
+ # Every boundary in the span, filtered out or not. A filter narrows where the
34
+ # span starts and ends, but the sequence inside it stays complete: dropping
35
+ # boundaries would fold the changes they carried into a neighbouring step and
36
+ # credit them to whoever made that one.
33
37
  # : () -> Array[ActivityStep]
34
38
  def build_between_versions: () -> Array[ActivityStep]
35
39
 
@@ -67,8 +71,8 @@ module PaperTrailDiff
67
71
  # Only the activity view gains the closing removal. The endpoint diff and
68
72
  # the root timeline keep their `compare` and `timeline` semantics, under
69
73
  # which the state at a destroy version is the state before the deletion.
70
- # : (Array[untyped], Array[ActivityEvent], ActivityHistory) -> Analysis
71
- def build_analysis: (Array[untyped], Array[ActivityEvent], ActivityHistory) -> Analysis
74
+ # : (RootVersionPlan, Array[ActivityEvent], ActivityHistory) -> Analysis
75
+ def build_analysis: (RootVersionPlan, Array[ActivityEvent], ActivityHistory) -> Analysis
72
76
 
73
77
  # : () -> TimeActivityTimelineBuilder
74
78
  def time_builder: () -> TimeActivityTimelineBuilder
@@ -9,15 +9,24 @@ module PaperTrailDiff
9
9
 
10
10
  attr_reader activity_timeline: Array[ActivityStep]?
11
11
 
12
+ attr_reader from_snapshot: RecordSnapshot?
13
+
14
+ attr_reader to_snapshot: RecordSnapshot?
15
+
12
16
  # The result for a record whose requested history contains no versions,
13
17
  # which is an empty history rather than a failed request.
14
18
  # : () -> Analysis
15
19
  def self.empty: () -> Analysis
16
20
 
17
- # : (diff: Diff, timeline: Array[Step], ?activity_timeline: Array[ActivityStep]?) -> void
18
- def initialize: (diff: Diff, timeline: Array[Step], ?activity_timeline: Array[ActivityStep]?) -> void
21
+ # The reconstructed states the diff was taken between. A report that has to
22
+ # render unchanged columns needs the whole final state, not only what moved.
23
+ # : (diff: Diff, timeline: Array[Step], ?activity_timeline: Array[ActivityStep]?, ?from_snapshot: RecordSnapshot?, ?to_snapshot: RecordSnapshot?) -> void
24
+ def initialize: (diff: Diff, timeline: Array[Step], ?activity_timeline: Array[ActivityStep]?, ?from_snapshot: RecordSnapshot?, ?to_snapshot: RecordSnapshot?) -> void
19
25
 
20
- # : () -> Hash[Symbol, untyped]
21
- def to_h: () -> Hash[Symbol, untyped]
26
+ # Reconstructed endpoint states are opt-in, because they carry the whole
27
+ # selected graph whether or not anything changed, which dwarfs the rest of
28
+ # the payload for a wide graph.
29
+ # : (?snapshots: bool) -> Hash[Symbol, untyped]
30
+ def to_h: (?snapshots: bool) -> Hash[Symbol, untyped]
22
31
  end
23
32
  end