paper_trail_diff 0.14.0 → 0.15.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 (137) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +343 -0
  3. data/QUICKSTART.md +21 -4
  4. data/README.md +58 -55
  5. data/docs/architecture.md +32 -11
  6. data/docs/performance.md +116 -391
  7. data/docs/reference.md +345 -106
  8. data/docs/testing.md +63 -7
  9. data/lib/paper_trail_diff/activity_boundary.rb +83 -26
  10. data/lib/paper_trail_diff/activity_child_candidate_loader.rb +2 -1
  11. data/lib/paper_trail_diff/activity_event.rb +41 -10
  12. data/lib/paper_trail_diff/activity_history.rb +34 -9
  13. data/lib/paper_trail_diff/activity_range.rb +18 -3
  14. data/lib/paper_trail_diff/activity_snapshot_sequence.rb +76 -37
  15. data/lib/paper_trail_diff/activity_target_candidate_loader.rb +5 -5
  16. data/lib/paper_trail_diff/activity_timeline_builder.rb +266 -111
  17. data/lib/paper_trail_diff/activity_transaction_grouper.rb +2 -1
  18. data/lib/paper_trail_diff/activity_tree_filter.rb +114 -0
  19. data/lib/paper_trail_diff/activity_version_collector.rb +7 -5
  20. data/lib/paper_trail_diff/activity_view.rb +69 -0
  21. data/lib/paper_trail_diff/analysis.rb +13 -1
  22. data/lib/paper_trail_diff/analysis_batch.rb +24 -30
  23. data/lib/paper_trail_diff/approximate_paths.rb +48 -0
  24. data/lib/paper_trail_diff/association_activity_edge.rb +6 -6
  25. data/lib/paper_trail_diff/association_discovery.rb +9 -1
  26. data/lib/paper_trail_diff/association_key.rb +56 -0
  27. data/lib/paper_trail_diff/association_traversal.rb +13 -4
  28. data/lib/paper_trail_diff/batch_boundary_resolver.rb +3 -4
  29. data/lib/paper_trail_diff/batch_preload.rb +53 -0
  30. data/lib/paper_trail_diff/batched_root_analyzer.rb +12 -14
  31. data/lib/paper_trail_diff/batched_root_versions.rb +38 -14
  32. data/lib/paper_trail_diff/change_row.rb +133 -0
  33. data/lib/paper_trail_diff/collection_comparator.rb +5 -32
  34. data/lib/paper_trail_diff/configuration.rb +35 -12
  35. data/lib/paper_trail_diff/deleted_analysis_batch.rb +18 -18
  36. data/lib/paper_trail_diff/deleted_root_filter.rb +79 -0
  37. data/lib/paper_trail_diff/deprecation.rb +61 -0
  38. data/lib/paper_trail_diff/diagnostics.rb +84 -99
  39. data/lib/paper_trail_diff/endpoint.rb +26 -3
  40. data/lib/paper_trail_diff/errors.rb +3 -1
  41. data/lib/paper_trail_diff/fallback_paths.rb +38 -0
  42. data/lib/paper_trail_diff/historical_association_reifier.rb +2 -4
  43. data/lib/paper_trail_diff/historical_filter.rb +19 -10
  44. data/lib/paper_trail_diff/historical_snapshot_store.rb +57 -67
  45. data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +1 -35
  46. data/lib/paper_trail_diff/nested_comparator.rb +19 -18
  47. data/lib/paper_trail_diff/paper_trail_adapter.rb +122 -127
  48. data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +13 -1
  49. data/lib/paper_trail_diff/prepared_activity_index.rb +1 -1
  50. data/lib/paper_trail_diff/prepared_edge_loader.rb +2 -1
  51. data/lib/paper_trail_diff/prepared_history.rb +9 -1
  52. data/lib/paper_trail_diff/prepared_history_loader.rb +1 -0
  53. data/lib/paper_trail_diff/prepared_record_index.rb +38 -7
  54. data/lib/paper_trail_diff/root_version_selection.rb +4 -2
  55. data/lib/paper_trail_diff/scope_condition.rb +76 -21
  56. data/lib/paper_trail_diff/scope_ranking.rb +12 -2
  57. data/lib/paper_trail_diff/scoped_analysis.rb +11 -0
  58. data/lib/paper_trail_diff/scoped_analysis_builder.rb +16 -8
  59. data/lib/paper_trail_diff/scoped_root_selection.rb +18 -12
  60. data/lib/paper_trail_diff/snapshot.rb +16 -1
  61. data/lib/paper_trail_diff/support.rb +29 -2
  62. data/lib/paper_trail_diff/time_range.rb +32 -10
  63. data/lib/paper_trail_diff/timeline_builder.rb +7 -7
  64. data/lib/paper_trail_diff/timeline_range.rb +9 -1
  65. data/lib/paper_trail_diff/timeline_snapshot_provider.rb +24 -17
  66. data/lib/paper_trail_diff/transaction_cut.rb +181 -0
  67. data/lib/paper_trail_diff/traversal_preparer.rb +134 -29
  68. data/lib/paper_trail_diff/version.rb +1 -1
  69. data/lib/paper_trail_diff/version_boundary.rb +37 -8
  70. data/lib/paper_trail_diff/version_query.rb +34 -10
  71. data/lib/paper_trail_diff/version_scope_filter.rb +9 -10
  72. data/lib/paper_trail_diff/version_sequence_diagnostics.rb +31 -16
  73. data/lib/paper_trail_diff/version_table.rb +60 -0
  74. data/lib/paper_trail_diff.rb +98 -56
  75. data/sig/generated/paper_trail_diff/activity_boundary.rbs +54 -19
  76. data/sig/generated/paper_trail_diff/activity_event.rbs +25 -4
  77. data/sig/generated/paper_trail_diff/activity_history.rbs +21 -6
  78. data/sig/generated/paper_trail_diff/activity_range.rbs +9 -2
  79. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +48 -9
  80. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +134 -30
  81. data/sig/generated/paper_trail_diff/activity_tree_filter.rbs +73 -0
  82. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +2 -0
  83. data/sig/generated/paper_trail_diff/activity_view.rbs +42 -0
  84. data/sig/generated/paper_trail_diff/analysis.rbs +8 -1
  85. data/sig/generated/paper_trail_diff/analysis_batch.rbs +8 -15
  86. data/sig/generated/paper_trail_diff/approximate_paths.rbs +23 -0
  87. data/sig/generated/paper_trail_diff/association_discovery.rbs +5 -0
  88. data/sig/generated/paper_trail_diff/association_key.rbs +29 -0
  89. data/sig/generated/paper_trail_diff/association_traversal.rbs +4 -0
  90. data/sig/generated/paper_trail_diff/batch_preload.rbs +29 -0
  91. data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +9 -10
  92. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +14 -4
  93. data/sig/generated/paper_trail_diff/change_row.rbs +86 -0
  94. data/sig/generated/paper_trail_diff/collection_comparator.rbs +2 -16
  95. data/sig/generated/paper_trail_diff/configuration.rbs +19 -3
  96. data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +8 -8
  97. data/sig/generated/paper_trail_diff/deleted_root_filter.rbs +48 -0
  98. data/sig/generated/paper_trail_diff/deprecation.rbs +35 -0
  99. data/sig/generated/paper_trail_diff/diagnostics.rbs +37 -30
  100. data/sig/generated/paper_trail_diff/endpoint.rbs +11 -2
  101. data/sig/generated/paper_trail_diff/errors.rbs +3 -1
  102. data/sig/generated/paper_trail_diff/fallback_paths.rbs +22 -0
  103. data/sig/generated/paper_trail_diff/historical_filter.rbs +12 -2
  104. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +33 -28
  105. data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +0 -15
  106. data/sig/generated/paper_trail_diff/nested_comparator.rbs +10 -7
  107. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +54 -52
  108. data/sig/generated/paper_trail_diff/preloaded_endpoint_batch_loader.rbs +9 -1
  109. data/sig/generated/paper_trail_diff/prepared_history.rbs +5 -0
  110. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +19 -2
  111. data/sig/generated/paper_trail_diff/root_version_selection.rbs +2 -0
  112. data/sig/generated/paper_trail_diff/scope_condition.rbs +43 -9
  113. data/sig/generated/paper_trail_diff/scope_ranking.rbs +8 -0
  114. data/sig/generated/paper_trail_diff/scoped_analysis.rbs +6 -0
  115. data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +6 -1
  116. data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +8 -4
  117. data/sig/generated/paper_trail_diff/snapshot.rbs +8 -0
  118. data/sig/generated/paper_trail_diff/support.rbs +15 -0
  119. data/sig/generated/paper_trail_diff/time_range.rbs +20 -6
  120. data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
  121. data/sig/generated/paper_trail_diff/timeline_range.rbs +2 -0
  122. data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +17 -7
  123. data/sig/generated/paper_trail_diff/transaction_cut.rbs +94 -0
  124. data/sig/generated/paper_trail_diff/traversal_preparer.rbs +66 -8
  125. data/sig/generated/paper_trail_diff/version_boundary.rbs +20 -3
  126. data/sig/generated/paper_trail_diff/version_query.rbs +18 -2
  127. data/sig/generated/paper_trail_diff/version_scope_filter.rbs +4 -5
  128. data/sig/generated/paper_trail_diff/version_sequence_diagnostics.rbs +16 -9
  129. data/sig/generated/paper_trail_diff/version_table.rbs +30 -0
  130. data/sig/generated/paper_trail_diff.rbs +29 -26
  131. metadata +46 -10
  132. data/lib/paper_trail_diff/activity_grouping.rb +0 -38
  133. data/lib/paper_trail_diff/batch_safety.rb +0 -63
  134. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +0 -223
  135. data/sig/generated/paper_trail_diff/activity_grouping.rbs +0 -22
  136. data/sig/generated/paper_trail_diff/batch_safety.rbs +0 -30
  137. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +0 -92
