paper_trail_diff 0.12.0 → 0.13.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 (130) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +127 -0
  3. data/QUICKSTART.md +149 -79
  4. data/README.md +179 -1388
  5. data/docs/architecture.md +88 -0
  6. data/docs/performance.md +309 -0
  7. data/docs/reference.md +977 -0
  8. data/docs/testing.md +146 -0
  9. data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +2 -12
  10. data/lib/paper_trail_diff/activity_boundary.rb +20 -8
  11. data/lib/paper_trail_diff/activity_child_candidate_loader.rb +29 -0
  12. data/lib/paper_trail_diff/activity_collection_event_applier.rb +10 -7
  13. data/lib/paper_trail_diff/activity_collection_route_updater.rb +1 -11
  14. data/lib/paper_trail_diff/activity_event_record_normalizer.rb +4 -0
  15. data/lib/paper_trail_diff/activity_event_record_resolver.rb +66 -6
  16. data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +6 -4
  17. data/lib/paper_trail_diff/activity_grouping.rb +12 -0
  18. data/lib/paper_trail_diff/activity_history.rb +9 -3
  19. data/lib/paper_trail_diff/activity_relationship.rb +2 -0
  20. data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +1 -0
  21. data/lib/paper_trail_diff/activity_snapshot_sequence.rb +9 -2
  22. data/lib/paper_trail_diff/activity_timeline_builder.rb +39 -35
  23. data/lib/paper_trail_diff/activity_version_collector.rb +27 -18
  24. data/lib/paper_trail_diff/analysis.rb +9 -4
  25. data/lib/paper_trail_diff/analysis_batch.rb +28 -7
  26. data/lib/paper_trail_diff/association_traversal.rb +23 -0
  27. data/lib/paper_trail_diff/batch_boundary_resolver.rb +18 -26
  28. data/lib/paper_trail_diff/batch_safety.rb +55 -0
  29. data/lib/paper_trail_diff/batched_root_analyzer.rb +14 -5
  30. data/lib/paper_trail_diff/batched_root_versions.rb +9 -37
  31. data/lib/paper_trail_diff/branch_snapshot_refresher.rb +7 -8
  32. data/lib/paper_trail_diff/deleted_analysis_batch.rb +93 -0
  33. data/lib/paper_trail_diff/direct_record_state_loader.rb +81 -0
  34. data/lib/paper_trail_diff/endpoint.rb +10 -0
  35. data/lib/paper_trail_diff/engine.rb +4 -2
  36. data/lib/paper_trail_diff/errors.rb +9 -0
  37. data/lib/paper_trail_diff/historical_association_reifier.rb +26 -1
  38. data/lib/paper_trail_diff/historical_filter.rb +181 -0
  39. data/lib/paper_trail_diff/historical_snapshot_store.rb +51 -35
  40. data/lib/paper_trail_diff/instrumentation.rb +2 -2
  41. data/lib/paper_trail_diff/nested_comparator.rb +39 -15
  42. data/lib/paper_trail_diff/paper_trail_adapter.rb +109 -55
  43. data/lib/paper_trail_diff/prepared_activity_index.rb +121 -0
  44. data/lib/paper_trail_diff/prepared_edge_loader.rb +11 -11
  45. data/lib/paper_trail_diff/prepared_history.rb +21 -30
  46. data/lib/paper_trail_diff/prepared_history_loader.rb +14 -2
  47. data/lib/paper_trail_diff/prepared_record_index.rb +102 -29
  48. data/lib/paper_trail_diff/prepared_snapshot_builder.rb +64 -0
  49. data/lib/paper_trail_diff/prepared_state_association_reader.rb +77 -0
  50. data/lib/paper_trail_diff/root_version_plan.rb +25 -2
  51. data/lib/paper_trail_diff/root_version_selection.rb +16 -5
  52. data/lib/paper_trail_diff/scoped_analysis.rb +17 -2
  53. data/lib/paper_trail_diff/scoped_analysis_builder.rb +47 -0
  54. data/lib/paper_trail_diff/scoped_root_selection.rb +106 -13
  55. data/lib/paper_trail_diff/snapshot.rb +9 -0
  56. data/lib/paper_trail_diff/snapshot_normalizer.rb +41 -2
  57. data/lib/paper_trail_diff/step.rb +10 -5
  58. data/lib/paper_trail_diff/stored_time_precision.rb +68 -0
  59. data/lib/paper_trail_diff/support.rb +13 -3
  60. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +19 -7
  61. data/lib/paper_trail_diff/time_range.rb +22 -2
  62. data/lib/paper_trail_diff/time_version_range.rb +1 -15
  63. data/lib/paper_trail_diff/timeline_builder.rb +5 -4
  64. data/lib/paper_trail_diff/timeline_range.rb +25 -43
  65. data/lib/paper_trail_diff/timeline_snapshot_provider.rb +13 -2
  66. data/lib/paper_trail_diff/traversal.rb +1 -1
  67. data/lib/paper_trail_diff/version.rb +1 -1
  68. data/lib/paper_trail_diff/version_association_candidate_scope.rb +1 -1
  69. data/lib/paper_trail_diff/version_boundary.rb +48 -0
  70. data/lib/paper_trail_diff/version_query.rb +33 -0
  71. data/lib/paper_trail_diff/version_range.rb +10 -38
  72. data/lib/paper_trail_diff.rb +78 -42
  73. data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +0 -3
  74. data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -4
  75. data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +11 -0
  76. data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +4 -4
  77. data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +0 -3
  78. data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +19 -3
  79. data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +2 -2
  80. data/sig/generated/paper_trail_diff/activity_grouping.rbs +3 -0
  81. data/sig/generated/paper_trail_diff/activity_history.rbs +6 -2
  82. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +7 -2
  83. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +8 -3
  84. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +4 -8
  85. data/sig/generated/paper_trail_diff/analysis.rbs +2 -2
  86. data/sig/generated/paper_trail_diff/analysis_batch.rbs +14 -3
  87. data/sig/generated/paper_trail_diff/association_traversal.rbs +11 -0
  88. data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +7 -10
  89. data/sig/generated/paper_trail_diff/batch_safety.rbs +24 -0
  90. data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +8 -2
  91. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +4 -16
  92. data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
  93. data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +44 -0
  94. data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +42 -0
  95. data/sig/generated/paper_trail_diff/endpoint.rbs +5 -0
  96. data/sig/generated/paper_trail_diff/engine.rbs +4 -2
  97. data/sig/generated/paper_trail_diff/errors.rbs +12 -0
  98. data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +8 -0
  99. data/sig/generated/paper_trail_diff/historical_filter.rbs +75 -0
  100. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +17 -4
  101. data/sig/generated/paper_trail_diff/nested_comparator.rbs +15 -7
  102. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +36 -15
  103. data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +56 -0
  104. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +3 -3
  105. data/sig/generated/paper_trail_diff/prepared_history.rbs +12 -14
  106. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -0
  107. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +43 -6
  108. data/sig/generated/paper_trail_diff/prepared_snapshot_builder.rbs +24 -0
  109. data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +33 -0
  110. data/sig/generated/paper_trail_diff/root_version_plan.rbs +15 -2
  111. data/sig/generated/paper_trail_diff/root_version_selection.rbs +8 -1
  112. data/sig/generated/paper_trail_diff/scoped_analysis.rbs +14 -2
  113. data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +22 -0
  114. data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +46 -6
  115. data/sig/generated/paper_trail_diff/snapshot.rbs +4 -0
  116. data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +13 -0
  117. data/sig/generated/paper_trail_diff/step.rbs +8 -2
  118. data/sig/generated/paper_trail_diff/stored_time_precision.rbs +36 -0
  119. data/sig/generated/paper_trail_diff/support.rbs +7 -0
  120. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +7 -2
  121. data/sig/generated/paper_trail_diff/time_range.rbs +9 -0
  122. data/sig/generated/paper_trail_diff/time_version_range.rbs +0 -6
  123. data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
  124. data/sig/generated/paper_trail_diff/timeline_range.rbs +10 -14
  125. data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +2 -0
  126. data/sig/generated/paper_trail_diff/version_boundary.rbs +23 -0
  127. data/sig/generated/paper_trail_diff/version_query.rbs +15 -0
  128. data/sig/generated/paper_trail_diff/version_range.rbs +5 -14
  129. data/sig/generated/paper_trail_diff.rbs +31 -25
  130. metadata +31 -5
