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
@@ -7,6 +7,9 @@ module PaperTrailDiff
7
7
  # the only place allowed to know about both PaperTrail and the pure engine.
8
8
  # Reconstruction logic lives in the collaborators it wires together.
9
9
  class PaperTrailAdapter # rubocop:disable Metrics/ClassLength
10
+ # The only thing a wall-clock window can close on besides a later version.
11
+ CLOSE_ON_CURRENT = :current
12
+
10
13
  #: (associations: Array[String | Symbol], ignore: ignore_option, ?reload_live_endpoints: bool) -> void
11
14
  def initialize(associations:, ignore:, reload_live_endpoints: true)
12
15
  @association_tree = AssociationTree.build(associations)
@@ -50,57 +53,64 @@ module PaperTrailDiff
50
53
  end
51
54
  end
52
55
 
53
- #: (untyped, from: untyped, to: untyped, within: untyped) -> Array[Step]
54
- def timeline(record, from:, to:, within:)
56
+ #: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?) -> Array[Step]
57
+ def timeline(record, from:, to:, within:, version_scope: nil, close_on: nil) # rubocop:disable Metrics/ParameterLists
55
58
  @traversal_preparer.call(record.class, historical: true)
56
- builder = TimelineBuilder.new(
59
+ TimelineBuilder.new(
57
60
  record,
58
61
  from: from,
59
62
  to: to,
60
63
  within: within,
64
+ version_scope: version_scope,
65
+ live_endpoint: live_endpoint_for(record, close_on, within),
61
66
  snapshotter: @timeline_snapshotter
62
- )
63
- builder.build
67
+ ).build
64
68
  end
65
69
 
66
- #: (untyped, from: untyped, to: untyped, within: untyped) -> Array[ActivityStep]
67
- def activity_timeline(record, from:, to:, within:)
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
68
72
  payload = @instrumentation_payload.merge(model_type: record.class.base_class.name.to_s)
69
73
  Instrumentation.instrument('activity_timeline', payload) do
70
74
  @traversal_preparer.call(record.class, historical: true)
71
- reject_live_habtm_activity!(record.class) if Endpoint.record?(to)
72
- steps = activity_builder(record, from: from, to: to, within: within).build
75
+ live = live_endpoint_for(record, close_on, within)
76
+ reject_live_habtm_activity!(record.class) if Endpoint.record?(to) || live
77
+ steps = activity_builder(
78
+ record, from: from, to: to, within: within, version_scope: version_scope,
79
+ live_endpoint: live
80
+ ).build
73
81
  payload[:step_count] = steps.length
74
82
  steps
75
83
  end
76
84
  end
77
85
 
78
- #: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool) -> Analysis
79
- def analyze(record, from:, to:, within:, activity: false)
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
88
+ @traversal_preparer.call(record.class, historical: true)
89
+ live = live_endpoint_for(record, close_on, within)
80
90
  if activity
81
- @traversal_preparer.call(record.class, historical: true)
82
- return activity_builder(record, from: from, to: to, within: within).analyze
91
+ return analyze_activity(
92
+ record, from: from, to: to, within: within,
93
+ version_scope: version_scope, live_endpoint: live
94
+ )
83
95
  end
84
96
 
85
- @traversal_preparer.call(record.class, historical: true)
86
97
  TimelineBuilder.new(
87
- record,
88
- from: from,
89
- to: to,
90
- within: within,
91
- snapshotter: @timeline_snapshotter
98
+ record, from: from, to: to, within: within, version_scope: version_scope,
99
+ live_endpoint: live, snapshotter: @timeline_snapshotter
92
100
  ).analyze
93
101
  end
94
102
 
95
103
  # Analyzes many roots over one shared range, preparing their history once.
96
- #: (Array[untyped], within: untyped, ?activity: bool) -> Hash[identity, Analysis]
97
- def analyze_many(records, within:, activity: false)
104
+ #: (Array[untyped], within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Hash[identity, Analysis]
105
+ def analyze_many(records, within:, activity: false, version_scope: nil, close_on: nil)
98
106
  count = records.is_a?(Array) ? records.length : 0
