paper_trail_diff 0.4.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fac7dbbad6368383d2b73f4c5acbdcabb1599da6c096a5c8363327a8602a1298
4
- data.tar.gz: 7657cc9ae4b3e8f7d9f1026ac06fb93b6571ba7258bca66183b84053bb8469c0
3
+ metadata.gz: a4d3c1e15ef20c4e3d05f5c0442d7e55cd0eb811a15766d2119a3cf35b2e729c
4
+ data.tar.gz: 4cc8dceb10e7aa71f1feb7d9fcc3917040bec4b2cc69641b4b3817d6761aad94
5
5
  SHA512:
6
- metadata.gz: adadf85c15151889247d0791ffc944731c781c6dc3f4d23b4a04fa046e489b74f2a6501a16fb53386e6c80c3fae4d6c583d4b91db198bfbe8919a3a19eaf1a4e
7
- data.tar.gz: 2a3bf4422b73c513981b519d04f0c556574f75ebc08963fab606f872c2428acac2455869675318ae75d8f71bddeb208e952642eeafedae7131b4745b5f36f534
6
+ metadata.gz: 3887a515ee90f1c14b890a21d4a91b4892f4097b74c9ec27f136fd8476f01cd285c0a310a03b0c528e709cdca299bca2e243522844d09a86d272c043e48879b1
7
+ data.tar.gz: a1390db78cc31cbf20420fd18b536ad8cd826de4927943cbdddc08b7a31f9dad5f81acee77db97059f044ad435bd3170e54f8ef2ccd5c68f3e256bfa5d848642
data/CHANGELOG.md CHANGED
@@ -3,6 +3,26 @@
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.5.0] - 2026-08-11
7
+
8
+ ### Added
9
+
10
+ - Add `analyze_many`, which analyzes many roots over one shared `within:` window,
11
+ or over each root's whole history when the window is omitted, selecting their
12
+ versions and preparing their history once for the batch. Query cost is flat in
13
+ the number of roots for the endpoint diff and checkpoint timeline; descendant
14
+ event discovery under `activity: true` remains per-root. A root with no
15
+ versions in range returns an empty `Analysis`, and roots are supplied as live
16
+ records so a root deleted inside the window cannot be included.
17
+ - Accept `:first` and `:last` as `compare_many` endpoints, resolved against the
18
+ record the pair's other endpoint names, in two queries per model class rather
19
+ than one lookup per root. A root with no recorded history compares as an empty
20
+ `Diff`, and an unanchored `{ from: :first, to: :last }` raises.
21
+ - Accept `reload_live_endpoints:` on `activity_timeline`, which reads live state
22
+ whenever `to:` is a current record but previously had no way to reuse an
23
+ already-preloaded graph. `timeline` and `analyze` are bounded by versions and
24
+ never read live state, so the option is deliberately absent there.
25
+
6
26
  ## [0.4.0] - 2026-08-11
7
27
 
8
28
  ### Added