data/docs/testing.md ADDED
@@ -0,0 +1,146 @@
1
+ # Testing
2
+
3
+ The default suite uses an in-memory SQLite database:
4
+
5
+ ```console
6
+ mise exec -- bundle install
7
+ mise exec -- bundle exec rake
8
+ ```
9
+
10
+ The default task runs the core specs without PT-AT loaded, the association specs
11
+ in a separate process, RuboCop (with the rubocop-performance and rubocop-rspec
12
+ plugins), generated-signature verification, and Steep.
13
+ Ruby blocks in the README, quickstart, and API reference marked with
14
+ `<!-- executable:... -->` run inside those specs, so documented examples stay
15
+ correct.
16
+
17
+ For reporting workloads, query counts, reconstruction cost, and memory readings,
18
+ see [Reporting performance](performance.md). Those measurements run separately
19
+ from the correctness suite and have no timing-based CI gate.
20
+
21
+ CI keeps the Ruby 3.1–4.0 and PaperTrail 16/17 compatibility matrix on SQLite,
22
+ including Windows runs. Separate Linux jobs run both core and association suites
23
+ on PostgreSQL 17, MySQL 8.4, and SQL Server 2019 and 2022 with Ruby 4.0 and
24
+ PaperTrail 17.
25
+ The SQL jobs exercise the same regressions, including joined-column ordering,
26
+ duplicate roots, pagination, and historical association reconstruction.
27
+
28
+ ## Consistency checks
29
+
30
+ `spec/association/consistency_spec.rb` and `spec/integration/consistency_spec.rb`
31
+ build seeded, generated histories: root edits, checkpoints, and child creates,
32
+ edits, and deletes. For each one they check that every route to the same answer
33
+ agrees: `analyze`, `analyze_many`, and `analyze_scope`; checkpoint and activity
34
+ modes; `compare` and `timeline` against `analyze`; and that activity steps
35
+ chain into the final state. They run over random windows as well as the whole
36
+ history, with integer and string `versions.item_id`, YAML and JSON history, and
37
+ a `has_paper_trail skip:` column. When one fails, its seed reproduces it. Add a
38
+ variant here when a bug turns out to depend on a schema or configuration.
39
+
40
+ ## Mutation testing
41
+
42
+ [Mutant](https://github.com/mbj/mutant) changes the comparison code one
43
+ expression at a time and expects a spec to fail each time. A change that every
44
+ spec still passes is behavior nothing checks. `mutant.yml` lists the classes it
45
+ mutates: `Engine`, `CollectionComparator`, `CollectionIdentityIndex`, and
46
+ `NestedComparator`, which decide what counts as a change. It runs the
47
+ association suite, which reaches all of their paths.
48
+
49
+ Mutant needs Ruby 3.3 or newer, so it has its own bundle:
50
+
51
+ ```console
52
+ BUNDLE_GEMFILE=gemfiles/mutant.gemfile mise exec -- bundle install
53
+ mise exec -- bundle exec rake mutant
54
+ ```
55
+
56
+ Pass a subject to mutate one class, and `--since main` to mutate only what a
57
+ branch changed:
58
+
59
+ ```console
60
+ BUNDLE_GEMFILE=gemfiles/mutant.gemfile mise exec -- bundle exec mutant run 'PaperTrailDiff::Engine*'
61
+ BUNDLE_GEMFILE=gemfiles/mutant.gemfile mise exec -- bundle exec mutant run --since main
62
+ ```
63
+
64
+ When a mutation survives, either add the spec that would catch it, or, if the
65
+ mutated code means the same thing, simplify the code to that form. Reserve a
66
+ `mutant:disable` comment for a method whose mutations all mean the same thing,
67
+ and say why next to it. Mutant forks a worker per CPU; `spec/support/mutant_hooks.rb`
68
+ gives each worker its own copy of the SQLite fixture database.
69
+
70
+ ## Test clock
71
+
72
+ The specs build time windows from `Time.now` around versions they have just
73
+ written, so they need a clock that never runs backwards. Virtualized hosts,
74
+ WSL2 in particular, step the wall clock back by hundreds of milliseconds when
75
+ they resynchronize, which made window-based specs fail at random.
76
+ `spec/support/monotonic_clock.rb` therefore makes `Time.now` strictly
77
+ increasing, in whole microseconds, for the test process. ActiveRecord
78
+ timestamps, PaperTrail's `created_at`, and windows built from `Time.now` all
79
+ read it, so they stay consistent however the host clock moves. It affects only
80
+ the test process; the gem itself reads the ordinary clock.
81
+
82
+ ## Other databases locally
83
+
84
+ Install the adapter's client development libraries first. On Debian/Ubuntu,
85
+ the packages are `libpq-dev` for PostgreSQL, `default-libmysqlclient-dev` for
86
+ MySQL, and `freetds-dev` for SQL Server. The optional development group includes
87
+ all three adapters:
88
+
89
+ ```console
90
+ BUNDLE_WITH=database_adapters mise exec -- bundle install
91
+ ```
92
+
93
+ Create a dedicated disposable database named `paper_trail_diff_test`. The suites
94
+ recreate their fixture tables. Set `PAPER_TRAIL_DIFF_DATABASE_URL` to its
95
+ connection URL and run both suites:
96
+
97
+ ```console
98
+ BUNDLE_WITH=database_adapters PAPER_TRAIL_DIFF_DATABASE_URL=postgresql://paper_trail_diff:test@127.0.0.1:5432/paper_trail_diff_test mise exec -- bundle exec rake spec
99
+ BUNDLE_WITH=database_adapters PAPER_TRAIL_DIFF_DATABASE_URL=mysql2://paper_trail_diff:test@127.0.0.1:3306/paper_trail_diff_test mise exec -- bundle exec rake spec
100
+ BUNDLE_WITH=database_adapters PAPER_TRAIL_DIFF_DATABASE_URL=sqlserver://sa:PaperTrail_Test2026@127.0.0.1:1433/paper_trail_diff_test mise exec -- bundle exec rake spec
101
+ ```
102
+
103
+ These credentials are examples for disposable local instances. SQL Server's
104
+ adapter version follows ActiveRecord's version; Bundler selects a compatible
105
+ adapter. No database driver is a runtime dependency of the gem.
106
+
107
+ SQL Server's adapter wraps datetime2 values during serialization. With
108
+ PaperTrail's default YAML serializer, the fixtures explicitly permit
109
+ `ActiveRecord::ConnectionAdapters::SQLServer::Type::Data` and
110
+ `ActiveRecord::ConnectionAdapters::SQLServer::Type::DateTime2`, in addition to
111
+ `Symbol` and `Time`. Applications using that serializer need the corresponding
112
+ `ActiveRecord.yaml_column_permitted_classes` configuration. The gem does not
113
+ change an application's YAML configuration.
114
+
115
+ The CI service configuration is in `.github/workflows/ci.yml`. PostgreSQL and
116
+ MySQL create the named database during container initialization. The SQL Server
117
+ jobs create it explicitly after the service passes its health check.
118
+
119
+ ## Generated signatures
120
+
121
+ Inline RBS comments in `lib/` are the source of truth:
122
+
123
+ ```console
124
+ mise exec -- bundle exec rake rbs
125
+ mise exec -- bundle exec rake typecheck
126
+ ```
127
+
128
+ The typecheck task regenerates signatures and compares them with Git's index
129
+ before running Steep. Unstaged generated changes therefore fail the gate even
130
+ when they are correct. Review and stage matching source/signature changes before
131
+ running the complete gate. The Steep wrapper also fails on internal errors even
132
+ if Steep returns exit status zero.
133
+
134
+ ## Releasing
135
+
136
+ Tags matching `v*` publish through RubyGems trusted publishing. Never add a
137
+ long-lived RubyGems API key. Before tagging:
138
+
139
+ 1. Update `PaperTrailDiff::VERSION` and replace `Unreleased` in the changelog
140
+ with that version and the release date.
141
+ 2. Run `mise exec -- bundle exec rake release:preflight`. It runs the full gate,
142
+ checks that the tree is clean and pushed, that the Appraisal gemfiles are
143
+ current, and that the changelog names the version, then builds the gem. It
144
+ does not tag, push, or publish.
145
+ 3. Configure the RubyGems trusted publisher for `release.yml` and the `release`
146
+ environment.
@@ -45,7 +45,7 @@ module PaperTrailDiff
45
45
 
46
46
  #: (Array[untyped], untyped, SnapshotNormalizer, untyped, untyped) -> RecordSnapshot?
47
47
  def normalized_route_record(route, version, normalizer, root_endpoint, context_endpoint)
48
- record = @record_resolver.record_after(version)
48
+ record = @record_resolver.record_after(version, direct: route.last.fetch(2).empty?)
49
49
  return unless record
50
50
 
51
51
  _name, reflection, subtree, path = route.last
@@ -70,7 +70,7 @@ module PaperTrailDiff
70
70
  return [snapshot, false] unless changed
71
71
 
72
72
  updated_association = AssociationSnapshot.new(kind: association.kind, records: records)
73
- [replace_association(snapshot, name, updated_association), true]
73
+ [snapshot.with_association(name, updated_association), true]
74
74
  end
75
75
 
76
76
  #: (Array[RecordSnapshot], Array[untyped], untyped, RecordSnapshot?, Integer) -> [Array[RecordSnapshot], bool]
@@ -101,16 +101,6 @@ module PaperTrailDiff
101
101
  [updated.freeze, true]
102
102
  end
103
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
104
  #: (untyped, RecordSnapshot?) -> String
115
105
  def replacement_type(version, replacement)
116
106
  return replacement.type.to_s if replacement
@@ -107,15 +107,19 @@ module PaperTrailDiff
107
107
  kind == :destroyed
108
108
  end
109
109
 
110
- #: () -> Hash[Symbol, untyped]
111
- def to_h
112
- {
110
+ #: (?metadata: bool) -> Hash[Symbol, untyped]
111
+ def to_h(metadata: false)
112
+ value = {
113
113
  kind: kind,
114
114
  version_id: version_id,
115
115
  item_type: item_type,
116
116
  item_id: item_id,
117
117
  recorded_at: recorded_at
118
118
  }
119
+ return value unless metadata
120
+
121
+ value.merge(event: event, whodunnit: whodunnit, transaction_id: transaction_id,
122
+ record: record.to_h)
119
123
  end
120
124
  end
121
125
 
@@ -161,13 +165,21 @@ module PaperTrailDiff
161
165
  diff.empty?
162
166
  end
163
167
 
164
- #: () -> Hash[Symbol, untyped]
165
- def to_h
166
- {
167
- from: from_boundary.to_h,
168
- to: to_boundary.to_h,
168
+ # The event whose change this step reports. For a transaction group, this
169
+ # is the first event in the group, not necessarily its only actor or record.
170
+ #: () -> ActivityBoundary
171
+ def source_boundary = from_boundary
172
+
173
+ #: (?metadata: bool, ?snapshots: bool) -> Hash[Symbol, untyped]
174
+ def to_h(metadata: false, snapshots: false)
175
+ value = {
176
+ from: from_boundary.to_h(metadata: metadata),
177
+ to: to_boundary.to_h(metadata: metadata),
169
178
  diff: diff.to_h
170
179
  }
180
+ return value unless snapshots
181
+
182
+ value.merge(from_snapshot: from_snapshot&.to_h, to_snapshot: to_snapshot&.to_h)
171
183
  end
172
184
  end
173
185
  end
@@ -17,6 +17,27 @@ module PaperTrailDiff
17
17
  historical_ids | current_ids
18
18
  end
19
19
 
20
+ # One shared query retains the owner and timestamp needed to partition a
21
+ # batch's candidates back into each root's exact activity range.
22
+ #: () -> Array[Array[untyped]]
23
+ def historical_rows
24
+ version_class = @parent_class.paper_trail.version_class
25
+ versions = version_class.arel_table
26
+ associations = @parent_class.paper_trail.version_association_class.arel_table
27
+ typed_rows(
28
+ candidate_scope(version_class).call(historical_relation(version_class)).distinct.pluck(
29
+ associations[:foreign_key_id], versions[:item_id], versions[:created_at], versions[:event]
30
+ )
31
+ )
32
+ end
33
+
34
+ #: () -> Array[Array[untyped]]
35
+ def current_pairs
36
+ relation = @reflection.klass.base_class.unscoped.where(@reflection.foreign_key => @parent_ids)
37
+ relation = relation.where(@reflection.type => parent_types) if @reflection.options[:as]
38
+ relation.distinct.pluck(@reflection.foreign_key, @reflection.klass.primary_key)
39
+ end
40
+
20
41
  private
21
42
 
22
43
  # @rbs @parent_class: untyped
@@ -24,11 +45,19 @@ module PaperTrailDiff
24
45
  # @rbs @reflection: untyped
25
46
  # @rbs @range: ActivityRange
26
47
 
48
+ #: (Array[Array[untyped]]) -> Array[Array[untyped]]
49
+ def typed_rows(rows)
50
+ rows.map do |owner_id, item_id, created_at, event|
51
+ [owner_id, Support.record_id(@reflection.klass, item_id), created_at, event]
52
+ end
53
+ end
54
+
27
55
  #: () -> Array[untyped]
28
56
  def historical_ids
29
57
  version_class = @parent_class.paper_trail.version_class
30
58
  candidate_scope(version_class).call(historical_relation(version_class))
31
59
  .distinct.pluck(version_class.arel_table[:item_id])
60
+ .map { |id| Support.record_id(@reflection.klass, id) }
32
61
  end
33
62
 
34
63
  #: (untyped) -> untyped
@@ -30,9 +30,10 @@ module PaperTrailDiff
30
30
  )
31
31
  fallback_record = [] #: Array[untyped]
32
32
  endpoints = [root_endpoint, context_endpoint]
33
+ direct = routes.all? { |route| route.last.fetch(2).empty? }
33
34
  snapshot = routes.reduce(previous) do |current, route|
34
35
  apply_route(
35
- current, route, version, delta, fallback_record, normalizer, endpoints
36
+ current, route, version, delta, fallback_record, normalizer, endpoints, direct: direct
36
37
  )
37
38
  end
38
39
  [true, snapshot]
@@ -45,12 +46,12 @@ module PaperTrailDiff
45
46
  # @rbs @route_updater: ActivityCollectionRouteUpdater
46
47
  # @rbs @relationship: ActivityRelationship
47
48
 
48
- #: (RecordSnapshot, Array[untyped], untyped, ActivitySnapshotDelta?, Array[untyped], SnapshotNormalizer, Array[untyped]) -> RecordSnapshot
49
+ #: (RecordSnapshot, Array[untyped], untyped, ActivitySnapshotDelta?, Array[untyped], SnapshotNormalizer, Array[untyped], ?direct: bool) -> RecordSnapshot
49
50
  def apply_route( # rubocop:disable Metrics/ParameterLists
50
- snapshot, route, version, delta, fallback_record, normalizer, endpoints
51
+ snapshot, route, version, delta, fallback_record, normalizer, endpoints, direct: false
51
52
  )
52
53
  record, replacement = route_event_state(
53
- snapshot, route, version, delta, fallback_record, normalizer, endpoints
54
+ snapshot, route, version, delta, fallback_record, normalizer, endpoints, direct: direct
54
55
  )
55
56
  change = ActivityCollectionRouteChange.new(
56
57
  route: route,
@@ -81,14 +82,16 @@ module PaperTrailDiff
81
82
  end
82
83
  end
83
84
 
84
- #: (RecordSnapshot, Array[untyped], untyped, ActivitySnapshotDelta?, Array[untyped], SnapshotNormalizer, Array[untyped]) -> [untyped, RecordSnapshot?]
85
+ #: (RecordSnapshot, Array[untyped], untyped, ActivitySnapshotDelta?, Array[untyped], SnapshotNormalizer, Array[untyped], ?direct: bool) -> [untyped, RecordSnapshot?]
85
86
  def route_event_state( # rubocop:disable Metrics/ParameterLists
86
- snapshot, route, version, delta, fallback_record, normalizer, endpoints
87
+ snapshot, route, version, delta, fallback_record, normalizer, endpoints, direct: false
87
88
  )
88
89
  existing = existing_delta_record(snapshot, route, version, delta) if delta
89
90
  return [delta, delta.apply(existing)] if delta && existing
90
91
 
91
- fallback_record << @record_resolver.record_after(version) if fallback_record.empty?
92
+ if fallback_record.empty?
93
+ fallback_record << @record_resolver.record_after(version, direct: direct)
94
+ end
92
95
  record = fallback_record.first
93
96
  return [record, nil] unless record
94
97
 
@@ -28,7 +28,7 @@ module PaperTrailDiff
28
28
  membership_preserved: membership_preserved
29
29
  )
30
30
  )
31
- updated_snapshot = replace_association(snapshot, name, updated_association)
31
+ updated_snapshot = snapshot.with_association(name, updated_association)
32
32
  parent_path = depth.zero? ? '' : change.route.fetch(depth - 1).fetch(3)
33
33
  updated_snapshot = @pool.record(parent_path, updated_snapshot) unless parent_path.empty?
34
34
  [updated_snapshot, true]
@@ -114,15 +114,5 @@ module PaperTrailDiff
114
114
  owner_id = @relationship.owner_id(membership_record, reflection, state: :after)
115
115
  association.position_for_id(owner_id)
116
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
117
  end
128
118
  end
@@ -19,6 +19,10 @@ module PaperTrailDiff
19
19
  root_endpoint:,
20
20
  context_endpoint:
21
21
  )
