paper_trail_diff 0.12.0 → 0.13.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 (130) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +127 -0
  3. data/QUICKSTART.md +149 -79
  4. data/README.md +179 -1388
  5. data/docs/architecture.md +88 -0
  6. data/docs/performance.md +309 -0
  7. data/docs/reference.md +977 -0
  8. data/docs/testing.md +146 -0
  9. data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +2 -12
  10. data/lib/paper_trail_diff/activity_boundary.rb +20 -8
  11. data/lib/paper_trail_diff/activity_child_candidate_loader.rb +29 -0
  12. data/lib/paper_trail_diff/activity_collection_event_applier.rb +10 -7
  13. data/lib/paper_trail_diff/activity_collection_route_updater.rb +1 -11
  14. data/lib/paper_trail_diff/activity_event_record_normalizer.rb +4 -0
  15. data/lib/paper_trail_diff/activity_event_record_resolver.rb +66 -6
  16. data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +6 -4
  17. data/lib/paper_trail_diff/activity_grouping.rb +12 -0
  18. data/lib/paper_trail_diff/activity_history.rb +9 -3
  19. data/lib/paper_trail_diff/activity_relationship.rb +2 -0
  20. data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +1 -0
  21. data/lib/paper_trail_diff/activity_snapshot_sequence.rb +9 -2
  22. data/lib/paper_trail_diff/activity_timeline_builder.rb +39 -35
  23. data/lib/paper_trail_diff/activity_version_collector.rb +27 -18
  24. data/lib/paper_trail_diff/analysis.rb +9 -4
  25. data/lib/paper_trail_diff/analysis_batch.rb +28 -7
  26. data/lib/paper_trail_diff/association_traversal.rb +23 -0
  27. data/lib/paper_trail_diff/batch_boundary_resolver.rb +18 -26
  28. data/lib/paper_trail_diff/batch_safety.rb +55 -0
  29. data/lib/paper_trail_diff/batched_root_analyzer.rb +14 -5
  30. data/lib/paper_trail_diff/batched_root_versions.rb +9 -37
  31. data/lib/paper_trail_diff/branch_snapshot_refresher.rb +7 -8
  32. data/lib/paper_trail_diff/deleted_analysis_batch.rb +93 -0
  33. data/lib/paper_trail_diff/direct_record_state_loader.rb +81 -0
  34. data/lib/paper_trail_diff/endpoint.rb +10 -0
  35. data/lib/paper_trail_diff/engine.rb +4 -2
  36. data/lib/paper_trail_diff/errors.rb +9 -0
  37. data/lib/paper_trail_diff/historical_association_reifier.rb +26 -1
  38. data/lib/paper_trail_diff/historical_filter.rb +181 -0
  39. data/lib/paper_trail_diff/historical_snapshot_store.rb +51 -35
  40. data/lib/paper_trail_diff/instrumentation.rb +2 -2
  41. data/lib/paper_trail_diff/nested_comparator.rb +39 -15
  42. data/lib/paper_trail_diff/paper_trail_adapter.rb +109 -55
  43. data/lib/paper_trail_diff/prepared_activity_index.rb +121 -0
  44. data/lib/paper_trail_diff/prepared_edge_loader.rb +11 -11
  45. data/lib/paper_trail_diff/prepared_history.rb +21 -30
  46. data/lib/paper_trail_diff/prepared_history_loader.rb +14 -2
  47. data/lib/paper_trail_diff/prepared_record_index.rb +102 -29
  48. data/lib/paper_trail_diff/prepared_snapshot_builder.rb +64 -0
  49. data/lib/paper_trail_diff/prepared_state_association_reader.rb +77 -0
  50. data/lib/paper_trail_diff/root_version_plan.rb +25 -2
  51. data/lib/paper_trail_diff/root_version_selection.rb +16 -5
  52. data/lib/paper_trail_diff/scoped_analysis.rb +17 -2
  53. data/lib/paper_trail_diff/scoped_analysis_builder.rb +47 -0
  54. data/lib/paper_trail_diff/scoped_root_selection.rb +106 -13
  55. data/lib/paper_trail_diff/snapshot.rb +9 -0
  56. data/lib/paper_trail_diff/snapshot_normalizer.rb +41 -2
  57. data/lib/paper_trail_diff/step.rb +10 -5
  58. data/lib/paper_trail_diff/stored_time_precision.rb +68 -0
  59. data/lib/paper_trail_diff/support.rb +13 -3
  60. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +19 -7
  61. data/lib/paper_trail_diff/time_range.rb +22 -2
  62. data/lib/paper_trail_diff/time_version_range.rb +1 -15
  63. data/lib/paper_trail_diff/timeline_builder.rb +5 -4
  64. data/lib/paper_trail_diff/timeline_range.rb +25 -43
  65. data/lib/paper_trail_diff/timeline_snapshot_provider.rb +13 -2
  66. data/lib/paper_trail_diff/traversal.rb +1 -1
  67. data/lib/paper_trail_diff/version.rb +1 -1
  68. data/lib/paper_trail_diff/version_association_candidate_scope.rb +1 -1
  69. data/lib/paper_trail_diff/version_boundary.rb +48 -0
  70. data/lib/paper_trail_diff/version_query.rb +33 -0
  71. data/lib/paper_trail_diff/version_range.rb +10 -38
  72. data/lib/paper_trail_diff.rb +78 -42
  73. data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +0 -3
  74. data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -4
  75. data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +11 -0
  76. data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +4 -4
  77. data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +0 -3
  78. data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +19 -3
  79. data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +2 -2
  80. data/sig/generated/paper_trail_diff/activity_grouping.rbs +3 -0
  81. data/sig/generated/paper_trail_diff/activity_history.rbs +6 -2
  82. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +7 -2
  83. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +8 -3
  84. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +4 -8
  85. data/sig/generated/paper_trail_diff/analysis.rbs +2 -2
  86. data/sig/generated/paper_trail_diff/analysis_batch.rbs +14 -3
  87. data/sig/generated/paper_trail_diff/association_traversal.rbs +11 -0
  88. data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +7 -10
  89. data/sig/generated/paper_trail_diff/batch_safety.rbs +24 -0
  90. data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +8 -2
  91. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +4 -16
  92. data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
  93. data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +44 -0
  94. data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +42 -0
  95. data/sig/generated/paper_trail_diff/endpoint.rbs +5 -0
  96. data/sig/generated/paper_trail_diff/engine.rbs +4 -2
  97. data/sig/generated/paper_trail_diff/errors.rbs +12 -0
  98. data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +8 -0
  99. data/sig/generated/paper_trail_diff/historical_filter.rbs +75 -0
  100. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +17 -4
  101. data/sig/generated/paper_trail_diff/nested_comparator.rbs +15 -7
  102. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +36 -15
  103. data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +56 -0
  104. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +3 -3
  105. data/sig/generated/paper_trail_diff/prepared_history.rbs +12 -14
  106. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -0
  107. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +43 -6
  108. data/sig/generated/paper_trail_diff/prepared_snapshot_builder.rbs +24 -0
  109. data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +33 -0
  110. data/sig/generated/paper_trail_diff/root_version_plan.rbs +15 -2
  111. data/sig/generated/paper_trail_diff/root_version_selection.rbs +8 -1
  112. data/sig/generated/paper_trail_diff/scoped_analysis.rbs +14 -2
  113. data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +22 -0
  114. data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +46 -6
  115. data/sig/generated/paper_trail_diff/snapshot.rbs +4 -0
  116. data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +13 -0
  117. data/sig/generated/paper_trail_diff/step.rbs +8 -2
  118. data/sig/generated/paper_trail_diff/stored_time_precision.rbs +36 -0
  119. data/sig/generated/paper_trail_diff/support.rbs +7 -0
  120. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +7 -2
  121. data/sig/generated/paper_trail_diff/time_range.rbs +9 -0
  122. data/sig/generated/paper_trail_diff/time_version_range.rbs +0 -6
  123. data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
  124. data/sig/generated/paper_trail_diff/timeline_range.rbs +10 -14
  125. data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +2 -0
  126. data/sig/generated/paper_trail_diff/version_boundary.rbs +23 -0
  127. data/sig/generated/paper_trail_diff/version_query.rbs +15 -0
  128. data/sig/generated/paper_trail_diff/version_range.rbs +5 -14
  129. data/sig/generated/paper_trail_diff.rbs +31 -25
  130. metadata +31 -5
