paper_trail_diff 0.5.0 → 0.6.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +64 -1
  4. data/lib/paper_trail_diff/activity_root_steps.rb +9 -7
  5. data/lib/paper_trail_diff/activity_timeline_builder.rb +15 -8
  6. data/lib/paper_trail_diff/analysis.rb +19 -5
  7. data/lib/paper_trail_diff/analysis_batch.rb +28 -10
  8. data/lib/paper_trail_diff/batched_root_analyzer.rb +9 -9
  9. data/lib/paper_trail_diff/batched_root_versions.rb +48 -30
  10. data/lib/paper_trail_diff/paper_trail_adapter.rb +25 -18
  11. data/lib/paper_trail_diff/root_version_plan.rb +86 -0
  12. data/lib/paper_trail_diff/root_version_selection.rb +131 -0
  13. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +25 -13
  14. data/lib/paper_trail_diff/time_version_range.rb +19 -24
  15. data/lib/paper_trail_diff/timeline_builder.rb +21 -18
  16. data/lib/paper_trail_diff/timeline_range.rb +31 -7
  17. data/lib/paper_trail_diff/version.rb +1 -1
  18. data/lib/paper_trail_diff/version_range.rb +45 -5
  19. data/lib/paper_trail_diff/version_scope_filter.rb +37 -0
  20. data/lib/paper_trail_diff.rb +24 -13
  21. data/sig/generated/paper_trail_diff/activity_root_steps.rbs +2 -2
  22. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +6 -2
  23. data/sig/generated/paper_trail_diff/analysis.rbs +13 -4
  24. data/sig/generated/paper_trail_diff/analysis_batch.rbs +13 -6
  25. data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +4 -4
  26. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +21 -14
  27. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +10 -10
  28. data/sig/generated/paper_trail_diff/root_version_plan.rbs +56 -0
  29. data/sig/generated/paper_trail_diff/root_version_selection.rbs +77 -0
  30. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +9 -4
  31. data/sig/generated/paper_trail_diff/time_version_range.rbs +7 -9
  32. data/sig/generated/paper_trail_diff/timeline_builder.rbs +6 -6
  33. data/sig/generated/paper_trail_diff/timeline_range.rbs +11 -2
  34. data/sig/generated/paper_trail_diff/version_range.rbs +17 -2
  35. data/sig/generated/paper_trail_diff/version_scope_filter.rbs +22 -0
  36. data/sig/generated/paper_trail_diff.rbs +8 -8
  37. metadata +10 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a4d3c1e15ef20c4e3d05f5c0442d7e55cd0eb811a15766d2119a3cf35b2e729c
4
- data.tar.gz: 4cc8dceb10e7aa71f1feb7d9fcc3917040bec4b2cc69641b4b3817d6761aad94
3
+ metadata.gz: 88e9b952c5cfe214974d5eab1a9d6e5a30dcfeb34ebc4193110f478d0cd9884a
4
+ data.tar.gz: 3a66b3ae3a77b65097ece227010c423beb05ec4ebd981edee396b824638c801c
5
5
  SHA512:
6
- metadata.gz: 3887a515ee90f1c14b890a21d4a91b4892f4097b74c9ec27f136fd8476f01cd285c0a310a03b0c528e709cdca299bca2e243522844d09a86d272c043e48879b1
7
- data.tar.gz: a1390db78cc31cbf20420fd18b536ad8cd826de4927943cbdddc08b7a31f9dad5f81acee77db97059f044ad435bd3170e54f8ef2ccd5c68f3e256bfa5d848642
6
+ metadata.gz: 413fcd69a9ef2b49abb2238d77b279ac2dd4001cc8b5d98d1bbcb92843a6b428aeed967ba7ba8efb10dca1fcd5e912ca4221b0a8a854e0cd624a7b95fcc21ae1
7
+ data.tar.gz: f5968acfec8ce8189e1fcd3122b4fc3934b7cf989278c1b8feb87ed087da93132c35f7d20df8d64e49832fabe052b5cd80d6fdc5182af3e9be6c2d308ab3acf1
data/CHANGELOG.md CHANGED
@@ -3,6 +3,30 @@
3
3
  All notable changes to this project will be documented in this file. The