22
+ if record.is_a?(PreparedRecordState)
23
+ return normalizer.from_state(record, tree: subtree, path: path, incoming: reflection)
24
+ end
25
+
22
26
  boundary = Endpoint.version?(root_endpoint) ? root_endpoint : context_endpoint
23
27
  reifier = @association_reader.call(context_endpoint, habtm_version: boundary)
24
28
  normalizer.call_child(
@@ -10,15 +10,39 @@ module PaperTrailDiff
10
10
  }.freeze
11
11
  private_constant :RECORD_AFTER_HANDLERS
12
12
 
13
- #: (?record_transition: untyped) -> void
14
- def initialize(record_transition: nil)
13
+ #: (?record_transition: untyped, ?prepared_record_after: untyped) -> void
14
+ def initialize(record_transition: nil, prepared_record_after: nil)
15
15
  @record_transition = record_transition
16
+ @prepared_record_after = prepared_record_after
17
+ @direct_loader = DirectRecordStateLoader.new
16
18
  end
17
19
 
18
- #: (untyped) -> untyped
19
- def record_after(version)
20
+ #: (untyped, ?direct: bool) -> untyped
21
+ def record_after(version, direct: false)
20
22
  return if version.event.to_s == 'destroy'
21
23
 
24
+ if direct
25
+ handled, state = direct_after(version)
26
+ return state if handled
27
+ end
28
+
29
+ handled, prepared = @prepared_record_after&.call(version)
30
+ return prepared if handled
31
+
32
+ fallback_record_after(version)
33
+ end
34
+
35
+ #: (untyped) -> [bool, untyped]
36
+ def direct_after(version)
37
+ handled, state = @prepared_record_after&.call(version, direct: true)
38
+ return [true, state] if handled
39
+
40
+ state = direct_record_after(version)
41
+ [!state.nil?, state]
42
+ end
43
+
44
+ #: (untyped) -> untyped
45
+ def fallback_record_after(version)
22
46
  changed_record = changed_record_after(version)
23
47
  return changed_record if changed_record
24
48
 
@@ -70,18 +94,54 @@ module PaperTrailDiff
70
94
  # +version.item+, which would issue one live-record query per event.
71
95
  #: (untyped, untyped) -> untyped
72
96
  def deserialized_changeset(version, model_class)
73
- return unless model_class && version.class.column_names.include?('object_changes')
97
+ return unless model_class
98
+ unless version.class.column_names.include?('object_changes')
99
+ return prepared_changeset(version)
100
+ end
74
101
 
75
102
  paper_trail = Object.const_get(:PaperTrail) #: untyped
76
103
  adapter = paper_trail.config.object_changes_adapter
77
104
  return adapter.load_changeset(version) if adapter.respond_to?(:load_changeset)
78
105
 
79
- standard_changeset(paper_trail, version, model_class)
106
+ changes = standard_changeset(paper_trail, version, model_class)
107
+ changes.empty? ? prepared_changeset(version) : changes
80
108
  end
81
109
 
82
110
  private
83
111
 
84
112
  # @rbs @record_transition: untyped
113
+ # @rbs @prepared_record_after: untyped
114
+ # @rbs @direct_loader: DirectRecordStateLoader
115
+
116
+ #: (untyped) -> PreparedRecordState?
117
+ def direct_record_after(version)
118
+ model = Endpoint.model_class(version)
119
+ changes = deserialized_changeset(version, model)
120
+ return unless changes.respond_to?(:each) && !changes.empty?
121
+
122
+ before = if version.object
123
+ @direct_loader.call(version)
124
+ else
125
+ PreparedRecordState.from_attributes(
126
+ model, {}
127
+ )
128
+ end
129
+ return unless before
130
+
131
+ attributes = before.attributes.merge(after_attributes(changes, model))
132
+ @direct_loader.complete(PreparedRecordState.from_attributes(model, attributes))
133
+ end
134
+
135
+ #: (untyped) -> untyped
136
+ def prepared_changeset(version)
137
+ transition = @record_transition&.call(version)
138
+ return {} unless transition
139
+
140
+ before, after = transition
141
+ (before.keys | after.keys).filter_map do |name|
142
+ [name, [before[name], after[name]]] unless before[name] == after[name]
143
+ end.to_h
144
+ end
85
145
 
