paper_trail_diff 0.12.0 → 0.13.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 (130) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +127 -0
  3. data/QUICKSTART.md +149 -79
  4. data/README.md +179 -1388
  5. data/docs/architecture.md +88 -0
  6. data/docs/performance.md +309 -0
  7. data/docs/reference.md +977 -0
  8. data/docs/testing.md +146 -0
  9. data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +2 -12
  10. data/lib/paper_trail_diff/activity_boundary.rb +20 -8
  11. data/lib/paper_trail_diff/activity_child_candidate_loader.rb +29 -0
  12. data/lib/paper_trail_diff/activity_collection_event_applier.rb +10 -7
  13. data/lib/paper_trail_diff/activity_collection_route_updater.rb +1 -11
  14. data/lib/paper_trail_diff/activity_event_record_normalizer.rb +4 -0
  15. data/lib/paper_trail_diff/activity_event_record_resolver.rb +66 -6
  16. data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +6 -4
  17. data/lib/paper_trail_diff/activity_grouping.rb +12 -0
  18. data/lib/paper_trail_diff/activity_history.rb +9 -3
  19. data/lib/paper_trail_diff/activity_relationship.rb +2 -0
  20. data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +1 -0
  21. data/lib/paper_trail_diff/activity_snapshot_sequence.rb +9 -2
  22. data/lib/paper_trail_diff/activity_timeline_builder.rb +39 -35
  23. data/lib/paper_trail_diff/activity_version_collector.rb +27 -18
  24. data/lib/paper_trail_diff/analysis.rb +9 -4
  25. data/lib/paper_trail_diff/analysis_batch.rb +28 -7
  26. data/lib/paper_trail_diff/association_traversal.rb +23 -0
  27. data/lib/paper_trail_diff/batch_boundary_resolver.rb +18 -26
  28. data/lib/paper_trail_diff/batch_safety.rb +55 -0
  29. data/lib/paper_trail_diff/batched_root_analyzer.rb +14 -5
  30. data/lib/paper_trail_diff/batched_root_versions.rb +9 -37
  31. data/lib/paper_trail_diff/branch_snapshot_refresher.rb +7 -8
  32. data/lib/paper_trail_diff/deleted_analysis_batch.rb +93 -0
  33. data/lib/paper_trail_diff/direct_record_state_loader.rb +81 -0
  34. data/lib/paper_trail_diff/endpoint.rb +10 -0
  35. data/lib/paper_trail_diff/engine.rb +4 -2
  36. data/lib/paper_trail_diff/errors.rb +9 -0
  37. data/lib/paper_trail_diff/historical_association_reifier.rb +26 -1
  38. data/lib/paper_trail_diff/historical_filter.rb +181 -0
  39. data/lib/paper_trail_diff/historical_snapshot_store.rb +51 -35
  40. data/lib/paper_trail_diff/instrumentation.rb +2 -2
  41. data/lib/paper_trail_diff/nested_comparator.rb +39 -15
  42. data/lib/paper_trail_diff/paper_trail_adapter.rb +109 -55
  43. data/lib/paper_trail_diff/prepared_activity_index.rb +121 -0
  44. data/lib/paper_trail_diff/prepared_edge_loader.rb +11 -11
  45. data/lib/paper_trail_diff/prepared_history.rb +21 -30
  46. data/lib/paper_trail_diff/prepared_history_loader.rb +14 -2
  47. data/lib/paper_trail_diff/prepared_record_index.rb +102 -29
  48. data/lib/paper_trail_diff/prepared_snapshot_builder.rb +64 -0
  49. data/lib/paper_trail_diff/prepared_state_association_reader.rb +77 -0
  50. data/lib/paper_trail_diff/root_version_plan.rb +25 -2
  51. data/lib/paper_trail_diff/root_version_selection.rb +16 -5
  52. data/lib/paper_trail_diff/scoped_analysis.rb +17 -2
  53. data/lib/paper_trail_diff/scoped_analysis_builder.rb +47 -0
  54. data/lib/paper_trail_diff/scoped_root_selection.rb +106 -13
  55. data/lib/paper_trail_diff/snapshot.rb +9 -0
  56. data/lib/paper_trail_diff/snapshot_normalizer.rb +41 -2
  57. data/lib/paper_trail_diff/step.rb +10 -5
  58. data/lib/paper_trail_diff/stored_time_precision.rb +68 -0
  59. data/lib/paper_trail_diff/support.rb +13 -3
  60. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +19 -7
  61. data/lib/paper_trail_diff/time_range.rb +22 -2
  62. data/lib/paper_trail_diff/time_version_range.rb +1 -15
  63. data/lib/paper_trail_diff/timeline_builder.rb +5 -4
  64. data/lib/paper_trail_diff/timeline_range.rb +25 -43
  65. data/lib/paper_trail_diff/timeline_snapshot_provider.rb +13 -2
  66. data/lib/paper_trail_diff/traversal.rb +1 -1
  67. data/lib/paper_trail_diff/version.rb +1 -1
  68. data/lib/paper_trail_diff/version_association_candidate_scope.rb +1 -1
  69. data/lib/paper_trail_diff/version_boundary.rb +48 -0
  70. data/lib/paper_trail_diff/version_query.rb +33 -0
  71. data/lib/paper_trail_diff/version_range.rb +10 -38
  72. data/lib/paper_trail_diff.rb +78 -42
  73. data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +0 -3
  74. data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -4
  75. data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +11 -0
  76. data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +4 -4
  77. data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +0 -3
  78. data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +19 -3
  79. data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +2 -2
  80. data/sig/generated/paper_trail_diff/activity_grouping.rbs +3 -0
  81. data/sig/generated/paper_trail_diff/activity_history.rbs +6 -2
  82. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +7 -2
  83. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +8 -3
  84. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +4 -8
  85. data/sig/generated/paper_trail_diff/analysis.rbs +2 -2
  86. data/sig/generated/paper_trail_diff/analysis_batch.rbs +14 -3
  87. data/sig/generated/paper_trail_diff/association_traversal.rbs +11 -0
  88. data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +7 -10
  89. data/sig/generated/paper_trail_diff/batch_safety.rbs +24 -0
  90. data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +8 -2
  91. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +4 -16
  92. data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
  93. data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +44 -0
  94. data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +42 -0
  95. data/sig/generated/paper_trail_diff/endpoint.rbs +5 -0
  96. data/sig/generated/paper_trail_diff/engine.rbs +4 -2
  97. data/sig/generated/paper_trail_diff/errors.rbs +12 -0
  98. data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +8 -0
  99. data/sig/generated/paper_trail_diff/historical_filter.rbs +75 -0
  100. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +17 -4
  101. data/sig/generated/paper_trail_diff/nested_comparator.rbs +15 -7
  102. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +36 -15
  103. data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +56 -0
  104. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +3 -3
  105. data/sig/generated/paper_trail_diff/prepared_history.rbs +12 -14
  106. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -0
  107. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +43 -6
  108. data/sig/generated/paper_trail_diff/prepared_snapshot_builder.rbs +24 -0
  109. data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +33 -0
  110. data/sig/generated/paper_trail_diff/root_version_plan.rbs +15 -2
  111. data/sig/generated/paper_trail_diff/root_version_selection.rbs +8 -1
  112. data/sig/generated/paper_trail_diff/scoped_analysis.rbs +14 -2
  113. data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +22 -0
  114. data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +46 -6
  115. data/sig/generated/paper_trail_diff/snapshot.rbs +4 -0
  116. data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +13 -0
  117. data/sig/generated/paper_trail_diff/step.rbs +8 -2
  118. data/sig/generated/paper_trail_diff/stored_time_precision.rbs +36 -0
  119. data/sig/generated/paper_trail_diff/support.rbs +7 -0
  120. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +7 -2
  121. data/sig/generated/paper_trail_diff/time_range.rbs +9 -0
  122. data/sig/generated/paper_trail_diff/time_version_range.rbs +0 -6
  123. data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
  124. data/sig/generated/paper_trail_diff/timeline_range.rbs +10 -14
  125. data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +2 -0
  126. data/sig/generated/paper_trail_diff/version_boundary.rbs +23 -0
  127. data/sig/generated/paper_trail_diff/version_query.rbs +15 -0
  128. data/sig/generated/paper_trail_diff/version_range.rbs +5 -14
  129. data/sig/generated/paper_trail_diff.rbs +31 -25
  130. metadata +31 -5
