paper_trail_diff 0.3.0 → 0.4.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 +76 -0
- data/README.md +269 -30
- data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +126 -0
- data/lib/paper_trail_diff/activity_boundary.rb +21 -0
- data/lib/paper_trail_diff/activity_collection_event_applier.rb +137 -0
- data/lib/paper_trail_diff/activity_collection_record_updater.rb +67 -0
- data/lib/paper_trail_diff/activity_collection_route_change.rb +21 -0
- data/lib/paper_trail_diff/activity_collection_route_updater.rb +128 -0
- data/lib/paper_trail_diff/activity_event_record_normalizer.rb +42 -0
- data/lib/paper_trail_diff/activity_event_record_resolver.rb +120 -0
- data/lib/paper_trail_diff/activity_event_route_finder.rb +92 -0
- data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +56 -650
- data/lib/paper_trail_diff/activity_relationship.rb +55 -0
- data/lib/paper_trail_diff/activity_root_steps.rb +27 -0
- data/lib/paper_trail_diff/activity_snapshot_sequence.rb +8 -3
- data/lib/paper_trail_diff/activity_timeline_builder.rb +47 -32
- data/lib/paper_trail_diff/analysis.rb +9 -0
- data/lib/paper_trail_diff/branch_snapshot_refresher.rb +2 -0
- data/lib/paper_trail_diff/collection_comparator.rb +14 -1
- data/lib/paper_trail_diff/collection_transition.rb +6 -3
- data/lib/paper_trail_diff/endpoint.rb +27 -2
- data/lib/paper_trail_diff/errors.rb +3 -0
- data/lib/paper_trail_diff/historical_association_reifier.rb +8 -5
- data/lib/paper_trail_diff/historical_snapshot_store.rb +9 -2
- data/lib/paper_trail_diff/prepared_association_reifier.rb +8 -5
- data/lib/paper_trail_diff/prepared_edge_loader.rb +6 -4
- data/lib/paper_trail_diff/prepared_history_loader.rb +3 -2
- data/lib/paper_trail_diff/prepared_record_index.rb +90 -15
- data/lib/paper_trail_diff/snapshot.rb +26 -0
- data/lib/paper_trail_diff/snapshot_normalizer.rb +14 -2
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +52 -32
- data/lib/paper_trail_diff/time_version_range.rb +11 -0
- data/lib/paper_trail_diff/timeline_range.rb +57 -4
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_association_candidate_scope.rb +46 -19
- data/lib/paper_trail_diff.rb +10 -0
- data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +41 -0
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -0
- data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +46 -0
- data/sig/generated/paper_trail_diff/activity_collection_record_updater.rbs +25 -0
- data/sig/generated/paper_trail_diff/activity_collection_route_change.rbs +17 -0
- data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +44 -0
- data/sig/generated/paper_trail_diff/activity_event_record_normalizer.rbs +19 -0
- data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +44 -0
- data/sig/generated/paper_trail_diff/activity_event_route_finder.rbs +47 -0
- data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +18 -110
- data/sig/generated/paper_trail_diff/activity_relationship.rbs +23 -0
- data/sig/generated/paper_trail_diff/activity_root_steps.rbs +13 -0
- data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +2 -2
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +20 -10
- data/sig/generated/paper_trail_diff/analysis.rbs +5 -0
- data/sig/generated/paper_trail_diff/collection_comparator.rbs +7 -0
- data/sig/generated/paper_trail_diff/collection_transition.rbs +4 -1
- data/sig/generated/paper_trail_diff/endpoint.rbs +12 -0
- data/sig/generated/paper_trail_diff/errors.rbs +4 -0
- data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +1 -1
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +2 -2
- data/sig/generated/paper_trail_diff/prepared_association_reifier.rbs +1 -1
- data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +1 -0
- data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -2
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +39 -6
- data/sig/generated/paper_trail_diff/snapshot.rbs +20 -0
- data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +8 -0
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +21 -10
- data/sig/generated/paper_trail_diff/time_version_range.rbs +7 -0
- data/sig/generated/paper_trail_diff/timeline_range.rbs +29 -0
- data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +17 -4
- metadata +24 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fac7dbbad6368383d2b73f4c5acbdcabb1599da6c096a5c8363327a8602a1298
|
|
4
|
+
data.tar.gz: 7657cc9ae4b3e8f7d9f1026ac06fb93b6571ba7258bca66183b84053bb8469c0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: adadf85c15151889247d0791ffc944731c781c6dc3f4d23b4a04fa046e489b74f2a6501a16fb53386e6c80c3fae4d6c583d4b91db198bfbe8919a3a19eaf1a4e
|
|
7
|
+
data.tar.gz: 2a3bf4422b73c513981b519d04f0c556574f75ebc08963fab606f872c2428acac2455869675318ae75d8f71bddeb208e952642eeafedae7131b4745b5f36f534
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,82 @@
|
|
|
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.4.0] - 2026-08-11
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- Close an activity timeline that ends at the root's own `destroy` version with
|
|
11
|
+
a step into a new `kind: :destroyed` boundary, reporting the record's removal
|
|
12
|
+
as a `record_presence_change` to `nil`. `ActivityBoundary` gains a
|
|
13
|
+
`destroyed?` predicate, so consumers branching only on `version?` should
|
|
14
|
+
handle the third kind. `compare`, `timeline`, and `analyze`'s endpoint diff
|
|
15
|
+
are unchanged.
|
|
16
|
+
- Accept a `within:` window whose last selected mutation is the root's own
|
|
17
|
+
destruction, which no later root version can follow, instead of raising
|
|
18
|
+
`IncompleteTimeRangeError` for a range that could never be satisfied. A
|
|
19
|
+
destruction outside the window remains reconstruction context only.
|
|
20
|
+
- Accept `:first` and `:last` as `from:` and `to:` boundaries on `timeline`,
|
|
21
|
+
`activity_timeline`, and `analyze`, resolved without depending on the order
|
|
22
|
+
the `versions` association happens to use. A record with no versions resolves
|
|
23
|
+
to an empty timeline rather than raising, so listing pages need no special
|
|
24
|
+
case.
|
|
25
|
+
|
|
26
|
+
### Removed
|
|
27
|
+
|
|
28
|
+
- Reject two version endpoints given in reverse chronological order with the
|
|
29
|
+
new `PaperTrailDiff::ReversedEndpointsError`, in `compare` and
|
|
30
|
+
`compare_many`. A transposed pair silently produced the inverse diff, and a
|
|
31
|
+
result carries no direction that would reveal it. A current-record endpoint
|
|
32
|
+
may still appear on either side.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- Rebuild prepared scalar state with direct attribute writes so a model that
|
|
37
|
+
overrides an attribute writer reconstructs the state PaperTrail recorded
|
|
38
|
+
instead of reapplying the override.
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
- Order collection `added`, `removed`, and `changed` results naturally within
|
|
43
|
+
one id type instead of by the printed form, so numeric ids sort `2` before
|
|
44
|
+
`10`. Ordering remains deterministic and total for mixed id types.
|
|
45
|
+
- Resolve a boundary's prepared record state by chronological search and an
|
|
46
|
+
indexed boundary transaction instead of scanning a record's versions, so
|
|
47
|
+
timelines over long single-record histories stay linear in their step count.
|
|
48
|
+
- Bound prepared scalar history at the selected range plus one trailing version
|
|
49
|
+
per identity, instead of every version recorded between the range and the
|
|
50
|
+
present, so a short range early in a long history no longer pays for the
|
|
51
|
+
history after it.
|
|
52
|
+
- Document that `timeline` and `analyze(activity: true)` produce the same
|
|
53
|
+
root-checkpoint steps through different reconstruction strategies, and when
|
|
54
|
+
each one is cheaper.
|
|
55
|
+
- Borrow a connection through `with_connection` where Active Record provides
|
|
56
|
+
it, so applications that opt into deprecating permanent checkouts no longer
|
|
57
|
+
see a deprecation warning from association candidate selection.
|
|
58
|
+
- Identify carried-forward collection snapshots and per-pass reification by
|
|
59
|
+
an owned serial and by record identity, rather than by `object_id`, which
|
|
60
|
+
Ruby only guarantees to be unique among live objects.
|
|
61
|
+
- Retain activity-event routes for every event type a timeline visits, rather
|
|
62
|
+
than only the most recent one, so interleaved descendant types stop
|
|
63
|
+
rediscovering the same routes.
|
|
64
|
+
- Resolve excluded attributes once per model class and selected path, and key
|
|
65
|
+
historical child identities instead of rescanning them.
|
|
66
|
+
|
|
67
|
+
## [0.3.1] - 2026-08-10
|
|
68
|
+
|
|
69
|
+
### Fixed
|
|
70
|
+
|
|
71
|
+
- Preserve immutable activity snapshots for nested events that do not match a
|
|
72
|
+
selected child record, instead of reporting a false change.
|
|
73
|
+
|
|
74
|
+
### Changed
|
|
75
|
+
|
|
76
|
+
- Split activity-event reconstruction, route discovery, relationship matching,
|
|
77
|
+
collection mutation, and `belongs_to` target application into focused
|
|
78
|
+
internal collaborators without changing result shapes.
|
|
79
|
+
- Reuse immutable activity-event routes and cached branch components across
|
|
80
|
+
repeated event types to reduce timeline allocation overhead.
|
|
81
|
+
|
|
6
82
|
## [0.3.0] - 2026-08-10
|
|
7
83
|
|
|
8
84
|
### Added
|
data/README.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# paper_trail_diff
|
|
2
2
|
|
|
3
|
+
> [!WARNING]
|
|
4
|
+
> **Experimental software and AI disclosure:** This gem is experimental and may
|
|
5
|
+
> contain incomplete behavior, reconstruction errors, performance problems, or
|
|
6
|
+
> breaking changes. AI-assisted coding tools made significant contributions to
|
|
7
|
+
> its design, implementation, tests, and documentation under maintainer
|
|
8
|
+
> direction. AI involvement is not a substitute for independent review: audit
|
|
9
|
+
> the code and validate it against your own PaperTrail history before relying on
|
|
10
|
+
> it in production, compliance, security, or other high-stakes workflows.
|
|
11
|
+
|
|
3
12
|
`paper_trail_diff` adds structured endpoint, checkpoint-timeline, and activity
|
|
4
13
|
comparisons to
|
|
5
14
|
[PaperTrail](https://github.com/paper-trail-gem/paper_trail). It returns immutable
|
|
@@ -35,12 +44,84 @@ PaperTrail.config.track_associations = true
|
|
|
35
44
|
All associated models whose historical state is compared must use
|
|
36
45
|
`has_paper_trail`.
|
|
37
46
|
|
|
47
|
+
## How PaperTrail records state
|
|
48
|
+
|
|
49
|
+
Almost everything else in this README follows from one property of PaperTrail,
|
|
50
|
+
so it is worth being precise about it first: **a version stores the state that
|
|
51
|
+
existed _before_ the event that created it.** A version is a record of what was
|
|
52
|
+
overwritten, not of what was written.
|
|
53
|
+
|
|
54
|
+
Take an article created as `"Draft"`, then updated to `"Published"`, then to
|
|
55
|
+
`"Final"`:
|
|
56
|
+
|
|
57
|
+
```text
|
|
58
|
+
v1 v2 v3 (no version)
|
|
59
|
+
"create" "update" "update"
|
|
60
|
+
| | | |
|
|
61
|
+
──────●───────────────●─────────────────●─────────────────●──────▶ time
|
|
62
|
+
| | | |
|
|
63
|
+
└─── "Draft" ───┘ | |
|
|
64
|
+
stored in v2 | |
|
|
65
|
+
└─ "Published" ───┘ |
|
|
66
|
+
stored in v3 |
|
|
67
|
+
└──── "Final" ────┘
|
|
68
|
+
only in the table
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Each state is stored by the version at the *end* of the interval it was live
|
|
72
|
+
for. Three consequences run through the rest of this document:
|
|
73
|
+
|
|
74
|
+
- **A `create` version reifies to `nil`.** Nothing preceded it. Comparing it
|
|
75
|
+
with a later state reports a structured `record_presence_change` rather than
|
|
76
|
+
inventing scalar changes.
|
|
77
|
+
- **The newest state has no version at all.** It exists only in the table. A
|
|
78
|
+
fully historical result therefore needs a version *later* than the last change
|
|
79
|
+
it should reveal, which is why a `within:` window can raise
|
|
80
|
+
`IncompleteTimeRangeError`, and why `activity_timeline(..., to: article)`
|
|
81
|
+
exists for ending at current state instead.
|
|
82
|
+
- **A change is visible between two boundaries**, never "at" one. This is why
|
|
83
|
+
both timeline APIs return steps rather than events.
|
|
84
|
+
|
|
85
|
+
The [Quickstart](QUICKSTART.md) walks through the same idea against a real
|
|
86
|
+
console session.
|
|
87
|
+
|
|
88
|
+
## Choosing an entry point
|
|
89
|
+
|
|
90
|
+
| You need | Call |
|
|
91
|
+
| --- | --- |
|
|
92
|
+
| The net difference between two endpoints | `compare` |
|
|
93
|
+
| The same, for many records in one pass | `compare_many` |
|
|
94
|
+
| One step per version of the root record | `timeline` |
|
|
95
|
+
| One step per version of the root *or a selected child* | `activity_timeline` |
|
|
96
|
+
| A net difference and a timeline from one history pass | `analyze` |
|
|
97
|
+
|
|
98
|
+
`timeline` and `activity_timeline` differ only in which recorded versions
|
|
99
|
+
become boundaries. Given an article with two comment edits between two article
|
|
100
|
+
versions:
|
|
101
|
+
|
|
102
|
+
```text
|
|
103
|
+
recorded versions A1 C1 C2 A2 A = Article version
|
|
104
|
+
| | | | C = Comment version
|
|
105
|
+
────●─────────●───────●─────────●────▶ time
|
|
106
|
+
| | | |
|
|
107
|
+
timeline └───────── 1 step ──────────┘
|
|
108
|
+
both comment changes land inside that one step
|
|
109
|
+
|
|
110
|
+
activity_timeline └── 1 ────┴── 2 ──┴─── 3 ───┘
|
|
111
|
+
each recorded version becomes its own boundary
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Both report the same underlying data: a `timeline` step still contains every
|
|
115
|
+
selected association, because it has to describe what changed beneath the root.
|
|
116
|
+
They differ in how finely that change is split, and in what the reconstruction
|
|
117
|
+
costs. That cost trade-off is covered under
|
|
118
|
+
[choosing a reconstruction strategy](#choosing-between-timeline-and-analyzeactivity-true).
|
|
119
|
+
|
|
38
120
|
## Compare two endpoints
|
|
39
121
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
their net difference:
|
|
122
|
+
`compare` accepts two explicit endpoints: each may be a PaperTrail version or a
|
|
123
|
+
clean, persisted model instance representing current database state. It reports
|
|
124
|
+
only their net difference:
|
|
44
125
|
|
|
45
126
|
```ruby
|
|
46
127
|
diff = PaperTrailDiff.compare(article.versions[1], article.versions[4])
|
|
@@ -60,10 +141,21 @@ diff.to_h
|
|
|
60
141
|
|
|
61
142
|
Intermediate edits do not affect `compare`. If a title changes and later
|
|
62
143
|
returns to its original value, endpoint comparison reports no title change.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
144
|
+
|
|
145
|
+
`record_presence_change` reports whether the root record *existed* at each
|
|
146
|
+
endpoint, and is `nil` for the ordinary case where it existed at both. It is
|
|
147
|
+
populated when one endpoint reifies to `nil` — most often a `create` version,
|
|
148
|
+
whose pre-change state is the absence of the record — and it then carries whole
|
|
149
|
+
`RecordSnapshot` values rather than fake scalar changes from `nil`.
|
|
150
|
+
|
|
151
|
+
Destroying the root record is not reported this way, and the reason follows
|
|
152
|
+
from the [pre-change model](#how-papertrail-records-state): the state *at* a
|
|
153
|
+
`destroy` version is the state immediately before the deletion, so the record
|
|
154
|
+
is still present there. A comparison ending at a `destroy` version reports that
|
|
155
|
+
last edit, not the deletion. Deleting a *selected child* is reported normally,
|
|
156
|
+
as a `removed` member of its parent's collection, and `activity_timeline`
|
|
157
|
+
reports a destroyed root through a
|
|
158
|
+
[closing removal step](#closing-a-destroyed-root).
|
|
67
159
|
|
|
68
160
|
Pass the record explicitly when the desired endpoint is current state:
|
|
69
161
|
|
|
@@ -75,10 +167,18 @@ diff = PaperTrailDiff.compare(
|
|
|
75
167
|
)
|
|
76
168
|
```
|
|
77
169
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
170
|
+
Two version endpoints must be given in chronological order. A transposed pair
|
|
171
|
+
produces the inverse diff, which is easy to do by accident and impossible to
|
|
172
|
+
detect afterwards because a result carries no direction of its own, so it
|
|
173
|
+
raises `PaperTrailDiff::ReversedEndpointsError` instead. Nothing is lost by
|
|
174
|
+
this: the two orders differ only in which side of each change is `from`.
|
|
175
|
+
|
|
176
|
+
A current-record endpoint is exempt and may appear on either side, because it
|
|
177
|
+
is self-evidently the live state and placing it first is a deliberate reverse
|
|
178
|
+
comparison. Current state is never inferred. The record must be persisted, not
|
|
179
|
+
destroyed, and free of unsaved attribute changes. The gem reloads it unscoped
|
|
180
|
+
before normalization, so stale association caches and in-memory edits are not
|
|
181
|
+
compared. Use a database
|
|
82
182
|
transaction with an appropriate isolation level when several live association
|
|
83
183
|
queries must represent one atomic application snapshot.
|
|
84
184
|
|
|
@@ -87,17 +187,30 @@ preload each explicitly selected live association path across the batch. Each
|
|
|
87
187
|
entry has the same endpoints and options as `compare`; results are returned in
|
|
88
188
|
input order as a frozen hash keyed by `[item_type, item_id]` strings:
|
|
89
189
|
|
|
190
|
+
Endpoints are still supplied by the caller, so look them up in bulk too —
|
|
191
|
+
otherwise the per-root queries this API removes come straight back. Two
|
|
192
|
+
queries resolve the earliest version of every root, whatever the batch size:
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
orders = Order.where(id: order_ids).to_a
|
|
196
|
+
|
|
197
|
+
earliest_ids = PaperTrail::Version
|
|
198
|
+
.where(item_type: "Order", item_id: orders.map(&:id))
|
|
199
|
+
.group(:item_id)
|
|
200
|
+
.minimum(:id)
|
|
201
|
+
first_versions = PaperTrail::Version
|
|
202
|
+
.where(id: earliest_ids.values)
|
|
203
|
+
.index_by(&:item_id)
|
|
204
|
+
```
|
|
205
|
+
|
|
90
206
|
```ruby
|
|
91
207
|
diffs = PaperTrailDiff.compare_many(
|
|
92
|
-
|
|
93
|
-
{ from: first_versions.fetch(order_a.id), to: order_a },
|
|
94
|
-
{ from: first_versions.fetch(order_b.id), to: order_b }
|
|
95
|
-
],
|
|
208
|
+
orders.map { |order| { from: first_versions.fetch(order.id), to: order } },
|
|
96
209
|
associations: [:line_items],
|
|
97
210
|
ignore: []
|
|
98
211
|
)
|
|
99
212
|
|
|
100
|
-
diffs.fetch(["Order",
|
|
213
|
+
diffs.fetch(["Order", orders.first.id.to_s]) # => PaperTrailDiff::Diff
|
|
101
214
|
```
|
|
102
215
|
|
|
103
216
|
Root identities must be unique within one call. Historical reconstruction for
|
|
@@ -116,7 +229,8 @@ must observe one atomic snapshot.
|
|
|
116
229
|
already owns a consistent, fully preloaded graph may opt out:
|
|
117
230
|
|
|
118
231
|
```ruby
|
|
119
|
-
orders = Order.where(id:
|
|
232
|
+
orders = Order.where(id: order_ids).preload(line_items: :product).to_a
|
|
233
|
+
# `first_versions` is the same bulk endpoint lookup shown above.
|
|
120
234
|
|
|
121
235
|
diffs = PaperTrailDiff.compare_many(
|
|
122
236
|
orders.map do |order|
|
|
@@ -173,8 +287,28 @@ end
|
|
|
173
287
|
|
|
174
288
|
## Build a root-checkpoint timeline
|
|
175
289
|
|
|
176
|
-
`timeline` accepts two version objects from the supplied record's history
|
|
177
|
-
|
|
290
|
+
`timeline` accepts two version objects from the supplied record's history, or
|
|
291
|
+
the symbols `:first` and `:last` when the range is simply the record's whole
|
|
292
|
+
recorded history:
|
|
293
|
+
|
|
294
|
+
```ruby
|
|
295
|
+
steps = PaperTrailDiff.timeline(article, from: :first, to: :last)
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
`:first` and `:last` are resolved by the gem, independently of the order the
|
|
299
|
+
`versions` association happens to use, so a caller never has to know it is
|
|
300
|
+
sorted. They work anywhere a version does, including mixed with an explicit
|
|
301
|
+
one (`from: :first, to: some_version`) and on `activity_timeline` and
|
|
302
|
+
`analyze`. A record with no versions has no boundaries to resolve; that is an
|
|
303
|
+
empty history rather than a bad request, so the result is an empty timeline
|
|
304
|
+
instead of an error — which is usually what an index page wants.
|
|
305
|
+
|
|
306
|
+
Combine `from: :first` with `to: article` for the fullest activity view of a
|
|
307
|
+
live record: the whole recorded history, ending at current state. See
|
|
308
|
+
[activity timelines](#build-an-activity-timeline) for why that end differs from
|
|
309
|
+
`to: :last`.
|
|
310
|
+
|
|
311
|
+
The range is inclusive, must be chronological, and produces one `Step` for each
|
|
178
312
|
adjacent pair:
|
|
179
313
|
|
|
180
314
|
```ruby
|
|
@@ -196,7 +330,7 @@ steps.first.to_h
|
|
|
196
330
|
|
|
197
331
|
Both timeline types expose `from_boundary`, `to_boundary`, `diff`, and
|
|
198
332
|
`empty?`. A historical boundary has `event`, `whodunnit`, `record`,
|
|
199
|
-
`recorded_at`, `version?`, and `
|
|
333
|
+
`recorded_at`, `version?`, `current?`, and `destroyed?` readers. Checkpoint `Step` objects
|
|
200
334
|
also retain their original `from_version` and `to_version` for callers that
|
|
201
335
|
need custom PaperTrail metadata. Existing `Step#to_h` and
|
|
202
336
|
`ActivityBoundary#to_h` shapes remain unchanged; use the readers for the new
|
|
@@ -226,7 +360,9 @@ steps.reject(&:empty?)
|
|
|
226
360
|
```
|
|
227
361
|
|
|
228
362
|
Pass the record explicitly as `to:` to include current state without creating a
|
|
229
|
-
final root checkpoint:
|
|
363
|
+
final root checkpoint. This is not the same as `to: :last`: descendant
|
|
364
|
+
mutations recorded after the record's final root version fall outside a
|
|
365
|
+
version-bounded range, so only a live end reports them.
|
|
230
366
|
|
|
231
367
|
```ruby
|
|
232
368
|
steps = PaperTrailDiff.activity_timeline(
|
|
@@ -259,6 +395,46 @@ boundary. Passing `to: article` is what removes the need to touch the parent
|
|
|
259
395
|
after an ordinary versioned child mutation; current state is still never
|
|
260
396
|
implicit.
|
|
261
397
|
|
|
398
|
+
### Closing a destroyed root
|
|
399
|
+
|
|
400
|
+
A `destroy` version is the one boundary whose following state needs no later
|
|
401
|
+
version: the event itself says the record is gone. When an activity timeline
|
|
402
|
+
ends at the root's own `destroy` version, it therefore closes with one more
|
|
403
|
+
step, from that version to a `kind: :destroyed` boundary, whose diff is a
|
|
404
|
+
`record_presence_change` from the record's final state to `nil`:
|
|
405
|
+
|
|
406
|
+
```ruby
|
|
407
|
+
steps = PaperTrailDiff.activity_timeline(
|
|
408
|
+
article,
|
|
409
|
+
from: article.versions.first,
|
|
410
|
+
to: article.versions.last # a destroy version
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
removal = steps.last
|
|
414
|
+
removal.to_boundary.destroyed? # => true
|
|
415
|
+
removal.to_boundary.kind # => :destroyed
|
|
416
|
+
removal.diff.record_presence_change.from # the state it was deleted in
|
|
417
|
+
removal.diff.record_presence_change.to # => nil
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
The removal step's `from_boundary` is the ordinary `kind: :version` boundary
|
|
421
|
+
for the same destroy version, since that boundary still holds the record.
|
|
422
|
+
Boundaries therefore have three kinds — `:version`, `:current`, and
|
|
423
|
+
`:destroyed` — so a consumer that branches on `version?` alone should also
|
|
424
|
+
handle `destroyed?`.
|
|
425
|
+
|
|
426
|
+
`analyze(activity: true)` reports the same closing step in its
|
|
427
|
+
`activity_timeline`. Its `diff` and `timeline` keep their `compare` and
|
|
428
|
+
`timeline` semantics and do not report the deletion.
|
|
429
|
+
|
|
430
|
+
A `within:` window behaves the same way. A window whose last selected mutation
|
|
431
|
+
is the root's destruction needs no later root version, because none can ever
|
|
432
|
+
exist, so it closes on the removal instead of raising
|
|
433
|
+
`IncompleteTimeRangeError`. When the destruction falls *outside* the window it
|
|
434
|
+
remains ordinary reconstruction context and is not reported as a selected
|
|
435
|
+
mutation. The same relaxation lets the plain `timeline` accept such a window,
|
|
436
|
+
though it still reports only the edits.
|
|
437
|
+
|
|
262
438
|
Live-ended HABTM activity is rejected with
|
|
263
439
|
`PaperTrailDiff::UnsupportedLiveActivityError`. HABTM join rows have no model
|
|
264
440
|
versions, so the gem cannot reliably split their membership mutations into
|
|
@@ -306,7 +482,9 @@ If the window contains a relevant mutation but no later root version exists,
|
|
|
306
482
|
the call raises `PaperTrailDiff::IncompleteTimeRangeError`. Create a root
|
|
307
483
|
checkpoint after the reporting window before running historical analysis. The
|
|
308
484
|
gem does not silently substitute current database state. A root-only window
|
|
309
|
-
with no selected mutation returns a frozen empty timeline.
|
|
485
|
+
with no selected mutation returns a frozen empty timeline. The one exception is
|
|
486
|
+
a window that closes on the root's own destruction, which no later version can
|
|
487
|
+
ever follow; see [closing a destroyed root](#closing-a-destroyed-root).
|
|
310
488
|
|
|
311
489
|
Time ranges and explicit `from:`/`to:` endpoints are mutually exclusive.
|
|
312
490
|
Malformed, open-ended, or reversed ranges raise
|
|
@@ -343,6 +521,37 @@ work is performed. `analyze` accepts explicit historical versions or
|
|
|
343
521
|
`within:`, but not a current-record endpoint; use the standalone
|
|
344
522
|
`activity_timeline(..., to: article)` API when the final boundary must be live.
|
|
345
523
|
|
|
524
|
+
### Choosing between `timeline` and `analyze(activity: true)`
|
|
525
|
+
|
|
526
|
+
`analysis.timeline` and `timeline` return the same root-checkpoint steps for the
|
|
527
|
+
same range. They are two reconstruction strategies for one result, not two
|
|
528
|
+
levels of detail: a `timeline` step covers only root version boundaries, but
|
|
529
|
+
each of its snapshots still contains every selected association, because a step
|
|
530
|
+
must report what changed underneath the root as well.
|
|
531
|
+
|
|
532
|
+
The one behavioural difference is at the edge of a `within:` window. Selected
|
|
533
|
+
descendants can move inside a window that contains no root version at all, so
|
|
534
|
+
the activity form requires a root boundary it can reconstruct from and raises
|
|
535
|
+
`IncompleteTimeRangeError` when there is none. `timeline` has no activity view
|
|
536
|
+
to anchor and returns no steps for that window.
|
|
537
|
+
|
|
538
|
+
The strategies differ in what that costs:
|
|
539
|
+
|
|
540
|
+
- `timeline` reconstructs the whole selected graph independently at every root
|
|
541
|
+
boundary, so it costs roughly *root versions x selected graph size*. It is
|
|
542
|
+
insensitive to how much descendant activity happened in between.
|
|
543
|
+
- `analyze(activity: true)` reconstructs once and then advances that snapshot
|
|
544
|
+
through each recorded mutation, so it costs roughly *one reconstruction +
|
|
545
|
+
total events*. It is insensitive to how wide the selected graph is.
|
|
546
|
+
|
|
547
|
+
Neither dominates. Reconstructing once per checkpoint wins when a few root
|
|
548
|
+
versions span very heavy descendant churn; advancing incrementally wins when
|
|
549
|
+
the selected graph is wide and descendant activity is comparable to root
|
|
550
|
+
activity. As a rule of thumb, prefer `analyze(activity: true)` when selected
|
|
551
|
+
associations are wide, and `timeline` when descendant events greatly outnumber
|
|
552
|
+
root versions. Measure with `ActiveSupport::Notifications` on a representative
|
|
553
|
+
history rather than a seeded example if the choice matters.
|
|
554
|
+
|
|
346
555
|
`timeline`, `activity_timeline`, and both forms of `analyze` prepare the selected
|
|
347
556
|
historical range once. The loader walks only the explicit association paths and
|
|
348
557
|
builds an immutable temporal index of scalar states, relationship candidates,
|
|
@@ -378,12 +587,13 @@ retain the general comparator and traversal fallback.
|
|
|
378
587
|
Activity event loading is bounded to the selected range. Association identity
|
|
379
588
|
discovery retains one indexed checkpoint for members present at the starting
|
|
380
589
|
boundary, then considers later association activity and current members; it no
|
|
381
|
-
longer materializes every pre-range association row.
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
590
|
+
longer materializes every pre-range association row. Because a PaperTrail
|
|
591
|
+
version is a pre-change snapshot, the state at a range's final boundary can
|
|
592
|
+
live only in the next version after it, so prepared scalar state retains one
|
|
593
|
+
trailing version per selected identity. It does not retain the rest of the
|
|
594
|
+
history recorded after the range, so a short range early in a long history
|
|
595
|
+
costs the same as the same range in a short one. Keep requested paths and
|
|
596
|
+
ranges intentional. An activity timeline must also emit a diff for every selected
|
|
387
597
|
event. Repeated events within one wide collection still copy the frozen records
|
|
388
598
|
array when producing each immutable snapshot, so they can do pointer-copying
|
|
389
599
|
work proportional to the number of events times the collection width even when
|
|
@@ -617,6 +827,29 @@ when join attributes or join mutations must appear as first-class history.
|
|
|
617
827
|
|
|
618
828
|
## Result objects
|
|
619
829
|
|
|
830
|
+
Every level of a result separates the same two kinds of change in the same way:
|
|
831
|
+
|
|
832
|
+
| | a record appears or disappears | a record stays and its fields change |
|
|
833
|
+
| --- | --- | --- |
|
|
834
|
+
| the root record | `record_presence_change` | `attributes`, `associations` |
|
|
835
|
+
| `has_many`, HABTM | `added`, `removed` | `changed` |
|
|
836
|
+
| `belongs_to`, `has_one` | `relationship` | `changed` |
|
|
837
|
+
|
|
838
|
+
The left column carries whole `RecordSnapshot` values; the right column carries
|
|
839
|
+
field-level `ValueChange` deltas. That split is deliberate. A record that has
|
|
840
|
+
just appeared has no previous value for any of its fields, so reporting one
|
|
841
|
+
`nil` to value change per attribute would both add noise and blur the
|
|
842
|
+
difference between "this field was edited" and "this record did not exist".
|
|
843
|
+
|
|
844
|
+
The consequence is that reading a result tree directly means branching on which
|
|
845
|
+
column applies: a created record's state is under
|
|
846
|
+
`record_presence_change.to.attributes`, an edited record's is under
|
|
847
|
+
`attributes`. Consumers that would rather not branch should use
|
|
848
|
+
[`each_entry`](#traverse-result-trees), which flattens both into one stream —
|
|
849
|
+
an edit arrives as `attribute_changed` carrying a `ValueChange`, and a created
|
|
850
|
+
record's fields arrive as `attribute_included` entries carrying values with
|
|
851
|
+
`state: :after`.
|
|
852
|
+
|
|
620
853
|
The public result types are:
|
|
621
854
|
|
|
622
855
|
- `PaperTrailDiff::Diff`
|
|
@@ -636,7 +869,9 @@ The public result types are:
|
|
|
636
869
|
- `PaperTrailDiff::DiagnosticIssue`
|
|
637
870
|
|
|
638
871
|
They expose readers, are frozen after construction, and provide deterministic
|
|
639
|
-
`to_h` output.
|
|
872
|
+
`to_h` output. Collection results are ordered by record identity: by type, then
|
|
873
|
+
naturally within one id type, so numeric ids sort `2` before `10`. Mixed or
|
|
874
|
+
unusual id types still order totally rather than raising. Structural hash keys are symbols; attribute and association
|
|
640
875
|
names are strings. Attribute values retain their Ruby types. `RecordChange#record`
|
|
641
876
|
is a `RecordReference` with `type` and `id` readers. `TraversalEntry#record` and
|
|
642
877
|
`#association` return the final components of their corresponding paths. `Step`
|
|
@@ -662,6 +897,10 @@ and PT-AT can reconstruct. In particular:
|
|
|
662
897
|
- HABTM membership is limited to the join snapshots PT-AT recorded in
|
|
663
898
|
`version_associations`; historical target attributes require versioned target
|
|
664
899
|
models, otherwise PT-AT may return live target state;
|
|
900
|
+
- `compare`, `timeline`, and `analyze`'s endpoint diff do not report the root
|
|
901
|
+
record's destruction, because the state recorded at a `destroy` version is the
|
|
902
|
+
state before the deletion; `activity_timeline` closes on a `:destroyed`
|
|
903
|
+
boundary instead, and a selected child's removal is reported by its parent;
|
|
665
904
|
- `timeline` and `analyze` are bounded by root versions; `activity_timeline`
|
|
666
905
|
adds recorded descendant boundaries and may terminate at an explicitly passed
|
|
667
906
|
current record, while a fully historical result still requires a later root
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Applies selected non-polymorphic belongs-to target updates to a snapshot.
|
|
6
|
+
class ActivityBelongsToEventApplier
|
|
7
|
+
#: (route_finder: ActivityEventRouteFinder, record_resolver: ActivityEventRecordResolver, record_normalizer: ActivityEventRecordNormalizer) -> void
|
|
8
|
+
def initialize(route_finder:, record_resolver:, record_normalizer:)
|
|
9
|
+
@route_finder = route_finder
|
|
10
|
+
@record_resolver = record_resolver
|
|
11
|
+
@record_normalizer = record_normalizer
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
#: (untyped, untyped, RecordSnapshot, AssociationTree, SnapshotNormalizer, untyped) -> [bool, RecordSnapshot?]
|
|
15
|
+
def call( # rubocop:disable Metrics/ParameterLists
|
|
16
|
+
root_endpoint,
|
|
17
|
+
context_endpoint,
|
|
18
|
+
previous,
|
|
19
|
+
tree,
|
|
20
|
+
normalizer,
|
|
21
|
+
version
|
|
22
|
+
)
|
|
23
|
+
return [false, nil] unless %w[update destroy].include?(version.event.to_s)
|
|
24
|
+
|
|
25
|
+
routes = @route_finder.belongs_to_routes(
|
|
26
|
+
endpoint_model_class(root_endpoint), tree, version.item_type.to_s
|
|
27
|
+
)
|
|
28
|
+
return [false, nil] if routes.empty?
|
|
29
|
+
|
|
30
|
+
snapshot = previous
|
|
31
|
+
routes.each do |route|
|
|
32
|
+
replacement = normalized_route_record(
|
|
33
|
+
route, version, normalizer, root_endpoint, context_endpoint
|
|
34
|
+
)
|
|
35
|
+
snapshot, = replace_route(snapshot, route, version, replacement)
|
|
36
|
+
end
|
|
37
|
+
[true, snapshot]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# @rbs @route_finder: ActivityEventRouteFinder
|
|
43
|
+
# @rbs @record_resolver: ActivityEventRecordResolver
|
|
44
|
+
# @rbs @record_normalizer: ActivityEventRecordNormalizer
|
|
45
|
+
|
|
46
|
+
#: (Array[untyped], untyped, SnapshotNormalizer, untyped, untyped) -> RecordSnapshot?
|
|
47
|
+
def normalized_route_record(route, version, normalizer, root_endpoint, context_endpoint)
|
|
48
|
+
record = @record_resolver.record_after(version)
|
|
49
|
+
return unless record
|
|
50
|
+
|
|
51
|
+
_name, reflection, subtree, path = route.last
|
|
52
|
+
@record_normalizer.call(
|
|
53
|
+
record,
|
|
54
|
+
reflection: reflection,
|
|
55
|
+
subtree: subtree,
|
|
56
|
+
path: path,
|
|
57
|
+
normalizer: normalizer,
|
|
58
|
+
root_endpoint: root_endpoint,
|
|
59
|
+
context_endpoint: context_endpoint
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
#: (RecordSnapshot, Array[untyped], untyped, RecordSnapshot?, ?depth: Integer) -> [RecordSnapshot, bool]
|
|
64
|
+
def replace_route(snapshot, route, version, replacement, depth: 0)
|
|
65
|
+
name, _reflection, _subtree, = route.fetch(depth)
|
|
66
|
+
association = snapshot.associations.fetch(name)
|
|
67
|
+
records, changed = replacement_records(
|
|
68
|
+
association.records, route, version, replacement, depth
|
|
69
|
+
)
|
|
70
|
+
return [snapshot, false] unless changed
|
|
71
|
+
|
|
72
|
+
updated_association = AssociationSnapshot.new(kind: association.kind, records: records)
|
|
73
|
+
[replace_association(snapshot, name, updated_association), true]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
#: (Array[RecordSnapshot], Array[untyped], untyped, RecordSnapshot?, Integer) -> [Array[RecordSnapshot], bool]
|
|
77
|
+
def replacement_records(records, route, version, replacement, depth)
|
|
78
|
+
return replace_existing_target(records, version, replacement) if depth == route.length - 1
|
|
79
|
+
|
|
80
|
+
changed = false
|
|
81
|
+
updated = records.map do |record|
|
|
82
|
+
result, record_changed = replace_route(
|
|
83
|
+
record, route, version, replacement, depth: depth + 1
|
|
84
|
+
)
|
|
85
|
+
changed ||= record_changed
|
|
86
|
+
result
|
|
87
|
+
end.freeze
|
|
88
|
+
[updated, changed]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
#: (Array[RecordSnapshot], untyped, RecordSnapshot?) -> [Array[RecordSnapshot], bool]
|
|
92
|
+
def replace_existing_target(records, version, replacement)
|
|
93
|
+
index = records.index do |record|
|
|
94
|
+
record.id.to_s == version.item_id.to_s &&
|
|
95
|
+
record.type.to_s == replacement_type(version, replacement)
|
|
96
|
+
end
|
|
97
|
+
return [records, false] unless index
|
|
98
|
+
|
|
99
|
+
updated = records.dup
|
|
100
|
+
replacement ? updated[index] = replacement : updated.delete_at(index)
|
|
101
|
+
[updated.freeze, true]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
#: (RecordSnapshot, String, AssociationSnapshot) -> RecordSnapshot
|
|
105
|
+
def replace_association(snapshot, name, association)
|
|
106
|
+
RecordSnapshot.new(
|
|
107
|
+
type: snapshot.type,
|
|
108
|
+
id: snapshot.id,
|
|
109
|
+
attributes: snapshot.attributes,
|
|
110
|
+
associations: snapshot.associations.merge(name => association)
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
#: (untyped, RecordSnapshot?) -> String
|
|
115
|
+
def replacement_type(version, replacement)
|
|
116
|
+
return replacement.type.to_s if replacement
|
|
117
|
+
|
|
118
|
+
Endpoint.model_class(version).name.to_s
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
#: (untyped) -> untyped
|
|
122
|
+
def endpoint_model_class(endpoint)
|
|
123
|
+
Endpoint.version?(endpoint) ? Endpoint.model_class(endpoint) : endpoint.class
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -37,6 +37,22 @@ module PaperTrailDiff
|
|
|
37
37
|
recorded_at: captured_at
|
|
38
38
|
)
|
|
39
39
|
end
|
|
40
|
+
|
|
41
|
+
# The state a `destroy` version leaves behind. A version records the state
|
|
42
|
+
# before its own event, so the boundary built from a destroy version still
|
|
43
|
+
# holds the record; this one is the absence that follows it.
|
|
44
|
+
#: (untyped) -> ActivityBoundary
|
|
45
|
+
def destroyed(version)
|
|
46
|
+
new(
|
|
47
|
+
kind: :destroyed,
|
|
48
|
+
version_id: version.id,
|
|
49
|
+
item_type: version.item_type,
|
|
50
|
+
item_id: version.item_id,
|
|
51
|
+
recorded_at: version.created_at,
|
|
52
|
+
event: version.event,
|
|
53
|
+
whodunnit: version.whodunnit
|
|
54
|
+
)
|
|
55
|
+
end
|
|
40
56
|
end
|
|
41
57
|
|
|
42
58
|
#: (kind: Symbol, version_id: untyped, item_type: untyped, item_id: untyped, recorded_at: untyped, ?event: untyped, ?whodunnit: untyped) -> void
|
|
@@ -70,6 +86,11 @@ module PaperTrailDiff
|
|
|
70
86
|
kind == :current
|
|
71
87
|
end
|
|
72
88
|
|
|
89
|
+
#: () -> bool
|
|
90
|
+
def destroyed?
|
|
91
|
+
kind == :destroyed
|
|
92
|
+
end
|
|
93
|
+
|
|
73
94
|
#: () -> Hash[Symbol, untyped]
|
|
74
95
|
def to_h
|
|
75
96
|
{
|