paper_trail_diff 0.6.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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -0
  3. data/README.md +85 -9
  4. data/lib/paper_trail_diff/activity_root_steps.rb +17 -7
  5. data/lib/paper_trail_diff/analysis_batch.rb +11 -7
  6. data/lib/paper_trail_diff/batched_root_versions.rb +21 -6
  7. data/lib/paper_trail_diff/paper_trail_adapter.rb +63 -23
  8. data/lib/paper_trail_diff/root_version_plan.rb +16 -3
  9. data/lib/paper_trail_diff/root_version_selection.rb +36 -12
  10. data/lib/paper_trail_diff/step.rb +14 -5
  11. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +83 -25
  12. data/lib/paper_trail_diff/time_version_range.rb +6 -3
  13. data/lib/paper_trail_diff/timeline_builder.rb +17 -8
  14. data/lib/paper_trail_diff/timeline_range.rb +6 -3
  15. data/lib/paper_trail_diff/timeline_snapshot_provider.rb +20 -4
  16. data/lib/paper_trail_diff/version.rb +1 -1
  17. data/lib/paper_trail_diff.rb +20 -12
  18. data/sig/generated/paper_trail_diff/activity_root_steps.rbs +7 -2
  19. data/sig/generated/paper_trail_diff/analysis_batch.rbs +6 -4
  20. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +11 -4
  21. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +26 -10
  22. data/sig/generated/paper_trail_diff/root_version_plan.rbs +12 -2
  23. data/sig/generated/paper_trail_diff/root_version_selection.rbs +13 -2
  24. data/sig/generated/paper_trail_diff/step.rbs +5 -2
  25. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +33 -12
  26. data/sig/generated/paper_trail_diff/time_version_range.rbs +4 -2
  27. data/sig/generated/paper_trail_diff/timeline_builder.rbs +5 -2
  28. data/sig/generated/paper_trail_diff/timeline_range.rbs +4 -2
  29. data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +11 -2
  30. data/sig/generated/paper_trail_diff.rbs +8 -8
  31. metadata +4 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 88e9b952c5cfe214974d5eab1a9d6e5a30dcfeb34ebc4193110f478d0cd9884a
4
- data.tar.gz: 3a66b3ae3a77b65097ece227010c423beb05ec4ebd981edee396b824638c801c
3
+ metadata.gz: 33b445f893d6d95faf0ad22b72d35e8b248f3c00240e537898cac2d067ece043
4
+ data.tar.gz: 57ee0b8fc242825852726933a9aa332cd309c38d2af21724a41c4bfcfbf2bd41
5
5
  SHA512:
6
- metadata.gz: 413fcd69a9ef2b49abb2238d77b279ac2dd4001cc8b5d98d1bbcb92843a6b428aeed967ba7ba8efb10dca1fcd5e912ca4221b0a8a854e0cd624a7b95fcc21ae1
7
- data.tar.gz: f5968acfec8ce8189e1fcd3122b4fc3934b7cf989278c1b8feb87ed087da93132c35f7d20df8d64e49832fabe052b5cd80d6fdc5182af3e9be6c2d308ab3acf1
6
+ metadata.gz: bbbdea8be4b4300705da0f915dbe3b61ede862817fa27c44f0371c8454f9995f20b16120acc43cf0272f8fdecfdef4fa5fce160642812805a2c636f6f6acf9a0
7
+ data.tar.gz: e6f9a063c376a1214b1deacbd0ea15e325e82a1cccecd86d3d747268471bac0b20585249a4dcb1f039730f39bb019cc35a7e12a2e0bbfb963d4cc856acd6d2ef
data/CHANGELOG.md CHANGED
@@ -3,6 +3,34 @@
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
+
6
34
  ## [0.6.0] - 2026-08-11
7
35
 
8
36
  ### Added
data/README.md CHANGED
@@ -324,10 +324,10 @@ 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 mutations
327
+ ### Reporting on a subset of root mutations
328
328
 
329
- `version_scope:` narrows which root versions count as *selected mutations*,
330
- which is what a "changes made by a user" report needs:
329
+ `version_scope:` narrows which root versions count as *selected mutations*, so a
330
+ checkpoint timeline reports only some of them:
331
331
 
