paper_trail_diff 0.10.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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -0
  3. data/README.md +132 -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 +1 -1
  9. data/lib/paper_trail_diff/errors.rb +4 -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 +1 -1
  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 +5 -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.rbs +58 -4
  32. metadata +14 -4
@@ -0,0 +1,54 @@
1
+ # Generated from lib/paper_trail_diff/activity_transaction_grouper.rb with RBS::Inline
2
+
3
+ module PaperTrailDiff
4
+ # Reports one saved transaction as one activity step.
5
+ #
6
+ # A parent and its children saved together produce a version each, and the
7
+ # timeline reports the gap between every pair of them. One deliberate action
8
+ # therefore arrives as several steps, none of which is the thing the person
9
+ # did.
10
+ #
11
+ # A version records the state before its own event. Two things follow.
12
+ #
13
+ # The change a step reports was made by the event that *opens* it, so a step
14
+ # belongs to the transaction of its `from_boundary`, and consecutive steps
15
+ # sharing one are the parts of a single save. Grouping on the closing boundary
16
+ # instead would credit each change to whoever made the next one.
17
+ #
18
+ # And a child's new value is revealed only by something later -- a further
19
+ # version of that child, its destroy version, or the live row. Inside a
20
+ # transaction there is usually none of those yet, so a step can read as empty
21
+ # while a change was in fact made at that boundary, and the change surfaces
22
+ # later, folded in with whatever that step carried. Grouping puts it back
23
+ # together with the save it belongs to.
24
+ #
25
+ # Merging compares the group's outer snapshots rather than combining the
26
+ # diffs between them. A field set and then restored inside one transaction
27
+ # has not changed, and only a comparison of the endpoints can say so.
28
+ #
29
+ # A boundary with no transaction groups with nothing. PaperTrail leaves the
30
+ # column nil outside a transaction, and a custom version class need not carry
31
+ # it at all; treating those as one shared transaction would merge unrelated
32
+ # history into a single step.
33
+ class ActivityTransactionGrouper
34
+ # : (Array[ActivityStep], retain: bool) -> void
35
+ def initialize: (Array[ActivityStep], retain: bool) -> void
36
+
37
+ # : () -> Array[ActivityStep]
38
+ def call: () -> Array[ActivityStep]
39
+
40
+ private
41
+
42
+ @retain: bool
43
+
44
+ @steps: Array[ActivityStep]
45
+
46
+ # : (Array[ActivityStep], ActivityStep) -> bool
47
+ def continues?: (Array[ActivityStep], ActivityStep) -> bool
48
+
49
+ # Rebuilt rather than reused even when a group holds one step, so that every
50
+ # returned step retains its snapshots on the same terms.
51
+ # : (Array[ActivityStep]) -> ActivityStep
52
+ def merge: (Array[ActivityStep]) -> ActivityStep
53
+ end
54
+ end
@@ -41,6 +41,11 @@ module PaperTrailDiff
41
41
  class AmbiguousVersionOrderError < Error
42
42
  end
43
43
 
44
+ # Raised when a relation selects more roots than the batch was told to analyze.
45
+ # Truncating instead would hand back a report that is short without saying so.
46
+ class BatchLimitExceededError < Error
47
+ end
48
+
44
49
  # Raised when a requested ActiveRecord association does not exist.
45
50
  class UnknownAssociationError < Error
46
51
  end
