paper_trail_diff 0.6.0 → 0.7.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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +49 -0
  3. data/README.md +92 -11
  4. data/lib/paper_trail_diff/activity_root_steps.rb +17 -7
  5. data/lib/paper_trail_diff/analysis_batch.rb +11 -7
  6. data/lib/paper_trail_diff/batched_root_versions.rb +21 -6
  7. data/lib/paper_trail_diff/live_endpoint_provider.rb +21 -2
  8. data/lib/paper_trail_diff/paper_trail_adapter.rb +63 -23
  9. data/lib/paper_trail_diff/root_version_plan.rb +16 -3
  10. data/lib/paper_trail_diff/root_version_selection.rb +43 -14
  11. data/lib/paper_trail_diff/step.rb +14 -5
  12. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +86 -26
  13. data/lib/paper_trail_diff/time_version_range.rb +6 -3
  14. data/lib/paper_trail_diff/timeline_builder.rb +17 -8
  15. data/lib/paper_trail_diff/timeline_range.rb +6 -3
  16. data/lib/paper_trail_diff/timeline_snapshot_provider.rb +20 -4
  17. data/lib/paper_trail_diff/version.rb +1 -1
  18. data/lib/paper_trail_diff.rb +20 -12
  19. data/sig/generated/paper_trail_diff/activity_root_steps.rbs +7 -2
  20. data/sig/generated/paper_trail_diff/analysis_batch.rbs +6 -4
  21. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +11 -4
  22. data/sig/generated/paper_trail_diff/live_endpoint_provider.rbs +9 -0
  23. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +26 -10
  24. data/sig/generated/paper_trail_diff/root_version_plan.rbs +12 -2
  25. data/sig/generated/paper_trail_diff/root_version_selection.rbs +16 -2
  26. data/sig/generated/paper_trail_diff/step.rbs +5 -2
  27. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +33 -12
  28. data/sig/generated/paper_trail_diff/time_version_range.rbs +4 -2
  29. data/sig/generated/paper_trail_diff/timeline_builder.rbs +5 -2
  30. data/sig/generated/paper_trail_diff/timeline_range.rbs +4 -2
  31. data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +11 -2
  32. data/sig/generated/paper_trail_diff.rbs +8 -8
  33. metadata +4 -4
@@ -11,11 +11,17 @@ module PaperTrailDiff
11
11
  # Both the single-record and the batched selectors resolve this here, because
12
12
  # the rule is subtle enough that two copies of it would drift apart.
13
13
  class RootVersionSelection
14
- INCOMPLETE = 'time range requires a later root version to reconstruct its final change'
15
-
16
- #: (in_range: Array[untyped], selected: Array[untyped], after_range: untyped, windowed: bool, ?context_required: bool, ?filtered: bool) -> void
14
+ # An error is a specification, so it names the way out as well as the wall.
15
+ # A window ending at the present can never gain a later version, which makes
16
+ # "write a checkpoint afterwards" impossible advice on its own.
17
+ INCOMPLETE = 'time range requires a later root version to reconstruct its ' \
18
+ 'final change: pass close_on: :current to end at current state, ' \
19
+ 'or narrow the window to end before the last recorded version'
20
+
21
+ #: (in_range: Array[untyped], selected: Array[untyped], after_range: untyped, windowed: bool, ?context_required: bool, ?filtered: bool, ?live_endpoint: untyped) -> void
17
22
  def initialize( # rubocop:disable Metrics/ParameterLists
18
- in_range:, selected:, after_range:, windowed:, context_required: false, filtered: false
23
+ in_range:, selected:, after_range:, windowed:, context_required: false, filtered: false,
24
+ live_endpoint: nil
19
25
  )
20
26
  @in_range = in_range
21
27
  @selected = selected
@@ -23,18 +29,18 @@ module PaperTrailDiff
23
29
  @windowed = windowed
24
30
  @context_required = context_required
25
31
  @filtered = filtered
32
+ @live_endpoint = live_endpoint
26
33
  end
27
34
 
28
35
  #: () -> RootVersionPlan
29
36
  def call
30
37
  return without_selection if @selected.empty?
31
38
 
32
- revealing = revealing_version
33
- raise IncompleteTimeRangeError, INCOMPLETE if !revealing && @windowed && !terminal_destroy?
34
- return filtered_plan(revealing) if @filtered
39
+ closing = revealing_version || @live_endpoint
40
+ raise IncompleteTimeRangeError, INCOMPLETE if !closing && @windowed && !terminal_destroy?
41
+ return filtered_plan(closing) if @filtered
35
42
 
