paper_trail_diff 0.7.1 → 0.8.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 +47 -0
- data/QUICKSTART.md +27 -2
- data/README.md +76 -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 +30 -1
- data/lib/paper_trail_diff/errors.rb +3 -0
- data/lib/paper_trail_diff/paper_trail_adapter.rb +15 -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 +35 -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.rb +10 -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 +9 -0
- data/sig/generated/paper_trail_diff/errors.rbs +4 -0
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +8 -8
- data/sig/generated/paper_trail_diff/support.rbs +16 -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.rbs +4 -4
- 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: 41f2b97428f5dc66c22e29bb1a01cdda0beb1ad29eafe5f86ce8809fb1c61bc0
|
|
4
|
+
data.tar.gz: 9f6ef5ebef159decfaf875b6df4e6341f6243c9431085b65a67987baa674191f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 28d69434ddf8c9886f1785dc665a54040c38721e1aae84738f50668f2cf677cd39f8a3f9870cc06f3627a0c9f134b935a26dceb2f50c2eff8634bd2dc52bbc8f
|
|
7
|
+
data.tar.gz: 704c54404a71a76a9deaa6f3e7d03b425763fdce010b240f8e24e4a293d8656c448a11bcf0e5eabcf6e3d0c2c7bb83b6d277acad83e27a7a749bdb1f0116edf5
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,53 @@
|
|
|
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.8.0] - 2026-08-12
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- Accept `snapshots: true` on `activity_timeline` and `analyze(activity: true)`,
|
|
11
|
+
retaining the reconstructed states each step was compared between as
|
|
12
|
+
`ActivityStep#from_snapshot` and `#to_snapshot`. A diff names what changed; a
|
|
13
|
+
renderer that has to name an unchanged field of a changed record needed the
|
|
14
|
+
whole state and had no way to reach it, so consumers were rebuilding it from
|
|
15
|
+
the version table by hand — slower, and easy to get wrong in ways that stay
|
|
16
|
+
quiet. The gem already builds these states to compute each diff and discarded
|
|
17
|
+
them, so the option costs no extra queries. Off by default because each one
|
|
18
|
+
holds the whole selected graph.
|
|
19
|
+
- Add `examples/demo.rb`, a self-contained tour that needs no application: it
|
|
20
|
+
builds an in-memory database, writes a small multi-author history, and prints
|
|
21
|
+
an endpoint diff, a checkpoint timeline, per-person attribution across a
|
|
22
|
+
nested record, and one person's changes alone. It runs standalone, inside a
|
|
23
|
+
project, or under `bundle exec`, fetching what it needs on first run.
|
|
24
|
+
|
|
25
|
+
- Raise `PaperTrailDiff::AmbiguousVersionOrderError` when versions sharing a
|
|
26
|
+
timestamp have ids that cannot order them. Ordering falls back to the id when
|
|
27
|
+
timestamps tie, which recovers the real sequence only while ids increase with
|
|
28
|
+
insertion; a UUID version id does not, and a MySQL `datetime` column stores
|
|
29
|
+
whole seconds, so the two together are ordinary rather than exotic. The gem
|
|
30
|
+
previously returned a plausible-looking timeline with steps in the wrong
|
|
31
|
+
order and others dropped. `diagnose` reports the same condition as an error
|
|
32
|
+
so it can be caught before a run rather than after a wrong answer.
|
|
33
|
+
- Document the model and schema shapes the suite covers: single-table
|
|
34
|
+
inheritance (results are keyed by the base class, so build keys with
|
|
35
|
+
`Endpoint.identity`), non-integer primary keys, and a custom version class
|
|
36
|
+
via `has_paper_trail versions: { class_name: }`.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- Create the models it uses in the quickstart. It declared `has_paper_trail` on
|
|
41
|
+
an `Article` and then used `Comment` without ever generating either, so
|
|
42
|
+
following it in a new application failed on a missing table at the first
|
|
43
|
+
console step.
|
|
44
|
+
- Run `diagnose`'s version-order check whether or not `associations:` are
|
|
45
|
+
selected, so a report never answers `ok?` having inspected nothing. Document
|
|
46
|
+
that `ok?` means no errors among the checks that ran.
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- Cover `Analysis#to_h(snapshots: true)` with specs. It was shipped in 0.6.0
|
|
51
|
+
with no test at all.
|
|
52
|
+
|
|
6
53
|
## [0.7.1] - 2026-08-11
|
|
7
54
|
|
|
8
55
|
### 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,15 @@ 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).
|
|
25
33
|
|
|
26
34
|
## Installation
|
|
27
35
|
|
|
@@ -83,7 +91,38 @@ for. Three consequences run through the rest of this document:
|
|
|
83
91
|
both timeline APIs return steps rather than events.
|
|
84
92
|
|
|
85
93
|
The [Quickstart](QUICKSTART.md) walks through the same idea against a real
|
|
86
|
-
console session.
|
|
94
|
+
console session, creating the models it uses as it goes.
|
|
95
|
+
|
|
96
|
+
## Which models and schemas work
|
|
97
|
+
|
|
98
|
+
Anything `has_paper_trail` tracks, including these, which are covered by the
|
|
99
|
+
test suite rather than assumed:
|
|
100
|
+
|
|
101
|
+
- **Single-table inheritance.** Results are keyed by the *base* class, matching
|
|
102
|
+
what PaperTrail writes to `item_type`. Analyzing a `Book < Publication`
|
|
103
|
+
produces the key `["Publication", "12"]`, so `results.fetch(["Book", ...])`
|
|
104
|
+
raises `KeyError`. Build keys with `PaperTrailDiff::Endpoint.identity(record)`
|
|
105
|
+
rather than by hand.
|
|
106
|
+
- **Non-integer primary keys** on the tracked model, such as a UUID column.
|
|
107
|
+
- **A custom version class**, via `has_paper_trail versions: { class_name: }`.
|
|
108
|
+
Nothing here references `PaperTrail::Version` directly.
|
|
109
|
+
|
|
110
|
+
### Versions that cannot be ordered
|
|
111
|
+
|
|
112
|
+
Versions are ordered by `created_at`, falling back to the id when timestamps
|
|
113
|
+
tie. That fallback recovers the real sequence only while ids increase with
|
|
114
|
+
insertion. An autoincrement id does; a **UUID version id does not**.
|
|
115
|
+
|
|
116
|
+
So two conditions together are unsafe: version ids that are not sequential,
|
|
117
|
+
*and* versions sharing a timestamp — which is ordinary on a MySQL `datetime`
|
|
118
|
+
column, since it stores whole seconds. The order is then unrecoverable, and
|
|
119
|
+
rather than report a plausible-looking history in the wrong order the gem
|
|
120
|
+
raises `PaperTrailDiff::AmbiguousVersionOrderError` naming both versions.
|
|
121
|
+
`diagnose` reports the same condition as an error, so a caller can check before
|
|
122
|
+
running rather than after a surprise.
|
|
123
|
+
|
|
124
|
+
Either half alone is fine. Sequential ids order tied timestamps correctly, and
|
|
125
|
+
distinct timestamps never reach the fallback.
|
|
87
126
|
|
|
88
127
|
## Choosing an entry point
|
|
89
128
|
|
|
@@ -549,6 +588,35 @@ boundary. Passing `to: article` is what removes the need to touch the parent
|
|
|
549
588
|
after an ordinary versioned child mutation; current state is still never
|
|
550
589
|
implicit.
|
|
551
590
|
|
|
591
|
+
### Reading the state behind a step
|
|
592
|
+
|
|
593
|
+
A step's diff carries what changed. A renderer often needs what did *not* — to
|
|
594
|
+
say whose comment was edited, it needs the comment's author, which the diff has
|
|
595
|
+
no reason to mention. `snapshots: true` retains the reconstructed states each
|
|
596
|
+
step was compared between:
|
|
597
|
+
|
|
598
|
+
```ruby
|
|
599
|
+
steps = PaperTrailDiff.activity_timeline(
|
|
600
|
+
article, from: :first, to: article, associations: [:comments], snapshots: true
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
step = steps.reject(&:empty?).last
|
|
604
|
+
step.from_snapshot.associations["comments"].records.first.attributes["author"]
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
The gem already builds these states to compute each diff and otherwise discards
|
|
608
|
+
them, so asking for them costs no extra queries. Reconstructing them yourself
|
|
609
|
+
from the version table means reimplementing boundary ordering, and getting it
|
|
610
|
+
wrong is quiet rather than loud.
|
|
611
|
+
|
|
612
|
+
They are off by default because each one holds the whole selected graph, and
|
|
613
|
+
retaining one per step keeps the entire timeline's graph alive. A step whose
|
|
614
|
+
`from_boundary` is a `create` version has a `nil` `from_snapshot`: the record
|
|
615
|
+
did not exist yet, which is the answer rather than a missing one.
|
|
616
|
+
|
|
617
|
+
`analyze(activity: true, snapshots: true)` populates the same fields on the
|
|
618
|
+
`activity_timeline` it returns.
|
|
619
|
+
|
|
552
620
|
### Closing a destroyed root
|
|
553
621
|
|
|
554
622
|
A `destroy` version is the one boundary whose following state needs no later
|
|
@@ -975,6 +1043,12 @@ report.errors.map(&:code)
|
|
|
975
1043
|
report.warnings.map(&:code)
|
|
976
1044
|
```
|
|
977
1045
|
|
|
1046
|
+
`ok?` means no *errors* were found among the checks that ran, not that every
|
|
1047
|
+
possible hazard was ruled out. Association checks need `associations:` to have
|
|
1048
|
+
something to inspect; without it only the version-order check runs, which is
|
|
1049
|
+
why that one runs whether or not associations are selected — an unorderable
|
|
1050
|
+
history corrupts a scalar timeline just as thoroughly.
|
|
1051
|
+
|
|
978
1052
|
Diagnostics are read-only guidance, not proof that arbitrary old data is
|
|
979
1053
|
complete. HABTM endpoints without transaction-backed association snapshots fail
|
|
980
1054
|
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_order
|
|
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,31 @@ module PaperTrailDiff
|
|
|
132
136
|
inspect_transaction_metadata(paths)
|
|
133
137
|
end
|
|
134
138
|
|
|
139
|
+
# Ordering falls back to the id when timestamps tie, which only recovers the
|
|
140
|
+
# real sequence for ids that increase with insertion. Reported before a run
|
|
141
|
+
# rather than after a wrong answer.
|
|
142
|
+
#: () -> void
|
|
143
|
+
def inspect_version_order
|
|
144
|
+
pair = Support.ambiguous_pair(ordered_range_versions)
|
|
145
|
+
return unless pair
|
|
146
|
+
|
|
147
|
+
add_error(:ambiguous_version_order, Support.ambiguous_message(pair), nil, pair.first.id)
|
|
148
|
+
rescue StandardError
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
#: () -> Array[untyped]
|
|
153
|
+
def ordered_range_versions
|
|
154
|
+
bounds = [@from_version.created_at, @to_version.created_at].compact.sort
|
|
155
|
+
return [] unless bounds.length == 2
|
|
156
|
+
|
|
157
|
+
@from_version.class
|
|
158
|
+
.where(item_type: @from_version.item_type, item_id: @from_version.item_id)
|
|
159
|
+
.where(created_at: bounds.first..bounds.last)
|
|
160
|
+
.to_a
|
|
161
|
+
.sort_by { |version| Support.chronological_version_key(version) }
|
|
162
|
+
end
|
|
163
|
+
|
|
135
164
|
#: (untyped) -> void
|
|
136
165
|
def inspect_checkpoint_timestamp(model_class)
|
|
137
166
|
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
|
|
|
@@ -67,8 +67,8 @@ module PaperTrailDiff
|
|
|
67
67
|
).build
|
|
68
68
|
end
|
|
69
69
|
|
|
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
|
|
70
|
+
#: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Array[ActivityStep]
|
|
71
|
+
def activity_timeline(record, from:, to:, within:, version_scope: nil, close_on: nil, snapshots: false) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
|
|
72
72
|
payload = @instrumentation_payload.merge(model_type: record.class.base_class.name.to_s)
|
|
73
73
|
Instrumentation.instrument('activity_timeline', payload) do
|
|
74
74
|
@traversal_preparer.call(record.class, historical: true)
|
|
@@ -76,21 +76,21 @@ module PaperTrailDiff
|
|
|
76
76
|
reject_live_habtm_activity!(record.class) if Endpoint.record?(to) || live
|
|
77
77
|
steps = activity_builder(
|
|
78
78
|
record, from: from, to: to, within: within, version_scope: version_scope,
|
|
79
|
-
live_endpoint: live
|
|
79
|
+
live_endpoint: live, snapshots: snapshots
|
|
80
80
|
).build
|
|
81
81
|
payload[:step_count] = steps.length
|
|
82
82
|
steps
|
|
83
83
|
end
|
|
84
84
|
end
|
|
85
85
|
|
|
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
|
|
86
|
+
#: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Analysis
|
|
87
|
+
def analyze(record, from:, to:, within:, activity: false, version_scope: nil, close_on: nil, snapshots: false) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
|
|
88
88
|
@traversal_preparer.call(record.class, historical: true)
|
|
89
89
|
live = live_endpoint_for(record, close_on, within)
|
|
90
90
|
if activity
|
|
91
91
|
return analyze_activity(
|
|
92
|
-
record, from: from, to: to, within: within,
|
|
93
|
-
|
|
92
|
+
record, from: from, to: to, within: within, version_scope: version_scope,
|
|
93
|
+
live_endpoint: live, snapshots: snapshots
|
|
94
94
|
)
|
|
95
95
|
end
|
|
96
96
|
|
|
@@ -132,12 +132,12 @@ module PaperTrailDiff
|
|
|
132
132
|
# @rbs @timeline_snapshotter: TimelineSnapshotProvider
|
|
133
133
|
# @rbs @activity_snapshotter: ActivitySnapshotProvider
|
|
134
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
|
|
135
|
+
#: (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped, ?snapshots: bool) -> Analysis
|
|
136
|
+
def analyze_activity(record, from:, to:, within:, version_scope:, live_endpoint:, snapshots: false) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
|
|
137
137
|
reject_live_habtm_activity!(record.class) if live_endpoint
|
|
138
138
|
activity_builder(
|
|
139
|
-
record, from: from, to: to, within: within,
|
|
140
|
-
|
|
139
|
+
record, from: from, to: to, within: within, version_scope: version_scope,
|
|
140
|
+
live_endpoint: live_endpoint, snapshots: snapshots
|
|
141
141
|
).analyze
|
|
142
142
|
end
|
|
143
143
|
|
|
@@ -212,8 +212,8 @@ module PaperTrailDiff
|
|
|
212
212
|
)
|
|
213
213
|
end
|
|
214
214
|
|
|
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
|
|
215
|
+
#: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped, ?snapshots: bool) -> ActivityTimelineBuilder
|
|
216
|
+
def activity_builder(record, from:, to:, within:, version_scope: nil, live_endpoint: nil, snapshots: false) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
|
|
217
217
|
ActivityTimelineBuilder.new(
|
|
218
218
|
record,
|
|
219
219
|
range: TimelineRange.new(
|
|
@@ -221,7 +221,8 @@ module PaperTrailDiff
|
|
|
221
221
|
live_endpoint: live_endpoint
|
|
222
222
|
),
|
|
223
223
|
tree: @association_tree,
|
|
224
|
-
snapshotter: @activity_snapshotter
|
|
224
|
+
snapshotter: @activity_snapshotter,
|
|
225
|
+
snapshots: snapshots
|
|
225
226
|
)
|
|
226
227
|
end
|
|
227
228
|
|
|
@@ -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,41 @@ 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
|
+
#: (untyped) -> bool
|
|
76
|
+
def sequential_id?(id)
|
|
77
|
+
id.is_a?(Integer) || id.to_s.match?(/\A\d+\z/)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
#: (Array[untyped]) -> String
|
|
81
|
+
def ambiguous_message(pair)
|
|
82
|
+
left, right = pair
|
|
83
|
+
"versions #{left.id.inspect} and #{right.id.inspect} share the timestamp " \
|
|
84
|
+
"#{left.created_at.inspect} and have ids that do not order them, so their " \
|
|
85
|
+
'sequence cannot be recovered; record versions at sub-second precision or ' \
|
|
86
|
+
'with sequential ids'
|
|
87
|
+
end
|
|
88
|
+
|
|
54
89
|
#: (untyped, untyped) -> Integer
|
|
55
90
|
def compare_versions(left, right)
|
|
56
91
|
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
|
data/lib/paper_trail_diff.rb
CHANGED
|
@@ -169,7 +169,7 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
|
169
169
|
# Compares adjacent root and selected-descendant activity boundaries.
|
|
170
170
|
# `reload_live_endpoints:` applies only when `to:` is a current record; the
|
|
171
171
|
# 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]
|
|
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?, ?snapshots: bool) -> Array[ActivityStep]
|
|
173
173
|
def activity_timeline( # rubocop:disable Metrics/ParameterLists
|
|
174
174
|
record,
|
|
175
175
|
from: nil,
|
|
@@ -179,7 +179,8 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
|
179
179
|
ignore: DEFAULT_IGNORED_ATTRIBUTES,
|
|
180
180
|
reload_live_endpoints: true,
|
|
181
181
|
version_scope: nil,
|
|
182
|
-
close_on: nil
|
|
182
|
+
close_on: nil,
|
|
183
|
+
snapshots: false
|
|
183
184
|
)
|
|
184
185
|
PaperTrailAdapter.new(
|
|
185
186
|
associations: associations,
|
|
@@ -191,12 +192,13 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
|
191
192
|
to: to,
|
|
192
193
|
within: within,
|
|
193
194
|
version_scope: version_scope,
|
|
194
|
-
close_on: close_on
|
|
195
|
+
close_on: close_on,
|
|
196
|
+
snapshots: snapshots
|
|
195
197
|
)
|
|
196
198
|
end
|
|
197
199
|
|
|
198
200
|
# 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
|
|
201
|
+
#: (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
202
|
def analyze( # rubocop:disable Metrics/ParameterLists
|
|
201
203
|
record,
|
|
202
204
|
from: nil,
|
|
@@ -206,7 +208,8 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
|
206
208
|
ignore: DEFAULT_IGNORED_ATTRIBUTES,
|
|
207
209
|
activity: false,
|
|
208
210
|
version_scope: nil,
|
|
209
|
-
close_on: nil
|
|
211
|
+
close_on: nil,
|
|
212
|
+
snapshots: false
|
|
210
213
|
)
|
|
211
214
|
PaperTrailAdapter.new(associations: associations, ignore: ignore).analyze(
|
|
212
215
|
record,
|
|
@@ -215,7 +218,8 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
|
215
218
|
within: within,
|
|
216
219
|
activity: activity,
|
|
217
220
|
version_scope: version_scope,
|
|
218
|
-
close_on: close_on
|
|
221
|
+
close_on: close_on,
|
|
222
|
+
snapshots: snapshots
|
|
219
223
|
)
|
|
220
224
|
end
|
|
221
225
|
|
|
@@ -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,15 @@ module PaperTrailDiff
|
|
|
67
67
|
# : (Array[Array[untyped]]) -> void
|
|
68
68
|
def inspect_habtm: (Array[Array[untyped]]) -> void
|
|
69
69
|
|
|
70
|
+
# Ordering falls back to the id when timestamps tie, which only recovers the
|
|
71
|
+
# real sequence for ids that increase with insertion. Reported before a run
|
|
72
|
+
# rather than after a wrong answer.
|
|
73
|
+
# : () -> void
|
|
74
|
+
def inspect_version_order: () -> void
|
|
75
|
+
|
|
76
|
+
# : () -> Array[untyped]
|
|
77
|
+
def ordered_range_versions: () -> Array[untyped]
|
|
78
|
+
|
|
70
79
|
# : (untyped) -> void
|
|
71
80
|
def inspect_checkpoint_timestamp: (untyped) -> void
|
|
72
81
|
|
|
@@ -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
|
|
@@ -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,8 @@ 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
61
|
|
|
62
62
|
# `close_on:` names what ends a wall-clock window, so it is meaningless for a
|
|
63
63
|
# range whose endpoints the caller already gave explicitly.
|
|
@@ -81,8 +81,8 @@ module PaperTrailDiff
|
|
|
81
81
|
# : () -> ActivitySnapshotProvider
|
|
82
82
|
def build_activity_snapshotter: () -> ActivitySnapshotProvider
|
|
83
83
|
|
|
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
|
|
84
|
+
# : (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped, ?snapshots: bool) -> ActivityTimelineBuilder
|
|
85
|
+
def activity_builder: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped, ?snapshots: bool) -> ActivityTimelineBuilder
|
|
86
86
|
|
|
87
87
|
# : (untyped) -> void
|
|
88
88
|
def reject_live_habtm_activity!: (untyped) -> void
|
|
@@ -15,6 +15,22 @@ 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
|
+
# : (untyped) -> bool
|
|
29
|
+
def self?.sequential_id?: (untyped) -> bool
|
|
30
|
+
|
|
31
|
+
# : (Array[untyped]) -> String
|
|
32
|
+
def self?.ambiguous_message: (Array[untyped]) -> String
|
|
33
|
+
|
|
18
34
|
# : (untyped, untyped) -> Integer
|
|
19
35
|
def self?.compare_versions: (untyped, untyped) -> Integer
|
|
20
36
|
|
|
@@ -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
|
|
@@ -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.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex Williams
|
|
@@ -200,11 +200,11 @@ licenses:
|
|
|
200
200
|
metadata:
|
|
201
201
|
allowed_push_host: https://rubygems.org
|
|
202
202
|
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.
|
|
203
|
+
changelog_uri: https://github.com/aheathwilliams/paper_trail_diff/blob/v0.8.0/CHANGELOG.md
|
|
204
|
+
documentation_uri: https://github.com/aheathwilliams/paper_trail_diff/blob/v0.8.0/README.md
|
|
205
205
|
homepage_uri: https://github.com/aheathwilliams/paper_trail_diff
|
|
206
206
|
rubygems_mfa_required: 'true'
|
|
207
|
-
source_code_uri: https://github.com/aheathwilliams/paper_trail_diff/tree/v0.
|
|
207
|
+
source_code_uri: https://github.com/aheathwilliams/paper_trail_diff/tree/v0.8.0
|
|
208
208
|
rdoc_options: []
|
|
209
209
|
require_paths:
|
|
210
210
|
- lib
|