paper_trail_diff 0.9.0 → 0.11.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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -0
  3. data/README.md +181 -0
  4. data/lib/paper_trail_diff/activity_boundary.rb +20 -4
  5. data/lib/paper_trail_diff/activity_grouping.rb +26 -0
  6. data/lib/paper_trail_diff/activity_timeline_builder.rb +13 -6
  7. data/lib/paper_trail_diff/activity_transaction_grouper.rb +83 -0
  8. data/lib/paper_trail_diff/diagnostics.rb +4 -2
  9. data/lib/paper_trail_diff/errors.rb +8 -0
  10. data/lib/paper_trail_diff/nested_comparator.rb +108 -0
  11. data/lib/paper_trail_diff/paper_trail_adapter.rb +31 -12
  12. data/lib/paper_trail_diff/prepared_history_loader.rb +1 -1
  13. data/lib/paper_trail_diff/scoped_analysis.rb +31 -0
  14. data/lib/paper_trail_diff/scoped_root_selection.rb +148 -0
  15. data/lib/paper_trail_diff/support.rb +13 -0
  16. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +13 -8
  17. data/lib/paper_trail_diff/traversal_preparer.rb +25 -0
  18. data/lib/paper_trail_diff/version.rb +1 -1
  19. data/lib/paper_trail_diff.rb +101 -12
  20. data/sig/generated/paper_trail_diff/activity_boundary.rbs +13 -2
  21. data/sig/generated/paper_trail_diff/activity_grouping.rbs +19 -0
  22. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +4 -2
  23. data/sig/generated/paper_trail_diff/activity_transaction_grouper.rbs +54 -0
  24. data/sig/generated/paper_trail_diff/errors.rbs +10 -0
  25. data/sig/generated/paper_trail_diff/nested_comparator.rbs +68 -0
  26. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +15 -8
  27. data/sig/generated/paper_trail_diff/scoped_analysis.rbs +25 -0
  28. data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +93 -0
  29. data/sig/generated/paper_trail_diff/support.rbs +11 -0
  30. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +4 -2
  31. data/sig/generated/paper_trail_diff/traversal_preparer.rbs +10 -0
  32. data/sig/generated/paper_trail_diff.rbs +58 -4
  33. metadata +14 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1ee80500dbeb48421e7b0e0766a5f5559b73aa71528360a5f1a16866f9e6ccfa
4
- data.tar.gz: 6692d3fd2d725918e16da15b4d39312b5e1a42deba3aa8e50a269abfd9fc6bd9
3
+ metadata.gz: 205c52c1f80517b072fa68452447963c3d7245ba0707b049d5ebd5d2528727b3
4
+ data.tar.gz: 65cc6e544662366fc185719a3660b29c3a9796f7c003fe7eeff4c551c9114c11
5
5
  SHA512:
6
- metadata.gz: f809075c65c923ca0dd828e8337477d8461dfa13b2de537a9f99548f0826f867379a33c8bf4a5061476dabd1685829adc5ab21e07052a4d40ebfff4736a44946
7
- data.tar.gz: 6212fab6601c900b2701a9401e7e9b66c5236f12da0340788f0641efef759340a29ea6f95e7b51d86c2792d847f5553db8f30d7c96e4370e37ceec0b8a227656
6
+ metadata.gz: 78c62fcbc641a5f2dd029e69bf656453612a6f2768b1e3268048a13ea9fb4d17ff934048e36cb0798ae3d37aa3102f3c39ef289effdd2f96087796636e1ac921
7
+ data.tar.gz: 3ba0a0592b1dd997c6177f2ad654bc28a93b00aebd5f83720732b7edaf08200c43aae19ac8e6eae2bbe351147343a2a8cf38b6352ca9427a9f2ad35f1be7c36d
data/CHANGELOG.md CHANGED
@@ -3,6 +3,100 @@
3
3
  All notable changes to this project will be documented in this file. The
4
4
  project follows [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [0.11.0] - 2026-08-18
7
+
8
+ ### Added
9
+
10
+ - Add `PaperTrailDiff.analyze_scope`, which selects the roots to analyze from an
11
+ ActiveRecord relation or model class instead of an array the caller assembled,
12
+ in a fixed number of queries. Also reachable as
13
+ `PaperTrailDiff.analyze_many(scope: ..., limit: ...)`. Every other option
14
+ behaves as it does for `analyze_many`.
15
+ - `analyze_scope` returns a `ScopedAnalysis`, which destructures into the usual
16
+ analyses hash and the roots the relation could not reach. A relation's
17
+ conditions are evaluated against the live table, so a root destroyed during
18
+ the window cannot be tested against them at all, even though its history is
19
+ intact and the state it held at destruction may have matched. Those roots are
20
+ named rather than dropped, so a page auditing deletions is told where to look
21
+ instead of coming up short without saying so.
22
+ - Add `PaperTrailDiff.nested_changes`, which reports the keys that changed
23
+ inside an attribute the database stores whole, such as a JSON or jsonb column
24
+ or one holding JSON as text. An attribute diff could only say the blob
25
+ changed, leaving the caller to diff it again by hand. Paths are arrays rather
26
+ than dotted strings, because a JSON key may itself contain a dot. Arrays are
27
+ reported whole rather than by index, since their elements carry no identity
28
+ and a list that merely shifted would otherwise look changed throughout. A key
29
+ that was absent reads as `NestedComparator::ABSENT` rather than nil, because
30
+ `{"a": null}` and `{}` mean different things in JSON and an audit trail that
31
+ showed them alike would be lying about one of them. Additive: the existing
32
+ attribute diff is unchanged, and a pair that is not two readable structures
33
+ reports nothing.
34
+ - Raise `PaperTrailDiff::BatchLimitExceededError` when a relation selects more
35
+ roots than `limit:` allows. `limit:` is required for `analyze_scope`: root
36
+ selection moves into the gem, so the bound on how much work one page can ask
37
+ for moves with it. It refuses rather than truncating, because a report that is
38
+ quietly short is worse than one that fails.
39
+ - Accept `group: :transaction` on `activity_timeline` and `analyze(activity:
40
+ true)`, reporting one saved transaction as one step. A parent and its
41
+ children saved together produce a version each, so one deliberate action
42
+ arrived as several steps, and some of them read as empty even though a change
43
+ was made at that boundary: a version records the state before its own event,
44
+ so a child's new value is revealed only by a later version of that child, its
45
+ destroy version, or the live row, none of which exists yet inside the
46
+ transaction. The change therefore surfaced a step or more later, folded in
47
+ with whatever that step carried. A step belongs to the transaction of the
48
+ event that opens it, since that is the event whose change it reports; a step
49
+ therefore belongs to its `from_boundary`'s transaction, and consecutive steps
50
+ sharing one are the parts of a single save. Merging compares the group's
51
+ outer states rather than combining the diffs between them, so a field set and
52
+ restored inside one transaction correctly reports as unchanged. Off by
53
+ default.
54
+ - Expose `ActivityBoundary#transaction_id`.
55
+ - A boundary that records no transaction groups with nothing. PaperTrail leaves
56
+ the column nil outside a transaction and a custom version class need not
57
+ carry it at all, so treating those as one shared transaction would merge
58
+ unrelated history into a single step.
59
+
60
+ ### Changed
61
+
62
+ - Check that the generated Appraisal gemfiles match the `Gemfile`, in CI and in
63
+ `rake release:preflight`. Every other job runs against a generated gemfile, so
64
+ a dependency changed only in the `Gemfile` was one CI never installed: the
65
+ jobs passed having exercised the old version.
66
+
67
+ ### Fixed
68
+
69
+ - Resolve "does this model record history" through one predicate,
70
+ `Support.versioned?`. Three copies existed and one of them tested only
71
+ `respond_to?(:paper_trail)`, which PaperTrail defines on every ActiveRecord
72
+ model and which therefore answers true for models that never called
73
+ `has_paper_trail`. Nothing reached that copy with an unversioned model, since
74
+ the traversal preparer rejects those first, so no released behaviour changed --
75
+ but the check looked right while being unable to fail, and reading history
76
+ through it would have raised at the version class rather than at the question.
77
+
78
+ ## [0.10.0] - 2026-08-15
79
+
80
+ ### Added
81
+
82
+ - Add `rake release:preflight`, which runs the full gate and then checks
83
+ everything that must hold before a tag: a clean working tree, a HEAD that
84
+ matches its upstream, an unused tag, a dated changelog section for the
85
+ version, and that the version is not already on RubyGems. It reports and
86
+ never tags, pushes, or publishes. An unreachable RubyGems is reported as
87
+ unknown rather than treated as "not published".
88
+ - Raise `PaperTrailDiff::UnversionedAssociationError` when a selected
89
+ association's target is not versioned and history is being reconstructed.
90
+ Such a comparison could only ever answer "nothing changed", which is a wrong
91
+ answer rather than an empty one. Live-to-live comparison reads current state
92
+ and is unaffected. `diagnose` now reports the same condition as an error
93
+ rather than a warning, so `ok?` no longer stays true for a comparison that
94
+ will raise.
95
+ - Document ActiveStorage attachments, which this reaches through:
96
+ `has_one_attached` points at models Rails owns and PaperTrail never versions,
97
+ so attachments are audited through a versioned model of your own that mirrors
98
+ the metadata onto ordinary columns.
99
+
6
100
  ## [0.9.0] - 2026-08-14
7
101
 
8
102
  ### Added
data/README.md CHANGED
@@ -152,6 +152,55 @@ that touches a record and its children in quick succession will manage it — so
152
152
  this is worth checking before trusting an association history. Recording
153
153
  versions at sub-second precision separates them.
154
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:
167
+
168
+ ```ruby
169
+ class DocumentRevision < ApplicationRecord
170
+ belongs_to :attachable, polymorphic: true
171
+ has_one_attached :file
172
+ has_paper_trail
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
194
+ 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
+
155
204
  ## Choosing an entry point
156
205
 
157
206
  | You need | Call |
@@ -305,6 +354,47 @@ argument, are loaded per root; other selected branches remain batched. Callers
305
354
  should still use an appropriate database transaction when all live queries
306
355
  must observe one atomic snapshot.
307
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 whole, not by index.** Their elements carry no identity, so
390
+ an insertion at the front would make every later index look changed. This is the
391
+ same rule the collection comparator follows for records it cannot identify.
392
+
393
+ **An absent key is not a null one.** `{"a": null}` and `{}` mean different things
394
+ in JSON, and an audit trail that showed them alike would be lying about one of
395
+ them, so a missing key reads as `PaperTrailDiff::NestedComparator::ABSENT`
396
+ rather than `nil`.
397
+
308
398
  ### Reuse already-preloaded current endpoints
309
399
 
310
400
  `compare`, `compare_many`, and `activity_timeline(..., to: record)` reload
@@ -396,6 +486,54 @@ value is the same `Analysis` that `analyze` returns for one record. A root with
396
486
  no versions inside the window gets an empty `Analysis` rather than raising, so a
397
487
  listing page needs no special case. Root identities must be unique.
398
488
 
489
+ ### Letting a relation choose the roots
490
+
491
+ Assembling that array means querying `versions` for which roots moved, filtering,
492
+ and loading the records — the same selection this gem already performs.
493
+ `analyze_scope` does it for you:
494
+
495
+ ```ruby
496
+ analyses, unreachable = PaperTrailDiff.analyze_scope(
497
+ Order.where(status: "open"),
498
+ within: Time.zone.parse("2026-08-01")...Time.zone.parse("2026-09-01"),
499
+ associations: [:line_items],
500
+ limit: 500
501
+ )
502
+ ```
503
+
504
+ `analyses` is the same frozen hash `analyze_many` returns. It is also available
505
+ as `PaperTrailDiff.analyze_many(scope: ..., limit: ...)`, and it accepts a model
506
+ class (`Order`) as readily as a relation. Every other option — `within:`,
507
+ `version_scope:`, `associations:`, `ignore:`, `activity:`, `close_on:` — behaves
508
+ exactly as it does for `analyze_many`, and the query cost stays flat in the
509
+ number of roots.
510
+
511
+ Two things to know before using it.
512
+
513
+ **`limit:` is required, and exceeding it raises.** Selection moves into the gem
514
+ here, so the bound on how much work one page can ask for has to move with it. It
515
+ refuses rather than truncating, because an audit report that is quietly short is
516
+ worse than one that fails.
517
+
518
+ **`unreachable` names roots the relation could not reach.** A relation's
519
+ conditions are evaluated against the live table, so a root destroyed during the
520
+ window cannot be tested against them at all — its history is intact, and the
521
+ state it held when it was destroyed may well have matched:
522
+
523
+ ```ruby
524
+ unreachable # => [["Order", "412"]]
525
+ ```
526
+
527
+ These are reported rather than dropped so that a page auditing deletions is told
528
+ where to look instead of silently coming up short. `analyze_many` requires live
529
+ records by design, so to analyze those roots reach for `activity_timeline`, which
530
+ reads history without needing current state.
531
+
532
+ Note also that a relation selects on **current** state, not on state during the
533
+ window. `where(status: "open")` means open *now*, which is a different set from
534
+ what was open while the window was open. If you need the historical population,
535
+ select on the version history instead and pass the records.
536
+
399
537
  ### Reporting on a subset of root mutations
400
538
 
401
539
  `version_scope:` narrows which root versions count as *selected mutations*, so a
@@ -616,6 +754,49 @@ boundary. Passing `to: article` is what removes the need to touch the parent
616
754
  after an ordinary versioned child mutation; current state is still never
617
755
  implicit.
618
756
 
757
+ ### Reporting one save as one step
758
+
759
+ A parent and its children saved together produce a version each, so one
760
+ deliberate action arrives as several steps. `group: :transaction` reports it as
761
+ one:
762
+
763
+ ```ruby
764
+ PaperTrailDiff.activity_timeline(
765
+ article, from: :first, to: article,
766
+ associations: [:comments], group: :transaction
767
+ )
768
+ ```
769
+
770
+ Given a single transaction that retitled an article, revised one comment, and
771
+ added another, the ungrouped timeline reports three steps — the title change,
772
+ then a step that reads as empty, then the comment changes. Grouped, it reports
773
+ one step carrying the title *and* the comment changes, credited to whoever made
774
+ the save. Off by default; also accepted by `analyze(activity: true)`.
775
+
776
+ The middle step reads as empty for a reason worth knowing, because it is the
777
+ same rule the rest of this document turns on. A version records the state
778
+ *before* its own event, so a child's new value is revealed only by something
779
+ later: a further version of that child, its destroy version, or the live row.
780
+ Inside a transaction there is usually none of those yet, so a change made at
781
+ that boundary surfaces a step or more afterwards, folded in with whatever that
782
+ step carried. Nothing is lost — but "when" and "with what" both read wrong.
783
+
784
+ Two properties this relies on:
785
+
786
+ **A step belongs to the transaction of the event that opens it.** That is the
787
+ event whose change the step reports, so grouping on the closing boundary instead
788
+ would credit each change to whoever made the next one.
789
+
790
+ **Merging compares the group's outer states**, rather than combining the diffs
791
+ between them. A field set and then restored inside one transaction has not
792
+ changed, and only comparing the endpoints can say so.
793
+
794
+ A boundary that records no transaction groups with nothing. PaperTrail leaves
795
+ `transaction_id` nil outside a transaction, and a custom version class need not
796
+ carry the column at all; treating those as one shared transaction would merge
797
+ unrelated history into a single step. `ActivityBoundary#transaction_id` exposes
798
+ what a boundary recorded.
799
+
619
800
  ### Reading the state behind a step
620
801
 
621
802
  A step's diff carries what changed. A renderer often needs what did *not* — to
@@ -12,6 +12,10 @@ module PaperTrailDiff
12
12
  attr_reader :event #: String?
13
13
  attr_reader :whodunnit #: untyped
14
14
  attr_reader :record #: RecordReference
15
+ # The transaction the version was written in, when PaperTrail recorded one.
16
+ # Several versions saved together share it, which is what lets a timeline
17
+ # report one save as one step rather than as its parts.
18
+ attr_reader :transaction_id #: untyped
15
19
 
16
20
  class << self
17
21
  #: (untyped) -> ActivityBoundary
@@ -23,7 +27,8 @@ module PaperTrailDiff
23
27
  item_id: version.item_id,
24
28
  recorded_at: version.created_at,
25
29
  event: version.event,
26
- whodunnit: version.whodunnit
30
+ whodunnit: version.whodunnit,
31
+ transaction_id: transaction_id(version)
27
32
  )
28
33
  end
29
34
 
@@ -50,12 +55,21 @@ module PaperTrailDiff
50
55
  item_id: version.item_id,
51
56
  recorded_at: version.created_at,
52
57
  event: version.event,
53
- whodunnit: version.whodunnit
58
+ whodunnit: version.whodunnit,
59
+ transaction_id: transaction_id(version)
54
60
  )
55
61
  end
62
+
63
+ # A custom version class need not carry the column, and PaperTrail leaves
64
+ # it nil outside a transaction. Both read as "no transaction here", which
65
+ # groups with nothing.
66
+ #: (untyped) -> untyped
67
+ def transaction_id(version)
68
+ version.transaction_id if version.respond_to?(:transaction_id)
69
+ end
56
70
  end
57
71
 
58
- #: (kind: Symbol, version_id: untyped, item_type: untyped, item_id: untyped, recorded_at: untyped, ?event: untyped, ?whodunnit: untyped) -> void
72
+ #: (kind: Symbol, version_id: untyped, item_type: untyped, item_id: untyped, recorded_at: untyped, ?event: untyped, ?whodunnit: untyped, ?transaction_id: untyped) -> void
59
73
  def initialize( # rubocop:disable Metrics/ParameterLists
60
74
  kind:,
61
75
  version_id:,
@@ -63,7 +77,8 @@ module PaperTrailDiff
63
77
  item_id:,
64
78
  recorded_at:,
65
79
  event: nil,
66
- whodunnit: nil
80
+ whodunnit: nil,
81
+ transaction_id: nil
67
82
  )
68
83
  @kind = kind
69
84
  @version_id = Support.immutable_copy(version_id)
@@ -72,6 +87,7 @@ module PaperTrailDiff
72
87
  @recorded_at = Support.immutable_copy(recorded_at)
73
88
  @event = Support.immutable_copy(event&.to_s)
74
89
  @whodunnit = Support.immutable_copy(whodunnit)
90
+ @transaction_id = Support.immutable_copy(transaction_id)
75
91
  @record = RecordReference.new(type: @item_type, id: @item_id)
76
92
  freeze
77
93
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Shared by the two activity builders. They differ in how they choose
6
+ # boundaries -- one from an explicit version range, one from a wall-clock
7
+ # window -- but a transaction has to collapse the same way in both, or the
8
+ # same history would read differently depending on how it was asked for.
9
+ module ActivityGrouping
10
+ private
11
+
12
+ #: () -> bool
13
+ def grouping?
14
+ @group == :transaction
15
+ end
16
+
17
+ # Applied last, to finished steps, so grouping sees the same timeline the
18
+ # caller would otherwise have received.
19
+ #: (Array[ActivityStep]) -> Array[ActivityStep]
20
+ def group_steps(steps)
21
+ return steps unless grouping?
22
+
23
+ ActivityTransactionGrouper.new(steps, retain: @snapshots).call
24
+ end
25
+ end
26
+ end
@@ -4,9 +4,15 @@
4
4
  module PaperTrailDiff
5
5
  # Compares adjacent root and selected-descendant activity boundaries.
6
6
  class ActivityTimelineBuilder
7
- #: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool) -> void
8
- def initialize(record, range:, tree:, snapshotter:, snapshots: false)
7
+ include ActivityGrouping
8
+
9
+ #: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool, ?group: Symbol?) -> void
10
+ def initialize(record, range:, tree:, snapshotter:, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists
9
11
  @snapshots = snapshots
12
+ @group = group
13
+ # Merging a group compares its outer states, so the snapshots must survive
14
+ # the build even when the caller did not ask to keep them.
15
+ @retain = snapshots || grouping?
10
16
  @record = record
11
17
  @from = range.from
12
18
  @to = range.to
@@ -155,10 +161,10 @@ module PaperTrailDiff
155
161
  steps << ActivityStep.between(
156
162
  from_boundary: previous_boundary, to_boundary: final_boundary,
157
163
  from_snapshot: history.last_snapshot, to_snapshot: final_snapshot,
158
- retain: @snapshots
164
+ retain: @retain
159
165
  )
160
166
  end
161
- steps.freeze
167
+ group_steps(steps).freeze
162
168
  end
163
169
 
164
170
  # A destroyed root has no later version, but its own event states that
@@ -178,7 +184,7 @@ module PaperTrailDiff
178
184
  events,
179
185
  @snapshotter,
180
186
  current: current,
181
- snapshots: @snapshots
187
+ snapshots: @retain
182
188
  ).call
183
189
  end
184
190
 
@@ -205,7 +211,8 @@ module PaperTrailDiff
205
211
  range: @range,
206
212
  tree: @tree,
207
213
  snapshotter: @snapshotter,
208
- snapshots: @snapshots
214
+ snapshots: @snapshots,
215
+ group: @group
209
216
  )
210
217
  end
211
218
  end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Reports one saved transaction as one activity step.