86
146
  #: (untyped, untyped) -> Hash[untyped, untyped]
87
147
  def after_attributes(changes, model_class)
@@ -4,10 +4,12 @@
4
4
  module PaperTrailDiff
5
5
  # Dispatches safe descendant events to focused immutable snapshot appliers.
6
6
  class ActivityEventSnapshotRefresher
7
- #: (traversal: AssociationTraversal, pool: SnapshotPool, components: untyped, ?association_reader: untyped, ?record_transition: untyped) -> void
8
- def initialize(traversal:, pool:, components:, association_reader: nil, record_transition: nil)
7
+ #: (traversal: AssociationTraversal, pool: SnapshotPool, components: untyped, ?association_reader: untyped, ?record_transition: untyped, ?prepared_record_after: untyped) -> void
8
+ def initialize(traversal:, pool:, components:, association_reader: nil, record_transition: nil, prepared_record_after: nil) # rubocop:disable Layout/LineLength, Metrics/ParameterLists
9
9
  @components = components
10
- @record_resolver = ActivityEventRecordResolver.new(record_transition: record_transition)
10
+ @record_resolver = ActivityEventRecordResolver.new(
11
+ record_transition: record_transition, prepared_record_after: prepared_record_after
12
+ )
11
13
  @route_finder = ActivityEventRouteFinder.new(traversal)