332
332
  ```ruby
333
333
  user_edits = ->(scope) { scope.where.not(whodunnit: nil) }
@@ -340,6 +340,12 @@ The hook receives the version relation for the range and returns a narrowed one.
340
340
  It is accepted by `timeline`, `activity_timeline`, `analyze`, and
341
341
  `analyze_many`, with any range form.
342
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
+
343
349
  It filters *selected mutations only*. Versions the filter excludes are still
344
350
  loaded, because a version records the state before its own event: without the
345
351
  one that follows a selected change, whatever that change produced cannot be
@@ -385,6 +391,49 @@ batches far less.
385
391
  Roots are supplied as live records, so a root deleted inside the window cannot
386
392
  be included; use `activity_timeline` for a history that ends in a deletion.
387
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
+
388
437
  ## Build a root-checkpoint timeline
389
438
 
390
439
  `timeline` accepts two version objects from the supplied record's history, or
@@ -579,12 +628,39 @@ version to reveal the final selected mutation. That version is reconstruction
579
628
  context, not an additional selected mutation.
580
629
 
581
630
  If the window contains a relevant mutation but no later root version exists,
582
- the call raises `PaperTrailDiff::IncompleteTimeRangeError`. Create a root
583
- checkpoint after the reporting window before running historical analysis. The
584
- gem does not silently substitute current database state. A root-only window
585
- with no selected mutation returns a frozen empty timeline. The one exception is
586
- a window that closes on the root's own destruction, which no later version can
587
- 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.
588
664
 
589
665
  Time ranges and explicit `from:`/`to:` endpoints are mutually exclusive.
590
666
  Malformed, open-ended, or reversed ranges raise
@@ -7,20 +7,30 @@ module PaperTrailDiff
7
7
  module ActivityRootSteps
8
8
  module_function
9
9
 
10
- #: (RootVersionPlan, Hash[Array[untyped], RecordSnapshot?]) -> Array[Step]
11
- def call(plan, root_snapshots)
12
- plan.steps.map do |from_version, to_version|
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|
13
15
  Step.new(
14
- from_version: from_version,
15
- to_version: to_version,
16
+ from_version: from_endpoint,
17
+ to_version: to_endpoint,
18
+ captured_at: captured_at,
16
19
  diff: Engine.compare(
17
- root_snapshots[version_key(from_version)],
18
- root_snapshots[version_key(to_version)]
20
+ root_snapshots[version_key(from_endpoint)],
21
+ snapshot_for(to_endpoint, root_snapshots, closing_snapshot)
19
22
  )
20
23
  )
21
24
  end.freeze
22
25
  end
23
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
+
24
34
  #: (untyped) -> Array[untyped]
25
35
  def version_key(version)
26
36
  [version.class.name, version.id]
@@ -6,13 +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, ?version_scope: untyped) -> void
9
+ #: (Array[untyped], time_range: TimeRange?, live_loader: untyped, history_preparer: untyped, analyzer: untyped, ?version_scope: untyped, ?close_on_current: bool) -> void
10
10
  def initialize( # rubocop:disable Metrics/ParameterLists
11
- records, time_range:, live_loader:, history_preparer:, analyzer:, version_scope: nil
11
+ records, time_range:, live_loader:, history_preparer:, analyzer:, version_scope: nil,
12
+ close_on_current: false
12
13
  )
13
14
  @records = records
14
15
  @time_range = time_range
15
16
  @version_scope = validated_scope(version_scope)
17
+ @close_on_current = close_on_current
16
18
  @live_loader = live_loader
17
19
  @history_preparer = history_preparer
18
20
  @analyzer = analyzer
@@ -21,10 +23,12 @@ module PaperTrailDiff
21
23
  #: () -> Hash[identity, Analysis]
22
24
  def call
23
25
  records = validated_records
26
+ loaded = @live_loader.call(records)
24
27
  selected = BatchedRootVersions.new(
25
- records, time_range: @time_range, version_scope: @version_scope
28
+ records, time_range: @time_range, version_scope: @version_scope,
29
+ live_endpoints: (loaded if @close_on_current)
26
30
  ).call
27
- prepare(records, selected)
31
+ prepare(records, selected, loaded)
28
32
  records.to_h do |record|
29
33
  key = Endpoint.identity(record)
30
34
  plan = selected.fetch(key, RootVersionPlan.empty)
@@ -37,6 +41,7 @@ module PaperTrailDiff
37
41
  # @rbs @records: Array[untyped]
38
42
  # @rbs @time_range: TimeRange?
39
43
  # @rbs @version_scope: untyped
44
+ # @rbs @close_on_current: bool
40
45
  # @rbs @live_loader: untyped
41
46
  # @rbs @history_preparer: untyped
42
47
  # @rbs @analyzer: untyped
@@ -66,9 +71,8 @@ module PaperTrailDiff
66
71
  # The roots are preloaded first, because preparation reads their current
67
72
  # association state as a fallback and would otherwise walk it one root at a
68
73
  # time.
69
- #: (Array[untyped], Hash[Array[String], RootVersionPlan]) -> void
70
- def prepare(records, selected)
71
- loaded = @live_loader.call(records)
74
+ #: (Array[untyped], Hash[Array[String], RootVersionPlan], Hash[Array[String], untyped]) -> void
75
+ def prepare(records, selected, loaded)
72
76
  records.group_by(&:class).each_value do |grouped|
73
77
  versions = grouped.flat_map do |record|
74
78
  selected.fetch(Endpoint.identity(record), RootVersionPlan.empty).versions
@@ -6,11 +6,12 @@ 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?, ?version_scope: untyped) -> void
10
- def initialize(records, time_range:, version_scope: nil)
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
13
  @version_scope = version_scope
14
+ @live_endpoints = live_endpoints
14
15
  end
15
16
 
16
17
  # Returns a plan per record identity.
@@ -30,6 +31,7 @@ module PaperTrailDiff
30
31
  # @rbs @records: Array[untyped]
31
32
  # @rbs @time_range: TimeRange?
32
33
  # @rbs @version_scope: untyped
34
+ # @rbs @live_endpoints: Hash[Array[String], untyped]?
33
35
 
34
36
  #: (untyped, Array[untyped], Hash[Array[String], RootVersionPlan]) -> void
35
37
  def select_model(model_class, records, selected)
@@ -38,7 +40,8 @@ module PaperTrailDiff
38
40
  records.each do |record|
39
41
  key = identity(model_class, record.id)
40
42
  selected[key] = versions_for(
41
- in_range.fetch(record.id.to_s, []), chosen, trailing[record.id.to_s]
43
+ in_range.fetch(record.id.to_s, []), chosen, trailing[record.id.to_s],
44
+ live_endpoint(key, record)
42
45
  )
43
46
  end
44
47
  end
@@ -54,17 +57,29 @@ module PaperTrailDiff
54
57
  ]
55
58
  end
56
59
 
57
- #: (Array[untyped], Set[untyped]?, untyped) -> RootVersionPlan
58
- def versions_for(in_range, chosen, after_range)
60
+ #: (Array[untyped], Set[untyped]?, untyped, untyped) -> RootVersionPlan
61
+ def versions_for(in_range, chosen, after_range, live_endpoint)
59
62
  RootVersionSelection.new(
60
63
  in_range: in_range,
61
64
  selected: chosen ? in_range.select { |version| chosen.include?(version.id) } : in_range,
62
65
  after_range: after_range,
63
66
  windowed: !@time_range.nil?,
64
- filtered: !@version_scope.nil?
67
+ filtered: !@version_scope.nil?,
68
+ live_endpoint: live_endpoint
65
69
  ).call
66
70
  end
67
71
 
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
78
+
79
+ current = loaded.fetch(key, record)
80
+ current unless current.destroyed?
81
+ end
82
+
68
83
  # One extra query names the selected mutations without discarding the
69
84
  # unfiltered versions the successor lookup still needs.
70
85
  #: (untyped, Array[untyped], TimeRange?) -> Set[untyped]?
@@ -7,6 +7,9 @@ module PaperTrailDiff
7
7
  # the only place allowed to know about both PaperTrail and the pure engine.
8
8
  # Reconstruction logic lives in the collaborators it wires together.
9
9
  class PaperTrailAdapter # rubocop:disable Metrics/ClassLength
10
+ # The only thing a wall-clock window can close on besides a later version.
11
+ CLOSE_ON_CURRENT = :current
12
+
10
13
  #: (associations: Array[String | Symbol], ignore: ignore_option, ?reload_live_endpoints: bool) -> void
11
14
  def initialize(associations:, ignore:, reload_live_endpoints: true)
12
15
  @association_tree = AssociationTree.build(associations)
@@ -50,8 +53,8 @@ module PaperTrailDiff
50
53
  end
51
54
  end
52
55
 
53
- #: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped) -> Array[Step]
54
- def timeline(record, from:, to:, within:, version_scope: nil)
56
+ #: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?) -> Array[Step]
57
+ def timeline(record, from:, to:, within:, version_scope: nil, close_on: nil) # rubocop:disable Metrics/ParameterLists
55
58
  @traversal_preparer.call(record.class, historical: true)
56
59
  TimelineBuilder.new(
57
60
  record,
@@ -59,46 +62,47 @@ module PaperTrailDiff
59
62
  to: to,
60
63
  within: within,
61
64
  version_scope: version_scope,
65
+ live_endpoint: live_endpoint_for(record, close_on, within),
62
66
  snapshotter: @timeline_snapshotter
63
67
  ).build
64
68
  end
65
69
 
66
- #: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped) -> Array[ActivityStep]
67
- def activity_timeline(record, from:, to:, within:, version_scope: nil)
70
+ #: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?) -> Array[ActivityStep]
71
+ def activity_timeline(record, from:, to:, within:, version_scope: nil, close_on: nil) # rubocop:disable Metrics/ParameterLists
68
72
  payload = @instrumentation_payload.merge(model_type: record.class.base_class.name.to_s)
69
73
  Instrumentation.instrument('activity_timeline', payload) do
70
74
  @traversal_preparer.call(record.class, historical: true)
71
- reject_live_habtm_activity!(record.class) if Endpoint.record?(to)
75
+ live = live_endpoint_for(record, close_on, within)
76
+ reject_live_habtm_activity!(record.class) if Endpoint.record?(to) || live
72
77
  steps = activity_builder(
73
- record, from: from, to: to, within: within, version_scope: version_scope
78
+ record, from: from, to: to, within: within, version_scope: version_scope,
79
+ live_endpoint: live
74
80
  ).build
75
81
  payload[:step_count] = steps.length
76
82
  steps
77
83
  end
78
84
  end
79
85
 
80
- #: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped) -> Analysis
81
- def analyze(record, from:, to:, within:, activity: false, version_scope: nil) # rubocop:disable Metrics/ParameterLists
86
+ #: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Analysis
87
+ def analyze(record, from:, to:, within:, activity: false, version_scope: nil, close_on: nil) # rubocop:disable Metrics/ParameterLists
82
88
  @traversal_preparer.call(record.class, historical: true)
89
+ live = live_endpoint_for(record, close_on, within)
83
90
  if activity
84
- return activity_builder(
85
- record, from: from, to: to, within: within, version_scope: version_scope
86
- ).analyze
91
+ return analyze_activity(
92
+ record, from: from, to: to, within: within,
93
+ version_scope: version_scope, live_endpoint: live
94
+ )
87
95
  end
88
96
 
89
97
  TimelineBuilder.new(
90
- record,
91
- from: from,
92
- to: to,
93
- within: within,
94
- version_scope: version_scope,
95
- snapshotter: @timeline_snapshotter
98
+ record, from: from, to: to, within: within, version_scope: version_scope,
99
+ live_endpoint: live, snapshotter: @timeline_snapshotter
96
100
  ).analyze
97
101
  end
98
102
 
99
103
  # Analyzes many roots over one shared range, preparing their history once.
100
- #: (Array[untyped], within: untyped, ?activity: bool, ?version_scope: untyped) -> Hash[identity, Analysis]
101
- def analyze_many(records, within:, activity: false, version_scope: nil)
104
+ #: (Array[untyped], within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Hash[identity, Analysis]
105
+ def analyze_many(records, within:, activity: false, version_scope: nil, close_on: nil)
102
106
  count = records.is_a?(Array) ? records.length : 0
103
107
  payload = @instrumentation_payload.merge(comparison_count: count)
104
108
  Instrumentation.instrument('analyze_many', payload) do
@@ -106,6 +110,7 @@ module PaperTrailDiff
106
110
  records,
107
111
  time_range: within.nil? ? nil : TimeRange.new(within),
108
112
  version_scope: version_scope,
113
+ close_on_current: close_on_current?(close_on, within),
109
114
  live_loader: @live_endpoints.method(:call),
110
115
  history_preparer: @historical_store.method(:prepare_batch),
111
116
  analyzer: batched_root_analyzer(activity)
@@ -127,10 +132,44 @@ module PaperTrailDiff
127
132
  # @rbs @timeline_snapshotter: TimelineSnapshotProvider
128
133
  # @rbs @activity_snapshotter: ActivitySnapshotProvider
129
134
 
135
+ #: (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped) -> Analysis
136
+ def analyze_activity(record, from:, to:, within:, version_scope:, live_endpoint:) # rubocop:disable Metrics/ParameterLists
137
+ reject_live_habtm_activity!(record.class) if live_endpoint
138
+ activity_builder(
139
+ record, from: from, to: to, within: within,
140
+ version_scope: version_scope, live_endpoint: live_endpoint
141
+ ).analyze
142
+ end
143
+
144
+ # `close_on:` names what ends a wall-clock window, so it is meaningless for a
145
+ # range whose endpoints the caller already gave explicitly.
146
+ #: (Symbol?, untyped) -> bool
147
+ def close_on_current?(close_on, within)
148
+ return false if close_on.nil?
149
+ unless close_on == CLOSE_ON_CURRENT
150
+ raise ConfigurationError, "close_on: must be #{CLOSE_ON_CURRENT.inspect} or nil"
151
+ end
152
+ raise ConfigurationError, 'close_on: requires `within`' if within.nil?
153
+
154
+ true
155
+ end
156
+
157
+ # A destroyed root has no current state to close on, and its own destroy
158
+ # version already terminates the history.
159
+ #: (untyped, Symbol?, untyped) -> untyped
160
+ def live_endpoint_for(record, close_on, within)
161
+ return unless close_on_current?(close_on, within)
162
+ return unless Endpoint.record?(record) && !record.destroyed?
163
+
164
+ record
165
+ end
166
+
130
167
  #: () -> void
131
168
  def build_snapshotters
132
169
  @historical_store = build_historical_store
133
- @timeline_snapshotter = TimelineSnapshotProvider.new(@historical_store)
170
+ @timeline_snapshotter = TimelineSnapshotProvider.new(
171
+ @historical_store, live_snapshotter: method(:live_snapshot)
172
+ )
134
173
  @activity_snapshotter = build_activity_snapshotter
135
174
  end
136
175
 
@@ -173,12 +212,13 @@ module PaperTrailDiff
173
212
  )
174
213
  end
175
214
 
176
- #: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped) -> ActivityTimelineBuilder
177
- def activity_builder(record, from:, to:, within:, version_scope: nil)
215
+ #: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped) -> ActivityTimelineBuilder
216
+ def activity_builder(record, from:, to:, within:, version_scope: nil, live_endpoint: nil) # rubocop:disable Metrics/ParameterLists
178
217
  ActivityTimelineBuilder.new(
179
218
  record,
180
219
  range: TimelineRange.new(
181
- record, from: from, to: to, within: within, version_scope: version_scope
220
+ record, from: from, to: to, within: within, version_scope: version_scope,
221
+ live_endpoint: live_endpoint
182
222
  ),
183
223
  tree: @association_tree,
184
224
  snapshotter: @activity_snapshotter
@@ -20,6 +20,10 @@ module PaperTrailDiff
20
20
  attr_reader :reconstruction_versions #: Array[untyped]
21
21
  attr_reader :steps #: Array[[untyped, untyped]]
22
22
  attr_reader :context_version #: untyped
23
+ # The live record a window closes on when no later version can reveal its
24
+ # final mutation. It is a boundary, never a version, so it stays out of
25
+ # `versions` and out of anything that reconstructs from version history.
26
+ attr_reader :closing_record #: untyped
23
27
  # The root versions this plan reports as mutations, which excludes any
24
28
  # version present only to reveal what the last of them produced.
25
29
  attr_reader :mutations #: Array[untyped]
@@ -43,10 +47,12 @@ module PaperTrailDiff
43
47
  end
44
48
  end
45
49
 
46
- #: (versions: Array[untyped], steps: Array[[untyped, untyped]], ?context_version: untyped, ?reconstruction_versions: Array[untyped]?, ?mutations: Array[untyped]?) -> void
47
- def initialize(
48
- versions:, steps:, context_version: nil, reconstruction_versions: nil, mutations: nil
50
+ #: (versions: Array[untyped], steps: Array[[untyped, untyped]], ?context_version: untyped, ?reconstruction_versions: Array[untyped]?, ?mutations: Array[untyped]?, ?closing_record: untyped) -> void
51
+ def initialize( # rubocop:disable Metrics/ParameterLists
52
+ versions:, steps:, context_version: nil, reconstruction_versions: nil, mutations: nil,
53
+ closing_record: nil
49
54
  )
55
+ @closing_record = closing_record
50
56
  @versions = versions.freeze
51
57
  @reconstruction_versions = (reconstruction_versions || versions).freeze
52
58
  @steps = steps.freeze
@@ -61,6 +67,13 @@ module PaperTrailDiff
61
67
  versions.empty?
62
68
  end
63
69
 
70
+ # What the range's final state is read from, which is the live record when
71
+ # the window closes on current state and the last version otherwise.
72
+ #: () -> untyped
73
+ def final_endpoint
74
+ @closing_record || @versions.last
75
+ end
76
+
64
77
  #: (untyped) -> bool
65
78
  def mutation?(version)
66
79
  @mutation_keys.include?(key(version))
@@ -13,9 +13,10 @@ module PaperTrailDiff
13
13
  class RootVersionSelection
14
14
  INCOMPLETE = 'time range requires a later root version to reconstruct its final change'
15
15
 
16
- #: (in_range: Array[untyped], selected: Array[untyped], after_range: untyped, windowed: bool, ?context_required: bool, ?filtered: bool) -> void
16
+ #: (in_range: Array[untyped], selected: Array[untyped], after_range: untyped, windowed: bool, ?context_required: bool, ?filtered: bool, ?live_endpoint: untyped) -> void
17
17
  def initialize( # rubocop:disable Metrics/ParameterLists
18
- in_range:, selected:, after_range:, windowed:, context_required: false, filtered: false
18
+ in_range:, selected:, after_range:, windowed:, context_required: false, filtered: false,
19
+ live_endpoint: nil
19
20
  )
20
21
  @in_range = in_range
21
22
  @selected = selected
@@ -23,18 +24,18 @@ module PaperTrailDiff
23
24
  @windowed = windowed
24
25
  @context_required = context_required
25
26
  @filtered = filtered
27
+ @live_endpoint = live_endpoint
26
28
  end
27
29
 
28
30
  #: () -> RootVersionPlan
29
31
  def call
30
32
  return without_selection if @selected.empty?
31
33
 
32
- revealing = revealing_version
33
- raise IncompleteTimeRangeError, INCOMPLETE if !revealing && @windowed && !terminal_destroy?
34
- return filtered_plan(revealing) if @filtered
34
+ closing = revealing_version || @live_endpoint
35
+ raise IncompleteTimeRangeError, INCOMPLETE if !closing && @windowed && !terminal_destroy?
36
+ return filtered_plan(closing) if @filtered
35
37
 
36
- versions = revealing ? (@selected + [revealing]) : @selected
37
- RootVersionPlan.contiguous(versions, context_version: revealing)
38
+ contiguous_plan(closing)
38
39
  end
39
40
 
40
41
  private
@@ -45,6 +46,22 @@ module PaperTrailDiff
45
46
  # @rbs @windowed: bool
46
47
  # @rbs @context_required: bool
47
48
  # @rbs @filtered: bool
49
+ # @rbs @live_endpoint: untyped
50
+
51
+ # A window reaching past the last recorded version has only the live record
52
+ # left to show what its final mutation produced. That record is a closing
53
+ # boundary rather than a selected mutation, so it never joins `versions`.
54
+ #: (untyped) -> RootVersionPlan
55
+ def contiguous_plan(closing)
56
+ return RootVersionPlan.contiguous(@selected) unless closing
57
+ unless Endpoint.record?(closing)
58
+ return RootVersionPlan.contiguous(@selected + [closing], context_version: closing)
59
+ end
60
+
61
+ steps = @selected.each_cons(2).map { |from, to| [from, to] } #: Array[[untyped, untyped]]
62
+ steps << [@selected.last, closing]
63
+ RootVersionPlan.new(versions: @selected, steps: steps, closing_record: closing)
64
+ end
48
65
 
49
66
  # An activity view still needs a root to reconstruct from even when no root
50
67
  # version falls inside the window, because descendants may have moved.
@@ -68,13 +85,20 @@ module PaperTrailDiff
68
85
  successor = version.equal?(@selected.last) ? revealing : immediate_successor(version)
69
86
  [version, successor] if successor
70
87
  end #: Array[[untyped, untyped]]
71
- versions = chronological(steps.flatten(1) + closing_versions)
88
+ filtered_plan_for(steps, revealing)
89
+ end
90
+
91
+ #: (Array[[untyped, untyped]], untyped) -> RootVersionPlan
92
+ def filtered_plan_for(steps, revealing)
93
+ live = revealing if Endpoint.record?(revealing)
94
+ versions = chronological(
95
+ (steps.flatten(1) + closing_versions).reject { |entry| Endpoint.record?(entry) }
96
+ )
72
97
  RootVersionPlan.new(
73
- versions: versions,
74
- steps: steps,
75
- context_version: revealing,
98
+ versions: versions, steps: steps,
99
+ context_version: (revealing unless live),
76
100
  reconstruction_versions: spanned(versions),
77
- mutations: @selected
101
+ mutations: @selected, closing_record: live
78
102
  )
79
103
  end
80
104
 
@@ -10,12 +10,20 @@ module PaperTrailDiff
10
10
  attr_reader :to_boundary #: ActivityBoundary
11
11
  attr_reader :diff #: Diff
12
12
 
13
- #: (from_version: untyped, to_version: untyped, diff: Diff) -> void
14
- def initialize(from_version:, to_version:, diff:)
13
+ # `to_version` is nil for the one step that closes on current state, which a
14
+ # window reaching past the last recorded version has to do. Read
15
+ # `to_boundary` instead when a caller may have opted into that.
16
+ #: (from_version: untyped, to_version: untyped, diff: Diff, ?captured_at: untyped) -> void
17
+ def initialize(from_version:, to_version:, diff:, captured_at: nil)
18
+ live = Endpoint.record?(to_version)
15
19
  @from_version = from_version
16
- @to_version = to_version
20
+ @to_version = (to_version unless live)
17
21
  @from_boundary = ActivityBoundary.from_version(from_version)
18
- @to_boundary = ActivityBoundary.from_version(to_version)
22
+ @to_boundary = if live
23
+ ActivityBoundary.current(to_version, captured_at: captured_at)
24
+ else
25
+ ActivityBoundary.from_version(to_version)
26
+ end
19
27
  @diff = diff
20
28
  freeze
21
29
  end
@@ -29,7 +37,8 @@ module PaperTrailDiff
29
37
  def to_h
30
38
  {
31
39
  from_version_id: from_version.id,
32
- to_version_id: to_version.id,
40
+ to_version_id: to_version&.id,
41
+ to_boundary: to_boundary.to_h,
33
42
  diff: diff.to_h
34
43
  }
35
44
  end