paper_trail_diff 0.5.0 → 0.7.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +52 -0
  3. data/README.md +146 -7
  4. data/lib/paper_trail_diff/activity_root_steps.rb +19 -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 +35 -13
  8. data/lib/paper_trail_diff/batched_root_analyzer.rb +9 -9
  9. data/lib/paper_trail_diff/batched_root_versions.rb +62 -29
  10. data/lib/paper_trail_diff/paper_trail_adapter.rb +72 -25
  11. data/lib/paper_trail_diff/root_version_plan.rb +99 -0
  12. data/lib/paper_trail_diff/root_version_selection.rb +155 -0
  13. data/lib/paper_trail_diff/step.rb +14 -5
  14. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +99 -29
  15. data/lib/paper_trail_diff/time_version_range.rb +22 -24
  16. data/lib/paper_trail_diff/timeline_builder.rb +32 -20
  17. data/lib/paper_trail_diff/timeline_range.rb +34 -7
  18. data/lib/paper_trail_diff/timeline_snapshot_provider.rb +20 -4
  19. data/lib/paper_trail_diff/version.rb +1 -1
  20. data/lib/paper_trail_diff/version_range.rb +45 -5
  21. data/lib/paper_trail_diff/version_scope_filter.rb +37 -0
  22. data/lib/paper_trail_diff.rb +32 -13
  23. data/sig/generated/paper_trail_diff/activity_root_steps.rbs +7 -2
  24. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +6 -2
  25. data/sig/generated/paper_trail_diff/analysis.rbs +13 -4
  26. data/sig/generated/paper_trail_diff/analysis_batch.rbs +15 -6
  27. data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +4 -4
  28. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +28 -14
  29. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +26 -10
  30. data/sig/generated/paper_trail_diff/root_version_plan.rbs +66 -0
  31. data/sig/generated/paper_trail_diff/root_version_selection.rbs +88 -0
  32. data/sig/generated/paper_trail_diff/step.rbs +5 -2
  33. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +41 -15
  34. data/sig/generated/paper_trail_diff/time_version_range.rbs +9 -9
  35. data/sig/generated/paper_trail_diff/timeline_builder.rbs +9 -6
  36. data/sig/generated/paper_trail_diff/timeline_range.rbs +13 -2
  37. data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +11 -2
  38. data/sig/generated/paper_trail_diff/version_range.rbs +17 -2
  39. data/sig/generated/paper_trail_diff/version_scope_filter.rbs +22 -0
  40. data/sig/generated/paper_trail_diff.rbs +8 -8
  41. metadata +10 -4
@@ -4,34 +4,41 @@
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, ?live_endpoint: untyped) -> void
8
+ def initialize(record, time_range:, version_scope: nil, live_endpoint: nil)
9
9
  @record = record
10
10
  @time_range = time_range
11
+ @version_scope = version_scope
12
+ @live_endpoint = live_endpoint
11
13
  end
12
14
 
13
15
  #: (?context_required: bool) -> Array[untyped]
14
16
  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
17
+ select_plan(context_required: context_required).versions
18
+ end
27
19
 
28
- (selected + [trailing]).freeze
20
+ #: (?context_required: bool) -> RootVersionPlan
21
+ def select_plan(context_required: false)
22
+ relation = versions_relation
23
+ in_range = ordered(@time_range.scope(relation).to_a)
24
+ RootVersionSelection.new(
25
+ in_range: in_range,
26
+ selected: VersionScopeFilter.new(@version_scope).call(@time_range.scope(relation),
27
+ in_range),
28
+ after_range: trailing_version(relation),
29
+ windowed: true,
30
+ context_required: context_required,
31
+ filtered: !@version_scope.nil?,
32
+ live_endpoint: @live_endpoint
33
+ ).call
29
34
  end
30
35
 
31
36
  private
32
37
 
33
38
  # @rbs @record: untyped
34
39
  # @rbs @time_range: TimeRange
40
+ # @rbs @version_scope: untyped
41
+ # @rbs @live_endpoint: untyped
35
42
 
36
43
  #: () -> untyped
37
44
  def versions_relation
@@ -42,15 +49,6 @@ module PaperTrailDiff
42
49
  raise InvalidTimelineRangeError, message, cause: e
43
50
  end
44
51
 
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
52
  #: (untyped) -> untyped
55
53
  def trailing_version(relation)
56
54
  @time_range.trailing_scope(relation).reorder(created_at: :asc, id: :asc).first
