Skip to content

Person timeline: normalize PaperTrail changesets across create/update/destroy at read time #2250

Description

@maebeale

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

  • Decide where the normalizer lives (Version decorator / timeline PORO / helper)
  • Route the new person-timeline reader through it
  • Route the existing app/views/application/_papertrail_versions.html.erb partial through it (currently skips destroy diffs entirely: version.event != 'destroy' — should show "everything removed")
  • Replace the actor N+1 in that partial (User.find_by(id: version.whodunnit) per row) with a batched preload
  • Handle nil whodunnit (system / console / seed / rake / background-job actions) — display as "System"/"Unknown" and account for it in any actor filter
  • Spec create/update/destroy cases

Notes

  • Serialization is YAML (no PaperTrail initializer → defaults).
  • object_changes is auto-enabled because the object_changes column exists on versions.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions