paper_trail_diff 0.14.0 → 0.15.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 (137) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +343 -0
  3. data/QUICKSTART.md +21 -4
  4. data/README.md +58 -55
  5. data/docs/architecture.md +32 -11
  6. data/docs/performance.md +116 -391
  7. data/docs/reference.md +345 -106
  8. data/docs/testing.md +63 -7
  9. data/lib/paper_trail_diff/activity_boundary.rb +83 -26
  10. data/lib/paper_trail_diff/activity_child_candidate_loader.rb +2 -1
  11. data/lib/paper_trail_diff/activity_event.rb +41 -10
  12. data/lib/paper_trail_diff/activity_history.rb +34 -9
  13. data/lib/paper_trail_diff/activity_range.rb +18 -3
  14. data/lib/paper_trail_diff/activity_snapshot_sequence.rb +76 -37
  15. data/lib/paper_trail_diff/activity_target_candidate_loader.rb +5 -5
  16. data/lib/paper_trail_diff/activity_timeline_builder.rb +266 -111
  17. data/lib/paper_trail_diff/activity_transaction_grouper.rb +2 -1
  18. data/lib/paper_trail_diff/activity_tree_filter.rb +114 -0
  19. data/lib/paper_trail_diff/activity_version_collector.rb +7 -5
  20. data/lib/paper_trail_diff/activity_view.rb +69 -0
  21. data/lib/paper_trail_diff/analysis.rb +13 -1
  22. data/lib/paper_trail_diff/analysis_batch.rb +24 -30
  23. data/lib/paper_trail_diff/approximate_paths.rb +48 -0
  24. data/lib/paper_trail_diff/association_activity_edge.rb +6 -6
  25. data/lib/paper_trail_diff/association_discovery.rb +9 -1
  26. data/lib/paper_trail_diff/association_key.rb +56 -0
  27. data/lib/paper_trail_diff/association_traversal.rb +13 -4
  28. data/lib/paper_trail_diff/batch_boundary_resolver.rb +3 -4
  29. data/lib/paper_trail_diff/batch_preload.rb +53 -0
  30. data/lib/paper_trail_diff/batched_root_analyzer.rb +12 -14
  31. data/lib/paper_trail_diff/batched_root_versions.rb +38 -14
  32. data/lib/paper_trail_diff/change_row.rb +133 -0
  33. data/lib/paper_trail_diff/collection_comparator.rb +5 -32
  34. data/lib/paper_trail_diff/configuration.rb +35 -12
  35. data/lib/paper_trail_diff/deleted_analysis_batch.rb +18 -18
  36. data/lib/paper_trail_diff/deleted_root_filter.rb +79 -0
  37. data/lib/paper_trail_diff/deprecation.rb +61 -0
  38. data/lib/paper_trail_diff/diagnostics.rb +84 -99
  39. data/lib/paper_trail_diff/endpoint.rb +26 -3
  40. data/lib/paper_trail_diff/errors.rb +3 -1
  41. data/lib/paper_trail_diff/fallback_paths.rb +38 -0
  42. data/lib/paper_trail_diff/historical_association_reifier.rb +2 -4
  43. data/lib/paper_trail_diff/historical_filter.rb +19 -10
  44. data/lib/paper_trail_diff/historical_snapshot_store.rb +57 -67
  45. data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +1 -35
  46. data/lib/paper_trail_diff/nested_comparator.rb +19 -18
  47. data/lib/paper_trail_diff/paper_trail_adapter.rb +122 -127
  48. data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +13 -1
  49. data/lib/paper_trail_diff/prepared_activity_index.rb +1 -1
  50. data/lib/paper_trail_diff/prepared_edge_loader.rb +2 -1
  51. data/lib/paper_trail_diff/prepared_history.rb +9 -1
  52. data/lib/paper_trail_diff/prepared_history_loader.rb +1 -0
  53. data/lib/paper_trail_diff/prepared_record_index.rb +38 -7
  54. data/lib/paper_trail_diff/root_version_selection.rb +4 -2
  55. data/lib/paper_trail_diff/scope_condition.rb +76 -21
  56. data/lib/paper_trail_diff/scope_ranking.rb +12 -2
  57. data/lib/paper_trail_diff/scoped_analysis.rb +11 -0
  58. data/lib/paper_trail_diff/scoped_analysis_builder.rb +16 -8
  59. data/lib/paper_trail_diff/scoped_root_selection.rb +18 -12
  60. data/lib/paper_trail_diff/snapshot.rb +16 -1
  61. data/lib/paper_trail_diff/support.rb +29 -2
  62. data/lib/paper_trail_diff/time_range.rb +32 -10
  63. data/lib/paper_trail_diff/timeline_builder.rb +7 -7
  64. data/lib/paper_trail_diff/timeline_range.rb +9 -1
  65. data/lib/paper_trail_diff/timeline_snapshot_provider.rb +24 -17
  66. data/lib/paper_trail_diff/transaction_cut.rb +181 -0
  67. data/lib/paper_trail_diff/traversal_preparer.rb +134 -29
  68. data/lib/paper_trail_diff/version.rb +1 -1
  69. data/lib/paper_trail_diff/version_boundary.rb +37 -8
  70. data/lib/paper_trail_diff/version_query.rb +34 -10
  71. data/lib/paper_trail_diff/version_scope_filter.rb +9 -10
  72. data/lib/paper_trail_diff/version_sequence_diagnostics.rb +31 -16
  73. data/lib/paper_trail_diff/version_table.rb +60 -0
  74. data/lib/paper_trail_diff.rb +98 -56
  75. data/sig/generated/paper_trail_diff/activity_boundary.rbs +54 -19
  76. data/sig/generated/paper_trail_diff/activity_event.rbs +25 -4
  77. data/sig/generated/paper_trail_diff/activity_history.rbs +21 -6
  78. data/sig/generated/paper_trail_diff/activity_range.rbs +9 -2
  79. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +48 -9
  80. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +134 -30
  81. data/sig/generated/paper_trail_diff/activity_tree_filter.rbs +73 -0
  82. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +2 -0
  83. data/sig/generated/paper_trail_diff/activity_view.rbs +42 -0
  84. data/sig/generated/paper_trail_diff/analysis.rbs +8 -1
  85. data/sig/generated/paper_trail_diff/analysis_batch.rbs +8 -15
  86. data/sig/generated/paper_trail_diff/approximate_paths.rbs +23 -0
  87. data/sig/generated/paper_trail_diff/association_discovery.rbs +5 -0
  88. data/sig/generated/paper_trail_diff/association_key.rbs +29 -0
  89. data/sig/generated/paper_trail_diff/association_traversal.rbs +4 -0
  90. data/sig/generated/paper_trail_diff/batch_preload.rbs +29 -0
  91. data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +9 -10
  92. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +14 -4
  93. data/sig/generated/paper_trail_diff/change_row.rbs +86 -0
  94. data/sig/generated/paper_trail_diff/collection_comparator.rbs +2 -16
  95. data/sig/generated/paper_trail_diff/configuration.rbs +19 -3
  96. data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +8 -8
  97. data/sig/generated/paper_trail_diff/deleted_root_filter.rbs +48 -0
  98. data/sig/generated/paper_trail_diff/deprecation.rbs +35 -0
  99. data/sig/generated/paper_trail_diff/diagnostics.rbs +37 -30
  100. data/sig/generated/paper_trail_diff/endpoint.rbs +11 -2
  101. data/sig/generated/paper_trail_diff/errors.rbs +3 -1
  102. data/sig/generated/paper_trail_diff/fallback_paths.rbs +22 -0
  103. data/sig/generated/paper_trail_diff/historical_filter.rbs +12 -2
  104. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +33 -28
  105. data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +0 -15
  106. data/sig/generated/paper_trail_diff/nested_comparator.rbs +10 -7
  107. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +54 -52
  108. data/sig/generated/paper_trail_diff/preloaded_endpoint_batch_loader.rbs +9 -1
  109. data/sig/generated/paper_trail_diff/prepared_history.rbs +5 -0
  110. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +19 -2
  111. data/sig/generated/paper_trail_diff/root_version_selection.rbs +2 -0
  112. data/sig/generated/paper_trail_diff/scope_condition.rbs +43 -9
  113. data/sig/generated/paper_trail_diff/scope_ranking.rbs +8 -0
  114. data/sig/generated/paper_trail_diff/scoped_analysis.rbs +6 -0
  115. data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +6 -1
  116. data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +8 -4
  117. data/sig/generated/paper_trail_diff/snapshot.rbs +8 -0
  118. data/sig/generated/paper_trail_diff/support.rbs +15 -0
  119. data/sig/generated/paper_trail_diff/time_range.rbs +20 -6
  120. data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
  121. data/sig/generated/paper_trail_diff/timeline_range.rbs +2 -0
  122. data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +17 -7
  123. data/sig/generated/paper_trail_diff/transaction_cut.rbs +94 -0
  124. data/sig/generated/paper_trail_diff/traversal_preparer.rbs +66 -8
  125. data/sig/generated/paper_trail_diff/version_boundary.rbs +20 -3
  126. data/sig/generated/paper_trail_diff/version_query.rbs +18 -2
  127. data/sig/generated/paper_trail_diff/version_scope_filter.rbs +4 -5
  128. data/sig/generated/paper_trail_diff/version_sequence_diagnostics.rbs +16 -9
  129. data/sig/generated/paper_trail_diff/version_table.rbs +30 -0
  130. data/sig/generated/paper_trail_diff.rbs +29 -26
  131. metadata +46 -10
  132. data/lib/paper_trail_diff/activity_grouping.rb +0 -38
  133. data/lib/paper_trail_diff/batch_safety.rb +0 -63
  134. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +0 -223
  135. data/sig/generated/paper_trail_diff/activity_grouping.rbs +0 -22
  136. data/sig/generated/paper_trail_diff/batch_safety.rbs +0 -30
  137. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +0 -92
