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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dfe7bccfe2f013af725d6ac92c772d357127d5a0c14cae72fac0000cb58fad62
4
- data.tar.gz: 308cc912a32875582ff8e083ec52016d4ca586ee75f3b0e36172360d2600b01d
3
+ metadata.gz: 8b8f753f8bf15574c43fe572252b467b6066f097d0ad9990b6f1d8a74f41d938
4
+ data.tar.gz: 536266a31306236cc26e71b3024af058f96a0544d363f0fb971b5b49f9b1b578
5
5
  SHA512:
6
- metadata.gz: e948b33a959fc8ffdd00d7468819917f126b961c3daa4548f38260315dabab6fa36cddd9019b171c6aad306eae2b09485568591d5f2f07d42ba9a661e93bfd32
7
- data.tar.gz: 2ea2cae4b3147f1c4655e79d2ebe2a68202e991f61227718990ec9dcdce38e631f0122ae6fe510aac217ae15f54c1e223095e28148c08944614698a73c3c42dd
6
+ metadata.gz: 5070f94bc6bb46a58d6a80352aebd2f7c2392e0486c0197fe8977cb257af3b1a7644643fc1587df33bf6b0138df25ac67a3629ccf004fc627de3be883b321db1
7
+ data.tar.gz: 8771c47edd7d8ec39347b7d76883af7c2ff7137ac7670064672dbc5fccea0c196d5e461624c183c8d51890b8a84472625fba8cc283c72d1375a0331fd3cb1efd
data/CHANGELOG.md CHANGED
@@ -3,6 +3,293 @@
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.14.0] - 2026-10-29
7
+
8
+ ### Breaking changes
9
+
10
+ - A `has_and_belongs_to_many` with a scope, or whose target has a default
11
+ scope, raises `UnsupportedAssociationError` up front in every method, and
12
+ `association_paths` leaves it out. PT-AT records a HABTM's members through
13
+ its scope when the owner saves, so a target that entered the scope between
14
+ saves was missing from history, and `activity_timeline` raised `KeyError`.
15
+ Replace it with a `has_many :through` a versioned join model.
16
+ - `report`, and `analyze_scope` with `activity: true`, also select a record
17
+ when only a record on one of its requested `associations:` changed in the
18
+ window: editing a comment selects its article. They used to skip it, although
19
+ `analyze_many` reports that activity. A report can therefore return more
20
+ records and reach `limit:` sooner. Nested paths count at every level,
21
+ `has_many :through` counts its join rows, and a child that moved selects its
22
+ old and new owner. Selection adds one query per association level, not per
23
+ record. Checkpoint analyses (`activity: false`) are unchanged: they compare a
24
+ record's own versions, so only those select it. To keep only records that
25
+ changed themselves, drop analyses whose `timeline` is empty. A record
26
+ selected through a HABTM path in a window that ends at the current record
27
+ raises `UnsupportedLiveActivityError`, as one that changed itself already
28
+ does.
29
+ - A path that continues past a polymorphic `belongs_to`, such as
30
+ `"notable.comments"`, raises `UnsupportedAssociationError`. The part after
31
+ it used to be ignored without a word.
32
+ - A HABTM below the root, such as `"tags.articles"`, `"author.articles.tags"`,
33
+ or `"comments.article.tags"`, raises `UnsupportedAssociationError` up front
34
+ in every method, naming the path and the HABTM. `association_paths` leaves
35
+ such a path out. PT-AT records HABTM membership only when its owner saves a
36
+ version, in rows that do not name the owner, so a root checkpoint holds the
37
+ root's membership alone. These paths read the root's rows instead and
38
+ returned a wrong diff. An article that joined a tag went unreported, and
39
+ against the current record every member read as added. A sibling article
40
+ reached through its author was given the root's tags. `activity_timeline`
41
+ raised `KeyError`. Only a path that led back to the root, like
42
+ `"comments.article.tags"`, read correctly; request `"tags"` instead.
43
+ - A root HABTM read at a version saved in the same transaction as a version of
44
+ another record with a HABTM of that name raises
45
+ `IncompleteAssociationHistoryError`, naming the association and the
46
+ transaction, when PT-AT recorded members there. Those rows do not say which
47
+ record they belong to, so the root used to read every such record's members
48
+ as its own: two articles updated in one transaction each gained the other's
49
+ tags, and an unchanged article reported them removed. It happened in every
50
+ method, with no error. A transaction where only one record with that
51
+ association saved a version, or where none recorded a member, reads as
52
+ before, and `diagnose` reports the shared case as `habtm_transaction_shared`.
53
+ Save HABTM owners' versions in separate transactions, as a
54
+ [checkpoint](docs/reference.md#saving-a-checkpoint) does.
55
+
56
+ ### Added
57
+
58
+ - `recorded_changes(version)` returns what PaperTrail stored with one version
59
+ in `object_changes`, as a frozen hash of `ValueChange` objects, typed by the
60
+ version's model. Unlike PaperTrail's `version.changeset`, it never loads the
61
+ record, so it costs no query per version and still types a deleted record's
62
+ values. It returns `nil` when nothing was recorded, and takes an `ignore:`
63
+ array that defaults to `updated_at`.
64
+
65
+ ### Changed
66
+
67
+ - Batch reads of a `has_many :through` and of a polymorphic `belongs_to` no
68
+ longer call `reify` on each root version. They are read from stored
69
+ attributes like other associations. In the reconstruction probe, `report`
70
+ over a through went from 7 `reify` calls per root to none and builds about
71
+ half as many model instances. Results are unchanged. A type that names no
72
+ class, an unversioned target, and a scope that cannot be read as conditions
73
+ still take the record path.
74
+ - Batch methods read an association scope, or a target's default scope, made
75
+ of plain conditions on the target's columns (equality, `IN`, `nil`, their
76
+ `where.not` forms, combined with `and` and `or`) from prepared history, so
77
+ they no longer need `allow_expensive: true` for it and its query count no
78
+ longer grows with the records. String conditions are read this way only on
79
+ SQLite and PostgreSQL. Scopes that take the owner and other conditions are
80
+ still rejected without the opt-in. A default-scoped model whose scope assigns
81
+ no attributes is read without `reify`.
82
+ - Batch methods also read a `limit` or `offset`, and the order of a `has_one`,
83
+ from prepared history when the order is total, reaching the primary key, and
84
+ sorts only by integer, boolean, or integer enum columns of the target. NULL
85
+ sorts as each adapter puts it by default. In the reconstruction probe,
86
+ `report` over `order(:id).limit(1)` went from 252 queries and 160 `reify`
87
+ calls at 20 roots, with the opt-in, to 32 queries and none without it. A
88
+ string order, an order with ties, and a `has_one :through` over a ranked
89
+ part are still rejected without the opt-in.
90
+ - Batch methods also rank by date columns, and by datetime columns when the
91
+ column declares a precision that history keeps and times are stored in UTC,
92
+ such as `order(created_at: :desc).limit(5)` with YAML history. JSON history
93
+ keeps milliseconds by default, so with it only a `datetime(3)` column is
94
+ read. A time-of-day column, and a datetime without a precision, are still
95
+ rejected without the opt-in.
96
+ - Batch methods read a `has_many :through` over a part with a `limit`,
97
+ `offset`, or `has_one` order, and one with its own `limit` or `offset`, from
98
+ prepared history without the opt-in.
99
+ - The README recipe for deleted records also reads each record as it was just
100
+ before deletion, which a record created inside the window has no other
101
+ snapshot of.
102
+ - The documentation now says where a change saved without a version, such as
103
+ with `update_columns`, appears: in the step for that record's previous
104
+ recorded change, credited to its author, anywhere in a range. It used to say
105
+ only that the last step of a range ending at the current record shows them,
106
+ and that a step's diff is exactly what its event changed. Ending a range at a
107
+ version leaves out only the writes after it. Specs pin this behavior, which
108
+ is unchanged.
109
+ - The README has a recipe that turns a report into one row per changed field,
110
+ with the record, whether it changed, appeared, or disappeared, and the step's
111
+ author, event, and time.
112
+ - Batch methods no longer need `allow_expensive: true` for a
113
+ `has_many :through` whose source is a `has_many`, such as an author's
114
+ comments through articles. It is read from prepared history like the other
115
+ associations, so its queries no longer grow with the records: the five-record
116
+ probe in `docs/performance.md` went from 200 queries to 20.
117
+
118
+ ### Fixed
119
+
120
+ - A `limit` or `offset` scope, or an ordered `has_one`, read at a version
121
+ returns the records its query returned then, when the order is total and
122
+ sorts by columns batch methods can rank. It used to start from
123
+ the association's current rows, so a range whose records later changed
124
+ reported the wrong ones: with comments `first` and `second` under
125
+ `order(:id).limit(1)`, destroying `first` and adding `third` reported
126
+ `third` added instead of `second`. Other ranked scopes still read the
127
+ current rows.
128
+ - A `has_many :through` reads its parts as its own query does, keeping their
129
+ conditions but not their `limit`, `offset`, or `has_one` cut. History used
130
+ to apply them, and read with the opt-in it could raise PT-AT's
131
+ `FoundMoreThanOne`; batched history kept only one record through a `has_one`
132
+ part. A through's own `limit` or `offset` counts a record reached through
133
+ two join records twice, as the query does.
134
+ - Batch comparisons with a current endpoint load a `has_many :through` over a
135
+ part with a `limit`, `offset`, or `has_one` per record. Active Record's
136
+ preloader applied the part's limit across every record in the batch, so a
137
+ record could show another's members or none.
138
+ - A record reached through two join records is one member of a current
139
+ association, as it already was of a historical one, so a through loaded per
140
+ record no longer raises a duplicate identity error.
141
+ - In a batch, a `has_one` with an `offset` is loaded per record at the current
142
+ end. The preloader applied the offset across all records at once, so a
143
+ record could show another record's member.
144
+ - Activity reports a change to a `belongs_to` target that is an STI subclass.
145
+ When the target was destroyed, or its `type` changed between a subclass and
146
+ another class, activity reported nothing while checkpoint analysis reported
147
+ it, because a target's version names only the base class. This includes the
148
+ same target further down a path, such as `"note.badge"`.
149
+ - An association that only an STI subclass declares can be requested on a
150
+ root of that subclass in every method. Without an `item_subtype` column on
151
+ versions, each version resolved to the base class, so every method raised
152
+ `UnknownAssociationError`. The subclass is now read from the type the
153
+ version stored, or for a `create`, from its recorded changes. A stored type
154
+ that names no class raises `InvalidEndpointError`.
155
+ - A `has_many :through` with `source_type:` holds only the records reached
156
+ through join records of that type, in every method. History used to ignore
157
+ the option, so a join record pointing at another type made its target a
158
+ member: a holder whose links named a tag reported the tag among its authors,
159
+ and the tag's changes opened activity steps and selected the holder in
160
+ `report`. The current record was read correctly, so a comparison with it
161
+ reported the tag as removed. A join record whose type changes now adds or
162
+ removes its target in the step of that version.
163
+ - A scoped or default-scoped association read from history now holds the
164
+ records that met the scope at each point. It used to ignore the scope's
165
+ conditions for history: a record that left the scope was reported as
166
+ changed instead of removed, one that entered it as changed instead of
167
+ added, and a record outside the scope could be reported as removed when it
168
+ was destroyed or moved. Activity reports entering or leaving the scope as
169
+ an addition or removal at the version that did it.
170
+ - `compare` and `compare_many` between two versions report a `has_many` child
171
+ that moved to another owner afterwards. They reported no change: the
172
+ comparison read associations through PT-AT, which finds children by the keys
173
+ their versions recorded after each change, so the child was not found at the
174
+ earlier version. Version pairs now read the same prepared history as a
175
+ comparison with the current record, including for a record that was deleted.
176
+ - Historical analysis through a polymorphic `belongs_to` works in every method.
177
+ Only comparisons between two versions used to work: timelines, analyses,
178
+ reports, and comparisons with the current record raised `ArgumentError`.
179
+ Batches load the targets with one query per target class. A target class
180
+ without `has_paper_trail` raises `UnversionedAssociationError` when a record
181
+ names it, rather than a `NoMethodError` from PT-AT, and a default-scoped
182
+ target class is read as its association returns it. A type that names no
183
+ class, such as one a renamed model left in its versions, raises
184
+ `IncompleteAssociationHistoryError` naming the record and the type, rather
185
+ than a `NameError` that names neither.
186
+ - Activity leaves out the `belongs_to` target a record moves to in the
187
+ version that closes the window. Its changes inside the window used to add
188
+ empty steps, attributed to a record the root never pointed at there.
189
+ - Activity reports a change to a `belongs_to` target, or to the source of a
190
+ `has_many :through`, when the record pointing at it did not change in the
191
+ window: an author renamed under an article nobody edited. It used to report
192
+ nothing. A record that changed its target in the window also reports
193
+ changes to the target it pointed at before.
194
+ - `include_deleted: true` with `activity: true` reports the association
195
+ activity of a deleted record that did not change itself in the window. Its
196
+ analysis used to be empty.
197
+ - History of an association to an STI subclass, such as
198
+ `has_many :pinned_badges, class_name: 'PinnedBadge'`, holds only that class
199
+ and its subclasses, as the live association does. It used to hold the
200
+ sibling classes' records too, so a range ending at the current record
201
+ reported them as removed. Activity used to report a sibling's changes under
202
+ the association too, and to open a step for each version of a sibling or of
203
+ its children.
204
+ - Activity reports a record whose STI type changed in the step of the version
205
+ that changed it, as a removal of the old type and an addition of the new,
206
+ and adds it to or removes it from an association to an STI subclass there.
207
+ It used to add the new type alongside the old one and leave the old one in
208
+ place until a later step, or to the end of a range ending at a version.
209
+ - `activity_timeline`, and `analyze` with `activity: true`, return no steps for
210
+ a range that ends at a version when `version_scope:` keeps none of its
211
+ versions, as a checkpoint timeline does. They used to raise `NoMethodError`.
212
+ - `analyze_scope` and `report` list `unreachable` in the same sorted order
213
+ whatever the type of `versions.item_id`. With a string `item_id` it used to
214
+ follow the database's order.
215
+ - On SQL Server, activity places a version saved within two milliseconds of a
216
+ window's start or end on the side it was saved. The window's bounds used to
217
+ be rounded to 1/300 second, so such a version could be read as outside the
218
+ window, or one just past its end as inside it.
219
+ - A `has_many :through` that goes through a `has_and_belongs_to_many`, as its
220
+ `through:`, its `source:`, or further down its chain, raises
221
+ `UnsupportedAssociationError` up front in every method, naming the path and
222
+ the HABTM. Comparing two versions used to raise `NoMethodError` from inside
223
+ PT-AT, and analyses `ActiveRecord::StatementInvalid`. A `has_many :through`
224
+ Active Record cannot follow at all, such as one through a polymorphic
225
+ `belongs_to`, likewise raises Active Record's own error in every method,
226
+ where historical work used to fail partway with `ArgumentError` or
227
+ `NoMethodError`.
228
+ - `association_paths` leaves out a `has_many :through` that goes through a
229
+ `has_and_belongs_to_many`, so every path it lists can be requested. It
230
+ decides with the same check a request makes. It used to list such a path,
231
+ and a settings screen built from it offered one that failed when picked. A
232
+ model it reaches with a `has_many :through` Active Record cannot follow,
233
+ such as one through a polymorphic `belongs_to`, raises Active Record's own
234
+ error, where discovery used to raise `ArgumentError`, `NameError`, or
235
+ `NoMethodError` from inside the reflection, or list a path that raised when
236
+ requested.
237
+ - A `has_many :through` whose source is a `has_many` follows records that
238
+ move. A comment moved to another article leaves the first article's author
239
+ and joins the other's, and an article that moves to another author takes its
240
+ comments with it. Analyses, timelines, and comparisons with the current
241
+ record used to report no change for such a move, and could report a record
242
+ that left and came back as changed. They read these associations through
243
+ PT-AT, which finds a record by the keys its later versions recorded.
244
+ - A `has_many :through` whose `through:` is itself a `has_many :through`, such
245
+ as `has_many :comment_reviewers, through: :comments, source: :reviewer` with
246
+ `comments` through `articles`, reports its changes in analyses, timelines,
247
+ and batches. It used to read as empty at every point, so it never reported a
248
+ change.
249
+
250
+ ### Development
251
+
252
+ - Mutation testing also covers `ScopeCondition`, which decides the records an
253
+ association scope admits. The reconstruction probe has a `scoped` scenario.
254
+ - The consistency specs also check that activity selection includes every
255
+ record whose analysis reports a change.
256
+ - Mutation testing also covers `TimeRange`, `VersionRange`,
257
+ `TimeVersionRange`, `BatchedRootVersions`, and `DeletedAnalysisBatch`, each
258
+ with a unit spec of its own; all 5,485 mutations across the fourteen classes
259
+ are caught.
260
+ - Mutation testing also covers `RecordedChanges`; all 257 of its mutations are
261
+ caught.
262
+ - Mutation testing also covers the association code: `ScopedRootSelection`,
263
+ `AssociationActivitySelection`, `AssociationActivityEdge`,
264
+ `ActivityTargetCandidateLoader`, `PreparedHistoryLoader`,
265
+ `PreparedEdgeLoader`, `ActivityTimelineBuilder`,
266
+ `ActivityTransactionGrouper`, and `BatchSafety`, each with a spec of its
267
+ own. All 10,732 mutations across the 24 classes are caught.
268
+ - Mutation testing also covers `AssociationTraversal`, which decides which
269
+ association paths a request accepts, with a spec of its own; all 764 of its
270
+ mutations are caught.
271
+ - Mutation testing also covers range and boundary handling:
272
+ `ActivityRange`, `VersionAssociationCandidateScope`, and
273
+ `PreparedActivityIndex`, each with a spec of its own; all 1,507 of their
274
+ mutations are caught.
275
+ - Mutation testing also covers `HabtmTransactionOwners`, which decides whether
276
+ a transaction's HABTM members can be read as one record's, with a spec of
277
+ its own.
278
+ - `benchmark/investigation/reconstruction.rb` runs again: the association
279
+ fixtures load the monotonic test clock they use. Its `prepared-through`
280
+ experiment and `benchmark/investigation/prepared_through_override.rb` are
281
+ removed, since the gem now reads those throughs that way.
282
+ - `spec/association/through_membership_spec.rb` writes out the expected
283
+ membership of a `has_many :through` with a `has_many` source over moves,
284
+ deletions, transactions, and changes saved without a version, alone and in
285
+ a batch.
286
+ - `ActivityEventRouteFinder` no longer checks for a polymorphic `belongs_to`
287
+ before following a path below it. Validation already rejects such a path,
288
+ so the check could not be reached.
289
+ - The `PreparedHistoryLoader` spec checks that an edge into a versioned model
290
+ with records stays prepared. A full mutation run of the 29 classes now
291
+ catches all 13,284 mutations.
292
+
6
293
  ## [0.13.1] - 2026-10-28