@@ -4,18 +4,21 @@
4
4
  module PaperTrailDiff
5
5
  # Chooses explicit-version or wall-clock selection for one timeline request.
6
6
  class TimelineRange
7
- BOUNDARY_SYMBOLS = %i[first last].freeze
7
+ BOUNDARY_SYMBOLS = %i[first].freeze
8
+ # `:last` once named the last version, whose recorded state is the one
9
+ # *before* the latest change, so a range ending there silently dropped it.
10
+ LAST_REMOVED = '`:last` was removed because it left out the latest change: omit `to:` ' \
11
+ 'to end at current state, or pass a version'
8
12
 
9
13
  attr_reader :from #: untyped
10
14
  attr_reader :to #: untyped
11
15
  attr_reader :time_range #: TimeRange?
12
16
 
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
17
+ #: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?plan: RootVersionPlan?, ?live_endpoint: untyped) -> void
18
+ def initialize(record, from:, to:, within:, version_scope: nil, plan: nil, live_endpoint: nil) # rubocop:disable Metrics/ParameterLists
15
19
  @record = record
16
20
  @plan = plan
17
21
  @live_endpoint = live_endpoint
18
- @versions = (plan ? plan.versions : versions)&.freeze
19
22
  @version_scope = version_scope
20
23
  @requested_from = from
21
24
  @requested_to = to