99
107
  payload = @instrumentation_payload.merge(comparison_count: count)
100
108
  Instrumentation.instrument('analyze_many', payload) do
101
109
  AnalysisBatch.new(
102
110
  records,
103
111
  time_range: within.nil? ? nil : TimeRange.new(within),
112
+ version_scope: version_scope,
113
+ close_on_current: close_on_current?(close_on, within),
104
114
  live_loader: @live_endpoints.method(:call),
105
115
  history_preparer: @historical_store.method(:prepare_batch),
106
116
  analyzer: batched_root_analyzer(activity)
@@ -122,10 +132,44 @@ module PaperTrailDiff
122
132
  # @rbs @timeline_snapshotter: TimelineSnapshotProvider
123
133
  # @rbs @activity_snapshotter: ActivitySnapshotProvider
124
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
137
+ reject_live_habtm_activity!(record.class) if live_endpoint
138
+ activity_builder(
139
+ record, from: from, to: to, within: within,
140
+ version_scope: version_scope, live_endpoint: live_endpoint
141
+ ).analyze
142
+ end
143
+
144
+ # `close_on:` names what ends a wall-clock window, so it is meaningless for a
145
+ # range whose endpoints the caller already gave explicitly.
146
+ #: (Symbol?, untyped) -> bool
147
+ def close_on_current?(close_on, within)
148
+ return false if close_on.nil?
149
+ unless close_on == CLOSE_ON_CURRENT
150
+ raise ConfigurationError, "close_on: must be #{CLOSE_ON_CURRENT.inspect} or nil"
151
+ end
152
+ raise ConfigurationError, 'close_on: requires `within`' if within.nil?
153
+
154
+ true
155
+ end
156
+
157
+ # A destroyed root has no current state to close on, and its own destroy
158
+ # version already terminates the history.
159
+ #: (untyped, Symbol?, untyped) -> untyped
160
+ def live_endpoint_for(record, close_on, within)
161
+ return unless close_on_current?(close_on, within)
162
+ return unless Endpoint.record?(record) && !record.destroyed?
163
+
164
+ record
165
+ end
166
+
125
167
  #: () -> void
126
168
  def build_snapshotters
127
169
  @historical_store = build_historical_store
128
- @timeline_snapshotter = TimelineSnapshotProvider.new(@historical_store)
170
+ @timeline_snapshotter = TimelineSnapshotProvider.new(
171
+ @historical_store, live_snapshotter: method(:live_snapshot)
172
+ )
129
173
  @activity_snapshotter = build_activity_snapshotter
130
174
  end
131
175
 
@@ -168,11 +212,14 @@ module PaperTrailDiff
168
212
  )
169
213
  end
170
214
 
