paper_trail_diff 0.13.1 → 0.14.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 (108) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +287 -0
  3. data/QUICKSTART.md +9 -6
  4. data/README.md +84 -9
  5. data/docs/architecture.md +32 -9
  6. data/docs/performance.md +106 -13
  7. data/docs/reference.md +242 -29
  8. data/docs/testing.md +34 -11
  9. data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +19 -9
  10. data/lib/paper_trail_diff/activity_collection_event_applier.rb +11 -1
  11. data/lib/paper_trail_diff/activity_collection_record_updater.rb +16 -2
  12. data/lib/paper_trail_diff/activity_event_record_resolver.rb +20 -1
  13. data/lib/paper_trail_diff/activity_event_route_finder.rb +2 -1
  14. data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +1 -1
  15. data/lib/paper_trail_diff/activity_range.rb +14 -2
  16. data/lib/paper_trail_diff/activity_relationship.rb +12 -0
  17. data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +5 -2
  18. data/lib/paper_trail_diff/activity_target_candidate_loader.rb +131 -0
  19. data/lib/paper_trail_diff/activity_timeline_builder.rb +30 -24
  20. data/lib/paper_trail_diff/activity_version_collector.rb +31 -20
  21. data/lib/paper_trail_diff/association_activity_edge.rb +236 -0
  22. data/lib/paper_trail_diff/association_activity_selection.rb +197 -0
  23. data/lib/paper_trail_diff/association_discovery.rb +8 -13
  24. data/lib/paper_trail_diff/association_traversal.rb +169 -45
  25. data/lib/paper_trail_diff/batch_safety.rb +23 -15
  26. data/lib/paper_trail_diff/batched_root_versions.rb +21 -39
  27. data/lib/paper_trail_diff/comparison_batch.rb +37 -10
  28. data/lib/paper_trail_diff/deleted_analysis_batch.rb +34 -16
  29. data/lib/paper_trail_diff/diagnostics.rb +26 -8
  30. data/lib/paper_trail_diff/direct_record_state_loader.rb +10 -1
  31. data/lib/paper_trail_diff/errors.rb +2 -1
  32. data/lib/paper_trail_diff/habtm_transaction_owners.rb +68 -0
  33. data/lib/paper_trail_diff/historical_association_reifier.rb +32 -2
  34. data/lib/paper_trail_diff/historical_snapshot_store.rb +6 -9
  35. data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +35 -2
  36. data/lib/paper_trail_diff/live_graph_collector.rb +1 -1
  37. data/lib/paper_trail_diff/paper_trail_adapter.rb +22 -6
  38. data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +1 -1
  39. data/lib/paper_trail_diff/prepared_activity_index.rb +110 -36
  40. data/lib/paper_trail_diff/prepared_edge_loader.rb +29 -45
  41. data/lib/paper_trail_diff/prepared_history.rb +63 -54
  42. data/lib/paper_trail_diff/prepared_history_loader.rb +33 -43
  43. data/lib/paper_trail_diff/prepared_record_index.rb +26 -0
  44. data/lib/paper_trail_diff/prepared_snapshot_builder.rb +1 -1
  45. data/lib/paper_trail_diff/prepared_state_association_reader.rb +119 -29
  46. data/lib/paper_trail_diff/recorded_changes.rb +83 -0
  47. data/lib/paper_trail_diff/recorded_class.rb +53 -0
  48. data/lib/paper_trail_diff/scope_condition.rb +331 -0
  49. data/lib/paper_trail_diff/scope_ranking.rb +196 -0
  50. data/lib/paper_trail_diff/scoped_analysis_builder.rb +4 -3
  51. data/lib/paper_trail_diff/scoped_root_selection.rb +93 -43
  52. data/lib/paper_trail_diff/snapshot_normalizer.rb +10 -3
  53. data/lib/paper_trail_diff/support.rb +9 -4
  54. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +0 -1
  55. data/lib/paper_trail_diff/time_range.rb +9 -12
  56. data/lib/paper_trail_diff/time_version_range.rb +5 -5
  57. data/lib/paper_trail_diff/version.rb +1 -1
  58. data/lib/paper_trail_diff/version_association_candidate_scope.rb +13 -3
  59. data/lib/paper_trail_diff/version_range.rb +8 -15
  60. data/lib/paper_trail_diff/version_scope_filter.rb +11 -3
  61. data/lib/paper_trail_diff.rb +31 -2
  62. data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +10 -2
  63. data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +5 -0
  64. data/sig/generated/paper_trail_diff/activity_collection_record_updater.rbs +5 -0
  65. data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +7 -0
  66. data/sig/generated/paper_trail_diff/activity_range.rbs +6 -2
  67. data/sig/generated/paper_trail_diff/activity_relationship.rbs +3 -0
  68. data/sig/generated/paper_trail_diff/activity_root_snapshot_refresher.rbs +2 -0
  69. data/sig/generated/paper_trail_diff/activity_target_candidate_loader.rbs +79 -0
  70. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +11 -5
  71. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +10 -4
  72. data/sig/generated/paper_trail_diff/association_activity_edge.rbs +148 -0
  73. data/sig/generated/paper_trail_diff/association_activity_selection.rbs +112 -0
  74. data/sig/generated/paper_trail_diff/association_discovery.rbs +3 -5
  75. data/sig/generated/paper_trail_diff/association_traversal.rbs +84 -9
  76. data/sig/generated/paper_trail_diff/batch_safety.rbs +10 -4
  77. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +2 -8
  78. data/sig/generated/paper_trail_diff/comparison_batch.rbs +22 -4
  79. data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +16 -4
  80. data/sig/generated/paper_trail_diff/diagnostics.rbs +7 -2
  81. data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +6 -0
  82. data/sig/generated/paper_trail_diff/errors.rbs +2 -1
  83. data/sig/generated/paper_trail_diff/habtm_transaction_owners.rbs +40 -0
  84. data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +13 -0
  85. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +3 -2
  86. data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +17 -0
  87. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +5 -0
  88. data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +52 -10
  89. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +4 -12
  90. data/sig/generated/paper_trail_diff/prepared_history.rbs +33 -22
  91. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +15 -13
  92. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +15 -0
  93. data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +56 -11
  94. data/sig/generated/paper_trail_diff/recorded_changes.rbs +47 -0
  95. data/sig/generated/paper_trail_diff/recorded_class.rbs +22 -0
  96. data/sig/generated/paper_trail_diff/scope_condition.rbs +185 -0
  97. data/sig/generated/paper_trail_diff/scope_ranking.rbs +117 -0
  98. data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +2 -2
  99. data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +41 -7
  100. data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +6 -0
  101. data/sig/generated/paper_trail_diff/support.rbs +5 -2
  102. data/sig/generated/paper_trail_diff/time_range.rbs +4 -0
  103. data/sig/generated/paper_trail_diff/time_version_range.rbs +4 -4
  104. data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +7 -0
  105. data/sig/generated/paper_trail_diff/version_range.rbs +4 -2
  106. data/sig/generated/paper_trail_diff/version_scope_filter.rbs +4 -0
  107. data/sig/generated/paper_trail_diff.rbs +21 -2
  108. metadata +20 -4
