paper_trail_diff 0.12.0 → 0.13.1

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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +158 -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 +169 -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 +10 -38
  31. data/lib/paper_trail_diff/branch_snapshot_refresher.rb +7 -8
  32. data/lib/paper_trail_diff/collection_comparator.rb +18 -20
  33. data/lib/paper_trail_diff/collection_identity_index.rb +8 -12
  34. data/lib/paper_trail_diff/deleted_analysis_batch.rb +94 -0
  35. data/lib/paper_trail_diff/direct_record_state_loader.rb +81 -0
  36. data/lib/paper_trail_diff/endpoint.rb +10 -0
  37. data/lib/paper_trail_diff/engine.rb +4 -2
  38. data/lib/paper_trail_diff/errors.rb +9 -0
  39. data/lib/paper_trail_diff/historical_association_reifier.rb +26 -1
  40. data/lib/paper_trail_diff/historical_filter.rb +176 -0
  41. data/lib/paper_trail_diff/historical_snapshot_store.rb +51 -35
  42. data/lib/paper_trail_diff/instrumentation.rb +2 -2
  43. data/lib/paper_trail_diff/nested_comparator.rb +39 -15
  44. data/lib/paper_trail_diff/paper_trail_adapter.rb +109 -55
  45. data/lib/paper_trail_diff/prepared_activity_index.rb +121 -0
  46. data/lib/paper_trail_diff/prepared_edge_loader.rb +11 -11
  47. data/lib/paper_trail_diff/prepared_history.rb +21 -30
  48. data/lib/paper_trail_diff/prepared_history_loader.rb +14 -2
  49. data/lib/paper_trail_diff/prepared_record_index.rb +102 -29
  50. data/lib/paper_trail_diff/prepared_snapshot_builder.rb +64 -0
  51. data/lib/paper_trail_diff/prepared_state_association_reader.rb +77 -0
  52. data/lib/paper_trail_diff/root_version_plan.rb +35 -3
  53. data/lib/paper_trail_diff/root_version_selection.rb +16 -5
  54. data/lib/paper_trail_diff/scoped_analysis.rb +17 -2
  55. data/lib/paper_trail_diff/scoped_analysis_builder.rb +47 -0
  56. data/lib/paper_trail_diff/scoped_root_selection.rb +106 -13
  57. data/lib/paper_trail_diff/snapshot.rb +14 -1
  58. data/lib/paper_trail_diff/snapshot_normalizer.rb +75 -40
  59. data/lib/paper_trail_diff/step.rb +10 -5
  60. data/lib/paper_trail_diff/stored_time_precision.rb +68 -0
  61. data/lib/paper_trail_diff/support.rb +13 -3
  62. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +19 -7
  63. data/lib/paper_trail_diff/time_range.rb +21 -2
  64. data/lib/paper_trail_diff/time_version_range.rb +2 -16
  65. data/lib/paper_trail_diff/timeline_builder.rb +5 -4
  66. data/lib/paper_trail_diff/timeline_range.rb +29 -46
  67. data/lib/paper_trail_diff/timeline_snapshot_provider.rb +13 -2
  68. data/lib/paper_trail_diff/traversal.rb +1 -1
  69. data/lib/paper_trail_diff/version.rb +1 -1
  70. data/lib/paper_trail_diff/version_association_candidate_scope.rb +1 -1
  71. data/lib/paper_trail_diff/version_boundary.rb +48 -0
  72. data/lib/paper_trail_diff/version_query.rb +41 -0
  73. data/lib/paper_trail_diff/version_range.rb +10 -38
  74. data/lib/paper_trail_diff.rb +78 -42
  75. data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +0 -3
  76. data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -4
  77. data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +11 -0
  78. data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +4 -4
  79. data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +0 -3
  80. data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +19 -3
  81. data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +2 -2
  82. data/sig/generated/paper_trail_diff/activity_grouping.rbs +3 -0
  83. data/sig/generated/paper_trail_diff/activity_history.rbs +6 -2
  84. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +7 -2
  85. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +8 -3
  86. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +4 -8
  87. data/sig/generated/paper_trail_diff/analysis.rbs +2 -2
  88. data/sig/generated/paper_trail_diff/analysis_batch.rbs +14 -3
  89. data/sig/generated/paper_trail_diff/association_traversal.rbs +11 -0
  90. data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +7 -10
  91. data/sig/generated/paper_trail_diff/batch_safety.rbs +24 -0
  92. data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +8 -2
  93. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +4 -16
  94. data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
  95. data/sig/generated/paper_trail_diff/collection_comparator.rbs +6 -3
  96. data/sig/generated/paper_trail_diff/collection_identity_index.rbs +3 -3
  97. data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +44 -0
  98. data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +42 -0
  99. data/sig/generated/paper_trail_diff/endpoint.rbs +5 -0
  100. data/sig/generated/paper_trail_diff/engine.rbs +4 -2
  101. data/sig/generated/paper_trail_diff/errors.rbs +12 -0
  102. data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +8 -0
  103. data/sig/generated/paper_trail_diff/historical_filter.rbs +75 -0
  104. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +17 -4
  105. data/sig/generated/paper_trail_diff/nested_comparator.rbs +15 -7
  106. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +36 -15
  107. data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +56 -0
  108. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +3 -3
  109. data/sig/generated/paper_trail_diff/prepared_history.rbs +12 -14
  110. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -0
  111. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +43 -6
  112. data/sig/generated/paper_trail_diff/prepared_snapshot_builder.rbs +24 -0
  113. data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +33 -0
  114. data/sig/generated/paper_trail_diff/root_version_plan.rbs +22 -2
  115. data/sig/generated/paper_trail_diff/root_version_selection.rbs +8 -1
  116. data/sig/generated/paper_trail_diff/scoped_analysis.rbs +14 -2
  117. data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +22 -0
  118. data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +46 -6
  119. data/sig/generated/paper_trail_diff/snapshot.rbs +4 -0
  120. data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +34 -11
  121. data/sig/generated/paper_trail_diff/step.rbs +8 -2
  122. data/sig/generated/paper_trail_diff/stored_time_precision.rbs +36 -0
  123. data/sig/generated/paper_trail_diff/support.rbs +7 -0
  124. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +7 -2
  125. data/sig/generated/paper_trail_diff/time_range.rbs +9 -0
  126. data/sig/generated/paper_trail_diff/time_version_range.rbs +0 -6
  127. data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
  128. data/sig/generated/paper_trail_diff/timeline_range.rbs +12 -15
  129. data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +2 -0
  130. data/sig/generated/paper_trail_diff/version_boundary.rbs +23 -0
  131. data/sig/generated/paper_trail_diff/version_query.rbs +21 -0
  132. data/sig/generated/paper_trail_diff/version_range.rbs +5 -14
  133. data/sig/generated/paper_trail_diff.rbs +31 -25
  134. metadata +31 -5