@@ -26,7 +29,7 @@ module PaperTrailDiff
26
29
  freeze
27
30
  end
28
31
 
29
- # A record with no versions has no first or last boundary to resolve, which
32
+ # A record with no versions has no first boundary to resolve, which
30
33
  # is an empty history rather than a bad request.
31
34
  #: () -> bool
32
35
  def unresolved?
@@ -49,27 +52,7 @@ module PaperTrailDiff
49
52
  live_endpoint: @live_endpoint
50
53
  ).select_plan(context_required: context_required)
51
54
  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
-
59
- #: (?context_required: bool) -> Array[untyped]
60
- def select(context_required: false)
61
- preselected = @versions
62
- return preselected if preselected
63
-
64
- range = time_range
65
- if range
66
- return TimeVersionRange.new(
67
- @record, time_range: range, version_scope: @version_scope
68
- ).select(context_required: context_required)
69
- end
70
- return empty_versions if unresolved?
71
-
72
- VersionRange.new(@record, from: @from, to: @to, version_scope: @version_scope).select
55
+ explicit_plan
73
56
  end
74
57
 
75
58
  #: () -> bool
@@ -91,7 +74,6 @@ module PaperTrailDiff
91
74
  private
92
75
 
93
76
  # @rbs @record: untyped
94
- # @rbs @versions: Array[untyped]?
95
77
  # @rbs @plan: RootVersionPlan?
96
78
  # @rbs @version_scope: untyped
97
79
  # @rbs @live_endpoint: untyped
@@ -100,10 +82,17 @@ module PaperTrailDiff
100
82
  # @rbs @from: untyped
101
83
  # @rbs @to: untyped
102
84
 
103
- #: () -> Array[untyped]
104
- def empty_versions
105
- versions = [] #: Array[untyped]
106
- versions.freeze
85
+ #: () -> RootVersionPlan
86
+ def explicit_plan
87
+ return RootVersionPlan.empty if unresolved?
88
+ if Endpoint.record?(@to)
89
+ return VersionRange.new(@record, from: @from, to: @from, version_scope: @version_scope)
90
+ .select_plan_through_current(@to)
91
+ end
92
+
93
+ VersionRange.new(
94
+ @record, from: @from, to: @to, version_scope: @version_scope
95
+ ).select_plan_for_range
107
96
  end
108
97
 
109
98
  #: (untyped) -> bool
@@ -111,32 +100,25 @@ module PaperTrailDiff
111
100
  boundary.is_a?(Symbol)
112
101
  end
113
102
 
114
- # Resolving `:first` and `:last` here keeps callers from depending on the
103
+ # Resolving `:first` here keeps callers from depending on the
115
104
  # order PaperTrail happens to give its versions association, which a caller
116
105
  # is also free to reorder.
117
106
  #: (untyped) -> untyped
118
107
  def resolve(boundary)
119
108
  return boundary unless symbolic?(boundary)
109
+ raise InvalidTimelineRangeError, LAST_REMOVED if boundary == :last
120
110
 
121
111
  unless BOUNDARY_SYMBOLS.include?(boundary)
122
112
  raise InvalidTimelineRangeError,
123
- "unsupported boundary: #{boundary.inspect}; use :first, :last, a version, or a record"
113
+ "unsupported boundary: #{boundary.inspect}; use :first, a version, or a record"
124
114
  end
125
115
 
126
- boundary == :first ? ordered_versions.first : ordered_versions.last
116
+ ordered_versions.first
127
117
  end
128
118
 
129
119
  #: () -> untyped
130
120
  def ordered_versions
131
- versions_relation.reorder(created_at: :asc, id: :asc)
132
- end
133
-
134
- #: () -> untyped
135
- def versions_relation
136
- @record.public_send(@record.class.versions_association_name)
137
- rescue NoMethodError => e
138
- message = 'record does not expose a PaperTrail version history'
139
- raise InvalidTimelineRangeError, message, cause: e
121
+ Endpoint.versions(@record).reorder(created_at: :asc, id: :asc)
140
122
  end
141
123
 
142
124
  #: (untyped) -> TimeRange?
@@ -8,10 +8,15 @@ module PaperTrailDiff
8
8
  def initialize(store, live_snapshotter:)
9
9
  @store = store
10
10
  @live_snapshotter = live_snapshotter
11
+ @snapshots = {} #: Hash[Array[untyped], RecordSnapshot?]
11
12
  end
12
13
 
13
14
  #: (untyped, Array[untyped]) -> void
14
15
  def prepare(record, versions)
16
+ # Adjacent steps share endpoints. Keep their immutable snapshots for this
17
+ # root only, including nil create states, and release them before the next
18
+ # root so batch memory does not retain every reconstructed graph.
19
+ @snapshots.clear
15
20
  @store.prepare(record, versions)
16
21
  end
17
22
 
@@ -19,9 +24,14 @@ module PaperTrailDiff
19
24
  # version, which is the one endpoint no version history can reconstruct.
