paper_trail_diff 0.3.1 → 0.4.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +61 -0
  3. data/README.md +260 -30
  4. data/lib/paper_trail_diff/activity_boundary.rb +21 -0
  5. data/lib/paper_trail_diff/activity_event_route_finder.rb +24 -44
  6. data/lib/paper_trail_diff/activity_root_steps.rb +27 -0
  7. data/lib/paper_trail_diff/activity_snapshot_sequence.rb +8 -3
  8. data/lib/paper_trail_diff/activity_timeline_builder.rb +47 -32
  9. data/lib/paper_trail_diff/analysis.rb +9 -0
  10. data/lib/paper_trail_diff/collection_comparator.rb +14 -1
  11. data/lib/paper_trail_diff/collection_transition.rb +6 -3
  12. data/lib/paper_trail_diff/endpoint.rb +27 -2
  13. data/lib/paper_trail_diff/errors.rb +3 -0
  14. data/lib/paper_trail_diff/historical_association_reifier.rb +8 -5
  15. data/lib/paper_trail_diff/historical_snapshot_store.rb +9 -2
  16. data/lib/paper_trail_diff/prepared_association_reifier.rb +8 -5
  17. data/lib/paper_trail_diff/prepared_edge_loader.rb +6 -4
  18. data/lib/paper_trail_diff/prepared_history_loader.rb +3 -2
  19. data/lib/paper_trail_diff/prepared_record_index.rb +90 -15
  20. data/lib/paper_trail_diff/snapshot.rb +26 -0
  21. data/lib/paper_trail_diff/snapshot_normalizer.rb +14 -2
  22. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +52 -32
  23. data/lib/paper_trail_diff/time_version_range.rb +11 -0
  24. data/lib/paper_trail_diff/timeline_range.rb +57 -4
  25. data/lib/paper_trail_diff/version.rb +1 -1
  26. data/lib/paper_trail_diff/version_association_candidate_scope.rb +46 -19
  27. data/lib/paper_trail_diff.rb +1 -0
  28. data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -0
  29. data/sig/generated/paper_trail_diff/activity_event_route_finder.rbs +10 -17
  30. data/sig/generated/paper_trail_diff/activity_root_steps.rbs +13 -0
  31. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +2 -2
  32. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +20 -10
  33. data/sig/generated/paper_trail_diff/analysis.rbs +5 -0
  34. data/sig/generated/paper_trail_diff/collection_comparator.rbs +7 -0
  35. data/sig/generated/paper_trail_diff/collection_transition.rbs +4 -1
  36. data/sig/generated/paper_trail_diff/endpoint.rbs +12 -0
  37. data/sig/generated/paper_trail_diff/errors.rbs +4 -0
  38. data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +1 -1
  39. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +2 -2
  40. data/sig/generated/paper_trail_diff/prepared_association_reifier.rbs +1 -1
  41. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +1 -0
  42. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -2
  43. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +39 -6
  44. data/sig/generated/paper_trail_diff/snapshot.rbs +20 -0
  45. data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +8 -0
  46. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +21 -10
  47. data/sig/generated/paper_trail_diff/time_version_range.rbs +7 -0
  48. data/sig/generated/paper_trail_diff/timeline_range.rbs +29 -0
  49. data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +17 -4
  50. metadata +6 -4
@@ -14,17 +14,17 @@ module PaperTrailDiff
14
14
 
15
15
  #: () -> Array[ActivityStep]
16
16
  def build
17
- history, = history_and_versions
18
- history.steps
17
+ history, _root_versions, closing = history_and_versions
18
+ activity_steps(history, closing)
19
19
  end
20
20
 
21
21
  #: () -> Analysis
22
22
  def analyze
23
- history, root_versions = history_and_versions
23
+ history, root_versions, closing = history_and_versions
24
24
  Analysis.new(
25
25
  diff: Engine.compare(history.first_snapshot, history.last_snapshot),
26
- timeline: root_steps(root_versions, history.root_snapshots),
27
- activity_timeline: history.steps
26
+ timeline: ActivityRootSteps.call(root_versions, history.root_snapshots),
27
+ activity_timeline: activity_steps(history, closing)
28
28
  )
29
29
  end
30
30
 
@@ -35,22 +35,61 @@ module PaperTrailDiff
35
35
  # @rbs @tree: AssociationTree
36
36
  # @rbs @snapshotter: untyped
37
37
 
38
- #: () -> [ActivityHistory, Array[untyped]]
38
+ #: () -> [ActivityHistory, Array[untyped], ActivityStep?]
39
39
  def history_and_versions
40
40
  root_versions = @range.select(context_required: !@tree.empty?)
41
- return [ActivityHistory.empty, root_versions] if root_versions.empty?
41
+ return [ActivityHistory.empty, root_versions, nil] if root_versions.empty?
42
42
 
43
43
  prepare_history(root_versions)
44
44
  events = collect_events(root_versions)
45
- return [ActivityHistory.empty, root_versions] unless time_events?(events)
45
+ selected = selected_events(events)
46
+ return [ActivityHistory.empty, root_versions, nil] unless time_events?(selected, events)
46
47
 
47
- history = ActivityHistoryBuilder.new(
48
+ history = build_history(root_versions, events)
49
+ [history, root_versions, closing_step(history, selected.last)]
50
+ end
51
+
52
+ #: (Array[untyped], Array[ActivityEvent]) -> ActivityHistory
53
+ def build_history(root_versions, events)
54
+ ActivityHistoryBuilder.new(
48
55
  root_versions,
49
56
  events,
50
57
  @snapshotter,
51
58
  include_step: ->(event) { @range.include?(event.version) }
52
59
  ).call
53
- [history, root_versions]
60
+ end
61
+
62
+ #: (ActivityHistory, ActivityStep?) -> Array[ActivityStep]
63
+ def activity_steps(history, closing)
64
+ return history.steps unless closing
65
+
66
+ (history.steps + [closing]).freeze
67
+ end
68
+
69
+ #: (Array[ActivityEvent]) -> Array[ActivityEvent]
70
+ def selected_events(events)
71
+ events.select { |event| @range.include?(event.version) }
72
+ end
73
+
74
+ # The window's last selected mutation is the root's own destruction, so the
75
+ # timeline closes on the absence it leaves rather than on a later boundary.
76
+ #: (ActivityHistory, ActivityEvent?) -> ActivityStep?
77
+ def closing_step(history, event)
78
+ return unless event && terminal_destroy?(event)
79
+
80
+ version = event.version
81
+ ActivityStep.new(
82
+ from_boundary: ActivityBoundary.from_version(version),
83
+ to_boundary: ActivityBoundary.destroyed(version),
84
+ diff: Engine.compare(history.root_snapshots[ActivityRootSteps.version_key(version)], nil)
85
+ )
86
+ end
87
+
88
+ #: (ActivityEvent?) -> bool
89
+ def terminal_destroy?(event)
90
+ return false unless event
91
+
92
+ event.root? && event.version.event.to_s == 'destroy'
54
93
  end
55
94
 
56
95
  #: (Array[untyped]) -> void
@@ -72,11 +111,11 @@ module PaperTrailDiff
72
111
  ).call
73
112
  end
74
113
 
75
- #: (Array[ActivityEvent]) -> bool
76
- def time_events?(events)
77
- selected = events.select { |event| @range.include?(event.version) }
114
+ #: (Array[ActivityEvent], Array[ActivityEvent]) -> bool
115
+ def time_events?(selected, events)
78
116
  return false if selected.empty?
79
117
  return true if later_event?(selected.last, events)
118
+ return true if terminal_destroy?(selected.last)
80
119
 
81
120
  message = 'time range requires a later activity boundary to reconstruct its final change'
82
121
  raise IncompleteTimeRangeError, message
@@ -88,24 +127,5 @@ module PaperTrailDiff
88
127
  Support.compare_versions(selected.version, event.version).negative?
89
128
  end
90
129
  end
91
-
92
- #: (Array[untyped], Hash[Array[untyped], RecordSnapshot?]) -> Array[Step]
93
- def root_steps(root_versions, root_snapshots)
94
- snapshots = root_versions.map do |version|
95
- root_snapshots.fetch(version_key(version))
96
- end
97
- root_versions.each_cons(2).with_index.map do |versions, index|
98
- Step.new(
99
- from_version: versions.fetch(0),
100
- to_version: versions.fetch(1),
101
- diff: Engine.compare(snapshots.fetch(index), snapshots.fetch(index + 1))
102
- )
103
- end.freeze
104
- end
105
-
106
- #: (untyped) -> Array[untyped]
107
- def version_key(version)
108
- [version.class.name, version.id]
109
- end
110
130
  end
111
131
  end
@@ -19,6 +19,8 @@ module PaperTrailDiff
19
19
 
20
20
  trailing = trailing_version(relation)
21
21
  unless trailing
22
+ return selected.freeze if terminal_destroy?(selected)
23
+
22
24
  message = 'time range requires a later root version to reconstruct its final change'
23
25
  raise IncompleteTimeRangeError, message
24
26
  end
@@ -40,6 +42,15 @@ module PaperTrailDiff
40
42
  raise InvalidTimelineRangeError, message, cause: e
41
43
  end
42
44
 
45
+ # A window closing on the record's own destruction needs no later version:
46
+ # the destroy reveals the preceding mutation and nothing can follow it, so
47
+ # demanding a checkpoint that can never be written would reject the range
48
+ # permanently.
49
+ #: (Array[untyped]) -> bool
50
+ def terminal_destroy?(versions)
51
+ versions.last&.event.to_s == 'destroy'
52
+ end
53
+
43
54
  #: (untyped) -> untyped
44
55
  def trailing_version(relation)
45
56
  @time_range.trailing_scope(relation).reorder(created_at: :asc, id: :asc).first
@@ -4,6 +4,8 @@
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
8
+
7
9
  attr_reader :from #: untyped
8
10
  attr_reader :to #: untyped
9
11
  attr_reader :time_range #: TimeRange?
@@ -11,13 +13,22 @@ module PaperTrailDiff
11
13
  #: (untyped, from: untyped, to: untyped, within: untyped) -> void
12
14
  def initialize(record, from:, to:, within:)
13
15
  @record = record
14
- @from = from
15
- @to = to
16
+ @requested_from = from
17
+ @requested_to = to
16
18
  @time_range = build_time_range(within)
17
19
  validate_mode!
20
+ @from = resolve(from)
21
+ @to = resolve(to)
18
22
  freeze
19
23
  end
20
24
 
25
+ # A record with no versions has no first or last boundary to resolve, which
26
+ # is an empty history rather than a bad request.
27
+ #: () -> bool
28
+ def unresolved?
29
+ (symbolic?(@requested_from) && @from.nil?) || (symbolic?(@requested_to) && @to.nil?)
30
+ end
31
+
21
32
  #: (?context_required: bool) -> Array[untyped]
22
33
  def select(context_required: false)
23
34
  range = time_range
@@ -26,6 +37,7 @@ module PaperTrailDiff
26
37
  context_required: context_required
27
38
  )
28
39
  end
40
+ return empty_versions if unresolved?
29
41
 
30
42
  VersionRange.new(@record, from: @from, to: @to).select
31
43
  end
@@ -49,9 +61,50 @@ module PaperTrailDiff
49
61
  private
50
62
 
51
63
  # @rbs @record: untyped
64
+ # @rbs @requested_from: untyped
65
+ # @rbs @requested_to: untyped
52
66
  # @rbs @from: untyped
53
67
  # @rbs @to: untyped
54
68
 
69
+ #: () -> Array[untyped]
70
+ def empty_versions
71
+ versions = [] #: Array[untyped]
72
+ versions.freeze
73
+ end
74
+
75
+ #: (untyped) -> bool
76
+ def symbolic?(boundary)
77
+ boundary.is_a?(Symbol)
78
+ end
79
+
80
+ # Resolving `:first` and `:last` here keeps callers from depending on the
81
+ # order PaperTrail happens to give its versions association, which a caller
82
+ # is also free to reorder.
83
+ #: (untyped) -> untyped
84
+ def resolve(boundary)
85
+ return boundary unless symbolic?(boundary)
86
+
87
+ unless BOUNDARY_SYMBOLS.include?(boundary)
88
+ raise InvalidTimelineRangeError,
89
+ "unsupported boundary: #{boundary.inspect}; use :first, :last, a version, or a record"
90
+ end
91
+
92
+ boundary == :first ? ordered_versions.first : ordered_versions.last
93
+ end
94
+
95
+ #: () -> untyped
96
+ def ordered_versions
97
+ versions_relation.reorder(created_at: :asc, id: :asc)
98
+ end
99
+
100
+ #: () -> untyped
101
+ def versions_relation
102
+ @record.public_send(@record.class.versions_association_name)
103
+ rescue NoMethodError => e
104
+ message = 'record does not expose a PaperTrail version history'
105
+ raise InvalidTimelineRangeError, message, cause: e
106
+ end
107
+
55
108
  #: (untyped) -> TimeRange?
56
109
  def build_time_range(within)
57
110
  TimeRange.new(within) unless within.nil?
@@ -60,11 +113,11 @@ module PaperTrailDiff
60
113
  #: () -> void
61
114
  def validate_mode!
62
115
  if time?
63
- return if @from.nil? && @to.nil?
116
+ return if @requested_from.nil? && @requested_to.nil?
64
117
 
65
118
  raise InvalidTimelineRangeError, '`within` cannot be combined with `from` or `to`'
66
119
  end
67
- return unless @from.nil? || @to.nil?
120
+ return unless @requested_from.nil? || @requested_to.nil?
68
121
 
69
122
  raise InvalidTimelineRangeError, 'provide both `from` and `to`, or provide `within`'
70
123
  end
@@ -2,5 +2,5 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module PaperTrailDiff
5
- VERSION = '0.3.1'
5
+ VERSION = '0.4.0'
6
6
  end
@@ -24,45 +24,72 @@ module PaperTrailDiff
24
24
  # @rbs @start_at: untyped
25
25
  # @rbs @end_at: untyped
26
26
 
27
+ # Only quoting helpers are needed, so the connection is borrowed for the
28
+ # duration of building the condition rather than checked out permanently,
29
+ # which Active Record 7.2 and newer can deprecate. Active Record 7.1 has no
30
+ # `with_connection` and reaches the same connection through the accessor.
31
+ #: () { (untyped) -> String } -> String
32
+ def with_connection(&block)
33
+ return @version_class.with_connection(&block) if @version_class.respond_to?(:with_connection)
34
+
35
+ block.call(@version_class.connection)
36
+ end
37
+
27
38
  #: () -> String
28
- def candidate_condition # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
29
- connection = @version_class.connection
39
+ def candidate_condition
40
+ with_connection { |connection| condition_sql(connection) }
41
+ end
42
+
43
+ #: (untyped) -> String
44
+ def condition_sql(connection) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
30
45
  outer = connection.quote_table_name(@version_class.table_name)
31
46
  later = connection.quote_table_name('paper_trail_diff_later_versions')
32
47
  start_at = connection.quote(@start_at)
33
- destroyed = connection.quote('destroy')
34
- activity = activity_condition(outer, start_at)
48
+ columns = quoted_columns(connection, outer, later)
35
49
  compact_sql(<<~SQL)
36
50
  (
37
- #{activity}
51
+ #{activity_condition(columns, start_at, connection)}
38
52
  OR (
39
- #{column(outer, 'created_at')} < #{start_at}
40
- AND #{column(outer, 'event')} != #{destroyed}
53
+ #{columns.fetch(:outer_created_at)} < #{start_at}
54
+ AND #{columns.fetch(:outer_event)} != #{connection.quote('destroy')}
41
55
  AND NOT EXISTS (
42
56
  SELECT 1
43
57
  FROM #{outer} #{later}
44
- WHERE #{column(later, 'item_type')} = #{column(outer, 'item_type')}
45
- AND #{column(later, 'item_id')} = #{column(outer, 'item_id')}
46
- AND #{column(later, 'created_at')} < #{start_at}
47
- AND #{column(later, 'created_at')} > #{column(outer, 'created_at')}
58
+ WHERE #{columns.fetch(:later_item_type)} = #{columns.fetch(:outer_item_type)}
59
+ AND #{columns.fetch(:later_item_id)} = #{columns.fetch(:outer_item_id)}
60
+ AND #{columns.fetch(:later_created_at)} < #{start_at}
61
+ AND #{columns.fetch(:later_created_at)} > #{columns.fetch(:outer_created_at)}
48
62
  )
49
63
  )
50
64
  )
51
65
  SQL
52
66
  end
53
67
 
54
- #: (String, String) -> String
55
- def activity_condition(table, start_at)
56
- condition = "#{column(table, 'created_at')} >= #{start_at}"
68
+ #: (untyped, String, String) -> Hash[Symbol, String]
69
+ def quoted_columns(connection, outer, later)
70
+ {
71
+ outer_created_at: column(connection, outer, 'created_at'),
72
+ outer_event: column(connection, outer, 'event'),
73
+ outer_item_type: column(connection, outer, 'item_type'),
74
+ outer_item_id: column(connection, outer, 'item_id'),
75
+ later_created_at: column(connection, later, 'created_at'),
76
+ later_item_type: column(connection, later, 'item_type'),
77
+ later_item_id: column(connection, later, 'item_id')
78
+ }
79
+ end
80
+
81
+ #: (Hash[Symbol, String], String, untyped) -> String
82
+ def activity_condition(columns, start_at, connection)
83
+ created_at = columns.fetch(:outer_created_at)
84
+ condition = "#{created_at} >= #{start_at}"
57
85
  return condition unless @end_at
58
86
 
59
- end_at = @version_class.connection.quote(@end_at)
60
- "(#{condition} AND #{column(table, 'created_at')} <= #{end_at})"
87
+ "(#{condition} AND #{created_at} <= #{connection.quote(@end_at)})"
61
88
  end
62
89
 
63
- #: (String, String) -> String
64
- def column(table, name)
65
- "#{table}.#{@version_class.connection.quote_column_name(name)}"
90
+ #: (untyped, String, String) -> String
91
+ def column(connection, table, name)
92
+ "#{table}.#{connection.quote_column_name(name)}"
66
93
  end
67
94
 
68
95
  #: (String) -> String
@@ -55,6 +55,7 @@ require_relative 'paper_trail_diff/branch_snapshot_refresher'
55
55
  require_relative 'paper_trail_diff/activity_boundary'
56
56
  require_relative 'paper_trail_diff/step'
57
57
  require_relative 'paper_trail_diff/analysis'
58
+ require_relative 'paper_trail_diff/activity_root_steps'
58
59
  require_relative 'paper_trail_diff/version_range'
59
60
  require_relative 'paper_trail_diff/time_range'
60
61
  require_relative 'paper_trail_diff/time_version_range'
@@ -25,6 +25,12 @@ module PaperTrailDiff
25
25
  # : (untyped, captured_at: untyped) -> ActivityBoundary
26
26
  def self.current: (untyped, captured_at: untyped) -> ActivityBoundary
27
27
 
28
+ # The state a `destroy` version leaves behind. A version records the state
29
+ # before its own event, so the boundary built from a destroy version still
30
+ # holds the record; this one is the absence that follows it.
31
+ # : (untyped) -> ActivityBoundary
32
+ def self.destroyed: (untyped) -> ActivityBoundary
33
+
28
34
  # : (kind: Symbol, version_id: untyped, item_type: untyped, item_id: untyped, recorded_at: untyped, ?event: untyped, ?whodunnit: untyped) -> void
29
35
  def initialize: (kind: Symbol, version_id: untyped, item_type: untyped, item_id: untyped, recorded_at: untyped, ?event: untyped, ?whodunnit: untyped) -> void
30
36
 
@@ -34,6 +40,9 @@ module PaperTrailDiff
34
40
  # : () -> bool
35
41
  def current?: () -> bool
36
42
 
43
+ # : () -> bool
44
+ def destroyed?: () -> bool
45
+
37
46
  # : () -> Hash[Symbol, untyped]
38
47
  def to_h: () -> Hash[Symbol, untyped]
39
48
  end
@@ -14,27 +14,20 @@ module PaperTrailDiff
14
14
 
15
15
  private
16
16
 
17
- @belongs_to_model: untyped
17
+ @belongs_to_routes: Hash[Array[untyped], Array[Array[untyped]]]
18
18
 
19
- @belongs_to_path: String?
19
+ @collection_routes: Hash[Array[untyped], Array[Array[untyped]]]
20
20
 
21
- @belongs_to_routes: Array[Array[untyped]]?
22
-
23
- @belongs_to_tree: AssociationTree?
24
-
25
- @belongs_to_type: String?
26
-
27
- @collection_model: untyped
28
-
29
- @collection_path: String?
30
-
31
- @collection_routes: Array[Array[untyped]]?
32
-
33
- @collection_tree: AssociationTree?
21
+ @traversal: AssociationTraversal
34
22
 
35
- @collection_type: String?
23
+ # Timelines interleave event types, so every requested combination is
24
+ # retained. Model, tree, and path are fixed per adapter, leaving one entry
25
+ # per selected item type.
26
+ # : (Hash[Array[untyped], Array[Array[untyped]]], untyped, AssociationTree, String, String, Symbol) -> Array[Array[untyped]]
27
+ def cached: (Hash[Array[untyped], Array[Array[untyped]]], untyped, AssociationTree, String, String, Symbol) -> Array[Array[untyped]]
36
28
 
37
- @traversal: AssociationTraversal
29
+ # : (Array[untyped]) -> Array[untyped]
30
+ def cache_key: (Array[untyped]) -> Array[untyped]
38
31
 
39
32
  # : (Array[Array[untyped]]) -> Array[Array[untyped]]
40
33
  def freeze_routes: (Array[Array[untyped]]) -> Array[Array[untyped]]
@@ -0,0 +1,13 @@
1
+ # Generated from lib/paper_trail_diff/activity_root_steps.rb with RBS::Inline
2
+
3
+ module PaperTrailDiff
4
+ # Root checkpoint steps recovered from the snapshots an activity pass retained,
5
+ # so a combined result does not reconstruct the same boundaries twice.
6
+ module ActivityRootSteps
7
+ # : (Array[untyped], Hash[Array[untyped], RecordSnapshot?]) -> Array[Step]
8
+ def self?.call: (Array[untyped], Hash[Array[untyped], RecordSnapshot?]) -> Array[Step]
9
+
10
+ # : (untyped) -> Array[untyped]
11
+ def self?.version_key: (untyped) -> Array[untyped]
12
+ end
13
+ end
@@ -6,8 +6,8 @@ module PaperTrailDiff
6
6
  # : (snapshotter: untyped, refresher: untyped, preparer: untyped) -> void
7
7
  def initialize: (snapshotter: untyped, refresher: untyped, preparer: untyped) -> void
8
8
 
9
- # : (untyped, Array[untyped], ?start_at: untyped) -> void
10
- def prepare: (untyped, Array[untyped], ?start_at: untyped) -> void
9
+ # : (untyped, Array[untyped], ?start_at: untyped, ?end_at: untyped) -> void
10
+ def prepare: (untyped, Array[untyped], ?start_at: untyped, ?end_at: untyped) -> void
11
11
 
12
12
  # : (untyped, untyped) -> RecordSnapshot?
13
13
  def call: (untyped, untyped) -> RecordSnapshot?
@@ -27,6 +27,9 @@ module PaperTrailDiff
27
27
 
28
28
  @tree: AssociationTree
29
29
 
30
+ # : () -> Array[ActivityStep]
31
+ def no_steps: () -> Array[ActivityStep]
32
+
30
33
  # : () -> Array[ActivityStep]
31
34
  def build_between_versions: () -> Array[ActivityStep]
32
35
 
@@ -42,23 +45,30 @@ module PaperTrailDiff
42
45
  # : (Array[untyped], ?range_start: untyped, ?range_end: untyped) -> Array[ActivityEvent]
43
46
  def collect_events: (Array[untyped], ?range_start: untyped, ?range_end: untyped) -> Array[ActivityEvent]
44
47
 
45
- # : (Array[untyped], ?start_at: untyped) -> void
46
- def prepare_history: (Array[untyped], ?start_at: untyped) -> void
48
+ # : (Array[untyped], ?end_at: untyped) -> void
49
+ def prepare_history: (Array[untyped], ?end_at: untyped) -> void
47
50
 
48
51
  # : (Array[untyped], Array[ActivityEvent], ?current: untyped, ?final_boundary: ActivityBoundary?, ?final_snapshot: RecordSnapshot?) -> Array[ActivityStep]
49
52
  def build_event_steps: (Array[untyped], Array[ActivityEvent], ?current: untyped, ?final_boundary: ActivityBoundary?, ?final_snapshot: RecordSnapshot?) -> Array[ActivityStep]
50
53
 
51
- # : (Array[untyped], Array[ActivityEvent], ?current: untyped) -> ActivityHistory
52
- def event_history: (Array[untyped], Array[ActivityEvent], ?current: untyped) -> ActivityHistory
54
+ # Appends the transition into an explicit closing boundary, which is either
55
+ # a requested current record or the absence a final root destroy leaves.
56
+ # : (ActivityHistory, Array[ActivityEvent], ActivityBoundary?, RecordSnapshot?) -> Array[ActivityStep]
57
+ def activity_steps: (ActivityHistory, Array[ActivityEvent], ActivityBoundary?, RecordSnapshot?) -> Array[ActivityStep]
53
58
 