@@ -25,6 +25,8 @@ module PaperTrailDiff
25
25
  # window. `where(status: 'published')` selects what is published now, which is
26
26
  # not the same set as what was published while the window was open.
27
27
  class ScopedRootSelection
28
+ PAGE_SIZE: ::Integer
29
+
28
30
  # A plain class rather than Data.define, which arrived in Ruby 3.2 while this
29
31
  # gem supports 3.1.
30
32
  class Result
@@ -36,20 +38,43 @@ module PaperTrailDiff
36
38
  def initialize: (records: Array[untyped], unreachable: Array[Array[String]]) -> void
37
39
  end
38
40
 
39
- # : (untyped, time_range: TimeRange?, limit: Integer) -> void
40
- def initialize: (untyped, time_range: TimeRange?, limit: Integer) -> void
41
+ # : (untyped, time_range: TimeRange?, limit: Integer, ?historical_filter: untyped, ?max_filter_versions: Integer) -> void
42
+ def initialize: (untyped, time_range: TimeRange?, limit: Integer, ?historical_filter: untyped, ?max_filter_versions: Integer) -> void
41
43
 
42
44
  # : () -> Result
43
45
  def call: () -> Result
44
46
 
45
47
  private
46
48
 
49
+ @historical_filter: untyped
50
+
47
51
  @limit: Integer
