paper_trail_diff 0.3.0 → 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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -0
  3. data/README.md +269 -30
  4. data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +126 -0
  5. data/lib/paper_trail_diff/activity_boundary.rb +21 -0
  6. data/lib/paper_trail_diff/activity_collection_event_applier.rb +137 -0
  7. data/lib/paper_trail_diff/activity_collection_record_updater.rb +67 -0
  8. data/lib/paper_trail_diff/activity_collection_route_change.rb +21 -0
  9. data/lib/paper_trail_diff/activity_collection_route_updater.rb +128 -0
  10. data/lib/paper_trail_diff/activity_event_record_normalizer.rb +42 -0
  11. data/lib/paper_trail_diff/activity_event_record_resolver.rb +120 -0
  12. data/lib/paper_trail_diff/activity_event_route_finder.rb +92 -0
  13. data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +56 -650
  14. data/lib/paper_trail_diff/activity_relationship.rb +55 -0
  15. data/lib/paper_trail_diff/activity_root_steps.rb +27 -0
  16. data/lib/paper_trail_diff/activity_snapshot_sequence.rb +8 -3
  17. data/lib/paper_trail_diff/activity_timeline_builder.rb +47 -32
  18. data/lib/paper_trail_diff/analysis.rb +9 -0
  19. data/lib/paper_trail_diff/branch_snapshot_refresher.rb +2 -0
  20. data/lib/paper_trail_diff/collection_comparator.rb +14 -1
  21. data/lib/paper_trail_diff/collection_transition.rb +6 -3
  22. data/lib/paper_trail_diff/endpoint.rb +27 -2
  23. data/lib/paper_trail_diff/errors.rb +3 -0
  24. data/lib/paper_trail_diff/historical_association_reifier.rb +8 -5
  25. data/lib/paper_trail_diff/historical_snapshot_store.rb +9 -2
  26. data/lib/paper_trail_diff/prepared_association_reifier.rb +8 -5
  27. data/lib/paper_trail_diff/prepared_edge_loader.rb +6 -4
  28. data/lib/paper_trail_diff/prepared_history_loader.rb +3 -2
  29. data/lib/paper_trail_diff/prepared_record_index.rb +90 -15
  30. data/lib/paper_trail_diff/snapshot.rb +26 -0
  31. data/lib/paper_trail_diff/snapshot_normalizer.rb +14 -2
  32. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +52 -32
  33. data/lib/paper_trail_diff/time_version_range.rb +11 -0
  34. data/lib/paper_trail_diff/timeline_range.rb +57 -4
  35. data/lib/paper_trail_diff/version.rb +1 -1
  36. data/lib/paper_trail_diff/version_association_candidate_scope.rb +46 -19
  37. data/lib/paper_trail_diff.rb +10 -0
  38. data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +41 -0
  39. data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -0
  40. data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +46 -0
  41. data/sig/generated/paper_trail_diff/activity_collection_record_updater.rbs +25 -0
  42. data/sig/generated/paper_trail_diff/activity_collection_route_change.rbs +17 -0
  43. data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +44 -0
  44. data/sig/generated/paper_trail_diff/activity_event_record_normalizer.rbs +19 -0
  45. data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +44 -0
  46. data/sig/generated/paper_trail_diff/activity_event_route_finder.rbs +47 -0
  47. data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +18 -110
  48. data/sig/generated/paper_trail_diff/activity_relationship.rbs +23 -0
  49. data/sig/generated/paper_trail_diff/activity_root_steps.rbs +13 -0
  50. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +2 -2
  51. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +20 -10
  52. data/sig/generated/paper_trail_diff/analysis.rbs +5 -0
  53. data/sig/generated/paper_trail_diff/collection_comparator.rbs +7 -0
  54. data/sig/generated/paper_trail_diff/collection_transition.rbs +4 -1
  55. data/sig/generated/paper_trail_diff/endpoint.rbs +12 -0
  56. data/sig/generated/paper_trail_diff/errors.rbs +4 -0
  57. data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +1 -1
  58. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +2 -2
  59. data/sig/generated/paper_trail_diff/prepared_association_reifier.rbs +1 -1
  60. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +1 -0
  61. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -2
  62. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +39 -6
  63. data/sig/generated/paper_trail_diff/snapshot.rbs +20 -0
  64. data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +8 -0
  65. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +21 -10
  66. data/sig/generated/paper_trail_diff/time_version_range.rbs +7 -0
  67. data/sig/generated/paper_trail_diff/timeline_range.rbs +29 -0
  68. data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +17 -4
  69. metadata +24 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4d60569584c3d30bd35c62c1f0f699fd8859545d81f873c926dfdd8268aed3df
4
- data.tar.gz: 4961a77099927fff3476641b0409cb234e1f398e9b84e9cb3cc9e11b2d580064
3
+ metadata.gz: fac7dbbad6368383d2b73f4c5acbdcabb1599da6c096a5c8363327a8602a1298
4
+ data.tar.gz: 7657cc9ae4b3e8f7d9f1026ac06fb93b6571ba7258bca66183b84053bb8469c0
5
5
  SHA512:
6
- metadata.gz: 2cf13bffaca3d199b2332b0e3d1c7c7c00ef7c6f82a4c9e5a75c51f376889ff57c60e1bfab1f578a7122e972698318d2720bce5f8da2755abea50aebf6e7083e
7
- data.tar.gz: a071cd71f7fb766105eda1e018991ed8030885822da23382404797984b265818c0012378aea5912a507fb7bc24e9294331201b0cf1918860df3d9613476d580d
6
+ metadata.gz: adadf85c15151889247d0791ffc944731c781c6dc3f4d23b4a04fa046e489b74f2a6501a16fb53386e6c80c3fae4d6c583d4b91db198bfbe8919a3a19eaf1a4e
7
+ data.tar.gz: 2a3bf4422b73c513981b519d04f0c556574f75ebc08963fab606f872c2428acac2455869675318ae75d8f71bddeb208e952642eeafedae7131b4745b5f36f534
data/CHANGELOG.md CHANGED
@@ -3,6 +3,82 @@
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
+
67
+ ## [0.3.1] - 2026-08-10
68
+
69
+ ### Fixed
70
+
71
+ - Preserve immutable activity snapshots for nested events that do not match a
72
+ selected child record, instead of reporting a false change.
73
+
74
+ ### Changed
75
+
76
+ - Split activity-event reconstruction, route discovery, relationship matching,
77
+ collection mutation, and `belongs_to` target application into focused
78
+ internal collaborators without changing result shapes.
79
+ - Reuse immutable activity-event routes and cached branch components across
80
+ repeated event types to reduce timeline allocation overhead.
81
+
6
82
  ## [0.3.0] - 2026-08-10
7
83
 
8
84
  ### Added
data/README.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # paper_trail_diff
2
2
 
3
+ > [!WARNING]
4
+ > **Experimental software and AI disclosure:** This gem is experimental and may
5
+ > contain incomplete behavior, reconstruction errors, performance problems, or
6
+ > breaking changes. AI-assisted coding tools made significant contributions to
7
+ > its design, implementation, tests, and documentation under maintainer
8
+ > direction. AI involvement is not a substitute for independent review: audit
9
+ > the code and validate it against your own PaperTrail history before relying on
10
+ > it in production, compliance, security, or other high-stakes workflows.
11
+
3
12
  `paper_trail_diff` adds structured endpoint, checkpoint-timeline, and activity
4
13
  comparisons to