@@ -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.1'
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,41 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Query pieces shared by version lookups.
6
+ module VersionQuery
7
+ module_function
8
+
9
+ # Oldest first. Ids break ties between versions written in the same
10
+ # instant, and the timestamp comes first because ids need not follow time:
11
+ # a UUID key does not.
12
+ #: (untyped) -> untyped
13
+ def chronological(relation)
14
+ relation.reorder(created_at: :asc, id: :asc)
15
+ end
16
+
17
+ # Keeps each item's earliest version among those the block also accepts:
18
+ # no other version of the same item that the block accepts is earlier. A
19
+ # correlated NOT EXISTS stays portable where window functions are not.
20
+ #: (untyped) ?{ (untyped) -> untyped } -> untyped
21
+ def no_earlier_version(table, &block)
22
+ arel = Object.const_get(:Arel) #: untyped
23
+ earlier = table.alias('paper_trail_diff_earlier_versions')
24
+ arel.const_get(:SelectManager).new
25
+ .from(earlier)
26
+ .project(arel.sql('1'))
27
+ .where(earlier_version(table, earlier, &block))
28
+ .exists
29
+ .not
30
+ end
31
+
32
+ #: (untyped, untyped) ?{ (untyped) -> untyped } -> untyped
33
+ def earlier_version(table, earlier)
34
+ same_item = earlier[:item_type].eq(table[:item_type])
35
+ .and(earlier[:item_id].eq(table[:item_id]))
36
+ .and(earlier[:created_at].lt(table[:created_at]))
37
+ block_given? ? same_item.and(yield(earlier)) : same_item
38
+ end
39
+ private_class_method :earlier_version
40
+ end
41
+ 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
 
@@ -22,8 +22,8 @@ module PaperTrailDiff
22
22
 
23
23
  @route_updater: ActivityCollectionRouteUpdater
24
24
 
25
- # : (RecordSnapshot, Array[untyped], untyped, ActivitySnapshotDelta?, Array[untyped], SnapshotNormalizer, Array[untyped]) -> RecordSnapshot
26
- def apply_route: (RecordSnapshot, Array[untyped], untyped, ActivitySnapshotDelta?, Array[untyped], SnapshotNormalizer, Array[untyped]) -> RecordSnapshot
25
+ # : (RecordSnapshot, Array[untyped], untyped, ActivitySnapshotDelta?, Array[untyped], SnapshotNormalizer, Array[untyped], ?direct: bool) -> RecordSnapshot
26
+ def apply_route: (RecordSnapshot, Array[untyped], untyped, ActivitySnapshotDelta?, Array[untyped], SnapshotNormalizer, Array[untyped], ?direct: bool) -> RecordSnapshot
27
27
 
28
28
  # : (Array[Array[untyped]], untyped) -> bool
29
29
  def unsafe_through_membership_event?: (Array[Array[untyped]], untyped) -> bool
@@ -31,8 +31,8 @@ module PaperTrailDiff
31
31
  # : (Array[Array[untyped]], ActivitySnapshotDelta?) -> bool
32
32
  def unsafe_nested_membership_delta?: (Array[Array[untyped]], ActivitySnapshotDelta?) -> bool
33
33
 
34
- # : (RecordSnapshot, Array[untyped], untyped, ActivitySnapshotDelta?, Array[untyped], SnapshotNormalizer, Array[untyped]) -> [untyped, RecordSnapshot?]
35
- def route_event_state: (RecordSnapshot, Array[untyped], untyped, ActivitySnapshotDelta?, Array[untyped], SnapshotNormalizer, Array[untyped]) -> [ untyped, RecordSnapshot? ]
34
+ # : (RecordSnapshot, Array[untyped], untyped, ActivitySnapshotDelta?, Array[untyped], SnapshotNormalizer, Array[untyped], ?direct: bool) -> [untyped, RecordSnapshot?]
35
+ def route_event_state: (RecordSnapshot, Array[untyped], untyped, ActivitySnapshotDelta?, Array[untyped], SnapshotNormalizer, Array[untyped], ?direct: bool) -> [ untyped, RecordSnapshot? ]
36
36
 
37
37
  # : (untyped, Array[untyped], SnapshotNormalizer, Array[untyped]) -> RecordSnapshot?
38
38
  def normalized_event_record: (untyped, Array[untyped], SnapshotNormalizer, Array[untyped]) -> RecordSnapshot?
@@ -37,8 +37,5 @@ module PaperTrailDiff
37
37
 
38
38
  # : (AssociationSnapshot, untyped, ActivityCollectionRouteChange) -> Integer?
39
39
  def nested_owner_position: (AssociationSnapshot, untyped, ActivityCollectionRouteChange) -> Integer?
40
-
41
- # : (RecordSnapshot, String, AssociationSnapshot) -> RecordSnapshot
42
- def replace_association: (RecordSnapshot, String, AssociationSnapshot) -> RecordSnapshot
43
40
  end
44
41
  end
@@ -5,11 +5,17 @@ module PaperTrailDiff
5
5
  class ActivityEventRecordResolver
6
6
  RECORD_AFTER_HANDLERS: untyped
7
7
 
8
- # : (?record_transition: untyped) -> void
9
- def initialize: (?record_transition: untyped) -> void
8
+ # : (?record_transition: untyped, ?prepared_record_after: untyped) -> void
9
+ def initialize: (?record_transition: untyped, ?prepared_record_after: untyped) -> void
10
+
11
+ # : (untyped, ?direct: bool) -> untyped
12
+ def record_after: (untyped, ?direct: bool) -> untyped
13
+
14
+ # : (untyped) -> [bool, untyped]
15
+ def direct_after: (untyped) -> [ bool, untyped ]
10
16
 