36
- versions = revealing ? (@selected + [revealing]) : @selected
37
- RootVersionPlan.contiguous(versions, context_version: revealing)
43
+ contiguous_plan(closing)
38
44
  end
39
45
 
40
46
  private
@@ -45,6 +51,22 @@ module PaperTrailDiff
45
51
  # @rbs @windowed: bool
46
52
  # @rbs @context_required: bool
47
53
  # @rbs @filtered: bool
54
+ # @rbs @live_endpoint: untyped
55
+
56
+ # A window reaching past the last recorded version has only the live record
57
+ # left to show what its final mutation produced. That record is a closing
58
+ # boundary rather than a selected mutation, so it never joins `versions`.
59
+ #: (untyped) -> RootVersionPlan
60
+ def contiguous_plan(closing)
61
+ return RootVersionPlan.contiguous(@selected) unless closing
62
+ unless Endpoint.record?(closing)
63
+ return RootVersionPlan.contiguous(@selected + [closing], context_version: closing)
64
+ end
65
+
66
+ steps = @selected.each_cons(2).map { |from, to| [from, to] } #: Array[[untyped, untyped]]
67
+ steps << [@selected.last, closing]
68
+ RootVersionPlan.new(versions: @selected, steps: steps, closing_record: closing)
69
+ end
48
70
 
49
71
  # An activity view still needs a root to reconstruct from even when no root
50
72
  # version falls inside the window, because descendants may have moved.
@@ -68,13 +90,20 @@ module PaperTrailDiff
68
90
  successor = version.equal?(@selected.last) ? revealing : immediate_successor(version)
69
91
  [version, successor] if successor
70
92
  end #: Array[[untyped, untyped]]
71
- versions = chronological(steps.flatten(1) + closing_versions)
93
+ filtered_plan_for(steps, revealing)
94
+ end
95
+
96
+ #: (Array[[untyped, untyped]], untyped) -> RootVersionPlan
97
+ def filtered_plan_for(steps, revealing)
98
+ live = revealing if Endpoint.record?(revealing)
99
+ versions = chronological(
100
+ (steps.flatten(1) + closing_versions).reject { |entry| Endpoint.record?(entry) }
101
+ )
72
102
  RootVersionPlan.new(
73
- versions: versions,
74
- steps: steps,
75
- context_version: revealing,
103
+ versions: versions, steps: steps,
104
+ context_version: (revealing unless live),
76
105
  reconstruction_versions: spanned(versions),
77
- mutations: @selected
106
+ mutations: @selected, closing_record: live
78
107
  )
79
108
  end
80
109
 
@@ -10,12 +10,20 @@ module PaperTrailDiff
10
10
  attr_reader :to_boundary #: ActivityBoundary
11
11
  attr_reader :diff #: Diff
12
12
 
13
- #: (from_version: untyped, to_version: untyped, diff: Diff) -> void
14
- def initialize(from_version:, to_version:, diff:)
13
+ # `to_version` is nil for the one step that closes on current state, which a
14
+ # window reaching past the last recorded version has to do. Read
15
+ # `to_boundary` instead when a caller may have opted into that.
16
+ #: (from_version: untyped, to_version: untyped, diff: Diff, ?captured_at: untyped) -> void
17
+ def initialize(from_version:, to_version:, diff:, captured_at: nil)
18
+ live = Endpoint.record?(to_version)
15
19
  @from_version = from_version
16
- @to_version = to_version
20
+ @to_version = (to_version unless live)
17
21
  @from_boundary = ActivityBoundary.from_version(from_version)
18
- @to_boundary = ActivityBoundary.from_version(to_version)
22
+ @to_boundary = if live
23
+ ActivityBoundary.current(to_version, captured_at: captured_at)
24
+ else
25
+ ActivityBoundary.from_version(to_version)
26
+ end
19
27
  @diff = diff
20
28
  freeze
21
29
  end
@@ -29,7 +37,8 @@ module PaperTrailDiff
29
37
  def to_h
30
38
  {
31
39
  from_version_id: from_version.id,
32
- to_version_id: to_version.id,
40
+ to_version_id: to_version&.id,
41
+ to_boundary: to_boundary.to_h,
33
42
  diff: diff.to_h
34
43
  }
35
44
  end
@@ -14,19 +14,23 @@ module PaperTrailDiff
14
14
 
15
15
  #: () -> Array[ActivityStep]
16
16
  def build
17
- history, _plan, closing = history_and_versions
17
+ history, _plan, closing, = history_and_versions
18
18
  activity_steps(history, closing)
19
19
  end
20
20
 
21
21
  #: () -> Analysis
22
22
  def analyze
23
- history, plan, closing = history_and_versions
23
+ history, plan, closing, closing_snapshot, captured_at = history_and_versions
24
+ final = closing_snapshot || history.last_snapshot
24
25
  Analysis.new(
25
- diff: Engine.compare(history.first_snapshot, history.last_snapshot),
26
- timeline: ActivityRootSteps.call(plan, history.root_snapshots),
26
+ diff: Engine.compare(history.first_snapshot, final),
27
+ timeline: ActivityRootSteps.call(
28
+ plan, history.root_snapshots,
29
+ closing_snapshot: closing_snapshot, captured_at: captured_at
30
+ ),
27
31
  activity_timeline: activity_steps(history, closing),
28
32
  from_snapshot: history.first_snapshot,
29
- to_snapshot: history.last_snapshot
33
+ to_snapshot: final
30
34
  )
31
35
  end
32
36
 
@@ -37,19 +41,46 @@ module PaperTrailDiff
37
41
  # @rbs @tree: AssociationTree
38
42
  # @rbs @snapshotter: untyped
39
43
 
40
- #: () -> [ActivityHistory, RootVersionPlan, ActivityStep?]
44
+ #: () -> [ActivityHistory, RootVersionPlan, ActivityStep?, RecordSnapshot?, untyped]
41
45
  def history_and_versions
42
46
  plan = @range.select_plan(context_required: !@tree.empty?)
43
- root_versions = plan.reconstruction_versions
44
- return [ActivityHistory.empty, plan, nil] if root_versions.empty?
47
+ return [ActivityHistory.empty, plan, nil, nil, nil] if plan.reconstruction_versions.empty?
48
+
49
+ # A window closing on current state runs to the instant it is captured,
50
+ # not to the last root version, or descendants that moved after that
51
+ # version would be missing from a report that claims to reach now.
52
+ history_for(plan, plan.closing_record ? Time.now.utc : nil)
53
+ end
45
54
 
46
- prepare_history(root_versions)
47
- events = collect_events(root_versions)
55
+ #: (RootVersionPlan, untyped) -> [ActivityHistory, RootVersionPlan, ActivityStep?, RecordSnapshot?, untyped]
56
+ def history_for(plan, captured_at)
57
+ root_versions = plan.reconstruction_versions
58
+ events = events_through(root_versions, captured_at)
48
59
  selected = selected_events(events, plan)
49
- return [ActivityHistory.empty, plan, nil] unless time_events?(selected, events)
60
+ unless time_events?(selected, events, plan)
61
+ return [ActivityHistory.empty, plan, nil, nil, nil]
62
+ end
50
63
 
51
64
  history = build_history(root_versions, events)
52
- [history, plan, closing_step(history, selected.last)]
65
+ snapshot = current_snapshot(plan)
66
+ closing = closing_step(history, selected.last, plan, captured_at, snapshot)
67
+ [history, plan, closing, snapshot, captured_at]
68
+ end
69
+
70
+ # One live read serves both the closing activity step and the endpoint the
71
+ # analysis reports, which would otherwise reconstruct the graph twice.
72
+ #: (RootVersionPlan) -> RecordSnapshot?
73
+ def current_snapshot(plan)
74
+ record = plan.closing_record
75
+ return unless record
76
+
77
+ @snapshotter.call(record, record)
78
+ end
79
+
80
+ #: (Array[untyped], untyped) -> Array[ActivityEvent]
81
+ def events_through(root_versions, captured_at)
82
+ prepare_history(root_versions, end_at: captured_at)
83
+ collect_events(root_versions, range_end: captured_at)
53
84
  end
54
85
 
55
86
  #: (Array[untyped], Array[ActivityEvent]) -> ActivityHistory
@@ -83,12 +114,20 @@ module PaperTrailDiff
83
114
  end
84
115
  end
85
116
 
86
- # The window's last selected mutation is the root's own destruction, so the
87
- # timeline closes on the absence it leaves rather than on a later boundary.
88
- #: (ActivityHistory, ActivityEvent?) -> ActivityStep?
89
- def closing_step(history, event)
90
- return unless event && terminal_destroy?(event)
117
+ # A window either closes on the absence its final destruction leaves, or on
118
+ # current state when it reaches past everything recorded. Both are boundaries
119
+ # no version can supply.
120
+ #: (ActivityHistory, ActivityEvent?, RootVersionPlan, untyped, RecordSnapshot?) -> ActivityStep?
121
+ def closing_step(history, event, plan, captured_at, snapshot)
122
+ return unless event
123
+ return destroyed_step(history, event) if terminal_destroy?(event)
124
+ return unless plan.closing_record
125
+
126
+ current_step(history, plan.closing_record, captured_at, snapshot)
127
+ end
91
128
 
129
+ #: (ActivityHistory, ActivityEvent) -> ActivityStep
130
+ def destroyed_step(history, event)
92
131
  version = event.version
93
132
  ActivityStep.new(
94
133
  from_boundary: ActivityBoundary.from_version(version),
@@ -97,6 +136,21 @@ module PaperTrailDiff
97
136
  )
98
137
  end
99
138
 
139
+ # The last boundary reached is where current state is compared from, which
140
+ # is the final event rather than the final root version once descendants
141
+ # have moved since it.
142
+ #: (ActivityHistory, untyped, untyped, RecordSnapshot?) -> ActivityStep?
143
+ def current_step(history, record, captured_at, snapshot)
144
+ previous = history.steps.last&.to_boundary
145
+ return unless previous
146
+
147
+ ActivityStep.new(
148
+ from_boundary: previous,
149
+ to_boundary: ActivityBoundary.current(record, captured_at: captured_at),
150
+ diff: Engine.compare(history.last_snapshot, snapshot)
151
+ )
152
+ end
153
+
100
154
  #: (ActivityEvent?) -> bool
101
155
  def terminal_destroy?(event)
102
156
  return false unless event
@@ -104,32 +158,38 @@ module PaperTrailDiff
104
158
  event.root? && event.version.event.to_s == 'destroy'
105
159
  end
106
160
 
107
- #: (Array[untyped]) -> void
108
- def prepare_history(root_versions)
161
+ #: (Array[untyped], ?end_at: untyped) -> void
162
+ def prepare_history(root_versions, end_at: nil)
109
163
  return unless @snapshotter.respond_to?(:prepare)
110
164
 
111
- @snapshotter.prepare(@record, root_versions, start_at: @range.begin_time)
165
+ start_at = @range.begin_time
166
+ return @snapshotter.prepare(@record, root_versions, start_at: start_at) unless end_at
167
+
168
+ @snapshotter.prepare(@record, root_versions, start_at: start_at, end_at: end_at)
112
169
  end
113
170
 
114
- #: (Array[untyped]) -> Array[ActivityEvent]
115
- def collect_events(root_versions)
171
+ #: (Array[untyped], ?range_end: untyped) -> Array[ActivityEvent]
172
+ def collect_events(root_versions, range_end: nil)
116
173
  ActivityVersionCollector.new(
117
174
  @record,
118
175
  root_versions: root_versions,
119
176
  tree: @tree,
120
177
  traversal: AssociationTraversal.new(@tree),
121
178
  range_start: @range.begin_time,
122
- range_end: root_versions.last
179
+ range_end: range_end || root_versions.last
123
180
  ).call
124
181
  end
125
182
 
126
- #: (Array[ActivityEvent], Array[ActivityEvent]) -> bool
127
- def time_events?(selected, events)
183
+ #: (Array[ActivityEvent], Array[ActivityEvent], RootVersionPlan) -> bool
184
+ def time_events?(selected, events, plan)
128
185
  return false if selected.empty?
129
186
  return true if later_event?(selected.last, events)
130
187
  return true if terminal_destroy?(selected.last)
188
+ return true if plan.closing_record
131
189
 
132
- message = 'time range requires a later activity boundary to reconstruct its final change'
190
+ message = 'time range requires a later activity boundary to reconstruct its ' \
191
+ 'final change: pass close_on: :current to end at current state, ' \
192
+ 'or narrow the window to end before the last recorded boundary'
133
193
  raise IncompleteTimeRangeError, message
134
194
  end
135
195
 
@@ -4,11 +4,12 @@
4
4
  module PaperTrailDiff
5
5
  # Selects in-range root versions plus one later reconstruction boundary.
6
6
  class TimeVersionRange
7
- #: (untyped, time_range: TimeRange, ?version_scope: untyped) -> void
8
- def initialize(record, time_range:, version_scope: nil)
7
+ #: (untyped, time_range: TimeRange, ?version_scope: untyped, ?live_endpoint: untyped) -> void
8
+ def initialize(record, time_range:, version_scope: nil, live_endpoint: nil)
9
9
  @record = record
10
10
  @time_range = time_range
11
11
  @version_scope = version_scope
12
+ @live_endpoint = live_endpoint
12
13
  end
13
14
 
14
15
  #: (?context_required: bool) -> Array[untyped]
@@ -27,7 +28,8 @@ module PaperTrailDiff
27
28
  after_range: trailing_version(relation),
28
29
  windowed: true,
29
30
  context_required: context_required,
30
- filtered: !@version_scope.nil?
31
+ filtered: !@version_scope.nil?,
32
+ live_endpoint: @live_endpoint
31
33
  ).call
32
34
  end
33
35
 
@@ -36,6 +38,7 @@ module PaperTrailDiff
36
38
  # @rbs @record: untyped
37
39
  # @rbs @time_range: TimeRange
38
40
  # @rbs @version_scope: untyped
41
+ # @rbs @live_endpoint: untyped
39
42
 
40
43
  #: () -> untyped
41
44
  def versions_relation
@@ -4,14 +4,16 @@
4
4
  module PaperTrailDiff
5
5
  # Selects and compares a chronological slice of a record's version history.
6
6
  class TimelineBuilder
7
- #: (untyped, from: untyped, to: untyped, snapshotter: untyped, ?within: untyped, ?versions: Array[untyped]?, ?version_scope: untyped, ?plan: RootVersionPlan?) -> void
7
+ #: (untyped, from: untyped, to: untyped, snapshotter: untyped, ?within: untyped, ?versions: Array[untyped]?, ?version_scope: untyped, ?plan: RootVersionPlan?, ?live_endpoint: untyped) -> void
8
8
  def initialize( # rubocop:disable Metrics/ParameterLists
9
- record, from:, to:, snapshotter:, within: nil, versions: nil, version_scope: nil, plan: nil
9
+ record, from:, to:, snapshotter:, within: nil, versions: nil, version_scope: nil, plan: nil,
10
+ live_endpoint: nil
10
11
  )
11
12
  @record = record
12
13
  @range = TimelineRange.new(
13
14
  record, from: from, to: to, within: within,
14
- versions: versions, version_scope: version_scope, plan: plan
15
+ versions: versions, version_scope: version_scope, plan: plan,
16
+ live_endpoint: live_endpoint
15
17
  )
16
18
  @snapshotter = snapshotter
17
19
  end
@@ -45,14 +47,21 @@ module PaperTrailDiff
45
47
 
46
48
  versions = plan.versions
47
49
  @snapshotter.prepare(@record, versions) if @snapshotter.respond_to?(:prepare)
48
- steps = plan.steps.map do |from_version, to_version|
50
+ steps = build_steps(plan)
51
+ [steps, @snapshotter.call(versions.first), @snapshotter.call(plan.final_endpoint)]
52
+ end
53
+
54
+ #: (RootVersionPlan) -> Array[Step]
55
+ def build_steps(plan)
56
+ captured_at = Time.now.utc if plan.closing_record
57
+ plan.steps.map do |from_endpoint, to_endpoint|
49
58
  Step.new(
50
- from_version: from_version,
51
- to_version: to_version,
52
- diff: Engine.compare(@snapshotter.call(from_version), @snapshotter.call(to_version))
59
+ from_version: from_endpoint,
60
+ to_version: to_endpoint,
61
+ captured_at: captured_at,
62
+ diff: Engine.compare(@snapshotter.call(from_endpoint), @snapshotter.call(to_endpoint))
53
63
  )
54
64
  end.freeze
55
- [steps, @snapshotter.call(versions.first), @snapshotter.call(versions.last)]
56
65
  end
57
66
 
58
67
  #: () -> [Array[Step], nil, nil]
@@ -10,10 +10,11 @@ module PaperTrailDiff
10
10
  attr_reader :to #: untyped
11
11
  attr_reader :time_range #: TimeRange?
12
12
 
13
- #: (untyped, from: untyped, to: untyped, within: untyped, ?versions: Array[untyped]?, ?version_scope: untyped, ?plan: RootVersionPlan?) -> void
14
- def initialize(record, from:, to:, within:, versions: nil, version_scope: nil, plan: nil) # rubocop:disable Metrics/ParameterLists
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
15
15
  @record = record
16
16
  @plan = plan
17
+ @live_endpoint = live_endpoint
17
18
  @versions = (plan ? plan.versions : versions)&.freeze
18
19
  @version_scope = version_scope
19
20
  @requested_from = from
@@ -44,7 +45,8 @@ module PaperTrailDiff
44
45
  range = time_range
45
46
  if range
46
47
  return TimeVersionRange.new(
47
- @record, time_range: range, version_scope: @version_scope
48
+ @record, time_range: range, version_scope: @version_scope,
49
+ live_endpoint: @live_endpoint
48
50
  ).select_plan(context_required: context_required)
49
51
  end
50
52
  return RootVersionPlan.empty if unresolved?
@@ -92,6 +94,7 @@ module PaperTrailDiff
92
94
  # @rbs @versions: Array[untyped]?
93
95
  # @rbs @plan: RootVersionPlan?
94
96
  # @rbs @version_scope: untyped
97
+ # @rbs @live_endpoint: untyped
95
98
  # @rbs @requested_from: untyped
96
99
  # @rbs @requested_to: untyped
97
100
  # @rbs @from: untyped
@@ -4,9 +4,10 @@
4
4
  module PaperTrailDiff
5
5
  # Gives TimelineBuilder one-argument access to a range-prepared snapshot store.
6
6
  class TimelineSnapshotProvider
7
- #: (HistoricalSnapshotStore) -> void
8
- def initialize(store)
7
+ #: (HistoricalSnapshotStore, ?live_snapshotter: untyped) -> void
8
+ def initialize(store, live_snapshotter: nil)
9
9
  @store = store
10
+ @live_snapshotter = live_snapshotter
10
11
  end
11
12
 
12
13
  #: (untyped, Array[untyped]) -> void
@@ -14,11 +15,26 @@ module PaperTrailDiff
14
15
  @store.prepare(record, versions)
15
16
  end
16
17
 
18
+ # A window closing on current state ends at the live record rather than at a
19
+ # version, which is the one endpoint no version history can reconstruct.
17
20
  #: (untyped) -> RecordSnapshot?
18
- def call(version)
19
- @store.uncached(version, version)
21
+ def call(endpoint)
22
+ return live_snapshot(endpoint) if Endpoint.record?(endpoint)
23
+
24
+ @store.uncached(endpoint, endpoint)
25
+ end
26
+
27
+ private
28
+
29
+ #: (untyped) -> RecordSnapshot?
30
+ def live_snapshot(record)
31
+ snapshotter = @live_snapshotter
32
+ raise InvalidTimelineRangeError, 'live endpoints are unavailable here' unless snapshotter
33
+
34
+ snapshotter.call(record)
20
35
  end
21
36
 
22
37
  # @rbs @store: HistoricalSnapshotStore
38
+ # @rbs @live_snapshotter: untyped
23
39
  end
24
40
  end
@@ -2,5 +2,5 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module PaperTrailDiff
5
- VERSION = '0.6.0'
5
+ VERSION = '0.7.1'
6
6
  end
@@ -145,7 +145,7 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
145
145
  end
146
146
 
147
147
  # Compares every adjacent reconstructed state in an inclusive version range.
148
- #: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?version_scope: untyped) -> Array[Step]
148
+ #: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?version_scope: untyped, ?close_on: Symbol?) -> Array[Step]
149
149
  def timeline( # rubocop:disable Metrics/ParameterLists
150
150
  record,
151
151
  from: nil,
@@ -153,21 +153,23 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
153
153
  within: nil,
154
154
  associations: [],
155
155
  ignore: DEFAULT_IGNORED_ATTRIBUTES,
156
- version_scope: nil
156
+ version_scope: nil,
157
+ close_on: nil
157
158
  )
158
159
  PaperTrailAdapter.new(associations: associations, ignore: ignore).timeline(
159
160
  record,
160
161
  from: from,
161
162
  to: to,
162
163
  within: within,
163
- version_scope: version_scope
164
+ version_scope: version_scope,
165
+ close_on: close_on
164
166
  )
165
167
  end
166
168
 
167
169
  # Compares adjacent root and selected-descendant activity boundaries.
168
170
  # `reload_live_endpoints:` applies only when `to:` is a current record; the
169
171
  # other range forms never read live state.
170
- #: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool, ?version_scope: untyped) -> Array[ActivityStep]
172
+ #: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Array[ActivityStep]
171
173
  def activity_timeline( # rubocop:disable Metrics/ParameterLists
172
174
  record,
173
175
  from: nil,
@@ -176,7 +178,8 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
176
178
  associations: [],
177
179
  ignore: DEFAULT_IGNORED_ATTRIBUTES,
178
180
  reload_live_endpoints: true,
179
- version_scope: nil
181
+ version_scope: nil,
182
+ close_on: nil
180
183
  )
181
184
  PaperTrailAdapter.new(
182
185
  associations: associations,
@@ -187,12 +190,13 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
187
190
  from: from,
188
191
  to: to,
189
192
  within: within,
190
- version_scope: version_scope
193
+ version_scope: version_scope,
194
+ close_on: close_on
191
195
  )
192
196
  end
193
197
 
194
198
  # Builds an endpoint diff and root-checkpoint timeline while normalizing each version once.
195
- #: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped) -> Analysis
199
+ #: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Analysis
196
200
  def analyze( # rubocop:disable Metrics/ParameterLists
197
201
  record,
198
202
  from: nil,
@@ -201,7 +205,8 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
201
205
  associations: [],
202
206
  ignore: DEFAULT_IGNORED_ATTRIBUTES,
203
207
  activity: false,
204
- version_scope: nil
208
+ version_scope: nil,
209
+ close_on: nil
205
210
  )
206
211
  PaperTrailAdapter.new(associations: associations, ignore: ignore).analyze(
207
212
  record,
@@ -209,27 +214,30 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
209
214
  to: to,
210
215
  within: within,
211
216
  activity: activity,
212
- version_scope: version_scope
217
+ version_scope: version_scope,
218
+ close_on: close_on
213
219
  )
214
220
  end
215
221
 
216
222
  # Analyzes many roots over one shared time window, preparing their selected
217
223
  # history once for the batch instead of once per record. Roots with no
218
224
  # versions in the window return an empty `Analysis`.
219
- #: (Array[untyped], ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped) -> Hash[identity, Analysis]
225
+ #: (Array[untyped], ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Hash[identity, Analysis]
220
226
  def analyze_many( # rubocop:disable Metrics/ParameterLists
221
227
  records,
222
228
  within: nil,
223
229
  associations: [],
224
230
  ignore: DEFAULT_IGNORED_ATTRIBUTES,
225
231
  activity: false,
226
- version_scope: nil
232
+ version_scope: nil,
233
+ close_on: nil
227
234
  )
228
235
  PaperTrailAdapter.new(associations: associations, ignore: ignore).analyze_many(
229
236
  records,
230
237
  within: within,
231
238
  activity: activity,
232
- version_scope: version_scope
239
+ version_scope: version_scope,
240
+ close_on: close_on
233
241
  )
234
242
  end
235
243
 
@@ -4,8 +4,13 @@ module PaperTrailDiff
4
4
  # Root checkpoint steps recovered from the snapshots an activity pass retained,
5
5
  # so a combined result does not reconstruct the same boundaries twice.
6
6
  module ActivityRootSteps
7
- # : (RootVersionPlan, Hash[Array[untyped], RecordSnapshot?]) -> Array[Step]
8
- def self?.call: (RootVersionPlan, Hash[Array[untyped], RecordSnapshot?]) -> Array[Step]
7
+ # A window closing on current state ends at the live record, which no
8
+ # version-keyed snapshot can supply, so that endpoint is passed in.
9
+ # : (RootVersionPlan, Hash[Array[untyped], RecordSnapshot?], ?closing_snapshot: RecordSnapshot?, ?captured_at: untyped) -> Array[Step]
10
+ def self?.call: (RootVersionPlan, Hash[Array[untyped], RecordSnapshot?], ?closing_snapshot: RecordSnapshot?, ?captured_at: untyped) -> Array[Step]
11
+
12
+ # : (untyped, Hash[Array[untyped], RecordSnapshot?], RecordSnapshot?) -> RecordSnapshot?
13
+ def self?.snapshot_for: (untyped, Hash[Array[untyped], RecordSnapshot?], RecordSnapshot?) -> RecordSnapshot?
9
14
 
10
15
  # : (untyped) -> Array[untyped]
11
16
  def self?.version_key: (untyped) -> Array[untyped]
@@ -5,8 +5,8 @@ module PaperTrailDiff
5
5
  # versions and preparing their association history once for the whole batch
