paper_trail_diff 0.12.0 → 0.13.1
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +158 -0
- data/QUICKSTART.md +149 -79
- data/README.md +179 -1388
- data/docs/architecture.md +88 -0
- data/docs/performance.md +309 -0
- data/docs/reference.md +977 -0
- data/docs/testing.md +169 -0
- data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +2 -12
- data/lib/paper_trail_diff/activity_boundary.rb +20 -8
- data/lib/paper_trail_diff/activity_child_candidate_loader.rb +29 -0
- data/lib/paper_trail_diff/activity_collection_event_applier.rb +10 -7
- data/lib/paper_trail_diff/activity_collection_route_updater.rb +1 -11
- data/lib/paper_trail_diff/activity_event_record_normalizer.rb +4 -0
- data/lib/paper_trail_diff/activity_event_record_resolver.rb +66 -6
- data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +6 -4
- data/lib/paper_trail_diff/activity_grouping.rb +12 -0
- data/lib/paper_trail_diff/activity_history.rb +9 -3
- data/lib/paper_trail_diff/activity_relationship.rb +2 -0
- data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +1 -0
- data/lib/paper_trail_diff/activity_snapshot_sequence.rb +9 -2
- data/lib/paper_trail_diff/activity_timeline_builder.rb +39 -35
- data/lib/paper_trail_diff/activity_version_collector.rb +27 -18
- data/lib/paper_trail_diff/analysis.rb +9 -4
- data/lib/paper_trail_diff/analysis_batch.rb +28 -7
- data/lib/paper_trail_diff/association_traversal.rb +23 -0
- data/lib/paper_trail_diff/batch_boundary_resolver.rb +18 -26
- data/lib/paper_trail_diff/batch_safety.rb +55 -0
- data/lib/paper_trail_diff/batched_root_analyzer.rb +14 -5
- data/lib/paper_trail_diff/batched_root_versions.rb +10 -38
- data/lib/paper_trail_diff/branch_snapshot_refresher.rb +7 -8
- data/lib/paper_trail_diff/collection_comparator.rb +18 -20
- data/lib/paper_trail_diff/collection_identity_index.rb +8 -12
- data/lib/paper_trail_diff/deleted_analysis_batch.rb +94 -0
- data/lib/paper_trail_diff/direct_record_state_loader.rb +81 -0
- data/lib/paper_trail_diff/endpoint.rb +10 -0
- data/lib/paper_trail_diff/engine.rb +4 -2
- data/lib/paper_trail_diff/errors.rb +9 -0
- data/lib/paper_trail_diff/historical_association_reifier.rb +26 -1
- data/lib/paper_trail_diff/historical_filter.rb +176 -0
- data/lib/paper_trail_diff/historical_snapshot_store.rb +51 -35
- data/lib/paper_trail_diff/instrumentation.rb +2 -2
- data/lib/paper_trail_diff/nested_comparator.rb +39 -15
- data/lib/paper_trail_diff/paper_trail_adapter.rb +109 -55
- data/lib/paper_trail_diff/prepared_activity_index.rb +121 -0
- data/lib/paper_trail_diff/prepared_edge_loader.rb +11 -11
- data/lib/paper_trail_diff/prepared_history.rb +21 -30
- data/lib/paper_trail_diff/prepared_history_loader.rb +14 -2
- data/lib/paper_trail_diff/prepared_record_index.rb +102 -29
- data/lib/paper_trail_diff/prepared_snapshot_builder.rb +64 -0
- data/lib/paper_trail_diff/prepared_state_association_reader.rb +77 -0
- data/lib/paper_trail_diff/root_version_plan.rb +35 -3
- data/lib/paper_trail_diff/root_version_selection.rb +16 -5
- data/lib/paper_trail_diff/scoped_analysis.rb +17 -2
- data/lib/paper_trail_diff/scoped_analysis_builder.rb +47 -0
- data/lib/paper_trail_diff/scoped_root_selection.rb +106 -13
- data/lib/paper_trail_diff/snapshot.rb +14 -1
- data/lib/paper_trail_diff/snapshot_normalizer.rb +75 -40
- data/lib/paper_trail_diff/step.rb +10 -5
- data/lib/paper_trail_diff/stored_time_precision.rb +68 -0
- data/lib/paper_trail_diff/support.rb +13 -3
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +19 -7
- data/lib/paper_trail_diff/time_range.rb +21 -2
- data/lib/paper_trail_diff/time_version_range.rb +2 -16
- data/lib/paper_trail_diff/timeline_builder.rb +5 -4
- data/lib/paper_trail_diff/timeline_range.rb +29 -46
- data/lib/paper_trail_diff/timeline_snapshot_provider.rb +13 -2
- data/lib/paper_trail_diff/traversal.rb +1 -1
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_association_candidate_scope.rb +1 -1
- data/lib/paper_trail_diff/version_boundary.rb +48 -0
- data/lib/paper_trail_diff/version_query.rb +41 -0
- data/lib/paper_trail_diff/version_range.rb +10 -38
- data/lib/paper_trail_diff.rb +78 -42
- data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +0 -3
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -4
- data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +11 -0
- data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +4 -4
- data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +0 -3
- data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +19 -3
- data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +2 -2
- data/sig/generated/paper_trail_diff/activity_grouping.rbs +3 -0
- data/sig/generated/paper_trail_diff/activity_history.rbs +6 -2
- data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +7 -2
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +8 -3
- data/sig/generated/paper_trail_diff/activity_version_collector.rbs +4 -8
- data/sig/generated/paper_trail_diff/analysis.rbs +2 -2
- data/sig/generated/paper_trail_diff/analysis_batch.rbs +14 -3
- data/sig/generated/paper_trail_diff/association_traversal.rbs +11 -0
- data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +7 -10
- data/sig/generated/paper_trail_diff/batch_safety.rbs +24 -0
- data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +8 -2
- data/sig/generated/paper_trail_diff/batched_root_versions.rbs +4 -16
- data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
- data/sig/generated/paper_trail_diff/collection_comparator.rbs +6 -3
- data/sig/generated/paper_trail_diff/collection_identity_index.rbs +3 -3
- data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +44 -0
- data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +42 -0
- data/sig/generated/paper_trail_diff/endpoint.rbs +5 -0
- data/sig/generated/paper_trail_diff/engine.rbs +4 -2
- data/sig/generated/paper_trail_diff/errors.rbs +12 -0
- data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +8 -0
- data/sig/generated/paper_trail_diff/historical_filter.rbs +75 -0
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +17 -4
- data/sig/generated/paper_trail_diff/nested_comparator.rbs +15 -7
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +36 -15
- data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +56 -0
- data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +3 -3
- data/sig/generated/paper_trail_diff/prepared_history.rbs +12 -14
- data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -0
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +43 -6
- data/sig/generated/paper_trail_diff/prepared_snapshot_builder.rbs +24 -0
- data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +33 -0
- data/sig/generated/paper_trail_diff/root_version_plan.rbs +22 -2
- data/sig/generated/paper_trail_diff/root_version_selection.rbs +8 -1
- data/sig/generated/paper_trail_diff/scoped_analysis.rbs +14 -2
- data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +22 -0
- data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +46 -6
- data/sig/generated/paper_trail_diff/snapshot.rbs +4 -0
- data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +34 -11
- data/sig/generated/paper_trail_diff/step.rbs +8 -2
- data/sig/generated/paper_trail_diff/stored_time_precision.rbs +36 -0
- data/sig/generated/paper_trail_diff/support.rbs +7 -0
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +7 -2
- data/sig/generated/paper_trail_diff/time_range.rbs +9 -0
- data/sig/generated/paper_trail_diff/time_version_range.rbs +0 -6
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
- data/sig/generated/paper_trail_diff/timeline_range.rbs +12 -15
- data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +2 -0
- data/sig/generated/paper_trail_diff/version_boundary.rbs +23 -0
- data/sig/generated/paper_trail_diff/version_query.rbs +21 -0
- data/sig/generated/paper_trail_diff/version_range.rbs +5 -14
- data/sig/generated/paper_trail_diff.rbs +31 -25
- metadata +31 -5
data/README.md
CHANGED
|
@@ -1,1453 +1,244 @@
|
|
|
1
1
|
# paper_trail_diff
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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.
|
|
3
|
+
Compare past and present records using the history saved by
|
|
4
|
+
[PaperTrail](https://github.com/paper-trail-gem/paper_trail). Show changed fields,
|
|
5
|
+
include changes to associated records, or build a report for many records.
|
|
6
|
+
Results are Ruby objects with methods for reading the changes and `to_h` for
|
|
7
|
+
turning them into hashes. The gem does not generate display text or save changes.
|
|
11
8
|
|
|
12
|
-
`
|
|
13
|
-
|
|
14
|
-
[PaperTrail](https://github.com/paper-trail-gem/paper_trail). It returns immutable
|
|
15
|
-
Ruby value objects and hashes rather than formatted text.
|
|
9
|
+
If you already use `has_paper_trail` and `record.versions`, you can pass those
|
|
10
|
+
versions directly to this gem. You do not need to call `reify` yourself.
|
|
16
11
|
|
|
17
|
-
|
|
18
|
-
available when
|
|
19
|
-
[paper_trail-association_tracking](https://github.com/westonganger/paper_trail-association_tracking)
|
|
20
|
-
(PT-AT) is installed, loaded, and enabled.
|
|
12
|
+
## Get started
|
|
21
13
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
New to the gem? Run [`examples/demo.rb`](examples/demo.rb) to see it work in
|
|
25
|
-
about ten seconds — one self-contained file, no application and no migrations,
|
|
26
|
-
which fetches what it needs on first run:
|
|
14
|
+
Add the gem to your Rails application:
|
|
27
15
|
|
|
28
16
|
```console
|
|
29
|
-
|
|
17
|
+
bundle add paper_trail_diff
|
|
30
18
|
```
|
|
31
19
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
nested, through-association, and HABTM history against it.
|
|
20
|
+
Already have a model with `has_paper_trail`? Try the comparison below.
|
|
21
|
+
Otherwise, follow the [Rails quickstart](QUICKSTART.md) to set up PaperTrail
|
|
22
|
+
and create your first comparison.
|
|
36
23
|
|
|
37
|
-
|
|
24
|
+
To try it without Rails, download [examples/demo.rb](examples/demo.rb) and run
|
|
25
|
+
`ruby demo.rb`. It installs its dependencies on the first run. The
|
|
26
|
+
[demo application](https://github.com/aheathwilliams/paper_trail_summary_test_app)
|
|
27
|
+
shows reports in a Rails UI.
|
|
38
28
|
|
|
39
|
-
|
|
29
|
+
Ruby 3.1+ and PaperTrail 16 or 17 are supported, subject to
|
|
30
|
+
[PaperTrail's Ruby and ActiveRecord requirements](https://github.com/paper-trail-gem/paper_trail#1a-compatibility).
|
|
31
|
+
Comparing association history also requires
|
|
32
|
+
[paper_trail-association_tracking](https://github.com/westonganger/paper_trail-association_tracking),
|
|
33
|
+
an optional PaperTrail plugin. The quickstart includes its setup.
|
|
40
34
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
35
|
+
> **Experimental:** AI-assisted coding tools made significant contributions to
|
|
36
|
+
> this project. Check results against your application's history before relying
|
|
37
|
+
> on them. Changes that were never recorded cannot be recovered.
|
|
44
38
|
|
|
45
|
-
|
|
39
|
+
## 1. Compare an earlier version with the current record
|
|
46
40
|
|
|
47
|
-
|
|
48
|
-
gem "paper_trail-association_tracking"
|
|
49
|
-
```
|
|
41
|
+
This example assumes an `Article` model with a `title` column and `has_paper_trail`:
|
|
50
42
|
|
|
43
|
+
<!-- executable:recipe-compare -->
|
|
51
44
|
```ruby
|
|
52
|
-
|
|
53
|
-
|
|
45
|
+
article = Article.create!(title: "Draft")
|
|
46
|
+
article.update!(title: "Published")
|
|
54
47
|
|
|
55
|
-
|
|
56
|
-
|
|
48
|
+
# PaperTrail stores the values from BEFORE the update.
|
|
49
|
+
before = article.versions.last
|
|
50
|
+
diff = PaperTrailDiff.compare(before, article)
|
|
57
51
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
Almost everything else in this README follows from one property of PaperTrail,
|
|
61
|
-
so it is worth being precise about it first: **a version stores the state that
|
|
62
|
-
existed _before_ the event that created it.** A version is a record of what was
|
|
63
|
-
overwritten, not of what was written.
|
|
64
|
-
|
|
65
|
-
Take an article created as `"Draft"`, then updated to `"Published"`, then to
|
|
66
|
-
`"Final"`:
|
|
67
|
-
|
|
68
|
-
```text
|
|
69
|
-
v1 v2 v3 (no version)
|
|
70
|
-
"create" "update" "update"
|
|
71
|
-
| | | |
|
|
72
|
-
──────●───────────────●─────────────────●─────────────────●──────▶ time
|
|
73
|
-
| | | |
|
|
74
|
-
└─── "Draft" ───┘ | |
|
|
75
|
-
stored in v2 | |
|
|
76
|
-
└─ "Published" ───┘ |
|
|
77
|
-
stored in v3 |
|
|
78
|
-
└──── "Final" ────┘
|
|
79
|
-
only in the table
|
|
52
|
+
diff.attributes.fetch("title").to_h
|
|
53
|
+
# => { from: "Draft", to: "Published" }
|
|
80
54
|
```
|
|
81
55
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
with a later state reports a structured `record_presence_change` rather than
|
|
87
|
-
inventing scalar changes.
|
|
88
|
-
- **The newest state has no version at all.** It exists only in the table. A
|
|
89
|
-
fully historical result therefore needs a version *later* than the last change
|
|
90
|
-
it should reveal, which is why a `within:` window can raise
|
|
91
|
-
`IncompleteTimeRangeError`, and why `activity_timeline(..., to: article)`
|
|
92
|
-
exists for ending at current state instead.
|
|
93
|
-
- **A change is visible between two boundaries**, never "at" one. This is why
|
|
94
|
-
both timeline APIs return steps rather than events.
|
|
95
|
-
|
|
96
|
-
The [Quickstart](QUICKSTART.md) walks through the same idea against a real
|
|
97
|
-
console session, creating the models it uses as it goes.
|
|
98
|
-
|
|
99
|
-
## Which models and schemas work
|
|
56
|
+
PaperTrail's `version.reify` returns the record as it was before that version's
|
|
57
|
+
change. Passing a version to `compare` uses that same earlier state. Passing
|
|
58
|
+
`article` uses its current saved values. Pass two versions to compare two
|
|
59
|
+
historical states.
|
|
100
60
|
|
|
101
|
-
|
|
102
|
-
|
|
61
|
+
PaperTrail's `version.changeset` describes one recorded change. `compare`
|
|
62
|
+
describes the net difference between the two states you choose. If a field
|
|
63
|
+
changes and then changes back, there is no net difference for that field. To
|
|
64
|
+
see every change instead, use `PaperTrailDiff.timeline(article)`, which covers
|
|
65
|
+
the article's whole history through its current saved values.
|
|
103
66
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
rather than by hand.
|
|
109
|
-
- **Non-integer primary keys** on the tracked model, such as a UUID column.
|
|
110
|
-
- **A custom version class**, via `has_paper_trail versions: { class_name: }`.
|
|
111
|
-
Nothing here references `PaperTrail::Version` directly.
|
|
67
|
+
An attribute appears in `diff.attributes` only when it changed. `diff.empty?`
|
|
68
|
+
tells you whether anything changed. A PaperTrail `create` version has no earlier
|
|
69
|
+
record (`reify` returns `nil`), so comparing it with the current record reports
|
|
70
|
+
`diff.record_presence_change`, meaning the record was added.
|
|
112
71
|
|
|
113
|
-
|
|
72
|
+
To include comments, add `associations: [:comments]`. If comments have replies,
|
|
73
|
+
`associations: ["comments.replies"]` includes those too. Associated models must
|
|
74
|
+
also use `has_paper_trail`, and association tracking must be enabled.
|
|
114
75
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
insertion. An autoincrement id does; a **UUID version id does not**.
|
|
118
|
-
|
|
119
|
-
So two conditions together are unsafe: version ids that are not sequential,
|
|
120
|
-
*and* versions sharing a timestamp — which is ordinary on a MySQL `datetime`
|
|
121
|
-
column, since it stores whole seconds. The order is then unrecoverable, and
|
|
122
|
-
rather than report a plausible-looking history in the wrong order the gem
|
|
123
|
-
raises `PaperTrailDiff::AmbiguousVersionOrderError` naming both versions.
|
|
124
|
-
`diagnose` reports the same condition as an error, so a caller can check before
|
|
125
|
-
running rather than after a surprise.
|
|
126
|
-
|
|
127
|
-
Either half alone is fine. Sequential ids order tied timestamps correctly, and
|
|
128
|
-
distinct timestamps never reach the fallback.
|
|
129
|
-
|
|
130
|
-
### Association changes across a shared timestamp
|
|
131
|
-
|
|
132
|
-
Sequential ids recover the *order* of tied versions, but not their association
|
|
133
|
-
state. PT-AT records association membership per version and resolves it by
|
|
134
|
-
timestamp, so two versions sharing one are indistinguishable for that purpose:
|
|
135
|
-
**an association change between them is invisible.** It is not reported as
|
|
136
|
-
absent — the association simply does not appear in the diff.
|
|
137
|
-
|
|
138
|
-
Unlike an unorderable sequence this is not always wrong. If nothing associated
|
|
139
|
-
changed between the two versions, the result is correct, and the gem cannot
|
|
140
|
-
tell the two cases apart, because not seeing the change is the symptom. So it
|
|
141
|
-
does not raise. Instead:
|
|
142
|
-
|
|
143
|
-
- `diagnose` reports `:tied_version_timestamps` as a **warning** whenever
|
|
144
|
-
`associations:` are selected and versions in range share a timestamp.
|
|
145
|
-
- a comparison in that state emits
|
|
146
|
-
`ambiguous_association_boundary.paper_trail_diff` through ActiveSupport
|
|
147
|
-
notifications, carrying the two version ids and the shared timestamp. Nothing
|
|
148
|
-
is logged; subscribe if you want it surfaced.
|
|
149
|
-
|
|
150
|
-
Writing several versions inside one clock tick is easy to do — a service object
|
|
151
|
-
that touches a record and its children in quick succession will manage it — so
|
|
152
|
-
this is worth checking before trusting an association history. Recording
|
|
153
|
-
versions at sub-second precision separates them.
|
|
154
|
-
|
|
155
|
-
### ActiveStorage attachments
|
|
156
|
-
|
|
157
|
-
`has_one_attached` and `has_many_attached` point at `ActiveStorage::Attachment`
|
|
158
|
-
and `ActiveStorage::Blob`, which Rails owns and PaperTrail does not version.
|
|
159
|
-
There is no history behind them, so a historical comparison over those paths
|
|
160
|
-
raises `PaperTrailDiff::UnversionedAssociationError` rather than reporting that
|
|
161
|
-
nothing changed. Adding `has_paper_trail` to Rails' own models does not rescue
|
|
162
|
-
it either: `has_one_attached` is a scoped `has_one`, and reifying it from
|
|
163
|
-
history is ambiguous.
|
|
164
|
-
|
|
165
|
-
Audit the attachment through a model you own instead. Give it the facts worth
|
|
166
|
-
auditing as ordinary columns, and version it:
|
|
76
|
+
For nested results, `each_change` lets you read changes without walking every
|
|
77
|
+
association yourself:
|
|
167
78
|
|
|
168
79
|
```ruby
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
# Attaching a file writes to ActiveStorage's tables, not to this record, so
|
|
175
|
-
# nothing would be versioned without copying the facts across.
|
|
176
|
-
after_save :record_file_metadata, if: -> { file.attached? }
|
|
177
|
-
|
|
178
|
-
def record_file_metadata
|
|
179
|
-
blob = file.blob
|
|
180
|
-
return if filename == blob.filename.to_s && checksum == blob.checksum
|
|
181
|
-
|
|
182
|
-
update_columns(
|
|
183
|
-
filename: blob.filename.to_s, content_type: blob.content_type,
|
|
184
|
-
byte_size: blob.byte_size, checksum: blob.checksum
|
|
185
|
-
)
|
|
186
|
-
end
|
|
187
|
-
end
|
|
188
|
-
```
|
|
189
|
-
|
|
190
|
-
```ruby
|
|
191
|
-
class Article < ApplicationRecord
|
|
192
|
-
has_many :document_revisions, as: :attachable
|
|
193
|
-
has_paper_trail
|
|
80
|
+
diff.each_change do |entry|
|
|
81
|
+
entry.kind # e.g. :attribute_changed or :record_added
|
|
82
|
+
entry.association_path # e.g. ["comments", "replies"]
|
|
83
|
+
entry.value # the changed values or the added/removed record's data
|
|
194
84
|
end
|
|
195
|
-
|
|
196
|
-
PaperTrailDiff.compare(before, after, associations: [:document_revisions])
|
|
197
|
-
```
|
|
198
|
-
|
|
199
|
-
The bytes stay in ActiveStorage; the *auditable facts* live where PaperTrail
|
|
200
|
-
can see them. Nothing about the column names matters to this gem — it reports
|
|
201
|
-
whichever columns changed. What matters is that replacing a file writes to a
|
|
202
|
-
versioned record, because attaching one on its own does not.
|
|
203
|
-
|
|
204
|
-
## Choosing an entry point
|
|
205
|
-
|
|
206
|
-
| You need | Call |
|
|
207
|
-
| --- | --- |
|
|
208
|
-
| The net difference between two endpoints | `compare` |
|
|
209
|
-
| The same, for many records in one pass | `compare_many` |
|
|
210
|
-
| One step per version of the root record | `timeline` |
|
|
211
|
-
| One step per version of the root *or a selected child* | `activity_timeline` |
|
|
212
|
-
| A net difference and a timeline from one history pass | `analyze` |
|
|
213
|
-
|
|
214
|
-
`timeline` and `activity_timeline` differ only in which recorded versions
|
|
215
|
-
become boundaries. Given an article with two comment edits between two article
|
|
216
|
-
versions:
|
|
217
|
-
|
|
218
|
-
```text
|
|
219
|
-
recorded versions A1 C1 C2 A2 A = Article version
|
|
220
|
-
| | | | C = Comment version
|
|
221
|
-
────●─────────●───────●─────────●────▶ time
|
|
222
|
-
| | | |
|
|
223
|
-
timeline └───────── 1 step ──────────┘
|
|
224
|
-
both comment changes land inside that one step
|
|
225
|
-
|
|
226
|
-
activity_timeline └── 1 ────┴── 2 ──┴─── 3 ───┘
|
|
227
|
-
each recorded version becomes its own boundary
|
|
228
85
|
```
|
|
229
86
|
|
|
230
|
-
|
|
231
|
-
selected association, because it has to describe what changed beneath the root.
|
|
232
|
-
They differ in how finely that change is split, and in what the reconstruction
|
|
233
|
-
costs. That cost trade-off is covered under
|
|
234
|
-
[choosing a reconstruction strategy](#choosing-between-timeline-and-analyzeactivity-true).
|
|
87
|
+
## 2. Show who changed a record and its comments
|
|
235
88
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
only their net difference:
|
|
89
|
+
This example requires the association setup in the quickstart. PaperTrail's
|
|
90
|
+
`whodunnit` records who made a change; here we set it with `PaperTrail.request`.
|
|
91
|
+
The timeline covers the article's whole history, including comment changes made
|
|
92
|
+
after the article's last version.
|
|
241
93
|
|
|
94
|
+
<!-- executable:recipe-activity -->
|
|
242
95
|
```ruby
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
# => {
|
|
250
|
-
# record_presence_change: nil,
|
|
251
|
-
# attributes: {
|
|
252
|
-
# "title" => { from: "Draft", to: "Published" }
|
|
253
|
-
# },
|
|
254
|
-
# associations: {}
|
|
255
|
-
# }
|
|
256
|
-
```
|
|
257
|
-
|
|
258
|
-
Intermediate edits do not affect `compare`. If a title changes and later
|
|
259
|
-
returns to its original value, endpoint comparison reports no title change.
|
|
260
|
-
|
|
261
|
-
`record_presence_change` reports whether the root record *existed* at each
|
|
262
|
-
endpoint, and is `nil` for the ordinary case where it existed at both. It is
|
|
263
|
-
populated when one endpoint reifies to `nil` — most often a `create` version,
|
|
264
|
-
whose pre-change state is the absence of the record — and it then carries whole
|
|
265
|
-
`RecordSnapshot` values rather than fake scalar changes from `nil`.
|
|
266
|
-
|
|
267
|
-
Destroying the root record is not reported this way, and the reason follows
|
|
268
|
-
from the [pre-change model](#how-papertrail-records-state): the state *at* a
|
|
269
|
-
`destroy` version is the state immediately before the deletion, so the record
|
|
270
|
-
is still present there. A comparison ending at a `destroy` version reports that
|
|
271
|
-
last edit, not the deletion. Deleting a *selected child* is reported normally,
|
|
272
|
-
as a `removed` member of its parent's collection, and `activity_timeline`
|
|
273
|
-
reports a destroyed root through a
|
|
274
|
-
[closing removal step](#closing-a-destroyed-root).
|
|
275
|
-
|
|
276
|
-
Pass the record explicitly when the desired endpoint is current state:
|
|
277
|
-
|
|
278
|
-
```ruby
|
|
279
|
-
diff = PaperTrailDiff.compare(
|
|
280
|
-
article.versions.last,
|
|
281
|
-
article,
|
|
282
|
-
associations: ["comments.replies"]
|
|
283
|
-
)
|
|
284
|
-
```
|
|
285
|
-
|
|
286
|
-
Two version endpoints must be given in chronological order. A transposed pair
|
|
287
|
-
produces the inverse diff, which is easy to do by accident and impossible to
|
|
288
|
-
detect afterwards because a result carries no direction of its own, so it
|
|
289
|
-
raises `PaperTrailDiff::ReversedEndpointsError` instead. Nothing is lost by
|
|
290
|
-
this: the two orders differ only in which side of each change is `from`.
|
|
291
|
-
|
|
292
|
-
A current-record endpoint is exempt and may appear on either side, because it
|
|
293
|
-
is self-evidently the live state and placing it first is a deliberate reverse
|
|
294
|
-
comparison. Current state is never inferred. The record must be persisted, not
|
|
295
|
-
destroyed, and free of unsaved attribute changes. The gem reloads it unscoped
|
|
296
|
-
before normalization, so stale association caches and in-memory edits are not
|
|
297
|
-
compared. Use a database
|
|
298
|
-
transaction with an appropriate isolation level when several live association
|
|
299
|
-
queries must represent one atomic application snapshot.
|
|
300
|
-
|
|
301
|
-
For collection-level reports, use `compare_many` to reload current roots and
|
|
302
|
-
preload each explicitly selected live association path across the batch. Each
|
|
303
|
-
entry has the same endpoints and options as `compare`; results are returned in
|
|
304
|
-
input order as a frozen hash keyed by `[item_type, item_id]` strings:
|
|
305
|
-
|
|
306
|
-
Endpoints are still supplied by the caller, so look them up in bulk too —
|
|
307
|
-
otherwise the per-root queries this API removes come straight back. Two
|
|
308
|
-
queries resolve the earliest version of every root, whatever the batch size:
|
|
309
|
-
|
|
310
|
-
```ruby
|
|
311
|
-
orders = Order.where(id: order_ids).to_a
|
|
312
|
-
|
|
313
|
-
earliest_ids = PaperTrail::Version
|
|
314
|
-
.where(item_type: "Order", item_id: orders.map(&:id))
|
|
315
|
-
.group(:item_id)
|
|
316
|
-
.minimum(:id)
|
|
317
|
-
first_versions = PaperTrail::Version
|
|
318
|
-
.where(id: earliest_ids.values)
|
|
319
|
-
.index_by(&:item_id)
|
|
320
|
-
```
|
|
321
|
-
|
|
322
|
-
```ruby
|
|
323
|
-
diffs = PaperTrailDiff.compare_many(
|
|
324
|
-
orders.map { |order| { from: first_versions.fetch(order.id), to: order } },
|
|
325
|
-
associations: [:line_items],
|
|
326
|
-
ignore: []
|
|
327
|
-
)
|
|
328
|
-
|
|
329
|
-
diffs.fetch(["Order", orders.first.id.to_s]) # => PaperTrailDiff::Diff
|
|
330
|
-
```
|
|
331
|
-
|
|
332
|
-
Endpoints may also be given as `:first` or `:last`, resolved against the record
|
|
333
|
-
the pair's other endpoint names. That replaces the lookup above entirely, and
|
|
334
|
-
resolves every root in two queries per model class rather than one per root:
|
|
335
|
-
|
|
336
|
-
```ruby
|
|
337
|
-
diffs = PaperTrailDiff.compare_many(
|
|
338
|
-
orders.map { |order| { from: :first, to: order } },
|
|
339
|
-
associations: [:line_items]
|
|
340
|
-
)
|
|
341
|
-
```
|
|
342
|
-
|
|
343
|
-
A symbol carries no identity of its own, so `{ from: :first, to: :last }` raises
|
|
344
|
-
rather than guessing. A root with no recorded history resolves to an empty
|
|
345
|
-
`Diff`, matching how the timeline APIs answer the same question.
|
|
346
|
-
|
|
347
|
-
Root identities must be unique within one call. Historical reconstruction for
|
|
348
|
-
ordinary versioned, unscoped association paths is also prepared across the
|
|
349
|
-
collection. Paths that require the existing point-in-time PT-AT fallback retain
|
|
350
|
-
their per-endpoint behavior and all historical reconstruction retains the same
|
|
351
|
-
PaperTrail Association Tracking requirements as `compare`. Live collection
|
|
352
|
-
scopes with per-owner semantics, such as `limit`, `offset`, or an owner
|
|
353
|
-
argument, are loaded per root; other selected branches remain batched. Callers
|
|
354
|
-
should still use an appropriate database transaction when all live queries
|
|
355
|
-
must observe one atomic snapshot.
|
|
356
|
-
|
|
357
|
-
### Look inside a JSON column
|
|
358
|
-
|
|
359
|
-
A `json` or `jsonb` column reifies to one Hash, so an attribute diff can only
|
|
360
|
-
say the blob changed. `nested_changes` says which keys changed:
|
|
361
|
-
|
|
362
|
-
```ruby
|
|
363
|
-
diff = PaperTrailDiff.compare(from_version, to_version)
|
|
364
|
-
|
|
365
|
-
PaperTrailDiff.nested_changes(diff.attributes.fetch("config"))
|
|
366
|
-
# => { ["theme"] => <from "dark" to "light">,
|
|
367
|
-
# ["limits", "max"] => <from 10 to 20> }
|
|
368
|
-
```
|
|
369
|
-
|
|
370
|
-
It reads a column that stores JSON as text as readily as a native one, and also
|
|
371
|
-
takes a bare pair: `PaperTrailDiff.nested_changes(from_value, to_value)`.
|
|
372
|
-
|
|
373
|
-
Nothing about the attribute diff itself changes. A pair that is not two readable
|
|
374
|
-
structures — text on one side and JSON on the other, or a value that does not
|
|
375
|
-
parse — reports nothing, and the caller still has the whole-value change it
|
|
376
|
-
already had.
|
|
377
|
-
|
|
378
|
-
Three things about the shape are worth knowing:
|
|
379
|
-
|
|
380
|
-
**Paths are arrays, not dotted strings.** A JSON key may itself contain a dot, so
|
|
381
|
-
`"a.b"` would be ambiguous between one key and two:
|
|
382
|
-
|
|
383
|
-
```ruby
|
|
384
|
-
PaperTrailDiff.nested_changes({ "a.b" => 1, "a" => { "b" => 1 } },
|
|
385
|
-
{ "a.b" => 2, "a" => { "b" => 3 } })
|
|
386
|
-
# => { ["a.b"] => <from 1 to 2>, ["a", "b"] => <from 1 to 3> }
|
|
387
|
-
```
|
|
388
|
-
|
|
389
|
-
**Arrays are reported by membership, not by position.** Elements carry no
|
|
390
|
-
identity, so an insertion at the front would make every later index look
|
|
391
|
-
changed — one insertion described as several edits. An `ArrayChange` says what
|
|
392
|
-
was added and removed, matching by value and respecting duplicates:
|
|
393
|
-
|
|
394
|
-
```ruby
|
|
395
|
-
change = PaperTrailDiff.nested_changes(
|
|
396
|
-
{ "keywords" => %w[apollo nasa] },
|
|
397
|
-
{ "keywords" => %w[saturn apollo nasa] }
|
|
398
|
-
).fetch(["keywords"])
|
|
399
|
-
|
|
400
|
-
change.added # => ["saturn"]
|
|
401
|
-
change.removed # => []
|
|
402
|
-
change.reordered? # => false
|
|
403
|
-
change.from # => ["apollo", "nasa"] (the whole values remain)
|
|
404
|
-
```
|
|
405
|
-
|
|
406
|
-
Membership cannot see one thing, so it is named rather than passed over: the
|
|
407
|
-
same elements in a new order report `reordered?` as true with nothing added or
|
|
408
|
-
removed.
|
|
409
|
-
|
|
410
|
-
An element that is itself an object is reported whole. Saying which field of
|
|
411
|
-
which object changed would need a pairing of before-elements with
|
|
412
|
-
after-elements, and nothing in the value licenses that pairing.
|
|
413
|
-
|
|
414
|
-
**An absent key is not a null one.** `{"a": null}` and `{}` mean different things
|
|
415
|
-
in JSON, and an audit trail that showed them alike would be lying about one of
|
|
416
|
-
them, so a missing key reads as `PaperTrailDiff::NestedComparator::ABSENT`
|
|
417
|
-
rather than `nil`.
|
|
418
|
-
|
|
419
|
-
### Reuse already-preloaded current endpoints
|
|
420
|
-
|
|
421
|
-
`compare`, `compare_many`, and `activity_timeline(..., to: record)` reload
|
|
422
|
-
current endpoints by default. A caller that already owns a consistent, fully
|
|
423
|
-
preloaded graph may opt out. Each endpoint is loaded once per call, so a batch
|
|
424
|
-
that resolves every root up front and then reads one of them again pays
|
|
425
|
-
nothing the second time, and every root in one result reflects the same instant.
|
|
426
|
-
|
|
427
|
-
The option is not accepted by `timeline`, `analyze`, or `analyze_many`. Those
|
|
428
|
-
are bounded by versions and read live state only under
|
|
429
|
-
[`close_on: :current`](#reporting-up-to-now), which always reloads:
|
|
430
|
-
|
|
431
|
-
```ruby
|
|
432
|
-
orders = Order.where(id: order_ids).preload(line_items: :product).to_a
|
|
433
|
-
# `first_versions` is the same bulk endpoint lookup shown above.
|
|
434
|
-
|
|
435
|
-
diffs = PaperTrailDiff.compare_many(
|
|
436
|
-
orders.map do |order|
|
|
437
|
-
{ from: first_versions.fetch(order.id), to: order }
|
|
438
|
-
end,
|
|
439
|
-
associations: ["line_items.product"],
|
|
440
|
-
reload_live_endpoints: false
|
|
441
|
-
)
|
|
442
|
-
```
|
|
443
|
-
|
|
444
|
-
With `reload_live_endpoints: false`, scalar values and association targets come
|
|
445
|
-
from the supplied instances. Every requested association path must already be
|
|
446
|
-
loaded; otherwise `PaperTrailDiff::UnloadedAssociationError` is raised before
|
|
447
|
-
normalization instead of silently issuing an N+1 query. The caller is
|
|
448
|
-
responsible for the consistency and freshness of that in-memory graph.
|
|
449
|
-
|
|
450
|
-
### Runtime performance diagnostics
|
|
451
|
-
|
|
452
|
-
The gem never prints or logs automatically. It emits these
|
|
453
|
-
`ActiveSupport::Notifications` events so applications can choose their logger
|
|
454
|
-
and formatting:
|
|
455
|
-
|
|
456
|
-
- `compare.paper_trail_diff`
|
|
457
|
-
- `compare_many.paper_trail_diff`
|
|
458
|
-
- `activity_timeline.paper_trail_diff`
|
|
459
|
-
- `load_live_endpoints.paper_trail_diff`
|
|
460
|
-
- `prepare_history.paper_trail_diff`
|
|
461
|
-
|
|
462
|
-
```ruby
|
|
463
|
-
ActiveSupport::Notifications.subscribe(/\.paper_trail_diff\z/) do |name, start, finish, _id, payload|
|
|
464
|
-
Rails.logger.debug(
|
|
465
|
-
event: name,
|
|
466
|
-
duration_ms: ((finish - start) * 1_000).round(1),
|
|
467
|
-
**payload
|
|
468
|
-
)
|
|
469
|
-
end
|
|
470
|
-
```
|
|
471
|
-
|
|
472
|
-
The payloads contain counts, model names, association paths, and reload mode,
|
|
473
|
-
not endpoint objects or record attributes. The activity-timeline event wraps
|
|
474
|
-
the complete call and includes `step_count`, so its notification duration is
|
|
475
|
-
the user-visible runtime. To inspect the exact SQL generated by one report,
|
|
476
|
-
scope a standard `sql.active_record` subscriber around it:
|
|
477
|
-
|
|
478
|
-
```ruby
|
|
479
|
-
callback = proc do |_name, _start, _finish, _id, payload|
|
|
480
|
-
Rails.logger.debug(payload[:sql]) unless payload[:cached] || payload[:name] == "SCHEMA"
|
|
481
|
-
end
|
|
482
|
-
|
|
483
|
-
ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do
|
|
484
|
-
PaperTrailDiff.compare_many(comparisons, associations: [:line_items])
|
|
96
|
+
article = Article.create!(title: "Draft")
|
|
97
|
+
PaperTrail.request(whodunnit: "alice") do
|
|
98
|
+
Article.transaction do
|
|
99
|
+
article.update!(title: "Published")
|
|
100
|
+
article.comments.create!(body: "Please review")
|
|
101
|
+
end
|
|
485
102
|
end
|
|
486
|
-
```
|
|
487
|
-
|
|
488
|
-
## Analyze many records over one window
|
|
489
|
-
|
|
490
|
-
`analyze_many` answers "what changed for these records during this period" for a
|
|
491
|
-
whole collection, selecting every root's versions and preparing their selected
|
|
492
|
-
association history once for the batch:
|
|
493
|
-
|
|
494
|
-
```ruby
|
|
495
|
-
results = PaperTrailDiff.analyze_many(
|
|
496
|
-
Order.where(status: "open").to_a,
|
|
497
|
-
within: Time.zone.parse("2026-08-01")...Time.zone.parse("2026-09-01"),
|
|
498
|
-
associations: [:line_items]
|
|
499
|
-
)
|
|
500
|
-
|
|
501
|
-
results.fetch(["Order", order.id.to_s]).diff # net change across the window
|
|
502
|
-
results.fetch(["Order", order.id.to_s]).timeline # its checkpoint steps
|
|
503
|
-
```
|
|
504
|
-
|
|
505
|
-
Results are a frozen hash keyed by `[item_type, item_id]` strings, and each
|
|
506
|
-
value is the same `Analysis` that `analyze` returns for one record. A root with
|
|
507
|
-
no versions inside the window gets an empty `Analysis` rather than raising, so a
|
|
508
|
-
listing page needs no special case. Root identities must be unique.
|
|
509
|
-
|
|
510
|
-
### Letting a relation choose the roots
|
|
511
|
-
|
|
512
|
-
Assembling that array means querying `versions` for which roots moved, filtering,
|
|
513
|
-
and loading the records — the same selection this gem already performs.
|
|
514
|
-
`analyze_scope` does it for you:
|
|
515
|
-
|
|
516
|
-
```ruby
|
|
517
|
-
analyses, unreachable = PaperTrailDiff.analyze_scope(
|
|
518
|
-
Order.where(status: "open"),
|
|
519
|
-
within: Time.zone.parse("2026-08-01")...Time.zone.parse("2026-09-01"),
|
|
520
|
-
associations: [:line_items],
|
|
521
|
-
limit: 500
|
|
522
|
-
)
|
|
523
|
-
```
|
|
524
|
-
|
|
525
|
-
`analyses` is the same frozen hash `analyze_many` returns. It is also available
|
|
526
|
-
as `PaperTrailDiff.analyze_many(scope: ..., limit: ...)`, and it accepts a model
|
|
527
|
-
class (`Order`) as readily as a relation. Every other option — `within:`,
|
|
528
|
-
`version_scope:`, `associations:`, `ignore:`, `activity:`, `close_on:` — behaves
|
|
529
|
-
exactly as it does for `analyze_many`, and the query cost stays flat in the
|
|
530
|
-
number of roots.
|
|
531
|
-
|
|
532
|
-
Two things to know before using it.
|
|
533
|
-
|
|
534
|
-
**`limit:` is required, and exceeding it raises.** Selection moves into the gem
|
|
535
|
-
here, so the bound on how much work one page can ask for has to move with it. It
|
|
536
|
-
refuses rather than truncating, because an audit report that is quietly short is
|
|
537
|
-
worse than one that fails.
|
|
538
|
-
|
|
539
|
-
**`unreachable` names roots the relation could not reach.** A relation's
|
|
540
|
-
conditions are evaluated against the live table, so a root destroyed during the
|
|
541
|
-
window cannot be tested against them at all — its history is intact, and the
|
|
542
|
-
state it held when it was destroyed may well have matched:
|
|
543
|
-
|
|
544
|
-
```ruby
|
|
545
|
-
unreachable # => [["Order", "412"]]
|
|
546
|
-
```
|
|
547
|
-
|
|
548
|
-
These are reported rather than dropped so that a page auditing deletions is told
|
|
549
|
-
where to look instead of silently coming up short. `analyze_many` requires live
|
|
550
|
-
records by design, so to analyze those roots reach for `activity_timeline`, which
|
|
551
|
-
reads history without needing current state.
|
|
552
|
-
|
|
553
|
-
Note also that a relation selects on **current** state, not on state during the
|
|
554
|
-
window. `where(status: "open")` means open *now*, which is a different set from
|
|
555
|
-
what was open while the window was open. If you need the historical population,
|
|
556
|
-
select on the version history instead and pass the records.
|
|
557
|
-
|
|
558
|
-
### Reporting on a subset of root mutations
|
|
559
|
-
|
|
560
|
-
`version_scope:` narrows which root versions count as *selected mutations*, so a
|
|
561
|
-
checkpoint timeline reports only some of them:
|
|
562
|
-
|
|
563
|
-
```ruby
|
|
564
|
-
user_edits = ->(scope) { scope.where.not(whodunnit: nil) }
|
|
565
|
-
|
|
566
|
-
PaperTrailDiff.analyze_many(articles, within: window, version_scope: user_edits)
|
|
567
|
-
PaperTrailDiff.timeline(article, within: window, version_scope: user_edits)
|
|
568
|
-
```
|
|
569
|
-
|
|
570
|
-
The hook receives the version relation for the range and returns a narrowed one.
|
|
571
|
-
It is accepted by `timeline`, `activity_timeline`, `analyze`, and
|
|
572
|
-
`analyze_many`, with any range form.
|
|
573
|
-
|
|
574
|
-
**It sees root versions only.** Someone who edited only comments or tags has no
|
|
575
|
-
root versions to select, so filtering to them yields an empty result even though
|
|
576
|
-
they changed plenty. For "what did this person change?", reach for
|
|
577
|
-
[one person's changes](#reporting-on-one-persons-changes) instead — this hook
|
|
578
|
-
answers the different question of which *root checkpoints* a timeline reports.
|
|
579
|
-
|
|
580
|
-
It filters *selected mutations only*. Versions the filter excludes are still
|
|
581
|
-
loaded, because a version records the state before its own event: without the
|
|
582
|
-
one that follows a selected change, whatever that change produced cannot be
|
|
583
|
-
shown at all. Those extra versions are reconstruction context, so their own
|
|
584
|
-
changes are never attributed to a selected mutation.
|
|
585
|
-
|
|
586
|
-
Each selected mutation is bounded by the version that immediately followed it,
|
|
587
|
-
not by the next selected one. So each step's diff is exactly what that mutation
|
|
588
|
-
did, and it reads the same however many excluded changes happen to follow it:
|
|
589
|
-
|
|
590
|
-
```
|
|
591
|
-
versions system → alice → system → bob → system
|
|
592
|
-
reported alice: what alice changed bob: what bob changed
|
|
593
|
-
```
|
|
594
|
-
|
|
595
|
-
Given a user edit followed by a system edit, filtering to user changes yields
|
|
596
|
-
one step running from the user version to the system version, whose diff is
|
|
597
|
-
exactly the user's change. A selected mutation that nothing follows yet is not
|
|
598
|
-
reported, since no version records the state it produced — the same blind spot
|
|
599
|
-
an unfiltered timeline has at its `to:` boundary. A selected *destruction* is
|
|
600
|
-
the exception: nothing can follow one, but the absence it leaves is what it
|
|
601
|
-
produced, so `activity_timeline` still closes on it. A root with no selected
|
|
602
|
-
mutation reports an empty `Analysis` rather than raising.
|
|
603
|
-
|
|
604
|
-
The hook applies to root versions only. Under `activity: true` the filter
|
|
605
|
-
decides where the span starts and ends, and `timeline` within that span reports
|
|
606
|
-
only selected mutations, but `activity_timeline` still lists every boundary
|
|
607
|
-
inside it — dropping one would fold the change it carried into a neighbouring
|
|
608
|
-
step and credit it to whoever made that one.
|
|
609
|
-
|
|
610
|
-
Omit `within:` to analyze each root's whole recorded history instead, which is
|
|
611
|
-
the batched equivalent of `analyze(record, from: :first, to: :last)`. Explicit
|
|
612
|
-
version endpoints are not accepted, because a single pair cannot mean the same
|
|
613
|
-
thing for every root.
|
|
614
|
-
|
|
615
|
-
Query cost is flat in the number of roots for the diff and timeline views:
|
|
616
|
-
selecting versions and preparing history are both shared across the batch. On a
|
|
617
|
-
twenty-root batch that is 7 queries against 120 for the same work done one
|
|
618
|
-
record at a time. Passing `activity: true` also works and returns the same
|
|
619
|
-
results, but discovering descendant events is inherently per-root, so that view
|
|
620
|
-
batches far less.
|
|
621
|
-
|
|
622
|
-
Roots are supplied as live records, so a root deleted inside the window cannot
|
|
623
|
-
be included; use `activity_timeline` for a history that ends in a deletion.
|
|
624
|
-
|
|
625
|
-
### Reporting on one person's changes
|
|
626
|
-
|
|
627
|
-
An activity timeline already carries this. Every boundary in the span becomes a
|
|
628
|
-
step, and a step's diff is exactly what the event at its `from_boundary` did, so
|
|
629
|
-
that boundary's `whodunnit` is who made the change:
|
|
630
|
-
|
|
631
|
-
<!-- executable:readme-person-changes -->
|
|
632
|
-
```ruby
|
|
633
|
-
steps = PaperTrailDiff.activity_timeline(
|
|
634
|
-
article, within: window, associations: %i[comments tags]
|
|
635
|
-
)
|
|
636
|
-
|
|
637
|
-
attributed = steps.reject(&:empty?)
|
|
638
|
-
by_priya = attributed.select { |step| step.from_boundary.whodunnit == "Priya Shah" }
|
|
639
|
-
|
|
640
|
-
by_priya.first.from_boundary.whodunnit # "Priya Shah"
|
|
641
|
-
by_priya.first.diff # exactly what that one event changed
|
|
642
|
-
|
|
643
|
-
authors = attributed.group_by { |step| step.from_boundary.whodunnit }
|
|
644
|
-
.transform_values(&:length)
|
|
645
|
-
```
|
|
646
|
-
|
|
647
|
-
This covers descendants, which is what `version_scope:` cannot do. On one demo
|
|
648
|
-
history a contributor who only ever touched comments and authorships has four
|
|
649
|
-
attributed activity steps and *zero* root steps.
|
|
650
|
-
|
|
651
|
-
Filter in Ruby rather than asking the gem to select fewer versions. Attribution
|
|
652
|
-
is only correct because every boundary is reconstructed: skip one and the
|
|
653
|
-
snapshot carried into the next step is a state the record had already moved
|
|
654
|
-
past, so the change lands on the wrong person. There is nothing to save by
|
|
655
|
-
filtering earlier — the work is the reconstruction, not the comparison — and a
|
|
656
|
-
predicate can say things a version relation cannot, such as several people at
|
|
657
|
-
once or "anyone but the importer".
|
|
658
|
-
|
|
659
|
-
`reject(&:empty?)` first, because a boundary that changed nothing you selected
|
|
660
|
-
still produces a step. The final event in the range has no boundary after it, so
|
|
661
|
-
what it produced is not shown — the same blind spot `to:` has everywhere else.
|
|
662
|
-
|
|
663
|
-
For "which of these records did someone touch?" across a listing, note that
|
|
664
|
-
`analyze_many` batches the diff and timeline views but not descendant discovery.
|
|
665
|
-
Querying versions by `whodunnit` and mapping them back to roots yourself will be
|
|
666
|
-
much cheaper than an activity pass per record.
|
|
667
|
-
|
|
668
|
-
## Build a root-checkpoint timeline
|
|
669
|
-
|
|
670
|
-
`timeline` accepts two version objects from the supplied record's history, or
|
|
671
|
-
the symbols `:first` and `:last` when the range is simply the record's whole
|
|
672
|
-
recorded history:
|
|
673
|
-
|
|
674
|
-
```ruby
|
|
675
|
-
steps = PaperTrailDiff.timeline(article, from: :first, to: :last)
|
|
676
|
-
```
|
|
677
|
-
|
|
678
|
-
`:first` and `:last` are resolved by the gem, independently of the order the
|
|
679
|
-
`versions` association happens to use, so a caller never has to know it is
|
|
680
|
-
sorted. They work anywhere a version does, including mixed with an explicit
|
|
681
|
-
one (`from: :first, to: some_version`) and on `activity_timeline` and
|
|
682
|
-
`analyze`. A record with no versions has no boundaries to resolve; that is an
|
|
683
|
-
empty history rather than a bad request, so the result is an empty timeline
|
|
684
|
-
instead of an error — which is usually what an index page wants.
|
|
685
103
|
|
|
686
|
-
Combine `from: :first` with `to: article` for the fullest activity view of a
|
|
687
|
-
live record: the whole recorded history, ending at current state. See
|
|
688
|
-
[activity timelines](#build-an-activity-timeline) for why that end differs from
|
|
689
|
-
`to: :last`.
|
|
690
|
-
|
|
691
|
-
The range is inclusive, must be chronological, and produces one `Step` for each
|
|
692
|
-
adjacent pair:
|
|
693
|
-
|
|
694
|
-
```ruby
|
|
695
|
-
steps = PaperTrailDiff.timeline(
|
|
696
|
-
article,
|
|
697
|
-
from: article.versions[1],
|
|
698
|
-
to: article.versions[4]
|
|
699
|
-
)
|
|
700
|
-
|
|
701
|
-
steps.first.from_version # the original PaperTrail version
|
|
702
|
-
steps.first.to_version # the next PaperTrail version
|
|
703
|
-
steps.first.from_boundary # immutable presentation metadata
|
|
704
|
-
steps.first.to_boundary
|
|
705
|
-
steps.first.diff # a PaperTrailDiff::Diff
|
|
706
|
-
steps.first.empty? # delegates to the diff
|
|
707
|
-
steps.first.to_h
|
|
708
|
-
# => { from_version_id: 2, to_version_id: 3, diff: { ... } }
|
|
709
|
-
```
|
|
710
|
-
|
|
711
|
-
Both timeline types expose `from_boundary`, `to_boundary`, `diff`, and
|
|
712
|
-
`empty?`. A historical boundary has `event`, `whodunnit`, `record`,
|
|
713
|
-
`recorded_at`, `version?`, `current?`, and `destroyed?` readers. Checkpoint `Step` objects
|
|
714
|
-
also retain their original `from_version` and `to_version` for callers that
|
|
715
|
-
need custom PaperTrail metadata. Existing `Step#to_h` and
|
|
716
|
-
`ActivityBoundary#to_h` shapes remain unchanged; use the readers for the new
|
|
717
|
-
metadata.
|
|
718
|
-
|
|
719
|
-
Every version boundary remains in the result, even when its diff is empty after
|
|
720
|
-
ignored fields are removed. Equal boundaries return a frozen empty array.
|
|
721
|
-
|
|
722
|
-
This is deliberately a root-record timeline. Several child changes between two
|
|
723
|
-
root versions appear together in the same step.
|
|
724
|
-
|
|
725
|
-
## Build an activity timeline
|
|
726
|
-
|
|
727
|
-
`activity_timeline` merges versions for the root and explicitly selected
|
|
728
|
-
descendants. Child changes can therefore form separate steps instead of waiting
|
|
729
|
-
to be aggregated at the next root checkpoint:
|
|
730
|
-
|
|
731
|
-
```ruby
|
|
732
104
|
steps = PaperTrailDiff.activity_timeline(
|
|
733
|
-
article,
|
|
734
|
-
from: article.versions[1],
|
|
735
|
-
to: article.versions[4],
|
|
736
|
-
associations: ["comments.replies", :author]
|
|
105
|
+
article, associations: [:comments], group: :transaction, snapshots: true
|
|
737
106
|
)
|
|
738
107
|
|
|
739
|
-
steps.reject(&:empty?)
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
mutations recorded after the record's final root version fall outside a
|
|
745
|
-
version-bounded range, so only a live end reports them.
|
|
746
|
-
|
|
747
|
-
```ruby
|
|
748
|
-
steps = PaperTrailDiff.activity_timeline(
|
|
749
|
-
article,
|
|
750
|
-
from: article.versions[1],
|
|
751
|
-
to: article,
|
|
752
|
-
associations: ["comments.replies", :author]
|
|
753
|
-
)
|
|
754
|
-
```
|
|
755
|
-
|
|
756
|
-
The result is a frozen array of `ActivityStep` objects. Each step has immutable
|
|
757
|
-
`from_boundary`, `to_boundary`, and `diff` readers. Historical boundaries are
|
|
758
|
-
`kind: :version`; an explicitly requested live endpoint is `kind: :current`:
|
|
759
|
-
|
|
760
|
-
```ruby
|
|
761
|
-
steps.last.to_boundary.to_h
|
|
762
|
-
# => {
|
|
763
|
-
# kind: :current,
|
|
764
|
-
# version_id: nil,
|
|
765
|
-
# item_type: "Article",
|
|
766
|
-
# item_id: 42,
|
|
767
|
-
# recorded_at: 2026-08-08 12:00:00 UTC
|
|
768
|
-
# }
|
|
769
|
-
```
|
|
770
|
-
|
|
771
|
-
A historical boundary may belong to the root or a selected descendant model.
|
|
772
|
-
PaperTrail versions describe pre-change state, so a mutation becomes visible
|
|
773
|
-
between its version boundary and the next historical or explicit current
|
|
774
|
-
boundary. Passing `to: article` is what removes the need to touch the parent
|
|
775
|
-
after an ordinary versioned child mutation; current state is still never
|
|
776
|
-
implicit.
|
|
777
|
-
|
|
778
|
-
### Reporting one save as one step
|
|
779
|
-
|
|
780
|
-
A parent and its children saved together produce a version each, so one
|
|
781
|
-
deliberate action arrives as several steps. `group: :transaction` reports it as
|
|
782
|
-
one:
|
|
783
|
-
|
|
784
|
-
```ruby
|
|
785
|
-
PaperTrailDiff.activity_timeline(
|
|
786
|
-
article, from: :first, to: article,
|
|
787
|
-
associations: [:comments], group: :transaction
|
|
788
|
-
)
|
|
789
|
-
```
|
|
790
|
-
|
|
791
|
-
Given a single transaction that retitled an article, revised one comment, and
|
|
792
|
-
added another, the ungrouped timeline reports three steps — the title change,
|
|
793
|
-
then a step that reads as empty, then the comment changes. Grouped, it reports
|
|
794
|
-
one step carrying the title *and* the comment changes, credited to whoever made
|
|
795
|
-
the save. Off by default; also accepted by `analyze(activity: true)`.
|
|
796
|
-
|
|
797
|
-
The middle step reads as empty for a reason worth knowing, because it is the
|
|
798
|
-
same rule the rest of this document turns on. A version records the state
|
|
799
|
-
*before* its own event, so a child's new value is revealed only by something
|
|
800
|
-
later: a further version of that child, its destroy version, or the live row.
|
|
801
|
-
Inside a transaction there is usually none of those yet, so a change made at
|
|
802
|
-
that boundary surfaces a step or more afterwards, folded in with whatever that
|
|
803
|
-
step carried. Nothing is lost — but "when" and "with what" both read wrong.
|
|
804
|
-
|
|
805
|
-
Two properties this relies on:
|
|
806
|
-
|
|
807
|
-
**A step belongs to the transaction of the event that opens it.** That is the
|
|
808
|
-
event whose change the step reports, so grouping on the closing boundary instead
|
|
809
|
-
would credit each change to whoever made the next one.
|
|
810
|
-
|
|
811
|
-
**Merging compares the group's outer states**, rather than combining the diffs
|
|
812
|
-
between them. A field set and then restored inside one transaction has not
|
|
813
|
-
changed, and only comparing the endpoints can say so.
|
|
814
|
-
|
|
815
|
-
A boundary that records no transaction groups with nothing. PaperTrail leaves
|
|
816
|
-
`transaction_id` nil outside a transaction, and a custom version class need not
|
|
817
|
-
carry the column at all; treating those as one shared transaction would merge
|
|
818
|
-
unrelated history into a single step. `ActivityBoundary#transaction_id` exposes
|
|
819
|
-
what a boundary recorded.
|
|
820
|
-
|
|
821
|
-
### Reading the state behind a step
|
|
822
|
-
|
|
823
|
-
A step's diff carries what changed. A renderer often needs what did *not* — to
|
|
824
|
-
say whose comment was edited, it needs the comment's author, which the diff has
|
|
825
|
-
no reason to mention. `snapshots: true` retains the reconstructed states each
|
|
826
|
-
step was compared between:
|
|
827
|
-
|
|
828
|
-
```ruby
|
|
829
|
-
steps = PaperTrailDiff.activity_timeline(
|
|
830
|
-
article, from: :first, to: article, associations: [:comments], snapshots: true
|
|
831
|
-
)
|
|
832
|
-
|
|
833
|
-
step = steps.reject(&:empty?).last
|
|
834
|
-
step.from_snapshot.associations["comments"].records.first.attributes["author"]
|
|
835
|
-
```
|
|
836
|
-
|
|
837
|
-
The gem already builds these states to compute each diff and otherwise discards
|
|
838
|
-
them, so asking for them costs no extra queries. Reconstructing them yourself
|
|
839
|
-
from the version table means reimplementing boundary ordering, and getting it
|
|
840
|
-
wrong is quiet rather than loud.
|
|
841
|
-
|
|
842
|
-
They are off by default because each one holds the whole selected graph, and
|
|
843
|
-
retaining one per step keeps the entire timeline's graph alive. A step whose
|
|
844
|
-
`from_boundary` is a `create` version has a `nil` `from_snapshot`: the record
|
|
845
|
-
did not exist yet, which is the answer rather than a missing one.
|
|
846
|
-
|
|
847
|
-
`analyze(activity: true, snapshots: true)` populates the same fields on the
|
|
848
|
-
`activity_timeline` it returns.
|
|
849
|
-
|
|
850
|
-
### Closing a destroyed root
|
|
851
|
-
|
|
852
|
-
A `destroy` version is the one boundary whose following state needs no later
|
|
853
|
-
version: the event itself says the record is gone. When an activity timeline
|
|
854
|
-
ends at the root's own `destroy` version, it therefore closes with one more
|
|
855
|
-
step, from that version to a `kind: :destroyed` boundary, whose diff is a
|
|
856
|
-
`record_presence_change` from the record's final state to `nil`:
|
|
857
|
-
|
|
858
|
-
```ruby
|
|
859
|
-
steps = PaperTrailDiff.activity_timeline(
|
|
860
|
-
article,
|
|
861
|
-
from: article.versions.first,
|
|
862
|
-
to: article.versions.last # a destroy version
|
|
863
|
-
)
|
|
864
|
-
|
|
865
|
-
removal = steps.last
|
|
866
|
-
removal.to_boundary.destroyed? # => true
|
|
867
|
-
removal.to_boundary.kind # => :destroyed
|
|
868
|
-
removal.diff.record_presence_change.from # the state it was deleted in
|
|
869
|
-
removal.diff.record_presence_change.to # => nil
|
|
870
|
-
```
|
|
871
|
-
|
|
872
|
-
The removal step's `from_boundary` is the ordinary `kind: :version` boundary
|
|
873
|
-
for the same destroy version, since that boundary still holds the record.
|
|
874
|
-
Boundaries therefore have three kinds — `:version`, `:current`, and
|
|
875
|
-
`:destroyed` — so a consumer that branches on `version?` alone should also
|
|
876
|
-
handle `destroyed?`.
|
|
877
|
-
|
|
878
|
-
`analyze(activity: true)` reports the same closing step in its
|
|
879
|
-
`activity_timeline`. Its `diff` and `timeline` keep their `compare` and
|
|
880
|
-
`timeline` semantics and do not report the deletion.
|
|
881
|
-
|
|
882
|
-
A `within:` window behaves the same way. A window whose last selected mutation
|
|
883
|
-
is the root's destruction needs no later root version, because none can ever
|
|
884
|
-
exist, so it closes on the removal instead of raising
|
|
885
|
-
`IncompleteTimeRangeError`. When the destruction falls *outside* the window it
|
|
886
|
-
remains ordinary reconstruction context and is not reported as a selected
|
|
887
|
-
mutation. The same relaxation lets the plain `timeline` accept such a window,
|
|
888
|
-
though it still reports only the edits.
|
|
889
|
-
|
|
890
|
-
Live-ended HABTM activity is rejected with
|
|
891
|
-
`PaperTrailDiff::UnsupportedLiveActivityError`. HABTM join rows have no model
|
|
892
|
-
versions, so the gem cannot reliably split their membership mutations into
|
|
893
|
-
intermediate events without transaction-backed owner checkpoints. Historical
|
|
894
|
-
HABTM activity and `compare(version, article, associations: [:tags])` remain
|
|
895
|
-
supported where the historical endpoint has usable PT-AT metadata.
|
|
896
|
-
|
|
897
|
-
Both timeline APIs preserve empty boundaries. A display may use
|
|
898
|
-
`steps.reject(&:empty?)`, while audit-oriented callers can retain every
|
|
899
|
-
recorded boundary.
|
|
900
|
-
|
|
901
|
-
## Select mutations by time
|
|
902
|
-
|
|
903
|
-
`timeline`, `activity_timeline`, and `analyze` also accept a finite time range
|
|
904
|
-
through `within:` instead of explicit `from:` and `to:` versions. Half-open
|
|
905
|
-
ranges are recommended for adjacent reporting windows:
|
|
906
|
-
|
|
907
|
-
```ruby
|
|
908
|
-
window = Time.zone.parse("2026-08-01")...Time.zone.parse("2026-09-01")
|
|
909
|
-
|
|
910
|
-
steps = PaperTrailDiff.timeline(article, within: window)
|
|
911
|
-
|
|
912
|
-
activity_steps = PaperTrailDiff.activity_timeline(
|
|
913
|
-
article,
|
|
914
|
-
within: window,
|
|
915
|
-
associations: ["comments.replies", :author]
|
|
916
|
-
)
|
|
917
|
-
|
|
918
|
-
analysis = PaperTrailDiff.analyze(
|
|
919
|
-
article,
|
|
920
|
-
within: window,
|
|
921
|
-
associations: ["comments.replies"],
|
|
922
|
-
activity: true
|
|
923
|
-
)
|
|
924
|
-
```
|
|
925
|
-
|
|
926
|
-
The range selects mutations by the timestamp of their source PaperTrail
|
|
927
|
-
version. Ruby's inclusive (`..`) and exclusive (`...`) end semantics are
|
|
928
|
-
honored. A returned step's `to_boundary` may be after the range: because a
|
|
929
|
-
PaperTrail version is a pre-change snapshot, the gem needs one later root
|
|
930
|
-
version to reveal the final selected mutation. That version is reconstruction
|
|
931
|
-
context, not an additional selected mutation.
|
|
932
|
-
|
|
933
|
-
If the window contains a relevant mutation but no later root version exists,
|
|
934
|
-
the call raises `PaperTrailDiff::IncompleteTimeRangeError`. The gem does not
|
|
935
|
-
silently substitute current database state; pass
|
|
936
|
-
[`close_on: :current`](#reporting-up-to-now) to ask for it, or create a root
|
|
937
|
-
checkpoint after the reporting window. A root-only window with no selected
|
|
938
|
-
mutation returns a frozen empty timeline. The one exception is a window that
|
|
939
|
-
closes on the root's own destruction, which no later version can ever follow;
|
|
940
|
-
see [closing a destroyed root](#closing-a-destroyed-root).
|
|
941
|
-
|
|
942
|
-
### Reporting up to now
|
|
943
|
-
|
|
944
|
-
A window whose end is the present has nothing recorded after its final
|
|
945
|
-
mutation, so by default it raises. `close_on: :current` closes it on the live
|
|
946
|
-
record instead:
|
|
947
|
-
|
|
948
|
-
```ruby
|
|
949
|
-
PaperTrailDiff.analyze_many(
|
|
950
|
-
articles, within: month_start..Time.current, close_on: :current
|
|
951
|
-
)
|
|
952
|
-
```
|
|
953
|
-
|
|
954
|
-
It is accepted by `timeline`, `activity_timeline`, `analyze`, and
|
|
955
|
-
`analyze_many`, and only alongside `within:` — a range whose endpoints you gave
|
|
956
|
-
explicitly already says where it ends. The closing step's `to_boundary.kind` is
|
|
957
|
-
`:current` and its `to_version` is `nil`, so read the boundary rather than the
|
|
958
|
-
version on the last step. Under `activity: true` the window runs to the instant
|
|
959
|
-
state is captured rather than to the last root version, so a descendant that
|
|
960
|
-
moved after that version is still reported.
|
|
961
|
-
|
|
962
|
-
Two things follow from closing on live state. Current state includes changes
|
|
963
|
-
PaperTrail never recorded — an `update_columns` write shows up in that final
|
|
964
|
-
step, attributed to the version that precedes it, because nothing else records
|
|
965
|
-
who made it. And a destroyed root has no current state to close on, so it keeps
|
|
966
|
-
closing on its own destruction as it already did.
|
|
967
|
-
|
|
968
|
-
Time ranges and explicit `from:`/`to:` endpoints are mutually exclusive.
|
|
969
|
-
Malformed, open-ended, or reversed ranges raise
|
|
970
|
-
`PaperTrailDiff::InvalidTimeRangeError`. With `activity_timeline`, selected
|
|
971
|
-
descendant versions are included even when no root mutation occurred inside
|
|
972
|
-
the window; the later root checkpoint is still required so PT-AT can
|
|
973
|
-
reconstruct the enclosing graph and determine whether descendant activity is
|
|
974
|
-
present. The ordinary `timeline` remains a root-only checkpoint timeline.
|
|
975
|
-
|
|
976
|
-
## Build an endpoint and timeline together
|
|
977
|
-
|
|
978
|
-
When a caller needs the net endpoint diff and root timeline, `analyze`
|
|
979
|
-
normalizes each selected version once. Pass `activity: true` when the same view
|
|
980
|
-
also needs descendant-aware activity. The endpoint diff and root timeline are
|
|
981
|
-
then derived from the same activity snapshot pass instead of reconstructing the
|
|
982
|
-
root history separately:
|
|
983
|
-
|
|
984
|
-
```ruby
|
|
985
|
-
analysis = PaperTrailDiff.analyze(
|
|
986
|
-
article,
|
|
987
|
-
from: article.versions[1],
|
|
988
|
-
to: article.versions[4],
|
|
989
|
-
associations: ["comments.replies"],
|
|
990
|
-
activity: true
|
|
991
|
-
)
|
|
992
|
-
|
|
993
|
-
analysis.diff
|
|
994
|
-
analysis.timeline
|
|
995
|
-
analysis.activity_timeline
|
|
996
|
-
```
|
|
997
|
-
|
|
998
|
-
Without `activity: true`, `analysis.activity_timeline` is `nil` and no activity
|
|
999
|
-
work is performed. `analyze` accepts explicit historical versions or
|
|
1000
|
-
`within:`, but not a current-record endpoint; use the standalone
|
|
1001
|
-
`activity_timeline(..., to: article)` API when the final boundary must be live.
|
|
1002
|
-
|
|
1003
|
-
### Choosing between `timeline` and `analyze(activity: true)`
|
|
1004
|
-
|
|
1005
|
-
`analysis.timeline` and `timeline` return the same root-checkpoint steps for the
|
|
1006
|
-
same range. They are two reconstruction strategies for one result, not two
|
|
1007
|
-
levels of detail: a `timeline` step covers only root version boundaries, but
|
|
1008
|
-
each of its snapshots still contains every selected association, because a step
|
|
1009
|
-
must report what changed underneath the root as well.
|
|
1010
|
-
|
|
1011
|
-
The one behavioural difference is at the edge of a `within:` window. Selected
|
|
1012
|
-
descendants can move inside a window that contains no root version at all, so
|
|
1013
|
-
the activity form requires a root boundary it can reconstruct from and raises
|
|
1014
|
-
`IncompleteTimeRangeError` when there is none. `timeline` has no activity view
|
|
1015
|
-
to anchor and returns no steps for that window.
|
|
1016
|
-
|
|
1017
|
-
The strategies differ in what that costs:
|
|
1018
|
-
|
|
1019
|
-
- `timeline` reconstructs the whole selected graph independently at every root
|
|
1020
|
-
boundary, so it costs roughly *root versions x selected graph size*. It is
|
|
1021
|
-
insensitive to how much descendant activity happened in between.
|
|
1022
|
-
- `analyze(activity: true)` reconstructs once and then advances that snapshot
|
|
1023
|
-
through each recorded mutation, so it costs roughly *one reconstruction +
|
|
1024
|
-
total events*. It is insensitive to how wide the selected graph is.
|
|
1025
|
-
|
|
1026
|
-
Neither dominates. Reconstructing once per checkpoint wins when a few root
|
|
1027
|
-
versions span very heavy descendant churn; advancing incrementally wins when
|
|
1028
|
-
the selected graph is wide and descendant activity is comparable to root
|
|
1029
|
-
activity. As a rule of thumb, prefer `analyze(activity: true)` when selected
|
|
1030
|
-
associations are wide, and `timeline` when descendant events greatly outnumber
|
|
1031
|
-
root versions. Measure with `ActiveSupport::Notifications` on a representative
|
|
1032
|
-
history rather than a seeded example if the choice matters.
|
|
1033
|
-
|
|
1034
|
-
`timeline`, `activity_timeline`, and both forms of `analyze` prepare the selected
|
|
1035
|
-
historical range once. The loader walks only the explicit association paths and
|
|
1036
|
-
builds an immutable temporal index of scalar states, relationship candidates,
|
|
1037
|
-
HABTM transaction snapshots, and live fallbacks. Direct relationships,
|
|
1038
|
-
`has_many :through` with a `belongs_to` source, and transaction-backed HABTM
|
|
1039
|
-
membership are resolved from that index. Endpoint-only `compare` retains the
|
|
1040
|
-
lighter two-point reconstruction path.
|
|
1041
|
-
|
|
1042
|
-
Activity reconstruction then carries immutable snapshots forward between
|
|
1043
|
-
boundaries. Isolated root and descendant events with usable serialized changes
|
|
1044
|
-
advance only the affected immutable nodes. Events sharing a PT-AT transaction
|
|
1045
|
-
refresh their combined branches atomically. Scoped associations, unversioned
|
|
1046
|
-
targets, composite relationship keys, collection-source through associations,
|
|
1047
|
-
and other unsupported shapes fall back to the ordinary PT-AT point reifier on
|
|
1048
|
-
a per-reflection basis. This hybrid path preserves existing reconstruction
|
|
1049
|
-
behavior while avoiding repeated association queries for supported paths.
|
|
1050
|
-
|
|
1051
|
-
For supported collection events, adjacent snapshots share an immutable
|
|
1052
|
-
identity-position index and carry a one-step transition hint. Diffing therefore
|
|
1053
|
-
visits only the changed member instead of hashing the whole collection again.
|
|
1054
|
-
Prepared scalar history also exposes the predecessor and successor attributes
|
|
1055
|
-
for isolated update events, allowing activity reconstruction to update the
|
|
1056
|
-
immutable snapshot directly instead of reifying and deserializing the same
|
|
1057
|
-
PaperTrail event again. Compatible scalar payloads are decoded into prepared
|
|
1058
|
-
state without first constructing a disposable Active Record object. Encrypted,
|
|
1059
|
-
schema-mismatched, missing, ambiguous, or identity-changing states fall back to
|
|
1060
|
-
the ordinary event reifier.
|
|
1061
|
-
For a direct nested `has_many` such as `comments.replies`, the child's foreign
|
|
1062
|
-
key also locates its parent snapshot directly rather than walking every
|
|
1063
|
-
comment. Membership changes and ambiguous or unsupported relationship shapes
|
|
1064
|
-
retain the general comparator and traversal fallback.
|
|
1065
|
-
|
|
1066
|
-
Activity event loading is bounded to the selected range. Association identity
|
|
1067
|
-
discovery retains one indexed checkpoint for members present at the starting
|
|
1068
|
-
boundary, then considers later association activity and current members; it no
|
|
1069
|
-
longer materializes every pre-range association row. Because a PaperTrail
|
|
1070
|
-
version is a pre-change snapshot, the state at a range's final boundary can
|
|
1071
|
-
live only in the next version after it, so prepared scalar state retains one
|
|
1072
|
-
trailing version per selected identity. It does not retain the rest of the
|
|
1073
|
-
history recorded after the range, so a short range early in a long history
|
|
1074
|
-
costs the same as the same range in a short one. Keep requested paths and
|
|
1075
|
-
ranges intentional. An activity timeline must also emit a diff for every selected
|
|
1076
|
-
event. Repeated events within one wide collection still copy the frozen records
|
|
1077
|
-
array when producing each immutable snapshot, so they can do pointer-copying
|
|
1078
|
-
work proportional to the number of events times the collection width even when
|
|
1079
|
-
SQL and Ruby-level comparison work stay linear. Bound or paginate unusually
|
|
1080
|
-
wide activity ranges in latency-sensitive requests.
|
|
1081
|
-
|
|
1082
|
-
For large histories, applications should give the database a matching
|
|
1083
|
-
composite index. A typical PaperTrail installation can add one without making
|
|
1084
|
-
it a requirement of this gem:
|
|
1085
|
-
|
|
1086
|
-
```ruby
|
|
1087
|
-
add_index :versions, %i[item_type item_id created_at id],
|
|
1088
|
-
name: "index_versions_on_item_and_timeline"
|
|
1089
|
-
```
|
|
1090
|
-
|
|
1091
|
-
This index is especially useful for repeated `within:` queries because root
|
|
1092
|
-
selection is constrained by `item_type`, `item_id`, and `created_at`, then
|
|
1093
|
-
ordered deterministically by timestamp and version ID.
|
|
1094
|
-
|
|
1095
|
-
PT-AT's normal index beginning with `foreign_key_name`, `foreign_key_id`, and
|
|
1096
|
-
`foreign_type` should also be retained on `version_associations`.
|
|
1097
|
-
|
|
1098
|
-
## Ignore noise fields
|
|
1099
|
-
|
|
1100
|
-
The comparison APIs ignore `updated_at` by default. `ignore:` replaces that default and
|
|
1101
|
-
accepts string or symbol names. The array form applies to the root and every
|
|
1102
|
-
selected association:
|
|
1103
|
-
|
|
1104
|
-
```ruby
|
|
1105
|
-
PaperTrailDiff.compare(from, to, ignore: %i[updated_at lock_version])
|
|
1106
|
-
PaperTrailDiff.compare(from, to, ignore: []) # compare every scalar field
|
|
1107
|
-
```
|
|
1108
|
-
|
|
1109
|
-
For exact path control, pass `all:` plus `paths:`. `$` identifies the root:
|
|
1110
|
-
|
|
1111
|
-
```ruby
|
|
1112
|
-
PaperTrailDiff.compare(
|
|
1113
|
-
from,
|
|
1114
|
-
to,
|
|
1115
|
-
associations: ["comments.replies"],
|
|
1116
|
-
ignore: {
|
|
1117
|
-
all: [:updated_at],
|
|
1118
|
-
paths: {
|
|
1119
|
-
"$" => [:lock_version],
|
|
1120
|
-
"comments.replies" => [:delivery_state]
|
|
1121
|
-
}
|
|
108
|
+
feed = steps.reject(&:empty?).map do |step|
|
|
109
|
+
{
|
|
110
|
+
actor: step.source_boundary.whodunnit,
|
|
111
|
+
recorded_at: step.source_boundary.recorded_at,
|
|
112
|
+
changes: step.diff.to_h
|
|
1122
113
|
}
|
|
1123
|
-
)
|
|
1124
|
-
```
|
|
1125
|
-
|
|
1126
|
-
Hash rules replace the default just like an array. `all:` applies everywhere;
|
|
1127
|
-
each path entry applies only at that exact path, not its descendants. Ignore
|
|
1128
|
-
paths must be `$` or one of the selected or implicitly selected association
|
|
1129
|
-
paths.
|
|
1130
|
-
|
|
1131
|
-
Primary keys are always represented as record identity rather than scalar
|
|
1132
|
-
attributes.
|
|
1133
|
-
|
|
1134
|
-
## Compare associations
|
|
1135
|
-
|
|
1136
|
-
Pass explicit names or dot-separated paths. Paths are finite traversal plans,
|
|
1137
|
-
not a request to recursively inspect the whole object graph. Ancestors are
|
|
1138
|
-
selected implicitly:
|
|
1139
|
-
|
|
1140
|
-
```ruby
|
|
1141
|
-
diff = PaperTrailDiff.compare(
|
|
1142
|
-
from,
|
|
1143
|
-
to,
|
|
1144
|
-
associations: [:author, :profile, "comments.replies.author"]
|
|
1145
|
-
)
|
|
1146
|
-
|
|
1147
|
-
diff.associations["author"].relationship
|
|
1148
|
-
# => #<PaperTrailDiff::ValueChange ...> # belongs_to replacement
|
|
1149
|
-
|
|
1150
|
-
comments = diff.associations["comments"]
|
|
1151
|
-
comments.added # full RecordSnapshot objects
|
|
1152
|
-
comments.removed # full RecordSnapshot objects
|
|
1153
|
-
comments.changed # RecordChange objects for stable identities
|
|
1154
|
-
|
|
1155
|
-
replies = comments.changed.first.associations["replies"]
|
|
1156
|
-
replies.added
|
|
1157
|
-
replies.removed
|
|
1158
|
-
replies.changed
|
|
1159
|
-
```
|
|
1160
|
-
|
|
1161
|
-
For `belongs_to` and `has_one`, replacing the related identity is a
|
|
1162
|
-
`relationship` change. Updating attributes on the same related identity is a
|
|
1163
|
-
`changed` record. For `has_many` and `has_and_belongs_to_many`, membership is
|
|
1164
|
-
split into `added` and `removed`; shared identities with scalar changes appear
|
|
1165
|
-
in `changed`. The result preserves the reflected macro in `kind`.
|
|
1166
|
-
|
|
1167
|
-
Both Rails many-to-many forms are supported. `has_many :through` uses PT-AT's
|
|
1168
|
-
history for the versioned join model. HABTM reports membership and target
|
|
1169
|
-
attribute changes, but it cannot report join attributes because HABTM has no
|
|
1170
|
-
join model.
|
|
1171
|
-
|
|
1172
|
-
The same structure repeats at every selected depth. `RecordChange#associations`
|
|
1173
|
-
contains nested changes for a stable parent identity. Added and removed record
|
|
1174
|
-
snapshots retain their selected subtrees so nested state is not discarded.
|
|
1175
|
-
|
|
1176
|
-
Selecting a `belongs_to` removes its foreign-key (and polymorphic type) column
|
|
1177
|
-
from scalar changes at that path. Without association selection, that column
|
|
1178
|
-
remains a normal scalar attribute. A direct `has_one` or `has_many` edge also
|
|
1179
|
-
removes its incoming foreign key from the child snapshot because membership is
|
|
1180
|
-
already represented structurally. Join-model attributes for `has_many :through`
|
|
1181
|
-
remain available. Cyclic model relationships are safe because only the finite
|
|
1182
|
-
paths supplied by the caller are traversed.
|
|
1183
|
-
|
|
1184
|
-
Requesting associations for any historical endpoint without loaded and enabled
|
|
1185
|
-
PT-AT raises `PaperTrailDiff::AssociationTrackingUnavailableError`. A comparison
|
|
1186
|
-
whose endpoints are both live records can normalize associations without PT-AT,
|
|
1187
|
-
though it will normally be empty because both records reload the same current
|
|
1188
|
-
database state. Unknown names and unsupported macros raise explicit
|
|
1189
|
-
`PaperTrailDiff::Error` subclasses.
|
|
1190
|
-
Malformed public options raise `PaperTrailDiff::ConfigurationError`, also under
|
|
1191
|
-
that base error.
|
|
1192
|
-
|
|
1193
|
-
## Traverse result trees
|
|
1194
|
-
|
|
1195
|
-
The nested tree remains the canonical, lossless result. For renderers,
|
|
1196
|
-
counters, exports, and notifications, `Diff#each_change` provides a
|
|
1197
|
-
deterministic depth-first stream of semantic changes:
|
|
1198
|
-
|
|
1199
|
-
<!-- executable:readme-traversal-changes -->
|
|
1200
|
-
```ruby
|
|
1201
|
-
diff.each_change do |entry|
|
|
1202
|
-
entry.kind # :attribute_changed, :record_added, ...
|
|
1203
|
-
entry.association_path # ["comments", "replies"]
|
|
1204
|
-
entry.record_path # frozen RecordReference objects
|
|
1205
|
-
entry.association_kind # :has_many, :belongs_to, ...
|
|
1206
|
-
entry.attribute # "body" for an attribute entry
|
|
1207
|
-
entry.value # ValueChange, RecordChange, or RecordSnapshot
|
|
1208
114
|
end
|
|
1209
115
|
|
|
1210
|
-
|
|
1211
|
-
```
|
|
1212
|
-
|
|
1213
|
-
`record_changed` entries are emitted before that record's attribute and nested
|
|
1214
|
-
association changes, allowing an application to count both changed records and
|
|
1215
|
-
changed fields. Singular relationship operations are classified separately as
|
|
1216
|
-
`relationship_added`, `relationship_removed`, or `relationship_replaced`.
|
|
1217
|
-
Root create/delete transitions remain `record_presence_changed`.
|
|
1218
|
-
|
|
1219
|
-
Added, removed, and replaced records carry complete bounded snapshots. Use
|
|
1220
|
-
`each_entry` when a renderer also needs that nested state without writing a
|
|
1221
|
-
second snapshot walker:
|
|
1222
|
-
|
|
1223
|
-
<!-- executable:readme-traversal-entries -->
|
|
1224
|
-
```ruby
|
|
1225
|
-
diff.each_entry do |entry|
|
|
1226
|
-
next unless entry.included_state?
|
|
1227
|
-
|
|
1228
|
-
entry.kind # :record_included, :attribute_included, :association_included
|
|
1229
|
-
entry.state # :before or :after
|
|
1230
|
-
entry.context # :included_state
|
|
1231
|
-
end
|
|
116
|
+
payload = steps.map { |step| step.to_h(metadata: true) }
|
|
1232
117
|
```
|
|
1233
118
|
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
paths; descendant `record_path` values contain only the explicitly traversed
|
|
1240
|
-
descendant identities.
|
|
119
|
+
Each step contains a `diff`. `source_boundary` describes the PaperTrail version
|
|
120
|
+
responsible for that change, including its `whodunnit`, `event`, `item_type`,
|
|
121
|
+
and `item_id`. Its `recorded_at` is the version's `created_at`.
|
|
122
|
+
Use this source information for attribution: the version used to read the
|
|
123
|
+
resulting state, exposed as `to_boundary`, may have been saved by someone else.
|
|
1241
124
|
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
Walking a result never performs database access and never discovers additional
|
|
1246
|
-
associations.
|
|
125
|
+
`group: :transaction` combines changes from one database transaction. Its source
|
|
126
|
+
information comes from the first event in that group. Omit `group:` if you need
|
|
127
|
+
to attribute each version separately.
|
|
1247
128
|
|
|
1248
|
-
|
|
129
|
+
`snapshots: true` also keeps copies of the compared records' data, including
|
|
130
|
+
unchanged fields. These read-only copies are called snapshots. Access them as
|
|
131
|
+
`step.from_snapshot` and `step.to_snapshot`, or include them in a hash with
|
|
132
|
+
`step.to_h(snapshots: true)`. Keeping them uses more memory. Omit the option if
|
|
133
|
+
changed values are enough. `metadata: true` adds event and author information
|
|
134
|
+
to the hash.
|
|
1249
135
|
|
|
1250
|
-
|
|
1251
|
-
gem's macro and cycle logic:
|
|
136
|
+
## 3. Report on records changed since a given time
|
|
1252
137
|
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
```
|
|
1257
|
-
|
|
1258
|
-
Descriptors include `path`, `kind`, `target_type`, `through`, `polymorphic`, and
|
|
1259
|
-
`cycle`. Cycles are marked and not descended; PaperTrail's infrastructure
|
|
1260
|
-
`versions` association is excluded.
|
|
1261
|
-
|
|
1262
|
-
Before relying on association history, inspect known setup hazards:
|
|
138
|
+
Use `report` with a model or ActiveRecord relation. It includes activity from
|
|
139
|
+
`since:` through the current saved state. Association tracking is only needed
|
|
140
|
+
if you also pass `associations:`.
|
|
1263
141
|
|
|
142
|
+
<!-- executable:recipe-report -->
|
|
1264
143
|
```ruby
|
|
1265
|
-
report = PaperTrailDiff.
|
|
1266
|
-
|
|
1267
|
-
to_version,
|
|
1268
|
-
associations: [:tags, "comments.replies"]
|
|
144
|
+
report = PaperTrailDiff.report(
|
|
145
|
+
Article.all, since: 1.day.ago, limit: 100, include_deleted: true
|
|
1269
146
|
)
|
|
1270
147
|
|
|
1271
|
-
report.
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
why that one runs whether or not associations are selected — an unorderable
|
|
1280
|
-
history corrupts a scalar timeline just as thoroughly.
|
|
1281
|
-
|
|
1282
|
-
Diagnostics are read-only guidance, not proof that arbitrary old data is
|
|
1283
|
-
complete. HABTM endpoints without transaction-backed association snapshots fail
|
|
1284
|
-
loudly with `PaperTrailDiff::IncompleteAssociationHistoryError` during normal
|
|
1285
|
-
comparison as well.
|
|
1286
|
-
|
|
1287
|
-
### Recommended checkpoint recipe
|
|
1288
|
-
|
|
1289
|
-
For PT-AT history—especially HABTM—use transaction-backed checkpoints, disable
|
|
1290
|
-
PaperTrail's version timestamp synchronization on the root, and checkpoint a
|
|
1291
|
-
fresh record instance:
|
|
1292
|
-
|
|
1293
|
-
```ruby
|
|
1294
|
-
class Article < ApplicationRecord
|
|
1295
|
-
has_paper_trail synchronize_version_creation_timestamp: false
|
|
148
|
+
rows = report.analyses.map do |identity, analysis|
|
|
149
|
+
{
|
|
150
|
+
record: identity, # ["Article", "42"]: model name and ID
|
|
151
|
+
changes: analysis.diff.to_h,
|
|
152
|
+
activity: analysis.activity_timeline.reject(&:empty?).map do |step|
|
|
153
|
+
step.to_h(metadata: true)
|
|
154
|
+
end
|
|
155
|
+
}
|
|
1296
156
|
end
|
|
1297
157
|
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
Article.find(article_id).paper_trail.save_with_version
|
|
1301
|
-
end
|
|
1302
|
-
end
|
|
158
|
+
report.unreachable # Model names and IDs for records no longer in the database
|
|
159
|
+
report.deleted # Reports for those records, or nil when destroy history is missing
|
|
1303
160
|
```
|
|
1304
161
|
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
view, `activity_timeline(..., to: article)` can instead terminate explicitly at
|
|
1309
|
-
current state without touching the root. Prefer checkpoints for reproducible
|
|
1310
|
-
audits and HABTM, and prefer an explicit join model with `has_many :through`
|
|
1311
|
-
when join attributes or join mutations must appear as first-class history.
|
|
1312
|
-
|
|
1313
|
-
## Result objects
|
|
1314
|
-
|
|
1315
|
-
Every level of a result separates the same two kinds of change in the same way:
|
|
1316
|
-
|
|
1317
|
-
| | a record appears or disappears | a record stays and its fields change |
|
|
1318
|
-
| --- | --- | --- |
|
|
1319
|
-
| the root record | `record_presence_change` | `attributes`, `associations` |
|
|
1320
|
-
| `has_many`, HABTM | `added`, `removed` | `changed` |
|
|
1321
|
-
| `belongs_to`, `has_one` | `relationship` | `changed` |
|
|
1322
|
-
|
|
1323
|
-
The left column carries whole `RecordSnapshot` values; the right column carries
|
|
1324
|
-
field-level `ValueChange` deltas. That split is deliberate. A record that has
|
|
1325
|
-
just appeared has no previous value for any of its fields, so reporting one
|
|
1326
|
-
`nil` to value change per attribute would both add noise and blur the
|
|
1327
|
-
difference between "this field was edited" and "this record did not exist".
|
|
1328
|
-
|
|
1329
|
-
The consequence is that reading a result tree directly means branching on which
|
|
1330
|
-
column applies: a created record's state is under
|
|
1331
|
-
`record_presence_change.to.attributes`, an edited record's is under
|
|
1332
|
-
`attributes`. Consumers that would rather not branch should use
|
|
1333
|
-
[`each_entry`](#traverse-result-trees), which flattens both into one stream —
|
|
1334
|
-
an edit arrives as `attribute_changed` carrying a `ValueChange`, and a created
|
|
1335
|
-
record's fields arrive as `attribute_included` entries carrying values with
|
|
1336
|
-
`state: :after`.
|
|
1337
|
-
|
|
1338
|
-
The public result types are:
|
|
1339
|
-
|
|
1340
|
-
- `PaperTrailDiff::Diff`
|
|
1341
|
-
- `PaperTrailDiff::Step`
|
|
1342
|
-
- `PaperTrailDiff::ActivityStep`
|
|
1343
|
-
- `PaperTrailDiff::ActivityBoundary`
|
|
1344
|
-
- `PaperTrailDiff::Analysis`
|
|
1345
|
-
- `PaperTrailDiff::ValueChange`
|
|
1346
|
-
- `PaperTrailDiff::TraversalEntry`
|
|
1347
|
-
- `PaperTrailDiff::RecordReference`
|
|
1348
|
-
- `PaperTrailDiff::RecordSnapshot`
|
|
1349
|
-
- `PaperTrailDiff::RecordChange`
|
|
1350
|
-
- `PaperTrailDiff::ToOneAssociationDiff`
|
|
1351
|
-
- `PaperTrailDiff::CollectionAssociationDiff`
|
|
1352
|
-
- `PaperTrailDiff::AssociationDescriptor`
|
|
1353
|
-
- `PaperTrailDiff::DiagnosticReport`
|
|
1354
|
-
- `PaperTrailDiff::DiagnosticIssue`
|
|
162
|
+
`report.analyses` is a hash of reports for records that still exist. Each key is
|
|
163
|
+
`[model_name, id_as_a_string]`. Each value has the net `diff` and an
|
|
164
|
+
`activity_timeline`. `report.roots` returns the selected ActiveRecord objects.
|
|
1355
165
|
|
|
1356
|
-
`
|
|
1357
|
-
|
|
1358
|
-
the whole final state, not only what moved, and these are the states the gem
|
|
1359
|
-
already reconstructed:
|
|
166
|
+
`include_deleted: true` also builds reports from PaperTrail's destroy versions.
|
|
167
|
+
Read them from `report.deleted` using the same keys:
|
|
1360
168
|
|
|
169
|
+
<!-- executable:recipe-deleted -->
|
|
1361
170
|
```ruby
|
|
1362
|
-
|
|
1363
|
-
|
|
171
|
+
unavailable = report.deleted.select { |_identity, analysis| analysis.nil? }.keys
|
|
172
|
+
deleted_activity = report.deleted.transform_values do |analysis|
|
|
173
|
+
analysis&.activity_timeline
|
|
174
|
+
end
|
|
1364
175
|
```
|
|
1365
176
|
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
the record. Following the precedent set by `Step`, `Analysis#to_h` is unchanged;
|
|
1369
|
-
serialize `to_snapshot.to_h` when a serialized form is wanted.
|
|
1370
|
-
|
|
1371
|
-
They expose readers, are frozen after construction, and provide deterministic
|
|
1372
|
-
`to_h` output. Collection results are ordered by record identity: by type, then
|
|
1373
|
-
naturally within one id type, so numeric ids sort `2` before `10`. Mixed or
|
|
1374
|
-
unusual id types still order totally rather than raising.
|
|
1375
|
-
|
|
1376
|
-
Structural hash keys are symbols; attribute and association
|
|
1377
|
-
names are strings. Attribute values retain their Ruby types. `RecordChange#record`
|
|
1378
|
-
is a `RecordReference` with `type` and `id` readers. `TraversalEntry#record` and
|
|
1379
|
-
`#association` return the final components of their corresponding paths. `Step`
|
|
1380
|
-
itself is frozen but intentionally retains the original, potentially mutable
|
|
1381
|
-
PaperTrail version objects for metadata access; its new boundary readers provide
|
|
1382
|
-
an immutable presentation interface, while `Step#to_h` continues to emit only
|
|
1383
|
-
version IDs. `ActivityStep` retains only immutable boundary metadata and
|
|
1384
|
-
serializes as `{ from: ..., to: ..., diff: ... }`.
|
|
177
|
+
The results are already computed; you do not need to query or reify each deleted
|
|
178
|
+
record. Omit `include_deleted:` if you only need records that still exist.
|
|
1385
179
|
|
|
1386
|
-
|
|
180
|
+
Three selection rules matter:
|
|
1387
181
|
|
|
1388
|
-
The
|
|
1389
|
-
|
|
182
|
+
- The record itself needs a PaperTrail version in the time window. For example,
|
|
183
|
+
editing only a comment does not select its article. Comment activity is included
|
|
184
|
+
for selected articles when you request `associations: [:comments]`.
|
|
185
|
+
- A relation such as `Article.where(status: "open")` checks today's values.
|
|
186
|
+
It cannot establish whether deleted articles matched that condition. Deleted
|
|
187
|
+
records are therefore listed separately.
|
|
188
|
+
- `limit: 100` is a safety ceiling, not pagination. Selecting too many records
|
|
189
|
+
raises `BatchLimitExceededError`. With `include_deleted: true`, both existing
|
|
190
|
+
and deleted records count toward it. Use a narrower relation or time window,
|
|
191
|
+
or deliberately raise the ceiling.
|
|
1390
192
|
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
multiple queries are not automatically one database-isolated snapshot;
|
|
1397
|
-
- PT-AT requires its schema, callbacks, versioned child models, and transaction
|
|
1398
|
-
metadata; callback-skipping writes may not be reconstructable;
|
|
1399
|
-
- HABTM membership is limited to the join snapshots PT-AT recorded in
|
|
1400
|
-
`version_associations`; historical target attributes require versioned target
|
|
1401
|
-
models, otherwise PT-AT may return live target state;
|
|
1402
|
-
- `compare`, `timeline`, and `analyze`'s endpoint diff do not report the root
|
|
1403
|
-
record's destruction, because the state recorded at a `destroy` version is the
|
|
1404
|
-
state before the deletion; `activity_timeline` closes on a `:destroyed`
|
|
1405
|
-
boundary instead, and a selected child's removal is reported by its parent;
|
|
1406
|
-
- `timeline` and `analyze` are bounded by root versions; `activity_timeline`
|
|
1407
|
-
adds recorded descendant boundaries and may terminate at an explicitly passed
|
|
1408
|
-
current record, while a fully historical result still requires a later root
|
|
1409
|
-
version because PaperTrail stores pre-change snapshots;
|
|
1410
|
-
- HABTM join-table mutations do not have their own model versions and therefore
|
|
1411
|
-
cannot become standalone activity boundaries; live-ended HABTM activity is
|
|
1412
|
-
rejected, and a versioned join model should be used when that activity matters;
|
|
1413
|
-
- activity discovery uses PT-AT association rows and cannot recover descendant
|
|
1414
|
-
events whose relevant callbacks or association metadata were never recorded;
|
|
1415
|
-
- PT-AT has documented edge cases, especially around some `has_one` histories;
|
|
1416
|
-
`paper_trail_diff` respects PT-AT's configured reification error behavior;
|
|
1417
|
-
- deeper paths require more historical reconstruction and database work, so
|
|
1418
|
-
callers should select only the branches they need;
|
|
1419
|
-
- no implicit or unbounded recursive association traversal is performed.
|
|
193
|
+
Use the relation's `order`, `offset`, and `limit` to choose a page, for example
|
|
194
|
+
`Article.order(:id).limit(20)`. Joined rows can repeat a record; each record is
|
|
195
|
+
analyzed once. See the [reporting reference](docs/reference.md#which-records-are-selected)
|
|
196
|
+
for pagination, deleted records, and selecting by past values with
|
|
197
|
+
`historical_filter:`.
|
|
1420
198
|
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
before relying on reconstructed history for restoration or compliance work.
|
|
199
|
+
For a fixed past window, use `analyze_scope(Article.all, within: start_time...end_time,
|
|
200
|
+
limit: 100)`.
|
|
1424
201
|
|
|
1425
|
-
##
|
|
202
|
+
## Choose a method
|
|
1426
203
|
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
204
|
+
| What you need | Method |
|
|
205
|
+
| --- | --- |
|
|
206
|
+
| Net difference between two versions, or a version and the current record | `compare(from, to)` |
|
|
207
|
+
| The same for a list of `{ from:, to: }` pairs, one per record | `compare_many(pairs)` |
|
|
208
|
+
| Each change to one record, version by version | `timeline(record)` |
|
|
209
|
+
| Each change to one record and its chosen associations, with who made it | `activity_timeline(record, associations: ...)` |
|
|
210
|
+
| Net difference and timeline together | `analyze(record)` |
|
|
211
|
+
| The same, plus the activity timeline | `analyze(record, activity: true)` |
|
|
212
|
+
| Analyses for an array of records | `analyze_many(records)` |
|
|
213
|
+
| Analyses for the records a relation selects, over a time window | `analyze_scope(scope, within:, limit:)` |
|
|
214
|
+
| Recent activity through now, for the records a relation selects | `report(scope, since:, limit:)` |
|
|
215
|
+
|
|
216
|
+
`timeline`, `activity_timeline`, and `analyze` cover the whole history through
|
|
217
|
+
the current saved record by default. Narrow them with `from:` and `to:`
|
|
218
|
+
(versions) or `within:` (a time range). See
|
|
219
|
+
[choosing a range](docs/reference.md#choosing-a-range).
|
|
220
|
+
|
|
221
|
+
## Performance and defaults
|
|
222
|
+
|
|
223
|
+
Use the batch methods for reports instead of calling `reify` or a single-record
|
|
224
|
+
method inside a loop. They share history loading across records. Ordinary
|
|
225
|
+
record and direct-association histories can be read without creating a model
|
|
226
|
+
instance for every historical state.
|
|
227
|
+
|
|
228
|
+
Some associations still need per-record queries. Batch methods reject scoped
|
|
229
|
+
associations and certain `has_many :through` histories by default with
|
|
230
|
+
`UnsupportedBatchPathError`. The error names the association. Remove it from
|
|
231
|
+
`associations:`, or pass `allow_expensive: true` if you accept that cost.
|
|
232
|
+
Callbacks and callable historical filters can also issue queries. See the
|
|
233
|
+
[performance guide](docs/performance.md) for measured behavior and limitations.
|
|
234
|
+
|
|
235
|
+
- `updated_at` is ignored. Passing `ignore:` replaces the default list.
|
|
236
|
+
- Records passed as current state must be saved and have no unsaved changes.
|
|
237
|
+
They are reloaded from the database.
|
|
238
|
+
- Empty timeline steps are kept. Use `reject(&:empty?)` to hide them in a feed.
|
|
239
|
+
- Only the associations you name are included.
|
|
240
|
+
- Historical data cannot be recovered if PaperTrail did not record it.
|
|
241
|
+
|
|
242
|
+
See the [API reference](docs/reference.md) for all options and result methods,
|
|
243
|
+
[testing](docs/testing.md) for development checks, and
|
|
244
|
+
[CHANGELOG.md](CHANGELOG.md) for changes. Licensed under the [MIT license](LICENSE).
|