paper_trail_diff 0.3.1 → 0.4.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +61 -0
  3. data/README.md +260 -30
  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/collection_comparator.rb +14 -1
  11. data/lib/paper_trail_diff/collection_transition.rb +6 -3
  12. data/lib/paper_trail_diff/endpoint.rb +27 -2
  13. data/lib/paper_trail_diff/errors.rb +3 -0
  14. data/lib/paper_trail_diff/historical_association_reifier.rb +8 -5
  15. data/lib/paper_trail_diff/historical_snapshot_store.rb +9 -2
  16. data/lib/paper_trail_diff/prepared_association_reifier.rb +8 -5
  17. data/lib/paper_trail_diff/prepared_edge_loader.rb +6 -4
  18. data/lib/paper_trail_diff/prepared_history_loader.rb +3 -2
  19. data/lib/paper_trail_diff/prepared_record_index.rb +90 -15
  20. data/lib/paper_trail_diff/snapshot.rb +26 -0
  21. data/lib/paper_trail_diff/snapshot_normalizer.rb +14 -2
  22. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +52 -32
  23. data/lib/paper_trail_diff/time_version_range.rb +11 -0
  24. data/lib/paper_trail_diff/timeline_range.rb +57 -4
  25. data/lib/paper_trail_diff/version.rb +1 -1
  26. data/lib/paper_trail_diff/version_association_candidate_scope.rb +46 -19
  27. data/lib/paper_trail_diff.rb +1 -0
  28. data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -0
  29. data/sig/generated/paper_trail_diff/activity_event_route_finder.rbs +10 -17
  30. data/sig/generated/paper_trail_diff/activity_root_steps.rbs +13 -0
  31. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +2 -2
  32. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +20 -10
  33. data/sig/generated/paper_trail_diff/analysis.rbs +5 -0
  34. data/sig/generated/paper_trail_diff/collection_comparator.rbs +7 -0
  35. data/sig/generated/paper_trail_diff/collection_transition.rbs +4 -1
  36. data/sig/generated/paper_trail_diff/endpoint.rbs +12 -0
  37. data/sig/generated/paper_trail_diff/errors.rbs +4 -0
  38. data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +1 -1
  39. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +2 -2
  40. data/sig/generated/paper_trail_diff/prepared_association_reifier.rbs +1 -1
  41. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +1 -0
  42. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -2
  43. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +39 -6
  44. data/sig/generated/paper_trail_diff/snapshot.rbs +20 -0
  45. data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +8 -0
  46. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +21 -10
  47. data/sig/generated/paper_trail_diff/time_version_range.rbs +7 -0
  48. data/sig/generated/paper_trail_diff/timeline_range.rbs +29 -0
  49. data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +17 -4
  50. metadata +6 -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: fac7dbbad6368383d2b73f4c5acbdcabb1599da6c096a5c8363327a8602a1298
4
+ data.tar.gz: 7657cc9ae4b3e8f7d9f1026ac06fb93b6571ba7258bca66183b84053bb8469c0
5
5
  SHA512:
