paper_trail_diff 0.2.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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -0
  3. data/QUICKSTART.md +44 -2
  4. data/README.md +188 -14
  5. data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +126 -0
  6. data/lib/paper_trail_diff/activity_child_candidate_loader.rb +73 -0
  7. data/lib/paper_trail_diff/activity_collection_event_applier.rb +137 -0
  8. data/lib/paper_trail_diff/activity_collection_record_updater.rb +67 -0
  9. data/lib/paper_trail_diff/activity_collection_route_change.rb +21 -0
  10. data/lib/paper_trail_diff/activity_collection_route_updater.rb +128 -0
  11. data/lib/paper_trail_diff/activity_event_record_normalizer.rb +42 -0
  12. data/lib/paper_trail_diff/activity_event_record_resolver.rb +120 -0
  13. data/lib/paper_trail_diff/activity_event_route_finder.rb +112 -0
  14. data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +59 -437
  15. data/lib/paper_trail_diff/activity_history.rb +136 -0
  16. data/lib/paper_trail_diff/activity_range.rb +28 -12
  17. data/lib/paper_trail_diff/activity_relationship.rb +55 -0
  18. data/lib/paper_trail_diff/activity_snapshot_delta.rb +64 -0
  19. data/lib/paper_trail_diff/activity_snapshot_sequence.rb +3 -3
  20. data/lib/paper_trail_diff/activity_timeline_builder.rb +54 -45
  21. data/lib/paper_trail_diff/activity_version_collector.rb +14 -17
  22. data/lib/paper_trail_diff/branch_snapshot_refresher.rb +7 -3
  23. data/lib/paper_trail_diff/collection_comparator.rb +155 -0
  24. data/lib/paper_trail_diff/collection_identity_index.rb +53 -0
  25. data/lib/paper_trail_diff/collection_transition.rb +25 -0
  26. data/lib/paper_trail_diff/comparison_batch.rb +151 -0
  27. data/lib/paper_trail_diff/engine.rb +5 -45
  28. data/lib/paper_trail_diff/errors.rb +9 -0
  29. data/lib/paper_trail_diff/historical_snapshot_store.rb +55 -3
  30. data/lib/paper_trail_diff/instrumentation.rb +30 -0
  31. data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +133 -0
  32. data/lib/paper_trail_diff/live_endpoint_provider.rb +48 -0
  33. data/lib/paper_trail_diff/live_graph_collector.rb +52 -0
  34. data/lib/paper_trail_diff/paper_trail_adapter.rb +61 -34
  35. data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +48 -0
  36. data/lib/paper_trail_diff/prepared_edge_loader.rb +54 -17
  37. data/lib/paper_trail_diff/prepared_history.rb +14 -4
  38. data/lib/paper_trail_diff/prepared_history_loader.rb +17 -9
  39. data/lib/paper_trail_diff/prepared_record_index.rb +138 -10
  40. data/lib/paper_trail_diff/snapshot.rb +66 -3
  41. data/lib/paper_trail_diff/support.rb +18 -0
  42. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +111 -0
  43. data/lib/paper_trail_diff/time_range.rb +85 -0
  44. data/lib/paper_trail_diff/time_version_range.rb +53 -0
  45. data/lib/paper_trail_diff/timeline_builder.rb +13 -7
  46. data/lib/paper_trail_diff/timeline_range.rb +72 -0
  47. data/lib/paper_trail_diff/version.rb +1 -1
  48. data/lib/paper_trail_diff/version_association_candidate_scope.rb +73 -0
  49. data/lib/paper_trail_diff.rb +83 -12
  50. data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +41 -0
  51. data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +40 -0
  52. data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +46 -0
  53. data/sig/generated/paper_trail_diff/activity_collection_record_updater.rbs +25 -0
  54. data/sig/generated/paper_trail_diff/activity_collection_route_change.rbs +17 -0
  55. data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +44 -0
  56. data/sig/generated/paper_trail_diff/activity_event_record_normalizer.rbs +19 -0
  57. data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +44 -0
  58. data/sig/generated/paper_trail_diff/activity_event_route_finder.rbs +54 -0
  59. data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +20 -71
  60. data/sig/generated/paper_trail_diff/activity_history.rbs +79 -0
  61. data/sig/generated/paper_trail_diff/activity_range.rbs +14 -5
  62. data/sig/generated/paper_trail_diff/activity_relationship.rbs +23 -0
  63. data/sig/generated/paper_trail_diff/activity_snapshot_delta.rbs +33 -0
  64. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +2 -2
  65. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +15 -10
  66. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +2 -5
  67. data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
  68. data/sig/generated/paper_trail_diff/collection_comparator.rbs +59 -0
  69. data/sig/generated/paper_trail_diff/collection_identity_index.rbs +29 -0
  70. data/sig/generated/paper_trail_diff/collection_transition.rbs +18 -0
  71. data/sig/generated/paper_trail_diff/comparison_batch.rbs +65 -0
  72. data/sig/generated/paper_trail_diff/engine.rbs +0 -12
  73. data/sig/generated/paper_trail_diff/errors.rbs +12 -0
  74. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +22 -2
  75. data/sig/generated/paper_trail_diff/instrumentation.rbs +12 -0
  76. data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +52 -0
  77. data/sig/generated/paper_trail_diff/live_endpoint_provider.rbs +23 -0
  78. data/sig/generated/paper_trail_diff/live_graph_collector.rbs +27 -0
  79. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +21 -10
  80. data/sig/generated/paper_trail_diff/preloaded_endpoint_batch_loader.rbs +24 -0
  81. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +22 -8
  82. data/sig/generated/paper_trail_diff/prepared_history.rbs +6 -0
  83. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +4 -2
  84. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +61 -6
  85. data/sig/generated/paper_trail_diff/snapshot.rbs +37 -2
  86. data/sig/generated/paper_trail_diff/support.rbs +6 -0
  87. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +46 -0
  88. data/sig/generated/paper_trail_diff/time_range.rbs +41 -0
  89. data/sig/generated/paper_trail_diff/time_version_range.rbs +27 -0
  90. data/sig/generated/paper_trail_diff/timeline_builder.rbs +6 -5
  91. data/sig/generated/paper_trail_diff/timeline_range.rbs +41 -0
  92. data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +32 -0
  93. data/sig/generated/paper_trail_diff.rbs +16 -8
  94. metadata +56 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 808e6dba6570e96679cb5b355b774e8afcd3b6bf81c38c1a0b0fc088d6f9f416