4
4
  project follows [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [0.6.0] - 2026-08-11
7
+
8
+ ### Added
9
+
10
+ - Accept `version_scope:` on `timeline`, `activity_timeline`, `analyze`, and
11
+ `analyze_many`, narrowing which root versions count as selected mutations.
12
+ Excluded versions are still loaded, because a version records the state before
13
+ its own event and the one following a selected change is what reveals it;
14
+ their own changes are never attributed to a selected mutation. Each selected
15
+ mutation is bounded by the version that immediately followed it rather than by
16
+ the next selected one, so its diff is exactly what that mutation did however
17
+ many excluded changes follow it. A selected mutation nothing follows yet is
18
+ not reported, since no version records the state it produced; a selected
19
+ destruction is the exception, because the absence it leaves is what it
20
+ produced and `activity_timeline` closes on it. A root left with
21
+ no selected mutation reports an empty `Analysis`. Under `activity: true` the
22
+ filter decides the span; `activity_timeline` still lists every boundary inside
23
+ it, because dropping one would fold its change into a neighbouring step.
24
+ - Expose `from_snapshot` and `to_snapshot` on `Analysis`, the reconstructed
25
+ states its diff was taken between, so a report can render unchanged columns
26
+ without selecting versions or reifying them itself. `Analysis#to_h` keeps its
27
+ shape by default and takes `snapshots: true` to include them, because they
28
+ carry the whole selected graph whether or not anything changed.
29
+
6
30
  ## [0.5.0] - 2026-08-11
7
31
 
8
32
  ### Added
data/README.md CHANGED
@@ -324,6 +324,52 @@ value is the same `Analysis` that `analyze` returns for one record. A root with
324
324
  no versions inside the window gets an empty `Analysis` rather than raising, so a
325
325
  listing page needs no special case. Root identities must be unique.
326
326
 
327
+ ### Reporting on a subset of mutations
328
+
329
+ `version_scope:` narrows which root versions count as *selected mutations*,
330
+ which is what a "changes made by a user" report needs:
331
+
332
+ ```ruby
333
+ user_edits = ->(scope) { scope.where.not(whodunnit: nil) }
334
+
335
+ PaperTrailDiff.analyze_many(articles, within: window, version_scope: user_edits)
336
+ PaperTrailDiff.timeline(article, within: window, version_scope: user_edits)
337
+ ```
338
+
339
+ The hook receives the version relation for the range and returns a narrowed one.
340
+ It is accepted by `timeline`, `activity_timeline`, `analyze`, and
341
+ `analyze_many`, with any range form.
342
+
343
+ It filters *selected mutations only*. Versions the filter excludes are still
344
+ loaded, because a version records the state before its own event: without the
345
+ one that follows a selected change, whatever that change produced cannot be
346
+ shown at all. Those extra versions are reconstruction context, so their own
347
+ changes are never attributed to a selected mutation.
348
+
349
+ Each selected mutation is bounded by the version that immediately followed it,
350
+ not by the next selected one. So each step's diff is exactly what that mutation
351
+ did, and it reads the same however many excluded changes happen to follow it:
352
+
353
+ ```
354
+ versions system → alice → system → bob → system
355
+ reported alice: what alice changed bob: what bob changed
356
+ ```
357
+
358
+ Given a user edit followed by a system edit, filtering to user changes yields
359
+ one step running from the user version to the system version, whose diff is
360
+ exactly the user's change. A selected mutation that nothing follows yet is not
361
+ reported, since no version records the state it produced — the same blind spot
362
+ an unfiltered timeline has at its `to:` boundary. A selected *destruction* is
363
+ the exception: nothing can follow one, but the absence it leaves is what it
364
+ produced, so `activity_timeline` still closes on it. A root with no selected
365
+ mutation reports an empty `Analysis` rather than raising.
366
+
367
+ The hook applies to root versions only. Under `activity: true` the filter
368
+ decides where the span starts and ends, and `timeline` within that span reports
369
+ only selected mutations, but `activity_timeline` still lists every boundary
370
+ inside it — dropping one would fold the change it carried into a neighbouring
371
+ step and credit it to whoever made that one.
372
+
327
373
  Omit `within:` to analyze each root's whole recorded history instead, which is
328
374
  the batched equivalent of `analyze(record, from: :first, to: :last)`. Explicit
329
375
  version endpoints are not accepted, because a single pair cannot mean the same
@@ -922,10 +968,27 @@ The public result types are:
922
968
  - `PaperTrailDiff::DiagnosticReport`
923
969
  - `PaperTrailDiff::DiagnosticIssue`
924
970
 
971
+ `Analysis` also exposes the reconstructed states its diff was taken between, as
972
+ `from_snapshot` and `to_snapshot`. A report that renders unchanged columns needs
973
+ the whole final state, not only what moved, and these are the states the gem
974
+ already reconstructed:
975
+
976
+ ```ruby
977
+ analysis.to_snapshot.attributes # every selected scalar, changed or not
978
+ analysis.to_snapshot.associations # the selected association tree
979
+ ```
980
+
981
+ Either is `nil` when that endpoint has no reconstructable state — most often a
982
+ `from_snapshot` at a `create` boundary, whose pre-change state is the absence of
983
+ the record. Following the precedent set by `Step`, `Analysis#to_h` is unchanged;
984
+ serialize `to_snapshot.to_h` when a serialized form is wanted.
985
+
925
986
  They expose readers, are frozen after construction, and provide deterministic
926
987
  `to_h` output. Collection results are ordered by record identity: by type, then
927
988
  naturally within one id type, so numeric ids sort `2` before `10`. Mixed or
928
- unusual id types still order totally rather than raising. Structural hash keys are symbols; attribute and association
989
+ unusual id types still order totally rather than raising.
990
+
991
+ Structural hash keys are symbols; attribute and association
929
992
  names are strings. Attribute values retain their Ruby types. `RecordChange#record`
930
993
  is a `RecordReference` with `type` and `id` readers. `TraversalEntry#record` and
931
994
  `#association` return the final components of their corresponding paths. `Step`
@@ -7,14 +7,16 @@ module PaperTrailDiff
7
7
  module ActivityRootSteps
8
8
  module_function
9
9
 
10
- #: (Array[untyped], Hash[Array[untyped], RecordSnapshot?]) -> Array[Step]
11
- def call(root_versions, root_snapshots)
12
- snapshots = root_versions.map { |version| root_snapshots.fetch(version_key(version)) }
13
- root_versions.each_cons(2).with_index.map do |versions, index|
10
+ #: (RootVersionPlan, Hash[Array[untyped], RecordSnapshot?]) -> Array[Step]
11
+ def call(plan, root_snapshots)
12
+ plan.steps.map do |from_version, to_version|
14
13
  Step.new(
15
- from_version: versions.fetch(0),
16
- to_version: versions.fetch(1),
17
- diff: Engine.compare(snapshots.fetch(index), snapshots.fetch(index + 1))
14
+ from_version: from_version,
15
+ to_version: to_version,
16
+ diff: Engine.compare(
17
+ root_snapshots[version_key(from_version)],
18
+ root_snapshots[version_key(to_version)]
19
+ )
18
20
  )
19
21
  end.freeze
20
22
  end
@@ -34,10 +34,11 @@ module PaperTrailDiff
34
34
  raise InvalidTimelineRangeError, '`to` must be a root PaperTrail version'
35
35
  end
36
36
 
37
- root_versions = @range.select
37
+ plan = @range.select_plan
38
+ root_versions = plan.reconstruction_versions
38
39
  prepare_history(root_versions)
39
40
  events = collect_events(root_versions)
40
- build_analysis(root_versions, events, event_history(root_versions, events))
41
+ build_analysis(plan, events, event_history(root_versions, events))
41
42
  end
42
43
 
43
44
  private
@@ -55,9 +56,13 @@ module PaperTrailDiff
55
56
  steps.freeze
56
57
  end
57
58
 
59
+ # Every boundary in the span, filtered out or not. A filter narrows where the
60
+ # span starts and ends, but the sequence inside it stays complete: dropping
61
+ # boundaries would fold the changes they carried into a neighbouring step and
62
+ # credit them to whoever made that one.
58
63
  #: () -> Array[ActivityStep]
59
64
  def build_between_versions
60
- root_versions = @range.select
65
+ root_versions = @range.select_plan.reconstruction_versions
61
66
  prepare_history(root_versions)
62
67
  events = collect_events(root_versions)
63
68
  build_event_steps(root_versions, events)
@@ -177,14 +182,16 @@ module PaperTrailDiff
177
182
  # Only the activity view gains the closing removal. The endpoint diff and
178
183
  # the root timeline keep their `compare` and `timeline` semantics, under
179
184
  # which the state at a destroy version is the state before the deletion.
180
- #: (Array[untyped], Array[ActivityEvent], ActivityHistory) -> Analysis
181
- def build_analysis(root_versions, events, history)
185
+ #: (RootVersionPlan, Array[ActivityEvent], ActivityHistory) -> Analysis
186
+ def build_analysis(plan, events, history)
182
187
  Analysis.new(
183
188
  diff: Engine.compare(history.first_snapshot, history.last_snapshot),
184
- timeline: ActivityRootSteps.call(root_versions, history.root_snapshots),
189
+ timeline: ActivityRootSteps.call(plan, history.root_snapshots),
185
190
  activity_timeline: activity_steps(
186
- history, events, destroyed_boundary(root_versions), nil
187
- )
191
+ history, events, destroyed_boundary(plan.reconstruction_versions), nil
192
+ ),
193
+ from_snapshot: history.first_snapshot,
194
+ to_snapshot: history.last_snapshot
188
195
  )
