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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +52 -0
- data/README.md +146 -7
- data/lib/paper_trail_diff/activity_root_steps.rb +19 -7
- data/lib/paper_trail_diff/activity_timeline_builder.rb +15 -8
- data/lib/paper_trail_diff/analysis.rb +19 -5
- data/lib/paper_trail_diff/analysis_batch.rb +35 -13
- data/lib/paper_trail_diff/batched_root_analyzer.rb +9 -9
- data/lib/paper_trail_diff/batched_root_versions.rb +62 -29
- data/lib/paper_trail_diff/paper_trail_adapter.rb +72 -25
- data/lib/paper_trail_diff/root_version_plan.rb +99 -0
- data/lib/paper_trail_diff/root_version_selection.rb +155 -0
- data/lib/paper_trail_diff/step.rb +14 -5
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +99 -29
- data/lib/paper_trail_diff/time_version_range.rb +22 -24
- data/lib/paper_trail_diff/timeline_builder.rb +32 -20
- data/lib/paper_trail_diff/timeline_range.rb +34 -7
- 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/version_range.rb +45 -5
- data/lib/paper_trail_diff/version_scope_filter.rb +37 -0
- data/lib/paper_trail_diff.rb +32 -13
- data/sig/generated/paper_trail_diff/activity_root_steps.rbs +7 -2
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +6 -2
- data/sig/generated/paper_trail_diff/analysis.rbs +13 -4
- data/sig/generated/paper_trail_diff/analysis_batch.rbs +15 -6
- data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +4 -4
- data/sig/generated/paper_trail_diff/batched_root_versions.rbs +28 -14
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +26 -10
- data/sig/generated/paper_trail_diff/root_version_plan.rbs +66 -0
- data/sig/generated/paper_trail_diff/root_version_selection.rbs +88 -0
- data/sig/generated/paper_trail_diff/step.rbs +5 -2
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +41 -15
- data/sig/generated/paper_trail_diff/time_version_range.rbs +9 -9
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +9 -6
- data/sig/generated/paper_trail_diff/timeline_range.rbs +13 -2
- data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +11 -2
- data/sig/generated/paper_trail_diff/version_range.rbs +17 -2
- data/sig/generated/paper_trail_diff/version_scope_filter.rbs +22 -0
- data/sig/generated/paper_trail_diff.rbs +8 -8
- metadata +10 -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,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`.
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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.
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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:
|
|
16
|
-
to_version:
|
|
17
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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
|
-
#: (
|
|
181
|
-
def build_analysis(
|
|
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(
|
|
189
|
+
timeline: ActivityRootSteps.call(plan, history.root_snapshots),
|
|
185
190
|
activity_timeline: activity_steps(
|
|
186
|
-
history, events, destroyed_boundary(
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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(
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
|
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,
|
|
68
|
-
def analysis_for(record,
|
|
69
|
-
return Analysis.empty if
|
|
89
|
+
#: (untyped, RootVersionPlan) -> Analysis
|
|
90
|
+
def analysis_for(record, plan)
|
|
91
|
+
return Analysis.empty if plan.empty?
|
|
70
92
|
|
|
71
|
-
@analyzer.call(record,
|
|
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,
|
|
18
|
-
def call(record,
|
|
17
|
+
#: (untyped, RootVersionPlan) -> Analysis
|
|
18
|
+
def call(record, plan)
|
|
19
19
|
@preparer.call(record.class, historical: true)
|
|
20
|
-
return activity_analysis(record,
|
|
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
|
-
|
|
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,
|
|
41
|
-
def activity_analysis(record,
|
|
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,
|
|
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
|
|
16
|
-
#: () -> Hash[Array[String],
|
|
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],
|
|
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],
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
(
|
|
79
|
+
current = loaded.fetch(key, record)
|
|
80
|
+
current unless current.destroyed?
|
|
60
81
|
end
|
|
61
82
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|