data/README.md CHANGED
@@ -213,6 +213,21 @@ diffs = PaperTrailDiff.compare_many(
213
213
  diffs.fetch(["Order", orders.first.id.to_s]) # => PaperTrailDiff::Diff
214
214
  ```
215
215
 
216
+ Endpoints may also be given as `:first` or `:last`, resolved against the record
217
+ the pair's other endpoint names. That replaces the lookup above entirely, and
218
+ resolves every root in two queries per model class rather than one per root:
219
+
220
+ ```ruby
221
+ diffs = PaperTrailDiff.compare_many(
222
+ orders.map { |order| { from: :first, to: order } },
223
+ associations: [:line_items]
224
+ )
225
+ ```
226
+
227
+ A symbol carries no identity of its own, so `{ from: :first, to: :last }` raises
228
+ rather than guessing. A root with no recorded history resolves to an empty
229
+ `Diff`, matching how the timeline APIs answer the same question.
230
+
216
231
  Root identities must be unique within one call. Historical reconstruction for
217
232
  ordinary versioned, unscoped association paths is also prepared across the
218
233
  collection. Paths that require the existing point-in-time PT-AT fallback retain
@@ -225,8 +240,10 @@ must observe one atomic snapshot.
225
240
 
226
241
  ### Reuse already-preloaded current endpoints
227
242
 
228
- `compare` and `compare_many` reload current endpoints by default. A caller that
229
- already owns a consistent, fully preloaded graph may opt out:
243
+ `compare`, `compare_many`, and `activity_timeline(..., to: record)` reload
244
+ current endpoints by default. A caller that already owns a consistent, fully
245
+ preloaded graph may opt out. The option has no effect on `timeline` or
246
+ `analyze`, which are bounded by versions and never read live state:
230
247
 
231
248
  ```ruby
232
249
  orders = Order.where(id: order_ids).preload(line_items: :product).to_a
@@ -285,6 +302,43 @@ ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do
285
302
  end
286
303
  ```
287
304
 
305
+ ## Analyze many records over one window
306
+
307
+ `analyze_many` answers "what changed for these records during this period" for a
308
+ whole collection, selecting every root's versions and preparing their selected
309
+ association history once for the batch:
310
+
311
+ ```ruby
312
+ results = PaperTrailDiff.analyze_many(
313
+ Order.where(status: "open").to_a,
314
+ within: Time.zone.parse("2026-08-01")...Time.zone.parse("2026-09-01"),
315
+ associations: [:line_items]
316
+ )
317
+
318
+ results.fetch(["Order", order.id.to_s]).diff # net change across the window
319
+ results.fetch(["Order", order.id.to_s]).timeline # its checkpoint steps
320
+ ```
321
+
322
+ Results are a frozen hash keyed by `[item_type, item_id]` strings, and each
323
+ value is the same `Analysis` that `analyze` returns for one record. A root with
324
+ no versions inside the window gets an empty `Analysis` rather than raising, so a
325
+ listing page needs no special case. Root identities must be unique.
326
+
327
+ Omit `within:` to analyze each root's whole recorded history instead, which is
328
+ the batched equivalent of `analyze(record, from: :first, to: :last)`. Explicit
329
+ version endpoints are not accepted, because a single pair cannot mean the same
330
+ thing for every root.
331
+
332
+ Query cost is flat in the number of roots for the diff and timeline views:
333
+ selecting versions and preparing history are both shared across the batch. On a
334
+ twenty-root batch that is 7 queries against 120 for the same work done one
335
+ record at a time. Passing `activity: true` also works and returns the same
336
+ results, but discovering descendant events is inherently per-root, so that view
337
+ batches far less.
338
+
339
+ Roots are supplied as live records, so a root deleted inside the window cannot
340
+ be included; use `activity_timeline` for a history that ends in a deletion.
341
+
288
342
  ## Build a root-checkpoint timeline
289
343
 
290
344
  `timeline` accepts two version objects from the supplied record's history, or
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Builds one Analysis per root over a shared range, selecting every root's
6
+ # versions and preparing their association history once for the whole batch
7
+ # rather than once per root.
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:)
11
+ @records = records
12
+ @time_range = time_range
13
+ @live_loader = live_loader
14
+ @history_preparer = history_preparer
15
+ @analyzer = analyzer
16
+ end
17
+
18
+ #: () -> Hash[identity, Analysis]
19
+ def call
20
+ records = validated_records
21
+ selected = BatchedRootVersions.new(records, time_range: @time_range).call
22
+ prepare(records, selected)
23
+ records.to_h do |record|
24
+ key = Endpoint.identity(record)
25
+ [Support.immutable_copy(key), analysis_for(record, selected.fetch(key, []))]
26
+ end.freeze
27
+ end
28
+
29
+ private
30
+
31
+ # @rbs @records: Array[untyped]
32
+ # @rbs @time_range: TimeRange?
33
+ # @rbs @live_loader: untyped
34
+ # @rbs @history_preparer: untyped
35
+ # @rbs @analyzer: untyped
36
+
37
+ #: () -> Array[untyped]
38
+ def validated_records
39
+ raise ConfigurationError, 'records: must be an array' unless @records.is_a?(Array)
40
+
41
+ @records.each { |record| Endpoint.validate!(record) }
42
+ identities = @records.map { |record| Endpoint.identity(record) }
43
+ return @records if identities.uniq.length == identities.length
44
+
45
+ raise ConfigurationError, 'records: identities must be unique'
46
+ end
47
+
48
+ # Association history is prepared per model class across every selected root
49
+ # version, which is the work that would otherwise repeat for each record.
50
+ # The roots are preloaded first, because preparation reads their current
51
+ # association state as a fallback and would otherwise walk it one root at a
52
+ # time.
53
+ #: (Array[untyped], Hash[Array[String], Array[untyped]]) -> void
54
+ def prepare(records, selected)
55
+ loaded = @live_loader.call(records)
56
+ records.group_by(&:class).each_value do |grouped|
57
+ versions = grouped.flat_map { |record| selected.fetch(Endpoint.identity(record), []) }
58
+ next if versions.empty?
59
+
60
+ roots = grouped.map { |record| loaded.fetch(Endpoint.identity(record), record) }
61
+ @history_preparer.call(roots, versions)
62
+ end
63
+ end
64
+
65
+ # A root with no versions in range has nothing to report, which is an empty
66
+ # result rather than a failed request.
67
+ #: (untyped, Array[untyped]) -> Analysis
68
+ def analysis_for(record, versions)
69
+ return Analysis.empty if versions.empty?
70
+
71
+ @analyzer.call(record, versions)
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Resolves `:first` and `:last` endpoints for a whole batch in two queries per
6
+ # model class. Left to the caller this is a per-root lookup, which reintroduces
7
+ # exactly the queries a batched comparison exists to remove.
8
+ class BatchBoundaryResolver
9
+ BOUNDARIES = %i[first last].freeze
10
+
11
+ class << self
12
+ #: (untyped) -> bool
13
+ def symbolic?(endpoint)
14
+ endpoint.is_a?(Symbol)
15
+ end
16
+ end
17
+
18
+ #: (Array[[untyped, untyped]]) -> void
19
+ def initialize(pairs)
20
+ @pairs = pairs
21
+ end
22
+
23
+ # Returns the pairs with every resolvable symbol replaced. A symbol that
24
+ # names history the record does not have is left in place, so the caller can
25
+ # decide what an absent history means rather than being handed nil.
26
+ #: () -> Array[[untyped, untyped]]
27
+ def call
28
+ return @pairs unless @pairs.flatten(1).any? { |endpoint| symbolic?(endpoint) }
29
+
30
+ index = boundary_index
31
+ @pairs.map do |from, to|
32
+ [resolve(from, to, index), resolve(to, from, index)]
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ # @rbs @pairs: Array[[untyped, untyped]]
39
+
40
+ #: (untyped) -> bool
41
+ def symbolic?(endpoint)
42
+ self.class.symbolic?(endpoint)
43
+ end
44
+
45
+ #: (untyped, untyped, Hash[Array[String], Hash[Symbol, untyped]]) -> untyped
46
+ def resolve(endpoint, other, index)
47
+ return endpoint unless symbolic?(endpoint)
48
+
49
+ unless BOUNDARIES.include?(endpoint)
50
+ raise ConfigurationError,
51
+ "unsupported boundary: #{endpoint.inspect}; use :first, :last, a version, or a record"
52
+ end
53
+
54
+ identity = anchor_identity(other)
55
+ unless identity
56
+ raise ConfigurationError,
57
+ 'a :first or :last endpoint needs the other endpoint to name a record'
58
+ end
59
+
60
+ index.dig(identity, endpoint) || endpoint
61
+ end
62
+
63
+ # A symbol carries no identity of its own, so the pair's other endpoint has
64
+ # to say which record is meant.
65
+ #: (untyped) -> Array[String]?
66
+ def anchor_identity(endpoint)
67
+ return if endpoint.nil? || symbolic?(endpoint)
68
+
69
+ Endpoint.identity(endpoint)
70
+ rescue InvalidEndpointError
71
+ nil
72
+ end
73
+
74
+ #: () -> Hash[Array[String], Hash[Symbol, untyped]]
75
+ def boundary_index
76
+ index = {} #: Hash[Array[String], Hash[Symbol, untyped]]
77
+ anchors.group_by { |model_class, _id| model_class }.each do |model_class, entries|
78
+ add_model_boundaries(index, model_class, entries.map { |_klass, id| id }.uniq)
79
+ end
80
+ index
81
+ end
82
+
83
+ #: () -> Array[[untyped, untyped]]
84
+ def anchors
85
+ @pairs.flatten(1).filter_map { |endpoint| anchor(endpoint) }.uniq
86
+ end
87
+
88
+ #: (untyped) -> [untyped, untyped]?
89
+ def anchor(endpoint)
90
+ return if endpoint.nil? || symbolic?(endpoint)
91
+
92
+ if Endpoint.version?(endpoint)
93
+ [Endpoint.model_class(endpoint), endpoint.item_id]
94
+ elsif Endpoint.record?(endpoint)
95
+ [endpoint.class, endpoint.id]
96
+ end
97
+ rescue InvalidEndpointError
98
+ nil
99
+ end
100
+
101
+ #: (Hash[Array[String], Hash[Symbol, untyped]], untyped, Array[untyped]) -> void
102
+ def add_model_boundaries(index, model_class, ids)
103
+ first_ids, last_ids, versions = boundary_versions(model_class, ids)
104
+ ids.each do |id|
105
+ index[identity_key(model_class, id)] = {
106
+ first: versions[boundary_key(first_ids, id)],
107
+ last: versions[boundary_key(last_ids, id)]
108
+ }
109
+ end
110
+ end
111
+
112
+ # Two grouped queries name each root's outermost versions, and one more
113
+ # loads them, whatever the size of the batch.
114
+ #: (untyped, Array[untyped]) -> [Hash[untyped, untyped], Hash[untyped, untyped], Hash[untyped, untyped]]
115
+ def boundary_versions(model_class, ids)
116
+ version_class = model_class.paper_trail.version_class
117
+ scope = version_class.where(item_type: model_class.base_class.name.to_s, item_id: ids)
118
+ first_ids = scope.group(:item_id).minimum(:id)
119
+ last_ids = scope.group(:item_id).maximum(:id)
120
+ loaded = version_class.where(id: (first_ids.values + last_ids.values).uniq).index_by(&:id)
121
+ [first_ids, last_ids, loaded]
122
+ end
123
+
124
+ # Grouped keys come back with whatever type the column uses, so match on the
125
+ # string form rather than assuming integers.
126
+ #: (Hash[untyped, untyped], untyped) -> untyped
127
+ def boundary_key(grouped, id)
128
+ key = grouped.keys.find { |candidate| candidate.to_s == id.to_s }
129
+ grouped[key]
130
+ end
131
+
132
+ #: (untyped, untyped) -> Array[String]
133
+ def identity_key(model_class, id)
134
+ [model_class.base_class.name.to_s, id.to_s]
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Builds one root's Analysis inside a batch, from versions the batch already
6
+ # selected and history it already prepared.
7
+ class BatchedRootAnalyzer
8
+ #: (tree: AssociationTree, timeline_snapshotter: untyped, activity_snapshotter: untyped, preparer: untyped, activity: bool) -> void
9
+ def initialize(tree:, timeline_snapshotter:, activity_snapshotter:, preparer:, activity:)
10
+ @tree = tree
11
+ @timeline_snapshotter = timeline_snapshotter
12
+ @activity_snapshotter = activity_snapshotter
13
+ @preparer = preparer
14
+ @activity = activity
15
+ end
16
+
17
+ #: (untyped, Array[untyped]) -> Analysis
18
+ def call(record, versions)
19
+ @preparer.call(record.class, historical: true)
20
+ return activity_analysis(record, versions) if @activity
21
+
22
+ TimelineBuilder.new(
23
+ record,
24
+ from: versions.first,
25
+ to: versions.last,
26
+ within: nil,
27
+ versions: versions,
28
+ snapshotter: @timeline_snapshotter
29
+ ).analyze
30
+ end
31
+
32
+ private
33
+
34
+ # @rbs @tree: AssociationTree
35
+ # @rbs @timeline_snapshotter: untyped
36
+ # @rbs @activity_snapshotter: untyped
37
+ # @rbs @preparer: untyped
38
+ # @rbs @activity: bool
39
+
40
+ #: (untyped, Array[untyped]) -> Analysis
41
+ def activity_analysis(record, versions)
42
+ range = TimelineRange.new(
43
+ record, from: versions.first, to: versions.last, within: nil, versions: versions
44
+ )
45
+ ActivityTimelineBuilder.new(
46
+ record, range: range, tree: @tree, snapshotter: @activity_snapshotter
47
+ ).analyze
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Selects each root's versions for a batched range in a fixed number of
6
+ # queries. Only the range forms that mean the same thing for every root are
7
+ # supported: a shared wall-clock window, or each root's own whole history.
8
+ class BatchedRootVersions
9
+ #: (Array[untyped], time_range: TimeRange?) -> void
10
+ def initialize(records, time_range:)
11
+ @records = records
12
+ @time_range = time_range
13
+ end
14
+
15
+ # Returns root versions per record identity, in chronological order.
16
+ #: () -> Hash[Array[String], Array[untyped]]
17
+ def call
18
+ return {} if @records.empty?
19
+
20
+ selected = {} #: Hash[Array[String], Array[untyped]]
21
+ @records.group_by(&:class).each do |model_class, records|
22
+ select_model(model_class, records, selected)
23
+ end
24
+ selected
25
+ end
26
+
27
+ private
28
+
29
+ # @rbs @records: Array[untyped]
30
+ # @rbs @time_range: TimeRange?
31
+
32
+ #: (untyped, Array[untyped], Hash[Array[String], Array[untyped]]) -> void
33
+ def select_model(model_class, records, selected)
34
+ 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
39
+ records.each do |record|
40
+ key = identity(model_class, record.id)
41
+ selected[key] = versions_for(grouped.fetch(record.id.to_s, []), trailing[record.id.to_s])
42
+ end
43
+ end
44
+
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
58
+
59
+ (in_range + [trailing]).freeze
60
+ end
61
+
62
+ #: (Array[untyped]) -> bool
63
+ def terminal_destroy?(versions)
64
+ versions.last&.event.to_s == 'destroy'
65
+ end
66
+
67
+ #: (untyped, Array[untyped], TimeRange?) -> Hash[String, Array[untyped]]
68
+ def grouped_versions(model_class, ids, range)
69
+ scope = base_scope(model_class, ids)
70
+ scope = range.scope(scope) if range
71
+ ordered(scope).group_by { |version| version.item_id.to_s }
72
+ end
73
+
74
+ # One row per root: the earliest version after the window, found without a
75
+ # window function so the query stays portable.
76
+ #: (untyped, Array[untyped], TimeRange) -> Hash[String, untyped]
77
+ def trailing_versions(model_class, ids, range)
78
+ table = model_class.paper_trail.version_class.arel_table
79
+ relation = range.trailing_scope(base_scope(model_class, ids))
80
+ ordered(relation.where(no_earlier_trailing(table, range)))
81
+ .to_h { |version| [version.item_id.to_s, version] }
82
+ end
83
+
84
+ #: (untyped, TimeRange) -> untyped
85
+ def no_earlier_trailing(table, range)
86
+ arel = Object.const_get(:Arel) #: untyped
87
+ later = table.alias('paper_trail_diff_later_roots')
88
+ arel.const_get(:SelectManager).new
89
+ .from(later)
90
+ .project(arel.sql('1'))
91
+ .where(preceding(table, later, range))
92
+ .exists
93
+ .not
94
+ end
95
+
96
+ # Mirrors the window's own end handling, so a version sitting exactly on an
97
+ # inclusive boundary stays inside the window instead of masking the trailing
98
+ # version that follows it.
99
+ #: (untyped, untyped, TimeRange) -> untyped
100
+ def preceding(table, later, range)
101
+ same_item = later[:item_type].eq(table[:item_type])
102
+ .and(later[:item_id].eq(table[:item_id]))
103
+ same_item.and(after_window(later, range)).and(later[:created_at].lt(table[:created_at]))
104
+ end
105
+
106
+ #: (untyped, TimeRange) -> untyped
107
+ def after_window(later, range)
108
+ return later[:created_at].gteq(range.end_time) if range.exclude_end?
109
+
110
+ later[:created_at].gt(range.end_time)
111
+ end
112
+
113
+ #: (untyped, Array[untyped]) -> untyped
114
+ def base_scope(model_class, ids)
115
+ model_class.paper_trail.version_class.where(
116
+ item_type: model_class.base_class.name.to_s,
117
+ item_id: ids
118
+ )
119
+ end
120
+
121
+ #: (untyped) -> Array[untyped]
122
+ def ordered(relation)
123
+ relation.reorder(created_at: :asc, id: :asc).to_a
124
+ end
125
+
126
+ #: (untyped, untyped) -> Array[String]
127
+ def identity(model_class, id)
128
+ [model_class.base_class.name.to_s, id.to_s]
129
+ end
130
+ end
131
+ end
@@ -23,18 +23,10 @@ module PaperTrailDiff
23
23
 
24
24
  #: () -> comparison_results
25
25
  def call
26
- pairs = comparison_pairs
27
- pairs.each { |from, to| Endpoint.validate_pair!(from, to) }
28
- ensure_unique_identities!(pairs)
29
- prepare_endpoint_classes!(pairs)
30
- live_records = load_live_records(pairs.flatten)
31
- prepare_historical_batches!(pairs, live_records)
32
- live_snapshots = normalize_live_records(live_records)
33
-
34
- pairs.to_h do |from, to|
35
- identity = Support.immutable_copy(Endpoint.identity(from))
36
- [identity, compare(from, to, live_snapshots)]
37
- end.freeze
26
+ resolved = BatchBoundaryResolver.new(comparison_pairs).call
27
+ pairs = resolved.reject { |pair| pair.any? { |endpoint| symbolic?(endpoint) } }
28
+ ensure_unique_identities!(resolved)
29
+ results(resolved, prepared_live_snapshots(pairs)).freeze
38
30
  end
39
31
 
40
32
  private
@@ -46,6 +38,38 @@ module PaperTrailDiff
46
38
  # @rbs @historical_snapshotter: untyped
47
39
  # @rbs @live_normalizer: untyped
48
40
 
41
+ #: (Array[[untyped, untyped]]) -> Hash[identity, RecordSnapshot]
42
+ def prepared_live_snapshots(pairs)
43
+ pairs.each { |from, to| Endpoint.validate_pair!(from, to) }
44
+ prepare_endpoint_classes!(pairs)
45
+ live_records = load_live_records(pairs.flatten)
46
+ prepare_historical_batches!(pairs, live_records)
47
+ normalize_live_records(live_records)
48
+ end
49
+
50
+ #: (untyped) -> bool
51
+ def symbolic?(endpoint)
52
+ BatchBoundaryResolver.symbolic?(endpoint)
53
+ end
54
+
55
+ # A boundary symbol that stayed unresolved means the root has no recorded
56
+ # history. An absent history is an empty result rather than a failed
57
+ # request, matching how the timeline APIs answer the same question.
58
+ #: (Array[[untyped, untyped]], Hash[identity, RecordSnapshot]) -> comparison_results
59
+ def results(resolved, live_snapshots)
60
+ resolved.to_h do |from, to|
61
+ identity = Support.immutable_copy(entry_identity(from, to))
62
+ next [identity, Diff.new] if symbolic?(from) || symbolic?(to)
63
+
64
+ [identity, compare(from, to, live_snapshots)]
65
+ end
66
+ end
67
+
68
+ #: (untyped, untyped) -> identity
69
+ def entry_identity(from, to)
70
+ Endpoint.identity(symbolic?(from) ? to : from)
71
+ end
72
+
49
73
  #: (untyped, untyped, Hash[identity, RecordSnapshot]) -> Diff
50
74
  def compare(from, to, live_snapshots)
51
75
  Engine.compare(snapshot(from, live_snapshots), snapshot(to, live_snapshots))
@@ -125,7 +149,7 @@ module PaperTrailDiff
125
149
 
126
150
  #: (Array[[untyped, untyped]]) -> void
127
151
  def ensure_unique_identities!(pairs)
128
- identities = pairs.map { |from, _to| Endpoint.identity(from) }
152
+ identities = pairs.map { |from, to| entry_identity(from, to) }
129
153
  return if identities.uniq.length == identities.length
130
154
 
131
155
  raise ConfigurationError, 'comparisons: root identities must be unique'
@@ -24,6 +24,8 @@ module PaperTrailDiff
24
24
  end_at: root_versions.last.created_at
25
25
  )
26
26
  return if @tree.empty?
27
+ # A batched preparation already covers these roots.
28
+ return if root_versions.any? { |v| @prepared_histories.key?(context_key(v)) }
27
29
 
28
30
  @prepared_history = PreparedHistoryLoader.new(
29
31
  record,
@@ -35,7 +37,6 @@ module PaperTrailDiff
35
37
  ).call
36
38
  end
37
39
 
38
- # Prepares selected history for several roots of the same model class.
39
40
  #: (Array[untyped], Array[untyped]) -> void
40
41
  def prepare_batch(records, root_versions)
41
42
  return if @tree.empty? || records.empty? || root_versions.empty?
@@ -50,12 +51,7 @@ module PaperTrailDiff
50
51
  key = snapshot_key(root_endpoint, context_endpoint)
51
52
  return @snapshots[key] if @snapshots.key?(key)
52
53
 
53
- @snapshots[key] = custom(
54
- root_endpoint,
55
- context_endpoint,
56
- tree: @tree,
57
- normalizer: @normalizer
58
- )
54
+ @snapshots[key] = uncached(root_endpoint, context_endpoint)
59
55
  end
60
56
 
61
57
  #: (untyped, untyped) -> RecordSnapshot?