@@ -2,24 +2,35 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module PaperTrailDiff
5
- # Optional full/partial snapshot operations consumed by the activity sequence.
5
+ # Prepares the snapshot store for an activity build, one root at a time.
6
+ # Before that, it reads the live record a range closes on.
6
7
  class ActivitySnapshotProvider
7
- #: (snapshotter: untyped, refresher: untyped, preparer: untyped, ?activity_index: untyped) -> void
8
- def initialize(snapshotter:, refresher:, preparer:, activity_index: nil)
9
- @activity_index = activity_index
8
+ # `snapshotter` reads a full snapshot with a given history, and `refresher`
9
+ # builds the incremental refresher for one root's snapshots.
10
+ #: (store: HistoricalSnapshotStore, snapshotter: untyped, refresher: untyped) -> void
11
+ def initialize(store:, snapshotter:, refresher:)
12
+ @store = store
10
13
  @snapshotter = snapshotter
11
14
  @refresher = refresher
12
- @preparer = preparer
13
15
  end
14
16
 
15
- #: (untyped, Array[untyped], ?start_at: untyped, ?end_at: untyped) -> void
17
+ # Roots a batch prepared together share its history, so consecutive ones
18
+ # share these snapshots too, and the caches their refresher builds. They
19
+ # are the same for any root with that history, so reusing them depends on
20
+ # nothing but the history.
21
+ #: (untyped, Array[untyped], ?start_at: untyped, ?end_at: untyped) -> PreparedActivitySnapshots
16
22
  def prepare(
17
23
  record,
18
24
  root_versions,
19
25
  start_at: root_versions.first.created_at,
20
26
  end_at: root_versions.last.created_at
21
27
  )
