papertrail

package module
v0.0.0-...-42a2496 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 17, 2026 License: BSD-3-Clause Imports: 5 Imported by: 0

README

go-ruby-paper-trail/paper-trail

paper-trail — go-ruby-paper-trail

Docs License Go Coverage

A pure-Go (no cgo) reimplementation of the deterministic core of Ruby's paper_trail gem — the model versioning / audit engine. It records a Version on every create, update and destroy, computes the per-attribute changeset, reifies past model states, navigates an item's version history, and resolves the whodunnit — without any Ruby runtime.

It is the audit/versioning engine for go-embedded-ruby, but a standalone, reusable module — a sibling of go-ruby-openbao.

What it is — and isn't. Everything paper_trail does around persistence is deterministic and needs no interpreter, so it lives here as pure Go: detecting the event, building the object snapshot of the attributes before the change, computing the object_changes changeset ({attr => [old, new]}) from a before/after attribute map, applying the only / ignore / skip / on filters, reifying a past state, and resolving the whodunnit. The model attribute snapshot is provided by the host as a plain map[string]any (before and after). The version store is a host seam (Store), wired to ActiveRecord in the binding; the clock (Clock) and the request context (RequestContext, i.e. PaperTrail.request.whodunnit) are injected. Serialization is a seam too: the default JSONSerializer matches modern PaperTrail JSON/JSONB columns. Tests use an in-memory store, an injected clock and a fixed whodunnit — the core opens no database of its own. A future rbgo binding wires the seams to Ruby.

Features

Faithful port of the paper_trail versioning core:

  • Version modelVersion{ItemType, ItemID, Event, Whodunnit, Object, ObjectChanges, CreatedAt}, one-to-one with the gem's versions table.
  • RecordingTracker.RecordCreate / RecordUpdate / RecordDestroy, or Record which infers the event from the presence of the before/after maps. object snapshots the state before the change (empty on create); an update with no notable change records nothing (the no-op skip); destroy records no changeset (matching the gem).
  • Changesetobject_changes is the {attr => [old, new]} diff of the before/after attribute maps.
  • FiltersConfig{Only, Ignore, Skip, On} mirrors has_paper_trail only:/ignore:/skip:/on:: only/ignore govern which changes trigger a version, skip additionally excludes attributes from serialization, on restricts the versioned lifecycle events.
  • ReifyTracker.Reify(version, opts...) reconstructs the pre-change attributes; ReifyWithCurrent + ReifyUnversioned reproduce reify(unversioned_attributes: :nil | :preserve).
  • QueryingVersions, VersionAt(t) (with a live result), PreviousVersion / NextVersion, and Live (live?).
  • WhodunnitRequestContext{Whodunnit, Enabled} is the PaperTrail.request seam; Enabled = false suppresses versioning.

Usage

tr := papertrail.New(papertrail.Options{ItemType: "Widget"})
tr.Request.Whodunnit = "alice@example.com"

// create
tr.RecordCreate("42", map[string]any{"name": "gadget", "price": 9.99})

// update — records the pre-change snapshot + the {attr:[old,new]} diff
v, _ := tr.RecordUpdate("42",
    map[string]any{"name": "gadget", "price": 9.99},
    map[string]any{"name": "gadget", "price": 12.50})

// reconstruct the state before that update
prev, _ := tr.Reify(*v) // => {"name":"gadget","price":9.99}

Ruby surface (rbgo binding)

The seams above back the familiar paper_trail API: has_paper_trail(only:, ignore:, skip:, on:), model.versions, version.reify(...), version.object / version.object_changes, model.paper_trail.previous_version / .next_version / .live?, Model.paper_trail.version_at(t), and PaperTrail.request.whodunnit.

Tests & coverage

go test -race with a 100% statement-coverage gate, on three host OSes, across six 64-bit architectures (amd64/arm64 native, riscv64/loong64/ppc64le/s390x under qemu), plus js/wasm and wasip1/wasm build checks — all CGO_ENABLED=0.

License

BSD-3-Clause — see LICENSE.

Documentation

Overview

Package papertrail is a pure-Go (CGO=0), MRI-faithful reimplementation of the deterministic core of Ruby's paper_trail gem — the model-versioning / audit engine.