7
294
 
8
295
  ### Fixed
data/QUICKSTART.md CHANGED
@@ -203,9 +203,10 @@ report.analyses.each_value { |analysis| analysis.diff.to_h }
203
203
  are selected, the call raises instead of returning a partial report. Omit
204
204
  `include_deleted:` if you only need articles that still exist.
205
205
 
206
- An article needs its own PaperTrail version in the time window to be selected.
207
- Editing a comment alone does not select its article. A relation's `where`
208
- conditions check current values; they cannot check a deleted article's values.
206
+ An article is selected when it has a PaperTrail version in the time window. With
207
+ `associations: [:comments]`, editing a comment alone also selects its article. A
208
+ relation's `where` conditions check current values; they cannot check a deleted
209
+ article's values.
209
210
 
210
211
  For one article, use a window ending now:
211
212
 
@@ -374,9 +375,11 @@ window to select that article.
374
375
  change. Pass `article` to read the current saved values.
375
376
  - PaperTrail's creation version returns `nil` from `reify`. There was no record
376
377
  before creation; the diff reports the record being added.
377
- - A range ending at the current article includes changes PaperTrail never
378
- recorded, such as `update_columns`. Pass a version as `to:` to stop at
379
- recorded history.
378
+ - Changes PaperTrail never recorded, such as `update_columns`, still show up:
379
+ in the step for the article's previous recorded change, credited to its
380
+ author. Passing a version as `to:` leaves out only the ones made after it.
381
+ `PaperTrailDiff.recorded_changes(version)` reads just what a version
382
+ recorded.
380
383
  - `ignore:` replaces the default list. Include `updated_at` yourself when using