6
+ #
7
+ # A parent and its children saved together produce a version each, and the
8
+ # timeline reports the gap between every pair of them. One deliberate action
9
+ # therefore arrives as several steps, none of which is the thing the person
10
+ # did.
11
+ #
12
+ # A version records the state before its own event. Two things follow.
13
+ #
14
+ # The change a step reports was made by the event that *opens* it, so a step
15
+ # belongs to the transaction of its `from_boundary`, and consecutive steps
16
+ # sharing one are the parts of a single save. Grouping on the closing boundary
17
+ # instead would credit each change to whoever made the next one.
18
+ #
19
+ # And a child's new value is revealed only by something later -- a further
20
+ # version of that child, its destroy version, or the live row. Inside a
21
+ # transaction there is usually none of those yet, so a step can read as empty
22
+ # while a change was in fact made at that boundary, and the change surfaces
23
+ # later, folded in with whatever that step carried. Grouping puts it back
24
+ # together with the save it belongs to.
25
+ #
26
+ # Merging compares the group's outer snapshots rather than combining the
27
+ # diffs between them. A field set and then restored inside one transaction
28
+ # has not changed, and only a comparison of the endpoints can say so.
29
+ #
30
+ # A boundary with no transaction groups with nothing. PaperTrail leaves the
31
+ # column nil outside a transaction, and a custom version class need not carry
32
+ # it at all; treating those as one shared transaction would merge unrelated
33
+ # history into a single step.
34
+ class ActivityTransactionGrouper
35
+ #: (Array[ActivityStep], retain: bool) -> void
36
+ def initialize(steps, retain:)
37
+ @steps = steps
38
+ @retain = retain
39
+ end
40
+
41
+ #: () -> Array[ActivityStep]
42
+ def call
43
+ grouped = [] #: Array[Array[ActivityStep]]
44
+ @steps.each do |step|
45
+ open_group = grouped.last
46
+ if open_group && continues?(open_group, step)
47
+ open_group << step
48
+ else
49
+ grouped << [step]
50
+ end
51
+ end
52
+ grouped.map { |group| merge(group) }
53
+ end
54
+
55
+ private
56
+
57
+ # @rbs @steps: Array[ActivityStep]
58
+ # @rbs @retain: bool
59
+
60
+ #: (Array[ActivityStep], ActivityStep) -> bool
61
+ def continues?(open_group, step)
62
+ transaction = step.from_boundary.transaction_id
63
+ return false if transaction.nil?
64
+
65
+ open_group.last.from_boundary.transaction_id == transaction
66
+ end
67
+
68
+ # Rebuilt rather than reused even when a group holds one step, so that every
69
+ # returned step retains its snapshots on the same terms.
70
+ #: (Array[ActivityStep]) -> ActivityStep
71
+ def merge(group)
72
+ first = group.first #: ActivityStep
73
+ last = group.last #: ActivityStep
74
+ ActivityStep.between(
75
+ from_boundary: first.from_boundary,
76
+ to_boundary: last.to_boundary,
77
+ from_snapshot: first.from_snapshot,
78
+ to_snapshot: last.to_snapshot,
79
+ retain: @retain
80
+ )
81
+ end
82
+ end
83
+ end
@@ -175,7 +175,9 @@ module PaperTrailDiff
175
175
  def inspect_versioned_model(model_class, path)
176
176
  return if model_versioned?(model_class)
177
177
 
178
- add_warning(
178
+ # An error rather than a warning: `ok?` must not stay true for a
179
+ # comparison that cannot work, and this one raises at runtime.
180
+ add_error(
179
181
  :unversioned_association_target,
180
182
  "#{model_class.name} does not appear to have PaperTrail enabled",
181
183
  path
@@ -196,7 +198,7 @@ module PaperTrailDiff
196
198
 
197
199
  #: (untyped) -> bool
198
200
  def model_versioned?(model_class)
199
- model_class.respond_to?(:paper_trail_options) && !model_class.paper_trail_options.nil?
201
+ Support.versioned?(model_class)
200
202
  end
201
203
 
202
204
  #: (untyped) -> bool
@@ -32,9 +32,17 @@ module PaperTrailDiff
32
32
  # Raised when versions sharing a timestamp cannot be ordered by their ids.
33
33
  class AmbiguousVersionOrderError < Error; end
34
34
 
35
+ # Raised when a relation selects more roots than the batch was told to analyze.
36
+ # Truncating instead would hand back a report that is short without saying so.
37
+ class BatchLimitExceededError < Error; end
38
+
35
39
  # Raised when a requested ActiveRecord association does not exist.
36
40
  class UnknownAssociationError < Error; end
37
41
 
42
+ # Raised when a selected association's target is not versioned, so its history
43
+ # cannot be reconstructed at all.
44
+ class UnversionedAssociationError < Error; end
45
+
38
46
  # Raised when a requested association macro is not supported.
39
47
  class UnsupportedAssociationError < Error; end
40
48