12
14
  @relationship = ActivityRelationship.new
13
15
  configure_appliers(pool, association_reader)
@@ -99,7 +101,7 @@ module PaperTrailDiff
99
101
 
100
102
  #: (untyped) -> untyped
101
103
  def record_after(version)
102
- @record_resolver.record_after(version)
104
+ @record_resolver.record_after(version, direct: true)
103
105
  end
104
106
 
105
107
  #: (untyped, untyped) -> untyped
@@ -7,6 +7,18 @@ module PaperTrailDiff
7
7
  # window -- but a transaction has to collapse the same way in both, or the
8
8
  # same history would read differently depending on how it was asked for.
9
9
  module ActivityGrouping
10
+ #: (Symbol?, ?activity: bool, ?snapshots: bool) -> void
11
+ def self.validate!(group, activity: true, snapshots: false)
12
+ unless group.nil? || group == :transaction
13
+ raise ConfigurationError, 'group: must be :transaction or nil'
14
+ end
15
+ raise ConfigurationError, 'snapshots: requires activity: true' if snapshots && !activity
16
+
17
+ return if group.nil? || activity
18
+
19
+ raise ConfigurationError, 'group: requires activity: true'
20
+ end
21
+
10
22
  private
11
23
 
12
24
  #: () -> bool