54
- # : (Array[untyped], ActivityHistory) -> Analysis
55
- def build_analysis: (Array[untyped], ActivityHistory) -> Analysis
59
+ # A destroyed root has no later version, but its own event states that
60
+ # nothing follows it, so the removal can still close the timeline.
61
+ # : (Array[untyped]) -> ActivityBoundary?
62
+ def destroyed_boundary: (Array[untyped]) -> ActivityBoundary?
56
63
 
57
- # : (Array[untyped], Array[RecordSnapshot?]) -> Array[Step]
58
- def build_root_steps: (Array[untyped], Array[RecordSnapshot?]) -> Array[Step]
64
+ # : (Array[untyped], Array[ActivityEvent], ?current: untyped) -> ActivityHistory
65
+ def event_history: (Array[untyped], Array[ActivityEvent], ?current: untyped) -> ActivityHistory
59
66
 
60
- # : (untyped) -> Array[untyped]
61
- def version_key: (untyped) -> Array[untyped]
67
+ # Only the activity view gains the closing removal. The endpoint diff and
68
+ # the root timeline keep their `compare` and `timeline` semantics, under
69
+ # which the state at a destroy version is the state before the deletion.
70
+ # : (Array[untyped], Array[ActivityEvent], ActivityHistory) -> Analysis
71
+ def build_analysis: (Array[untyped], Array[ActivityEvent], ActivityHistory) -> Analysis
62
72
 
63
73
  # : () -> TimeActivityTimelineBuilder
64
74
  def time_builder: () -> TimeActivityTimelineBuilder
@@ -9,6 +9,11 @@ module PaperTrailDiff
9
9
 
10
10
  attr_reader activity_timeline: Array[ActivityStep]?
11
11
 
12
+ # The result for a record whose requested history contains no versions,
13
+ # which is an empty history rather than a failed request.
14
+ # : () -> Analysis
15
+ def self.empty: () -> Analysis
16
+
12
17
  # : (diff: Diff, timeline: Array[Step], ?activity_timeline: Array[ActivityStep]?) -> void
13
18
  def initialize: (diff: Diff, timeline: Array[Step], ?activity_timeline: Array[ActivityStep]?) -> void
14
19
 
@@ -50,9 +50,16 @@ module PaperTrailDiff
50
50
  # : (Array[identity]) -> Array[identity]
51
51
  def sorted_identities: (Array[identity]) -> Array[identity]
52
52
 
53
+ # Identities sort by type first so that mixed id types stay comparable, then
54
+ # naturally within one type. Ordering by the printed form instead would put
55
+ # id 10 before id 2, which is deterministic but reads as unsorted wherever a
56
+ # result is rendered.
53
57
  # : (identity) -> Array[untyped]
54
58
  def sortable_identity: (identity) -> Array[untyped]
55
59
 
60
+ # : (untyped) -> Array[untyped]
61
+ def sortable_id: (untyped) -> Array[untyped]
62
+
56
63
  # : (identity) -> void
57
64
  def raise_duplicate_identity!: (identity) -> void
58
65
  end
@@ -7,12 +7,15 @@ module PaperTrailDiff
7
7
 
8
8
  attr_reader after: RecordSnapshot?
9
9
 
10
+ # The origin is recorded by serial rather than by reference, because
11
+ # holding it would retain every earlier snapshot at the same path for the
12
+ # whole timeline.
10
13
  # : (from: AssociationSnapshot, before: RecordSnapshot?, after: RecordSnapshot?) -> void
11
14
  def initialize: (from: AssociationSnapshot, before: RecordSnapshot?, after: RecordSnapshot?) -> void
12
15
 
13
16
  # : (AssociationSnapshot) -> bool
14
17
  def from?: (AssociationSnapshot) -> bool
15
18
 
16
- @from_object_id: Integer
19
+ @from_serial: Integer
17
20
  end
18
21
  end
@@ -6,6 +6,18 @@ module PaperTrailDiff
6
6
  # : (untyped, untyped) -> void
7
7
  def self?.validate_pair!: (untyped, untyped) -> void
8
8
 
9
+ # Two versions carry no visible cue about which is earlier, so transposing
10
+ # them is easy to do by accident and impossible to detect afterwards: the
11
+ # result is a valid inverse diff and carries no direction of its own. It is
12
+ # also redundant, because the two orders differ only in which side of each
13
+ # change is `from`. A current-record endpoint is exempt: it is self-evidently
14
+ # the live state, so putting it first is a deliberate reverse comparison.
15
+ # : (untyped, untyped) -> void
16
+ def self?.validate_order!: (untyped, untyped) -> void
17
+
18
+ # : (untyped, untyped) -> bool
19
+ def self?.reversed_versions?: (untyped, untyped) -> bool
20
+
9
21
  # : (untyped) -> void