5
14
  [PaperTrail](https://github.com/paper-trail-gem/paper_trail). It returns immutable
@@ -35,12 +44,84 @@ PaperTrail.config.track_associations = true
35
44
  All associated models whose historical state is compared must use
36
45
  `has_paper_trail`.
37
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
+
38
120
  ## Compare two endpoints
39
121
 
40
- PaperTrail stores an object's state before each recorded event. `compare`
41
- accepts two explicit endpoints: each may be a PaperTrail version or a clean,
42
- persisted model instance representing current database state. It reports only
43
- 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:
44
125
 
45
126
  ```ruby
46
127
  diff = PaperTrailDiff.compare(article.versions[1], article.versions[4])
@@ -60,10 +141,21 @@ diff.to_h
60
141
 
61
142
  Intermediate edits do not affect `compare`. If a title changes and later
62
143
  returns to its original value, endpoint comparison reports no title change.
63
- A `create` version reifies to `nil`; comparing it with a record state produces
64
- a structured `record_presence_change` instead of fake scalar changes. For
65
- ordinary updates, `record_presence_change` is `nil`, meaning the root record is
66
- 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).
67
159
 
68
160
  Pass the record explicitly when the desired endpoint is current state:
69
161
 
@@ -75,10 +167,18 @@ diff = PaperTrailDiff.compare(
75
167
  )
76
168
  ```
77
169
 
78
- Version and record endpoints may appear in either order. Current state is never
79
- inferred. The record must be persisted, not destroyed, and free of unsaved
80
- attribute changes. The gem reloads it unscoped before normalization, so stale
81
- 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
82
182
  transaction with an appropriate isolation level when several live association
83
183
  queries must represent one atomic application snapshot.
84
184
 
@@ -87,17 +187,30 @@ preload each explicitly selected live association path across the batch. Each
87
187
  entry has the same endpoints and options as `compare`; results are returned in
88
188
  input order as a frozen hash keyed by `[item_type, item_id]` strings:
89
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
+
90
206
  ```ruby
91
207
  diffs = PaperTrailDiff.compare_many(
92
- [
93
- { from: first_versions.fetch(order_a.id), to: order_a },
94
- { from: first_versions.fetch(order_b.id), to: order_b }
95
- ],
208
+ orders.map { |order| { from: first_versions.fetch(order.id), to: order } },
96
209
  associations: [:line_items],
97
210
  ignore: []
98
211
  )
99
212
 
100
- diffs.fetch(["Order", order_a.id.to_s]) # => PaperTrailDiff::Diff
213
+ diffs.fetch(["Order", orders.first.id.to_s]) # => PaperTrailDiff::Diff
101
214
  ```
102
215
 
103
216
  Root identities must be unique within one call. Historical reconstruction for
@@ -116,7 +229,8 @@ must observe one atomic snapshot.
116
229
  already owns a consistent, fully preloaded graph may opt out:
117
230
 
118
231
  ```ruby
119
- 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.
120
234
 
121
235
  diffs = PaperTrailDiff.compare_many(
122
236
  orders.map do |order|
@@ -173,8 +287,28 @@ end
173
287
 
174
288
  ## Build a root-checkpoint timeline
175
289
 
176
- `timeline` accepts two version objects from the supplied record's history. The
177
- 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
178
312
  adjacent pair:
179
313
 
180
314
  ```ruby
@@ -196,7 +330,7 @@ steps.first.to_h
196
330
 
197
331
  Both timeline types expose `from_boundary`, `to_boundary`, `diff`, and
198
332
  `empty?`. A historical boundary has `event`, `whodunnit`, `record`,
199
- `recorded_at`, `version?`, and `current?` readers. Checkpoint `Step` objects
333
+ `recorded_at`, `version?`, `current?`, and `destroyed?` readers. Checkpoint `Step` objects
200
334
  also retain their original `from_version` and `to_version` for callers that
201
335
  need custom PaperTrail metadata. Existing `Step#to_h` and
202
336
  `ActivityBoundary#to_h` shapes remain unchanged; use the readers for the new
@@ -226,7 +360,9 @@ steps.reject(&:empty?)
226
360
  ```
227
361
 
228
362
  Pass the record explicitly as `to:` to include current state without creating a
229
- 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.
230
366
 
231
367
  ```ruby
232
368
  steps = PaperTrailDiff.activity_timeline(
@@ -259,6 +395,46 @@ boundary. Passing `to: article` is what removes the need to touch the parent
259
395
  after an ordinary versioned child mutation; current state is still never
260
396
  implicit.
261
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
+
262
438
  Live-ended HABTM activity is rejected with
263
439
  `PaperTrailDiff::UnsupportedLiveActivityError`. HABTM join rows have no model
264
440
  versions, so the gem cannot reliably split their membership mutations into
@@ -306,7 +482,9 @@ If the window contains a relevant mutation but no later root version exists,
306
482
  the call raises `PaperTrailDiff::IncompleteTimeRangeError`. Create a root
307
483
  checkpoint after the reporting window before running historical analysis. The
308
484
  gem does not silently substitute current database state. A root-only window
309
- 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).
310
488
 
311
489
  Time ranges and explicit `from:`/`to:` endpoints are mutually exclusive.
312
490
  Malformed, open-ended, or reversed ranges raise
@@ -343,6 +521,37 @@ work is performed. `analyze` accepts explicit historical versions or
343
521
  `within:`, but not a current-record endpoint; use the standalone
344
522
  `activity_timeline(..., to: article)` API when the final boundary must be live.
345
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
+
346
555
  `timeline`, `activity_timeline`, and both forms of `analyze` prepare the selected
347
556
  historical range once. The loader walks only the explicit association paths and
348
557
  builds an immutable temporal index of scalar states, relationship candidates,
@@ -378,12 +587,13 @@ retain the general comparator and traversal fallback.
378
587
  Activity event loading is bounded to the selected range. Association identity
379
588
  discovery retains one indexed checkpoint for members present at the starting
380
589
  boundary, then considers later association activity and current members; it no
381
- longer materializes every pre-range association row. Prepared scalar state also
382
- retains later successor versions for selected identities because a PaperTrail
383
- version is a pre-change snapshot and may be the only correct state for an
384
- earlier boundary. Memory therefore scales with relevant selected history, not
385
- only with the number of returned steps. Keep requested paths and ranges
386
- 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
387
597
  event. Repeated events within one wide collection still copy the frozen records
388
598
  array when producing each immutable snapshot, so they can do pointer-copying
389
599
  work proportional to the number of events times the collection width even when
@@ -617,6 +827,29 @@ when join attributes or join mutations must appear as first-class history.
617
827
 
618
828
  ## Result objects
619
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
+
620
853
  The public result types are:
621
854
 
622
855
  - `PaperTrailDiff::Diff`
@@ -636,7 +869,9 @@ The public result types are:
636
869
  - `PaperTrailDiff::DiagnosticIssue`
637
870
 
638
871
  They expose readers, are frozen after construction, and provide deterministic
639
- `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
640
875
  names are strings. Attribute values retain their Ruby types. `RecordChange#record`
641
876
  is a `RecordReference` with `type` and `id` readers. `TraversalEntry#record` and
642
877
  `#association` return the final components of their corresponding paths. `Step`
@@ -662,6 +897,10 @@ and PT-AT can reconstruct. In particular:
662
897
  - HABTM membership is limited to the join snapshots PT-AT recorded in
663
898
  `version_associations`; historical target attributes require versioned target
664
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;
665
904
  - `timeline` and `analyze` are bounded by root versions; `activity_timeline`
666
905
  adds recorded descendant boundaries and may terminate at an explicitly passed
667
906
  current record, while a fully historical result still requires a later root
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Applies selected non-polymorphic belongs-to target updates to a snapshot.
6
+ class ActivityBelongsToEventApplier
7
+ #: (route_finder: ActivityEventRouteFinder, record_resolver: ActivityEventRecordResolver, record_normalizer: ActivityEventRecordNormalizer) -> void
8
+ def initialize(route_finder:, record_resolver:, record_normalizer:)
9
+ @route_finder = route_finder
10
+ @record_resolver = record_resolver
11
+ @record_normalizer = record_normalizer
12
+ end
13
+
14
+ #: (untyped, untyped, RecordSnapshot, AssociationTree, SnapshotNormalizer, untyped) -> [bool, RecordSnapshot?]
15
+ def call( # rubocop:disable Metrics/ParameterLists
16
+ root_endpoint,
17
+ context_endpoint,
18
+ previous,
19
+ tree,
20
+ normalizer,
21
+ version
22
+ )
23
+ return [false, nil] unless %w[update destroy].include?(version.event.to_s)
24
+
25
+ routes = @route_finder.belongs_to_routes(
26
+ endpoint_model_class(root_endpoint), tree, version.item_type.to_s
27
+ )
28
+ return [false, nil] if routes.empty?
29
+
30
+ snapshot = previous
31
+ routes.each do |route|
32
+ replacement = normalized_route_record(
33
+ route, version, normalizer, root_endpoint, context_endpoint
34
+ )
35
+ snapshot, = replace_route(snapshot, route, version, replacement)
36
+ end
37
+ [true, snapshot]
38
+ end
39
+
40
+ private
41
+
42
+ # @rbs @route_finder: ActivityEventRouteFinder
43
+ # @rbs @record_resolver: ActivityEventRecordResolver
44
+ # @rbs @record_normalizer: ActivityEventRecordNormalizer
45
+
46
+ #: (Array[untyped], untyped, SnapshotNormalizer, untyped, untyped) -> RecordSnapshot?
47
+ def normalized_route_record(route, version, normalizer, root_endpoint, context_endpoint)
48
+ record = @record_resolver.record_after(version)
49
+ return unless record
50
+
51
+ _name, reflection, subtree, path = route.last
52
+ @record_normalizer.call(
53
+ record,
54
+ reflection: reflection,
55
+ subtree: subtree,
56
+ path: path,
57
+ normalizer: normalizer,
58
+ root_endpoint: root_endpoint,
59
+ context_endpoint: context_endpoint
60
+ )
61
+ end
62
+
63
+ #: (RecordSnapshot, Array[untyped], untyped, RecordSnapshot?, ?depth: Integer) -> [RecordSnapshot, bool]
64
+ def replace_route(snapshot, route, version, replacement, depth: 0)
65
+ name, _reflection, _subtree, = route.fetch(depth)
66
+ association = snapshot.associations.fetch(name)
67
+ records, changed = replacement_records(
68
+ association.records, route, version, replacement, depth
69
+ )
70
+ return [snapshot, false] unless changed
71
+
72
+ updated_association = AssociationSnapshot.new(kind: association.kind, records: records)
73
+ [replace_association(snapshot, name, updated_association), true]
74
+ end
75
+
76
+ #: (Array[RecordSnapshot], Array[untyped], untyped, RecordSnapshot?, Integer) -> [Array[RecordSnapshot], bool]
77
+ def replacement_records(records, route, version, replacement, depth)
78
+ return replace_existing_target(records, version, replacement) if depth == route.length - 1
79
+
80
+ changed = false
81
+ updated = records.map do |record|
82
+ result, record_changed = replace_route(
83
+ record, route, version, replacement, depth: depth + 1
84
+ )
85
+ changed ||= record_changed
86
+ result
87
+ end.freeze
88
+ [updated, changed]
89
+ end
90
+
91
+ #: (Array[RecordSnapshot], untyped, RecordSnapshot?) -> [Array[RecordSnapshot], bool]
92
+ def replace_existing_target(records, version, replacement)
93
+ index = records.index do |record|
94
+ record.id.to_s == version.item_id.to_s &&
95
+ record.type.to_s == replacement_type(version, replacement)
96
+ end
97
+ return [records, false] unless index
98
+
99
+ updated = records.dup
100
+ replacement ? updated[index] = replacement : updated.delete_at(index)
101
+ [updated.freeze, true]
102
+ end
103
+
104
+ #: (RecordSnapshot, String, AssociationSnapshot) -> RecordSnapshot
105
+ def replace_association(snapshot, name, association)
106
+ RecordSnapshot.new(
107
+ type: snapshot.type,
108
+ id: snapshot.id,
109
+ attributes: snapshot.attributes,
110
+ associations: snapshot.associations.merge(name => association)
111
+ )
112
+ end
113
+
114
+ #: (untyped, RecordSnapshot?) -> String
115
+ def replacement_type(version, replacement)
116
+ return replacement.type.to_s if replacement
117
+
118
+ Endpoint.model_class(version).name.to_s
119
+ end
120
+
121
+ #: (untyped) -> untyped
122
+ def endpoint_model_class(endpoint)
123
+ Endpoint.version?(endpoint) ? Endpoint.model_class(endpoint) : endpoint.class
124
+ end
125
+ end
126
+ end
@@ -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
  {