22
- @preparer.call(record, root_versions, start_at: start_at, end_at: end_at)
28
+ history = @store.prepare(record, root_versions, start_at: start_at, end_at: end_at)
29
+ last = @last
30
+ return last if last && last.history.equal?(history)
31
+
32
+ @last = PreparedActivitySnapshots.new(history, store: @store, snapshotter: @snapshotter,
33
+ refresher: @refresher)
23
34
  end
24
35
 
25
36
  #: (untyped, untyped) -> RecordSnapshot?
@@ -27,44 +38,73 @@ module PaperTrailDiff
27
38
  @snapshotter.call(root_endpoint, context_endpoint)
28
39
  end
29
40
 
41
+ # @rbs @store: HistoricalSnapshotStore
42
+ # @rbs @snapshotter: untyped
43
+ # @rbs @refresher: untyped
44
+ # @rbs @last: PreparedActivitySnapshots?
45
+ end
46
+
47
+ # One root's full and incremental snapshots during an activity build. Each
48
+ # read passes along the history prepared for this root, so none depends on
49
+ # which root was prepared last.
50
+ class PreparedActivitySnapshots
51
+ attr_reader :history #: PreparedHistory?
52
+
53
+ #: (PreparedHistory?, store: HistoricalSnapshotStore, snapshotter: untyped, refresher: untyped) -> void
54
+ def initialize(history, store:, snapshotter:, refresher:)
55
+ @history = history
56
+ @store = store
57
+ @snapshotter = snapshotter
58
+ @refresher = refresher.call(self)
59
+ end
60
+
61
+ #: () -> PreparedActivityIndex?
62
+ def activity_index
63
+ @history&.activity_index
64
+ end
65
+
66
+ #: (untyped, untyped) -> RecordSnapshot?
67
+ def call(root_endpoint, context_endpoint)
68
+ @snapshotter.call(root_endpoint, context_endpoint, history: @history)
69
+ end
70
+
30
71
  #: (untyped, untyped, RecordSnapshot?, Array[String], event: ActivityEvent, isolated: bool) -> RecordSnapshot?
31
- def refresh( # rubocop:disable Metrics/ParameterLists
32
- root_endpoint,
33
- context_endpoint,
34
- previous_snapshot,
35
- branches,
36
- event:,
37
- isolated:
38
- )
39
- @refresher.call(
40
- root_endpoint,
41
- context_endpoint,
42
- previous_snapshot,
43
- branches,
44
- event: event,
45
- isolated: isolated
46
- )
72
+ def refresh(root_endpoint, context_endpoint, previous_snapshot, branches, event:, isolated:) # rubocop:disable Metrics/ParameterLists
73
+ @refresher.call(root_endpoint, context_endpoint, previous_snapshot, branches,
74
+ event: event, isolated: isolated)
47
75
  end
48
76
 
49
77
  #: (untyped, untyped, RecordSnapshot?, event: ActivityEvent) -> RecordSnapshot?
50
78
  def advance_root(root_endpoint, context_endpoint, previous_snapshot, event:)
51
- @refresher.advance_root(
52
- root_endpoint,
53
- context_endpoint,
54
- previous_snapshot,
55
- event: event
56
- )
79
+ @refresher.advance_root(root_endpoint, context_endpoint, previous_snapshot, event: event)
57
80
  end
58
81
 
59
- #: () -> PreparedActivityIndex?
60
- def activity_index
61
- @activity_index&.call
82
+ # The branches a refresh rebuilds, with this root's history.
83
+ #: (untyped, untyped, tree: AssociationTree, normalizer: SnapshotNormalizer) -> RecordSnapshot?
84
+ def custom(root_endpoint, context_endpoint, tree:, normalizer:)
85
+ @store.custom(root_endpoint, context_endpoint, tree: tree, normalizer: normalizer,
86
+ history: @history)
87
+ end
88
+
89
+ #: (untyped, habtm_version: untyped) -> untyped
90
+ def association_reader(context_version, habtm_version:)
91
+ @store.association_reader(context_version, habtm_version: habtm_version, history: @history)
92
+ end
93
+
94
+ #: (untyped) -> [Hash[untyped, untyped], Hash[untyped, untyped]]?
95
+ def record_transition(version)
96
+ @history&.record_transition(Endpoint.model_class(version), version.item_id, version)
97
+ end
98
+
99
+ #: (untyped, ?direct: bool) -> [bool, untyped]
100
+ def record_after(version, direct: false)
101
+ @history&.record_after(Endpoint.model_class(version), version.item_id, version,
102
+ direct: direct) || [false, nil]
62
103
  end
