paper_trail_diff 0.3.0 → 0.3.1

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 (27) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/README.md +9 -0
  4. data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +126 -0
  5. data/lib/paper_trail_diff/activity_collection_event_applier.rb +137 -0
  6. data/lib/paper_trail_diff/activity_collection_record_updater.rb +67 -0
  7. data/lib/paper_trail_diff/activity_collection_route_change.rb +21 -0
  8. data/lib/paper_trail_diff/activity_collection_route_updater.rb +128 -0
  9. data/lib/paper_trail_diff/activity_event_record_normalizer.rb +42 -0
  10. data/lib/paper_trail_diff/activity_event_record_resolver.rb +120 -0
  11. data/lib/paper_trail_diff/activity_event_route_finder.rb +112 -0
  12. data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +56 -650
  13. data/lib/paper_trail_diff/activity_relationship.rb +55 -0
  14. data/lib/paper_trail_diff/branch_snapshot_refresher.rb +2 -0
  15. data/lib/paper_trail_diff/version.rb +1 -1
  16. data/lib/paper_trail_diff.rb +9 -0
  17. data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +41 -0
  18. data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +46 -0
  19. data/sig/generated/paper_trail_diff/activity_collection_record_updater.rbs +25 -0
  20. data/sig/generated/paper_trail_diff/activity_collection_route_change.rbs +17 -0
  21. data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +44 -0
  22. data/sig/generated/paper_trail_diff/activity_event_record_normalizer.rbs +19 -0
  23. data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +44 -0
  24. data/sig/generated/paper_trail_diff/activity_event_route_finder.rbs +54 -0
  25. data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +18 -110
  26. data/sig/generated/paper_trail_diff/activity_relationship.rbs +23 -0
  27. metadata +22 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4d60569584c3d30bd35c62c1f0f699fd8859545d81f873c926dfdd8268aed3df
4
- data.tar.gz: 4961a77099927fff3476641b0409cb234e1f398e9b84e9cb3cc9e11b2d580064
3
+ metadata.gz: 6aa099f4051971c69ed9b957e68ed4acca7e3cdde7c97efd2cb9a1af90d91dd1
4
+ data.tar.gz: 67ef88d6978b8b559a1a156f52aaea97a6535c71ec04df5d913f062114ec7a43
5
5
  SHA512:
6
- metadata.gz: 2cf13bffaca3d199b2332b0e3d1c7c7c00ef7c6f82a4c9e5a75c51f376889ff57c60e1bfab1f578a7122e972698318d2720bce5f8da2755abea50aebf6e7083e
7
- data.tar.gz: a071cd71f7fb766105eda1e018991ed8030885822da23382404797984b265818c0012378aea5912a507fb7bc24e9294331201b0cf1918860df3d9613476d580d
6
+ metadata.gz: d916bf31322056bc95bfe39d7640207f2ddc6b30f154ca8e517b4615558ae728b1642f01040bb793ff1f258b3b77113ab6564f6f24daae9a23b09114ec7a6dca
7
+ data.tar.gz: b39856d07d0d335d12f7e1855b67f07b2a84443ff799f48a77e9b6a41609547310927fe8b8b69e32add5fbb30711982af3b125a80a40a59b1185a6ae8d1eb4fb
data/CHANGELOG.md CHANGED
@@ -3,6 +3,21 @@
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.3.1] - 2026-08-10
7
+
8
+ ### Fixed
9
+
10
+ - Preserve immutable activity snapshots for nested events that do not match a
11
+ selected child record, instead of reporting a false change.
12
+
13
+ ### Changed
14
+
15
+ - Split activity-event reconstruction, route discovery, relationship matching,
16
+ collection mutation, and `belongs_to` target application into focused
17
+ internal collaborators without changing result shapes.
18
+ - Reuse immutable activity-event routes and cached branch components across
19
+ repeated event types to reduce timeline allocation overhead.
20
+
6
21
  ## [0.3.0] - 2026-08-10
7
22
 
8
23
  ### Added
data/README.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # paper_trail_diff
2
2
 
3
+ > [!WARNING]
4
+ > **Experimental software and AI disclosure:** This gem is experimental and may
5
+ > contain incomplete behavior, reconstruction errors, performance problems, or
6
+ > breaking changes. AI-assisted coding tools made significant contributions to
7
+ > its design, implementation, tests, and documentation under maintainer
8
+ > direction. AI involvement is not a substitute for independent review: audit
9
+ > the code and validate it against your own PaperTrail history before relying on
10
+ > it in production, compliance, security, or other high-stakes workflows.
11
+
3
12
  `paper_trail_diff` adds structured endpoint, checkpoint-timeline, and activity