4
- data.tar.gz: 5978755e10f821777aadc86b6f4d79728a827cf48c1ba3e95c89a6f627eaea6e
3
+ metadata.gz: 6aa099f4051971c69ed9b957e68ed4acca7e3cdde7c97efd2cb9a1af90d91dd1
4
+ data.tar.gz: 67ef88d6978b8b559a1a156f52aaea97a6535c71ec04df5d913f062114ec7a43
5
5
  SHA512:
6
- metadata.gz: '048b927dd9d8274675ae60692ad705ed586c9d31ed6f4c11a581521d86ff8621dedda7daf33a4e4c90223b31a42bc60184c6f376ed4b1363a3cfcb5a2b2ab65a'
7
- data.tar.gz: 559ea7552ce0133864a3e55df66097fdbbee3315febf9d04cd7485e0caa14e9deb34625b48ecb660f00cd452ec00b4e12d1180689c88a2b2464e59e71182abb5
6
+ metadata.gz: d916bf31322056bc95bfe39d7640207f2ddc6b30f154ca8e517b4615558ae728b1642f01040bb793ff1f258b3b77113ab6564f6f24daae9a23b09114ec7a6dca
7
+ data.tar.gz: b39856d07d0d335d12f7e1855b67f07b2a84443ff799f48a77e9b6a41609547310927fe8b8b69e32add5fbb30711982af3b125a80a40a59b1185a6ae8d1eb4fb
data/CHANGELOG.md CHANGED
@@ -3,6 +3,68 @@
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
+
21
+ ## [0.3.0] - 2026-08-10
22
+
23
+ ### Added
24
+
25
+ - Add `within:` time-range selection to checkpoint timelines, activity
26
+ timelines, and combined analysis, with inclusive/exclusive end handling and
27
+ explicit errors when a final mutation cannot be reconstructed.
28
+ - Add `compare_many` for collection reports, with batched current-root loading,
29
+ bounded live-association preloading, cross-root prepared history, immutable
30
+ identity-keyed results, and scale-invariant query regression coverage.
31
+ - Add opt-in reuse of fully preloaded current endpoints and namespaced
32
+ ActiveSupport runtime instrumentation without automatic logging, including
33
+ end-to-end activity-timeline duration and step counts.
34
+
35
+ ### Changed
36
+
37
+ - Teach timeline filtering through the shared `Step#empty?` protocol in the
38
+ README and quickstart examples.
39
+ - Reuse the live graph loaded by `compare_many` while preparing historical
40
+ association state, and defer root version loading until an edge needs it.
41
+ - Reuse collection identity positions and adjacent transition hints so activity
42
+ steps compare only the changed member, and resolve direct nested collection
43
+ owners by foreign key instead of scanning every parent snapshot.
44
+ - Reuse prepared predecessor and successor scalar states for isolated activity
45
+ updates instead of reifying and deserializing each PaperTrail event again.
46
+ - Preserve both sides of nested collection membership moves by using the
47
+ general comparator when one event changes multiple parent snapshots.
48
+ - Preserve per-owner `limit`, `offset`, and owner-dependent association scopes
49
+ in `compare_many` while retaining batched preloading for safe branches.
50
+ - Replace lifetime association-identity materialization with an indexed start-state
51
+ checkpoint plus post-boundary activity and current members.
52
+ - Bound historical activity-child candidates at the selected end so membership
53
+ changes after the range cannot introduce unrelated empty timeline steps.
54
+ - Decode compatible prepared scalar version payloads without constructing an
55
+ intermediate Active Record object, with reification fallback for unsafe schemas.
56
+ - Reuse internally owned frozen collection arrays instead of defensively copying
57
+ them a second time during immutable activity reconstruction.
58
+ - Replace pre-existing versions-relation ordering when selecting a time range's
59
+ immediate trailing reconstruction boundary.
60
+ - Limit automatic CI to pull requests and `main` pushes, and cancel superseded
61
+ runs for the same pull request or ref.
62
+
63
+ ### Fixed
64
+
65
+ - Supply TZInfo's timezone database to the test bundle so zoned-time coverage
66
+ runs on Windows as well as systems with a native zoneinfo database.
67
+
6
68
  ## [0.2.0] - 2026-08-09