48
52
 
49
53
  @scope: untyped
50
54
 
51
55
  @time_range: TimeRange?
52
56
 
57
+ # Leaves the candidate set in the database. Plucking every root changed in
58
+ # the window would cost memory and a statement proportional to all activity
59
+ # in the window -- not to the relation or `limit:` -- so a report on one
60
+ # record could read every id in a busy table. A subquery keeps the work
61
+ # bounded by what the relation and the deletions actually contain.
62
+ # : () -> Result
63
+ def database_selection: () -> Result
64
+
65
+ # Comparing the two columns in SQL needs them to share a type: PostgreSQL
66
+ # rejects an integer key against a string item_id outright.
67
+ # : () -> bool
68
+ def comparable_keys?: () -> bool
69
+
70
+ # Changed in the window but without a row, found with an anti-join instead
71
+ # of comparing two plucked lists.
72
+ # : () -> Array[String]
73
+ def missing_ids: () -> Array[String]
74
+
75
+ # : () -> untyped
76
+ def window_versions: () -> untyped
77
+
53
78
  # Accepts a model class as readily as a relation: `Article` and
54
79
  # `Article.where(...)` both name a population, and `all` is what makes them
55
80
  # the same kind of thing.
@@ -70,10 +95,25 @@ module PaperTrailDiff
70
95
  # : (Array[String]) -> Array[String]
71
96
  def live_ids: (Array[String]) -> Array[String]
72
97
 
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]
98
+ # Deduplicate records rather than adding SQL DISTINCT: an otherwise valid
99
+ # relation may order by joined columns or select a PostgreSQL json column.
100
+ # Read bounded pages until the relation ends or too many unique roots appear.
101
+ # : (untyped) -> Array[untyped]
102
+ def selected_records: (untyped) -> Array[untyped]
103
+
104
+ # : (untyped, Integer) -> Array[untyped]
105
+ def record_page: (untyped, Integer) -> Array[untyped]
106
+
107
+ # Preserve explicit ordering: SQL Server rejects duplicate ORDER BY columns,
108
+ # even when the extra ordering would only have served as a tie-breaker.
109
+ # : () -> untyped
110
+ def ordered_scope: () -> untyped
111
+
112
+ # : (Integer) -> Integer
113
+ def page_limit: (Integer) -> Integer
114
+
115
+ # : (Integer) -> void
116
+ def reject_oversized!: (Integer) -> void
77
117
 
78
118
  # : (String) -> Array[String]
79
119
  def identity: (String) -> Array[String]
@@ -113,6 +113,10 @@ module PaperTrailDiff
113
113
  # : () -> RecordReference
114
114
  def reference: () -> RecordReference
115
115
 
116
+ # A copy holding a different state for one association.
117
+ # : (String, AssociationSnapshot) -> RecordSnapshot
118
+ def with_association: (String, AssociationSnapshot) -> RecordSnapshot
119
+
116
120
  # : () -> Hash[Symbol, untyped]
117
121
  def to_h: () -> Hash[Symbol, untyped]
118
122
 
@@ -37,8 +37,15 @@ module PaperTrailDiff
37
37
  # : (untyped, tree: AssociationTree, path: String) -> snapshot_attributes
38
38
  def attributes_for: (untyped, tree: AssociationTree, path: String) -> snapshot_attributes
39
39
 
40
+ # Same exclusions as AR normalization, applied to an already typed state.
41
+ # : (PreparedRecordState, tree: AssociationTree, path: String, ?incoming: untyped, ?associations: snapshot_associations) -> RecordSnapshot
42
+ def from_state: (PreparedRecordState, tree: AssociationTree, path: String, ?incoming: untyped, ?associations: snapshot_associations) -> RecordSnapshot
43
+
40
44
  private
41
45
 
46
+ # : (String, RecordSnapshot) -> RecordSnapshot
47
+ def pool_state: (String, RecordSnapshot) -> RecordSnapshot
48
+
42
49
  @excluded_attributes: Hash[Array[untyped], Array[String]]
43
50
 
44
51
  @ignore_policy: IgnorePolicy
@@ -66,6 +73,12 @@ module PaperTrailDiff
66
73
  # : (untyped, Array[untyped], String) -> Array[String]
67
74
  def build_excluded_attributes: (untyped, Array[untyped], String) -> Array[String]
68
75
 
76
+ # `has_paper_trail skip:` columns are never written to a version, so history
77
+ # holds only their defaults. Compared with a live row they would report a
78
+ # change nobody recorded.
79
+ # : (untyped) -> Array[String]
80
+ def skipped_columns: (untyped) -> Array[String]
81
+
69
82
  # : (Array[untyped]) -> Array[String]
70
83
  def relationship_columns: (Array[untyped]) -> Array[String]
71
84
 
@@ -22,7 +22,13 @@ module PaperTrailDiff
22
22
  # : () -> bool
23
23
  def empty?: () -> bool
24
24
 
25
- # : () -> Hash[Symbol, untyped]
26
- def to_h: () -> Hash[Symbol, untyped]
25
+ # The root event whose change this step reports.
26
+ # : () -> ActivityBoundary
27
+ def source_boundary: () -> ActivityBoundary
28
+
29
+ # The same shape as `ActivityStep#to_h`, so a consumer reads both step
30
+ # types alike; each boundary carries its own `version_id`.
31
+ # : (?metadata: bool) -> Hash[Symbol, untyped]
32
+ def to_h: (?metadata: bool) -> Hash[Symbol, untyped]
27
33
  end
28
34
  end
@@ -0,0 +1,36 @@
1
+ # Generated from lib/paper_trail_diff/stored_time_precision.rb with RBS::Inline
2
+
3
+ module PaperTrailDiff
4
+ # Matches time values to the precision PaperTrail actually stored.
5
+ #
6
+ # PaperTrail's JSON serializer, and a `json` or `jsonb` object column, write
7
+ # times through ActiveSupport's JSON encoder, which keeps only
8
+ # `ActiveSupport::JSON::Encoding.time_precision` fractional digits
9
+ # (milliseconds by default). The live row keeps the database's precision, so
10
+ # comparing the two would report every timestamp as changed even though
11
+ # nothing was edited. Truncating every side to the stored precision is
12
+ # idempotent, so values that came from history are unaffected and only the
13
+ # digits history could never have held are dropped.
14
+ module StoredTimePrecision
15
+ NANOSECOND_DIGITS: ::Integer
16
+
17
+ JSON_COLUMN_TYPES: untyped
18
+
19
+ # Fractional digits a model's history keeps for times, or nil when the
20
+ # history is stored losslessly.
21
+ # : (untyped) -> Integer?
22
+ def self?.digits: (untyped) -> Integer?
23
+
24
+ # : (Hash[untyped, untyped], Integer?) -> Hash[untyped, untyped]
25
+ def self?.apply: (Hash[untyped, untyped], Integer?) -> Hash[untyped, untyped]
26
+
27
+ # : (untyped, Integer) -> untyped
28
+ def self?.truncate: (untyped, Integer) -> untyped
29
+
30
+ # : (untyped) -> bool
31
+ def self?.json_history?: (untyped) -> bool
32
+
33
+ # : () -> bool
34
+ def self?.json_serializer?: () -> bool
35
+ end
36
+ end
@@ -43,6 +43,13 @@ module PaperTrailDiff
43
43
  # : (untyped) -> bool
44
44
  def self?.versioned?: (untyped) -> bool
45
45
 
46
+ # `versions.item_id` is often a string column -- PaperTrail's setup for UUID
47
+ # keys -- while the model's own key is an integer. An id read from history
48
+ # is cast to the key's type, or the same record read once from history and
49
+ # once from its live row would count as two.
50
+ # : (untyped, untyped) -> untyped
51
+ def self?.record_id: (untyped, untyped) -> untyped
52
+
46
53
  # : (untyped) -> bool
