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/README.md CHANGED
@@ -1,1453 +1,244 @@
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.
3
+ Compare past and present records using the history saved by
4
+ [PaperTrail](https://github.com/paper-trail-gem/paper_trail). Show changed fields,
5
+ include changes to associated records, or build a report for many records.
6
+ Results are Ruby objects with methods for reading the changes and `to_h` for
7
+ turning them into hashes. The gem does not generate display text or save changes.
11
8
 
12
- `paper_trail_diff` adds structured endpoint, checkpoint-timeline, and activity
13
- comparisons to
14
- [PaperTrail](https://github.com/paper-trail-gem/paper_trail). It returns immutable
15
- Ruby value objects and hashes rather than formatted text.
9
+ If you already use `has_paper_trail` and `record.versions`, you can pass those
10
+ versions directly to this gem. You do not need to call `reify` yourself.
16
11
 
17
- PaperTrail is required. Explicit, bounded association-path comparison is
18
- available when
19
- [paper_trail-association_tracking](https://github.com/westonganger/paper_trail-association_tracking)
20
- (PT-AT) is installed, loaded, and enabled.
12
+ ## Get started
21
13
 
22
- Ruby 3.1 or newer and PaperTrail 16 or 17 are supported.
23
-
24
- New to the gem? Run [`examples/demo.rb`](examples/demo.rb) to see it work in
25
- about ten seconds — one self-contained file, no application and no migrations,
26
- which fetches what it needs on first run:
14
+ Add the gem to your Rails application:
27
15
 
28
16
  ```console
29
- ruby demo.rb
17
+ bundle add paper_trail_diff
30
18
  ```
31
19
 
32
- Then start with the copyable [Quickstart](QUICKSTART.md), or browse
33
- [the demo application](https://github.com/aheathwilliams/paper_trail_summary_test_app)
34
- — a small Rails app that installs a published release and exercises scalar,
35
- nested, through-association, and HABTM history against it.
20
+ Already have a model with `has_paper_trail`? Try the comparison below.
21
+ Otherwise, follow the [Rails quickstart](QUICKSTART.md) to set up PaperTrail
22
+ and create your first comparison.
36
23
 
37
- ## Installation
24
+ To try it without Rails, download [examples/demo.rb](examples/demo.rb) and run
25
+ `ruby demo.rb`. It installs its dependencies on the first run. The
26
+ [demo application](https://github.com/aheathwilliams/paper_trail_summary_test_app)
27
+ shows reports in a Rails UI.
38
28
 
39
- Add the gem to your bundle:
29
+ Ruby 3.1+ and PaperTrail 16 or 17 are supported, subject to
30
+ [PaperTrail's Ruby and ActiveRecord requirements](https://github.com/paper-trail-gem/paper_trail#1a-compatibility).
31
+ Comparing association history also requires
32
+ [paper_trail-association_tracking](https://github.com/westonganger/paper_trail-association_tracking),
33
+ an optional PaperTrail plugin. The quickstart includes its setup.
40
34
 
41
- ```ruby
42
- gem "paper_trail_diff"
43
- ```
35
+ > **Experimental:** AI-assisted coding tools made significant contributions to
36
+ > this project. Check results against your application's history before relying
37
+ > on them. Changes that were never recorded cannot be recovered.
44
38
 
45
- For association history, also add and configure PT-AT using its migrations:
39
+ ## 1. Compare an earlier version with the current record
46
40
 
47
- ```ruby
48
- gem "paper_trail-association_tracking"
49
- ```
41
+ This example assumes an `Article` model with a `title` column and `has_paper_trail`:
50
42
 
43
+ <!-- executable:recipe-compare -->
51
44
  ```ruby
52
- PaperTrail.config.track_associations = true
53
- ```
45
+ article = Article.create!(title: "Draft")
46
+ article.update!(title: "Published")
54
47
 
55
- All associated models whose historical state is compared must use
56
- `has_paper_trail`.
48
+ # PaperTrail stores the values from BEFORE the update.
49
+ before = article.versions.last
50
+ diff = PaperTrailDiff.compare(before, article)
57
51
 
58
- ## How PaperTrail records state
59
-
60
- Almost everything else in this README follows from one property of PaperTrail,
61
- so it is worth being precise about it first: **a version stores the state that
62
- existed _before_ the event that created it.** A version is a record of what was
63
- overwritten, not of what was written.
64
-
65
- Take an article created as `"Draft"`, then updated to `"Published"`, then to
66
- `"Final"`:
67
-
68
- ```text
69
- v1 v2 v3 (no version)
70
- "create" "update" "update"
71
- | | | |
72
- ──────●───────────────●─────────────────●─────────────────●──────▶ time
73
- | | | |
74
- └─── "Draft" ───┘ | |
75
- stored in v2 | |
76
- └─ "Published" ───┘ |
77
- stored in v3 |
78
- └──── "Final" ────┘
79
- only in the table
52
+ diff.attributes.fetch("title").to_h
53
+ # => { from: "Draft", to: "Published" }
80
54
  ```
81
55
 
82
- Each state is stored by the version at the *end* of the interval it was live
83
- for. Three consequences run through the rest of this document:
84
-
85
- - **A `create` version reifies to `nil`.** Nothing preceded it. Comparing it
86
- with a later state reports a structured `record_presence_change` rather than
87
- inventing scalar changes.
88
- - **The newest state has no version at all.** It exists only in the table. A
89
- fully historical result therefore needs a version *later* than the last change
90
- it should reveal, which is why a `within:` window can raise
91
- `IncompleteTimeRangeError`, and why `activity_timeline(..., to: article)`
92
- exists for ending at current state instead.
93
- - **A change is visible between two boundaries**, never "at" one. This is why
94
- both timeline APIs return steps rather than events.
95
-
96
- The [Quickstart](QUICKSTART.md) walks through the same idea against a real
97
- console session, creating the models it uses as it goes.
98
-
99
- ## Which models and schemas work
56
+ PaperTrail's `version.reify` returns the record as it was before that version's
57
+ change. Passing a version to `compare` uses that same earlier state. Passing
58
+ `article` uses its current saved values. Pass two versions to compare two
59
+ historical states.
100
60
 
101
- Anything `has_paper_trail` tracks, including these, which are covered by the
102
- test suite rather than assumed:
61
+ PaperTrail's `version.changeset` describes one recorded change. `compare`
62
+ describes the net difference between the two states you choose. If a field
63
+ changes and then changes back, there is no net difference for that field. To
64
+ see every change instead, use `PaperTrailDiff.timeline(article)`, which covers
65
+ the article's whole history through its current saved values.
103
66
 
104
- - **Single-table inheritance.** Results are keyed by the *base* class, matching
105
- what PaperTrail writes to `item_type`. Analyzing a `Book < Publication`
106
- produces the key `["Publication", "12"]`, so `results.fetch(["Book", ...])`
107
- raises `KeyError`. Build keys with `PaperTrailDiff::Endpoint.identity(record)`
108
- rather than by hand.
109
- - **Non-integer primary keys** on the tracked model, such as a UUID column.
110
- - **A custom version class**, via `has_paper_trail versions: { class_name: }`.
111
- Nothing here references `PaperTrail::Version` directly.
67
+ An attribute appears in `diff.attributes` only when it changed. `diff.empty?`
68
+ tells you whether anything changed. A PaperTrail `create` version has no earlier
69
+ record (`reify` returns `nil`), so comparing it with the current record reports
70
+ `diff.record_presence_change`, meaning the record was added.
112
71
 
113
- ### Versions that cannot be ordered
72
+ To include comments, add `associations: [:comments]`. If comments have replies,
73
+ `associations: ["comments.replies"]` includes those too. Associated models must
74
+ also use `has_paper_trail`, and association tracking must be enabled.
114
75
 
115
- Versions are ordered by `created_at`, falling back to the id when timestamps
116
- tie. That fallback recovers the real sequence only while ids increase with
117
- insertion. An autoincrement id does; a **UUID version id does not**.
118
-
119
- So two conditions together are unsafe: version ids that are not sequential,
120
- *and* versions sharing a timestamp — which is ordinary on a MySQL `datetime`
121
- column, since it stores whole seconds. The order is then unrecoverable, and
122
- rather than report a plausible-looking history in the wrong order the gem
123
- raises `PaperTrailDiff::AmbiguousVersionOrderError` naming both versions.
124
- `diagnose` reports the same condition as an error, so a caller can check before
125
- running rather than after a surprise.
126
-
127
- Either half alone is fine. Sequential ids order tied timestamps correctly, and
128
- distinct timestamps never reach the fallback.
129
-
130
- ### Association changes across a shared timestamp
131
-
132
- Sequential ids recover the *order* of tied versions, but not their association
133
- state. PT-AT records association membership per version and resolves it by
134
- timestamp, so two versions sharing one are indistinguishable for that purpose:
135
- **an association change between them is invisible.** It is not reported as
136
- absent — the association simply does not appear in the diff.
137
-
138
- Unlike an unorderable sequence this is not always wrong. If nothing associated
139
- changed between the two versions, the result is correct, and the gem cannot
140
- tell the two cases apart, because not seeing the change is the symptom. So it
141
- does not raise. Instead:
142
-
143
- - `diagnose` reports `:tied_version_timestamps` as a **warning** whenever
144
- `associations:` are selected and versions in range share a timestamp.
145
- - a comparison in that state emits
146
- `ambiguous_association_boundary.paper_trail_diff` through ActiveSupport
147
- notifications, carrying the two version ids and the shared timestamp. Nothing
148
- is logged; subscribe if you want it surfaced.
149
-
150
- Writing several versions inside one clock tick is easy to do — a service object
151
- that touches a record and its children in quick succession will manage it — so
152
- this is worth checking before trusting an association history. Recording
153
- versions at sub-second precision separates them.
154
-
155
- ### ActiveStorage attachments
156
-
157
- `has_one_attached` and `has_many_attached` point at `ActiveStorage::Attachment`
158
- and `ActiveStorage::Blob`, which Rails owns and PaperTrail does not version.
159
- There is no history behind them, so a historical comparison over those paths
160
- raises `PaperTrailDiff::UnversionedAssociationError` rather than reporting that
161
- nothing changed. Adding `has_paper_trail` to Rails' own models does not rescue
162
- it either: `has_one_attached` is a scoped `has_one`, and reifying it from
163
- history is ambiguous.
164
-
165
- Audit the attachment through a model you own instead. Give it the facts worth
166
- auditing as ordinary columns, and version it:
76
+ For nested results, `each_change` lets you read changes without walking every
77
+ association yourself:
167
78
 
168
79
  ```ruby
169
- class DocumentRevision < ApplicationRecord
170
- belongs_to :attachable, polymorphic: true
171
- has_one_attached :file
172
- has_paper_trail
173
-
174
- # Attaching a file writes to ActiveStorage's tables, not to this record, so
175
- # nothing would be versioned without copying the facts across.
176
- after_save :record_file_metadata, if: -> { file.attached? }
177
-
178
- def record_file_metadata
179
- blob = file.blob
180
- return if filename == blob.filename.to_s && checksum == blob.checksum
181
-
182
- update_columns(
183
- filename: blob.filename.to_s, content_type: blob.content_type,
184
- byte_size: blob.byte_size, checksum: blob.checksum
185
- )
186
- end
187
- end
188
- ```
189
-
190
- ```ruby
191
- class Article < ApplicationRecord
192
- has_many :document_revisions, as: :attachable
193
- has_paper_trail
80
+ diff.each_change do |entry|
81
+ entry.kind # e.g. :attribute_changed or :record_added
82
+ entry.association_path # e.g. ["comments", "replies"]
83
+ entry.value # the changed values or the added/removed record's data
194
84
  end
195
-
196
- PaperTrailDiff.compare(before, after, associations: [:document_revisions])
197
- ```
198
-
199
- The bytes stay in ActiveStorage; the *auditable facts* live where PaperTrail
200
- can see them. Nothing about the column names matters to this gem — it reports
201
- whichever columns changed. What matters is that replacing a file writes to a
202
- versioned record, because attaching one on its own does not.
203
-
204
- ## Choosing an entry point
205
-
206
- | You need | Call |
207
- | --- | --- |
208
- | The net difference between two endpoints | `compare` |
209
- | The same, for many records in one pass | `compare_many` |
210
- | One step per version of the root record | `timeline` |
211
- | One step per version of the root *or a selected child* | `activity_timeline` |
212
- | A net difference and a timeline from one history pass | `analyze` |
213
-
214
- `timeline` and `activity_timeline` differ only in which recorded versions
215
- become boundaries. Given an article with two comment edits between two article
216
- versions:
217
-
218
- ```text
219
- recorded versions A1 C1 C2 A2 A = Article version
220
- | | | | C = Comment version
221
- ────●─────────●───────●─────────●────▶ time
222
- | | | |
223
- timeline └───────── 1 step ──────────┘
224
- both comment changes land inside that one step
225
-
226
- activity_timeline └── 1 ────┴── 2 ──┴─── 3 ───┘
227
- each recorded version becomes its own boundary
228
85
  ```
229
86
 
230
- Both report the same underlying data: a `timeline` step still contains every
231
- selected association, because it has to describe what changed beneath the root.
232
- They differ in how finely that change is split, and in what the reconstruction
233
- costs. That cost trade-off is covered under
234
- [choosing a reconstruction strategy](#choosing-between-timeline-and-analyzeactivity-true).
87
+ ## 2. Show who changed a record and its comments
235
88
 
236
- ## Compare two endpoints
237
-
238
- `compare` accepts two explicit endpoints: each may be a PaperTrail version or a
239
- clean, persisted model instance representing current database state. It reports
240
- only their net difference:
89
+ This example requires the association setup in the quickstart. PaperTrail's
90
+ `whodunnit` records who made a change; here we set it with `PaperTrail.request`.
91
+ The timeline covers the article's whole history, including comment changes made
92
+ after the article's last version.
241
93
 
94
+ <!-- executable:recipe-activity -->
242
95
  ```ruby
243
- diff = PaperTrailDiff.compare(article.versions[1], article.versions[4])
244
-
245
- diff.attributes["title"].from # => "Draft"
246
- diff.attributes["title"].to # => "Published"
247
- diff.empty? # => false
248
- diff.to_h
249
- # => {
250
- # record_presence_change: nil,
251
- # attributes: {
252
- # "title" => { from: "Draft", to: "Published" }
253
- # },
254
- # associations: {}
255
- # }
256
- ```
257
-
258
- Intermediate edits do not affect `compare`. If a title changes and later
259
- returns to its original value, endpoint comparison reports no title change.
260
-
261
- `record_presence_change` reports whether the root record *existed* at each
262
- endpoint, and is `nil` for the ordinary case where it existed at both. It is
263
- populated when one endpoint reifies to `nil` — most often a `create` version,
264
- whose pre-change state is the absence of the record — and it then carries whole
265
- `RecordSnapshot` values rather than fake scalar changes from `nil`.
266
-
267
- Destroying the root record is not reported this way, and the reason follows
268
- from the [pre-change model](#how-papertrail-records-state): the state *at* a
269
- `destroy` version is the state immediately before the deletion, so the record
270
- is still present there. A comparison ending at a `destroy` version reports that
271
- last edit, not the deletion. Deleting a *selected child* is reported normally,
272
- as a `removed` member of its parent's collection, and `activity_timeline`
273
- reports a destroyed root through a
274
- [closing removal step](#closing-a-destroyed-root).
275
-
276
- Pass the record explicitly when the desired endpoint is current state:
277
-
278
- ```ruby
279
- diff = PaperTrailDiff.compare(
280
- article.versions.last,
281
- article,
282
- associations: ["comments.replies"]
283
- )
284
- ```
285
-
286
- Two version endpoints must be given in chronological order. A transposed pair
287
- produces the inverse diff, which is easy to do by accident and impossible to
288
- detect afterwards because a result carries no direction of its own, so it
289
- raises `PaperTrailDiff::ReversedEndpointsError` instead. Nothing is lost by
290
- this: the two orders differ only in which side of each change is `from`.
291
-
292
- A current-record endpoint is exempt and may appear on either side, because it
293
- is self-evidently the live state and placing it first is a deliberate reverse
294
- comparison. Current state is never inferred. The record must be persisted, not
295
- destroyed, and free of unsaved attribute changes. The gem reloads it unscoped
296
- before normalization, so stale association caches and in-memory edits are not
297
- compared. Use a database
298
- transaction with an appropriate isolation level when several live association
299
- queries must represent one atomic application snapshot.
300
-
301
- For collection-level reports, use `compare_many` to reload current roots and
302
- preload each explicitly selected live association path across the batch. Each
303
- entry has the same endpoints and options as `compare`; results are returned in
304
- input order as a frozen hash keyed by `[item_type, item_id]` strings:
305
-
306
- Endpoints are still supplied by the caller, so look them up in bulk too —
307
- otherwise the per-root queries this API removes come straight back. Two
308
- queries resolve the earliest version of every root, whatever the batch size:
309
-
310
- ```ruby
311
- orders = Order.where(id: order_ids).to_a
312
-
313
- earliest_ids = PaperTrail::Version
314
- .where(item_type: "Order", item_id: orders.map(&:id))
315
- .group(:item_id)
316
- .minimum(:id)
317
- first_versions = PaperTrail::Version
318
- .where(id: earliest_ids.values)
319
- .index_by(&:item_id)
320
- ```
321
-
322
- ```ruby
323
- diffs = PaperTrailDiff.compare_many(
324
- orders.map { |order| { from: first_versions.fetch(order.id), to: order } },
325
- associations: [:line_items],
326
- ignore: []
327
- )
328
-
329
- diffs.fetch(["Order", orders.first.id.to_s]) # => PaperTrailDiff::Diff
330
- ```
331
-
332
- Endpoints may also be given as `:first` or `:last`, resolved against the record
333
- the pair's other endpoint names. That replaces the lookup above entirely, and
334
- resolves every root in two queries per model class rather than one per root:
335
-
336
- ```ruby
337
- diffs = PaperTrailDiff.compare_many(
338
- orders.map { |order| { from: :first, to: order } },
339
- associations: [:line_items]
340
- )
341
- ```
342
-
343
- A symbol carries no identity of its own, so `{ from: :first, to: :last }` raises
344
- rather than guessing. A root with no recorded history resolves to an empty
345
- `Diff`, matching how the timeline APIs answer the same question.
346
-
347
- Root identities must be unique within one call. Historical reconstruction for
348
- ordinary versioned, unscoped association paths is also prepared across the
349
- collection. Paths that require the existing point-in-time PT-AT fallback retain
350
- their per-endpoint behavior and all historical reconstruction retains the same
351
- PaperTrail Association Tracking requirements as `compare`. Live collection
352
- scopes with per-owner semantics, such as `limit`, `offset`, or an owner
353
- argument, are loaded per root; other selected branches remain batched. Callers
354
- should still use an appropriate database transaction when all live queries
355
- must observe one atomic snapshot.
356
-
357
- ### Look inside a JSON column
358
-
359
- A `json` or `jsonb` column reifies to one Hash, so an attribute diff can only
360
- say the blob changed. `nested_changes` says which keys changed:
361
-
362
- ```ruby
363
- diff = PaperTrailDiff.compare(from_version, to_version)
364
-
365
- PaperTrailDiff.nested_changes(diff.attributes.fetch("config"))
366
- # => { ["theme"] => <from "dark" to "light">,
367
- # ["limits", "max"] => <from 10 to 20> }
368
- ```
369
-
370
- It reads a column that stores JSON as text as readily as a native one, and also
371
- takes a bare pair: `PaperTrailDiff.nested_changes(from_value, to_value)`.
372
-
373
- Nothing about the attribute diff itself changes. A pair that is not two readable
374
- structures — text on one side and JSON on the other, or a value that does not
375
- parse — reports nothing, and the caller still has the whole-value change it
376
- already had.
377
-
378
- Three things about the shape are worth knowing:
379
-
380
- **Paths are arrays, not dotted strings.** A JSON key may itself contain a dot, so
381
- `"a.b"` would be ambiguous between one key and two:
382
-
383
- ```ruby
384
- PaperTrailDiff.nested_changes({ "a.b" => 1, "a" => { "b" => 1 } },
385
- { "a.b" => 2, "a" => { "b" => 3 } })
386
- # => { ["a.b"] => <from 1 to 2>, ["a", "b"] => <from 1 to 3> }
387
- ```
388
-
389
- **Arrays are reported by membership, not by position.** Elements carry no
390
- identity, so an insertion at the front would make every later index look
391
- changed — one insertion described as several edits. An `ArrayChange` says what
392
- was added and removed, matching by value and respecting duplicates:
393
-
394
- ```ruby
395
- change = PaperTrailDiff.nested_changes(
396
- { "keywords" => %w[apollo nasa] },
397
- { "keywords" => %w[saturn apollo nasa] }
398
- ).fetch(["keywords"])
399
-
400
- change.added # => ["saturn"]
401
- change.removed # => []
402
- change.reordered? # => false
403
- change.from # => ["apollo", "nasa"] (the whole values remain)
404
- ```
405
-
406
- Membership cannot see one thing, so it is named rather than passed over: the
407
- same elements in a new order report `reordered?` as true with nothing added or
408
- removed.
409
-
410
- An element that is itself an object is reported whole. Saying which field of
411
- which object changed would need a pairing of before-elements with
412
- after-elements, and nothing in the value licenses that pairing.
413
-
414
- **An absent key is not a null one.** `{"a": null}` and `{}` mean different things
415
- in JSON, and an audit trail that showed them alike would be lying about one of
416
- them, so a missing key reads as `PaperTrailDiff::NestedComparator::ABSENT`
417
- rather than `nil`.
418
-
419
- ### Reuse already-preloaded current endpoints
420
-
421
- `compare`, `compare_many`, and `activity_timeline(..., to: record)` reload
422
- current endpoints by default. A caller that already owns a consistent, fully
423
- preloaded graph may opt out. Each endpoint is loaded once per call, so a batch
424
- that resolves every root up front and then reads one of them again pays
425
- nothing the second time, and every root in one result reflects the same instant.
426
-
427
- The option is not accepted by `timeline`, `analyze`, or `analyze_many`. Those
428
- are bounded by versions and read live state only under
429
- [`close_on: :current`](#reporting-up-to-now), which always reloads:
430
-
431
- ```ruby
432
- orders = Order.where(id: order_ids).preload(line_items: :product).to_a
433
- # `first_versions` is the same bulk endpoint lookup shown above.
434
-
435
- diffs = PaperTrailDiff.compare_many(
436
- orders.map do |order|
437
- { from: first_versions.fetch(order.id), to: order }
438
- end,
439
- associations: ["line_items.product"],
440
- reload_live_endpoints: false
441
- )
442
- ```
443
-
444
- With `reload_live_endpoints: false`, scalar values and association targets come
445
- from the supplied instances. Every requested association path must already be
446
- loaded; otherwise `PaperTrailDiff::UnloadedAssociationError` is raised before
447
- normalization instead of silently issuing an N+1 query. The caller is
448
- responsible for the consistency and freshness of that in-memory graph.
449
-
450
- ### Runtime performance diagnostics
451
-
452
- The gem never prints or logs automatically. It emits these
453
- `ActiveSupport::Notifications` events so applications can choose their logger
454
- and formatting:
455
-
456
- - `compare.paper_trail_diff`
457
- - `compare_many.paper_trail_diff`
458
- - `activity_timeline.paper_trail_diff`
459
- - `load_live_endpoints.paper_trail_diff`
460
- - `prepare_history.paper_trail_diff`
461
-
462
- ```ruby
463
- ActiveSupport::Notifications.subscribe(/\.paper_trail_diff\z/) do |name, start, finish, _id, payload|
464
- Rails.logger.debug(
465
- event: name,
466
- duration_ms: ((finish - start) * 1_000).round(1),
467
- **payload
468
- )
469
- end
470
- ```
471
-
472
- The payloads contain counts, model names, association paths, and reload mode,
473
- not endpoint objects or record attributes. The activity-timeline event wraps
474
- the complete call and includes `step_count`, so its notification duration is
475
- the user-visible runtime. To inspect the exact SQL generated by one report,
476
- scope a standard `sql.active_record` subscriber around it:
477
-
478
- ```ruby
479
- callback = proc do |_name, _start, _finish, _id, payload|
480
- Rails.logger.debug(payload[:sql]) unless payload[:cached] || payload[:name] == "SCHEMA"
481
- end
482
-
483
- ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do
484
- PaperTrailDiff.compare_many(comparisons, associations: [:line_items])
96
+ article = Article.create!(title: "Draft")
97
+ PaperTrail.request(whodunnit: "alice") do
98
+ Article.transaction do
99
+ article.update!(title: "Published")
100
+ article.comments.create!(body: "Please review")
101
+ end
485
102
  end
486
- ```
487
-
488
- ## Analyze many records over one window
489
-
490
- `analyze_many` answers "what changed for these records during this period" for a
491
- whole collection, selecting every root's versions and preparing their selected
492
- association history once for the batch:
493
-
494
- ```ruby
495
- results = PaperTrailDiff.analyze_many(
496
- Order.where(status: "open").to_a,
497
- within: Time.zone.parse("2026-08-01")...Time.zone.parse("2026-09-01"),
498
- associations: [:line_items]
499
- )
500
-
501
- results.fetch(["Order", order.id.to_s]).diff # net change across the window
502
- results.fetch(["Order", order.id.to_s]).timeline # its checkpoint steps
503
- ```
504
-
505
- Results are a frozen hash keyed by `[item_type, item_id]` strings, and each
506
- value is the same `Analysis` that `analyze` returns for one record. A root with
507
- no versions inside the window gets an empty `Analysis` rather than raising, so a
508
- listing page needs no special case. Root identities must be unique.
509
-
510
- ### Letting a relation choose the roots
511
-
512
- Assembling that array means querying `versions` for which roots moved, filtering,
513
- and loading the records — the same selection this gem already performs.
514
- `analyze_scope` does it for you:
515
-
516
- ```ruby
517
- analyses, unreachable = PaperTrailDiff.analyze_scope(
518
- Order.where(status: "open"),
519
- within: Time.zone.parse("2026-08-01")...Time.zone.parse("2026-09-01"),
520
- associations: [:line_items],
521
- limit: 500
522
- )
523
- ```
524
-
525
- `analyses` is the same frozen hash `analyze_many` returns. It is also available
526
- as `PaperTrailDiff.analyze_many(scope: ..., limit: ...)`, and it accepts a model
527
- class (`Order`) as readily as a relation. Every other option — `within:`,
528
- `version_scope:`, `associations:`, `ignore:`, `activity:`, `close_on:` — behaves
529
- exactly as it does for `analyze_many`, and the query cost stays flat in the
530
- number of roots.
531
-
532
- Two things to know before using it.
533
-
534
- **`limit:` is required, and exceeding it raises.** Selection moves into the gem
535
- here, so the bound on how much work one page can ask for has to move with it. It
536
- refuses rather than truncating, because an audit report that is quietly short is
537
- worse than one that fails.
538
-
539
- **`unreachable` names roots the relation could not reach.** A relation's
540
- conditions are evaluated against the live table, so a root destroyed during the
541
- window cannot be tested against them at all — its history is intact, and the
542
- state it held when it was destroyed may well have matched:
543
-
544
- ```ruby
545
- unreachable # => [["Order", "412"]]
546
- ```
547
-
548
- These are reported rather than dropped so that a page auditing deletions is told
549
- where to look instead of silently coming up short. `analyze_many` requires live
550
- records by design, so to analyze those roots reach for `activity_timeline`, which
551
- reads history without needing current state.
552
-
553
- Note also that a relation selects on **current** state, not on state during the
554
- window. `where(status: "open")` means open *now*, which is a different set from
555
- what was open while the window was open. If you need the historical population,
556
- select on the version history instead and pass the records.
557
-
558
- ### Reporting on a subset of root mutations
559
-
560
- `version_scope:` narrows which root versions count as *selected mutations*, so a
561
- checkpoint timeline reports only some of them:
562
-
563
- ```ruby
564
- user_edits = ->(scope) { scope.where.not(whodunnit: nil) }
565
-
566
- PaperTrailDiff.analyze_many(articles, within: window, version_scope: user_edits)
567
- PaperTrailDiff.timeline(article, within: window, version_scope: user_edits)
568
- ```
569
-
570
- The hook receives the version relation for the range and returns a narrowed one.
571
- It is accepted by `timeline`, `activity_timeline`, `analyze`, and
572
- `analyze_many`, with any range form.
573
-
574
- **It sees root versions only.** Someone who edited only comments or tags has no
575
- root versions to select, so filtering to them yields an empty result even though
576
- they changed plenty. For "what did this person change?", reach for
577
- [one person's changes](#reporting-on-one-persons-changes) instead — this hook
578
- answers the different question of which *root checkpoints* a timeline reports.
579
-
580
- It filters *selected mutations only*. Versions the filter excludes are still
581
- loaded, because a version records the state before its own event: without the
582
- one that follows a selected change, whatever that change produced cannot be
583
- shown at all. Those extra versions are reconstruction context, so their own
584
- changes are never attributed to a selected mutation.
585
-
586
- Each selected mutation is bounded by the version that immediately followed it,
587
- not by the next selected one. So each step's diff is exactly what that mutation
588
- did, and it reads the same however many excluded changes happen to follow it:
589
-
590
- ```
591
- versions system → alice → system → bob → system
592
- reported alice: what alice changed bob: what bob changed
593
- ```
594
-
595
- Given a user edit followed by a system edit, filtering to user changes yields
596
- one step running from the user version to the system version, whose diff is
597
- exactly the user's change. A selected mutation that nothing follows yet is not
598
- reported, since no version records the state it produced — the same blind spot
599
- an unfiltered timeline has at its `to:` boundary. A selected *destruction* is
600
- the exception: nothing can follow one, but the absence it leaves is what it
601
- produced, so `activity_timeline` still closes on it. A root with no selected
602
- mutation reports an empty `Analysis` rather than raising.
603
-
604
- The hook applies to root versions only. Under `activity: true` the filter
605
- decides where the span starts and ends, and `timeline` within that span reports
606
- only selected mutations, but `activity_timeline` still lists every boundary
607
- inside it — dropping one would fold the change it carried into a neighbouring
608
- step and credit it to whoever made that one.
609
-
610
- Omit `within:` to analyze each root's whole recorded history instead, which is
611
- the batched equivalent of `analyze(record, from: :first, to: :last)`. Explicit
612
- version endpoints are not accepted, because a single pair cannot mean the same
613
- thing for every root.
614
-
615
- Query cost is flat in the number of roots for the diff and timeline views:
616
- selecting versions and preparing history are both shared across the batch. On a
617
- twenty-root batch that is 7 queries against 120 for the same work done one
618
- record at a time. Passing `activity: true` also works and returns the same
619
- results, but discovering descendant events is inherently per-root, so that view
620
- batches far less.
621
-
622
- Roots are supplied as live records, so a root deleted inside the window cannot
623
- be included; use `activity_timeline` for a history that ends in a deletion.
624
-
625
- ### Reporting on one person's changes
626
-
627
- An activity timeline already carries this. Every boundary in the span becomes a
628
- step, and a step's diff is exactly what the event at its `from_boundary` did, so
629
- that boundary's `whodunnit` is who made the change:
630
-
631
- <!-- executable:readme-person-changes -->
632
- ```ruby
633
- steps = PaperTrailDiff.activity_timeline(
634
- article, within: window, associations: %i[comments tags]
635
- )
636
-
637
- attributed = steps.reject(&:empty?)
638
- by_priya = attributed.select { |step| step.from_boundary.whodunnit == "Priya Shah" }
639
-
640
- by_priya.first.from_boundary.whodunnit # "Priya Shah"
641
- by_priya.first.diff # exactly what that one event changed
642
-
643
- authors = attributed.group_by { |step| step.from_boundary.whodunnit }
644
- .transform_values(&:length)
645
- ```
646
-
647
- This covers descendants, which is what `version_scope:` cannot do. On one demo
648
- history a contributor who only ever touched comments and authorships has four
649
- attributed activity steps and *zero* root steps.
650
-
651
- Filter in Ruby rather than asking the gem to select fewer versions. Attribution
652
- is only correct because every boundary is reconstructed: skip one and the
653
- snapshot carried into the next step is a state the record had already moved
654
- past, so the change lands on the wrong person. There is nothing to save by
655
- filtering earlier — the work is the reconstruction, not the comparison — and a
656
- predicate can say things a version relation cannot, such as several people at
657
- once or "anyone but the importer".
658
-
659
- `reject(&:empty?)` first, because a boundary that changed nothing you selected
660
- still produces a step. The final event in the range has no boundary after it, so
661
- what it produced is not shown — the same blind spot `to:` has everywhere else.
662
-
663
- For "which of these records did someone touch?" across a listing, note that
664
- `analyze_many` batches the diff and timeline views but not descendant discovery.
665
- Querying versions by `whodunnit` and mapping them back to roots yourself will be
666
- much cheaper than an activity pass per record.
667
-
668
- ## Build a root-checkpoint timeline
669
-
670
- `timeline` accepts two version objects from the supplied record's history, or
671
- the symbols `:first` and `:last` when the range is simply the record's whole
672
- recorded history:
673
-
674
- ```ruby
675
- steps = PaperTrailDiff.timeline(article, from: :first, to: :last)
676
- ```
677
-
678
- `:first` and `:last` are resolved by the gem, independently of the order the
679
- `versions` association happens to use, so a caller never has to know it is
680
- sorted. They work anywhere a version does, including mixed with an explicit
681
- one (`from: :first, to: some_version`) and on `activity_timeline` and
682
- `analyze`. A record with no versions has no boundaries to resolve; that is an
683
- empty history rather than a bad request, so the result is an empty timeline
684
- instead of an error — which is usually what an index page wants.
685
103
 
686
- Combine `from: :first` with `to: article` for the fullest activity view of a
687
- live record: the whole recorded history, ending at current state. See
688
- [activity timelines](#build-an-activity-timeline) for why that end differs from
689
- `to: :last`.
690
-
691
- The range is inclusive, must be chronological, and produces one `Step` for each
692
- adjacent pair:
693
-
694
- ```ruby
695
- steps = PaperTrailDiff.timeline(
696
- article,
697
- from: article.versions[1],
698
- to: article.versions[4]
699
- )
700
-
701
- steps.first.from_version # the original PaperTrail version
702
- steps.first.to_version # the next PaperTrail version
703
- steps.first.from_boundary # immutable presentation metadata
704
- steps.first.to_boundary
705
- steps.first.diff # a PaperTrailDiff::Diff
706
- steps.first.empty? # delegates to the diff
707
- steps.first.to_h
708
- # => { from_version_id: 2, to_version_id: 3, diff: { ... } }
709
- ```
710
-
711
- Both timeline types expose `from_boundary`, `to_boundary`, `diff`, and
712
- `empty?`. A historical boundary has `event`, `whodunnit`, `record`,
713
- `recorded_at`, `version?`, `current?`, and `destroyed?` readers. Checkpoint `Step` objects
714
- also retain their original `from_version` and `to_version` for callers that
715
- need custom PaperTrail metadata. Existing `Step#to_h` and
716
- `ActivityBoundary#to_h` shapes remain unchanged; use the readers for the new
717
- metadata.
718
-
719
- Every version boundary remains in the result, even when its diff is empty after
720
- ignored fields are removed. Equal boundaries return a frozen empty array.
721
-
722
- This is deliberately a root-record timeline. Several child changes between two
723
- root versions appear together in the same step.
724
-
725
- ## Build an activity timeline
726
-
727
- `activity_timeline` merges versions for the root and explicitly selected
728
- descendants. Child changes can therefore form separate steps instead of waiting
729
- to be aggregated at the next root checkpoint:
730
-
731
- ```ruby
732
104
  steps = PaperTrailDiff.activity_timeline(
733
- article,
734
- from: article.versions[1],
735
- to: article.versions[4],
736
- associations: ["comments.replies", :author]
105
+ article, associations: [:comments], group: :transaction, snapshots: true
737
106
  )
738
107
 
739
- steps.reject(&:empty?)
740
- ```
741
-
742
- Pass the record explicitly as `to:` to include current state without creating a
743
- final root checkpoint. This is not the same as `to: :last`: descendant
744
- mutations recorded after the record's final root version fall outside a
745
- version-bounded range, so only a live end reports them.
746
-
747
- ```ruby
748
- steps = PaperTrailDiff.activity_timeline(
749
- article,
750
- from: article.versions[1],
751
- to: article,
752
- associations: ["comments.replies", :author]
753
- )
754
- ```
755
-
756
- The result is a frozen array of `ActivityStep` objects. Each step has immutable
757
- `from_boundary`, `to_boundary`, and `diff` readers. Historical boundaries are
758
- `kind: :version`; an explicitly requested live endpoint is `kind: :current`:
759
-
760
- ```ruby
761
- steps.last.to_boundary.to_h
762
- # => {
763
- # kind: :current,
764
- # version_id: nil,
765
- # item_type: "Article",
766
- # item_id: 42,
767
- # recorded_at: 2026-08-08 12:00:00 UTC
768
- # }
769
- ```
770
-
771
- A historical boundary may belong to the root or a selected descendant model.
772
- PaperTrail versions describe pre-change state, so a mutation becomes visible
773
- between its version boundary and the next historical or explicit current
774
- boundary. Passing `to: article` is what removes the need to touch the parent
775
- after an ordinary versioned child mutation; current state is still never
776
- implicit.
777
-
778
- ### Reporting one save as one step
779
-
780
- A parent and its children saved together produce a version each, so one
781
- deliberate action arrives as several steps. `group: :transaction` reports it as
782
- one:
783
-
784
- ```ruby
785
- PaperTrailDiff.activity_timeline(
786
- article, from: :first, to: article,
787
- associations: [:comments], group: :transaction
788
- )
789
- ```
790
-
791
- Given a single transaction that retitled an article, revised one comment, and
792
- added another, the ungrouped timeline reports three steps — the title change,
793
- then a step that reads as empty, then the comment changes. Grouped, it reports
794
- one step carrying the title *and* the comment changes, credited to whoever made
795
- the save. Off by default; also accepted by `analyze(activity: true)`.
796
-
797
- The middle step reads as empty for a reason worth knowing, because it is the
798
- same rule the rest of this document turns on. A version records the state
799
- *before* its own event, so a child's new value is revealed only by something
800
- later: a further version of that child, its destroy version, or the live row.
801
- Inside a transaction there is usually none of those yet, so a change made at
802
- that boundary surfaces a step or more afterwards, folded in with whatever that
803
- step carried. Nothing is lost — but "when" and "with what" both read wrong.
804
-
805
- Two properties this relies on:
806
-
807
- **A step belongs to the transaction of the event that opens it.** That is the
808
- event whose change the step reports, so grouping on the closing boundary instead
809
- would credit each change to whoever made the next one.
810
-
811
- **Merging compares the group's outer states**, rather than combining the diffs
812
- between them. A field set and then restored inside one transaction has not
813
- changed, and only comparing the endpoints can say so.
814
-
815
- A boundary that records no transaction groups with nothing. PaperTrail leaves
816
- `transaction_id` nil outside a transaction, and a custom version class need not
817
- carry the column at all; treating those as one shared transaction would merge
818
- unrelated history into a single step. `ActivityBoundary#transaction_id` exposes
819
- what a boundary recorded.
820
-
821
- ### Reading the state behind a step
822
-
823
- A step's diff carries what changed. A renderer often needs what did *not* — to
824
- say whose comment was edited, it needs the comment's author, which the diff has
825
- no reason to mention. `snapshots: true` retains the reconstructed states each
826
- step was compared between:
827
-
828
- ```ruby
829
- steps = PaperTrailDiff.activity_timeline(
830
- article, from: :first, to: article, associations: [:comments], snapshots: true
831
- )
832
-
833
- step = steps.reject(&:empty?).last
834
- step.from_snapshot.associations["comments"].records.first.attributes["author"]
835
- ```
836
-
837
- The gem already builds these states to compute each diff and otherwise discards
838
- them, so asking for them costs no extra queries. Reconstructing them yourself
839
- from the version table means reimplementing boundary ordering, and getting it
840
- wrong is quiet rather than loud.
841
-
842
- They are off by default because each one holds the whole selected graph, and
843
- retaining one per step keeps the entire timeline's graph alive. A step whose
844
- `from_boundary` is a `create` version has a `nil` `from_snapshot`: the record
845
- did not exist yet, which is the answer rather than a missing one.
846
-
847
- `analyze(activity: true, snapshots: true)` populates the same fields on the
848
- `activity_timeline` it returns.
849
-
850
- ### Closing a destroyed root
851
-
852
- A `destroy` version is the one boundary whose following state needs no later
853
- version: the event itself says the record is gone. When an activity timeline
854
- ends at the root's own `destroy` version, it therefore closes with one more
855
- step, from that version to a `kind: :destroyed` boundary, whose diff is a
856
- `record_presence_change` from the record's final state to `nil`:
857
-
858
- ```ruby
859
- steps = PaperTrailDiff.activity_timeline(
860
- article,
861
- from: article.versions.first,
862
- to: article.versions.last # a destroy version
863
- )
864
-
865
- removal = steps.last
866
- removal.to_boundary.destroyed? # => true
867
- removal.to_boundary.kind # => :destroyed
868
- removal.diff.record_presence_change.from # the state it was deleted in
869
- removal.diff.record_presence_change.to # => nil
870
- ```
871
-
872
- The removal step's `from_boundary` is the ordinary `kind: :version` boundary
873
- for the same destroy version, since that boundary still holds the record.
874
- Boundaries therefore have three kinds — `:version`, `:current`, and
875
- `:destroyed` — so a consumer that branches on `version?` alone should also
876
- handle `destroyed?`.
877
-
878
- `analyze(activity: true)` reports the same closing step in its
879
- `activity_timeline`. Its `diff` and `timeline` keep their `compare` and
880
- `timeline` semantics and do not report the deletion.
881
-
882
- A `within:` window behaves the same way. A window whose last selected mutation
883
- is the root's destruction needs no later root version, because none can ever
884
- exist, so it closes on the removal instead of raising
885
- `IncompleteTimeRangeError`. When the destruction falls *outside* the window it
886
- remains ordinary reconstruction context and is not reported as a selected
887
- mutation. The same relaxation lets the plain `timeline` accept such a window,
888
- though it still reports only the edits.
889
-
890
- Live-ended HABTM activity is rejected with
891
- `PaperTrailDiff::UnsupportedLiveActivityError`. HABTM join rows have no model
892
- versions, so the gem cannot reliably split their membership mutations into
893
- intermediate events without transaction-backed owner checkpoints. Historical
894
- HABTM activity and `compare(version, article, associations: [:tags])` remain
895
- supported where the historical endpoint has usable PT-AT metadata.
896
-
897
- Both timeline APIs preserve empty boundaries. A display may use
898
- `steps.reject(&:empty?)`, while audit-oriented callers can retain every
899
- recorded boundary.
900
-
901
- ## Select mutations by time
902
-
903
- `timeline`, `activity_timeline`, and `analyze` also accept a finite time range
904
- through `within:` instead of explicit `from:` and `to:` versions. Half-open
905
- ranges are recommended for adjacent reporting windows:
906
-
907
- ```ruby
908
- window = Time.zone.parse("2026-08-01")...Time.zone.parse("2026-09-01")
909
-
910
- steps = PaperTrailDiff.timeline(article, within: window)
911
-
912
- activity_steps = PaperTrailDiff.activity_timeline(
913
- article,
914
- within: window,
915
- associations: ["comments.replies", :author]
916
- )
917
-
918
- analysis = PaperTrailDiff.analyze(
919
- article,
920
- within: window,
921
- associations: ["comments.replies"],
922
- activity: true
923
- )
924
- ```
925
-
926
- The range selects mutations by the timestamp of their source PaperTrail
927
- version. Ruby's inclusive (`..`) and exclusive (`...`) end semantics are
928
- honored. A returned step's `to_boundary` may be after the range: because a
929
- PaperTrail version is a pre-change snapshot, the gem needs one later root
930
- version to reveal the final selected mutation. That version is reconstruction
931
- context, not an additional selected mutation.
932
-
933
- If the window contains a relevant mutation but no later root version exists,
934
- the call raises `PaperTrailDiff::IncompleteTimeRangeError`. The gem does not
935
- silently substitute current database state; pass
936
- [`close_on: :current`](#reporting-up-to-now) to ask for it, or create a root
937
- checkpoint after the reporting window. A root-only window with no selected
938
- mutation returns a frozen empty timeline. The one exception is a window that
939
- closes on the root's own destruction, which no later version can ever follow;
940
- see [closing a destroyed root](#closing-a-destroyed-root).
941
-
942
- ### Reporting up to now
943
-
944
- A window whose end is the present has nothing recorded after its final
945
- mutation, so by default it raises. `close_on: :current` closes it on the live
946
- record instead:
947
-
948
- ```ruby
949
- PaperTrailDiff.analyze_many(
950
- articles, within: month_start..Time.current, close_on: :current
951
- )
952
- ```
953
-
954
- It is accepted by `timeline`, `activity_timeline`, `analyze`, and
955
- `analyze_many`, and only alongside `within:` — a range whose endpoints you gave
956
- explicitly already says where it ends. The closing step's `to_boundary.kind` is
957
- `:current` and its `to_version` is `nil`, so read the boundary rather than the
958
- version on the last step. Under `activity: true` the window runs to the instant
959
- state is captured rather than to the last root version, so a descendant that
960
- moved after that version is still reported.
961
-
962
- Two things follow from closing on live state. Current state includes changes
963
- PaperTrail never recorded — an `update_columns` write shows up in that final
964
- step, attributed to the version that precedes it, because nothing else records
965
- who made it. And a destroyed root has no current state to close on, so it keeps
966
- closing on its own destruction as it already did.
967
-
968
- Time ranges and explicit `from:`/`to:` endpoints are mutually exclusive.
969
- Malformed, open-ended, or reversed ranges raise
970
- `PaperTrailDiff::InvalidTimeRangeError`. With `activity_timeline`, selected
971
- descendant versions are included even when no root mutation occurred inside
972
- the window; the later root checkpoint is still required so PT-AT can
973
- reconstruct the enclosing graph and determine whether descendant activity is
974
- present. The ordinary `timeline` remains a root-only checkpoint timeline.
975
-
976
- ## Build an endpoint and timeline together
977
-
978
- When a caller needs the net endpoint diff and root timeline, `analyze`
979
- normalizes each selected version once. Pass `activity: true` when the same view
980
- also needs descendant-aware activity. The endpoint diff and root timeline are
981
- then derived from the same activity snapshot pass instead of reconstructing the
982
- root history separately:
983
-
984
- ```ruby
985
- analysis = PaperTrailDiff.analyze(
986
- article,
987
- from: article.versions[1],
988
- to: article.versions[4],
989
- associations: ["comments.replies"],
990
- activity: true
991
- )
992
-
993
- analysis.diff
994
- analysis.timeline
995
- analysis.activity_timeline
996
- ```
997
-
998
- Without `activity: true`, `analysis.activity_timeline` is `nil` and no activity
999
- work is performed. `analyze` accepts explicit historical versions or
1000
- `within:`, but not a current-record endpoint; use the standalone
1001
- `activity_timeline(..., to: article)` API when the final boundary must be live.
1002
-
1003
- ### Choosing between `timeline` and `analyze(activity: true)`
1004
-
1005
- `analysis.timeline` and `timeline` return the same root-checkpoint steps for the
1006
- same range. They are two reconstruction strategies for one result, not two
1007
- levels of detail: a `timeline` step covers only root version boundaries, but
1008
- each of its snapshots still contains every selected association, because a step
1009
- must report what changed underneath the root as well.
1010
-
1011
- The one behavioural difference is at the edge of a `within:` window. Selected
1012
- descendants can move inside a window that contains no root version at all, so
1013
- the activity form requires a root boundary it can reconstruct from and raises
1014
- `IncompleteTimeRangeError` when there is none. `timeline` has no activity view
1015
- to anchor and returns no steps for that window.
1016
-
1017
- The strategies differ in what that costs:
1018
-
1019
- - `timeline` reconstructs the whole selected graph independently at every root
1020
- boundary, so it costs roughly *root versions x selected graph size*. It is
1021
- insensitive to how much descendant activity happened in between.
1022
- - `analyze(activity: true)` reconstructs once and then advances that snapshot
1023
- through each recorded mutation, so it costs roughly *one reconstruction +
1024
- total events*. It is insensitive to how wide the selected graph is.
1025
-
1026
- Neither dominates. Reconstructing once per checkpoint wins when a few root
1027
- versions span very heavy descendant churn; advancing incrementally wins when
1028
- the selected graph is wide and descendant activity is comparable to root
1029
- activity. As a rule of thumb, prefer `analyze(activity: true)` when selected
1030
- associations are wide, and `timeline` when descendant events greatly outnumber
1031
- root versions. Measure with `ActiveSupport::Notifications` on a representative
1032
- history rather than a seeded example if the choice matters.
1033
-
1034
- `timeline`, `activity_timeline`, and both forms of `analyze` prepare the selected
1035
- historical range once. The loader walks only the explicit association paths and
1036
- builds an immutable temporal index of scalar states, relationship candidates,
1037
- HABTM transaction snapshots, and live fallbacks. Direct relationships,
1038
- `has_many :through` with a `belongs_to` source, and transaction-backed HABTM
1039
- membership are resolved from that index. Endpoint-only `compare` retains the
1040
- lighter two-point reconstruction path.
1041
-
1042
- Activity reconstruction then carries immutable snapshots forward between
1043
- boundaries. Isolated root and descendant events with usable serialized changes
1044
- advance only the affected immutable nodes. Events sharing a PT-AT transaction
1045
- refresh their combined branches atomically. Scoped associations, unversioned
1046
- targets, composite relationship keys, collection-source through associations,
1047
- and other unsupported shapes fall back to the ordinary PT-AT point reifier on
1048
- a per-reflection basis. This hybrid path preserves existing reconstruction
1049
- behavior while avoiding repeated association queries for supported paths.
1050
-
1051
- For supported collection events, adjacent snapshots share an immutable
1052
- identity-position index and carry a one-step transition hint. Diffing therefore
1053
- visits only the changed member instead of hashing the whole collection again.
1054
- Prepared scalar history also exposes the predecessor and successor attributes
1055
- for isolated update events, allowing activity reconstruction to update the
1056
- immutable snapshot directly instead of reifying and deserializing the same
1057
- PaperTrail event again. Compatible scalar payloads are decoded into prepared
1058
- state without first constructing a disposable Active Record object. Encrypted,
1059
- schema-mismatched, missing, ambiguous, or identity-changing states fall back to
1060
- the ordinary event reifier.
1061
- For a direct nested `has_many` such as `comments.replies`, the child's foreign
1062
- key also locates its parent snapshot directly rather than walking every
1063
- comment. Membership changes and ambiguous or unsupported relationship shapes
1064
- retain the general comparator and traversal fallback.
1065
-
1066
- Activity event loading is bounded to the selected range. Association identity
1067
- discovery retains one indexed checkpoint for members present at the starting
1068
- boundary, then considers later association activity and current members; it no
1069
- longer materializes every pre-range association row. Because a PaperTrail
1070
- version is a pre-change snapshot, the state at a range's final boundary can
1071
- live only in the next version after it, so prepared scalar state retains one
1072
- trailing version per selected identity. It does not retain the rest of the
1073
- history recorded after the range, so a short range early in a long history
1074
- costs the same as the same range in a short one. Keep requested paths and
1075
- ranges intentional. An activity timeline must also emit a diff for every selected
1076
- event. Repeated events within one wide collection still copy the frozen records
1077
- array when producing each immutable snapshot, so they can do pointer-copying
1078
- work proportional to the number of events times the collection width even when
1079
- SQL and Ruby-level comparison work stay linear. Bound or paginate unusually
1080
- wide activity ranges in latency-sensitive requests.
1081
-
1082
- For large histories, applications should give the database a matching
1083
- composite index. A typical PaperTrail installation can add one without making
1084
- it a requirement of this gem:
1085
-
1086
- ```ruby
1087
- add_index :versions, %i[item_type item_id created_at id],
1088
- name: "index_versions_on_item_and_timeline"
1089
- ```
1090
-
1091
- This index is especially useful for repeated `within:` queries because root
1092
- selection is constrained by `item_type`, `item_id`, and `created_at`, then
1093
- ordered deterministically by timestamp and version ID.
1094
-
1095
- PT-AT's normal index beginning with `foreign_key_name`, `foreign_key_id`, and
1096
- `foreign_type` should also be retained on `version_associations`.
1097
-
1098
- ## Ignore noise fields
1099
-
1100
- The comparison APIs ignore `updated_at` by default. `ignore:` replaces that default and
1101
- accepts string or symbol names. The array form applies to the root and every
1102
- selected association:
1103
-
1104
- ```ruby
1105
- PaperTrailDiff.compare(from, to, ignore: %i[updated_at lock_version])
1106
- PaperTrailDiff.compare(from, to, ignore: []) # compare every scalar field
1107
- ```
1108
-
1109
- For exact path control, pass `all:` plus `paths:`. `$` identifies the root:
1110
-
1111
- ```ruby
1112
- PaperTrailDiff.compare(
1113
- from,
1114
- to,
1115
- associations: ["comments.replies"],
1116
- ignore: {
1117
- all: [:updated_at],
1118
- paths: {
1119
- "$" => [:lock_version],
1120
- "comments.replies" => [:delivery_state]
1121
- }
108
+ feed = steps.reject(&:empty?).map do |step|
109
+ {
110
+ actor: step.source_boundary.whodunnit,
111
+ recorded_at: step.source_boundary.recorded_at,
112
+ changes: step.diff.to_h
1122
113
  }
1123
- )
1124
- ```
1125
-
1126
- Hash rules replace the default just like an array. `all:` applies everywhere;
1127
- each path entry applies only at that exact path, not its descendants. Ignore
1128
- paths must be `$` or one of the selected or implicitly selected association
1129
- paths.
1130
-
1131
- Primary keys are always represented as record identity rather than scalar
1132
- attributes.
1133
-
1134
- ## Compare associations
1135
-
1136
- Pass explicit names or dot-separated paths. Paths are finite traversal plans,
1137
- not a request to recursively inspect the whole object graph. Ancestors are
1138
- selected implicitly:
1139
-
1140
- ```ruby
1141
- diff = PaperTrailDiff.compare(
1142
- from,
1143
- to,
1144
- associations: [:author, :profile, "comments.replies.author"]
1145
- )
1146
-
1147
- diff.associations["author"].relationship
1148
- # => #<PaperTrailDiff::ValueChange ...> # belongs_to replacement
1149
-
1150
- comments = diff.associations["comments"]
1151
- comments.added # full RecordSnapshot objects
1152
- comments.removed # full RecordSnapshot objects
1153
- comments.changed # RecordChange objects for stable identities
1154
-
1155
- replies = comments.changed.first.associations["replies"]
1156
- replies.added
1157
- replies.removed
1158
- replies.changed
1159
- ```
1160
-
1161
- For `belongs_to` and `has_one`, replacing the related identity is a
1162
- `relationship` change. Updating attributes on the same related identity is a
1163
- `changed` record. For `has_many` and `has_and_belongs_to_many`, membership is
1164
- split into `added` and `removed`; shared identities with scalar changes appear
1165
- in `changed`. The result preserves the reflected macro in `kind`.
1166
-
1167
- Both Rails many-to-many forms are supported. `has_many :through` uses PT-AT's
1168
- history for the versioned join model. HABTM reports membership and target
1169
- attribute changes, but it cannot report join attributes because HABTM has no
1170
- join model.
1171
-
1172
- The same structure repeats at every selected depth. `RecordChange#associations`
1173
- contains nested changes for a stable parent identity. Added and removed record
1174
- snapshots retain their selected subtrees so nested state is not discarded.
1175
-
1176
- Selecting a `belongs_to` removes its foreign-key (and polymorphic type) column
1177
- from scalar changes at that path. Without association selection, that column
1178
- remains a normal scalar attribute. A direct `has_one` or `has_many` edge also
1179
- removes its incoming foreign key from the child snapshot because membership is
1180
- already represented structurally. Join-model attributes for `has_many :through`
1181
- remain available. Cyclic model relationships are safe because only the finite
1182
- paths supplied by the caller are traversed.
1183
-
1184
- Requesting associations for any historical endpoint without loaded and enabled
1185
- PT-AT raises `PaperTrailDiff::AssociationTrackingUnavailableError`. A comparison
1186
- whose endpoints are both live records can normalize associations without PT-AT,
1187
- though it will normally be empty because both records reload the same current
1188
- database state. Unknown names and unsupported macros raise explicit
1189
- `PaperTrailDiff::Error` subclasses.
1190
- Malformed public options raise `PaperTrailDiff::ConfigurationError`, also under
1191
- that base error.
1192
-
1193
- ## Traverse result trees
1194
-
1195
- The nested tree remains the canonical, lossless result. For renderers,
1196
- counters, exports, and notifications, `Diff#each_change` provides a
1197
- deterministic depth-first stream of semantic changes:
1198
-
1199
- <!-- executable:readme-traversal-changes -->
1200
- ```ruby
1201
- diff.each_change do |entry|
1202
- entry.kind # :attribute_changed, :record_added, ...
1203
- entry.association_path # ["comments", "replies"]
1204
- entry.record_path # frozen RecordReference objects
1205
- entry.association_kind # :has_many, :belongs_to, ...
1206
- entry.attribute # "body" for an attribute entry
1207
- entry.value # ValueChange, RecordChange, or RecordSnapshot
1208
114
  end
1209
115
 
1210
- counts = diff.each_change.map(&:kind).tally
1211
- ```
1212
-
1213
- `record_changed` entries are emitted before that record's attribute and nested
1214
- association changes, allowing an application to count both changed records and
1215
- changed fields. Singular relationship operations are classified separately as
1216
- `relationship_added`, `relationship_removed`, or `relationship_replaced`.
1217
- Root create/delete transitions remain `record_presence_changed`.
1218
-
1219
- Added, removed, and replaced records carry complete bounded snapshots. Use
1220
- `each_entry` when a renderer also needs that nested state without writing a
1221
- second snapshot walker:
1222
-
1223
- <!-- executable:readme-traversal-entries -->
1224
- ```ruby
1225
- diff.each_entry do |entry|
1226
- next unless entry.included_state?
1227
-
1228
- entry.kind # :record_included, :attribute_included, :association_included
1229
- entry.state # :before or :after
1230
- entry.context # :included_state
1231
- end
116
+ payload = steps.map { |step| step.to_h(metadata: true) }
1232
117
  ```
1233
118
 
1234
- An included record is historical context, not evidence that the nested record
1235
- changed independently. `each_change` therefore omits included-state entries.
1236
- Both methods return an `Enumerator` when no block is supplied and return the
1237
- diff when called with a block. Entries and their paths are immutable and have
1238
- deterministic `to_h` output. The root location uses empty association and record
1239
- paths; descendant `record_path` values contain only the explicitly traversed
1240
- descendant identities.
119
+ Each step contains a `diff`. `source_boundary` describes the PaperTrail version
120
+ responsible for that change, including its `whodunnit`, `event`, `item_type`,
121
+ and `item_id`. Its `recorded_at` is the version's `created_at`.
122
+ Use this source information for attribution: the version used to read the
123
+ resulting state, exposed as `to_boundary`, may have been saved by someone else.
1241
124
 
1242
- Traversal is path-preserving and does not deduplicate. If the same record is
1243
- reachable through two selected paths, it appears at both locations so the
1244
- consumer can choose whether path identity or record identity controls counting.
1245
- Walking a result never performs database access and never discovers additional
1246
- associations.
125
+ `group: :transaction` combines changes from one database transaction. Its source
126
+ information comes from the first event in that group. Omit `group:` if you need
127
+ to attribute each version separately.
1247
128
 
1248
- ## Discover and diagnose associations
129
+ `snapshots: true` also keeps copies of the compared records' data, including
130
+ unchanged fields. These read-only copies are called snapshots. Access them as
131
+ `step.from_snapshot` and `step.to_snapshot`, or include them in a hash with
132
+ `step.to_h(snapshots: true)`. Keeping them uses more memory. Omit the option if
133
+ changed values are enough. `metadata: true` adds event and author information
134
+ to the hash.
1249
135
 
1250
- Configuration UIs can use bounded public reflection instead of duplicating the
1251
- gem's macro and cycle logic:
136
+ ## 3. Report on records changed since a given time
1252
137
 
1253
- ```ruby
1254
- PaperTrailDiff.supported_association_macros
1255
- PaperTrailDiff.association_paths(Article, max_depth: 3).map(&:to_h)
1256
- ```
1257
-
1258
- Descriptors include `path`, `kind`, `target_type`, `through`, `polymorphic`, and
1259
- `cycle`. Cycles are marked and not descended; PaperTrail's infrastructure
1260
- `versions` association is excluded.
1261
-
1262
- Before relying on association history, inspect known setup hazards:
138
+ Use `report` with a model or ActiveRecord relation. It includes activity from
139
+ `since:` through the current saved state. Association tracking is only needed
140
+ if you also pass `associations:`.
1263
141
 
142
+ <!-- executable:recipe-report -->
1264
143
  ```ruby
1265
- report = PaperTrailDiff.diagnose(
1266
- from_version,
1267
- to_version,
1268
- associations: [:tags, "comments.replies"]
144
+ report = PaperTrailDiff.report(
145
+ Article.all, since: 1.day.ago, limit: 100, include_deleted: true
1269
146
  )
1270
147
 
1271
- report.ok?
1272
- report.errors.map(&:code)
1273
- report.warnings.map(&:code)
1274
- ```
1275
-
1276
- `ok?` means no *errors* were found among the checks that ran, not that every
1277
- possible hazard was ruled out. Association checks need `associations:` to have
1278
- something to inspect; without it only the version-order check runs, which is
1279
- why that one runs whether or not associations are selected — an unorderable
1280
- history corrupts a scalar timeline just as thoroughly.
1281
-
1282
- Diagnostics are read-only guidance, not proof that arbitrary old data is
1283
- complete. HABTM endpoints without transaction-backed association snapshots fail
1284
- loudly with `PaperTrailDiff::IncompleteAssociationHistoryError` during normal
1285
- comparison as well.
1286
-
1287
- ### Recommended checkpoint recipe
1288
-
1289
- For PT-AT history—especially HABTM—use transaction-backed checkpoints, disable
1290
- PaperTrail's version timestamp synchronization on the root, and checkpoint a
1291
- fresh record instance:
1292
-
1293
- ```ruby
1294
- class Article < ApplicationRecord
1295
- has_paper_trail synchronize_version_creation_timestamp: false
148
+ rows = report.analyses.map do |identity, analysis|
149
+ {
150
+ record: identity, # ["Article", "42"]: model name and ID
151
+ changes: analysis.diff.to_h,
152
+ activity: analysis.activity_timeline.reject(&:empty?).map do |step|
153
+ step.to_h(metadata: true)
154
+ end
155
+ }
1296
156
  end
1297
157
 
1298
- def checkpoint_article(article_id)
1299
- Article.transaction do
1300
- Article.find(article_id).paper_trail.save_with_version
1301
- end
1302
- end
158
+ report.unreachable # Model names and IDs for records no longer in the database
159
+ report.deleted # Reports for those records, or nil when destroy history is missing
1303
160
  ```
1304
161
 
1305
- A root checkpoint after a batch supplies an immutable historical final state.
1306
- The ordinary `timeline` aggregates that batch; `activity_timeline` uses recorded
1307
- descendant versions to split it into activity boundaries. For an interactive
1308
- view, `activity_timeline(..., to: article)` can instead terminate explicitly at
1309
- current state without touching the root. Prefer checkpoints for reproducible
1310
- audits and HABTM, and prefer an explicit join model with `has_many :through`
1311
- when join attributes or join mutations must appear as first-class history.
1312
-
1313
- ## Result objects
1314
-
1315
- Every level of a result separates the same two kinds of change in the same way:
1316
-
1317
- | | a record appears or disappears | a record stays and its fields change |
1318
- | --- | --- | --- |
1319
- | the root record | `record_presence_change` | `attributes`, `associations` |
1320
- | `has_many`, HABTM | `added`, `removed` | `changed` |
1321
- | `belongs_to`, `has_one` | `relationship` | `changed` |
1322
-
1323
- The left column carries whole `RecordSnapshot` values; the right column carries
1324
- field-level `ValueChange` deltas. That split is deliberate. A record that has
1325
- just appeared has no previous value for any of its fields, so reporting one
1326
- `nil` to value change per attribute would both add noise and blur the
1327
- difference between "this field was edited" and "this record did not exist".
1328
-
1329
- The consequence is that reading a result tree directly means branching on which
1330
- column applies: a created record's state is under
1331
- `record_presence_change.to.attributes`, an edited record's is under
1332
- `attributes`. Consumers that would rather not branch should use
1333
- [`each_entry`](#traverse-result-trees), which flattens both into one stream —
1334
- an edit arrives as `attribute_changed` carrying a `ValueChange`, and a created
1335
- record's fields arrive as `attribute_included` entries carrying values with
1336
- `state: :after`.
1337
-
1338
- The public result types are:
1339
-
1340
- - `PaperTrailDiff::Diff`
1341
- - `PaperTrailDiff::Step`
1342
- - `PaperTrailDiff::ActivityStep`
1343
- - `PaperTrailDiff::ActivityBoundary`
1344
- - `PaperTrailDiff::Analysis`
1345
- - `PaperTrailDiff::ValueChange`
1346
- - `PaperTrailDiff::TraversalEntry`
1347
- - `PaperTrailDiff::RecordReference`
1348
- - `PaperTrailDiff::RecordSnapshot`
1349
- - `PaperTrailDiff::RecordChange`
1350
- - `PaperTrailDiff::ToOneAssociationDiff`
1351
- - `PaperTrailDiff::CollectionAssociationDiff`
1352
- - `PaperTrailDiff::AssociationDescriptor`
1353
- - `PaperTrailDiff::DiagnosticReport`
1354
- - `PaperTrailDiff::DiagnosticIssue`
162
+ `report.analyses` is a hash of reports for records that still exist. Each key is
163
+ `[model_name, id_as_a_string]`. Each value has the net `diff` and an
164
+ `activity_timeline`. `report.roots` returns the selected ActiveRecord objects.
1355
165
 
1356
- `Analysis` also exposes the reconstructed states its diff was taken between, as
1357
- `from_snapshot` and `to_snapshot`. A report that renders unchanged columns needs
1358
- the whole final state, not only what moved, and these are the states the gem
1359
- already reconstructed:
166
+ `include_deleted: true` also builds reports from PaperTrail's destroy versions.
167
+ Read them from `report.deleted` using the same keys:
1360
168
 
169
+ <!-- executable:recipe-deleted -->
1361
170
  ```ruby
1362
- analysis.to_snapshot.attributes # every selected scalar, changed or not
1363
- analysis.to_snapshot.associations # the selected association tree
171
+ unavailable = report.deleted.select { |_identity, analysis| analysis.nil? }.keys
172
+ deleted_activity = report.deleted.transform_values do |analysis|
173
+ analysis&.activity_timeline
174
+ end
1364
175
  ```
1365
176
 
1366
- Either is `nil` when that endpoint has no reconstructable state — most often a
1367
- `from_snapshot` at a `create` boundary, whose pre-change state is the absence of
1368
- the record. Following the precedent set by `Step`, `Analysis#to_h` is unchanged;
1369
- serialize `to_snapshot.to_h` when a serialized form is wanted.
1370
-
1371
- They expose readers, are frozen after construction, and provide deterministic
1372
- `to_h` output. Collection results are ordered by record identity: by type, then
1373
- naturally within one id type, so numeric ids sort `2` before `10`. Mixed or
1374
- unusual id types still order totally rather than raising.
1375
-
1376
- Structural hash keys are symbols; attribute and association
1377
- names are strings. Attribute values retain their Ruby types. `RecordChange#record`
1378
- is a `RecordReference` with `type` and `id` readers. `TraversalEntry#record` and
1379
- `#association` return the final components of their corresponding paths. `Step`
1380
- itself is frozen but intentionally retains the original, potentially mutable
1381
- PaperTrail version objects for metadata access; its new boundary readers provide
1382
- an immutable presentation interface, while `Step#to_h` continues to emit only
1383
- version IDs. `ActivityStep` retains only immutable boundary metadata and
1384
- serializes as `{ from: ..., to: ..., diff: ... }`.
177
+ The results are already computed; you do not need to query or reify each deleted
178
+ record. Omit `include_deleted:` if you only need records that still exist.
1385
179
 
1386
- ## Historical correctness and limitations
180
+ Three selection rules matter:
1387
181
 
1388
- The output is only as correct and complete as the historical state PaperTrail
1389
- and PT-AT can reconstruct. In particular:
182
+ - The record itself needs a PaperTrail version in the time window. For example,
183
+ editing only a comment does not select its article. Comment activity is included
184
+ for selected articles when you request `associations: [:comments]`.
185
+ - A relation such as `Article.where(status: "open")` checks today's values.
186
+ It cannot establish whether deleted articles matched that condition. Deleted
187
+ records are therefore listed separately.
188
+ - `limit: 100` is a safety ceiling, not pagination. Selecting too many records
189
+ raises `BatchLimitExceededError`. With `include_deleted: true`, both existing
190
+ and deleted records count toward it. Use a narrower relation or time window,
191
+ or deliberately raise the ceiling.
1390
192
 
1391
- - attributes skipped by PaperTrail, deleted versions, and changes made while
1392
- versioning was disabled cannot be recovered;
1393
- - model/schema or serializer changes can affect reification of old versions;
1394
- - comparison and activity APIs add live state only when the caller explicitly
1395
- passes a persisted record endpoint; the adapter reloads that record, but
1396
- multiple queries are not automatically one database-isolated snapshot;
1397
- - PT-AT requires its schema, callbacks, versioned child models, and transaction
1398
- metadata; callback-skipping writes may not be reconstructable;
1399
- - HABTM membership is limited to the join snapshots PT-AT recorded in
1400
- `version_associations`; historical target attributes require versioned target
1401
- models, otherwise PT-AT may return live target state;
1402
- - `compare`, `timeline`, and `analyze`'s endpoint diff do not report the root
1403
- record's destruction, because the state recorded at a `destroy` version is the
1404
- state before the deletion; `activity_timeline` closes on a `:destroyed`
1405
- boundary instead, and a selected child's removal is reported by its parent;
1406
- - `timeline` and `analyze` are bounded by root versions; `activity_timeline`
1407
- adds recorded descendant boundaries and may terminate at an explicitly passed
1408
- current record, while a fully historical result still requires a later root
1409
- version because PaperTrail stores pre-change snapshots;
1410
- - HABTM join-table mutations do not have their own model versions and therefore
1411
- cannot become standalone activity boundaries; live-ended HABTM activity is
1412
- rejected, and a versioned join model should be used when that activity matters;
1413
- - activity discovery uses PT-AT association rows and cannot recover descendant
1414
- events whose relevant callbacks or association metadata were never recorded;
1415
- - PT-AT has documented edge cases, especially around some `has_one` histories;
1416
- `paper_trail_diff` respects PT-AT's configured reification error behavior;
1417
- - deeper paths require more historical reconstruction and database work, so
1418
- callers should select only the branches they need;
1419
- - no implicit or unbounded recursive association traversal is performed.
193
+ Use the relation's `order`, `offset`, and `limit` to choose a page, for example
194
+ `Article.order(:id).limit(20)`. Joined rows can repeat a record; each record is
195
+ analyzed once. See the [reporting reference](docs/reference.md#which-records-are-selected)
196
+ for pagination, deleted records, and selecting by past values with
197
+ `historical_filter:`.
1420
198
 
1421
- Review the [PaperTrail version semantics](https://github.com/paper-trail-gem/paper_trail#3-working-with-versions)
1422
- and [PT-AT limitations](https://github.com/westonganger/paper_trail-association_tracking#limitations)
1423
- before relying on reconstructed history for restoration or compliance work.
199
+ For a fixed past window, use `analyze_scope(Article.all, within: start_time...end_time,
200
+ limit: 100)`.
1424
201
 
1425
- ## Development
202
+ ## Choose a method
1426
203
 
1427
- The project uses mise for its pinned development tools:
1428
-
1429
- ```console
1430
- mise install
1431
- mise exec -- bundle install
1432
- mise exec -- bundle exec rake
1433
- mise exec -- bundle exec rake build
1434
- mise exec -- act push -j quality --matrix ruby:4.0 --matrix paper_trail:17
1435
- ```
1436
-
1437
- The default Rake task runs core specs without PT-AT loaded, association specs in
1438
- a separate process, RuboCop, generated-signature verification, and Steep.
1439
- Selected Ruby blocks in this README and the quickstart are executed in those
1440
- same isolated test sessions; the `<!-- executable:... -->` marker opts a block
1441
- into the appropriate stateful session.
1442
- Inline `#:` annotations in `lib/` generate the committed RBS files under
1443
- `sig/generated/`.
1444
-
1445
- Release tags matching `v*` publish through RubyGems trusted publishing. Before
1446
- tagging, replace `Unreleased` in the changelog with the release date, run the
1447
- full CI workflow, and configure the repository's `release` environment as a
1448
- trusted publisher on RubyGems. Do not store a long-lived RubyGems API key in
1449
- the repository.
1450
-
1451
- ## License
1452
-
1453
- MIT
204
+ | What you need | Method |
205
+ | --- | --- |
206
+ | Net difference between two versions, or a version and the current record | `compare(from, to)` |
207
+ | The same for a list of `{ from:, to: }` pairs, one per record | `compare_many(pairs)` |
208
+ | Each change to one record, version by version | `timeline(record)` |
209
+ | Each change to one record and its chosen associations, with who made it | `activity_timeline(record, associations: ...)` |
210
+ | Net difference and timeline together | `analyze(record)` |
211
+ | The same, plus the activity timeline | `analyze(record, activity: true)` |
212
+ | Analyses for an array of records | `analyze_many(records)` |
213
+ | Analyses for the records a relation selects, over a time window | `analyze_scope(scope, within:, limit:)` |
214
+ | Recent activity through now, for the records a relation selects | `report(scope, since:, limit:)` |
215
+
216
+ `timeline`, `activity_timeline`, and `analyze` cover the whole history through
217
+ the current saved record by default. Narrow them with `from:` and `to:`
218
+ (versions) or `within:` (a time range). See
219
+ [choosing a range](docs/reference.md#choosing-a-range).
220
+
221
+ ## Performance and defaults
222
+
223
+ Use the batch methods for reports instead of calling `reify` or a single-record
224
+ method inside a loop. They share history loading across records. Ordinary
225
+ record and direct-association histories can be read without creating a model
226
+ instance for every historical state.
227
+
228
+ Some associations still need per-record queries. Batch methods reject scoped
229
+ associations and certain `has_many :through` histories by default with
230
+ `UnsupportedBatchPathError`. The error names the association. Remove it from
231
+ `associations:`, or pass `allow_expensive: true` if you accept that cost.
232
+ Callbacks and callable historical filters can also issue queries. See the
233
+ [performance guide](docs/performance.md) for measured behavior and limitations.
234
+
235
+ - `updated_at` is ignored. Passing `ignore:` replaces the default list.
236
+ - Records passed as current state must be saved and have no unsaved changes.
237
+ They are reloaded from the database.
238
+ - Empty timeline steps are kept. Use `reject(&:empty?)` to hide them in a feed.
239
+ - Only the associations you name are included.
240
+ - Historical data cannot be recovered if PaperTrail did not record it.
241
+
242
+ See the [API reference](docs/reference.md) for all options and result methods,
243
+ [testing](docs/testing.md) for development checks, and
244
+ [CHANGELOG.md](CHANGELOG.md) for changes. Licensed under the [MIT license](LICENSE).