paper_trail_diff 0.2.0 → 0.3.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 +44 -2
- data/README.md +179 -14
- data/lib/paper_trail_diff/activity_child_candidate_loader.rb +73 -0
- data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +261 -45
- data/lib/paper_trail_diff/activity_history.rb +136 -0
- data/lib/paper_trail_diff/activity_range.rb +28 -12
- data/lib/paper_trail_diff/activity_snapshot_delta.rb +64 -0
- data/lib/paper_trail_diff/activity_snapshot_sequence.rb +3 -3
- data/lib/paper_trail_diff/activity_timeline_builder.rb +54 -45
- data/lib/paper_trail_diff/activity_version_collector.rb +14 -17
- data/lib/paper_trail_diff/branch_snapshot_refresher.rb +5 -3
- data/lib/paper_trail_diff/collection_comparator.rb +155 -0
- data/lib/paper_trail_diff/collection_identity_index.rb +53 -0
- data/lib/paper_trail_diff/collection_transition.rb +25 -0
- data/lib/paper_trail_diff/comparison_batch.rb +151 -0
- data/lib/paper_trail_diff/engine.rb +5 -45
- data/lib/paper_trail_diff/errors.rb +9 -0
- data/lib/paper_trail_diff/historical_snapshot_store.rb +55 -3
- data/lib/paper_trail_diff/instrumentation.rb +30 -0
- data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +133 -0
- data/lib/paper_trail_diff/live_endpoint_provider.rb +48 -0
- data/lib/paper_trail_diff/live_graph_collector.rb +52 -0
- data/lib/paper_trail_diff/paper_trail_adapter.rb +61 -34
- data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +48 -0
- data/lib/paper_trail_diff/prepared_edge_loader.rb +54 -17
- data/lib/paper_trail_diff/prepared_history.rb +14 -4
- data/lib/paper_trail_diff/prepared_history_loader.rb +17 -9
- data/lib/paper_trail_diff/prepared_record_index.rb +138 -10
- data/lib/paper_trail_diff/snapshot.rb +66 -3
- data/lib/paper_trail_diff/support.rb +18 -0
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +111 -0
- data/lib/paper_trail_diff/time_range.rb +85 -0
- data/lib/paper_trail_diff/time_version_range.rb +53 -0
- data/lib/paper_trail_diff/timeline_builder.rb +13 -7
- data/lib/paper_trail_diff/timeline_range.rb +72 -0
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_association_candidate_scope.rb +73 -0
- data/lib/paper_trail_diff.rb +74 -12
- data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +40 -0
- data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +49 -8
- data/sig/generated/paper_trail_diff/activity_history.rbs +79 -0
- data/sig/generated/paper_trail_diff/activity_range.rbs +14 -5
- data/sig/generated/paper_trail_diff/activity_snapshot_delta.rbs +33 -0
- data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +2 -2
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +15 -10
- data/sig/generated/paper_trail_diff/activity_version_collector.rbs +2 -5
- data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
- data/sig/generated/paper_trail_diff/collection_comparator.rbs +59 -0
- data/sig/generated/paper_trail_diff/collection_identity_index.rbs +29 -0
- data/sig/generated/paper_trail_diff/collection_transition.rbs +18 -0
- data/sig/generated/paper_trail_diff/comparison_batch.rbs +65 -0
- data/sig/generated/paper_trail_diff/engine.rbs +0 -12
- data/sig/generated/paper_trail_diff/errors.rbs +12 -0
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +22 -2
- data/sig/generated/paper_trail_diff/instrumentation.rbs +12 -0
- data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +52 -0
- data/sig/generated/paper_trail_diff/live_endpoint_provider.rbs +23 -0
- data/sig/generated/paper_trail_diff/live_graph_collector.rbs +27 -0
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +21 -10
- data/sig/generated/paper_trail_diff/preloaded_endpoint_batch_loader.rbs +24 -0
- data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +22 -8
- data/sig/generated/paper_trail_diff/prepared_history.rbs +6 -0
- data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +4 -2
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +61 -6
- data/sig/generated/paper_trail_diff/snapshot.rbs +37 -2
- data/sig/generated/paper_trail_diff/support.rbs +6 -0
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +46 -0
- data/sig/generated/paper_trail_diff/time_range.rbs +41 -0
- data/sig/generated/paper_trail_diff/time_version_range.rbs +27 -0
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +6 -5
- data/sig/generated/paper_trail_diff/timeline_range.rbs +41 -0
- data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +32 -0
- data/sig/generated/paper_trail_diff.rbs +16 -8
- metadata +38 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4d60569584c3d30bd35c62c1f0f699fd8859545d81f873c926dfdd8268aed3df
|
|
4
|
+
data.tar.gz: 4961a77099927fff3476641b0409cb234e1f398e9b84e9cb3cc9e11b2d580064
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2cf13bffaca3d199b2332b0e3d1c7c7c00ef7c6f82a4c9e5a75c51f376889ff57c60e1bfab1f578a7122e972698318d2720bce5f8da2755abea50aebf6e7083e
|
|
7
|
+
data.tar.gz: a071cd71f7fb766105eda1e018991ed8030885822da23382404797984b265818c0012378aea5912a507fb7bc24e9294331201b0cf1918860df3d9613476d580d
|
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.3.0] - 2026-08-10
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- Add `within:` time-range selection to checkpoint timelines, activity
|
|
11
|
+
timelines, and combined analysis, with inclusive/exclusive end handling and
|
|
12
|
+
explicit errors when a final mutation cannot be reconstructed.
|
|
13
|
+
- Add `compare_many` for collection reports, with batched current-root loading,
|
|
14
|
+
bounded live-association preloading, cross-root prepared history, immutable
|
|
15
|
+
identity-keyed results, and scale-invariant query regression coverage.
|
|
16
|
+
- Add opt-in reuse of fully preloaded current endpoints and namespaced
|
|
17
|
+
ActiveSupport runtime instrumentation without automatic logging, including
|
|
18
|
+
end-to-end activity-timeline duration and step counts.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- Teach timeline filtering through the shared `Step#empty?` protocol in the
|
|
23
|
+
README and quickstart examples.
|
|
24
|
+
- Reuse the live graph loaded by `compare_many` while preparing historical
|
|
25
|
+
association state, and defer root version loading until an edge needs it.
|
|
26
|
+
- Reuse collection identity positions and adjacent transition hints so activity
|
|
27
|
+
steps compare only the changed member, and resolve direct nested collection
|
|
28
|
+
owners by foreign key instead of scanning every parent snapshot.
|
|
29
|
+
- Reuse prepared predecessor and successor scalar states for isolated activity
|
|
30
|
+
updates instead of reifying and deserializing each PaperTrail event again.
|
|
31
|
+
- Preserve both sides of nested collection membership moves by using the
|
|
32
|
+
general comparator when one event changes multiple parent snapshots.
|
|
33
|
+
- Preserve per-owner `limit`, `offset`, and owner-dependent association scopes
|
|
34
|
+
in `compare_many` while retaining batched preloading for safe branches.
|
|
35
|
+
- Replace lifetime association-identity materialization with an indexed start-state
|
|
36
|
+
checkpoint plus post-boundary activity and current members.
|
|
37
|
+
- Bound historical activity-child candidates at the selected end so membership
|
|
38
|
+
changes after the range cannot introduce unrelated empty timeline steps.
|
|
39
|
+
- Decode compatible prepared scalar version payloads without constructing an
|
|
40
|
+
intermediate Active Record object, with reification fallback for unsafe schemas.
|
|
41
|
+
- Reuse internally owned frozen collection arrays instead of defensively copying
|
|
42
|
+
them a second time during immutable activity reconstruction.
|
|
43
|
+
- Replace pre-existing versions-relation ordering when selecting a time range's
|
|
44
|
+
immediate trailing reconstruction boundary.
|
|
45
|
+
- Limit automatic CI to pull requests and `main` pushes, and cancel superseded
|
|
46
|
+
runs for the same pull request or ref.
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- Supply TZInfo's timezone database to the test bundle so zoned-time coverage
|
|
51
|
+
runs on Windows as well as systems with a native zoneinfo database.
|
|
52
|
+
|
|
6
53
|
## [0.2.0] - 2026-08-09
|
|
7
54
|
|
|
8
55
|
### Added
|
data/QUICKSTART.md
CHANGED
|
@@ -118,17 +118,42 @@ steps.map do |step|
|
|
|
118
118
|
end
|
|
119
119
|
# => [["Draft", "Published"], ["Published", "Final"]]
|
|
120
120
|
|
|
121
|
-
visible_steps = steps.reject
|
|
121
|
+
visible_steps = steps.reject(&:empty?)
|
|
122
122
|
```
|
|
123
123
|
|
|
124
124
|
Empty steps remain in the timeline. Filter them only when the application's
|
|
125
125
|
display does not need every version boundary.
|
|
126
126
|
|
|
127
|
+
### Select mutations by time
|
|
128
|
+
|
|
129
|
+
Use `within:` when the application has a reporting window rather than saved
|
|
130
|
+
endpoint versions:
|
|
131
|
+
|
|
132
|
+
<!-- executable:quickstart-time-range -->
|
|
133
|
+
```ruby
|
|
134
|
+
window = draft_version.created_at...final_version.created_at
|
|
135
|
+
ranged_steps = PaperTrailDiff.timeline(article, within: window)
|
|
136
|
+
|
|
137
|
+
ranged_steps.map do |step|
|
|
138
|
+
change = step.diff.attributes["title"]
|
|
139
|
+
[change.from, change.to]
|
|
140
|
+
end
|
|
141
|
+
# => [["Draft", "Published"], ["Published", "Final"]]
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The half-open range selects the mutations recorded by `draft_version` and
|
|
145
|
+
`published_version`. The excluded `final_version` is still used as the trailing
|
|
146
|
+
boundary needed to reveal the last selected mutation. PaperTrail versions are
|
|
147
|
+
pre-change snapshots, so a range containing mutations must have a later root
|
|
148
|
+
version. The gem raises `PaperTrailDiff::IncompleteTimeRangeError` instead of
|
|
149
|
+
assuming that the current record is the missing endpoint.
|
|
150
|
+
|
|
127
151
|
## 6. Choose the right API
|
|
128
152
|
|
|
129
153
|
| Need | Call |
|
|
130
154
|
| --- | --- |
|
|
131
155
|
| Net difference between two endpoints | `compare` |
|
|
156
|
+
| Net differences for many current records | `compare_many` |
|
|
132
157
|
| One step per root-record version | `timeline` |
|
|
133
158
|
| Steps for root and selected child versions | `activity_timeline` |
|
|
134
159
|
| Net diff and timelines from one history pass | `analyze` |
|
|
@@ -228,7 +253,7 @@ steps = PaperTrailDiff.activity_timeline(
|
|
|
228
253
|
associations: ["comments"]
|
|
229
254
|
)
|
|
230
255
|
|
|
231
|
-
steps.reject
|
|
256
|
+
steps.reject(&:empty?).each do |step|
|
|
232
257
|
boundary = step.to_boundary
|
|
233
258
|
puts "#{boundary.item_type} ##{boundary.item_id}"
|
|
234
259
|
end
|
|
@@ -237,10 +262,27 @@ end
|
|
|
237
262
|
Use a later transaction-backed root checkpoint instead of `to: current` when
|
|
238
263
|
the result must remain reproducible after the database changes again.
|
|
239
264
|
|
|
265
|
+
For a historical reporting window, replace `from:` and `to:` with `within:`:
|
|
266
|
+
|
|
267
|
+
```ruby
|
|
268
|
+
steps = PaperTrailDiff.activity_timeline(
|
|
269
|
+
article,
|
|
270
|
+
within: report_start...report_end,
|
|
271
|
+
associations: ["comments"]
|
|
272
|
+
)
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
This selects versioned comment activity directly, even if the article had no
|
|
276
|
+
mutation during the window. Take a root checkpoint after `report_end`; it gives
|
|
277
|
+
the gem the historical context needed to expose the final child mutation.
|
|
278
|
+
|
|
240
279
|
## 9. Common surprises
|
|
241
280
|
|
|
242
281
|
- PaperTrail stores pre-change snapshots. The current record is not represented
|
|
243
282
|
by `versions.last`; pass the record explicitly when current state is wanted.
|
|
283
|
+
- A `within:` window containing mutations needs a later root checkpoint. The
|
|
284
|
+
trailing version is reconstruction context and may appear as a step's ending
|
|
285
|
+
boundary even though its own mutation is outside the window.
|
|
244
286
|
- `ignore:` replaces the default list. Include `updated_at` yourself when using
|
|
245
287
|
a custom list and you still want it ignored.
|
|
246
288
|
- Historical associations require PT-AT to be installed, loaded, migrated, and
|
data/README.md
CHANGED
|
@@ -82,6 +82,95 @@ association caches and in-memory edits are not compared. Use a database
|
|
|
82
82
|
transaction with an appropriate isolation level when several live association
|
|
83
83
|
queries must represent one atomic application snapshot.
|
|
84
84
|
|
|
85
|
+
For collection-level reports, use `compare_many` to reload current roots and
|
|
86
|
+
preload each explicitly selected live association path across the batch. Each
|
|
87
|
+
entry has the same endpoints and options as `compare`; results are returned in
|
|
88
|
+
input order as a frozen hash keyed by `[item_type, item_id]` strings:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
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
|
+
],
|
|
96
|
+
associations: [:line_items],
|
|
97
|
+
ignore: []
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
diffs.fetch(["Order", order_a.id.to_s]) # => PaperTrailDiff::Diff
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Root identities must be unique within one call. Historical reconstruction for
|
|
104
|
+
ordinary versioned, unscoped association paths is also prepared across the
|
|
105
|
+
collection. Paths that require the existing point-in-time PT-AT fallback retain
|
|
106
|
+
their per-endpoint behavior and all historical reconstruction retains the same
|
|
107
|
+
PaperTrail Association Tracking requirements as `compare`. Live collection
|
|
108
|
+
scopes with per-owner semantics, such as `limit`, `offset`, or an owner
|
|
109
|
+
argument, are loaded per root; other selected branches remain batched. Callers
|
|
110
|
+
should still use an appropriate database transaction when all live queries
|
|
111
|
+
must observe one atomic snapshot.
|
|
112
|
+
|
|
113
|
+
### Reuse already-preloaded current endpoints
|
|
114
|
+
|
|
115
|
+
`compare` and `compare_many` reload current endpoints by default. A caller that
|
|
116
|
+
already owns a consistent, fully preloaded graph may opt out:
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
orders = Order.where(id: ids).preload(line_items: :product).to_a
|
|
120
|
+
|
|
121
|
+
diffs = PaperTrailDiff.compare_many(
|
|
122
|
+
orders.map do |order|
|
|
123
|
+
{ from: first_versions.fetch(order.id), to: order }
|
|
124
|
+
end,
|
|
125
|
+
associations: ["line_items.product"],
|
|
126
|
+
reload_live_endpoints: false
|
|
127
|
+
)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
With `reload_live_endpoints: false`, scalar values and association targets come
|
|
131
|
+
from the supplied instances. Every requested association path must already be
|
|
132
|
+
loaded; otherwise `PaperTrailDiff::UnloadedAssociationError` is raised before
|
|
133
|
+
normalization instead of silently issuing an N+1 query. The caller is
|
|
134
|
+
responsible for the consistency and freshness of that in-memory graph.
|
|
135
|
+
|
|
136
|
+
### Runtime performance diagnostics
|
|
137
|
+
|
|
138
|
+
The gem never prints or logs automatically. It emits these
|
|
139
|
+
`ActiveSupport::Notifications` events so applications can choose their logger
|
|
140
|
+
and formatting:
|
|
141
|
+
|
|
142
|
+
- `compare.paper_trail_diff`
|
|
143
|
+
- `compare_many.paper_trail_diff`
|
|
144
|
+
- `activity_timeline.paper_trail_diff`
|
|
145
|
+
- `load_live_endpoints.paper_trail_diff`
|
|
146
|
+
- `prepare_history.paper_trail_diff`
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
ActiveSupport::Notifications.subscribe(/\.paper_trail_diff\z/) do |name, start, finish, _id, payload|
|
|
150
|
+
Rails.logger.debug(
|
|
151
|
+
event: name,
|
|
152
|
+
duration_ms: ((finish - start) * 1_000).round(1),
|
|
153
|
+
**payload
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The payloads contain counts, model names, association paths, and reload mode,
|
|
159
|
+
not endpoint objects or record attributes. The activity-timeline event wraps
|
|
160
|
+
the complete call and includes `step_count`, so its notification duration is
|
|
161
|
+
the user-visible runtime. To inspect the exact SQL generated by one report,
|
|
162
|
+
scope a standard `sql.active_record` subscriber around it:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
callback = proc do |_name, _start, _finish, _id, payload|
|
|
166
|
+
Rails.logger.debug(payload[:sql]) unless payload[:cached] || payload[:name] == "SCHEMA"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do
|
|
170
|
+
PaperTrailDiff.compare_many(comparisons, associations: [:line_items])
|
|
171
|
+
end
|
|
172
|
+
```
|
|
173
|
+
|
|
85
174
|
## Build a root-checkpoint timeline
|
|
86
175
|
|
|
87
176
|
`timeline` accepts two version objects from the supplied record's history. The
|
|
@@ -133,7 +222,7 @@ steps = PaperTrailDiff.activity_timeline(
|
|
|
133
222
|
associations: ["comments.replies", :author]
|
|
134
223
|
)
|
|
135
224
|
|
|
136
|
-
steps.reject
|
|
225
|
+
steps.reject(&:empty?)
|
|
137
226
|
```
|
|
138
227
|
|
|
139
228
|
Pass the record explicitly as `to:` to include current state without creating a
|
|
@@ -177,9 +266,55 @@ intermediate events without transaction-backed owner checkpoints. Historical
|
|
|
177
266
|
HABTM activity and `compare(version, article, associations: [:tags])` remain
|
|
178
267
|
supported where the historical endpoint has usable PT-AT metadata.
|
|
179
268
|
|
|
180
|
-
Both timeline APIs preserve empty boundaries. A display may
|
|
181
|
-
`
|
|
182
|
-
boundary.
|
|
269
|
+
Both timeline APIs preserve empty boundaries. A display may use
|
|
270
|
+
`steps.reject(&:empty?)`, while audit-oriented callers can retain every
|
|
271
|
+
recorded boundary.
|
|
272
|
+
|
|
273
|
+
## Select mutations by time
|
|
274
|
+
|
|
275
|
+
`timeline`, `activity_timeline`, and `analyze` also accept a finite time range
|
|
276
|
+
through `within:` instead of explicit `from:` and `to:` versions. Half-open
|
|
277
|
+
ranges are recommended for adjacent reporting windows:
|
|
278
|
+
|
|
279
|
+
```ruby
|
|
280
|
+
window = Time.zone.parse("2026-08-01")...Time.zone.parse("2026-09-01")
|
|
281
|
+
|
|
282
|
+
steps = PaperTrailDiff.timeline(article, within: window)
|
|
283
|
+
|
|
284
|
+
activity_steps = PaperTrailDiff.activity_timeline(
|
|
285
|
+
article,
|
|
286
|
+
within: window,
|
|
287
|
+
associations: ["comments.replies", :author]
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
analysis = PaperTrailDiff.analyze(
|
|
291
|
+
article,
|
|
292
|
+
within: window,
|
|
293
|
+
associations: ["comments.replies"],
|
|
294
|
+
activity: true
|
|
295
|
+
)
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
The range selects mutations by the timestamp of their source PaperTrail
|
|
299
|
+
version. Ruby's inclusive (`..`) and exclusive (`...`) end semantics are
|
|
300
|
+
honored. A returned step's `to_boundary` may be after the range: because a
|
|
301
|
+
PaperTrail version is a pre-change snapshot, the gem needs one later root
|
|
302
|
+
version to reveal the final selected mutation. That version is reconstruction
|
|
303
|
+
context, not an additional selected mutation.
|
|
304
|
+
|
|
305
|
+
If the window contains a relevant mutation but no later root version exists,
|
|
306
|
+
the call raises `PaperTrailDiff::IncompleteTimeRangeError`. Create a root
|
|
307
|
+
checkpoint after the reporting window before running historical analysis. The
|
|
308
|
+
gem does not silently substitute current database state. A root-only window
|
|
309
|
+
with no selected mutation returns a frozen empty timeline.
|
|
310
|
+
|
|
311
|
+
Time ranges and explicit `from:`/`to:` endpoints are mutually exclusive.
|
|
312
|
+
Malformed, open-ended, or reversed ranges raise
|
|
313
|
+
`PaperTrailDiff::InvalidTimeRangeError`. With `activity_timeline`, selected
|
|
314
|
+
descendant versions are included even when no root mutation occurred inside
|
|
315
|
+
the window; the later root checkpoint is still required so PT-AT can
|
|
316
|
+
reconstruct the enclosing graph and determine whether descendant activity is
|
|
317
|
+
present. The ordinary `timeline` remains a root-only checkpoint timeline.
|
|
183
318
|
|
|
184
319
|
## Build an endpoint and timeline together
|
|
185
320
|
|
|
@@ -204,8 +339,9 @@ analysis.activity_timeline
|
|
|
204
339
|
```
|
|
205
340
|
|
|
206
341
|
Without `activity: true`, `analysis.activity_timeline` is `nil` and no activity
|
|
207
|
-
work is performed. `analyze`
|
|
208
|
-
`
|
|
342
|
+
work is performed. `analyze` accepts explicit historical versions or
|
|
343
|
+
`within:`, but not a current-record endpoint; use the standalone
|
|
344
|
+
`activity_timeline(..., to: article)` API when the final boundary must be live.
|
|
209
345
|
|
|
210
346
|
`timeline`, `activity_timeline`, and both forms of `analyze` prepare the selected
|
|
211
347
|
historical range once. The loader walks only the explicit association paths and
|
|
@@ -224,20 +360,49 @@ and other unsupported shapes fall back to the ordinary PT-AT point reifier on
|
|
|
224
360
|
a per-reflection basis. This hybrid path preserves existing reconstruction
|
|
225
361
|
behavior while avoiding repeated association queries for supported paths.
|
|
226
362
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
without
|
|
363
|
+
For supported collection events, adjacent snapshots share an immutable
|
|
364
|
+
identity-position index and carry a one-step transition hint. Diffing therefore
|
|
365
|
+
visits only the changed member instead of hashing the whole collection again.
|
|
366
|
+
Prepared scalar history also exposes the predecessor and successor attributes
|
|
367
|
+
for isolated update events, allowing activity reconstruction to update the
|
|
368
|
+
immutable snapshot directly instead of reifying and deserializing the same
|
|
369
|
+
PaperTrail event again. Compatible scalar payloads are decoded into prepared
|
|
370
|
+
state without first constructing a disposable Active Record object. Encrypted,
|
|
371
|
+
schema-mismatched, missing, ambiguous, or identity-changing states fall back to
|
|
372
|
+
the ordinary event reifier.
|
|
373
|
+
For a direct nested `has_many` such as `comments.replies`, the child's foreign
|
|
374
|
+
key also locates its parent snapshot directly rather than walking every
|
|
375
|
+
comment. Membership changes and ambiguous or unsupported relationship shapes
|
|
376
|
+
retain the general comparator and traversal fallback.
|
|
377
|
+
|
|
378
|
+
Activity event loading is bounded to the selected range. Association identity
|
|
379
|
+
discovery retains one indexed checkpoint for members present at the starting
|
|
380
|
+
boundary, then considers later association activity and current members; it no
|
|
381
|
+
longer materializes every pre-range association row. Prepared scalar state also
|
|
382
|
+
retains later successor versions for selected identities because a PaperTrail
|
|
383
|
+
version is a pre-change snapshot and may be the only correct state for an
|
|
384
|
+
earlier boundary. Memory therefore scales with relevant selected history, not
|
|
385
|
+
only with the number of returned steps. Keep requested paths and ranges
|
|
386
|
+
intentional. An activity timeline must also emit a diff for every selected
|
|
387
|
+
event. Repeated events within one wide collection still copy the frozen records
|
|
388
|
+
array when producing each immutable snapshot, so they can do pointer-copying
|
|
389
|
+
work proportional to the number of events times the collection width even when
|
|
390
|
+
SQL and Ruby-level comparison work stay linear. Bound or paginate unusually
|
|
391
|
+
wide activity ranges in latency-sensitive requests.
|
|
392
|
+
|
|
393
|
+
For large histories, applications should give the database a matching
|
|
394
|
+
composite index. A typical PaperTrail installation can add one without making
|
|
395
|
+
it a requirement of this gem:
|
|
235
396
|
|
|
236
397
|
```ruby
|
|
237
398
|
add_index :versions, %i[item_type item_id created_at id],
|
|
238
399
|
name: "index_versions_on_item_and_timeline"
|
|
239
400
|
```
|
|
240
401
|
|
|
402
|
+
This index is especially useful for repeated `within:` queries because root
|
|
403
|
+
selection is constrained by `item_type`, `item_id`, and `created_at`, then
|
|
404
|
+
ordered deterministically by timestamp and version ID.
|
|
405
|
+
|
|
241
406
|
PT-AT's normal index beginning with `foreign_key_name`, `foreign_key_id`, and
|
|
242
407
|
`foreign_type` should also be retained on `version_associations`.
|
|
243
408
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Loads direct-child identities active at or after one activity boundary.
|
|
6
|
+
class ActivityChildCandidateLoader
|
|
7
|
+
#: (untyped, Array[untyped], untyped, ActivityRange) -> void
|
|
8
|
+
def initialize(parent_class, parent_ids, reflection, range)
|
|
9
|
+
@parent_class = parent_class
|
|
10
|
+
@parent_ids = parent_ids
|
|
11
|
+
@reflection = reflection
|
|
12
|
+
@range = range
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
#: () -> Array[untyped]
|
|
16
|
+
def call
|
|
17
|
+
historical_ids | current_ids
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# @rbs @parent_class: untyped
|
|
23
|
+
# @rbs @parent_ids: Array[untyped]
|
|
24
|
+
# @rbs @reflection: untyped
|
|
25
|
+
# @rbs @range: ActivityRange
|
|
26
|
+
|
|
27
|
+
#: () -> Array[untyped]
|
|
28
|
+
def historical_ids
|
|
29
|
+
version_class = @parent_class.paper_trail.version_class
|
|
30
|
+
candidate_scope(version_class).call(historical_relation(version_class))
|
|
31
|
+
.distinct.pluck(version_class.arel_table[:item_id])
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
#: (untyped) -> untyped
|
|
35
|
+
def historical_relation(version_class)
|
|
36
|
+
@parent_class.paper_trail.version_association_class.joins(:version).where(
|
|
37
|
+
foreign_key_name: @reflection.foreign_key.to_s,
|
|
38
|
+
foreign_key_id: @parent_ids,
|
|
39
|
+
foreign_type: parent_types
|
|
40
|
+
).where(version_class.table_name => { item_type: child_item_type })
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
#: (untyped) -> VersionAssociationCandidateScope
|
|
44
|
+
def candidate_scope(version_class)
|
|
45
|
+
VersionAssociationCandidateScope.new(
|
|
46
|
+
version_class,
|
|
47
|
+
@range.start_time,
|
|
48
|
+
end_at: @range.end_time
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
#: () -> Array[untyped]
|
|
53
|
+
def current_ids
|
|
54
|
+
return [] unless @range.current_end?
|
|
55
|
+
|
|
56
|
+
relation = @reflection.klass.base_class.unscoped.where(
|
|
57
|
+
@reflection.foreign_key => @parent_ids
|
|
58
|
+
)
|
|
59
|
+
relation = relation.where(@reflection.type => parent_types) if @reflection.options[:as]
|
|
60
|
+
relation.distinct.pluck(@reflection.klass.primary_key)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
#: () -> String
|
|
64
|
+
def child_item_type
|
|
65
|
+
@reflection.klass.base_class.name
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
#: () -> Array[String?]
|
|
69
|
+
def parent_types
|
|
70
|
+
[nil, '', @parent_class.name.to_s, @parent_class.base_class.name.to_s].uniq
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|