paper_trail_diff 0.5.0 → 0.7.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +52 -0
  3. data/README.md +146 -7
  4. data/lib/paper_trail_diff/activity_root_steps.rb +19 -7
  5. data/lib/paper_trail_diff/activity_timeline_builder.rb +15 -8
  6. data/lib/paper_trail_diff/analysis.rb +19 -5
  7. data/lib/paper_trail_diff/analysis_batch.rb +35 -13
  8. data/lib/paper_trail_diff/batched_root_analyzer.rb +9 -9
  9. data/lib/paper_trail_diff/batched_root_versions.rb +62 -29
  10. data/lib/paper_trail_diff/paper_trail_adapter.rb +72 -25
  11. data/lib/paper_trail_diff/root_version_plan.rb +99 -0
  12. data/lib/paper_trail_diff/root_version_selection.rb +155 -0
  13. data/lib/paper_trail_diff/step.rb +14 -5
  14. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +99 -29
  15. data/lib/paper_trail_diff/time_version_range.rb +22 -24
  16. data/lib/paper_trail_diff/timeline_builder.rb +32 -20
  17. data/lib/paper_trail_diff/timeline_range.rb +34 -7
  18. data/lib/paper_trail_diff/timeline_snapshot_provider.rb +20 -4
  19. data/lib/paper_trail_diff/version.rb +1 -1
  20. data/lib/paper_trail_diff/version_range.rb +45 -5
  21. data/lib/paper_trail_diff/version_scope_filter.rb +37 -0
  22. data/lib/paper_trail_diff.rb +32 -13
  23. data/sig/generated/paper_trail_diff/activity_root_steps.rbs +7 -2
  24. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +6 -2
  25. data/sig/generated/paper_trail_diff/analysis.rbs +13 -4
  26. data/sig/generated/paper_trail_diff/analysis_batch.rbs +15 -6
  27. data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +4 -4
  28. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +28 -14
  29. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +26 -10
  30. data/sig/generated/paper_trail_diff/root_version_plan.rbs +66 -0
  31. data/sig/generated/paper_trail_diff/root_version_selection.rbs +88 -0
  32. data/sig/generated/paper_trail_diff/step.rbs +5 -2
  33. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +41 -15
  34. data/sig/generated/paper_trail_diff/time_version_range.rbs +9 -9
  35. data/sig/generated/paper_trail_diff/timeline_builder.rbs +9 -6
  36. data/sig/generated/paper_trail_diff/timeline_range.rbs +13 -2
  37. data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +11 -2
  38. data/sig/generated/paper_trail_diff/version_range.rbs +17 -2
  39. data/sig/generated/paper_trail_diff/version_scope_filter.rbs +22 -0
  40. data/sig/generated/paper_trail_diff.rbs +8 -8
  41. metadata +10 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a4d3c1e15ef20c4e3d05f5c0442d7e55cd0eb811a15766d2119a3cf35b2e729c
4
- data.tar.gz: 4cc8dceb10e7aa71f1feb7d9fcc3917040bec4b2cc69641b4b3817d6761aad94
3
+ metadata.gz: 33b445f893d6d95faf0ad22b72d35e8b248f3c00240e537898cac2d067ece043
4
+ data.tar.gz: 57ee0b8fc242825852726933a9aa332cd309c38d2af21724a41c4bfcfbf2bd41
5
5
  SHA512:
6
- metadata.gz: 3887a515ee90f1c14b890a21d4a91b4892f4097b74c9ec27f136fd8476f01cd285c0a310a03b0c528e709cdca299bca2e243522844d09a86d272c043e48879b1
7
- data.tar.gz: a1390db78cc31cbf20420fd18b536ad8cd826de4927943cbdddc08b7a31f9dad5f81acee77db97059f044ad435bd3170e54f8ef2ccd5c68f3e256bfa5d848642
6
+ metadata.gz: bbbdea8be4b4300705da0f915dbe3b61ede862817fa27c44f0371c8454f9995f20b16120acc43cf0272f8fdecfdef4fa5fce160642812805a2c636f6f6acf9a0
7
+ data.tar.gz: e6f9a063c376a1214b1deacbd0ea15e325e82a1cccecd86d3d747268471bac0b20585249a4dcb1f039730f39bb019cc35a7e12a2e0bbfb963d4cc856acd6d2ef
data/CHANGELOG.md CHANGED
@@ -3,6 +3,58 @@
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.7.0] - 2026-08-11
7
+
8
+ ### Added
9
+
10
+ - Accept `close_on: :current` on `timeline`, `activity_timeline`, `analyze`, and
11
+ `analyze_many`, closing a wall-clock window on the live record when no later
12
+ root version can reveal its final mutation. A window ending at the present
13
+ otherwise raises `IncompleteTimeRangeError`, since a version records the state
14
+ before its own event and nothing follows the last one. The closing step's
15
+ `to_boundary.kind` is `:current` and its `to_version` is `nil`; `Step#to_h`
16
+ now carries `to_boundary` so a serialized step still names its endpoint. Only
17
+ valid alongside `within:`, because an explicit `from:`/`to:` range already
18
+ says where it ends. Under `activity: true` the window runs to the instant
19
+ state is captured rather than to the last root version, so a descendant that
20
+ moved after that version is reported rather than dropped. A destroyed root has
21
+ no current state to close on and keeps closing on its own destruction.
22
+
23
+ ### Changed
24
+
25
+ - Document that `version_scope:` selects root versions only, and that "what did
26
+ this person change?" is answered by an activity timeline filtered on
27
+ `step.from_boundary.whodunnit` instead. A step's diff is exactly what the
28
+ event at that boundary did, so the predicate covers descendant edits, which a
29
+ root-version filter cannot see at all. Filtering in Ruby rather than selecting
30
+ fewer versions is also what keeps the attribution correct: every boundary has
31
+ to be reconstructed, or the snapshot carried into the next step is a state the
32
+ record had already moved past.
33
+
34
+ ## [0.6.0] - 2026-08-11
35
+
36
+ ### Added
37
+
38
+ - Accept `version_scope:` on `timeline`, `activity_timeline`, `analyze`, and
39
+ `analyze_many`, narrowing which root versions count as selected mutations.
40
+ Excluded versions are still loaded, because a version records the state before
41
+ its own event and the one following a selected change is what reveals it;
42
+ their own changes are never attributed to a selected mutation. Each selected
43
+ mutation is bounded by the version that immediately followed it rather than by
44
+ the next selected one, so its diff is exactly what that mutation did however
45
+ many excluded changes follow it. A selected mutation nothing follows yet is
46
+ not reported, since no version records the state it produced; a selected
47
+ destruction is the exception, because the absence it leaves is what it
48
+ produced and `activity_timeline` closes on it. A root left with
49
+ no selected mutation reports an empty `Analysis`. Under `activity: true` the
50
+ filter decides the span; `activity_timeline` still lists every boundary inside
51
+ it, because dropping one would fold its change into a neighbouring step.
52
+ - Expose `from_snapshot` and `to_snapshot` on `Analysis`, the reconstructed
53
+ states its diff was taken between, so a report can render unchanged columns
54
+ without selecting versions or reifying them itself. `Analysis#to_h` keeps its
55
+ shape by default and takes `snapshots: true` to include them, because they
56
+ carry the whole selected graph whether or not anything changed.
57
+
6
58
  ## [0.5.0] - 2026-08-11
7
59
 
8
60
  ### Added
data/README.md CHANGED
@@ -324,6 +324,58 @@ value is the same `Analysis` that `analyze` returns for one record. A root with
324
324
  no versions inside the window gets an empty `Analysis` rather than raising, so a
325
325
  listing page needs no special case. Root identities must be unique.
326
326
 