381
384
  a custom list and you still want it ignored.
382
385
  - Historical associations require PT-AT to be installed, loaded, migrated, and
data/README.md CHANGED
@@ -121,6 +121,10 @@ responsible for that change, including its `whodunnit`, `event`, `item_type`,
121
121
  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
+ 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).
124
128
 
125
129
  `group: :transaction` combines changes from one database transaction. Its source
126
130
  information comes from the first event in that group. Omit `group:` if you need
@@ -172,16 +176,24 @@ unavailable = report.deleted.select { |_identity, analysis| analysis.nil? }.keys
172
176
  deleted_activity = report.deleted.transform_values do |analysis|
173
177
  analysis&.activity_timeline
174
178
  end
179
+ last_state = report.deleted.transform_values do |analysis|
180
+ analysis&.activity_timeline&.last&.diff&.record_presence_change&.from
181
+ end
175
182
  ```
176
183
 
177
184
  The results are already computed; you do not need to query or reify each deleted
178
- record. Omit `include_deleted:` if you only need records that still exist.
185
+ record. `last_state` is each record just before its deletion, which the final
186
+ step removes. Use it to show what a deleted record was, such as its title:
187
+ `analysis.from_snapshot` is the record when the window began, so it is `nil` for
188
+ one created inside the window. Omit `include_deleted:` if you only need records
189
+ that still exist.
179
190
 
180
191
  Three selection rules matter:
181
192
 
182
- - The record itself needs a PaperTrail version in the time window. For example,
183
- editing only a comment does not select its article. Comment activity is included
184
- for selected articles when you request `associations: [:comments]`.
193
+ - A record is selected when it, or a record on one of the `associations:` you
194
+ request, has a PaperTrail version in the time window. With
195
+ `associations: [:comments]`, editing only a comment selects its article, and
196
+ the article's report shows the edit.
185
197
  - A relation such as `Article.where(status: "open")` checks today's values.
186
198
  It cannot establish whether deleted articles matched that condition. Deleted
187
199
  records are therefore listed separately.
@@ -197,7 +209,64 @@ for pagination, deleted records, and selecting by past values with
197
209
  `historical_filter:`.
198
210
 
199
211
  For a fixed past window, use `analyze_scope(Article.all, within: start_time...end_time,
200
- limit: 100)`.
212
+ activity: true, limit: 100)`. Without `activity: true`, `analyze_scope` compares
213
+ each record's own versions, so it selects only records with a version of their
214
+ own.
215
+
216
+ ## 4. Turn a report into rows
217
+
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:
221
+
222
+ <!-- executable:recipe-summary -->
223
+ ```ruby
224
+ report = PaperTrailDiff.report(
225
+ Article.all, since: 1.day.ago, limit: 100,
226
+ associations: [:comments], include_deleted: true
227
+ )
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
257
+ end
258
+ ```
259
+
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.
201
270
 
