paper_trail_diff 0.12.0 → 0.13.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 (130) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +127 -0
  3. data/QUICKSTART.md +149 -79
  4. data/README.md +179 -1388
  5. data/docs/architecture.md +88 -0
  6. data/docs/performance.md +309 -0
  7. data/docs/reference.md +977 -0
  8. data/docs/testing.md +146 -0
  9. data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +2 -12
  10. data/lib/paper_trail_diff/activity_boundary.rb +20 -8
  11. data/lib/paper_trail_diff/activity_child_candidate_loader.rb +29 -0
  12. data/lib/paper_trail_diff/activity_collection_event_applier.rb +10 -7
  13. data/lib/paper_trail_diff/activity_collection_route_updater.rb +1 -11
  14. data/lib/paper_trail_diff/activity_event_record_normalizer.rb +4 -0
  15. data/lib/paper_trail_diff/activity_event_record_resolver.rb +66 -6
  16. data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +6 -4
  17. data/lib/paper_trail_diff/activity_grouping.rb +12 -0
  18. data/lib/paper_trail_diff/activity_history.rb +9 -3
  19. data/lib/paper_trail_diff/activity_relationship.rb +2 -0
  20. data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +1 -0
  21. data/lib/paper_trail_diff/activity_snapshot_sequence.rb +9 -2
  22. data/lib/paper_trail_diff/activity_timeline_builder.rb +39 -35
  23. data/lib/paper_trail_diff/activity_version_collector.rb +27 -18
  24. data/lib/paper_trail_diff/analysis.rb +9 -4
  25. data/lib/paper_trail_diff/analysis_batch.rb +28 -7
  26. data/lib/paper_trail_diff/association_traversal.rb +23 -0
  27. data/lib/paper_trail_diff/batch_boundary_resolver.rb +18 -26
  28. data/lib/paper_trail_diff/batch_safety.rb +55 -0
  29. data/lib/paper_trail_diff/batched_root_analyzer.rb +14 -5
  30. data/lib/paper_trail_diff/batched_root_versions.rb +9 -37
  31. data/lib/paper_trail_diff/branch_snapshot_refresher.rb +7 -8
  32. data/lib/paper_trail_diff/deleted_analysis_batch.rb +93 -0
  33. data/lib/paper_trail_diff/direct_record_state_loader.rb +81 -0
  34. data/lib/paper_trail_diff/endpoint.rb +10 -0
  35. data/lib/paper_trail_diff/engine.rb +4 -2
  36. data/lib/paper_trail_diff/errors.rb +9 -0
  37. data/lib/paper_trail_diff/historical_association_reifier.rb +26 -1
  38. data/lib/paper_trail_diff/historical_filter.rb +181 -0
  39. data/lib/paper_trail_diff/historical_snapshot_store.rb +51 -35
  40. data/lib/paper_trail_diff/instrumentation.rb +2 -2
  41. data/lib/paper_trail_diff/nested_comparator.rb +39 -15
  42. data/lib/paper_trail_diff/paper_trail_adapter.rb +109 -55
  43. data/lib/paper_trail_diff/prepared_activity_index.rb +121 -0
  44. data/lib/paper_trail_diff/prepared_edge_loader.rb +11 -11
  45. data/lib/paper_trail_diff/prepared_history.rb +21 -30
  46. data/lib/paper_trail_diff/prepared_history_loader.rb +14 -2
  47. data/lib/paper_trail_diff/prepared_record_index.rb +102 -29
  48. data/lib/paper_trail_diff/prepared_snapshot_builder.rb +64 -0
  49. data/lib/paper_trail_diff/prepared_state_association_reader.rb +77 -0
  50. data/lib/paper_trail_diff/root_version_plan.rb +25 -2
  51. data/lib/paper_trail_diff/root_version_selection.rb +16 -5
  52. data/lib/paper_trail_diff/scoped_analysis.rb +17 -2
  53. data/lib/paper_trail_diff/scoped_analysis_builder.rb +47 -0
  54. data/lib/paper_trail_diff/scoped_root_selection.rb +106 -13
  55. data/lib/paper_trail_diff/snapshot.rb +9 -0
  56. data/lib/paper_trail_diff/snapshot_normalizer.rb +41 -2
  57. data/lib/paper_trail_diff/step.rb +10 -5
  58. data/lib/paper_trail_diff/stored_time_precision.rb +68 -0
  59. data/lib/paper_trail_diff/support.rb +13 -3
  60. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +19 -7
  61. data/lib/paper_trail_diff/time_range.rb +22 -2
  62. data/lib/paper_trail_diff/time_version_range.rb +1 -15
  63. data/lib/paper_trail_diff/timeline_builder.rb +5 -4
  64. data/lib/paper_trail_diff/timeline_range.rb +25 -43
  65. data/lib/paper_trail_diff/timeline_snapshot_provider.rb +13 -2
  66. data/lib/paper_trail_diff/traversal.rb +1 -1
  67. data/lib/paper_trail_diff/version.rb +1 -1
  68. data/lib/paper_trail_diff/version_association_candidate_scope.rb +1 -1
  69. data/lib/paper_trail_diff/version_boundary.rb +48 -0
  70. data/lib/paper_trail_diff/version_query.rb +33 -0
  71. data/lib/paper_trail_diff/version_range.rb +10 -38
  72. data/lib/paper_trail_diff.rb +78 -42
  73. data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +0 -3
  74. data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -4
  75. data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +11 -0
  76. data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +4 -4
  77. data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +0 -3
  78. data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +19 -3
  79. data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +2 -2
  80. data/sig/generated/paper_trail_diff/activity_grouping.rbs +3 -0
  81. data/sig/generated/paper_trail_diff/activity_history.rbs +6 -2
  82. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +7 -2
  83. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +8 -3
  84. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +4 -8
  85. data/sig/generated/paper_trail_diff/analysis.rbs +2 -2
  86. data/sig/generated/paper_trail_diff/analysis_batch.rbs +14 -3
  87. data/sig/generated/paper_trail_diff/association_traversal.rbs +11 -0
  88. data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +7 -10
  89. data/sig/generated/paper_trail_diff/batch_safety.rbs +24 -0
  90. data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +8 -2
  91. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +4 -16
  92. data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
  93. data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +44 -0
  94. data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +42 -0
  95. data/sig/generated/paper_trail_diff/endpoint.rbs +5 -0
  96. data/sig/generated/paper_trail_diff/engine.rbs +4 -2
  97. data/sig/generated/paper_trail_diff/errors.rbs +12 -0
  98. data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +8 -0
  99. data/sig/generated/paper_trail_diff/historical_filter.rbs +75 -0
  100. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +17 -4
  101. data/sig/generated/paper_trail_diff/nested_comparator.rbs +15 -7
  102. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +36 -15
  103. data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +56 -0
  104. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +3 -3
  105. data/sig/generated/paper_trail_diff/prepared_history.rbs +12 -14
  106. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -0
  107. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +43 -6
  108. data/sig/generated/paper_trail_diff/prepared_snapshot_builder.rbs +24 -0
  109. data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +33 -0
  110. data/sig/generated/paper_trail_diff/root_version_plan.rbs +15 -2
  111. data/sig/generated/paper_trail_diff/root_version_selection.rbs +8 -1
  112. data/sig/generated/paper_trail_diff/scoped_analysis.rbs +14 -2
  113. data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +22 -0
  114. data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +46 -6
  115. data/sig/generated/paper_trail_diff/snapshot.rbs +4 -0
  116. data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +13 -0
  117. data/sig/generated/paper_trail_diff/step.rbs +8 -2
  118. data/sig/generated/paper_trail_diff/stored_time_precision.rbs +36 -0
  119. data/sig/generated/paper_trail_diff/support.rbs +7 -0
  120. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +7 -2
  121. data/sig/generated/paper_trail_diff/time_range.rbs +9 -0
  122. data/sig/generated/paper_trail_diff/time_version_range.rbs +0 -6
  123. data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
  124. data/sig/generated/paper_trail_diff/timeline_range.rbs +10 -14
  125. data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +2 -0
  126. data/sig/generated/paper_trail_diff/version_boundary.rbs +23 -0
  127. data/sig/generated/paper_trail_diff/version_query.rbs +15 -0
  128. data/sig/generated/paper_trail_diff/version_range.rbs +5 -14
  129. data/sig/generated/paper_trail_diff.rbs +31 -25
  130. metadata +31 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e747358edb04ac2ea49e43b11eb8b5b8e811c426b50b017ed6d1c72162bb4632