327
+ ### Reporting on a subset of root mutations
328
+
329
+ `version_scope:` narrows which root versions count as *selected mutations*, so a
330
+ checkpoint timeline reports only some of them:
331
+
332
+ ```ruby
333
+ user_edits = ->(scope) { scope.where.not(whodunnit: nil) }
334
+
335
+ PaperTrailDiff.analyze_many(articles, within: window, version_scope: user_edits)
336
+ PaperTrailDiff.timeline(article, within: window, version_scope: user_edits)
337
+ ```
338
+
339
+ The hook receives the version relation for the range and returns a narrowed one.
340
+ It is accepted by `timeline`, `activity_timeline`, `analyze`, and
341
+ `analyze_many`, with any range form.
342
+
343
+ **It sees root versions only.** Someone who edited only comments or tags has no
344
+ root versions to select, so filtering to them yields an empty result even though
345
+ they changed plenty. For "what did this person change?", reach for
346
+ [one person's changes](#reporting-on-one-persons-changes) instead — this hook
347
+ answers the different question of which *root checkpoints* a timeline reports.
348
+
349
+ It filters *selected mutations only*. Versions the filter excludes are still
350
+ loaded, because a version records the state before its own event: without the
351
+ one that follows a selected change, whatever that change produced cannot be
352
+ shown at all. Those extra versions are reconstruction context, so their own
353
+ changes are never attributed to a selected mutation.
354
+
355
+ Each selected mutation is bounded by the version that immediately followed it,
356
+ not by the next selected one. So each step's diff is exactly what that mutation
357
+ did, and it reads the same however many excluded changes happen to follow it:
358
+
359
+ ```
360
+ versions system → alice → system → bob → system
361
+ reported alice: what alice changed bob: what bob changed
362
+ ```
363
+
364
+ Given a user edit followed by a system edit, filtering to user changes yields
365
+ one step running from the user version to the system version, whose diff is
366
+ exactly the user's change. A selected mutation that nothing follows yet is not
367
+ reported, since no version records the state it produced — the same blind spot
368
+ an unfiltered timeline has at its `to:` boundary. A selected *destruction* is
369
+ the exception: nothing can follow one, but the absence it leaves is what it
370
+ produced, so `activity_timeline` still closes on it. A root with no selected
371
+ mutation reports an empty `Analysis` rather than raising.
372
+
373
+ The hook applies to root versions only. Under `activity: true` the filter
374
+ decides where the span starts and ends, and `timeline` within that span reports
375
+ only selected mutations, but `activity_timeline` still lists every boundary
376
+ inside it — dropping one would fold the change it carried into a neighbouring
377
+ step and credit it to whoever made that one.
378
+
327
379
  Omit `within:` to analyze each root's whole recorded history instead, which is
328
380
  the batched equivalent of `analyze(record, from: :first, to: :last)`. Explicit
329
381
  version endpoints are not accepted, because a single pair cannot mean the same
@@ -339,6 +391,49 @@ batches far less.
339
391
  Roots are supplied as live records, so a root deleted inside the window cannot
340
392
  be included; use `activity_timeline` for a history that ends in a deletion.
341
393
 
394
+ ### Reporting on one person's changes
395
+
396
+ An activity timeline already carries this. Every boundary in the span becomes a
397
+ step, and a step's diff is exactly what the event at its `from_boundary` did, so
398
+ that boundary's `whodunnit` is who made the change:
399
+
400
+ <!-- executable:readme-person-changes -->
401
+ ```ruby
402
+ steps = PaperTrailDiff.activity_timeline(
403
+ article, within: window, associations: %i[comments tags]
404
+ )
405
+
406
+ attributed = steps.reject(&:empty?)
407
+ by_priya = attributed.select { |step| step.from_boundary.whodunnit == "Priya Shah" }
408
+
409
+ by_priya.first.from_boundary.whodunnit # "Priya Shah"
410
+ by_priya.first.diff # exactly what that one event changed
411
+
412
+ authors = attributed.group_by { |step| step.from_boundary.whodunnit }
413
+ .transform_values(&:length)
414
+ ```
415
+
416
+ This covers descendants, which is what `version_scope:` cannot do. On one demo
417
+ history a contributor who only ever touched comments and authorships has four
418
+ attributed activity steps and *zero* root steps.
419
+
420
+ Filter in Ruby rather than asking the gem to select fewer versions. Attribution
421
+ is only correct because every boundary is reconstructed: skip one and the
422
+ snapshot carried into the next step is a state the record had already moved
423
+ past, so the change lands on the wrong person. There is nothing to save by
424
+ filtering earlier — the work is the reconstruction, not the comparison — and a
425
+ predicate can say things a version relation cannot, such as several people at
426
+ once or "anyone but the importer".
427
+
428
+ `reject(&:empty?)` first, because a boundary that changed nothing you selected
429
+ still produces a step. The final event in the range has no boundary after it, so
430
+ what it produced is not shown — the same blind spot `to:` has everywhere else.
431
+
432
+ For "which of these records did someone touch?" across a listing, note that
433
+ `analyze_many` batches the diff and timeline views but not descendant discovery.
434
+ Querying versions by `whodunnit` and mapping them back to roots yourself will be
435
+ much cheaper than an activity pass per record.
436
+
342
437
  ## Build a root-checkpoint timeline
343
438
 
344
439
  `timeline` accepts two version objects from the supplied record's history, or
@@ -533,12 +628,39 @@ version to reveal the final selected mutation. That version is reconstruction
533
628
  context, not an additional selected mutation.
534
629
 
535
630
  If the window contains a relevant mutation but no later root version exists,
536
- the call raises `PaperTrailDiff::IncompleteTimeRangeError`. Create a root
537
- checkpoint after the reporting window before running historical analysis. The
538
- gem does not silently substitute current database state. A root-only window
539
- with no selected mutation returns a frozen empty timeline. The one exception is
540
- a window that closes on the root's own destruction, which no later version can
541
- ever follow; see [closing a destroyed root](#closing-a-destroyed-root).
631
+ the call raises `PaperTrailDiff::IncompleteTimeRangeError`. The gem does not
632
+ silently substitute current database state; pass
633
+ [`close_on: :current`](#reporting-up-to-now) to ask for it, or create a root
634
+ checkpoint after the reporting window. A root-only window with no selected
635
+ mutation returns a frozen empty timeline. The one exception is a window that
636
+ closes on the root's own destruction, which no later version can ever follow;
637
+ see [closing a destroyed root](#closing-a-destroyed-root).
638
+
639
+ ### Reporting up to now
640
+
641
+ A window whose end is the present has nothing recorded after its final
642
+ mutation, so by default it raises. `close_on: :current` closes it on the live
643
+ record instead:
644
+
645
+ ```ruby
646
+ PaperTrailDiff.analyze_many(
647
+ articles, within: month_start..Time.current, close_on: :current
648
+ )
649
+ ```
650
+
651
+ It is accepted by `timeline`, `activity_timeline`, `analyze`, and
652
+ `analyze_many`, and only alongside `within:` — a range whose endpoints you gave
653
+ explicitly already says where it ends. The closing step's `to_boundary.kind` is
654
+ `:current` and its `to_version` is `nil`, so read the boundary rather than the
655
+ version on the last step. Under `activity: true` the window runs to the instant
656
+ state is captured rather than to the last root version, so a descendant that
657
+ moved after that version is still reported.
658
+
659
+ Two things follow from closing on live state. Current state includes changes
660
+ PaperTrail never recorded — an `update_columns` write shows up in that final
661
+ step, attributed to the version that precedes it, because nothing else records
662
+ who made it. And a destroyed root has no current state to close on, so it keeps
663
+ closing on its own destruction as it already did.
542
664
 
543
665
  Time ranges and explicit `from:`/`to:` endpoints are mutually exclusive.
544
666
  Malformed, open-ended, or reversed ranges raise
@@ -922,10 +1044,27 @@ The public result types are:
922
1044
  - `PaperTrailDiff::DiagnosticReport`
923
1045
  - `PaperTrailDiff::DiagnosticIssue`
924
1046
 
1047
+ `Analysis` also exposes the reconstructed states its diff was taken between, as
1048
+ `from_snapshot` and `to_snapshot`. A report that renders unchanged columns needs
1049
+ the whole final state, not only what moved, and these are the states the gem
1050
+ already reconstructed:
1051
+
1052
+ ```ruby
1053
+ analysis.to_snapshot.attributes # every selected scalar, changed or not
1054
+ analysis.to_snapshot.associations # the selected association tree
1055
+ ```
1056
+
1057
+ Either is `nil` when that endpoint has no reconstructable state — most often a
1058
+ `from_snapshot` at a `create` boundary, whose pre-change state is the absence of
1059
+ the record. Following the precedent set by `Step`, `Analysis#to_h` is unchanged;
1060
+ serialize `to_snapshot.to_h` when a serialized form is wanted.
1061
+
925
1062
  They expose readers, are frozen after construction, and provide deterministic
926
1063
  `to_h` output. Collection results are ordered by record identity: by type, then
927
1064
  naturally within one id type, so numeric ids sort `2` before `10`. Mixed or
928
- unusual id types still order totally rather than raising. Structural hash keys are symbols; attribute and association
1065
+ unusual id types still order totally rather than raising.
1066
+
1067
+ Structural hash keys are symbols; attribute and association
929
1068
  names are strings. Attribute values retain their Ruby types. `RecordChange#record`
930
1069
  is a `RecordReference` with `type` and `id` readers. `TraversalEntry#record` and
931
1070
  `#association` return the final components of their corresponding paths. `Step`
@@ -7,18 +7,30 @@ module PaperTrailDiff
7
7
  module ActivityRootSteps
8
8
  module_function
9
9
 
10
- #: (Array[untyped], Hash[Array[untyped], RecordSnapshot?]) -> Array[Step]
11
- def call(root_versions, root_snapshots)
12
- snapshots = root_versions.map { |version| root_snapshots.fetch(version_key(version)) }
13
- root_versions.each_cons(2).with_index.map do |versions, index|
10
+ # A window closing on current state ends at the live record, which no
11
+ # version-keyed snapshot can supply, so that endpoint is passed in.
12
+ #: (RootVersionPlan, Hash[Array[untyped], RecordSnapshot?], ?closing_snapshot: RecordSnapshot?, ?captured_at: untyped) -> Array[Step]
13
+ def call(plan, root_snapshots, closing_snapshot: nil, captured_at: nil)
14
+ plan.steps.map do |from_endpoint, to_endpoint|
14
15
  Step.new(
15
- from_version: versions.fetch(0),
16
- to_version: versions.fetch(1),
17
- diff: Engine.compare(snapshots.fetch(index), snapshots.fetch(index + 1))
16
+ from_version: from_endpoint,
17
+ to_version: to_endpoint,
18
+ captured_at: captured_at,
19
+ diff: Engine.compare(
20
+ root_snapshots[version_key(from_endpoint)],
21
+ snapshot_for(to_endpoint, root_snapshots, closing_snapshot)
22
+ )
18
23
  )
19
24
  end.freeze
20
25
  end
21
26
 
27
+ #: (untyped, Hash[Array[untyped], RecordSnapshot?], RecordSnapshot?) -> RecordSnapshot?
28
+ def snapshot_for(endpoint, root_snapshots, closing_snapshot)
29
+ return closing_snapshot if Endpoint.record?(endpoint)
30
+
31
+ root_snapshots[version_key(endpoint)]
32
+ end
33
+
22
34
  #: (untyped) -> Array[untyped]
23
35
  def version_key(version)
24
36
  [version.class.name, version.id]
@@ -34,10 +34,11 @@ module PaperTrailDiff
34
34
  raise InvalidTimelineRangeError, '`to` must be a root PaperTrail version'
35
35
  end
36
36
 
37
- root_versions = @range.select
37
+ plan = @range.select_plan
38
+ root_versions = plan.reconstruction_versions
38
39
  prepare_history(root_versions)
39
40
  events = collect_events(root_versions)
40
- build_analysis(root_versions, events, event_history(root_versions, events))
41
+ build_analysis(plan, events, event_history(root_versions, events))
41
42
  end
42
43
 
43
44
  private
@@ -55,9 +56,13 @@ module PaperTrailDiff
55
56
  steps.freeze
56
57
  end
57
58
 
59
+ # Every boundary in the span, filtered out or not. A filter narrows where the
60
+ # span starts and ends, but the sequence inside it stays complete: dropping
61
+ # boundaries would fold the changes they carried into a neighbouring step and
62
+ # credit them to whoever made that one.
58
63
  #: () -> Array[ActivityStep]
59
64
  def build_between_versions
60
- root_versions = @range.select
65
+ root_versions = @range.select_plan.reconstruction_versions
61
66
  prepare_history(root_versions)
62
67
  events = collect_events(root_versions)
63
68
  build_event_steps(root_versions, events)
@@ -177,14 +182,16 @@ module PaperTrailDiff
177
182
  # Only the activity view gains the closing removal. The endpoint diff and
178
183
  # the root timeline keep their `compare` and `timeline` semantics, under
179
184
  # which the state at a destroy version is the state before the deletion.
180
- #: (Array[untyped], Array[ActivityEvent], ActivityHistory) -> Analysis
181
- def build_analysis(root_versions, events, history)
185
+ #: (RootVersionPlan, Array[ActivityEvent], ActivityHistory) -> Analysis
186
+ def build_analysis(plan, events, history)
182
187
  Analysis.new(
183
188
  diff: Engine.compare(history.first_snapshot, history.last_snapshot),
184
- timeline: ActivityRootSteps.call(root_versions, history.root_snapshots),
189
+ timeline: ActivityRootSteps.call(plan, history.root_snapshots),
185
190
  activity_timeline: activity_steps(
186
- history, events, destroyed_boundary(root_versions), nil
187
- )
191
+ history, events, destroyed_boundary(plan.reconstruction_versions), nil
192
+ ),
193
+ from_snapshot: history.first_snapshot,
194
+ to_snapshot: history.last_snapshot
188
195
  )
189
196
  end
190
197
 
@@ -7,6 +7,8 @@ module PaperTrailDiff
7
7
  attr_reader :diff #: Diff
8
8
  attr_reader :timeline #: Array[Step]
9
9
  attr_reader :activity_timeline #: Array[ActivityStep]?
10
+ attr_reader :from_snapshot #: RecordSnapshot?
11
+ attr_reader :to_snapshot #: RecordSnapshot?
10
12
 
11
13
  # The result for a record whose requested history contains no versions,
12
14
  # which is an empty history rather than a failed request.
@@ -17,19 +19,31 @@ module PaperTrailDiff
17
19
  new(diff: Diff.new, timeline: timeline, activity_timeline: activity_timeline)
18
20
  end
19
21
 
20
- #: (diff: Diff, timeline: Array[Step], ?activity_timeline: Array[ActivityStep]?) -> void
21
- def initialize(diff:, timeline:, activity_timeline: nil)
22
+ # The reconstructed states the diff was taken between. A report that has to
23
+ # render unchanged columns needs the whole final state, not only what moved.
24
+ #: (diff: Diff, timeline: Array[Step], ?activity_timeline: Array[ActivityStep]?, ?from_snapshot: RecordSnapshot?, ?to_snapshot: RecordSnapshot?) -> void
25
+ def initialize(diff:, timeline:, activity_timeline: nil, from_snapshot: nil, to_snapshot: nil)
22
26
  @diff = diff
23
27
  @timeline = timeline.dup.freeze
24
28
  @activity_timeline = activity_timeline&.dup&.freeze
29
+ @from_snapshot = from_snapshot
30
+ @to_snapshot = to_snapshot
25
31
  freeze
26
32
  end
27
33
 
28
- #: () -> Hash[Symbol, untyped]
29
- def to_h
34
+ # Reconstructed endpoint states are opt-in, because they carry the whole
35
+ # selected graph whether or not anything changed, which dwarfs the rest of
36
+ # the payload for a wide graph.
37
+ #: (?snapshots: bool) -> Hash[Symbol, untyped]
38
+ def to_h(snapshots: false)
30
39
  value = { diff: diff.to_h, timeline: Support.serialize(timeline) }
31
40
  value[:activity_timeline] = Support.serialize(activity_timeline) if activity_timeline
32
- value
41
+ return value unless snapshots
42
+
43
+ value.merge(
44
+ from_snapshot: Support.serialize(from_snapshot),
45
+ to_snapshot: Support.serialize(to_snapshot)
46
+ )
33
47
  end
34
48
  end
35
49
  end
@@ -6,10 +6,15 @@ module PaperTrailDiff
6
6
  # versions and preparing their association history once for the whole batch
7
7
  # rather than once per root.
8
8
  class AnalysisBatch
9
- #: (Array[untyped], time_range: TimeRange?, live_loader: untyped, history_preparer: untyped, analyzer: untyped) -> void
10
- def initialize(records, time_range:, live_loader:, history_preparer:, analyzer:)
9
+ #: (Array[untyped], time_range: TimeRange?, live_loader: untyped, history_preparer: untyped, analyzer: untyped, ?version_scope: untyped, ?close_on_current: bool) -> void
10
+ def initialize( # rubocop:disable Metrics/ParameterLists
11
+ records, time_range:, live_loader:, history_preparer:, analyzer:, version_scope: nil,
12
+ close_on_current: false
13
+ )
11
14
  @records = records
12
15
  @time_range = time_range
16
+ @version_scope = validated_scope(version_scope)
17
+ @close_on_current = close_on_current
13
18
  @live_loader = live_loader
14
19
  @history_preparer = history_preparer
15
20
  @analyzer = analyzer
@@ -18,11 +23,16 @@ module PaperTrailDiff
18
23
  #: () -> Hash[identity, Analysis]
19
24
  def call
20
25
  records = validated_records
21
- selected = BatchedRootVersions.new(records, time_range: @time_range).call
22
- prepare(records, selected)
26
+ loaded = @live_loader.call(records)
27
+ selected = BatchedRootVersions.new(
28
+ records, time_range: @time_range, version_scope: @version_scope,
29
+ live_endpoints: (loaded if @close_on_current)
30
+ ).call
31
+ prepare(records, selected, loaded)
23
32
  records.to_h do |record|
24
33
  key = Endpoint.identity(record)
25
- [Support.immutable_copy(key), analysis_for(record, selected.fetch(key, []))]
34
+ plan = selected.fetch(key, RootVersionPlan.empty)
35
+ [Support.immutable_copy(key), analysis_for(record, plan)]
26
36
  end.freeze
27
37
  end
28
38
 
@@ -30,10 +40,21 @@ module PaperTrailDiff
30
40
 
31
41
  # @rbs @records: Array[untyped]
32
42
  # @rbs @time_range: TimeRange?
43
+ # @rbs @version_scope: untyped
44
+ # @rbs @close_on_current: bool
33
45
  # @rbs @live_loader: untyped
34
46
  # @rbs @history_preparer: untyped
35
47
  # @rbs @analyzer: untyped
36
48
 
49
+ # A filter is a callable that narrows the version relation, so it is checked
50
+ # up front rather than failing partway through a batch.
51
+ #: (untyped) -> untyped
52
+ def validated_scope(scope)
53
+ return scope if scope.nil? || scope.respond_to?(:call)
54
+
55
+ raise ConfigurationError, 'version_scope: must respond to call'
56
+ end
57
+
37
58
  #: () -> Array[untyped]
38
59
  def validated_records
39
60
  raise ConfigurationError, 'records: must be an array' unless @records.is_a?(Array)
@@ -50,11 +71,12 @@ module PaperTrailDiff
50
71
  # The roots are preloaded first, because preparation reads their current
51
72
  # association state as a fallback and would otherwise walk it one root at a
52
73
  # time.
53
- #: (Array[untyped], Hash[Array[String], Array[untyped]]) -> void
54
- def prepare(records, selected)
55
- loaded = @live_loader.call(records)
74
+ #: (Array[untyped], Hash[Array[String], RootVersionPlan], Hash[Array[String], untyped]) -> void
75
+ def prepare(records, selected, loaded)
56
76
  records.group_by(&:class).each_value do |grouped|
57
- versions = grouped.flat_map { |record| selected.fetch(Endpoint.identity(record), []) }
77
+ versions = grouped.flat_map do |record|
78
+ selected.fetch(Endpoint.identity(record), RootVersionPlan.empty).versions
79
+ end
58
80
  next if versions.empty?
59
81
 
60
82
  roots = grouped.map { |record| loaded.fetch(Endpoint.identity(record), record) }
@@ -64,11 +86,11 @@ module PaperTrailDiff
64
86
 
65
87
  # A root with no versions in range has nothing to report, which is an empty
66
88
  # result rather than a failed request.
67
- #: (untyped, Array[untyped]) -> Analysis
68
- def analysis_for(record, versions)
69
- return Analysis.empty if versions.empty?
89
+ #: (untyped, RootVersionPlan) -> Analysis
90
+ def analysis_for(record, plan)
91
+ return Analysis.empty if plan.empty?
70
92
 
71
- @analyzer.call(record, versions)
93
+ @analyzer.call(record, plan)
72
94
  end
73
95
  end
74
96
  end
@@ -14,17 +14,17 @@ module PaperTrailDiff
14
14
  @activity = activity
15
15
  end
16
16
 
17
- #: (untyped, Array[untyped]) -> Analysis
18
- def call(record, versions)
17
+ #: (untyped, RootVersionPlan) -> Analysis
18
+ def call(record, plan)
19
19
  @preparer.call(record.class, historical: true)
20
- return activity_analysis(record, versions) if @activity
20
+ return activity_analysis(record, plan) if @activity
21
21
 
22
22
  TimelineBuilder.new(
23
23
  record,
24
- from: versions.first,
25
- to: versions.last,
24
+ from: plan.versions.first,
25
+ to: plan.versions.last,
26
26
  within: nil,
27
- versions: versions,
27
+ plan: plan,
28
28
  snapshotter: @timeline_snapshotter
29
29
  ).analyze
30
30
  end
@@ -37,10 +37,10 @@ module PaperTrailDiff
37
37
  # @rbs @preparer: untyped
38
38
  # @rbs @activity: bool
39
39
 
40
- #: (untyped, Array[untyped]) -> Analysis
41
- def activity_analysis(record, versions)
40
+ #: (untyped, RootVersionPlan) -> Analysis
41
+ def activity_analysis(record, plan)
42
42
  range = TimelineRange.new(
43
- record, from: versions.first, to: versions.last, within: nil, versions: versions
43
+ record, from: plan.versions.first, to: plan.versions.last, within: nil, plan: plan
44
44
  )
45
45
  ActivityTimelineBuilder.new(
46
46
  record, range: range, tree: @tree, snapshotter: @activity_snapshotter
@@ -6,18 +6,20 @@ module PaperTrailDiff
6
6
  # queries. Only the range forms that mean the same thing for every root are
7
7
  # supported: a shared wall-clock window, or each root's own whole history.
8
8
  class BatchedRootVersions
9
- #: (Array[untyped], time_range: TimeRange?) -> void
10
- def initialize(records, time_range:)
9
+ #: (Array[untyped], time_range: TimeRange?, ?version_scope: untyped, ?live_endpoints: Hash[Array[String], untyped]?) -> void
10
+ def initialize(records, time_range:, version_scope: nil, live_endpoints: nil)
11
11
  @records = records
12
12
  @time_range = time_range
13
+ @version_scope = version_scope
14
+ @live_endpoints = live_endpoints
13
15
  end
14
16
 
15
- # Returns root versions per record identity, in chronological order.
16
- #: () -> Hash[Array[String], Array[untyped]]
17
+ # Returns a plan per record identity.
18
+ #: () -> Hash[Array[String], RootVersionPlan]
17
19
  def call
18
20
  return {} if @records.empty?
19
21
 
20
- selected = {} #: Hash[Array[String], Array[untyped]]
22
+ selected = {} #: Hash[Array[String], RootVersionPlan]
21
23
  @records.group_by(&:class).each do |model_class, records|
22
24
  select_model(model_class, records, selected)
23
25
  end
@@ -28,47 +30,78 @@ module PaperTrailDiff
28
30
 
29
31
  # @rbs @records: Array[untyped]
30
32
  # @rbs @time_range: TimeRange?
33
+ # @rbs @version_scope: untyped
34
+ # @rbs @live_endpoints: Hash[Array[String], untyped]?
31
35
 
32
- #: (untyped, Array[untyped], Hash[Array[String], Array[untyped]]) -> void
36
+ #: (untyped, Array[untyped], Hash[Array[String], RootVersionPlan]) -> void
33
37
  def select_model(model_class, records, selected)
34
38
  ids = records.map(&:id)
35
- range = @time_range
36
- grouped = grouped_versions(model_class, ids, range)
37
- empty = {} #: Hash[String, untyped]
38
- trailing = range ? trailing_versions(model_class, ids, range) : empty
39
+ in_range, chosen, trailing = model_versions(model_class, ids)
39
40
  records.each do |record|
40
41
  key = identity(model_class, record.id)
41
- selected[key] = versions_for(grouped.fetch(record.id.to_s, []), trailing[record.id.to_s])
42
+ selected[key] = versions_for(
43
+ in_range.fetch(record.id.to_s, []), chosen, trailing[record.id.to_s],
44
+ live_endpoint(key, record)
45
+ )
42
46
  end
43
47
  end
44
48
 
45
- # The window's own versions plus, when it has one, the later version needed
46
- # to reveal the last mutation inside it. A window closing on the root's own
47
- # destruction needs no later version, because none can exist.
48
- #: (Array[untyped], untyped) -> Array[untyped]
49
- def versions_for(in_range, trailing)
50
- return in_range if @time_range.nil?
51
- return in_range.freeze if in_range.empty?
52
- return in_range.freeze if trailing.nil? && terminal_destroy?(in_range)
49
+ #: (untyped, Array[untyped]) -> [Hash[String, Array[untyped]], Set[untyped]?, Hash[String, untyped]]
50
+ def model_versions(model_class, ids)
51
+ range = @time_range
52
+ empty = {} #: Hash[String, untyped]
53
+ [
54
+ grouped_versions(model_class, ids, range),
55
+ chosen_version_ids(model_class, ids, range),
56
+ range ? trailing_versions(model_class, ids, range) : empty
57
+ ]
58
+ end
59
+
60
+ #: (Array[untyped], Set[untyped]?, untyped, untyped) -> RootVersionPlan
61
+ def versions_for(in_range, chosen, after_range, live_endpoint)
62
+ RootVersionSelection.new(
63
+ in_range: in_range,
64
+ selected: chosen ? in_range.select { |version| chosen.include?(version.id) } : in_range,
65
+ after_range: after_range,
66
+ windowed: !@time_range.nil?,
67
+ filtered: !@version_scope.nil?,
68
+ live_endpoint: live_endpoint
69
+ ).call
70
+ end
53
71
 
54
- unless trailing
55
- raise IncompleteTimeRangeError,
56
- 'time range requires a later root version to reconstruct its final change'
57
- end
72
+ # The batch already loaded every root, so closing on current state reuses
73
+ # that rather than reading each record again.
74
+ #: (Array[String], untyped) -> untyped
75
+ def live_endpoint(key, record)
76
+ loaded = @live_endpoints
77
+ return unless loaded
58
78
 
59
- (in_range + [trailing]).freeze
79
+ current = loaded.fetch(key, record)
80
+ current unless current.destroyed?
60
81
  end
61
82
 
62
- #: (Array[untyped]) -> bool
63
- def terminal_destroy?(versions)
64
- versions.last&.event.to_s == 'destroy'
83
+ # One extra query names the selected mutations without discarding the
84
+ # unfiltered versions the successor lookup still needs.
85
+ #: (untyped, Array[untyped], TimeRange?) -> Set[untyped]?
86
+ def chosen_version_ids(model_class, ids, range)
87
+ scope = @version_scope
88
+ return unless scope
89
+
90
+ # A narrowed relation is the expected return. Active Support also gives
91
+ # `pluck` to plain enumerables, so an array of versions works too.
92
+ filtered = scope.call(range_scope(model_class, ids, range))
93
+ Set.new(filtered.pluck(:id))
65
94
  end
66
95
 
67
96
  #: (untyped, Array[untyped], TimeRange?) -> Hash[String, Array[untyped]]
68
97
  def grouped_versions(model_class, ids, range)
98
+ ordered(range_scope(model_class, ids, range)).group_by { |version| version.item_id.to_s }
99
+ end
100
+
101
+ #: (untyped, Array[untyped], TimeRange?) -> untyped
102
+ def range_scope(model_class, ids, range)
69
103
  scope = base_scope(model_class, ids)
70
- scope = range.scope(scope) if range
71
- ordered(scope).group_by { |version| version.item_id.to_s }
104
+ range ? range.scope(scope) : scope
72
105
  end
73
106
 
74
107
  # One row per root: the earliest version after the window, found without a