paper_trail_diff 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +45 -0
- data/LICENSE +22 -0
- data/README.md +473 -0
- data/lib/paper_trail_diff/activity_boundary.rb +82 -0
- data/lib/paper_trail_diff/activity_event.rb +128 -0
- data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +493 -0
- data/lib/paper_trail_diff/activity_range.rb +58 -0
- data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +156 -0
- data/lib/paper_trail_diff/activity_snapshot_sequence.rb +161 -0
- data/lib/paper_trail_diff/activity_timeline_builder.rb +177 -0
- data/lib/paper_trail_diff/activity_version_collector.rb +196 -0
- data/lib/paper_trail_diff/analysis.rb +26 -0
- data/lib/paper_trail_diff/association_discovery.rb +132 -0
- data/lib/paper_trail_diff/association_traversal.rb +131 -0
- data/lib/paper_trail_diff/branch_snapshot_refresher.rb +191 -0
- data/lib/paper_trail_diff/configuration.rb +170 -0
- data/lib/paper_trail_diff/diagnostics.rb +242 -0
- data/lib/paper_trail_diff/endpoint.rb +87 -0
- data/lib/paper_trail_diff/engine.rb +180 -0
- data/lib/paper_trail_diff/errors.rb +34 -0
- data/lib/paper_trail_diff/historical_association_reifier.rb +103 -0
- data/lib/paper_trail_diff/historical_snapshot_store.rb +144 -0
- data/lib/paper_trail_diff/live_association_reader.rb +10 -0
- data/lib/paper_trail_diff/paper_trail_adapter.rb +182 -0
- data/lib/paper_trail_diff/prepared_association_reifier.rb +48 -0
- data/lib/paper_trail_diff/prepared_edge_loader.rb +155 -0
- data/lib/paper_trail_diff/prepared_history.rb +217 -0
- data/lib/paper_trail_diff/prepared_history_loader.rb +136 -0
- data/lib/paper_trail_diff/prepared_record_index.rb +154 -0
- data/lib/paper_trail_diff/snapshot.rb +120 -0
- data/lib/paper_trail_diff/snapshot_normalizer.rb +182 -0
- data/lib/paper_trail_diff/step.rb +28 -0
- data/lib/paper_trail_diff/support.rb +80 -0
- data/lib/paper_trail_diff/timeline_builder.rb +60 -0
- data/lib/paper_trail_diff/timeline_snapshot_provider.rb +24 -0
- data/lib/paper_trail_diff/value_objects.rb +134 -0
- data/lib/paper_trail_diff/version.rb +6 -0
- data/lib/paper_trail_diff/version_range.rb +81 -0
- data/lib/paper_trail_diff.rb +131 -0
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +43 -0
- data/sig/generated/paper_trail_diff/activity_event.rbs +64 -0
- data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +98 -0
- data/sig/generated/paper_trail_diff/activity_range.rbs +36 -0
- data/sig/generated/paper_trail_diff/activity_root_snapshot_refresher.rbs +54 -0
- data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +71 -0
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +61 -0
- data/sig/generated/paper_trail_diff/activity_version_collector.rbs +71 -0
- data/sig/generated/paper_trail_diff/analysis.rbs +18 -0
- data/sig/generated/paper_trail_diff/association_discovery.rbs +63 -0
- data/sig/generated/paper_trail_diff/association_traversal.rbs +54 -0
- data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +56 -0
- data/sig/generated/paper_trail_diff/configuration.rbs +67 -0
- data/sig/generated/paper_trail_diff/diagnostics.rbs +109 -0
- data/sig/generated/paper_trail_diff/endpoint.rbs +30 -0
- data/sig/generated/paper_trail_diff/engine.rbs +54 -0
- data/sig/generated/paper_trail_diff/errors.rbs +43 -0
- data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +37 -0
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +50 -0
- data/sig/generated/paper_trail_diff/live_association_reader.rbs +9 -0
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +72 -0
- data/sig/generated/paper_trail_diff/prepared_association_reifier.rbs +27 -0
- data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +65 -0
- data/sig/generated/paper_trail_diff/prepared_history.rbs +93 -0
- data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +67 -0
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +84 -0
- data/sig/generated/paper_trail_diff/snapshot.rbs +69 -0
- data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +76 -0
- data/sig/generated/paper_trail_diff/step.rbs +18 -0
- data/sig/generated/paper_trail_diff/support.rbs +30 -0
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +31 -0
- data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +17 -0
- data/sig/generated/paper_trail_diff/value_objects.rbs +87 -0
- data/sig/generated/paper_trail_diff/version.rbs +5 -0
- data/sig/generated/paper_trail_diff/version_range.rbs +35 -0
- data/sig/generated/paper_trail_diff.rbs +56 -0
- metadata +144 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e85c7b69b7fe2c0c5b8fac36c28e94f0e8cb3e24bb0e84fa312a8c8c4de9a244
|
|
4
|
+
data.tar.gz: fa7118652d053ac5f882a6382c412534ca3c9fdbc3b613192652e7a3080fa205
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 04d797a11eadc6702892f0e581da107bc6e1a4f2c41f7bf9759bddc1f01534b29389eeb12830b945be378c5cebe4ae3336235326ce21d7423b1f10e3bc48db28
|
|
7
|
+
data.tar.gz: 15cf3d7c0b099e2fb4c4db6db84c64718afd121d42fc91f8f63e34513d68db54f2c1365354eed648faef435e4d6a4e1645115978f80cbf2834e4e382efb3c773
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file. The
|
|
4
|
+
project follows [Semantic Versioning](https://semver.org/).
|
|
5
|
+
|
|
6
|
+
## [0.1.0] - 2026-08-09
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- Add explicit, bounded nested association paths such as
|
|
11
|
+
`comments.replies.author`.
|
|
12
|
+
- Add nested association changes to stable-record diffs and selected subtrees
|
|
13
|
+
to added/removed record snapshots.
|
|
14
|
+
- Add path-aware ignore rules while preserving the existing global array form.
|
|
15
|
+
- Add root and nested `has_and_belongs_to_many` collection diffs, including
|
|
16
|
+
historical membership, target updates, timelines, and bounded cyclic paths.
|
|
17
|
+
- Reload the configured PaperTrail versions association before resolving
|
|
18
|
+
timeline boundaries.
|
|
19
|
+
- Rename the root `Diff#record` lifecycle field to the more explicit
|
|
20
|
+
`Diff#record_presence_change`, including its `to_h` key.
|
|
21
|
+
- Add `activity_timeline` for adjacent root and selected-descendant version
|
|
22
|
+
boundaries while preserving the root-only semantics of `timeline`.
|
|
23
|
+
- Add one-pass `analyze`, bounded association discovery, and structured history
|
|
24
|
+
diagnostics.
|
|
25
|
+
- Bound version loads to the requested time range, stream timeline construction,
|
|
26
|
+
and reuse immutable snapshot subtrees across adjacent boundaries.
|
|
27
|
+
- Apply event-local snapshot deltas for direct `has_many` membership changes at
|
|
28
|
+
any selected depth and selected non-polymorphic `belongs_to` target events,
|
|
29
|
+
with safe reconstruction fallbacks for other association shapes.
|
|
30
|
+
- Reconstruct post-create/update records from PaperTrail's serialized change
|
|
31
|
+
pairs to avoid per-event successor and live-record lookups.
|
|
32
|
+
- Derive combined endpoint, root-timeline, and activity results from one
|
|
33
|
+
snapshot pass; advance isolated root changes in place and selectively refresh
|
|
34
|
+
only ambiguous association branches.
|
|
35
|
+
- Add `RecordReference`, consistent configuration error subclasses, traversal
|
|
36
|
+
foreign-key suppression, and fail-loud HABTM endpoint validation.
|
|
37
|
+
- Fix selective `has_many :through` reconstruction by reifying the hidden
|
|
38
|
+
through collection before resolving target records.
|
|
39
|
+
- Prepare timeline record states and association membership once per requested
|
|
40
|
+
range, using indexed resolution for direct relationships, belongs-to-source
|
|
41
|
+
through collections, and transaction-backed HABTM membership.
|
|
42
|
+
- Retain point-in-time PT-AT reconstruction as a per-reflection fallback for
|
|
43
|
+
scoped, unversioned, composite-key, and unsupported through shapes.
|
|
44
|
+
- Keep multiple selected descendant events in one transaction atomic instead
|
|
45
|
+
of applying an individual event delta before the transaction boundary.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alex Williams
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
data/README.md
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
# paper_trail_diff
|
|
2
|
+
|
|
3
|
+
`paper_trail_diff` adds structured endpoint, checkpoint-timeline, and activity
|
|
4
|
+
comparisons to
|
|
5
|
+
[PaperTrail](https://github.com/paper-trail-gem/paper_trail). It returns immutable
|
|
6
|
+
Ruby value objects and hashes rather than formatted text.
|
|
7
|
+
|
|
8
|
+
PaperTrail is required. Explicit, bounded association-path comparison is
|
|
9
|
+
available when
|
|
10
|
+
[paper_trail-association_tracking](https://github.com/westonganger/paper_trail-association_tracking)
|
|
11
|
+
(PT-AT) is installed, loaded, and enabled.
|
|
12
|
+
|
|
13
|
+
Ruby 3.1 or newer and PaperTrail 16 or 17 are supported.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
Add the gem to your bundle:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
gem "paper_trail_diff"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
For association history, also add and configure PT-AT using its migrations:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
gem "paper_trail-association_tracking"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
PaperTrail.config.track_associations = true
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
All associated models whose historical state is compared must use
|
|
34
|
+
`has_paper_trail`.
|
|
35
|
+
|
|
36
|
+
## Compare two endpoints
|
|
37
|
+
|
|
38
|
+
PaperTrail stores an object's state before each recorded event. `compare`
|
|
39
|
+
accepts two explicit endpoints: each may be a PaperTrail version or a clean,
|
|
40
|
+
persisted model instance representing current database state. It reports only
|
|
41
|
+
their net difference:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
diff = PaperTrailDiff.compare(article.versions[1], article.versions[4])
|
|
45
|
+
|
|
46
|
+
diff.attributes["title"].from # => "Draft"
|
|
47
|
+
diff.attributes["title"].to # => "Published"
|
|
48
|
+
diff.empty? # => false
|
|
49
|
+
diff.to_h
|
|
50
|
+
# => {
|
|
51
|
+
# record_presence_change: nil,
|
|
52
|
+
# attributes: {
|
|
53
|
+
# "title" => { from: "Draft", to: "Published" }
|
|
54
|
+
# },
|
|
55
|
+
# associations: {}
|
|
56
|
+
# }
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Intermediate edits do not affect `compare`. If a title changes and later
|
|
60
|
+
returns to its original value, endpoint comparison reports no title change.
|
|
61
|
+
A `create` version reifies to `nil`; comparing it with a record state produces
|
|
62
|
+
a structured `record_presence_change` instead of fake scalar changes. For
|
|
63
|
+
ordinary updates, `record_presence_change` is `nil`, meaning the root record is
|
|
64
|
+
present at both endpoints; scalar changes remain under `attributes`.
|
|
65
|
+
|
|
66
|
+
Pass the record explicitly when the desired endpoint is current state:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
diff = PaperTrailDiff.compare(
|
|
70
|
+
article.versions.last,
|
|
71
|
+
article,
|
|
72
|
+
associations: ["comments.replies"]
|
|
73
|
+
)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Version and record endpoints may appear in either order. Current state is never
|
|
77
|
+
inferred. The record must be persisted, not destroyed, and free of unsaved
|
|
78
|
+
attribute changes. The gem reloads it unscoped before normalization, so stale
|
|
79
|
+
association caches and in-memory edits are not compared. Use a database
|
|
80
|
+
transaction with an appropriate isolation level when several live association
|
|
81
|
+
queries must represent one atomic application snapshot.
|
|
82
|
+
|
|
83
|
+
## Build a root-checkpoint timeline
|
|
84
|
+
|
|
85
|
+
`timeline` accepts two version objects from the supplied record's history. The
|
|
86
|
+
range is inclusive, must be chronological, and produces one `Step` for each
|
|
87
|
+
adjacent pair:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
steps = PaperTrailDiff.timeline(
|
|
91
|
+
article,
|
|
92
|
+
from: article.versions[1],
|
|
93
|
+
to: article.versions[4]
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
steps.first.from_version # the original PaperTrail version
|
|
97
|
+
steps.first.to_version # the next PaperTrail version
|
|
98
|
+
steps.first.diff # a PaperTrailDiff::Diff
|
|
99
|
+
steps.first.to_h
|
|
100
|
+
# => { from_version_id: 2, to_version_id: 3, diff: { ... } }
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Every version boundary remains in the result, even when its diff is empty after
|
|
104
|
+
ignored fields are removed. Equal boundaries return a frozen empty array.
|
|
105
|
+
|
|
106
|
+
This is deliberately a root-record timeline. Several child changes between two
|
|
107
|
+
root versions appear together in the same step.
|
|
108
|
+
|
|
109
|
+
## Build an activity timeline
|
|
110
|
+
|
|
111
|
+
`activity_timeline` merges versions for the root and explicitly selected
|
|
112
|
+
descendants. Child changes can therefore form separate steps instead of waiting
|
|
113
|
+
to be aggregated at the next root checkpoint:
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
steps = PaperTrailDiff.activity_timeline(
|
|
117
|
+
article,
|
|
118
|
+
from: article.versions[1],
|
|
119
|
+
to: article.versions[4],
|
|
120
|
+
associations: ["comments.replies", :author]
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
steps.reject { |step| step.diff.empty? }
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Pass the record explicitly as `to:` to include current state without creating a
|
|
127
|
+
final root checkpoint:
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
steps = PaperTrailDiff.activity_timeline(
|
|
131
|
+
article,
|
|
132
|
+
from: article.versions[1],
|
|
133
|
+
to: article,
|
|
134
|
+
associations: ["comments.replies", :author]
|
|
135
|
+
)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The result is a frozen array of `ActivityStep` objects. Each step has immutable
|
|
139
|
+
`from_boundary`, `to_boundary`, and `diff` readers. Historical boundaries are
|
|
140
|
+
`kind: :version`; an explicitly requested live endpoint is `kind: :current`:
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
steps.last.to_boundary.to_h
|
|
144
|
+
# => {
|
|
145
|
+
# kind: :current,
|
|
146
|
+
# version_id: nil,
|
|
147
|
+
# item_type: "Article",
|
|
148
|
+
# item_id: 42,
|
|
149
|
+
# recorded_at: 2026-08-08 12:00:00 UTC
|
|
150
|
+
# }
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
A historical boundary may belong to the root or a selected descendant model.
|
|
154
|
+
PaperTrail versions describe pre-change state, so a mutation becomes visible
|
|
155
|
+
between its version boundary and the next historical or explicit current
|
|
156
|
+
boundary. Passing `to: article` is what removes the need to touch the parent
|
|
157
|
+
after an ordinary versioned child mutation; current state is still never
|
|
158
|
+
implicit.
|
|
159
|
+
|
|
160
|
+
Live-ended HABTM activity is rejected with
|
|
161
|
+
`PaperTrailDiff::UnsupportedLiveActivityError`. HABTM join rows have no model
|
|
162
|
+
versions, so the gem cannot reliably split their membership mutations into
|
|
163
|
+
intermediate events without transaction-backed owner checkpoints. Historical
|
|
164
|
+
HABTM activity and `compare(version, article, associations: [:tags])` remain
|
|
165
|
+
supported where the historical endpoint has usable PT-AT metadata.
|
|
166
|
+
|
|
167
|
+
Both timeline APIs preserve empty boundaries. A display may filter
|
|
168
|
+
`step.diff.empty?`, while audit-oriented callers can retain every recorded
|
|
169
|
+
boundary.
|
|
170
|
+
|
|
171
|
+
## Build an endpoint and timeline together
|
|
172
|
+
|
|
173
|
+
When a caller needs the net endpoint diff and root timeline, `analyze`
|
|
174
|
+
normalizes each selected version once. Pass `activity: true` when the same view
|
|
175
|
+
also needs descendant-aware activity. The endpoint diff and root timeline are
|
|
176
|
+
then derived from the same activity snapshot pass instead of reconstructing the
|
|
177
|
+
root history separately:
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
analysis = PaperTrailDiff.analyze(
|
|
181
|
+
article,
|
|
182
|
+
from: article.versions[1],
|
|
183
|
+
to: article.versions[4],
|
|
184
|
+
associations: ["comments.replies"],
|
|
185
|
+
activity: true
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
analysis.diff
|
|
189
|
+
analysis.timeline
|
|
190
|
+
analysis.activity_timeline
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Without `activity: true`, `analysis.activity_timeline` is `nil` and no activity
|
|
194
|
+
work is performed. `analyze` remains version-bounded; use the standalone
|
|
195
|
+
`activity_timeline(..., to: article)` API for an explicit current endpoint.
|
|
196
|
+
|
|
197
|
+
`timeline`, `activity_timeline`, and both forms of `analyze` prepare the selected
|
|
198
|
+
historical range once. The loader walks only the explicit association paths and
|
|
199
|
+
builds an immutable temporal index of scalar states, relationship candidates,
|
|
200
|
+
HABTM transaction snapshots, and live fallbacks. Direct relationships,
|
|
201
|
+
`has_many :through` with a `belongs_to` source, and transaction-backed HABTM
|
|
202
|
+
membership are resolved from that index. Endpoint-only `compare` retains the
|
|
203
|
+
lighter two-point reconstruction path.
|
|
204
|
+
|
|
205
|
+
Activity reconstruction then carries immutable snapshots forward between
|
|
206
|
+
boundaries. Isolated root and descendant events with usable serialized changes
|
|
207
|
+
advance only the affected immutable nodes. Events sharing a PT-AT transaction
|
|
208
|
+
refresh their combined branches atomically. Scoped associations, unversioned
|
|
209
|
+
targets, composite relationship keys, collection-source through associations,
|
|
210
|
+
and other unsupported shapes fall back to the ordinary PT-AT point reifier on
|
|
211
|
+
a per-reflection basis. This hybrid path preserves existing reconstruction
|
|
212
|
+
behavior while avoiding repeated association queries for supported paths.
|
|
213
|
+
|
|
214
|
+
Activity event discovery is bounded to the selected range. Prepared scalar
|
|
215
|
+
state also retains later successor versions for selected identities because a
|
|
216
|
+
PaperTrail version is a pre-change snapshot and may be the only correct state
|
|
217
|
+
for an earlier boundary. Memory therefore scales with relevant selected
|
|
218
|
+
history, not only with the number of returned steps. Keep requested paths and
|
|
219
|
+
ranges intentional. For large histories, applications should give the database
|
|
220
|
+
a matching composite index. A typical PaperTrail installation can add one
|
|
221
|
+
without making it a requirement of this gem:
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
add_index :versions, %i[item_type item_id created_at id],
|
|
225
|
+
name: "index_versions_on_item_and_timeline"
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
PT-AT's normal index beginning with `foreign_key_name`, `foreign_key_id`, and
|
|
229
|
+
`foreign_type` should also be retained on `version_associations`.
|
|
230
|
+
|
|
231
|
+
## Ignore noise fields
|
|
232
|
+
|
|
233
|
+
The comparison APIs ignore `updated_at` by default. `ignore:` replaces that default and
|
|
234
|
+
accepts string or symbol names. The array form applies to the root and every
|
|
235
|
+
selected association:
|
|
236
|
+
|
|
237
|
+
```ruby
|
|
238
|
+
PaperTrailDiff.compare(from, to, ignore: %i[updated_at lock_version])
|
|
239
|
+
PaperTrailDiff.compare(from, to, ignore: []) # compare every scalar field
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
For exact path control, pass `all:` plus `paths:`. `$` identifies the root:
|
|
243
|
+
|
|
244
|
+
```ruby
|
|
245
|
+
PaperTrailDiff.compare(
|
|
246
|
+
from,
|
|
247
|
+
to,
|
|
248
|
+
associations: ["comments.replies"],
|
|
249
|
+
ignore: {
|
|
250
|
+
all: [:updated_at],
|
|
251
|
+
paths: {
|
|
252
|
+
"$" => [:lock_version],
|
|
253
|
+
"comments.replies" => [:delivery_state]
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
)
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Hash rules replace the default just like an array. `all:` applies everywhere;
|
|
260
|
+
each path entry applies only at that exact path, not its descendants. Ignore
|
|
261
|
+
paths must be `$` or one of the selected or implicitly selected association
|
|
262
|
+
paths.
|
|
263
|
+
|
|
264
|
+
Primary keys are always represented as record identity rather than scalar
|
|
265
|
+
attributes.
|
|
266
|
+
|
|
267
|
+
## Compare associations
|
|
268
|
+
|
|
269
|
+
Pass explicit names or dot-separated paths. Paths are finite traversal plans,
|
|
270
|
+
not a request to recursively inspect the whole object graph. Ancestors are
|
|
271
|
+
selected implicitly:
|
|
272
|
+
|
|
273
|
+
```ruby
|
|
274
|
+
diff = PaperTrailDiff.compare(
|
|
275
|
+
from,
|
|
276
|
+
to,
|
|
277
|
+
associations: [:author, :profile, "comments.replies.author"]
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
diff.associations["author"].relationship
|
|
281
|
+
# => #<PaperTrailDiff::ValueChange ...> # belongs_to replacement
|
|
282
|
+
|
|
283
|
+
comments = diff.associations["comments"]
|
|
284
|
+
comments.added # full RecordSnapshot objects
|
|
285
|
+
comments.removed # full RecordSnapshot objects
|
|
286
|
+
comments.changed # RecordChange objects for stable identities
|
|
287
|
+
|
|
288
|
+
replies = comments.changed.first.associations["replies"]
|
|
289
|
+
replies.added
|
|
290
|
+
replies.removed
|
|
291
|
+
replies.changed
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
For `belongs_to` and `has_one`, replacing the related identity is a
|
|
295
|
+
`relationship` change. Updating attributes on the same related identity is a
|
|
296
|
+
`changed` record. For `has_many` and `has_and_belongs_to_many`, membership is
|
|
297
|
+
split into `added` and `removed`; shared identities with scalar changes appear
|
|
298
|
+
in `changed`. The result preserves the reflected macro in `kind`.
|
|
299
|
+
|
|
300
|
+
Both Rails many-to-many forms are supported. `has_many :through` uses PT-AT's
|
|
301
|
+
history for the versioned join model. HABTM reports membership and target
|
|
302
|
+
attribute changes, but it cannot report join attributes because HABTM has no
|
|
303
|
+
join model.
|
|
304
|
+
|
|
305
|
+
The same structure repeats at every selected depth. `RecordChange#associations`
|
|
306
|
+
contains nested changes for a stable parent identity. Added and removed record
|
|
307
|
+
snapshots retain their selected subtrees so nested state is not discarded.
|
|
308
|
+
|
|
309
|
+
Selecting a `belongs_to` removes its foreign-key (and polymorphic type) column
|
|
310
|
+
from scalar changes at that path. Without association selection, that column
|
|
311
|
+
remains a normal scalar attribute. A direct `has_one` or `has_many` edge also
|
|
312
|
+
removes its incoming foreign key from the child snapshot because membership is
|
|
313
|
+
already represented structurally. Join-model attributes for `has_many :through`
|
|
314
|
+
remain available. Cyclic model relationships are safe because only the finite
|
|
315
|
+
paths supplied by the caller are traversed.
|
|
316
|
+
|
|
317
|
+
Requesting associations for any historical endpoint without loaded and enabled
|
|
318
|
+
PT-AT raises `PaperTrailDiff::AssociationTrackingUnavailableError`. A comparison
|
|
319
|
+
whose endpoints are both live records can normalize associations without PT-AT,
|
|
320
|
+
though it will normally be empty because both records reload the same current
|
|
321
|
+
database state. Unknown names and unsupported macros raise explicit
|
|
322
|
+
`PaperTrailDiff::Error` subclasses.
|
|
323
|
+
Malformed public options raise `PaperTrailDiff::ConfigurationError`, also under
|
|
324
|
+
that base error.
|
|
325
|
+
|
|
326
|
+
## Discover and diagnose associations
|
|
327
|
+
|
|
328
|
+
Configuration UIs can use bounded public reflection instead of duplicating the
|
|
329
|
+
gem's macro and cycle logic:
|
|
330
|
+
|
|
331
|
+
```ruby
|
|
332
|
+
PaperTrailDiff.supported_association_macros
|
|
333
|
+
PaperTrailDiff.association_paths(Article, max_depth: 3).map(&:to_h)
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
Descriptors include `path`, `kind`, `target_type`, `through`, `polymorphic`, and
|
|
337
|
+
`cycle`. Cycles are marked and not descended; PaperTrail's infrastructure
|
|
338
|
+
`versions` association is excluded.
|
|
339
|
+
|
|
340
|
+
Before relying on association history, inspect known setup hazards:
|
|
341
|
+
|
|
342
|
+
```ruby
|
|
343
|
+
report = PaperTrailDiff.diagnose(
|
|
344
|
+
from_version,
|
|
345
|
+
to_version,
|
|
346
|
+
associations: [:tags, "comments.replies"]
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
report.ok?
|
|
350
|
+
report.errors.map(&:code)
|
|
351
|
+
report.warnings.map(&:code)
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
Diagnostics are read-only guidance, not proof that arbitrary old data is
|
|
355
|
+
complete. HABTM endpoints without transaction-backed association snapshots fail
|
|
356
|
+
loudly with `PaperTrailDiff::IncompleteAssociationHistoryError` during normal
|
|
357
|
+
comparison as well.
|
|
358
|
+
|
|
359
|
+
### Recommended checkpoint recipe
|
|
360
|
+
|
|
361
|
+
For PT-AT history—especially HABTM—use transaction-backed checkpoints, disable
|
|
362
|
+
PaperTrail's version timestamp synchronization on the root, and checkpoint a
|
|
363
|
+
fresh record instance:
|
|
364
|
+
|
|
365
|
+
```ruby
|
|
366
|
+
class Article < ApplicationRecord
|
|
367
|
+
has_paper_trail synchronize_version_creation_timestamp: false
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def checkpoint_article(article_id)
|
|
371
|
+
Article.transaction do
|
|
372
|
+
Article.find(article_id).paper_trail.save_with_version
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
A root checkpoint after a batch supplies an immutable historical final state.
|
|
378
|
+
The ordinary `timeline` aggregates that batch; `activity_timeline` uses recorded
|
|
379
|
+
descendant versions to split it into activity boundaries. For an interactive
|
|
380
|
+
view, `activity_timeline(..., to: article)` can instead terminate explicitly at
|
|
381
|
+
current state without touching the root. Prefer checkpoints for reproducible
|
|
382
|
+
audits and HABTM, and prefer an explicit join model with `has_many :through`
|
|
383
|
+
when join attributes or join mutations must appear as first-class history.
|
|
384
|
+
|
|
385
|
+
## Result objects
|
|
386
|
+
|
|
387
|
+
The public result types are:
|
|
388
|
+
|
|
389
|
+
- `PaperTrailDiff::Diff`
|
|
390
|
+
- `PaperTrailDiff::Step`
|
|
391
|
+
- `PaperTrailDiff::ActivityStep`
|
|
392
|
+
- `PaperTrailDiff::ActivityBoundary`
|
|
393
|
+
- `PaperTrailDiff::Analysis`
|
|
394
|
+
- `PaperTrailDiff::ValueChange`
|
|
395
|
+
- `PaperTrailDiff::RecordReference`
|
|
396
|
+
- `PaperTrailDiff::RecordSnapshot`
|
|
397
|
+
- `PaperTrailDiff::RecordChange`
|
|
398
|
+
- `PaperTrailDiff::ToOneAssociationDiff`
|
|
399
|
+
- `PaperTrailDiff::CollectionAssociationDiff`
|
|
400
|
+
- `PaperTrailDiff::AssociationDescriptor`
|
|
401
|
+
- `PaperTrailDiff::DiagnosticReport`
|
|
402
|
+
- `PaperTrailDiff::DiagnosticIssue`
|
|
403
|
+
|
|
404
|
+
They expose readers, are frozen after construction, and provide deterministic
|
|
405
|
+
`to_h` output. Structural hash keys are symbols; attribute and association
|
|
406
|
+
names are strings. Attribute values retain their Ruby types. `RecordChange#record`
|
|
407
|
+
is a `RecordReference` with `type` and `id` readers. `Step` itself is frozen but
|
|
408
|
+
intentionally retains the original, potentially mutable PaperTrail version
|
|
409
|
+
objects for metadata access; `Step#to_h` emits only their IDs. `ActivityStep`
|
|
410
|
+
instead retains only immutable boundary metadata and serializes as
|
|
411
|
+
`{ from: ..., to: ..., diff: ... }`.
|
|
412
|
+
|
|
413
|
+
## Historical correctness and limitations
|
|
414
|
+
|
|
415
|
+
The output is only as correct and complete as the historical state PaperTrail
|
|
416
|
+
and PT-AT can reconstruct. In particular:
|
|
417
|
+
|
|
418
|
+
- attributes skipped by PaperTrail, deleted versions, and changes made while
|
|
419
|
+
versioning was disabled cannot be recovered;
|
|
420
|
+
- model/schema or serializer changes can affect reification of old versions;
|
|
421
|
+
- comparison and activity APIs add live state only when the caller explicitly
|
|
422
|
+
passes a persisted record endpoint; the adapter reloads that record, but
|
|
423
|
+
multiple queries are not automatically one database-isolated snapshot;
|
|
424
|
+
- PT-AT requires its schema, callbacks, versioned child models, and transaction
|
|
425
|
+
metadata; callback-skipping writes may not be reconstructable;
|
|
426
|
+
- HABTM membership is limited to the join snapshots PT-AT recorded in
|
|
427
|
+
`version_associations`; historical target attributes require versioned target
|
|
428
|
+
models, otherwise PT-AT may return live target state;
|
|
429
|
+
- `timeline` and `analyze` are bounded by root versions; `activity_timeline`
|
|
430
|
+
adds recorded descendant boundaries and may terminate at an explicitly passed
|
|
431
|
+
current record, while a fully historical result still requires a later root
|
|
432
|
+
version because PaperTrail stores pre-change snapshots;
|
|
433
|
+
- HABTM join-table mutations do not have their own model versions and therefore
|
|
434
|
+
cannot become standalone activity boundaries; live-ended HABTM activity is
|
|
435
|
+
rejected, and a versioned join model should be used when that activity matters;
|
|
436
|
+
- activity discovery uses PT-AT association rows and cannot recover descendant
|
|
437
|
+
events whose relevant callbacks or association metadata were never recorded;
|
|
438
|
+
- PT-AT has documented edge cases, especially around some `has_one` histories;
|
|
439
|
+
`paper_trail_diff` respects PT-AT's configured reification error behavior;
|
|
440
|
+
- deeper paths require more historical reconstruction and database work, so
|
|
441
|
+
callers should select only the branches they need;
|
|
442
|
+
- no implicit or unbounded recursive association traversal is performed.
|
|
443
|
+
|
|
444
|
+
Review the [PaperTrail version semantics](https://github.com/paper-trail-gem/paper_trail#3-working-with-versions)
|
|
445
|
+
and [PT-AT limitations](https://github.com/westonganger/paper_trail-association_tracking#limitations)
|
|
446
|
+
before relying on reconstructed history for restoration or compliance work.
|
|
447
|
+
|
|
448
|
+
## Development
|
|
449
|
+
|
|
450
|
+
The project uses mise for its pinned development tools:
|
|
451
|
+
|
|
452
|
+
```console
|
|
453
|
+
mise install
|
|
454
|
+
mise exec -- bundle install
|
|
455
|
+
mise exec -- bundle exec rake
|
|
456
|
+
mise exec -- bundle exec rake build
|
|
457
|
+
mise exec -- act push -j quality --matrix ruby:4.0 --matrix paper_trail:17
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
The default Rake task runs core specs without PT-AT loaded, association specs in
|
|
461
|
+
a separate process, RuboCop, generated-signature verification, and Steep.
|
|
462
|
+
Inline `#:` annotations in `lib/` generate the committed RBS files under
|
|
463
|
+
`sig/generated/`.
|
|
464
|
+
|
|
465
|
+
Release tags matching `v*` publish through RubyGems trusted publishing. Before
|
|
466
|
+
tagging, replace `Unreleased` in the changelog with the release date, run the
|
|
467
|
+
full CI workflow, and configure the repository's `release` environment as a
|
|
468
|
+
trusted publisher on RubyGems. Do not store a long-lived RubyGems API key in
|
|
469
|
+
the repository.
|
|
470
|
+
|
|
471
|
+
## License
|
|
472
|
+
|
|
473
|
+
MIT
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Immutable metadata identifying one historical or current activity boundary.
|
|
6
|
+
class ActivityBoundary
|
|
7
|
+
attr_reader :kind #: Symbol
|
|
8
|
+
attr_reader :version_id #: untyped
|
|
9
|
+
attr_reader :item_type #: String
|
|
10
|
+
attr_reader :item_id #: untyped
|
|
11
|
+
attr_reader :recorded_at #: untyped
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
#: (untyped) -> ActivityBoundary
|
|
15
|
+
def from_version(version)
|
|
16
|
+
new(
|
|
17
|
+
kind: :version,
|
|
18
|
+
version_id: version.id,
|
|
19
|
+
item_type: version.item_type,
|
|
20
|
+
item_id: version.item_id,
|
|
21
|
+
recorded_at: version.created_at
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
#: (untyped, captured_at: untyped) -> ActivityBoundary
|
|
26
|
+
def current(record, captured_at:)
|
|
27
|
+
new(
|
|
28
|
+
kind: :current,
|
|
29
|
+
version_id: nil,
|
|
30
|
+
item_type: record.class.base_class.name,
|
|
31
|
+
item_id: record.id,
|
|
32
|
+
recorded_at: captured_at
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
#: (kind: Symbol, version_id: untyped, item_type: untyped, item_id: untyped, recorded_at: untyped) -> void
|
|
38
|
+
def initialize(kind:, version_id:, item_type:, item_id:, recorded_at:)
|
|
39
|
+
@kind = kind
|
|
40
|
+
@version_id = Support.immutable_copy(version_id)
|
|
41
|
+
@item_type = Support.immutable_copy(item_type.to_s)
|
|
42
|
+
@item_id = Support.immutable_copy(item_id)
|
|
43
|
+
@recorded_at = Support.immutable_copy(recorded_at)
|
|
44
|
+
freeze
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
#: () -> Hash[Symbol, untyped]
|
|
48
|
+
def to_h
|
|
49
|
+
{
|
|
50
|
+
kind: kind,
|
|
51
|
+
version_id: version_id,
|
|
52
|
+
item_type: item_type,
|
|
53
|
+
item_id: item_id,
|
|
54
|
+
recorded_at: recorded_at
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# One adjacent transition in a descendant-aware activity timeline.
|
|
60
|
+
class ActivityStep
|
|
61
|
+
attr_reader :from_boundary #: ActivityBoundary
|
|
62
|
+
attr_reader :to_boundary #: ActivityBoundary
|
|
63
|
+
attr_reader :diff #: Diff
|
|
64
|
+
|
|
65
|
+
#: (from_boundary: ActivityBoundary, to_boundary: ActivityBoundary, diff: Diff) -> void
|
|
66
|
+
def initialize(from_boundary:, to_boundary:, diff:)
|
|
67
|
+
@from_boundary = from_boundary
|
|
68
|
+
@to_boundary = to_boundary
|
|
69
|
+
@diff = diff
|
|
70
|
+
freeze
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
#: () -> Hash[Symbol, untyped]
|
|
74
|
+
def to_h
|
|
75
|
+
{
|
|
76
|
+
from: from_boundary.to_h,
|
|
77
|
+
to: to_boundary.to_h,
|
|
78
|
+
diff: diff.to_h
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|