@@ -4,10 +4,17 @@
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, ?versions: Array[untyped]?) -> void
8
- def initialize(record, from:, to:, snapshotter:, within: nil, versions: nil) # rubocop:disable Metrics/ParameterLists
7
+ #: (untyped, from: untyped, to: untyped, snapshotter: untyped, ?within: untyped, ?versions: Array[untyped]?, ?version_scope: untyped, ?plan: RootVersionPlan?, ?live_endpoint: untyped) -> void
8
+ def initialize( # rubocop:disable Metrics/ParameterLists
9
+ record, from:, to:, snapshotter:, within: nil, versions: nil, version_scope: nil, plan: nil,
10
+ live_endpoint: nil
11
+ )
9
12
  @record = record
10
- @range = TimelineRange.new(record, from: from, to: to, within: within, versions: versions)
13
+ @range = TimelineRange.new(
14
+ record, from: from, to: to, within: within,
15
+ versions: versions, version_scope: version_scope, plan: plan,
16
+ live_endpoint: live_endpoint
17
+ )
11
18
  @snapshotter = snapshotter
12
19
  end
13
20
 
@@ -22,7 +29,9 @@ module PaperTrailDiff
22
29
  steps, first_snapshot, last_snapshot = compare_history(selected_versions)
23
30
  Analysis.new(
24
31
  diff: Engine.compare(first_snapshot, last_snapshot),
25
- timeline: steps
32
+ timeline: steps,
33
+ from_snapshot: first_snapshot,
34
+ to_snapshot: last_snapshot
26
35
  )
27
36
  end
28
37
 
@@ -32,24 +41,27 @@ module PaperTrailDiff
32
41
  # @rbs @range: TimelineRange
33
42
  # @rbs @snapshotter: untyped
34
43
 
35
- #: (Array[untyped]) -> [Array[Step], RecordSnapshot?, RecordSnapshot?]
36
- def compare_history(versions)
37
- return empty_history if versions.empty?
44
+ #: (RootVersionPlan) -> [Array[Step], RecordSnapshot?, RecordSnapshot?]
45
+ def compare_history(plan)
46
+ return empty_history if plan.empty?
38
47
 
48
+ versions = plan.versions
39
49
  @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(
45
- from_version: from_version,
46
- to_version: to_version,
47
- diff: Engine.compare(previous_snapshot, current_snapshot)
50
+ steps = build_steps(plan)
51
+ [steps, @snapshotter.call(versions.first), @snapshotter.call(plan.final_endpoint)]
52
+ end
53
+
54
+ #: (RootVersionPlan) -> Array[Step]
55
+ def build_steps(plan)
56
+ captured_at = Time.now.utc if plan.closing_record
57
+ plan.steps.map do |from_endpoint, to_endpoint|
58
+ Step.new(
59
+ from_version: from_endpoint,
60
+ to_version: to_endpoint,
61
+ captured_at: captured_at,
62
+ diff: Engine.compare(@snapshotter.call(from_endpoint), @snapshotter.call(to_endpoint))
48
63
  )
49
- previous_snapshot = current_snapshot
50
- step
51
64
  end.freeze
52
- [steps, first_snapshot, previous_snapshot]
53
65
  end
54
66
 
55
67
  #: () -> [Array[Step], nil, nil]
@@ -58,9 +70,9 @@ module PaperTrailDiff
58
70
  [steps.freeze, nil, nil]
59
71
  end
60
72
 
61
- #: () -> Array[untyped]
73
+ #: () -> RootVersionPlan
62
74
  def selected_versions
63
- @range.select
75
+ @range.select_plan
64
76
  end
65
77
  end
66
78
  end
@@ -10,10 +10,13 @@ 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, ?versions: Array[untyped]?) -> void
14
- def initialize(record, from:, to:, within:, versions: nil)
13
+ #: (untyped, from: untyped, to: untyped, within: untyped, ?versions: Array[untyped]?, ?version_scope: untyped, ?plan: RootVersionPlan?, ?live_endpoint: untyped) -> void
14
+ def initialize(record, from:, to:, within:, versions: nil, version_scope: nil, plan: nil, live_endpoint: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
15
15
  @record = record
16
- @versions = versions&.freeze
16
+ @plan = plan
17
+ @live_endpoint = live_endpoint
18
+ @versions = (plan ? plan.versions : versions)&.freeze
19
+ @version_scope = version_scope
17
20
  @requested_from = from
18
21
  @requested_to = to
19
22
  @time_range = build_time_range(within)
@@ -32,6 +35,27 @@ module PaperTrailDiff
32
35
 
33
36
  # A batch may have selected these versions already, in which case reselecting
34
37
  # them per record would undo the batching.
38
+ # The plan says which pairs of versions become steps, which a filter can
39
+ # make different from adjacent pairs of the selected versions.
40
+ #: (?context_required: bool) -> RootVersionPlan
41
+ def select_plan(context_required: false)
42
+ preselected = @plan
43
+ return preselected if preselected
44
+
45
+ range = time_range
46
+ if range
47
+ return TimeVersionRange.new(
48
+ @record, time_range: range, version_scope: @version_scope,
49
+ live_endpoint: @live_endpoint
50
+ ).select_plan(context_required: context_required)
51
+ end
52
+ return RootVersionPlan.empty if unresolved?
53
+
54
+ VersionRange.new(
55
+ @record, from: @from, to: @to, version_scope: @version_scope
56
+ ).select_plan_for_range
57
+ end
58
+
35
59
  #: (?context_required: bool) -> Array[untyped]
36
60
  def select(context_required: false)
37
61
  preselected = @versions
@@ -39,13 +63,13 @@ module PaperTrailDiff
39
63
 
40
64
  range = time_range
41
65
  if range
42
- return TimeVersionRange.new(@record, time_range: range).select(
43
- context_required: context_required
44
- )
66
+ return TimeVersionRange.new(
67
+ @record, time_range: range, version_scope: @version_scope
68
+ ).select(context_required: context_required)
45
69
  end
46
70
  return empty_versions if unresolved?
47
71
 
48
- VersionRange.new(@record, from: @from, to: @to).select
72
+ VersionRange.new(@record, from: @from, to: @to, version_scope: @version_scope).select
49
73
  end
50
74
 
51
75
  #: () -> bool
@@ -68,6 +92,9 @@ module PaperTrailDiff
68
92
 
69
93
  # @rbs @record: untyped
70
94
  # @rbs @versions: Array[untyped]?
95
+ # @rbs @plan: RootVersionPlan?
96
+ # @rbs @version_scope: untyped
97
+ # @rbs @live_endpoint: untyped
71
98
  # @rbs @requested_from: untyped
72
99
  # @rbs @requested_to: untyped
73
100
  # @rbs @from: untyped
@@ -4,9 +4,10 @@
4
4
  module PaperTrailDiff
5
5
  # Gives TimelineBuilder one-argument access to a range-prepared snapshot store.
6
6
  class TimelineSnapshotProvider
7
- #: (HistoricalSnapshotStore) -> void
8
- def initialize(store)
7
+ #: (HistoricalSnapshotStore, ?live_snapshotter: untyped) -> void
8
+ def initialize(store, live_snapshotter: nil)
9
9
  @store = store
10
+ @live_snapshotter = live_snapshotter
10
11
  end
11
12
 
12
13
  #: (untyped, Array[untyped]) -> void
@@ -14,11 +15,26 @@ module PaperTrailDiff
14
15
  @store.prepare(record, versions)
15
16
  end
16
17
 
18
+ # A window closing on current state ends at the live record rather than at a
19
+ # version, which is the one endpoint no version history can reconstruct.
17
20
  #: (untyped) -> RecordSnapshot?
18
- def call(version)
19
- @store.uncached(version, version)
21
+ def call(endpoint)
22
+ return live_snapshot(endpoint) if Endpoint.record?(endpoint)
23
+
24
+ @store.uncached(endpoint, endpoint)
25
+ end
26
+
27
+ private
28
+
29
+ #: (untyped) -> RecordSnapshot?
30
+ def live_snapshot(record)
31
+ snapshotter = @live_snapshotter
32
+ raise InvalidTimelineRangeError, 'live endpoints are unavailable here' unless snapshotter
33
+
34
+ snapshotter.call(record)
20
35
  end
21
36
 
22
37
  # @rbs @store: HistoricalSnapshotStore
38
+ # @rbs @live_snapshotter: untyped
23
39
  end
24
40
  end
@@ -2,5 +2,5 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module PaperTrailDiff
5
- VERSION = '0.5.0'
5
+ VERSION = '0.7.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
@@ -39,6 +39,9 @@ require_relative 'paper_trail_diff/live_endpoint_provider'
39
39
  require_relative 'paper_trail_diff/live_graph_collector'
40
40
  require_relative 'paper_trail_diff/batch_boundary_resolver'
41
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'
42
45
  require_relative 'paper_trail_diff/batched_root_versions'
43
46
  require_relative 'paper_trail_diff/snapshot_normalizer'
44
47
  require_relative 'paper_trail_diff/historical_snapshot_store'
@@ -142,27 +145,31 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
142
145
  end
143
146
 
144
147
  # Compares every adjacent reconstructed state in an inclusive version range.
145
- #: (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, ?close_on: Symbol?) -> Array[Step]
146
149
  def timeline( # rubocop:disable Metrics/ParameterLists
147
150
  record,
148
151
  from: nil,
149
152
  to: nil,
150
153
  within: nil,
151
154
  associations: [],
152
- ignore: DEFAULT_IGNORED_ATTRIBUTES
155
+ ignore: DEFAULT_IGNORED_ATTRIBUTES,
156
+ version_scope: nil,
157
+ close_on: nil
153
158
  )
154
159
  PaperTrailAdapter.new(associations: associations, ignore: ignore).timeline(
155
160
  record,
156
161
  from: from,
157
162
  to: to,
158
- within: within
163
+ within: within,
164
+ version_scope: version_scope,
165
+ close_on: close_on
159
166
  )
160
167
  end
161
168
 
162
169
  # Compares adjacent root and selected-descendant activity boundaries.
163
170
  # `reload_live_endpoints:` applies only when `to:` is a current record; the
164
171
  # other range forms never read live state.
165
- #: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool) -> 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?) -> Array[ActivityStep]
166
173
  def activity_timeline( # rubocop:disable Metrics/ParameterLists
167
174
  record,
168
175
  from: nil,
@@ -170,7 +177,9 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
170
177
  within: nil,
171
178
  associations: [],
172
179
  ignore: DEFAULT_IGNORED_ATTRIBUTES,
173
- reload_live_endpoints: true
180
+ reload_live_endpoints: true,
181
+ version_scope: nil,
182
+ close_on: nil
174
183
  )
175
184
  PaperTrailAdapter.new(
176
185
  associations: associations,
@@ -180,12 +189,14 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
180
189
  record,
181
190
  from: from,
182
191
  to: to,
183
- within: within
192
+ within: within,
193
+ version_scope: version_scope,
194
+ close_on: close_on
184
195
  )
185
196
  end
186
197
 
187
198
  # Builds an endpoint diff and root-checkpoint timeline while normalizing each version once.
188
- #: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool) -> Analysis
199
+ #: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Analysis
189
200
  def analyze( # rubocop:disable Metrics/ParameterLists
190
201
  record,
191
202
  from: nil,
@@ -193,32 +204,40 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
193
204
  within: nil,
194
205
  associations: [],
195
206
  ignore: DEFAULT_IGNORED_ATTRIBUTES,
196
- activity: false
207
+ activity: false,
208
+ version_scope: nil,
209
+ close_on: nil
197
210
  )
198
211
  PaperTrailAdapter.new(associations: associations, ignore: ignore).analyze(
199
212
  record,
200
213
  from: from,
201
214
  to: to,
202
215
  within: within,
203
- activity: activity
216
+ activity: activity,
217
+ version_scope: version_scope,
218
+ close_on: close_on
204
219
  )
205
220
  end
206
221
 
207
222
  # Analyzes many roots over one shared time window, preparing their selected
208
223
  # history once for the batch instead of once per record. Roots with no
209
224
  # versions in the window return an empty `Analysis`.
210
- #: (Array[untyped], ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool) -> Hash[identity, Analysis]
211
- def analyze_many(
225
+ #: (Array[untyped], ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Hash[identity, Analysis]
226
+ def analyze_many( # rubocop:disable Metrics/ParameterLists
212
227
  records,
213
228
  within: nil,
214
229
  associations: [],
215
230
  ignore: DEFAULT_IGNORED_ATTRIBUTES,
216
- activity: false
231
+ activity: false,
232
+ version_scope: nil,
233
+ close_on: nil
217
234
  )
218
235
  PaperTrailAdapter.new(associations: associations, ignore: ignore).analyze_many(
219
236
  records,
220
237
  within: within,
221
- activity: activity
238
+ activity: activity,
239
+ version_scope: version_scope,
240
+ close_on: close_on
222
241
  )
223
242
  end
224
243
 
@@ -4,8 +4,13 @@ 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
+ # A window closing on current state ends at the live record, which no
8
+ # version-keyed snapshot can supply, so that endpoint is passed in.
9
+ # : (RootVersionPlan, Hash[Array[untyped], RecordSnapshot?], ?closing_snapshot: RecordSnapshot?, ?captured_at: untyped) -> Array[Step]
10
+ def self?.call: (RootVersionPlan, Hash[Array[untyped], RecordSnapshot?], ?closing_snapshot: RecordSnapshot?, ?captured_at: untyped) -> Array[Step]
11
+
12
+ # : (untyped, Hash[Array[untyped], RecordSnapshot?], RecordSnapshot?) -> RecordSnapshot?
13
+ def self?.snapshot_for: (untyped, Hash[Array[untyped], RecordSnapshot?], RecordSnapshot?) -> RecordSnapshot?
9
14
 
10
15
  # : (untyped) -> Array[untyped]
11
16
  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
@@ -5,8 +5,8 @@ module PaperTrailDiff
5
5
  # versions and preparing their association history once for the whole batch
6
6
  # rather than once per root.
7
7
  class AnalysisBatch
8
- # : (Array[untyped], time_range: TimeRange?, live_loader: untyped, history_preparer: untyped, analyzer: untyped) -> void
9
- def initialize: (Array[untyped], time_range: TimeRange?, live_loader: untyped, history_preparer: untyped, analyzer: untyped) -> void
8
+ # : (Array[untyped], time_range: TimeRange?, live_loader: untyped, history_preparer: untyped, analyzer: untyped, ?version_scope: untyped, ?close_on_current: bool) -> void
9
+ def initialize: (Array[untyped], time_range: TimeRange?, live_loader: untyped, history_preparer: untyped, analyzer: untyped, ?version_scope: untyped, ?close_on_current: bool) -> void
10
10
 
11
11
  # : () -> Hash[identity, Analysis]
12
12
  def call: () -> Hash[identity, Analysis]
@@ -15,6 +15,8 @@ module PaperTrailDiff
15
15
 
16
16
  @analyzer: untyped
17
17
 
18
+ @close_on_current: bool
19
+
18
20
  @history_preparer: untyped
19
21
 
20
22
  @live_loader: untyped
@@ -23,6 +25,13 @@ module PaperTrailDiff
23
25
 
24
26
  @time_range: TimeRange?
25
27
 
28
+ @version_scope: untyped
29
+
30
+ # A filter is a callable that narrows the version relation, so it is checked
31
+ # up front rather than failing partway through a batch.
32
+ # : (untyped) -> untyped
33
+ def validated_scope: (untyped) -> untyped
34
+
26
35
  # : () -> Array[untyped]
27
36
  def validated_records: () -> Array[untyped]
28
37
 
@@ -31,12 +40,12 @@ module PaperTrailDiff
31
40
  # The roots are preloaded first, because preparation reads their current
32
41
  # association state as a fallback and would otherwise walk it one root at a
33
42
  # time.
34
- # : (Array[untyped], Hash[Array[String], Array[untyped]]) -> void
35
- def prepare: (Array[untyped], Hash[Array[String], Array[untyped]]) -> void
43
+ # : (Array[untyped], Hash[Array[String], RootVersionPlan], Hash[Array[String], untyped]) -> void
44
+ def prepare: (Array[untyped], Hash[Array[String], RootVersionPlan], Hash[Array[String], untyped]) -> void
36
45
 
37
46
  # A root with no versions in range has nothing to report, which is an empty
38
47
  # result rather than a failed request.
39
- # : (untyped, Array[untyped]) -> Analysis
40
- def analysis_for: (untyped, Array[untyped]) -> Analysis
48
+ # : (untyped, RootVersionPlan) -> Analysis
49
+ def analysis_for: (untyped, RootVersionPlan) -> Analysis
41
50
  end
42
51
  end
@@ -7,8 +7,8 @@ module PaperTrailDiff
7
7
  # : (tree: AssociationTree, timeline_snapshotter: untyped, activity_snapshotter: untyped, preparer: untyped, activity: bool) -> void
8
8
  def initialize: (tree: AssociationTree, timeline_snapshotter: untyped, activity_snapshotter: untyped, preparer: untyped, activity: bool) -> void
9
9
 
10
- # : (untyped, Array[untyped]) -> Analysis
11
- def call: (untyped, Array[untyped]) -> Analysis
10
+ # : (untyped, RootVersionPlan) -> Analysis
11
+ def call: (untyped, RootVersionPlan) -> Analysis
12
12
 
13
13
  private
14
14
 
@@ -22,7 +22,7 @@ module PaperTrailDiff
22
22
 
23
23
  @tree: AssociationTree
24
24
 
25
- # : (untyped, Array[untyped]) -> Analysis
26
- def activity_analysis: (untyped, Array[untyped]) -> Analysis
25
+ # : (untyped, RootVersionPlan) -> Analysis
26
+ def activity_analysis: (untyped, RootVersionPlan) -> Analysis
27
27
  end
28
28
  end