7
69
 
8
70
  ### Added
data/QUICKSTART.md CHANGED
@@ -118,17 +118,42 @@ steps.map do |step|
118
118
  end
119
119
  # => [["Draft", "Published"], ["Published", "Final"]]
120
120
 
121
- visible_steps = steps.reject { |step| step.diff.empty? }
121
+ visible_steps = steps.reject(&:empty?)
122
122
  ```
123
123
 
124
124
  Empty steps remain in the timeline. Filter them only when the application's
125
125
  display does not need every version boundary.
126
126
 
127
+ ### Select mutations by time
128
+
129
+ Use `within:` when the application has a reporting window rather than saved
130
+ endpoint versions:
131
+
132
+ <!-- executable:quickstart-time-range -->
133
+ ```ruby
134
+ window = draft_version.created_at...final_version.created_at
135
+ ranged_steps = PaperTrailDiff.timeline(article, within: window)
136
+
137
+ ranged_steps.map do |step|
138
+ change = step.diff.attributes["title"]
139
+ [change.from, change.to]
140
+ end
141
+ # => [["Draft", "Published"], ["Published", "Final"]]
142
+ ```
143
+
144
+ The half-open range selects the mutations recorded by `draft_version` and
145
+ `published_version`. The excluded `final_version` is still used as the trailing
146
+ boundary needed to reveal the last selected mutation. PaperTrail versions are
147
+ pre-change snapshots, so a range containing mutations must have a later root
148
+ version. The gem raises `PaperTrailDiff::IncompleteTimeRangeError` instead of
149
+ assuming that the current record is the missing endpoint.
150
+
127
151
  ## 6. Choose the right API
128
152
 
129
153
  | Need | Call |
130
154
  | --- | --- |
131
155
  | Net difference between two endpoints | `compare` |
156
+ | Net differences for many current records | `compare_many` |
132
157
  | One step per root-record version | `timeline` |
133
158
  | Steps for root and selected child versions | `activity_timeline` |
134
159
  | Net diff and timelines from one history pass | `analyze` |
@@ -228,7 +253,7 @@ steps = PaperTrailDiff.activity_timeline(
228
253
  associations: ["comments"]
229
254
  )
230
255
 
231
- steps.reject { |step| step.diff.empty? }.each do |step|
256
+ steps.reject(&:empty?).each do |step|
232
257
  boundary = step.to_boundary
233
258
  puts "#{boundary.item_type} ##{boundary.item_id}"
234
259
  end
@@ -237,10 +262,27 @@ end
237
262
  Use a later transaction-backed root checkpoint instead of `to: current` when
238
263
  the result must remain reproducible after the database changes again.
239
264
 
265
+ For a historical reporting window, replace `from:` and `to:` with `within:`:
266
+
267
+ ```ruby
268
+ steps = PaperTrailDiff.activity_timeline(
269
+ article,
270
+ within: report_start...report_end,
271
+ associations: ["comments"]
272
+ )
273
+ ```
274
+
275
+ This selects versioned comment activity directly, even if the article had no
276
+ mutation during the window. Take a root checkpoint after `report_end`; it gives
277
+ the gem the historical context needed to expose the final child mutation.
278
+
240
279
  ## 9. Common surprises
241
280
 
242
281
  - PaperTrail stores pre-change snapshots. The current record is not represented
243
282
  by `versions.last`; pass the record explicitly when current state is wanted.
283
+ - A `within:` window containing mutations needs a later root checkpoint. The
284
+ trailing version is reconstruction context and may appear as a step's ending
285
+ boundary even though its own mutation is outside the window.
244
286
  - `ignore:` replaces the default list. Include `updated_at` yourself when using
245
287
  a custom list and you still want it ignored.
246
288
  - Historical associations require PT-AT to be installed, loaded, migrated, and
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
@@ -82,6 +91,95 @@ association caches and in-memory edits are not compared. Use a database
82
91
  transaction with an appropriate isolation level when several live association
83
92
  queries must represent one atomic application snapshot.
84
93
 
94
+ For collection-level reports, use `compare_many` to reload current roots and
95
+ preload each explicitly selected live association path across the batch. Each
96
+ entry has the same endpoints and options as `compare`; results are returned in
97
+ input order as a frozen hash keyed by `[item_type, item_id]` strings:
98
+
99
+ ```ruby
100
+ diffs = PaperTrailDiff.compare_many(
101
+ [
102
+ { from: first_versions.fetch(order_a.id), to: order_a },
103
+ { from: first_versions.fetch(order_b.id), to: order_b }
104
+ ],
105
+ associations: [:line_items],
106
+ ignore: []
107
+ )
108
+
109
+ diffs.fetch(["Order", order_a.id.to_s]) # => PaperTrailDiff::Diff
110
+ ```
111
+
112
+ Root identities must be unique within one call. Historical reconstruction for
113
+ ordinary versioned, unscoped association paths is also prepared across the
114
+ collection. Paths that require the existing point-in-time PT-AT fallback retain
115
+ their per-endpoint behavior and all historical reconstruction retains the same
116
+ PaperTrail Association Tracking requirements as `compare`. Live collection
117
+ scopes with per-owner semantics, such as `limit`, `offset`, or an owner
118
+ argument, are loaded per root; other selected branches remain batched. Callers
119
+ should still use an appropriate database transaction when all live queries
120
+ must observe one atomic snapshot.
121
+
122
+ ### Reuse already-preloaded current endpoints
123
+
124
+ `compare` and `compare_many` reload current endpoints by default. A caller that
125
+ already owns a consistent, fully preloaded graph may opt out:
126
+
127
+ ```ruby
128
+ orders = Order.where(id: ids).preload(line_items: :product).to_a
129
+
130
+ diffs = PaperTrailDiff.compare_many(
131
+ orders.map do |order|
132
+ { from: first_versions.fetch(order.id), to: order }
133
+ end,
134
+ associations: ["line_items.product"],
135
+ reload_live_endpoints: false
136
+ )
137
+ ```
138
+
139
+ With `reload_live_endpoints: false`, scalar values and association targets come
140
+ from the supplied instances. Every requested association path must already be
141
+ loaded; otherwise `PaperTrailDiff::UnloadedAssociationError` is raised before
142
+ normalization instead of silently issuing an N+1 query. The caller is
143
+ responsible for the consistency and freshness of that in-memory graph.
144
+
145
+ ### Runtime performance diagnostics
146
+
147
+ The gem never prints or logs automatically. It emits these
148
+ `ActiveSupport::Notifications` events so applications can choose their logger
149
+ and formatting:
150
+
151
+ - `compare.paper_trail_diff`
152
+ - `compare_many.paper_trail_diff`
153
+ - `activity_timeline.paper_trail_diff`
154
+ - `load_live_endpoints.paper_trail_diff`
155
+ - `prepare_history.paper_trail_diff`
156
+
157
+ ```ruby
158
+ ActiveSupport::Notifications.subscribe(/\.paper_trail_diff\z/) do |name, start, finish, _id, payload|
159
+ Rails.logger.debug(
160
+ event: name,
161
+ duration_ms: ((finish - start) * 1_000).round(1),
162
+ **payload
163
+ )
164
+ end
165
+ ```
166
+
167
+ The payloads contain counts, model names, association paths, and reload mode,
168
+ not endpoint objects or record attributes. The activity-timeline event wraps
169
+ the complete call and includes `step_count`, so its notification duration is
170
+ the user-visible runtime. To inspect the exact SQL generated by one report,
171
+ scope a standard `sql.active_record` subscriber around it:
172
+
173
+ ```ruby
174
+ callback = proc do |_name, _start, _finish, _id, payload|
175
+ Rails.logger.debug(payload[:sql]) unless payload[:cached] || payload[:name] == "SCHEMA"
176
+ end
177
+
178
+ ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do
179
+ PaperTrailDiff.compare_many(comparisons, associations: [:line_items])
180
+ end
181
+ ```
182
+
85
183
  ## Build a root-checkpoint timeline
86
184
 
87
185
  `timeline` accepts two version objects from the supplied record's history. The
@@ -133,7 +231,7 @@ steps = PaperTrailDiff.activity_timeline(
133
231
  associations: ["comments.replies", :author]
134
232
  )
135
233
 
136
- steps.reject { |step| step.diff.empty? }
234
+ steps.reject(&:empty?)
137
235
  ```