data/docs/reference.md CHANGED
@@ -61,7 +61,7 @@ Take an article created as `"Draft"`, then updated to `"Published"`, then to
61
61
  only in the table
62
62
  ```
63
63
 
64
- Four things follow from this:
64
+ Six things follow from this:
65
65
 
66
66
  - **A `create` version stands for "no record yet".** Its `reify` returns `nil`.
67
67
  Comparing it with a later state reports the record as added
@@ -78,7 +78,17 @@ Four things follow from this:
78
78
  for that record's previous recorded change and is attributed to its author.
79
79
  Ending a range at a version leaves out only the writes after it.
80
80
  [`recorded_changes`](#what-a-version-recorded) reads what a version itself
81
- recorded.
81
+ recorded, and activity steps can [include it](#what-each-event-recorded-recorded_changes-true).
82
+ - **A record created without a version looks as if it always existed.** A
83
+ row written with versioning off, or imported with `insert_all`, has no
84
+ `create` version, so history never reports it as added: its earliest
85
+ version's stored values are its state from before history began, and an
86
+ association includes it there. Rows from before PaperTrail was installed
87
+ are the same, which is usually what you want.
88
+ - **Pruned history is gone.** With `has_paper_trail limit:`, PaperTrail
89
+ deletes a record's oldest versions, and the earliest one left becomes its
90
+ start: states before it cannot be read, and an older version passed in is
91
+ no longer there to pass.
82
92
 
83
93
  ## Choosing a method
84
94
 
@@ -128,6 +138,12 @@ PaperTrailDiff.timeline(article, within: 1.week.ago..Time.current)
128
138
  - `to:` defaults to the record itself, meaning its current state. Pass a
129
139
  version instead for a result that never changes when the record is edited
130
140
  again.
141
+ - A version stands for the state before its transaction, since PT-AT rebuilds
142
+ associations as they were before it. Child changes saved in the same
143
+ transaction, such as the update behind a `touch: true` parent version, fall
144
+ after a range that ends there and inside a range that starts there. So a
145
+ history split at that version reports each such change exactly once, in the
146
+ later part.
131
147
  - Versions must belong to the record and be given in time order.
132
148
  - A record with no versions has an empty history, not an error.
133
149
 
@@ -145,10 +161,33 @@ window = Time.zone.parse("2026-08-01")...Time.zone.parse("2026-09-01")
145
161
  steps = PaperTrailDiff.timeline(article, within: window)
146
162
  ```
147
163
 