20
25
  #: (untyped) -> RecordSnapshot?
21
26
  def call(endpoint)
22
- return live_snapshot(endpoint) if Endpoint.record?(endpoint)
27
+ key = [endpoint.class.name, endpoint.id]
28
+ return @snapshots[key] if @snapshots.key?(key)
23
29
 
24
- @store.uncached(endpoint, endpoint)
30
+ @snapshots[key] = if Endpoint.record?(endpoint)
31
+ live_snapshot(endpoint)
32
+ else
33
+ @store.uncached(endpoint, endpoint)
34
+ end
25
35
  end
26
36
 
27
37
  private
@@ -33,5 +43,6 @@ module PaperTrailDiff
33
43
 
34
44
  # @rbs @store: HistoricalSnapshotStore
35
45
  # @rbs @live_snapshotter: untyped
46
+ # @rbs @snapshots: Hash[Array[untyped], RecordSnapshot?]
36
47
  end
37
48
  end
@@ -73,7 +73,7 @@ module PaperTrailDiff
73
73
  def each_change(&receiver)
74
74
  return enum_for(:each_change) unless receiver
75
75
 
76
- each_entry { |entry| receiver.call(entry) if entry.change? }
76
+ each_entry { |entry| yield(entry) if entry.change? }
77
77
  self
78
78
  end
79
79
  end
@@ -2,5 +2,5 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module PaperTrailDiff
5
- VERSION = '0.12.0'
5
+ VERSION = '0.13.0'
6
6
  end
@@ -32,7 +32,7 @@ module PaperTrailDiff
32
32
  def with_connection(&block)
33
33
  return @version_class.with_connection(&block) if @version_class.respond_to?(:with_connection)
34
34
 
35
- block.call(@version_class.connection)
35
+ yield(@version_class.connection)
36
36
  end
37
37
 
38
38
  #: () -> String
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Places one version relative to another on the recorded timeline.
6
+ module VersionBoundary
7
+ module_function
8
+
9
+ # Whether `candidate` was recorded at or after `boundary`. Versions sharing
10
+ # a timestamp are ordered by id, which reflects insertion order only for
11
+ # sequential ids in one version table; any other tie cannot be ordered, and
12
+ # guessing would place a change on an arbitrary side of the boundary. A
13
+ # bare instant has no position among same-time versions, so it keeps them.
14
+ #: (untyped, untyped) -> bool
15
+ def at_or_after?(candidate, boundary)
16
+ tied = candidate.created_at == boundary.created_at
17
+ return candidate.created_at > boundary.created_at unless tied
18
+ return true unless version_record?(boundary)
19
+ return true if same_version?(candidate, boundary)
20
+
21
+ unless orderable_tie?(candidate, boundary)
22
+ raise AmbiguousVersionOrderError, Support.ambiguous_message([boundary, candidate])
23
+ end
24
+
25
+ Support.compare_versions(candidate, boundary) >= 0
26
+ end
27
+
28
+ #: (untyped) -> bool
29
+ def version_record?(value)
30
+ value.respond_to?(:id) && value.respond_to?(:item_type) &&
31
+ value.class.respond_to?(:table_name)
32
+ end
33
+ private_class_method :version_record?
34
+
35
+ #: (untyped, untyped) -> bool
36
+ def same_version?(left, right)
37
+ left.class.table_name == right.class.table_name && left.id.to_s == right.id.to_s
38
+ end
39
+ private_class_method :same_version?
40
+
41
+ #: (untyped, untyped) -> bool
42
+ def orderable_tie?(left, right)
43
+ left.class.table_name == right.class.table_name &&
44
+ Support.sequential_id?(left.id) && Support.sequential_id?(right.id)
45
+ end
46
+ private_class_method :orderable_tie?
47
+ end
48
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Arel conditions shared by the version queries that need one row per item.
6
+ module VersionQuery
7
+ module_function
8
+
9
+ # Keeps each item's earliest version among those the block also accepts:
10
+ # no other version of the same item that the block accepts is earlier. A
11
+ # correlated NOT EXISTS stays portable where window functions are not.
12
+ #: (untyped) ?{ (untyped) -> untyped } -> untyped
13
+ def no_earlier_version(table, &block)
14
+ arel = Object.const_get(:Arel) #: untyped
15
+ earlier = table.alias('paper_trail_diff_earlier_versions')
16
+ arel.const_get(:SelectManager).new
17
+ .from(earlier)
18
+ .project(arel.sql('1'))
19
+ .where(earlier_version(table, earlier, &block))
20
+ .exists
21
+ .not
22
+ end
23
+
24
+ #: (untyped, untyped) ?{ (untyped) -> untyped } -> untyped
25
+ def earlier_version(table, earlier)
26
+ same_item = earlier[:item_type].eq(table[:item_type])
27
+ .and(earlier[:item_id].eq(table[:item_id]))
28
+ .and(earlier[:created_at].lt(table[:created_at]))
29
+ block_given? ? same_item.and(yield(earlier)) : same_item
30
+ end
31
+ private_class_method :earlier_version
32
+ end
33
+ end
@@ -12,7 +12,7 @@ module PaperTrailDiff
12
12
  @version_scope = version_scope
