paper_trail_diff 0.2.0 → 0.3.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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -0
  3. data/QUICKSTART.md +44 -2
  4. data/README.md +179 -14
  5. data/lib/paper_trail_diff/activity_child_candidate_loader.rb +73 -0
  6. data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +261 -45
  7. data/lib/paper_trail_diff/activity_history.rb +136 -0
  8. data/lib/paper_trail_diff/activity_range.rb +28 -12
  9. data/lib/paper_trail_diff/activity_snapshot_delta.rb +64 -0
  10. data/lib/paper_trail_diff/activity_snapshot_sequence.rb +3 -3
  11. data/lib/paper_trail_diff/activity_timeline_builder.rb +54 -45
  12. data/lib/paper_trail_diff/activity_version_collector.rb +14 -17
  13. data/lib/paper_trail_diff/branch_snapshot_refresher.rb +5 -3
  14. data/lib/paper_trail_diff/collection_comparator.rb +155 -0
  15. data/lib/paper_trail_diff/collection_identity_index.rb +53 -0
  16. data/lib/paper_trail_diff/collection_transition.rb +25 -0
  17. data/lib/paper_trail_diff/comparison_batch.rb +151 -0
  18. data/lib/paper_trail_diff/engine.rb +5 -45
  19. data/lib/paper_trail_diff/errors.rb +9 -0
  20. data/lib/paper_trail_diff/historical_snapshot_store.rb +55 -3
  21. data/lib/paper_trail_diff/instrumentation.rb +30 -0
  22. data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +133 -0
  23. data/lib/paper_trail_diff/live_endpoint_provider.rb +48 -0
  24. data/lib/paper_trail_diff/live_graph_collector.rb +52 -0
  25. data/lib/paper_trail_diff/paper_trail_adapter.rb +61 -34
  26. data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +48 -0
  27. data/lib/paper_trail_diff/prepared_edge_loader.rb +54 -17
  28. data/lib/paper_trail_diff/prepared_history.rb +14 -4
  29. data/lib/paper_trail_diff/prepared_history_loader.rb +17 -9
  30. data/lib/paper_trail_diff/prepared_record_index.rb +138 -10
  31. data/lib/paper_trail_diff/snapshot.rb +66 -3
  32. data/lib/paper_trail_diff/support.rb +18 -0
  33. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +111 -0
  34. data/lib/paper_trail_diff/time_range.rb +85 -0
  35. data/lib/paper_trail_diff/time_version_range.rb +53 -0
  36. data/lib/paper_trail_diff/timeline_builder.rb +13 -7
  37. data/lib/paper_trail_diff/timeline_range.rb +72 -0
  38. data/lib/paper_trail_diff/version.rb +1 -1
  39. data/lib/paper_trail_diff/version_association_candidate_scope.rb +73 -0
  40. data/lib/paper_trail_diff.rb +74 -12
  41. data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +40 -0
  42. data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +49 -8
  43. data/sig/generated/paper_trail_diff/activity_history.rbs +79 -0
  44. data/sig/generated/paper_trail_diff/activity_range.rbs +14 -5
  45. data/sig/generated/paper_trail_diff/activity_snapshot_delta.rbs +33 -0
  46. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +2 -2
  47. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +15 -10
  48. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +2 -5
  49. data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
  50. data/sig/generated/paper_trail_diff/collection_comparator.rbs +59 -0
  51. data/sig/generated/paper_trail_diff/collection_identity_index.rbs +29 -0
  52. data/sig/generated/paper_trail_diff/collection_transition.rbs +18 -0
  53. data/sig/generated/paper_trail_diff/comparison_batch.rbs +65 -0
  54. data/sig/generated/paper_trail_diff/engine.rbs +0 -12
  55. data/sig/generated/paper_trail_diff/errors.rbs +12 -0
  56. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +22 -2
  57. data/sig/generated/paper_trail_diff/instrumentation.rbs +12 -0
  58. data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +52 -0
  59. data/sig/generated/paper_trail_diff/live_endpoint_provider.rbs +23 -0
  60. data/sig/generated/paper_trail_diff/live_graph_collector.rbs +27 -0
  61. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +21 -10
  62. data/sig/generated/paper_trail_diff/preloaded_endpoint_batch_loader.rbs +24 -0
  63. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +22 -8
  64. data/sig/generated/paper_trail_diff/prepared_history.rbs +6 -0
  65. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +4 -2
  66. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +61 -6
  67. data/sig/generated/paper_trail_diff/snapshot.rbs +37 -2
  68. data/sig/generated/paper_trail_diff/support.rbs +6 -0
  69. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +46 -0
  70. data/sig/generated/paper_trail_diff/time_range.rbs +41 -0
  71. data/sig/generated/paper_trail_diff/time_version_range.rbs +27 -0
  72. data/sig/generated/paper_trail_diff/timeline_builder.rbs +6 -5
  73. data/sig/generated/paper_trail_diff/timeline_range.rbs +41 -0
  74. data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +32 -0
  75. data/sig/generated/paper_trail_diff.rbs +16 -8
  76. metadata +38 -4
