paper_trail_diff 0.6.0 → 0.7.1
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 +49 -0
- data/README.md +92 -11
- 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/live_endpoint_provider.rb +21 -2
- 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 +43 -14
- data/lib/paper_trail_diff/step.rb +14 -5
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +86 -26
- 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/live_endpoint_provider.rbs +9 -0
- 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 +16 -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: a84116670d1dd65a6cf569a266ccc8587a9eab4e65d38cf84df485c26d33d9dc
|
|
4
|
+
data.tar.gz: 2c8ddb0fd1b2203e644f3c50ed24da33ed25cb4c4240665eab85368cd4534f22
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3e32ecbedd636456388129cc664cf688b0aa909d5c05b45124eb1a6bd82dabbffa96557fe5efc946e93fe459234d3c4c5ce9af11f39228ddab55228f5a53e2f9
|
|
7
|
+
data.tar.gz: 1b29680ab942334fd98ce5cc0a4f2bc823fd180b258282e8be2c07ba784c9c3b5aa9edc76e0e118fec3baabcfbc94344c208aef116702874c0eb4dd226cddcac
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,55 @@
|
|
|
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.1] - 2026-08-11
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- Load each current endpoint once per call instead of once per request for it,
|
|
11
|
+
so `close_on: :current` no longer costs a live read per root and
|
|
12
|
+
`analyze_many` keeps the flat query cost it exists for. A twenty-root batch
|
|
13
|
+
closing on current state issued 43 queries against the 3 a closed window
|
|
14
|
+
needs. Every root in one result now also reflects the same instant rather
|
|
15
|
+
than whenever its turn came.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Name the way out in `IncompleteTimeRangeError`: the message now points at
|
|
20
|
+
`close_on: :current` and at narrowing the window. Its previous advice — add a
|
|
21
|
+
checkpoint after the window — cannot be followed by a report about the
|
|
22
|
+
present, which is the case that raises it most.
|
|
23
|
+
- Correct the `reload_live_endpoints:` documentation, which claimed `timeline`
|
|
24
|
+
and `analyze` never read live state. They do under `close_on: :current`, and
|
|
25
|
+
they do not accept the option.
|
|
26
|
+
|
|
27
|
+
## [0.7.0] - 2026-08-11
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- Accept `close_on: :current` on `timeline`, `activity_timeline`, `analyze`, and
|
|
32
|
+
`analyze_many`, closing a wall-clock window on the live record when no later
|
|
33
|
+
root version can reveal its final mutation. A window ending at the present
|
|
34
|
+
otherwise raises `IncompleteTimeRangeError`, since a version records the state
|
|
35
|
+
before its own event and nothing follows the last one. The closing step's
|
|
36
|
+
`to_boundary.kind` is `:current` and its `to_version` is `nil`; `Step#to_h`
|
|
37
|
+
now carries `to_boundary` so a serialized step still names its endpoint. Only
|
|
38
|
+
valid alongside `within:`, because an explicit `from:`/`to:` range already
|
|
39
|
+
says where it ends. Under `activity: true` the window runs to the instant
|
|
40
|
+
state is captured rather than to the last root version, so a descendant that
|
|
41
|
+
moved after that version is reported rather than dropped. A destroyed root has
|
|
42
|
+
no current state to close on and keeps closing on its own destruction.
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
|
|
46
|
+
- Document that `version_scope:` selects root versions only, and that "what did
|
|
47
|
+
this person change?" is answered by an activity timeline filtered on
|
|
48
|
+
`step.from_boundary.whodunnit` instead. A step's diff is exactly what the
|
|
49
|
+
event at that boundary did, so the predicate covers descendant edits, which a
|
|
50
|
+
root-version filter cannot see at all. Filtering in Ruby rather than selecting
|
|
51
|
+
fewer versions is also what keeps the attribution correct: every boundary has
|
|
52
|
+
to be reconstructed, or the snapshot carried into the next step is a state the
|
|
53
|
+
record had already moved past.
|
|
54
|
+
|
|
6
55
|
## [0.6.0] - 2026-08-11
|
|
7
56
|
|
|
8
57
|
### Added
|
data/README.md
CHANGED
|
@@ -242,8 +242,13 @@ must observe one atomic snapshot.
|
|
|
242
242
|
|
|
243
243
|
`compare`, `compare_many`, and `activity_timeline(..., to: record)` reload
|
|
244
244
|
current endpoints by default. A caller that already owns a consistent, fully
|
|
245
|
-
preloaded graph may opt out.
|
|
246
|
-
|
|
245
|
+
preloaded graph may opt out. Each endpoint is loaded once per call, so a batch
|
|
246
|
+
that resolves every root up front and then reads one of them again pays
|
|
247
|
+
nothing the second time, and every root in one result reflects the same instant.
|
|
248
|
+
|
|
249
|
+
The option is not accepted by `timeline`, `analyze`, or `analyze_many`. Those
|
|
250
|
+
are bounded by versions and read live state only under
|
|
251
|
+
[`close_on: :current`](#reporting-up-to-now), which always reloads:
|
|
247
252
|
|
|
248
253
|
```ruby
|
|
249
254
|
orders = Order.where(id: order_ids).preload(line_items: :product).to_a
|
|
@@ -324,10 +329,10 @@ value is the same `Analysis` that `analyze` returns for one record. A root with
|
|
|
324
329
|
no versions inside the window gets an empty `Analysis` rather than raising, so a
|
|
325
330
|
listing page needs no special case. Root identities must be unique.
|
|
326
331
|
|
|
327
|
-
### Reporting on a subset of mutations
|
|
332
|
+
### Reporting on a subset of root mutations
|
|
328
333
|
|
|
329
|
-
`version_scope:` narrows which root versions count as *selected mutations*,
|
|
330
|
-
|
|
334
|
+
`version_scope:` narrows which root versions count as *selected mutations*, so a
|
|
335
|
+
checkpoint timeline reports only some of them:
|
|
331
336
|
|
|
332
337
|
```ruby
|
|
333
338
|
user_edits = ->(scope) { scope.where.not(whodunnit: nil) }
|
|
@@ -340,6 +345,12 @@ The hook receives the version relation for the range and returns a narrowed one.
|
|
|
340
345
|
It is accepted by `timeline`, `activity_timeline`, `analyze`, and
|
|
341
346
|
`analyze_many`, with any range form.
|
|
342
347
|
|
|
348
|
+
**It sees root versions only.** Someone who edited only comments or tags has no
|
|
349
|
+
root versions to select, so filtering to them yields an empty result even though
|
|
350
|
+
they changed plenty. For "what did this person change?", reach for
|
|
351
|
+
[one person's changes](#reporting-on-one-persons-changes) instead — this hook
|
|
352
|
+
answers the different question of which *root checkpoints* a timeline reports.
|
|
353
|
+
|
|
343
354
|
It filters *selected mutations only*. Versions the filter excludes are still
|
|
344
355
|
loaded, because a version records the state before its own event: without the
|
|
345
356
|
one that follows a selected change, whatever that change produced cannot be
|
|
@@ -385,6 +396,49 @@ batches far less.
|
|
|
385
396
|
Roots are supplied as live records, so a root deleted inside the window cannot
|
|
386
397
|
be included; use `activity_timeline` for a history that ends in a deletion.
|
|
387
398
|
|
|
399
|
+
### Reporting on one person's changes
|
|
400
|
+
|
|
401
|
+
An activity timeline already carries this. Every boundary in the span becomes a
|
|
402
|
+
step, and a step's diff is exactly what the event at its `from_boundary` did, so
|
|
403
|
+
that boundary's `whodunnit` is who made the change:
|
|
404
|
+
|
|
405
|
+
<!-- executable:readme-person-changes -->
|
|
406
|
+
```ruby
|
|
407
|
+
steps = PaperTrailDiff.activity_timeline(
|
|
408
|
+
article, within: window, associations: %i[comments tags]
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
attributed = steps.reject(&:empty?)
|
|
412
|
+
by_priya = attributed.select { |step| step.from_boundary.whodunnit == "Priya Shah" }
|
|
413
|
+
|
|
414
|
+
by_priya.first.from_boundary.whodunnit # "Priya Shah"
|
|
415
|
+
by_priya.first.diff # exactly what that one event changed
|
|
416
|
+
|
|
417
|
+
authors = attributed.group_by { |step| step.from_boundary.whodunnit }
|
|
418
|
+
.transform_values(&:length)
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
This covers descendants, which is what `version_scope:` cannot do. On one demo
|
|
422
|
+
history a contributor who only ever touched comments and authorships has four
|
|
423
|
+
attributed activity steps and *zero* root steps.
|
|
424
|
+
|
|
425
|
+
Filter in Ruby rather than asking the gem to select fewer versions. Attribution
|
|
426
|
+
is only correct because every boundary is reconstructed: skip one and the
|
|
427
|
+
snapshot carried into the next step is a state the record had already moved
|
|
428
|
+
past, so the change lands on the wrong person. There is nothing to save by
|
|
429
|
+
filtering earlier — the work is the reconstruction, not the comparison — and a
|
|
430
|
+
predicate can say things a version relation cannot, such as several people at
|
|
431
|
+
once or "anyone but the importer".
|
|
432
|
+
|
|
433
|
+
`reject(&:empty?)` first, because a boundary that changed nothing you selected
|
|
434
|
+
still produces a step. The final event in the range has no boundary after it, so
|
|
435
|
+
what it produced is not shown — the same blind spot `to:` has everywhere else.
|
|
436
|
+
|
|
437
|
+
For "which of these records did someone touch?" across a listing, note that
|
|
438
|
+
`analyze_many` batches the diff and timeline views but not descendant discovery.
|
|
439
|
+
Querying versions by `whodunnit` and mapping them back to roots yourself will be
|
|
440
|
+
much cheaper than an activity pass per record.
|
|
441
|
+
|
|
388
442
|
## Build a root-checkpoint timeline
|
|
389
443
|
|
|
390
444
|
`timeline` accepts two version objects from the supplied record's history, or
|
|
@@ -579,12 +633,39 @@ version to reveal the final selected mutation. That version is reconstruction
|
|
|
579
633
|
context, not an additional selected mutation.
|
|
580
634
|
|
|
581
635
|
If the window contains a relevant mutation but no later root version exists,
|
|
582
|
-
the call raises `PaperTrailDiff::IncompleteTimeRangeError`.
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
636
|
+
the call raises `PaperTrailDiff::IncompleteTimeRangeError`. The gem does not
|
|
637
|
+
silently substitute current database state; pass
|
|
638
|
+
[`close_on: :current`](#reporting-up-to-now) to ask for it, or create a root
|
|
639
|
+
checkpoint after the reporting window. A root-only window with no selected
|
|
640
|
+
mutation returns a frozen empty timeline. The one exception is a window that
|
|
641
|
+
closes on the root's own destruction, which no later version can ever follow;
|
|
642
|
+
see [closing a destroyed root](#closing-a-destroyed-root).
|
|
643
|
+
|
|
644
|
+
### Reporting up to now
|
|
645
|
+
|
|
646
|
+
A window whose end is the present has nothing recorded after its final
|
|
647
|
+
mutation, so by default it raises. `close_on: :current` closes it on the live
|
|
648
|
+
record instead:
|
|
649
|
+
|
|
650
|
+
```ruby
|
|
651
|
+
PaperTrailDiff.analyze_many(
|
|
652
|
+
articles, within: month_start..Time.current, close_on: :current
|
|
653
|
+
)
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
It is accepted by `timeline`, `activity_timeline`, `analyze`, and
|
|
657
|
+
`analyze_many`, and only alongside `within:` — a range whose endpoints you gave
|
|
658
|
+
explicitly already says where it ends. The closing step's `to_boundary.kind` is
|
|
659
|
+
`:current` and its `to_version` is `nil`, so read the boundary rather than the
|
|
660
|
+
version on the last step. Under `activity: true` the window runs to the instant
|
|
661
|
+
state is captured rather than to the last root version, so a descendant that
|
|
662
|
+
moved after that version is still reported.
|
|
663
|
+
|
|
664
|
+
Two things follow from closing on live state. Current state includes changes
|
|
665
|
+
PaperTrail never recorded — an `update_columns` write shows up in that final
|
|
666
|
+
step, attributed to the version that precedes it, because nothing else records
|
|
667
|
+
who made it. And a destroyed root has no current state to close on, so it keeps
|
|
668
|
+
closing on its own destruction as it already did.
|
|
588
669
|
|
|
589
670
|
Time ranges and explicit `from:`/`to:` endpoints are mutually exclusive.
|
|
590
671
|
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]?
|
|
@@ -23,18 +23,37 @@ module PaperTrailDiff
|
|
|
23
23
|
else
|
|
24
24
|
PreloadedEndpointBatchLoader.new(tree: tree, traversal: traversal)
|
|
25
25
|
end
|
|
26
|
+
@loaded = {} #: Hash[identity, untyped]
|
|
26
27
|
end
|
|
27
28
|
|
|
29
|
+
# Loads each endpoint at most once for the life of this provider, which is
|
|
30
|
+
# one public call. A batch that resolves every root up front and then asks
|
|
31
|
+
# again for one of them costs nothing the second time, and every root in the
|
|
32
|
+
# result reflects the same instant rather than whenever its turn came.
|
|
28
33
|
#: (Array[untyped]) -> Hash[identity, untyped]
|
|
29
34
|
def call(records)
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
missing = records.reject { |record| @loaded.key?(Endpoint.identity(record)) }
|
|
36
|
+
return known(records) if missing.empty?
|
|
37
|
+
|
|
38
|
+
loaded = Instrumentation.instrument('load_live_endpoints', payload(missing)) do
|
|
39
|
+
@loader.call(missing)
|
|
32
40
|
end
|
|
41
|
+
@loaded.merge!(loaded)
|
|
42
|
+
known(records)
|
|
33
43
|
end
|
|
34
44
|
|
|
35
45
|
private
|
|
36
46
|
|
|
37
47
|
# @rbs @loader: untyped
|
|
48
|
+
# @rbs @loaded: Hash[identity, untyped]
|
|
49
|
+
|
|
50
|
+
#: (Array[untyped]) -> Hash[identity, untyped]
|
|
51
|
+
def known(records)
|
|
52
|
+
records.to_h do |record|
|
|
53
|
+
identity = Endpoint.identity(record)
|
|
54
|
+
[identity, @loaded.fetch(identity)]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
38
57
|
|
|
39
58
|
#: (Array[untyped]) -> Hash[Symbol, untyped]
|
|
40
59
|
def payload(records)
|
|
@@ -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))
|