paper_trail_diff 0.6.0 → 0.7.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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -0
  3. data/README.md +85 -9
  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/paper_trail_adapter.rb +63 -23
  8. data/lib/paper_trail_diff/root_version_plan.rb +16 -3
  9. data/lib/paper_trail_diff/root_version_selection.rb +36 -12
  10. data/lib/paper_trail_diff/step.rb +14 -5
  11. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +83 -25
  12. data/lib/paper_trail_diff/time_version_range.rb +6 -3
  13. data/lib/paper_trail_diff/timeline_builder.rb +17 -8
  14. data/lib/paper_trail_diff/timeline_range.rb +6 -3
  15. data/lib/paper_trail_diff/timeline_snapshot_provider.rb +20 -4
  16. data/lib/paper_trail_diff/version.rb +1 -1
  17. data/lib/paper_trail_diff.rb +20 -12
  18. data/sig/generated/paper_trail_diff/activity_root_steps.rbs +7 -2
  19. data/sig/generated/paper_trail_diff/analysis_batch.rbs +6 -4
  20. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +11 -4
  21. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +26 -10
  22. data/sig/generated/paper_trail_diff/root_version_plan.rbs +12 -2
  23. data/sig/generated/paper_trail_diff/root_version_selection.rbs +13 -2
  24. data/sig/generated/paper_trail_diff/step.rbs +5 -2
  25. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +33 -12
  26. data/sig/generated/paper_trail_diff/time_version_range.rbs +4 -2
  27. data/sig/generated/paper_trail_diff/timeline_builder.rbs +5 -2
  28. data/sig/generated/paper_trail_diff/timeline_range.rbs +4 -2
  29. data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +11 -2
  30. data/sig/generated/paper_trail_diff.rbs +8 -8
  31. metadata +4 -4
@@ -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,30 +158,34 @@ 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
190
  message = 'time range requires a later activity boundary to reconstruct its final change'
133
191
  raise IncompleteTimeRangeError, message
@@ -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.0'
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.
@@ -6,6 +6,9 @@ module PaperTrailDiff
6
6
  # the only place allowed to know about both PaperTrail and the pure engine.
7
7
  # Reconstruction logic lives in the collaborators it wires together.
8
8
  class PaperTrailAdapter
9
+ # The only thing a wall-clock window can close on besides a later version.
10
+ CLOSE_ON_CURRENT: ::Symbol
11
+
9
12
  # : (associations: Array[String | Symbol], ignore: ignore_option, ?reload_live_endpoints: bool) -> void
10
13
  def initialize: (associations: Array[String | Symbol], ignore: ignore_option, ?reload_live_endpoints: bool) -> void
11
14
 
@@ -16,18 +19,18 @@ module PaperTrailDiff
16
19
  # : (Array[comparison_input]) -> comparison_results
17
20
  def compare_many: (Array[comparison_input]) -> comparison_results
18
21
 
19
- # : (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped) -> Array[Step]
20
- def timeline: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped) -> Array[Step]
22
+ # : (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?) -> Array[Step]
23
+ def timeline: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?) -> Array[Step]
21
24
 
22
- # : (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped) -> Array[ActivityStep]
23
- def activity_timeline: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped) -> Array[ActivityStep]
25
+ # : (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?) -> Array[ActivityStep]
26
+ def activity_timeline: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?) -> Array[ActivityStep]
24
27
 
25
- # : (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped) -> Analysis
26
- def analyze: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped) -> Analysis
28
+ # : (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Analysis
29
+ def analyze: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Analysis
27
30
 
28
31
  # Analyzes many roots over one shared range, preparing their history once.
29
- # : (Array[untyped], within: untyped, ?activity: bool, ?version_scope: untyped) -> Hash[identity, Analysis]
30
- def analyze_many: (Array[untyped], within: untyped, ?activity: bool, ?version_scope: untyped) -> Hash[identity, Analysis]
32
+ # : (Array[untyped], within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Hash[identity, Analysis]
33
+ def analyze_many: (Array[untyped], within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Hash[identity, Analysis]
31
34
 
32
35
  private
33
36
 
@@ -53,6 +56,19 @@ module PaperTrailDiff
53
56
 
54
57
  @traversal_preparer: TraversalPreparer
55
58
 
59
+ # : (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped) -> Analysis
60
+ def analyze_activity: (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped) -> Analysis
61
+
62
+ # `close_on:` names what ends a wall-clock window, so it is meaningless for a
63
+ # range whose endpoints the caller already gave explicitly.
64
+ # : (Symbol?, untyped) -> bool
65
+ def close_on_current?: (Symbol?, untyped) -> bool
66
+
67
+ # A destroyed root has no current state to close on, and its own destroy
68
+ # version already terminates the history.
69
+ # : (untyped, Symbol?, untyped) -> untyped
70
+ def live_endpoint_for: (untyped, Symbol?, untyped) -> untyped
71
+
56
72
  # : () -> void
57
73
  def build_snapshotters: () -> void
58
74
 
@@ -65,8 +81,8 @@ module PaperTrailDiff
65
81
  # : () -> ActivitySnapshotProvider
66
82
  def build_activity_snapshotter: () -> ActivitySnapshotProvider
67
83
 
68
- # : (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped) -> ActivityTimelineBuilder
69
- def activity_builder: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped) -> ActivityTimelineBuilder
84
+ # : (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped) -> ActivityTimelineBuilder
85
+ def activity_builder: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped) -> ActivityTimelineBuilder
70
86
 
71
87
  # : (untyped) -> void
72
88
  def reject_live_habtm_activity!: (untyped) -> void
@@ -23,6 +23,11 @@ module PaperTrailDiff
23
23
 
24
24
  attr_reader context_version: untyped
25
25
 
26
+ # The live record a window closes on when no later version can reveal its
27
+ # final mutation. It is a boundary, never a version, so it stays out of
28
+ # `versions` and out of anything that reconstructs from version history.
29
+ attr_reader closing_record: untyped
30
+
26
31
  # The root versions this plan reports as mutations, which excludes any
27
32
  # version present only to reveal what the last of them produced.
28
33
  attr_reader mutations: Array[untyped]
@@ -34,12 +39,17 @@ module PaperTrailDiff
34
39
  # : () -> RootVersionPlan
35
40
  def self.empty: () -> RootVersionPlan
36
41
 
37
- # : (versions: Array[untyped], steps: Array[[untyped, untyped]], ?context_version: untyped, ?reconstruction_versions: Array[untyped]?, ?mutations: Array[untyped]?) -> void
38
- def initialize: (versions: Array[untyped], steps: Array[[ untyped, untyped ]], ?context_version: untyped, ?reconstruction_versions: Array[untyped]?, ?mutations: Array[untyped]?) -> void
42
+ # : (versions: Array[untyped], steps: Array[[untyped, untyped]], ?context_version: untyped, ?reconstruction_versions: Array[untyped]?, ?mutations: Array[untyped]?, ?closing_record: untyped) -> void
43
+ def initialize: (versions: Array[untyped], steps: Array[[ untyped, untyped ]], ?context_version: untyped, ?reconstruction_versions: Array[untyped]?, ?mutations: Array[untyped]?, ?closing_record: untyped) -> void
39
44
 
40
45
  # : () -> bool
41
46
  def empty?: () -> bool
42
47
 
48
+ # What the range's final state is read from, which is the live record when
49
+ # the window closes on current state and the last version otherwise.
50
+ # : () -> untyped
51
+ def final_endpoint: () -> untyped
52
+
43
53
  # : (untyped) -> bool
44
54
  def mutation?: (untyped) -> bool
45
55