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/docs/reference.md ADDED
@@ -0,0 +1,977 @@
1
+ # API reference
2
+
3
+ Start with the [README](../README.md) examples or the
4
+ [Rails quickstart](../QUICKSTART.md). This page lists every method and option,
5
+ including ones most applications never need.
6
+
7
+ ## Contents
8
+
9
+ - [How PaperTrail stores history](#how-papertrail-stores-history)
10
+ - [Choosing a method](#choosing-a-method)
11
+ - [Choosing a range](#choosing-a-range)
12
+ - [compare](#compare) and [compare_many](#compare_many)
13
+ - [timeline](#timeline)
14
+ - [activity_timeline](#activity_timeline)
15
+ - [analyze](#analyze)
16
+ - [Reports for many records](#reports-for-many-records):
17
+ `analyze_many`, `analyze_scope`, `report`
18
+ - [Associations](#associations)
19
+ - [Ignoring fields](#ignoring-fields)
20
+ - [Reading results](#reading-results)
21
+ - [JSON columns](#json-columns)
22
+ - [Checking your setup](#checking-your-setup)
23
+ - [Models, schemas, and edge cases](#models-schemas-and-edge-cases)
24
+ - [Instrumentation](#instrumentation)
25
+ - [Errors](#errors)
26
+ - [Limitations](#limitations)
27
+
28
+ ## Words used on this page
29
+
30
+ | Word | Meaning |
31
+ | --- | --- |
32
+ | Root record | The record you ask about, such as the article in `timeline(article)` |
33
+ | Child | An associated record you include with `associations:`, such as a comment |
34
+ | Current state | The record's saved values in the database right now |
35
+ | Snapshot | A read-only copy of a record's data at one point, returned by this gem |
36
+ | Identity | A key with the model's base-class name and the ID as a string, such as `["Article", "42"]` |
37
+ | PT-AT | The optional [paper_trail-association_tracking](https://github.com/westonganger/paper_trail-association_tracking) plugin, needed for association history |
38
+
39
+ ## How PaperTrail stores history
40
+
41
+ Each PaperTrail version stores the record's values from **before** that
42
+ version's change. `version.reify` returns that earlier record. This gem uses
43
+ the same rule: passing a version means "the record as it was before this
44
+ version's change". Passing a saved record means its current state.
45
+
46
+ Take an article created as `"Draft"`, then updated to `"Published"`, then to
47
+ `"Final"`:
48
+
49
+ ```text
50
+ v1 v2 v3 (no version)
51
+ "create" "update" "update"
52
+ | | | |
53
+ ──────●───────────────●─────────────────●─────────────────●──────▶ time
54
+ | | | |
55
+ └─── "Draft" ───┘ | |
56
+ stored in v2 | |
57
+ └─ "Published" ───┘ |
58
+ stored in v3 |
59
+ └──── "Final" ────┘
60
+ only in the table
61
+ ```
62
+
63
+ Three things follow from this:
64
+
65
+ - **A `create` version stands for "no record yet".** Its `reify` returns `nil`.
66
+ Comparing it with a later state reports the record as added
67
+ (`record_presence_change`), not as a list of fields changing from `nil`.
68
+ - **The newest values are in no version.** They exist only in the table. When
69
+ nothing has been recorded after a change, the gem reads the result of that
70
+ change from the current record.
71
+ - **A change shows up between two points, never at one.** That is why the
72
+ timeline methods return steps, each comparing one state with the next.
73
+
74
+ ## Choosing a method
75
+
76
+ | What you need | Method |
77
+ | --- | --- |
78
+ | Net difference between two versions, or a version and the current record | `compare(from, to)` |
79
+ | The same for a list of `{ from:, to: }` pairs, one per record | `compare_many(pairs)` |
80
+ | Each change to one record, version by version | `timeline(record)` |
81
+ | Each change to one record and its chosen associations, with who made it | `activity_timeline(record, associations: ...)` |
82
+ | Net difference and timeline together | `analyze(record)` |
83
+ | The same, plus the activity timeline | `analyze(record, activity: true)` |
84
+ | Analyses for an array of records | `analyze_many(records)` |
85
+ | Analyses for the records a relation selects, over a time window | `analyze_scope(scope, within:, limit:)` |
86
+ | Recent activity through now, for the records a relation selects | `report(scope, since:, limit:)` |
87
+
88
+ `timeline` and `activity_timeline` differ in which versions become steps. Given
89
+ an article with two comment edits between two article versions:
90
+
91
+ ```text
92
+ recorded versions A1 C1 C2 A2 A = Article version
93
+ | | | | C = Comment version
94
+ ────●─────────●───────●─────────●────▶ time
95
+ | | | |
96
+ timeline └───────── 1 step ──────────┘
97
+ both comment changes land inside that one step
98
+
99
+ activity_timeline └── 1 ────┴── 2 ──┴─── 3 ───┘
100
+ each recorded version becomes its own step
101
+ ```
102
+
103
+ Both report the same changes. `activity_timeline` splits them up so each step
104
+ belongs to one recorded event, which is what you need to show who did what.
105
+
106
+ ## Choosing a range
107
+
108
+ `timeline`, `activity_timeline`, and `analyze` cover the record's whole history
109
+ through its current state unless you say otherwise:
110
+
111
+ ```ruby
112
+ PaperTrailDiff.timeline(article) # all of it
113
+ PaperTrailDiff.timeline(article, from: some_version) # from a version to now
114
+ PaperTrailDiff.timeline(article, from: :first, to: later_version)
115
+ PaperTrailDiff.timeline(article, within: 1.week.ago..Time.current)
116
+ ```
117
+
118
+ - `from:` defaults to `:first`, the record's earliest version.
119
+ - `to:` defaults to the record itself, meaning its current state. Pass a
120
+ version instead for a result that never changes when the record is edited
121
+ again.
122
+ - Versions must belong to the record and be given in time order.
123
+ - A record with no versions has an empty history, not an error.
124
+
125
+ `:last` is not accepted. It would mean "the state before the latest change",
126
+ which leaves that change out.
127
+
128
+ ### Time windows
129
+
130
+ `within:` selects changes by the time PaperTrail recorded them (`created_at`).
131
+ It cannot be combined with `from:` or `to:`. Ruby's `..` includes the end time
132
+ and `...` excludes it, which is useful for back-to-back windows:
133
+
134
+ ```ruby
135
+ window = Time.zone.parse("2026-08-01")...Time.zone.parse("2026-09-01")
136
+ steps = PaperTrailDiff.timeline(article, within: window)
137
+ ```
138
+
139
+ To show what the window's last change produced, the gem reads the next version
140
+ after the window. If there is none, nothing has been recorded since, so it reads
141
+ the current record instead. The step's `to_boundary.kind` is then `:current`.
142
+ This works the same for a window that ends now and one that ended long ago.
143
+
144
+ Reading the current record has one consequence: it includes changes PaperTrail
145
+ never recorded, such as an `update_columns` call. Those appear in the last step
146
+ of the window.
147
+
148
+ If the window's last change destroyed the record, the window closes on that
149
+ deletion instead. See [a destroyed record](#a-destroyed-record).
150
+
151
+ `close_on: :current` asks for the behavior above explicitly. It is the default
152
+ now and is still accepted.
153
+
154
+ `timeline` returns no steps for a window with no versions of the root record.
155
+ `activity_timeline` still reports chosen associations that changed in such a
156
+ window.
157
+
158
+ ### Reporting only some changes: `version_scope:`
159
+
160
+ `version_scope:` narrows which of the root record's versions are reported. It
161
+ receives the version relation and returns a narrower one:
162
+
163
+ ```ruby
164
+ user_edits = ->(scope) { scope.where.not(whodunnit: nil) }
165
+
166
+ PaperTrailDiff.timeline(article, within: window, version_scope: user_edits)
167
+ ```
168
+
169
+ It is accepted by `timeline`, `activity_timeline`, `analyze`, `analyze_many`,
170
+ and `analyze_scope`.
171
+
172
+ - Each reported change is compared with the version right after it, even if
173
+ that version is filtered out. So each step shows exactly what that one change
174
+ did:
175
+
176
+ ```text
177
+ versions system → alice → system → bob → system
178
+ reported alice: what alice changed bob: what bob changed
179
+ ```
180
+
181
+ - It looks at the root record's versions only. Someone who only edited comments
182
+ has no root versions to select. To find one person's changes, including to
183
+ associations, see [one person's changes](#one-persons-changes).
184
+ - `activity_timeline` uses the filter to decide where the range starts and
185
+ ends, but still lists every step inside it. Dropping one would move its
186
+ change into a neighboring step and credit the wrong person.
187
+
188
+ ## compare
189
+
190
+ `compare(from, to)` returns the net difference between two states. Each side is
191
+ a PaperTrail version or a saved record (its current state):
192
+
193
+ ```ruby
194
+ diff = PaperTrailDiff.compare(article.versions[1], article.versions[4])
195
+ diff = PaperTrailDiff.compare(article.versions.last, article, associations: [:comments])
196
+
197
+ diff.attributes["title"].from # => "Draft"
198
+ diff.attributes["title"].to # => "Published"
199
+ diff.empty? # => false
200
+ diff.to_h
201
+ # => {
202
+ # record_presence_change: nil,
203
+ # attributes: { "title" => { from: "Draft", to: "Published" } },
204
+ # associations: {}
205
+ # }
206
+ ```
207
+
208
+ - Only the two states matter. A field that changed and then changed back is not
209
+ reported.
210
+ - `record_presence_change` is set when the record did not exist on one side,
211
+ for example when `from` is a `create` version. It holds whole snapshots.
212
+ - A `destroy` version stands for the record just before deletion, so comparing
213
+ up to one reports the last edit, not the deletion. `activity_timeline`
214
+ reports deletions; see [a destroyed record](#a-destroyed-record).
215
+ - Two versions must be in time order. Reversed versions raise
216
+ `ReversedEndpointsError`, because the result would look valid with every
217
+ change backwards. A current record can be on either side.
218
+ - Both sides must belong to the same record.
219
+ - A current record must be saved, not destroyed, and have no unsaved changes.
220
+ The gem reloads it, so in-memory edits and cached associations are ignored.
221
+ - Options: `associations:`, `ignore:`, and `reload_live_endpoints:` (see
222
+ [reusing loaded records](#reusing-loaded-records)).
223
+
224
+ ## compare_many
225
+
226
+ `compare_many` compares many pairs at once and shares the database work. Each
227
+ entry has the same `from:` and `to:` as `compare`. The result is a frozen hash
228
+ keyed by identity, in input order:
229
+
230
+ ```ruby
231
+ orders = Order.where(id: order_ids).to_a
232
+
233
+ diffs = PaperTrailDiff.compare_many(
234
+ orders.map { |order| { from: :first, to: order } },
235
+ associations: [:line_items]
236
+ )
237
+
238
+ diffs.fetch(["Order", orders.first.id.to_s]) # => PaperTrailDiff::Diff
239
+ ```
240
+
241
+ - `:first` means the record's earliest version. The gem finds it for every
242
+ record in one query per model. It needs the other side of the pair to name the
243
+ record, so `{ from: :first, to: :first }` raises.
244
+ - A record with no versions gets an empty `Diff`.
245
+ - Each record can appear in only one pair, because results are keyed by record.
246
+ - Options: `associations:`, `ignore:`, `reload_live_endpoints:`, and
247
+ `allow_expensive:` (see [costly associations](#costly-associations)).
248
+
249
+ If you look up versions yourself, do it in bulk. Looking up one record at a
250
+ time brings back the per-record queries this method avoids.
251
+
252
+ ### Reusing loaded records
253
+
254
+ `compare`, `compare_many`, and `activity_timeline` reload current records by
255
+ default. If you already loaded them with every requested association, you can
256
+ skip the reload:
257
+
258
+ ```ruby
259
+ orders = Order.where(id: order_ids).preload(line_items: :product).to_a
260
+
261
+ diffs = PaperTrailDiff.compare_many(
262
+ orders.map { |order| { from: :first, to: order } },
263
+ associations: ["line_items.product"],
264
+ reload_live_endpoints: false
265
+ )
266
+ ```
267
+
268
+ The values then come from your objects, so keeping them fresh is up to you. A
269
+ requested association that is not loaded raises `UnloadedAssociationError`
270
+ instead of quietly running a query per record.
271
+
272
+ ## timeline
273
+
274
+ `timeline(record)` returns one step for each pair of neighboring versions of the
275
+ root record, ending at its current state by default. See
276
+ [choosing a range](#choosing-a-range) for `from:`, `to:`, and `within:`.
277
+
278
+ ```ruby
279
+ steps = PaperTrailDiff.timeline(article)
280
+
281
+ steps.first.diff # a PaperTrailDiff::Diff
282
+ steps.first.empty? # true if nothing reported changed
283
+ steps.first.source_boundary # the version whose change this step shows
284
+ steps.first.to_boundary # the version (or current state) after it
285
+ steps.first.from_version # the original PaperTrail version objects
286
+ steps.first.to_version # nil when the step ends at current state
287
+ steps.reject(&:empty?) # hide steps with no reported changes
288
+ ```
289
+
290
+ - Steps with no reported change are kept, for example when only ignored fields
291
+ changed. Use `reject(&:empty?)` for display.
292
+ - With `associations:`, each step also compares the chosen associations at the
293
+ two root versions. Several child changes between two root versions appear in
294
+ one step. Use `activity_timeline` to split them.
295
+ - `source_boundary` (also available as `from_boundary`) describes the version
296
+ responsible for the step, including `whodunnit`, `event`, and `recorded_at`.
297
+ - `step.to_h` returns `{ from:, to:, diff: }`, the same shape as an activity
298
+ step. `from` and `to` are boundary hashes with `kind`, `version_id`,
299
+ `item_type`, `item_id`, and `recorded_at`. `to_h(metadata: true)` adds author,
300
+ event, transaction ID, and record to each boundary.
301
+ - Options: `from:`, `to:`, `within:`, `associations:`, `ignore:`,
302
+ `version_scope:`, `close_on:`.
303
+
304
+ ## activity_timeline
305
+
306
+ `activity_timeline(record, associations: ...)` returns one step for each
307
+ recorded event of the root record **or** a chosen association. It ends at the
308
+ current record by default.
309
+
310
+ ```ruby
311
+ steps = PaperTrailDiff.activity_timeline(article, associations: ["comments.replies", :author])
312
+
313
+ steps.reject(&:empty?).each do |step|
314
+ step.source_boundary.whodunnit # who made this change
315
+ step.source_boundary.item_type # "Article", "Comment", ...
316
+ step.diff # what that one event changed
317
+ end
318
+ ```
319
+
320
+ - Each step's `diff` is what the event at its `source_boundary` changed. Use
321
+ `source_boundary` for attribution. `to_boundary` is only the next point in
322
+ time and may belong to someone else.
323
+ - Boundaries have a `kind`: `:version`, `:current` (the current record), or
324
+ `:destroyed` (after a deletion). They also have `event`, `whodunnit`,
325
+ `recorded_at`, `item_type`, `item_id`, `transaction_id`, and `record`, and
326
+ the predicates `version?`, `current?`, and `destroyed?`.
327
+ - `step.to_h` returns `{ from:, to:, diff: }`. `to_h(metadata: true)` adds
328
+ author, event, transaction ID, and record. `to_h(snapshots: true)` adds the
329
+ retained snapshots.
330
+ - A child's change becomes visible at the next recorded event, or at the
331
+ current record. You do not need to save the article after each comment change.
332
+ - Options: `from:`, `to:`, `within:`, `associations:`, `ignore:`,
333
+ `version_scope:`, `close_on:`, `reload_live_endpoints:`, `snapshots:`,
334
+ `group:`.
335
+
336
+ ### One person's changes
337
+
338
+ Filter the steps in Ruby by `source_boundary.whodunnit`:
339
+
340
+ <!-- executable:readme-person-changes -->
341
+ ```ruby
342
+ steps = PaperTrailDiff.activity_timeline(
343
+ article, within: window, associations: %i[comments tags]
344
+ )
345
+
346
+ attributed = steps.reject(&:empty?)
347
+ by_priya = attributed.select { |step| step.source_boundary.whodunnit == "Priya Shah" }
348
+
349
+ by_priya.first.source_boundary.whodunnit # "Priya Shah"
350
+ by_priya.first.diff # exactly what that one event changed
351
+
352
+ authors = attributed.group_by { |step| step.source_boundary.whodunnit }
353
+ .transform_values(&:length)
354
+ ```
355
+
356
+ This includes changes to associations, which `version_scope:` cannot see.
357
+ Filter after the call rather than asking the gem for fewer versions: every step
358
+ has to be rebuilt anyway for each one to be correct, so filtering earlier saves
359
+ nothing.
360
+
361
+ For many records, use `report` or `analyze_many(..., activity: true)` instead of
362
+ calling `activity_timeline` in a loop, so the database work is shared.
363
+
364
+ ### One step per transaction: `group: :transaction`
365
+
366
+ Saving an article and its comments together creates one version each, so one
367
+ action shows up as several steps. `group: :transaction` combines steps from the
368
+ same database transaction:
369
+
370
+ ```ruby
371
+ PaperTrailDiff.activity_timeline(article, associations: [:comments], group: :transaction)
372
+ ```
373
+
374
+ - A combined step compares the state before the transaction with the state after
375
+ it. A field set and then restored inside the transaction is not reported.
376
+ - Its `source_boundary` is the first event in the transaction. That event's
377
+ author is not necessarily the author of every grouped change.
378
+ - Versions without a `transaction_id` are never grouped.
379
+ - Also accepted by `analyze`, `analyze_many`, and `analyze_scope` with
380
+ `activity: true`, and by `report`.
381
+
382
+ Without grouping, a step inside a transaction can look empty. A child's new
383
+ value is only visible at the next recorded event, and inside a transaction that
384
+ event is often another part of the same save. Nothing is lost; the change
385
+ appears one step later.
386
+
387
+ ### Keeping snapshots: `snapshots: true`
388
+
389
+ A step's `diff` holds only what changed. To show unchanged fields too, such as
390
+ a comment's author next to an edited comment body, keep the snapshots:
391
+
392
+ ```ruby
393
+ steps = PaperTrailDiff.activity_timeline(article, associations: [:comments], snapshots: true)
394
+
395
+ step = steps.reject(&:empty?).last
396
+ step.from_snapshot.associations["comments"].records.first.attributes["author"]
397
+ ```
398
+
399
+ - The gem builds these snapshots anyway, so keeping them adds no queries. They
400
+ do use memory, because each one contains every chosen association.
401
+ - `from_snapshot` is `nil` for a step starting at a `create` version, because the
402
+ record did not exist yet.
403
+ - Also accepted by `analyze`, `analyze_many`, and `analyze_scope` with
404
+ `activity: true`, and by `report`.
405
+
406
+ ### A destroyed record
407
+
408
+ When the range ends at the record's `destroy` version, the activity timeline
409
+ adds a final step to a `:destroyed` boundary. Its diff reports the record being
410
+ removed:
411
+
412
+ ```ruby
413
+ removal = steps.last
414
+ removal.to_boundary.destroyed? # => true
415
+ removal.diff.record_presence_change.from # the record just before deletion
416
+ removal.diff.record_presence_change.to # => nil
417
+ ```
418
+
419
+ A `within:` window whose last change is the deletion ends the same way, and the
420
+ `diff` of an `analyze` over that window ends at "no record". Removing a chosen
421
+ child is reported as `removed` in its parent's association.
422
+
423
+ ### HABTM limitation
424
+
425
+ `has_and_belongs_to_many` join rows have no versions, so the gem cannot split
426
+ their changes into separate events once a range runs past the last root version
427
+ into the current record. That case raises `UnsupportedLiveActivityError`. End
428
+ the range at a version instead (see the
429
+ [checkpoint recipe](#saving-a-checkpoint)), or use a join model with
430
+ `has_many :through`.
431
+
432
+ ## analyze
433
+
434
+ `analyze(record)` returns an `Analysis` with the net `diff` and the `timeline`
435
+ for one range, reading each version once:
436
+
437
+ ```ruby
438
+ analysis = PaperTrailDiff.analyze(article, associations: ["comments.replies"], activity: true)
439
+
440
+ analysis.diff # net change over the range
441
+ analysis.timeline # the same steps timeline returns
442
+ analysis.activity_timeline # nil unless activity: true
443
+ analysis.from_snapshot # the full state at the start (nil before creation)
444
+ analysis.to_snapshot # the full state at the end
445
+ analysis.to_h(snapshots: true, metadata: true)
446
+ ```
447
+
448
+ - It accepts the same ranges as `timeline`.
449
+ - `activity: true` also builds the activity timeline. `snapshots:` and `group:`
450
+ apply to the activity timeline and require `activity: true`.
451
+ - Options: `from:`, `to:`, `within:`, `associations:`, `ignore:`, `activity:`,
452
+ `version_scope:`, `close_on:`, `snapshots:`, `group:`.
453
+
454
+ ## Reports for many records
455
+
456
+ Use these methods instead of calling a single-record method in a loop. They
457
+ load history for all records together.
458
+
459
+ ### analyze_many
460
+
461
+ `analyze_many(records, ...)` analyzes an array of saved records and returns a
462
+ frozen hash of `Analysis` objects keyed by identity:
463
+
464
+ ```ruby
465
+ results = PaperTrailDiff.analyze_many(
466
+ Order.where(status: "open").to_a,
467
+ within: Time.zone.parse("2026-08-01")...Time.zone.parse("2026-09-01"),
468
+ associations: [:line_items]
469
+ )
470
+
471
+ results.fetch(["Order", order.id.to_s]).diff
472
+ ```
473
+
474
+ - Without `within:`, each record's whole history through its current state is
475
+ analyzed.
476
+ - A record with nothing to report gets an empty `Analysis`.
477
+ - Records must be saved and unique.
478
+ - Options: `within:`, `associations:`, `ignore:`, `activity:`, `version_scope:`,
479
+ `close_on:`, `snapshots:`, `group:`, `allow_expensive:`.
480
+
481
+ ### analyze_scope
482
+
483
+ `analyze_scope(scope, within:, limit:)` lets a model or relation choose the
484
+ records. It returns a `ScopedAnalysis`:
485
+
486
+ ```ruby
487
+ report = PaperTrailDiff.analyze_scope(
488
+ Order.where(status: "open"),
489
+ within: Time.zone.parse("2026-08-01")...Time.zone.parse("2026-09-01"),
490
+ associations: [:line_items],
491
+ limit: 500
492
+ )
493
+
494
+ report.analyses # { identity => Analysis } for records that still exist
495
+ report.roots # the selected records, in relation order
496
+ report.unreachable # identities of selected records that no longer exist
497
+ report.deleted # { identity => Analysis or nil }, with include_deleted: true
498
+
499
+ analyses, unreachable = report # destructures into the first two
500
+ ```
501
+
502
+ It takes the same options as `analyze_many`, plus `limit:`, `historical_filter:`,
503
+ `max_filter_versions:`, and `include_deleted:`. `within:` can be omitted to use
504
+ all history.
505
+
506
+ ### report
507
+
508
+ `report(scope, since:, limit:)` is `analyze_scope` with
509
+ `within: since..Time.now.utc` and `activity: true`. Use it for "what happened
510
+ recently":
511
+
512
+ ```ruby
513
+ report = PaperTrailDiff.report(Article.all, since: 1.day.ago, limit: 100)
514
+ ```
515
+
516
+ It accepts `associations:`, `ignore:`, `snapshots:`, `group:`,
517
+ `include_deleted:`, `historical_filter:`, `max_filter_versions:`, and
518
+ `allow_expensive:`.
519
+
520
+ ### Which records are selected
521
+
522
+ - **A record needs its own version in the window.** Editing only a comment does
523
+ not select its article. Once an article is selected, its comment activity is
524
+ included when you request `associations: [:comments]`.
525
+ - **A relation checks current values.** `Order.where(status: "open")` means open
526
+ now, not open during the window. Use `historical_filter:` to select by past
527
+ values.
528
+ - **`limit:` is a safety ceiling, not a page size.** If more records are
529
+ selected, the call raises `BatchLimitExceededError` instead of returning part
530
+ of the answer. Narrow the relation or the window, or raise the limit.
531
+ - **To paginate, use the relation.** Its `order`, `limit`, and `offset` choose
532
+ the page. Without an `order`, records are taken in ascending ID order. Add an
533
+ ID tie-breaker to any other order. If a join repeats a record, it is analyzed
534
+ once, in order of first appearance.
535
+ - Selection still scans the versions in the window, so keep windows reasonable
536
+ for large tables.
537
+
538
+ ### Deleted records
539
+
540
+ A deleted record cannot be tested against a relation's conditions, because it
541
+ has no row. Records that changed in the window but no longer exist are listed in
542
+ `unreachable`. It is not paginated and does not mean those records matched the
543
+ relation.
544
+
545
+ `include_deleted: true` also analyzes them from their `destroy` versions and
546
+ returns the results in `deleted`, keyed the same way. A value is `nil` when no
547
+ `destroy` version was recorded. With this option, existing and deleted records
548
+ together count toward `limit:`.
549
+
550
+ ```ruby
551
+ missing_history = report.deleted.select { |_identity, analysis| analysis.nil? }.keys
552
+ ```
553
+
554
+ A deleted record's `diff` and `to_snapshot` end at "no record". Its `timeline`
555
+ lists only edits; its `activity_timeline` ends with the deletion.
556
+
557
+ ### Selecting by past values: `historical_filter:`
558
+
559
+ `historical_filter:` selects records by values they had at any point in the
560
+ window. For example, to find articles that were titled `"Published"` at some
561
+ point in the last day, including ones published during it:
562
+
563
+ ```ruby
564
+ report = PaperTrailDiff.report(
565
+ Article.all, since: 1.day.ago, limit: 100,
566
+ historical_filter: { title: "Published" }
567
+ )
568
+ ```
569
+
570
+ The filter checks the record as it was when the window started and after each
571
+ change made inside it. A record matches if any of those states match. With a
572
+ hash, every key must be equal (`==`). Values are read from the stored history
573
+ without calling `reify`, and unknown or unrecorded attributes raise an error
574
+ instead of matching `nil`. For other checks, pass a callable such as
575
+ `->(article) { article.title.start_with?("Pub") }`; it receives each state as a
576
+ model instance and may run queries of its own.
577
+
578
+ - It looks only at the record itself, not its associations, and does not check
579
+ whether a value was held for the whole window.
580
+ - The relation still filters current rows. Matching deleted records are listed
581
+ in `unreachable`.
582
+ - Every matching record counts toward `limit:`, including deleted ones and ones
583
+ the relation later excludes. Matching stops as soon as the limit is exceeded.
584
+ - `max_filter_versions:` limits how many versions the filter loads, including
585
+ the one version after the window it reads for each record. The default is
586
+ 10,000. Going over raises `FilterLimitExceededError` before the extra batch is
587
+ checked. It does not limit queries inside a callable.
588
+ - `version_scope:` narrows which changes are reported, not which states are
589
+ tested here.
590
+ - It is accepted by `analyze_scope` and `report`, which select records from a
591
+ relation.
592
+
593
+ ### Costly associations
594
+
595
+ Most associations are loaded for all records together. A few still need queries
596
+ per record: associations with a scope (including `limit` or owner-dependent
597
+ scopes), targets with a default scope, and `has_many :through` associations
598
+ whose source is not a `belongs_to`. Batch methods raise
599
+ `UnsupportedBatchPathError` for these by default and name the association.
600
+ Remove it from `associations:`, or pass `allow_expensive: true` to accept the
601
+ per-record cost. Single-record methods always allow them.
602
+
603
+ This check covers known association cases only. Your model callbacks, custom
604
+ attribute types, and callable filters can still run queries. See the
605
+ [performance guide](performance.md) for measurements.
606
+
607
+ ## Associations
608
+
609
+ Associations are only included when you name them. Use names or dot-separated
610
+ paths; the parents in a path are included automatically:
611
+
612
+ ```ruby
613
+ diff = PaperTrailDiff.compare(from, to, associations: [:author, "comments.replies"])
614
+ ```
615
+
616
+ Supported: `belongs_to`, `has_one`, `has_many` (including `:through`), and
617
+ `has_and_belongs_to_many`. Associated models must use `has_paper_trail`, and
618
+ historical states need PT-AT installed and enabled
619
+ (`PaperTrail.config.track_associations = true`). The gem never follows
620
+ associations you did not ask for, so cyclic models are safe.
621
+
622
+ How each kind is reported:
623
+
624
+ | Association | Record replaced, added, or removed | Same record, fields changed |
625
+ | --- | --- | --- |
626
+ | `belongs_to`, `has_one` | `relationship` (a `ValueChange` of snapshots) | `changed` |
627
+ | `has_many`, HABTM | `added`, `removed` (snapshots) | `changed` (`RecordChange`) |
628
+
629
+ ```ruby
630
+ diff.associations["author"].relationship # from one author to another
631
+ comments = diff.associations["comments"]
632
+ comments.added # full snapshots of new comments
633
+ comments.removed # full snapshots of removed comments
634
+ comments.changed # RecordChange objects for edited comments
635
+ comments.changed.first.associations["replies"].added
636
+ ```
637
+
638
+ - The same structure repeats at every level. Added and removed snapshots
639
+ include their chosen children.
640
+ - When a `belongs_to` association is included, its foreign key (and type
641
+ column for polymorphic associations) is left out of the attributes, because
642
+ the association already reports it. The same applies to the foreign key a
643
+ `has_one` or `has_many` child points back with. Without the association, the
644
+ key is an ordinary attribute.
645
+ - `has_many :through` uses the versioned join model's history, so join-model
646
+ attributes are available. HABTM reports membership and the target's
647
+ attributes; it has no join model, so it has no join attributes.
648
+ - Requesting associations for a historical state without PT-AT raises
649
+ `AssociationTrackingUnavailableError`. Unknown names raise
650
+ `UnknownAssociationError`; unsupported macros raise
651
+ `UnsupportedAssociationError`.
652
+
653
+ ### Saving a checkpoint
654
+
655
+ Sometimes you want a version of the root record without changing it, for
656
+ example to fix the end of a report or to capture HABTM membership. Save one
657
+ inside a transaction on a freshly loaded record, and turn off PaperTrail's
658
+ timestamp synchronization for that model so the version records the time it
659
+ was saved:
660
+
661
+ ```ruby
662
+ class Article < ApplicationRecord
663
+ has_paper_trail synchronize_version_creation_timestamp: false
664
+ end
665
+
666
+ def checkpoint_article(article_id)
667
+ Article.transaction do
668
+ Article.find(article_id).paper_trail.save_with_version
669
+ end
670
+ end
671
+ ```
672
+
673
+ Without `synchronize_version_creation_timestamp: false`, the checkpoint takes the
674
+ record's old `updated_at` as its time and lands in the past.
675
+
676
+ ### Files from ActiveStorage
677
+
678
+ `has_one_attached` and `has_many_attached` point to ActiveStorage's own models,
679
+ which PaperTrail does not version. Comparing them historically raises
680
+ `UnversionedAssociationError` rather than claiming nothing changed. Versioning
681
+ ActiveStorage's models does not help either, because `has_one_attached` is a
682
+ scoped `has_one`.
683
+
684
+ Record the facts you care about on a model you own, and version that:
685
+
686
+ <!-- executable:reference-file-metadata -->
687
+ ```ruby
688
+ class DocumentRevision < ApplicationRecord
689
+ belongs_to :attachable, polymorphic: true
690
+ has_one_attached :file
691
+ has_paper_trail
692
+
693
+ # Attaching a file writes to ActiveStorage's tables, not to this record, so
694
+ # nothing would be versioned without copying the facts across.
695
+ before_save :record_file_metadata, if: -> { file.attached? }
696
+
697
+ def record_file_metadata
698
+ blob = file.blob
699
+ assign_attributes(
700
+ filename: blob.filename.to_s, content_type: blob.content_type,
701
+ byte_size: blob.byte_size, checksum: blob.checksum
702
+ )
703
+ end
704
+ end
705
+ ```
706
+
707
+ ```ruby
708
+ class Article < ApplicationRecord
709
+ has_many :document_revisions, as: :attachable
710
+ has_paper_trail
711
+ end
712
+
713
+ PaperTrailDiff.compare(before, after, associations: [:document_revisions])
714
+ ```
715
+
716
+ The callback sets the metadata before the save, so PaperTrail records it with
717
+ that save. Save a revision for every file change; `update_columns` and
718
+ blob-only changes are not recorded.
719
+
720
+ ## Ignoring fields
721
+
722
+ `updated_at` is ignored by default. `ignore:` replaces that default list:
723
+
724
+ ```ruby
725
+ PaperTrailDiff.compare(from, to, ignore: %i[updated_at lock_version])
726
+ PaperTrailDiff.compare(from, to, ignore: []) # compare every field
727
+ ```
728
+
729
+ An array applies to the root record and every association. To ignore fields
730
+ only at certain paths, pass `all:` and `paths:`. `"$"` means the root record:
731
+
732
+ ```ruby
733
+ PaperTrailDiff.compare(
734
+ from, to,
735
+ associations: ["comments.replies"],
736
+ ignore: {
737
+ all: [:updated_at],
738
+ paths: { "$" => [:lock_version], "comments.replies" => [:delivery_state] }
739
+ }
740
+ )
741
+ ```
742
+
743
+ A path entry applies only at that exact path, not below it. Paths must be `"$"`
744
+ or one of the requested association paths.
745
+
746
+ Primary keys are never reported as attributes; they identify the record.
747
+ Columns listed in `has_paper_trail skip:` are also left out, because PaperTrail
748
+ never stores them.
749
+
750
+ ## Reading results
751
+
752
+ All result objects are frozen and have `to_h`. Hash keys for structure are
753
+ symbols; attribute and association names are strings. Attribute values keep
754
+ their Ruby types. Collections are ordered by record type, then ID (numerically
755
+ for numeric IDs).
756
+
757
+ | Type | What it is |
758
+ | --- | --- |
759
+ | `Diff` | `record_presence_change`, `attributes`, `associations`, `empty?` |
760
+ | `ValueChange` | `from` and `to` |
761
+ | `RecordSnapshot` | `type`, `id`, `attributes`, `associations` |
762
+ | `RecordChange` | `record` (a `RecordReference`), `attributes`, `associations` |
763
+ | `ToOneAssociationDiff` | `kind`, `relationship`, `changed` |
764
+ | `CollectionAssociationDiff` | `kind`, `added`, `removed`, `changed` |
765
+ | `Step` | one `timeline` step |
766
+ | `ActivityStep` | one `activity_timeline` step |
767
+ | `ActivityBoundary` | one end of a step |
768
+ | `Analysis` | result of `analyze` |
769
+ | `ScopedAnalysis` | result of `analyze_scope` and `report` |
770
+ | `TraversalEntry`, `RecordReference` | entries from `each_change` and `each_entry` |
771
+ | `AssociationDescriptor` | result of `association_paths` |
772
+ | `DiagnosticReport`, `DiagnosticIssue` | result of `diagnose` |
773
+
774
+ A new record's fields are in `record_presence_change.to.attributes`; an edited
775
+ record's are in `attributes`. To avoid handling both shapes, walk the result.
776
+
777
+ ### Walking a result
778
+
779
+ `each_change` lists every change as a flat sequence, depth first:
780
+
781
+ <!-- executable:readme-traversal-changes -->
782
+ ```ruby
783
+ diff.each_change do |entry|
784
+ entry.kind # :attribute_changed, :record_added, ...
785
+ entry.association_path # ["comments", "replies"]
786
+ entry.record_path # frozen RecordReference objects
787
+ entry.association_kind # :has_many, :belongs_to, ...
788
+ entry.attribute # "body" for an attribute entry
789
+ entry.value # ValueChange, RecordChange, or RecordSnapshot
790
+ end
791
+
792
+ counts = diff.each_change.map(&:kind).tally
793
+ ```
794
+
795
+ - A `:record_changed` entry comes before that record's field and child entries.
796
+ - To-one replacements are `:relationship_added`, `:relationship_removed`, or
797
+ `:relationship_replaced`. The root being created or removed is
798
+ `:record_presence_changed`.
799
+
800
+ `each_entry` also includes the fields of added and removed records, so you can
801
+ render a new record without a second walker:
802
+
803
+ <!-- executable:readme-traversal-entries -->
804
+ ```ruby
805
+ diff.each_entry do |entry|
806
+ next unless entry.included_state?
807
+
808
+ entry.kind # :record_included, :attribute_included, :association_included
809
+ entry.state # :before or :after
810
+ entry.context # :included_state
811
+ end
812
+ ```
813
+
814
+ Both return an `Enumerator` without a block. They never query the database. A
815
+ record reachable through two requested paths appears at both.
816
+
817
+ ## JSON columns
818
+
819
+ A `json` or `jsonb` column is compared as one value. `nested_changes` reports
820
+ which keys inside it changed:
821
+
822
+ ```ruby
823
+ PaperTrailDiff.nested_changes(diff.attributes.fetch("config"))
824
+ # => { ["theme"] => <from "dark" to "light">,
825
+ # ["limits", "max"] => <from 10 to 20> }
826
+
827
+ PaperTrailDiff.nested_changes(from_value, to_value) # also accepts two values
828
+ ```
829
+
830
+ - Paths are arrays because a key may contain a dot.
831
+ - Arrays are compared by their elements, not positions. An `ArrayChange` has
832
+ `added`, `removed`, and `reordered?`, plus the whole `from` and `to`. Objects
833
+ inside arrays are compared whole.
834
+ - A missing key is `PaperTrailDiff::NestedComparator::ABSENT`, not `nil`, because
835
+ `{"a": null}` and `{}` are different.
836
+ - A value that is not JSON on both sides returns `{}`; the attribute diff still
837
+ has the whole change.
838
+ - Keys such as `:a` and `"a"` in one object raise `AmbiguousNestedKeyError`.
839
+
840
+ ## Checking your setup
841
+
842
+ List the associations a model supports, for example to build a settings
843
+ screen:
844
+
845
+ ```ruby
846
+ PaperTrailDiff.supported_association_macros
847
+ PaperTrailDiff.association_paths(Article, max_depth: 3).map(&:to_h)
848
+ ```
849
+
850
+ Each descriptor has `path`, `kind`, `target_type`, `through`, `polymorphic`,
851
+ and `cycle`. Cycles are marked and not followed.
852
+
853
+ Check two versions for known problems before relying on association history:
854
+
855
+ ```ruby
856
+ report = PaperTrailDiff.diagnose(from_version, to_version, associations: [:tags, "comments.replies"])
857
+
858
+ report.ok? # no errors among the checks that ran
859
+ report.errors.map(&:code)
860
+ report.warnings.map(&:code)
861
+ ```
862
+
863
+ Association checks run only for requested associations. The version-order check
864
+ always runs. `ok?` does not prove the history is complete.
865
+
866
+ ## Models, schemas, and edge cases
867
+
868
+ - **Single-table inheritance.** Results are keyed by the base class, as
869
+ PaperTrail stores it: a `Book < Publication` is `["Publication", "12"]`. Use
870
+ `PaperTrailDiff::Endpoint.identity(record)` to build keys.
871
+ - **Non-integer primary keys**, such as UUIDs, are supported, and so is a string
872
+ `versions.item_id` column.
873
+ - **Custom version classes** (`has_paper_trail versions: { class_name: }`) are
874
+ supported.
875
+
876
+ ### Version order
877
+
878
+ Versions are ordered by `created_at`, then by ID when timestamps are equal. That
879
+ works when IDs increase with each insert, as autoincrement IDs do. UUID version
880
+ IDs do not, so two UUID versions with the same timestamp cannot be ordered; the
881
+ gem raises `AmbiguousVersionOrderError` naming both instead of guessing.
882
+ Timestamps stored to the second, as on a MySQL `datetime` column, make ties
883
+ common. `diagnose` reports this as an error.
884
+
885
+ `created_at` comes from your application server's clock. A clock that jumps
886
+ backwards, or servers whose clocks disagree, can record a later change with an
887
+ earlier time. Keep server clocks synchronized.
888
+
889
+ Association changes between versions with the same timestamp are placed using
890
+ the same order. A few association shapes fall back to PT-AT's own reifier, which
891
+ uses timestamps only: scoped associations, unversioned targets, and the others
892
+ listed in the [architecture notes](architecture.md). For those, `diagnose` warns
893
+ about tied timestamps, and a comparison between two tied versions emits the
894
+ `ambiguous_association_boundary.paper_trail_diff` notification. Recording
895
+ timestamps below one second avoids the problem.
896
+
897
+ ### History stored as JSON
898
+
899
+ PaperTrail's JSON serializer, and a `json` or `jsonb` `object` column, store
900
+ times to milliseconds by default (`ActiveSupport::JSON::Encoding.time_precision`).
901
+ For those histories the gem compares all times at that precision, so an
902
+ unchanged timestamp is not reported as changed. A change smaller than that
903
+ precision was never recorded and cannot be reported.
904
+
905
+ ## Instrumentation
906
+
907
+ The gem never logs. It emits `ActiveSupport::Notifications` events you can
908
+ subscribe to:
909
+
910
+ - `compare.paper_trail_diff`
911
+ - `compare_many.paper_trail_diff`
912
+ - `activity_timeline.paper_trail_diff` (includes `step_count`)
913
+ - `analyze_many.paper_trail_diff`
914
+ - `load_live_endpoints.paper_trail_diff`
915
+ - `prepare_history.paper_trail_diff`
916
+ - `ambiguous_association_boundary.paper_trail_diff`
917
+
918
+ ```ruby
919
+ ActiveSupport::Notifications.subscribe(/\.paper_trail_diff\z/) do |name, start, finish, _id, payload|
920
+ Rails.logger.debug(event: name, duration_ms: ((finish - start) * 1_000).round(1), **payload)
921
+ end
922
+ ```
923
+
924
+ Payloads contain counts, model names, association paths, and options, never
925
+ record data. To see the SQL for one call, subscribe to `sql.active_record`
926
+ around it with `ActiveSupport::Notifications.subscribed`.
927
+
928
+ ## Errors
929
+
930
+ All errors inherit from `PaperTrailDiff::Error`.
931
+
932
+ | Error | Raised when |
933
+ | --- | --- |
934
+ | `ConfigurationError` | An option is invalid |
935
+ | `UnsupportedBatchPathError` | A batch requests a [costly association](#costly-associations) without `allow_expensive: true` |
936
+ | `InvalidEndpointError` | A record is unsaved, destroyed, or has unsaved changes |
937
+ | `UnloadedAssociationError` | `reload_live_endpoints: false` and an association is not loaded |
938
+ | `VersionMismatchError` | Two endpoints belong to different records |
939
+ | `ReversedEndpointsError` | Two versions are given newest first |
940
+ | `InvalidTimelineRangeError` | A range is invalid, for example `within:` with `from:`, or `:last` |
941
+ | `InvalidTimeRangeError` | `within:` is not a finite, time-ordered range |
942
+ | `IncompleteTimeRangeError` | A window's last change has no later version and the record has no saved row to read instead |
943
+ | `AmbiguousVersionOrderError` | Versions cannot be ordered (see [version order](#version-order)) |
944
+ | `BatchLimitExceededError` | More records are selected than `limit:` allows |
945
+ | `FilterLimitExceededError` | `historical_filter:` needs more than `max_filter_versions:` |
946
+ | `AssociationTrackingUnavailableError` | Association history is requested without PT-AT |
947
+ | `UnknownAssociationError` | An association name does not exist |
948
+ | `UnsupportedAssociationError` | An association's macro is not supported |
949
+ | `UnversionedAssociationError` | An association's target model has no `has_paper_trail` |
950
+ | `IncompleteAssociationHistoryError` | PT-AT did not record enough to rebuild an association, such as HABTM without a transaction |
951
+ | `UnsupportedLiveActivityError` | A HABTM activity range ends at the current record |
952
+ | `AmbiguousNestedKeyError` | `nested_changes` finds keys like `:a` and `"a"` together |
953
+
954
+ ## Limitations
955
+
956
+ Results can only be as complete as the history PaperTrail and PT-AT recorded.
957
+
958
+ - Changes made with versioning off, with `update_columns`, or to `skip:` columns
959
+ were never recorded. A range ending at the current record shows the first two
960
+ in its last step, attributed to the change before them.
961
+ - Deleted versions, schema changes, and serializer changes can affect how old
962
+ versions read.
963
+ - Several queries against the current record are not one database snapshot.
964
+ Use a transaction with a suitable isolation level if that matters.
965
+ - HABTM membership history exists only where PT-AT recorded it in a
966
+ transaction. HABTM changes cannot become their own activity steps.
967
+ - PT-AT has its own
968
+ [limitations](https://github.com/westonganger/paper_trail-association_tracking#limitations),
969
+ especially for some `has_one` histories. The gem follows PT-AT's configured
970
+ error behavior.
971
+ - Deep association paths cost more to rebuild. Request only what you need.
972
+
973
+ Read PaperTrail's
974
+ [version semantics](https://github.com/paper-trail-gem/paper_trail#3-working-with-versions)
975
+ before relying on history for restoring data or for compliance.
976
+
977
+ For development commands, see [testing](testing.md).