47
54
  def self?.sequential_id?: (untyped) -> bool
48
55
 
@@ -29,6 +29,9 @@ module PaperTrailDiff
29
29
  # : () -> [ActivityHistory, RootVersionPlan, ActivityStep?, RecordSnapshot?, untyped]
30
30
  def history_and_versions: () -> [ ActivityHistory, RootVersionPlan, ActivityStep?, RecordSnapshot?, untyped ]
31
31
 
32
+ # : () -> void
33
+ def reject_live_habtm!: () -> void
34
+
32
35
  # : (RootVersionPlan, untyped) -> [ActivityHistory, RootVersionPlan, ActivityStep?, RecordSnapshot?, untyped]
33
36
  def history_for: (RootVersionPlan, untyped) -> [ ActivityHistory, RootVersionPlan, ActivityStep?, RecordSnapshot?, untyped ]
34
37
 
@@ -40,8 +43,10 @@ module PaperTrailDiff
40
43
  # : (Array[untyped], untyped) -> Array[ActivityEvent]
41
44
  def events_through: (Array[untyped], untyped) -> Array[ActivityEvent]
42
45
 
43
- # : (Array[untyped], Array[ActivityEvent]) -> ActivityHistory
44
- def build_history: (Array[untyped], Array[ActivityEvent]) -> ActivityHistory
46
+ # Events after the last root version read the root from the live record
47
+ # when the window closes on it, as an explicit `to: record` range does.
48
+ # : (Array[untyped], Array[ActivityEvent], ?current: untyped) -> ActivityHistory
49
+ def build_history: (Array[untyped], Array[ActivityEvent], ?current: untyped) -> ActivityHistory
45
50
 
46
51
  # : (ActivityHistory, ActivityStep?) -> Array[ActivityStep]
47
52
  def activity_steps: (ActivityHistory, ActivityStep?) -> Array[ActivityStep]
@@ -22,10 +22,19 @@ module PaperTrailDiff
22
22
  # : (untyped) -> untyped
23
23
  def trailing_scope: (untyped) -> untyped
24
24
 
25
+ # Each item's earliest version after the range, keyed by item id. One row
26
+ # per item comes back from a NOT EXISTS rather than a window function, so
27
+ # the query stays portable; a timestamp tie keeps the lower id.
28
+ # : (untyped) -> Hash[String, untyped]
29
+ def first_trailing_versions: (untyped) -> Hash[String, untyped]
30
+
25
31
  private
26
32
 
27
33
  @exclude_end: bool
28
34
 
35
+ # : (untyped) -> untyped
36
+ def after_end: (untyped) -> untyped
37
+
29
38
  # : () -> Range[Time]
30
39
  def query_range: () -> Range[Time]
31
40
 
@@ -6,9 +6,6 @@ module PaperTrailDiff
6
6
  # : (untyped, time_range: TimeRange, ?version_scope: untyped, ?live_endpoint: untyped) -> void
7
7
  def initialize: (untyped, time_range: TimeRange, ?version_scope: untyped, ?live_endpoint: untyped) -> void
8
8
 
9
- # : (?context_required: bool) -> Array[untyped]
10
- def select: (?context_required: bool) -> Array[untyped]
11
-
12
9
  # : (?context_required: bool) -> RootVersionPlan
13
10
  def select_plan: (?context_required: bool) -> RootVersionPlan
14
11
 
@@ -22,9 +19,6 @@ module PaperTrailDiff
22
19
 
23
20
  @version_scope: untyped
24
21
 
25
- # : () -> untyped
26
- def versions_relation: () -> untyped
27
-
28
22
  # : (untyped) -> untyped
29
23
  def trailing_version: (untyped) -> untyped
30
24
 
@@ -3,8 +3,8 @@
3
3
  module PaperTrailDiff
4
4
  # Selects and compares a chronological slice of a record's version history.
5
5
  class TimelineBuilder
6
- # : (untyped, from: untyped, to: untyped, snapshotter: untyped, ?within: untyped, ?versions: Array[untyped]?, ?version_scope: untyped, ?plan: RootVersionPlan?, ?live_endpoint: untyped) -> void
7
- def initialize: (untyped, from: untyped, to: untyped, snapshotter: untyped, ?within: untyped, ?versions: Array[untyped]?, ?version_scope: untyped, ?plan: RootVersionPlan?, ?live_endpoint: untyped) -> void
6
+ # : (untyped, from: untyped, to: untyped, snapshotter: untyped, ?within: untyped, ?version_scope: untyped, ?plan: RootVersionPlan?, ?live_endpoint: untyped) -> void
7
+ def initialize: (untyped, from: untyped, to: untyped, snapshotter: untyped, ?within: untyped, ?version_scope: untyped, ?plan: RootVersionPlan?, ?live_endpoint: untyped) -> void
8
8
 
9
9
  # : () -> Array[Step]
10
10
  def build: () -> Array[Step]
@@ -5,16 +5,20 @@ module PaperTrailDiff
5
5
  class TimelineRange
6
6
  BOUNDARY_SYMBOLS: untyped
7
7
 
8
+ # `:last` once named the last version, whose recorded state is the one
9
+ # *before* the latest change, so a range ending there silently dropped it.
10
+ LAST_REMOVED: ::String
11
+
8
12
  attr_reader from: untyped
9
13
 
10
14
  attr_reader to: untyped
11
15
 
12
16
  attr_reader time_range: TimeRange?
13
17
 
14
- # : (untyped, from: untyped, to: untyped, within: untyped, ?versions: Array[untyped]?, ?version_scope: untyped, ?plan: RootVersionPlan?, ?live_endpoint: untyped) -> void
15
- def initialize: (untyped, from: untyped, to: untyped, within: untyped, ?versions: Array[untyped]?, ?version_scope: untyped, ?plan: RootVersionPlan?, ?live_endpoint: untyped) -> void
18
+ # : (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?plan: RootVersionPlan?, ?live_endpoint: untyped) -> void
19
+ def initialize: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?plan: RootVersionPlan?, ?live_endpoint: untyped) -> void
16
20
 
17
- # A record with no versions has no first or last boundary to resolve, which
21
+ # A record with no versions has no first boundary to resolve, which
18
22
  # is an empty history rather than a bad request.
19
23
  # : () -> bool
20
24
  def unresolved?: () -> bool
@@ -26,9 +30,6 @@ module PaperTrailDiff
26
30
  # : (?context_required: bool) -> RootVersionPlan
27
31
  def select_plan: (?context_required: bool) -> RootVersionPlan
28
32
 
29
- # : (?context_required: bool) -> Array[untyped]
30
- def select: (?context_required: bool) -> Array[untyped]
31
-
32
33
  # : () -> bool
33
34
  def time?: () -> bool
34
35
 
@@ -56,15 +57,13 @@ module PaperTrailDiff
56
57
 
57
58
  @version_scope: untyped
58
59
 
59
- @versions: Array[untyped]?
60
-
61
- # : () -> Array[untyped]
62
- def empty_versions: () -> Array[untyped]
60
+ # : () -> RootVersionPlan
61
+ def explicit_plan: () -> RootVersionPlan
63
62
 
64
63
  # : (untyped) -> bool
65
64
  def symbolic?: (untyped) -> bool
66
65
 
67
- # Resolving `:first` and `:last` here keeps callers from depending on the
66
+ # Resolving `:first` here keeps callers from depending on the
68
67
  # order PaperTrail happens to give its versions association, which a caller
69
68
  # is also free to reorder.
70
69
  # : (untyped) -> untyped
@@ -73,9 +72,6 @@ module PaperTrailDiff
73
72
  # : () -> untyped
74
73
  def ordered_versions: () -> untyped
75
74
 
76
- # : () -> untyped
77
- def versions_relation: () -> untyped
78
-
79
75
  # : (untyped) -> TimeRange?
80
76
  def build_time_range: (untyped) -> TimeRange?
81
77
 
@@ -21,6 +21,8 @@ module PaperTrailDiff
21
21
 
22
22
  @live_snapshotter: untyped
23
23
 
24
+ @snapshots: Hash[Array[untyped], RecordSnapshot?]
25
+
24
26
  @store: HistoricalSnapshotStore
25
27
  end
26
28
  end
@@ -0,0 +1,23 @@
1
+ # Generated from lib/paper_trail_diff/version_boundary.rb with RBS::Inline
2
+
3
+ module PaperTrailDiff
4
+ # Places one version relative to another on the recorded timeline.
5
+ module VersionBoundary
6
+ # Whether `candidate` was recorded at or after `boundary`. Versions sharing
7
+ # a timestamp are ordered by id, which reflects insertion order only for
8
+ # sequential ids in one version table; any other tie cannot be ordered, and
9
+ # guessing would place a change on an arbitrary side of the boundary. A
10
+ # bare instant has no position among same-time versions, so it keeps them.
11
+ # : (untyped, untyped) -> bool
12
+ def self?.at_or_after?: (untyped, untyped) -> bool
13
+
14
+ # : (untyped) -> bool
15
+ def self?.version_record?: (untyped) -> bool
16
+
17
+ # : (untyped, untyped) -> bool
18
+ def self?.same_version?: (untyped, untyped) -> bool
19
+
20
+ # : (untyped, untyped) -> bool
21
+ def self?.orderable_tie?: (untyped, untyped) -> bool
22
+ end
23
+ end
@@ -0,0 +1,15 @@
1
+ # Generated from lib/paper_trail_diff/version_query.rb with RBS::Inline
2
+
3
+ module PaperTrailDiff
4
+ # Arel conditions shared by the version queries that need one row per item.
5
+ module VersionQuery
6
+ # Keeps each item's earliest version among those the block also accepts:
7
+ # no other version of the same item that the block accepts is earlier. A
8
+ # correlated NOT EXISTS stays portable where window functions are not.
9
+ # : (untyped) ?{ (untyped) -> untyped } -> untyped
10
+ def self?.no_earlier_version: (untyped) ?{ (untyped) -> untyped } -> untyped
11
+
12
+ # : (untyped, untyped) ?{ (untyped) -> untyped } -> untyped
13
+ def self?.earlier_version: (untyped, untyped) ?{ (untyped) -> untyped } -> untyped
14
+ end
15
+ end
@@ -6,18 +6,15 @@ module PaperTrailDiff
6
6
  # : (untyped, from: untyped, to: untyped, ?version_scope: untyped) -> void
7
7
  def initialize: (untyped, from: untyped, to: untyped, ?version_scope: untyped) -> void
8
8
 
9
- # Same selection as `select`, but keeping the step pairs a filter implies.
9
+ # Keep both reconstruction boundaries and the step pairs a filter implies.
10
10
  # : () -> RootVersionPlan
11
11
  def select_plan_for_range: () -> RootVersionPlan
12
12
 
13
13
  # : () -> untyped
14
14
  def validated_relation: () -> untyped
15
15
 
16
- # : () -> Array[untyped]
17
- def select: () -> Array[untyped]
18
-
19
- # : () -> Array[untyped]
20
- def select_through_latest: () -> Array[untyped]
16
+ # : (untyped) -> RootVersionPlan
17
+ def select_plan_through_current: (untyped) -> RootVersionPlan
21
18
 
22
19
  private
23
20
 
@@ -29,14 +26,8 @@ module PaperTrailDiff
29
26
 
30
27
  @version_scope: untyped
31
28
 
32
- # : () -> untyped
33
- def versions_relation: () -> untyped
34
-
35
- # : (untyped, ?through: untyped) -> Array[untyped]
36
- def select_relation: (untyped, ?through: untyped) -> Array[untyped]
37
-
38
- # : (untyped, ?through: untyped) -> RootVersionPlan
39
- def select_plan: (untyped, ?through: untyped) -> RootVersionPlan
29
+ # : (untyped, ?through: untyped, ?live_endpoint: untyped) -> RootVersionPlan
30
+ def select_plan: (untyped, ?through: untyped, ?live_endpoint: untyped) -> RootVersionPlan
40
31
 
41
32
  # : (Array[untyped]) -> Array[untyped]
42
33
  def ordered: (Array[untyped]) -> Array[untyped]
@@ -13,42 +13,39 @@ module PaperTrailDiff
13
13
  def self.compare: (untyped, untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool) -> Diff
14
14
 
15
15
  # Compares many independent endpoint pairs while batching current-record loading.
16
- # : (Array[comparison_input], ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool) -> comparison_results
17
- def self.compare_many: (Array[comparison_input], ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool) -> comparison_results
16
+ # : (Array[comparison_input], ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool, ?allow_expensive: bool) -> comparison_results
17
+ def self.compare_many: (Array[comparison_input], ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool, ?allow_expensive: bool) -> comparison_results
18
18
 
19
- # Compares every adjacent reconstructed state in an inclusive version range.
19
+ # Compares every adjacent reconstructed state in an inclusive version range,
20
+ # by default the record's whole history through its current state.
20
21
  # : (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?version_scope: untyped, ?close_on: Symbol?) -> Array[Step]