@@ -0,0 +1,68 @@
1
+ # Generated from lib/paper_trail_diff/nested_comparator.rb with RBS::Inline
2
+
3
+ module PaperTrailDiff
4
+ # Compares the inside of a value a database column holds whole.
5
+ #
6
+ # A JSON or jsonb column reifies to one Hash, so an ordinary attribute diff
7
+ # can only say that the blob changed. This says which keys changed, leaving
8
+ # the surrounding diff untouched.
9
+ #
10
+ # Three decisions worth knowing about.
11
+ #
12
+ # Paths are arrays, not dotted strings. A JSON key may contain a dot -- host
13
+ # names and locales routinely do -- and joining would make `a.b` ambiguous
14
+ # between one key and two.
15
+ #
16
+ # Arrays are leaves. Their elements carry no identity, so an insertion at the
17
+ # front makes every later index look changed; reporting "element 2 changed"
18
+ # would be confidently wrong about a list that merely shifted. The whole array
19
+ # is reported as one change, which is the same rule the collection comparator
20
+ # follows for records it cannot identify.
21
+ #
22
+ # An absent key is not a null one. `{"a": null}` and `{}` mean different
23
+ # things in JSON and an audit trail that conflated them would be lying about
24
+ # one of them, so absence is its own value rather than nil.
25
+ class NestedComparator
26
+ # Stands in for a key that was not there at all.
27
+ ABSENT: untyped
28
+
29
+ def inspect: () -> untyped
30
+
31
+ def to_s: () -> untyped
32
+
33
+ # : (untyped, untyped) -> Hash[Array[String], ValueChange]
34
+ def self.call: (untyped, untyped) -> Hash[Array[String], ValueChange]
35
+
36
+ # : (untyped, untyped) -> void
37
+ def initialize: (untyped, untyped) -> void
38
+
39
+ # Returns the changed paths, or an empty hash when the pair is not two
40
+ # structures this can look inside. An empty result therefore means "nothing
41
+ # to report at this depth", and the caller still has the whole-value change.
42
+ # : () -> Hash[Array[String], ValueChange]
43
+ def call: () -> Hash[Array[String], ValueChange]
44
+
45
+ private
46
+
47
+ @from_value: untyped
48
+
49
+ @to_value: untyped
50
+
51
+ # Both sides have to be readable as a Hash for a nested answer to mean
52
+ # anything. A column that held text on one side and JSON on the other
53
+ # changed wholesale, and saying so is the accurate report.
54
+ # : () -> [Hash[untyped, untyped]?, Hash[untyped, untyped]?]
55
+ def structures: () -> [ Hash[untyped, untyped]?, Hash[untyped, untyped]? ]
56
+
57
+ # : (untyped) -> Hash[untyped, untyped]?
58
+ def structure: (untyped) -> Hash[untyped, untyped]?
59
+
60
+ # : (Hash[untyped, untyped], Hash[untyped, untyped], Array[String], Hash[Array[String], ValueChange]) -> void
61
+ def walk: (Hash[untyped, untyped], Hash[untyped, untyped], Array[String], Hash[Array[String], ValueChange]) -> void
62
+
63
+ # Sorted so a report reads the same twice, and stringified because a hash
64
+ # loaded from JSON and one built in Ruby can key the same field differently.
65
+ # : (Hash[untyped, untyped], Hash[untyped, untyped]) -> Array[untyped]
66
+ def keys: (Hash[untyped, untyped], Hash[untyped, untyped]) -> Array[untyped]
67
+ end
68
+ end
@@ -22,16 +22,23 @@ module PaperTrailDiff
22
22
  # : (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?) -> Array[Step]
23
23
  def timeline: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?) -> Array[Step]
24
24
 
25
- # : (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Array[ActivityStep]
26
- def activity_timeline: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Array[ActivityStep]
25
+ # : (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?) -> Array[ActivityStep]
26
+ def activity_timeline: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?) -> Array[ActivityStep]
27
27
 
28
- # : (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Analysis
29
- def analyze: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Analysis
28
+ # : (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?) -> Analysis
29
+ def analyze: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?) -> Analysis
30
30
 
31
31
  # Analyzes many roots over one shared range, preparing their history once.
32
32
  # : (Array[untyped], within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Hash[identity, Analysis]
