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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8b8f753f8bf15574c43fe572252b467b6066f097d0ad9990b6f1d8a74f41d938
4
- data.tar.gz: 536266a31306236cc26e71b3024af058f96a0544d363f0fb971b5b49f9b1b578
3
+ metadata.gz: 4cc17a3d2f233d6f9bcc44605687ef4cabf1611f957ad41d0471b35236b8ab8d
4
+ data.tar.gz: e123b779da0c52350cb05b4ed18f8585626d8cbcf45e3c737cfe43777f7bcf14
5
5
  SHA512:
6
- metadata.gz: 5070f94bc6bb46a58d6a80352aebd2f7c2392e0486c0197fe8977cb257af3b1a7644643fc1587df33bf6b0138df25ac67a3629ccf004fc627de3be883b321db1
7
- data.tar.gz: 8771c47edd7d8ec39347b7d76883af7c2ff7137ac7670064672dbc5fccea0c196d5e461624c183c8d51890b8a84472625fba8cc283c72d1375a0331fd3cb1efd
6
+ metadata.gz: 65f32e4a83fd05ba8cfece667be5842d41f9452ae84f49f495ff8ad0051b87c76ebddfb07c1a99c09227689eaa393286ca0737042bf6b6f7c6dffea0b77039bf
7
+ data.tar.gz: 217119550346fc0eed08759dd20aa1a2fb53bd71dde161e43ef1243f191159fc7c1225924a6c39059dda8e2df4c4b17f0f1abed694938a2824a89e722e88e0d7
data/CHANGELOG.md CHANGED
@@ -3,6 +3,349 @@
3
3
  All notable changes to this project will be documented in this file. The