6
- metadata.gz: d916bf31322056bc95bfe39d7640207f2ddc6b30f154ca8e517b4615558ae728b1642f01040bb793ff1f258b3b77113ab6564f6f24daae9a23b09114ec7a6dca
7
- data.tar.gz: b39856d07d0d335d12f7e1855b67f07b2a84443ff799f48a77e9b6a41609547310927fe8b8b69e32add5fbb30711982af3b125a80a40a59b1185a6ae8d1eb4fb
6
+ metadata.gz: adadf85c15151889247d0791ffc944731c781c6dc3f4d23b4a04fa046e489b74f2a6501a16fb53386e6c80c3fae4d6c583d4b91db198bfbe8919a3a19eaf1a4e
7
+ data.tar.gz: 2a3bf4422b73c513981b519d04f0c556574f75ebc08963fab606f872c2428acac2455869675318ae75d8f71bddeb208e952642eeafedae7131b4745b5f36f534
data/CHANGELOG.md CHANGED
@@ -3,6 +3,67 @@
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.4.0] - 2026-08-11
7
+
8
+ ### Added
9
+
10
+ - Close an activity timeline that ends at the root's own `destroy` version with
11
+ a step into a new `kind: :destroyed` boundary, reporting the record's removal
12
+ as a `record_presence_change` to `nil`. `ActivityBoundary` gains a
13
+ `destroyed?` predicate, so consumers branching only on `version?` should
14
+ handle the third kind. `compare`, `timeline`, and `analyze`'s endpoint diff
15
+ are unchanged.
16
+ - Accept a `within:` window whose last selected mutation is the root's own
17
+ destruction, which no later root version can follow, instead of raising
18
+ `IncompleteTimeRangeError` for a range that could never be satisfied. A
19
+ destruction outside the window remains reconstruction context only.
20
+ - Accept `:first` and `:last` as `from:` and `to:` boundaries on `timeline`,
21
+ `activity_timeline`, and `analyze`, resolved without depending on the order
22
+ the `versions` association happens to use. A record with no versions resolves
23
+ to an empty timeline rather than raising, so listing pages need no special
24
+ case.
25
+
26
+ ### Removed
27
+
28
+ - Reject two version endpoints given in reverse chronological order with the
29
+ new `PaperTrailDiff::ReversedEndpointsError`, in `compare` and
30
+ `compare_many`. A transposed pair silently produced the inverse diff, and a
31
+ result carries no direction that would reveal it. A current-record endpoint
32
+ may still appear on either side.
33
+
34
+ ### Fixed
35
+
36
+ - Rebuild prepared scalar state with direct attribute writes so a model that
37
+ overrides an attribute writer reconstructs the state PaperTrail recorded
38
+ instead of reapplying the override.
39
+
40
+ ### Changed
41
+
42
+ - Order collection `added`, `removed`, and `changed` results naturally within
43
+ one id type instead of by the printed form, so numeric ids sort `2` before
44
+ `10`. Ordering remains deterministic and total for mixed id types.
45
+ - Resolve a boundary's prepared record state by chronological search and an
46
+ indexed boundary transaction instead of scanning a record's versions, so
47
+ timelines over long single-record histories stay linear in their step count.
48
+ - Bound prepared scalar history at the selected range plus one trailing version
49
+ per identity, instead of every version recorded between the range and the
50
+ present, so a short range early in a long history no longer pays for the
51
+ history after it.
52
+ - Document that `timeline` and `analyze(activity: true)` produce the same
53
+ root-checkpoint steps through different reconstruction strategies, and when
54
+ each one is cheaper.
55
+ - Borrow a connection through `with_connection` where Active Record provides
56
+ it, so applications that opt into deprecating permanent checkouts no longer
57
+ see a deprecation warning from association candidate selection.
58
+ - Identify carried-forward collection snapshots and per-pass reification by
59
+ an owned serial and by record identity, rather than by `object_id`, which
60
+ Ruby only guarantees to be unique among live objects.
61
+ - Retain activity-event routes for every event type a timeline visits, rather
62
+ than only the most recent one, so interleaved descendant types stop
63
+ rediscovering the same routes.
64
+ - Resolve excluded attributes once per model class and selected path, and key
65
+ historical child identities instead of rescanning them.
66
+
6
67
  ## [0.3.1] - 2026-08-10
7
68
 
8
69
  ### 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,17 +187,30 @@ 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
110
214
  ```
111
215
 
112
216
  Root identities must be unique within one call. Historical reconstruction for
@@ -125,7 +229,8 @@ must observe one atomic snapshot.
125
229
  already owns a consistent, fully preloaded graph may opt out:
126
230
 
127
231
  ```ruby
128
- orders = Order.where(id: ids).preload(line_items: :product).to_a
232
+ orders = Order.where(id: order_ids).preload(line_items: :product).to_a
233
+ # `first_versions` is the same bulk endpoint lookup shown above.
129
234
 
