paper_trail_diff 0.11.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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +144 -0
  3. data/QUICKSTART.md +149 -79
  4. data/README.md +179 -1367
  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 +59 -24
  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/value_objects.rb +66 -0
  68. data/lib/paper_trail_diff/version.rb +1 -1
  69. data/lib/paper_trail_diff/version_association_candidate_scope.rb +1 -1
  70. data/lib/paper_trail_diff/version_boundary.rb +48 -0
  71. data/lib/paper_trail_diff/version_query.rb +33 -0
  72. data/lib/paper_trail_diff/version_range.rb +10 -38
  73. data/lib/paper_trail_diff.rb +81 -43
  74. data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +0 -3
  75. data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -4
  76. data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +11 -0
  77. data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +4 -4
  78. data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +0 -3
  79. data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +19 -3
  80. data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +2 -2
  81. data/sig/generated/paper_trail_diff/activity_grouping.rbs +3 -0
  82. data/sig/generated/paper_trail_diff/activity_history.rbs +6 -2
  83. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +7 -2
  84. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +8 -3
  85. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +4 -8
  86. data/sig/generated/paper_trail_diff/analysis.rbs +2 -2
  87. data/sig/generated/paper_trail_diff/analysis_batch.rbs +14 -3
  88. data/sig/generated/paper_trail_diff/association_traversal.rbs +11 -0
  89. data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +7 -10
  90. data/sig/generated/paper_trail_diff/batch_safety.rbs +24 -0
  91. data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +8 -2
  92. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +4 -16
  93. data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
  94. data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +44 -0
  95. data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +42 -0
  96. data/sig/generated/paper_trail_diff/endpoint.rbs +5 -0
  97. data/sig/generated/paper_trail_diff/engine.rbs +4 -2
  98. data/sig/generated/paper_trail_diff/errors.rbs +12 -0
  99. data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +8 -0
  100. data/sig/generated/paper_trail_diff/historical_filter.rbs +75 -0
  101. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +17 -4
  102. data/sig/generated/paper_trail_diff/nested_comparator.rbs +31 -18
  103. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +36 -15
  104. data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +56 -0
  105. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +3 -3
  106. data/sig/generated/paper_trail_diff/prepared_history.rbs +12 -14
  107. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -0
  108. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +43 -6
  109. data/sig/generated/paper_trail_diff/prepared_snapshot_builder.rbs +24 -0
  110. data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +33 -0
  111. data/sig/generated/paper_trail_diff/root_version_plan.rbs +15 -2
  112. data/sig/generated/paper_trail_diff/root_version_selection.rbs +8 -1
  113. data/sig/generated/paper_trail_diff/scoped_analysis.rbs +14 -2
  114. data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +22 -0
  115. data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +46 -6
  116. data/sig/generated/paper_trail_diff/snapshot.rbs +4 -0
  117. data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +13 -0
  118. data/sig/generated/paper_trail_diff/step.rbs +8 -2
  119. data/sig/generated/paper_trail_diff/stored_time_precision.rbs +36 -0
  120. data/sig/generated/paper_trail_diff/support.rbs +7 -0
  121. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +7 -2
  122. data/sig/generated/paper_trail_diff/time_range.rbs +9 -0
  123. data/sig/generated/paper_trail_diff/time_version_range.rbs +0 -6
  124. data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
  125. data/sig/generated/paper_trail_diff/timeline_range.rbs +10 -14
  126. data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +2 -0
  127. data/sig/generated/paper_trail_diff/value_objects.rbs +45 -0
  128. data/sig/generated/paper_trail_diff/version_boundary.rbs +23 -0
  129. data/sig/generated/paper_trail_diff/version_query.rbs +15 -0
  130. data/sig/generated/paper_trail_diff/version_range.rbs +5 -14
  131. data/sig/generated/paper_trail_diff.rbs +37 -27
  132. metadata +31 -5
data/README.md CHANGED
@@ -1,1432 +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
100
-
101
- Anything `has_paper_trail` tracks, including these, which are covered by the
102
- test suite rather than assumed:
103
-
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.
112
-
113
- ### Versions that cannot be ordered
114
-
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.
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.
149
60
 
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.
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.
154
66
 