4
4
  project follows [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [0.15.0] - 2026-10-31
7
+
8
+ ### Breaking changes
9
+
10
+ - An association whose model keeps its versions, or its PT-AT version
11
+ associations, in different tables than its owner's raises
12
+ `UnsupportedAssociationError` in every method, and `association_paths`
13
+ leaves it out. History read through it was wrong: PT-AT records a child's
14
+ membership with the child's versions but reads it from the owner's tables,
15
+ so a child that moved to another owner was never reported as removed, and a
16
+ root with its own version table raised `ActiveRecord::StatementInvalid`.
17
+
18
+ - An association keyed on a column other than a primary key
19
+ (`primary_key: :legacy_id`), or a `has_many :through` with such a part,
20
+ raises `UnsupportedAssociationError` up front in every method, and
21
+ `association_paths` leaves it out. PT-AT records which record a child
22
+ pointed at by that record's ID, and history matched children by ID, so an
23
+ article's history showed another article's comments, and a comment's past
24
+ article was the wrong one. Comparisons against the current record were
25
+ right, so the same range disagreed with itself.
26
+ - An association path history cannot read, such as one with a scope written
27
+ as SQL text, a scope that takes the owner, or a condition on a string
28
+ column on MySQL or SQL Server, raises `UnsupportedAssociationError` in every
29
+ method that reads history, unless you pass the new `allow_approximate: true`.
30
+ The gem reads such a path with PT-AT, which rebuilds an association from its
31
+ current members and never checks the scope against them: a record that
32
+ entered the scope since was reported as a member it was not, so a comment
33
+ moved into `-> { where("position > 2") }` showed as changed rather than
34
+ added. Single-record methods did this without warning, and batch methods
35
+ described it only as costly.
36
+ - `include_deleted: true` keeps only the deleted records that met the
37
+ relation's conditions when they were destroyed, in both `deleted` and
38
+ `unreachable`. It returned every deleted record of the model that changed in
39
+ the window, so a report on one account's articles included other accounts'
40
+ deleted articles with their contents. The relation's conditions must be
41
+ plain conditions on the model's own columns; a join or SQL text raises
42
+ `ConfigurationError`.
43
+
44
+ ### Added
45
+
46
+ - `diagnose` reports every reason `compare` would refuse the same request as
47
+ an error, with `compare`'s message, so `ok?` is false whenever `compare`
48
+ would refuse. It said `ok?` for a path `compare` reads only approximately,
49
+ such as a scope written as SQL text, raised on an unknown or unsupported
50
+ association instead of reporting it, and checked only the join model a
51
+ `has_many :through` goes through first, not every model it reads. It
52
+ takes `allow_approximate:`, and with `true` reports such a path as an
53
+ `:approximate_association` warning. The new error codes are
54
+ `:unknown_association`, `:unsupported_association`, and
55
+ `:approximate_association`; an unversioned target's message now matches
56
+ `compare`'s.
57
+
58
+ - `diagnose` takes the current record as either endpoint, as `compare` does,
59
+ so `compare(version, record)` can be checked before it runs. Its version
60
+ checks then run from the version to the latest one. An endpoint that is
61
+ neither a version nor a saved record raises `InvalidEndpointError` naming
62
+ that, rather than `VersionMismatchError`.
63
+
64
+ - Activity steps list the events whose changes they show as `step.sources`,
65
+ oldest first. A step from `group: :transaction` lists every event in the
66
+ transaction, each with its own author, record, and version ID, where
67
+ `source_boundary` names only the first. `to_h(metadata: true)` adds a
68
+ `sources` list.
69
+ - `recorded_changes: true` on `activity_timeline`, on `analyze`, `analyze_many`,
70
+ and `analyze_scope` with `activity: true`, and on `report` gives each
71
+ boundary the changes its version recorded, as `recorded_changes(version)`
72
+ returns them. The versions are already loaded, so it adds no queries, and it
73
+ replaces the reference recipe that loaded every step's version again.
74
+ Without the option, `boundary.recorded_changes` raises `ConfigurationError`
75
+ rather than returning `nil`. Names in `ignore:` or `ignore: { all: }` are
76
+ left out, and so are names on the root path, `"$"`, for the root's own
77
+ versions. Names on an association path are not, because a child's version
78
+ does not say which path reached it.
79
+ - `change_rows` on an activity step, on an `Analysis` built with
80
+ `activity: true`, and on a `report` or `analyze_scope` result flattens
81
+ activity into `ChangeRow` objects: one per edited field, and one per record
82
+ that appeared or disappeared followed by its fields. Each names its record,
83
+ association path, and source version, and `to_h` gives a flat hash. It
84
+ replaces the README recipe for turning a report into rows, which had no rows
85
+ for whole records, so a record added or removed with only blank fields left
86
+ no trace.
87
+
88
+ - `associations:` takes what `includes` takes: a single name
89
+ (`associations: :comments`) or hashes of names to what to include below them
90
+ (`associations: [:author, { comments: :replies }]`), as well as the arrays of
91
+ names and dotted paths it took before.
92
+
93
+ - `nested_changes` compares two top-level arrays, as a `json` column can hold,
94
+ and reports one `ArrayChange` at the empty path, `[]`. It returned `{}`,
95
+ although arrays nested in an object were compared.
96
+
97
+ ### Changed
98
+
99
+ - The reference describes how PT-AT records HABTM membership and how the gem
100
+ credits it. A change made between two root versions is credited to the
101
+ step after the earlier one, so a checkpoint's capture is credited to whoever
102
+ saved the version before it; a checkpoint just before the change as well
103
+ credits it to its author. Only the first version an instance saves after
104
+ changing a HABTM is right; PT-AT never forgets the instance's changes, not
105
+ even on `reload`. A later save from it records the old membership, and the
106
+ gem reports members removed and added again that never left.
107
+
108
+ - The performance guide describes current behavior instead of logging each
109
+ past change: what the batch methods cost, what still grows with a report,
110
+ the version indexes, what costs a query per record, historical filters,
111
+ large reports, and how to measure. Its measurements were taken again. The
112
+ earlier ones remain in `benchmark/results/` and in the changelog.
113
+
114
+ - `historical_filter:` reads the history of the relation's records only, and
115
+ of deleted records, which a relation cannot test. It read every record of
116
+ the model that changed in the window, so a report on one account's articles
117
+ raised `BatchLimitExceededError` when another account had more matching
118
+ articles than `limit:`, and spent `max_filter_versions:` on their versions.
119
+ Matching deleted records no longer count toward `limit:` unless
120
+ `include_deleted: true` is given, as without a filter.
121
+
122
+ - The reference says how a record created without a version, such as one
123
+ imported with `insert_all`, and history pruned with `has_paper_trail
124
+ limit:` read.
125
+
126
+ - `IncompleteTimeRangeError` no longer advises the deprecated
127
+ `close_on: :current`. The quickstart and performance guide no longer say
128
+ that `has_many :through` or limited associations need the opt-ins, and the
129
+ reference says that `association_paths` lists paths that need
130
+ `allow_approximate: true`.
131
+
132
+ - The quickstart sets PaperTrail's `yaml_column_permitted_classes`. In a new
133
+ Rails 8.1 app, versions hold timestamps as `ActiveSupport::TimeWithZone`,
134
+ and the quickstart's first `reify` raised `Psych::DisallowedClass` without
135
+ it. Its documentation spec now reads a version in that form.
136
+
137
+ - The README and architecture guide recommend a second version index,
138
+ `(item_type, created_at, item_id)`. Batch methods with `since:` or
139
+ `within:` find changed records by type and time, which the one index they
140
+ recommended cannot narrow, so each report read every version of the model:
141
+ about 230 ms instead of 2 ms on PostgreSQL with 5 million versions.
142
+ - The gemspec requires Active Record 7.1 to 8.1. CI tests 7.2 through 8.1,
143
+ and the suite passes on 7.1. It named only PaperTrail, so Bundler could
144
+ install the gem with Active Record 6.1 or 7.0, whose relations and Arel
145
+ nodes the gem reads differently.
146
+ - `close_on:` is deprecated and warns through the new
147
+ `PaperTrailDiff.deprecator`. It has had no effect since a window began
148
+ closing on the current record by default; remove it. In a Rails app,
149
+ register the deprecator with
150
+ `Rails.application.deprecators[:paper_trail_diff] = PaperTrailDiff.deprecator`.
151
+ - `allow_expensive:` is deprecated and has no effect. It refused exactly the
152
+ paths `allow_approximate:` refuses, so batch methods needed both or
153
+ neither; `allow_approximate: true` alone now accepts such a path in every
154
+ method, and `UnsupportedBatchPathError` is no longer raised. Batch methods
155
+ refuse such a path before their first query, as before, now with
156
+ `UnsupportedAssociationError`.
157
+ - `reload_live_endpoints:` is deprecated and warns. It still works until it
158
+ is removed. Skipping the reload saves a query for the records and one per
159
+ association level in each call, 2 for 500 articles with their comments,
160
+ and reads values from objects that may be stale; it also needs every
161
+ requested association loaded, and reads limited ones again anyway. It was
162
+ on `compare`, `compare_many`, and `activity_timeline` only. Remove it.
163
+
164
+ ### Fixed
165
+
166
+ - A HABTM member created in the same transaction as the checkpoint that
167
+ records it is a member at that checkpoint, as a member attached there
168
+ already was. It was left out, because it had no state before the
169
+ transaction, so the checkpoint missed it and a comparison from the
170
+ checkpoint to now reported it added.
171
+
172
+ - `association_paths` leaves out an association whose target, or a model its
173
+ `has_many :through` goes through, has no `has_paper_trail`, such as the
174
+ `file_attachment` and `file_blob` that `has_one_attached` defines. Every
175
+ request that reads history refused them with `UnversionedAssociationError`,
176
+ so a settings screen built from the list offered paths that always failed.
177
+
178
+ - `diagnose` reports `:tied_version_timestamps`, and `compare` emits
179
+ `ambiguous_association_boundary.paper_trail_diff`, only when the request can
180
+ read a path with PT-AT: one history reads only approximately, a composite
181
+ foreign key, or a polymorphic `belongs_to`. PT-AT places association changes
182
+ by timestamp alone. For every other path, since 0.14.0, versions that share a
183
+ timestamp are ordered by ID, so a change between them is reported, yet both
184
+ warned that it could not be detected. A request refused for a path PT-AT
185
+ would read now raises before the notification.
186
+
187
+ - `timeline`, `activity_timeline`, and `analyze` read every version of a
188
+ model whose versions association has a scope
189
+ (`has_paper_trail versions: { scope: }`), as the other methods already did.
190
+ They read only the versions the scope kept, so a change saved in a version
191
+ it left out was credited to the next one kept: with a bot's edits hidden,
192
+ an editor's step showed the bot's later edit too, and the same record and
193
+ window gave different steps in `analyze` and `analyze_many`. A version the
194
+ scope leaves out is also accepted as an endpoint. To report only some
195
+ changes, pass `version_scope:`.
196
+
197
+ - Activity steps leave out the versions a record saved while it was outside
198
+ the root's tree. A comment edited on another article, then moved to this
199
+ one, brought that edit into the article's timeline as a step, and a
200
+ transaction group's `sources` listed it. Saved together with a comment added
201
+ to the article, the edit took the credit for the addition. The same went for
202
+ a comment edited after it moved away or left a scope, and for an author
203
+ renamed after the article changed authors. A version now counts when its
204
+ record is in the tree before or after its transaction. The versions of a
205
+ `has_many :through`'s join rows are all still kept.
206
+
207
+ - `timeline`, `activity_timeline`, `analyze`, and `analyze_many` raise
208
+ `InvalidEndpointError` naming what they were given when it is not a record
209
+ of a versioned model, such as `nil`, a model class, a relation, or a
210
+ version. They raised a bare `NoMethodError`, or said to provide `from` and
211
+ `to`. `report` names `since` when it is `nil` or a range.
212
+
213
+ - A scope ordered by a datetime, such as
214
+ `-> { order(created_at: :desc, id: :desc).limit(3) }`, is read from history
215
+ under Rails' default time-zone-aware attributes. Rails wraps those
216
+ attributes' types, which hid the datetime, so every method raised
217
+ `UnsupportedAssociationError` for the documented recipe.
218
+
219
+ - A `Date` bound of `within:` or `since:` is a whole day in `Time.zone`, and
220
+ a string a time in `Time.zone`, or in UTC without one. They were read in
221
+ the server's own zone, so the same window covered different hours on
222
+ different servers, and a same-day `Date..Date` window covered one instant
223
+ and found nothing.
224
+
225
+ - A `has_many :through` whose join model is not versioned raises
226
+ `UnversionedAssociationError` naming the join model when its history is
227
+ read, and `diagnose` reports it as an error, so `ok?` is false. A
228
+ comparison raised a bare `KeyError` while `diagnose` reported only a
229
+ warning.
230
+
231
+ - `reload_live_endpoints: false` reads an association with a `limit` or
232
+ `offset`, directly or in a part it goes through, or a scope that takes the
233
+ owner, again for each record. Active Record's `preload` applies the limit across all the records
234
+ it loads, so with three articles preloaded, two kept one comment each and
235
+ the third none, and their unchanged comments were reported as removed.
236
+
237
+ - A root and its associations that share one version table other than
238
+ PaperTrail's `versions` can be compared. Association lookups joined PT-AT's
239
+ version associations to the `versions` table whatever table the versions
240
+ were in, so every method raised `ActiveRecord::StatementInvalid`.
241
+
242
+ - A `within:` or `since:` window whose edge falls inside a transaction that
243
+ saved a version of the root record reports the whole transaction in the
244
+ later window. A window starting between a comment edit and the `touch: true`
245
+ article version saved with it showed the edit at the article's next change,
246
+ credited to that change's author, and the window ending there left it out;
247
+ when the article saved first, its version was in one window and the comment
248
+ edit in the other. Each root's edges move back to the transaction's start,
249
+ read in a fixed number of queries per batch.
250
+
251
+ - `activity_timeline`, and `analyze` with `activity: true`, no longer report a
252
+ child version saved in the same transaction as the root version that ends
253
+ the range. A `touch: true` parent version is saved this way after every
254
+ child save. PT-AT rebuilds the parent's associations at that version as they
255
+ were before the transaction, so the child's step was always empty and its
256
+ change was reported nowhere. The range now ends before that transaction, as
257
+ `compare` and `timeline` already did. End at a later version or at the
258
+ current record to include the change, with `group: :transaction` to credit it
259
+ to the child's version.
260
+ - A range that starts at a version now starts before that version's
261
+ transaction, as one that ends there already ended. A child version saved
262
+ earlier in the same transaction, such as the update behind a `touch: true`
263
+ parent version, was read after its change, so `compare`, `analyze`,
264
+ `timeline`, and `activity_timeline` from that version left the change out.
265
+ It was also missing from the range ending there, so a history split at that
266
+ version reported it nowhere. It is now inside the range that starts there.
267
+ - A past `within:` window in `activity_timeline`, and in `analyze`,
268
+ `analyze_many`, `analyze_scope`, and `report` with `activity: true`, no
269
+ longer includes association changes made after it. When the root had no
270
+ version after the window, the window closed on current state, so a comment
271
+ edited in August appeared in a July window, in its last step, `to_snapshot`,
272
+ and `diff`; with a later root version the same window was right. It now
273
+ closes on current state only when nothing on a requested path was recorded
274
+ after it. The reasoning in 0.8.0, that nothing had been recorded since,
275
+ checked only the root's own versions. `timeline`, and `analyze` without
276
+ `activity:`, still run their last step to the root's next version or the
277
+ current record; the reference now says so.
278
+ - Association scopes read from history now cover `IS NULL` and `IS NOT NULL`
279
+ on a column of any type, such as a soft-delete scope's
280
+ `where(deleted_at: nil)`, and `NOT` with SQL's rules for NULL, such as
281
+ `where.not(position: [1, nil])`. These were read with PT-AT before, so a
282
+ record restored from a soft delete showed as changed rather than added, and
283
+ batch methods refused them without `allow_expensive: true`. A condition on a
284
+ PostgreSQL `citext` column, which compares without regard to case, is no
285
+ longer read as exact text.
286
+ - A `historical_filter:` hash casts its values, and the recorded ones, to each
287
+ attribute's type before comparing them, as `where` does. An enum given as a
288
+ symbol (`{ status: :published }`), and a date or time read from a `json` or
289
+ `jsonb` version column, which PaperTrail returns as a string, never matched,
290
+ so the report silently selected nothing.
291
+ - Versions from two version tables saved in the same instant raise
292
+ `AmbiguousVersionOrderError`, as the reference promises, instead of being
293
+ ordered by their ids. Each table counts its ids separately, so their order
294
+ said nothing about which change came first, and an activity timeline built
295
+ from them could credit a change to the wrong step.
296
+ - `compare_many`, `analyze_many`, `analyze_scope`, and `report` load a
297
+ `has_one` of every current record in one query again, as before 0.14.0.
298
+ 0.14.0 meant to load only a `has_one` with a declared limit or offset per
299
+ record, but checked the association's scope, to which Active Record adds
300
+ `limit(1)` for every `has_one`, so every `has_one` cost one query per
301
+ record.
302
+ - `report`, `analyze_scope`, and `analyze_many` with `activity: true` no
303
+ longer read a root's current row once more for activity through a
304
+ `has_many :through` after its last version. The batch has already loaded
305
+ it, so the query count no longer grows with the number of roots.
306
+ - A snapshot's collections list their records by type, then ID, as the
307
+ reference says, wherever the snapshot was read from. Snapshots rebuilt from
308
+ history or carried through an activity timeline listed them in the order
309
+ they were found, and a ranked association in its query's order, so `to_h` of
310
+ the same state differed between methods. Diffs were already ordered.
311
+ - With a `json` or `jsonb` `object` column, an activity step no longer
312
+ reports an unchanged timestamp as changed. PaperTrail returns such a
313
+ column's times as strings, and a step after a child's update carried them
314
+ into the snapshot as they were, so the next step that read the child again
315
+ reported `created_at` changing from a string to the same time. Activity now
316
+ casts them as it does every other recorded state. The specs had covered the
317
+ JSON serializer on a text column only, which PaperTrail casts.
318
+
319
+ ### Development
320
+
321
+ - A new oracle suite, `rake spec_oracle`, checks rebuilt history against the
322
+ truth rather than against other gem methods. It makes seeded random edits to
323
+ an article graph one transaction at a time, records the live state after
324
+ each, and compares every endpoint, timeline step, activity step, window, and
325
+ batch result with those states. It also checks that two windows meeting
326
+ between transactions or inside one report each step once, scoped membership
327
+ against Active Record, and a tree of one model whose children have
328
+ encrypted, serialized, enum, custom-type, decimal, and JSON attributes.
329
+ `rake spec` runs it, so CI runs it on every database. `ORACLE_SEEDS` and
330
+ `ORACLE_STEPS` widen a run. See `docs/testing.md`.
331
+ - `PTD_PROBE_ALLOW_APPROXIMATE=1` makes the reconstruction probe pass
332
+ `allow_approximate: true`, so its PT-AT-read scenario runs rather than
333
+ raising, and neither benchmark passes the deprecated `close_on:` any more.
334
+ - On SQL Server the association specs keep their versions 5 ms apart. Specs
335
+ that compare the gem with PT-AT failed at random there: PT-AT compares
336
+ version times through SQL text, which the SQL Server adapter writes as
337
+ `datetime` values up to 1/300 s off, and after the host clock stepped back
338
+ the test clock packed versions microseconds apart until it caught up.
339
+ - The reporting benchmark loads the monotonic test clock, so its results no
340
+ longer depend on the host clock. A clock that stepped backwards, as WSL2's
341
+ does, could date a root's destroy before its last update, and
342
+ `deleted_roots` then raised `IncompleteTimeRangeError`. The seeded history
343
+ and query counts are unchanged. Allocations rise by under 1% in
344
+ `many_roots` and `retained_activity`, where the gem reads the clock once
345
+ per root. The investigation scripts that seed through the same workload
346
+ load the clock too; `scalar_cost.rb` freezes time in front of it, so its
347
+ parity check still sees one instant.
348
+
6
349
  ## [0.14.0] - 2026-10-29
7
350
 
8
351
  ### Breaking changes
data/QUICKSTART.md CHANGED
@@ -37,6 +37,19 @@ table. `--with-changes` adds `object_changes`, which PaperTrail uses for
37
37
  for each event. This gem can also compare the full earlier records saved in
38
38
  PaperTrail's `object` column.
39
39
 
40
+ PaperTrail stores earlier records as YAML, and Rails reads back only the YAML
41
+ classes you allow. Without this, reading the first version raises
42
+ `Psych::DisallowedClass`. Add PaperTrail's list to `config/application.rb`,
43
+ inside `class Application`:
44
+
45
+ <!-- executable:quickstart-config -->
46
+ ```ruby
47
+ config.active_record.yaml_column_permitted_classes = [
48
+ ActiveRecord::Type::Time::Value, ActiveSupport::TimeWithZone, ActiveSupport::TimeZone,
49
+ BigDecimal, Date, Symbol, Time
50
+ ]
51
+ ```
52
+
40
53
  ## 2. Version a model
41
54
 
42
55
  Create an article model and tell PaperTrail to track its changes:
@@ -233,10 +246,11 @@ The same `within:` option works on `activity_timeline`, `analyze`,
233
246
  | Recent activity through now, for the records a relation selects | `report(scope, since:, limit:)` |
234
247
 
235
248
  Use the batch methods for many records so they can share database queries.
236
- Some scoped associations and `has_many :through` histories still need queries
237
- for each record. Batch calls reject those with `UnsupportedBatchPathError`.
238
- The error names the association. `allow_expensive: true` permits that extra work;
239
- see the [performance guide](docs/performance.md) before using it in a large report.
249
+ A few scoped associations, such as one whose scope is SQL text, can be read
250
+ only with PT-AT, approximately and one record at a time. Every method rejects
251
+ them with `UnsupportedAssociationError`, naming the association; passing
252
+ `allow_approximate: true` accepts both costs. See
253
+ [costly associations](docs/reference.md#costly-associations).
240
254
 
241
255
  When associations are included, `timeline` compares their values at the article's
242
256
  versions. `activity_timeline` also reports the comments' own version events, even
@@ -388,6 +402,9 @@ window to select that article.
388
402
  comments does not automatically include every association on each comment.
389
403
  - Historical output cannot contain data that PaperTrail or PT-AT did not record
390
404
  or can no longer reconstruct.
405
+ - A report with `since:` or `within:` gets slow as the version table grows
406
+ unless you add the
407
+ [two version indexes](docs/architecture.md#database-indexes).
391
408
 
392
409
  For association support, performance, error handling, ignore rules, and all
393
410
  available result methods, see the
data/README.md CHANGED
@@ -122,13 +122,14 @@ and `item_id`. Its `recorded_at` is the version's `created_at`.
122
122
  Use this source information for attribution: the version used to read the
123
123
  resulting state, exposed as `to_boundary`, may have been saved by someone else.
124
124
  A change saved without a version, such as with `update_columns`, appears in the
125
- step for that record's previous recorded change. To read only what a version
126
- recorded, use `PaperTrailDiff.recorded_changes(version)`; see
127
- [what a version recorded](docs/reference.md#what-a-version-recorded).
125
+ step for that record's previous recorded change. To also read only what each
126
+ version recorded, pass `recorded_changes: true` and read
127
+ `step.source_boundary.recorded_changes`; see
128
+ [what each event recorded](docs/reference.md#what-each-event-recorded-recorded_changes-true).
128
129
 
129
130
  `group: :transaction` combines changes from one database transaction. Its source
130
- information comes from the first event in that group. Omit `group:` if you need
131
- to attribute each version separately.
131
+ information comes from the first event in that group, and `step.sources` lists
132
+ every event in it, each with its own author.
132
133
 
133
134
  `snapshots: true` also keeps copies of the compared records' data, including
134
135
  unchanged fields. These read-only copies are called snapshots. Access them as
@@ -195,8 +196,11 @@ Three selection rules matter:
195
196
  `associations: [:comments]`, editing only a comment selects its article, and
196
197
  the article's report shows the edit.
197
198
  - A relation such as `Article.where(status: "open")` checks today's values.
198
- It cannot establish whether deleted articles matched that condition. Deleted
199
- records are therefore listed separately.
199
+ Deleted records have none, so they are listed separately. With
200
+ `include_deleted: true`, the gem tests each against the relation as it was
201
+ when destroyed and keeps only those that match, so a report on one account's
202
+ records holds only that account's deletions. That needs plain conditions on
203
+ the model's own columns; a join or SQL text raises `ConfigurationError`.
200
204
  - `limit: 100` is a safety ceiling, not pagination. Selecting too many records
201
205
  raises `BatchLimitExceededError`. With `include_deleted: true`, both existing
202
206
  and deleted records count toward it. Use a narrower relation or time window,
@@ -215,9 +219,9 @@ own.
215
219
 
216
220
  ## 4. Turn a report into rows
217
221
 
218
- For a history table or an export, flatten a report into one row per changed
219
- field. Each row names the record, the change, and who made it. This example
220
- requires the association setup in the quickstart:
222
+ For a history table or an export, `change_rows` flattens a report into one row
223
+ per change. Each row names the record, the change, and the version it came
224
+ from. This example requires the association setup in the quickstart:
221
225
 
222
226
  <!-- executable:recipe-summary -->
223
227
  ```ruby
@@ -225,48 +229,35 @@ report = PaperTrailDiff.report(
225
229
  Article.all, since: 1.day.ago, limit: 100,
226
230
  associations: [:comments], include_deleted: true
227
231
  )
228
- analyses = report.analyses.merge(report.deleted.compact)
229
-
230
- rows = analyses.flat_map do |root, analysis|
231
- analysis.activity_timeline.flat_map do |step|
232
- source = step.source_boundary
233
- step.diff.each_entry.filter_map do |entry|
234
- next unless entry.attribute # skip entries for whole records and associations
235
-
236
- from, to, change =
237
- if entry.change?
238
- [entry.value.from, entry.value.to, :changed]
239
- elsif entry.state == :after
240
- [nil, entry.value, :added]
241
- else
242
- [entry.value, nil, :removed]
243
- end
244
- next if from.nil? && to.nil? # a blank field of an added or removed record
245
-
246
- record = entry.record # nil for the article itself
247
-
248
- {
249
- root: root,
250
- association: entry.association_path.join("."), # "" for the article itself
251
- record: record ? [record.type, record.id.to_s] : root,
252
- change: change, attribute: entry.attribute, from: from, to: to,
253
- actor: source.whodunnit, event: source.event, recorded_at: source.recorded_at
254
- }
255
- end
256
- end
232
+
233
+ rows = report.change_rows.flat_map do |root, changes|
234
+ changes.map { |row| row.to_h.merge(root: root) }
257
235
  end
258
236
  ```
259
237
 
260
- `change` is `:changed` for an edited field. It is `:added` or `:removed` for
261
- each field of a record that appeared or disappeared: the article was created
262
- or deleted, or a comment joined or left it. A comment moved from another
263
- article is also `:added`; its `event` is then `"update"` rather than
264
- `"create"`. `actor`, `event`, and `recorded_at` describe the step's source
265
- version. With `group: :transaction`, that is the transaction's first event.
266
-
267
- Rows follow the report's record order, with deleted records last, then each
268
- record's steps in time order. Labels, grouping, and formatting are left to your
269
- application.
238
+ `report.change_rows` is a hash keyed like `report.analyses`, with deleted
239
+ records last. Each value lists that record's rows in time order. Each row
240
+ covers one of two things:
241
+
242
+ - **A field**: `attribute` names it, and `change` is `:changed` for an edit.
243
+ For a record that appeared or disappeared, each of its fields gets an
244
+ `:added` or `:removed` row, holding its value in `to` or `from`. Blank
245
+ fields of such a record get no row.
246
+ - **A whole record**: `attribute` is `nil`, and `change` is `:added` or
247
+ `:removed`. The record was created or deleted, a comment joined or left the
248
+ article, or a `belongs_to` pointed somewhere new, which gives a `:removed`
249
+ row for the old record and an `:added` row for the new one. A comment moved
250
+ from another article is `:added` too; its `event` is then `"update"`.
251
+
252
+ `record` is the record the row is about, or `nil` for the article itself, and
253
+ `association_path` is how it was reached, such as `["comments"]`. `to_h` adds
254
+ the step's source version: `version_id`, `event`, `whodunnit`, `recorded_at`,
255
+ and `transaction_id`. With `group: :transaction`, that is the transaction's
256
+ first event; `step.sources` lists every event. `row.source` is the
257
+ `ActivityBoundary` itself.
258
+
259
+ `change_rows` is also on an `Analysis` built with `activity: true` and on each
260
+ activity step. Labels, grouping, and formatting are left to your application.
270
261
 
271
262
  ## Choose a method
272
263
 
@@ -298,15 +289,27 @@ Scopes and default scopes made of plain conditions, such as
298
289
  `-> { where(archived: false) }`, are batched too, and so is a `limit` or
299
290
  `offset` ordered by integer, boolean, date, or datetime columns down to the
300
291
  primary key, such as `-> { order(:position, :id).limit(3) }`, on a direct
301
- association or a `has_many :through`. Some associations still need per-record
302
- queries, such as a `limit` ordered by a string or scopes that take
303
- the owner.
304
- Batch methods reject those by default with `UnsupportedBatchPathError`. The
305
- error names the association. Remove it from `associations:`, or pass
306
- `allow_expensive: true` if you accept that cost.
292
+ association or a `has_many :through`. History cannot read a few scopes, such
293
+ as a `limit` ordered by a string, a scope that takes the owner, or SQL text.
294
+ PT-AT reads those instead, one record at a time and only approximately, so
295
+ every method raises `UnsupportedAssociationError` naming the association.
296
+ Remove it from `associations:`, or pass `allow_approximate: true` to accept
297
+ PT-AT's answer and its cost. See
298
+ [costly associations](docs/reference.md#costly-associations).
307
299
  Callbacks and callable historical filters can also issue queries. See the
308
300
  [performance guide](docs/performance.md) for measured behavior and limitations.
309
301
 
302
+ On a large version table, add these indexes, or every `since:` or `within:`
303
+ report reads all of the model's versions. See
304
+ [database indexes](docs/architecture.md#database-indexes) for why.
305
+
306
+ ```ruby
307
+ add_index :versions, %i[item_type item_id created_at id],
308
+ name: "index_versions_on_item_and_timeline"
309
+ add_index :versions, %i[item_type created_at item_id],
310
+ name: "index_versions_on_type_and_time"
311
+ ```
312
+
310
313
  - `updated_at` is ignored. Passing `ignore:` replaces the default list.
311
314
  - Records passed as current state must be saved and have no unsaved changes.
312
315
  They are reloaded from the database.
data/docs/architecture.md CHANGED
@@ -66,17 +66,19 @@ PT-AT transaction are applied together.
66
66
 
67
67
  These association shapes cannot be read from the loaded history. The gem asks
68
68
  PT-AT to rebuild them at each point instead, which costs queries per record and
69
- resolves association membership by timestamp only:
69
+ resolves association membership by timestamp only. PT-AT starts from the
70
+ association's current members and swaps in their older versions, without
71
+ checking a scope against them, so its membership is approximate:
70
72
 
71
73
  - associations with a scope the gem cannot read as plain conditions and an
72
74
  exact ranking, including owner-dependent scopes and a `limit` without a
73
75
  total integer order;
74
- - targets with such a default scope;
75
- - targets that are not versioned;
76
+ - targets with such a default scope, including a polymorphic `belongs_to`
77
+ target, whose class and scope are known only from each record;
76
78
  - composite foreign keys.
77
79
 
78
- Batch methods reject the scoped cases unless you pass
79
- `allow_expensive: true`; see [costly associations](reference.md#costly-associations).
80
+ Every method that reads history rejects the scoped cases unless you pass
81
+ `allow_approximate: true`; see [costly associations](reference.md#costly-associations).
80
82
 
81
83
  HABTM membership comes from the snapshots PT-AT records with each transaction,
82
84
  so it is only known at root versions saved in a transaction. Those snapshots
@@ -88,18 +90,32 @@ HABTM of the same name saved a version holds both records' members;
88
90
 
89
91
  ## Database indexes
90
92
 
91
- For large histories, add a composite index that matches how the gem reads
92
- versions:
93
+ For large histories, add two indexes to each version table, one for each way
94
+ the gem reads versions:
93
95
 
94
96
  ```ruby
95
97
  add_index :versions, %i[item_type item_id created_at id],
96
98
  name: "index_versions_on_item_and_timeline"
99
+ add_index :versions, %i[item_type created_at item_id],
100
+ name: "index_versions_on_type_and_time"
97
101
  ```
98
102
 
99
- It helps most with repeated `within:` queries, which filter by `item_type`,
100
- `item_id`, and `created_at`, then order by time and ID. Keep PT-AT's own index
101
- on `version_associations` (`foreign_key_name`, `foreign_key_id`,
102
- `foreign_type`).
103
+ The first serves reading one record's history: it filters by `item_type` and
104
+ `item_id`, then by time, in time and ID order. It covers PaperTrail's own
105
+ `(item_type, item_id)` index, which you can then drop.
106
+
107
+ The second serves finding which records changed in a window, as `report`,
108
+ `analyze_scope`, and the other batch methods do with `since:` or `within:`.
109
+ They filter by `item_type` and `created_at` alone, which the first index cannot
110
+ narrow, so without the second the database reads every version of the model.
111
+ On PostgreSQL with 5 million article versions, a report on five articles
112
+ changed in the last hour took about 230 ms without it and 2 ms with it.
113
+ Ending the index with `item_id` lets the database answer from the index alone,
114
+ and SQLite, which keeps no table statistics unless you run `ANALYZE`, picks it
115
+ only then.
116
+
117
+ Keep PT-AT's own index on `version_associations` (`foreign_key_name`,
118
+ `foreign_key_id`, `foreign_type`).
103
119
 
104
120
  ## Remaining costs
105
121
 
@@ -109,3 +125,8 @@ on `version_associations` (`foreign_key_name`, `foreign_key_id`,
109
125
  - Repeated events in one very wide collection still copy that collection's
110
126
  list of records for each step. The comparison work stays small, but memory
111
127
  traffic grows with *events × collection size*.
128
+ - With PT-AT installed, a `within:` or `since:` window looks up each root's
129
+ versions on either side of both edges, to move an edge that falls inside a
130
+ transaction (see [time windows](reference.md#time-windows)). These are
131
+ four indexed lookups per root, sent 200 roots per query, whatever the
132
+ length of the root's history: about 0.2 ms per root on SQLite.