Context
We're versioning financial/membership/registration models with PaperTrail (subject_person_id meta) to build a person-scoped timeline. PaperTrail stores diffs asymmetrically by event, so a naive reader has to special-case events:
| event |
object (full state) |
object_changes (diff) |
| create |
nil |
{field: [nil, new]} |
| update |
prior state |
{field: [old, new]} |
| destroy |
final state |
nil |
Destroy events never populate object_changes; the final state lives only in object.
Decision 1 — normalize changesets at read time
Do not force object_changes onto destroy at write time — it duplicates data already in object, is a semantic fiction ([value, nil]), fights PaperTrail defaults, and only half-fixes the asymmetry (create's object is still nil).
Instead, normalize at read time with a single helper that returns a uniform {field => [before, after]} for any event, deriving destroy's diff from the object snapshot. One tested place; stored data stays honest and minimal.
def timeline_changeset(version)
case version.event
when "destroy"
version.object_deserialized.transform_values { |v| [v, nil] }
else
version.changeset # create & update already give [before, after]
end
end
Decision 2 — actor (whodunnit) is read-time only, no schema change
whodunnit is native PaperTrail (the built-in column — t.string "whodunnit" in db/schema.rb — plus set_paper_trail_whodunnit / PaperTrail.request.whodunnit / version.whodunnit). It already holds the acting user's id, per row, so "changed by" is available straight from the versions table (distinct from subject_person_id, which is the subject).
- No label snapshot. The only strong case for freezing an actor label was data loss on user deletion — but users with history won't be deleted, so that's moot. A snapshot would also go stale on name changes.
- No new
user_id column. whodunnit already carries the id.
- Just batched-preload users at read time to kill the current N+1 (
User.find_by per row in the partial):
ids = versions.map(&:whodunnit).compact.map(&:to_i)
users = User.where(id: ids).index_by(&:id)
# per row: users[version.whodunnit.to_i]
Wart: whodunnit is a String, not a typed FK, so no includes(:user) and a .to_i cast is needed. Only if that ergonomics gets in the way, add a typed user_id meta later — a nicety, not a correctness need.
Scope
Notes
- Serialization is YAML (no PaperTrail initializer → defaults).
object_changes is auto-enabled because the object_changes column exists on versions.
Context
We're versioning financial/membership/registration models with PaperTrail (
subject_person_idmeta) to build a person-scoped timeline. PaperTrail stores diffs asymmetrically by event, so a naive reader has to special-case events:object(full state)object_changes(diff){field: [nil, new]}{field: [old, new]}Destroy events never populate
object_changes; the final state lives only inobject.Decision 1 — normalize changesets at read time
Do not force
object_changesonto destroy at write time — it duplicates data already inobject, is a semantic fiction ([value, nil]), fights PaperTrail defaults, and only half-fixes the asymmetry (create'sobjectis still nil).Instead, normalize at read time with a single helper that returns a uniform
{field => [before, after]}for any event, deriving destroy's diff from theobjectsnapshot. One tested place; stored data stays honest and minimal.Decision 2 — actor (whodunnit) is read-time only, no schema change
whodunnitis native PaperTrail (the built-in column —t.string "whodunnit"indb/schema.rb— plusset_paper_trail_whodunnit/PaperTrail.request.whodunnit/version.whodunnit). It already holds the acting user's id, per row, so "changed by" is available straight from the versions table (distinct fromsubject_person_id, which is the subject).user_idcolumn.whodunnitalready carries the id.User.find_byper row in the partial):Wart:
whodunnitis a String, not a typed FK, so noincludes(:user)and a.to_icast is needed. Only if that ergonomics gets in the way, add a typeduser_idmeta later — a nicety, not a correctness need.Scope
app/views/application/_papertrail_versions.html.erbpartial through it (currently skips destroy diffs entirely:version.event != 'destroy'— should show "everything removed")User.find_by(id: version.whodunnit)per row) with a batched preloadwhodunnit(system / console / seed / rake / background-job actions) — display as "System"/"Unknown" and account for it in any actor filterNotes
object_changesis auto-enabled because theobject_changescolumn exists onversions.