13
13
  end
14
14
 
15
- # Same selection as `select`, but keeping the step pairs a filter implies.
15
+ # Keep both reconstruction boundaries and the step pairs a filter implies.
16
16
  #: () -> RootVersionPlan
17
17
  def select_plan_for_range
18
18
  relation = validated_relation
@@ -24,7 +24,7 @@ module PaperTrailDiff
24
24
 
25
25
  #: () -> untyped
26
26
  def validated_relation
27
- relation = versions_relation
27
+ relation = Endpoint.versions(@record)
28
28
  validate_boundary!(@from, relation, boundary: :from)
29
29
  validate_boundary!(@to, relation, boundary: :to)
30
30
  if Support.compare_versions(@from, @to).positive?
@@ -34,26 +34,12 @@ module PaperTrailDiff
34
34
  relation
35
35
  end
36
36
 
37
- #: () -> Array[untyped]
38
- def select
39
- relation = versions_relation
37
+ #: (untyped) -> RootVersionPlan
38
+ def select_plan_through_current(current)
39
+ Endpoint.validate_pair!(@record, current)
40
+ relation = Endpoint.versions(@record)
40
41
  validate_boundary!(@from, relation, boundary: :from)
41
- validate_boundary!(@to, relation, boundary: :to)
42
- if Support.compare_versions(@from, @to).positive?
43
- raise InvalidTimelineRangeError, '`from` version must not follow `to` version'
44
- end
45
-
46
- select_relation(
47
- relation.where(created_at: @from.created_at..@to.created_at),
48
- through: @to
49
- )
50
- end
51
-
52
- #: () -> Array[untyped]
53
- def select_through_latest
54
- relation = versions_relation
55
- validate_boundary!(@from, relation, boundary: :from)
56
- select_relation(relation.where(created_at: @from.created_at..))
42
+ select_plan(relation.where(created_at: @from.created_at..), live_endpoint: current)
57
43
  end
58
44
 
59
45
  private
@@ -63,22 +49,8 @@ module PaperTrailDiff
63
49
  # @rbs @to: untyped
64
50
  # @rbs @version_scope: untyped
65
51
 
66
- #: () -> untyped
67
- def versions_relation
68
- association_name = @record.class.versions_association_name
69
- @record.public_send(association_name)
70
- rescue NoMethodError => e
71
- message = 'record does not expose a PaperTrail version history'
72
- raise InvalidTimelineRangeError, message, cause: e
73
- end
74
-
75
- #: (untyped, ?through: untyped) -> Array[untyped]
76
- def select_relation(relation, through: nil)
77
- select_plan(relation, through: through).versions
78
- end
79
-
80
- #: (untyped, ?through: untyped) -> RootVersionPlan
81
- def select_plan(relation, through: nil)
52
+ #: (untyped, ?through: untyped, ?live_endpoint: untyped) -> RootVersionPlan
53
+ def select_plan(relation, through: nil, live_endpoint: nil)
82
54
  in_range = ordered(relation.to_a.select do |version|
83
55
  next false if Support.compare_versions(@from, version).positive?
84
56
  next true unless through
@@ -90,7 +62,7 @@ module PaperTrailDiff
90
62
  selected: VersionScopeFilter.new(@version_scope).call(relation, in_range),
91
63
  after_range: nil,
92
64
  windowed: false,
93
- filtered: !@version_scope.nil?
65
+ filtered: !@version_scope.nil?, live_endpoint: live_endpoint
94
66
  ).call
95
67
  end
96
68
 
@@ -5,11 +5,15 @@ require 'paper_trail'
5
5
 
6
6
  require_relative 'paper_trail_diff/version'
7
7
  require_relative 'paper_trail_diff/support'
8
+ require_relative 'paper_trail_diff/version_query'
9
+ require_relative 'paper_trail_diff/stored_time_precision'
10
+ require_relative 'paper_trail_diff/version_boundary'
8
11
  require_relative 'paper_trail_diff/instrumentation'
9
12
  require_relative 'paper_trail_diff/errors'
10
13
  require_relative 'paper_trail_diff/configuration'
11
14
  require_relative 'paper_trail_diff/endpoint'
12
15
  require_relative 'paper_trail_diff/association_traversal'
16
+ require_relative 'paper_trail_diff/batch_safety'
13
17
  require_relative 'paper_trail_diff/traversal_preparer'
14
18
  require_relative 'paper_trail_diff/association_discovery'
15
19
  require_relative 'paper_trail_diff/diagnostics'
@@ -28,6 +32,10 @@ require_relative 'paper_trail_diff/nested_comparator'
28
32
  require_relative 'paper_trail_diff/engine'