171
- #: (untyped, from: untyped, to: untyped, within: untyped) -> ActivityTimelineBuilder
172
- def activity_builder(record, from:, to:, within:)
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
173
217
  ActivityTimelineBuilder.new(
174
218
  record,
175
- range: TimelineRange.new(record, from: from, to: to, within: within),
219
+ range: TimelineRange.new(
220
+ record, from: from, to: to, within: within, version_scope: version_scope,
221
+ live_endpoint: live_endpoint
222
+ ),
176
223
  tree: @association_tree,
177
224
  snapshotter: @activity_snapshotter
178
225
  )
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Which versions a range reconstructs, and which pairs of them become steps.
6
+ #
7
+ # Without a filter those are the same thing: every selected version is a
8
+ # boundary and adjacent ones bound each other. A filter separates them, because
9
+ # each selected mutation then has to be bounded by the version that actually
10
+ # reveals it rather than by the next mutation that happened to be selected.
11
+ #
12
+ # A filter also separates what is *reported* from what has to be *replayed*.
13
+ # The activity view rebuilds state by carrying one snapshot forward across
14
+ # every event in order, so skipping a filtered-out version would leave that
15
+ # snapshot holding a state the record had already moved past. `versions` are
16
+ # the boundaries the steps refer to; `reconstruction_versions` are every root
17
+ # version the span passes through, which is what any forward replay must walk.
18
+ class RootVersionPlan
19
+ attr_reader :versions #: Array[untyped]
20
+ attr_reader :reconstruction_versions #: Array[untyped]
21
+ attr_reader :steps #: Array[[untyped, untyped]]
22
+ attr_reader :context_version #: untyped
23
+ # The live record a window closes on when no later version can reveal its
24
+ # final mutation. It is a boundary, never a version, so it stays out of
25
+ # `versions` and out of anything that reconstructs from version history.
26
+ attr_reader :closing_record #: untyped
27
+ # The root versions this plan reports as mutations, which excludes any
28
+ # version present only to reveal what the last of them produced.
29
+ attr_reader :mutations #: Array[untyped]
30
+
31
+ class << self
32
+ # Adjacent boundaries, which is what an unfiltered range reports.
33
+ #: (Array[untyped], ?context_version: untyped) -> RootVersionPlan
34
+ def contiguous(versions, context_version: nil)
35
+ new(
36
+ versions: versions,
37
+ steps: versions.each_cons(2).map { |from, to| [from, to] },
38
+ context_version: context_version
39
+ )
40
+ end
41
+
42
+ #: () -> RootVersionPlan
43
+ def empty
44
+ versions = [] #: Array[untyped]
45
+ steps = [] #: Array[[untyped, untyped]]
46
+ new(versions: versions, steps: steps)
47
+ end
48
+ end
49
+
50
+ #: (versions: Array[untyped], steps: Array[[untyped, untyped]], ?context_version: untyped, ?reconstruction_versions: Array[untyped]?, ?mutations: Array[untyped]?, ?closing_record: untyped) -> void
51
+ def initialize( # rubocop:disable Metrics/ParameterLists
52
+ versions:, steps:, context_version: nil, reconstruction_versions: nil, mutations: nil,
53
+ closing_record: nil
54
+ )
55
+ @closing_record = closing_record
56
+ @versions = versions.freeze
57
+ @reconstruction_versions = (reconstruction_versions || versions).freeze
58
+ @steps = steps.freeze
59
+ @context_version = context_version
60
+ @mutations = (mutations || default_mutations).freeze
61
+ @mutation_keys = Set.new(@mutations.map { |version| key(version) }).freeze
62
+ freeze
63
+ end
64
+
65
+ #: () -> bool
66
+ def empty?
67
+ versions.empty?
68
+ end
69
+
70
+ # What the range's final state is read from, which is the live record when
71
+ # the window closes on current state and the last version otherwise.
72
+ #: () -> untyped
73
+ def final_endpoint
74
+ @closing_record || @versions.last
75
+ end
76
+
77
+ #: (untyped) -> bool
78
+ def mutation?(version)
79
+ @mutation_keys.include?(key(version))
80
+ end
81
+
82
+ private
83
+
84
+ # @rbs @mutation_keys: Set[Array[untyped]]
85
+
86
+ #: () -> Array[untyped]
87
+ def default_mutations
88
+ return @versions unless @context_version
89
+
90
+ context = key(@context_version)
91
+ @versions.reject { |version| key(version) == context }
92
+ end
93
+
94
+ #: (untyped) -> Array[untyped]
95
+ def key(version)
96
+ [version.class.name, version.id]
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Decides which versions a range reports, and which one reveals the last of
6
+ # them. A version records the state before its own event, so whatever follows
7
+ # the final selected mutation is the only thing that can show what it produced.
8
+ # That successor is reconstruction context rather than a reported mutation, so
9
+ # a filter must never remove it.
10
+ #
11
+ # Both the single-record and the batched selectors resolve this here, because
12
+ # the rule is subtle enough that two copies of it would drift apart.
13
+ class RootVersionSelection
14
+ INCOMPLETE = 'time range requires a later root version to reconstruct its final change'
15
+
16
+ #: (in_range: Array[untyped], selected: Array[untyped], after_range: untyped, windowed: bool, ?context_required: bool, ?filtered: bool, ?live_endpoint: untyped) -> void
17
+ def initialize( # rubocop:disable Metrics/ParameterLists
18
+ in_range:, selected:, after_range:, windowed:, context_required: false, filtered: false,
19
+ live_endpoint: nil
20
+ )
21
+ @in_range = in_range
22
+ @selected = selected
23
+ @after_range = after_range
24
+ @windowed = windowed
25
+ @context_required = context_required
26
+ @filtered = filtered
27
+ @live_endpoint = live_endpoint
28
+ end
29
+
30
+ #: () -> RootVersionPlan
31
+ def call
32
+ return without_selection if @selected.empty?
33
+
34
+ closing = revealing_version || @live_endpoint
35
+ raise IncompleteTimeRangeError, INCOMPLETE if !closing && @windowed && !terminal_destroy?
36
+ return filtered_plan(closing) if @filtered
37
+
38
+ contiguous_plan(closing)
39
+ end
40
+
41
+ private
42
+
43
+ # @rbs @in_range: Array[untyped]
44
+ # @rbs @selected: Array[untyped]
45
+ # @rbs @after_range: untyped
46
+ # @rbs @windowed: bool
47
+ # @rbs @context_required: bool
48
+ # @rbs @filtered: bool
49
+ # @rbs @live_endpoint: untyped
50
+
51
+ # A window reaching past the last recorded version has only the live record
52
+ # left to show what its final mutation produced. That record is a closing
53
+ # boundary rather than a selected mutation, so it never joins `versions`.
54
+ #: (untyped) -> RootVersionPlan
55
+ def contiguous_plan(closing)
56
+ return RootVersionPlan.contiguous(@selected) unless closing
57
+ unless Endpoint.record?(closing)
58
+ return RootVersionPlan.contiguous(@selected + [closing], context_version: closing)
59
+ end
60
+
61
+ steps = @selected.each_cons(2).map { |from, to| [from, to] } #: Array[[untyped, untyped]]
62
+ steps << [@selected.last, closing]
63
+ RootVersionPlan.new(versions: @selected, steps: steps, closing_record: closing)
64
+ end
65
+
66
+ # An activity view still needs a root to reconstruct from even when no root
67
+ # version falls inside the window, because descendants may have moved.
68
+ #: () -> RootVersionPlan
69
+ def without_selection
70
+ return RootVersionPlan.empty unless @context_required
71
+ if @after_range
72
+ return RootVersionPlan.contiguous([@after_range], context_version: @after_range)
73
+ end
74
+
75
+ raise IncompleteTimeRangeError, INCOMPLETE
76
+ end
77
+
78
+ # Each selected mutation is bounded by the version that reveals it, not by
79
+ # the next mutation that happened to be selected. Bounding by the next
80
+ # selection would fold anything filtered out in between into it, so the same
81
+ # edit would read differently depending on what followed it.
82
+ #: (untyped) -> RootVersionPlan
83
+ def filtered_plan(revealing)
84
+ steps = @selected.filter_map do |version|
85
+ successor = version.equal?(@selected.last) ? revealing : immediate_successor(version)
86
+ [version, successor] if successor
87
+ end #: Array[[untyped, untyped]]
88
+ filtered_plan_for(steps, revealing)
89
+ end
90
+
91
+ #: (Array[[untyped, untyped]], untyped) -> RootVersionPlan
92
+ def filtered_plan_for(steps, revealing)
93
+ live = revealing if Endpoint.record?(revealing)
94
+ versions = chronological(
95
+ (steps.flatten(1) + closing_versions).reject { |entry| Endpoint.record?(entry) }
96
+ )
97
+ RootVersionPlan.new(
98
+ versions: versions, steps: steps,
99
+ context_version: (revealing unless live),
100
+ reconstruction_versions: spanned(versions),
101
+ mutations: @selected, closing_record: live
102
+ )
103
+ end
104
+
105
+ # Nothing can follow a destroy, so it never pairs into a step. It is still a
106
+ # selected mutation, and the activity view closes on the absence it leaves,
107
+ # so it has to stay a boundary or a filtered report loses the deletion
108
+ # entirely — the one event it can least afford to drop.
109
+ #: () -> Array[untyped]
110
+ def closing_versions
111
+ terminal_destroy? ? [@selected.last] : []
112
+ end
113
+
114
+ # Everything the span passes through, filtered out or not. A replay that
115
+ # skipped the excluded versions would carry a stale state into the next
116
+ # reported step, so the two selected mutations either side of a gap would
117
+ # disagree about what the record looked like between them.
118
+ #: (Array[untyped]) -> Array[untyped]
119
+ def spanned(versions)
120
+ first = versions.first
121
+ last = versions.last
122
+ return versions unless first && last
123
+
124
+ @in_range.select do |candidate|
125
+ !Support.compare_versions(first, candidate).positive? &&
126
+ !Support.compare_versions(candidate, last).positive?
127
+ end
128
+ end
129
+
130
+ #: (Array[untyped]) -> Array[untyped]
131
+ def chronological(versions)
132
+ versions.uniq { |version| [version.class.name, version.id] }
133
+ .sort_by { |version| Support.chronological_version_key(version) }
134
+ end
135
+
136
+ #: (untyped) -> untyped
137
+ def immediate_successor(version)
138
+ @in_range.find { |candidate| Support.compare_versions(version, candidate).negative? }
139
+ end
140
+
141
+ # A version left out by a filter is still the state the last selected change
142
+ # produced, so it is preferred over anything after the range.
143
+ #: () -> untyped
144
+ def revealing_version
145
+ immediate_successor(@selected.last) || @after_range
146
+ end
147
+
148
+ # A range closing on the record's own destruction needs no later version:
149
+ # nothing can follow it, so demanding one would reject the range forever.
150
+ #: () -> bool
151
+ def terminal_destroy?
152
+ @selected.last&.event.to_s == 'destroy'
153
+ end
154
+ end
155
+ end
@@ -10,12 +10,20 @@ module PaperTrailDiff
10
10
  attr_reader :to_boundary #: ActivityBoundary