148
- To show what the window's last change produced, the gem reads the next version
149
- after the window. If there is none, nothing has been recorded since, so it reads
150
- the current record instead. The step's `to_boundary.kind` is then `:current`.
151
- This works the same for a window that ends now and one that ended long ago.
164
+ A `Date` bound is a whole day in `Time.zone`, so `Date.today..Date.today` is
165
+ all of today and `august...september` stops before September 1. A string is a
166
+ time in `Time.zone` unless it names an offset. Without `Time.zone`, both are
167
+ read in UTC, never in the server's own zone.
168
+
169
+ A window edge that falls inside a transaction moves back to where that
170
+ transaction began, so the whole transaction is in the later window, as it is
171
+ when a range [starts or ends at a version](#choosing-a-range). This applies to
172
+ transactions that saved a version of the root record, such as a comment edit
173
+ and the `touch: true` article version saved with it. So back-to-back windows,
174
+ or a `report` with `since:` after an earlier report, show each change exactly
175
+ once, credited to the transaction that made it. Each root moves its own edges,
176
+ so in a batch two roots can start a few moments apart.
177
+
178
+ To show what the window's last change produced, the gem reads the state just
179
+ before the next change after the window:
180
+
181
+ - With `activity_timeline`, or `analyze` with `activity: true`, that is the next
182
+ version of the record or of any record on a requested association path. If
183
+ none was recorded after the window, it reads the current record instead,
184
+ and the last step's `to_boundary.kind` is `:current`. So a window that ended
185
+ long ago ends at its own end, even when only a comment changed since.
186
+ - `timeline`, and `analyze` without `activity:`, compare the record's own
187
+ versions. The last step runs to the record's next version, or to the
188
+ current record when it has none, and an association change made in between
189
+ is part of that step, credited to its author. Use `activity: true` to keep
190
+ a past window's associations as they were at its end.
152
191
 
153
192
  Reading the current record has one consequence: it includes changes PaperTrail
154
193
  never recorded, such as an `update_columns` call. Like any
@@ -158,8 +197,10 @@ in the step for that record's last recorded change.
158
197
  If the window's last change destroyed the record, the window closes on that
159
198
  deletion instead. See [a destroyed record](#a-destroyed-record).
160
199
 
161
- `close_on: :current` asks for the behavior above explicitly. It is the default
162
- now and is still accepted.
200
+ `close_on: :current` is deprecated and has no effect; passing it warns through
201
+ `PaperTrailDiff.deprecator`. Remove it. In a Rails app, register the deprecator
202
+ with `Rails.application.deprecators[:paper_trail_diff] = PaperTrailDiff.deprecator`
203
+ to handle its warnings as the app's own.
163
204
 
164
205
  `timeline` returns no steps for a window with no versions of the root record.
165
206
  `activity_timeline` still reports chosen associations that changed in such a
@@ -233,8 +274,9 @@ diff.to_h
233
274
  deleted.
234
275
  - A current record must be saved, not destroyed, and have no unsaved changes.
235
276
  The gem reloads it, so in-memory edits and cached associations are ignored.
236
- - Options: `associations:`, `ignore:`, and `reload_live_endpoints:` (see
237
- [reusing loaded records](#reusing-loaded-records)).
277
+ - Options: `associations:`, `ignore:`, and `allow_approximate:` (see
278
+ [costly associations](#costly-associations)). `reload_live_endpoints:` is
279
+ deprecated (see [reusing loaded records](#reusing-loaded-records)).
238
280
 
239
281
  ## compare_many
240
282
 
@@ -258,31 +300,26 @@ diffs.fetch(["Order", orders.first.id.to_s]) # => PaperTrailDiff::Diff
258
300
  record, so `{ from: :first, to: :first }` raises.
259
301
  - A record with no versions gets an empty `Diff`.
260
302
  - Each record can appear in only one pair, because results are keyed by record.
261
- - Options: `associations:`, `ignore:`, `reload_live_endpoints:`, and
262
- `allow_expensive:` (see [costly associations](#costly-associations)).
303
+ - Options: `associations:`, `ignore:`, and `allow_approximate:` (see
304
+ [costly associations](#costly-associations)).
263
305
 
264
306
  If you look up versions yourself, do it in bulk. Looking up one record at a
265
307
  time brings back the per-record queries this method avoids.
266
308
 
267
309
  ### Reusing loaded records
268
310
 
269
- `compare`, `compare_many`, and `activity_timeline` reload current records by
270
- default. If you already loaded them with every requested association, you can
271
- skip the reload:
272
-
273
- ```ruby
274
- orders = Order.where(id: order_ids).preload(line_items: :product).to_a
275
-
276
- diffs = PaperTrailDiff.compare_many(
277
- orders.map { |order| { from: :first, to: order } },
278
- associations: ["line_items.product"],
279
- reload_live_endpoints: false
280
- )
281
- ```
282
-
283
- The values then come from your objects, so keeping them fresh is up to you. A
284
- requested association that is not loaded raises `UnloadedAssociationError`
285
- instead of quietly running a query per record.
311
+ Every method reloads the current records it compares with, together with the
312
+ requested associations, in a query for the records and one per association
313
+ level. `reload_live_endpoints: false` on `compare`, `compare_many`, and
314
+ `activity_timeline` skips that reload and reads your loaded objects instead.
315
+ It is deprecated and will be removed: it saves those few queries per call (2
316
+ for 500 articles with their comments), and the values then come from objects
317
+ that may be stale. Until it is removed it still works,
318
+ with a warning. A requested association that is not loaded raises
319
+ `UnloadedAssociationError`, and one with a `limit` or `offset`, directly or in
320
+ a part it goes through, or a scope that takes the owner, is read again for
321
+ each record, because Active Record's `preload` applies a limit to all the
322
+ records it loads together.
286
323
 
287
324
  ## timeline
288
325
 
@@ -314,7 +351,7 @@ steps.reject(&:empty?) # hide steps with no reported changes
314
351
  `item_type`, `item_id`, and `recorded_at`. `to_h(metadata: true)` adds author,
315
352
  event, transaction ID, and record to each boundary.
316
353
  - Options: `from:`, `to:`, `within:`, `associations:`, `ignore:`,
317
- `version_scope:`, `close_on:`.
354
+ `version_scope:`, `allow_approximate:`.
318
355
 
319
356
  ## activity_timeline
320
357
 
@@ -340,14 +377,21 @@ end
340
377
  `:destroyed` (after a deletion). They also have `event`, `whodunnit`,
341
378
  `recorded_at`, `item_type`, `item_id`, `transaction_id`, and `record`, and
342
379
  the predicates `version?`, `current?`, and `destroyed?`.
380
+ - `step.sources` lists the events whose changes the step shows, oldest first.
381
+ That is just `source_boundary`, unless the step is a
382
+ [transaction group](#one-step-per-transaction-group-transaction).
383
+ - A record's versions are steps only while it is in the tree. A comment edited
384
+ on another article and moved to this one later shows up when it moves, not
385
+ when it was edited there.
343
386
  - `step.to_h` returns `{ from:, to:, diff: }`. `to_h(metadata: true)` adds
344
- author, event, transaction ID, and record. `to_h(snapshots: true)` adds the
345
- retained snapshots.
387
+ author, event, transaction ID, and record, and a `sources` list.
388
+ `to_h(snapshots: true)` adds the retained snapshots.
346
389
  - A child's change becomes visible at the next recorded event, or at the
347
390
  current record. You do not need to save the article after each comment change.
348
391
  - Options: `from:`, `to:`, `within:`, `associations:`, `ignore:`,
349
- `version_scope:`, `close_on:`, `reload_live_endpoints:`, `snapshots:`,
350
- `group:`.
392
+ `version_scope:`, `snapshots:`, `group:`, `recorded_changes:`,
393
+ `allow_approximate:`. `reload_live_endpoints:` is deprecated (see
394
+ [reusing loaded records](#reusing-loaded-records)).
351
395
 
352
396
  ### One person's changes
353
397
 
@@ -390,7 +434,9 @@ PaperTrailDiff.activity_timeline(article, associations: [:comments], group: :tra
390
434
  - A combined step compares the state before the transaction with the state after
391
435
  it. A field set and then restored inside the transaction is not reported.
392
436
  - Its `source_boundary` is the first event in the transaction. That event's
393
- author is not necessarily the author of every grouped change.
437
+ author is not necessarily the author of every grouped change. `step.sources`
438
+ lists every event in the transaction, each with its own author, except
439
+ those of records that were outside the tree before and after it.
394
440
  - Versions without a `transaction_id` are never grouped.
395
441
  - Also accepted by `analyze`, `analyze_many`, and `analyze_scope` with
396
442
  `activity: true`, and by `report`.
@@ -398,7 +444,9 @@ PaperTrailDiff.activity_timeline(article, associations: [:comments], group: :tra
398
444
  Without grouping, a step inside a transaction can look empty. A child's new
399
445
  value is only visible at the next recorded event, and inside a transaction that
400
446
  event is often another part of the same save. Nothing is lost; the change
401
- appears one step later.
447
+ appears one step later. When the range ends at the version that closes that
448
+ save, there is no later step, so the change is
449
+ [outside the range](#choosing-a-range).
402
450
 
403
451
  ### Keeping snapshots: `snapshots: true`
404
452
 
@@ -419,6 +467,49 @@ step.from_snapshot.associations["comments"].records.first.attributes["author"]
419
467
  - Also accepted by `analyze`, `analyze_many`, and `analyze_scope` with
420
468
  `activity: true`, and by `report`.
421
469
 
470
+ ### What each event recorded: `recorded_changes: true`
471
+
472
+ A step's `diff` compares the state before and after an event. To also see what
473
+ PaperTrail [stored with each version](#what-a-version-recorded), such as for an
474
+ audit log, pass `recorded_changes: true`:
475
+
476
+ <!-- executable:reference-step-sources -->
477
+ ```ruby
478
+ steps = PaperTrailDiff.activity_timeline(
479
+ article, associations: [:comments], group: :transaction, recorded_changes: true
480
+ )
481
+
482
+ steps.reject(&:empty?).flat_map(&:sources).map do |source|
483
+ {
484
+ version_id: source.version_id,
485
+ record: source.record.to_h, # { type: "Comment", id: 7 }
486
+ event: source.event,
487
+ actor: source.whodunnit,
488
+ recorded_at: source.recorded_at,
489
+ transaction_id: source.transaction_id,
490
+ changes: source.recorded_changes # { "body" => <from "Old" to "New"> }
491
+ }
492
+ end
493
+ ```
494
+
495
+ - Each boundary's `recorded_changes` is what
496
+ `PaperTrailDiff.recorded_changes(version)` returns for its version: `nil`
497
+ when nothing was recorded, and always `nil` for the current record. The
498
+ versions are already loaded, so this adds no queries, but decoding each
499
+ version's changes takes time: about a third more for a `report` of 200
500
+ articles with comments, with history stored as YAML. Pass it only when you
501
+ use them.
502
+ - Without the option, `recorded_changes` raises `ConfigurationError` instead of
503
+ returning `nil`, so a missing option cannot look like a version that recorded
504
+ nothing.
505
+ - `to_h(metadata: true)` includes them on each boundary and source.
506
+ - Names in `ignore:`, or in `ignore: { all: }`, are left out. So are names
507
+ ignored on the root path, `"$"`, for the root's own versions. Names ignored
508
+ on an association path are not left out, because a child's version does not
509
+ say which path reached it.
510
+ - Also accepted by `analyze`, `analyze_many`, and `analyze_scope` with
511
+ `activity: true`, and by `report`.
512
+
422
513
  ### A destroyed record
423
514
 
424
515
  When the range ends at the record's `destroy` version, the activity timeline
@@ -452,6 +543,27 @@ and members were recorded there, reading the root's membership at that version
452
543
  raises `IncompleteAssociationHistoryError` instead of mixing both records'
453
544
  members. Save each HABTM owner's versions in a transaction of its own.
454
545
 
546
+ PT-AT records membership only when the owner saves a version, so the gem knows
547
+ it only at root versions. A change made between two root versions is reported
548
+ in the first step after the earlier one, and credited to that step's source:
549
+ usually the earlier root version, which someone else may have saved. A member
550
+ created between them is reported in the step that starts with its creation
551
+ instead. A change attached before a checkpoint is therefore credited to
552
+ whoever saved the version before the checkpoint, not to the checkpoint's
553
+ author. To credit a change to the person who made it, save a checkpoint just
554
+ before the change as well as after it: the step between the two checkpoints is
555
+ theirs.
556
+
557
+ PT-AT works out the membership a version records from the instance that saves
558
+ it: the instance's current members, with the changes that instance made undone,
559
+ so that the version holds the membership from before its own event. It never
560
+ forgets those changes, not even on `reload`. Only the first version an instance
561
+ saves after changing a HABTM is right; each later save from it records the
562
+ membership from before that change again. The gem then reports the change late,
563
+ or reports a member that never left as removed and added again. Load the record
564
+ again, with `find`, before saving it a second time, and save checkpoints from a
565
+ freshly loaded record, as the [checkpoint recipe](#saving-a-checkpoint) does.
566
+
455
567
  ## analyze
456
568
 
457
569
  `analyze(record)` returns an `Analysis` with the net `diff` and the `timeline`
@@ -469,10 +581,18 @@ analysis.to_h(snapshots: true, metadata: true)
469
581
  ```
470
582
 
471
583
  - It accepts the same ranges as `timeline`.
472
- - `activity: true` also builds the activity timeline. `snapshots:` and `group:`
473
- apply to the activity timeline and require `activity: true`.
584
+ - When nothing in the range is reported, the analysis is empty: an empty
585
+ `diff` and timelines, and `nil` for `from_snapshot` and `to_snapshot`. Here
586
+ `nil` means no state was read, not that the record did not exist; reading
587
+ one would rebuild the record's whole graph, a cost per record in a batch.
588
+ Use `compare` with a version and the record to see a state that did not
589
+ change.
590
+ - `activity: true` also builds the activity timeline. `snapshots:`, `group:`,
591
+ and `recorded_changes:` apply to the activity timeline and require
592
+ `activity: true`.
474
593
  - Options: `from:`, `to:`, `within:`, `associations:`, `ignore:`, `activity:`,
475
- `version_scope:`, `close_on:`, `snapshots:`, `group:`.
594
+ `version_scope:`, `snapshots:`, `group:`, `recorded_changes:`,
595
+ `allow_approximate:`.
476
596
 
477
597
  ## Reports for many records
478
598
 
@@ -496,10 +616,11 @@ results.fetch(["Order", order.id.to_s]).diff
496
616
 
497
617
  - Without `within:`, each record's whole history through its current state is
498
618
  analyzed.
499
- - A record with nothing to report gets an empty `Analysis`.
619
+ - A record with nothing to report gets an empty `Analysis`, whose snapshots
620
+ are `nil` (see [analyze](#analyze)).
500
621
  - Records must be saved and unique.
501
622
  - Options: `within:`, `associations:`, `ignore:`, `activity:`, `version_scope:`,
502
- `close_on:`, `snapshots:`, `group:`, `allow_expensive:`.
623
+ `snapshots:`, `group:`, `recorded_changes:`, `allow_approximate:`.
503
624
 
504
625
  ### analyze_scope
505
626
 
@@ -537,8 +658,8 @@ report = PaperTrailDiff.report(Article.all, since: 1.day.ago, limit: 100)
537
658
  ```
538
659
 
539
660
  It accepts `associations:`, `ignore:`, `snapshots:`, `group:`,
540
- `include_deleted:`, `historical_filter:`, `max_filter_versions:`, and
541
- `allow_expensive:`.
661
+ `recorded_changes:`, `include_deleted:`, `historical_filter:`, `max_filter_versions:`,
662
+ and `allow_approximate:`.
542
663
 
543
664
  ### Which records are selected
544
665
 
@@ -572,15 +693,26 @@ It accepts `associations:`, `ignore:`, `snapshots:`, `group:`,
572
693
 
573
694
  ### Deleted records
574
695
 
575
- A deleted record cannot be tested against a relation's conditions, because it
576
- has no row. Records that changed in the window but no longer exist are listed in
696
+ A relation's conditions read the table, and a deleted record has no row.
697
+ Records that changed in the window but no longer exist are listed in
577
698
  `unreachable`, including those selected by a change to their associations. It
578
- is not paginated and does not mean those records matched the relation.
579
-
580
- `include_deleted: true` also analyzes them from their `destroy` versions and
581
- returns the results in `deleted`, keyed the same way. A value is `nil` when no
582
- `destroy` version was recorded. With this option, existing and deleted records
583
- together count toward `limit:`.
699
+ is not paginated and, without `include_deleted:`, does not mean those records
700
+ matched the relation.
701
+
702
+ `include_deleted: true` tests each of them against the relation's conditions,
703
+ as it stood when it was destroyed, and keeps only those that match. So a
704
+ report on `Article.where(account_id: account.id)` returns that account's
705
+ deletions, not every account's. The relation's conditions must be plain
706
+ conditions on the model's own columns, as a [readable association
707
+ scope's](#costly-associations) must; anything else, such as a join or SQL
708
+ text, raises `ConfigurationError`. Its order, limit, and offset are ignored
709
+ here, since they choose a page of existing rows. A record whose destroy
710
+ PaperTrail did not record has no state to test and stays listed.
711
+
712
+ It then analyzes the kept records from their `destroy` versions and returns the
713
+ results in `deleted`, keyed the same way. A value is `nil` when no `destroy`
714
+ version was recorded. With this option, existing and deleted records together
715
+ count toward `limit:`.
584
716
 
585
717
  ```ruby
586
718
  missing_history = report.deleted.select { |_identity, analysis| analysis.nil? }.keys
@@ -607,7 +739,10 @@ report = PaperTrailDiff.report(
607
739
 
608
740
  The filter checks the record as it was when the window started and after each
609
741
  change made inside it. A record matches if any of those states match. With a
610
- hash, every key must be equal (`==`). Values are read from the stored history
742
+ hash, every key must be equal (`==`) once both sides are cast to the
743
+ attribute's type, as `where` casts its values: `{ status: :published }` matches
744
+ an enum, and a date or time matches whether the history stored it as YAML or
745
+ as a string in a `json` column. Values are read from the stored history
611
746
  without calling `reify`, and unknown or unrecorded attributes raise an error
612
747
  instead of matching `nil`. For other checks, pass a callable such as
613
748
  `->(article) { article.title.start_with?("Pub") }`; it receives each state as a
@@ -615,14 +750,18 @@ model instance and may run queries of its own.
615
750
 
616
751
  - It looks only at the record itself, not its associations, and does not check
617
752
  whether a value was held for the whole window.
618
- - The relation still filters current rows. Matching deleted records are listed
753
+ - Only the history of records the relation selects is read, and the relation's
754
+ order, `limit`, and `offset` then page the ones that match. Deleted records,
755
+ which a relation cannot test, are read too, and those that match are listed
619
756
  in `unreachable`.
620
- - Every matching record counts toward `limit:`, including deleted ones and ones
621
- the relation later excludes. Matching stops as soon as the limit is exceeded.
757
+ - Matching records the relation selects count toward `limit:`, and matching
758
+ stops as soon as the limit is exceeded. Matching deleted records count only
759
+ with `include_deleted: true`, as without a filter.
622
760
  - `max_filter_versions:` limits how many versions the filter loads, including
623
- the one version after the window it reads for each record. The default is
624
- 10,000. Going over raises `FilterLimitExceededError` before the extra batch is
625
- checked. It does not limit queries inside a callable.
761
+ the one version after the window it reads for each record, and the versions
762
+ of deleted records. The default is 10,000. Going over raises
763
+ `FilterLimitExceededError` before the extra batch is checked. It does not
764
+ limit queries inside a callable.
626
765
  - `version_scope:` narrows which changes are reported, not which states are
627
766
  tested here.
628
767
  - It is accepted by `analyze_scope` and `report`, which select records from a
@@ -642,12 +781,15 @@ default_scope { where.not(hidden: true) }
642
781
 
643
782
  The gem reads such a scope once and checks each record's stored values against
644
783
  it at every point, so a record that moves into or out of the scope is added or
645
- removed at the version that moved it. It reads equality, `IN`, `nil`, their
646
- `where.not` forms, and `and`/`or` combinations of them on integer, boolean,
647
- and enum columns. String and text columns are read only on SQLite and
648
- PostgreSQL, and only without a column collation: MySQL and SQL Server compare
649
- strings without regard to case or trailing spaces by default, which the gem
650
- cannot repeat.
784
+ removed at the version that moved it. It reads equality, `IN`, their
785
+ `where.not` forms, and `and`/`or`/`not` combinations of them on integer,
786
+ boolean, and enum columns, with SQL's rules for NULL. A `nil` test
787
+ (`IS NULL`, `IS NOT NULL`) is read on a column of any type, so a soft-delete
788
+ scope such as `where(deleted_at: nil)` is read too. String and text columns
789
+ are read only on SQLite and PostgreSQL, and only without a column collation:
790
+ MySQL and SQL Server compare strings without regard to case or trailing spaces
791
+ by default, which the gem cannot repeat, and neither is PostgreSQL's
792
+ `citext`.
651
793
 
652
794
  A `limit` or `offset`, and the order of a `has_one`, are read the same way
653
795
  when the order is total and the gem can repeat it:
@@ -685,14 +827,24 @@ join records fills two places, as in the query. A `has_one :through` whose
685
827
  own scope or whose parts have a limit, an offset, or a `has_one` order is not
686
828
  read.
687
829
 
688
- A few associations still need queries per record: a `limit` or `offset` whose
689
- order is not total or sorts by another column type (strings follow a
690
- collation) or by a datetime history cannot rank exactly, scopes that take the
691
- owner (`->(article) { ... }`), scopes written as SQL strings or ranges, scopes
692
- that join or select, and conditions on other column types or other tables. Batch methods raise `UnsupportedBatchPathError`
693
- for these by default and name the association. Remove it from
694
- `associations:`, or pass `allow_expensive: true` to accept the per-record
695
- cost. Single-record methods always allow them.
830
+ The gem cannot read the rest: a `limit` or `offset` whose order is not total
831
+ or sorts by another column type (strings follow a collation) or by a datetime
832
+ history cannot rank exactly, scopes that take the owner
833
+ (`->(article) { ... }`), scopes written as SQL strings or ranges, scopes that
834
+ join or select, and conditions on other column types or other tables, such as
835
+ a string column on MySQL. For these it asks PT-AT, which costs queries per
836
+ record and is approximate: PT-AT rebuilds an association from its current
837
+ members, swapping in their older versions, and never checks the scope against
838
+ them. A record that entered the scope since is reported as a member it was
839
+ not, so a comment moved into `-> { where("position > 2") }`, or restored from
840
+ a soft delete, shows as changed rather than added.
841
+
842
+ So every method that reads history raises `UnsupportedAssociationError` for
843
+ such a path and names it. Remove it from `associations:`, rewrite its scope as
844
+ plain conditions on the target's own columns, or pass `allow_approximate:
845
+ true` to accept PT-AT's answer, which also accepts its cost of queries per
846
+ record in batch methods. A comparison of two current records reads no history
847
+ and needs no opt-in. `allow_expensive:` is deprecated and has no effect.
696
848
 
697
849
  A polymorphic `belongs_to` is loaded together too, with one query per target
698
850
  class. Its classes are known only from the records, though, so batch methods
@@ -713,6 +865,10 @@ paths; the parents in a path are included automatically:
713
865
  diff = PaperTrailDiff.compare(from, to, associations: [:author, "comments.replies"])
714
866
  ```
715
867
 
868
+ `associations:` also takes what `includes` takes: a single name
869
+ (`associations: :comments`) or hashes of names to what to include below them
870
+ (`associations: [:author, { comments: :replies }]`).
871
+
716
872
  Supported: `belongs_to`, `has_one`, `has_many` (including `:through`), and
717
873
  `has_and_belongs_to_many`. Associated models must use `has_paper_trail`, and
718
874
  historical states need PT-AT installed and enabled
@@ -786,6 +942,13 @@ comments.changed.first.associations["replies"].added
786
942
  member history cannot show. Replace it with a `has_many :through` a
787
943
  versioned join model, which can be scoped. `association_paths` leaves it
788
944
  out.
945
+ - An association keyed on a column other than a primary key, such as
946
+ `has_many :comments, primary_key: :legacy_id`, raises
947
+ `UnsupportedAssociationError` up front in every method, and so does a
948
+ `has_many :through` with such a part. PT-AT records which record a child
949
+ pointed at by that record's ID, so history read that way would show other
950
+ records' children. `association_paths` leaves it out. Naming the primary key
951
+ itself, `primary_key: :id`, is accepted.
789
952
  - A root that uses single-table inheritance gets the associations of the
790
953
  class it had at each version, so you can request one that only a subclass
791
954
  declares. Without an `item_subtype` column, PaperTrail stores only the base
@@ -822,13 +985,19 @@ Without `synchronize_version_creation_timestamp: false`, the checkpoint takes th
822
985
  record's old `updated_at` as its time and lands in the past. Save one record per
823
986
  transaction: HABTM membership cannot be read from a transaction in which
824
987
  another record with the same HABTM also saved a version (see the
825
- [HABTM limitation](#habtm-limitation)).
988
+ [HABTM limitation](#habtm-limitation)). A checkpoint records membership as it
989
+ is when saved, including a member attached or created earlier in the same
990
+ transaction. A change it captures is credited to the step before it, so its
991
+ author is whoever saved the version before the checkpoint, unless that is a
992
+ checkpoint the same person saved just before the change. Don't save the
993
+ instance that made the HABTM change again; it records the old membership.
826
994
 
827
995
  ### Files from ActiveStorage
828
996
 
829
997
  `has_one_attached` and `has_many_attached` point to ActiveStorage's own models,
830
998
  which PaperTrail does not version. Comparing them historically raises
831
- `UnversionedAssociationError` rather than claiming nothing changed. Versioning
999
+ `UnversionedAssociationError` rather than claiming nothing changed, and
1000
+ `association_paths` leaves them out. Versioning
832
1001
  ActiveStorage's models does not help either, because `has_one_attached` is a
833
1002
  scoped `has_one`.
834
1003
 
@@ -916,6 +1085,7 @@ for numeric IDs).
916
1085
  | `Step` | one `timeline` step |
917
1086
  | `ActivityStep` | one `activity_timeline` step |
918
1087
  | `ActivityBoundary` | one end of a step |
1088
+ | `ChangeRow` | one row from `change_rows` |
919
1089
  | `Analysis` | result of `analyze` |
920
1090
  | `ScopedAnalysis` | result of `analyze_scope` and `report` |
921
1091
  | `TraversalEntry`, `RecordReference` | entries from `each_change` and `each_entry` |
@@ -965,6 +1135,38 @@ end
965
1135
  Both return an `Enumerator` without a block. They never query the database. A
966
1136
  record reachable through two requested paths appears at both.
967
1137
 
1138
+ ### Change rows
1139
+
1140
+ For a table or an export, `change_rows` flattens activity into one row per
1141
+ change, each attributed to its step's source version:
1142
+
1143
+ ```ruby
1144
+ steps.last.change_rows # one activity step
1145
+ analysis.change_rows # an Analysis built with activity: true
1146
+ report.change_rows # { identity => rows }, deleted records last
1147
+
1148
+ row = report.change_rows.values.first.first
1149
+ row.change # :changed, :added, or :removed
1150
+ row.attribute # "body", or nil for a row about a whole record
1151
+ row.from # nil for :added
1152
+ row.to # nil for :removed
1153
+ row.record # a RecordReference, or nil for the root
1154
+ row.association_path # ["comments"], or [] for the root
1155
+ row.source # the step's source_boundary
1156
+ row.to_h # the above, plus version_id, event, whodunnit, recorded_at,
1157
+ # and transaction_id
1158
+ ```
1159
+
1160
+ - An edited field is one `:changed` row. A record that appeared or disappeared
1161
+ gets an `:added` or `:removed` row with `attribute` `nil`, then a row for each
1162
+ field that has a value and for each record it holds.
1163
+ - A replaced `belongs_to` or `has_one` target gives the old record `:removed`
1164
+ rows, then the new one `:added` rows.
1165
+ - Rows follow the diff's walking order, and an analysis's rows follow its steps
1166
+ in time order.
1167
+ - `Analysis#change_rows` raises `ConfigurationError` without `activity: true`.
1168
+ `report.change_rows` leaves out a deleted record without destroy history.
1169
+
968
1170
  ## JSON columns
969
1171
 
970
1172
  A `json` or `jsonb` column is compared as one value. `nested_changes` reports
@@ -984,8 +1186,10 @@ PaperTrailDiff.nested_changes(from_value, to_value) # also accepts two values
984
1186
  inside arrays are compared whole.
985
1187
  - A missing key is `PaperTrailDiff::NestedComparator::ABSENT`, not `nil`, because
986
1188
  `{"a": null}` and `{}` are different.
987
- - A value that is not JSON on both sides returns `{}`; the attribute diff still
988
- has the whole change.
1189
+ - Two arrays at the top, as a `json` column can hold, return one `ArrayChange`
1190
+ at the empty path: `{ [] => <added [3], removed [2]> }`.
1191
+ - A value that is not JSON on both sides, or an object facing an array, returns
1192
+ `{}`; the attribute diff still has the whole change.
989
1193
  - Keys such as `:a` and `"a"` in one object raise `AmbiguousNestedKeyError`.
990
1194
 
991
1195
  ## What a version recorded
@@ -1025,17 +1229,18 @@ Other details:
1025
1229
  saves a query per version, and a deleted record's values keep their types,
1026
1230
  such as an enum's label rather than the integer PaperTrail stored.
1027
1231
 
1028
- Steps name their source version by ID. Load the versions in one query rather
1029
- than one per step; a model with its own version class needs that class here:
1232
+ For activity steps, pass `recorded_changes: true` instead of loading the
1233
+ versions again. Each step's sources then carry what their versions recorded,
1234
+ with no extra queries; see
1235
+ [what each event recorded](#what-each-event-recorded-recorded_changes-true):
1030
1236
 
1031
1237
  <!-- executable:reference-recorded-changes -->
1032
1238
  ```ruby
1033
- ids = steps.map { |step| step.source_boundary.version_id }
1034
- recorded = PaperTrail::Version.where(id: ids).to_h do |version|
1035
- [version.id, PaperTrailDiff.recorded_changes(version)]
1036
- end
1239
+ steps = PaperTrailDiff.activity_timeline(
1240
+ article, associations: [:comments], recorded_changes: true
1241
+ )
1037
1242
 
1038
- recorded.fetch(steps.last.source_boundary.version_id)
1243
+ steps.last.source_boundary.recorded_changes
1039
1244
  ```
1040
1245
 
1041
1246
  ## Checking your setup
@@ -1053,16 +1258,23 @@ and `cycle`. Cycles are marked and not followed.
1053
1258
 
1054
1259
  Only associations a request accepts are listed, so each path can be passed to
1055
1260
  `associations:`. The PaperTrail versions association, a `has_many :through`
1056
- that goes through a HABTM, a scoped HABTM, and a HABTM below the root are left
1057
- out. A
1058
- `has_many :through` Active Record cannot follow, such as one through a
1059
- polymorphic `belongs_to`, raises Active Record's own error from any model
1060
- discovery reaches, as requesting it does.
1061
-
1062
- Check two versions for known problems before relying on association history:
1261
+ that goes through a HABTM, a scoped HABTM, a HABTM below the root, an
1262
+ association keyed on a column other than a primary key, one whose ends keep
1263
+ their versions in different tables, and one whose target, or a model its
1264
+ `has_many :through` goes through, has no `has_paper_trail` are left out. A path
1265
+ history can read only approximately, such as one with a scope written as SQL
1266
+ text, is listed, but requesting it also takes `allow_approximate: true` (see
1267
+ [costly associations](#costly-associations)). A `has_many :through` Active
1268
+ Record cannot follow, such as one through a polymorphic `belongs_to`, raises
1269
+ Active Record's own error from any model discovery reaches, as requesting it
1270
+ does.
1271
+
1272
+ Check a comparison's endpoints for known problems before relying on
1273
+ association history. They are what `compare` takes: two versions, or a version
1274
+ and the current record.
1063
1275
 
1064
1276
  ```ruby
1065
- report = PaperTrailDiff.diagnose(from_version, to_version, associations: [:tags, "comments.replies"])
1277
+ report = PaperTrailDiff.diagnose(from_version, article, associations: [:tags, "comments.replies"])
1066
1278
 
1067
1279
  report.ok? # no errors among the checks that ran
1068
1280
  report.errors.map(&:code)
@@ -1070,7 +1282,12 @@ report.warnings.map(&:code)
1070
1282
  ```
1071
1283
 
1072
1284
  Association checks run only for requested associations. The version-order check
1073
- always runs. `ok?` does not prove the history is complete.
1285
+ always runs. Every reason `compare` would refuse the same request is an error,
1286
+ with the message `compare` would raise, so `ok?` is false whenever `compare`
1287
+ would refuse: an unknown or unsupported association, missing association
1288
+ tracking, an unversioned target, or a path read only approximately. Pass
1289
+ `allow_approximate: true`, as you would to `compare`, and such a path is a
1290
+ warning instead. `ok?` does not prove the history is complete.
1074
1291
 
1075
1292
  ## Models, schemas, and edge cases
1076
1293
 
@@ -1080,14 +1297,27 @@ always runs. `ok?` does not prove the history is complete.
1080
1297
  - **Non-integer primary keys**, such as UUIDs, are supported, and so is a string
1081
1298
  `versions.item_id` column.
1082
1299
  - **Custom version classes** (`has_paper_trail versions: { class_name: }`) are
1083
- supported.
1300
+ supported. For association history, a model and each association you ask
1301
+ for must keep their versions, and their PT-AT version associations, in the
1302
+ same tables. PT-AT reads an association's history from its owner's tables
1303
+ and numbers transactions within one table, so it cannot follow an
1304
+ association into a different version table. Such an association raises
1305
+ `UnsupportedAssociationError` naming both tables, and `association_paths`
1306
+ leaves it out. One custom table shared by the models works like
1307
+ PaperTrail's own.
1308
+ - **A scoped versions association** (`has_paper_trail versions: { scope: }`)
1309
+ does not change what is read: every method reads all of a record's
1310
+ versions, because a history replayed without some of them would credit
1311
+ their changes to the next version kept. To report only some changes, pass
1312
+ [`version_scope:`](#reporting-only-some-changes-version_scope).
1084
1313
 
1085
1314
  ### Version order
1086
1315
 
1087
1316
  Versions are ordered by `created_at`, then by ID when timestamps are equal. That
1088
- works when IDs increase with each insert, as autoincrement IDs do. UUID version
1089
- IDs do not, so two UUID versions with the same timestamp cannot be ordered; the
1090
- gem raises `AmbiguousVersionOrderError` naming both instead of guessing.
1317
+ works when IDs increase with each insert, as autoincrement IDs do, in one
1318
+ table. UUID version IDs do not, and models with their own version tables count
1319
+ IDs separately, so two such versions with the same timestamp cannot be ordered;
1320
+ the gem raises `AmbiguousVersionOrderError` naming both instead of guessing.
1091
1321
  Timestamps stored to the second, as on a MySQL `datetime` column, make ties
1092
1322
  common. `diagnose` reports this as an error.
1093
1323
 
@@ -1097,11 +1327,18 @@ earlier time. Keep server clocks synchronized.
1097
1327
 
1098
1328
  Association changes between versions with the same timestamp are placed using
1099
1329
  the same order. A few association shapes fall back to PT-AT's own reifier, which
1100
- uses timestamps only: scoped associations, unversioned targets, and the others
1101
- listed in the [architecture notes](architecture.md). For those, `diagnose` warns
1102
- about tied timestamps, and a comparison between two tied versions emits the
1103
- `ambiguous_association_boundary.paper_trail_diff` notification. Recording
1104
- timestamps below one second avoids the problem.
1330
+ uses timestamps only: a path history reads only approximately, which takes
1331
+ `allow_approximate: true`, a composite foreign key, and a polymorphic
1332
+ `belongs_to` whose target's default scope history cannot read. The
1333
+ [architecture notes](architecture.md) list them. When a request includes such a
1334
+ path, or any polymorphic `belongs_to`, whose targets are known only per record,
1335
+ `diagnose` warns about tied timestamps and a comparison between two tied
1336
+ versions emits the `ambiguous_association_boundary.paper_trail_diff`
1337
+ notification. Recording timestamps below one second avoids the problem. On SQL Server, PT-AT compares
1338
+ those timestamps only to about 3 milliseconds (a `datetime` tick, 1/300 s),
1339
+ because the adapter writes the times in its queries that way, so for these
1340
+ shapes two versions saved closer together than that may be read in the wrong
1341
+ order.
1105
1342
 
1106
1343
  ### History stored as JSON
1107
1344
 
@@ -1109,7 +1346,9 @@ PaperTrail's JSON serializer, and a `json` or `jsonb` `object` column, store
1109
1346
  times to milliseconds by default (`ActiveSupport::JSON::Encoding.time_precision`).
1110
1347
  For those histories the gem compares all times at that precision, so an
1111
1348
  unchanged timestamp is not reported as changed. A change smaller than that
1112
- precision was never recorded and cannot be reported.
1349
+ precision was never recorded and cannot be reported. A `json` or `jsonb`
1350
+ column also returns times and dates as strings, which PaperTrail leaves as
1351
+ they are; the gem casts them back to the attribute's type.
1113
1352
 
1114
1353
  ## Instrumentation
1115
1354
 
@@ -1141,9 +1380,9 @@ All errors inherit from `PaperTrailDiff::Error`.
1141
1380
  | Error | Raised when |
1142
1381
  | --- | --- |
1143
1382
  | `ConfigurationError` | An option is invalid |
1144
- | `UnsupportedBatchPathError` | A batch requests a [costly association](#costly-associations) without `allow_expensive: true` |
1383
+ | `UnsupportedBatchPathError` | No longer raised; kept until `allow_expensive:` is removed |
1145
1384
  | `InvalidEndpointError` | A record is unsaved, destroyed, or has unsaved changes, or `recorded_changes` is given something other than a version |
1146
- | `UnloadedAssociationError` | `reload_live_endpoints: false` and an association is not loaded |
1385
+ | `UnloadedAssociationError` | The deprecated `reload_live_endpoints: false`, and an association is not loaded |
1147
1386
  | `VersionMismatchError` | Two endpoints belong to different records |
1148
1387
  | `ReversedEndpointsError` | Two versions are given newest first |
1149
1388
  | `InvalidTimelineRangeError` | A range is invalid, for example `within:` with `from:`, or `:last` |
@@ -1154,8 +1393,8 @@ All errors inherit from `PaperTrailDiff::Error`.
1154
1393
  | `FilterLimitExceededError` | `historical_filter:` needs more than `max_filter_versions:` |
1155
1394
  | `AssociationTrackingUnavailableError` | Association history is requested without PT-AT |
1156
1395
  | `UnknownAssociationError` | An association name does not exist |
1157
- | `UnsupportedAssociationError` | An association's macro is not supported, a path continues past a polymorphic `belongs_to`, a `has_many :through` goes through a HABTM, or a HABTM is scoped or below the root |
1158
- | `UnversionedAssociationError` | An association's target model has no `has_paper_trail`, including the class a polymorphic `belongs_to` names |
1396
+ | `UnsupportedAssociationError` | An association's macro is not supported, a path continues past a polymorphic `belongs_to`, a `has_many :through` goes through a HABTM, a HABTM is scoped or below the root, an association is keyed on a column other than a primary key, or history cannot read a [costly association](#costly-associations) and `allow_approximate: true` was not passed |
1397
+ | `UnversionedAssociationError` | An association's target model, or a model a `has_many :through` goes through, has no `has_paper_trail`, including the class a polymorphic `belongs_to` names |
1159
1398
  | `IncompleteAssociationHistoryError` | PT-AT did not record enough to rebuild an association, such as HABTM without a transaction or in a transaction another record with that HABTM also saved a version in, or a polymorphic type names no class |
1160
1399
  | `UnsupportedLiveActivityError` | A HABTM activity range ends at the current record |
1161
1400
  | `AmbiguousNestedKeyError` | `nested_changes` finds keys like `:a` and `"a"` together |