33
33
  def analyze_many: (Array[untyped], within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Hash[identity, Analysis]
34
34
 
35
+ # Selects the roots from a relation before running the ordinary batch, so a
36
+ # caller reporting on a population does not have to rediscover which of its
37
+ # members changed. The roots the relation could not reach come back named
38
+ # rather than dropped -- see `PaperTrailDiff.analyze_scope`.
39
+ # : (untyped, limit: Integer?, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> ScopedAnalysis
40
+ def analyze_scope: (untyped, limit: Integer?, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> ScopedAnalysis
41
+
35
42
  private
36
43
 
37
44
  @activity_snapshotter: ActivitySnapshotProvider
@@ -56,8 +63,8 @@ module PaperTrailDiff
56
63
 
57
64
  @traversal_preparer: TraversalPreparer
58
65
 
59
- # : (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped, ?snapshots: bool) -> Analysis
60
- def analyze_activity: (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped, ?snapshots: bool) -> Analysis
66
+ # : (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped, ?snapshots: bool, ?group: Symbol?) -> Analysis
67
+ def analyze_activity: (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped, ?snapshots: bool, ?group: Symbol?) -> Analysis
61
68
 
62
69
  # Association membership is resolved by timestamp, so endpoints sharing one
63
70
  # cannot be told apart and any association change between them is invisible.
@@ -89,8 +96,8 @@ module PaperTrailDiff
89
96
  # : () -> ActivitySnapshotProvider
90
97
  def build_activity_snapshotter: () -> ActivitySnapshotProvider
91
98
 
92
- # : (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped, ?snapshots: bool) -> ActivityTimelineBuilder
93
- def activity_builder: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped, ?snapshots: bool) -> ActivityTimelineBuilder
99
+ # : (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped, ?snapshots: bool, ?group: Symbol?) -> ActivityTimelineBuilder
100
+ def activity_builder: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped, ?snapshots: bool, ?group: Symbol?) -> ActivityTimelineBuilder
94
101
 
95
102
  # : (untyped) -> void
96
103
  def reject_live_habtm_activity!: (untyped) -> void
