paper_trail_diff 0.1.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 (77) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +45 -0
  3. data/LICENSE +22 -0
  4. data/README.md +473 -0
  5. data/lib/paper_trail_diff/activity_boundary.rb +82 -0
  6. data/lib/paper_trail_diff/activity_event.rb +128 -0
  7. data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +493 -0
  8. data/lib/paper_trail_diff/activity_range.rb +58 -0
  9. data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +156 -0
  10. data/lib/paper_trail_diff/activity_snapshot_sequence.rb +161 -0
  11. data/lib/paper_trail_diff/activity_timeline_builder.rb +177 -0
  12. data/lib/paper_trail_diff/activity_version_collector.rb +196 -0
  13. data/lib/paper_trail_diff/analysis.rb +26 -0
  14. data/lib/paper_trail_diff/association_discovery.rb +132 -0
  15. data/lib/paper_trail_diff/association_traversal.rb +131 -0
  16. data/lib/paper_trail_diff/branch_snapshot_refresher.rb +191 -0
  17. data/lib/paper_trail_diff/configuration.rb +170 -0
  18. data/lib/paper_trail_diff/diagnostics.rb +242 -0
  19. data/lib/paper_trail_diff/endpoint.rb +87 -0
  20. data/lib/paper_trail_diff/engine.rb +180 -0
  21. data/lib/paper_trail_diff/errors.rb +34 -0
  22. data/lib/paper_trail_diff/historical_association_reifier.rb +103 -0
  23. data/lib/paper_trail_diff/historical_snapshot_store.rb +144 -0
  24. data/lib/paper_trail_diff/live_association_reader.rb +10 -0
  25. data/lib/paper_trail_diff/paper_trail_adapter.rb +182 -0
  26. data/lib/paper_trail_diff/prepared_association_reifier.rb +48 -0
  27. data/lib/paper_trail_diff/prepared_edge_loader.rb +155 -0
  28. data/lib/paper_trail_diff/prepared_history.rb +217 -0
  29. data/lib/paper_trail_diff/prepared_history_loader.rb +136 -0
  30. data/lib/paper_trail_diff/prepared_record_index.rb +154 -0
  31. data/lib/paper_trail_diff/snapshot.rb +120 -0
  32. data/lib/paper_trail_diff/snapshot_normalizer.rb +182 -0
  33. data/lib/paper_trail_diff/step.rb +28 -0
  34. data/lib/paper_trail_diff/support.rb +80 -0
  35. data/lib/paper_trail_diff/timeline_builder.rb +60 -0
  36. data/lib/paper_trail_diff/timeline_snapshot_provider.rb +24 -0
  37. data/lib/paper_trail_diff/value_objects.rb +134 -0
  38. data/lib/paper_trail_diff/version.rb +6 -0
  39. data/lib/paper_trail_diff/version_range.rb +81 -0
  40. data/lib/paper_trail_diff.rb +131 -0
  41. data/sig/generated/paper_trail_diff/activity_boundary.rbs +43 -0
  42. data/sig/generated/paper_trail_diff/activity_event.rbs +64 -0
  43. data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +98 -0
  44. data/sig/generated/paper_trail_diff/activity_range.rbs +36 -0
  45. data/sig/generated/paper_trail_diff/activity_root_snapshot_refresher.rbs +54 -0
  46. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +71 -0
  47. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +61 -0
  48. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +71 -0
  49. data/sig/generated/paper_trail_diff/analysis.rbs +18 -0
  50. data/sig/generated/paper_trail_diff/association_discovery.rbs +63 -0
  51. data/sig/generated/paper_trail_diff/association_traversal.rbs +54 -0
  52. data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +56 -0
  53. data/sig/generated/paper_trail_diff/configuration.rbs +67 -0
  54. data/sig/generated/paper_trail_diff/diagnostics.rbs +109 -0
  55. data/sig/generated/paper_trail_diff/endpoint.rbs +30 -0
  56. data/sig/generated/paper_trail_diff/engine.rbs +54 -0
  57. data/sig/generated/paper_trail_diff/errors.rbs +43 -0
  58. data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +37 -0
  59. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +50 -0
  60. data/sig/generated/paper_trail_diff/live_association_reader.rbs +9 -0
  61. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +72 -0
  62. data/sig/generated/paper_trail_diff/prepared_association_reifier.rbs +27 -0
  63. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +65 -0
  64. data/sig/generated/paper_trail_diff/prepared_history.rbs +93 -0
  65. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +67 -0
  66. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +84 -0
  67. data/sig/generated/paper_trail_diff/snapshot.rbs +69 -0
  68. data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +76 -0
  69. data/sig/generated/paper_trail_diff/step.rbs +18 -0
  70. data/sig/generated/paper_trail_diff/support.rbs +30 -0
  71. data/sig/generated/paper_trail_diff/timeline_builder.rbs +31 -0
  72. data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +17 -0
  73. data/sig/generated/paper_trail_diff/value_objects.rbs +87 -0
  74. data/sig/generated/paper_trail_diff/version.rbs +5 -0
  75. data/sig/generated/paper_trail_diff/version_range.rbs +35 -0
  76. data/sig/generated/paper_trail_diff.rbs +56 -0
  77. metadata +144 -0
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Endpoint and root-checkpoint timeline results built from one normalized history.
6
+ class Analysis
7
+ attr_reader :diff #: Diff
8
+ attr_reader :timeline #: Array[Step]
9
+ attr_reader :activity_timeline #: Array[ActivityStep]?
10
+
11
+ #: (diff: Diff, timeline: Array[Step], ?activity_timeline: Array[ActivityStep]?) -> void
12
+ def initialize(diff:, timeline:, activity_timeline: nil)
13
+ @diff = diff
14
+ @timeline = timeline.dup.freeze
15
+ @activity_timeline = activity_timeline&.dup&.freeze
16
+ freeze
17
+ end
18
+
19
+ #: () -> Hash[Symbol, untyped]
20
+ def to_h
21
+ value = { diff: diff.to_h, timeline: Support.serialize(timeline) }
22
+ value[:activity_timeline] = Support.serialize(activity_timeline) if activity_timeline
23
+ value
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # One discoverable ActiveRecord association path.
6
+ class AssociationDescriptor
7
+ attr_reader :path #: String
8
+ attr_reader :kind #: Symbol
9
+ attr_reader :target_type #: String?
10
+ attr_reader :through #: String?
11
+ attr_reader :polymorphic #: bool
12
+ attr_reader :cycle #: bool
13
+
14
+ #: (path: String, kind: Symbol, target_type: String?, ?through: String?, ?flags: Array[Symbol]) -> void
15
+ def initialize(path:, kind:, target_type:, through: nil, flags: [])
16
+ @path = Support.immutable_copy(path)
17
+ @kind = kind
18
+ @target_type = Support.immutable_copy(target_type)
19
+ @through = Support.immutable_copy(through)
20
+ @polymorphic = flags.include?(:polymorphic)
21
+ @cycle = flags.include?(:cycle)
22
+ freeze
23
+ end
24
+
25
+ #: () -> Hash[Symbol, untyped]
26
+ def to_h
27
+ {
28
+ path: path,
29
+ kind: kind,
30
+ target_type: target_type,
31
+ through: through,
32
+ polymorphic: polymorphic,
33
+ cycle: cycle
34
+ }
35
+ end
36
+ end
37
+
38
+ # Discovers finite, supported association paths for configuration UIs.
39
+ class AssociationDiscovery
40
+ #: (untyped, max_depth: Integer) -> void
41
+ def initialize(model_or_record, max_depth:)
42
+ unless max_depth.is_a?(Integer) && max_depth.positive?
43
+ raise ConfigurationError, 'max_depth: must be a positive integer'
44
+ end
45
+
46
+ @model_class = model_or_record.is_a?(Class) ? model_or_record : model_or_record.class
47
+ @max_depth = max_depth
48
+ return if @model_class.respond_to?(:reflect_on_all_associations)
49
+
50
+ raise ConfigurationError, 'expected an ActiveRecord model class or record'
51
+ end
52
+
53
+ #: () -> Array[AssociationDescriptor]
54
+ def call
55
+ discover(@model_class, prefix: '', ancestry: [@model_class], depth: 1).freeze
56
+ end
57
+
58
+ private
59
+
60
+ # @rbs @model_class: untyped
61
+ # @rbs @max_depth: Integer
62
+
63
+ #: (untyped, prefix: String, ancestry: Array[untyped], depth: Integer) -> Array[AssociationDescriptor]
64
+ def discover(model_class, prefix:, ancestry:, depth:)
65
+ supported_reflections(model_class).flat_map do |reflection|
66
+ describe_and_descend(reflection, prefix: prefix, ancestry: ancestry, depth: depth)
67
+ end
68
+ end
69
+
70
+ #: (untyped, prefix: String, ancestry: Array[untyped], depth: Integer) -> Array[AssociationDescriptor]
71
+ def describe_and_descend(reflection, prefix:, ancestry:, depth:)
72
+ path = association_path(prefix, reflection.name.to_s)
73
+ target_class = reflection_target(reflection)
74
+ cycle = target_class ? ancestry.include?(target_class) : false
75
+ descriptor = descriptor_for(reflection, path, target_class, cycle)
76
+ return [descriptor] unless target_class && !cycle && depth < @max_depth
77
+
78
+ descendants = discover(
79
+ target_class,
80
+ prefix: path,
81
+ ancestry: [*ancestry, target_class],
82
+ depth: depth + 1
83
+ )
84
+ [descriptor, *descendants]
85
+ end
86
+
87
+ #: (untyped, String, untyped, bool) -> AssociationDescriptor
88
+ def descriptor_for(reflection, path, target_class, cycle)
89
+ flags = [] #: Array[Symbol]
90
+ flags << :polymorphic if reflection.polymorphic?
91
+ flags << :cycle if cycle
92
+ AssociationDescriptor.new(
93
+ path: path,
94
+ kind: reflection.macro,
95
+ target_type: target_class&.name || reflection_target_name(reflection),
96
+ through: reflection.options[:through]&.to_s,
97
+ flags: flags
98
+ )
99
+ end
100
+
101
+ #: (untyped) -> Array[untyped]
102
+ def supported_reflections(model_class)
103
+ model_class.reflect_on_all_associations
104
+ .select { |reflection| SUPPORTED_ASSOCIATION_MACROS.include?(reflection.macro) }
105
+ .reject { |reflection| paper_trail_versions?(model_class, reflection) }
106
+ .sort_by { |reflection| reflection.name.to_s }
107
+ end
108
+
109
+ #: (untyped, untyped) -> bool
110
+ def paper_trail_versions?(model_class, reflection)
111
+ model_class.respond_to?(:versions_association_name) &&
112
+ reflection.name.to_s == model_class.versions_association_name.to_s
113
+ end
114
+
115
+ #: (untyped) -> untyped
116
+ def reflection_target(reflection)
117
+ reflection.klass unless reflection.polymorphic?
118
+ rescue NameError
119
+ nil
120
+ end
121
+
122
+ #: (untyped) -> String?
123
+ def reflection_target_name(reflection)
124
+ reflection.class_name.to_s unless reflection.polymorphic?
125
+ end
126
+
127
+ #: (String, String) -> String
128
+ def association_path(prefix, name)
129
+ prefix.empty? ? name : "#{prefix}.#{name}"
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Resolves and validates ActiveRecord reflections for a bounded association tree.
6
+ class AssociationTraversal
7
+ #: (AssociationTree) -> void
8
+ def initialize(tree)
9
+ @tree = tree
10
+ @reflection_cache = {}
11
+ end
12
+
13
+ #: (untyped) -> void
14
+ def validate!(model_class)
15
+ validate_tree!(model_class, @tree, path: '')
16
+ end
17
+
18
+ #: (untyped) -> Array[Array[untyped]]
19
+ def selected_reflections(model_class)
20
+ collect_reflections(model_class, @tree, path: '').freeze
21
+ end
22
+
23
+ #: (untyped, untyped) -> void
24
+ def ensure_habtm_history!(model_class, version)
25
+ paths = habtm_paths(model_class)
26
+ return if paths.empty?
27
+ return if version.respond_to?(:transaction_id) && version.transaction_id
28
+
29
+ message = "HABTM history is incomplete at version #{version.id}: #{paths.join(', ')}"
30
+ raise IncompleteAssociationHistoryError, message
31
+ end
32
+
33
+ #: (untyped) -> Array[String]
34
+ def habtm_paths(model_class)
35
+ selected_reflections(model_class).filter_map do |path, reflection|
36
+ path if habtm_reflection?(reflection)
37
+ end.freeze
38
+ end
39
+
40
+ #: (untyped, AssociationTree, path: String) -> Array[untyped]
41
+ def reflections_for(model_class, tree, path:)
42
+ key = [model_class.name.to_s, path, tree.object_id]
43
+ @reflection_cache[key] ||= requested_reflections(model_class, tree, path)
44
+ end
45
+
46
+ #: (untyped) -> Array[String]
47
+ def incoming_relationship_columns(reflection)
48
+ direct = %i[has_one has_many].include?(reflection.macro) && !reflection.options[:through]
49
+ return [] unless direct
50
+
51
+ foreign_keys = Array(reflection.foreign_key)
52
+ # @type var foreign_keys: Array[untyped]
53
+ columns = foreign_keys.map do |column| # rubocop:disable Style/SymbolProc
54
+ column.to_s
55
+ end
56
+ columns << reflection.type.to_s if reflection.options[:as]
57
+ columns
58
+ end
59
+
60
+ private
61
+
62
+ # @rbs @tree: AssociationTree
63
+ # @rbs @reflection_cache: Hash[Array[untyped], Array[untyped]]
64
+
65
+ #: (untyped, AssociationTree, path: String) -> Array[Array[untyped]]
66
+ def collect_reflections(model_class, tree, path:)
67
+ reflections_for(model_class, tree, path: path).flat_map do |reflection|
68
+ child_path = join_path(path, reflection.name.to_s)
69
+ result = [[child_path, reflection]]
70
+ subtree = tree.child(reflection.name)
71
+ next result unless subtree && !subtree.empty? && !reflection.polymorphic?
72
+
73
+ result + collect_reflections(reflection.klass, subtree, path: child_path)
74
+ end
75
+ end
76
+
77
+ #: (untyped, AssociationTree, path: String) -> void
78
+ def validate_tree!(model_class, tree, path:)
79
+ reflections_for(model_class, tree, path: path).each do |reflection|
80
+ subtree = tree.child(reflection.name)
81
+ next unless subtree && !subtree.empty? && !reflection.polymorphic?
82
+
83
+ child_path = join_path(path, reflection.name.to_s)
84
+ validate_tree!(reflection.klass, subtree, path: child_path)
85
+ end
86
+ end
87
+
88
+ #: (untyped, AssociationTree, String) -> Array[untyped]
89
+ def requested_reflections(model_class, tree, path)
90
+ tree.children.map do |name, _subtree|
91
+ resolve_reflection(model_class, name, join_path(path, name))
92
+ end.freeze
93
+ end
94
+
95
+ #: (untyped, String, String) -> untyped
96
+ def resolve_reflection(model_class, name, full_path)
97
+ reflection = model_class.reflect_on_association(name.to_sym)
98
+ raise UnknownAssociationError, "unknown association: #{full_path}" unless reflection
99
+
100
+ if paper_trail_versions?(model_class, reflection)
101
+ message = "unsupported association #{full_path}: PaperTrail versions"
102
+ raise UnsupportedAssociationError, message
103
+ end
104
+ unless SUPPORTED_ASSOCIATION_MACROS.include?(reflection.macro)
105
+ raise UnsupportedAssociationError,
106
+ "unsupported association #{full_path}: #{reflection.macro}"
107
+ end
108
+
109
+ reflection
110
+ end
111
+
112
+ #: (untyped, untyped) -> bool
113
+ def paper_trail_versions?(model_class, reflection)
114
+ model_class.respond_to?(:versions_association_name) &&
115
+ reflection.name.to_s == model_class.versions_association_name.to_s
116
+ end
117
+
118
+ #: (untyped) -> bool
119
+ def habtm_reflection?(reflection)
120
+ return true if reflection.macro == :has_and_belongs_to_many
121
+ return false unless reflection.options[:through]
122
+
123
+ habtm_reflection?(reflection.through_reflection)
124
+ end
125
+
126
+ #: (String, String) -> String
127
+ def join_path(parent, name)
128
+ parent.empty? ? name : "#{parent}.#{name}"
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Reconstructs selected root branches and merges them into a prior immutable snapshot.
6
+ class BranchSnapshotRefresher
7
+ #: (tree: AssociationTree, ignore_policy: IgnorePolicy, traversal: AssociationTraversal, pool: SnapshotPool, normalizer: SnapshotNormalizer, full_snapshotter: untyped, partial_snapshotter: untyped, association_reader: untyped) -> void
8
+ def initialize( # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
9
+ tree:,
10
+ ignore_policy:,
11
+ traversal:,
12
+ pool:,
13
+ normalizer:,
14
+ full_snapshotter:,
15
+ partial_snapshotter:,
16
+ association_reader:
17
+ )
18
+ @tree = tree
19
+ @ignore_policy = ignore_policy
20
+ @traversal = traversal
21
+ @pool = pool
22
+ @full_snapshotter = full_snapshotter
23
+ @partial_snapshotter = partial_snapshotter
24
+ @components = {} #: Hash[Array[String], [AssociationTree, SnapshotNormalizer]]
25
+ @event_refresher = ActivityEventSnapshotRefresher.new(
26
+ traversal: traversal,
27
+ pool: pool,
28
+ components: method(:components),
29
+ association_reader: association_reader
30
+ )
31
+ @root_refresher = ActivityRootSnapshotRefresher.new(
32
+ tree: tree,
33
+ traversal: traversal,
34
+ normalizer: normalizer,
35
+ record_after: @event_refresher.method(:record_after),
36
+ changeset: @event_refresher.method(:deserialized_changeset),
37
+ partial_snapshotter: method(:partial_snapshot)
38
+ )
39
+ end
40
+
41
+ #: (untyped, untyped, RecordSnapshot?, event: ActivityEvent) -> RecordSnapshot?
42
+ def advance_root(root_endpoint, context_endpoint, previous_snapshot, event:)
43
+ handled, incremental = @root_refresher.call(
44
+ root_endpoint,
45
+ context_endpoint,
46
+ previous_snapshot,
47
+ event
48
+ )
49
+ return incremental if handled
50
+
51
+ full_snapshot(root_endpoint, context_endpoint)
52
+ end
53
+
54
+ #: (untyped, untyped, RecordSnapshot?, Array[String], ?event: ActivityEvent?, ?isolated: bool) -> RecordSnapshot?
55
+ def call( # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
56
+ root_endpoint,
57
+ context_endpoint,
58
+ previous_snapshot,
59
+ branches,
60
+ event: nil,
61
+ isolated: false
62
+ )
63
+ return full_snapshot(root_endpoint, context_endpoint) unless previous_snapshot
64
+ if isolated && event && branches.length > 1
65
+ return refresh_independent_branches(
66
+ root_endpoint,
67
+ context_endpoint,
68
+ previous_snapshot,
69
+ branches,
70
+ event
71
+ )
72
+ end
73
+
74
+ endpoints = [root_endpoint, context_endpoint]
75
+ handled, incremental = incremental_event(
76
+ endpoints,
77
+ previous_snapshot,
78
+ branches,
79
+ event,
80
+ isolated
81
+ )
82
+ return incremental if handled
83
+
84
+ partial = partial_snapshot(root_endpoint, context_endpoint, branches)
85
+ return full_snapshot(root_endpoint, context_endpoint) unless partial
86
+
87
+ merge(previous_snapshot, partial)
88
+ end
89
+
90
+ private
91
+
92
+ # @rbs @tree: AssociationTree
93
+ # @rbs @ignore_policy: IgnorePolicy
94
+ # @rbs @traversal: AssociationTraversal
95
+ # @rbs @pool: SnapshotPool
96
+ # @rbs @full_snapshotter: untyped
97
+ # @rbs @partial_snapshotter: untyped
98
+ # @rbs @components: Hash[Array[String], [AssociationTree, SnapshotNormalizer]]
99
+ # @rbs @event_refresher: ActivityEventSnapshotRefresher
100
+ # @rbs @root_refresher: ActivityRootSnapshotRefresher
101
+
102
+ #: (Array[untyped], RecordSnapshot, Array[String], ActivityEvent?, bool) -> [bool, RecordSnapshot?]
103
+ def incremental_event(endpoints, previous, branches, event, isolated)
104
+ return [false, nil] unless isolated
105
+
106
+ @event_refresher.call(
107
+ endpoints.fetch(0),
108
+ endpoints.fetch(1),
109
+ previous,
110
+ branches,
111
+ event
112
+ )
113
+ end
114
+
115
+ #: (untyped, untyped, RecordSnapshot, Array[String], ActivityEvent) -> RecordSnapshot?
116
+ def refresh_independent_branches( # rubocop:disable Metrics/MethodLength
117
+ root_endpoint,
118
+ context_endpoint,
119
+ previous_snapshot,
120
+ branches,
121
+ event
122
+ )
123
+ snapshot = previous_snapshot
124
+ remaining = [] #: Array[String]
125
+ branches.each do |branch|
126
+ handled, incremental = @event_refresher.call(
127
+ root_endpoint,
128
+ context_endpoint,
129
+ snapshot,
130
+ [branch],
131
+ event
132
+ )
133
+ if handled
134
+ snapshot = incremental || snapshot
135
+ else
136
+ remaining << branch
137
+ end
138
+ end
139
+ return snapshot if remaining.empty?
140
+
141
+ partial = partial_snapshot(root_endpoint, context_endpoint, remaining)
142
+ return full_snapshot(root_endpoint, context_endpoint) unless partial
143
+
144
+ merge(snapshot, partial)
145
+ end
146
+
147
+ #: (untyped, untyped, Array[String]) -> RecordSnapshot?
148
+ def partial_snapshot(root_endpoint, context_endpoint, branches)
149
+ tree, normalizer = components(branches)
150
+ @partial_snapshotter.call(
151
+ root_endpoint,
152
+ context_endpoint,
153
+ tree: tree,
154
+ normalizer: normalizer
155
+ )
156
+ end
157
+
158
+ #: (Array[String]) -> [AssociationTree, SnapshotNormalizer]
159
+ def components(branches)
160
+ key = branches.sort.freeze
161
+ @components[key] ||= build_components(key)
162
+ end
163
+
164
+ #: (Array[String]) -> [AssociationTree, SnapshotNormalizer]
165
+ def build_components(branches)
166
+ tree = @tree.select(branches)
167
+ normalizer = SnapshotNormalizer.new(
168
+ tree: tree,
169
+ ignore_policy: @ignore_policy,
170
+ traversal: @traversal,
171
+ pool: @pool
172
+ )
173
+ [tree, normalizer]
174
+ end
175
+
176
+ #: (untyped, untyped) -> RecordSnapshot?
177
+ def full_snapshot(root_endpoint, context_endpoint)
178
+ @full_snapshotter.call(root_endpoint, context_endpoint)
179
+ end
180
+
181
+ #: (RecordSnapshot, RecordSnapshot) -> RecordSnapshot
182
+ def merge(previous, partial)
183
+ RecordSnapshot.new(
184
+ type: previous.type,
185
+ id: previous.id,
186
+ attributes: previous.attributes,
187
+ associations: previous.associations.merge(partial.associations)
188
+ )
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # An immutable, bounded tree built from explicit association paths.
6
+ class AssociationTree
7
+ attr_reader :children #: Hash[String, AssociationTree]
8
+
9
+ class << self
10
+ #: (untyped) -> AssociationTree
11
+ def build(values)
12
+ unless valid_values?(values)
13
+ raise ConfigurationError, 'associations: must be an array of strings or symbols'
14
+ end
15
+
16
+ tree = {} #: Hash[String, untyped]
17
+ values.each { |value| add_path(tree, value.to_s) }
18
+ from_hash(tree)
19
+ end
20
+
21
+ private
22
+
23
+ #: (Hash[String, untyped], String) -> void
24
+ def add_path(tree, path)
25
+ segments = path.split('.', -1)
26
+ if segments.empty? || segments.any?(&:empty?) || path == '$'
27
+ raise ConfigurationError, "invalid association path: #{path.inspect}"
28
+ end
29
+
30
+ segments.inject(tree) do |branch, segment|
31
+ branch[segment] ||= {} #: Hash[String, untyped]
32
+ end
33
+ end
34
+
35
+ #: (Hash[String, untyped]) -> AssociationTree
36
+ def from_hash(tree)
37
+ children = tree.sort.to_h do |name, branch|
38
+ [name, from_hash(branch)]
39
+ end
40
+ new(children)
41
+ end
42
+
43
+ #: (untyped) -> bool
44
+ def valid_values?(values)
45
+ values.is_a?(Array) && values.all? do |value|
46
+ value.is_a?(String) || value.is_a?(Symbol)
47
+ end
48
+ end
49
+ end
50
+
51
+ #: (Hash[String, AssociationTree]) -> void
52
+ def initialize(children)
53
+ @children = children.dup.freeze
54
+ freeze
55
+ end
56
+
57
+ #: () -> bool
58
+ def empty?
59
+ children.empty?
60
+ end
61
+
62
+ #: (String | Symbol) -> AssociationTree?
63
+ def child(name)
64
+ children[name.to_s]
65
+ end
66
+
67
+ #: (Array[String]) -> AssociationTree
68
+ def select(names)
69
+ self.class.new(children.slice(*names))
70
+ end
71
+
72
+ #: (?prefix: String) -> Array[String]
73
+ def paths(prefix: '')
74
+ children.flat_map do |name, subtree|
75
+ path = prefix.empty? ? name : "#{prefix}.#{name}"
76
+ [path, *subtree.paths(prefix: path)]
77
+ end.freeze
78
+ end
79
+ end
80
+
81
+ # Immutable rules for globally and path-specifically excluded attributes.
82
+ class IgnorePolicy
83
+ ROOT_PATH = '$'
84
+ VALID_KEYS = %w[all paths].freeze
85
+ private_constant :VALID_KEYS
86
+
87
+ class << self
88
+ #: (untyped, association_paths: Array[String]) -> IgnorePolicy
89
+ def build(value, association_paths:)
90
+ return new(all: normalize_names(value, context: 'ignore'), paths: {}) if value.is_a?(Array)
91
+
92
+ unless value.is_a?(Hash)
93
+ raise ConfigurationError, 'ignore: must be an array or a hash with all: and paths:'
94
+ end
95
+
96
+ options = normalize_options(value)
97
+ empty_paths = {} #: Hash[untyped, untyped]
98
+ new(
99
+ all: normalize_names(options.fetch('all', []), context: 'ignore[:all]'),
100
+ paths: normalize_paths(options.fetch('paths', empty_paths), association_paths)
101
+ )
102
+ end
103
+
104
+ private
105
+
106
+ #: (Hash[untyped, untyped]) -> Hash[String, untyped]
107
+ def normalize_options(value)
108
+ options = {} #: Hash[String, untyped]
109
+ value.each do |key, item|
110
+ name = key.to_s
111
+ unless VALID_KEYS.include?(name) && !options.key?(name)
112
+ raise ConfigurationError, "unknown or duplicate ignore option: #{key.inspect}"
113
+ end
114
+
115
+ options[name] = item
116
+ end
117
+ options
118
+ end
119
+
120
+ #: (untyped, Array[String]) -> Hash[String, Array[String]]
121
+ def normalize_paths(value, association_paths)
122
+ raise ConfigurationError, 'ignore[:paths]: must be a hash' unless value.is_a?(Hash)
123
+
124
+ allowed_paths = [ROOT_PATH, *association_paths]
125
+ paths = {} #: Hash[String, Array[String]]
126
+ value.each do |path, names|
127
+ add_path_rule(paths, path, names, allowed_paths)
128
+ end
129
+ paths.sort.to_h.freeze
130
+ end
131
+
132
+ #: (Hash[String, Array[String]], untyped, untyped, Array[String]) -> void
133
+ def add_path_rule(paths, path, names, allowed_paths)
134
+ normalized_path = path.to_s
135
+ unless allowed_paths.include?(normalized_path) && !paths.key?(normalized_path)
136
+ raise ConfigurationError, "unknown or duplicate ignore path: #{path.inspect}"
137
+ end
138
+
139
+ context = "ignore[:paths][#{normalized_path.inspect}]"
140
+ paths[normalized_path] = normalize_names(names, context: context)
141
+ end
142
+
143
+ #: (untyped, context: String) -> Array[String]
144
+ def normalize_names(values, context:)
145
+ valid = values.is_a?(Array) && values.all? do |value|
146
+ value.is_a?(String) || value.is_a?(Symbol)
147
+ end
148
+ raise ConfigurationError, "#{context}: must be an array of strings or symbols" unless valid
149
+
150
+ values.map(&:to_s).uniq.sort.freeze
151
+ end
152
+ end
153
+
154
+ attr_reader :all #: Array[String]
155
+ attr_reader :paths #: Hash[String, Array[String]]
156
+
157
+ #: (all: Array[String], paths: Hash[String, Array[String]]) -> void
158
+ def initialize(all:, paths:)
159
+ @all = all.dup.freeze
160
+ @paths = paths.dup.freeze
161
+ freeze
162
+ end
163
+
164
+ #: (String) -> Array[String]
165
+ def attributes_for(path)
166
+ external_path = path.empty? ? ROOT_PATH : path
167
+ (all + paths.fetch(external_path, [])).uniq.freeze
168
+ end
169
+ end
170
+ end