155
- ### ActiveStorage attachments
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.
156
71
 
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.
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.
164
75
 
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
- ```
229
-
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).
235
-
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:
241
-
242
- ```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 whole, not by index.** Their elements carry no identity, so
390
- an insertion at the front would make every later index look changed. This is the
391
- same rule the collection comparator follows for records it cannot identify.
392
-
393
- **An absent key is not a null one.** `{"a": null}` and `{}` mean different things
394
- in JSON, and an audit trail that showed them alike would be lying about one of
395
- them, so a missing key reads as `PaperTrailDiff::NestedComparator::ABSENT`
396
- rather than `nil`.
397
-
398
- ### Reuse already-preloaded current endpoints
399
-
400
- `compare`, `compare_many`, and `activity_timeline(..., to: record)` reload
401
- current endpoints by default. A caller that already owns a consistent, fully
402
- preloaded graph may opt out. Each endpoint is loaded once per call, so a batch
403
- that resolves every root up front and then reads one of them again pays
404
- nothing the second time, and every root in one result reflects the same instant.
405
-
406
- The option is not accepted by `timeline`, `analyze`, or `analyze_many`. Those
407
- are bounded by versions and read live state only under
408
- [`close_on: :current`](#reporting-up-to-now), which always reloads:
409
-
410
- ```ruby
411
- orders = Order.where(id: order_ids).preload(line_items: :product).to_a
412
- # `first_versions` is the same bulk endpoint lookup shown above.
413
-
414
- diffs = PaperTrailDiff.compare_many(
415
- orders.map do |order|
416
- { from: first_versions.fetch(order.id), to: order }
417
- end,
418
- associations: ["line_items.product"],
419
- reload_live_endpoints: false
420
- )
421
85
  ```
422
86
 
423
- With `reload_live_endpoints: false`, scalar values and association targets come
424
- from the supplied instances. Every requested association path must already be
425
- loaded; otherwise `PaperTrailDiff::UnloadedAssociationError` is raised before
426
- normalization instead of silently issuing an N+1 query. The caller is
427
- responsible for the consistency and freshness of that in-memory graph.
87
+ ## 2. Show who changed a record and its comments
428
88
 
429
- ### Runtime performance diagnostics
430
-
431
- The gem never prints or logs automatically. It emits these
432
- `ActiveSupport::Notifications` events so applications can choose their logger
433
- and formatting:
434
-
435
- - `compare.paper_trail_diff`
436
- - `compare_many.paper_trail_diff`
437
- - `activity_timeline.paper_trail_diff`
438
- - `load_live_endpoints.paper_trail_diff`
439
- - `prepare_history.paper_trail_diff`
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.
440
93
 
94
+ <!-- executable:recipe-activity -->
441
95
  ```ruby
442
- ActiveSupport::Notifications.subscribe(/\.paper_trail_diff\z/) do |name, start, finish, _id, payload|
443
- Rails.logger.debug(
444
- event: name,
445
- duration_ms: ((finish - start) * 1_000).round(1),
446
- **payload
447
- )
448
- end
449
- ```
450
-
451
- The payloads contain counts, model names, association paths, and reload mode,
452
- not endpoint objects or record attributes. The activity-timeline event wraps
453
- the complete call and includes `step_count`, so its notification duration is
454
- the user-visible runtime. To inspect the exact SQL generated by one report,
455
- scope a standard `sql.active_record` subscriber around it:
456
-
457
- ```ruby
458
- callback = proc do |_name, _start, _finish, _id, payload|
459
- Rails.logger.debug(payload[:sql]) unless payload[:cached] || payload[:name] == "SCHEMA"
460
- end
461
-
462
- ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do
463
- 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
464
102
  end
465
- ```
466
-
467
- ## Analyze many records over one window
468
-
469
- `analyze_many` answers "what changed for these records during this period" for a
470
- whole collection, selecting every root's versions and preparing their selected
471
- association history once for the batch:
472
-
473
- ```ruby
474
- results = PaperTrailDiff.analyze_many(
475
- Order.where(status: "open").to_a,
476
- within: Time.zone.parse("2026-08-01")...Time.zone.parse("2026-09-01"),
477
- associations: [:line_items]
478
- )
479
-
480
- results.fetch(["Order", order.id.to_s]).diff # net change across the window
481
- results.fetch(["Order", order.id.to_s]).timeline # its checkpoint steps
482
- ```
483
-
484
- Results are a frozen hash keyed by `[item_type, item_id]` strings, and each
485
- value is the same `Analysis` that `analyze` returns for one record. A root with
486
- no versions inside the window gets an empty `Analysis` rather than raising, so a
487
- listing page needs no special case. Root identities must be unique.
488
-
489
- ### Letting a relation choose the roots
490
-
491
- Assembling that array means querying `versions` for which roots moved, filtering,
492
- and loading the records — the same selection this gem already performs.
493
- `analyze_scope` does it for you:
494
-
495
- ```ruby
496
- analyses, unreachable = PaperTrailDiff.analyze_scope(
497
- Order.where(status: "open"),
498
- within: Time.zone.parse("2026-08-01")...Time.zone.parse("2026-09-01"),
499
- associations: [:line_items],
500
- limit: 500
501
- )
502
- ```
503
-
504
- `analyses` is the same frozen hash `analyze_many` returns. It is also available
505
- as `PaperTrailDiff.analyze_many(scope: ..., limit: ...)`, and it accepts a model
506
- class (`Order`) as readily as a relation. Every other option — `within:`,
507
- `version_scope:`, `associations:`, `ignore:`, `activity:`, `close_on:` — behaves
508
- exactly as it does for `analyze_many`, and the query cost stays flat in the
509
- number of roots.
510
-
511
- Two things to know before using it.
512
-
513
- **`limit:` is required, and exceeding it raises.** Selection moves into the gem
514
- here, so the bound on how much work one page can ask for has to move with it. It
515
- refuses rather than truncating, because an audit report that is quietly short is
516
- worse than one that fails.
517
-
518
- **`unreachable` names roots the relation could not reach.** A relation's
519
- conditions are evaluated against the live table, so a root destroyed during the
520
- window cannot be tested against them at all — its history is intact, and the
521
- state it held when it was destroyed may well have matched:
522
-
523
- ```ruby
524
- unreachable # => [["Order", "412"]]
525
- ```
526
-
527
- These are reported rather than dropped so that a page auditing deletions is told
528
- where to look instead of silently coming up short. `analyze_many` requires live
529
- records by design, so to analyze those roots reach for `activity_timeline`, which
530
- reads history without needing current state.
531
-
532
- Note also that a relation selects on **current** state, not on state during the
533
- window. `where(status: "open")` means open *now*, which is a different set from
534
- what was open while the window was open. If you need the historical population,
535
- select on the version history instead and pass the records.
536
-
537
- ### Reporting on a subset of root mutations
538
-
539
- `version_scope:` narrows which root versions count as *selected mutations*, so a
540
- checkpoint timeline reports only some of them:
541
-
542
- ```ruby
543
- user_edits = ->(scope) { scope.where.not(whodunnit: nil) }
544
-
545
- PaperTrailDiff.analyze_many(articles, within: window, version_scope: user_edits)
546
- PaperTrailDiff.timeline(article, within: window, version_scope: user_edits)
547
- ```
548
-
549
- The hook receives the version relation for the range and returns a narrowed one.
550
- It is accepted by `timeline`, `activity_timeline`, `analyze`, and
551
- `analyze_many`, with any range form.
552
-
553
- **It sees root versions only.** Someone who edited only comments or tags has no
554
- root versions to select, so filtering to them yields an empty result even though
555
- they changed plenty. For "what did this person change?", reach for
556
- [one person's changes](#reporting-on-one-persons-changes) instead — this hook
557
- answers the different question of which *root checkpoints* a timeline reports.
558
-
559
- It filters *selected mutations only*. Versions the filter excludes are still
560
- loaded, because a version records the state before its own event: without the
561
- one that follows a selected change, whatever that change produced cannot be
562
- shown at all. Those extra versions are reconstruction context, so their own
563
- changes are never attributed to a selected mutation.
564
-
565
- Each selected mutation is bounded by the version that immediately followed it,
566
- not by the next selected one. So each step's diff is exactly what that mutation
567
- did, and it reads the same however many excluded changes happen to follow it:
568
-
569
- ```
570
- versions system → alice → system → bob → system
571
- reported alice: what alice changed bob: what bob changed
572
- ```
573
-
574
- Given a user edit followed by a system edit, filtering to user changes yields
575
- one step running from the user version to the system version, whose diff is
576
- exactly the user's change. A selected mutation that nothing follows yet is not
577
- reported, since no version records the state it produced — the same blind spot
578
- an unfiltered timeline has at its `to:` boundary. A selected *destruction* is
579
- the exception: nothing can follow one, but the absence it leaves is what it
580
- produced, so `activity_timeline` still closes on it. A root with no selected
581
- mutation reports an empty `Analysis` rather than raising.
582
-
583
- The hook applies to root versions only. Under `activity: true` the filter
584
- decides where the span starts and ends, and `timeline` within that span reports
585
- only selected mutations, but `activity_timeline` still lists every boundary
586
- inside it — dropping one would fold the change it carried into a neighbouring
587
- step and credit it to whoever made that one.
588
-
589
- Omit `within:` to analyze each root's whole recorded history instead, which is
590
- the batched equivalent of `analyze(record, from: :first, to: :last)`. Explicit
591
- version endpoints are not accepted, because a single pair cannot mean the same
592
- thing for every root.
593
-
594
- Query cost is flat in the number of roots for the diff and timeline views:
595
- selecting versions and preparing history are both shared across the batch. On a
596
- twenty-root batch that is 7 queries against 120 for the same work done one
597
- record at a time. Passing `activity: true` also works and returns the same
598
- results, but discovering descendant events is inherently per-root, so that view
599
- batches far less.
600
-
601
- Roots are supplied as live records, so a root deleted inside the window cannot
602
- be included; use `activity_timeline` for a history that ends in a deletion.
603
-
604
- ### Reporting on one person's changes
605
-
606
- An activity timeline already carries this. Every boundary in the span becomes a
607
- step, and a step's diff is exactly what the event at its `from_boundary` did, so
608
- that boundary's `whodunnit` is who made the change:
609
-
610
- <!-- executable:readme-person-changes -->
611
- ```ruby
612
- steps = PaperTrailDiff.activity_timeline(
613
- article, within: window, associations: %i[comments tags]
614
- )
615
-
616
- attributed = steps.reject(&:empty?)
617
- by_priya = attributed.select { |step| step.from_boundary.whodunnit == "Priya Shah" }
618
-
619
- by_priya.first.from_boundary.whodunnit # "Priya Shah"
620
- by_priya.first.diff # exactly what that one event changed
621
-
622
- authors = attributed.group_by { |step| step.from_boundary.whodunnit }
623
- .transform_values(&:length)
624
- ```
625
-
626
- This covers descendants, which is what `version_scope:` cannot do. On one demo
627
- history a contributor who only ever touched comments and authorships has four
628
- attributed activity steps and *zero* root steps.
629
-
630
- Filter in Ruby rather than asking the gem to select fewer versions. Attribution
631
- is only correct because every boundary is reconstructed: skip one and the
632
- snapshot carried into the next step is a state the record had already moved
633
- past, so the change lands on the wrong person. There is nothing to save by
634
- filtering earlier — the work is the reconstruction, not the comparison — and a
635
- predicate can say things a version relation cannot, such as several people at
636
- once or "anyone but the importer".
637
-
638
- `reject(&:empty?)` first, because a boundary that changed nothing you selected
639
- still produces a step. The final event in the range has no boundary after it, so
640
- what it produced is not shown — the same blind spot `to:` has everywhere else.
641
-
642
- For "which of these records did someone touch?" across a listing, note that
643
- `analyze_many` batches the diff and timeline views but not descendant discovery.
644
- Querying versions by `whodunnit` and mapping them back to roots yourself will be
645
- much cheaper than an activity pass per record.
646
-
647
- ## Build a root-checkpoint timeline
648
-
649
- `timeline` accepts two version objects from the supplied record's history, or
650
- the symbols `:first` and `:last` when the range is simply the record's whole
651
- recorded history:
652
-
653
- ```ruby
654
- steps = PaperTrailDiff.timeline(article, from: :first, to: :last)
655
- ```
656
-
657
- `:first` and `:last` are resolved by the gem, independently of the order the
658
- `versions` association happens to use, so a caller never has to know it is
659
- sorted. They work anywhere a version does, including mixed with an explicit
660
- one (`from: :first, to: some_version`) and on `activity_timeline` and
661
- `analyze`. A record with no versions has no boundaries to resolve; that is an
662
- empty history rather than a bad request, so the result is an empty timeline
663
- instead of an error — which is usually what an index page wants.
664
-
665
- Combine `from: :first` with `to: article` for the fullest activity view of a
666
- live record: the whole recorded history, ending at current state. See
667
- [activity timelines](#build-an-activity-timeline) for why that end differs from
668
- `to: :last`.
669
-
670
- The range is inclusive, must be chronological, and produces one `Step` for each
671
- adjacent pair:
672
-
673
- ```ruby
674
- steps = PaperTrailDiff.timeline(
675
- article,
676
- from: article.versions[1],
677
- to: article.versions[4]
678
- )
679
-
680
- steps.first.from_version # the original PaperTrail version
681
- steps.first.to_version # the next PaperTrail version
682
- steps.first.from_boundary # immutable presentation metadata
683
- steps.first.to_boundary
684
- steps.first.diff # a PaperTrailDiff::Diff
685
- steps.first.empty? # delegates to the diff
686
- steps.first.to_h
687
- # => { from_version_id: 2, to_version_id: 3, diff: { ... } }
688
- ```
689
-
690
- Both timeline types expose `from_boundary`, `to_boundary`, `diff`, and
691
- `empty?`. A historical boundary has `event`, `whodunnit`, `record`,
692
- `recorded_at`, `version?`, `current?`, and `destroyed?` readers. Checkpoint `Step` objects
693
- also retain their original `from_version` and `to_version` for callers that
694
- need custom PaperTrail metadata. Existing `Step#to_h` and
695
- `ActivityBoundary#to_h` shapes remain unchanged; use the readers for the new
696
- metadata.
697
-
698
- Every version boundary remains in the result, even when its diff is empty after
699
- ignored fields are removed. Equal boundaries return a frozen empty array.
700
-
701
- This is deliberately a root-record timeline. Several child changes between two
702
- root versions appear together in the same step.
703
-
704
- ## Build an activity timeline
705
-
706
- `activity_timeline` merges versions for the root and explicitly selected
707
- descendants. Child changes can therefore form separate steps instead of waiting
708
- to be aggregated at the next root checkpoint:
709
-
710
- ```ruby
711
- steps = PaperTrailDiff.activity_timeline(
712
- article,
713
- from: article.versions[1],
714
- to: article.versions[4],
715
- associations: ["comments.replies", :author]
716
- )
717
-
718
- steps.reject(&:empty?)
719
- ```
720
-
721
- Pass the record explicitly as `to:` to include current state without creating a
722
- final root checkpoint. This is not the same as `to: :last`: descendant
723
- mutations recorded after the record's final root version fall outside a
724
- version-bounded range, so only a live end reports them.
725
-
726
- ```ruby
727
- steps = PaperTrailDiff.activity_timeline(
728
- article,
729
- from: article.versions[1],
730
- to: article,
731
- associations: ["comments.replies", :author]
732
- )
733
- ```
734
-
735
- The result is a frozen array of `ActivityStep` objects. Each step has immutable
736
- `from_boundary`, `to_boundary`, and `diff` readers. Historical boundaries are
737
- `kind: :version`; an explicitly requested live endpoint is `kind: :current`:
738
103
 
739
- ```ruby
740
- steps.last.to_boundary.to_h
741
- # => {
742
- # kind: :current,
743
- # version_id: nil,
744
- # item_type: "Article",
745
- # item_id: 42,
746
- # recorded_at: 2026-08-08 12:00:00 UTC
747
- # }
748
- ```
749
-
750
- A historical boundary may belong to the root or a selected descendant model.
751
- PaperTrail versions describe pre-change state, so a mutation becomes visible
752
- between its version boundary and the next historical or explicit current
753
- boundary. Passing `to: article` is what removes the need to touch the parent
754
- after an ordinary versioned child mutation; current state is still never
755
- implicit.
756
-
757
- ### Reporting one save as one step
758
-
759
- A parent and its children saved together produce a version each, so one
760
- deliberate action arrives as several steps. `group: :transaction` reports it as
761
- one:
762
-
763
- ```ruby
764
- PaperTrailDiff.activity_timeline(
765
- article, from: :first, to: article,
766
- associations: [:comments], group: :transaction
767
- )
768
- ```
769
-
770
- Given a single transaction that retitled an article, revised one comment, and
771
- added another, the ungrouped timeline reports three steps — the title change,
772
- then a step that reads as empty, then the comment changes. Grouped, it reports
773
- one step carrying the title *and* the comment changes, credited to whoever made
774
- the save. Off by default; also accepted by `analyze(activity: true)`.
775
-
776
- The middle step reads as empty for a reason worth knowing, because it is the
777
- same rule the rest of this document turns on. A version records the state
778
- *before* its own event, so a child's new value is revealed only by something
779
- later: a further version of that child, its destroy version, or the live row.
780
- Inside a transaction there is usually none of those yet, so a change made at
781
- that boundary surfaces a step or more afterwards, folded in with whatever that
782
- step carried. Nothing is lost — but "when" and "with what" both read wrong.
783
-
784
- Two properties this relies on:
785
-
786
- **A step belongs to the transaction of the event that opens it.** That is the
787
- event whose change the step reports, so grouping on the closing boundary instead
788
- would credit each change to whoever made the next one.
789
-
790
- **Merging compares the group's outer states**, rather than combining the diffs
791
- between them. A field set and then restored inside one transaction has not
792
- changed, and only comparing the endpoints can say so.
793
-
794
- A boundary that records no transaction groups with nothing. PaperTrail leaves
795
- `transaction_id` nil outside a transaction, and a custom version class need not
796
- carry the column at all; treating those as one shared transaction would merge
797
- unrelated history into a single step. `ActivityBoundary#transaction_id` exposes
798
- what a boundary recorded.
799
-
800
- ### Reading the state behind a step
801
-
802
- A step's diff carries what changed. A renderer often needs what did *not* — to
803
- say whose comment was edited, it needs the comment's author, which the diff has
804
- no reason to mention. `snapshots: true` retains the reconstructed states each
805
- step was compared between:
806
-
807
- ```ruby
808
104
  steps = PaperTrailDiff.activity_timeline(
809
- article, from: :first, to: article, associations: [:comments], snapshots: true
105
+ article, associations: [:comments], group: :transaction, snapshots: true
810
106
  )
811
107
 
812
- step = steps.reject(&:empty?).last
813
- step.from_snapshot.associations["comments"].records.first.attributes["author"]
814
- ```
815
-
816
- The gem already builds these states to compute each diff and otherwise discards
817
- them, so asking for them costs no extra queries. Reconstructing them yourself
818
- from the version table means reimplementing boundary ordering, and getting it
819
- wrong is quiet rather than loud.
820
-
821
- They are off by default because each one holds the whole selected graph, and
822
- retaining one per step keeps the entire timeline's graph alive. A step whose
823
- `from_boundary` is a `create` version has a `nil` `from_snapshot`: the record
824
- did not exist yet, which is the answer rather than a missing one.
825
-
826
- `analyze(activity: true, snapshots: true)` populates the same fields on the
827
- `activity_timeline` it returns.
828
-
829
- ### Closing a destroyed root
830
-
831
- A `destroy` version is the one boundary whose following state needs no later
832
- version: the event itself says the record is gone. When an activity timeline
833
- ends at the root's own `destroy` version, it therefore closes with one more
834
- step, from that version to a `kind: :destroyed` boundary, whose diff is a
835
- `record_presence_change` from the record's final state to `nil`:
836
-
837
- ```ruby
838
- steps = PaperTrailDiff.activity_timeline(
839
- article,
840
- from: article.versions.first,
841
- to: article.versions.last # a destroy version
842
- )
843
-
844
- removal = steps.last
845
- removal.to_boundary.destroyed? # => true
846
- removal.to_boundary.kind # => :destroyed
847
- removal.diff.record_presence_change.from # the state it was deleted in
848
- removal.diff.record_presence_change.to # => nil
849
- ```
850
-
851
- The removal step's `from_boundary` is the ordinary `kind: :version` boundary
852
- for the same destroy version, since that boundary still holds the record.
853
- Boundaries therefore have three kinds — `:version`, `:current`, and
854
- `:destroyed` — so a consumer that branches on `version?` alone should also
855
- handle `destroyed?`.
856
-
857
- `analyze(activity: true)` reports the same closing step in its
858
- `activity_timeline`. Its `diff` and `timeline` keep their `compare` and
859
- `timeline` semantics and do not report the deletion.
860
-
861
- A `within:` window behaves the same way. A window whose last selected mutation
862
- is the root's destruction needs no later root version, because none can ever
863
- exist, so it closes on the removal instead of raising
864
- `IncompleteTimeRangeError`. When the destruction falls *outside* the window it
865
- remains ordinary reconstruction context and is not reported as a selected
866
- mutation. The same relaxation lets the plain `timeline` accept such a window,
867
- though it still reports only the edits.
868
-
869
- Live-ended HABTM activity is rejected with
870
- `PaperTrailDiff::UnsupportedLiveActivityError`. HABTM join rows have no model
871
- versions, so the gem cannot reliably split their membership mutations into
872
- intermediate events without transaction-backed owner checkpoints. Historical
873
- HABTM activity and `compare(version, article, associations: [:tags])` remain
874
- supported where the historical endpoint has usable PT-AT metadata.
875
-
876
- Both timeline APIs preserve empty boundaries. A display may use
877
- `steps.reject(&:empty?)`, while audit-oriented callers can retain every
878
- recorded boundary.
879
-
880
- ## Select mutations by time
881
-
882
- `timeline`, `activity_timeline`, and `analyze` also accept a finite time range
883
- through `within:` instead of explicit `from:` and `to:` versions. Half-open
884
- ranges are recommended for adjacent reporting windows:
885
-
886
- ```ruby
887
- window = Time.zone.parse("2026-08-01")...Time.zone.parse("2026-09-01")
888
-
889
- steps = PaperTrailDiff.timeline(article, within: window)
890
-
891
- activity_steps = PaperTrailDiff.activity_timeline(
892
- article,
893
- within: window,
894
- associations: ["comments.replies", :author]
895
- )
896
-
897
- analysis = PaperTrailDiff.analyze(
898
- article,
899
- within: window,
900
- associations: ["comments.replies"],
901
- activity: true
902
- )
903
- ```
904
-
905
- The range selects mutations by the timestamp of their source PaperTrail
906
- version. Ruby's inclusive (`..`) and exclusive (`...`) end semantics are
907
- honored. A returned step's `to_boundary` may be after the range: because a
908
- PaperTrail version is a pre-change snapshot, the gem needs one later root
909
- version to reveal the final selected mutation. That version is reconstruction
910
- context, not an additional selected mutation.
911
-
912
- If the window contains a relevant mutation but no later root version exists,
913
- the call raises `PaperTrailDiff::IncompleteTimeRangeError`. The gem does not
914
- silently substitute current database state; pass
915
- [`close_on: :current`](#reporting-up-to-now) to ask for it, or create a root
916
- checkpoint after the reporting window. A root-only window with no selected
917
- mutation returns a frozen empty timeline. The one exception is a window that
918
- closes on the root's own destruction, which no later version can ever follow;
919
- see [closing a destroyed root](#closing-a-destroyed-root).
920
-
921
- ### Reporting up to now
922
-
923
- A window whose end is the present has nothing recorded after its final
924
- mutation, so by default it raises. `close_on: :current` closes it on the live
925
- record instead:
926
-
927
- ```ruby
928
- PaperTrailDiff.analyze_many(
929
- articles, within: month_start..Time.current, close_on: :current
930
- )
931
- ```
932
-
933
- It is accepted by `timeline`, `activity_timeline`, `analyze`, and
934
- `analyze_many`, and only alongside `within:` — a range whose endpoints you gave
935
- explicitly already says where it ends. The closing step's `to_boundary.kind` is
936
- `:current` and its `to_version` is `nil`, so read the boundary rather than the
937
- version on the last step. Under `activity: true` the window runs to the instant
938
- state is captured rather than to the last root version, so a descendant that
939
- moved after that version is still reported.
940
-
941
- Two things follow from closing on live state. Current state includes changes
942
- PaperTrail never recorded — an `update_columns` write shows up in that final
943
- step, attributed to the version that precedes it, because nothing else records
944
- who made it. And a destroyed root has no current state to close on, so it keeps
945
- closing on its own destruction as it already did.
946
-
947
- Time ranges and explicit `from:`/`to:` endpoints are mutually exclusive.
948
- Malformed, open-ended, or reversed ranges raise
949
- `PaperTrailDiff::InvalidTimeRangeError`. With `activity_timeline`, selected
950
- descendant versions are included even when no root mutation occurred inside
951
- the window; the later root checkpoint is still required so PT-AT can
952
- reconstruct the enclosing graph and determine whether descendant activity is
953
- present. The ordinary `timeline` remains a root-only checkpoint timeline.
954
-
955
- ## Build an endpoint and timeline together
956
-
957
- When a caller needs the net endpoint diff and root timeline, `analyze`
958
- normalizes each selected version once. Pass `activity: true` when the same view
959
- also needs descendant-aware activity. The endpoint diff and root timeline are
960
- then derived from the same activity snapshot pass instead of reconstructing the
961
- root history separately:
962
-
963
- ```ruby
964
- analysis = PaperTrailDiff.analyze(
965
- article,
966
- from: article.versions[1],
967
- to: article.versions[4],
968
- associations: ["comments.replies"],
969
- activity: true
970
- )
971
-
972
- analysis.diff
973
- analysis.timeline
974
- analysis.activity_timeline
975
- ```
976
-
977
- Without `activity: true`, `analysis.activity_timeline` is `nil` and no activity
978
- work is performed. `analyze` accepts explicit historical versions or
979
- `within:`, but not a current-record endpoint; use the standalone
980
- `activity_timeline(..., to: article)` API when the final boundary must be live.
981
-
982
- ### Choosing between `timeline` and `analyze(activity: true)`
983
-
984
- `analysis.timeline` and `timeline` return the same root-checkpoint steps for the
985
- same range. They are two reconstruction strategies for one result, not two
986
- levels of detail: a `timeline` step covers only root version boundaries, but
987
- each of its snapshots still contains every selected association, because a step
988
- must report what changed underneath the root as well.
989
-
990
- The one behavioural difference is at the edge of a `within:` window. Selected
991
- descendants can move inside a window that contains no root version at all, so
992
- the activity form requires a root boundary it can reconstruct from and raises
993
- `IncompleteTimeRangeError` when there is none. `timeline` has no activity view
994
- to anchor and returns no steps for that window.
995
-
996
- The strategies differ in what that costs:
997
-
998
- - `timeline` reconstructs the whole selected graph independently at every root
999
- boundary, so it costs roughly *root versions x selected graph size*. It is
1000
- insensitive to how much descendant activity happened in between.
1001
- - `analyze(activity: true)` reconstructs once and then advances that snapshot
1002
- through each recorded mutation, so it costs roughly *one reconstruction +
1003
- total events*. It is insensitive to how wide the selected graph is.
1004
-
1005
- Neither dominates. Reconstructing once per checkpoint wins when a few root
1006
- versions span very heavy descendant churn; advancing incrementally wins when
1007
- the selected graph is wide and descendant activity is comparable to root
1008
- activity. As a rule of thumb, prefer `analyze(activity: true)` when selected
1009
- associations are wide, and `timeline` when descendant events greatly outnumber
1010
- root versions. Measure with `ActiveSupport::Notifications` on a representative
1011
- history rather than a seeded example if the choice matters.
1012
-
1013
- `timeline`, `activity_timeline`, and both forms of `analyze` prepare the selected
1014
- historical range once. The loader walks only the explicit association paths and
1015
- builds an immutable temporal index of scalar states, relationship candidates,
1016
- HABTM transaction snapshots, and live fallbacks. Direct relationships,
1017
- `has_many :through` with a `belongs_to` source, and transaction-backed HABTM
1018
- membership are resolved from that index. Endpoint-only `compare` retains the
1019
- lighter two-point reconstruction path.
1020
-
1021
- Activity reconstruction then carries immutable snapshots forward between
1022
- boundaries. Isolated root and descendant events with usable serialized changes
1023
- advance only the affected immutable nodes. Events sharing a PT-AT transaction
1024
- refresh their combined branches atomically. Scoped associations, unversioned
1025
- targets, composite relationship keys, collection-source through associations,
1026
- and other unsupported shapes fall back to the ordinary PT-AT point reifier on
1027
- a per-reflection basis. This hybrid path preserves existing reconstruction
1028
- behavior while avoiding repeated association queries for supported paths.
1029
-
1030
- For supported collection events, adjacent snapshots share an immutable
1031
- identity-position index and carry a one-step transition hint. Diffing therefore
1032
- visits only the changed member instead of hashing the whole collection again.
1033
- Prepared scalar history also exposes the predecessor and successor attributes
1034
- for isolated update events, allowing activity reconstruction to update the
1035
- immutable snapshot directly instead of reifying and deserializing the same
1036
- PaperTrail event again. Compatible scalar payloads are decoded into prepared
1037
- state without first constructing a disposable Active Record object. Encrypted,
1038
- schema-mismatched, missing, ambiguous, or identity-changing states fall back to
1039
- the ordinary event reifier.
1040
- For a direct nested `has_many` such as `comments.replies`, the child's foreign
1041
- key also locates its parent snapshot directly rather than walking every
1042
- comment. Membership changes and ambiguous or unsupported relationship shapes
1043
- retain the general comparator and traversal fallback.
1044
-
1045
- Activity event loading is bounded to the selected range. Association identity
1046
- discovery retains one indexed checkpoint for members present at the starting
1047
- boundary, then considers later association activity and current members; it no
1048
- longer materializes every pre-range association row. Because a PaperTrail
1049
- version is a pre-change snapshot, the state at a range's final boundary can
1050
- live only in the next version after it, so prepared scalar state retains one
1051
- trailing version per selected identity. It does not retain the rest of the
1052
- history recorded after the range, so a short range early in a long history
1053
- costs the same as the same range in a short one. Keep requested paths and
1054
- ranges intentional. An activity timeline must also emit a diff for every selected
1055
- event. Repeated events within one wide collection still copy the frozen records
1056
- array when producing each immutable snapshot, so they can do pointer-copying
1057
- work proportional to the number of events times the collection width even when
1058
- SQL and Ruby-level comparison work stay linear. Bound or paginate unusually
1059
- wide activity ranges in latency-sensitive requests.
1060
-
1061
- For large histories, applications should give the database a matching
1062
- composite index. A typical PaperTrail installation can add one without making
1063
- it a requirement of this gem:
1064
-
1065
- ```ruby
1066
- add_index :versions, %i[item_type item_id created_at id],
1067
- name: "index_versions_on_item_and_timeline"
1068
- ```
1069
-
1070
- This index is especially useful for repeated `within:` queries because root
1071
- selection is constrained by `item_type`, `item_id`, and `created_at`, then
1072
- ordered deterministically by timestamp and version ID.
1073
-
1074
- PT-AT's normal index beginning with `foreign_key_name`, `foreign_key_id`, and
1075
- `foreign_type` should also be retained on `version_associations`.
1076
-
1077
- ## Ignore noise fields
1078
-
1079
- The comparison APIs ignore `updated_at` by default. `ignore:` replaces that default and
1080
- accepts string or symbol names. The array form applies to the root and every
1081
- selected association:
1082
-
1083
- ```ruby
1084
- PaperTrailDiff.compare(from, to, ignore: %i[updated_at lock_version])
1085
- PaperTrailDiff.compare(from, to, ignore: []) # compare every scalar field
1086
- ```
1087
-
1088
- For exact path control, pass `all:` plus `paths:`. `$` identifies the root:
1089
-
1090
- ```ruby
1091
- PaperTrailDiff.compare(
1092
- from,
1093
- to,
1094
- associations: ["comments.replies"],
1095
- ignore: {
1096
- all: [:updated_at],
1097
- paths: {
1098
- "$" => [:lock_version],
1099
- "comments.replies" => [:delivery_state]
1100
- }
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
1101
113
  }
1102
- )
1103
- ```
1104
-
1105
- Hash rules replace the default just like an array. `all:` applies everywhere;
1106
- each path entry applies only at that exact path, not its descendants. Ignore
1107
- paths must be `$` or one of the selected or implicitly selected association
1108
- paths.
1109
-
1110
- Primary keys are always represented as record identity rather than scalar
1111
- attributes.
1112
-
1113
- ## Compare associations
1114
-
1115
- Pass explicit names or dot-separated paths. Paths are finite traversal plans,
1116
- not a request to recursively inspect the whole object graph. Ancestors are
1117
- selected implicitly:
1118
-
1119
- ```ruby
1120
- diff = PaperTrailDiff.compare(
1121
- from,
1122
- to,
1123
- associations: [:author, :profile, "comments.replies.author"]
1124
- )
1125
-
1126
- diff.associations["author"].relationship
1127
- # => #<PaperTrailDiff::ValueChange ...> # belongs_to replacement
1128
-
1129
- comments = diff.associations["comments"]
1130
- comments.added # full RecordSnapshot objects
1131
- comments.removed # full RecordSnapshot objects
1132
- comments.changed # RecordChange objects for stable identities
1133
-
1134
- replies = comments.changed.first.associations["replies"]
1135
- replies.added
1136
- replies.removed
1137
- replies.changed
1138
- ```
1139
-
1140
- For `belongs_to` and `has_one`, replacing the related identity is a
1141
- `relationship` change. Updating attributes on the same related identity is a
1142
- `changed` record. For `has_many` and `has_and_belongs_to_many`, membership is
1143
- split into `added` and `removed`; shared identities with scalar changes appear
1144
- in `changed`. The result preserves the reflected macro in `kind`.
1145
-
1146
- Both Rails many-to-many forms are supported. `has_many :through` uses PT-AT's
1147
- history for the versioned join model. HABTM reports membership and target
1148
- attribute changes, but it cannot report join attributes because HABTM has no
1149
- join model.
1150
-
1151
- The same structure repeats at every selected depth. `RecordChange#associations`
1152
- contains nested changes for a stable parent identity. Added and removed record
1153
- snapshots retain their selected subtrees so nested state is not discarded.
1154
-
1155
- Selecting a `belongs_to` removes its foreign-key (and polymorphic type) column
1156
- from scalar changes at that path. Without association selection, that column
1157
- remains a normal scalar attribute. A direct `has_one` or `has_many` edge also
1158
- removes its incoming foreign key from the child snapshot because membership is
1159
- already represented structurally. Join-model attributes for `has_many :through`
1160
- remain available. Cyclic model relationships are safe because only the finite
1161
- paths supplied by the caller are traversed.
1162
-
1163
- Requesting associations for any historical endpoint without loaded and enabled
1164
- PT-AT raises `PaperTrailDiff::AssociationTrackingUnavailableError`. A comparison
1165
- whose endpoints are both live records can normalize associations without PT-AT,
1166
- though it will normally be empty because both records reload the same current
1167
- database state. Unknown names and unsupported macros raise explicit
1168
- `PaperTrailDiff::Error` subclasses.
1169
- Malformed public options raise `PaperTrailDiff::ConfigurationError`, also under
1170
- that base error.
1171
-
1172
- ## Traverse result trees
1173
-
1174
- The nested tree remains the canonical, lossless result. For renderers,
1175
- counters, exports, and notifications, `Diff#each_change` provides a
1176
- deterministic depth-first stream of semantic changes:
1177
-
1178
- <!-- executable:readme-traversal-changes -->
1179
- ```ruby
1180
- diff.each_change do |entry|
1181
- entry.kind # :attribute_changed, :record_added, ...
1182
- entry.association_path # ["comments", "replies"]
1183
- entry.record_path # frozen RecordReference objects
1184
- entry.association_kind # :has_many, :belongs_to, ...
1185
- entry.attribute # "body" for an attribute entry
1186
- entry.value # ValueChange, RecordChange, or RecordSnapshot
1187
114
  end
1188
115
 
1189
- counts = diff.each_change.map(&:kind).tally
116
+ payload = steps.map { |step| step.to_h(metadata: true) }
1190
117
  ```
1191
118
 
1192
- `record_changed` entries are emitted before that record's attribute and nested
1193
- association changes, allowing an application to count both changed records and
1194
- changed fields. Singular relationship operations are classified separately as
1195
- `relationship_added`, `relationship_removed`, or `relationship_replaced`.
1196
- Root create/delete transitions remain `record_presence_changed`.
1197
-
1198
- Added, removed, and replaced records carry complete bounded snapshots. Use
1199
- `each_entry` when a renderer also needs that nested state without writing a
1200
- second snapshot walker:
1201
-
1202
- <!-- executable:readme-traversal-entries -->
1203
- ```ruby
1204
- diff.each_entry do |entry|
1205
- next unless entry.included_state?
1206
-
1207
- entry.kind # :record_included, :attribute_included, :association_included
1208
- entry.state # :before or :after
1209
- entry.context # :included_state
1210
- end
1211
- ```
1212
-
1213
- An included record is historical context, not evidence that the nested record
1214
- changed independently. `each_change` therefore omits included-state entries.
1215
- Both methods return an `Enumerator` when no block is supplied and return the
1216
- diff when called with a block. Entries and their paths are immutable and have
1217
- deterministic `to_h` output. The root location uses empty association and record
1218
- paths; descendant `record_path` values contain only the explicitly traversed
1219
- descendant identities.
1220
-
1221
- Traversal is path-preserving and does not deduplicate. If the same record is
1222
- reachable through two selected paths, it appears at both locations so the
1223
- consumer can choose whether path identity or record identity controls counting.
1224
- Walking a result never performs database access and never discovers additional
1225
- associations.
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.
1226
124
 
1227
- ## Discover and diagnose 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.
1228
128
 
1229
- Configuration UIs can use bounded public reflection instead of duplicating the
1230
- gem's macro and cycle logic:
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.
1231
135
 
1232
- ```ruby
1233
- PaperTrailDiff.supported_association_macros
1234
- PaperTrailDiff.association_paths(Article, max_depth: 3).map(&:to_h)
1235
- ```
1236
-
1237
- Descriptors include `path`, `kind`, `target_type`, `through`, `polymorphic`, and
1238
- `cycle`. Cycles are marked and not descended; PaperTrail's infrastructure
1239
- `versions` association is excluded.
136
+ ## 3. Report on records changed since a given time
1240
137
 
1241
- 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:`.
1242
141
 
142
+ <!-- executable:recipe-report -->
1243
143
  ```ruby
1244
- report = PaperTrailDiff.diagnose(
1245
- from_version,
1246
- to_version,
1247
- associations: [:tags, "comments.replies"]
144
+ report = PaperTrailDiff.report(
145
+ Article.all, since: 1.day.ago, limit: 100, include_deleted: true
1248
146
  )
1249
147
 
1250
- report.ok?
1251
- report.errors.map(&:code)
1252
- report.warnings.map(&:code)
1253
- ```
1254
-
1255
- `ok?` means no *errors* were found among the checks that ran, not that every
1256
- possible hazard was ruled out. Association checks need `associations:` to have
1257
- something to inspect; without it only the version-order check runs, which is
1258
- why that one runs whether or not associations are selected — an unorderable
1259
- history corrupts a scalar timeline just as thoroughly.
1260
-
1261
- Diagnostics are read-only guidance, not proof that arbitrary old data is
1262
- complete. HABTM endpoints without transaction-backed association snapshots fail
1263
- loudly with `PaperTrailDiff::IncompleteAssociationHistoryError` during normal
1264
- comparison as well.
1265
-
1266
- ### Recommended checkpoint recipe
1267
-
1268
- For PT-AT history—especially HABTM—use transaction-backed checkpoints, disable
1269
- PaperTrail's version timestamp synchronization on the root, and checkpoint a
1270
- fresh record instance:
1271
-
1272
- ```ruby
1273
- class Article < ApplicationRecord
1274
- 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
+ }
1275
156
  end
1276
157
 
1277
- def checkpoint_article(article_id)
1278
- Article.transaction do
1279
- Article.find(article_id).paper_trail.save_with_version
1280
- end
1281
- 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
1282
160
  ```
1283
161
 
1284
- A root checkpoint after a batch supplies an immutable historical final state.
1285
- The ordinary `timeline` aggregates that batch; `activity_timeline` uses recorded
1286
- descendant versions to split it into activity boundaries. For an interactive
1287
- view, `activity_timeline(..., to: article)` can instead terminate explicitly at
1288
- current state without touching the root. Prefer checkpoints for reproducible
1289
- audits and HABTM, and prefer an explicit join model with `has_many :through`
1290
- when join attributes or join mutations must appear as first-class history.
1291
-
1292
- ## Result objects
1293
-
1294
- Every level of a result separates the same two kinds of change in the same way:
1295
-
1296
- | | a record appears or disappears | a record stays and its fields change |
1297
- | --- | --- | --- |
1298
- | the root record | `record_presence_change` | `attributes`, `associations` |
1299
- | `has_many`, HABTM | `added`, `removed` | `changed` |
1300
- | `belongs_to`, `has_one` | `relationship` | `changed` |
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.
1301
165
 
1302
- The left column carries whole `RecordSnapshot` values; the right column carries
1303
- field-level `ValueChange` deltas. That split is deliberate. A record that has
1304
- just appeared has no previous value for any of its fields, so reporting one
1305
- `nil` to value change per attribute would both add noise and blur the
1306
- difference between "this field was edited" and "this record did not exist".
1307
-
1308
- The consequence is that reading a result tree directly means branching on which
1309
- column applies: a created record's state is under
1310
- `record_presence_change.to.attributes`, an edited record's is under
1311
- `attributes`. Consumers that would rather not branch should use
1312
- [`each_entry`](#traverse-result-trees), which flattens both into one stream —
1313
- an edit arrives as `attribute_changed` carrying a `ValueChange`, and a created
1314
- record's fields arrive as `attribute_included` entries carrying values with
1315
- `state: :after`.
1316
-
1317
- The public result types are:
1318
-
1319
- - `PaperTrailDiff::Diff`
1320
- - `PaperTrailDiff::Step`
1321
- - `PaperTrailDiff::ActivityStep`
1322
- - `PaperTrailDiff::ActivityBoundary`
1323
- - `PaperTrailDiff::Analysis`
1324
- - `PaperTrailDiff::ValueChange`
1325
- - `PaperTrailDiff::TraversalEntry`
1326
- - `PaperTrailDiff::RecordReference`
1327
- - `PaperTrailDiff::RecordSnapshot`
1328
- - `PaperTrailDiff::RecordChange`
1329
- - `PaperTrailDiff::ToOneAssociationDiff`
1330
- - `PaperTrailDiff::CollectionAssociationDiff`
1331
- - `PaperTrailDiff::AssociationDescriptor`
1332
- - `PaperTrailDiff::DiagnosticReport`
1333
- - `PaperTrailDiff::DiagnosticIssue`
1334
-
1335
- `Analysis` also exposes the reconstructed states its diff was taken between, as
1336
- `from_snapshot` and `to_snapshot`. A report that renders unchanged columns needs
1337
- the whole final state, not only what moved, and these are the states the gem
1338
- already reconstructed:
166
+ `include_deleted: true` also builds reports from PaperTrail's destroy versions.
167
+ Read them from `report.deleted` using the same keys:
1339
168
 
169
+ <!-- executable:recipe-deleted -->
1340
170
  ```ruby
1341
- analysis.to_snapshot.attributes # every selected scalar, changed or not
1342
- 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
1343
175
  ```
1344
176
 
1345
- Either is `nil` when that endpoint has no reconstructable state — most often a
1346
- `from_snapshot` at a `create` boundary, whose pre-change state is the absence of
1347
- the record. Following the precedent set by `Step`, `Analysis#to_h` is unchanged;
1348
- serialize `to_snapshot.to_h` when a serialized form is wanted.
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.
1349
179
 
1350
- They expose readers, are frozen after construction, and provide deterministic
1351
- `to_h` output. Collection results are ordered by record identity: by type, then
1352
- naturally within one id type, so numeric ids sort `2` before `10`. Mixed or
1353
- unusual id types still order totally rather than raising.
180
+ Three selection rules matter:
1354
181
 
1355
- Structural hash keys are symbols; attribute and association
1356
- names are strings. Attribute values retain their Ruby types. `RecordChange#record`
1357
- is a `RecordReference` with `type` and `id` readers. `TraversalEntry#record` and
1358
- `#association` return the final components of their corresponding paths. `Step`
1359
- itself is frozen but intentionally retains the original, potentially mutable
1360
- PaperTrail version objects for metadata access; its new boundary readers provide
1361
- an immutable presentation interface, while `Step#to_h` continues to emit only
1362
- version IDs. `ActivityStep` retains only immutable boundary metadata and
1363
- serializes as `{ from: ..., to: ..., diff: ... }`.
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.
1364
192
 
1365
- ## Historical correctness and limitations
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:`.
1366
198
 
1367
- The output is only as correct and complete as the historical state PaperTrail
1368
- and PT-AT can reconstruct. In particular:
199
+ For a fixed past window, use `analyze_scope(Article.all, within: start_time...end_time,
200
+ limit: 100)`.
1369
201
 
1370
- - attributes skipped by PaperTrail, deleted versions, and changes made while
1371
- versioning was disabled cannot be recovered;
1372
- - model/schema or serializer changes can affect reification of old versions;
1373
- - comparison and activity APIs add live state only when the caller explicitly
1374
- passes a persisted record endpoint; the adapter reloads that record, but
1375
- multiple queries are not automatically one database-isolated snapshot;
1376
- - PT-AT requires its schema, callbacks, versioned child models, and transaction
1377
- metadata; callback-skipping writes may not be reconstructable;
1378
- - HABTM membership is limited to the join snapshots PT-AT recorded in
1379
- `version_associations`; historical target attributes require versioned target
1380
- models, otherwise PT-AT may return live target state;
1381
- - `compare`, `timeline`, and `analyze`'s endpoint diff do not report the root
1382
- record's destruction, because the state recorded at a `destroy` version is the
1383
- state before the deletion; `activity_timeline` closes on a `:destroyed`
1384
- boundary instead, and a selected child's removal is reported by its parent;
1385
- - `timeline` and `analyze` are bounded by root versions; `activity_timeline`
1386
- adds recorded descendant boundaries and may terminate at an explicitly passed
1387
- current record, while a fully historical result still requires a later root
1388
- version because PaperTrail stores pre-change snapshots;
1389
- - HABTM join-table mutations do not have their own model versions and therefore
1390
- cannot become standalone activity boundaries; live-ended HABTM activity is
1391
- rejected, and a versioned join model should be used when that activity matters;
1392
- - activity discovery uses PT-AT association rows and cannot recover descendant
1393
- events whose relevant callbacks or association metadata were never recorded;
1394
- - PT-AT has documented edge cases, especially around some `has_one` histories;
1395
- `paper_trail_diff` respects PT-AT's configured reification error behavior;
1396
- - deeper paths require more historical reconstruction and database work, so
1397
- callers should select only the branches they need;
1398
- - no implicit or unbounded recursive association traversal is performed.
202
+ ## Choose a method
1399
203
 
1400
- Review the [PaperTrail version semantics](https://github.com/paper-trail-gem/paper_trail#3-working-with-versions)
1401
- and [PT-AT limitations](https://github.com/westonganger/paper_trail-association_tracking#limitations)
1402
- before relying on reconstructed history for restoration or compliance work.
1403
-
1404
- ## Development
1405
-
1406
- The project uses mise for its pinned development tools:
1407
-
1408
- ```console
1409
- mise install
1410
- mise exec -- bundle install
1411
- mise exec -- bundle exec rake
1412
- mise exec -- bundle exec rake build
1413
- mise exec -- act push -j quality --matrix ruby:4.0 --matrix paper_trail:17
1414
- ```
1415
-
1416
- The default Rake task runs core specs without PT-AT loaded, association specs in
1417
- a separate process, RuboCop, generated-signature verification, and Steep.
1418
- Selected Ruby blocks in this README and the quickstart are executed in those
1419
- same isolated test sessions; the `<!-- executable:... -->` marker opts a block
1420
- into the appropriate stateful session.
1421
- Inline `#:` annotations in `lib/` generate the committed RBS files under
1422
- `sig/generated/`.
1423
-
1424
- Release tags matching `v*` publish through RubyGems trusted publishing. Before
1425
- tagging, replace `Unreleased` in the changelog with the release date, run the
1426
- full CI workflow, and configure the repository's `release` environment as a
1427
- trusted publisher on RubyGems. Do not store a long-lived RubyGems API key in
1428
- the repository.
1429
-
1430
- ## License
1431
-
1432
- 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).