PaperTrail records a Version every time a tracked model is created, updated or destroyed. Each version carries the item's type and id, the event name, who did it ("whodunnit"), a serialized snapshot of the attributes *before* the change ("object"), and the per-attribute changeset ("object_changes", {attr => [old, new]}). Past model states can be reconstructed with [Reify], navigated with previous/next version, and queried at a point in time with Tracker.VersionAt.

Faithfulness and seams

Everything PaperTrail does *around* persistence is deterministic and needs no Ruby interpreter, so it lives here as pure Go: event detection, building the object snapshot, computing the changeset from a before/after attribute map, applying the only/ignore/skip/on filters, reifying a past state, and resolving the whodunnit. The host provides the model's attribute snapshot as a plain map[string]any (before and after); the version STORE is a persistence seam (Store); the CLOCK (Clock) and the request context (RequestContext, PaperTrail.request.whodunnit) are injected. Serialization is a seam too (Serializer); the default JSONSerializer matches modern PaperTrail JSON columns (see serializer.go for why JSON over YAML). An in-memory store, an injected clock and a fixed whodunnit back the tests; an rbgo binding wires the same seams to ActiveRecord and Ruby.

Index

Constants

View Source
const (
	// UnversionedNil sets attributes present on the current record but absent
	// from the version's snapshot to nil (PaperTrail's default).
	UnversionedNil = "nil"
	// UnversionedPreserve keeps such attributes at their current value.
	UnversionedPreserve = "preserve"
)

Unversioned-attribute handling for [Reify], mirroring PaperTrail's reify(unversioned_attributes:) option.

View Source
const (
	EventCreate  = "create"
	EventUpdate  = "update"
	EventDestroy = "destroy"
)

Event names recorded on a Version, mirroring PaperTrail's `event` column.

Variables

View Source
var ErrNoAttributes = errors.New("papertrail: cannot infer event from nil before and after attributes")

ErrNoAttributes is returned by Tracker.Record when both the before and after attribute maps are nil, so no event can be inferred.

Functions

This section is empty.

Types

type Change

type Change struct {
	Old any
	New any
}

Change is the [old, new] pair PaperTrail records per attribute in `object_changes`.

type Clock

type Clock interface {
	Now() time.Time
}

Clock is the time seam stamped onto Version.CreatedAt. Production uses SystemClock; tests inject a fixed clock for deterministic timestamps.

var SystemClock Clock = ClockFunc(time.Now)

SystemClock is the default wall-clock Clock.

type ClockFunc

type ClockFunc func() time.Time

ClockFunc adapts a func to a Clock.

func (ClockFunc) Now

func (f ClockFunc) Now() time.Time

Now implements Clock.

type Config

type Config struct {
	// Only, when non-empty, restricts version-triggering to changes on these
	// attributes (has_paper_trail only:). Changes outside the list do not, on
	// their own, create an update version.
	Only []string
	// Ignore lists attributes whose changes do not, on their own, trigger an
	// update version (has_paper_trail ignore:). Ignored attributes are still
	// serialized if another attribute triggers the version.
	Ignore []string
	// Skip lists attributes excluded entirely from the `object` snapshot and the
	// `object_changes` changeset, and which never trigger a version
	// (has_paper_trail skip:).
	Skip []string
	// On restricts which lifecycle events are versioned (has_paper_trail
	// on: [...]). Empty means all of create/update/destroy.
	On []string
}

Config mirrors the has_paper_trail options that govern *when* a version is recorded. It affects version triggering only; the skip list additionally excludes attributes from serialization.

type JSONSerializer

type JSONSerializer struct{}

JSONSerializer is the default Serializer; it stores snapshots and changesets as JSON, matching PaperTrail's JSON/JSONB columns.

func (JSONSerializer) DumpChanges

func (JSONSerializer) DumpChanges(m map[string]Change) (string, error)

DumpChanges implements Serializer. The changeset is emitted as {"attr": [old, new]}, PaperTrail's on-disk shape.

func (JSONSerializer) DumpObject

func (JSONSerializer) DumpObject(m map[string]any) (string, error)

