paper_trail_diff 0.1.0 → 0.3.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -0
  3. data/QUICKSTART.md +302 -0
  4. data/README.md +258 -19
  5. data/lib/paper_trail_diff/activity_boundary.rb +34 -3
  6. data/lib/paper_trail_diff/activity_child_candidate_loader.rb +73 -0
  7. data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +261 -45
  8. data/lib/paper_trail_diff/activity_history.rb +136 -0
  9. data/lib/paper_trail_diff/activity_range.rb +28 -12
  10. data/lib/paper_trail_diff/activity_snapshot_delta.rb +64 -0
  11. data/lib/paper_trail_diff/activity_snapshot_sequence.rb +3 -3
  12. data/lib/paper_trail_diff/activity_timeline_builder.rb +54 -45
  13. data/lib/paper_trail_diff/activity_version_collector.rb +14 -17
  14. data/lib/paper_trail_diff/association_diff_traversal.rb +184 -0
  15. data/lib/paper_trail_diff/branch_snapshot_refresher.rb +5 -3
  16. data/lib/paper_trail_diff/collection_comparator.rb +155 -0
  17. data/lib/paper_trail_diff/collection_identity_index.rb +53 -0
  18. data/lib/paper_trail_diff/collection_transition.rb +25 -0
  19. data/lib/paper_trail_diff/comparison_batch.rb +151 -0
  20. data/lib/paper_trail_diff/engine.rb +5 -45
  21. data/lib/paper_trail_diff/errors.rb +9 -0
  22. data/lib/paper_trail_diff/historical_snapshot_store.rb +55 -3
  23. data/lib/paper_trail_diff/instrumentation.rb +30 -0
  24. data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +133 -0
  25. data/lib/paper_trail_diff/live_endpoint_provider.rb +48 -0
  26. data/lib/paper_trail_diff/live_graph_collector.rb +52 -0
  27. data/lib/paper_trail_diff/paper_trail_adapter.rb +61 -34
  28. data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +48 -0
  29. data/lib/paper_trail_diff/prepared_edge_loader.rb +54 -17
  30. data/lib/paper_trail_diff/prepared_history.rb +14 -4
  31. data/lib/paper_trail_diff/prepared_history_loader.rb +17 -9
  32. data/lib/paper_trail_diff/prepared_record_index.rb +138 -10
  33. data/lib/paper_trail_diff/snapshot.rb +66 -3
  34. data/lib/paper_trail_diff/snapshot_traversal.rb +97 -0
  35. data/lib/paper_trail_diff/step.rb +9 -0
  36. data/lib/paper_trail_diff/support.rb +18 -0
  37. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +111 -0
  38. data/lib/paper_trail_diff/time_range.rb +85 -0
  39. data/lib/paper_trail_diff/time_version_range.rb +53 -0
  40. data/lib/paper_trail_diff/timeline_builder.rb +13 -7
  41. data/lib/paper_trail_diff/timeline_range.rb +72 -0
  42. data/lib/paper_trail_diff/traversal.rb +80 -0
  43. data/lib/paper_trail_diff/traversal_emitter.rb +42 -0
  44. data/lib/paper_trail_diff/traversal_entry.rb +94 -0
  45. data/lib/paper_trail_diff/version.rb +1 -1
  46. data/lib/paper_trail_diff/version_association_candidate_scope.rb +73 -0
  47. data/lib/paper_trail_diff.rb +84 -13
  48. data/sig/generated/paper_trail_diff/activity_boundary.rbs +17 -2
  49. data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +40 -0
  50. data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +49 -8
  51. data/sig/generated/paper_trail_diff/activity_history.rbs +79 -0
  52. data/sig/generated/paper_trail_diff/activity_range.rbs +14 -5
  53. data/sig/generated/paper_trail_diff/activity_snapshot_delta.rbs +33 -0
  54. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +2 -2
  55. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +15 -10
  56. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +2 -5
  57. data/sig/generated/paper_trail_diff/association_diff_traversal.rbs +58 -0
  58. data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
  59. data/sig/generated/paper_trail_diff/collection_comparator.rbs +59 -0
  60. data/sig/generated/paper_trail_diff/collection_identity_index.rbs +29 -0
  61. data/sig/generated/paper_trail_diff/collection_transition.rbs +18 -0
  62. data/sig/generated/paper_trail_diff/comparison_batch.rbs +65 -0
  63. data/sig/generated/paper_trail_diff/engine.rbs +0 -12
  64. data/sig/generated/paper_trail_diff/errors.rbs +12 -0
  65. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +22 -2
  66. data/sig/generated/paper_trail_diff/instrumentation.rbs +12 -0
  67. data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +52 -0
  68. data/sig/generated/paper_trail_diff/live_endpoint_provider.rbs +23 -0
  69. data/sig/generated/paper_trail_diff/live_graph_collector.rbs +27 -0
  70. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +21 -10
  71. data/sig/generated/paper_trail_diff/preloaded_endpoint_batch_loader.rbs +24 -0
  72. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +22 -8
  73. data/sig/generated/paper_trail_diff/prepared_history.rbs +6 -0
  74. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +4 -2
  75. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +61 -6
  76. data/sig/generated/paper_trail_diff/snapshot.rbs +37 -2
  77. data/sig/generated/paper_trail_diff/snapshot_traversal.rbs +29 -0
  78. data/sig/generated/paper_trail_diff/step.rbs +7 -0
  79. data/sig/generated/paper_trail_diff/support.rbs +6 -0
  80. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +46 -0
  81. data/sig/generated/paper_trail_diff/time_range.rbs +41 -0
  82. data/sig/generated/paper_trail_diff/time_version_range.rbs +27 -0
  83. data/sig/generated/paper_trail_diff/timeline_builder.rbs +6 -5
  84. data/sig/generated/paper_trail_diff/timeline_range.rbs +41 -0
  85. data/sig/generated/paper_trail_diff/traversal.rbs +41 -0
  86. data/sig/generated/paper_trail_diff/traversal_emitter.rbs +16 -0
  87. data/sig/generated/paper_trail_diff/traversal_entry.rbs +52 -0
  88. data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +32 -0
  89. data/sig/generated/paper_trail_diff.rbs +24 -8
  90. metadata +49 -4
data/README.md CHANGED
@@ -12,6 +12,8 @@ available when
12
12
 
13
13
  Ruby 3.1 or newer and PaperTrail 16 or 17 are supported.
14
14
 
15
+ New to the gem? Start with the copyable [Quickstart](QUICKSTART.md).
16
+
15
17
  ## Installation
16
18
 
17
19
  Add the gem to your bundle:
@@ -80,6 +82,95 @@ association caches and in-memory edits are not compared. Use a database
80
82
  transaction with an appropriate isolation level when several live association
81
83
  queries must represent one atomic application snapshot.
82
84
 
85
+ For collection-level reports, use `compare_many` to reload current roots and
86
+ preload each explicitly selected live association path across the batch. Each
87
+ entry has the same endpoints and options as `compare`; results are returned in
88
+ input order as a frozen hash keyed by `[item_type, item_id]` strings:
89
+
90
+ ```ruby
91
+ diffs = PaperTrailDiff.compare_many(
92
+ [
93
+ { from: first_versions.fetch(order_a.id), to: order_a },
94
+ { from: first_versions.fetch(order_b.id), to: order_b }
95
+ ],
96
+ associations: [:line_items],
97
+ ignore: []
98
+ )
99
+
100
+ diffs.fetch(["Order", order_a.id.to_s]) # => PaperTrailDiff::Diff
101
+ ```
102
+
103
+ Root identities must be unique within one call. Historical reconstruction for
104
+ ordinary versioned, unscoped association paths is also prepared across the
105
+ collection. Paths that require the existing point-in-time PT-AT fallback retain
106
+ their per-endpoint behavior and all historical reconstruction retains the same
107
+ PaperTrail Association Tracking requirements as `compare`. Live collection
108
+ scopes with per-owner semantics, such as `limit`, `offset`, or an owner
109
+ argument, are loaded per root; other selected branches remain batched. Callers
110
+ should still use an appropriate database transaction when all live queries
111
+ must observe one atomic snapshot.
112
+
113
+ ### Reuse already-preloaded current endpoints
114
+
115
+ `compare` and `compare_many` reload current endpoints by default. A caller that
116
+ already owns a consistent, fully preloaded graph may opt out:
117
+
118
+ ```ruby
119
+ orders = Order.where(id: ids).preload(line_items: :product).to_a
120
+
121
+ diffs = PaperTrailDiff.compare_many(
122
+ orders.map do |order|
123
+ { from: first_versions.fetch(order.id), to: order }
124
+ end,
125
+ associations: ["line_items.product"],
126
+ reload_live_endpoints: false
127
+ )
128
+ ```
129
+
130
+ With `reload_live_endpoints: false`, scalar values and association targets come
131
+ from the supplied instances. Every requested association path must already be
132
+ loaded; otherwise `PaperTrailDiff::UnloadedAssociationError` is raised before
133
+ normalization instead of silently issuing an N+1 query. The caller is
134
+ responsible for the consistency and freshness of that in-memory graph.
135
+
136
+ ### Runtime performance diagnostics
137
+
138
+ The gem never prints or logs automatically. It emits these
139
+ `ActiveSupport::Notifications` events so applications can choose their logger
140
+ and formatting:
141
+
142
+ - `compare.paper_trail_diff`
143
+ - `compare_many.paper_trail_diff`
144
+ - `activity_timeline.paper_trail_diff`
145
+ - `load_live_endpoints.paper_trail_diff`
146
+ - `prepare_history.paper_trail_diff`
147
+
148
+ ```ruby
149
+ ActiveSupport::Notifications.subscribe(/\.paper_trail_diff\z/) do |name, start, finish, _id, payload|
150
+ Rails.logger.debug(
151
+ event: name,
152
+ duration_ms: ((finish - start) * 1_000).round(1),
153
+ **payload
154
+ )
155
+ end
156
+ ```
157
+
158
+ The payloads contain counts, model names, association paths, and reload mode,
159
+ not endpoint objects or record attributes. The activity-timeline event wraps
160
+ the complete call and includes `step_count`, so its notification duration is
161
+ the user-visible runtime. To inspect the exact SQL generated by one report,
162
+ scope a standard `sql.active_record` subscriber around it:
163
+
164
+ ```ruby
165
+ callback = proc do |_name, _start, _finish, _id, payload|
166
+ Rails.logger.debug(payload[:sql]) unless payload[:cached] || payload[:name] == "SCHEMA"
167
+ end
168
+
169
+ ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do
170
+ PaperTrailDiff.compare_many(comparisons, associations: [:line_items])
171
+ end
172
+ ```
173
+
83
174
  ## Build a root-checkpoint timeline
84
175
 
85
176
  `timeline` accepts two version objects from the supplied record's history. The
@@ -95,11 +186,22 @@ steps = PaperTrailDiff.timeline(
95
186
 
96
187
  steps.first.from_version # the original PaperTrail version
97
188
  steps.first.to_version # the next PaperTrail version
189
+ steps.first.from_boundary # immutable presentation metadata
190
+ steps.first.to_boundary
98
191
  steps.first.diff # a PaperTrailDiff::Diff
192
+ steps.first.empty? # delegates to the diff
99
193
  steps.first.to_h
100
194
  # => { from_version_id: 2, to_version_id: 3, diff: { ... } }
101
195
  ```
102
196
 
197
+ Both timeline types expose `from_boundary`, `to_boundary`, `diff`, and
198
+ `empty?`. A historical boundary has `event`, `whodunnit`, `record`,
199
+ `recorded_at`, `version?`, and `current?` readers. Checkpoint `Step` objects
200
+ also retain their original `from_version` and `to_version` for callers that
201
+ need custom PaperTrail metadata. Existing `Step#to_h` and
202
+ `ActivityBoundary#to_h` shapes remain unchanged; use the readers for the new
203
+ metadata.
204
+
103
205
  Every version boundary remains in the result, even when its diff is empty after
104
206
  ignored fields are removed. Equal boundaries return a frozen empty array.
105
207
 
@@ -120,7 +222,7 @@ steps = PaperTrailDiff.activity_timeline(
120
222
  associations: ["comments.replies", :author]
121
223
  )
122
224
 
123
- steps.reject { |step| step.diff.empty? }
225
+ steps.reject(&:empty?)
124
226
  ```
125
227
 
126
228
  Pass the record explicitly as `to:` to include current state without creating a
@@ -164,9 +266,55 @@ intermediate events without transaction-backed owner checkpoints. Historical
164
266
  HABTM activity and `compare(version, article, associations: [:tags])` remain
165
267
  supported where the historical endpoint has usable PT-AT metadata.
166
268
 
167
- Both timeline APIs preserve empty boundaries. A display may filter
168
- `step.diff.empty?`, while audit-oriented callers can retain every recorded
169
- boundary.
269
+ Both timeline APIs preserve empty boundaries. A display may use
270
+ `steps.reject(&:empty?)`, while audit-oriented callers can retain every
271
+ recorded boundary.
272
+
273
+ ## Select mutations by time
274
+
275
+ `timeline`, `activity_timeline`, and `analyze` also accept a finite time range
276
+ through `within:` instead of explicit `from:` and `to:` versions. Half-open
277
+ ranges are recommended for adjacent reporting windows:
278
+
279
+ ```ruby
280
+ window = Time.zone.parse("2026-08-01")...Time.zone.parse("2026-09-01")
281
+
282
+ steps = PaperTrailDiff.timeline(article, within: window)
283
+
284
+ activity_steps = PaperTrailDiff.activity_timeline(
285
+ article,
286
+ within: window,
287
+ associations: ["comments.replies", :author]
288
+ )
289
+
290
+ analysis = PaperTrailDiff.analyze(
291
+ article,
292
+ within: window,
293
+ associations: ["comments.replies"],
294
+ activity: true
295
+ )
296
+ ```
297
+
298
+ The range selects mutations by the timestamp of their source PaperTrail
299
+ version. Ruby's inclusive (`..`) and exclusive (`...`) end semantics are
300
+ honored. A returned step's `to_boundary` may be after the range: because a
301
+ PaperTrail version is a pre-change snapshot, the gem needs one later root
302
+ version to reveal the final selected mutation. That version is reconstruction
303
+ context, not an additional selected mutation.
304
+
305
+ If the window contains a relevant mutation but no later root version exists,
306
+ the call raises `PaperTrailDiff::IncompleteTimeRangeError`. Create a root
307
+ checkpoint after the reporting window before running historical analysis. The
308
+ gem does not silently substitute current database state. A root-only window
309
+ with no selected mutation returns a frozen empty timeline.
310
+
311
+ Time ranges and explicit `from:`/`to:` endpoints are mutually exclusive.
312
+ Malformed, open-ended, or reversed ranges raise
313
+ `PaperTrailDiff::InvalidTimeRangeError`. With `activity_timeline`, selected
314
+ descendant versions are included even when no root mutation occurred inside
315
+ the window; the later root checkpoint is still required so PT-AT can
316
+ reconstruct the enclosing graph and determine whether descendant activity is
317
+ present. The ordinary `timeline` remains a root-only checkpoint timeline.
170
318
 
171
319
  ## Build an endpoint and timeline together
172
320
 
@@ -191,8 +339,9 @@ analysis.activity_timeline
191
339
  ```
192
340
 
193
341
  Without `activity: true`, `analysis.activity_timeline` is `nil` and no activity
194
- work is performed. `analyze` remains version-bounded; use the standalone
195
- `activity_timeline(..., to: article)` API for an explicit current endpoint.
342
+ work is performed. `analyze` accepts explicit historical versions or
343
+ `within:`, but not a current-record endpoint; use the standalone
344
+ `activity_timeline(..., to: article)` API when the final boundary must be live.
196
345
 
197
346
  `timeline`, `activity_timeline`, and both forms of `analyze` prepare the selected
198
347
  historical range once. The loader walks only the explicit association paths and
@@ -211,20 +360,49 @@ and other unsupported shapes fall back to the ordinary PT-AT point reifier on
211
360
  a per-reflection basis. This hybrid path preserves existing reconstruction
212
361
  behavior while avoiding repeated association queries for supported paths.
213
362
 
214
- Activity event discovery is bounded to the selected range. Prepared scalar
215
- state also retains later successor versions for selected identities because a
216
- PaperTrail version is a pre-change snapshot and may be the only correct state
217
- for an earlier boundary. Memory therefore scales with relevant selected
218
- history, not only with the number of returned steps. Keep requested paths and
219
- ranges intentional. For large histories, applications should give the database
220
- a matching composite index. A typical PaperTrail installation can add one
221
- without making it a requirement of this gem:
363
+ For supported collection events, adjacent snapshots share an immutable
364
+ identity-position index and carry a one-step transition hint. Diffing therefore
365
+ visits only the changed member instead of hashing the whole collection again.
366
+ Prepared scalar history also exposes the predecessor and successor attributes
367
+ for isolated update events, allowing activity reconstruction to update the
368
+ immutable snapshot directly instead of reifying and deserializing the same
369
+ PaperTrail event again. Compatible scalar payloads are decoded into prepared
370
+ state without first constructing a disposable Active Record object. Encrypted,
371
+ schema-mismatched, missing, ambiguous, or identity-changing states fall back to
372
+ the ordinary event reifier.
373
+ For a direct nested `has_many` such as `comments.replies`, the child's foreign
374
+ key also locates its parent snapshot directly rather than walking every
375
+ comment. Membership changes and ambiguous or unsupported relationship shapes
376
+ retain the general comparator and traversal fallback.
377
+
378
+ Activity event loading is bounded to the selected range. Association identity
379
+ discovery retains one indexed checkpoint for members present at the starting
380
+ boundary, then considers later association activity and current members; it no
381
+ longer materializes every pre-range association row. Prepared scalar state also
382
+ retains later successor versions for selected identities because a PaperTrail
383
+ version is a pre-change snapshot and may be the only correct state for an
384
+ earlier boundary. Memory therefore scales with relevant selected history, not
385
+ only with the number of returned steps. Keep requested paths and ranges
386
+ intentional. An activity timeline must also emit a diff for every selected
387
+ event. Repeated events within one wide collection still copy the frozen records
388
+ array when producing each immutable snapshot, so they can do pointer-copying
389
+ work proportional to the number of events times the collection width even when
390
+ SQL and Ruby-level comparison work stay linear. Bound or paginate unusually
391
+ wide activity ranges in latency-sensitive requests.
392
+
393
+ For large histories, applications should give the database a matching
394
+ composite index. A typical PaperTrail installation can add one without making
395
+ it a requirement of this gem:
222
396
 
223
397
  ```ruby
224
398
  add_index :versions, %i[item_type item_id created_at id],
225
399
  name: "index_versions_on_item_and_timeline"
226
400
  ```
227
401
 
402
+ This index is especially useful for repeated `within:` queries because root
403
+ selection is constrained by `item_type`, `item_id`, and `created_at`, then
404
+ ordered deterministically by timestamp and version ID.
405
+
228
406
  PT-AT's normal index beginning with `foreign_key_name`, `foreign_key_id`, and
229
407
  `foreign_type` should also be retained on `version_associations`.
230
408
 
@@ -323,6 +501,61 @@ database state. Unknown names and unsupported macros raise explicit
323
501
  Malformed public options raise `PaperTrailDiff::ConfigurationError`, also under
324
502
  that base error.
325
503
 
504
+ ## Traverse result trees
505
+
506
+ The nested tree remains the canonical, lossless result. For renderers,
507
+ counters, exports, and notifications, `Diff#each_change` provides a
508
+ deterministic depth-first stream of semantic changes:
509
+
510
+ <!-- executable:readme-traversal-changes -->
511
+ ```ruby
512
+ diff.each_change do |entry|
513
+ entry.kind # :attribute_changed, :record_added, ...
514
+ entry.association_path # ["comments", "replies"]
515
+ entry.record_path # frozen RecordReference objects
516
+ entry.association_kind # :has_many, :belongs_to, ...
517
+ entry.attribute # "body" for an attribute entry
518
+ entry.value # ValueChange, RecordChange, or RecordSnapshot
519
+ end
520
+
521
+ counts = diff.each_change.map(&:kind).tally
522
+ ```
523
+
524
+ `record_changed` entries are emitted before that record's attribute and nested
525
+ association changes, allowing an application to count both changed records and
526
+ changed fields. Singular relationship operations are classified separately as
527
+ `relationship_added`, `relationship_removed`, or `relationship_replaced`.
528
+ Root create/delete transitions remain `record_presence_changed`.
529
+
530
+ Added, removed, and replaced records carry complete bounded snapshots. Use
531
+ `each_entry` when a renderer also needs that nested state without writing a
532
+ second snapshot walker:
533
+
534
+ <!-- executable:readme-traversal-entries -->
535
+ ```ruby
536
+ diff.each_entry do |entry|
537
+ next unless entry.included_state?
538
+
539
+ entry.kind # :record_included, :attribute_included, :association_included
540
+ entry.state # :before or :after
541
+ entry.context # :included_state
542
+ end
543
+ ```
544
+
545
+ An included record is historical context, not evidence that the nested record
546
+ changed independently. `each_change` therefore omits included-state entries.
547
+ Both methods return an `Enumerator` when no block is supplied and return the
548
+ diff when called with a block. Entries and their paths are immutable and have
549
+ deterministic `to_h` output. The root location uses empty association and record
550
+ paths; descendant `record_path` values contain only the explicitly traversed
551
+ descendant identities.
552
+
553
+ Traversal is path-preserving and does not deduplicate. If the same record is
554
+ reachable through two selected paths, it appears at both locations so the
555
+ consumer can choose whether path identity or record identity controls counting.
556
+ Walking a result never performs database access and never discovers additional
557
+ associations.
558
+
326
559
  ## Discover and diagnose associations
327
560
 
328
561
  Configuration UIs can use bounded public reflection instead of duplicating the
@@ -392,6 +625,7 @@ The public result types are:
392
625
  - `PaperTrailDiff::ActivityBoundary`
393
626
  - `PaperTrailDiff::Analysis`
394
627
  - `PaperTrailDiff::ValueChange`
628
+ - `PaperTrailDiff::TraversalEntry`
395
629
  - `PaperTrailDiff::RecordReference`
396
630
  - `PaperTrailDiff::RecordSnapshot`
397
631
  - `PaperTrailDiff::RecordChange`
@@ -404,11 +638,13 @@ The public result types are:
404
638
  They expose readers, are frozen after construction, and provide deterministic
405
639
  `to_h` output. Structural hash keys are symbols; attribute and association
406
640
  names are strings. Attribute values retain their Ruby types. `RecordChange#record`
407
- is a `RecordReference` with `type` and `id` readers. `Step` itself is frozen but
408
- intentionally retains the original, potentially mutable PaperTrail version
409
- objects for metadata access; `Step#to_h` emits only their IDs. `ActivityStep`
410
- instead retains only immutable boundary metadata and serializes as
411
- `{ from: ..., to: ..., diff: ... }`.
641
+ is a `RecordReference` with `type` and `id` readers. `TraversalEntry#record` and
642
+ `#association` return the final components of their corresponding paths. `Step`
643
+ itself is frozen but intentionally retains the original, potentially mutable
644
+ PaperTrail version objects for metadata access; its new boundary readers provide
645
+ an immutable presentation interface, while `Step#to_h` continues to emit only
646
+ version IDs. `ActivityStep` retains only immutable boundary metadata and
647
+ serializes as `{ from: ..., to: ..., diff: ... }`.
412
648
 
413
649
  ## Historical correctness and limitations
414
650
 
@@ -459,6 +695,9 @@ mise exec -- act push -j quality --matrix ruby:4.0 --matrix paper_trail:17
459
695
 
460
696
  The default Rake task runs core specs without PT-AT loaded, association specs in
461
697
  a separate process, RuboCop, generated-signature verification, and Steep.
698
+ Selected Ruby blocks in this README and the quickstart are executed in those
699
+ same isolated test sessions; the `<!-- executable:... -->` marker opts a block
700
+ into the appropriate stateful session.
462
701
  Inline `#:` annotations in `lib/` generate the committed RBS files under
463
702
  `sig/generated/`.
464
703
 
@@ -9,6 +9,9 @@ module PaperTrailDiff
9
9
  attr_reader :item_type #: String
10
10
  attr_reader :item_id #: untyped
11
11
  attr_reader :recorded_at #: untyped
12
+ attr_reader :event #: String?
13
+ attr_reader :whodunnit #: untyped
14
+ attr_reader :record #: RecordReference
12
15
 
13
16
  class << self
14
17
  #: (untyped) -> ActivityBoundary
@@ -18,7 +21,9 @@ module PaperTrailDiff
18
21
  version_id: version.id,
19
22
  item_type: version.item_type,
20
23
  item_id: version.item_id,
21
- recorded_at: version.created_at
24
+ recorded_at: version.created_at,
25
+ event: version.event,
26
+ whodunnit: version.whodunnit
22
27
  )
23
28
  end
24
29
 
@@ -34,16 +39,37 @@ module PaperTrailDiff
34
39
  end
35
40
  end
36
41
 
37
- #: (kind: Symbol, version_id: untyped, item_type: untyped, item_id: untyped, recorded_at: untyped) -> void
38
- def initialize(kind:, version_id:, item_type:, item_id:, recorded_at:)
42
+ #: (kind: Symbol, version_id: untyped, item_type: untyped, item_id: untyped, recorded_at: untyped, ?event: untyped, ?whodunnit: untyped) -> void
43
+ def initialize( # rubocop:disable Metrics/ParameterLists
44
+ kind:,
45
+ version_id:,
46
+ item_type:,
47
+ item_id:,
48
+ recorded_at:,
49
+ event: nil,
50
+ whodunnit: nil
51
+ )
39
52
  @kind = kind
40
53
  @version_id = Support.immutable_copy(version_id)
41
54
  @item_type = Support.immutable_copy(item_type.to_s)
42
55
  @item_id = Support.immutable_copy(item_id)
43
56
  @recorded_at = Support.immutable_copy(recorded_at)
57
+ @event = Support.immutable_copy(event&.to_s)
58
+ @whodunnit = Support.immutable_copy(whodunnit)
59
+ @record = RecordReference.new(type: @item_type, id: @item_id)
44
60
  freeze