data/docs/performance.md CHANGED
@@ -8,14 +8,103 @@ does not establish that a batch API meets that purpose.
8
8
  The [follow-up reconstruction investigation](../benchmark/investigation/README.md)
9
9
  records the pre-fix N+1 paths and direct-snapshot experiments. Production now uses
10
10
  prepared event successors even without changesets, and directly normalizes plain
11
- root/direct-child states. Scoped and collection-source through batch paths require
12
- `allow_expensive: true`; that opt-in can still cause N+1 work. Model callbacks,
11
+ root/direct-child states. Scopes and default scopes made of plain conditions,
12
+ and a `limit` or `offset` with a total integer order, are read from prepared
13
+ history; other scoped batch paths, such as owner-dependent scopes, require
14
+ `allow_expensive: true`, and that opt-in can still cause N+1 work. Model callbacks,
13
15
  custom types and callable predicates also remain outside a flat-query guarantee.
14
16
 
15
17
  The benchmark exercises `analyze_scope` with genuine PaperTrail history. It is
16
18
  a local SQLite baseline for finding work inside the gem, not a production
17
19
  latency guarantee or a comparison of SQL dialects.
18
20
 
21
+ ## Limited associations: 2026-10-29
22
+
23
+ A `limit` or `offset` with a total order on integer or boolean columns, and an
24
+ ordered `has_one`, are now read from prepared history: each record's stored
25
+ values decide whether it meets the scope's conditions, and the matching records
26
+ are ranked as the association's query ranks them. Batch methods no longer need
27
+ `allow_expensive: true` for them. The probe's `limited` scenario reports
28
+ articles with `has_many :limited_comments, -> { order(:id).limit(1) }`:
29
+
30
+ | `limited` | SQL at 1 / 5 / 20 roots | `reify` calls | Model initializations |
31
+ | --- | --- | --- | --- |
32
+ | before, with `allow_expensive: true` | 24 / 72 / 252 | 8 / 40 / 160 | 30 / 150 / 600 |
33
+ | after, without an opt-in | 13 / 17 / 32 | 0 / 0 / 0 | 10 / 50 / 200 |
34
+
35
+ One query per root remains: the current end loads each article's limited
36
+ comments on its own, because Active Record's preloader applies a limit or
37
+ offset to all owners' records at once rather than to each owner's.
38
+
39
+ ## Scoped associations: 2026-10-29
40
+
41
+ An association scope, or a target's default scope, made of plain conditions
42
+ (`where(reviewer_id: nil)`, `where.not(body: "hidden")`) used to fall back to
43
+ PT-AT for every record, so batch methods required `allow_expensive: true` for
44
+ it. It is now read from prepared history, checking each record's stored values
45
+ against the scope. The probe below reports articles with their comments,
46
+ through `has_many :scoped_comments, -> { where(reviewer_id: nil) }`
47
+ (`scoped`) and through a `TrackedComment` default scope (`default_scope`):
48
+
49
+ | Scenario and code | SQL at 1 / 5 / 20 roots | `reify` calls at 1 / 5 / 20 roots |
50
+ | --- | --- | --- |
51
+ | `scoped`, before, with `allow_expensive: true` | 15 / 27 / 72 | 1 / 5 / 20 |
52
+ | `scoped`, after, without an opt-in | 12 / 12 / 12 | 0 / 0 / 0 |
53
+ | `default_scope`, before, with `allow_expensive: true` | 15 / 27 / 72 | 1 / 5 / 20 |
54
+ | `default_scope`, after, without an opt-in | 12 / 12 / 12 | 0 / 0 / 0 |
55
+
56
+ Model initializations fell from 17 / 85 / 340 (`scoped`) and 19 / 95 / 380
57
+ (`default_scope`) to 10 / 50 / 200, the same as unscoped `comments`. The
58
+ default-scoped comments are read without `reify` because their scope assigns
59
+ no attributes to a new record; one such as `where(hidden: false)` does, so
60
+ those records are still rebuilt with `reify`, though the query count stays
61
+ flat. `limited` (`order(:id).limit(1)`) and `owner` (a scope that takes the
62
+ article) still raise without the opt-in, and cost what they did.
63
+
64
+ ## Fewer `reify` calls: 2026-10-29
65
+
66
+ The `reify` calls that remained came from one place: building a root's
67
+ snapshot from its own version (`HistoricalSnapshotStore#snapshot_from_version`).
68
+ That snapshot is built from stored attributes only when every requested
69
+ association can be read the same way, and `has_many :through` and polymorphic
70
+ `belongs_to` could not. So the whole root was rebuilt with `reify`, and its
71
+ associations as model instances. Both are now read from stored attributes,
72
+ resolving each part of a through as the record path does, and keeping only
73
+ join records of the `source_type:` when there is one. A type that names no
74
+ class, a default-scoped or unversioned target, and every scoped association
75
+ still take the record path as before.
76
+
77
+ Counts from `benchmark/investigation/reconstruction.rb` at 1 / 5 / 20 roots.
78
+ `source_type_through` and `polymorphic_belongs_to` are new probe scenarios: a
79
+ holder's links to an author and to a tag, one moving to another author.
80
+
81
+ | Scenario | `reify` before | `reify` after | Model instances before | Model instances after |
82
+ | --- | --- | --- | --- | --- |
83
+ | `through_has_many` | 7 / 35 / 140 | 0 / 0 / 0 | 36 / 180 / 720 | 14 / 70 / 280 |
84
+ | `source_type_through` | 7 / 35 / 140 | 0 / 0 / 0 | 45 / 225 / 900 | 22 / 110 / 440 |
85
+ | `polymorphic_belongs_to` | 2 / 10 / 40 | 0 / 0 / 0 | 14 / 70 / 280 | 10 / 50 / 200 |
86
+
87
+ Query counts are unchanged: 20, 20 and 14 at every size. `belongs_to`,
88
+ `comments` and `checkpoints` made no `reify` calls before and are unchanged.
89
+ Model instances still grow with the roots: they are the snapshots' own records
90
+ and, for the two polymorphic scenarios, a few states the activity timeline
91
+ builds as records.
92
+
93
+ ## Through associations: 2026-10-29
94
+
95
+ A `has_many :through` whose source is a `has_many` used to fall back to PT-AT
96
+ for every record, so batch methods required `allow_expensive: true` for it. It
97
+ now reads prepared history like any other through. The five-root
98
+ `through_has_many` probe below, an author's comments through articles:
99
+
100
+ | Code | SQL at 1 / 5 / 20 roots | `reify` calls at 1 / 5 / 20 roots |
101
+ | --- | --- | --- |
102
+ | Before, with `allow_expensive: true` | 56 / 200 / 740 | 15 / 75 / 300 |
103
+ | After, without an opt-in | 20 / 20 / 20 | 7 / 35 / 140 |
104
+
105
+ The `reify` calls still grew with the roots: each root's own versions were
106
+ still rebuilt that way, until the change above.
107
+
19
108
  ## Direct reconstruction follow-up: 2026-09-07