11
11
  attr_reader :diff #: Diff
12
12
 
13
- #: (from_version: untyped, to_version: untyped, diff: Diff) -> void
14
- def initialize(from_version:, to_version:, diff:)
13
+ # `to_version` is nil for the one step that closes on current state, which a
14
+ # window reaching past the last recorded version has to do. Read
15
+ # `to_boundary` instead when a caller may have opted into that.
16
+ #: (from_version: untyped, to_version: untyped, diff: Diff, ?captured_at: untyped) -> void
17
+ def initialize(from_version:, to_version:, diff:, captured_at: nil)
18
+ live = Endpoint.record?(to_version)
15
19
  @from_version = from_version
16
- @to_version = to_version
20
+ @to_version = (to_version unless live)
17
21
  @from_boundary = ActivityBoundary.from_version(from_version)
18
- @to_boundary = ActivityBoundary.from_version(to_version)
22
+ @to_boundary = if live
23
+ ActivityBoundary.current(to_version, captured_at: captured_at)
24
+ else
25
+ ActivityBoundary.from_version(to_version)
26
+ end
19
27
  @diff = diff
20
28
  freeze
21
29
  end
@@ -29,7 +37,8 @@ module PaperTrailDiff
29
37
  def to_h
30
38
  {
31
39
  from_version_id: from_version.id,
32
- to_version_id: to_version.id,
40
+ to_version_id: to_version&.id,
41
+ to_boundary: to_boundary.to_h,
33
42
  diff: diff.to_h
34
43
  }