189
196
  end
190
197
 
@@ -7,6 +7,8 @@ module PaperTrailDiff
7
7
  attr_reader :diff #: Diff
8
8
  attr_reader :timeline #: Array[Step]
9
9
  attr_reader :activity_timeline #: Array[ActivityStep]?
10
+ attr_reader :from_snapshot #: RecordSnapshot?
11
+ attr_reader :to_snapshot #: RecordSnapshot?
10
12
 
11
13
  # The result for a record whose requested history contains no versions,
12
14
  # which is an empty history rather than a failed request.
@@ -17,19 +19,31 @@ module PaperTrailDiff
17
19
  new(diff: Diff.new, timeline: timeline, activity_timeline: activity_timeline)
18
20
  end
19
21
 
20
- #: (diff: Diff, timeline: Array[Step], ?activity_timeline: Array[ActivityStep]?) -> void
21
- def initialize(diff:, timeline:, activity_timeline: nil)
22
+ # The reconstructed states the diff was taken between. A report that has to
23
+ # render unchanged columns needs the whole final state, not only what moved.
24
+ #: (diff: Diff, timeline: Array[Step], ?activity_timeline: Array[ActivityStep]?, ?from_snapshot: RecordSnapshot?, ?to_snapshot: RecordSnapshot?) -> void
25
+ def initialize(diff:, timeline:, activity_timeline: nil, from_snapshot: nil, to_snapshot: nil)
22
26
  @diff = diff
