paper_trail_diff 0.3.1 → 0.5.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -0
  3. data/README.md +316 -32
  4. data/lib/paper_trail_diff/activity_boundary.rb +21 -0
  5. data/lib/paper_trail_diff/activity_event_route_finder.rb +24 -44
  6. data/lib/paper_trail_diff/activity_root_steps.rb +27 -0
  7. data/lib/paper_trail_diff/activity_snapshot_sequence.rb +8 -3
  8. data/lib/paper_trail_diff/activity_timeline_builder.rb +47 -32
  9. data/lib/paper_trail_diff/analysis.rb +9 -0
  10. data/lib/paper_trail_diff/analysis_batch.rb +74 -0
  11. data/lib/paper_trail_diff/batch_boundary_resolver.rb +137 -0
  12. data/lib/paper_trail_diff/batched_root_analyzer.rb +50 -0
  13. data/lib/paper_trail_diff/batched_root_versions.rb +131 -0
  14. data/lib/paper_trail_diff/collection_comparator.rb +14 -1
  15. data/lib/paper_trail_diff/collection_transition.rb +6 -3
  16. data/lib/paper_trail_diff/comparison_batch.rb +37 -13
  17. data/lib/paper_trail_diff/endpoint.rb +27 -2
  18. data/lib/paper_trail_diff/errors.rb +3 -0
  19. data/lib/paper_trail_diff/historical_association_reifier.rb +8 -5
  20. data/lib/paper_trail_diff/historical_snapshot_store.rb +12 -9
  21. data/lib/paper_trail_diff/paper_trail_adapter.rb +49 -33
  22. data/lib/paper_trail_diff/prepared_association_reifier.rb +8 -5
  23. data/lib/paper_trail_diff/prepared_edge_loader.rb +6 -4
  24. data/lib/paper_trail_diff/prepared_history_loader.rb +3 -2
  25. data/lib/paper_trail_diff/prepared_record_index.rb +90 -15
  26. data/lib/paper_trail_diff/snapshot.rb +26 -0
  27. data/lib/paper_trail_diff/snapshot_normalizer.rb +14 -2
  28. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +52 -32
  29. data/lib/paper_trail_diff/time_version_range.rb +11 -0
  30. data/lib/paper_trail_diff/timeline_builder.rb +3 -3
  31. data/lib/paper_trail_diff/timeline_range.rb +66 -6
  32. data/lib/paper_trail_diff/traversal_preparer.rb +40 -0
  33. data/lib/paper_trail_diff/version.rb +1 -1
  34. data/lib/paper_trail_diff/version_association_candidate_scope.rb +46 -19
  35. data/lib/paper_trail_diff.rb +37 -5
  36. data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -0
  37. data/sig/generated/paper_trail_diff/activity_event_route_finder.rbs +10 -17
  38. data/sig/generated/paper_trail_diff/activity_root_steps.rbs +13 -0
  39. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +2 -2
  40. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +20 -10
  41. data/sig/generated/paper_trail_diff/analysis.rbs +5 -0
  42. data/sig/generated/paper_trail_diff/analysis_batch.rbs +42 -0
  43. data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +62 -0
  44. data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +28 -0
  45. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +62 -0
  46. data/sig/generated/paper_trail_diff/collection_comparator.rbs +7 -0
  47. data/sig/generated/paper_trail_diff/collection_transition.rbs +4 -1
  48. data/sig/generated/paper_trail_diff/comparison_batch.rbs +15 -0
  49. data/sig/generated/paper_trail_diff/endpoint.rbs +12 -0
  50. data/sig/generated/paper_trail_diff/errors.rbs +4 -0
  51. data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +1 -1
  52. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +2 -3
  53. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +16 -7
  54. data/sig/generated/paper_trail_diff/prepared_association_reifier.rbs +1 -1
  55. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +1 -0
  56. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -2
  57. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +39 -6
  58. data/sig/generated/paper_trail_diff/snapshot.rbs +20 -0
  59. data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +8 -0
  60. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +21 -10
  61. data/sig/generated/paper_trail_diff/time_version_range.rbs +7 -0
  62. data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
  63. data/sig/generated/paper_trail_diff/timeline_range.rbs +35 -2
  64. data/sig/generated/paper_trail_diff/traversal_preparer.rbs +22 -0
  65. data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +17 -4
  66. data/sig/generated/paper_trail_diff.rbs +13 -3
  67. metadata +16 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6aa099f4051971c69ed9b957e68ed4acca7e3cdde7c97efd2cb9a1af90d91dd1