DumpObject implements Serializer.

func (JSONSerializer) LoadChanges

func (JSONSerializer) LoadChanges(s string) (map[string]Change, error)

LoadChanges implements Serializer.

func (JSONSerializer) LoadObject

func (JSONSerializer) LoadObject(s string) (map[string]any, error)

LoadObject implements Serializer.

type MemoryStore

type MemoryStore struct {
	// contains filtered or unexported fields
}

MemoryStore is an in-memory Store for tests and non-persistent hosts. It is safe for the sequential access the tests perform.

func NewMemoryStore

func NewMemoryStore() *MemoryStore

NewMemoryStore returns an empty MemoryStore.

func (*MemoryStore) SaveVersion

func (s *MemoryStore) SaveVersion(v Version) (Version, error)

SaveVersion implements Store, assigning a monotonically increasing ID.

func (*MemoryStore) VersionsFor

func (s *MemoryStore) VersionsFor(itemType, itemID string) ([]Version, error)

VersionsFor implements Store.

type Options

type Options struct {
	ItemType   string
	Config     Config
	Store      Store
	Clock      Clock
	Serializer Serializer
	Request    *RequestContext
}

Options configures a Tracker. Only ItemType is required; the remaining seams default to an in-memory store, the system clock, JSON serialization and a fresh enabled request context.

type ReifyOption

type ReifyOption func(*reifyConfig)

ReifyOption customizes Tracker.Reify.

func ReifyUnversioned

func ReifyUnversioned(mode string) ReifyOption

ReifyUnversioned selects how attributes present on the current record but absent from the snapshot are handled: UnversionedNil (default) or UnversionedPreserve.

func ReifyWithCurrent

func ReifyWithCurrent(current map[string]any) ReifyOption

ReifyWithCurrent supplies the record's current attributes so that Tracker.Reify can decide how to handle attributes that were not versioned (see ReifyUnversioned).

type RequestContext

type RequestContext struct {
	// Whodunnit is PaperTrail.request.whodunnit — the actor stamped onto each
	// recorded version.
	Whodunnit string
	// Enabled mirrors PaperTrail.request.enabled?; when false, no version is
	// recorded. NewRequest defaults it to true.
	Enabled bool
}

RequestContext is the per-request seam behind PaperTrail.request. It carries the whodunnit applied to versions recorded during the request and the enabled/disabled toggle (PaperTrail.request.enabled = false suppresses versioning). In the gem this is thread/fiber-local; here the host owns a RequestContext per logical request and passes it to New.

func NewRequest

func NewRequest() *RequestContext

NewRequest returns an enabled RequestContext with an empty whodunnit.

type Serializer

type Serializer interface {
	// DumpObject serializes an attribute snapshot. A nil map serializes to the
	// empty string (an absent snapshot, as for a create event).
	DumpObject(map[string]any) (string, error)
	// LoadObject is the inverse of DumpObject; the empty string loads to nil.
	LoadObject(string) (map[string]any, error)
	// DumpChanges serializes a {attr => [old, new]} changeset. A nil map
	// serializes to the empty string.
	DumpChanges(map[string]Change) (string, error)
	// LoadChanges is the inverse of DumpChanges; the empty string loads to nil.
	LoadChanges(string) (map[string]Change, error)
}

Serializer is the seam PaperTrail uses to (de)serialize the `object` snapshot and the `object_changes` changeset for storage. PaperTrail::Serializers::YAML is the gem's historical default (Rails text columns), but modern PaperTrail on Postgres/MySQL uses native JSON/JSONB columns via PaperTrail::Serializers::JSON. We adopt JSON as the default (JSONSerializer): it is stdlib-only (encoding/json, CGO=0), round-trips deterministically (map keys are emitted sorted), and matches the JSON-column deployment. The seam lets an rbgo binding plug a YAML serializer when a legacy YAML column must be matched byte-for-byte.

type Store

type Store interface {
	// SaveVersion persists v, assigns and returns it with its ID populated.
	SaveVersion(v Version) (Version, error)
	// VersionsFor returns every stored version for the given item, ordered
	// oldest-first (ascending ID).
	VersionsFor(itemType, itemID string) ([]Version, error)
}