23
27
  @timeline = timeline.dup.freeze
24
28
  @activity_timeline = activity_timeline&.dup&.freeze
29
+ @from_snapshot = from_snapshot
30
+ @to_snapshot = to_snapshot
25
31
  freeze
26
32
  end
27
33
 
28
- #: () -> Hash[Symbol, untyped]
29
- def to_h
34
+ # Reconstructed endpoint states are opt-in, because they carry the whole
35
+ # selected graph whether or not anything changed, which dwarfs the rest of
36
+ # the payload for a wide graph.
37
+ #: (?snapshots: bool) -> Hash[Symbol, untyped]
38
+ def to_h(snapshots: false)
30
39
  value = { diff: diff.to_h, timeline: Support.serialize(timeline) }
31
40
  value[:activity_timeline] = Support.serialize(activity_timeline) if activity_timeline
32
- value
41
+ return value unless snapshots
42
+
43
+ value.merge(
44
+ from_snapshot: Support.serialize(from_snapshot),
45
+ to_snapshot: Support.serialize(to_snapshot)
46
+ )
33
47
  end
34
48
  end
35
49
  end
@@ -6,10 +6,13 @@ module PaperTrailDiff
6
6
  # versions and preparing their association history once for the whole batch
7
7
  # rather than once per root.
8
8
  class AnalysisBatch
9
- #: (Array[untyped], time_range: TimeRange?, live_loader: untyped, history_preparer: untyped, analyzer: untyped) -> void
10
- def initialize(records, time_range:, live_loader:, history_preparer:, analyzer:)
9
+ #: (Array[untyped], time_range: TimeRange?, live_loader: untyped, history_preparer: untyped, analyzer: untyped, ?version_scope: untyped) -> void
10
+ def initialize( # rubocop:disable Metrics/ParameterLists
11
+ records, time_range:, live_loader:, history_preparer:, analyzer:, version_scope: nil
12
+ )
11
13
  @records = records
12
14
  @time_range = time_range
15
+ @version_scope = validated_scope(version_scope)
13
16
  @live_loader = live_loader
14
17
  @history_preparer = history_preparer
15
18
  @analyzer = analyzer
@@ -18,11 +21,14 @@ module PaperTrailDiff
18
21
  #: () -> Hash[identity, Analysis]
19
22
  def call
20
23
  records = validated_records
21
- selected = BatchedRootVersions.new(records, time_range: @time_range).call
24
+ selected = BatchedRootVersions.new(
25
+ records, time_range: @time_range, version_scope: @version_scope
26
+ ).call
22
27
  prepare(records, selected)
23
28
  records.to_h do |record|
24
29
  key = Endpoint.identity(record)
25
- [Support.immutable_copy(key), analysis_for(record, selected.fetch(key, []))]
30
+ plan = selected.fetch(key, RootVersionPlan.empty)
31
+ [Support.immutable_copy(key), analysis_for(record, plan)]
26
32
  end.freeze
27
33
  end
28
34
 
@@ -30,10 +36,20 @@ module PaperTrailDiff
30
36
 
31
37
  # @rbs @records: Array[untyped]
32
38
  # @rbs @time_range: TimeRange?
39
+ # @rbs @version_scope: untyped
33
40
  # @rbs @live_loader: untyped
34
41
  # @rbs @history_preparer: untyped
35
42
  # @rbs @analyzer: untyped
36
43
 
44
+ # A filter is a callable that narrows the version relation, so it is checked
45
+ # up front rather than failing partway through a batch.
46
+ #: (untyped) -> untyped
47
+ def validated_scope(scope)
48
+ return scope if scope.nil? || scope.respond_to?(:call)
49
+
50
+ raise ConfigurationError, 'version_scope: must respond to call'
51
+ end
52
+
37
53
  #: () -> Array[untyped]
38
54
  def validated_records
39
55
  raise ConfigurationError, 'records: must be an array' unless @records.is_a?(Array)
@@ -50,11 +66,13 @@ module PaperTrailDiff
50
66
  # The roots are preloaded first, because preparation reads their current
51
67
  # association state as a fallback and would otherwise walk it one root at a
52
68
  # time.
53
- #: (Array[untyped], Hash[Array[String], Array[untyped]]) -> void
69
+ #: (Array[untyped], Hash[Array[String], RootVersionPlan]) -> void
54
70
  def prepare(records, selected)
55
71
  loaded = @live_loader.call(records)
56
72
  records.group_by(&:class).each_value do |grouped|
57
- versions = grouped.flat_map { |record| selected.fetch(Endpoint.identity(record), []) }
73
+ versions = grouped.flat_map do |record|
74
+ selected.fetch(Endpoint.identity(record), RootVersionPlan.empty).versions
75
+ end
58
76
  next if versions.empty?
59
77
 
60
78
  roots = grouped.map { |record| loaded.fetch(Endpoint.identity(record), record) }
@@ -64,11 +82,11 @@ module PaperTrailDiff
64
82
 