11
17
  # : (untyped) -> untyped
12
- def record_after: (untyped) -> untyped
18
+ def fallback_record_after: (untyped) -> untyped
13
19
 
14
20
  # : (untyped) -> ActivitySnapshotDelta?
15
21
  def snapshot_delta: (untyped) -> ActivitySnapshotDelta?
@@ -30,8 +36,18 @@ module PaperTrailDiff
30
36
 
31
37
  private
32
38
 
39
+ @direct_loader: DirectRecordStateLoader
40
+
41
+ @prepared_record_after: untyped
42
+
33
43
  @record_transition: untyped
34
44
 
45
+ # : (untyped) -> PreparedRecordState?
46
+ def direct_record_after: (untyped) -> PreparedRecordState?
47
+
48
+ # : (untyped) -> untyped
49
+ def prepared_changeset: (untyped) -> untyped
50
+
35
51
  # : (untyped, untyped) -> Hash[untyped, untyped]
36
52
  def after_attributes: (untyped, untyped) -> Hash[untyped, untyped]
37
53
 
@@ -3,8 +3,8 @@
3
3
  module PaperTrailDiff
4
4
  # Dispatches safe descendant events to focused immutable snapshot appliers.
5
5
  class ActivityEventSnapshotRefresher
6
- # : (traversal: AssociationTraversal, pool: SnapshotPool, components: untyped, ?association_reader: untyped, ?record_transition: untyped) -> void
7
- def initialize: (traversal: AssociationTraversal, pool: SnapshotPool, components: untyped, ?association_reader: untyped, ?record_transition: untyped) -> void
6
+ # : (traversal: AssociationTraversal, pool: SnapshotPool, components: untyped, ?association_reader: untyped, ?record_transition: untyped, ?prepared_record_after: untyped) -> void
7
+ def initialize: (traversal: AssociationTraversal, pool: SnapshotPool, components: untyped, ?association_reader: untyped, ?record_transition: untyped, ?prepared_record_after: untyped) -> void
8
8
 
9
9
  # : (SnapshotPool, untyped) -> void
10
10
  def configure_appliers: (SnapshotPool, untyped) -> void
@@ -6,6 +6,9 @@ module PaperTrailDiff
6
6
  # window -- but a transaction has to collapse the same way in both, or the
7
7
  # same history would read differently depending on how it was asked for.
8
8
  module ActivityGrouping
9
+ # : (Symbol?, ?activity: bool, ?snapshots: bool) -> void
10
+ def self.validate!: (Symbol?, ?activity: bool, ?snapshots: bool) -> void
11
+
9
12
  private
10
13
 
11
14
  # : () -> bool
@@ -11,8 +11,12 @@ module PaperTrailDiff
11
11
 
12
12
  attr_reader last_snapshot: RecordSnapshot?
13
13
 
14
- # : (steps: Array[ActivityStep], root_snapshots: Hash[Array[untyped], RecordSnapshot?], first_snapshot: RecordSnapshot?, last_snapshot: RecordSnapshot?) -> void
15
- def initialize: (steps: Array[ActivityStep], root_snapshots: Hash[Array[untyped], RecordSnapshot?], first_snapshot: RecordSnapshot?, last_snapshot: RecordSnapshot?) -> void
14
+ attr_reader last_boundary: ActivityBoundary?
15
+
16
+ attr_reader last_event_snapshot: RecordSnapshot?
17
+
18
+ # : (steps: Array[ActivityStep], root_snapshots: Hash[Array[untyped], RecordSnapshot?], first_snapshot: RecordSnapshot?, last_snapshot: RecordSnapshot?, ?last_boundary: ActivityBoundary?, ?last_event_snapshot: RecordSnapshot?) -> void
19
+ def initialize: (steps: Array[ActivityStep], root_snapshots: Hash[Array[untyped], RecordSnapshot?], first_snapshot: RecordSnapshot?, last_snapshot: RecordSnapshot?, ?last_boundary: ActivityBoundary?, ?last_event_snapshot: RecordSnapshot?) -> void
16
20
 
17
21
  # : () -> ActivityHistory
18
22
  def self.empty: () -> ActivityHistory