paper_trail_diff 0.2.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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -0
  3. data/QUICKSTART.md +44 -2
  4. data/README.md +179 -14
  5. data/lib/paper_trail_diff/activity_child_candidate_loader.rb +73 -0
  6. data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +261 -45
  7. data/lib/paper_trail_diff/activity_history.rb +136 -0
  8. data/lib/paper_trail_diff/activity_range.rb +28 -12
  9. data/lib/paper_trail_diff/activity_snapshot_delta.rb +64 -0
  10. data/lib/paper_trail_diff/activity_snapshot_sequence.rb +3 -3
  11. data/lib/paper_trail_diff/activity_timeline_builder.rb +54 -45
  12. data/lib/paper_trail_diff/activity_version_collector.rb +14 -17
  13. data/lib/paper_trail_diff/branch_snapshot_refresher.rb +5 -3
  14. data/lib/paper_trail_diff/collection_comparator.rb +155 -0
  15. data/lib/paper_trail_diff/collection_identity_index.rb +53 -0
  16. data/lib/paper_trail_diff/collection_transition.rb +25 -0
  17. data/lib/paper_trail_diff/comparison_batch.rb +151 -0
  18. data/lib/paper_trail_diff/engine.rb +5 -45
  19. data/lib/paper_trail_diff/errors.rb +9 -0
  20. data/lib/paper_trail_diff/historical_snapshot_store.rb +55 -3
  21. data/lib/paper_trail_diff/instrumentation.rb +30 -0
  22. data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +133 -0
  23. data/lib/paper_trail_diff/live_endpoint_provider.rb +48 -0
  24. data/lib/paper_trail_diff/live_graph_collector.rb +52 -0
  25. data/lib/paper_trail_diff/paper_trail_adapter.rb +61 -34
  26. data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +48 -0
  27. data/lib/paper_trail_diff/prepared_edge_loader.rb +54 -17
  28. data/lib/paper_trail_diff/prepared_history.rb +14 -4
  29. data/lib/paper_trail_diff/prepared_history_loader.rb +17 -9
  30. data/lib/paper_trail_diff/prepared_record_index.rb +138 -10
  31. data/lib/paper_trail_diff/snapshot.rb +66 -3
  32. data/lib/paper_trail_diff/support.rb +18 -0
  33. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +111 -0
  34. data/lib/paper_trail_diff/time_range.rb +85 -0
  35. data/lib/paper_trail_diff/time_version_range.rb +53 -0
  36. data/lib/paper_trail_diff/timeline_builder.rb +13 -7
  37. data/lib/paper_trail_diff/timeline_range.rb +72 -0
  38. data/lib/paper_trail_diff/version.rb +1 -1
  39. data/lib/paper_trail_diff/version_association_candidate_scope.rb +73 -0
  40. data/lib/paper_trail_diff.rb +74 -12
  41. data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +40 -0
  42. data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +49 -8
  43. data/sig/generated/paper_trail_diff/activity_history.rbs +79 -0
  44. data/sig/generated/paper_trail_diff/activity_range.rbs +14 -5
  45. data/sig/generated/paper_trail_diff/activity_snapshot_delta.rbs +33 -0
  46. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +2 -2
  47. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +15 -10
  48. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +2 -5
  49. data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
  50. data/sig/generated/paper_trail_diff/collection_comparator.rbs +59 -0
  51. data/sig/generated/paper_trail_diff/collection_identity_index.rbs +29 -0
  52. data/sig/generated/paper_trail_diff/collection_transition.rbs +18 -0
  53. data/sig/generated/paper_trail_diff/comparison_batch.rbs +65 -0
  54. data/sig/generated/paper_trail_diff/engine.rbs +0 -12
  55. data/sig/generated/paper_trail_diff/errors.rbs +12 -0
  56. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +22 -2
  57. data/sig/generated/paper_trail_diff/instrumentation.rbs +12 -0
  58. data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +52 -0
  59. data/sig/generated/paper_trail_diff/live_endpoint_provider.rbs +23 -0
  60. data/sig/generated/paper_trail_diff/live_graph_collector.rbs +27 -0
  61. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +21 -10
  62. data/sig/generated/paper_trail_diff/preloaded_endpoint_batch_loader.rbs +24 -0
  63. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +22 -8
  64. data/sig/generated/paper_trail_diff/prepared_history.rbs +6 -0
  65. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +4 -2
  66. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +61 -6
  67. data/sig/generated/paper_trail_diff/snapshot.rbs +37 -2
  68. data/sig/generated/paper_trail_diff/support.rbs +6 -0
  69. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +46 -0
  70. data/sig/generated/paper_trail_diff/time_range.rbs +41 -0
  71. data/sig/generated/paper_trail_diff/time_version_range.rbs +27 -0
  72. data/sig/generated/paper_trail_diff/timeline_builder.rbs +6 -5
  73. data/sig/generated/paper_trail_diff/timeline_range.rbs +41 -0
  74. data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +32 -0
  75. data/sig/generated/paper_trail_diff.rbs +16 -8
  76. metadata +38 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 808e6dba6570e96679cb5b355b774e8afcd3b6bf81c38c1a0b0fc088d6f9f416