@@ -0,0 +1,25 @@
1
+ # Generated from lib/paper_trail_diff/scoped_analysis.rb with RBS::Inline
2
+
3
+ module PaperTrailDiff
4
+ # What the relation form of `analyze_many` returns: the analyses, plus the
5
+ # roots the relation could not reach.
6
+ #
7
+ # `to_ary` is defined so the pair destructures, which keeps the common case
8
+ # reading like the plain Hash the record form returns:
9
+ #
10
+ # analyses, unreachable = PaperTrailDiff.analyze_many(scope: ..., limit: 500)
11
+ #
12
+ # It deliberately does not pretend to be a Hash beyond that. Somebody
13
+ # iterating this object should have to decide which half they meant.
14
+ class ScopedAnalysis
15
+ attr_reader analyses: Hash[Array[String], Analysis]
16
+
17
+ attr_reader unreachable: Array[Array[String]]
18
+
19
+ # : (analyses: Hash[Array[String], Analysis], unreachable: Array[Array[String]]) -> void
20
+ def initialize: (analyses: Hash[Array[String], Analysis], unreachable: Array[Array[String]]) -> void
21
+
22
+ # : () -> [Hash[Array[String], Analysis], Array[Array[String]]]
23
+ def to_ary: () -> [ Hash[Array[String], Analysis], Array[Array[String]] ]
24
+ end
25
+ end
@@ -0,0 +1,93 @@
1
+ # Generated from lib/paper_trail_diff/scoped_root_selection.rb with RBS::Inline
2
+
3
+ module PaperTrailDiff
4
+ # Chooses the roots a batch will analyze from a relation instead of from an
5
+ # array the caller assembled, so a reporting page does not reimplement the
6
+ # root selection this gem already performs.
7
+ #
8
+ # Two populations are deliberately kept apart, because conflating them would
9
+ # make the report wrong in a way nothing announces:
10
+ #
11
+ # - A root whose live row does not satisfy the relation is filtered out. That
12
+ # is what the caller asked for, and it needs no reporting.
13
+ # - A root whose live row is gone cannot be filtered at all. The relation's
14
+ # conditions read the live table, and a destroyed root has nothing there to
15
+ # read -- even though its history is intact, and the state it held when it
16
+ # was destroyed may well have satisfied those conditions. Reifying every
17
+ # candidate to find out would cost the batched query plan this class exists
18
+ # to provide.
19
+ #
20
+ # So the second population is returned by name rather than dropped. A caller
21
+ # that does not care can ignore it; one auditing deletions is told where to
22
+ # look instead of silently coming up short.
23
+ #
24
+ # Note that a relation filters on current state, not on state during the
25
+ # window. `where(status: 'published')` selects what is published now, which is
26
+ # not the same set as what was published while the window was open.
27
+ class ScopedRootSelection
28
+ # A plain class rather than Data.define, which arrived in Ruby 3.2 while this
29
+ # gem supports 3.1.
30
+ class Result
31
+ attr_reader records: Array[untyped]
32
+
33
+ attr_reader unreachable: Array[Array[String]]
34
+
35
+ # : (records: Array[untyped], unreachable: Array[Array[String]]) -> void
36
+ def initialize: (records: Array[untyped], unreachable: Array[Array[String]]) -> void
37
+ end
38
+
39
+ # : (untyped, time_range: TimeRange?, limit: Integer) -> void
40
+ def initialize: (untyped, time_range: TimeRange?, limit: Integer) -> void
41
+
42
+ # : () -> Result
43
+ def call: () -> Result
44
+
45
+ private
46
+
47
+ @limit: Integer
48
+
49
+ @scope: untyped
50
+
51
+ @time_range: TimeRange?
52
+
53
+ # Accepts a model class as readily as a relation: `Article` and
54
+ # `Article.where(...)` both name a population, and `all` is what makes them
55
+ # the same kind of thing.
56
+ # : (untyped) -> untyped
57
+ def normalize_scope: (untyped) -> untyped
58
+
59
+ # : (Integer) -> Integer
60
+ def validate_limit: (Integer) -> Integer
61
+
62
+ # Every root whose history moved inside the window, destroyed ones included.
63
+ # This is the gem's own notion of "which roots changed", answered from the
64
+ # version table alone so that it does not depend on rows still existing.
65
+ # : () -> Array[String]
66
+ def versioned_ids: () -> Array[String]
67
+
68
+ # Which of those candidates still have a row, asked without the relation's
69
+ # conditions so that "filtered out" and "no longer exists" stay separable.
70
+ # : (Array[String]) -> Array[String]
71
+ def live_ids: (Array[String]) -> Array[String]
72
+
73
+ # Loading one past the limit is what turns an oversized page into an error
74
+ # rather than a silently truncated report.
75
+ # : (Array[String]) -> Array[untyped]
76
+ def selected_records: (Array[String]) -> Array[untyped]
77
+
78
+ # : (String) -> Array[String]
79
+ def identity: (String) -> Array[String]
80
+
81
+ # : () -> String
82
+ def item_type: () -> String
83
+
84
+ # : () -> untyped
85
+ def base_class: () -> untyped
86
+
87
+ # : () -> untyped
88
+ def primary_key: () -> untyped
89
+
90
+ # : () -> untyped
91
+ def version_class: () -> untyped
92
+ end
93
+ end
@@ -32,6 +32,17 @@ module PaperTrailDiff
32
32
  # : (Array[untyped]) -> Array[untyped]?
33
33
  def self?.tied_timestamp_pair: (Array[untyped]) -> Array[untyped]?
34
34
 
