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.
Files changed (77) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +45 -0
  3. data/LICENSE +22 -0
  4. data/README.md +473 -0
  5. data/lib/paper_trail_diff/activity_boundary.rb +82 -0
  6. data/lib/paper_trail_diff/activity_event.rb +128 -0
  7. data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +493 -0
  8. data/lib/paper_trail_diff/activity_range.rb +58 -0
  9. data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +156 -0
  10. data/lib/paper_trail_diff/activity_snapshot_sequence.rb +161 -0
  11. data/lib/paper_trail_diff/activity_timeline_builder.rb +177 -0
  12. data/lib/paper_trail_diff/activity_version_collector.rb +196 -0
  13. data/lib/paper_trail_diff/analysis.rb +26 -0
  14. data/lib/paper_trail_diff/association_discovery.rb +132 -0
  15. data/lib/paper_trail_diff/association_traversal.rb +131 -0
  16. data/lib/paper_trail_diff/branch_snapshot_refresher.rb +191 -0
  17. data/lib/paper_trail_diff/configuration.rb +170 -0
  18. data/lib/paper_trail_diff/diagnostics.rb +242 -0
  19. data/lib/paper_trail_diff/endpoint.rb +87 -0
  20. data/lib/paper_trail_diff/engine.rb +180 -0
  21. data/lib/paper_trail_diff/errors.rb +34 -0
  22. data/lib/paper_trail_diff/historical_association_reifier.rb +103 -0
  23. data/lib/paper_trail_diff/historical_snapshot_store.rb +144 -0
  24. data/lib/paper_trail_diff/live_association_reader.rb +10 -0
  25. data/lib/paper_trail_diff/paper_trail_adapter.rb +182 -0
  26. data/lib/paper_trail_diff/prepared_association_reifier.rb +48 -0
  27. data/lib/paper_trail_diff/prepared_edge_loader.rb +155 -0
  28. data/lib/paper_trail_diff/prepared_history.rb +217 -0
  29. data/lib/paper_trail_diff/prepared_history_loader.rb +136 -0
  30. data/lib/paper_trail_diff/prepared_record_index.rb +154 -0
  31. data/lib/paper_trail_diff/snapshot.rb +120 -0
  32. data/lib/paper_trail_diff/snapshot_normalizer.rb +182 -0
  33. data/lib/paper_trail_diff/step.rb +28 -0
  34. data/lib/paper_trail_diff/support.rb +80 -0
  35. data/lib/paper_trail_diff/timeline_builder.rb +60 -0
  36. data/lib/paper_trail_diff/timeline_snapshot_provider.rb +24 -0
  37. data/lib/paper_trail_diff/value_objects.rb +134 -0
  38. data/lib/paper_trail_diff/version.rb +6 -0
  39. data/lib/paper_trail_diff/version_range.rb +81 -0
  40. data/lib/paper_trail_diff.rb +131 -0
  41. data/sig/generated/paper_trail_diff/activity_boundary.rbs +43 -0
  42. data/sig/generated/paper_trail_diff/activity_event.rbs +64 -0
  43. data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +98 -0
  44. data/sig/generated/paper_trail_diff/activity_range.rbs +36 -0
  45. data/sig/generated/paper_trail_diff/activity_root_snapshot_refresher.rbs +54 -0
  46. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +71 -0
  47. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +61 -0
  48. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +71 -0
  49. data/sig/generated/paper_trail_diff/analysis.rbs +18 -0
  50. data/sig/generated/paper_trail_diff/association_discovery.rbs +63 -0
  51. data/sig/generated/paper_trail_diff/association_traversal.rbs +54 -0
  52. data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +56 -0
  53. data/sig/generated/paper_trail_diff/configuration.rbs +67 -0
  54. data/sig/generated/paper_trail_diff/diagnostics.rbs +109 -0
  55. data/sig/generated/paper_trail_diff/endpoint.rbs +30 -0
  56. data/sig/generated/paper_trail_diff/engine.rbs +54 -0
  57. data/sig/generated/paper_trail_diff/errors.rbs +43 -0
  58. data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +37 -0
  59. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +50 -0
  60. data/sig/generated/paper_trail_diff/live_association_reader.rbs +9 -0
  61. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +72 -0
  62. data/sig/generated/paper_trail_diff/prepared_association_reifier.rbs +27 -0
  63. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +65 -0
  64. data/sig/generated/paper_trail_diff/prepared_history.rbs +93 -0
  65. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +67 -0
  66. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +84 -0
  67. data/sig/generated/paper_trail_diff/snapshot.rbs +69 -0
  68. data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +76 -0
  69. data/sig/generated/paper_trail_diff/step.rbs +18 -0
  70. data/sig/generated/paper_trail_diff/support.rbs +30 -0
  71. data/sig/generated/paper_trail_diff/timeline_builder.rbs +31 -0
  72. data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +17 -0
  73. data/sig/generated/paper_trail_diff/value_objects.rbs +87 -0
  74. data/sig/generated/paper_trail_diff/version.rbs +5 -0
  75. data/sig/generated/paper_trail_diff/version_range.rbs +35 -0
  76. data/sig/generated/paper_trail_diff.rbs +56 -0
  77. 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