130
235
  diffs = PaperTrailDiff.compare_many(
131
236
  orders.map do |order|
@@ -182,8 +287,28 @@ end
182
287
 
183
288
  ## Build a root-checkpoint timeline
184
289
 
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
290
+ `timeline` accepts two version objects from the supplied record's history, or
291
+ the symbols `:first` and `:last` when the range is simply the record's whole
292
+ recorded history:
293
+
294
+ ```ruby
295
+ steps = PaperTrailDiff.timeline(article, from: :first, to: :last)
296
+ ```
297
+
298
+ `:first` and `:last` are resolved by the gem, independently of the order the
299
+ `versions` association happens to use, so a caller never has to know it is
300
+ sorted. They work anywhere a version does, including mixed with an explicit
301
+ one (`from: :first, to: some_version`) and on `activity_timeline` and
302
+ `analyze`. A record with no versions has no boundaries to resolve; that is an
303
+ empty history rather than a bad request, so the result is an empty timeline
304
+ instead of an error — which is usually what an index page wants.
305
+
306
+ Combine `from: :first` with `to: article` for the fullest activity view of a
307
+ live record: the whole recorded history, ending at current state. See
308
+ [activity timelines](#build-an-activity-timeline) for why that end differs from
309
+ `to: :last`.
310
+
311
+ The range is inclusive, must be chronological, and produces one `Step` for each
187
312
  adjacent pair:
188
313
 
189
314
  ```ruby
@@ -205,7 +330,7 @@ steps.first.to_h
205
330
 
206
331
  Both timeline types expose `from_boundary`, `to_boundary`, `diff`, and
207
332
  `empty?`. A historical boundary has `event`, `whodunnit`, `record`,
208
- `recorded_at`, `version?`, and `current?` readers. Checkpoint `Step` objects
333
+ `recorded_at`, `version?`, `current?`, and `destroyed?` readers. Checkpoint `Step` objects
209
334
  also retain their original `from_version` and `to_version` for callers that
210
335
  need custom PaperTrail metadata. Existing `Step#to_h` and
211
336
  `ActivityBoundary#to_h` shapes remain unchanged; use the readers for the new
@@ -235,7 +360,9 @@ steps.reject(&:empty?)
235
360
  ```
236
361
 
237
362
  Pass the record explicitly as `to:` to include current state without creating a
238
- final root checkpoint:
363
+ final root checkpoint. This is not the same as `to: :last`: descendant
364
+ mutations recorded after the record's final root version fall outside a
365
+ version-bounded range, so only a live end reports them.
239
366
 
240
367
  ```ruby
241
368
  steps = PaperTrailDiff.activity_timeline(
@@ -268,6 +395,46 @@ boundary. Passing `to: article` is what removes the need to touch the parent
268
395
  after an ordinary versioned child mutation; current state is still never
269
396
  implicit.
270
397
 
398
+ ### Closing a destroyed root
399
+
400
+ A `destroy` version is the one boundary whose following state needs no later
401
+ version: the event itself says the record is gone. When an activity timeline
402
+ ends at the root's own `destroy` version, it therefore closes with one more
403
+ step, from that version to a `kind: :destroyed` boundary, whose diff is a
404
+ `record_presence_change` from the record's final state to `nil`:
405
+
406
+ ```ruby
407
+ steps = PaperTrailDiff.activity_timeline(
408
+ article,
409
+ from: article.versions.first,
410
+ to: article.versions.last # a destroy version
411
+ )
412
+
413
+ removal = steps.last
414
+ removal.to_boundary.destroyed? # => true
415
+ removal.to_boundary.kind # => :destroyed
416
+ removal.diff.record_presence_change.from # the state it was deleted in
417
+ removal.diff.record_presence_change.to # => nil
418
+ ```
419
+
420
+ The removal step's `from_boundary` is the ordinary `kind: :version` boundary
421
+ for the same destroy version, since that boundary still holds the record.
422
+ Boundaries therefore have three kinds — `:version`, `:current`, and
423
+ `:destroyed` — so a consumer that branches on `version?` alone should also
424
+ handle `destroyed?`.
425
+
426
+ `analyze(activity: true)` reports the same closing step in its
427
+ `activity_timeline`. Its `diff` and `timeline` keep their `compare` and
428
+ `timeline` semantics and do not report the deletion.
429
+
430
+ A `within:` window behaves the same way. A window whose last selected mutation
431
+ is the root's destruction needs no later root version, because none can ever
432
+ exist, so it closes on the removal instead of raising
433
+ `IncompleteTimeRangeError`. When the destruction falls *outside* the window it
434
+ remains ordinary reconstruction context and is not reported as a selected
435
+ mutation. The same relaxation lets the plain `timeline` accept such a window,
436
+ though it still reports only the edits.
437
+
271
438
  Live-ended HABTM activity is rejected with
272
439
  `PaperTrailDiff::UnsupportedLiveActivityError`. HABTM join rows have no model
273
440
  versions, so the gem cannot reliably split their membership mutations into
@@ -315,7 +482,9 @@ If the window contains a relevant mutation but no later root version exists,
315
482
  the call raises `PaperTrailDiff::IncompleteTimeRangeError`. Create a root
316
483
  checkpoint after the reporting window before running historical analysis. The
317
484
  gem does not silently substitute current database state. A root-only window
318
- with no selected mutation returns a frozen empty timeline.
485
+ with no selected mutation returns a frozen empty timeline. The one exception is
486
+ a window that closes on the root's own destruction, which no later version can
487
+ ever follow; see [closing a destroyed root](#closing-a-destroyed-root).
319
488
 
320
489
  Time ranges and explicit `from:`/`to:` endpoints are mutually exclusive.
321
490
  Malformed, open-ended, or reversed ranges raise
@@ -352,6 +521,37 @@ work is performed. `analyze` accepts explicit historical versions or
352
521
  `within:`, but not a current-record endpoint; use the standalone
353
522
  `activity_timeline(..., to: article)` API when the final boundary must be live.
354
523
 
524
+ ### Choosing between `timeline` and `analyze(activity: true)`
525
+
526
+ `analysis.timeline` and `timeline` return the same root-checkpoint steps for the
527
+ same range. They are two reconstruction strategies for one result, not two
528
+ levels of detail: a `timeline` step covers only root version boundaries, but
529
+ each of its snapshots still contains every selected association, because a step
530
+ must report what changed underneath the root as well.
531
+
532
+ The one behavioural difference is at the edge of a `within:` window. Selected
533
+ descendants can move inside a window that contains no root version at all, so
534
+ the activity form requires a root boundary it can reconstruct from and raises
535
+ `IncompleteTimeRangeError` when there is none. `timeline` has no activity view
536
+ to anchor and returns no steps for that window.
537
+
538
+ The strategies differ in what that costs:
539
+
540
+ - `timeline` reconstructs the whole selected graph independently at every root
541
+ boundary, so it costs roughly *root versions x selected graph size*. It is
542
+ insensitive to how much descendant activity happened in between.
543
+ - `analyze(activity: true)` reconstructs once and then advances that snapshot
544
+ through each recorded mutation, so it costs roughly *one reconstruction +
545
+ total events*. It is insensitive to how wide the selected graph is.
546
+
547
+ Neither dominates. Reconstructing once per checkpoint wins when a few root
548
+ versions span very heavy descendant churn; advancing incrementally wins when
549
+ the selected graph is wide and descendant activity is comparable to root
550
+ activity. As a rule of thumb, prefer `analyze(activity: true)` when selected
551
+ associations are wide, and `timeline` when descendant events greatly outnumber
552
+ root versions. Measure with `ActiveSupport::Notifications` on a representative
553
+ history rather than a seeded example if the choice matters.
554
+
355
555
  `timeline`, `activity_timeline`, and both forms of `analyze` prepare the selected
356
556
  historical range once. The loader walks only the explicit association paths and
357
557
  builds an immutable temporal index of scalar states, relationship candidates,
@@ -387,12 +587,13 @@ retain the general comparator and traversal fallback.
387
587
  Activity event loading is bounded to the selected range. Association identity
388
588
  discovery retains one indexed checkpoint for members present at the starting
389
589
  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
590
+ longer materializes every pre-range association row. Because a PaperTrail
591
+ version is a pre-change snapshot, the state at a range's final boundary can
592
+ live only in the next version after it, so prepared scalar state retains one
593
+ trailing version per selected identity. It does not retain the rest of the
594
+ history recorded after the range, so a short range early in a long history
595
+ costs the same as the same range in a short one. Keep requested paths and
596
+ ranges intentional. An activity timeline must also emit a diff for every selected
396
597
  event. Repeated events within one wide collection still copy the frozen records
397
598
  array when producing each immutable snapshot, so they can do pointer-copying
398
599
  work proportional to the number of events times the collection width even when
@@ -626,6 +827,29 @@ when join attributes or join mutations must appear as first-class history.
626
827
 
627
828
  ## Result objects
628
829
 
830
+ Every level of a result separates the same two kinds of change in the same way:
831
+
832
+ | | a record appears or disappears | a record stays and its fields change |
833
+ | --- | --- | --- |
834
+ | the root record | `record_presence_change` | `attributes`, `associations` |
835
+ | `has_many`, HABTM | `added`, `removed` | `changed` |
836
+ | `belongs_to`, `has_one` | `relationship` | `changed` |
837
+
838
+ The left column carries whole `RecordSnapshot` values; the right column carries
839
+ field-level `ValueChange` deltas. That split is deliberate. A record that has
840
+ just appeared has no previous value for any of its fields, so reporting one
841
+ `nil` to value change per attribute would both add noise and blur the
842
+ difference between "this field was edited" and "this record did not exist".
843
+
844
+ The consequence is that reading a result tree directly means branching on which
845
+ column applies: a created record's state is under
846
+ `record_presence_change.to.attributes`, an edited record's is under
847
+ `attributes`. Consumers that would rather not branch should use
848
+ [`each_entry`](#traverse-result-trees), which flattens both into one stream —
849
+ an edit arrives as `attribute_changed` carrying a `ValueChange`, and a created
850
+ record's fields arrive as `attribute_included` entries carrying values with
851
+ `state: :after`.
852
+
629
853
  The public result types are:
630
854
 
631
855
  - `PaperTrailDiff::Diff`
@@ -645,7 +869,9 @@ The public result types are:
645
869
  - `PaperTrailDiff::DiagnosticIssue`
646
870
 
647
871
  They expose readers, are frozen after construction, and provide deterministic
648
- `to_h` output. Structural hash keys are symbols; attribute and association
872
+ `to_h` output. Collection results are ordered by record identity: by type, then
873
+ naturally within one id type, so numeric ids sort `2` before `10`. Mixed or
874
+ unusual id types still order totally rather than raising. Structural hash keys are symbols; attribute and association
649
875
  names are strings. Attribute values retain their Ruby types. `RecordChange#record`
650
876
  is a `RecordReference` with `type` and `id` readers. `TraversalEntry#record` and
651
877
  `#association` return the final components of their corresponding paths. `Step`
@@ -671,6 +897,10 @@ and PT-AT can reconstruct. In particular:
671
897
  - HABTM membership is limited to the join snapshots PT-AT recorded in
672
898
  `version_associations`; historical target attributes require versioned target
673
899
  models, otherwise PT-AT may return live target state;
900
+ - `compare`, `timeline`, and `analyze`'s endpoint diff do not report the root
901
+ record's destruction, because the state recorded at a `destroy` version is the
902
+ state before the deletion; `activity_timeline` closes on a `:destroyed`
903
+ boundary instead, and a selected child's removal is reported by its parent;
674
904
  - `timeline` and `analyze` are bounded by root versions; `activity_timeline`
675
905
  adds recorded descendant boundaries and may terminate at an explicitly passed
676
906
  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
  {
@@ -7,63 +7,43 @@ module PaperTrailDiff
7
7
  #: (AssociationTraversal) -> void
8
8
  def initialize(traversal)
9
9
  @traversal = traversal
10
- @collection_model = nil
11
- @collection_tree = nil #: AssociationTree?
12
- @collection_type = nil #: String?
13
- @collection_path = nil #: String?
14
- @collection_routes = nil #: Array[Array[untyped]]?
15
- @belongs_to_model = nil
16
- @belongs_to_tree = nil #: AssociationTree?
17
- @belongs_to_type = nil #: String?
18
- @belongs_to_path = nil #: String?
19
- @belongs_to_routes = nil #: Array[Array[untyped]]?
10
+ @collection_routes = {} #: Hash[Array[untyped], Array[Array[untyped]]]
11
+ @belongs_to_routes = {} #: Hash[Array[untyped], Array[Array[untyped]]]
20
12
  end
21
13
 
22
14
  #: (untyped, AssociationTree, String, ?path: String) -> Array[Array[untyped]]
23
15
  def collection_routes(model_class, tree, target_type, path: '')
24
- cached = @collection_routes
25
- if cached && @collection_model.equal?(model_class) && @collection_tree.equal?(tree) &&
26
- @collection_type == target_type && @collection_path == path
27
- return cached
28
- end
29
-
30
- computed = freeze_routes(routes(model_class, tree, target_type, :has_many, path: path))
31
- @collection_model = model_class
32
- @collection_tree = tree
33
- @collection_type = Support.immutable_copy(target_type)
34
- @collection_path = Support.immutable_copy(path)
35
- @collection_routes = computed
16
+ cached(@collection_routes, model_class, tree, target_type, path, :has_many)
36
17
  end
37
18
 
38
19
  #: (untyped, AssociationTree, String, ?path: String) -> Array[Array[untyped]]
39
20
  def belongs_to_routes(model_class, tree, target_type, path: '')
40
- cached = @belongs_to_routes
41
- if cached && @belongs_to_model.equal?(model_class) && @belongs_to_tree.equal?(tree) &&
42
- @belongs_to_type == target_type && @belongs_to_path == path
43
- return cached
44
- end
45
-
46
- computed = freeze_routes(routes(model_class, tree, target_type, :belongs_to, path: path))
47
- @belongs_to_model = model_class
48
- @belongs_to_tree = tree
49
- @belongs_to_type = Support.immutable_copy(target_type)
50
- @belongs_to_path = Support.immutable_copy(path)
51
- @belongs_to_routes = computed
21
+ cached(@belongs_to_routes, model_class, tree, target_type, path, :belongs_to)
52
22
  end
53
23
 
54
24
  private
55
25
 
56
26
  # @rbs @traversal: AssociationTraversal
57
- # @rbs @collection_model: untyped
58
- # @rbs @collection_tree: AssociationTree?
59
- # @rbs @collection_type: String?
60
- # @rbs @collection_path: String?
61
- # @rbs @collection_routes: Array[Array[untyped]]?
62
- # @rbs @belongs_to_model: untyped
63
- # @rbs @belongs_to_tree: AssociationTree?
64
- # @rbs @belongs_to_type: String?
65
- # @rbs @belongs_to_path: String?
66
- # @rbs @belongs_to_routes: Array[Array[untyped]]?
27
+ # @rbs @collection_routes: Hash[Array[untyped], Array[Array[untyped]]]
28
+ # @rbs @belongs_to_routes: Hash[Array[untyped], Array[Array[untyped]]]
29
+
30
+ # Timelines interleave event types, so every requested combination is
31
+ # retained. Model, tree, and path are fixed per adapter, leaving one entry
32
+ # per selected item type.
33
+ #: (Hash[Array[untyped], Array[Array[untyped]]], untyped, AssociationTree, String, String, Symbol) -> Array[Array[untyped]]
34
+ def cached(store, model_class, tree, target_type, path, macro) # rubocop:disable Metrics/ParameterLists
35
+ key = [model_class, tree, target_type, path]
36
+ existing = store[key]
37
+ return existing if existing
38
+
39
+ store[cache_key(key)] =
40
+ freeze_routes(routes(model_class, tree, target_type, macro, path: path))
41
+ end
42
+
43
+ #: (Array[untyped]) -> Array[untyped]
44
+ def cache_key(key)
45
+ key.map { |part| part.is_a?(String) ? -part : part }.freeze
46
+ end
67
47
 
68
48
  #: (Array[Array[untyped]]) -> Array[Array[untyped]]
69
49
  def freeze_routes(routes)
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Root checkpoint steps recovered from the snapshots an activity pass retained,
6
+ # so a combined result does not reconstruct the same boundaries twice.
7
+ module ActivityRootSteps
8
+ module_function
9
+
10
+ #: (Array[untyped], Hash[Array[untyped], RecordSnapshot?]) -> Array[Step]
11
+ def call(root_versions, root_snapshots)
12
+ snapshots = root_versions.map { |version| root_snapshots.fetch(version_key(version)) }
13
+ root_versions.each_cons(2).with_index.map do |versions, index|
14
+ Step.new(
15
+ from_version: versions.fetch(0),
16
+ to_version: versions.fetch(1),
17
+ diff: Engine.compare(snapshots.fetch(index), snapshots.fetch(index + 1))
18
+ )
19
+ end.freeze
20
+ end
21
+
22
+ #: (untyped) -> Array[untyped]
23
+ def version_key(version)
24
+ [version.class.name, version.id]
25
+ end
26
+ end
27
+ end
@@ -11,9 +11,14 @@ module PaperTrailDiff
11
11
  @preparer = preparer
12
12
  end
13
13
 
14
- #: (untyped, Array[untyped], ?start_at: untyped) -> void
15
- def prepare(record, root_versions, start_at: root_versions.first.created_at)
16
- @preparer.call(record, root_versions, start_at: start_at)
14
+ #: (untyped, Array[untyped], ?start_at: untyped, ?end_at: untyped) -> void
15
+ def prepare(
16
+ record,
17
+ root_versions,
18
+ start_at: root_versions.first.created_at,
19
+ end_at: root_versions.last.created_at
20
+ )
21
+ @preparer.call(record, root_versions, start_at: start_at, end_at: end_at)
17
22
  end
18
23
 
19
24
  #: (untyped, untyped) -> RecordSnapshot?