35
44
  end
@@ -14,17 +14,23 @@ 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, closing_snapshot, captured_at = history_and_versions
24
+ final = closing_snapshot || history.last_snapshot
24
25
  Analysis.new(
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
+ diff: Engine.compare(history.first_snapshot, final),
27
+ timeline: ActivityRootSteps.call(
28
+ plan, history.root_snapshots,
29
+ closing_snapshot: closing_snapshot, captured_at: captured_at
30
+ ),
31
+ activity_timeline: activity_steps(history, closing),
32
+ from_snapshot: history.first_snapshot,
33
+ to_snapshot: final
28
34
  )
29
35
  end
30
36
 
@@ -35,18 +41,46 @@ module PaperTrailDiff
35
41
  # @rbs @tree: AssociationTree
36
42
  # @rbs @snapshotter: untyped
37
43
 
38
- #: () -> [ActivityHistory, Array[untyped], ActivityStep?]
44
+ #: () -> [ActivityHistory, RootVersionPlan, ActivityStep?, RecordSnapshot?, untyped]
39
45
  def history_and_versions
40
- root_versions = @range.select(context_required: !@tree.empty?)
41
- return [ActivityHistory.empty, root_versions, nil] if root_versions.empty?
46
+ plan = @range.select_plan(context_required: !@tree.empty?)
47
+ return [ActivityHistory.empty, plan, nil, nil, nil] if plan.reconstruction_versions.empty?
42
48
 