10
22
  def self?.validate!: (untyped) -> void
11
23
 
@@ -13,6 +13,10 @@ module PaperTrailDiff
13
13
  class VersionMismatchError < Error
14
14
  end
15
15
 
16
+ # Raised when comparison endpoints are given in reverse chronological order.
17
+ class ReversedEndpointsError < Error
18
+ end
19
+
16
20
  # Raised when a live endpoint is not a clean, persisted ActiveRecord record.
17
21
  class InvalidEndpointError < Error
18
22
  end
@@ -13,7 +13,7 @@ module PaperTrailDiff
13
13
 
14
14
  @habtm_transaction_id: untyped
15
15
 
16
- @reified_associations: Hash[Array[untyped], bool]
16
+ @reified_associations: Hash[untyped, Hash[untyped, bool]]
17
17
 
18
18
  @transaction_id: untyped
19
19
 
@@ -6,8 +6,8 @@ module PaperTrailDiff
6
6
  # : (tree: AssociationTree, traversal: AssociationTraversal, normalizer: SnapshotNormalizer, preparer: untyped) -> void
7
7
  def initialize: (tree: AssociationTree, traversal: AssociationTraversal, normalizer: SnapshotNormalizer, preparer: untyped) -> void
8
8
 
9
- # : (untyped, Array[untyped], ?start_at: untyped) -> void
10
- def prepare: (untyped, Array[untyped], ?start_at: untyped) -> void
9
+ # : (untyped, Array[untyped], ?start_at: untyped, ?end_at: untyped) -> void
10
+ def prepare: (untyped, Array[untyped], ?start_at: untyped, ?end_at: untyped) -> void
11
11
 
12
12
  # Prepares selected history for several roots of the same model class.
13
13
  # : (Array[untyped], Array[untyped]) -> void
@@ -19,7 +19,7 @@ module PaperTrailDiff
19
19
 
20
20
  @history: PreparedHistory
21
21
 
22
- @reified: Hash[Array[untyped], bool]
22
+ @reified: Hash[untyped, Hash[untyped, bool]]
23
23
 
24
24
  # : (untyped, untyped) -> void
25
25
  def reify_association: (untyped, untyped) -> void
@@ -64,6 +64,7 @@ module PaperTrailDiff
64
64
  # : (untyped, Array[untyped], ?owners: Hash[String, Array[untyped]]?) -> Hash[String, Hash[Symbol, untyped]]
65
65
  def group_for: (untyped, Array[untyped], ?owners: Hash[String, Array[untyped]]?) -> Hash[String, Hash[Symbol, untyped]]
66
66
 
67
+ # Keyed rather than scanned so that a wide edge stays linear in its width.
67
68
  # : (Array[Array[untyped]]) -> Hash[String, Array[untyped]]
68
69
  def group_ids_by_owner: (Array[Array[untyped]]) -> Hash[String, Array[untyped]]
69
70
 
@@ -3,8 +3,8 @@
3
3
  module PaperTrailDiff
4
4
  # Expands the explicit association tree into a request-scoped PreparedHistory.
5
5
  class PreparedHistoryLoader
6
- # : (untyped, root_versions: Array[untyped], tree: AssociationTree, traversal: AssociationTraversal, ?root_ids: Array[untyped], ?start_at: untyped, ?live_records: Array[untyped]) -> void
7
- def initialize: (untyped, root_versions: Array[untyped], tree: AssociationTree, traversal: AssociationTraversal, ?root_ids: Array[untyped], ?start_at: untyped, ?live_records: Array[untyped]) -> void
6
+ # : (untyped, root_versions: Array[untyped], tree: AssociationTree, traversal: AssociationTraversal, ?root_ids: Array[untyped], ?start_at: untyped, ?end_at: untyped, ?live_records: Array[untyped]) -> void
7
+ def initialize: (untyped, root_versions: Array[untyped], tree: AssociationTree, traversal: AssociationTraversal, ?root_ids: Array[untyped], ?start_at: untyped, ?end_at: untyped, ?live_records: Array[untyped]) -> void
8
8
 
9
9
  # : () -> PreparedHistory
10
10
  def call: () -> PreparedHistory