138
236
 
139
237
  Pass the record explicitly as `to:` to include current state without creating a
@@ -177,9 +275,55 @@ intermediate events without transaction-backed owner checkpoints. Historical
177
275
  HABTM activity and `compare(version, article, associations: [:tags])` remain
178
276
  supported where the historical endpoint has usable PT-AT metadata.
179
277
 
180
- Both timeline APIs preserve empty boundaries. A display may filter
181
- `step.diff.empty?`, while audit-oriented callers can retain every recorded
182
- boundary.
278
+ Both timeline APIs preserve empty boundaries. A display may use
279
+ `steps.reject(&:empty?)`, while audit-oriented callers can retain every
280
+ recorded boundary.
281
+
282
+ ## Select mutations by time
283
+
284
+ `timeline`, `activity_timeline`, and `analyze` also accept a finite time range
285
+ through `within:` instead of explicit `from:` and `to:` versions. Half-open
286
+ ranges are recommended for adjacent reporting windows:
287
+
288
+ ```ruby
289
+ window = Time.zone.parse("2026-08-01")...Time.zone.parse("2026-09-01")
290
+
291
+ steps = PaperTrailDiff.timeline(article, within: window)
292
+
293
+ activity_steps = PaperTrailDiff.activity_timeline(
294
+ article,
295
+ within: window,
296
+ associations: ["comments.replies", :author]
297
+ )
298
+
299
+ analysis = PaperTrailDiff.analyze(
300
+ article,
301
+ within: window,
302
+ associations: ["comments.replies"],
303
+ activity: true
304
+ )
305
+ ```
306
+
307
+ The range selects mutations by the timestamp of their source PaperTrail
308
+ version. Ruby's inclusive (`..`) and exclusive (`...`) end semantics are
309
+ honored. A returned step's `to_boundary` may be after the range: because a
310
+ PaperTrail version is a pre-change snapshot, the gem needs one later root
311
+ version to reveal the final selected mutation. That version is reconstruction
312
+ context, not an additional selected mutation.
313
+
314
+ If the window contains a relevant mutation but no later root version exists,
315
+ the call raises `PaperTrailDiff::IncompleteTimeRangeError`. Create a root
316
+ checkpoint after the reporting window before running historical analysis. The
317
+ gem does not silently substitute current database state. A root-only window
318
+ with no selected mutation returns a frozen empty timeline.
319
+
320
+ Time ranges and explicit `from:`/`to:` endpoints are mutually exclusive.
321
+ Malformed, open-ended, or reversed ranges raise
322
+ `PaperTrailDiff::InvalidTimeRangeError`. With `activity_timeline`, selected
323
+ descendant versions are included even when no root mutation occurred inside
324
+ the window; the later root checkpoint is still required so PT-AT can
325
+ reconstruct the enclosing graph and determine whether descendant activity is
326
+ present. The ordinary `timeline` remains a root-only checkpoint timeline.
183
327
 