45
61
  end
46
62
 
63
+ #: () -> bool
64
+ def version?
65
+ kind == :version
66
+ end
67
+
68
+ #: () -> bool
69
+ def current?
70
+ kind == :current
71
+ end
72
+
47
73
  #: () -> Hash[Symbol, untyped]
48
74
  def to_h
49
75
  {
@@ -70,6 +96,11 @@ module PaperTrailDiff
70
96
  freeze
71
97
  end
72
98
 
99
+ #: () -> bool
100
+ def empty?
101
+ diff.empty?
102
+ end
103
+
73
104
  #: () -> Hash[Symbol, untyped]
74
105
  def to_h
75
106
  {
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Loads direct-child identities active at or after one activity boundary.
6
+ class ActivityChildCandidateLoader
7
+ #: (untyped, Array[untyped], untyped, ActivityRange) -> void
8
+ def initialize(parent_class, parent_ids, reflection, range)
9
+ @parent_class = parent_class
10
+ @parent_ids = parent_ids
11
+ @reflection = reflection
12
+ @range = range
13
+ end
14
+
15
+ #: () -> Array[untyped]
16
+ def call
17
+ historical_ids | current_ids
18
+ end
19
+
20
+ private
21
+
22
+ # @rbs @parent_class: untyped
23
+ # @rbs @parent_ids: Array[untyped]
24
+ # @rbs @reflection: untyped
25
+ # @rbs @range: ActivityRange
26
+
27
+ #: () -> Array[untyped]
28
+ def historical_ids
29
+ version_class = @parent_class.paper_trail.version_class
30
+ candidate_scope(version_class).call(historical_relation(version_class))
31
+ .distinct.pluck(version_class.arel_table[:item_id])
32
+ end
33
+
34
+ #: (untyped) -> untyped
35
+ def historical_relation(version_class)
36
+ @parent_class.paper_trail.version_association_class.joins(:version).where(
37
+ foreign_key_name: @reflection.foreign_key.to_s,
38
+ foreign_key_id: @parent_ids,
39
+ foreign_type: parent_types
40
+ ).where(version_class.table_name => { item_type: child_item_type })
41
+ end
42
+
43
+ #: (untyped) -> VersionAssociationCandidateScope
44
+ def candidate_scope(version_class)
45
+ VersionAssociationCandidateScope.new(
46
+ version_class,
47
+ @range.start_time,
48
+ end_at: @range.end_time
49
+ )
50
+ end
51
+
52
+ #: () -> Array[untyped]
53
+ def current_ids
54
+ return [] unless @range.current_end?
55
+
56
+ relation = @reflection.klass.base_class.unscoped.where(
57
+ @reflection.foreign_key => @parent_ids
58
+ )
59
+ relation = relation.where(@reflection.type => parent_types) if @reflection.options[:as]
60
+ relation.distinct.pluck(@reflection.klass.primary_key)
61
+ end
62
+
63
+ #: () -> String
64
+ def child_item_type
65
+ @reflection.klass.base_class.name
66
+ end
67
+
68
+ #: () -> Array[String?]
69
+ def parent_types
70
+ [nil, '', @parent_class.name.to_s, @parent_class.base_class.name.to_s].uniq
71
+ end
72
+ end
73
+ end