4
13
  comparisons to
5
14
  [PaperTrail](https://github.com/paper-trail-gem/paper_trail). It returns immutable
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Applies selected non-polymorphic belongs-to target updates to a snapshot.
6
+ class ActivityBelongsToEventApplier
7
+ #: (route_finder: ActivityEventRouteFinder, record_resolver: ActivityEventRecordResolver, record_normalizer: ActivityEventRecordNormalizer) -> void
8
+ def initialize(route_finder:, record_resolver:, record_normalizer:)
9
+ @route_finder = route_finder
10
+ @record_resolver = record_resolver
11
+ @record_normalizer = record_normalizer
12
+ end
13
+
14
+ #: (untyped, untyped, RecordSnapshot, AssociationTree, SnapshotNormalizer, untyped) -> [bool, RecordSnapshot?]
15
+ def call( # rubocop:disable Metrics/ParameterLists
16
+ root_endpoint,
17
+ context_endpoint,
18
+ previous,
19
+ tree,
20
+ normalizer,
21
+ version
22
+ )
23
+ return [false, nil] unless %w[update destroy].include?(version.event.to_s)
24
+
25
+ routes = @route_finder.belongs_to_routes(
26
+ endpoint_model_class(root_endpoint), tree, version.item_type.to_s
27
+ )
28
+ return [false, nil] if routes.empty?
29
+
30
+ snapshot = previous
31
+ routes.each do |route|
32
+ replacement = normalized_route_record(
33
+ route, version, normalizer, root_endpoint, context_endpoint
34
+ )
35
+ snapshot, = replace_route(snapshot, route, version, replacement)
36
+ end
37
+ [true, snapshot]
38
+ end
39
+
40
+ private
41
+
42
+ # @rbs @route_finder: ActivityEventRouteFinder
43
+ # @rbs @record_resolver: ActivityEventRecordResolver
44
+ # @rbs @record_normalizer: ActivityEventRecordNormalizer
45
+
46
+ #: (Array[untyped], untyped, SnapshotNormalizer, untyped, untyped) -> RecordSnapshot?
47
+ def normalized_route_record(route, version, normalizer, root_endpoint, context_endpoint)
48
+ record = @record_resolver.record_after(version)
49
+ return unless record
50
+
51
+ _name, reflection, subtree, path = route.last
52
+ @record_normalizer.call(
53
+ record,
54
+ reflection: reflection,
55
+ subtree: subtree,
56
+ path: path,
57
+ normalizer: normalizer,
58
+ root_endpoint: root_endpoint,
59
+ context_endpoint: context_endpoint
60
+ )
61
+ end
62
+
63
+ #: (RecordSnapshot, Array[untyped], untyped, RecordSnapshot?, ?depth: Integer) -> [RecordSnapshot, bool]
64
+ def replace_route(snapshot, route, version, replacement, depth: 0)
65
+ name, _reflection, _subtree, = route.fetch(depth)
66
+ association = snapshot.associations.fetch(name)
67
+ records, changed = replacement_records(
68
+ association.records, route, version, replacement, depth
69
+ )
70
+ return [snapshot, false] unless changed
71
+
72
+ updated_association = AssociationSnapshot.new(kind: association.kind, records: records)
73
+ [replace_association(snapshot, name, updated_association), true]
74
+ end
75
+
76
+ #: (Array[RecordSnapshot], Array[untyped], untyped, RecordSnapshot?, Integer) -> [Array[RecordSnapshot], bool]
77
+ def replacement_records(records, route, version, replacement, depth)
78
+ return replace_existing_target(records, version, replacement) if depth == route.length - 1
79
+
80
+ changed = false
81
+ updated = records.map do |record|
82
+ result, record_changed = replace_route(
83
+ record, route, version, replacement, depth: depth + 1
84
+ )
85
+ changed ||= record_changed
86
+ result
87
+ end.freeze
88
+ [updated, changed]
89
+ end
90
+
91
+ #: (Array[RecordSnapshot], untyped, RecordSnapshot?) -> [Array[RecordSnapshot], bool]
92
+ def replace_existing_target(records, version, replacement)
93
+ index = records.index do |record|
94
+ record.id.to_s == version.item_id.to_s &&
95
+ record.type.to_s == replacement_type(version, replacement)
96
+ end
97
+ return [records, false] unless index
98
+
99
+ updated = records.dup
100
+ replacement ? updated[index] = replacement : updated.delete_at(index)
101
+ [updated.freeze, true]
102
+ end
103
+
104
+ #: (RecordSnapshot, String, AssociationSnapshot) -> RecordSnapshot
105
+ def replace_association(snapshot, name, association)
106
+ RecordSnapshot.new(
107
+ type: snapshot.type,
108
+ id: snapshot.id,
109
+ attributes: snapshot.attributes,
110
+ associations: snapshot.associations.merge(name => association)
111
+ )
112
+ end
113
+
114
+ #: (untyped, RecordSnapshot?) -> String
115
+ def replacement_type(version, replacement)
116
+ return replacement.type.to_s if replacement
117
+
118
+ Endpoint.model_class(version).name.to_s
119
+ end
120
+
121
+ #: (untyped) -> untyped
122
+ def endpoint_model_class(endpoint)
123
+ Endpoint.version?(endpoint) ? Endpoint.model_class(endpoint) : endpoint.class
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Applies a safe collection event across every selected route for its record type.
6
+ class ActivityCollectionEventApplier
7
+ #: (record_resolver: ActivityEventRecordResolver, record_normalizer: ActivityEventRecordNormalizer, route_updater: ActivityCollectionRouteUpdater, relationship: ActivityRelationship) -> void
8
+ def initialize(record_resolver:, record_normalizer:, route_updater:, relationship:)
9
+ @record_resolver = record_resolver
10
+ @record_normalizer = record_normalizer
11
+ @route_updater = route_updater
12
+ @relationship = relationship
13
+ end
14
+
15
+ #: (Array[Array[untyped]], untyped, ActivitySnapshotDelta?) -> bool
16
+ def safe?(routes, version, delta)
17
+ !unsafe_through_membership_event?(routes, version) &&
18
+ !unsafe_nested_membership_delta?(routes, delta)
19
+ end
20
+
21
+ #: (untyped, untyped, RecordSnapshot, SnapshotNormalizer, Array[Array[untyped]], untyped, ActivitySnapshotDelta?) -> [bool, RecordSnapshot]
22
+ def call( # rubocop:disable Metrics/ParameterLists
23
+ root_endpoint,
24
+ context_endpoint,
25
+ previous,
26
+ normalizer,
27
+ routes,
28
+ version,
29
+ delta
30
+ )
31
+ fallback_record = [] #: Array[untyped]
32
+ endpoints = [root_endpoint, context_endpoint]
33
+ snapshot = routes.reduce(previous) do |current, route|
34
+ apply_route(
35
+ current, route, version, delta, fallback_record, normalizer, endpoints
36
+ )
37
+ end
38
+ [true, snapshot]
39
+ end
40
+
41
+ private
42
+
43
+ # @rbs @record_resolver: ActivityEventRecordResolver
44
+ # @rbs @record_normalizer: ActivityEventRecordNormalizer
45
+ # @rbs @route_updater: ActivityCollectionRouteUpdater
46
+ # @rbs @relationship: ActivityRelationship
47
+
48
+ #: (RecordSnapshot, Array[untyped], untyped, ActivitySnapshotDelta?, Array[untyped], SnapshotNormalizer, Array[untyped]) -> RecordSnapshot
49
+ def apply_route( # rubocop:disable Metrics/ParameterLists
50
+ snapshot, route, version, delta, fallback_record, normalizer, endpoints
51
+ )
52
+ record, replacement = route_event_state(
53
+ snapshot, route, version, delta, fallback_record, normalizer, endpoints
54
+ )
55
+ change = ActivityCollectionRouteChange.new(
56
+ route: route,
57
+ version: version,
58
+ record: record,
59
+ replacement: replacement
60
+ )
61
+ @route_updater.call(snapshot, change).fetch(0)
62
+ end
63
+
64
+ #: (Array[Array[untyped]], untyped) -> bool
65
+ def unsafe_through_membership_event?(routes, version)
66
+ return false if version.event.to_s == 'update'
67
+
68
+ routes.any? do |route|
69
+ route.first(route.length - 1).any? do |entry|
70
+ entry.fetch(1).options[:through]
71
+ end
72
+ end
73
+ end
74
+
75
+ #: (Array[Array[untyped]], ActivitySnapshotDelta?) -> bool
76
+ def unsafe_nested_membership_delta?(routes, delta)
77
+ return false unless delta
78
+
79
+ routes.any? do |route|
80
+ route.length > 1 && delta.relationship_changed?(route.last.fetch(1))
81
+ end
82
+ end
83
+
84
+ #: (RecordSnapshot, Array[untyped], untyped, ActivitySnapshotDelta?, Array[untyped], SnapshotNormalizer, Array[untyped]) -> [untyped, RecordSnapshot?]
85
+ def route_event_state( # rubocop:disable Metrics/ParameterLists
86
+ snapshot, route, version, delta, fallback_record, normalizer, endpoints
87
+ )
88
+ existing = existing_delta_record(snapshot, route, version, delta) if delta
89
+ return [delta, delta.apply(existing)] if delta && existing
90
+
91
+ fallback_record << @record_resolver.record_after(version) if fallback_record.empty?
92
+ record = fallback_record.first
93
+ return [record, nil] unless record
94
+
95
+ [record, normalized_event_record(record, route, normalizer, endpoints)]
96
+ end
97
+
98
+ #: (untyped, Array[untyped], SnapshotNormalizer, Array[untyped]) -> RecordSnapshot?
99
+ def normalized_event_record(record, route, normalizer, endpoints)
100
+ _name, reflection, subtree, path = route.last
101
+ @record_normalizer.call(
102
+ record,
103
+ reflection: reflection,
104
+ subtree: subtree,
105
+ path: path,
106
+ normalizer: normalizer,
107
+ root_endpoint: endpoints.fetch(0),
108
+ context_endpoint: endpoints.fetch(1)
109
+ )
110
+ end
111
+
112
+ #: (RecordSnapshot, Array[untyped], untyped, ActivitySnapshotDelta?) -> RecordSnapshot?
113
+ def existing_delta_record(snapshot, route, version, delta)
114
+ return unless delta && route.length.between?(1, 2)
115
+
116
+ owner = route.length == 1 ? snapshot : nested_delta_owner(snapshot, route, delta)
117
+ return unless owner
118
+
119
+ association = owner.associations.fetch(route.last.fetch(0))
120
+ position = association.position_for_id(version.item_id)
121
+ association.records.fetch(position) if position
122
+ end
123
+
124
+ #: (RecordSnapshot, Array[untyped], ActivitySnapshotDelta) -> RecordSnapshot?
125
+ def nested_delta_owner(snapshot, route, delta)
126
+ parent_name = route.first.fetch(0)
127
+ reflection = route.last.fetch(1)
128
+ association = snapshot.associations.fetch(parent_name)
129
+ owner_id = @relationship.owner_id(delta, reflection, state: :before)
130
+ position = association.position_for_id(owner_id)
131
+ return unless position
132
+
133
+ owner = association.records.fetch(position)
134
+ owner if @relationship.member_of_owner?(delta, reflection, owner, state: :before)
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Applies one event to the leaf records of a collection association snapshot.
6
+ class ActivityCollectionRecordUpdater
7
+ #: (ActivityRelationship) -> void
8
+ def initialize(relationship)
9
+ @relationship = relationship
10
+ end
11
+
12
+ #: (AssociationSnapshot, ActivityCollectionRouteChange, untyped, RecordSnapshot) -> RecordSnapshot?
13
+ def event_child(association, change, reflection, owner)
14
+ version = change.version
15
+ record = change.record
16
+ replacement = change.replacement
17
+ return unless record
18
+
19
+ unless record.is_a?(ActivitySnapshotDelta)
20
+ return replacement if @relationship.member_of_owner?(record, reflection, owner)
21
+
22
+ return
23
+ end
24
+ unless record.relationship_changed?(reflection)
25
+ return replacement if association.position_for_id(version.item_id)
26
+
27
+ return
28
+ end
29
+
30
+ replacement if @relationship.member_of_owner?(record, reflection, owner)
31
+ end
32
+
33
+ #: (AssociationSnapshot, untyped, RecordSnapshot?) -> [Array[RecordSnapshot], RecordSnapshot?, RecordSnapshot?, bool]
34
+ def call(association, version, replacement)
35
+ records = association.records
36
+ position = association.position(replacement_type(version, replacement), version.item_id)
37
+ return [records, nil, nil, true] unless replacement || position
38
+
39
+ before = records.fetch(position) if position
40
+ updated = replacement_records(records, position, replacement)
41
+ [updated, before, replacement, !before.nil? && !replacement.nil?]
42
+ end
43
+
44
+ #: (untyped, RecordSnapshot?) -> String
45
+ def replacement_type(version, replacement)
46
+ return replacement.type.to_s if replacement
47
+
48
+ Endpoint.model_class(version).name.to_s
49
+ end
50
+
51
+ private
52
+
53
+ # @rbs @relationship: ActivityRelationship
54
+
55
+ #: (Array[RecordSnapshot], Integer?, RecordSnapshot?) -> Array[RecordSnapshot]
56
+ def replacement_records(records, position, replacement)
57
+ updated = records.dup
58
+ if replacement
59
+ position ? updated[position] = replacement : updated << replacement
60
+ else
61
+ position ||= raise(ConfigurationError, 'missing collection event identity')
62
+ updated.delete_at(position)
63
+ end
64
+ updated.freeze
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Carries one collection event through recursive immutable route replacement.
6
+ class ActivityCollectionRouteChange
7
+ attr_reader :route #: Array[untyped]
8
+ attr_reader :version #: untyped
9
+ attr_reader :record #: untyped
10
+ attr_reader :replacement #: RecordSnapshot?
11
+
12
+ #: (route: Array[untyped], version: untyped, record: untyped, replacement: RecordSnapshot?) -> void
13
+ def initialize(route:, version:, record:, replacement:)
14
+ @route = route
15
+ @version = version
16
+ @record = record
17
+ @replacement = replacement
18
+ freeze
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Replaces a collection event record along one explicit immutable snapshot route.
6
+ class ActivityCollectionRouteUpdater
7
+ #: (pool: SnapshotPool, relationship: ActivityRelationship) -> void
8
+ def initialize(pool:, relationship:)
9
+ @pool = pool
10
+ @relationship = relationship
11
+ @record_updater = ActivityCollectionRecordUpdater.new(relationship)
12
+ end
13
+
14
+ #: (RecordSnapshot, ActivityCollectionRouteChange, ?depth: Integer) -> [RecordSnapshot, bool]
15
+ def call(snapshot, change, depth: 0) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
16
+ name, _reflection, _subtree, path = change.route.fetch(depth)
17
+ association = snapshot.associations.fetch(name)
18
+ transition = updated_records(association, snapshot, change, depth)
19
+ records, before, after, membership_preserved, changed = transition
20
+ return [snapshot, false] unless changed
21
+
22
+ updated_association = @pool.association(
23
+ path,
24
+ association.transition_to(
25
+ records,
26
+ before: before,
27
+ after: after,
28
+ membership_preserved: membership_preserved
29
+ )
30
+ )
31
+ updated_snapshot = replace_association(snapshot, name, updated_association)
32
+ parent_path = depth.zero? ? '' : change.route.fetch(depth - 1).fetch(3)
33
+ updated_snapshot = @pool.record(parent_path, updated_snapshot) unless parent_path.empty?
34
+ [updated_snapshot, true]
35
+ end
36
+
37
+ private
38
+
39
+ # @rbs @pool: SnapshotPool
40
+ # @rbs @relationship: ActivityRelationship
41
+ # @rbs @record_updater: ActivityCollectionRecordUpdater
42
+
43
+ #: (AssociationSnapshot, RecordSnapshot, ActivityCollectionRouteChange, Integer) -> [Array[RecordSnapshot], RecordSnapshot?, RecordSnapshot?, bool, bool]
44
+ def updated_records(association, owner, change, depth)
45
+ return leaf_records(association, owner, change) if depth == change.route.length - 1
46
+
47
+ targeted = targeted_parent(association, change, depth)
48
+ if targeted
49
+ records, before, after, changed = targeted
50
+ return [records, before, after, true, changed]
51
+ end
52
+
53
+ recursive_records(association, change, depth)
54
+ end
55
+
56
+ #: (AssociationSnapshot, RecordSnapshot, ActivityCollectionRouteChange) -> [Array[RecordSnapshot], RecordSnapshot?, RecordSnapshot?, bool, bool]
57
+ def leaf_records(association, owner, change)
58
+ reflection = change.route.last.fetch(1)
59
+ child = @record_updater.event_child(association, change, reflection, owner)
60
+ records, before, after, preserved = @record_updater.call(
61
+ association, change.version, child
62
+ )
63
+ [records, before, after, preserved, !records.equal?(association.records)]
64
+ end
65
+
66
+ #: (AssociationSnapshot, ActivityCollectionRouteChange, Integer) -> [Array[RecordSnapshot], RecordSnapshot?, RecordSnapshot?, bool]?
67
+ def targeted_parent(association, change, depth)
68
+ return unless depth == change.route.length - 2
69
+
70
+ reflection = change.route.fetch(depth + 1).fetch(1)
71
+ position = nested_owner_position(association, reflection, change)
72
+ return unless position
73
+
74
+ before = association.records.fetch(position)
75
+ after, changed = call(before, change, depth: depth + 1)
76
+ targeted_parent_replacement(association.records, position, before, after, changed)
77
+ end
78
+
79
+ #: (AssociationSnapshot, ActivityCollectionRouteChange, Integer) -> [Array[RecordSnapshot], RecordSnapshot?, RecordSnapshot?, bool, bool]
80
+ def recursive_records(association, change, depth)
81
+ state = [nil, nil, false] #: [RecordSnapshot?, RecordSnapshot?, bool]
82
+ records = association.records.map do |child|
83
+ updated, child_changed = call(child, change, depth: depth + 1)
84
+ state = recursive_transition(state, child, updated, child_changed)
85
+ updated
86
+ end.freeze
87
+ [records, state.fetch(0), state.fetch(1), true, state.fetch(2)]
88
+ end
89
+
90
+ #: ([RecordSnapshot?, RecordSnapshot?, bool], RecordSnapshot, RecordSnapshot, bool) -> [RecordSnapshot?, RecordSnapshot?, bool]
91
+ def recursive_transition(state, child, updated, child_changed)
92
+ return state unless child_changed
93
+ return [nil, nil, true] if state.fetch(2)
94
+
95
+ [child, updated, true]
96
+ end
97
+
98
+ #: (Array[RecordSnapshot], Integer, RecordSnapshot, RecordSnapshot, bool) -> [Array[RecordSnapshot], RecordSnapshot?, RecordSnapshot?, bool]
99
+ def targeted_parent_replacement(records, position, before, after, changed)
100
+ return [records, nil, nil, false] unless changed
101
+
102
+ updated = records.dup
103
+ updated[position] = after
104
+ [updated.freeze, before, after, true]
105
+ end
106
+
107
+ #: (AssociationSnapshot, untyped, ActivityCollectionRouteChange) -> Integer?
108
+ def nested_owner_position(association, reflection, change)
109
+ membership_record = change.record || change.version.reify(dup: true)
110
+ return unless membership_record
111
+ return if membership_record.is_a?(ActivitySnapshotDelta) &&
112
+ membership_record.relationship_changed?(reflection)
113
+
114
+ owner_id = @relationship.owner_id(membership_record, reflection, state: :after)
115
+ association.position_for_id(owner_id)
116
+ end
117
+
118
+ #: (RecordSnapshot, String, AssociationSnapshot) -> RecordSnapshot
119
+ def replace_association(snapshot, name, association)
120
+ RecordSnapshot.new(
121
+ type: snapshot.type,
122
+ id: snapshot.id,
123
+ attributes: snapshot.attributes,
124
+ associations: snapshot.associations.merge(name => association)
125
+ )
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Normalizes one event record with the historical reader for its boundary.
6
+ class ActivityEventRecordNormalizer
7
+ #: (?association_reader: untyped) -> void
8
+ def initialize(association_reader: nil)
9
+ @association_reader = association_reader || method(:historical_reader)
10
+ end
11
+
12
+ #: (untyped, reflection: untyped, subtree: AssociationTree, path: String, normalizer: SnapshotNormalizer, root_endpoint: untyped, context_endpoint: untyped) -> RecordSnapshot?
13
+ def call( # rubocop:disable Metrics/ParameterLists
14
+ record,
15
+ reflection:,
16
+ subtree:,
17
+ path:,
18
+ normalizer:,
19
+ root_endpoint:,
20
+ context_endpoint:
21
+ )
22
+ boundary = Endpoint.version?(root_endpoint) ? root_endpoint : context_endpoint
23
+ reifier = @association_reader.call(context_endpoint, habtm_version: boundary)
24
+ normalizer.call_child(
25
+ record,
26
+ tree: subtree,
27
+ path: path,
28
+ incoming: reflection,
29
+ reifier: reifier
30
+ )
31
+ end
32
+
33
+ private
34
+
35
+ # @rbs @association_reader: untyped
36
+
37
+ #: (untyped, habtm_version: untyped) -> HistoricalAssociationReifier
38
+ def historical_reader(context_endpoint, habtm_version:)
39
+ HistoricalAssociationReifier.new(context_endpoint, habtm_version: habtm_version)
40
+ end
41
+ end
42
+ end