21
22
  def self.timeline: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?version_scope: untyped, ?close_on: Symbol?) -> Array[Step]
22
23
 
23
24
  # Compares adjacent root and selected-descendant activity boundaries.
24
- # `reload_live_endpoints:` applies only when `to:` is a current record; the
25
- # other range forms never read live state.
26
- # : (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]
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]
25
+ # `reload_live_endpoints:` applies whenever the range ends at the current
26
+ # record: an explicit `to:` record, the default range, or a window with no
27
+ # later version.
28
+ # : (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, ?group: Symbol?) -> Array[ActivityStep]
29
+ 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, ?group: Symbol?) -> Array[ActivityStep]
28
30
 
29
31
  # Builds an endpoint diff and root-checkpoint timeline while normalizing each version once.
30
32
  # : (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
33
  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
34
 
33
- # Analyzes many roots over one shared time window, preparing their selected
34
- # history once for the batch instead of once per record. Roots with no
35
- # versions in the window return an empty `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)
35
+ # Analyzes an array of records over one shared range, preparing their
36
+ # selected history once for the batch instead of once per record, and
37
+ # returns a frozen Hash keyed by identity. Roots with nothing to report in
38
+ # the window return an empty `Analysis`. To let a relation choose the
39
+ # records, use `analyze_scope`.
40
+ # : (Array[untyped], ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?, ?allow_expensive: bool) -> Hash[identity, Analysis]
41
+ def self.analyze_many: (Array[untyped], ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?, ?allow_expensive: bool) -> Hash[identity, Analysis]
44
42
 
45
43
  # 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.
44
+ # window. Joined duplicates are read in bounded pages and analyzed once.
48
45
  #
49
46
  # `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.
47
+ # here. The relation's own limit/offset select the live page; this separate
48
+ # ceiling rejects too many unique roots rather than truncating that page.
52
49
  #
53
50
  # Returns a `ScopedAnalysis`, which destructures:
54
51
  #
@@ -57,7 +54,8 @@ module PaperTrailDiff
57
54
  # )
58
55
  #
59
56
  # `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
57
+ # left, independently of the live page's pagination and safety ceiling.
58
+ # A relation's conditions are evaluated against the live table, so a
61
59
  # destroyed root cannot be tested against them at all -- its history is
62
60
  # intact and the state it held at destruction may well have matched. Those
63
61
  # roots are reported rather than dropped so that a page auditing deletions
@@ -66,8 +64,14 @@ module PaperTrailDiff
66
64
  # Note also that a relation selects on current state, not on state during
67
65
  # the window: `where(status: 'published')` means published *now*, which is a
68
66
  # 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
67
+ # : (untyped, limit: Integer?, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?, ?historical_filter: untyped, ?max_filter_versions: Integer, ?include_deleted: bool, ?allow_expensive: bool) -> ScopedAnalysis
68
+ def self.analyze_scope: (untyped, limit: Integer?, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?, ?historical_filter: untyped, ?max_filter_versions: Integer, ?include_deleted: bool, ?allow_expensive: bool) -> ScopedAnalysis
69
+
70
+ # Recent root and selected-child activity through the current saved state.
71
+ # Historical windows remain explicit on analyze_scope; this entry point is
72
+ # deliberately named for a report through now rather than an as-of query.
73
+ # : (untyped, since: untyped, limit: Integer, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?snapshots: bool, ?group: Symbol?, ?include_deleted: bool, ?historical_filter: untyped, ?max_filter_versions: Integer, ?allow_expensive: bool) -> ScopedAnalysis
74
+ def self.report: (untyped, since: untyped, limit: Integer, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?snapshots: bool, ?group: Symbol?, ?include_deleted: bool, ?historical_filter: untyped, ?max_filter_versions: Integer, ?allow_expensive: bool) -> ScopedAnalysis
71
75
 
72
76
  # Returns the association macros this release can normalize.
73
77
  # : () -> Array[Symbol]
@@ -126,6 +130,8 @@ module PaperTrailDiff
126
130
 
127
131
  type comparison_results = Hash[identity, Diff]
128
132
 
133
+ type report_options = { within: untyped, activity: bool, version_scope: untyped, close_on: Symbol?, snapshots: bool, group: Symbol? }
134
+
129
135
  type ignore_option = Array[String | Symbol] | Hash[String | Symbol, untyped]
130
136
 
131
137
  type reference_key = :type | :id | "type" | "id"