4
- data.tar.gz: b330e3bb80e43e478a0e88ff7afeb2b6576d3550af0a54075948fb0e60dc045d
3
+ metadata.gz: a4e484cc975317987834dd585f1bc5432985575c66b473b1db5c31b3feb4995d
4
+ data.tar.gz: 127f6d1e5ae63a839be293ea22beb6493108b90f3cbc71d34bdf1814613a54ab
5
5
  SHA512:
6
- metadata.gz: 8bcf171950fd74de94c8118dabf3f911e8db3923ce4ed48463c505b3b5748bafbb9c39819b7af49841bf3ea3f6635e797c9536f5d845a42f0a9df9eb1018d1de
7
- data.tar.gz: 2eb4d0d286b9daf4ed91e1d0d674c6835569ef3912a2712034fad27d31648c5eeed1e410f34fbfbec3d59808b5d82f178df84b896a233eaec570c3b24b6b52ab
6
+ metadata.gz: 9d65a4239a738367dd8b0536670b2f00cecfae7283db328c87308323fd19d8080fa13eed2a8910d7a0e67a2380f286c65d1b4c943a72bd1903f3d1695b2dfc18
7
+ data.tar.gz: f316b5892247e31b58a2531dd86073c812327e27bd36f5d4f020efd47d543e280b94ab513467d58a6445173106b89c1378716df471077ee7f1e35fe4bd0999d9
data/CHANGELOG.md CHANGED
@@ -3,6 +3,133 @@
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.13.0] - 2026-10-28
7
+
8
+ ### Breaking changes
9
+
10
+ - `:last` is no longer accepted as an endpoint. It named the last version, whose
11
+ recorded state is the one *before* the latest change, so ranges ending there
12
+ silently left that change out. Omit `to:` to end at current state, or pass a
13
+ version. Passing `:last` raises with that advice.
14
+ - `analyze_many` without `within:` now ends each record's history at its current
15
+ state, so the latest change is included. It previously stopped at the last
16
+ version.
17
+ - Batch methods reject scoped associations, default-scoped targets, and
18
+ `has_many :through` associations whose source is not a `belongs_to`, because
19
+ they need queries per record. The error names the association. Pass
20
+ `allow_expensive: true` to accept that cost.
21
+ - `Step#to_h` returns `{ from:, to:, diff: }`, the same shape as
22
+ `ActivityStep#to_h`, with boundary hashes on both sides. Read the version ids
23
+ from `from[:version_id]` and `to[:version_id]` instead of `from_version_id`
24
+ and `to_version_id`; `metadata: true` adds details to both boundaries.
25
+ - `analyze_many` takes only an array of records. Use `analyze_scope` with a
26
+ model or relation; `analyze_many(scope:, limit:)` and the scoped options it
27
+ accepted have been removed.
28
+
29
+ ### Added
30
+
31
+ - `report(scope, since:, limit:)` for recent activity through current state.
32
+ - `historical_filter:` on `analyze_scope` and `report`, selecting records by
33
+ values they had at any point in the window: their state when it started and
34
+ after each change inside it. Pass an attribute hash, which reads stored values
35
+ without reifying, or a callable. `max_filter_versions:` (default 10,000) caps
36
+ the versions it loads and raises `FilterLimitExceededError` beyond that.
37
+ - `include_deleted: true` on `analyze_scope` and `report`, returning analyses of
38
+ deleted records in `deleted` (`nil` when no destroy version was recorded).
39
+ Existing and deleted records together count toward `limit:`.
40
+ - `ScopedAnalysis#roots`, the selected records in relation order.
41
+ - `timeline`, `activity_timeline`, and `analyze` default to `from: :first` and
42
+ `to: record`, so `timeline(article)` covers the whole history through the
43
+ current saved record.
44
+ - `source_boundary` on both step types names the event whose change a step
45
+ reports. For a transaction group it is the first event.
46
+ - `metadata: true` on `to_h` for boundaries, steps, and analyses adds author,
47
+ event, transaction ID, and record. `ActivityStep#to_h(snapshots: true)` and
48
+ `Analysis#to_h(snapshots: true)` include retained states.
49
+ - `group:` and `snapshots:` on `analyze_many` and `analyze_scope` with
50
+ `activity: true`.
51
+ - Explicit current-record `to:` endpoints on `timeline` and `analyze`.
52
+
53
+ ### Changed
54
+
55
+ - A `within:` window with no version after its last change closes on the current
56
+ record instead of raising `IncompleteTimeRangeError`. Nothing has been
57
+ recorded since that change, so the current record holds what it produced.
58
+ This also applies to windows in which only chosen associations changed.
59
+ `close_on: :current` is no longer needed and is still accepted.
60
+ - Live-ended HABTM activity is rejected only when a range actually ends at the
61
+ current record, including in batches.
62
+ - `compare` with `associations:` reads association state from the prepared
63
+ history `compare_many` uses, instead of PT-AT's timestamp-only reifier.
64
+ - Plain recorded attributes of root and direct-child records are read without
65
+ building a model instance per historical state. Models with initialization
66
+ callbacks, custom types or defaults, default scopes, or encrypted attributes
67
+ still use PaperTrail's `reify`.
68
+ - Batch activity reports share association discovery and prepared history
69
+ across records, and each analysis reuses its checkpoint snapshots, so standard
70
+ reports no longer add queries per record.
71
+ - Activity without changesets uses prepared successor states instead of a query
72
+ per event.
73
+ - `has_many :through` reconstruction visits only the requested source
74
+ association.
75
+ - Scope selection keeps candidate records in a database subquery when
76
+ `versions.item_id` and the primary key share a column type, reads joined rows
77
+ in pages of 250, and sorts `unreachable`.
78
+ - `snapshots: true` without `activity: true` raises `ConfigurationError`, as
79
+ `group:` already did, instead of being ignored.
80
+ - The README is now a set of recipes; the full API reference and architecture
81
+ notes are separate pages.
82
+
83
+ ### Fixed
84
+
85
+ - Association timelines no longer raise `duplicate record identity` when
86
+ `versions.item_id` is a string column (PaperTrail's setup for UUID keys) and
87
+ associated models use integer keys.
88
+ - Columns listed in `has_paper_trail skip:` are left out of snapshots. PaperTrail
89
+ never stores them, so comparing history with the current record reported them
90
+ as changed from their default.
91
+ - `analyze_many(..., activity: true)` reports association activity for a record
92
+ with no version of its own in the window, matching `analyze`. It previously
93
+ returned an empty analysis.
94
+ - Histories stored with PaperTrail's JSON serializer or a `json`/`jsonb` object
95
+ column no longer report every timestamp as changed when compared with the
96
+ current record.
97
+ - Association changes at versions sharing a timestamp are placed by version id,
98
+ so `compare`, `compare_many`, and `timeline` no longer put them on the wrong
99
+ side of a boundary. Ties that ids cannot order raise
100
+ `AmbiguousVersionOrderError`.
101
+ - A window that closes on the record's deletion ends its `diff` and
102
+ `to_snapshot` at "no record". A record created and deleted inside the window
103
+ previously read as added.
104
+ - `compare_many` resolves `:first` to each record's earliest version by time,
105
+ not the lowest id, which differs for UUID version ids.
106
+ - `version_scope:` is honored on activity timelines that end at the current
107
+ record.
108
+ - Batch, single-record, and scoped activity results agree on the reporting
109
+ window and closing state.
110
+ - A lone creation or update in an activity window that ends at the current
111
+ record is reported.
112
+ - Joined relations count unique records against `limit:` without adding SQL
113
+ `DISTINCT`, so ordering by joined columns works on PostgreSQL and SQL Server.
114
+ - Nested results are fully frozen, including nested paths and empty results.
115
+ - Nested object diffs match string and symbol keys consistently and raise
116
+ `AmbiguousNestedKeyError` on collisions instead of losing a change.
117
+
118
+ ### Development
119
+
120
+ - CI runs both suites on PostgreSQL 17, MySQL 8.4, and SQL Server 2022 alongside
121
+ the SQLite Ruby/PaperTrail matrix. Database adapters are optional development
122
+ dependencies.
123
+ - The type-check gate fails on Steep internal errors and checks public API call
124
+ examples.
125
+ - RuboCop runs with the rubocop-performance and rubocop-rspec plugins.
126
+ - Consistency specs check that every method agrees on seeded, generated
127
+ histories across schema and serializer variants.
128
+ - Mutation testing with mutant covers the comparison code, in CI and through
129
+ `rake mutant`. New specs close the gaps it found: attributes present on only
130
+ one side, a record removed, singular relationships added or removed, and
131
+ JSON values held in Hash, Array, or String subclasses.
132
+
6
133
  ## [0.12.0] - 2026-08-19
7
134
 
8
135
  ### Changed
data/QUICKSTART.md CHANGED
@@ -1,11 +1,16 @@
1
1
  # Quickstart
2
2
 
3
- This guide gets `paper_trail_diff` running in a Rails application and shows the
4
- smallest useful examples. Ruby 3.1 or newer and PaperTrail 16 or 17 are
5
- supported.
3
+ This guide sets up PaperTrail, saves a few versions of an article, and reads
4
+ its changes with `paper_trail_diff`. You need Ruby, Bundler, and Rails installed.
5
+ The gem supports Ruby 3.1+ with PaperTrail 16 or 17; your Rails and PaperTrail
6
+ versions may require a newer Ruby.
6
7
 
7
- Every command and console snippet below is meant to be run in order against a
8
- scratch application, so nothing here assumes models you already have:
8
+ Already using PaperTrail? Skip the setup and use your own model in
9
+ [Create a little history](#3-create-a-little-history).
10
+
11
+ Otherwise, start with a new Rails application. Run the following examples in
12
+ order. Commands run in your terminal; Ruby examples run in the Rails console
13
+ unless a file path is shown:
9
14
 
10
15
  ```console
11
16
  rails new diff-demo --minimal
@@ -27,13 +32,14 @@ bin/rails db:migrate
27
32
  ```
28
33
 
29
34
  Skip the generator if the application already has a PaperTrail `versions`
30
- table. The `object_changes` column created by `--with-changes` is recommended
31
- for efficient activity analysis, although endpoint comparison is based on
32
- reified state rather than a PaperTrail changeset.
35
+ table. `--with-changes` adds `object_changes`, which PaperTrail uses for
36
+ `version.changeset`. Keep it if you want PaperTrail to store the changed values
37
+ for each event. This gem can also compare the full earlier records saved in
38
+ PaperTrail's `object` column.
33
39
 
34
40
  ## 2. Version a model
35
41
 
36
- Create the model this guide uses, then declare it versioned:
42
+ Create an article model and tell PaperTrail to track its changes:
37
43
 
38
44
  ```console
39
45
  bin/rails generate model Article title:string
@@ -47,7 +53,7 @@ class Article < ApplicationRecord
47
53
  end
48
54
  ```
49
55
 
50
- Restart the Rails console after changing the Gemfile or an initializer.
56
+ Restart the Rails console after changing dependencies, model configuration, or an initializer.
51
57
 
52
58
  ## 3. Create a little history
53
59
 
@@ -64,8 +70,8 @@ article.update!(title: "Final")
64
70
  published_version = article.versions.last
65
71
  ```
66
72
 
67
- The variable names are intentional. PaperTrail versions contain the state
68
- *before* their event:
73
+ PaperTrail saves the record's values **before** each change. Its public
74
+ `reify` method builds an earlier record from a version:
69
75
 
70
76
  ```ruby
71
77
  draft_version.reify.title # => "Draft"
@@ -93,13 +99,18 @@ diff.to_h
93
99
  # }
94
100
  ```
95
101
 
96
- `compare` reports only the net difference between its endpoints. It does not
97
- report intermediate changes.
102
+ Pass the PaperTrail versions themselves to `compare`; you do not need to call
103
+ `reify` first. The result has one entry per changed attribute, with `from` and
104
+ `to` values. Unchanged attributes are left out.
105
+
106
+ `compare` reports the net difference between the two records. For every change
107
+ in between, use `timeline`, shown below. Reading a diff does not save or modify
108
+ any record.
98
109
 
99
110
  ### Compare a version with current state
100
111
 
101
- Pass the persisted record explicitly when the second endpoint should be the
102
- current database state:
112
+ Pass `article` instead of a second version to compare with its current saved
113
+ values:
103
114
 
104
115
  <!-- executable:quickstart-current -->
105
116
  ```ruby
@@ -108,12 +119,13 @@ diff = PaperTrailDiff.compare(published_version, article)
108
119
  diff.attributes["title"].to # => "Final"
109
120
  ```
110
121
 
111
- The record must be persisted, not destroyed, and have no unsaved changes.
112
- Current state is never inferred automatically.
122
+ The article must still exist in the database and have no unsaved changes.
123
+ The gem reloads it before comparing. `article.versions.last` is different: it
124
+ contains the values from before the latest recorded change.
113
125
 
114
- ## 5. Build a checkpoint timeline
126
+ ## 5. Read changes between versions
115
127
 
116
- Create one more historical endpoint:
128
+ Save another change so PaperTrail records the previous title, `"Final"`:
117
129
 
118
130
  <!-- executable:quickstart-timeline-state -->
119
131
  ```ruby
@@ -121,7 +133,7 @@ article.update!(title: "Archived")
121
133
  final_version = article.versions.last # reifies to "Final"
122
134
  ```
123
135
 
124
- Then compare every adjacent root version:
136
+ Read the changes between each pair of article versions:
125
137
 
126
138
  <!-- executable:quickstart-timeline -->
127
139
  ```ruby
@@ -140,13 +152,17 @@ end
140
152
  visible_steps = steps.reject(&:empty?)
141
153
  ```
142
154
 
143
- Empty steps remain in the timeline. Filter them only when the application's
144
- display does not need every version boundary.
155
+ Each item in `steps` has a `diff`, just like the comparison above. Steps with
156
+ no reported changes are kept; `reject(&:empty?)` removes them for display.
145
157
 
146
- ### Select mutations by time
158
+ Without `from:` and `to:`, `PaperTrailDiff.timeline(article)` reads the whole
159
+ history through the current saved article, including the change to
160
+ `"Archived"`. Pass a version as `to:` when the result should stay the same after
161
+ later edits.
147
162
 
148
- Use `within:` when the application has a reporting window rather than saved
149
- endpoint versions:
163
+ ### Select changes by time
164
+
165
+ Use `within:` to choose changes by their PaperTrail version's `created_at`:
150
166
 
151
167
  <!-- executable:quickstart-time-range -->
152
168
  ```ruby
@@ -160,28 +176,72 @@ end
160
176
  # => [["Draft", "Published"], ["Published", "Final"]]
161
177
  ```
162
178
 
163
- The half-open range selects the mutations recorded by `draft_version` and
164
- `published_version`. The excluded `final_version` is still used as the trailing
165
- boundary needed to reveal the last selected mutation. PaperTrail versions are
166
- pre-change snapshots, so a range containing mutations must have a later root
167
- version. The gem raises `PaperTrailDiff::IncompleteTimeRangeError` instead of
168
- assuming that the current record is the missing endpoint.
179
+ Ruby's `...` excludes the end of the range. Here the selected versions are
180
+ `draft_version` and `published_version`.
169
181
 
170
- ## 6. Choose the right API
182
+ Each PaperTrail version stores the values **before** its own change, so the
183
+ values after the window's last change are in the next version, `final_version`.
184
+ The gem reads that version even though it is outside the window. When no later
185
+ version exists, nothing has been recorded since, and the gem reads the current
186
+ saved article instead.
171
187
 
172
- | Need | Call |
173
- | --- | --- |
174
- | Net difference between two endpoints | `compare` |
175
- | Net differences for many current records | `compare_many` |
176
- | One step per root-record version | `timeline` |
177
- | Steps for root and selected child versions | `activity_timeline` |
178
- | Net diff and timelines from one history pass | `analyze` |
188
+ ### Report through now
189
+
190
+ To report on many articles, pass a model or ActiveRecord relation to `report`.
191
+ It includes changes from `since:` through now:
192
+
193
+ ```ruby
194
+ report = PaperTrailDiff.report(
195
+ Article.all, since: 1.day.ago, limit: 100, include_deleted: true
196
+ )
197
+ report.analyses # Hash: [model name, ID string] => report for an existing record
198
+ report.deleted # Same keys for deleted records; nil if no destroy version was saved
199
+ report.analyses.each_value { |analysis| analysis.diff.to_h }
200
+ ```
201
+
202
+ `limit: 100` is a safety ceiling. If more than 100 existing and deleted articles
203
+ are selected, the call raises instead of returning a partial report. Omit
204
+ `include_deleted:` if you only need articles that still exist.
205
+
206
+ An article needs its own PaperTrail version in the time window to be selected.
207
+ Editing a comment alone does not select its article. A relation's `where`
208
+ conditions check current values; they cannot check a deleted article's values.
209
+
210
+ For one article, use a window ending now:
211
+
212
+ <!-- executable:quickstart-current-window -->
213
+ ```ruby
214
+ current_steps = PaperTrailDiff.timeline(article, within: 1.day.ago..Time.current)
215
+ ```
179
216
 
180
- `timeline` is a root-checkpoint timeline. A child change becomes visible at the
181
- next root boundary. `activity_timeline` can make a versioned child change its
182
- own boundary and can end at an explicitly supplied current record.
217
+ The same `within:` option works on `activity_timeline`, `analyze`,
218
+ `analyze_many`, and `analyze_scope`.
183
219
 
184
- ## 7. Ignore noise fields
220
+ ## 6. Choose a method
221
+
222
+ | What you need | Method |
223
+ | --- | --- |
224
+ | Net difference between two versions, or a version and the current record | `compare(from, to)` |
225
+ | The same for a list of `{ from:, to: }` pairs, one per record | `compare_many(pairs)` |
226
+ | Each change to one record, version by version | `timeline(record)` |
227
+ | Each change to one record and its chosen associations, with who made it | `activity_timeline(record, associations: ...)` |
228
+ | Net difference and timeline together | `analyze(record)` |
229
+ | The same, plus the activity timeline | `analyze(record, activity: true)` |
230
+ | Analyses for an array of records | `analyze_many(records)` |
231
+ | Analyses for the records a relation selects, over a time window | `analyze_scope(scope, within:, limit:)` |
232
+ | Recent activity through now, for the records a relation selects | `report(scope, since:, limit:)` |
233
+
234
+ Use the batch methods for many records so they can share database queries.
235
+ Some scoped associations and `has_many :through` histories still need queries
236
+ for each record. Batch calls reject those with `UnsupportedBatchPathError`.
237
+ The error names the association. `allow_expensive: true` permits that extra work;
238
+ see the [performance guide](docs/performance.md) before using it in a large report.
239
+
240
+ When associations are included, `timeline` compares their values at the article's
241
+ versions. `activity_timeline` also reports the comments' own version events, even
242
+ when the article was not edited. The next section on associations shows this.
243
+
244
+ ## 7. Choose fields to ignore
185
245
 
186
246
  `updated_at` is ignored by default. Passing `ignore:` replaces that default:
187
247
 
@@ -196,16 +256,16 @@ PaperTrailDiff.compare(
196
256
  PaperTrailDiff.compare(
197
257
  draft_version,
198
258
  published_version,
199
- ignore: [] # compare every available scalar attribute
259
+ ignore: [] # include updated_at too
200
260
  )
201
261
  ```
202
262
 
203
- See the main README for exact path-specific ignore rules.
263
+ See the [API reference](docs/reference.md) to ignore different fields on different associations.
204
264
 
205
265
  ## 8. Add association history when needed
206
266
 
207
- Associations are optional. Add PT-AT only when the application needs historical
208
- association reconstruction:
267
+ To compare an article's earlier comments, install the optional
268
+ `paper_trail-association_tracking` plugin (called PT-AT in the reference):
209
269
 
210
270
  ```console
211
271
  bundle add paper_trail-association_tracking
@@ -214,14 +274,18 @@ bin/rails db:migrate
214
274
  ```
215
275
 
216
276
  The generator creates `version_associations` and enables
217
- `PaperTrail.config.track_associations`. This section also needs a second model:
277
+ `PaperTrail.config.track_associations` in an initializer. If your application
278
+ already has that initializer, check that it contains
279
+ `PaperTrail.config.track_associations = true`.
280
+
281
+ Add a comment model:
218
282
 
219
283
  ```console
220
284
  bin/rails generate model Comment article:references body:string
221
285
  bin/rails db:migrate
222
286
  ```
223
287
 
224
- Every model whose historical state is needed must be versioned:
288
+ Add `has_paper_trail` to both models so their earlier values are recorded:
225
289
 
226
290
  ```ruby
227
291
  class Article < ApplicationRecord
@@ -235,7 +299,14 @@ class Comment < ApplicationRecord
235
299
  end
236
300
  ```
237
301
 
238
- Take an explicit root checkpoint before the example change:
302
+ The article option `synchronize_version_creation_timestamp: false` lets
303
+ manually saved versions record the time they were taken. Restart the console
304
+ after this setup and reload your article with `article = Article.first!`.
305
+
306
+ Use PaperTrail's `paper_trail.save_with_version` to save an article version even
307
+ though none of its attributes changed. That gives the comparison a starting
308
+ point before adding the comment. The transaction also records the information
309
+ needed for association history:
239
310
 
240
311
  <!-- executable:quickstart-association -->
241
312
  ```ruby
@@ -257,7 +328,7 @@ diff.associations["comments"].added.first.attributes["body"]
257
328
  # => "First comment"
258
329
  ```
259
330
 
260
- Nested paths are explicit and bounded:
331
+ If your models also define `replies` and `author`, name those associations to include them:
261
332
 
262
333
  ```ruby
263
334
  diff = PaperTrailDiff.compare(
@@ -267,7 +338,7 @@ diff = PaperTrailDiff.compare(
267
338
  )
268
339
  ```
269
340
 
270
- To see versioned child events without touching the article after each change:
341
+ To show the comment creation as its own activity step, without updating the article:
271
342
 
272
343
  <!-- executable:quickstart-activity -->
273
344
  ```ruby
@@ -279,47 +350,46 @@ steps = PaperTrailDiff.activity_timeline(
279
350
  )
280
351
 
281
352
  steps.reject(&:empty?).each do |step|
282
- boundary = step.to_boundary
283
- puts "#{boundary.item_type} ##{boundary.item_id}"
353
+ source = step.source_boundary
354
+ puts "#{source.item_type} ##{source.item_id}"
284
355
  end
285
356
  ```
286
357
 
287
- Use a later transaction-backed root checkpoint instead of `to: current` when
288
- the result must remain reproducible after the database changes again.
289
-
290
- For a historical reporting window, replace `from:` and `to:` with `within:`:
358
+ `source_boundary` describes the version responsible for a step. Its `item_type`
359
+ and `item_id` are PaperTrail's model name and record ID. It also exposes the
360
+ version's `whodunnit` and `event`. See the README for a feed using these values.
291
361
 
292
- ```ruby
293
- steps = PaperTrailDiff.activity_timeline(
294
- article,
295
- within: report_start...report_end,
296
- associations: ["comments"]
297
- )
298
- ```
362
+ To keep the result fixed after later edits, save another article version with
363
+ `paper_trail.save_with_version` inside a transaction, then pass that version
364
+ instead of `to: current`.
299
365
 
300
- This selects versioned comment activity directly, even if the article had no
301
- mutation during the window. Take a root checkpoint after `report_end`; it gives
302
- the gem the historical context needed to expose the final child mutation.
366
+ You can also pass `within: start_time...end_time`. This single-article method
367
+ includes comment changes even if the article was not edited in that period.
368
+ Unlike this method, a relation-based `report` needs an article version in the
369
+ window to select that article.
303
370
 
304
371
  ## 9. Common surprises
305
372
 
306
- - PaperTrail stores pre-change snapshots. The current record is not represented
307
- by `versions.last`; pass the record explicitly when current state is wanted.
308
- - A `within:` window containing mutations needs a later root checkpoint. The
309
- trailing version is reconstruction context and may appear as a step's ending
310
- boundary even though its own mutation is outside the window.
373
+ - `article.versions.last.reify` gives the article **before** its latest recorded
374
+ change. Pass `article` to read the current saved values.
375
+ - PaperTrail's creation version returns `nil` from `reify`. There was no record
376
+ before creation; the diff reports the record being added.
377
+ - A range ending at the current article includes changes PaperTrail never
378
+ recorded, such as `update_columns`. Pass a version as `to:` to stop at
379
+ recorded history.
311
380
  - `ignore:` replaces the default list. Include `updated_at` yourself when using
312
381
  a custom list and you still want it ignored.
313
382
  - Historical associations require PT-AT to be installed, loaded, migrated, and
314
383
  enabled. Restart the console after setup.
315
- - Only requested association paths are traversed. The gem never recursively
316
- discovers the whole model graph.
384
+ - Only the associations listed in `associations:` are included. Asking for
385
+ comments does not automatically include every association on each comment.
317
386
  - Historical output cannot contain data that PaperTrail or PT-AT did not record
318
387
  or can no longer reconstruct.
319
388
 
320
- For performance guidance, HABTM limitations, diagnostics, discovery, path-aware
321
- ignore rules, and complete result shapes, continue with the
322
- [README](README.md).
389
+ For association support, performance, error handling, ignore rules, and all
390
+ available result methods, see the
391
+ [API reference](docs/reference.md). For complete activity-feed and batch-report
392
+ recipes, including deleted records, see the [README](README.md).
323
393
 
324
394
  Upstream setup references:
325
395
  [PaperTrail installation](https://github.com/paper-trail-gem/paper_trail#1b-installation)