20
109
 
21
110
  Implementation: `1b6b1b9`, followed by belongs-to discovery fix `f6a4ae2`.
@@ -45,9 +134,10 @@ Ruby memory was about 15.0, 20.8 and 19.9 MiB respectively. These timings do not
45
134
  establish a retained-activity improvement.
46
135
 
47
136
  A querying initialization callback still produced 10 / 26 / 86 queries, and a
48
- querying historical predicate produced 8 / 12 / 27. Custom behavior, polymorphic
49
- or advanced graph reconstruction, and non-leaf activity fallbacks may still
50
- instantiate historical records. There is no blanket no-N+1 guarantee.
137
+ querying historical predicate produced 8 / 12 / 27. Custom behavior, advanced
138
+ graph reconstruction, and non-leaf activity fallbacks may still instantiate
139
+ historical records, as does a polymorphic `belongs_to` to a default-scoped
140
+ class. There is no blanket no-N+1 guarantee.
51
141
 
52
142
  Production parity matches PaperTrail for STI, enum, decimal, date, boolean, JSON
53
143
  and serialized values, including missing fields supplied by callbacks/default
@@ -57,7 +147,7 @@ scripts are not included in the installed gem), reproduce these checks with:
57
147
  ```console
58
148
  PTD_PRODUCTION=1 mise exec -- bundle exec ruby benchmark/investigation/scalar_parity.rb
59
149
  mise exec -- bundle exec ruby benchmark/investigation/reconstruction.rb comments_without_changes 20
60
- PTD_PROBE_ALLOW_EXPENSIVE=1 mise exec -- bundle exec ruby benchmark/investigation/reconstruction.rb through_has_many 5
150
+ mise exec -- bundle exec ruby benchmark/investigation/reconstruction.rb through_has_many 5
61
151
  ```
62
152
 
63
153
  ## Reproduce
@@ -300,10 +390,13 @@ Deleted history has no original timed counterpart here. Its five queries at both
300
390
  a small root ceiling can load a full 250-candidate version batch, subject to the
301
391
  separate version budget. Requested analysis history and retained snapshots
302
392
  still consume memory proportional to the data involved.
303
- - **Selection semantics are unchanged.** Historical filters test the states a
304
- record held during the window, not continuous-time membership. Scope selection needs a root
305
- version in the window; child-only edits do not select an otherwise quiet root.
306
-
307
- Further batching of advanced association fallbacks needs temporal parity tests,
308
- especially around ownership changes. A graph-wide selector for child-only activity
309
- also needs its own bounded query design.
393
+ - **Selection reads the window's versions.** Historical filters test the states a
394
+ record held during the window, not continuous-time membership. Activity
395
+ selection also reaches roots through their requested associations, with one
396
+ query per association level, not per root. Those queries scan the window's
397
+ versions of each associated model and their `version_associations` rows; ids
398
+ stay in subqueries unless `versions.item_id` and the keys differ in type, in
399
+ which case they are read in slices of 250.
400
+
401
+ Batching scoped associations needs tests of membership over time like those
402
+ for `has_many :through`, especially around ownership changes.
data/docs/reference.md CHANGED
@@ -19,6 +19,7 @@ including ones most applications never need.
19
19
  - [Ignoring fields](#ignoring-fields)
20
20
  - [Reading results](#reading-results)
21
21
  - [JSON columns](#json-columns)
22
+ - [What a version recorded](#what-a-version-recorded): `recorded_changes`
22
23
  - [Checking your setup](#checking-your-setup)
23
24
  - [Models, schemas, and edge cases](#models-schemas-and-edge-cases)
24
25
  - [Instrumentation](#instrumentation)
@@ -60,7 +61,7 @@ Take an article created as `"Draft"`, then updated to `"Published"`, then to
60
61
  only in the table
61
62
  ```
62
63
 
63
- Three things follow from this:
64
+ Four things follow from this:
64
65
 
65
66
  - **A `create` version stands for "no record yet".** Its `reify` returns `nil`.
66
67
  Comparing it with a later state reports the record as added
@@ -70,6 +71,14 @@ Three things follow from this:
70
71
  change from the current record.
71
72
  - **A change shows up between two points, never at one.** That is why the
72
73
  timeline methods return steps, each comparing one state with the next.
74
+ - **A change saved without a version joins the change before it.** Writing
75
+ with `update_columns` or with versioning off creates no version, but it
76
+ changes the values the record's next version stores, or its current row. The
77
+ gem reads the result of a change from there, so the write appears in the step
78
+ for that record's previous recorded change and is attributed to its author.
79
+ Ending a range at a version leaves out only the writes after it.
80
+ [`recorded_changes`](#what-a-version-recorded) reads what a version itself
81
+ recorded.
73
82
 
74
83
  ## Choosing a method
75
84
 
@@ -142,8 +151,9 @@ the current record instead. The step's `to_boundary.kind` is then `:current`.
142
151
  This works the same for a window that ends now and one that ended long ago.
143
152
 
144
153
  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.
154
+ never recorded, such as an `update_columns` call. Like any
155
+ [change saved without a version](#how-papertrail-stores-history), each appears
156
+ in the step for that record's last recorded change.
147
157
 
148
158
  If the window's last change destroyed the record, the window closes on that
149
159
  deletion instead. See [a destroyed record](#a-destroyed-record).
@@ -216,6 +226,11 @@ diff.to_h
216
226
  `ReversedEndpointsError`, because the result would look valid with every
217
227
  change backwards. A current record can be on either side.
218
228
  - Both sides must belong to the same record.
229
+ - Associations at a version are read from the history recorded since then, plus
230
+ the current rows for records that have not changed since. A comment that later
231
+ moved to another article is still found at the versions where it belonged.
232
+ This also holds when both sides are versions, or the record has since been
233
+ deleted.
219
234
  - A current record must be saved, not destroyed, and have no unsaved changes.
220
235
  The gem reloads it, so in-memory edits and cached associations are ignored.
221
236
  - Options: `associations:`, `ignore:`, and `reload_live_endpoints:` (see
@@ -317,9 +332,10 @@ steps.reject(&:empty?).each do |step|
317
332
  end
318
333
  ```
319
334
 
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.
335
+ - Each step's `diff` is what the event at its `source_boundary` changed, plus
336
+ anything [saved without a version](#how-papertrail-stores-history) to the
337
+ same record before its next version. Use `source_boundary` for attribution.
338
+ `to_boundary` is only the next point in time and may belong to someone else.
323
339
  - Boundaries have a `kind`: `:version`, `:current` (the current record), or
324
340
  `:destroyed` (after a deletion). They also have `event`, `whodunnit`,
325
341
  `recorded_at`, `item_type`, `item_id`, `transaction_id`, and `record`, and
@@ -347,7 +363,7 @@ attributed = steps.reject(&:empty?)
347
363
  by_priya = attributed.select { |step| step.source_boundary.whodunnit == "Priya Shah" }
348
364
 
349
365
  by_priya.first.source_boundary.whodunnit # "Priya Shah"
350
- by_priya.first.diff # exactly what that one event changed
366
+ by_priya.first.diff # what that one event changed
351
367
 
352
368
  authors = attributed.group_by { |step| step.source_boundary.whodunnit }
353
369
  .transform_values(&:length)
@@ -429,6 +445,13 @@ the range at a version instead (see the
429
445
  [checkpoint recipe](#saving-a-checkpoint)), or use a join model with
430
446
  `has_many :through`.
431
447
 
448
+ PT-AT records HABTM members under the transaction a version was saved in, and
449
+ does not say which record they belong to. If another record with a HABTM of the
450
+ same name, such as a second article, also saved a version in that transaction
451
+ and members were recorded there, reading the root's membership at that version
452
+ raises `IncompleteAssociationHistoryError` instead of mixing both records'
453
+ members. Save each HABTM owner's versions in a transaction of its own.
454
+
432
455
  ## analyze
433
456
 
434
457
  `analyze(record)` returns an `Analysis` with the net `diff` and the `timeline`
@@ -519,9 +542,18 @@ It accepts `associations:`, `ignore:`, `snapshots:`, `group:`,
519
542
 
520
543
  ### Which records are selected
521
544
 
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]`.
545
+ - **A record is selected when it, or a record on a requested association,
546
+ changed in the window.** With `activity: true`, which `report` always uses,
547
+ editing only a comment selects its article when you request
548
+ `associations: [:comments]`. A comment that moved between articles selects
549
+ both. Nested paths count at every level, and a `has_many :through` counts its
550
+ join rows too. Without `activity: true`, an analysis compares a record's own
551
+ versions and cannot show a change to its associations alone, so only a
552
+ record's own version selects it.
553
+ - **An analysis can still be empty.** A record is selected by any version on its
554
+ paths, but its analysis compares only the state you requested. A join row
555
+ whose role changed, or a version that touched only ignored columns, selects a
556
+ record with nothing to report.
525
557
  - **A relation checks current values.** `Order.where(status: "open")` means open
526
558
  now, not open during the window. Use `historical_filter:` to select by past
527
559
  values.
@@ -533,14 +565,17 @@ It accepts `associations:`, `ignore:`, `snapshots:`, `group:`,
533
565
  ID tie-breaker to any other order. If a join repeats a record, it is analyzed
534
566
  once, in order of first appearance.
535
567
  - Selection still scans the versions in the window, so keep windows reasonable
536
- for large tables.
568
+ for large tables. Each requested association adds one query per level, however
569
+ many records it selects. Changes to associations are found from PT-AT's
570
+ `version_associations`, so history recorded before PT-AT was installed selects
571
+ nothing.
537
572
 
538
573
  ### Deleted records
539
574
 
540
575
  A deleted record cannot be tested against a relation's conditions, because it
541
576
  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.
577
+ `unreachable`, including those selected by a change to their associations. It
578
+ is not paginated and does not mean those records matched the relation.
544
579
 
545
580
  `include_deleted: true` also analyzes them from their `destroy` versions and
546
581
  returns the results in `deleted`, keyed the same way. A value is `nil` when no
@@ -552,7 +587,10 @@ missing_history = report.deleted.select { |_identity, analysis| analysis.nil? }.
552
587
  ```
553
588
 
554
589
  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.
590
+ lists only edits; its `activity_timeline` ends with the deletion. That last
591
+ step's `diff.record_presence_change.from` is the record just before it was
592
+ deleted, even when it was also created inside the window, where
593
+ `from_snapshot` is `nil`.
556
594
 
557
595
  ### Selecting by past values: `historical_filter:`
558
596
 
@@ -592,13 +630,75 @@ model instance and may run queries of its own.
592
630
 
593
631
  ### Costly associations
594
632
 
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.
633
+ Most associations are loaded for all records together. That includes an
634
+ association whose scope, or whose target's default scope, is plain conditions
635
+ on the target's own columns:
636
+
637
+ ```ruby
638
+ has_many :active_cards, -> { where(archived: false) }, class_name: "Card"
639
+ has_many :urgent_cards, -> { where(priority: [1, 2]).order(:id) }, class_name: "Card"
640
+ default_scope { where.not(hidden: true) }
641
+ ```
642
+
643
+ The gem reads such a scope once and checks each record's stored values against
644
+ 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.
651
+
652
+ A `limit` or `offset`, and the order of a `has_one`, are read the same way
653
+ when the order is total and the gem can repeat it:
654
+
655
+ ```ruby
656
+ has_many :first_comments, -> { order(:position, :id).limit(3) }
657
+ has_one :latest_comment, -> { order(id: :desc) }
658
+ ```
659
+
660
+ At every point the gem ranks the records that meet the scope's conditions by
661
+ the order, then skips the offset and keeps the limit, as the association's own
662
+ query would have. The order must reach the primary key, so that no two records
663
+ tie, and each key before it must be an integer, boolean, integer enum, date,
664
+ or datetime column of the target, ascending or descending. NULL sorts where the
665
+ database puts it by default: last in an ascending order on PostgreSQL, first on
666
+ SQLite, MySQL, and SQL Server.
667
+
668
+ A datetime key, such as `order(created_at: :desc)`, is read when its column
669
+ declares a precision, as Rails 7 and later give every new `datetime` column,
670
+ when history keeps that many fractional digits, and when Active Record stores
671
+ times in UTC, its default. Active Record truncates a time to its column's
672
+ precision before writing it, so history then holds the value the database
673
+ sorts by. YAML history keeps every digit; JSON history, and a `json` or
674
+ `jsonb` object column, keep `ActiveSupport::JSON::Encoding.time_precision`
675
+ digits, 3 by default, so with them only a `datetime(3)` or coarser column is
676
+ read. A time-of-day column is not read: Active Record keeps the date it was
677
+ given in memory but stores only the time.
678
+
679
+ A `has_many :through` reads its parts as Active Record's own query joins them:
680
+ it keeps each part's conditions but not its `limit`, `offset`, or `has_one`
681
+ cut, so a through over `has_one :latest_comment` holds the records of every
682
+ comment that meets the part's conditions, not only the latest. Its own `limit`
683
+ or `offset` ranks the rows the join yields, so a record reached through two
684
+ join records fills two places, as in the query. A `has_one :through` whose
685
+ own scope or whose parts have a limit, an offset, or a `has_one` order is not
686
+ read.
687
+
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.
696
+
697
+ A polymorphic `belongs_to` is loaded together too, with one query per target
698
+ class. Its classes are known only from the records, though, so batch methods
699
+ cannot reject one with a scope they cannot read in advance: a record that
700
+ points at such a class is read on its own, without an error. A scope on the
701
+ polymorphic `belongs_to` itself is rejected.
602
702
 
603
703
  This check covers known association cases only. Your model callbacks, custom
604
704
  attribute types, and callable filters can still run queries. See the
@@ -642,9 +742,57 @@ comments.changed.first.associations["replies"].added
642
742
  the association already reports it. The same applies to the foreign key a
643
743
  `has_one` or `has_many` child points back with. Without the association, the
644
744
  key is an ordinary attribute.
745
+ - A polymorphic `belongs_to` reads each record's target from its type
746
+ column. Because the targets' classes differ, a path cannot continue past
747
+ it: `"notable.comments"` raises `UnsupportedAssociationError`. A record
748
+ whose type names a model without `has_paper_trail` raises
749
+ `UnversionedAssociationError` when its history is read; the gem cannot
750
+ check this earlier, since only the record names the class. A type that
751
+ names no class, such as one a renamed model left in its versions, raises
752
+ `IncompleteAssociationHistoryError` naming the record.
645
753
  - `has_many :through` uses the versioned join model's history, so join-model
646
754
  attributes are available. HABTM reports membership and the target's
647
755
  attributes; it has no join model, so it has no join attributes.
756
+ - A `has_many :through` holds, at each point, the records the owner reached
757
+ through the join records it had then. A record that moves to another join
758
+ record leaves one owner and joins the other, and so does every record under
759
+ a join record that moves to another owner.
760
+ - A `has_many :through` with `source_type:` holds only the records reached
761
+ through join records whose type column names that class, as Active Record
762
+ reads it. A join record that points at another type is never a member, and
763
+ its changes add no activity step. A join record whose type changes adds or
764
+ removes its target in the step of that version.
765
+ - A `has_many :through` that goes through a HABTM, as its `through:`, its
766
+ `source:`, or further down its chain, raises `UnsupportedAssociationError`
767
+ up front in every method. Active Record joins a HABTM through a hidden
768
+ model with no versions, so the through cannot be rebuilt from history.
769
+ Replace the HABTM with a `has_many :through` a versioned join model to
770
+ compare it. `association_paths` leaves such a through out.
771
+ - A HABTM can only be rebuilt on the root record. PT-AT records membership
772
+ when the record that owns it saves a version, in rows that name the
773
+ transaction and the association but not the owner, so a root checkpoint
774
+ holds the root's membership and no one else's. A HABTM later in a path, such
775
+ as `"tags.articles"` or `"author.articles.tags"`, raises
776
+ `UnsupportedAssociationError` up front in every method, even when the path
777
+ leads back to the root, as `"comments.article.tags"` does.
778
+ `association_paths` leaves such a path out. To follow another record's
779
+ memberships, compare that record's own checkpoints, or replace the HABTM
780
+ with a `has_many :through` a versioned join model.
781
+ - A HABTM with a scope, such as `-> { where(active: true) }`, or whose target
782
+ has a default scope, raises `UnsupportedAssociationError` up front in every
783
+ method. PT-AT records a HABTM's members by loading the association through
784
+ its scope when the owner saves, so a target outside the scope then is never
785
+ recorded, and one that enters the scope before the owner's next save is a
786
+ member history cannot show. Replace it with a `has_many :through` a
787
+ versioned join model, which can be scoped. `association_paths` leaves it
788
+ out.
789
+ - A root that uses single-table inheritance gets the associations of the
790
+ class it had at each version, so you can request one that only a subclass
791
+ declares. Without an `item_subtype` column, PaperTrail stores only the base
792
+ class; the gem reads the subclass from the type column the version stored.
793
+ A version where the record was a class without that association raises
794
+ `UnknownAssociationError`, as a current record of that class would. A
795
+ stored type that names no class raises `InvalidEndpointError`.
648
796
  - Requesting associations for a historical state without PT-AT raises
649
797
  `AssociationTrackingUnavailableError`. Unknown names raise
650
798
  `UnknownAssociationError`; unsupported macros raise
@@ -671,7 +819,10 @@ end
671
819
  ```
672
820
 
673
821
  Without `synchronize_version_creation_timestamp: false`, the checkpoint takes the
674
- record's old `updated_at` as its time and lands in the past.
822
+ record's old `updated_at` as its time and lands in the past. Save one record per
823
+ transaction: HABTM membership cannot be read from a transaction in which
824
+ another record with the same HABTM also saved a version (see the
825
+ [HABTM limitation](#habtm-limitation)).
675
826
 
676
827
  ### Files from ActiveStorage
677
828
 
@@ -837,6 +988,56 @@ PaperTrailDiff.nested_changes(from_value, to_value) # also accepts two values
837
988
  has the whole change.
838
989
  - Keys such as `:a` and `"a"` in one object raise `AmbiguousNestedKeyError`.
839
990
 
991
+ ## What a version recorded
992
+
993
+ `recorded_changes(version)` returns the changes PaperTrail stored with one
994
+ version in its `object_changes` column, as a frozen hash of `ValueChange`
995
+ objects in attribute-name order:
996
+
997
+ ```ruby
998
+ PaperTrailDiff.recorded_changes(version)
999
+ # => { "title" => <from "Draft" to "Published"> }
1000
+ ```
1001
+
1002
+ A step's `diff` compares the record before and after an event instead. The two
1003
+ usually agree. They differ when:
1004
+
1005
+ - something was [saved without a version](#how-papertrail-stores-history)
1006
+ before the record's next version. The diff includes it; this does not.
1007
+ - `has_paper_trail` ignores an attribute or leaves it out of `only:`. PaperTrail
1008
+ stores its value but not its changes, so only the diff reports them.
1009
+ - the version is a `create` or a `destroy`. The diff reports
1010
+ `record_presence_change`; this lists each attribute changing from `nil`, or
1011
+ to `nil`.
1012
+ - an included association's foreign key changed. The diff reports the
1013
+ association instead; this keeps the column.
1014
+
1015
+ Other details:
1016
+
1017
+ - It returns `nil` when nothing was recorded: the version table has no
1018
+ `object_changes` column, or this version has no value in it. A version that
1019
+ recorded no changes, such as a [checkpoint](#saving-a-checkpoint), returns
1020
+ `{}`.
1021
+ - `ignore:` takes an array of attribute names and defaults to `updated_at`, as
1022
+ elsewhere. It does not take `paths:`, because a version does not say which
1023
+ association path it was reached through. Primary keys are left out.
1024
+ - Unlike PaperTrail's `version.changeset`, it never loads the record. That
1025
+ saves a query per version, and a deleted record's values keep their types,
1026
+ such as an enum's label rather than the integer PaperTrail stored.
1027
+
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:
1030
+
1031
+ <!-- executable:reference-recorded-changes -->
1032
+ ```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
1037
+
1038
+ recorded.fetch(steps.last.source_boundary.version_id)
1039
+ ```
1040
+
840
1041
  ## Checking your setup
841
1042
 
842
1043
  List the associations a model supports, for example to build a settings
@@ -850,6 +1051,14 @@ PaperTrailDiff.association_paths(Article, max_depth: 3).map(&:to_h)
850
1051
  Each descriptor has `path`, `kind`, `target_type`, `through`, `polymorphic`,
851
1052
  and `cycle`. Cycles are marked and not followed.
852
1053
 
1054
+ Only associations a request accepts are listed, so each path can be passed to
1055
+ `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
+
853
1062
  Check two versions for known problems before relying on association history:
854
1063
 
855
1064
  ```ruby
@@ -933,7 +1142,7 @@ All errors inherit from `PaperTrailDiff::Error`.
933
1142
  | --- | --- |
934
1143
  | `ConfigurationError` | An option is invalid |
935
1144
  | `UnsupportedBatchPathError` | A batch requests a [costly association](#costly-associations) without `allow_expensive: true` |
936
- | `InvalidEndpointError` | A record is unsaved, destroyed, or has unsaved changes |
1145
+ | `InvalidEndpointError` | A record is unsaved, destroyed, or has unsaved changes, or `recorded_changes` is given something other than a version |
937
1146
  | `UnloadedAssociationError` | `reload_live_endpoints: false` and an association is not loaded |
938
1147
  | `VersionMismatchError` | Two endpoints belong to different records |
939
1148
  | `ReversedEndpointsError` | Two versions are given newest first |
@@ -945,9 +1154,9 @@ All errors inherit from `PaperTrailDiff::Error`.
945
1154
  | `FilterLimitExceededError` | `historical_filter:` needs more than `max_filter_versions:` |
946
1155
  | `AssociationTrackingUnavailableError` | Association history is requested without PT-AT |
947
1156
  | `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 |
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 |
1159
+ | `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 |
951
1160
  | `UnsupportedLiveActivityError` | A HABTM activity range ends at the current record |
952
1161
  | `AmbiguousNestedKeyError` | `nested_changes` finds keys like `:a` and `"a"` together |
953
1162
 
@@ -956,14 +1165,18 @@ All errors inherit from `PaperTrailDiff::Error`.
956
1165
  Results can only be as complete as the history PaperTrail and PT-AT recorded.
957
1166
 
958
1167
  - 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.
1168
+ were never recorded. The first two still appear, in the step for the record's
1169
+ previous recorded change and attributed to its author; see
1170
+ [how PaperTrail stores history](#how-papertrail-stores-history).
961
1171
  - Deleted versions, schema changes, and serializer changes can affect how old
962
1172
  versions read.
963
1173
  - Several queries against the current record are not one database snapshot.
964
1174
  Use a transaction with a suitable isolation level if that matters.
965
1175
  - HABTM membership history exists only where PT-AT recorded it in a
966
- transaction. HABTM changes cannot become their own activity steps.
1176
+ transaction, and only for the root record without a scope: a scoped HABTM,
1177
+ or one below the root, raises `UnsupportedAssociationError`, and a transaction that another record with
1178
+ that HABTM also saved a version in raises `IncompleteAssociationHistoryError`. HABTM changes cannot become their own
1179
+ activity steps.
967
1180
  - PT-AT has its own
968
1181
  [limitations](https://github.com/westonganger/paper_trail-association_tracking#limitations),
969
1182
  especially for some `has_one` histories. The gem follows PT-AT's configured
data/docs/testing.md CHANGED
@@ -46,19 +46,40 @@ passes is behavior nothing checks. `mutant.yml` lists the classes it mutates:
46
46
  - what counts as a change: `Engine`, `CollectionComparator`,
47
47
  `CollectionIdentityIndex`, and `NestedComparator`;
48
48
  - which columns are compared: `SnapshotNormalizer` and `SnapshotPool`;
49
- - which history a range or filter reads: `TimelineRange`, `RootVersionPlan`,
50
- and `HistoricalFilter`.
49
+ - which versions a range reads: `TimeRange`, `TimelineRange`, `VersionRange`,
50
+ `TimeVersionRange`, and `RootVersionPlan`;
51
+ - which versions a batch or filter reads: `BatchedRootVersions`,
52
+ `DeletedAnalysisBatch`, and `HistoricalFilter`;
53
+ - what a version recorded: `RecordedChanges`;
54
+ - which records a scope or report selects: `ScopedRootSelection`,
55
+ `AssociationActivitySelection`, and `AssociationActivityEdge`;
56
+ - which children and targets a record had at each point:
57
+ `PreparedHistoryLoader`, `PreparedEdgeLoader`,
58
+ `ActivityTargetCandidateLoader`, and `PreparedActivityIndex`;
59
+ - where an activity range starts and ends, and which association history its
60
+ query reads: `ActivityRange` and `VersionAssociationCandidateScope`;
61
+ - what activity a record shows: `ActivityTimelineBuilder` and
62
+ `ActivityTransactionGrouper`;
63
+ - which association paths a request accepts: `AssociationTraversal`;
64
+ - whether a transaction's HABTM members are one record's:
65
+ `HabtmTransactionOwners`;
66
+ - which association paths a batch refuses: `BatchSafety`;
67
+ - which records an association scope admits, and how it ranks them:
68
+ `ScopeCondition` and `ScopeRanking`.
51
69
 
52
70
  The specs for the classes that read the database use tables of their own
53
- (`spec/support/filter_database.rb`, `spec/support/normalizer_database.rb`), so
54
- they run in both the core and the association suite.
71
+ (`spec/support/filter_database.rb`, `spec/support/normalizer_database.rb`,
72
+ `spec/support/recorded_changes_database.rb`), so they run in both the core and
73
+ the association suite. The association classes need PT-AT, so their specs are
74
+ in `spec/association` and define any extra tables in the spec file itself.
55
75
 
56
- Mutant does not run every spec for every mutation. It runs the specs whose
76
+ Mutant reads `spec/unit` and `spec/association`, never `spec/integration`.
77
+ Even there it does not run every spec for every mutation, only those whose
57
78
  description names the mutated class (`RSpec.describe PaperTrailDiff::Engine`),
58
- and only falls back to wider names such as `PaperTrailDiff` when none does. A
59
- spec named after a behavior, as most integration specs are, never runs. So
60
- each mutated class has a unit spec of its own that catches every mutation in
61
- it, and a class added to `mutant.yml` needs one too.
79
+ falling back to wider names such as `PaperTrailDiff` when none does. A spec
80
+ named after a behavior, as most integration specs are, never runs. So each
81
+ mutated class has a unit spec of its own that catches every mutation in it,
82
+ and a class added to `mutant.yml` needs one too.
62
83
 
63
84
  Mutant counts a mutation that runs past its time limit as caught, so
64
85
  `mutant.yml` sets the limit well above the time the whole suite takes. With
@@ -87,8 +108,10 @@ BUNDLE_GEMFILE=gemfiles/mutant.gemfile mise exec -- bundle exec mutant run --sin
87
108
  When a mutation survives, either add the spec that would catch it, or, if the
88
109
  mutated code means the same thing, simplify the code to that form. Reserve a
89
110
  `mutant:disable` comment for a method whose surviving mutations all mean the
90
- same thing as the code, and say why next to it. Mutant forks a worker per CPU; `spec/support/mutant_hooks.rb`
91
- gives each worker its own copy of the SQLite fixture database.
111
+ same thing as the code, and say why next to it.
112
+
113
+ Mutant forks a worker per CPU; `spec/support/mutant_hooks.rb` gives each
114
+ worker its own copy of the SQLite fixture database.
92
115
 
93
116
  ## Test clock
94
117