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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -0
- data/README.md +85 -9
- data/lib/paper_trail_diff/activity_root_steps.rb +17 -7
- data/lib/paper_trail_diff/analysis_batch.rb +11 -7
- data/lib/paper_trail_diff/batched_root_versions.rb +21 -6
- data/lib/paper_trail_diff/paper_trail_adapter.rb +63 -23
- data/lib/paper_trail_diff/root_version_plan.rb +16 -3
- data/lib/paper_trail_diff/root_version_selection.rb +36 -12
- data/lib/paper_trail_diff/step.rb +14 -5
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +83 -25
- data/lib/paper_trail_diff/time_version_range.rb +6 -3
- data/lib/paper_trail_diff/timeline_builder.rb +17 -8
- data/lib/paper_trail_diff/timeline_range.rb +6 -3
- data/lib/paper_trail_diff/timeline_snapshot_provider.rb +20 -4
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff.rb +20 -12
- data/sig/generated/paper_trail_diff/activity_root_steps.rbs +7 -2
- data/sig/generated/paper_trail_diff/analysis_batch.rbs +6 -4
- data/sig/generated/paper_trail_diff/batched_root_versions.rbs +11 -4
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +26 -10
- data/sig/generated/paper_trail_diff/root_version_plan.rbs +12 -2
- data/sig/generated/paper_trail_diff/root_version_selection.rbs +13 -2
- data/sig/generated/paper_trail_diff/step.rbs +5 -2
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +33 -12
- data/sig/generated/paper_trail_diff/time_version_range.rbs +4 -2
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +5 -2
- data/sig/generated/paper_trail_diff/timeline_range.rbs +4 -2
- data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +11 -2
- data/sig/generated/paper_trail_diff.rbs +8 -8
- metadata +4 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 33b445f893d6d95faf0ad22b72d35e8b248f3c00240e537898cac2d067ece043
|
|
4
|
+
data.tar.gz: 57ee0b8fc242825852726933a9aa332cd309c38d2af21724a41c4bfcfbf2bd41
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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`.
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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:
|
|
15
|
-
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(
|
|
18
|
-
root_snapshots
|
|
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
|
-
|
|
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
|
|
85
|
-
record, from: from, to: to, within: within,
|
|
86
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
33
|
-
raise IncompleteTimeRangeError, INCOMPLETE if !
|
|
34
|
-
return filtered_plan(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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 =
|
|
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
|
|
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
|