35
+ # Whether a model records history at all.
36
+ #
37
+ # PaperTrail defines `paper_trail` on every ActiveRecord model, so asking
38
+ # whether a class responds to it says nothing -- it is true for models that
39
+ # never called `has_paper_trail`, and reading history from one of those fails
40
+ # at the version class rather than at the question. Only configured options
41
+ # distinguish the two, which is why this lives in one place: the predicate is
42
+ # easy to write in a form that looks right and always answers true.
43
+ # : (untyped) -> bool
44
+ def self?.versioned?: (untyped) -> bool
45
+
35
46
  # : (untyped) -> bool
36
47
  def self?.sequential_id?: (untyped) -> bool
37
48
 
@@ -3,8 +3,10 @@
3
3
  module PaperTrailDiff
4
4
  # Builds activity views for mutations selected by a wall-clock range.
5
5
  class TimeActivityTimelineBuilder
6
- # : (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool) -> void
7
- def initialize: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool) -> void
6
+ include ActivityGrouping
7
+
8
+ # : (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool, ?group: Symbol?) -> void
9
+ def initialize: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool, ?group: Symbol?) -> void
8
10
 
9
11
  # : () -> Array[ActivityStep]
10
12
  def build: () -> Array[ActivityStep]
@@ -27,14 +27,47 @@ module PaperTrailDiff
27
27
  def self.activity_timeline: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Array[ActivityStep]
28
28
 
29
29
  # Builds an endpoint diff and root-checkpoint timeline while normalizing each version once.
30
- # : (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Analysis
31
- def self.analyze: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Analysis
30
+ # : (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?) -> Analysis
31
+ def self.analyze: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?) -> Analysis
32
32
 
33
33
  # Analyzes many roots over one shared time window, preparing their selected
34
34
  # history once for the batch instead of once per record. Roots with no
35
35
  # versions in the window return an empty `Analysis`.
