paper_trail_diff 0.3.1 → 0.5.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 +81 -0
- data/README.md +316 -32
- data/lib/paper_trail_diff/activity_boundary.rb +21 -0
- data/lib/paper_trail_diff/activity_event_route_finder.rb +24 -44
- 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/analysis_batch.rb +74 -0
- data/lib/paper_trail_diff/batch_boundary_resolver.rb +137 -0
- data/lib/paper_trail_diff/batched_root_analyzer.rb +50 -0
- data/lib/paper_trail_diff/batched_root_versions.rb +131 -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/comparison_batch.rb +37 -13
- 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 +12 -9
- data/lib/paper_trail_diff/paper_trail_adapter.rb +49 -33
- 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_builder.rb +3 -3
- data/lib/paper_trail_diff/timeline_range.rb +66 -6
- data/lib/paper_trail_diff/traversal_preparer.rb +40 -0
- 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 +37 -5
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -0
- data/sig/generated/paper_trail_diff/activity_event_route_finder.rbs +10 -17
- 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/analysis_batch.rbs +42 -0
- data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +62 -0
- data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +28 -0
- data/sig/generated/paper_trail_diff/batched_root_versions.rbs +62 -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/comparison_batch.rbs +15 -0
- 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 -3
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +16 -7
- 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_builder.rbs +2 -2
- data/sig/generated/paper_trail_diff/timeline_range.rbs +35 -2
- data/sig/generated/paper_trail_diff/traversal_preparer.rbs +22 -0
- data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +17 -4
- data/sig/generated/paper_trail_diff.rbs +13 -3
- metadata +16 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a4d3c1e15ef20c4e3d05f5c0442d7e55cd0eb811a15766d2119a3cf35b2e729c
|
|
4
|
+
data.tar.gz: 4cc8dceb10e7aa71f1feb7d9fcc3917040bec4b2cc69641b4b3817d6761aad94
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3887a515ee90f1c14b890a21d4a91b4892f4097b74c9ec27f136fd8476f01cd285c0a310a03b0c528e709cdca299bca2e243522844d09a86d272c043e48879b1
|
|
7
|
+
data.tar.gz: a1390db78cc31cbf20420fd18b536ad8cd826de4927943cbdddc08b7a31f9dad5f81acee77db97059f044ad435bd3170e54f8ef2ccd5c68f3e256bfa5d848642
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,87 @@
|
|
|
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.5.0] - 2026-08-11
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- Add `analyze_many`, which analyzes many roots over one shared `within:` window,
|
|
11
|
+
or over each root's whole history when the window is omitted, selecting their
|
|
12
|
+
versions and preparing their history once for the batch. Query cost is flat in
|
|
13
|
+
the number of roots for the endpoint diff and checkpoint timeline; descendant
|
|
14
|
+
event discovery under `activity: true` remains per-root. A root with no
|
|
15
|
+
versions in range returns an empty `Analysis`, and roots are supplied as live
|
|
16
|
+
records so a root deleted inside the window cannot be included.
|
|
17
|
+
- Accept `:first` and `:last` as `compare_many` endpoints, resolved against the
|
|
18
|
+
record the pair's other endpoint names, in two queries per model class rather
|
|
19
|
+
than one lookup per root. A root with no recorded history compares as an empty
|
|
20
|
+
`Diff`, and an unanchored `{ from: :first, to: :last }` raises.
|
|
21
|
+
- Accept `reload_live_endpoints:` on `activity_timeline`, which reads live state
|
|
22
|
+
whenever `to:` is a current record but previously had no way to reuse an
|
|
23
|
+
already-preloaded graph. `timeline` and `analyze` are bounded by versions and
|
|
24
|
+
never read live state, so the option is deliberately absent there.
|
|
25
|
+
|
|
26
|
+
## [0.4.0] - 2026-08-11
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- Close an activity timeline that ends at the root's own `destroy` version with
|
|
31
|
+
a step into a new `kind: :destroyed` boundary, reporting the record's removal
|
|
32
|
+
as a `record_presence_change` to `nil`. `ActivityBoundary` gains a
|
|
33
|
+
`destroyed?` predicate, so consumers branching only on `version?` should
|
|
34
|
+
handle the third kind. `compare`, `timeline`, and `analyze`'s endpoint diff
|
|
35
|
+
are unchanged.
|
|
36
|
+
- Accept a `within:` window whose last selected mutation is the root's own
|
|
37
|
+
destruction, which no later root version can follow, instead of raising
|
|
38
|
+
`IncompleteTimeRangeError` for a range that could never be satisfied. A
|
|
39
|
+
destruction outside the window remains reconstruction context only.
|
|
40
|
+
- Accept `:first` and `:last` as `from:` and `to:` boundaries on `timeline`,
|
|
41
|
+
`activity_timeline`, and `analyze`, resolved without depending on the order
|
|
42
|
+
the `versions` association happens to use. A record with no versions resolves
|
|
43
|
+
to an empty timeline rather than raising, so listing pages need no special
|
|
44
|
+
case.
|
|
45
|
+
|
|
46
|
+
### Removed
|
|
47
|
+
|
|
48
|
+
- Reject two version endpoints given in reverse chronological order with the
|
|
49
|
+
new `PaperTrailDiff::ReversedEndpointsError`, in `compare` and
|
|
50
|
+
`compare_many`. A transposed pair silently produced the inverse diff, and a
|
|
51
|
+
result carries no direction that would reveal it. A current-record endpoint
|
|
52
|
+
may still appear on either side.
|
|
53
|
+
|
|
54
|
+
### Fixed
|
|
55
|
+
|
|
56
|
+
- Rebuild prepared scalar state with direct attribute writes so a model that
|
|
57
|
+
overrides an attribute writer reconstructs the state PaperTrail recorded
|
|
58
|
+
instead of reapplying the override.
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
|
|
62
|
+
- Order collection `added`, `removed`, and `changed` results naturally within
|
|
63
|
+
one id type instead of by the printed form, so numeric ids sort `2` before
|
|
64
|
+
`10`. Ordering remains deterministic and total for mixed id types.
|
|
65
|
+
- Resolve a boundary's prepared record state by chronological search and an
|
|
66
|
+
indexed boundary transaction instead of scanning a record's versions, so
|
|
67
|
+
timelines over long single-record histories stay linear in their step count.
|
|
68
|
+
- Bound prepared scalar history at the selected range plus one trailing version
|
|
69
|
+
per identity, instead of every version recorded between the range and the
|
|
70
|
+
present, so a short range early in a long history no longer pays for the
|
|
71
|
+
history after it.
|
|
72
|
+
- Document that `timeline` and `analyze(activity: true)` produce the same
|
|
73
|
+
root-checkpoint steps through different reconstruction strategies, and when
|
|
74
|
+
each one is cheaper.
|
|
75
|
+
- Borrow a connection through `with_connection` where Active Record provides
|
|
76
|
+
it, so applications that opt into deprecating permanent checkouts no longer
|
|
77
|
+
see a deprecation warning from association candidate selection.
|
|
78
|
+
- Identify carried-forward collection snapshots and per-pass reification by
|
|
79
|
+
an owned serial and by record identity, rather than by `object_id`, which
|
|
80
|
+
Ruby only guarantees to be unique among live objects.
|
|
81
|
+
- Retain activity-event routes for every event type a timeline visits, rather
|
|
82
|
+
than only the most recent one, so interleaved descendant types stop
|
|
83
|
+
rediscovering the same routes.
|
|
84
|
+
- Resolve excluded attributes once per model class and selected path, and key
|
|
85
|
+
historical child identities instead of rescanning them.
|
|
86
|
+
|
|
6
87
|
## [0.3.1] - 2026-08-10
|
|
7
88
|
|
|
8
89
|
### Fixed
|
data/README.md
CHANGED
|
@@ -44,12 +44,84 @@ PaperTrail.config.track_associations = true
|
|
|
44
44
|
All associated models whose historical state is compared must use
|
|
45
45
|
`has_paper_trail`.
|
|
46
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
|
+
|
|
47
120
|
## Compare two endpoints
|
|
48
121
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
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:
|
|
53
125
|
|
|
54
126
|
```ruby
|
|
55
127
|
diff = PaperTrailDiff.compare(article.versions[1], article.versions[4])
|
|
@@ -69,10 +141,21 @@ diff.to_h
|
|
|
69
141
|
|
|
70
142
|
Intermediate edits do not affect `compare`. If a title changes and later
|
|
71
143
|
returns to its original value, endpoint comparison reports no title change.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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).
|
|
76
159
|
|
|
77
160
|
Pass the record explicitly when the desired endpoint is current state:
|
|
78
161
|
|
|
@@ -84,10 +167,18 @@ diff = PaperTrailDiff.compare(
|
|
|
84
167
|
)
|
|
85
168
|
```
|
|
86
169
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
91
182
|
transaction with an appropriate isolation level when several live association
|
|
92
183
|
queries must represent one atomic application snapshot.
|
|
93
184
|
|
|
@@ -96,19 +187,47 @@ preload each explicitly selected live association path across the batch. Each
|
|
|
96
187
|
entry has the same endpoints and options as `compare`; results are returned in
|
|
97
188
|
input order as a frozen hash keyed by `[item_type, item_id]` strings:
|
|
98
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
|
+
|
|
99
206
|
```ruby
|
|
100
207
|
diffs = PaperTrailDiff.compare_many(
|
|
101
|
-
|
|
102
|
-
{ from: first_versions.fetch(order_a.id), to: order_a },
|
|
103
|
-
{ from: first_versions.fetch(order_b.id), to: order_b }
|
|
104
|
-
],
|
|
208
|
+
orders.map { |order| { from: first_versions.fetch(order.id), to: order } },
|
|
105
209
|
associations: [:line_items],
|
|
106
210
|
ignore: []
|
|
107
211
|
)
|
|
108
212
|
|
|
109
|
-
diffs.fetch(["Order",
|
|
213
|
+
diffs.fetch(["Order", orders.first.id.to_s]) # => PaperTrailDiff::Diff
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Endpoints may also be given as `:first` or `:last`, resolved against the record
|
|
217
|
+
the pair's other endpoint names. That replaces the lookup above entirely, and
|
|
218
|
+
resolves every root in two queries per model class rather than one per root:
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
diffs = PaperTrailDiff.compare_many(
|
|
222
|
+
orders.map { |order| { from: :first, to: order } },
|
|
223
|
+
associations: [:line_items]
|
|
224
|
+
)
|
|
110
225
|
```
|
|
111
226
|
|
|
227
|
+
A symbol carries no identity of its own, so `{ from: :first, to: :last }` raises
|
|
228
|
+
rather than guessing. A root with no recorded history resolves to an empty
|
|
229
|
+
`Diff`, matching how the timeline APIs answer the same question.
|
|
230
|
+
|
|
112
231
|
Root identities must be unique within one call. Historical reconstruction for
|
|
113
232
|
ordinary versioned, unscoped association paths is also prepared across the
|
|
114
233
|
collection. Paths that require the existing point-in-time PT-AT fallback retain
|
|
@@ -121,11 +240,14 @@ must observe one atomic snapshot.
|
|
|
121
240
|
|
|
122
241
|
### Reuse already-preloaded current endpoints
|
|
123
242
|
|
|
124
|
-
`compare` and `
|
|
125
|
-
already owns a consistent, fully
|
|
243
|
+
`compare`, `compare_many`, and `activity_timeline(..., to: record)` reload
|
|
244
|
+
current endpoints by default. A caller that already owns a consistent, fully
|
|
245
|
+
preloaded graph may opt out. The option has no effect on `timeline` or
|
|
246
|
+
`analyze`, which are bounded by versions and never read live state:
|
|
126
247
|
|
|
127
248
|
```ruby
|
|
128
|
-
orders = Order.where(id:
|
|
249
|
+
orders = Order.where(id: order_ids).preload(line_items: :product).to_a
|
|
250
|
+
# `first_versions` is the same bulk endpoint lookup shown above.
|
|
129
251
|
|
|
130
252
|
diffs = PaperTrailDiff.compare_many(
|
|
131
253
|
orders.map do |order|
|
|
@@ -180,10 +302,67 @@ ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do
|
|
|
180
302
|
end
|
|
181
303
|
```
|
|
182
304
|
|
|
305
|
+
## Analyze many records over one window
|
|
306
|
+
|
|
307
|
+
`analyze_many` answers "what changed for these records during this period" for a
|
|
308
|
+
whole collection, selecting every root's versions and preparing their selected
|
|
309
|
+
association history once for the batch:
|
|
310
|
+
|
|
311
|
+
```ruby
|
|
312
|
+
results = PaperTrailDiff.analyze_many(
|
|
313
|
+
Order.where(status: "open").to_a,
|
|
314
|
+
within: Time.zone.parse("2026-08-01")...Time.zone.parse("2026-09-01"),
|
|
315
|
+
associations: [:line_items]
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
results.fetch(["Order", order.id.to_s]).diff # net change across the window
|
|
319
|
+
results.fetch(["Order", order.id.to_s]).timeline # its checkpoint steps
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Results are a frozen hash keyed by `[item_type, item_id]` strings, and each
|
|
323
|
+
value is the same `Analysis` that `analyze` returns for one record. A root with
|
|
324
|
+
no versions inside the window gets an empty `Analysis` rather than raising, so a
|
|
325
|
+
listing page needs no special case. Root identities must be unique.
|
|
326
|
+
|
|
327
|
+
Omit `within:` to analyze each root's whole recorded history instead, which is
|
|
328
|
+
the batched equivalent of `analyze(record, from: :first, to: :last)`. Explicit
|
|
329
|
+
version endpoints are not accepted, because a single pair cannot mean the same
|
|
330
|
+
thing for every root.
|
|
331
|
+
|
|
332
|
+
Query cost is flat in the number of roots for the diff and timeline views:
|
|
333
|
+
selecting versions and preparing history are both shared across the batch. On a
|
|
334
|
+
twenty-root batch that is 7 queries against 120 for the same work done one
|
|
335
|
+
record at a time. Passing `activity: true` also works and returns the same
|
|
336
|
+
results, but discovering descendant events is inherently per-root, so that view
|
|
337
|
+
batches far less.
|
|
338
|
+
|
|
339
|
+
Roots are supplied as live records, so a root deleted inside the window cannot
|
|
340
|
+
be included; use `activity_timeline` for a history that ends in a deletion.
|
|
341
|
+
|
|
183
342
|
## Build a root-checkpoint timeline
|
|
184
343
|
|
|
185
|
-
`timeline` accepts two version objects from the supplied record's history
|
|
186
|
-
|
|
344
|
+
`timeline` accepts two version objects from the supplied record's history, or
|
|
345
|
+
the symbols `:first` and `:last` when the range is simply the record's whole
|
|
346
|
+
recorded history:
|
|
347
|
+
|
|
348
|
+
```ruby
|
|
349
|
+
steps = PaperTrailDiff.timeline(article, from: :first, to: :last)
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
`:first` and `:last` are resolved by the gem, independently of the order the
|
|
353
|
+
`versions` association happens to use, so a caller never has to know it is
|
|
354
|
+
sorted. They work anywhere a version does, including mixed with an explicit
|
|
355
|
+
one (`from: :first, to: some_version`) and on `activity_timeline` and
|
|
356
|
+
`analyze`. A record with no versions has no boundaries to resolve; that is an
|
|
357
|
+
empty history rather than a bad request, so the result is an empty timeline
|
|
358
|
+
instead of an error — which is usually what an index page wants.
|
|
359
|
+
|
|
360
|
+
Combine `from: :first` with `to: article` for the fullest activity view of a
|
|
361
|
+
live record: the whole recorded history, ending at current state. See
|
|
362
|
+
[activity timelines](#build-an-activity-timeline) for why that end differs from
|
|
363
|
+
`to: :last`.
|
|
364
|
+
|
|
365
|
+
The range is inclusive, must be chronological, and produces one `Step` for each
|
|
187
366
|
adjacent pair:
|
|
188
367
|
|
|
189
368
|
```ruby
|
|
@@ -205,7 +384,7 @@ steps.first.to_h
|
|
|
205
384
|
|
|
206
385
|
Both timeline types expose `from_boundary`, `to_boundary`, `diff`, and
|
|
207
386
|
`empty?`. A historical boundary has `event`, `whodunnit`, `record`,
|
|
208
|
-
`recorded_at`, `version?`, and `
|
|
387
|
+
`recorded_at`, `version?`, `current?`, and `destroyed?` readers. Checkpoint `Step` objects
|
|
209
388
|
also retain their original `from_version` and `to_version` for callers that
|
|
210
389
|
need custom PaperTrail metadata. Existing `Step#to_h` and
|
|
211
390
|
`ActivityBoundary#to_h` shapes remain unchanged; use the readers for the new
|
|
@@ -235,7 +414,9 @@ steps.reject(&:empty?)
|
|
|
235
414
|
```
|
|
236
415
|
|
|
237
416
|
Pass the record explicitly as `to:` to include current state without creating a
|
|
238
|
-
final root checkpoint:
|
|
417
|
+
final root checkpoint. This is not the same as `to: :last`: descendant
|
|
418
|
+
mutations recorded after the record's final root version fall outside a
|
|
419
|
+
version-bounded range, so only a live end reports them.
|
|
239
420
|
|
|
240
421
|
```ruby
|
|
241
422
|
steps = PaperTrailDiff.activity_timeline(
|
|
@@ -268,6 +449,46 @@ boundary. Passing `to: article` is what removes the need to touch the parent
|
|
|
268
449
|
after an ordinary versioned child mutation; current state is still never
|
|
269
450
|
implicit.
|
|
270
451
|
|
|
452
|
+
### Closing a destroyed root
|
|
453
|
+
|
|
454
|
+
A `destroy` version is the one boundary whose following state needs no later
|
|
455
|
+
version: the event itself says the record is gone. When an activity timeline
|
|
456
|
+
ends at the root's own `destroy` version, it therefore closes with one more
|
|
457
|
+
step, from that version to a `kind: :destroyed` boundary, whose diff is a
|
|
458
|
+
`record_presence_change` from the record's final state to `nil`:
|
|
459
|
+
|
|
460
|
+
```ruby
|
|
461
|
+
steps = PaperTrailDiff.activity_timeline(
|
|
462
|
+
article,
|
|
463
|
+
from: article.versions.first,
|
|
464
|
+
to: article.versions.last # a destroy version
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
removal = steps.last
|
|
468
|
+
removal.to_boundary.destroyed? # => true
|
|
469
|
+
removal.to_boundary.kind # => :destroyed
|
|
470
|
+
removal.diff.record_presence_change.from # the state it was deleted in
|
|
471
|
+
removal.diff.record_presence_change.to # => nil
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
The removal step's `from_boundary` is the ordinary `kind: :version` boundary
|
|
475
|
+
for the same destroy version, since that boundary still holds the record.
|
|
476
|
+
Boundaries therefore have three kinds — `:version`, `:current`, and
|
|
477
|
+
`:destroyed` — so a consumer that branches on `version?` alone should also
|
|
478
|
+
handle `destroyed?`.
|
|
479
|
+
|
|
480
|
+
`analyze(activity: true)` reports the same closing step in its
|
|
481
|
+
`activity_timeline`. Its `diff` and `timeline` keep their `compare` and
|
|
482
|
+
`timeline` semantics and do not report the deletion.
|
|
483
|
+
|
|
484
|
+
A `within:` window behaves the same way. A window whose last selected mutation
|
|
485
|
+
is the root's destruction needs no later root version, because none can ever
|
|
486
|
+
exist, so it closes on the removal instead of raising
|
|
487
|
+
`IncompleteTimeRangeError`. When the destruction falls *outside* the window it
|
|
488
|
+
remains ordinary reconstruction context and is not reported as a selected
|
|
489
|
+
mutation. The same relaxation lets the plain `timeline` accept such a window,
|
|
490
|
+
though it still reports only the edits.
|
|
491
|
+
|
|
271
492
|
Live-ended HABTM activity is rejected with
|
|
272
493
|
`PaperTrailDiff::UnsupportedLiveActivityError`. HABTM join rows have no model
|
|
273
494
|
versions, so the gem cannot reliably split their membership mutations into
|
|
@@ -315,7 +536,9 @@ If the window contains a relevant mutation but no later root version exists,
|
|
|
315
536
|
the call raises `PaperTrailDiff::IncompleteTimeRangeError`. Create a root
|
|
316
537
|
checkpoint after the reporting window before running historical analysis. The
|
|
317
538
|
gem does not silently substitute current database state. A root-only window
|
|
318
|
-
with no selected mutation returns a frozen empty timeline.
|
|
539
|
+
with no selected mutation returns a frozen empty timeline. The one exception is
|
|
540
|
+
a window that closes on the root's own destruction, which no later version can
|
|
541
|
+
ever follow; see [closing a destroyed root](#closing-a-destroyed-root).
|
|
319
542
|
|
|
320
543
|
Time ranges and explicit `from:`/`to:` endpoints are mutually exclusive.
|
|
321
544
|
Malformed, open-ended, or reversed ranges raise
|
|
@@ -352,6 +575,37 @@ work is performed. `analyze` accepts explicit historical versions or
|
|
|
352
575
|
`within:`, but not a current-record endpoint; use the standalone
|
|
353
576
|
`activity_timeline(..., to: article)` API when the final boundary must be live.
|
|
354
577
|
|
|
578
|
+
### Choosing between `timeline` and `analyze(activity: true)`
|
|
579
|
+
|
|
580
|
+
`analysis.timeline` and `timeline` return the same root-checkpoint steps for the
|
|
581
|
+
same range. They are two reconstruction strategies for one result, not two
|
|
582
|
+
levels of detail: a `timeline` step covers only root version boundaries, but
|
|
583
|
+
each of its snapshots still contains every selected association, because a step
|
|
584
|
+
must report what changed underneath the root as well.
|
|
585
|
+
|
|
586
|
+
The one behavioural difference is at the edge of a `within:` window. Selected
|
|
587
|
+
descendants can move inside a window that contains no root version at all, so
|
|
588
|
+
the activity form requires a root boundary it can reconstruct from and raises
|
|
589
|
+
`IncompleteTimeRangeError` when there is none. `timeline` has no activity view
|
|
590
|
+
to anchor and returns no steps for that window.
|
|
591
|
+
|
|
592
|
+
The strategies differ in what that costs:
|
|
593
|
+
|
|
594
|
+
- `timeline` reconstructs the whole selected graph independently at every root
|
|
595
|
+
boundary, so it costs roughly *root versions x selected graph size*. It is
|
|
596
|
+
insensitive to how much descendant activity happened in between.
|
|
597
|
+
- `analyze(activity: true)` reconstructs once and then advances that snapshot
|
|
598
|
+
through each recorded mutation, so it costs roughly *one reconstruction +
|
|
599
|
+
total events*. It is insensitive to how wide the selected graph is.
|
|
600
|
+
|
|
601
|
+
Neither dominates. Reconstructing once per checkpoint wins when a few root
|
|
602
|
+
versions span very heavy descendant churn; advancing incrementally wins when
|
|
603
|
+
the selected graph is wide and descendant activity is comparable to root
|
|
604
|
+
activity. As a rule of thumb, prefer `analyze(activity: true)` when selected
|
|
605
|
+
associations are wide, and `timeline` when descendant events greatly outnumber
|
|
606
|
+
root versions. Measure with `ActiveSupport::Notifications` on a representative
|
|
607
|
+
history rather than a seeded example if the choice matters.
|
|
608
|
+
|
|
355
609
|
`timeline`, `activity_timeline`, and both forms of `analyze` prepare the selected
|
|
356
610
|
historical range once. The loader walks only the explicit association paths and
|
|
357
611
|
builds an immutable temporal index of scalar states, relationship candidates,
|
|
@@ -387,12 +641,13 @@ retain the general comparator and traversal fallback.
|
|
|
387
641
|
Activity event loading is bounded to the selected range. Association identity
|
|
388
642
|
discovery retains one indexed checkpoint for members present at the starting
|
|
389
643
|
boundary, then considers later association activity and current members; it no
|
|
390
|
-
longer materializes every pre-range association row.
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
644
|
+
longer materializes every pre-range association row. Because a PaperTrail
|
|
645
|
+
version is a pre-change snapshot, the state at a range's final boundary can
|
|
646
|
+
live only in the next version after it, so prepared scalar state retains one
|
|
647
|
+
trailing version per selected identity. It does not retain the rest of the
|
|
648
|
+
history recorded after the range, so a short range early in a long history
|
|
649
|
+
costs the same as the same range in a short one. Keep requested paths and
|
|
650
|
+
ranges intentional. An activity timeline must also emit a diff for every selected
|
|
396
651
|
event. Repeated events within one wide collection still copy the frozen records
|
|
397
652
|
array when producing each immutable snapshot, so they can do pointer-copying
|
|
398
653
|
work proportional to the number of events times the collection width even when
|
|
@@ -626,6 +881,29 @@ when join attributes or join mutations must appear as first-class history.
|
|
|
626
881
|
|
|
627
882
|
## Result objects
|
|
628
883
|
|
|
884
|
+
Every level of a result separates the same two kinds of change in the same way:
|
|
885
|
+
|
|
886
|
+
| | a record appears or disappears | a record stays and its fields change |
|
|
887
|
+
| --- | --- | --- |
|
|
888
|
+
| the root record | `record_presence_change` | `attributes`, `associations` |
|
|
889
|
+
| `has_many`, HABTM | `added`, `removed` | `changed` |
|
|
890
|
+
| `belongs_to`, `has_one` | `relationship` | `changed` |
|
|
891
|
+
|
|
892
|
+
The left column carries whole `RecordSnapshot` values; the right column carries
|
|
893
|
+
field-level `ValueChange` deltas. That split is deliberate. A record that has
|
|
894
|
+
just appeared has no previous value for any of its fields, so reporting one
|
|
895
|
+
`nil` to value change per attribute would both add noise and blur the
|
|
896
|
+
difference between "this field was edited" and "this record did not exist".
|
|
897
|
+
|
|
898
|
+
The consequence is that reading a result tree directly means branching on which
|
|
899
|
+
column applies: a created record's state is under
|
|
900
|
+
`record_presence_change.to.attributes`, an edited record's is under
|
|
901
|
+
`attributes`. Consumers that would rather not branch should use
|
|
902
|
+
[`each_entry`](#traverse-result-trees), which flattens both into one stream —
|
|
903
|
+
an edit arrives as `attribute_changed` carrying a `ValueChange`, and a created
|
|
904
|
+
record's fields arrive as `attribute_included` entries carrying values with
|
|
905
|
+
`state: :after`.
|
|
906
|
+
|
|
629
907
|
The public result types are:
|
|
630
908
|
|
|
631
909
|
- `PaperTrailDiff::Diff`
|
|
@@ -645,7 +923,9 @@ The public result types are:
|
|
|
645
923
|
- `PaperTrailDiff::DiagnosticIssue`
|
|
646
924
|
|
|
647
925
|
They expose readers, are frozen after construction, and provide deterministic
|
|
648
|
-
`to_h` output.
|
|
926
|
+
`to_h` output. Collection results are ordered by record identity: by type, then
|
|
927
|
+
naturally within one id type, so numeric ids sort `2` before `10`. Mixed or
|
|
928
|
+
unusual id types still order totally rather than raising. Structural hash keys are symbols; attribute and association
|
|
649
929
|
names are strings. Attribute values retain their Ruby types. `RecordChange#record`
|
|
650
930
|
is a `RecordReference` with `type` and `id` readers. `TraversalEntry#record` and
|
|
651
931
|
`#association` return the final components of their corresponding paths. `Step`
|
|
@@ -671,6 +951,10 @@ and PT-AT can reconstruct. In particular:
|
|
|
671
951
|
- HABTM membership is limited to the join snapshots PT-AT recorded in
|
|
672
952
|
`version_associations`; historical target attributes require versioned target
|
|
673
953
|
models, otherwise PT-AT may return live target state;
|
|
954
|
+
- `compare`, `timeline`, and `analyze`'s endpoint diff do not report the root
|
|
955
|
+
record's destruction, because the state recorded at a `destroy` version is the
|
|
956
|
+
state before the deletion; `activity_timeline` closes on a `:destroyed`
|
|
957
|
+
boundary instead, and a selected child's removal is reported by its parent;
|
|
674
958
|
- `timeline` and `analyze` are bounded by root versions; `activity_timeline`
|
|
675
959
|
adds recorded descendant boundaries and may terminate at an explicitly passed
|
|
676
960
|
current record, while a fully historical result still requires a later root
|
|
@@ -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
|
{
|