Store is the persistence seam for versions. In production an rbgo binding backs it with ActiveRecord (the `versions` table); tests use MemoryStore.

type Tracker

type Tracker struct {
	ItemType   string
	Config     Config
	Store      Store
	Clock      Clock
	Serializer Serializer
	Request    *RequestContext
}

Tracker is the has_paper_trail engine for one model class. It builds versions from before/after attribute maps, persists them through the Store seam, and answers version queries. It is the Go surface an rbgo binding drives from the model's create/update/destroy callbacks.

func New

func New(o Options) *Tracker

New builds a Tracker, filling unset seams with defaults.

func (*Tracker) Live

func (t *Tracker) Live(itemID string) (bool, error)

Live reports whether the item currently exists as a live record, i.e. its latest version is not a destroy. An item with no versions is treated as live.

func (*Tracker) NextVersion

func (t *Tracker) NextVersion(v Version) (*Version, error)

NextVersion returns the version immediately following v for the same item, or nil if v is the latest (the item is at its live state).

func (*Tracker) PreviousVersion

func (t *Tracker) PreviousVersion(v Version) (*Version, error)

PreviousVersion returns the version immediately preceding v for the same item, or nil if v is the earliest.

func (*Tracker) Record

func (t *Tracker) Record(itemID string, before, after map[string]any) (*Version, error)

Record infers the event from the presence of before/after attributes (before-only ⇒ destroy, after-only ⇒ create, both ⇒ update) and records it.

func (*Tracker) RecordCreate

func (t *Tracker) RecordCreate(itemID string, after map[string]any) (*Version, error)

RecordCreate records a create version for a newly created record.

func (*Tracker) RecordDestroy

func (t *Tracker) RecordDestroy(itemID string, before map[string]any) (*Version, error)

RecordDestroy records a destroy version for a record being deleted.

func (*Tracker) RecordUpdate

func (t *Tracker) RecordUpdate(itemID string, before, after map[string]any) (*Version, error)

RecordUpdate records an update version, or returns (nil, nil) when the change is a no-op under the only/ignore/skip filters.

func (*Tracker) Reify

func (t *Tracker) Reify(v Version, opts ...ReifyOption) (map[string]any, error)

Reify reconstructs the attribute state captured by v's `object` snapshot — the record as it was *before* v's change. A create version has no prior state and reifies to nil. When ReifyWithCurrent is supplied, attributes on the current record that were not versioned are set to nil, or preserved under ReifyUnversioned(UnversionedPreserve).

func (*Tracker) VersionAt

func (t *Tracker) VersionAt(itemID string, ts time.Time) (map[string]any, bool, error)

VersionAt reconstructs the item's attributes as they were at time ts. It returns (attrs, live=false) reified from the first version recorded after ts, or (nil, live=true) when ts is at or after the latest change, meaning the caller should use the current live record.

func (*Tracker) Versions

func (t *Tracker) Versions(itemID string) ([]Version, error)

Versions returns every stored version for the item, oldest-first.

type Version

type Version struct {
	// ID is the store-assigned primary key. It is zero until a [Store] persists
	// the version; ordering by ID reproduces PaperTrail's insertion order.
	ID int64
	// ItemType / ItemID identify the tracked record (STI base class name and id
	// in the gem).
	ItemType string
	ItemID   string
	// Event is one of [EventCreate], [EventUpdate], [EventDestroy].
	Event string
	// Whodunnit is the actor responsible for the change, resolved from the
	// [RequestContext] at record time.
	Whodunnit string
	// Object is the serialized snapshot of the attributes *before* the change:
	// empty for create (there was no prior state), the pre-update attributes for
	// update, and the pre-destroy attributes for destroy.
	Object string
	// ObjectChanges is the serialized {attr => [old, new]} changeset. It is set
	// for create and update, and empty for destroy (matching the gem, which
	// records no changeset when a record is wiped).
	ObjectChanges string
	// CreatedAt is the [Clock] time the version was recorded.
	CreatedAt time.Time
}

Version is the audit record PaperTrail writes for each tracked change. It maps one-to-one onto the gem's `versions` table columns.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL