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
@@ -24,6 +24,7 @@ require_relative 'paper_trail_diff/snapshot_traversal'
24
24
  require_relative 'paper_trail_diff/association_diff_traversal'
25
25
  require_relative 'paper_trail_diff/traversal'
26
26
  require_relative 'paper_trail_diff/collection_comparator'
27
+ require_relative 'paper_trail_diff/nested_comparator'
27
28
  require_relative 'paper_trail_diff/engine'
28
29
  require_relative 'paper_trail_diff/historical_association_reifier'
29
30
  require_relative 'paper_trail_diff/prepared_record_index'
@@ -59,10 +60,14 @@ require_relative 'paper_trail_diff/activity_event_snapshot_refresher'
59
60
  require_relative 'paper_trail_diff/activity_root_snapshot_refresher'
60
61
  require_relative 'paper_trail_diff/branch_snapshot_refresher'
61
62
  require_relative 'paper_trail_diff/activity_boundary'
63
+ require_relative 'paper_trail_diff/activity_grouping'
64
+ require_relative 'paper_trail_diff/activity_transaction_grouper'
62
65
  require_relative 'paper_trail_diff/step'
63
66
  require_relative 'paper_trail_diff/analysis'
64
67
  require_relative 'paper_trail_diff/activity_root_steps'
65
68
  require_relative 'paper_trail_diff/analysis_batch'
69
+ require_relative 'paper_trail_diff/scoped_analysis'
70
+ require_relative 'paper_trail_diff/scoped_root_selection'
66
71
  require_relative 'paper_trail_diff/batched_root_analyzer'
67
72
  require_relative 'paper_trail_diff/version_range'
68
73
  require_relative 'paper_trail_diff/version_sequence_diagnostics'
@@ -181,7 +186,8 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
181
186
  reload_live_endpoints: true,
182
187
  version_scope: nil,
183
188
  close_on: nil,
184
- snapshots: false
189
+ snapshots: false,
190
+ group: nil
185
191
  )
186
192
  PaperTrailAdapter.new(
187
193
  associations: associations,
@@ -194,12 +200,13 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
194
200
  within: within,
195
201
  version_scope: version_scope,
196
202
  close_on: close_on,
203
+ group: group,
197
204
  snapshots: snapshots
198
205
  )
199
206
  end
200
207
 
201
208
  # Builds an endpoint diff and root-checkpoint timeline while normalizing each version once.
202
- #: (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
209
+ #: (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
203
210
  def analyze( # rubocop:disable Metrics/ParameterLists
204
211
  record,
205
212
  from: nil,
@@ -210,7 +217,8 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
210
217
  activity: false,
211
218
  version_scope: nil,
212
219
  close_on: nil,
213
- snapshots: false
220
+ snapshots: false,
221
+ group: nil
214
222
  )
215
223
  PaperTrailAdapter.new(associations: associations, ignore: ignore).analyze(
216
224
  record,
@@ -220,16 +228,25 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
220
228
  activity: activity,
221
229
  version_scope: version_scope,
222
230
  close_on: close_on,
223
- snapshots: snapshots
231
+ snapshots: snapshots,
232
+ group: group
224
233
  )
225
234
  end
226
235
 
227
236
  # Analyzes many roots over one shared time window, preparing their selected
228
237
  # history once for the batch instead of once per record. Roots with no
229
238
  # versions in the window return an empty `Analysis`.
230
- #: (Array[untyped], ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Hash[identity, Analysis]
239
+ #
240
+ # Pass `records` to analyze a list you assembled, which returns a Hash keyed
241
+ # by identity. Pass `scope:` with a `limit:` to have the roots selected for
242
+ # you from a relation, which returns a `ScopedAnalysis` -- the same Hash,
243
+ # plus the roots the relation could not reach. See `analyze_scope` for why
244
+ # that second collection exists.
245
+ #: (?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)
231
246
  def analyze_many( # rubocop:disable Metrics/ParameterLists
232
- records,
247
+ records = nil,
248
+ scope: nil,
249
+ limit: nil,
233
250
  within: nil,
234
251
  associations: [],
235
252
  ignore: DEFAULT_IGNORED_ATTRIBUTES,
@@ -237,12 +254,57 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
237
254
  version_scope: nil,
238
255
  close_on: nil
239
256
  )
240
- PaperTrailAdapter.new(associations: associations, ignore: ignore).analyze_many(
241
- records,
242
- within: within,
243
- activity: activity,
244
- version_scope: version_scope,
245
- close_on: close_on
257
+ adapter = PaperTrailAdapter.new(associations: associations, ignore: ignore)
258
+ if scope
259
+ raise ConfigurationError, 'pass either records or scope:, not both' unless records.nil?
260
+
261
+ return adapter.analyze_scope(scope, limit: limit, within: within, activity: activity,
262
+ version_scope: version_scope, close_on: close_on)
263
+ end
264
+ raise ConfigurationError, 'pass records or scope:' if records.nil?
265
+
266
+ adapter.analyze_many(records, within: within, activity: activity,
267
+ version_scope: version_scope, close_on: close_on)
268
+ end
269
+
270
+ # Analyzes every root the relation reaches whose history moved inside the
271
+ # window, selecting them in a fixed number of queries rather than making the
272
+ # caller rediscover them.
273
+ #
274
+ # `limit:` is required and exceeding it raises. Selection moves into the gem
275
+ # here, so the bound on how much work a page can ask for has to move with
276
+ # it, and a truncated audit report is worse than a refused one.
277
+ #
278
+ # Returns a `ScopedAnalysis`, which destructures:
279
+ #
280
+ # analyses, unreachable = PaperTrailDiff.analyze_scope(
281
+ # Article.where(status: 'published'), within: july, limit: 500
282
+ # )
283
+ #
284
+ # `unreachable` names roots that changed in the window but have no live row
285
+ # left. A relation's conditions are evaluated against the live table, so a
286
+ # destroyed root cannot be tested against them at all -- its history is
287
+ # intact and the state it held at destruction may well have matched. Those
288
+ # roots are reported rather than dropped so that a page auditing deletions
289
+ # is told where to look instead of quietly coming up short.
290
+ #
291
+ # Note also that a relation selects on current state, not on state during
292
+ # the window: `where(status: 'published')` means published *now*, which is a
293
+ # different set from what was published while the window was open.
294
+ #: (untyped, limit: Integer?, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> ScopedAnalysis
295
+ def analyze_scope( # rubocop:disable Metrics/ParameterLists
296
+ scope,
297
+ limit:,
298
+ within: nil,
299
+ associations: [],
300
+ ignore: DEFAULT_IGNORED_ATTRIBUTES,
301
+ activity: false,
302
+ version_scope: nil,
303
+ close_on: nil
304
+ )
305
+ PaperTrailAdapter.new(associations: associations, ignore: ignore).analyze_scope(
306
+ scope, limit: limit, within: within, activity: activity,
307
+ version_scope: version_scope, close_on: close_on
246
308
  )
247
309
  end
248
310
 
@@ -263,5 +325,32 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
263
325
  def diagnose(from_version, to_version, associations: [])
264
326
  HistoryDiagnostics.new(from_version, to_version, associations: associations).call
265
327
  end
328
+
329
+ # Looks inside an attribute the database stores whole, such as a JSON or
330
+ # jsonb column, and reports which keys changed.
331
+ #
332
+ # change = diff.attributes.fetch('config')
333
+ # PaperTrailDiff.nested_changes(change)
334
+ # # => { ['theme'] => <from "dark" to "light">,
335
+ # # ['limits', 'max'] => <from 10 to 20> }
336
+ #
337
+ # Accepts the `ValueChange` an attribute diff already produced, or a bare
338
+ # pair. Returns an empty hash when the pair is not two readable structures,
339
+ # which is the honest answer: a column that held text on one side and JSON
340
+ # on the other changed wholesale, and the caller still has that change.
341
+ #
342
+ # Paths are arrays because a JSON key may contain a dot. Arrays are reported
343
+ # whole rather than by index, since their elements carry no identity and a
344
+ # list that merely shifted would otherwise look changed throughout. A key
345
+ # that was absent reads as `NestedComparator::ABSENT` rather than nil, which
346
+ # JSON uses for a present null.
347
+ #: (untyped, ?untyped) -> Hash[Array[String], ValueChange]
348
+ def nested_changes(change, to_value = nil)
349
+ # Tested by type rather than by responding to `from`: ActiveSupport gives
350
+ # String#from, so duck-typing here quietly reads a plain string as a pair.
351
+ return NestedComparator.call(change.from, change.to) if change.is_a?(ValueChange)
352
+
353
+ NestedComparator.call(change, to_value)
354
+ end
266
355
  end
267
356
  end
@@ -19,6 +19,11 @@ module PaperTrailDiff
19
19
 
20
20
  attr_reader record: RecordReference
21
21
 
22
+ # The transaction the version was written in, when PaperTrail recorded one.
23
+ # Several versions saved together share it, which is what lets a timeline
24
+ # report one save as one step rather than as its parts.
25
+ attr_reader transaction_id: untyped
26
+
22
27
  # : (untyped) -> ActivityBoundary
23
28
  def self.from_version: (untyped) -> ActivityBoundary
24
29
 
@@ -31,8 +36,14 @@ module PaperTrailDiff
31
36
  # : (untyped) -> ActivityBoundary
32
37
  def self.destroyed: (untyped) -> ActivityBoundary
33
38
 
34
- # : (kind: Symbol, version_id: untyped, item_type: untyped, item_id: untyped, recorded_at: untyped, ?event: untyped, ?whodunnit: untyped) -> void
35
- def initialize: (kind: Symbol, version_id: untyped, item_type: untyped, item_id: untyped, recorded_at: untyped, ?event: untyped, ?whodunnit: untyped) -> void
39
+ # A custom version class need not carry the column, and PaperTrail leaves
40
+ # it nil outside a transaction. Both read as "no transaction here", which
41
+ # groups with nothing.
42
+ # : (untyped) -> untyped
43
+ def self.transaction_id: (untyped) -> untyped
44
+
45
+ # : (kind: Symbol, version_id: untyped, item_type: untyped, item_id: untyped, recorded_at: untyped, ?event: untyped, ?whodunnit: untyped, ?transaction_id: untyped) -> void
46
+ def initialize: (kind: Symbol, version_id: untyped, item_type: untyped, item_id: untyped, recorded_at: untyped, ?event: untyped, ?whodunnit: untyped, ?transaction_id: untyped) -> void
36
47
 
37
48
  # : () -> bool
38
49
  def version?: () -> bool
@@ -0,0 +1,19 @@
1
+ # Generated from lib/paper_trail_diff/activity_grouping.rb with RBS::Inline
2
+
3
+ module PaperTrailDiff
4
+ # Shared by the two activity builders. They differ in how they choose
5
+ # boundaries -- one from an explicit version range, one from a wall-clock
6
+ # window -- but a transaction has to collapse the same way in both, or the
7
+ # same history would read differently depending on how it was asked for.
8
+ module ActivityGrouping
9
+ private
10
+
11
+ # : () -> bool
12
+ def grouping?: () -> bool
13
+
14
+ # Applied last, to finished steps, so grouping sees the same timeline the
15
+ # caller would otherwise have received.
16
+ # : (Array[ActivityStep]) -> Array[ActivityStep]
17
+ def group_steps: (Array[ActivityStep]) -> Array[ActivityStep]
18
+ end
19
+ end
@@ -3,8 +3,10 @@
3
3
  module PaperTrailDiff
4
4
  # Compares adjacent root and selected-descendant activity boundaries.
5
5
  class ActivityTimelineBuilder
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]
@@ -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,10 +41,20 @@ 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
47
52
 
53
+ # Raised when a selected association's target is not versioned, so its history
54
+ # cannot be reconstructed at all.
55
+ class UnversionedAssociationError < Error
56
+ end
57
+
48
58
  # Raised when a requested association macro is not supported.
49
59
  class UnsupportedAssociationError < Error
50
60
  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]
@@ -16,6 +16,16 @@ module PaperTrailDiff
16
16
 
17
17
  @tree: AssociationTree
18
18
 
19
+ # A model PaperTrail never versioned has no history to reconstruct, so a
20
+ # comparison over it can only ever answer "nothing changed" -- which is a
21
+ # wrong answer rather than an empty one. Live-to-live comparison reads
22
+ # current state and is unaffected, so this applies to historical work only.
23
+ # : (untyped) -> void
24
+ def ensure_versioned_targets!: (untyped) -> void
25
+
26
+ # : (untyped) -> bool
27
+ def versioned?: (untyped) -> bool
28
+
19
29
  # : () -> void
20
30
  def ensure_association_tracking!: () -> void
21
31
  end