4
- data.tar.gz: 5978755e10f821777aadc86b6f4d79728a827cf48c1ba3e95c89a6f627eaea6e
3
+ metadata.gz: 4d60569584c3d30bd35c62c1f0f699fd8859545d81f873c926dfdd8268aed3df
4
+ data.tar.gz: 4961a77099927fff3476641b0409cb234e1f398e9b84e9cb3cc9e11b2d580064
5
5
  SHA512:
6
- metadata.gz: '048b927dd9d8274675ae60692ad705ed586c9d31ed6f4c11a581521d86ff8621dedda7daf33a4e4c90223b31a42bc60184c6f376ed4b1363a3cfcb5a2b2ab65a'
7
- data.tar.gz: 559ea7552ce0133864a3e55df66097fdbbee3315febf9d04cd7485e0caa14e9deb34625b48ecb660f00cd452ec00b4e12d1180689c88a2b2464e59e71182abb5
6
+ metadata.gz: 2cf13bffaca3d199b2332b0e3d1c7c7c00ef7c6f82a4c9e5a75c51f376889ff57c60e1bfab1f578a7122e972698318d2720bce5f8da2755abea50aebf6e7083e
7
+ data.tar.gz: a071cd71f7fb766105eda1e018991ed8030885822da23382404797984b265818c0012378aea5912a507fb7bc24e9294331201b0cf1918860df3d9613476d580d
data/CHANGELOG.md CHANGED
@@ -3,6 +3,53 @@
3
3
  All notable changes to this project will be documented in this file. The
4
4
  project follows [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [0.3.0] - 2026-08-10
7
+
8
+ ### Added
9
+
10
+ - Add `within:` time-range selection to checkpoint timelines, activity
11
+ timelines, and combined analysis, with inclusive/exclusive end handling and
12
+ explicit errors when a final mutation cannot be reconstructed.
13
+ - Add `compare_many` for collection reports, with batched current-root loading,
14
+ bounded live-association preloading, cross-root prepared history, immutable
15
+ identity-keyed results, and scale-invariant query regression coverage.
16
+ - Add opt-in reuse of fully preloaded current endpoints and namespaced
17
+ ActiveSupport runtime instrumentation without automatic logging, including
18
+ end-to-end activity-timeline duration and step counts.
19
+
20
+ ### Changed
21
+
22
+ - Teach timeline filtering through the shared `Step#empty?` protocol in the
23
+ README and quickstart examples.
24
+ - Reuse the live graph loaded by `compare_many` while preparing historical
25
+ association state, and defer root version loading until an edge needs it.
26
+ - Reuse collection identity positions and adjacent transition hints so activity
27
+ steps compare only the changed member, and resolve direct nested collection
28
+ owners by foreign key instead of scanning every parent snapshot.
29
+ - Reuse prepared predecessor and successor scalar states for isolated activity
30
+ updates instead of reifying and deserializing each PaperTrail event again.
31
+ - Preserve both sides of nested collection membership moves by using the
32
+ general comparator when one event changes multiple parent snapshots.
33
+ - Preserve per-owner `limit`, `offset`, and owner-dependent association scopes
34
+ in `compare_many` while retaining batched preloading for safe branches.
35
+ - Replace lifetime association-identity materialization with an indexed start-state
36
+ checkpoint plus post-boundary activity and current members.
37
+ - Bound historical activity-child candidates at the selected end so membership
38
+ changes after the range cannot introduce unrelated empty timeline steps.
39
+ - Decode compatible prepared scalar version payloads without constructing an
40
+ intermediate Active Record object, with reification fallback for unsafe schemas.
41
+ - Reuse internally owned frozen collection arrays instead of defensively copying
42
+ them a second time during immutable activity reconstruction.
43
+ - Replace pre-existing versions-relation ordering when selecting a time range's
44
+ immediate trailing reconstruction boundary.
45
+ - Limit automatic CI to pull requests and `main` pushes, and cancel superseded
46
+ runs for the same pull request or ref.
47
+
48
+ ### Fixed
49
+
50
+ - Supply TZInfo's timezone database to the test bundle so zoned-time coverage
51
+ runs on Windows as well as systems with a native zoneinfo database.
52
+
6
53
  ## [0.2.0] - 2026-08-09
7
54
 
8
55
  ### Added
data/QUICKSTART.md CHANGED
@@ -118,17 +118,42 @@ steps.map do |step|
118
118
  end
119
119
  # => [["Draft", "Published"], ["Published", "Final"]]
120
120
 
121
- visible_steps = steps.reject { |step| step.diff.empty? }
121
+ visible_steps = steps.reject(&:empty?)
122
122
  ```
123
123
 
124
124
  Empty steps remain in the timeline. Filter them only when the application's
125
125
  display does not need every version boundary.
126
126
 
127
+ ### Select mutations by time
128
+
129
+ Use `within:` when the application has a reporting window rather than saved
130
+ endpoint versions:
131
+
132
+ <!-- executable:quickstart-time-range -->
133
+ ```ruby
134
+ window = draft_version.created_at...final_version.created_at
135
+ ranged_steps = PaperTrailDiff.timeline(article, within: window)
136
+
137
+ ranged_steps.map do |step|
138
+ change = step.diff.attributes["title"]
139
+ [change.from, change.to]
140
+ end
141
+ # => [["Draft", "Published"], ["Published", "Final"]]
142
+ ```
143
+
144
+ The half-open range selects the mutations recorded by `draft_version` and
145
+ `published_version`. The excluded `final_version` is still used as the trailing
146
+ boundary needed to reveal the last selected mutation. PaperTrail versions are
147
+ pre-change snapshots, so a range containing mutations must have a later root
148
+ version. The gem raises `PaperTrailDiff::IncompleteTimeRangeError` instead of
149
+ assuming that the current record is the missing endpoint.
150
+
127
151
  ## 6. Choose the right API
128
152
 
129
153
  | Need | Call |
130
154
  | --- | --- |
131
155
  | Net difference between two endpoints | `compare` |
156
+ | Net differences for many current records | `compare_many` |
132
157
  | One step per root-record version | `timeline` |
133
158
  | Steps for root and selected child versions | `activity_timeline` |
134
159
  | Net diff and timelines from one history pass | `analyze` |
@@ -228,7 +253,7 @@ steps = PaperTrailDiff.activity_timeline(
228
253
  associations: ["comments"]
229
254
  )
230
255
 
231
- steps.reject { |step| step.diff.empty? }.each do |step|
256
+ steps.reject(&:empty?).each do |step|
232
257
  boundary = step.to_boundary
233
258
  puts "#{boundary.item_type} ##{boundary.item_id}"
234
259
  end
@@ -237,10 +262,27 @@ end
237
262
  Use a later transaction-backed root checkpoint instead of `to: current` when
238
263
  the result must remain reproducible after the database changes again.
239
264
 
265
+ For a historical reporting window, replace `from:` and `to:` with `within:`:
266
+
267
+ ```ruby
268
+ steps = PaperTrailDiff.activity_timeline(
269
+ article,
270
+ within: report_start...report_end,
271
+ associations: ["comments"]
272
+ )
273
+ ```
274
+
275
+ This selects versioned comment activity directly, even if the article had no
276
+ mutation during the window. Take a root checkpoint after `report_end`; it gives
277
+ the gem the historical context needed to expose the final child mutation.
278
+
240
279
  ## 9. Common surprises
241
280
 
242
281
  - PaperTrail stores pre-change snapshots. The current record is not represented
243
282
  by `versions.last`; pass the record explicitly when current state is wanted.
283
+ - A `within:` window containing mutations needs a later root checkpoint. The
284
+ trailing version is reconstruction context and may appear as a step's ending
285
+ boundary even though its own mutation is outside the window.
244
286
  - `ignore:` replaces the default list. Include `updated_at` yourself when using
245
287
  a custom list and you still want it ignored.
246
288
  - Historical associations require PT-AT to be installed, loaded, migrated, and
data/README.md CHANGED
@@ -82,6 +82,95 @@ association caches and in-memory edits are not compared. Use a database
82
82
  transaction with an appropriate isolation level when several live association
83
83
  queries must represent one atomic application snapshot.
84
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
+
85
174
  ## Build a root-checkpoint timeline
86
175
 
87
176
  `timeline` accepts two version objects from the supplied record's history. The
@@ -133,7 +222,7 @@ steps = PaperTrailDiff.activity_timeline(
133
222
  associations: ["comments.replies", :author]
134
223
  )
135
224
 
136
- steps.reject { |step| step.diff.empty? }
225
+ steps.reject(&:empty?)
137
226
  ```
138
227
 
139
228
  Pass the record explicitly as `to:` to include current state without creating a
@@ -177,9 +266,55 @@ intermediate events without transaction-backed owner checkpoints. Historical
177
266
  HABTM activity and `compare(version, article, associations: [:tags])` remain
178
267
  supported where the historical endpoint has usable PT-AT metadata.
179
268
 
180
- Both timeline APIs preserve empty boundaries. A display may filter
181
- `step.diff.empty?`, while audit-oriented callers can retain every recorded
182
- 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.
183
318
 
184
319
  ## Build an endpoint and timeline together
185
320
 
@@ -204,8 +339,9 @@ analysis.activity_timeline
204
339
  ```
205
340
 
206
341
  Without `activity: true`, `analysis.activity_timeline` is `nil` and no activity
207
- work is performed. `analyze` remains version-bounded; use the standalone
208
- `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.
209
345
 
210
346
  `timeline`, `activity_timeline`, and both forms of `analyze` prepare the selected
211
347
  historical range once. The loader walks only the explicit association paths and
@@ -224,20 +360,49 @@ and other unsupported shapes fall back to the ordinary PT-AT point reifier on
224
360
  a per-reflection basis. This hybrid path preserves existing reconstruction
225
361
  behavior while avoiding repeated association queries for supported paths.
226
362
 
227
- Activity event discovery is bounded to the selected range. Prepared scalar
228
- state also retains later successor versions for selected identities because a
229
- PaperTrail version is a pre-change snapshot and may be the only correct state
230
- for an earlier boundary. Memory therefore scales with relevant selected
231
- history, not only with the number of returned steps. Keep requested paths and
232
- ranges intentional. For large histories, applications should give the database
233
- a matching composite index. A typical PaperTrail installation can add one
234
- 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:
235
396
 
236
397
  ```ruby
237
398
  add_index :versions, %i[item_type item_id created_at id],
238
399
  name: "index_versions_on_item_and_timeline"
239
400
  ```
240
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
+
241
406
  PT-AT's normal index beginning with `foreign_key_name`, `foreign_key_id`, and
242
407
  `foreign_type` should also be retained on `version_associations`.
243
408
 
@@ -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