36
- # : (Array[untyped], ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Hash[identity, Analysis]
37
- def self.analyze_many: (Array[untyped], ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Hash[identity, Analysis]
36
+ #
37
+ # Pass `records` to analyze a list you assembled, which returns a Hash keyed
38
+ # by identity. Pass `scope:` with a `limit:` to have the roots selected for
39
+ # you from a relation, which returns a `ScopedAnalysis` -- the same Hash,
40
+ # plus the roots the relation could not reach. See `analyze_scope` for why
41
+ # that second collection exists.
42
+ # : (?Array[untyped]?, ?scope: untyped, ?limit: Integer?, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> (Hash[identity, Analysis] | ScopedAnalysis)
43
+ def self.analyze_many: (?Array[untyped]?, ?scope: untyped, ?limit: Integer?, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> (Hash[identity, Analysis] | ScopedAnalysis)
44
+
45
+ # Analyzes every root the relation reaches whose history moved inside the
46
+ # window, selecting them in a fixed number of queries rather than making the
47
+ # caller rediscover them.
48
+ #
49
+ # `limit:` is required and exceeding it raises. Selection moves into the gem
50
+ # here, so the bound on how much work a page can ask for has to move with
51
+ # it, and a truncated audit report is worse than a refused one.
52
+ #
53
+ # Returns a `ScopedAnalysis`, which destructures:
54
+ #
55
+ # analyses, unreachable = PaperTrailDiff.analyze_scope(
56
+ # Article.where(status: 'published'), within: july, limit: 500
57
+ # )
58
+ #
59
+ # `unreachable` names roots that changed in the window but have no live row
60
+ # left. A relation's conditions are evaluated against the live table, so a
61
+ # destroyed root cannot be tested against them at all -- its history is
62
+ # intact and the state it held at destruction may well have matched. Those
63
+ # roots are reported rather than dropped so that a page auditing deletions
64
+ # is told where to look instead of quietly coming up short.
65
+ #
66
+ # Note also that a relation selects on current state, not on state during
67
+ # the window: `where(status: 'published')` means published *now*, which is a
68
+ # different set from what was published while the window was open.
69
+ # : (untyped, limit: Integer?, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> ScopedAnalysis
70
+ def self.analyze_scope: (untyped, limit: Integer?, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> ScopedAnalysis
38
71
 
39
72
  # Returns the association macros this release can normalize.
40
73
  # : () -> Array[Symbol]
@@ -47,6 +80,27 @@ module PaperTrailDiff
47
80
  # Reports known reconstruction hazards without mutating application state.
48
81
  # : (untyped, untyped, ?associations: Array[String | Symbol]) -> DiagnosticReport
49
82
  def self.diagnose: (untyped, untyped, ?associations: Array[String | Symbol]) -> DiagnosticReport
83
+
84
+ # Looks inside an attribute the database stores whole, such as a JSON or
85
+ # jsonb column, and reports which keys changed.
86
+ #
87
+ # change = diff.attributes.fetch('config')
88
+ # PaperTrailDiff.nested_changes(change)
89
+ # # => { ['theme'] => <from "dark" to "light">,
90
+ # # ['limits', 'max'] => <from 10 to 20> }
91
+ #
92
+ # Accepts the `ValueChange` an attribute diff already produced, or a bare
93
+ # pair. Returns an empty hash when the pair is not two readable structures,
94
+ # which is the honest answer: a column that held text on one side and JSON
95
+ # on the other changed wholesale, and the caller still has that change.
96
+ #
97
+ # Paths are arrays because a JSON key may contain a dot. Arrays are reported
98
+ # whole rather than by index, since their elements carry no identity and a
99
+ # list that merely shifted would otherwise look changed throughout. A key
100
+ # that was absent reads as `NestedComparator::ABSENT` rather than nil, which
101
+ # JSON uses for a present null.
102
+ # : (untyped, ?untyped) -> Hash[Array[String], ValueChange]
103
+ def self.nested_changes: (untyped, ?untyped) -> Hash[Array[String], ValueChange]
50
104
  end
51
105
 
52
106
  module PaperTrailDiff
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: paper_trail_diff
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex Williams
@@ -55,6 +55,7 @@ files:
55
55
  - lib/paper_trail_diff/activity_event_record_resolver.rb
56
56
  - lib/paper_trail_diff/activity_event_route_finder.rb
57
57
  - lib/paper_trail_diff/activity_event_snapshot_refresher.rb
58
+ - lib/paper_trail_diff/activity_grouping.rb
58
59
  - lib/paper_trail_diff/activity_history.rb
59
60
  - lib/paper_trail_diff/activity_range.rb
60
61
  - lib/paper_trail_diff/activity_relationship.rb
@@ -63,6 +64,7 @@ files:
63
64
  - lib/paper_trail_diff/activity_snapshot_delta.rb
64
65
  - lib/paper_trail_diff/activity_snapshot_sequence.rb
65
66
  - lib/paper_trail_diff/activity_timeline_builder.rb
67
+ - lib/paper_trail_diff/activity_transaction_grouper.rb
66
68
  - lib/paper_trail_diff/activity_version_collector.rb
67
69
  - lib/paper_trail_diff/analysis.rb
68
70
  - lib/paper_trail_diff/analysis_batch.rb
@@ -89,6 +91,7 @@ files:
89
91
  - lib/paper_trail_diff/live_endpoint_batch_loader.rb
90
92
  - lib/paper_trail_diff/live_endpoint_provider.rb
91
93
  - lib/paper_trail_diff/live_graph_collector.rb
94
+ - lib/paper_trail_diff/nested_comparator.rb
92
95
  - lib/paper_trail_diff/paper_trail_adapter.rb
93
96
  - lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb
94
97
  - lib/paper_trail_diff/prepared_association_reifier.rb
@@ -98,6 +101,8 @@ files:
98
101
  - lib/paper_trail_diff/prepared_record_index.rb
99
102
  - lib/paper_trail_diff/root_version_plan.rb
100
103
  - lib/paper_trail_diff/root_version_selection.rb
104
+ - lib/paper_trail_diff/scoped_analysis.rb
105
+ - lib/paper_trail_diff/scoped_root_selection.rb
101
106
  - lib/paper_trail_diff/snapshot.rb
102
107
  - lib/paper_trail_diff/snapshot_normalizer.rb
103
108
  - lib/paper_trail_diff/snapshot_traversal.rb
@@ -132,6 +137,7 @@ files:
132
137
  - sig/generated/paper_trail_diff/activity_event_record_resolver.rbs
133
138
  - sig/generated/paper_trail_diff/activity_event_route_finder.rbs
134
139
  - sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs
140
+ - sig/generated/paper_trail_diff/activity_grouping.rbs
135
141
  - sig/generated/paper_trail_diff/activity_history.rbs
136
142
  - sig/generated/paper_trail_diff/activity_range.rbs
137
143
  - sig/generated/paper_trail_diff/activity_relationship.rbs
@@ -140,6 +146,7 @@ files:
140
146
  - sig/generated/paper_trail_diff/activity_snapshot_delta.rbs
141
147
  - sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs
142
148
  - sig/generated/paper_trail_diff/activity_timeline_builder.rbs
149
+ - sig/generated/paper_trail_diff/activity_transaction_grouper.rbs
143
150
  - sig/generated/paper_trail_diff/activity_version_collector.rbs
144
151
  - sig/generated/paper_trail_diff/analysis.rbs
145
152
  - sig/generated/paper_trail_diff/analysis_batch.rbs
@@ -166,6 +173,7 @@ files:
166
173
  - sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs
167
174
  - sig/generated/paper_trail_diff/live_endpoint_provider.rbs
168
175
  - sig/generated/paper_trail_diff/live_graph_collector.rbs
176
+ - sig/generated/paper_trail_diff/nested_comparator.rbs
169
177
  - sig/generated/paper_trail_diff/paper_trail_adapter.rbs
170
178
  - sig/generated/paper_trail_diff/preloaded_endpoint_batch_loader.rbs
171
179
  - sig/generated/paper_trail_diff/prepared_association_reifier.rbs
@@ -175,6 +183,8 @@ files:
175
183
  - sig/generated/paper_trail_diff/prepared_record_index.rbs
176
184
  - sig/generated/paper_trail_diff/root_version_plan.rbs
177
185
  - sig/generated/paper_trail_diff/root_version_selection.rbs
186
+ - sig/generated/paper_trail_diff/scoped_analysis.rbs
187
+ - sig/generated/paper_trail_diff/scoped_root_selection.rbs
178
188
  - sig/generated/paper_trail_diff/snapshot.rbs
179
189
  - sig/generated/paper_trail_diff/snapshot_normalizer.rbs
180
190
  - sig/generated/paper_trail_diff/snapshot_traversal.rbs
@@ -202,11 +212,11 @@ licenses:
202
212
  metadata:
203
213
  allowed_push_host: https://rubygems.org
204
214
  bug_tracker_uri: https://github.com/aheathwilliams/paper_trail_diff/issues
205
- changelog_uri: https://github.com/aheathwilliams/paper_trail_diff/blob/v0.10.0/CHANGELOG.md
206
- documentation_uri: https://github.com/aheathwilliams/paper_trail_diff/blob/v0.10.0/README.md
215
+ changelog_uri: https://github.com/aheathwilliams/paper_trail_diff/blob/v0.11.0/CHANGELOG.md
216
+ documentation_uri: https://github.com/aheathwilliams/paper_trail_diff/blob/v0.11.0/README.md
207
217
  homepage_uri: https://github.com/aheathwilliams/paper_trail_diff
208
218
  rubygems_mfa_required: 'true'
209
- source_code_uri: https://github.com/aheathwilliams/paper_trail_diff/tree/v0.10.0
219
+ source_code_uri: https://github.com/aheathwilliams/paper_trail_diff/tree/v0.11.0
210
220
  rdoc_options: []
211
221
  require_paths:
212
222
  - lib