202
271
  ## Choose a method
203
272
 
@@ -225,10 +294,16 @@ method inside a loop. They share history loading across records. Ordinary
225
294
  record and direct-association histories can be read without creating a model
226
295
  instance for every historical state.
227
296
 
228
- Some associations still need per-record queries. Batch methods reject scoped
229
- associations and certain `has_many :through` histories by default with
230
- `UnsupportedBatchPathError`. The error names the association. Remove it from
231
- `associations:`, or pass `allow_expensive: true` if you accept that cost.
297
+ Scopes and default scopes made of plain conditions, such as
298
+ `-> { where(archived: false) }`, are batched too, and so is a `limit` or
299
+ `offset` ordered by integer, boolean, date, or datetime columns down to the
300
+ 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.
232
307
  Callbacks and callable historical filters can also issue queries. See the
233
308
  [performance guide](docs/performance.md) for measured behavior and limitations.
234
309
 
data/docs/architecture.md CHANGED
@@ -36,9 +36,26 @@ only the association paths you request:
36
36
 
37
37
  From that, the gem builds each state without querying again. Plain recorded
38
38
  attributes are read straight from the stored version, without creating a model
39
- instance. Models with initialization callbacks, custom attribute types or
40
- defaults, default scopes, or encrypted attributes are rebuilt with PaperTrail's
41
- `reify` instead, so their behavior is kept.
39
+ instance, including the members of a `has_many :through` and the target of a
40
+ polymorphic `belongs_to`. Models with initialization callbacks, custom
41
+ attribute types or defaults, default scopes that assign attributes, or
42
+ encrypted attributes are rebuilt with PaperTrail's `reify` instead, so their
43
+ behavior is kept.
44
+
45
+ An association scope or a target's default scope is read once into a
46
+ `ScopeCondition`: the `where` conditions of the relation it builds, kept only
47
+ when each is an equality, `IN`, or `nil` test, or a negation of one, on the
48
+ target's own column of a type Ruby compares as the database does. A record
49
+ belongs to the association at a point when its key matches and its state
50
+ there meets the condition. A `limit`, `offset`, or `has_one` order is read
51
+ into a `ScopeRanking` when its order is total and every key compares in Ruby
52
+ as the database sorts it, including where each adapter puts NULL; the records
53
+ that meet the condition are then sorted, offset, and limited as the
54
+ association's query would. A `has_many :through` reads its parts' conditions
55
+ only, at any depth, as its query joins them, and ranks by its own order the
56
+ rows the join yields, a record reached twice counting twice. Activity reads a scoped branch again at each event
57
+ on it rather than patching the previous snapshot, since any edit can move a
58
+ record into or out of the scope, and with a ranking, move another record too.
42
59
 