29
33
  require_relative 'paper_trail_diff/historical_association_reifier'
30
34
  require_relative 'paper_trail_diff/prepared_record_index'
35
+ require_relative 'paper_trail_diff/direct_record_state_loader'
36
+ require_relative 'paper_trail_diff/prepared_state_association_reader'
37
+ require_relative 'paper_trail_diff/prepared_snapshot_builder'
38
+ require_relative 'paper_trail_diff/prepared_activity_index'
31
39
  require_relative 'paper_trail_diff/prepared_history'
32
40
  require_relative 'paper_trail_diff/version_association_candidate_scope'
33
41
  require_relative 'paper_trail_diff/prepared_edge_loader'
@@ -66,8 +74,11 @@ require_relative 'paper_trail_diff/step'
66
74
  require_relative 'paper_trail_diff/analysis'
67
75
  require_relative 'paper_trail_diff/activity_root_steps'
68
76
  require_relative 'paper_trail_diff/analysis_batch'
77
+ require_relative 'paper_trail_diff/deleted_analysis_batch'
69
78
  require_relative 'paper_trail_diff/scoped_analysis'
79
+ require_relative 'paper_trail_diff/historical_filter'
70
80
  require_relative 'paper_trail_diff/scoped_root_selection'
81
+ require_relative 'paper_trail_diff/scoped_analysis_builder'
71
82
  require_relative 'paper_trail_diff/batched_root_analyzer'
72
83
  require_relative 'paper_trail_diff/version_range'
73
84
  require_relative 'paper_trail_diff/version_sequence_diagnostics'
@@ -98,6 +109,10 @@ require_relative 'paper_trail_diff/paper_trail_adapter'
98
109
  # type identity = Array[untyped]
99
110
  # type comparison_input = Hash[String | Symbol, untyped]
100
111
  # type comparison_results = Hash[identity, Diff]
112
+ # type report_options = {
113
+ # within: untyped, activity: bool, version_scope: untyped,
114
+ # close_on: Symbol?, snapshots: bool, group: Symbol?
115
+ # }
101
116
  # type ignore_option = Array[String | Symbol] | Hash[String | Symbol, untyped]
102
117
  # type reference_key = :type | :id | "type" | "id"
103
118
  # type traversal_context = :change | :included_state
@@ -117,7 +132,7 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
117
132
  has_and_belongs_to_many
118
133
  ].freeze
119
134
 
120
- class << self
135
+ class << self # rubocop:disable Metrics/ClassLength
121
136
  # Compares net state between explicit PaperTrail-version or current-record endpoints.
122
137
  #: (untyped, untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool) -> Diff