184
328
  ## Build an endpoint and timeline together
185
329
 
@@ -204,8 +348,9 @@ analysis.activity_timeline
204
348
  ```
205
349
 
206
350
  Without `activity: true`, `analysis.activity_timeline` is `nil` and no activity
207
- work is performed. `analyze` remains version-bounded; use the standalone
208
- `activity_timeline(..., to: article)` API for an explicit current endpoint.
351
+ work is performed. `analyze` accepts explicit historical versions or
352
+ `within:`, but not a current-record endpoint; use the standalone
353
+ `activity_timeline(..., to: article)` API when the final boundary must be live.
209
354
 
210
355
  `timeline`, `activity_timeline`, and both forms of `analyze` prepare the selected
211
356
  historical range once. The loader walks only the explicit association paths and
@@ -224,20 +369,49 @@ and other unsupported shapes fall back to the ordinary PT-AT point reifier on
224
369
  a per-reflection basis. This hybrid path preserves existing reconstruction
225
370
  behavior while avoiding repeated association queries for supported paths.
226
371
 
227
- Activity event discovery is bounded to the selected range. Prepared scalar
228
- state also retains later successor versions for selected identities because a
229
- PaperTrail version is a pre-change snapshot and may be the only correct state
230
- for an earlier boundary. Memory therefore scales with relevant selected
231
- history, not only with the number of returned steps. Keep requested paths and
232
- ranges intentional. For large histories, applications should give the database
233
- a matching composite index. A typical PaperTrail installation can add one
234
- without making it a requirement of this gem:
372
+ For supported collection events, adjacent snapshots share an immutable
373
+ identity-position index and carry a one-step transition hint. Diffing therefore
374
+ visits only the changed member instead of hashing the whole collection again.
375
+ Prepared scalar history also exposes the predecessor and successor attributes
376
+ for isolated update events, allowing activity reconstruction to update the
377
+ immutable snapshot directly instead of reifying and deserializing the same
378
+ PaperTrail event again. Compatible scalar payloads are decoded into prepared
379
+ state without first constructing a disposable Active Record object. Encrypted,
380
+ schema-mismatched, missing, ambiguous, or identity-changing states fall back to
381
+ the ordinary event reifier.
382
+ For a direct nested `has_many` such as `comments.replies`, the child's foreign
383
+ key also locates its parent snapshot directly rather than walking every
384
+ comment. Membership changes and ambiguous or unsupported relationship shapes
385
+ retain the general comparator and traversal fallback.
386
+
387
+ Activity event loading is bounded to the selected range. Association identity
388
+ discovery retains one indexed checkpoint for members present at the starting
389
+ boundary, then considers later association activity and current members; it no
390
+ longer materializes every pre-range association row. Prepared scalar state also
391
+ retains later successor versions for selected identities because a PaperTrail
392
+ version is a pre-change snapshot and may be the only correct state for an
393
+ earlier boundary. Memory therefore scales with relevant selected history, not
394
+ only with the number of returned steps. Keep requested paths and ranges
395
+ intentional. An activity timeline must also emit a diff for every selected
396
+ event. Repeated events within one wide collection still copy the frozen records
397
+ array when producing each immutable snapshot, so they can do pointer-copying
398
+ work proportional to the number of events times the collection width even when
399
+ SQL and Ruby-level comparison work stay linear. Bound or paginate unusually
400
+ wide activity ranges in latency-sensitive requests.
401
+
402
+ For large histories, applications should give the database a matching
403
+ composite index. A typical PaperTrail installation can add one without making
404
+ it a requirement of this gem:
235
405
 
236
406
  ```ruby