@@ -8,13 +8,17 @@ module PaperTrailDiff
8
8
  attr_reader :root_snapshots #: Hash[Array[untyped], RecordSnapshot?]
9
9
  attr_reader :first_snapshot #: RecordSnapshot?
10
10
  attr_reader :last_snapshot #: RecordSnapshot?
11
+ attr_reader :last_boundary #: ActivityBoundary?
12
+ attr_reader :last_event_snapshot #: RecordSnapshot?
11
13
 
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: Array[ActivityStep], root_snapshots: Hash[Array[untyped], RecordSnapshot?], first_snapshot: RecordSnapshot?, last_snapshot: RecordSnapshot?, ?last_boundary: ActivityBoundary?, ?last_event_snapshot: RecordSnapshot?) -> void
15
+ def initialize(steps:, root_snapshots:, first_snapshot:, last_snapshot:, last_boundary: nil, last_event_snapshot: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
14
16
  @steps = steps.freeze
15
17
  @root_snapshots = root_snapshots.freeze
16
18
  @first_snapshot = first_snapshot
17
19
  @last_snapshot = last_snapshot
20
+ @last_boundary = last_boundary
21
+ @last_event_snapshot = last_event_snapshot
18
22
  freeze
19
23
  end
20
24
 
@@ -46,7 +50,9 @@ module PaperTrailDiff
46
50
  steps: @steps,
47
51
  root_snapshots: @root_snapshots,
48
52
  first_snapshot: @first_snapshot,
49
- last_snapshot: final_snapshot
53
+ last_snapshot: final_snapshot,
54
+ last_boundary: @previous_boundary,
55
+ last_event_snapshot: @previous_snapshot
50
56
  )