43
60
  Each rebuilt state is an immutable snapshot. Activity timelines carry the
44
61
  previous snapshot forward and replace only the parts an event changed, so an
@@ -51,17 +68,23 @@ These association shapes cannot be read from the loaded history. The gem asks
51
68
  PT-AT to rebuild them at each point instead, which costs queries per record and
52
69
  resolves association membership by timestamp only:
53
70
 
54
- - associations with a scope, including `limit` and owner-dependent scopes;
55
- - targets with a default scope;
71
+ - associations with a scope the gem cannot read as plain conditions and an
72
+ exact ranking, including owner-dependent scopes and a `limit` without a
73
+ total integer order;
74
+ - targets with such a default scope;
56
75
  - targets that are not versioned;
57
- - composite foreign keys;
58
- - `has_many :through` associations whose source is not a `belongs_to`.
76
+ - composite foreign keys.
59
77
 
60
- Batch methods reject the scoped and through cases unless you pass
78
+ Batch methods reject the scoped cases unless you pass
61
79
  `allow_expensive: true`; see [costly associations](reference.md#costly-associations).
62
80
 
63
81
  HABTM membership comes from the snapshots PT-AT records with each transaction,
64
- so it is only known at root versions saved in a transaction.
82
+ so it is only known at root versions saved in a transaction. Those snapshots
83
+ name the transaction and the association but not the owner, so they give only
84
+ the root's own membership, and a HABTM below the root is rejected before any
85
+ history is read. Even at the root, a transaction in which another record with a
86
+ HABTM of the same name saved a version holds both records' members;
87
+ `HabtmTransactionOwners` finds those transactions, and reading one raises.
65
88
 
66
89
  ## Database indexes
67
90