paper_trail_diff 0.5.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +52 -0
- data/README.md +146 -7
- data/lib/paper_trail_diff/activity_root_steps.rb +19 -7
- data/lib/paper_trail_diff/activity_timeline_builder.rb +15 -8
- data/lib/paper_trail_diff/analysis.rb +19 -5
- data/lib/paper_trail_diff/analysis_batch.rb +35 -13
- data/lib/paper_trail_diff/batched_root_analyzer.rb +9 -9
- data/lib/paper_trail_diff/batched_root_versions.rb +62 -29
- data/lib/paper_trail_diff/paper_trail_adapter.rb +72 -25
- data/lib/paper_trail_diff/root_version_plan.rb +99 -0
- data/lib/paper_trail_diff/root_version_selection.rb +155 -0
- data/lib/paper_trail_diff/step.rb +14 -5
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +99 -29
- data/lib/paper_trail_diff/time_version_range.rb +22 -24
- data/lib/paper_trail_diff/timeline_builder.rb +32 -20
- data/lib/paper_trail_diff/timeline_range.rb +34 -7
- data/lib/paper_trail_diff/timeline_snapshot_provider.rb +20 -4
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_range.rb +45 -5
- data/lib/paper_trail_diff/version_scope_filter.rb +37 -0
- data/lib/paper_trail_diff.rb +32 -13
- data/sig/generated/paper_trail_diff/activity_root_steps.rbs +7 -2
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +6 -2
- data/sig/generated/paper_trail_diff/analysis.rbs +13 -4
- data/sig/generated/paper_trail_diff/analysis_batch.rbs +15 -6
- data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +4 -4
- data/sig/generated/paper_trail_diff/batched_root_versions.rbs +28 -14
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +26 -10
- data/sig/generated/paper_trail_diff/root_version_plan.rbs +66 -0
- data/sig/generated/paper_trail_diff/root_version_selection.rbs +88 -0
- data/sig/generated/paper_trail_diff/step.rbs +5 -2
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +41 -15
- data/sig/generated/paper_trail_diff/time_version_range.rbs +9 -9
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +9 -6
- data/sig/generated/paper_trail_diff/timeline_range.rbs +13 -2
- data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +11 -2
- data/sig/generated/paper_trail_diff/version_range.rbs +17 -2
- data/sig/generated/paper_trail_diff/version_scope_filter.rbs +22 -0
- data/sig/generated/paper_trail_diff.rbs +8 -8
- metadata +10 -4
|
@@ -7,6 +7,9 @@ module PaperTrailDiff
|
|
|
7
7
|
# the only place allowed to know about both PaperTrail and the pure engine.
|
|
8
8
|
# Reconstruction logic lives in the collaborators it wires together.
|
|
9
9
|
class PaperTrailAdapter # rubocop:disable Metrics/ClassLength
|
|
10
|
+
# The only thing a wall-clock window can close on besides a later version.
|
|
11
|
+
CLOSE_ON_CURRENT = :current
|
|
12
|
+
|
|
10
13
|
#: (associations: Array[String | Symbol], ignore: ignore_option, ?reload_live_endpoints: bool) -> void
|
|
11
14
|
def initialize(associations:, ignore:, reload_live_endpoints: true)
|
|
12
15
|
@association_tree = AssociationTree.build(associations)
|
|
@@ -50,57 +53,64 @@ module PaperTrailDiff
|
|
|
50
53
|
end
|
|
51
54
|
end
|
|
52
55
|
|
|
53
|
-
#: (untyped, from: untyped, to: untyped, within: untyped) -> Array[Step]
|
|
54
|
-
def timeline(record, from:, to:, within:)
|
|
56
|
+
#: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?) -> Array[Step]
|
|
57
|
+
def timeline(record, from:, to:, within:, version_scope: nil, close_on: nil) # rubocop:disable Metrics/ParameterLists
|
|
55
58
|
@traversal_preparer.call(record.class, historical: true)
|
|
56
|
-
|
|
59
|
+
TimelineBuilder.new(
|
|
57
60
|
record,
|
|
58
61
|
from: from,
|
|
59
62
|
to: to,
|
|
60
63
|
within: within,
|
|
64
|
+
version_scope: version_scope,
|
|
65
|
+
live_endpoint: live_endpoint_for(record, close_on, within),
|
|
61
66
|
snapshotter: @timeline_snapshotter
|
|
62
|
-
)
|
|
63
|
-
builder.build
|
|
67
|
+
).build
|
|
64
68
|
end
|
|
65
69
|
|
|
66
|
-
#: (untyped, from: untyped, to: untyped, within: untyped) -> Array[ActivityStep]
|
|
67
|
-
def activity_timeline(record, from:, to:, within:)
|
|
70
|
+
#: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?) -> Array[ActivityStep]
|
|
71
|
+
def activity_timeline(record, from:, to:, within:, version_scope: nil, close_on: nil) # rubocop:disable Metrics/ParameterLists
|
|
68
72
|
payload = @instrumentation_payload.merge(model_type: record.class.base_class.name.to_s)
|
|
69
73
|
Instrumentation.instrument('activity_timeline', payload) do
|
|
70
74
|
@traversal_preparer.call(record.class, historical: true)
|
|
71
|
-
|
|
72
|
-
|
|
75
|
+
live = live_endpoint_for(record, close_on, within)
|
|
76
|
+
reject_live_habtm_activity!(record.class) if Endpoint.record?(to) || live
|
|
77
|
+
steps = activity_builder(
|
|
78
|
+
record, from: from, to: to, within: within, version_scope: version_scope,
|
|
79
|
+
live_endpoint: live
|
|
80
|
+
).build
|
|
73
81
|
payload[:step_count] = steps.length
|
|
74
82
|
steps
|
|
75
83
|
end
|
|
76
84
|
end
|
|
77
85
|
|
|
78
|
-
#: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool) -> Analysis
|
|
79
|
-
def analyze(record, from:, to:, within:, activity: false)
|
|
86
|
+
#: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Analysis
|
|
87
|
+
def analyze(record, from:, to:, within:, activity: false, version_scope: nil, close_on: nil) # rubocop:disable Metrics/ParameterLists
|
|
88
|
+
@traversal_preparer.call(record.class, historical: true)
|
|
89
|
+
live = live_endpoint_for(record, close_on, within)
|
|
80
90
|
if activity
|
|
81
|
-
|
|
82
|
-
|
|
91
|
+
return analyze_activity(
|
|
92
|
+
record, from: from, to: to, within: within,
|
|
93
|
+
version_scope: version_scope, live_endpoint: live
|
|
94
|
+
)
|
|
83
95
|
end
|
|
84
96
|
|
|
85
|
-
@traversal_preparer.call(record.class, historical: true)
|
|
86
97
|
TimelineBuilder.new(
|
|
87
|
-
record,
|
|
88
|
-
|
|
89
|
-
to: to,
|
|
90
|
-
within: within,
|
|
91
|
-
snapshotter: @timeline_snapshotter
|
|
98
|
+
record, from: from, to: to, within: within, version_scope: version_scope,
|
|
99
|
+
live_endpoint: live, snapshotter: @timeline_snapshotter
|
|
92
100
|
).analyze
|
|
93
101
|
end
|
|
94
102
|
|
|
95
103
|
# Analyzes many roots over one shared range, preparing their history once.
|
|
96
|
-
#: (Array[untyped], within: untyped, ?activity: bool) -> Hash[identity, Analysis]
|
|
97
|
-
def analyze_many(records, within:, activity: false)
|
|
104
|
+
#: (Array[untyped], within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Hash[identity, Analysis]
|
|
105
|
+
def analyze_many(records, within:, activity: false, version_scope: nil, close_on: nil)
|
|
98
106
|
count = records.is_a?(Array) ? records.length : 0
|
|
99
107
|
payload = @instrumentation_payload.merge(comparison_count: count)
|
|
100
108
|
Instrumentation.instrument('analyze_many', payload) do
|
|
101
109
|
AnalysisBatch.new(
|
|
102
110
|
records,
|
|
103
111
|
time_range: within.nil? ? nil : TimeRange.new(within),
|
|
112
|
+
version_scope: version_scope,
|
|
113
|
+
close_on_current: close_on_current?(close_on, within),
|
|
104
114
|
live_loader: @live_endpoints.method(:call),
|
|
105
115
|
history_preparer: @historical_store.method(:prepare_batch),
|
|
106
116
|
analyzer: batched_root_analyzer(activity)
|
|
@@ -122,10 +132,44 @@ module PaperTrailDiff
|
|
|
122
132
|
# @rbs @timeline_snapshotter: TimelineSnapshotProvider
|
|
123
133
|
# @rbs @activity_snapshotter: ActivitySnapshotProvider
|
|
124
134
|
|
|
135
|
+
#: (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped) -> Analysis
|
|
136
|
+
def analyze_activity(record, from:, to:, within:, version_scope:, live_endpoint:) # rubocop:disable Metrics/ParameterLists
|
|
137
|
+
reject_live_habtm_activity!(record.class) if live_endpoint
|
|
138
|
+
activity_builder(
|
|
139
|
+
record, from: from, to: to, within: within,
|
|
140
|
+
version_scope: version_scope, live_endpoint: live_endpoint
|
|
141
|
+
).analyze
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# `close_on:` names what ends a wall-clock window, so it is meaningless for a
|
|
145
|
+
# range whose endpoints the caller already gave explicitly.
|
|
146
|
+
#: (Symbol?, untyped) -> bool
|
|
147
|
+
def close_on_current?(close_on, within)
|
|
148
|
+
return false if close_on.nil?
|
|
149
|
+
unless close_on == CLOSE_ON_CURRENT
|
|
150
|
+
raise ConfigurationError, "close_on: must be #{CLOSE_ON_CURRENT.inspect} or nil"
|
|
151
|
+
end
|
|
152
|
+
raise ConfigurationError, 'close_on: requires `within`' if within.nil?
|
|
153
|
+
|
|
154
|
+
true
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# A destroyed root has no current state to close on, and its own destroy
|
|
158
|
+
# version already terminates the history.
|
|
159
|
+
#: (untyped, Symbol?, untyped) -> untyped
|
|
160
|
+
def live_endpoint_for(record, close_on, within)
|
|
161
|
+
return unless close_on_current?(close_on, within)
|
|
162
|
+
return unless Endpoint.record?(record) && !record.destroyed?
|
|
163
|
+
|
|
164
|
+
record
|
|
165
|
+
end
|
|
166
|
+
|
|
125
167
|
#: () -> void
|
|
126
168
|
def build_snapshotters
|
|
127
169
|
@historical_store = build_historical_store
|
|
128
|
-
@timeline_snapshotter = TimelineSnapshotProvider.new(
|
|
170
|
+
@timeline_snapshotter = TimelineSnapshotProvider.new(
|
|
171
|
+
@historical_store, live_snapshotter: method(:live_snapshot)
|
|
172
|
+
)
|
|
129
173
|
@activity_snapshotter = build_activity_snapshotter
|
|
130
174
|
end
|
|
131
175
|
|
|
@@ -168,11 +212,14 @@ module PaperTrailDiff
|
|
|
168
212
|
)
|
|
169
213
|
end
|
|
170
214
|
|
|
171
|
-
#: (untyped, from: untyped, to: untyped, within: untyped) -> ActivityTimelineBuilder
|
|
172
|
-
def activity_builder(record, from:, to:, within:)
|
|
215
|
+
#: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped) -> ActivityTimelineBuilder
|
|
216
|
+
def activity_builder(record, from:, to:, within:, version_scope: nil, live_endpoint: nil) # rubocop:disable Metrics/ParameterLists
|
|
173
217
|
ActivityTimelineBuilder.new(
|
|
174
218
|
record,
|
|
175
|
-
range: TimelineRange.new(
|
|
219
|
+
range: TimelineRange.new(
|
|
220
|
+
record, from: from, to: to, within: within, version_scope: version_scope,
|
|
221
|
+
live_endpoint: live_endpoint
|
|
222
|
+
),
|
|
176
223
|
tree: @association_tree,
|
|
177
224
|
snapshotter: @activity_snapshotter
|
|
178
225
|
)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Which versions a range reconstructs, and which pairs of them become steps.
|
|
6
|
+
#
|
|
7
|
+
# Without a filter those are the same thing: every selected version is a
|
|
8
|
+
# boundary and adjacent ones bound each other. A filter separates them, because
|
|
9
|
+
# each selected mutation then has to be bounded by the version that actually
|
|
10
|
+
# reveals it rather than by the next mutation that happened to be selected.
|
|
11
|
+
#
|
|
12
|
+
# A filter also separates what is *reported* from what has to be *replayed*.
|
|
13
|
+
# The activity view rebuilds state by carrying one snapshot forward across
|
|
14
|
+
# every event in order, so skipping a filtered-out version would leave that
|
|
15
|
+
# snapshot holding a state the record had already moved past. `versions` are
|
|
16
|
+
# the boundaries the steps refer to; `reconstruction_versions` are every root
|
|
17
|
+
# version the span passes through, which is what any forward replay must walk.
|
|
18
|
+
class RootVersionPlan
|
|
19
|
+
attr_reader :versions #: Array[untyped]
|
|
20
|
+
attr_reader :reconstruction_versions #: Array[untyped]
|
|
21
|
+
attr_reader :steps #: Array[[untyped, untyped]]
|
|
22
|
+
attr_reader :context_version #: untyped
|
|
23
|
+
# The live record a window closes on when no later version can reveal its
|
|
24
|
+
# final mutation. It is a boundary, never a version, so it stays out of
|
|
25
|
+
# `versions` and out of anything that reconstructs from version history.
|
|
26
|
+
attr_reader :closing_record #: untyped
|
|
27
|
+
# The root versions this plan reports as mutations, which excludes any
|
|
28
|
+
# version present only to reveal what the last of them produced.
|
|
29
|
+
attr_reader :mutations #: Array[untyped]
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
# Adjacent boundaries, which is what an unfiltered range reports.
|
|
33
|
+
#: (Array[untyped], ?context_version: untyped) -> RootVersionPlan
|
|
34
|
+
def contiguous(versions, context_version: nil)
|
|
35
|
+
new(
|
|
36
|
+
versions: versions,
|
|
37
|
+
steps: versions.each_cons(2).map { |from, to| [from, to] },
|
|
38
|
+
context_version: context_version
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
#: () -> RootVersionPlan
|
|
43
|
+
def empty
|
|
44
|
+
versions = [] #: Array[untyped]
|
|
45
|
+
steps = [] #: Array[[untyped, untyped]]
|
|
46
|
+
new(versions: versions, steps: steps)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
#: (versions: Array[untyped], steps: Array[[untyped, untyped]], ?context_version: untyped, ?reconstruction_versions: Array[untyped]?, ?mutations: Array[untyped]?, ?closing_record: untyped) -> void
|
|
51
|
+
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
52
|
+
versions:, steps:, context_version: nil, reconstruction_versions: nil, mutations: nil,
|
|
53
|
+
closing_record: nil
|
|
54
|
+
)
|
|
55
|
+
@closing_record = closing_record
|
|
56
|
+
@versions = versions.freeze
|
|
57
|
+
@reconstruction_versions = (reconstruction_versions || versions).freeze
|
|
58
|
+
@steps = steps.freeze
|
|
59
|
+
@context_version = context_version
|
|
60
|
+
@mutations = (mutations || default_mutations).freeze
|
|
61
|
+
@mutation_keys = Set.new(@mutations.map { |version| key(version) }).freeze
|
|
62
|
+
freeze
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
#: () -> bool
|
|
66
|
+
def empty?
|
|
67
|
+
versions.empty?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# What the range's final state is read from, which is the live record when
|
|
71
|
+
# the window closes on current state and the last version otherwise.
|
|
72
|
+
#: () -> untyped
|
|
73
|
+
def final_endpoint
|
|
74
|
+
@closing_record || @versions.last
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
#: (untyped) -> bool
|
|
78
|
+
def mutation?(version)
|
|
79
|
+
@mutation_keys.include?(key(version))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# @rbs @mutation_keys: Set[Array[untyped]]
|
|
85
|
+
|
|
86
|
+
#: () -> Array[untyped]
|
|
87
|
+
def default_mutations
|
|
88
|
+
return @versions unless @context_version
|
|
89
|
+
|
|
90
|
+
context = key(@context_version)
|
|
91
|
+
@versions.reject { |version| key(version) == context }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
#: (untyped) -> Array[untyped]
|
|
95
|
+
def key(version)
|
|
96
|
+
[version.class.name, version.id]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Decides which versions a range reports, and which one reveals the last of
|
|
6
|
+
# them. A version records the state before its own event, so whatever follows
|
|
7
|
+
# the final selected mutation is the only thing that can show what it produced.
|
|
8
|
+
# That successor is reconstruction context rather than a reported mutation, so
|
|
9
|
+
# a filter must never remove it.
|
|
10
|
+
#
|
|
11
|
+
# Both the single-record and the batched selectors resolve this here, because
|
|
12
|
+
# the rule is subtle enough that two copies of it would drift apart.
|
|
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, ?live_endpoint: untyped) -> void
|
|
17
|
+
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
18
|
+
in_range:, selected:, after_range:, windowed:, context_required: false, filtered: false,
|
|
19
|
+
live_endpoint: nil
|
|
20
|
+
)
|
|
21
|
+
@in_range = in_range
|
|
22
|
+
@selected = selected
|
|
23
|
+
@after_range = after_range
|
|
24
|
+
@windowed = windowed
|
|
25
|
+
@context_required = context_required
|
|
26
|
+
@filtered = filtered
|
|
27
|
+
@live_endpoint = live_endpoint
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
#: () -> RootVersionPlan
|
|
31
|
+
def call
|
|
32
|
+
return without_selection if @selected.empty?
|
|
33
|
+
|
|
34
|
+
closing = revealing_version || @live_endpoint
|
|
35
|
+
raise IncompleteTimeRangeError, INCOMPLETE if !closing && @windowed && !terminal_destroy?
|
|
36
|
+
return filtered_plan(closing) if @filtered
|
|
37
|
+
|
|
38
|
+
contiguous_plan(closing)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# @rbs @in_range: Array[untyped]
|
|
44
|
+
# @rbs @selected: Array[untyped]
|
|
45
|
+
# @rbs @after_range: untyped
|
|
46
|
+
# @rbs @windowed: bool
|
|
47
|
+
# @rbs @context_required: bool
|
|
48
|
+
# @rbs @filtered: bool
|
|
49
|
+
# @rbs @live_endpoint: untyped
|
|
50
|
+
|
|
51
|
+
# A window reaching past the last recorded version has only the live record
|
|
52
|
+
# left to show what its final mutation produced. That record is a closing
|
|
53
|
+
# boundary rather than a selected mutation, so it never joins `versions`.
|
|
54
|
+
#: (untyped) -> RootVersionPlan
|
|
55
|
+
def contiguous_plan(closing)
|
|
56
|
+
return RootVersionPlan.contiguous(@selected) unless closing
|
|
57
|
+
unless Endpoint.record?(closing)
|
|
58
|
+
return RootVersionPlan.contiguous(@selected + [closing], context_version: closing)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
steps = @selected.each_cons(2).map { |from, to| [from, to] } #: Array[[untyped, untyped]]
|
|
62
|
+
steps << [@selected.last, closing]
|
|
63
|
+
RootVersionPlan.new(versions: @selected, steps: steps, closing_record: closing)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# An activity view still needs a root to reconstruct from even when no root
|
|
67
|
+
# version falls inside the window, because descendants may have moved.
|
|
68
|
+
#: () -> RootVersionPlan
|
|
69
|
+
def without_selection
|
|
70
|
+
return RootVersionPlan.empty unless @context_required
|
|
71
|
+
if @after_range
|
|
72
|
+
return RootVersionPlan.contiguous([@after_range], context_version: @after_range)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
raise IncompleteTimeRangeError, INCOMPLETE
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Each selected mutation is bounded by the version that reveals it, not by
|
|
79
|
+
# the next mutation that happened to be selected. Bounding by the next
|
|
80
|
+
# selection would fold anything filtered out in between into it, so the same
|
|
81
|
+
# edit would read differently depending on what followed it.
|
|
82
|
+
#: (untyped) -> RootVersionPlan
|
|
83
|
+
def filtered_plan(revealing)
|
|
84
|
+
steps = @selected.filter_map do |version|
|
|
85
|
+
successor = version.equal?(@selected.last) ? revealing : immediate_successor(version)
|
|
86
|
+
[version, successor] if successor
|
|
87
|
+
end #: Array[[untyped, untyped]]
|
|
88
|
+
filtered_plan_for(steps, revealing)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
#: (Array[[untyped, untyped]], untyped) -> RootVersionPlan
|
|
92
|
+
def filtered_plan_for(steps, revealing)
|
|
93
|
+
live = revealing if Endpoint.record?(revealing)
|
|
94
|
+
versions = chronological(
|
|
95
|
+
(steps.flatten(1) + closing_versions).reject { |entry| Endpoint.record?(entry) }
|
|
96
|
+
)
|
|
97
|
+
RootVersionPlan.new(
|
|
98
|
+
versions: versions, steps: steps,
|
|
99
|
+
context_version: (revealing unless live),
|
|
100
|
+
reconstruction_versions: spanned(versions),
|
|
101
|
+
mutations: @selected, closing_record: live
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Nothing can follow a destroy, so it never pairs into a step. It is still a
|
|
106
|
+
# selected mutation, and the activity view closes on the absence it leaves,
|
|
107
|
+
# so it has to stay a boundary or a filtered report loses the deletion
|
|
108
|
+
# entirely — the one event it can least afford to drop.
|
|
109
|
+
#: () -> Array[untyped]
|
|
110
|
+
def closing_versions
|
|
111
|
+
terminal_destroy? ? [@selected.last] : []
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Everything the span passes through, filtered out or not. A replay that
|
|
115
|
+
# skipped the excluded versions would carry a stale state into the next
|
|
116
|
+
# reported step, so the two selected mutations either side of a gap would
|
|
117
|
+
# disagree about what the record looked like between them.
|
|
118
|
+
#: (Array[untyped]) -> Array[untyped]
|
|
119
|
+
def spanned(versions)
|
|
120
|
+
first = versions.first
|
|
121
|
+
last = versions.last
|
|
122
|
+
return versions unless first && last
|
|
123
|
+
|
|
124
|
+
@in_range.select do |candidate|
|
|
125
|
+
!Support.compare_versions(first, candidate).positive? &&
|
|
126
|
+
!Support.compare_versions(candidate, last).positive?
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
#: (Array[untyped]) -> Array[untyped]
|
|
131
|
+
def chronological(versions)
|
|
132
|
+
versions.uniq { |version| [version.class.name, version.id] }
|
|
133
|
+
.sort_by { |version| Support.chronological_version_key(version) }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
#: (untyped) -> untyped
|
|
137
|
+
def immediate_successor(version)
|
|
138
|
+
@in_range.find { |candidate| Support.compare_versions(version, candidate).negative? }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# A version left out by a filter is still the state the last selected change
|
|
142
|
+
# produced, so it is preferred over anything after the range.
|
|
143
|
+
#: () -> untyped
|
|
144
|
+
def revealing_version
|
|
145
|
+
immediate_successor(@selected.last) || @after_range
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# A range closing on the record's own destruction needs no later version:
|
|
149
|
+
# nothing can follow it, so demanding one would reject the range forever.
|
|
150
|
+
#: () -> bool
|
|
151
|
+
def terminal_destroy?
|
|
152
|
+
@selected.last&.event.to_s == 'destroy'
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -10,12 +10,20 @@ module PaperTrailDiff
|
|
|
10
10
|
attr_reader :to_boundary #: ActivityBoundary
|
|
11
11
|
attr_reader :diff #: Diff
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
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 =
|
|
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
|
|
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,17 +14,23 @@ module PaperTrailDiff
|
|
|
14
14
|
|
|
15
15
|
#: () -> Array[ActivityStep]
|
|
16
16
|
def build
|
|
17
|
-
history,
|
|
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,
|
|
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,
|
|
26
|
-
timeline: ActivityRootSteps.call(
|
|
27
|
-
|
|
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
|
+
),
|
|
31
|
+
activity_timeline: activity_steps(history, closing),
|
|
32
|
+
from_snapshot: history.first_snapshot,
|
|
33
|
+
to_snapshot: final
|
|
28
34
|
)
|
|
29
35
|
end
|
|
30
36
|
|
|
@@ -35,18 +41,46 @@ module PaperTrailDiff
|
|
|
35
41
|
# @rbs @tree: AssociationTree
|
|
36
42
|
# @rbs @snapshotter: untyped
|
|
37
43
|
|
|
38
|
-
#: () -> [ActivityHistory,
|
|
44
|
+
#: () -> [ActivityHistory, RootVersionPlan, ActivityStep?, RecordSnapshot?, untyped]
|
|
39
45
|
def history_and_versions
|
|
40
|
-
|
|
41
|
-
return [ActivityHistory.empty,
|
|
46
|
+
plan = @range.select_plan(context_required: !@tree.empty?)
|
|
47
|
+
return [ActivityHistory.empty, plan, nil, nil, nil] if plan.reconstruction_versions.empty?
|
|
42
48
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
54
|
+
|
|
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)
|
|
59
|
+
selected = selected_events(events, plan)
|
|
60
|
+
unless time_events?(selected, events, plan)
|
|
61
|
+
return [ActivityHistory.empty, plan, nil, nil, nil]
|
|
62
|
+
end
|
|
47
63
|
|
|
48
64
|
history = build_history(root_versions, events)
|
|
49
|
-
|
|
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)
|
|
50
84
|
end
|
|
51
85
|
|
|
52
86
|
#: (Array[untyped], Array[ActivityEvent]) -> ActivityHistory
|
|
@@ -66,17 +100,34 @@ module PaperTrailDiff
|
|
|
66
100
|
(history.steps + [closing]).freeze
|
|
67
101
|
end
|
|
68
102
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
103
|
+
# Root versions inside the window that the plan does not report are context
|
|
104
|
+
# rather than mutations: the version appended to reveal the last selected
|
|
105
|
+
# change, and anything a filter excluded but the replay still walks. Neither
|
|
106
|
+
# may be counted as a selected mutation. Descendant events are not filtered,
|
|
107
|
+
# so window membership is the whole test for them.
|
|
108
|
+
#: (Array[ActivityEvent], RootVersionPlan) -> Array[ActivityEvent]
|
|
109
|
+
def selected_events(events, plan)
|
|
110
|
+
events.select do |event|
|
|
111
|
+
next false unless @range.include?(event.version)
|
|
112
|
+
|
|
113
|
+
!event.root? || plan.mutation?(event.version)
|
|
114
|
+
end
|
|
72
115
|
end
|
|
73
116
|
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
79
125
|
|
|
126
|
+
current_step(history, plan.closing_record, captured_at, snapshot)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
#: (ActivityHistory, ActivityEvent) -> ActivityStep
|
|
130
|
+
def destroyed_step(history, event)
|
|
80
131
|
version = event.version
|
|
81
132
|
ActivityStep.new(
|
|
82
133
|
from_boundary: ActivityBoundary.from_version(version),
|
|
@@ -85,6 +136,21 @@ module PaperTrailDiff
|
|
|
85
136
|
)
|
|
86
137
|
end
|
|
87
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
|
+
|
|
88
154
|
#: (ActivityEvent?) -> bool
|
|
89
155
|
def terminal_destroy?(event)
|
|
90
156
|
return false unless event
|
|
@@ -92,30 +158,34 @@ module PaperTrailDiff
|
|
|
92
158
|
event.root? && event.version.event.to_s == 'destroy'
|
|
93
159
|
end
|
|
94
160
|
|
|
95
|
-
#: (Array[untyped]) -> void
|
|
96
|
-
def prepare_history(root_versions)
|
|
161
|
+
#: (Array[untyped], ?end_at: untyped) -> void
|
|
162
|
+
def prepare_history(root_versions, end_at: nil)
|
|
97
163
|
return unless @snapshotter.respond_to?(:prepare)
|
|
98
164
|
|
|
99
|
-
|
|
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)
|
|
100
169
|
end
|
|
101
170
|
|
|
102
|
-
#: (Array[untyped]) -> Array[ActivityEvent]
|
|
103
|
-
def collect_events(root_versions)
|
|
171
|
+
#: (Array[untyped], ?range_end: untyped) -> Array[ActivityEvent]
|
|
172
|
+
def collect_events(root_versions, range_end: nil)
|
|
104
173
|
ActivityVersionCollector.new(
|
|
105
174
|
@record,
|
|
106
175
|
root_versions: root_versions,
|
|
107
176
|
tree: @tree,
|
|
108
177
|
traversal: AssociationTraversal.new(@tree),
|
|
109
178
|
range_start: @range.begin_time,
|
|
110
|
-
range_end: root_versions.last
|
|
179
|
+
range_end: range_end || root_versions.last
|
|
111
180
|
).call
|
|
112
181
|
end
|
|
113
182
|
|
|
114
|
-
#: (Array[ActivityEvent], Array[ActivityEvent]) -> bool
|
|
115
|
-
def time_events?(selected, events)
|
|
183
|
+
#: (Array[ActivityEvent], Array[ActivityEvent], RootVersionPlan) -> bool
|
|
184
|
+
def time_events?(selected, events, plan)
|
|
116
185
|
return false if selected.empty?
|
|
117
186
|
return true if later_event?(selected.last, events)
|
|
118
187
|
return true if terminal_destroy?(selected.last)
|
|
188
|
+
return true if plan.closing_record
|
|
119
189
|
|
|
120
190
|
message = 'time range requires a later activity boundary to reconstruct its final change'
|
|
121
191
|
raise IncompleteTimeRangeError, message
|