6
6
  # rather than once per root.
7
7
  class AnalysisBatch
8
- # : (Array[untyped], time_range: TimeRange?, live_loader: untyped, history_preparer: untyped, analyzer: untyped, ?version_scope: untyped) -> void
9
- def initialize: (Array[untyped], time_range: TimeRange?, live_loader: untyped, history_preparer: untyped, analyzer: untyped, ?version_scope: untyped) -> void
8
+ # : (Array[untyped], time_range: TimeRange?, live_loader: untyped, history_preparer: untyped, analyzer: untyped, ?version_scope: untyped, ?close_on_current: bool) -> void
9
+ def initialize: (Array[untyped], time_range: TimeRange?, live_loader: untyped, history_preparer: untyped, analyzer: untyped, ?version_scope: untyped, ?close_on_current: bool) -> void
10
10
 
11
11
  # : () -> Hash[identity, Analysis]
12
12
  def call: () -> Hash[identity, Analysis]
@@ -15,6 +15,8 @@ module PaperTrailDiff
15
15
 
16
16
  @analyzer: untyped
17
17
 
18
+ @close_on_current: bool
19
+
18
20
  @history_preparer: untyped
19
21
 
20
22
  @live_loader: untyped
@@ -38,8 +40,8 @@ module PaperTrailDiff
38
40
  # The roots are preloaded first, because preparation reads their current
39
41
  # association state as a fallback and would otherwise walk it one root at a
40
42
  # time.
41
- # : (Array[untyped], Hash[Array[String], RootVersionPlan]) -> void
42
- def prepare: (Array[untyped], Hash[Array[String], RootVersionPlan]) -> void
43
+ # : (Array[untyped], Hash[Array[String], RootVersionPlan], Hash[Array[String], untyped]) -> void
44
+ def prepare: (Array[untyped], Hash[Array[String], RootVersionPlan], Hash[Array[String], untyped]) -> void
43
45
 
44
46
  # A root with no versions in range has nothing to report, which is an empty
45
47
  # result rather than a failed request.
@@ -5,8 +5,8 @@ module PaperTrailDiff
5
5
  # queries. Only the range forms that mean the same thing for every root are
6
6
  # supported: a shared wall-clock window, or each root's own whole history.
7
7
  class BatchedRootVersions
8
- # : (Array[untyped], time_range: TimeRange?, ?version_scope: untyped) -> void
9
- def initialize: (Array[untyped], time_range: TimeRange?, ?version_scope: untyped) -> void
8
+ # : (Array[untyped], time_range: TimeRange?, ?version_scope: untyped, ?live_endpoints: Hash[Array[String], untyped]?) -> void
9
+ def initialize: (Array[untyped], time_range: TimeRange?, ?version_scope: untyped, ?live_endpoints: Hash[Array[String], untyped]?) -> void
10
10
 
11
11
  # Returns a plan per record identity.
12
12
  # : () -> Hash[Array[String], RootVersionPlan]
@@ -14,6 +14,8 @@ module PaperTrailDiff
14
14
 
15
15
  private
16
16
 
17
+ @live_endpoints: Hash[Array[String], untyped]?
18
+
17
19
  @records: Array[untyped]
18
20
 
19
21
  @time_range: TimeRange?
@@ -26,8 +28,13 @@ module PaperTrailDiff
26
28
  # : (untyped, Array[untyped]) -> [Hash[String, Array[untyped]], Set[untyped]?, Hash[String, untyped]]
27
29
  def model_versions: (untyped, Array[untyped]) -> [ Hash[String, Array[untyped]], Set[untyped]?, Hash[String, untyped] ]
28
30
 
29
- # : (Array[untyped], Set[untyped]?, untyped) -> RootVersionPlan
30
- def versions_for: (Array[untyped], Set[untyped]?, untyped) -> RootVersionPlan
31
+ # : (Array[untyped], Set[untyped]?, untyped, untyped) -> RootVersionPlan
32
+ def versions_for: (Array[untyped], Set[untyped]?, untyped, untyped) -> RootVersionPlan
33
+
34
+ # The batch already loaded every root, so closing on current state reuses
35
+ # that rather than reading each record again.
36
+ # : (Array[String], untyped) -> untyped
37
+ def live_endpoint: (Array[String], untyped) -> untyped
31
38
 
32
39
  # One extra query names the selected mutations without discarding the
33
40
  # unfiltered versions the successor lookup still needs.