43
- prepare_history(root_versions)
44
- events = collect_events(root_versions)
45
- selected = selected_events(events)
46
- return [ActivityHistory.empty, root_versions, nil] unless time_events?(selected, events)
49
+ # A window closing on current state runs to the instant it is captured,
50
+ # not to the last root version, or descendants that moved after that
51
+ # version would be missing from a report that claims to reach now.
52
+ history_for(plan, plan.closing_record ? Time.now.utc : nil)
53
+ end
54
+
55
+ #: (RootVersionPlan, untyped) -> [ActivityHistory, RootVersionPlan, ActivityStep?, RecordSnapshot?, untyped]
56
+ def history_for(plan, captured_at)
57
+ root_versions = plan.reconstruction_versions
58
+ events = events_through(root_versions, captured_at)
59
+ selected = selected_events(events, plan)
60
+ unless time_events?(selected, events, plan)
61
+ return [ActivityHistory.empty, plan, nil, nil, nil]
62
+ end
47
63
 
48
64
  history = build_history(root_versions, events)
49
- [history, root_versions, closing_step(history, selected.last)]
65
+ snapshot = current_snapshot(plan)
66
+ closing = closing_step(history, selected.last, plan, captured_at, snapshot)
67
+ [history, plan, closing, snapshot, captured_at]
68
+ end
69
+
70
+ # One live read serves both the closing activity step and the endpoint the
71
+ # analysis reports, which would otherwise reconstruct the graph twice.
72
+ #: (RootVersionPlan) -> RecordSnapshot?
73
+ def current_snapshot(plan)
74
+ record = plan.closing_record
75
+ return unless record
76
+
77
+ @snapshotter.call(record, record)
78
+ end
79
+
80
+ #: (Array[untyped], untyped) -> Array[ActivityEvent]
81
+ def events_through(root_versions, captured_at)
82
+ prepare_history(root_versions, end_at: captured_at)
83
+ collect_events(root_versions, range_end: captured_at)
50
84
  end
51
85
 
52
86
  #: (Array[untyped], Array[ActivityEvent]) -> ActivityHistory
@@ -66,17 +100,34 @@ module PaperTrailDiff
66
100
  (history.steps + [closing]).freeze
67
101
  end
68
102
 
69
- #: (Array[ActivityEvent]) -> Array[ActivityEvent]
70
- def selected_events(events)
71
- events.select { |event| @range.include?(event.version) }
103
+ # Root versions inside the window that the plan does not report are context
104
+ # rather than mutations: the version appended to reveal the last selected
105
+ # change, and anything a filter excluded but the replay still walks. Neither
106
+ # may be counted as a selected mutation. Descendant events are not filtered,
107
+ # so window membership is the whole test for them.
108
+ #: (Array[ActivityEvent], RootVersionPlan) -> Array[ActivityEvent]
109
+ def selected_events(events, plan)
110
+ events.select do |event|
111
+ next false unless @range.include?(event.version)
112
+
113
+ !event.root? || plan.mutation?(event.version)
114
+ end
72
115
  end
73
116
 