237
407
  add_index :versions, %i[item_type item_id created_at id],
238
408
  name: "index_versions_on_item_and_timeline"
239
409
  ```
240
410
 
411
+ This index is especially useful for repeated `within:` queries because root
412
+ selection is constrained by `item_type`, `item_id`, and `created_at`, then
413
+ ordered deterministically by timestamp and version ID.
414
+
241
415
  PT-AT's normal index beginning with `foreign_key_name`, `foreign_key_id`, and
242
416
  `foreign_type` should also be retained on `version_associations`.
243
417
 
@@ -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,73 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Loads direct-child identities active at or after one activity boundary.
6
+ class ActivityChildCandidateLoader
7
+ #: (untyped, Array[untyped], untyped, ActivityRange) -> void
8
+ def initialize(parent_class, parent_ids, reflection, range)
9
+ @parent_class = parent_class
10
+ @parent_ids = parent_ids
11
+ @reflection = reflection
12
+ @range = range
13
+ end
14
+
15
+ #: () -> Array[untyped]
16
+ def call
17
+ historical_ids | current_ids
18
+ end
19
+
20
+ private
21
+
22
+ # @rbs @parent_class: untyped
23
+ # @rbs @parent_ids: Array[untyped]
24
+ # @rbs @reflection: untyped
25
+ # @rbs @range: ActivityRange
26
+
27
+ #: () -> Array[untyped]
28
+ def historical_ids
29
+ version_class = @parent_class.paper_trail.version_class
30
+ candidate_scope(version_class).call(historical_relation(version_class))
31
+ .distinct.pluck(version_class.arel_table[:item_id])
32
+ end
33
+
34
+ #: (untyped) -> untyped
35
+ def historical_relation(version_class)
36
+ @parent_class.paper_trail.version_association_class.joins(:version).where(
37
+ foreign_key_name: @reflection.foreign_key.to_s,
38
+ foreign_key_id: @parent_ids,
39
+ foreign_type: parent_types
40
+ ).where(version_class.table_name => { item_type: child_item_type })
41
+ end
42
+
43
+ #: (untyped) -> VersionAssociationCandidateScope
44
+ def candidate_scope(version_class)
45
+ VersionAssociationCandidateScope.new(
46
+ version_class,
47
+ @range.start_time,
48
+ end_at: @range.end_time
49
+ )
50
+ end
51
+
52
+ #: () -> Array[untyped]
53
+ def current_ids
54
+ return [] unless @range.current_end?
55
+
56
+ relation = @reflection.klass.base_class.unscoped.where(
57
+ @reflection.foreign_key => @parent_ids
58
+ )
59
+ relation = relation.where(@reflection.type => parent_types) if @reflection.options[:as]
60
+ relation.distinct.pluck(@reflection.klass.primary_key)
61
+ end
62
+
63
+ #: () -> String
64
+ def child_item_type
65
+ @reflection.klass.base_class.name
66
+ end
67
+
68
+ #: () -> Array[String?]
69
+ def parent_types
70
+ [nil, '', @parent_class.name.to_s, @parent_class.base_class.name.to_s].uniq
71
+ end
72
+ end
73
+ end