123
138
  def compare(
@@ -138,21 +153,23 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
138
153
  end
139
154
 
140
155
  # Compares many independent endpoint pairs while batching current-record loading.
141
- #: (Array[comparison_input], ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool) -> comparison_results
156
+ #: (Array[comparison_input], ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool, ?allow_expensive: bool) -> comparison_results
142
157
  def compare_many(
143
158
  comparisons,
144
159
  associations: [],
145
160
  ignore: DEFAULT_IGNORED_ATTRIBUTES,
146
- reload_live_endpoints: true
161
+ reload_live_endpoints: true,
162
+ allow_expensive: false
147
163
  )
148
164
  PaperTrailAdapter.new(
149
165
  associations: associations,
150
166
  ignore: ignore,
151
- reload_live_endpoints: reload_live_endpoints
167
+ reload_live_endpoints: reload_live_endpoints, allow_expensive: allow_expensive
152
168
  ).compare_many(comparisons)
153
169
  end
154
170
 
155
- # Compares every adjacent reconstructed state in an inclusive version range.
171
+ # Compares every adjacent reconstructed state in an inclusive version range,
172
+ # by default the record's whole history through its current state.
156
173
  #: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?version_scope: untyped, ?close_on: Symbol?) -> Array[Step]
157
174
  def timeline( # rubocop:disable Metrics/ParameterLists
158
175
  record,
@@ -175,9 +192,10 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
175
192
  end
176
193
 
177
194
  # Compares adjacent root and selected-descendant activity boundaries.
178
- # `reload_live_endpoints:` applies only when `to:` is a current record; the
179
- # other range forms never read live state.
180
- #: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Array[ActivityStep]
195
+ # `reload_live_endpoints:` applies whenever the range ends at the current
196
+ # record: an explicit `to:` record, the default range, or a window with no
197
+ # later version.
198
+ #: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?) -> Array[ActivityStep]
181
199
  def activity_timeline( # rubocop:disable Metrics/ParameterLists
182
200
  record,
183
201
  from: nil,
@@ -235,47 +253,36 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
235
253
  )
236
254
  end
237
255
 
238
- # Analyzes many roots over one shared time window, preparing their selected
239
- # history once for the batch instead of once per record. Roots with no
240
- # versions in the window return an empty `Analysis`.
241
- #
242
- # Pass `records` to analyze a list you assembled, which returns a Hash keyed
243
- # by identity. Pass `scope:` with a `limit:` to have the roots selected for
244
- # you from a relation, which returns a `ScopedAnalysis` -- the same Hash,
245
- # plus the roots the relation could not reach. See `analyze_scope` for why
246
- # that second collection exists.
247
- #: (?Array[untyped]?, ?scope: untyped, ?limit: Integer?, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> (Hash[identity, Analysis] | ScopedAnalysis)
256
+ # Analyzes an array of records over one shared range, preparing their
257
+ # selected history once for the batch instead of once per record, and
258
+ # returns a frozen Hash keyed by identity. Roots with nothing to report in
259
+ # the window return an empty `Analysis`. To let a relation choose the
260
+ # records, use `analyze_scope`.
261
+ #: (Array[untyped], ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?, ?allow_expensive: bool) -> Hash[identity, Analysis]
248
262
  def analyze_many( # rubocop:disable Metrics/ParameterLists
249
- records = nil,
250
- scope: nil,
251
- limit: nil,
263
+ records,
252
264
  within: nil,
253
265
  associations: [],
254
266
  ignore: DEFAULT_IGNORED_ATTRIBUTES,
255
267
  activity: false,
256
268
  version_scope: nil,
257
- close_on: nil
269
+ close_on: nil,
270
+ snapshots: false,
271
+ group: nil,
272
+ allow_expensive: false
258
273
  )
259
- adapter = PaperTrailAdapter.new(associations: associations, ignore: ignore)
260
- if scope
261
- raise ConfigurationError, 'pass either records or scope:, not both' unless records.nil?
262
-
263
- return adapter.analyze_scope(scope, limit: limit, within: within, activity: activity,
264
- version_scope: version_scope, close_on: close_on)
265
- end
266
- raise ConfigurationError, 'pass records or scope:' if records.nil?
267
-
268
- adapter.analyze_many(records, within: within, activity: activity,
269
- version_scope: version_scope, close_on: close_on)
274
+ PaperTrailAdapter.new(
275
+ associations: associations, ignore: ignore, allow_expensive: allow_expensive
276
+ ).analyze_many(records, within: within, activity: activity, version_scope: version_scope,
277
+ close_on: close_on, snapshots: snapshots, group: group)
270
278
  end
271
279
 
272
280
  # Analyzes every root the relation reaches whose history moved inside the
273
- # window, selecting them in a fixed number of queries rather than making the
274
- # caller rediscover them.
281
+ # window. Joined duplicates are read in bounded pages and analyzed once.
275
282
  #
276
283
  # `limit:` is required and exceeding it raises. Selection moves into the gem
277
- # here, so the bound on how much work a page can ask for has to move with
278
- # it, and a truncated audit report is worse than a refused one.
284
+ # here. The relation's own limit/offset select the live page; this separate
285
+ # ceiling rejects too many unique roots rather than truncating that page.
279
286
  #
280
287
  # Returns a `ScopedAnalysis`, which destructures:
281
288
  #
@@ -284,7 +291,8 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
284
291
  # )
285
292
  #
286
293
  # `unreachable` names roots that changed in the window but have no live row
287
- # left. A relation's conditions are evaluated against the live table, so a
294
+ # left, independently of the live page's pagination and safety ceiling.
295
+ # A relation's conditions are evaluated against the live table, so a
288
296
  # destroyed root cannot be tested against them at all -- its history is
289
297
  # intact and the state it held at destruction may well have matched. Those
290
298
  # roots are reported rather than dropped so that a page auditing deletions
@@ -293,7 +301,7 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
293
301
  # Note also that a relation selects on current state, not on state during
294
302
  # the window: `where(status: 'published')` means published *now*, which is a
295
303
  # different set from what was published while the window was open.
296
- #: (untyped, limit: Integer?, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> ScopedAnalysis
304
+ #: (untyped, limit: Integer?, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?, ?historical_filter: untyped, ?max_filter_versions: Integer, ?include_deleted: bool, ?allow_expensive: bool) -> ScopedAnalysis
297
305
  def analyze_scope( # rubocop:disable Metrics/ParameterLists
298
306
  scope,
299
307
  limit:,
@@ -302,11 +310,39 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
302
310
  ignore: DEFAULT_IGNORED_ATTRIBUTES,
303
311
  activity: false,
304
312
  version_scope: nil,
305
- close_on: nil
313
+ close_on: nil,
314
+ snapshots: false,
315
+ group: nil,
316
+ historical_filter: nil,
317
+ max_filter_versions: HistoricalFilter::DEFAULT_VERSION_LIMIT,
318
+ include_deleted: false,
319
+ allow_expensive: false
306
320
  )
307
- PaperTrailAdapter.new(associations: associations, ignore: ignore).analyze_scope(
321
+ adapter = PaperTrailAdapter.new(associations: associations, ignore: ignore,
322
+ allow_expensive: allow_expensive)
323
+ adapter.analyze_scope(
308
324
  scope, limit: limit, within: within, activity: activity,
309
- version_scope: version_scope, close_on: close_on
325
+ version_scope: version_scope, close_on: close_on, snapshots: snapshots, group: group,
326
+ historical_filter: historical_filter, max_filter_versions: max_filter_versions,
327
+ include_deleted: include_deleted
328
+ )
329
+ end
330
+
331
+ # Recent root and selected-child activity through the current saved state.
332
+ # Historical windows remain explicit on analyze_scope; this entry point is
333
+ # deliberately named for a report through now rather than an as-of query.
334
+ #: (untyped, since: untyped, limit: Integer, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?snapshots: bool, ?group: Symbol?, ?include_deleted: bool, ?historical_filter: untyped, ?max_filter_versions: Integer, ?allow_expensive: bool) -> ScopedAnalysis
335
+ def report( # rubocop:disable Metrics/ParameterLists
336
+ scope, since:, limit:, associations: [], ignore: DEFAULT_IGNORED_ATTRIBUTES,
337
+ snapshots: false, group: nil, include_deleted: false, historical_filter: nil,
338
+ max_filter_versions: HistoricalFilter::DEFAULT_VERSION_LIMIT, allow_expensive: false
339
+ )
340
+ analyze_scope(
341
+ scope,
342
+ within: since..Time.now.utc, limit: limit, associations: associations,
343
+ ignore: ignore, activity: true, close_on: :current, snapshots: snapshots, group: group,
344
+ include_deleted: include_deleted, historical_filter: historical_filter,
345
+ max_filter_versions: max_filter_versions, allow_expensive: allow_expensive
310
346
  )
311
347
  end
312
348
 
@@ -29,9 +29,6 @@ module PaperTrailDiff
29
29
  # : (Array[RecordSnapshot], untyped, RecordSnapshot?) -> [Array[RecordSnapshot], bool]
30
30
  def replace_existing_target: (Array[RecordSnapshot], untyped, RecordSnapshot?) -> [ Array[RecordSnapshot], bool ]
31
31
 
32
- # : (RecordSnapshot, String, AssociationSnapshot) -> RecordSnapshot
33
- def replace_association: (RecordSnapshot, String, AssociationSnapshot) -> RecordSnapshot
34
-
35
32
  # : (untyped, RecordSnapshot?) -> String
36
33
  def replacement_type: (untyped, RecordSnapshot?) -> String
37
34
 
@@ -54,8 +54,8 @@ module PaperTrailDiff
54
54
  # : () -> bool
55
55
  def destroyed?: () -> bool
56
56
 
57
- # : () -> Hash[Symbol, untyped]
58
- def to_h: () -> Hash[Symbol, untyped]
57
+ # : (?metadata: bool) -> Hash[Symbol, untyped]
58
+ def to_h: (?metadata: bool) -> Hash[Symbol, untyped]
59
59
  end
60
60
 
61
61
  # One adjacent transition in a descendant-aware activity timeline.
@@ -87,7 +87,12 @@ module PaperTrailDiff
87
87
  # : () -> bool
88
88
  def empty?: () -> bool
89
89
 
90
- # : () -> Hash[Symbol, untyped]
91
- def to_h: () -> Hash[Symbol, untyped]
90
+ # The event whose change this step reports. For a transaction group, this
91
+ # is the first event in the group, not necessarily its only actor or record.
92
+ # : () -> ActivityBoundary
93
+ def source_boundary: () -> ActivityBoundary
94
+
95
+ # : (?metadata: bool, ?snapshots: bool) -> Hash[Symbol, untyped]
96
+ def to_h: (?metadata: bool, ?snapshots: bool) -> Hash[Symbol, untyped]
92
97
  end
93
98
  end
@@ -9,6 +9,14 @@ module PaperTrailDiff
9
9
  # : () -> Array[untyped]
10
10
  def call: () -> Array[untyped]
11
11
 
12
+ # One shared query retains the owner and timestamp needed to partition a
13
+ # batch's candidates back into each root's exact activity range.
14
+ # : () -> Array[Array[untyped]]
15
+ def historical_rows: () -> Array[Array[untyped]]
16
+
17
+ # : () -> Array[Array[untyped]]
18
+ def current_pairs: () -> Array[Array[untyped]]
19
+
12
20
  private
13
21
 
14
22
  @parent_class: untyped
@@ -19,6 +27,9 @@ module PaperTrailDiff
19
27
 
20
28
  @reflection: untyped
21
29
 
30
+ # : (Array[Array[untyped]]) -> Array[Array[untyped]]
31
+ def typed_rows: (Array[Array[untyped]]) -> Array[Array[untyped]]
32
+
22
33
  # : () -> Array[untyped]
23
34
  def historical_ids: () -> Array[untyped]
24
35