63
104
 
64
- # @rbs @activity_index: untyped
105
+ # @rbs @store: HistoricalSnapshotStore
65
106
  # @rbs @snapshotter: untyped
66
107
  # @rbs @refresher: untyped
67
- # @rbs @preparer: untyped
68
108
  end
69
109
 
70
110
  # Builds chronological snapshots, refreshing only branches affected by prior events.
@@ -116,7 +156,7 @@ module PaperTrailDiff
116
156
  incremental_root?(previous_event)
117
157
 
118
158
  branches = @transaction_groups.branches_for(previous_event)
119
- return full_snapshot(event) unless branches && @snapshotter.respond_to?(:refresh)
159
+ return full_snapshot(event) unless branches
120
160
 
121
161
  @snapshotter.refresh(
122
162
  root_endpoint(event.version),
@@ -130,8 +170,7 @@ module PaperTrailDiff
130
170
 
131
171
  #: (ActivityEvent) -> bool
132
172
  def incremental_root?(event)
133
- event.root? && @transaction_groups.isolated?(event) &&
134
- @snapshotter.respond_to?(:advance_root)
173
+ event.root? && @transaction_groups.isolated?(event)
135
174
  end
136
175
 
137
176
  #: (ActivityEvent, ActivityEvent, RecordSnapshot?) -> RecordSnapshot?
@@ -83,11 +83,11 @@ module PaperTrailDiff
83
83
 
84
84
  #: (untyped) -> untyped
85
85
  def historical_relation(version_class)
86
- association_class.joins(:version)
87
- .where(foreign_key_name: @reflection.foreign_key)
88
- .where(version_class.table_name => {
89
- item_type: @owner_class.base_class.name, item_id: @owner_ids
90
- })
86
+ VersionTable.joined(association_class, version_class)
87
+ .where(foreign_key_name: @reflection.foreign_key)
88
+ .where(version_class.table_name => {
89
+ item_type: @owner_class.base_class.name, item_id: @owner_ids
90
+ })
91
91
  end
92
92
 
93
93
  #: (untyped, untyped) -> Array[Array[untyped]]
@@ -2,18 +2,17 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module PaperTrailDiff
5
- # Compares adjacent root and selected-descendant activity boundaries.
6
- class ActivityTimelineBuilder
7
- include ActivityGrouping
8
-
9
- #: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, snapshots: bool, group: Symbol?) -> void
10
- def initialize(record, range:, tree:, snapshotter:, snapshots:, group:) # rubocop:disable Metrics/ParameterLists
11
- ActivityGrouping.validate!(group)
12
- @snapshots = snapshots
13
- @group = group
14
- # Merging a group compares its outer states, so the snapshots must survive
15
- # the build even when the caller did not ask to keep them.
16
- @retain = snapshots || grouping?
5
+ # Compares adjacent root and selected-descendant activity boundaries, between
6
+ # explicit endpoints or over a wall-clock window. Both replay the range's
7
+ # events into steps the same way, and close on the live row the same way.
8
+ # They differ in what they report and what closes them: explicit endpoints
9
+ # report every step between them and close on the endpoint asked for; a
10
+ # window reports only the events inside it, and closes on a final destroy,
11
+ # or on the live row when nothing recorded follows it.
12
+ class ActivityTimelineBuilder # rubocop:disable Metrics/ClassLength
13
+ #: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, view: ActivityView) -> void
14
+ def initialize(record, range:, tree:, snapshotter:, view:)
15
+ @view = view
17
16
  @record = record
18
17
  @to = range.to
19
18
  @range = range
@@ -23,7 +22,7 @@ module PaperTrailDiff
23
22
 
24
23
  #: () -> Array[ActivityStep]
25
24
  def build
26
- return time_builder.build if @range.time?
25
+ return window_steps if @range.time?
27
26
  return no_steps if @range.unresolved?
28
27
  return build_to_current if Endpoint.record?(@to)
29
28
 
@@ -31,20 +30,18 @@ module PaperTrailDiff
31
30
  build_between_versions
32
31
  end
33
32
 
34
- # Builds all three version-bounded views from one activity snapshot pass.
33
+ # Builds the endpoint diff, the root timeline, and the activity from one
34
+ # activity snapshot pass.
35
35
  #: () -> Analysis
36
36
  def analyze
37
- return time_builder.analyze if @range.time?
37
+ return analyze_window if @range.time?
38
38
  return Analysis.empty if @range.unresolved?
39
39
  return analyze_current if Endpoint.record?(@to)
40
40
 
41
41
  plan = @range.select_plan
42
42
  return Analysis.empty if plan.empty?
43
43
 
44
- root_versions = plan.reconstruction_versions
45
- prepare_history(root_versions)
46
- events = collect_events(root_versions)
47
- build_analysis(plan, events, event_history(root_versions, events))
44
+ build_analysis(plan, version_history(plan.reconstruction_versions))
48
45
  end
49
46
 
50
47
  private
@@ -54,7 +51,7 @@ module PaperTrailDiff
54
51
  # @rbs @range: TimelineRange
55
52
  # @rbs @tree: AssociationTree
56
53
  # @rbs @snapshotter: untyped
57
- # @rbs @snapshots: bool
54
+ # @rbs @view: ActivityView
58
55
 
59
56
  #: () -> Array[ActivityStep]
60
57
  def no_steps
@@ -73,56 +70,66 @@ module PaperTrailDiff
73
70
  return no_steps if plan.empty?
74
71
 
75
72
  root_versions = plan.reconstruction_versions
76
- prepare_history(root_versions)
77
- events = collect_events(root_versions)
78
- history = event_history(root_versions, events)
79
- activity_steps(history, events, destroyed_boundary(root_versions), nil)
73
+ activity_steps(version_history(root_versions), destroyed_boundary(root_versions), nil)
74
+ end
75
+
76
+ # Activity from the first to the last of the root versions.
77
+ #: (Array[untyped]) -> ActivityHistory
78
+ def version_history(root_versions)
79
+ snapshots = prepare_history(root_versions)
80
+ events = collect_events(root_versions, snapshots, range_end: root_versions.last)
81
+ replay(root_versions, events, snapshots)
80
82
  end
81
83
 
82
84
  #: () -> Array[ActivityStep]
83
85
  def build_to_current
84
- _plan, events, history, snapshot, boundary = current_history
85
- activity_steps(history, events, boundary, snapshot)
86
+ _plan, history, snapshot, boundary = current_history
87
+ activity_steps(history, boundary, snapshot)
86
88
  end
87
89
 
88
90
  #: () -> Analysis
89
91
  def analyze_current
90
- plan, events, history, snapshot, boundary = current_history
91
- final = boundary ? snapshot : history.last_snapshot
92
- Analysis.new(
93
- diff: Engine.compare(history.first_snapshot, final),
94
- timeline: ActivityRootSteps.call(
95
- plan, history.root_snapshots, closing_snapshot: snapshot,
96
- captured_at: boundary&.recorded_at
97
- ),
98
- activity_timeline: activity_steps(history, events, boundary, snapshot),
99
- from_snapshot: history.first_snapshot, to_snapshot: final
100
- )
92
+ plan, history, snapshot, boundary = current_history
93
+ analysis(plan, history, final: boundary ? snapshot : history.last_snapshot,
94
+ steps: activity_steps(history, boundary, snapshot),
95
+ closing_snapshot: snapshot, captured_at: boundary&.recorded_at)
101
96
  end
102
97
 
103
98
  # The explicit live endpoint and a batch's live closing endpoint use the
104
99
  # same selected plan. A version filter can close earlier, on its successor.
105
- #: () -> [RootVersionPlan, Array[ActivityEvent], ActivityHistory, RecordSnapshot?, ActivityBoundary?]
100
+ #: () -> [RootVersionPlan, ActivityHistory, RecordSnapshot?, ActivityBoundary?]
106
101
  def current_history
107
102
  validate_current_range!
108
103
  plan = @range.select_plan
109
104
  return empty_history(plan) if plan.empty?
110
105
 
111
106
  snapshot, captured_at = capture_current if plan.closing_record
107
+ history = history_until(plan, snapshot, captured_at)
108
+ boundary = current_boundary(captured_at) if plan.closing_record
109
+ [plan, history, snapshot, boundary]
110
+ end
111
+
112
+ # Events up to the instant the current record was read. A range that a
113
+ # version filter closes earlier ends at that version, with no snapshot.
114
+ #: (RootVersionPlan, RecordSnapshot?, untyped) -> ActivityHistory
115
+ def history_until(plan, snapshot, captured_at)
112
116
  versions = plan.reconstruction_versions
113
- prepare_history(versions, end_at: captured_at)
114
- events = collect_events(versions, range_end: captured_at || versions.last)
115
- history = event_history(versions, events, current: plan.closing_record)
116
- boundary = ActivityBoundary.current(@to, captured_at: captured_at) if plan.closing_record
117
- [plan, events, history, snapshot, boundary]
117
+ snapshots = prepare_history(versions, end_at: captured_at)
118
+ events = collect_events(versions, snapshots, range_end: captured_at || versions.last)
119
+ replay(versions, events, snapshots, current: plan.closing_record, closing: snapshot)
118
120
  end
119
121
 
120
- # Without a closing boundary nothing reads the events or the closing pair,
121
- # so their mutations cannot change an answer.
122
+ # Without a closing boundary nothing reads the closing pair, so its
123
+ # mutations cannot change an answer.
122
124
  # mutant:disable
123
- #: (RootVersionPlan) -> [RootVersionPlan, Array[ActivityEvent], ActivityHistory, RecordSnapshot?, ActivityBoundary?]
125
+ #: (RootVersionPlan) -> [RootVersionPlan, ActivityHistory, RecordSnapshot?, ActivityBoundary?]
124
126
  def empty_history(plan)
125
- [plan, [], ActivityHistory.empty, nil, nil]
127
+ [plan, ActivityHistory.empty, nil, nil]
128
+ end
129
+
130
+ #: (untyped) -> ActivityBoundary
131
+ def current_boundary(captured_at)
132
+ ActivityBoundary.current(@to, captured_at: captured_at, recorded: root_recorded)
126
133
  end
127
134
 
128
135
  #: () -> [RecordSnapshot?, untyped]
@@ -137,49 +144,20 @@ module PaperTrailDiff
137
144
  end
138
145
 
139
146
  Endpoint.validate_pair!(@range.from, @to)
140
- AssociationTraversal.new(@tree).reject_live_habtm_activity!(@record.class)
141
- end
142
-
143
- #: (Array[untyped], ?range_start: untyped, ?range_end: untyped) -> Array[ActivityEvent]
144
- def collect_events(
145
- root_versions,
146
- range_start: root_versions.first,
147
- range_end: root_versions.last
148
- )
149
- ActivityVersionCollector.new(
150
- @record,
151
- root_versions: root_versions,
152
- tree: @tree,
153
- prepared: (@snapshotter.activity_index if @snapshotter.respond_to?(:activity_index)),
154
- range_start: range_start,
155
- range_end: range_end
156
- ).call
157
- end
158
-
159
- #: (Array[untyped], ?end_at: untyped) -> void
160
- def prepare_history(root_versions, end_at: nil)
161
- return unless @snapshotter.respond_to?(:prepare)
162
- return @snapshotter.prepare(@record, root_versions) unless end_at
163
-
164
- @snapshotter.prepare(@record, root_versions, end_at: end_at)
147
+ reject_live_habtm!
165
148
  end
166
149
 
167
150
  # Appends the transition into an explicit closing boundary, which is either
168
151
  # a requested current record or the absence a final root destroy leaves.
169
- # Either follows the root versions, which are themselves events.
170
- #: (ActivityHistory, Array[ActivityEvent], ActivityBoundary?, RecordSnapshot?) -> Array[ActivityStep]
171
- def activity_steps(history, events, final_boundary, final_snapshot)
172
- steps = history.steps.dup
173
- if final_boundary
174
- last_event = events.last #: ActivityEvent
175
- steps << ActivityStep.between(
176
- from_boundary: ActivityBoundary.from_version(last_event.version),
177
- to_boundary: final_boundary,
178
- from_snapshot: history.last_snapshot, to_snapshot: final_snapshot,
179
- retain: @retain
180
- )
181
- end
182
- group_steps(steps).freeze
152
+ # Either follows the root versions, which are themselves events, so the
153
+ # last boundary the history kept opens it.
154
+ #: (ActivityHistory, ActivityBoundary?, RecordSnapshot?) -> Array[ActivityStep]
155
+ def activity_steps(history, final_boundary, final_snapshot)
156
+ return present(history.steps) unless final_boundary
157
+
158
+ last_boundary = history.last_boundary #: ActivityBoundary
159
+ present(history.steps + [step_from_last(history, last_boundary, final_boundary,
160
+ final_snapshot)])
183
161
  end
184
162
 
185
163
  # A destroyed root has no later version, but its own event states that
@@ -189,41 +167,218 @@ module PaperTrailDiff
189
167
  version = root_versions.last
190
168
  return unless version.event == 'destroy'
191
169
 
192
- ActivityBoundary.destroyed(version)
170
+ ActivityBoundary.destroyed(version, recorded: root_recorded)
171
+ end
172
+
173
+ # Only the activity view gains the closing removal. The endpoint diff and
174
+ # the root timeline keep their `compare` and `timeline` semantics, under
175
+ # which the state at a destroy version is the state before the deletion.
176
+ #: (RootVersionPlan, ActivityHistory) -> Analysis
177
+ def build_analysis(plan, history)
178
+ steps = activity_steps(history, destroyed_boundary(plan.reconstruction_versions), nil)
179
+ analysis(plan, history, final: history.last_snapshot, steps: steps)
180
+ end
181
+
182
+ #: () -> Array[ActivityStep]
183
+ def window_steps
184
+ history, _plan, closing, = window_history
185
+ present(closing ? history.steps + [closing] : history.steps)
186
+ end
187
+
188
+ #: () -> Analysis
189
+ def analyze_window
190
+ history, plan, closing, closing_snapshot, captured_at = window_history
191
+ analysis(plan, history,
192
+ final: window_final_snapshot(history, closing, closing_snapshot),
193
+ steps: present(closing ? history.steps + [closing] : history.steps),
194
+ closing_snapshot: closing_snapshot, captured_at: captured_at)
195
+ end
196
+
197
+ # A window with a closing step ends at what closed it: the live row, or
198
+ # nothing after a destroy, where no live row is read. One that ends before
199
+ # a later event ends at the state that event found.
200
+ #: (ActivityHistory, ActivityStep?, RecordSnapshot?) -> RecordSnapshot?
201
+ def window_final_snapshot(history, closing, closing_snapshot)
202
+ closing ? closing_snapshot : history.last_snapshot
203
+ end
204
+
205
+ #: () -> [ActivityHistory, RootVersionPlan, ActivityStep?, RecordSnapshot?, untyped]
206
+ def window_history
207
+ plan = @range.select_plan(context_required: !@tree.empty?)
208
+ reject_live_habtm! if plan.closing_record
209
+ return empty_window(plan) if plan.empty? && !plan.closing_record
210
+
211
+ # A window closing on current state runs to the instant it is captured,
212
+ # not to the last root version, or descendants that moved after that
213
+ # version would be missing from a report that claims to reach now.
214
+ window_replay(plan, (Time.now.utc if plan.closing_record))
215
+ end
216
+
217
+ # Nothing reads a window's closing step, live snapshot, or capture time
218
+ # when it reported nothing, so mutations of those cannot change an answer.
219
+ # mutant:disable
220
+ #: (RootVersionPlan) -> [ActivityHistory, RootVersionPlan, ActivityStep?, RecordSnapshot?, untyped]
221
+ def empty_window(plan)
222
+ [ActivityHistory.empty, plan, nil, nil, nil]
223
+ end
224
+
225
+ # A window with no selected event reports nothing. Any other has a
226
+ # boundary after its last one: the plan refuses a window that has no later
227
+ # version, ends on no destroy, and does not close on the live row.
228
+ #: (RootVersionPlan, untyped) -> [ActivityHistory, RootVersionPlan, ActivityStep?, RecordSnapshot?, untyped]
229
+ def window_replay(plan, captured_at)
230
+ versions = plan.reconstruction_versions
231
+ snapshots = prepare_history(versions, end_at: captured_at)
232
+ events = collect_events(versions, snapshots, range_end: captured_at || versions.last)
233
+ selected = selected_events(events, plan)
234
+ last = selected.last
235
+ return empty_window(plan) unless last
236
+
237
+ snapshot = window_live_snapshot(plan)
238
+ history = replay(versions, events, snapshots, current: plan.closing_record, closing: snapshot)
239
+ closing = window_closing(history, last, plan, captured_at, snapshot, events)
240
+ [history, plan, closing, snapshot, captured_at]
241
+ end
242
+
243
+ # One live read serves both the closing activity step and the endpoint the
244
+ # analysis reports, which would otherwise reconstruct the graph twice.
245
+ #: (RootVersionPlan) -> RecordSnapshot?
246
+ def window_live_snapshot(plan)
247
+ record = plan.closing_record
248
+ return unless record
249
+
250
+ @snapshotter.call(record, record)
251
+ end
252
+
253
+ # Root versions inside the window that the plan does not report are context
254
+ # rather than mutations: the version appended to reveal the last selected
255
+ # change, and anything a filter excluded but the replay still walks. Neither
256
+ # may be counted as a selected mutation. Descendant events are not filtered,
257
+ # so window membership is the whole test for them.
258
+ #: (Array[ActivityEvent], RootVersionPlan) -> Array[ActivityEvent]
259
+ def selected_events(events, plan)
260
+ events.select do |event|
261
+ @range.include?(event.version) && (!event.root? || plan.mutation?(event.version))
262
+ end
263
+ end
264
+
265
+ # A window either closes on the absence its final destruction leaves, or on
266
+ # current state when it reaches past everything recorded. Both are boundaries
267
+ # no version can supply.
268
+ #
269
+ # Everything recorded means on every requested path, not only the root's
270
+ # own versions. When an association changed after the window, its last
271
+ # step already ends at that change, at the state the window left; closing
272
+ # on current state would add the later change to a window it was not in.
273
+ #: (ActivityHistory, ActivityEvent, RootVersionPlan, untyped, RecordSnapshot?, Array[ActivityEvent]) -> ActivityStep?
274
+ def window_closing(history, event, plan, captured_at, snapshot, events) # rubocop:disable Metrics/ParameterLists
275
+ return window_destroyed_step(history, event) if terminal_destroy?(event)
276
+ return unless plan.closing_record
277
+ return if events.any? { |later| !@range.include?(later.version) }
278
+
279
+ window_current_step(history, plan.closing_record, captured_at, snapshot)
193
280
  end
194
281
 
195
- #: (Array[untyped], Array[ActivityEvent], ?current: untyped) -> ActivityHistory
196
- def event_history(root_versions, events, current: nil)
282
+ #: (ActivityHistory, ActivityEvent) -> ActivityStep
283
+ def window_destroyed_step(history, event)
284
+ version = event.version
285
+ ActivityStep.between(
286
+ from_boundary: ActivityBoundary.from_version(version, recorded: root_recorded),
287
+ to_boundary: ActivityBoundary.destroyed(version, recorded: root_recorded),
288
+ from_snapshot: history.root_snapshots.fetch(ActivityRootSteps.version_key(version)),
289
+ to_snapshot: nil, retain: @view.retain?
290
+ )
291
+ end
292
+
293
+ # The last boundary reached is where current state is compared from, which
294
+ # is the final event rather than the final root version once descendants
295
+ # have moved since it. A selected event is a boundary the history kept.
296
+ #: (ActivityHistory, untyped, untyped, RecordSnapshot?) -> ActivityStep
297
+ def window_current_step(history, record, captured_at, snapshot)
298
+ previous = history.last_boundary #: ActivityBoundary
299
+ current = ActivityBoundary.current(record, captured_at: captured_at, recorded: root_recorded)
300
+ step_from_last(history, previous, current, snapshot)
301
+ end
302
+
303
+ #: (ActivityEvent) -> bool
304
+ def terminal_destroy?(event)
305
+ event.root? && event.version.event == 'destroy'
306
+ end
307
+
308
+ #: () -> void
309
+ def reject_live_habtm!
310
+ AssociationTraversal.new(@tree).reject_live_habtm_activity!(@record.class)
311
+ end
312
+
313
+ #: () -> Array[String]?
314
+ def root_recorded
315
+ @view.recorded_attributes(root: true)
316
+ end
317
+
318
+ # A window prepares from its start, explicit endpoints from their first
319
+ # version.
320
+ #: (Array[untyped], ?end_at: untyped) -> untyped
321
+ def prepare_history(root_versions, end_at: nil)
322
+ options = { start_at: @range.begin_time, end_at: end_at }.compact
323
+ @snapshotter.prepare(@record, root_versions, **options)
324
+ end
325
+
326
+ # A window collects from its start, explicit endpoints from their first
327
+ # version. Explicit endpoints also stop before the rest of the closing
328
+ # version's transaction (see ActivityTransactionGroups.before_closing); a
329
+ # window keeps every event it collected and reports only those inside it.
330
+ #: (Array[untyped], untyped, range_end: untyped) -> Array[ActivityEvent]
331
+ def collect_events(root_versions, snapshots, range_end:)
332
+ events = ActivityVersionCollector.new(
333
+ @record,
334
+ root_versions: root_versions,
335
+ tree: @tree,
336
+ prepared: snapshots.activity_index,
337
+ range_start: @range.begin_time || root_versions.first,
338
+ range_end: range_end
339
+ ).call
340
+ @range.time? ? events : ActivityTransactionGroups.before_closing(events, range_end)
341
+ end
342
+
343
+ # Events after the last root version read the root from the live record
344
+ # when the range closes on it. Explicit endpoints report every step, as
345
+ # their range includes every version.
346
+ #: (Array[untyped], Array[ActivityEvent], untyped, ?current: untyped, ?closing: RecordSnapshot?) -> ActivityHistory
347
+ def replay(root_versions, events, snapshots, current: nil, closing: nil)
197
348
  ActivityHistoryBuilder.new(
198
- root_versions,
199
- events,
200
- @snapshotter,
349
+ root_versions, events, snapshots,
201
350
  current: current,
202
- snapshots: @retain
351
+ include_step: ->(event) { @range.include?(event.version) },
352
+ view: @view,
353
+ closing: closing
203
354
  ).call
204
355
  end
205
356
 
206
- # Only the activity view gains the closing removal. The endpoint diff and
207
- # the root timeline keep their `compare` and `timeline` semantics, under
208
- # which the state at a destroy version is the state before the deletion.
209
- #: (RootVersionPlan, Array[ActivityEvent], ActivityHistory) -> Analysis
210
- def build_analysis(plan, events, history)
211
- Analysis.new(
212
- diff: Engine.compare(history.first_snapshot, history.last_snapshot),
213
- timeline: ActivityRootSteps.call(plan, history.root_snapshots),
214
- activity_timeline: activity_steps(
215
- history, events, destroyed_boundary(plan.reconstruction_versions), nil
216
- ),
217
- from_snapshot: history.first_snapshot,
218
- to_snapshot: history.last_snapshot
357
+ # The step from the last boundary the history reached to one that closes
358
+ # the range.
359
+ #: (ActivityHistory, ActivityBoundary, ActivityBoundary, RecordSnapshot?) -> ActivityStep
360
+ def step_from_last(history, last_boundary, closing_boundary, closing_snapshot)
361
+ ActivityStep.between(
362
+ from_boundary: last_boundary, to_boundary: closing_boundary,
363
+ from_snapshot: history.last_event_snapshot, to_snapshot: closing_snapshot,
364
+ retain: @view.retain?
219
365
  )
220
366
  end
221
367
 
222
- #: () -> TimeActivityTimelineBuilder
223
- def time_builder
224
- TimeActivityTimelineBuilder.new(@record, range: @range, tree: @tree,
225
- snapshotter: @snapshotter, snapshots: @snapshots,
226
- group: @group)
368
+ #: (Array[ActivityStep]) -> Array[ActivityStep]
369
+ def present(steps)
370
+ @view.present(steps).freeze
371
+ end
372
+
373
+ #: (RootVersionPlan, ActivityHistory, final: RecordSnapshot?, steps: Array[ActivityStep], ?closing_snapshot: RecordSnapshot?, ?captured_at: untyped) -> Analysis
374
+ def analysis(plan, history, final:, steps:, **closing)
375
+ Analysis.new(
376
+ diff: Engine.compare(history.first_snapshot, final),
377
+ timeline: ActivityRootSteps.call(plan, history.root_snapshots, **closing),
378
+ activity_timeline: steps,
379
+ from_snapshot: history.first_snapshot,
380
+ to_snapshot: final
381
+ )
227
382
  end
228
383
  end
229
384
  end
@@ -76,7 +76,8 @@ module PaperTrailDiff
76
76
  to_boundary: last.to_boundary,
77
77
  from_snapshot: first.from_snapshot,
78
78
  to_snapshot: last.to_snapshot,
79
- retain: @retain
79
+ retain: @retain,
80
+ sources: group.flat_map(&:sources)
80
81
  )
81
82
  end
82
83
  end