51
57
  end
52
58
 
@@ -36,6 +36,8 @@ module PaperTrailDiff
36
36
 
37
37
  #: (untyped, untyped, state: Symbol) -> untyped
38
38
  def event_attribute(record, name, state:)
39
+ return record.attributes[name.to_s] if record.is_a?(PreparedRecordState)
40
+
39
41
  if record.is_a?(ActivitySnapshotDelta)
40
42
  return state == :before ? record.before_value(name) : record.after_value(name)
41
43
  end
@@ -38,6 +38,7 @@ module PaperTrailDiff
38
38
 
39
39
  changes = @changeset.call(version, model_class)
40
40
  return [false, nil] unless changes.respond_to?(:each)
41
+ return [false, nil] if changes.empty?
41
42
 
42
43
  branches = branches_to_refresh(model_class, changes)
43
44
  partial = nil #: RecordSnapshot?
@@ -4,8 +4,9 @@
4
4
  module PaperTrailDiff
5
5
  # Optional full/partial snapshot operations consumed by the activity sequence.
6
6
  class ActivitySnapshotProvider
7
- #: (snapshotter: untyped, refresher: untyped, preparer: untyped) -> void
8
- def initialize(snapshotter:, refresher:, preparer:)
7
+ #: (snapshotter: untyped, refresher: untyped, preparer: untyped, ?activity_index: untyped) -> void
8
+ def initialize(snapshotter:, refresher:, preparer:, activity_index: nil)
9
+ @activity_index = activity_index
9
10
  @snapshotter = snapshotter
10
11
  @refresher = refresher
11
12
  @preparer = preparer
@@ -55,6 +56,12 @@ module PaperTrailDiff
55
56
  )
56
57
  end
57
58
 
59
+ #: () -> PreparedActivityIndex?
60
+ def activity_index
61
+ @activity_index&.call
62
+ end
63
+
64
+ # @rbs @activity_index: untyped
58
65
  # @rbs @snapshotter: untyped
59
66
  # @rbs @refresher: untyped
60
67
  # @rbs @preparer: untyped