4
- data.tar.gz: 67ef88d6978b8b559a1a156f52aaea97a6535c71ec04df5d913f062114ec7a43
3
+ metadata.gz: a4d3c1e15ef20c4e3d05f5c0442d7e55cd0eb811a15766d2119a3cf35b2e729c
4
+ data.tar.gz: 4cc8dceb10e7aa71f1feb7d9fcc3917040bec4b2cc69641b4b3817d6761aad94
5
5
  SHA512:
6
- metadata.gz: d916bf31322056bc95bfe39d7640207f2ddc6b30f154ca8e517b4615558ae728b1642f01040bb793ff1f258b3b77113ab6564f6f24daae9a23b09114ec7a6dca
7
- data.tar.gz: b39856d07d0d335d12f7e1855b67f07b2a84443ff799f48a77e9b6a41609547310927fe8b8b69e32add5fbb30711982af3b125a80a40a59b1185a6ae8d1eb4fb
6
+ metadata.gz: 3887a515ee90f1c14b890a21d4a91b4892f4097b74c9ec27f136fd8476f01cd285c0a310a03b0c528e709cdca299bca2e243522844d09a86d272c043e48879b1
7
+ data.tar.gz: a1390db78cc31cbf20420fd18b536ad8cd826de4927943cbdddc08b7a31f9dad5f81acee77db97059f044ad435bd3170e54f8ef2ccd5c68f3e256bfa5d848642
data/CHANGELOG.md CHANGED
@@ -3,6 +3,87 @@
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.5.0] - 2026-08-11
7
+
8
+ ### Added
9
+
10
+ - Add `analyze_many`, which analyzes many roots over one shared `within:` window,
11
+ or over each root's whole history when the window is omitted, selecting their
12
+ versions and preparing their history once for the batch. Query cost is flat in
13
+ the number of roots for the endpoint diff and checkpoint timeline; descendant
14
+ event discovery under `activity: true` remains per-root. A root with no
15
+ versions in range returns an empty `Analysis`, and roots are supplied as live
16
+ records so a root deleted inside the window cannot be included.
17
+ - Accept `:first` and `:last` as `compare_many` endpoints, resolved against the
18
+ record the pair's other endpoint names, in two queries per model class rather
19
+ than one lookup per root. A root with no recorded history compares as an empty
20
+ `Diff`, and an unanchored `{ from: :first, to: :last }` raises.
21
+ - Accept `reload_live_endpoints:` on `activity_timeline`, which reads live state
22
+ whenever `to:` is a current record but previously had no way to reuse an
23
+ already-preloaded graph. `timeline` and `analyze` are bounded by versions and
24
+ never read live state, so the option is deliberately absent there.
25
+
26
+ ## [0.4.0] - 2026-08-11
27
+
28
+ ### Added
29
+
30
+ - Close an activity timeline that ends at the root's own `destroy` version with
31
+ a step into a new `kind: :destroyed` boundary, reporting the record's removal
32
+ as a `record_presence_change` to `nil`. `ActivityBoundary` gains a
33
+ `destroyed?` predicate, so consumers branching only on `version?` should
34
+ handle the third kind. `compare`, `timeline`, and `analyze`'s endpoint diff
35
+ are unchanged.
36
+ - Accept a `within:` window whose last selected mutation is the root's own
37
+ destruction, which no later root version can follow, instead of raising
38
+ `IncompleteTimeRangeError` for a range that could never be satisfied. A
39
+ destruction outside the window remains reconstruction context only.
40
+ - Accept `:first` and `:last` as `from:` and `to:` boundaries on `timeline`,
41
+ `activity_timeline`, and `analyze`, resolved without depending on the order
42
+ the `versions` association happens to use. A record with no versions resolves
43
+ to an empty timeline rather than raising, so listing pages need no special
44
+ case.
45
+
46
+ ### Removed
47
+
48
+ - Reject two version endpoints given in reverse chronological order with the
49
+ new `PaperTrailDiff::ReversedEndpointsError`, in `compare` and
50
+ `compare_many`. A transposed pair silently produced the inverse diff, and a
51
+ result carries no direction that would reveal it. A current-record endpoint
52
+ may still appear on either side.
53
+
54
+ ### Fixed
55
+
56
+ - Rebuild prepared scalar state with direct attribute writes so a model that
57
+ overrides an attribute writer reconstructs the state PaperTrail recorded
58
+ instead of reapplying the override.
59
+
60
+ ### Changed
61
+
62
+ - Order collection `added`, `removed`, and `changed` results naturally within
63
+ one id type instead of by the printed form, so numeric ids sort `2` before
64
+ `10`. Ordering remains deterministic and total for mixed id types.
65
+ - Resolve a boundary's prepared record state by chronological search and an
66
+ indexed boundary transaction instead of scanning a record's versions, so
67
+ timelines over long single-record histories stay linear in their step count.
68
+ - Bound prepared scalar history at the selected range plus one trailing version
69
+ per identity, instead of every version recorded between the range and the
70
+ present, so a short range early in a long history no longer pays for the
71
+ history after it.
72
+ - Document that `timeline` and `analyze(activity: true)` produce the same
73
+ root-checkpoint steps through different reconstruction strategies, and when
74
+ each one is cheaper.
75
+ - Borrow a connection through `with_connection` where Active Record provides
76
+ it, so applications that opt into deprecating permanent checkouts no longer
77
+ see a deprecation warning from association candidate selection.
78
+ - Identify carried-forward collection snapshots and per-pass reification by
79
+ an owned serial and by record identity, rather than by `object_id`, which
80
+ Ruby only guarantees to be unique among live objects.
81
+ - Retain activity-event routes for every event type a timeline visits, rather
82
+ than only the most recent one, so interleaved descendant types stop
83
+ rediscovering the same routes.
84
+ - Resolve excluded attributes once per model class and selected path, and key
85
+ historical child identities instead of rescanning them.
86
+
6
87
  ## [0.3.1] - 2026-08-10
7
88
 
8
89
  ### Fixed
data/README.md CHANGED
@@ -44,12 +44,84 @@ PaperTrail.config.track_associations = true
44
44
  All associated models whose historical state is compared must use
45
45
  `has_paper_trail`.
46
46
 
47
+ ## How PaperTrail records state
48
+
49
+ Almost everything else in this README follows from one property of PaperTrail,
50
+ so it is worth being precise about it first: **a version stores the state that
51
+ existed _before_ the event that created it.** A version is a record of what was
52
+ overwritten, not of what was written.
53
+
54
+ Take an article created as `"Draft"`, then updated to `"Published"`, then to
55
+ `"Final"`:
56
+
57
+ ```text
58
+ v1 v2 v3 (no version)
59
+ "create" "update" "update"
60
+ | | | |
61
+ ──────●───────────────●─────────────────●─────────────────●──────▶ time
62
+ | | | |
63
+ └─── "Draft" ───┘ | |
64
+ stored in v2 | |
65
+ └─ "Published" ───┘ |
66
+ stored in v3 |
67
+ └──── "Final" ────┘
68
+ only in the table
69
+ ```
70
+
71
+ Each state is stored by the version at the *end* of the interval it was live
72
+ for. Three consequences run through the rest of this document:
73
+
74
+ - **A `create` version reifies to `nil`.** Nothing preceded it. Comparing it
75
+ with a later state reports a structured `record_presence_change` rather than
76
+ inventing scalar changes.
77
+ - **The newest state has no version at all.** It exists only in the table. A
78
+ fully historical result therefore needs a version *later* than the last change
79
+ it should reveal, which is why a `within:` window can raise
80
+ `IncompleteTimeRangeError`, and why `activity_timeline(..., to: article)`
81
+ exists for ending at current state instead.
82
+ - **A change is visible between two boundaries**, never "at" one. This is why
83
+ both timeline APIs return steps rather than events.
84
+
85
+ The [Quickstart](QUICKSTART.md) walks through the same idea against a real
86
+ console session.
87
+
88
+ ## Choosing an entry point
89
+
90
+ | You need | Call |
91
+ | --- | --- |
92
+ | The net difference between two endpoints | `compare` |
93
+ | The same, for many records in one pass | `compare_many` |
94
+ | One step per version of the root record | `timeline` |
95
+ | One step per version of the root *or a selected child* | `activity_timeline` |
96
+ | A net difference and a timeline from one history pass | `analyze` |
97
+
98
+ `timeline` and `activity_timeline` differ only in which recorded versions
99
+ become boundaries. Given an article with two comment edits between two article
100
+ versions:
101
+
102
+ ```text
103
+ recorded versions A1 C1 C2 A2 A = Article version
104
+ | | | | C = Comment version
105
+ ────●─────────●───────●─────────●────▶ time
106
+ | | | |
107
+ timeline └───────── 1 step ──────────┘
108
+ both comment changes land inside that one step
109
+
110
+ activity_timeline └── 1 ────┴── 2 ──┴─── 3 ───┘
111
+ each recorded version becomes its own boundary
112
+ ```
113
+
114
+ Both report the same underlying data: a `timeline` step still contains every
115
+ selected association, because it has to describe what changed beneath the root.
116
+ They differ in how finely that change is split, and in what the reconstruction
117
+ costs. That cost trade-off is covered under
118
+ [choosing a reconstruction strategy](#choosing-between-timeline-and-analyzeactivity-true).
119
+
47
120
  ## Compare two endpoints
48
121
 
49
- PaperTrail stores an object's state before each recorded event. `compare`
50
- accepts two explicit endpoints: each may be a PaperTrail version or a clean,
51
- persisted model instance representing current database state. It reports only
52
- their net difference:
122
+ `compare` accepts two explicit endpoints: each may be a PaperTrail version or a
123
+ clean, persisted model instance representing current database state. It reports
124
+ only their net difference:
53
125
 
54
126
  ```ruby
55
127
  diff = PaperTrailDiff.compare(article.versions[1], article.versions[4])
@@ -69,10 +141,21 @@ diff.to_h
69
141
 
70
142
  Intermediate edits do not affect `compare`. If a title changes and later
71
143
  returns to its original value, endpoint comparison reports no title change.
72
- A `create` version reifies to `nil`; comparing it with a record state produces
73
- a structured `record_presence_change` instead of fake scalar changes. For
74
- ordinary updates, `record_presence_change` is `nil`, meaning the root record is
75
- present at both endpoints; scalar changes remain under `attributes`.
144
+
145
+ `record_presence_change` reports whether the root record *existed* at each
146
+ endpoint, and is `nil` for the ordinary case where it existed at both. It is
147
+ populated when one endpoint reifies to `nil` most often a `create` version,
148
+ whose pre-change state is the absence of the record — and it then carries whole
149
+ `RecordSnapshot` values rather than fake scalar changes from `nil`.
150
+
151
+ Destroying the root record is not reported this way, and the reason follows
152
+ from the [pre-change model](#how-papertrail-records-state): the state *at* a
153
+ `destroy` version is the state immediately before the deletion, so the record
154
+ is still present there. A comparison ending at a `destroy` version reports that
155
+ last edit, not the deletion. Deleting a *selected child* is reported normally,
156
+ as a `removed` member of its parent's collection, and `activity_timeline`
157
+ reports a destroyed root through a
158
+ [closing removal step](#closing-a-destroyed-root).
76
159
 
77
160
  Pass the record explicitly when the desired endpoint is current state:
78
161
 
@@ -84,10 +167,18 @@ diff = PaperTrailDiff.compare(
84
167
  )
85
168
  ```
86
169
 
87
- Version and record endpoints may appear in either order. Current state is never
88
- inferred. The record must be persisted, not destroyed, and free of unsaved
89
- attribute changes. The gem reloads it unscoped before normalization, so stale
90
- association caches and in-memory edits are not compared. Use a database
170
+ Two version endpoints must be given in chronological order. A transposed pair
171
+ produces the inverse diff, which is easy to do by accident and impossible to
172
+ detect afterwards because a result carries no direction of its own, so it
173
+ raises `PaperTrailDiff::ReversedEndpointsError` instead. Nothing is lost by
174
+ this: the two orders differ only in which side of each change is `from`.
175
+
176
+ A current-record endpoint is exempt and may appear on either side, because it
177
+ is self-evidently the live state and placing it first is a deliberate reverse
178
+ comparison. Current state is never inferred. The record must be persisted, not
179
+ destroyed, and free of unsaved attribute changes. The gem reloads it unscoped
180
+ before normalization, so stale association caches and in-memory edits are not
181
+ compared. Use a database
91
182
  transaction with an appropriate isolation level when several live association
92
183
  queries must represent one atomic application snapshot.
93
184
 
@@ -96,19 +187,47 @@ preload each explicitly selected live association path across the batch. Each
96
187
  entry has the same endpoints and options as `compare`; results are returned in
97
188
  input order as a frozen hash keyed by `[item_type, item_id]` strings:
98
189
 
190
+ Endpoints are still supplied by the caller, so look them up in bulk too —
191
+ otherwise the per-root queries this API removes come straight back. Two
192
+ queries resolve the earliest version of every root, whatever the batch size:
193
+
194
+ ```ruby
195
+ orders = Order.where(id: order_ids).to_a
196
+
197
+ earliest_ids = PaperTrail::Version
198
+ .where(item_type: "Order", item_id: orders.map(&:id))
199
+ .group(:item_id)
200
+ .minimum(:id)
201
+ first_versions = PaperTrail::Version
202
+ .where(id: earliest_ids.values)
203
+ .index_by(&:item_id)
204
+ ```
205
+
99
206
  ```ruby
100
207
  diffs = PaperTrailDiff.compare_many(
101
- [
102
- { from: first_versions.fetch(order_a.id), to: order_a },
103
- { from: first_versions.fetch(order_b.id), to: order_b }
104
- ],
208
+ orders.map { |order| { from: first_versions.fetch(order.id), to: order } },
105
209
  associations: [:line_items],
106
210
  ignore: []
107
211
  )
108
212
 
109
- diffs.fetch(["Order", order_a.id.to_s]) # => PaperTrailDiff::Diff
213
+ diffs.fetch(["Order", orders.first.id.to_s]) # => PaperTrailDiff::Diff
214
+ ```
215
+
216
+ Endpoints may also be given as `:first` or `:last`, resolved against the record
217
+ the pair's other endpoint names. That replaces the lookup above entirely, and
218
+ resolves every root in two queries per model class rather than one per root:
219
+
220
+ ```ruby
221
+ diffs = PaperTrailDiff.compare_many(
222
+ orders.map { |order| { from: :first, to: order } },
223
+ associations: [:line_items]
224
+ )
110
225
  ```
111
226
 
227
+ A symbol carries no identity of its own, so `{ from: :first, to: :last }` raises
228
+ rather than guessing. A root with no recorded history resolves to an empty
229
+ `Diff`, matching how the timeline APIs answer the same question.
230
+
112
231
  Root identities must be unique within one call. Historical reconstruction for
113
232
  ordinary versioned, unscoped association paths is also prepared across the
114
233
  collection. Paths that require the existing point-in-time PT-AT fallback retain
@@ -121,11 +240,14 @@ must observe one atomic snapshot.
121
240
 
122
241
  ### Reuse already-preloaded current endpoints
123
242
 
124
- `compare` and `compare_many` reload current endpoints by default. A caller that
125
- already owns a consistent, fully preloaded graph may opt out:
243
+ `compare`, `compare_many`, and `activity_timeline(..., to: record)` reload
244
+ current endpoints by default. A caller that already owns a consistent, fully
245
+ preloaded graph may opt out. The option has no effect on `timeline` or
246
+ `analyze`, which are bounded by versions and never read live state:
126
247
 
127
248
  ```ruby
128
- orders = Order.where(id: ids).preload(line_items: :product).to_a
249
+ orders = Order.where(id: order_ids).preload(line_items: :product).to_a
250
+ # `first_versions` is the same bulk endpoint lookup shown above.
129
251
 
130
252
  diffs = PaperTrailDiff.compare_many(
131
253
  orders.map do |order|
@@ -180,10 +302,67 @@ ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do
180
302
  end
181
303
  ```
182
304
 
305
+ ## Analyze many records over one window
306
+
307
+ `analyze_many` answers "what changed for these records during this period" for a
308
+ whole collection, selecting every root's versions and preparing their selected
309
+ association history once for the batch:
310
+
311
+ ```ruby
312
+ results = PaperTrailDiff.analyze_many(
313
+ Order.where(status: "open").to_a,
314
+ within: Time.zone.parse("2026-08-01")...Time.zone.parse("2026-09-01"),
315
+ associations: [:line_items]
316
+ )
317
+
318
+ results.fetch(["Order", order.id.to_s]).diff # net change across the window
319
+ results.fetch(["Order", order.id.to_s]).timeline # its checkpoint steps
320
+ ```
321
+
322
+ Results are a frozen hash keyed by `[item_type, item_id]` strings, and each
323
+ value is the same `Analysis` that `analyze` returns for one record. A root with
324
+ no versions inside the window gets an empty `Analysis` rather than raising, so a
325
+ listing page needs no special case. Root identities must be unique.
326
+
327
+ Omit `within:` to analyze each root's whole recorded history instead, which is
328
+ the batched equivalent of `analyze(record, from: :first, to: :last)`. Explicit
329
+ version endpoints are not accepted, because a single pair cannot mean the same
330
+ thing for every root.
331
+
332
+ Query cost is flat in the number of roots for the diff and timeline views:
333
+ selecting versions and preparing history are both shared across the batch. On a
334
+ twenty-root batch that is 7 queries against 120 for the same work done one
335
+ record at a time. Passing `activity: true` also works and returns the same
336
+ results, but discovering descendant events is inherently per-root, so that view
337
+ batches far less.
338
+
339
+ Roots are supplied as live records, so a root deleted inside the window cannot
340
+ be included; use `activity_timeline` for a history that ends in a deletion.
341
+
183
342
  ## Build a root-checkpoint timeline
184
343
 
185
- `timeline` accepts two version objects from the supplied record's history. The
186
- range is inclusive, must be chronological, and produces one `Step` for each
344
+ `timeline` accepts two version objects from the supplied record's history, or
345
+ the symbols `:first` and `:last` when the range is simply the record's whole
346
+ recorded history:
347
+
348
+ ```ruby
349
+ steps = PaperTrailDiff.timeline(article, from: :first, to: :last)
350
+ ```
351
+
352
+ `:first` and `:last` are resolved by the gem, independently of the order the
353
+ `versions` association happens to use, so a caller never has to know it is
354
+ sorted. They work anywhere a version does, including mixed with an explicit
355
+ one (`from: :first, to: some_version`) and on `activity_timeline` and
356
+ `analyze`. A record with no versions has no boundaries to resolve; that is an
357
+ empty history rather than a bad request, so the result is an empty timeline
358
+ instead of an error — which is usually what an index page wants.
359
+
360
+ Combine `from: :first` with `to: article` for the fullest activity view of a
361
+ live record: the whole recorded history, ending at current state. See
362
+ [activity timelines](#build-an-activity-timeline) for why that end differs from
363
+ `to: :last`.
364
+
365
+ The range is inclusive, must be chronological, and produces one `Step` for each
187
366
  adjacent pair:
188
367
 
189
368
  ```ruby
@@ -205,7 +384,7 @@ steps.first.to_h
205
384
 
206
385
  Both timeline types expose `from_boundary`, `to_boundary`, `diff`, and
207
386
  `empty?`. A historical boundary has `event`, `whodunnit`, `record`,
208
- `recorded_at`, `version?`, and `current?` readers. Checkpoint `Step` objects
387
+ `recorded_at`, `version?`, `current?`, and `destroyed?` readers. Checkpoint `Step` objects
209
388
  also retain their original `from_version` and `to_version` for callers that
210
389
  need custom PaperTrail metadata. Existing `Step#to_h` and
211
390
  `ActivityBoundary#to_h` shapes remain unchanged; use the readers for the new
@@ -235,7 +414,9 @@ steps.reject(&:empty?)
235
414
  ```
236
415
 
237
416
  Pass the record explicitly as `to:` to include current state without creating a
238
- final root checkpoint:
417
+ final root checkpoint. This is not the same as `to: :last`: descendant
418
+ mutations recorded after the record's final root version fall outside a
419
+ version-bounded range, so only a live end reports them.
239
420
 
240
421
  ```ruby
241
422
  steps = PaperTrailDiff.activity_timeline(
@@ -268,6 +449,46 @@ boundary. Passing `to: article` is what removes the need to touch the parent
268
449
  after an ordinary versioned child mutation; current state is still never
269
450
  implicit.
270
451
 
452
+ ### Closing a destroyed root
453
+
454
+ A `destroy` version is the one boundary whose following state needs no later
455
+ version: the event itself says the record is gone. When an activity timeline
456
+ ends at the root's own `destroy` version, it therefore closes with one more
457
+ step, from that version to a `kind: :destroyed` boundary, whose diff is a
458
+ `record_presence_change` from the record's final state to `nil`:
459
+
460
+ ```ruby
461
+ steps = PaperTrailDiff.activity_timeline(
462
+ article,
463
+ from: article.versions.first,
464
+ to: article.versions.last # a destroy version
465
+ )
466
+
467
+ removal = steps.last
468
+ removal.to_boundary.destroyed? # => true
469
+ removal.to_boundary.kind # => :destroyed
470
+ removal.diff.record_presence_change.from # the state it was deleted in
471
+ removal.diff.record_presence_change.to # => nil
472
+ ```
473
+
474
+ The removal step's `from_boundary` is the ordinary `kind: :version` boundary
475
+ for the same destroy version, since that boundary still holds the record.
476
+ Boundaries therefore have three kinds — `:version`, `:current`, and
477
+ `:destroyed` — so a consumer that branches on `version?` alone should also
478
+ handle `destroyed?`.
479
+
480
+ `analyze(activity: true)` reports the same closing step in its
481
+ `activity_timeline`. Its `diff` and `timeline` keep their `compare` and
482
+ `timeline` semantics and do not report the deletion.
483
+
484
+ A `within:` window behaves the same way. A window whose last selected mutation
485
+ is the root's destruction needs no later root version, because none can ever
486
+ exist, so it closes on the removal instead of raising
487
+ `IncompleteTimeRangeError`. When the destruction falls *outside* the window it
488
+ remains ordinary reconstruction context and is not reported as a selected
489
+ mutation. The same relaxation lets the plain `timeline` accept such a window,
490
+ though it still reports only the edits.
491
+
271
492
  Live-ended HABTM activity is rejected with
272
493
  `PaperTrailDiff::UnsupportedLiveActivityError`. HABTM join rows have no model
273
494
  versions, so the gem cannot reliably split their membership mutations into
@@ -315,7 +536,9 @@ If the window contains a relevant mutation but no later root version exists,
315
536
  the call raises `PaperTrailDiff::IncompleteTimeRangeError`. Create a root
316
537
  checkpoint after the reporting window before running historical analysis. The
317
538
  gem does not silently substitute current database state. A root-only window
318
- with no selected mutation returns a frozen empty timeline.
539
+ with no selected mutation returns a frozen empty timeline. The one exception is
540
+ a window that closes on the root's own destruction, which no later version can
541
+ ever follow; see [closing a destroyed root](#closing-a-destroyed-root).
319
542
 
320
543
  Time ranges and explicit `from:`/`to:` endpoints are mutually exclusive.
321
544
  Malformed, open-ended, or reversed ranges raise
@@ -352,6 +575,37 @@ work is performed. `analyze` accepts explicit historical versions or
352
575
  `within:`, but not a current-record endpoint; use the standalone
353
576
  `activity_timeline(..., to: article)` API when the final boundary must be live.
354
577
 
578
+ ### Choosing between `timeline` and `analyze(activity: true)`
579
+
580
+ `analysis.timeline` and `timeline` return the same root-checkpoint steps for the
581
+ same range. They are two reconstruction strategies for one result, not two
582
+ levels of detail: a `timeline` step covers only root version boundaries, but
583
+ each of its snapshots still contains every selected association, because a step
584
+ must report what changed underneath the root as well.
585
+
586
+ The one behavioural difference is at the edge of a `within:` window. Selected
587
+ descendants can move inside a window that contains no root version at all, so
588
+ the activity form requires a root boundary it can reconstruct from and raises
589
+ `IncompleteTimeRangeError` when there is none. `timeline` has no activity view
590
+ to anchor and returns no steps for that window.
591
+
592
+ The strategies differ in what that costs:
593
+
594
+ - `timeline` reconstructs the whole selected graph independently at every root
595
+ boundary, so it costs roughly *root versions x selected graph size*. It is
596
+ insensitive to how much descendant activity happened in between.
597
+ - `analyze(activity: true)` reconstructs once and then advances that snapshot
598
+ through each recorded mutation, so it costs roughly *one reconstruction +
599
+ total events*. It is insensitive to how wide the selected graph is.
600
+
601
+ Neither dominates. Reconstructing once per checkpoint wins when a few root
602
+ versions span very heavy descendant churn; advancing incrementally wins when
603
+ the selected graph is wide and descendant activity is comparable to root
604
+ activity. As a rule of thumb, prefer `analyze(activity: true)` when selected
605
+ associations are wide, and `timeline` when descendant events greatly outnumber
606
+ root versions. Measure with `ActiveSupport::Notifications` on a representative
607
+ history rather than a seeded example if the choice matters.
608
+
355
609
  `timeline`, `activity_timeline`, and both forms of `analyze` prepare the selected
356
610
  historical range once. The loader walks only the explicit association paths and
357
611
  builds an immutable temporal index of scalar states, relationship candidates,
@@ -387,12 +641,13 @@ retain the general comparator and traversal fallback.
387
641
  Activity event loading is bounded to the selected range. Association identity
388
642
  discovery retains one indexed checkpoint for members present at the starting
389
643
  boundary, then considers later association activity and current members; it no
390
- longer materializes every pre-range association row. Prepared scalar state also
391
- retains later successor versions for selected identities because a PaperTrail
392
- version is a pre-change snapshot and may be the only correct state for an
393
- earlier boundary. Memory therefore scales with relevant selected history, not
394
- only with the number of returned steps. Keep requested paths and ranges
395
- intentional. An activity timeline must also emit a diff for every selected
644
+ longer materializes every pre-range association row. Because a PaperTrail
645
+ version is a pre-change snapshot, the state at a range's final boundary can
646
+ live only in the next version after it, so prepared scalar state retains one
647
+ trailing version per selected identity. It does not retain the rest of the
648
+ history recorded after the range, so a short range early in a long history
649
+ costs the same as the same range in a short one. Keep requested paths and
650
+ ranges intentional. An activity timeline must also emit a diff for every selected
396
651
  event. Repeated events within one wide collection still copy the frozen records
397
652
  array when producing each immutable snapshot, so they can do pointer-copying
398
653
  work proportional to the number of events times the collection width even when
@@ -626,6 +881,29 @@ when join attributes or join mutations must appear as first-class history.
626
881
 
627
882
  ## Result objects
628
883
 
884
+ Every level of a result separates the same two kinds of change in the same way:
885
+
886
+ | | a record appears or disappears | a record stays and its fields change |
887
+ | --- | --- | --- |
888
+ | the root record | `record_presence_change` | `attributes`, `associations` |
889
+ | `has_many`, HABTM | `added`, `removed` | `changed` |
890
+ | `belongs_to`, `has_one` | `relationship` | `changed` |
891
+
892
+ The left column carries whole `RecordSnapshot` values; the right column carries
893
+ field-level `ValueChange` deltas. That split is deliberate. A record that has
894
+ just appeared has no previous value for any of its fields, so reporting one
895
+ `nil` to value change per attribute would both add noise and blur the
896
+ difference between "this field was edited" and "this record did not exist".
897
+
898
+ The consequence is that reading a result tree directly means branching on which
899
+ column applies: a created record's state is under
900
+ `record_presence_change.to.attributes`, an edited record's is under
901
+ `attributes`. Consumers that would rather not branch should use
902
+ [`each_entry`](#traverse-result-trees), which flattens both into one stream —
903
+ an edit arrives as `attribute_changed` carrying a `ValueChange`, and a created
904
+ record's fields arrive as `attribute_included` entries carrying values with
905
+ `state: :after`.
906
+
629
907
  The public result types are:
630
908
 
631
909
  - `PaperTrailDiff::Diff`
@@ -645,7 +923,9 @@ The public result types are:
645
923
  - `PaperTrailDiff::DiagnosticIssue`
646
924
 
647
925
  They expose readers, are frozen after construction, and provide deterministic
648
- `to_h` output. Structural hash keys are symbols; attribute and association
926
+ `to_h` output. Collection results are ordered by record identity: by type, then
927
+ naturally within one id type, so numeric ids sort `2` before `10`. Mixed or
928
+ unusual id types still order totally rather than raising. Structural hash keys are symbols; attribute and association
649
929
  names are strings. Attribute values retain their Ruby types. `RecordChange#record`
650
930
  is a `RecordReference` with `type` and `id` readers. `TraversalEntry#record` and
651
931
  `#association` return the final components of their corresponding paths. `Step`
@@ -671,6 +951,10 @@ and PT-AT can reconstruct. In particular:
671
951
  - HABTM membership is limited to the join snapshots PT-AT recorded in
672
952
  `version_associations`; historical target attributes require versioned target
673
953
  models, otherwise PT-AT may return live target state;
954
+ - `compare`, `timeline`, and `analyze`'s endpoint diff do not report the root
955
+ record's destruction, because the state recorded at a `destroy` version is the
956
+ state before the deletion; `activity_timeline` closes on a `:destroyed`
957
+ boundary instead, and a selected child's removal is reported by its parent;
674
958
  - `timeline` and `analyze` are bounded by root versions; `activity_timeline`
675
959
  adds recorded descendant boundaries and may terminate at an explicitly passed
676
960
  current record, while a fully historical result still requires a later root
@@ -37,6 +37,22 @@ module PaperTrailDiff
37
37
  recorded_at: captured_at
38
38
  )
39
39
  end
40
+
41
+ # The state a `destroy` version leaves behind. A version records the state
42
+ # before its own event, so the boundary built from a destroy version still
43
+ # holds the record; this one is the absence that follows it.
44
+ #: (untyped) -> ActivityBoundary
45
+ def destroyed(version)
46
+ new(
47
+ kind: :destroyed,
48
+ version_id: version.id,
49
+ item_type: version.item_type,
50
+ item_id: version.item_id,
51
+ recorded_at: version.created_at,
52
+ event: version.event,
53
+ whodunnit: version.whodunnit
54
+ )
55
+ end
40
56
  end
41
57
 
42
58
  #: (kind: Symbol, version_id: untyped, item_type: untyped, item_id: untyped, recorded_at: untyped, ?event: untyped, ?whodunnit: untyped) -> void
@@ -70,6 +86,11 @@ module PaperTrailDiff
70
86
  kind == :current
71
87
  end
72
88
 
89
+ #: () -> bool
90
+ def destroyed?
91
+ kind == :destroyed
92
+ end
93
+
73
94
  #: () -> Hash[Symbol, untyped]
74
95
  def to_h
75
96
  {