paper_trail_diff 0.7.1 → 0.9.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 +65 -0
- data/QUICKSTART.md +27 -2
- data/README.md +104 -2
- data/lib/paper_trail_diff/activity_boundary.rb +25 -2
- data/lib/paper_trail_diff/activity_history.rb +9 -6
- data/lib/paper_trail_diff/activity_timeline_builder.rb +12 -8
- data/lib/paper_trail_diff/diagnostics.rb +14 -1
- data/lib/paper_trail_diff/errors.rb +3 -0
- data/lib/paper_trail_diff/instrumentation.rb +8 -0
- data/lib/paper_trail_diff/paper_trail_adapter.rb +37 -14
- data/lib/paper_trail_diff/prepared_record_index.rb +1 -1
- data/lib/paper_trail_diff/root_version_selection.rb +1 -2
- data/lib/paper_trail_diff/support.rb +44 -0
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +11 -7
- data/lib/paper_trail_diff/time_version_range.rb +1 -1
- data/lib/paper_trail_diff/timeline_snapshot_provider.rb +3 -6
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_range.rb +1 -1
- data/lib/paper_trail_diff/version_sequence_diagnostics.rb +82 -0
- data/lib/paper_trail_diff.rb +11 -6
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +17 -2
- data/sig/generated/paper_trail_diff/activity_history.rbs +4 -2
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +4 -2
- data/sig/generated/paper_trail_diff/diagnostics.rbs +3 -0
- data/sig/generated/paper_trail_diff/errors.rbs +4 -0
- data/sig/generated/paper_trail_diff/instrumentation.rbs +5 -0
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +16 -8
- data/sig/generated/paper_trail_diff/support.rbs +23 -0
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +4 -2
- data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +2 -2
- data/sig/generated/paper_trail_diff/version_sequence_diagnostics.rbs +39 -0
- data/sig/generated/paper_trail_diff.rbs +4 -4
- metadata +6 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1ee80500dbeb48421e7b0e0766a5f5559b73aa71528360a5f1a16866f9e6ccfa
|
|
4
|
+
data.tar.gz: 6692d3fd2d725918e16da15b4d39312b5e1a42deba3aa8e50a269abfd9fc6bd9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f809075c65c923ca0dd828e8337477d8461dfa13b2de537a9f99548f0826f867379a33c8bf4a5061476dabd1685829adc5ab21e07052a4d40ebfff4736a44946
|
|
7
|
+
data.tar.gz: 6212fab6601c900b2701a9401e7e9b66c5236f12da0340788f0641efef759340a29ea6f95e7b51d86c2792d847f5553db8f30d7c96e4370e37ceec0b8a227656
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,71 @@
|
|
|
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.9.0] - 2026-08-14
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- Report versions that share a timestamp, which hides any association change
|
|
11
|
+
between them: PT-AT records membership per version but resolves it by
|
|
12
|
+
timestamp, so such a pair is indistinguishable even when sequential ids order
|
|
13
|
+
it correctly. `diagnose` reports `:tied_version_timestamps` as a warning when
|
|
14
|
+
`associations:` are selected, and a comparison in that state emits
|
|
15
|
+
`ambiguous_association_boundary.paper_trail_diff`. It warns rather than
|
|
16
|
+
raising because the result may be perfectly correct — if nothing associated
|
|
17
|
+
changed, nothing was lost — and the gem cannot distinguish the cases, since
|
|
18
|
+
not seeing the change is the symptom.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- Link the demo application from the README now that it is public.
|
|
23
|
+
|
|
24
|
+
## [0.8.0] - 2026-08-12
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- Accept `snapshots: true` on `activity_timeline` and `analyze(activity: true)`,
|
|
29
|
+
retaining the reconstructed states each step was compared between as
|
|
30
|
+
`ActivityStep#from_snapshot` and `#to_snapshot`. A diff names what changed; a
|
|
31
|
+
renderer that has to name an unchanged field of a changed record needed the
|
|
32
|
+
whole state and had no way to reach it, so consumers were rebuilding it from
|
|
33
|
+
the version table by hand — slower, and easy to get wrong in ways that stay
|
|
34
|
+
quiet. The gem already builds these states to compute each diff and discarded
|
|
35
|
+
them, so the option costs no extra queries. Off by default because each one
|
|
36
|
+
holds the whole selected graph.
|
|
37
|
+
- Add `examples/demo.rb`, a self-contained tour that needs no application: it
|
|
38
|
+
builds an in-memory database, writes a small multi-author history, and prints
|
|
39
|
+
an endpoint diff, a checkpoint timeline, per-person attribution across a
|
|
40
|
+
nested record, and one person's changes alone. It runs standalone, inside a
|
|
41
|
+
project, or under `bundle exec`, fetching what it needs on first run.
|
|
42
|
+
|
|
43
|
+
- Raise `PaperTrailDiff::AmbiguousVersionOrderError` when versions sharing a
|
|
44
|
+
timestamp have ids that cannot order them. Ordering falls back to the id when
|
|
45
|
+
timestamps tie, which recovers the real sequence only while ids increase with
|
|
46
|
+
insertion; a UUID version id does not, and a MySQL `datetime` column stores
|
|
47
|
+
whole seconds, so the two together are ordinary rather than exotic. The gem
|
|
48
|
+
previously returned a plausible-looking timeline with steps in the wrong
|
|
49
|
+
order and others dropped. `diagnose` reports the same condition as an error
|
|
50
|
+
so it can be caught before a run rather than after a wrong answer.
|
|
51
|
+
- Document the model and schema shapes the suite covers: single-table
|
|
52
|
+
inheritance (results are keyed by the base class, so build keys with
|
|
53
|
+
`Endpoint.identity`), non-integer primary keys, and a custom version class
|
|
54
|
+
via `has_paper_trail versions: { class_name: }`.
|
|
55
|
+
|
|
56
|
+
### Changed
|
|
57
|
+
|
|
58
|
+
- Create the models it uses in the quickstart. It declared `has_paper_trail` on
|
|
59
|
+
an `Article` and then used `Comment` without ever generating either, so
|
|
60
|
+
following it in a new application failed on a missing table at the first
|
|
61
|
+
console step.
|
|
62
|
+
- Run `diagnose`'s version-order check whether or not `associations:` are
|
|
63
|
+
selected, so a report never answers `ok?` having inspected nothing. Document
|
|
64
|
+
that `ok?` means no errors among the checks that ran.
|
|
65
|
+
|
|
66
|
+
### Fixed
|
|
67
|
+
|
|
68
|
+
- Cover `Analysis#to_h(snapshots: true)` with specs. It was shipped in 0.6.0
|
|
69
|
+
with no test at all.
|
|
70
|
+
|
|
6
71
|
## [0.7.1] - 2026-08-11
|
|
7
72
|
|
|
8
73
|
### Fixed
|
data/QUICKSTART.md
CHANGED
|
@@ -4,6 +4,18 @@ This guide gets `paper_trail_diff` running in a Rails application and shows the
|
|
|
4
4
|
smallest useful examples. Ruby 3.1 or newer and PaperTrail 16 or 17 are
|
|
5
5
|
supported.
|
|
6
6
|
|
|
7
|
+
Every command and console snippet below is meant to be run in order against a
|
|
8
|
+
scratch application, so nothing here assumes models you already have:
|
|
9
|
+
|
|
10
|
+
```console
|
|
11
|
+
rails new diff-demo --minimal
|
|
12
|
+
cd diff-demo
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
**Just want to see it work?** [`examples/demo.rb`](examples/demo.rb) is a single
|
|
16
|
+
self-contained file — no application, no migrations, nothing to undo. Download
|
|
17
|
+
it anywhere and run `ruby demo.rb`; it fetches what it needs on first run.
|
|
18
|
+
|
|
7
19
|
## 1. Install the gem
|
|
8
20
|
|
|
9
21
|
From the Rails application directory:
|
|
@@ -21,6 +33,13 @@ reified state rather than a PaperTrail changeset.
|
|
|
21
33
|
|
|
22
34
|
## 2. Version a model
|
|
23
35
|
|
|
36
|
+
Create the model this guide uses, then declare it versioned:
|
|
37
|
+
|
|
38
|
+
```console
|
|
39
|
+
bin/rails generate model Article title:string
|
|
40
|
+
bin/rails db:migrate
|
|
41
|
+
```
|
|
42
|
+
|
|
24
43
|
```ruby
|
|
25
44
|
# app/models/article.rb
|
|
26
45
|
class Article < ApplicationRecord
|
|
@@ -195,8 +214,14 @@ bin/rails db:migrate
|
|
|
195
214
|
```
|
|
196
215
|
|
|
197
216
|
The generator creates `version_associations` and enables
|
|
198
|
-
`PaperTrail.config.track_associations`.
|
|
199
|
-
|
|
217
|
+
`PaperTrail.config.track_associations`. This section also needs a second model:
|
|
218
|
+
|
|
219
|
+
```console
|
|
220
|
+
bin/rails generate model Comment article:references body:string
|
|
221
|
+
bin/rails db:migrate
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Every model whose historical state is needed must be versioned:
|
|
200
225
|
|
|
201
226
|
```ruby
|
|
202
227
|
class Article < ApplicationRecord
|
data/README.md
CHANGED
|
@@ -21,7 +21,18 @@ available when
|
|
|
21
21
|
|
|
22
22
|
Ruby 3.1 or newer and PaperTrail 16 or 17 are supported.
|
|
23
23
|
|
|
24
|
-
New to the gem?
|
|
24
|
+
New to the gem? Run [`examples/demo.rb`](examples/demo.rb) to see it work in
|
|
25
|
+
about ten seconds — one self-contained file, no application and no migrations,
|
|
26
|
+
which fetches what it needs on first run:
|
|
27
|
+
|
|
28
|
+
```console
|
|
29
|
+
ruby demo.rb
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Then start with the copyable [Quickstart](QUICKSTART.md), or browse
|
|
33
|
+
[the demo application](https://github.com/aheathwilliams/paper_trail_summary_test_app)
|
|
34
|
+
— a small Rails app that installs a published release and exercises scalar,
|
|
35
|
+
nested, through-association, and HABTM history against it.
|
|
25
36
|
|
|
26
37
|
## Installation
|
|
27
38
|
|
|
@@ -83,7 +94,63 @@ for. Three consequences run through the rest of this document:
|
|
|
83
94
|
both timeline APIs return steps rather than events.
|
|
84
95
|
|
|
85
96
|
The [Quickstart](QUICKSTART.md) walks through the same idea against a real
|
|
86
|
-
console session.
|
|
97
|
+
console session, creating the models it uses as it goes.
|
|
98
|
+
|
|
99
|
+
## Which models and schemas work
|
|
100
|
+
|
|
101
|
+
Anything `has_paper_trail` tracks, including these, which are covered by the
|
|
102
|
+
test suite rather than assumed:
|
|
103
|
+
|
|
104
|
+
- **Single-table inheritance.** Results are keyed by the *base* class, matching
|
|
105
|
+
what PaperTrail writes to `item_type`. Analyzing a `Book < Publication`
|
|
106
|
+
produces the key `["Publication", "12"]`, so `results.fetch(["Book", ...])`
|
|
107
|
+
raises `KeyError`. Build keys with `PaperTrailDiff::Endpoint.identity(record)`
|
|
108
|
+
rather than by hand.
|
|
109
|
+
- **Non-integer primary keys** on the tracked model, such as a UUID column.
|
|
110
|
+
- **A custom version class**, via `has_paper_trail versions: { class_name: }`.
|
|
111
|
+
Nothing here references `PaperTrail::Version` directly.
|
|
112
|
+
|
|
113
|
+
### Versions that cannot be ordered
|
|
114
|
+
|
|
115
|
+
Versions are ordered by `created_at`, falling back to the id when timestamps
|
|
116
|
+
tie. That fallback recovers the real sequence only while ids increase with
|
|
117
|
+
insertion. An autoincrement id does; a **UUID version id does not**.
|
|
118
|
+
|
|
119
|
+
So two conditions together are unsafe: version ids that are not sequential,
|
|
120
|
+
*and* versions sharing a timestamp — which is ordinary on a MySQL `datetime`
|
|
121
|
+
column, since it stores whole seconds. The order is then unrecoverable, and
|
|
122
|
+
rather than report a plausible-looking history in the wrong order the gem
|
|
123
|
+
raises `PaperTrailDiff::AmbiguousVersionOrderError` naming both versions.
|
|
124
|
+
`diagnose` reports the same condition as an error, so a caller can check before
|
|
125
|
+
running rather than after a surprise.
|
|
126
|
+
|
|
127
|
+
Either half alone is fine. Sequential ids order tied timestamps correctly, and
|
|
128
|
+
distinct timestamps never reach the fallback.
|
|
129
|
+
|
|
130
|
+
### Association changes across a shared timestamp
|
|
131
|
+
|
|
132
|
+
Sequential ids recover the *order* of tied versions, but not their association
|
|
133
|
+
state. PT-AT records association membership per version and resolves it by
|
|
134
|
+
timestamp, so two versions sharing one are indistinguishable for that purpose:
|
|
135
|
+
**an association change between them is invisible.** It is not reported as
|
|
136
|
+
absent — the association simply does not appear in the diff.
|
|
137
|
+
|
|
138
|
+
Unlike an unorderable sequence this is not always wrong. If nothing associated
|
|
139
|
+
changed between the two versions, the result is correct, and the gem cannot
|
|
140
|
+
tell the two cases apart, because not seeing the change is the symptom. So it
|
|
141
|
+
does not raise. Instead:
|
|
142
|
+
|
|
143
|
+
- `diagnose` reports `:tied_version_timestamps` as a **warning** whenever
|
|
144
|
+
`associations:` are selected and versions in range share a timestamp.
|
|
145
|
+
- a comparison in that state emits
|
|
146
|
+
`ambiguous_association_boundary.paper_trail_diff` through ActiveSupport
|
|
147
|
+
notifications, carrying the two version ids and the shared timestamp. Nothing
|
|
148
|
+
is logged; subscribe if you want it surfaced.
|
|
149
|
+
|
|
150
|
+
Writing several versions inside one clock tick is easy to do — a service object
|
|
151
|
+
that touches a record and its children in quick succession will manage it — so
|
|
152
|
+
this is worth checking before trusting an association history. Recording
|
|
153
|
+
versions at sub-second precision separates them.
|
|
87
154
|
|
|
88
155
|
## Choosing an entry point
|
|
89
156
|
|
|
@@ -549,6 +616,35 @@ boundary. Passing `to: article` is what removes the need to touch the parent
|
|
|
549
616
|
after an ordinary versioned child mutation; current state is still never
|
|
550
617
|
implicit.
|
|
551
618
|
|
|
619
|
+
### Reading the state behind a step
|
|
620
|
+
|
|
621
|
+
A step's diff carries what changed. A renderer often needs what did *not* — to
|
|
622
|
+
say whose comment was edited, it needs the comment's author, which the diff has
|
|
623
|
+
no reason to mention. `snapshots: true` retains the reconstructed states each
|
|
624
|
+
step was compared between:
|
|
625
|
+
|
|
626
|
+
```ruby
|
|
627
|
+
steps = PaperTrailDiff.activity_timeline(
|
|
628
|
+
article, from: :first, to: article, associations: [:comments], snapshots: true
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
step = steps.reject(&:empty?).last
|
|
632
|
+
step.from_snapshot.associations["comments"].records.first.attributes["author"]
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
The gem already builds these states to compute each diff and otherwise discards
|
|
636
|
+
them, so asking for them costs no extra queries. Reconstructing them yourself
|
|
637
|
+
from the version table means reimplementing boundary ordering, and getting it
|
|
638
|
+
wrong is quiet rather than loud.
|
|
639
|
+
|
|
640
|
+
They are off by default because each one holds the whole selected graph, and
|
|
641
|
+
retaining one per step keeps the entire timeline's graph alive. A step whose
|
|
642
|
+
`from_boundary` is a `create` version has a `nil` `from_snapshot`: the record
|
|
643
|
+
did not exist yet, which is the answer rather than a missing one.
|
|
644
|
+
|
|
645
|
+
`analyze(activity: true, snapshots: true)` populates the same fields on the
|
|
646
|
+
`activity_timeline` it returns.
|
|
647
|
+
|
|
552
648
|
### Closing a destroyed root
|
|
553
649
|
|
|
554
650
|
A `destroy` version is the one boundary whose following state needs no later
|
|
@@ -975,6 +1071,12 @@ report.errors.map(&:code)
|
|
|
975
1071
|
report.warnings.map(&:code)
|
|
976
1072
|
```
|
|
977
1073
|
|
|
1074
|
+
`ok?` means no *errors* were found among the checks that ran, not that every
|
|
1075
|
+
possible hazard was ruled out. Association checks need `associations:` to have
|
|
1076
|
+
something to inspect; without it only the version-order check runs, which is
|
|
1077
|
+
why that one runs whether or not associations are selected — an unorderable
|
|
1078
|
+
history corrupts a scalar timeline just as thoroughly.
|
|
1079
|
+
|
|
978
1080
|
Diagnostics are read-only guidance, not proof that arbitrary old data is
|
|
979
1081
|
complete. HABTM endpoints without transaction-backed association snapshots fail
|
|
980
1082
|
loudly with `PaperTrailDiff::IncompleteAssociationHistoryError` during normal
|
|
@@ -108,12 +108,35 @@ module PaperTrailDiff
|
|
|
108
108
|
attr_reader :from_boundary #: ActivityBoundary
|
|
109
109
|
attr_reader :to_boundary #: ActivityBoundary
|
|
110
110
|
attr_reader :diff #: Diff
|
|
111
|
+
# The reconstructed states this step was compared between, present only when
|
|
112
|
+
# a caller asked for them. A diff carries what changed; a renderer that has
|
|
113
|
+
# to name an unchanged field of a changed record needs the whole state, and
|
|
114
|
+
# rebuilding it from the version table by hand is both slower and easy to
|
|
115
|
+
# get wrong.
|
|
116
|
+
attr_reader :from_snapshot #: RecordSnapshot?
|
|
117
|
+
attr_reader :to_snapshot #: RecordSnapshot?
|
|
111
118
|
|
|
112
|
-
|
|
113
|
-
|
|
119
|
+
# Compares two reconstructed states and keeps them only when asked, which is
|
|
120
|
+
# every caller's shape: the diff always comes from the pair, the pair itself
|
|
121
|
+
# is retained on request.
|
|
122
|
+
#: (from_boundary: ActivityBoundary, to_boundary: ActivityBoundary, from_snapshot: RecordSnapshot?, to_snapshot: RecordSnapshot?, retain: bool) -> ActivityStep
|
|
123
|
+
def self.between(from_boundary:, to_boundary:, from_snapshot:, to_snapshot:, retain:)
|
|
124
|
+
new(
|
|
125
|
+
from_boundary: from_boundary,
|
|
126
|
+
to_boundary: to_boundary,
|
|
127
|
+
diff: Engine.compare(from_snapshot, to_snapshot),
|
|
128
|
+
from_snapshot: (from_snapshot if retain),
|
|
129
|
+
to_snapshot: (to_snapshot if retain)
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
#: (from_boundary: ActivityBoundary, to_boundary: ActivityBoundary, diff: Diff, ?from_snapshot: RecordSnapshot?, ?to_snapshot: RecordSnapshot?) -> void
|
|
134
|
+
def initialize(from_boundary:, to_boundary:, diff:, from_snapshot: nil, to_snapshot: nil)
|
|
114
135
|
@from_boundary = from_boundary
|
|
115
136
|
@to_boundary = to_boundary
|
|
116
137
|
@diff = diff
|
|
138
|
+
@from_snapshot = from_snapshot
|
|
139
|
+
@to_snapshot = to_snapshot
|
|
117
140
|
freeze
|
|
118
141
|
end
|
|
119
142
|
|
|
@@ -26,13 +26,16 @@ module PaperTrailDiff
|
|
|
26
26
|
|
|
27
27
|
# Builds activity steps while retaining root snapshots for combined analysis.
|
|
28
28
|
class ActivityHistoryBuilder
|
|
29
|
-
#: (Array[untyped], Array[ActivityEvent], untyped, ?current: untyped, ?include_step: untyped) -> void
|
|
30
|
-
def initialize(
|
|
29
|
+
#: (Array[untyped], Array[ActivityEvent], untyped, ?current: untyped, ?include_step: untyped, ?snapshots: bool) -> void
|
|
30
|
+
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
31
|
+
root_versions, events, snapshotter, current: nil, include_step: nil, snapshots: false
|
|
32
|
+
)
|
|
31
33
|
@root_versions = root_versions
|
|
32
34
|
@events = events
|
|
33
35
|
@snapshotter = snapshotter
|
|
34
36
|
@current = current
|
|
35
37
|
@include_step = include_step
|
|
38
|
+
@snapshots = snapshots
|
|
36
39
|
end
|
|
37
40
|
|
|
38
41
|
#: () -> ActivityHistory
|
|
@@ -54,6 +57,7 @@ module PaperTrailDiff
|
|
|
54
57
|
# @rbs @snapshotter: untyped
|
|
55
58
|
# @rbs @current: untyped
|
|
56
59
|
# @rbs @include_step: untyped
|
|
60
|
+
# @rbs @snapshots: bool
|
|
57
61
|
# @rbs @steps: Array[ActivityStep]
|
|
58
62
|
# @rbs @root_snapshots: Hash[Array[untyped], RecordSnapshot?]
|
|
59
63
|
# @rbs @first_snapshot: RecordSnapshot?
|
|
@@ -110,10 +114,9 @@ module PaperTrailDiff
|
|
|
110
114
|
previous_boundary = @previous_boundary
|
|
111
115
|
return unless previous_boundary
|
|
112
116
|
|
|
113
|
-
@steps << ActivityStep.
|
|
114
|
-
from_boundary: previous_boundary,
|
|
115
|
-
|
|
116
|
-
diff: Engine.compare(@previous_snapshot, snapshot)
|
|
117
|
+
@steps << ActivityStep.between(
|
|
118
|
+
from_boundary: previous_boundary, to_boundary: boundary,
|
|
119
|
+
from_snapshot: @previous_snapshot, to_snapshot: snapshot, retain: @snapshots
|
|
117
120
|
)
|
|
118
121
|
@selected_last_snapshot = snapshot
|
|
119
122
|
end
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
5
|
# Compares adjacent root and selected-descendant activity boundaries.
|
|
6
6
|
class ActivityTimelineBuilder
|
|
7
|
-
#: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped) -> void
|
|
8
|
-
def initialize(record, range:, tree:, snapshotter:)
|
|
7
|
+
#: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool) -> void
|
|
8
|
+
def initialize(record, range:, tree:, snapshotter:, snapshots: false)
|
|
9
|
+
@snapshots = snapshots
|
|
9
10
|
@record = record
|
|
10
11
|
@from = range.from
|
|
11
12
|
@to = range.to
|
|
@@ -49,6 +50,7 @@ module PaperTrailDiff
|
|
|
49
50
|
# @rbs @range: TimelineRange
|
|
50
51
|
# @rbs @tree: AssociationTree
|
|
51
52
|
# @rbs @snapshotter: untyped
|
|
53
|
+
# @rbs @snapshots: bool
|
|
52
54
|
|
|
53
55
|
#: () -> Array[ActivityStep]
|
|
54
56
|
def no_steps
|
|
@@ -150,10 +152,10 @@ module PaperTrailDiff
|
|
|
150
152
|
previous_event = events.last
|
|
151
153
|
previous_boundary = ActivityBoundary.from_version(previous_event.version) if previous_event
|
|
152
154
|
if final_boundary && previous_boundary
|
|
153
|
-
steps << ActivityStep.
|
|
154
|
-
from_boundary: previous_boundary,
|
|
155
|
-
|
|
156
|
-
|
|
155
|
+
steps << ActivityStep.between(
|
|
156
|
+
from_boundary: previous_boundary, to_boundary: final_boundary,
|
|
157
|
+
from_snapshot: history.last_snapshot, to_snapshot: final_snapshot,
|
|
158
|
+
retain: @snapshots
|
|
157
159
|
)
|
|
158
160
|
end
|
|
159
161
|
steps.freeze
|
|
@@ -175,7 +177,8 @@ module PaperTrailDiff
|
|
|
175
177
|
root_versions,
|
|
176
178
|
events,
|
|
177
179
|
@snapshotter,
|
|
178
|
-
current: current
|
|
180
|
+
current: current,
|
|
181
|
+
snapshots: @snapshots
|
|
179
182
|
).call
|
|
180
183
|
end
|
|
181
184
|
|
|
@@ -201,7 +204,8 @@ module PaperTrailDiff
|
|
|
201
204
|
@record,
|
|
202
205
|
range: @range,
|
|
203
206
|
tree: @tree,
|
|
204
|
-
snapshotter: @snapshotter
|
|
207
|
+
snapshotter: @snapshotter,
|
|
208
|
+
snapshots: @snapshots
|
|
205
209
|
)
|
|
206
210
|
end
|
|
207
211
|
end
|
|
@@ -76,7 +76,11 @@ module PaperTrailDiff
|
|
|
76
76
|
#: () -> DiagnosticReport
|
|
77
77
|
def call
|
|
78
78
|
model_class = validated_model_class
|
|
79
|
-
|
|
79
|
+
# Runs whether or not associations are selected: unorderable versions
|
|
80
|
+
# corrupt a scalar timeline just as surely, and a report that inspected
|
|
81
|
+
# nothing has no business answering `ok?`.
|
|
82
|
+
inspect_version_sequence
|
|
83
|
+
return DiagnosticReport.new(issues: @issues) if @tree.empty?
|
|
80
84
|
|
|
81
85
|
unless association_tracking_available?
|
|
82
86
|
add_error(:association_tracking_unavailable, tracking_unavailable_message)
|
|
@@ -132,6 +136,15 @@ module PaperTrailDiff
|
|
|
132
136
|
inspect_transaction_metadata(paths)
|
|
133
137
|
end
|
|
134
138
|
|
|
139
|
+
#: () -> void
|
|
140
|
+
def inspect_version_sequence
|
|
141
|
+
@issues.concat(
|
|
142
|
+
VersionSequenceDiagnostics.new(
|
|
143
|
+
@from_version, @to_version, associations_selected: !@tree.empty?
|
|
144
|
+
).call
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
|
|
135
148
|
#: (untyped) -> void
|
|
136
149
|
def inspect_checkpoint_timestamp(model_class)
|
|
137
150
|
return if synchronized_timestamp_disabled?(model_class)
|
|
@@ -29,6 +29,9 @@ module PaperTrailDiff
|
|
|
29
29
|
# Raised when an in-range mutation has no later root boundary for reconstruction.
|
|
30
30
|
class IncompleteTimeRangeError < InvalidTimelineRangeError; end
|
|
31
31
|
|
|
32
|
+
# Raised when versions sharing a timestamp cannot be ordered by their ids.
|
|
33
|
+
class AmbiguousVersionOrderError < Error; end
|
|
34
|
+
|
|
32
35
|
# Raised when a requested ActiveRecord association does not exist.
|
|
33
36
|
class UnknownAssociationError < Error; end
|
|
34
37
|
|
|
@@ -19,6 +19,14 @@ module PaperTrailDiff
|
|
|
19
19
|
)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
# Reports a condition rather than timing work: nothing failed, but the
|
|
23
|
+
# result may be incomplete and only the application can judge that.
|
|
24
|
+
#: (String | Symbol, Hash[Symbol, untyped]) -> void
|
|
25
|
+
def notify(event, payload)
|
|
26
|
+
instrument(event, payload) { nil }
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
|
|
22
30
|
#: (association_paths: Array[String], reload_live_endpoints: bool) -> Hash[Symbol, untyped]
|
|
23
31
|
def comparison_payload(association_paths:, reload_live_endpoints:)
|
|
24
32
|
{
|
|
@@ -33,6 +33,7 @@ module PaperTrailDiff
|
|
|
33
33
|
payload = @instrumentation_payload.merge(comparison_count: 1)
|
|
34
34
|
Instrumentation.instrument('compare', payload) do
|
|
35
35
|
Endpoint.validate_pair!(from_endpoint, to_endpoint)
|
|
36
|
+
notify_ambiguous_association_boundary(from_endpoint, to_endpoint)
|
|
36
37
|
Engine.compare(snapshot_for_endpoint(from_endpoint), snapshot_for_endpoint(to_endpoint))
|
|
37
38
|
end
|
|
38
39
|
end
|
|
@@ -67,8 +68,8 @@ module PaperTrailDiff
|
|
|
67
68
|
).build
|
|
68
69
|
end
|
|
69
70
|
|
|
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
|
|
71
|
+
#: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Array[ActivityStep]
|
|
72
|
+
def activity_timeline(record, from:, to:, within:, version_scope: nil, close_on: nil, snapshots: false) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
|
|
72
73
|
payload = @instrumentation_payload.merge(model_type: record.class.base_class.name.to_s)
|
|
73
74
|
Instrumentation.instrument('activity_timeline', payload) do
|
|
74
75
|
@traversal_preparer.call(record.class, historical: true)
|
|
@@ -76,21 +77,21 @@ module PaperTrailDiff
|
|
|
76
77
|
reject_live_habtm_activity!(record.class) if Endpoint.record?(to) || live
|
|
77
78
|
steps = activity_builder(
|
|
78
79
|
record, from: from, to: to, within: within, version_scope: version_scope,
|
|
79
|
-
live_endpoint: live
|
|
80
|
+
live_endpoint: live, snapshots: snapshots
|
|
80
81
|
).build
|
|
81
82
|
payload[:step_count] = steps.length
|
|
82
83
|
steps
|
|
83
84
|
end
|
|
84
85
|
end
|
|
85
86
|
|
|
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
|
|
87
|
+
#: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Analysis
|
|
88
|
+
def analyze(record, from:, to:, within:, activity: false, version_scope: nil, close_on: nil, snapshots: false) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
|
|
88
89
|
@traversal_preparer.call(record.class, historical: true)
|
|
89
90
|
live = live_endpoint_for(record, close_on, within)
|
|
90
91
|
if activity
|
|
91
92
|
return analyze_activity(
|
|
92
|
-
record, from: from, to: to, within: within,
|
|
93
|
-
|
|
93
|
+
record, from: from, to: to, within: within, version_scope: version_scope,
|
|
94
|
+
live_endpoint: live, snapshots: snapshots
|
|
94
95
|
)
|
|
95
96
|
end
|
|
96
97
|
|
|
@@ -132,15 +133,36 @@ module PaperTrailDiff
|
|
|
132
133
|
# @rbs @timeline_snapshotter: TimelineSnapshotProvider
|
|
133
134
|
# @rbs @activity_snapshotter: ActivitySnapshotProvider
|
|
134
135
|
|
|
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
|
|
136
|
+
#: (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped, ?snapshots: bool) -> Analysis
|
|
137
|
+
def analyze_activity(record, from:, to:, within:, version_scope:, live_endpoint:, snapshots: false) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
|
|
137
138
|
reject_live_habtm_activity!(record.class) if live_endpoint
|
|
138
139
|
activity_builder(
|
|
139
|
-
record, from: from, to: to, within: within,
|
|
140
|
-
|
|
140
|
+
record, from: from, to: to, within: within, version_scope: version_scope,
|
|
141
|
+
live_endpoint: live_endpoint, snapshots: snapshots
|
|
141
142
|
).analyze
|
|
142
143
|
end
|
|
143
144
|
|
|
145
|
+
# Association membership is resolved by timestamp, so endpoints sharing one
|
|
146
|
+
# cannot be told apart and any association change between them is invisible.
|
|
147
|
+
# The result may still be correct -- nothing associated may have changed --
|
|
148
|
+
# and the gem cannot tell which, since not seeing the change is the symptom.
|
|
149
|
+
# So it reports the condition and leaves the judgement to the application.
|
|
150
|
+
#: (untyped, untyped) -> void
|
|
151
|
+
def notify_ambiguous_association_boundary(from_endpoint, to_endpoint)
|
|
152
|
+
return if @association_tree.empty?
|
|
153
|
+
return unless Endpoint.version?(from_endpoint) && Endpoint.version?(to_endpoint)
|
|
154
|
+
return unless from_endpoint.created_at == to_endpoint.created_at
|
|
155
|
+
|
|
156
|
+
Instrumentation.notify(
|
|
157
|
+
'ambiguous_association_boundary',
|
|
158
|
+
@instrumentation_payload.merge(
|
|
159
|
+
item_type: from_endpoint.item_type.to_s,
|
|
160
|
+
version_ids: [from_endpoint.id, to_endpoint.id].freeze,
|
|
161
|
+
recorded_at: from_endpoint.created_at
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
end
|
|
165
|
+
|
|
144
166
|
# `close_on:` names what ends a wall-clock window, so it is meaningless for a
|
|
145
167
|
# range whose endpoints the caller already gave explicitly.
|
|
146
168
|
#: (Symbol?, untyped) -> bool
|
|
@@ -212,8 +234,8 @@ module PaperTrailDiff
|
|
|
212
234
|
)
|
|
213
235
|
end
|
|
214
236
|
|
|
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
|
|
237
|
+
#: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped, ?snapshots: bool) -> ActivityTimelineBuilder
|
|
238
|
+
def activity_builder(record, from:, to:, within:, version_scope: nil, live_endpoint: nil, snapshots: false) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
|
|
217
239
|
ActivityTimelineBuilder.new(
|
|
218
240
|
record,
|
|
219
241
|
range: TimelineRange.new(
|
|
@@ -221,7 +243,8 @@ module PaperTrailDiff
|
|
|
221
243
|
live_endpoint: live_endpoint
|
|
222
244
|
),
|
|
223
245
|
tree: @association_tree,
|
|
224
|
-
snapshotter: @activity_snapshotter
|
|
246
|
+
snapshotter: @activity_snapshotter,
|
|
247
|
+
snapshots: snapshots
|
|
225
248
|
)
|
|
226
249
|
end
|
|
227
250
|
|
|
@@ -109,7 +109,7 @@ module PaperTrailDiff
|
|
|
109
109
|
|
|
110
110
|
#: (versions: Array[untyped], live: PreparedRecordState?, ?state_loader: PreparedVersionStateLoader) -> void
|
|
111
111
|
def initialize(versions:, live:, state_loader: PreparedVersionStateLoader.new)
|
|
112
|
-
@versions =
|
|
112
|
+
@versions = Support.chronological_sort(versions).freeze
|
|
113
113
|
@version_positions = @versions.each_with_index.to_h do |version, index|
|
|
114
114
|
[version.id.to_s, index]
|
|
115
115
|
end.freeze
|
|
@@ -134,8 +134,7 @@ module PaperTrailDiff
|
|
|
134
134
|
|
|
135
135
|
#: (Array[untyped]) -> Array[untyped]
|
|
136
136
|
def chronological(versions)
|
|
137
|
-
versions.uniq { |version| [version.class.name, version.id] }
|
|
138
|
-
.sort_by { |version| Support.chronological_version_key(version) }
|
|
137
|
+
Support.chronological_sort(versions.uniq { |version| [version.class.name, version.id] })
|
|
139
138
|
end
|
|
140
139
|
|
|
141
140
|
#: (untyped) -> untyped
|
|
@@ -51,6 +51,50 @@ module PaperTrailDiff
|
|
|
51
51
|
[version.created_at, version.id.to_s.rjust(32, '0')]
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
# Ordering falls back to the id whenever timestamps tie, which recovers the
|
|
55
|
+
# real order only while ids increase with insertion. An autoincrement id
|
|
56
|
+
# does; a UUID does not, so a tie between UUID-keyed versions is genuinely
|
|
57
|
+
# unorderable and any timeline built from it would be fiction.
|
|
58
|
+
#: (Array[untyped]) -> Array[untyped]
|
|
59
|
+
def chronological_sort(versions)
|
|
60
|
+
sorted = versions.sort_by { |version| chronological_version_key(version) }
|
|
61
|
+
ambiguous = ambiguous_pair(sorted)
|
|
62
|
+
return sorted unless ambiguous
|
|
63
|
+
|
|
64
|
+
raise AmbiguousVersionOrderError, ambiguous_message(ambiguous)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
#: (Array[untyped]) -> Array[untyped]?
|
|
68
|
+
def ambiguous_pair(sorted)
|
|
69
|
+
sorted.each_cons(2).find do |left, right|
|
|
70
|
+
left.created_at == right.created_at &&
|
|
71
|
+
!(sequential_id?(left.id) && sequential_id?(right.id))
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Versions sharing a timestamp, whether or not their ids order them. PT-AT
|
|
76
|
+
# indexes association membership per version but resolves it by timestamp,
|
|
77
|
+
# so association state cannot be told apart across such a pair even when the
|
|
78
|
+
# scalar sequence is perfectly recoverable.
|
|
79
|
+
#: (Array[untyped]) -> Array[untyped]?
|
|
80
|
+
def tied_timestamp_pair(versions)
|
|
81
|
+
versions.each_cons(2).find { |left, right| left.created_at == right.created_at }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
#: (untyped) -> bool
|
|
85
|
+
def sequential_id?(id)
|
|
86
|
+
id.is_a?(Integer) || id.to_s.match?(/\A\d+\z/)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
#: (Array[untyped]) -> String
|
|
90
|
+
def ambiguous_message(pair)
|
|
91
|
+
left, right = pair
|
|
92
|
+
"versions #{left.id.inspect} and #{right.id.inspect} share the timestamp " \
|
|
93
|
+
"#{left.created_at.inspect} and have ids that do not order them, so their " \
|
|
94
|
+
'sequence cannot be recovered; record versions at sub-second precision or ' \
|
|
95
|
+
'with sequential ids'
|
|
96
|
+
end
|
|
97
|
+
|
|
54
98
|
#: (untyped, untyped) -> Integer
|
|
55
99
|
def compare_versions(left, right)
|
|
56
100
|
chronological_version_key(left) <=> chronological_version_key(right) ||
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
5
|
# Builds activity views for mutations selected by a wall-clock range.
|
|
6
6
|
class TimeActivityTimelineBuilder
|
|
7
|
-
#: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped) -> void
|
|
8
|
-
def initialize(record, range:, tree:, snapshotter:)
|
|
7
|
+
#: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool) -> void
|
|
8
|
+
def initialize(record, range:, tree:, snapshotter:, snapshots: false)
|
|
9
|
+
@snapshots = snapshots
|
|
9
10
|
@record = record
|
|
10
11
|
@range = range
|
|
11
12
|
@tree = tree
|
|
@@ -40,6 +41,7 @@ module PaperTrailDiff
|
|
|
40
41
|
# @rbs @range: TimelineRange
|
|
41
42
|
# @rbs @tree: AssociationTree
|
|
42
43
|
# @rbs @snapshotter: untyped
|
|
44
|
+
# @rbs @snapshots: bool
|
|
43
45
|
|
|
44
46
|
#: () -> [ActivityHistory, RootVersionPlan, ActivityStep?, RecordSnapshot?, untyped]
|
|
45
47
|
def history_and_versions
|
|
@@ -89,7 +91,8 @@ module PaperTrailDiff
|
|
|
89
91
|
root_versions,
|
|
90
92
|
events,
|
|
91
93
|
@snapshotter,
|
|
92
|
-
include_step: ->(event) { @range.include?(event.version) }
|
|
94
|
+
include_step: ->(event) { @range.include?(event.version) },
|
|
95
|
+
snapshots: @snapshots
|
|
93
96
|
).call
|
|
94
97
|
end
|
|
95
98
|
|
|
@@ -129,10 +132,11 @@ module PaperTrailDiff
|
|
|
129
132
|
#: (ActivityHistory, ActivityEvent) -> ActivityStep
|
|
130
133
|
def destroyed_step(history, event)
|
|
131
134
|
version = event.version
|
|
132
|
-
ActivityStep.
|
|
135
|
+
ActivityStep.between(
|
|
133
136
|
from_boundary: ActivityBoundary.from_version(version),
|
|
134
137
|
to_boundary: ActivityBoundary.destroyed(version),
|
|
135
|
-
|
|
138
|
+
from_snapshot: history.root_snapshots[ActivityRootSteps.version_key(version)],
|
|
139
|
+
to_snapshot: nil, retain: @snapshots
|
|
136
140
|
)
|
|
137
141
|
end
|
|
138
142
|
|
|
@@ -144,10 +148,10 @@ module PaperTrailDiff
|
|
|
144
148
|
previous = history.steps.last&.to_boundary
|
|
145
149
|
return unless previous
|
|
146
150
|
|
|
147
|
-
ActivityStep.
|
|
151
|
+
ActivityStep.between(
|
|
148
152
|
from_boundary: previous,
|
|
149
153
|
to_boundary: ActivityBoundary.current(record, captured_at: captured_at),
|
|
150
|
-
|
|
154
|
+
from_snapshot: history.last_snapshot, to_snapshot: snapshot, retain: @snapshots
|
|
151
155
|
)
|
|
152
156
|
end
|
|
153
157
|
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
5
|
# Gives TimelineBuilder one-argument access to a range-prepared snapshot store.
|
|
6
6
|
class TimelineSnapshotProvider
|
|
7
|
-
#: (HistoricalSnapshotStore,
|
|
8
|
-
def initialize(store, live_snapshotter:
|
|
7
|
+
#: (HistoricalSnapshotStore, live_snapshotter: untyped) -> void
|
|
8
|
+
def initialize(store, live_snapshotter:)
|
|
9
9
|
@store = store
|
|
10
10
|
@live_snapshotter = live_snapshotter
|
|
11
11
|
end
|
|
@@ -28,10 +28,7 @@ module PaperTrailDiff
|
|
|
28
28
|
|
|
29
29
|
#: (untyped) -> RecordSnapshot?
|
|
30
30
|
def live_snapshot(record)
|
|
31
|
-
|
|
32
|
-
raise InvalidTimelineRangeError, 'live endpoints are unavailable here' unless snapshotter
|
|
33
|
-
|
|
34
|
-
snapshotter.call(record)
|
|
31
|
+
@live_snapshotter.call(record)
|
|
35
32
|
end
|
|
36
33
|
|
|
37
34
|
# @rbs @store: HistoricalSnapshotStore
|
|
@@ -96,7 +96,7 @@ module PaperTrailDiff
|
|
|
96
96
|
|
|
97
97
|
#: (Array[untyped]) -> Array[untyped]
|
|
98
98
|
def ordered(versions)
|
|
99
|
-
|
|
99
|
+
Support.chronological_sort(versions)
|
|
100
100
|
end
|
|
101
101
|
|
|
102
102
|
#: (untyped, untyped, boundary: Symbol) -> void
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Checks the recorded sequence itself, before any question of association
|
|
6
|
+
# setup: whether the versions can be ordered at all, and whether timestamps
|
|
7
|
+
# they share hide changes that ordering alone cannot recover.
|
|
8
|
+
class VersionSequenceDiagnostics
|
|
9
|
+
#: (untyped, untyped, ?associations_selected: bool) -> void
|
|
10
|
+
def initialize(from_version, to_version, associations_selected: false)
|
|
11
|
+
@from_version = from_version
|
|
12
|
+
@to_version = to_version
|
|
13
|
+
@associations_selected = associations_selected
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
#: () -> Array[DiagnosticIssue]
|
|
17
|
+
def call
|
|
18
|
+
versions = ordered_range_versions
|
|
19
|
+
issues = [] #: Array[DiagnosticIssue?]
|
|
20
|
+
issues << unorderable(versions)
|
|
21
|
+
issues << tied_timestamps(versions) if @associations_selected
|
|
22
|
+
issues.compact
|
|
23
|
+
rescue StandardError
|
|
24
|
+
[]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# @rbs @from_version: untyped
|
|
30
|
+
# @rbs @to_version: untyped
|
|
31
|
+
# @rbs @associations_selected: bool
|
|
32
|
+
|
|
33
|
+
# Ordering falls back to the id when timestamps tie, which only recovers the
|
|
34
|
+
# real sequence for ids that increase with insertion. Reported before a run
|
|
35
|
+
# rather than after a wrong answer.
|
|
36
|
+
#: (Array[untyped]) -> DiagnosticIssue?
|
|
37
|
+
def unorderable(versions)
|
|
38
|
+
pair = Support.ambiguous_pair(versions)
|
|
39
|
+
return unless pair
|
|
40
|
+
|
|
41
|
+
DiagnosticIssue.new(
|
|
42
|
+
severity: :error,
|
|
43
|
+
code: :ambiguous_version_order,
|
|
44
|
+
message: Support.ambiguous_message(pair),
|
|
45
|
+
version_id: pair.first.id
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Association membership is recorded per version but resolved by timestamp,
|
|
50
|
+
# so a tie hides any association change across that pair. Unlike an
|
|
51
|
+
# unorderable sequence this is not always wrong: if nothing associated
|
|
52
|
+
# changed between them the result is correct, and the gem cannot tell which
|
|
53
|
+
# it is, because not seeing the change is the symptom. So it warns.
|
|
54
|
+
#: (Array[untyped]) -> DiagnosticIssue?
|
|
55
|
+
def tied_timestamps(versions)
|
|
56
|
+
pair = Support.tied_timestamp_pair(versions)
|
|
57
|
+
return unless pair
|
|
58
|
+
|
|
59
|
+
left, right = pair
|
|
60
|
+
DiagnosticIssue.new(
|
|
61
|
+
severity: :warning,
|
|
62
|
+
code: :tied_version_timestamps,
|
|
63
|
+
message: "versions #{left.id.inspect} and #{right.id.inspect} share the timestamp " \
|
|
64
|
+
"#{left.created_at.inspect}, so association changes between them cannot " \
|
|
65
|
+
'be detected; record versions at sub-second precision to separate them',
|
|
66
|
+
version_id: left.id
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
#: () -> Array[untyped]
|
|
71
|
+
def ordered_range_versions
|
|
72
|
+
bounds = [@from_version.created_at, @to_version.created_at].compact.sort
|
|
73
|
+
return [] unless bounds.length == 2
|
|
74
|
+
|
|
75
|
+
@from_version.class
|
|
76
|
+
.where(item_type: @from_version.item_type, item_id: @from_version.item_id)
|
|
77
|
+
.where(created_at: bounds.first..bounds.last)
|
|
78
|
+
.to_a
|
|
79
|
+
.sort_by { |version| Support.chronological_version_key(version) }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
data/lib/paper_trail_diff.rb
CHANGED
|
@@ -65,6 +65,7 @@ require_relative 'paper_trail_diff/activity_root_steps'
|
|
|
65
65
|
require_relative 'paper_trail_diff/analysis_batch'
|
|
66
66
|
require_relative 'paper_trail_diff/batched_root_analyzer'
|
|
67
67
|
require_relative 'paper_trail_diff/version_range'
|
|
68
|
+
require_relative 'paper_trail_diff/version_sequence_diagnostics'
|
|
68
69
|
require_relative 'paper_trail_diff/time_range'
|
|
69
70
|
require_relative 'paper_trail_diff/time_version_range'
|
|
70
71
|
require_relative 'paper_trail_diff/timeline_range'
|
|
@@ -169,7 +170,7 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
|
169
170
|
# Compares adjacent root and selected-descendant activity boundaries.
|
|
170
171
|
# `reload_live_endpoints:` applies only when `to:` is a current record; the
|
|
171
172
|
# other range forms never read live state.
|
|
172
|
-
#: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Array[ActivityStep]
|
|
173
|
+
#: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Array[ActivityStep]
|
|
173
174
|
def activity_timeline( # rubocop:disable Metrics/ParameterLists
|
|
174
175
|
record,
|
|
175
176
|
from: nil,
|
|
@@ -179,7 +180,8 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
|
179
180
|
ignore: DEFAULT_IGNORED_ATTRIBUTES,
|
|
180
181
|
reload_live_endpoints: true,
|
|
181
182
|
version_scope: nil,
|
|
182
|
-
close_on: nil
|
|
183
|
+
close_on: nil,
|
|
184
|
+
snapshots: false
|
|
183
185
|
)
|
|
184
186
|
PaperTrailAdapter.new(
|
|
185
187
|
associations: associations,
|
|
@@ -191,12 +193,13 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
|
191
193
|
to: to,
|
|
192
194
|
within: within,
|
|
193
195
|
version_scope: version_scope,
|
|
194
|
-
close_on: close_on
|
|
196
|
+
close_on: close_on,
|
|
197
|
+
snapshots: snapshots
|
|
195
198
|
)
|
|
196
199
|
end
|
|
197
200
|
|
|
198
201
|
# Builds an endpoint diff and root-checkpoint timeline while normalizing each version once.
|
|
199
|
-
#: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Analysis
|
|
202
|
+
#: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Analysis
|
|
200
203
|
def analyze( # rubocop:disable Metrics/ParameterLists
|
|
201
204
|
record,
|
|
202
205
|
from: nil,
|
|
@@ -206,7 +209,8 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
|
206
209
|
ignore: DEFAULT_IGNORED_ATTRIBUTES,
|
|
207
210
|
activity: false,
|
|
208
211
|
version_scope: nil,
|
|
209
|
-
close_on: nil
|
|
212
|
+
close_on: nil,
|
|
213
|
+
snapshots: false
|
|
210
214
|
)
|
|
211
215
|
PaperTrailAdapter.new(associations: associations, ignore: ignore).analyze(
|
|
212
216
|
record,
|
|
@@ -215,7 +219,8 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
|
215
219
|
within: within,
|
|
216
220
|
activity: activity,
|
|
217
221
|
version_scope: version_scope,
|
|
218
|
-
close_on: close_on
|
|
222
|
+
close_on: close_on,
|
|
223
|
+
snapshots: snapshots
|
|
219
224
|
)
|
|
220
225
|
end
|
|
221
226
|
|
|
@@ -55,8 +55,23 @@ module PaperTrailDiff
|
|
|
55
55
|
|
|
56
56
|
attr_reader diff: Diff
|
|
57
57
|
|
|
58
|
-
#
|
|
59
|
-
|
|
58
|
+
# The reconstructed states this step was compared between, present only when
|
|
59
|
+
# a caller asked for them. A diff carries what changed; a renderer that has
|
|
60
|
+
# to name an unchanged field of a changed record needs the whole state, and
|
|
61
|
+
# rebuilding it from the version table by hand is both slower and easy to
|
|
62
|
+
# get wrong.
|
|
63
|
+
attr_reader from_snapshot: RecordSnapshot?
|
|
64
|
+
|
|
65
|
+
attr_reader to_snapshot: RecordSnapshot?
|
|
66
|
+
|
|
67
|
+
# Compares two reconstructed states and keeps them only when asked, which is
|
|
68
|
+
# every caller's shape: the diff always comes from the pair, the pair itself
|
|
69
|
+
# is retained on request.
|
|
70
|
+
# : (from_boundary: ActivityBoundary, to_boundary: ActivityBoundary, from_snapshot: RecordSnapshot?, to_snapshot: RecordSnapshot?, retain: bool) -> ActivityStep
|
|
71
|
+
def self.between: (from_boundary: ActivityBoundary, to_boundary: ActivityBoundary, from_snapshot: RecordSnapshot?, to_snapshot: RecordSnapshot?, retain: bool) -> ActivityStep
|
|
72
|
+
|
|
73
|
+
# : (from_boundary: ActivityBoundary, to_boundary: ActivityBoundary, diff: Diff, ?from_snapshot: RecordSnapshot?, ?to_snapshot: RecordSnapshot?) -> void
|
|
74
|
+
def initialize: (from_boundary: ActivityBoundary, to_boundary: ActivityBoundary, diff: Diff, ?from_snapshot: RecordSnapshot?, ?to_snapshot: RecordSnapshot?) -> void
|
|
60
75
|
|
|
61
76
|
# : () -> bool
|
|
62
77
|
def empty?: () -> bool
|
|
@@ -20,8 +20,8 @@ module PaperTrailDiff
|
|
|
20
20
|
|
|
21
21
|
# Builds activity steps while retaining root snapshots for combined analysis.
|
|
22
22
|
class ActivityHistoryBuilder
|
|
23
|
-
# : (Array[untyped], Array[ActivityEvent], untyped, ?current: untyped, ?include_step: untyped) -> void
|
|
24
|
-
def initialize: (Array[untyped], Array[ActivityEvent], untyped, ?current: untyped, ?include_step: untyped) -> void
|
|
23
|
+
# : (Array[untyped], Array[ActivityEvent], untyped, ?current: untyped, ?include_step: untyped, ?snapshots: bool) -> void
|
|
24
|
+
def initialize: (Array[untyped], Array[ActivityEvent], untyped, ?current: untyped, ?include_step: untyped, ?snapshots: bool) -> void
|
|
25
25
|
|
|
26
26
|
# : () -> ActivityHistory
|
|
27
27
|
def call: () -> ActivityHistory
|
|
@@ -48,6 +48,8 @@ module PaperTrailDiff
|
|
|
48
48
|
|
|
49
49
|
@selected_last_snapshot: RecordSnapshot?
|
|
50
50
|
|
|
51
|
+
@snapshots: bool
|
|
52
|
+
|
|
51
53
|
@snapshotter: untyped
|
|
52
54
|
|
|
53
55
|
@steps: Array[ActivityStep]
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
module PaperTrailDiff
|
|
4
4
|
# Compares adjacent root and selected-descendant activity boundaries.
|
|
5
5
|
class ActivityTimelineBuilder
|
|
6
|
-
# : (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped) -> void
|
|
7
|
-
def initialize: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped) -> void
|
|
6
|
+
# : (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool) -> void
|
|
7
|
+
def initialize: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool) -> void
|
|
8
8
|
|
|
9
9
|
# : () -> Array[ActivityStep]
|
|
10
10
|
def build: () -> Array[ActivityStep]
|
|
@@ -21,6 +21,8 @@ module PaperTrailDiff
|
|
|
21
21
|
|
|
22
22
|
@record: untyped
|
|
23
23
|
|
|
24
|
+
@snapshots: bool
|
|
25
|
+
|
|
24
26
|
@snapshotter: untyped
|
|
25
27
|
|
|
26
28
|
@to: untyped
|
|
@@ -67,6 +67,9 @@ module PaperTrailDiff
|
|
|
67
67
|
# : (Array[Array[untyped]]) -> void
|
|
68
68
|
def inspect_habtm: (Array[Array[untyped]]) -> void
|
|
69
69
|
|
|
70
|
+
# : () -> void
|
|
71
|
+
def inspect_version_sequence: () -> void
|
|
72
|
+
|
|
70
73
|
# : (untyped) -> void
|
|
71
74
|
def inspect_checkpoint_timestamp: (untyped) -> void
|
|
72
75
|
|
|
@@ -37,6 +37,10 @@ module PaperTrailDiff
|
|
|
37
37
|
class IncompleteTimeRangeError < InvalidTimelineRangeError
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
# Raised when versions sharing a timestamp cannot be ordered by their ids.
|
|
41
|
+
class AmbiguousVersionOrderError < Error
|
|
42
|
+
end
|
|
43
|
+
|
|
40
44
|
# Raised when a requested ActiveRecord association does not exist.
|
|
41
45
|
class UnknownAssociationError < Error
|
|
42
46
|
end
|
|
@@ -6,6 +6,11 @@ module PaperTrailDiff
|
|
|
6
6
|
# : (String | Symbol, Hash[Symbol, untyped]) { () -> untyped } -> untyped
|
|
7
7
|
def self?.instrument: (String | Symbol, Hash[Symbol, untyped]) { () -> untyped } -> untyped
|
|
8
8
|
|
|
9
|
+
# Reports a condition rather than timing work: nothing failed, but the
|
|
10
|
+
# result may be incomplete and only the application can judge that.
|
|
11
|
+
# : (String | Symbol, Hash[Symbol, untyped]) -> void
|
|
12
|
+
def self?.notify: (String | Symbol, Hash[Symbol, untyped]) -> void
|
|
13
|
+
|
|
9
14
|
# : (association_paths: Array[String], reload_live_endpoints: bool) -> Hash[Symbol, untyped]
|
|
10
15
|
def self?.comparison_payload: (association_paths: Array[String], reload_live_endpoints: bool) -> Hash[Symbol, untyped]
|
|
11
16
|
end
|
|
@@ -22,11 +22,11 @@ module PaperTrailDiff
|
|
|
22
22
|
# : (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?) -> Array[Step]
|
|
23
23
|
def timeline: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?) -> Array[Step]
|
|
24
24
|
|
|
25
|
-
# : (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?) -> Array[ActivityStep]
|
|
26
|
-
def activity_timeline: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?) -> Array[ActivityStep]
|
|
25
|
+
# : (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Array[ActivityStep]
|
|
26
|
+
def activity_timeline: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Array[ActivityStep]
|
|
27
27
|
|
|
28
|
-
# : (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Analysis
|
|
29
|
-
def analyze: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Analysis
|
|
28
|
+
# : (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Analysis
|
|
29
|
+
def analyze: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Analysis
|
|
30
30
|
|
|
31
31
|
# Analyzes many roots over one shared range, preparing their history once.
|
|
32
32
|
# : (Array[untyped], within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Hash[identity, Analysis]
|
|
@@ -56,8 +56,16 @@ module PaperTrailDiff
|
|
|
56
56
|
|
|
57
57
|
@traversal_preparer: TraversalPreparer
|
|
58
58
|
|
|
59
|
-
# : (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped) -> Analysis
|
|
60
|
-
def analyze_activity: (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped) -> Analysis
|
|
59
|
+
# : (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped, ?snapshots: bool) -> Analysis
|
|
60
|
+
def analyze_activity: (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped, ?snapshots: bool) -> Analysis
|
|
61
|
+
|
|
62
|
+
# Association membership is resolved by timestamp, so endpoints sharing one
|
|
63
|
+
# cannot be told apart and any association change between them is invisible.
|
|
64
|
+
# The result may still be correct -- nothing associated may have changed --
|
|
65
|
+
# and the gem cannot tell which, since not seeing the change is the symptom.
|
|
66
|
+
# So it reports the condition and leaves the judgement to the application.
|
|
67
|
+
# : (untyped, untyped) -> void
|
|
68
|
+
def notify_ambiguous_association_boundary: (untyped, untyped) -> void
|
|
61
69
|
|
|
62
70
|
# `close_on:` names what ends a wall-clock window, so it is meaningless for a
|
|
63
71
|
# range whose endpoints the caller already gave explicitly.
|
|
@@ -81,8 +89,8 @@ module PaperTrailDiff
|
|
|
81
89
|
# : () -> ActivitySnapshotProvider
|
|
82
90
|
def build_activity_snapshotter: () -> ActivitySnapshotProvider
|
|
83
91
|
|
|
84
|
-
# : (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped) -> ActivityTimelineBuilder
|
|
85
|
-
def activity_builder: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped) -> ActivityTimelineBuilder
|
|
92
|
+
# : (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped, ?snapshots: bool) -> ActivityTimelineBuilder
|
|
93
|
+
def activity_builder: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped, ?snapshots: bool) -> ActivityTimelineBuilder
|
|
86
94
|
|
|
87
95
|
# : (untyped) -> void
|
|
88
96
|
def reject_live_habtm_activity!: (untyped) -> void
|
|
@@ -15,6 +15,29 @@ module PaperTrailDiff
|
|
|
15
15
|
# : (untyped) -> Array[untyped]
|
|
16
16
|
def self?.chronological_version_key: (untyped) -> Array[untyped]
|
|
17
17
|
|
|
18
|
+
# Ordering falls back to the id whenever timestamps tie, which recovers the
|
|
19
|
+
# real order only while ids increase with insertion. An autoincrement id
|
|
20
|
+
# does; a UUID does not, so a tie between UUID-keyed versions is genuinely
|
|
21
|
+
# unorderable and any timeline built from it would be fiction.
|
|
22
|
+
# : (Array[untyped]) -> Array[untyped]
|
|
23
|
+
def self?.chronological_sort: (Array[untyped]) -> Array[untyped]
|
|
24
|
+
|
|
25
|
+
# : (Array[untyped]) -> Array[untyped]?
|
|
26
|
+
def self?.ambiguous_pair: (Array[untyped]) -> Array[untyped]?
|
|
27
|
+
|
|
28
|
+
# Versions sharing a timestamp, whether or not their ids order them. PT-AT
|
|
29
|
+
# indexes association membership per version but resolves it by timestamp,
|
|
30
|
+
# so association state cannot be told apart across such a pair even when the
|
|
31
|
+
# scalar sequence is perfectly recoverable.
|
|
32
|
+
# : (Array[untyped]) -> Array[untyped]?
|
|
33
|
+
def self?.tied_timestamp_pair: (Array[untyped]) -> Array[untyped]?
|
|
34
|
+
|
|
35
|
+
# : (untyped) -> bool
|
|
36
|
+
def self?.sequential_id?: (untyped) -> bool
|
|
37
|
+
|
|
38
|
+
# : (Array[untyped]) -> String
|
|
39
|
+
def self?.ambiguous_message: (Array[untyped]) -> String
|
|
40
|
+
|
|
18
41
|
# : (untyped, untyped) -> Integer
|
|
19
42
|
def self?.compare_versions: (untyped, untyped) -> Integer
|
|
20
43
|
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
module PaperTrailDiff
|
|
4
4
|
# Builds activity views for mutations selected by a wall-clock range.
|
|
5
5
|
class TimeActivityTimelineBuilder
|
|
6
|
-
# : (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped) -> void
|
|
7
|
-
def initialize: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped) -> void
|
|
6
|
+
# : (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool) -> void
|
|
7
|
+
def initialize: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool) -> void
|
|
8
8
|
|
|
9
9
|
# : () -> Array[ActivityStep]
|
|
10
10
|
def build: () -> Array[ActivityStep]
|
|
@@ -18,6 +18,8 @@ module PaperTrailDiff
|
|
|
18
18
|
|
|
19
19
|
@record: untyped
|
|
20
20
|
|
|
21
|
+
@snapshots: bool
|
|
22
|
+
|
|
21
23
|
@snapshotter: untyped
|
|
22
24
|
|
|
23
25
|
@tree: AssociationTree
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
module PaperTrailDiff
|
|
4
4
|
# Gives TimelineBuilder one-argument access to a range-prepared snapshot store.
|
|
5
5
|
class TimelineSnapshotProvider
|
|
6
|
-
# : (HistoricalSnapshotStore,
|
|
7
|
-
def initialize: (HistoricalSnapshotStore,
|
|
6
|
+
# : (HistoricalSnapshotStore, live_snapshotter: untyped) -> void
|
|
7
|
+
def initialize: (HistoricalSnapshotStore, live_snapshotter: untyped) -> void
|
|
8
8
|
|
|
9
9
|
# : (untyped, Array[untyped]) -> void
|
|
10
10
|
def prepare: (untyped, Array[untyped]) -> void
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Generated from lib/paper_trail_diff/version_sequence_diagnostics.rb with RBS::Inline
|
|
2
|
+
|
|
3
|
+
module PaperTrailDiff
|
|
4
|
+
# Checks the recorded sequence itself, before any question of association
|
|
5
|
+
# setup: whether the versions can be ordered at all, and whether timestamps
|
|
6
|
+
# they share hide changes that ordering alone cannot recover.
|
|
7
|
+
class VersionSequenceDiagnostics
|
|
8
|
+
# : (untyped, untyped, ?associations_selected: bool) -> void
|
|
9
|
+
def initialize: (untyped, untyped, ?associations_selected: bool) -> void
|
|
10
|
+
|
|
11
|
+
# : () -> Array[DiagnosticIssue]
|
|
12
|
+
def call: () -> Array[DiagnosticIssue]
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
@associations_selected: bool
|
|
17
|
+
|
|
18
|
+
@from_version: untyped
|
|
19
|
+
|
|
20
|
+
@to_version: untyped
|
|
21
|
+
|
|
22
|
+
# Ordering falls back to the id when timestamps tie, which only recovers the
|
|
23
|
+
# real sequence for ids that increase with insertion. Reported before a run
|
|
24
|
+
# rather than after a wrong answer.
|
|
25
|
+
# : (Array[untyped]) -> DiagnosticIssue?
|
|
26
|
+
def unorderable: (Array[untyped]) -> DiagnosticIssue?
|
|
27
|
+
|
|
28
|
+
# Association membership is recorded per version but resolved by timestamp,
|
|
29
|
+
# so a tie hides any association change across that pair. Unlike an
|
|
30
|
+
# unorderable sequence this is not always wrong: if nothing associated
|
|
31
|
+
# changed between them the result is correct, and the gem cannot tell which
|
|
32
|
+
# it is, because not seeing the change is the symptom. So it warns.
|
|
33
|
+
# : (Array[untyped]) -> DiagnosticIssue?
|
|
34
|
+
def tied_timestamps: (Array[untyped]) -> DiagnosticIssue?
|
|
35
|
+
|
|
36
|
+
# : () -> Array[untyped]
|
|
37
|
+
def ordered_range_versions: () -> Array[untyped]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -23,12 +23,12 @@ module PaperTrailDiff
|
|
|
23
23
|
# Compares adjacent root and selected-descendant activity boundaries.
|
|
24
24
|
# `reload_live_endpoints:` applies only when `to:` is a current record; the
|
|
25
25
|
# other range forms never read live state.
|
|
26
|
-
# : (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Array[ActivityStep]
|
|
27
|
-
def self.activity_timeline: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Array[ActivityStep]
|
|
26
|
+
# : (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Array[ActivityStep]
|
|
27
|
+
def self.activity_timeline: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Array[ActivityStep]
|
|
28
28
|
|
|
29
29
|
# Builds an endpoint diff and root-checkpoint timeline while normalizing each version once.
|
|
30
|
-
# : (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Analysis
|
|
31
|
-
def self.analyze: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Analysis
|
|
30
|
+
# : (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Analysis
|
|
31
|
+
def self.analyze: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Analysis
|
|
32
32
|
|
|
33
33
|
# Analyzes many roots over one shared time window, preparing their selected
|
|
34
34
|
# history once for the batch instead of once per record. Roots with no
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: paper_trail_diff
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex Williams
|
|
@@ -118,6 +118,7 @@ files:
|
|
|
118
118
|
- lib/paper_trail_diff/version_association_candidate_scope.rb
|
|
119
119
|
- lib/paper_trail_diff/version_range.rb
|
|
120
120
|
- lib/paper_trail_diff/version_scope_filter.rb
|
|
121
|
+
- lib/paper_trail_diff/version_sequence_diagnostics.rb
|
|
121
122
|
- sig/generated/paper_trail_diff.rbs
|
|
122
123
|
- sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs
|
|
123
124
|
- sig/generated/paper_trail_diff/activity_boundary.rbs
|
|
@@ -194,17 +195,18 @@ files:
|
|
|
194
195
|
- sig/generated/paper_trail_diff/version_association_candidate_scope.rbs
|
|
195
196
|
- sig/generated/paper_trail_diff/version_range.rbs
|
|
196
197
|
- sig/generated/paper_trail_diff/version_scope_filter.rbs
|
|
198
|
+
- sig/generated/paper_trail_diff/version_sequence_diagnostics.rbs
|
|
197
199
|
homepage: https://github.com/aheathwilliams/paper_trail_diff
|
|
198
200
|
licenses:
|
|
199
201
|
- MIT
|
|
200
202
|
metadata:
|
|
201
203
|
allowed_push_host: https://rubygems.org
|
|
202
204
|
bug_tracker_uri: https://github.com/aheathwilliams/paper_trail_diff/issues
|
|
203
|
-
changelog_uri: https://github.com/aheathwilliams/paper_trail_diff/blob/v0.
|
|
204
|
-
documentation_uri: https://github.com/aheathwilliams/paper_trail_diff/blob/v0.
|
|
205
|
+
changelog_uri: https://github.com/aheathwilliams/paper_trail_diff/blob/v0.9.0/CHANGELOG.md
|
|
206
|
+
documentation_uri: https://github.com/aheathwilliams/paper_trail_diff/blob/v0.9.0/README.md
|
|
205
207
|
homepage_uri: https://github.com/aheathwilliams/paper_trail_diff
|
|
206
208
|
rubygems_mfa_required: 'true'
|
|
207
|
-
source_code_uri: https://github.com/aheathwilliams/paper_trail_diff/tree/v0.
|
|
209
|
+
source_code_uri: https://github.com/aheathwilliams/paper_trail_diff/tree/v0.9.0
|
|
208
210
|
rdoc_options: []
|
|
209
211
|
require_paths:
|
|
210
212
|
- lib
|