@@ -11,16 +11,17 @@ module PaperTrailDiff
11
11
  }.freeze
12
12
  private_constant :RECORD_AFTER_HANDLERS
13
13
 
14
- #: (traversal: AssociationTraversal, pool: SnapshotPool, components: untyped, ?association_reader: untyped) -> void
15
- def initialize(traversal:, pool:, components:, association_reader: nil)
14
+ #: (traversal: AssociationTraversal, pool: SnapshotPool, components: untyped, ?association_reader: untyped, ?record_transition: untyped) -> void
15
+ def initialize(traversal:, pool:, components:, association_reader: nil, record_transition: nil)
16
16
  @traversal = traversal
17
17
  @pool = pool
18
18
  @components = components
19
19
  @association_reader = association_reader || method(:historical_reader)
20
+ @record_transition = record_transition
20
21
  end
21
22
 
22
23
  #: (untyped, untyped, RecordSnapshot, Array[String], ActivityEvent?) -> [bool, RecordSnapshot?]
23
- def call( # rubocop:disable Metrics/MethodLength
24
+ def call( # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
24
25
  root_endpoint,
25
26
  context_endpoint,
26
27
  previous,
@@ -38,13 +39,17 @@ module PaperTrailDiff
38
39
  unless collection_routes.empty?
39
40
  return [false, nil] if unsafe_through_membership_event?(collection_routes, event.version)
40
41
 
42
+ delta = activity_snapshot_delta(event.version)
43
+ return [false, nil] if unsafe_nested_membership_delta?(collection_routes, delta)
44
+
41
45
  return collection_target_snapshot(
42
46
  root_endpoint,
43
47
  context_endpoint,
44
48
  previous,
45
49
  normalizer,
46
50
  collection_routes,
47
- event.version
51
+ event.version,
52
+ delta
48
53
  )
49
54
  end
50
55
 
@@ -64,6 +69,7 @@ module PaperTrailDiff
64
69
  # @rbs @pool: SnapshotPool
65
70
  # @rbs @components: untyped
66
71
  # @rbs @association_reader: untyped
72
+ # @rbs @record_transition: untyped
67
73
 
68
74
  #: (Array[Array[untyped]], untyped) -> bool
69
75
  def unsafe_through_membership_event?(routes, version)
@@ -77,35 +83,111 @@ module PaperTrailDiff
77
83
  end
78
84
  end
79
85
 
80
- #: (untyped, untyped, RecordSnapshot, SnapshotNormalizer, Array[Array[untyped]], untyped) -> [bool, RecordSnapshot]
86
+ #: (Array[Array[untyped]], ActivitySnapshotDelta?) -> bool
87
+ def unsafe_nested_membership_delta?(routes, delta)
88
+ return false unless delta
89
+
90
+ routes.any? do |route|
91
+ route.length > 1 && delta.relationship_changed?(route.last.fetch(1))
92
+ end
93
+ end
94
+
95
+ #: (untyped, untyped, RecordSnapshot, SnapshotNormalizer, Array[Array[untyped]], untyped, ActivitySnapshotDelta?) -> [bool, RecordSnapshot]
81
96
  def collection_target_snapshot( # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
82
97
  root_endpoint,
83
98
  context_endpoint,
84
99
  previous,
85
100
  normalizer,
86
101
  routes,
87
- version
102
+ version,
103
+ delta
88
104
  )
89
- record = record_after(version)
105
+ fallback_record = [] #: Array[untyped]
90
106
  snapshot = previous
91
107
  routes.each do |route|
92
- _name, reflection, subtree, path = route.last
93
- replacement = if record
94
- normalize_event_record(
95
- record,
96
- reflection,
97
- subtree,
98
- path,
99
- normalizer,
100
- root_endpoint,
101
- context_endpoint
102
- )
103
- end
108
+ record, replacement = collection_route_event_state(
109
+ snapshot,
110
+ route,
111
+ version,
112
+ delta,
113
+ fallback_record,
114
+ normalizer,
115
+ root_endpoint,
116
+ context_endpoint
117
+ )
104
118
  snapshot, = replace_collection_route(snapshot, route, version, record, replacement)
105
119
  end
106
120
  [true, snapshot]
107
121
  end
108
122
 
123
+ #: (RecordSnapshot, Array[untyped], untyped, ActivitySnapshotDelta?, Array[untyped], SnapshotNormalizer, untyped, untyped) -> [untyped, RecordSnapshot?]
124
+ def collection_route_event_state( # rubocop:disable Metrics/ParameterLists
125
+ snapshot,
126
+ route,
127
+ version,
128
+ delta,
129
+ fallback_record,
130
+ normalizer,
131
+ root_endpoint,
132
+ context_endpoint
133
+ )
134
+ existing = existing_delta_record(snapshot, route, version, delta) if delta
135
+ return [delta, delta.apply(existing)] if delta && existing
136
+
137
+ fallback_record << record_after(version) if fallback_record.empty?
138
+ record = fallback_record.first
139
+ return [record, nil] unless record
140
+
141
+ [record, normalized_collection_event_record(
142
+ record, route, normalizer, root_endpoint, context_endpoint
143
+ )]
144
+ end
145
+
146
+ #: (untyped, Array[untyped], SnapshotNormalizer, untyped, untyped) -> RecordSnapshot?
147
+ def normalized_collection_event_record(
148
+ record,
149
+ route,
150
+ normalizer,
151
+ root_endpoint,
152
+ context_endpoint
153
+ )
154
+ _name, reflection, subtree, path = route.last
155
+ normalize_event_record(
156
+ record,
157
+ reflection,
158
+ subtree,
159
+ path,
160
+ normalizer,
161
+ root_endpoint,
162
+ context_endpoint
163
+ )
164
+ end
165
+
166
+ #: (RecordSnapshot, Array[untyped], untyped, ActivitySnapshotDelta?) -> RecordSnapshot?
167
+ def existing_delta_record(snapshot, route, version, delta)
168
+ return unless delta && route.length.between?(1, 2)
169
+
170
+ owner = route.length == 1 ? snapshot : nested_delta_owner(snapshot, route, delta)
171
+ return unless owner
172
+
173
+ association = owner.associations.fetch(route.last.fetch(0))
174
+ position = association.position_for_id(version.item_id)
175
+ association.records.fetch(position) if position
176
+ end
177
+
178
+ #: (RecordSnapshot, Array[untyped], ActivitySnapshotDelta) -> RecordSnapshot?
179
+ def nested_delta_owner(snapshot, route, delta)
180
+ parent_name = route.first.fetch(0)
181
+ reflection = route.last.fetch(1)
182
+ association = snapshot.associations.fetch(parent_name)
183
+ owner_id = relationship_owner_id(delta, reflection, state: :before)
184
+ position = association.position_for_id(owner_id)
185
+ return unless position
186
+
187
+ owner = association.records.fetch(position)
188
+ owner if member_of_owner?(delta, reflection, owner, state: :before)
189
+ end
190
+
109
191
  #: (untyped, AssociationTree, String, ?path: String) -> Array[Array[untyped]]
110
192
  def direct_collection_routes( # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
111
193
  model_class,
@@ -150,15 +232,31 @@ module PaperTrailDiff
150
232
  name, reflection, _subtree, path = route.fetch(depth)
151
233
  association = snapshot.associations.fetch(name)
152
234
  changed = false
235
+ transition_before = nil #: RecordSnapshot?
236
+ transition_after = nil #: RecordSnapshot?
237
+ membership_preserved = true
153
238
  updated_records = if depth == route.length - 1
154
- child = if record && member_of_owner?(record, reflection, snapshot)
155
- replacement
156
- end
157
- replace_collection_record(
158
- association.records,
239
+ child = collection_event_child(
240
+ association,
159
241
  version,
160
- child
161
- ).tap { |value| changed = !value.equal?(association.records) }
242
+ record,
243
+ reflection,
244
+ snapshot,
245
+ replacement
246
+ )
247
+ records, transition_before, transition_after, membership_preserved =
248
+ replace_collection_record(association, version, child)
249
+ records.tap { |value| changed = !value.equal?(association.records) }
250
+ elsif (targeted = replace_targeted_collection_parent(
251
+ association,
252
+ route,
253
+ version,
254
+ record,
255
+ replacement,
256
+ depth
257
+ ))
258
+ records, transition_before, transition_after, changed = targeted
259
+ records
162
260
  else
163
261
  association.records.map do |child_snapshot|
164
262
  updated, child_changed = replace_collection_route(
@@ -169,15 +267,28 @@ module PaperTrailDiff
169
267
  replacement,
170
268
  depth: depth + 1
171
269
  )
172
- changed ||= child_changed
270
+ if child_changed
271
+ transition_before = nil if changed
272
+ transition_after = nil if changed
273
+ unless changed
274
+ transition_before = child_snapshot
275
+ transition_after = updated
276
+ end
277
+ changed = true
278
+ end
173
279
  updated
174
- end
280
+ end.freeze
175
281
  end
176
282
  return [snapshot, false] unless changed
177
283
 
178
284
  updated_association = @pool.association(
179
285
  path,
180
- AssociationSnapshot.new(kind: association.kind, records: updated_records)
286
+ association.transition_to(
287
+ updated_records,
288
+ before: transition_before,
289
+ after: transition_after,
290
+ membership_preserved: membership_preserved
291
+ )
181
292
  )
182
293
  updated_snapshot = RecordSnapshot.new(
183
294
  type: snapshot.type,
@@ -190,6 +301,73 @@ module PaperTrailDiff
190
301
  [updated_snapshot, true]
191
302
  end
192
303
 
304
+ #: (AssociationSnapshot, untyped, untyped, untyped, RecordSnapshot, RecordSnapshot?) -> RecordSnapshot?
305
+ def collection_event_child( # rubocop:disable Metrics/ParameterLists
306
+ association, version, record, reflection, owner, replacement
307
+ )
308
+ return unless record
309
+
310
+ unless record.is_a?(ActivitySnapshotDelta)
311
+ return replacement if member_of_owner?(record, reflection, owner)
312
+
313
+ return
314
+ end
315
+ unless record.relationship_changed?(reflection)
316
+ return replacement if association.position_for_id(version.item_id)
317
+
318
+ return
319
+ end
320
+
321
+ replacement if member_of_owner?(record, reflection, owner)
322
+ end
323
+
324
+ #: (AssociationSnapshot, Array[untyped], untyped, untyped, RecordSnapshot?, Integer) -> [Array[RecordSnapshot], RecordSnapshot?, RecordSnapshot?, bool]?
325
+ def replace_targeted_collection_parent( # rubocop:disable Metrics/ParameterLists
326
+ association,
327
+ route,
328
+ version,
329
+ record,
330
+ replacement,
331
+ depth
332
+ )
333
+ return unless depth == route.length - 2
334
+
335
+ reflection = route.fetch(depth + 1).fetch(1)
336
+ position = nested_owner_position(association, reflection, version, record)
337
+ return unless position
338
+
339
+ before = association.records.fetch(position)
340
+ after, changed = replace_collection_route(
341
+ before,
342
+ route,
343
+ version,
344
+ record,
345
+ replacement,
346
+ depth: depth + 1
347
+ )
348
+ targeted_parent_replacement(association.records, position, before, after, changed)
349
+ end
350
+
351
+ #: (Array[RecordSnapshot], Integer, RecordSnapshot, RecordSnapshot, bool) -> [Array[RecordSnapshot], RecordSnapshot?, RecordSnapshot?, bool]
352
+ def targeted_parent_replacement(records, position, before, after, changed)
353
+ return [records, nil, nil, false] unless changed
354
+
355
+ updated = records.dup
356
+ updated[position] = after
357
+ [updated.freeze, before, after, true]
358
+ end
359
+
360
+ #: (AssociationSnapshot, untyped, untyped, untyped) -> Integer?
361
+ def nested_owner_position(association, reflection, version, record)
362
+ membership_record = record || version.reify(dup: true)
363
+ return unless membership_record
364
+ return if membership_record.is_a?(ActivitySnapshotDelta) &&
365
+ membership_record.relationship_changed?(reflection)
366
+
367
+ owner_id = relationship_owner_id(membership_record, reflection, state: :after)
368
+ association.position_for_id(owner_id)
369
+ end
370
+
193
371
  #: (untyped, untyped, RecordSnapshot, AssociationTree, SnapshotNormalizer, untyped) -> [bool, RecordSnapshot?]
194
372
  def belongs_to_target_snapshot( # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
195
373
  root_endpoint,
@@ -291,7 +469,7 @@ module PaperTrailDiff
291
469
  )
292
470
  changed ||= record_changed
293
471
  updated
294
- end
472
+ end.freeze
295
473
  end
296
474
  return [snapshot, false] unless changed
297
475
 
@@ -318,7 +496,7 @@ module PaperTrailDiff
318
496
 
319
497
  updated = records.dup
320
498
  replacement ? updated[index] = replacement : updated.delete_at(index)
321
- updated
499
+ updated.freeze
322
500
  end
323
501
 
324
502
  #: (untyped) -> untyped
@@ -409,10 +587,23 @@ module PaperTrailDiff
409
587
  changes
410
588
  end
411
589
 
412
- #: (untyped, untyped, RecordSnapshot) -> bool
413
- def member_of_owner?(record, reflection, owner) # rubocop:disable Metrics/AbcSize
590
+ #: (untyped) -> ActivitySnapshotDelta?
591
+ def activity_snapshot_delta(version)
592
+ return unless version.event.to_s == 'update' && version.object
593
+
594
+ transition = @record_transition&.call(version)
595
+ return unless transition
596
+
597
+ ActivitySnapshotDelta.new(
598
+ before_attributes: transition.fetch(0),
599
+ after_attributes: transition.fetch(1)
600
+ )
601
+ end
602
+
603
+ #: (untyped, untyped, RecordSnapshot, ?state: Symbol) -> bool
604
+ def member_of_owner?(record, reflection, owner, state: :after) # rubocop:disable Metrics/AbcSize
414
605
  foreign_keys = Array(reflection.foreign_key)
415
- actual_ids = foreign_keys.map { |key| record.public_send(key) }
606
+ actual_ids = foreign_keys.map { |key| event_attribute(record, key, state: state) }
416
607
  expected_ids = Array(owner.id)
417
608
  # @type var expected_ids: Array[untyped]
418
609
  # Explicit blocks keep the inline RBS checker from inferring an unusable Proc type.
@@ -421,7 +612,7 @@ module PaperTrailDiff
421
612
  return false unless actual == expected
422
613
  return true unless reflection.options[:as]
423
614
 
424
- type = record.public_send(reflection.type).to_s
615
+ type = event_attribute(record, reflection.type, state: state).to_s
425
616
  owner_types = [
426
617
  owner.type.to_s,
427
618
  reflection.active_record.name.to_s,
@@ -430,6 +621,23 @@ module PaperTrailDiff
430
621
  owner_types.include?(type)
431
622
  end
432
623
 
624
+ #: (untyped, untyped, state: Symbol) -> untyped
625
+ def event_attribute(record, name, state:)
626
+ if record.is_a?(ActivitySnapshotDelta)
627
+ return state == :before ? record.before_value(name) : record.after_value(name)
628
+ end
629
+
630
+ record.public_send(name)
631
+ end
632
+
633
+ #: (untyped, untyped, state: Symbol) -> untyped
634
+ def relationship_owner_id(record, reflection, state:)
635
+ owner_ids = Array(reflection.foreign_key).map do |foreign_key|
636
+ event_attribute(record, foreign_key, state: state)
637
+ end
638
+ owner_ids.one? ? owner_ids.first : owner_ids
639
+ end
640
+
433
641
  #: (untyped, untyped, AssociationTree, String, SnapshotNormalizer, untyped, untyped) -> RecordSnapshot?
434
642
  def normalize_event_record( # rubocop:disable Metrics/ParameterLists
435
643
  record,
@@ -459,22 +667,30 @@ module PaperTrailDiff
459
667
  HistoricalAssociationReifier.new(context_endpoint, habtm_version: habtm_version)
460
668
  end
461
669
 
462
- #: (Array[RecordSnapshot], untyped, RecordSnapshot?) -> Array[RecordSnapshot]
463
- def replace_collection_record(records, version, replacement)
464
- index = records.index do |record|
465
- record.id.to_s == version.item_id.to_s &&
466
- record.type.to_s == replacement_type(version, replacement)
467
- end
468
- return records unless replacement || index
670
+ #: (AssociationSnapshot, untyped, RecordSnapshot?) -> [Array[RecordSnapshot], RecordSnapshot?, RecordSnapshot?, bool]
671
+ def replace_collection_record(association, version, replacement)
672
+ records = association.records
673
+ position = association.position(
674
+ replacement_type(version, replacement),
675
+ version.item_id
676
+ )
677
+ return [records, nil, nil, true] unless replacement || position
678
+
679
+ before = records.fetch(position) if position
680
+ updated = replacement_collection_records(records, position, replacement)
681
+ [updated, before, replacement, !before.nil? && !replacement.nil?]
682
+ end
469
683
 
684
+ #: (Array[RecordSnapshot], Integer?, RecordSnapshot?) -> Array[RecordSnapshot]
685
+ def replacement_collection_records(records, position, replacement)
470
686
  updated = records.dup
471
687
  if replacement
472
- index ? updated[index] = replacement : updated << replacement
688
+ position ? updated[position] = replacement : updated << replacement
473
689
  else
474
- position = index || raise(ConfigurationError, 'missing collection event identity')
690
+ position ||= raise(ConfigurationError, 'missing collection event identity')
475
691
  updated.delete_at(position)
476
692
  end
477
- updated
693
+ updated.freeze
478
694
  end
479
695
 
480
696
  #: (untyped, RecordSnapshot?) -> String
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Immutable snapshots and steps produced by one activity-history pass.
6
+ class ActivityHistory
7
+ attr_reader :steps #: Array[ActivityStep]
8
+ attr_reader :root_snapshots #: Hash[Array[untyped], RecordSnapshot?]
9
+ attr_reader :first_snapshot #: RecordSnapshot?
10
+ attr_reader :last_snapshot #: RecordSnapshot?
11
+
12
+ #: (steps: Array[ActivityStep], root_snapshots: Hash[Array[untyped], RecordSnapshot?], first_snapshot: RecordSnapshot?, last_snapshot: RecordSnapshot?) -> void
13
+ def initialize(steps:, root_snapshots:, first_snapshot:, last_snapshot:)
14
+ @steps = steps.freeze
15
+ @root_snapshots = root_snapshots.freeze
16
+ @first_snapshot = first_snapshot
17
+ @last_snapshot = last_snapshot
18
+ freeze
19
+ end
20
+
21
+ #: () -> ActivityHistory
22
+ def self.empty
23
+ new(steps: [], root_snapshots: {}, first_snapshot: nil, last_snapshot: nil)
24
+ end
25
+ end
26
+
27
+ # Builds activity steps while retaining root snapshots for combined analysis.
28
+ class ActivityHistoryBuilder
29
+ #: (Array[untyped], Array[ActivityEvent], untyped, ?current: untyped, ?include_step: untyped) -> void
30
+ def initialize(root_versions, events, snapshotter, current: nil, include_step: nil)
31
+ @root_versions = root_versions
32
+ @events = events
33
+ @snapshotter = snapshotter
34
+ @current = current
35
+ @include_step = include_step
36
+ end
37
+
38
+ #: () -> ActivityHistory
39
+ def call
40
+ reset
41
+ sequence.each { |event, snapshot| consume(event, snapshot) }
42
+ ActivityHistory.new(
43
+ steps: @steps,
44
+ root_snapshots: @root_snapshots,
45
+ first_snapshot: @first_snapshot,
46
+ last_snapshot: final_snapshot
47
+ )
48
+ end
49
+
50
+ private
51
+
52
+ # @rbs @root_versions: Array[untyped]
53
+ # @rbs @events: Array[ActivityEvent]
54
+ # @rbs @snapshotter: untyped
55
+ # @rbs @current: untyped
56
+ # @rbs @include_step: untyped
57
+ # @rbs @steps: Array[ActivityStep]
58
+ # @rbs @root_snapshots: Hash[Array[untyped], RecordSnapshot?]
59
+ # @rbs @first_snapshot: RecordSnapshot?
60
+ # @rbs @previous_snapshot: RecordSnapshot?
61
+ # @rbs @selected_last_snapshot: RecordSnapshot?
62
+ # @rbs @previous_boundary: ActivityBoundary?
63
+ # @rbs @previous_event: ActivityEvent?
64
+
65
+ #: () -> void
66
+ def reset
67
+ @steps = []
68
+ @root_snapshots = {}
69
+ @first_snapshot = nil
70
+ @first_snapshot_seen = false
71
+ @previous_snapshot = nil
72
+ @selected_last_snapshot = nil
73
+ @previous_boundary = nil
74
+ @previous_event = nil
75
+ end
76
+
77
+ #: () -> ActivitySnapshotSequence
78
+ def sequence
79
+ ActivitySnapshotSequence.new(
80
+ @root_versions,
81
+ @events,
82
+ @snapshotter,
83
+ current: @current
84
+ )
85
+ end
86
+
87
+ #: (ActivityEvent, RecordSnapshot?) -> void
88
+ def consume(event, snapshot)
89
+ capture_first(snapshot)
90
+ boundary = ActivityBoundary.from_version(event.version)
91
+ @root_snapshots[version_key(event.version)] = snapshot if event.root?
92
+ append_step(boundary, snapshot) if @previous_boundary
93
+ @previous_boundary = boundary
94
+ @previous_event = event
95
+ @previous_snapshot = snapshot
96
+ end
97
+
98
+ #: (RecordSnapshot?) -> void
99
+ def capture_first(snapshot)
100
+ return if @first_snapshot_seen
101
+
102
+ @first_snapshot = snapshot
103
+ @first_snapshot_seen = true
104
+ end
105
+
106
+ #: (ActivityBoundary, RecordSnapshot?) -> void
107
+ def append_step(boundary, snapshot)
108
+ return unless include_previous_step?
109
+
110
+ previous_boundary = @previous_boundary
111
+ return unless previous_boundary
112
+
113
+ @steps << ActivityStep.new(
114
+ from_boundary: previous_boundary,
115
+ to_boundary: boundary,
116
+ diff: Engine.compare(@previous_snapshot, snapshot)
117
+ )
118
+ @selected_last_snapshot = snapshot
119
+ end
120
+
121
+ #: () -> bool
122
+ def include_previous_step?
123
+ !@include_step || @include_step.call(@previous_event)
124
+ end
125
+
126
+ #: () -> RecordSnapshot?
127
+ def final_snapshot
128
+ @include_step ? @selected_last_snapshot : @previous_snapshot
129
+ end
130
+
131
+ #: (untyped) -> Array[untyped]
132
+ def version_key(version)
133
+ [version.class.name, version.id]
134
+ end
135
+ end
136
+ end
@@ -4,11 +4,18 @@
4
4
  module PaperTrailDiff
5
5
  # Tests activity versions against a historical-version or wall-clock range end.
6
6
  class ActivityRange
7
+ attr_reader :start_time #: untyped
8
+ attr_reader :end_time #: untyped
9
+
7
10
  #: (untyped, untyped) -> void
8
- def initialize(start_version, end_boundary)
9
- @start_version = start_version
11
+ def initialize(start_boundary, end_boundary)
12
+ @start_boundary = start_boundary
10
13
  @end_boundary = end_boundary
11
- @start_key = Support.chronological_version_key(start_version)
14
+ @start_key = if Endpoint.version?(start_boundary)
15
+ Support.chronological_version_key(start_boundary)
16
+ end
17
+ @start_time = Endpoint.version?(start_boundary) ? start_boundary.created_at : start_boundary
18
+ @end_time = Endpoint.version?(end_boundary) ? end_boundary.created_at : end_boundary
12
19
  @end_key = if Endpoint.version?(end_boundary)
13
20
  Support.chronological_version_key(end_boundary)
14
21
  end
@@ -17,23 +24,37 @@ module PaperTrailDiff
17
24
  #: (untyped) -> bool
18
25
  def include?(version)
19
26
  key = Support.chronological_version_key(version)
20
- key_before_or_equal?(@start_key, key) && before_end?(version, key)
27
+ after_start?(version, key) && before_end?(version, key)
21
28
  end
22
29
 
23
30
  # Narrows an Active Record version relation by timestamp. Exact tie-breaking
24
31
  # remains in #include? so this works for integer, UUID, and custom version IDs.
25
32
  #: (untyped) -> untyped
26
33
  def scope(relation)
27
- relation.where(created_at: @start_version.created_at..end_time)
34
+ relation.where(created_at: @start_time..end_time)
35
+ end
36
+
37
+ #: () -> bool
38
+ def current_end?
39
+ !Endpoint.version?(@end_boundary)
28
40
  end
29
41
 
30
42
  private
31
43
 
32
- # @rbs @start_version: untyped
44
+ # @rbs @start_boundary: untyped
33
45
  # @rbs @end_boundary: untyped
34
- # @rbs @start_key: Array[untyped]
46
+ # @rbs @start_key: Array[untyped]?
47
+ # @rbs @start_time: untyped
35
48
  # @rbs @end_key: Array[untyped]?
36
49
 
50
+ #: (untyped, Array[untyped]) -> bool
51
+ def after_start?(version, key)
52
+ start_key = @start_key
53
+ return key_before_or_equal?(start_key, key) if start_key
54
+
55
+ version.created_at >= @start_time
56
+ end
57
+
37
58
  #: (untyped, Array[untyped]) -> bool
38
59
  def before_end?(version, key)
39
60
  end_key = @end_key
@@ -42,11 +63,6 @@ module PaperTrailDiff
42
63
  version.created_at <= @end_boundary
43
64
  end
44
65
 
45
- #: () -> untyped
46
- def end_time
47
- Endpoint.version?(@end_boundary) ? @end_boundary.created_at : @end_boundary
48
- end
49
-
50
66
  #: (Array[untyped], Array[untyped]) -> bool
51
67
  def key_before_or_equal?(left, right)
52
68
  comparison = left <=> right