65
83
  # A root with no versions in range has nothing to report, which is an empty
66
84
  # result rather than a failed request.
67
- #: (untyped, Array[untyped]) -> Analysis
68
- def analysis_for(record, versions)
69
- return Analysis.empty if versions.empty?
85
+ #: (untyped, RootVersionPlan) -> Analysis
86
+ def analysis_for(record, plan)
87
+ return Analysis.empty if plan.empty?
70
88
 
71
- @analyzer.call(record, versions)
89
+ @analyzer.call(record, plan)
72
90
  end
73
91
  end
74
92
  end
@@ -14,17 +14,17 @@ module PaperTrailDiff
14
14
  @activity = activity
15
15
  end
16
16
 
17
- #: (untyped, Array[untyped]) -> Analysis
18
- def call(record, versions)
17
+ #: (untyped, RootVersionPlan) -> Analysis
18
+ def call(record, plan)
19
19
  @preparer.call(record.class, historical: true)
20
- return activity_analysis(record, versions) if @activity
20
+ return activity_analysis(record, plan) if @activity
21
21
 
22
22
  TimelineBuilder.new(
23
23
  record,
24
- from: versions.first,
25
- to: versions.last,
24
+ from: plan.versions.first,
25
+ to: plan.versions.last,
26
26
  within: nil,
27
- versions: versions,
27
+ plan: plan,
28
28
  snapshotter: @timeline_snapshotter
29
29
  ).analyze
30
30
  end
@@ -37,10 +37,10 @@ module PaperTrailDiff
37
37
  # @rbs @preparer: untyped
38
38
  # @rbs @activity: bool
39
39
 
40
- #: (untyped, Array[untyped]) -> Analysis
41
- def activity_analysis(record, versions)
40
+ #: (untyped, RootVersionPlan) -> Analysis
41
+ def activity_analysis(record, plan)
42
42
  range = TimelineRange.new(
43
- record, from: versions.first, to: versions.last, within: nil, versions: versions
43
+ record, from: plan.versions.first, to: plan.versions.last, within: nil, plan: plan
44
44
  )
45
45
  ActivityTimelineBuilder.new(
46
46
  record, range: range, tree: @tree, snapshotter: @activity_snapshotter
@@ -6,18 +6,19 @@ module PaperTrailDiff
6
6
  # queries. Only the range forms that mean the same thing for every root are
7
7
  # supported: a shared wall-clock window, or each root's own whole history.
8
8
  class BatchedRootVersions
9
- #: (Array[untyped], time_range: TimeRange?) -> void
10
- def initialize(records, time_range:)
9
+ #: (Array[untyped], time_range: TimeRange?, ?version_scope: untyped) -> void
10
+ def initialize(records, time_range:, version_scope: nil)
11
11
  @records = records
12
12
  @time_range = time_range
13
+ @version_scope = version_scope
13
14
  end
14
15
 
15
- # Returns root versions per record identity, in chronological order.
16
- #: () -> Hash[Array[String], Array[untyped]]
16
+ # Returns a plan per record identity.
17
+ #: () -> Hash[Array[String], RootVersionPlan]
17
18
  def call
18
19
  return {} if @records.empty?
19
20
 
20
- selected = {} #: Hash[Array[String], Array[untyped]]
21
+ selected = {} #: Hash[Array[String], RootVersionPlan]
21
22
  @records.group_by(&:class).each do |model_class, records|
22
23
  select_model(model_class, records, selected)
23
24
  end
@@ -28,47 +29,64 @@ module PaperTrailDiff
28
29
 
29
30
  # @rbs @records: Array[untyped]
30
31
  # @rbs @time_range: TimeRange?
32
+ # @rbs @version_scope: untyped
31
33
 
32
- #: (untyped, Array[untyped], Hash[Array[String], Array[untyped]]) -> void
34
+ #: (untyped, Array[untyped], Hash[Array[String], RootVersionPlan]) -> void
33
35
  def select_model(model_class, records, selected)
34
36
  ids = records.map(&:id)
35
- range = @time_range
36
- grouped = grouped_versions(model_class, ids, range)
37
- empty = {} #: Hash[String, untyped]
38
- trailing = range ? trailing_versions(model_class, ids, range) : empty
37
+ in_range, chosen, trailing = model_versions(model_class, ids)
39
38
  records.each do |record|
40
39
  key = identity(model_class, record.id)
41
- selected[key] = versions_for(grouped.fetch(record.id.to_s, []), trailing[record.id.to_s])
40
+ selected[key] = versions_for(
41
+ in_range.fetch(record.id.to_s, []), chosen, trailing[record.id.to_s]
42
+ )
42
43
  end
43
44
  end
44
45
 
45
- # The window's own versions plus, when it has one, the later version needed
46
- # to reveal the last mutation inside it. A window closing on the root's own
47
- # destruction needs no later version, because none can exist.
48
- #: (Array[untyped], untyped) -> Array[untyped]
49
- def versions_for(in_range, trailing)
50
- return in_range if @time_range.nil?
51
- return in_range.freeze if in_range.empty?
52
- return in_range.freeze if trailing.nil? && terminal_destroy?(in_range)
53
-
54
- unless trailing
55
- raise IncompleteTimeRangeError,
56
- 'time range requires a later root version to reconstruct its final change'
57
- end
46
+ #: (untyped, Array[untyped]) -> [Hash[String, Array[untyped]], Set[untyped]?, Hash[String, untyped]]
47
+ def model_versions(model_class, ids)
48
+ range = @time_range
49
+ empty = {} #: Hash[String, untyped]
50
+ [
51
+ grouped_versions(model_class, ids, range),
52
+ chosen_version_ids(model_class, ids, range),
53
+ range ? trailing_versions(model_class, ids, range) : empty
54
+ ]
55
+ end
58
56
 
59
- (in_range + [trailing]).freeze
57
+ #: (Array[untyped], Set[untyped]?, untyped) -> RootVersionPlan
58
+ def versions_for(in_range, chosen, after_range)
59
+ RootVersionSelection.new(
60
+ in_range: in_range,
61
+ selected: chosen ? in_range.select { |version| chosen.include?(version.id) } : in_range,
62
+ after_range: after_range,
63
+ windowed: !@time_range.nil?,
64
+ filtered: !@version_scope.nil?
65
+ ).call
60
66
  end
61
67
 
62
- #: (Array[untyped]) -> bool
63
- def terminal_destroy?(versions)
64
- versions.last&.event.to_s == 'destroy'
68
+ # One extra query names the selected mutations without discarding the
69
+ # unfiltered versions the successor lookup still needs.
70
+ #: (untyped, Array[untyped], TimeRange?) -> Set[untyped]?
71
+ def chosen_version_ids(model_class, ids, range)
72
+ scope = @version_scope
73
+ return unless scope
74
+
75
+ # A narrowed relation is the expected return. Active Support also gives
76
+ # `pluck` to plain enumerables, so an array of versions works too.
77
+ filtered = scope.call(range_scope(model_class, ids, range))
78
+ Set.new(filtered.pluck(:id))
65
79
  end
66
80
 
67
81
  #: (untyped, Array[untyped], TimeRange?) -> Hash[String, Array[untyped]]
68
82
  def grouped_versions(model_class, ids, range)
83
+ ordered(range_scope(model_class, ids, range)).group_by { |version| version.item_id.to_s }
84
+ end
85
+
86
+ #: (untyped, Array[untyped], TimeRange?) -> untyped
87
+ def range_scope(model_class, ids, range)
69
88
  scope = base_scope(model_class, ids)
70
- scope = range.scope(scope) if range
71
- ordered(scope).group_by { |version| version.item_id.to_s }
89
+ range ? range.scope(scope) : scope
72
90
  end
73
91
 
74
92
  # One row per root: the earliest version after the window, found without a
@@ -50,57 +50,62 @@ module PaperTrailDiff
50
50
  end
51
51
  end
52
52
 
53
- #: (untyped, from: untyped, to: untyped, within: untyped) -> Array[Step]
54
- def timeline(record, from:, to:, within:)
53
+ #: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped) -> Array[Step]
54
+ def timeline(record, from:, to:, within:, version_scope: nil)
55
55
  @traversal_preparer.call(record.class, historical: true)
56
- builder = TimelineBuilder.new(
56
+ TimelineBuilder.new(
57
57
  record,
58
58
  from: from,
59
59
  to: to,
60
60
  within: within,
61
+ version_scope: version_scope,
61
62
  snapshotter: @timeline_snapshotter
62
- )
63
- builder.build
63
+ ).build
64
64
  end
65
65
 
66
- #: (untyped, from: untyped, to: untyped, within: untyped) -> Array[ActivityStep]
67
- def activity_timeline(record, from:, to:, within:)
66
+ #: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped) -> Array[ActivityStep]
67
+ def activity_timeline(record, from:, to:, within:, version_scope: nil)
68
68
  payload = @instrumentation_payload.merge(model_type: record.class.base_class.name.to_s)
69
69
  Instrumentation.instrument('activity_timeline', payload) do
70
70
  @traversal_preparer.call(record.class, historical: true)
71
71
  reject_live_habtm_activity!(record.class) if Endpoint.record?(to)
72
- steps = activity_builder(record, from: from, to: to, within: within).build
72
+ steps = activity_builder(
73
+ record, from: from, to: to, within: within, version_scope: version_scope
74
+ ).build
73
75
  payload[:step_count] = steps.length
74
76
  steps
75
77
  end
76
78
  end
77
79
 
78
- #: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool) -> Analysis
79
- def analyze(record, from:, to:, within:, activity: false)
80
+ #: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped) -> Analysis
81
+ def analyze(record, from:, to:, within:, activity: false, version_scope: nil) # rubocop:disable Metrics/ParameterLists
82
+ @traversal_preparer.call(record.class, historical: true)
80
83
  if activity
81
- @traversal_preparer.call(record.class, historical: true)
82
- return activity_builder(record, from: from, to: to, within: within).analyze
84
+ return activity_builder(
85
+ record, from: from, to: to, within: within, version_scope: version_scope
86
+ ).analyze
83
87
  end
84
88
 
85
- @traversal_preparer.call(record.class, historical: true)
86
89
  TimelineBuilder.new(
87
90
  record,
88
91
  from: from,
89
92
  to: to,
90
93
  within: within,
94
+ version_scope: version_scope,
91
95
  snapshotter: @timeline_snapshotter
92
96
  ).analyze
93
97
  end
94
98
 
95
99
  # 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)
100
+ #: (Array[untyped], within: untyped, ?activity: bool, ?version_scope: untyped) -> Hash[identity, Analysis]
101
+ def analyze_many(records, within:, activity: false, version_scope: nil)
98
102
  count = records.is_a?(Array) ? records.length : 0
99
103
  payload = @instrumentation_payload.merge(comparison_count: count)
100
104
  Instrumentation.instrument('analyze_many', payload) do
101
105
  AnalysisBatch.new(
102
106
  records,
103
107
  time_range: within.nil? ? nil : TimeRange.new(within),
108
+ version_scope: version_scope,
104
109
  live_loader: @live_endpoints.method(:call),
105
110
  history_preparer: @historical_store.method(:prepare_batch),
106
111
  analyzer: batched_root_analyzer(activity)
@@ -168,11 +173,13 @@ module PaperTrailDiff
168
173
  )
169
174
  end
170
175
 
171
- #: (untyped, from: untyped, to: untyped, within: untyped) -> ActivityTimelineBuilder
172
- def activity_builder(record, from:, to:, within:)
176
+ #: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped) -> ActivityTimelineBuilder
177
+ def activity_builder(record, from:, to:, within:, version_scope: nil)
173
178
  ActivityTimelineBuilder.new(
174
179
  record,
175
- range: TimelineRange.new(record, from: from, to: to, within: within),
180
+ range: TimelineRange.new(
181
+ record, from: from, to: to, within: within, version_scope: version_scope
182
+ ),
176
183
  tree: @association_tree,
177
184
  snapshotter: @activity_snapshotter
178
185
  )