74
- # The window's last selected mutation is the root's own destruction, so the
75
- # timeline closes on the absence it leaves rather than on a later boundary.
76
- #: (ActivityHistory, ActivityEvent?) -> ActivityStep?
77
- def closing_step(history, event)
78
- return unless event && terminal_destroy?(event)
117
+ # A window either closes on the absence its final destruction leaves, or on
118
+ # current state when it reaches past everything recorded. Both are boundaries
119
+ # no version can supply.
120
+ #: (ActivityHistory, ActivityEvent?, RootVersionPlan, untyped, RecordSnapshot?) -> ActivityStep?
121
+ def closing_step(history, event, plan, captured_at, snapshot)
122
+ return unless event
123
+ return destroyed_step(history, event) if terminal_destroy?(event)
124
+ return unless plan.closing_record
79
125
 
126
+ current_step(history, plan.closing_record, captured_at, snapshot)
127
+ end
128
+
129
+ #: (ActivityHistory, ActivityEvent) -> ActivityStep
130
+ def destroyed_step(history, event)
80
131
  version = event.version
81
132
  ActivityStep.new(
82
133
  from_boundary: ActivityBoundary.from_version(version),
@@ -85,6 +136,21 @@ module PaperTrailDiff
85
136
  )
86
137
  end
87
138
 
139
+ # The last boundary reached is where current state is compared from, which
140
+ # is the final event rather than the final root version once descendants
141
+ # have moved since it.
142
+ #: (ActivityHistory, untyped, untyped, RecordSnapshot?) -> ActivityStep?
143
+ def current_step(history, record, captured_at, snapshot)
144
+ previous = history.steps.last&.to_boundary
145
+ return unless previous
146
+
147
+ ActivityStep.new(
148
+ from_boundary: previous,
149
+ to_boundary: ActivityBoundary.current(record, captured_at: captured_at),
150
+ diff: Engine.compare(history.last_snapshot, snapshot)
151
+ )
152
+ end
153
+
88
154
  #: (ActivityEvent?) -> bool
89
155
  def terminal_destroy?(event)
90
156
  return false unless event
@@ -92,30 +158,34 @@ module PaperTrailDiff
92
158
  event.root? && event.version.event.to_s == 'destroy'
93
159
  end
94
160
 
95
- #: (Array[untyped]) -> void
96
- def prepare_history(root_versions)
161
+ #: (Array[untyped], ?end_at: untyped) -> void
162
+ def prepare_history(root_versions, end_at: nil)
97
163
  return unless @snapshotter.respond_to?(:prepare)
98
164
 
99
- @snapshotter.prepare(@record, root_versions, start_at: @range.begin_time)
165
+ start_at = @range.begin_time
166
+ return @snapshotter.prepare(@record, root_versions, start_at: start_at) unless end_at
167
+
168
+ @snapshotter.prepare(@record, root_versions, start_at: start_at, end_at: end_at)
100
169
  end
101
170
 
102
- #: (Array[untyped]) -> Array[ActivityEvent]
103
- def collect_events(root_versions)
171
+ #: (Array[untyped], ?range_end: untyped) -> Array[ActivityEvent]
172
+ def collect_events(root_versions, range_end: nil)
104
173
  ActivityVersionCollector.new(
105
174
  @record,
106
175
  root_versions: root_versions,
107
176
  tree: @tree,
108
177
  traversal: AssociationTraversal.new(@tree),
109
178
  range_start: @range.begin_time,
110
- range_end: root_versions.last
179
+ range_end: range_end || root_versions.last
111
180
  ).call
112
181
  end
113
182
 
114
- #: (Array[ActivityEvent], Array[ActivityEvent]) -> bool
115
- def time_events?(selected, events)
183
+ #: (Array[ActivityEvent], Array[ActivityEvent], RootVersionPlan) -> bool
184
+ def time_events?(selected, events, plan)
116
185
  return false if selected.empty?
117
186
  return true if later_event?(selected.last, events)
118
187
  return true if terminal_destroy?(selected.last)
188
+ return true if plan.closing_record
119
189
 
120
190
  message = 'time range requires a later activity boundary to reconstruct its final change'
121
191
  raise IncompleteTimeRangeError, message