history

package
v0.0.4 Latest Latest
Warning

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

Go to latest
Published: Jul 8, 2026 License: MIT Imports: 17 Imported by: 0

Documentation

Overview

Package history provides activity log / change tracking for r3 CRUD repositories.

It works as a decorator around any r3.CRUD[T, ID] implementation, recording every Create, Update, Patch, and Delete as a ChangeRecord. The decorator is transparent: the wrapped repository still satisfies r3.CRUD[T, ID].

"Everything is a R3po" — change records and snapshots are themselves r3 entities, stored via any r3.CRUD[ChangeRecord, string] and r3.CRUD[Snapshot, string]. The history feature has zero knowledge of storage backends. You can use the exact same driver (SQL, GORM, MongoDB, etc.) that you use for your entities, or a completely different one (e.g. entities in PostgreSQL, history in MongoDB).

Key features:

  • Field-level diffs (what changed, old value, new value)
  • Opt-in full entity snapshots via configurable SnapshotRules
  • Tree/nested queries (e.g. Campaign + its Adsets + their Creatives)
  • Revert to any historical version (purely diff-based reconstruction)
  • r3.Actor integration for automatic actor attribution — ActorID/ActorType are first-class, queryable columns on ChangeRecord (filter the log by actor)
  • Context-based metadata (source, request ID, etc.)
  • Convenience query builders (QueryForRecord, QueryForType, QueryForTree, etc.)
  • Retention policies (age-based TTL, per-entity version limits)

Actor Integration

The history decorator records the actor as first-class ChangeRecord fields — ActorID and ActorType — resolved from r3.GetActor(ctx) on every mutation. Because they are real columns (not buried in the Metadata JSON blob), the activity log can be filtered and grouped by actor (see QueryForActor).

To attribute a change to a specific actor, set it on the context with r3.WithActor(ctx, r3.Actor{ID: "42", Type: "user"}) — typically in auth middleware. When no actor is set, the zero/SystemActor (Type "system") is recorded. MetadataFunc is for surrounding context only (Source, Extra); it no longer carries the actor.

Retention

RetentionEnforcer deletes old change records based on age (MaxAge) or per-entity version count (MaxVersions). It deletes records one-by-one via the r3.CRUD Delete method. For large datasets this may be slow; a future BatchDelete interface could optimize bulk deletions for capable backends.

RetentionEnforcer.Start runs periodic enforcement via a ticker-based background loop. The first enforcement fires after the configured interval elapses, not immediately on start.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrVersionNotFound is returned when a requested version does not exist.
	ErrVersionNotFound = errors.New("r3history: version not found")

	// ErrRecordNotFound is returned when a requested change record does not exist.
	ErrRecordNotFound = errors.New("r3history: record not found")

	// ErrNoHistory is returned when attempting to revert an entity with no history.
	ErrNoHistory = errors.New("r3history: no history for this record")
)

Sentinel errors for history operations.

View Source
var ErrIDFuncRequired = errors.New("r3history: IDFunc is required; use WithIDFunc option")

ErrIDFuncRequired is returned when WithHistory is called without an IDFunc. The decorator needs to know how to extract the primary key from an entity.

Functions

func MarshalSnapshot

func MarshalSnapshot(entity any) json.RawMessage

MarshalSnapshot serializes an entity to JSON bytes for storage in a Snapshot. Returns nil if the entity is nil or serialization fails.

func QueryForActor

func QueryForActor(actorID string) r3.Query

QueryForActor builds a Query that filters change records by the actor who performed them, sorted by created_at descending. This answers "show me everything user X did" — possible only because the actor is a first-class column on ChangeRecord (not buried in the Metadata JSON blob).

func QueryForRecord

func QueryForRecord(recordType, recordID string) r3.Query

QueryForRecord builds a Query that filters change records for a specific entity instance, sorted by version ascending.

Equivalent to the old Store.ForRecord(ctx, recordType, recordID).

func QueryForTree

func QueryForTree(scopes []TreeScope) r3.Query

QueryForTree builds a Query that matches change records across a parent-child hierarchy. Each TreeScope defines one level; they are combined with OR.

Equivalent to the old Store.ForTree(ctx, scopes).

func QueryForType

func QueryForType(recordType string) r3.Query

QueryForType builds a Query that filters change records for all entities of a given type, sorted by created_at descending.

Equivalent to the old Store.ForType(ctx, recordType).

func QueryForVersion

func QueryForVersion(recordType, recordID string, version int64) r3.Query

QueryForVersion builds a Query that matches a specific version of a specific entity.

Equivalent to the old Store.GetVersion(ctx, recordType, recordID, version).

func QueryLatestSnapshot

func QueryLatestSnapshot(recordType, recordID string) r3.Query

QueryLatestSnapshot builds a Query for the most recent snapshot of an entity.

func QueryLatestVersion

func QueryLatestVersion(recordType, recordID string) r3.Query

QueryLatestVersion builds a Query that returns the most recent change record for a specific entity (sort by version desc, limit 1).

Used internally to compute NextVersion and to implement RevertLast.

func QueryListSnapshots

func QueryListSnapshots(recordType, recordID string) r3.Query

QueryListSnapshots builds a Query that lists all snapshots for a specific entity, ordered by version descending.

func QuerySnapshotForVersion

func QuerySnapshotForVersion(recordType, recordID string, version int64) r3.Query

QuerySnapshotForVersion builds a Query for retrieving a snapshot at a specific version.

func UnmarshalSnapshot

func UnmarshalSnapshot[T any](data json.RawMessage) (T, error)

UnmarshalSnapshot deserializes a JSON snapshot back into an entity of type T.

Types

type Action

type Action string

Action represents the type of mutation that was performed on an entity.

const (
	// ActionCreate indicates a new entity was inserted.
	ActionCreate Action = "create"
	// ActionUpdate indicates a full entity update.
	ActionUpdate Action = "update"
	// ActionPatch indicates a partial update (specific fields only).
	ActionPatch Action = "patch"
	// ActionDelete indicates the entity was deleted (or soft-deleted).
	ActionDelete Action = "delete"
	// ActionRevert indicates the entity was reverted to a previous version.
	ActionRevert Action = "revert"
	// ActionEvent indicates a domain/synthetic event on the entity's timeline
	// rather than a field-level mutation — see ChangeRecord.EventType.
	ActionEvent Action = "event"
)

type CRUD

type CRUD[T any, ID comparable] struct {
	// contains filtered or unexported fields
}

CRUD is a decorator that wraps any r3.CRUD[T, ID] and records every mutation as a ChangeRecord in the configured history store.

It transparently satisfies the r3.CRUD[T, ID] interface, so it can be used as a drop-in replacement for any CRUD repository. Read operations (Get, List) are delegated directly to the inner CRUD without recording anything.

The history store is any r3.CRUD[ChangeRecord, string] — the same CRUD abstraction used everywhere in r3. "Everything is a R3po."

Usage:

repo := r3history.WithHistory[Order, int64](
    gormOrderCRUD,
    historyStore,  // any r3.CRUD[ChangeRecord, string]
    r3history.WithIDFunc[Order, int64](func(o Order) int64 { return o.ID }),
    r3history.WithMetadataFunc[Order, int64](metadataFromCtx),
)
// repo satisfies r3.CRUD[Order, int64]

func WithHistory

func WithHistory[T any, ID comparable](
	inner r3.CRUD[T, ID],
	store r3.CRUD[ChangeRecord, string],
	optFns ...Option[T, ID],
) *CRUD[T, ID]

WithHistory wraps an existing r3.CRUD with history tracking. The IDFunc option is required — the decorator must know how to extract the primary key from an entity.

The store parameter is any r3.CRUD[ChangeRecord, string] — use the same CRUD implementation you use for everything else (SQL, GORM, MongoDB, etc.).

Example:

repo := r3history.WithHistory[Order, int64](
    innerRepo, store,
    r3history.WithIDFunc[Order, int64](func(o Order) int64 { return o.ID }),
)

func (*CRUD[T, ID]) Aggregate added in v0.0.3

func (h *CRUD[T, ID]) Aggregate(ctx context.Context, qarg ...r3.Query) ([]r3.AggregateRow, error)

Aggregate passes through to the inner CRUD's Aggregate. No history is recorded for reads.

func (*CRUD[T, ID]) Count

func (h *CRUD[T, ID]) Count(ctx context.Context, qarg ...r3.Query) (int64, error)

Count returns the number of matching entities. No history is recorded for reads.

func (*CRUD[T, ID]) Create

func (h *CRUD[T, ID]) Create(ctx context.Context, entity T) (T, error)

Create inserts a new entity and records a "create" change.

func (*CRUD[T, ID]) Delete

func (h *CRUD[T, ID]) Delete(ctx context.Context, id ID) error

Delete removes an entity and records a "delete" change.

func (*CRUD[T, ID]) Get

func (h *CRUD[T, ID]) Get(ctx context.Context, id ID, qarg ...r3.Query) (T, error)

Get retrieves an entity by ID. No history is recorded for reads.

func (*CRUD[T, ID]) History

func (h *CRUD[T, ID]) History() r3.CRUD[ChangeRecord, string]

History returns the change record CRUD for querying history directly. Use it with the query builders (QueryForRecord, QueryForType, etc.) to retrieve change records.

func (*CRUD[T, ID]) Inner

func (h *CRUD[T, ID]) Inner() r3.CRUD[T, ID]

Inner returns the underlying CRUD repository (unwrapped). Useful when you need to bypass history tracking for a specific operation.

func (*CRUD[T, ID]) List

func (h *CRUD[T, ID]) List(ctx context.Context, qarg ...r3.Query) ([]T, int64, error)

List retrieves entities. No history is recorded for reads.

func (*CRUD[T, ID]) Patch

func (h *CRUD[T, ID]) Patch(ctx context.Context, entity T, fields r3.Fields) (T, error)

Patch performs a partial update and records a "patch" change with field-level diff.

func (*CRUD[T, ID]) PatchWhere added in v0.0.4

func (h *CRUD[T, ID]) PatchWhere(
	ctx context.Context, filters r3.Filters, entity T, fields r3.Fields,
) (int64, error)

PatchWhere runs a bulk conditional update via the inner CRUD and records one ActionPatch change per affected row. It snapshots the matching rows before the write (a List over the same filters) and re-reads each by PK afterward to diff old vs new — so a bulk sweep still leaves a per-row audit trail. This is bounded work intended for the small recovery sweeps this capability targets.

Recording per-row history requires IDFunc; without it the change cannot be attributed to specific rows, so PatchWhere reports the gap via the error handler (loud, never silent) and still performs the update.

func (*CRUD[T, ID]) RecordEvent

func (h *CRUD[T, ID]) RecordEvent(
	ctx context.Context, id ID, eventType string, data map[string]any,
) (ChangeRecord, error)

RecordEvent appends a domain or synthetic event to an entity's activity timeline (Action ActionEvent), separate from field-level changes. eventType names the event (e.g. "comment_added", "activity_logging_started"); data is an optional structured payload. The actor is resolved like any change (the fixed actor if configured, else the context actor). The event takes the next timeline version and is skipped by state reconstruction (it carries no diff).

func (*CRUD[T, ID]) RecordSyntheticCreate

func (h *CRUD[T, ID]) RecordSyntheticCreate(
	ctx context.Context, entity T, at time.Time,
) (ChangeRecord, error)

RecordSyntheticCreate writes a best-effort "create" for an entity that already existed before tracking began: it captures the entity's CURRENT state as the create diff, dated `at` (the entity's real creation time), and flags the record Synthetic with an explanatory Note. Use during backfill so an entity with no recorded history still shows an origin point.

func (*CRUD[T, ID]) Reverter

func (h *CRUD[T, ID]) Reverter() *Reverter[T, ID]

Reverter returns a Reverter for undo/revert operations on this entity type.

func (*CRUD[T, ID]) Rewrap

func (h *CRUD[T, ID]) Rewrap(inner r3.CRUD[T, ID]) r3.CRUD[T, ID]

Rewrap rebuilds this decorator around a different inner CRUD (used to re-apply the history layer on top of a transaction-bound CRUD). The change store, options, and version locks are shared with the rebuilt instance so version assignment stays serialized across the original and tx-bound layers.

func (*CRUD[T, ID]) Unwrap

func (h *CRUD[T, ID]) Unwrap() r3.CRUD[T, ID]

Unwrap returns the wrapped CRUD so capability detection and transaction propagation can walk the decorator chain.

func (*CRUD[T, ID]) Update

func (h *CRUD[T, ID]) Update(ctx context.Context, entity T) (T, error)

Update modifies an existing entity and records an "update" change with field-level diff.

func (*CRUD[T, ID]) Upsert added in v0.0.4

func (h *CRUD[T, ID]) Upsert(ctx context.Context, entity T, opts ...r3.UpsertOption) (T, error)

Upsert inserts-or-updates via the inner CRUD and records the change. To decide between a create and an update entry — and to diff the update — it fetches the pre-state by primary key (via IDFunc) before the write: a hit records an ActionUpdate with a field-level diff, a miss records an ActionCreate.

The pre-state lookup is by PK. When the upsert's conflict target is NOT the PK (a custom OnConflict on a unique column), a colliding row may be missed and the change recorded as a create — an accepted approximation, since the audited KV/settings use case conflicts on its primary key.

type ChangeRecord

type ChangeRecord struct {
	// ID is the unique identifier for this change record (UUID).
	ID string `json:"id" db:"id,pk" bson:"_id"`

	// RecordType is the type name of the entity (e.g. "orders", "campaigns").
	// Derived automatically from the struct type or set explicitly via options.
	RecordType string `json:"record_type" db:"record_type" bson:"record_type"`

	// RecordID is the primary key of the entity, stringified.
	RecordID string `json:"record_id" db:"record_id" bson:"record_id"`

	// Action is the type of mutation (create, update, patch, delete, revert).
	Action Action `json:"action" db:"action" bson:"action"`

	// ActorID identifies who performed the change (e.g. user ID, API key ID).
	// It is a first-class, indexable column — resolved from r3.GetActor(ctx) —
	// so the activity log can be filtered/grouped by actor ("show everything
	// user 7 did") without reaching into the Metadata JSON blob. Empty for the
	// system/anonymous actor.
	ActorID string `json:"actor_id,omitempty" db:"actor_id" bson:"actor_id,omitempty"`

	// ActorType categorizes the actor (e.g. "user", "system", "api_key", "cron").
	// First-class column alongside ActorID; resolved from r3.GetActor(ctx).
	ActorType string `json:"actor_type,omitempty" db:"actor_type" bson:"actor_type,omitempty"`

	// Version is a monotonically increasing counter per (record_type, record_id).
	// Version 1 is always the initial create. This enables ordered history
	// and "revert to version N" functionality.
	Version int64 `json:"version" db:"version" bson:"version"`

	// Changes contains the field-level diff for this mutation.
	// For create: each field appears with OldValue=nil and NewValue=<value>.
	// For delete: each field appears with OldValue=<value> and NewValue=nil.
	// For update/patch: only changed fields appear.
	// For revert: the diff between pre-revert state and the reverted-to state.
	//
	// State reconstruction (Reverter.Reconstruct) works purely from diffs:
	// version 1 (create) always contains every field, so replaying from v1
	// forward reconstructs any historical state without needing snapshots.
	//
	// Stored as a JSON blob in SQL databases via JSONColumn.
	Changes r3.JSONColumn[[]FieldChange] `json:"changes" db:"changes" bson:"changes"`

	// ParentType is the record type of the parent entity (for tree queries).
	// Empty if this entity has no parent. Example: "campaigns" for an adset.
	ParentType string `json:"parent_type,omitempty" db:"parent_type" bson:"parent_type,omitempty"`

	// ParentID is the primary key of the parent entity, stringified.
	// Empty if this entity has no parent.
	ParentID string `json:"parent_id,omitempty" db:"parent_id" bson:"parent_id,omitempty"`

	// EventType, when non-empty, marks this entry as a domain or synthetic
	// *event* on the entity's timeline (Action is ActionEvent) rather than a
	// field-level mutation — e.g. "activity_logging_started", "comment_added".
	// Changes is usually empty for events; EventData carries any structured
	// payload. This makes the activity log a single timeline of changes AND
	// events, queried and ordered together.
	EventType string `json:"event_type,omitempty" db:"event_type" bson:"event_type,omitempty"`

	// EventData is an optional structured payload for an event (e.g. the comment
	// text, the signer). Stored as a JSON blob via JSONColumn.
	EventData r3.JSONColumn[map[string]any] `json:"event_data" db:"event_data" bson:"event_data,omitempty"`

	// Synthetic marks a record that was reconstructed rather than observed — e.g.
	// a backfilled "create" inferred from an entity's CURRENT state because
	// tracking began after the entity already existed. Its Changes are
	// best-effort, not the values at the original mutation.
	Synthetic bool `json:"synthetic,omitempty" db:"synthetic" bson:"synthetic,omitempty"`

	// Note is an optional human remark shown alongside the entry (e.g. why a
	// record is synthetic).
	Note string `json:"note,omitempty" db:"note" bson:"note,omitempty"`

	// Metadata carries contextual information about who made the change and why.
	// Stored as a JSON blob in SQL databases via JSONColumn.
	Metadata r3.JSONColumn[Metadata] `json:"metadata" db:"metadata" bson:"metadata"`

	// CreatedAt is the timestamp when this change was recorded.
	CreatedAt time.Time `json:"created_at" db:"created_at" bson:"created_at"`
}

ChangeRecord represents a single entry in the activity log. It captures what happened to an entity at a specific point in time as field-level diffs. Snapshots are a separate, opt-in feature with their own storage — see SnapshotRule.

ChangeRecord is a first-class r3 entity: it can be stored via any r3.CRUD[ChangeRecord, string] implementation (SQL, GORM, MongoDB, etc.).

type DiffFunc

type DiffFunc[T any] func(old, cur T) []FieldChange

DiffFunc computes field-level changes between two entities. Use this to provide a custom diff implementation instead of the default reflection-based differ.

type FieldChange

type FieldChange struct {
	// Field is the column/field name that changed (dot-notation for nested structs).
	Field string `json:"field"`

	// OldValue is the previous value (nil for create).
	OldValue any `json:"old_value"`

	// NewValue is the new value (nil for delete).
	NewValue any `json:"new_value"`
}

FieldChange represents a single field-level change within a mutation. Field names use flat dot-notation (e.g. "address.city") matching the storage column/field names derived from struct tags.

func Diff

func Diff[T any](old, cur T) []FieldChange

Diff computes field-level changes between two entities of the same type. It compares exported fields that have `db`, `bson`, or `r3` struct tags, using flat dot-notation for nested structs (e.g. "address.city").

Returns nil if there are no differences.

func DiffCreate

func DiffCreate[T any](entity T) []FieldChange

DiffCreate generates FieldChanges for a newly created entity. Every field gets OldValue=nil, NewValue=<current value>.

func DiffDelete

func DiffDelete[T any](entity T) []FieldChange

DiffDelete generates FieldChanges for a deleted entity. Every field gets OldValue=<current value>, NewValue=nil.

func DiffWithFields

func DiffWithFields[T any](old, cur T, fields []string) []FieldChange

DiffWithFields computes changes only for the specified field names. Field names should match the column/field names (snake_case), not Go struct names.

func (FieldChange) String

func (fc FieldChange) String() string

String returns a human-readable representation of the field change.

type IDFunc

type IDFunc[T any, ID comparable] func(entity T) ID

IDFunc extracts the primary key from an entity and returns it as a string. Used by the decorator to obtain the record ID for change records.

type Metadata

type Metadata struct {
	// Source describes where the change originated (e.g. "api", "admin_ui", "worker").
	Source string `json:"source,omitempty"`

	// Extra holds arbitrary key-value pairs for additional context
	// (e.g. request_id, ip_address, user_agent).
	Extra map[string]string `json:"extra,omitempty"`
}

Metadata carries optional contextual information about a change beyond who performed it. The actor is a first-class concern and lives on ChangeRecord directly (ActorID/ActorType); Metadata is for the surrounding context (where it came from, request correlation, etc.). It is extracted from a context.Context via a user-provided MetadataFunc.

type MetadataFunc

type MetadataFunc func(ctx context.Context) Metadata

MetadataFunc extracts surrounding-context Metadata (Source, Extra) from a context.Context. The actor is NOT set here — it is a first-class ChangeRecord field resolved from r3.GetActor(ctx). Use this for where-it-came-from and request-correlation data.

Example:

func metadataFromCtx(ctx context.Context) Metadata {
    return r3history.Metadata{
        Source: "admin_ui",
        Extra:  map[string]string{"request_id": reqID(ctx)},
    }
}

type Option

type Option[T any, ID comparable] func(*Options[T, ID])

Option is a functional option for configuring CRUD.

func WithAsync

func WithAsync[T any, ID comparable]() Option[T, ID]

WithAsync enables asynchronous history recording.

func WithDiffFunc

func WithDiffFunc[T any, ID comparable](fn DiffFunc[T]) Option[T, ID]

WithDiffFunc sets a custom diff function.

func WithErrorHandler

func WithErrorHandler[T any, ID comparable](fn func(error)) Option[T, ID]

WithErrorHandler sets a handler invoked when writing a change record fails. It overrides the default slog logging. It does not change whether the operation fails — use WithFailOnError for that.

func WithFailOnError

func WithFailOnError[T any, ID comparable]() Option[T, ID]

WithFailOnError makes a failed change-record write return its error from the CRUD operation in synchronous mode. See Options.FailOnError for the important caveat that the underlying mutation has already been applied. Ignored in async mode.

func WithFixedActor

func WithFixedActor[T any, ID comparable](actor r3.Actor) Option[T, ID]

WithFixedActor attributes every change recorded by this decorator to actor, ignoring the context actor. Use for a system/worker repo whose writes are always one identity. See Options.FixedActor.

func WithIDFunc

func WithIDFunc[T any, ID comparable](fn IDFunc[T, ID]) Option[T, ID]

WithIDFunc sets the function that extracts the primary key from an entity.

func WithMetadataFunc

func WithMetadataFunc[T any, ID comparable](fn MetadataFunc) Option[T, ID]

WithMetadataFunc sets the function that extracts metadata from context.

func WithParentRef

func WithParentRef[T any, ID comparable](parentType string, fkField string) Option[T, ID]

WithParentRef configures parent-child linking for tree queries.

func WithRecordType

func WithRecordType[T any, ID comparable](name string) Option[T, ID]

WithRecordType sets an explicit record type name.

func WithSnapshotRules

func WithSnapshotRules[T any, ID comparable](rules ...SnapshotRule[T]) Option[T, ID]

WithSnapshotRules configures snapshot rules for opt-in snapshotting. Snapshots are stored separately from change records via a SnapshotStore.

type Options

type Options[T any, ID comparable] struct {
	// RecordType is the name used to identify this entity type in change records.
	// If empty, it is derived automatically from the struct type T
	// (e.g. Order -> "orders", CampaignAdset -> "campaign_adsets").
	RecordType string

	// MetadataFunc extracts surrounding-context metadata (Source, Extra) from the
	// request context. The actor is always recorded independently as the
	// first-class ChangeRecord.ActorID/ActorType fields, resolved from
	// r3.GetActor(ctx) — MetadataFunc does not affect it.
	MetadataFunc MetadataFunc

	// FixedActor, when set, attributes every change recorded by this decorator to
	// the given actor, ignoring r3.GetActor(ctx). Use it for a repository whose
	// writes are always one identity — e.g. a system/worker repo where mutations
	// run on background contexts that carry no principal. When nil (default), the
	// actor is resolved from the context per-call.
	FixedActor *r3.Actor

	// DiffFunc provides a custom diff implementation.
	// If nil, the default reflection-based Diff[T] is used.
	DiffFunc DiffFunc[T]

	// IDFunc extracts the primary key from an entity.
	// This is required — the decorator needs to know how to get the ID
	// from an entity for fetching the old state before updates.
	IDFunc IDFunc[T, ID]

	// ParentRef links this entity to a parent for tree queries.
	// If nil, change records will not include parent information.
	ParentRef *ParentRef

	// SnapshotRules defines conditions under which full entity snapshots
	// are taken and stored in a separate SnapshotStore. Snapshots are
	// completely decoupled from change records — they have their own
	// storage (separate table/collection) and custom trigger conditions.
	//
	// Example: snapshot a BenefitSheet when status changes from "draft" to "published".
	SnapshotRules []SnapshotRule[T]

	// Async when true records history in a background goroutine.
	// The CRUD operation returns immediately; history recording errors are
	// reported via ErrorHandler but never affect the CRUD result (FailOnError is
	// ignored in async mode). Default: false (synchronous).
	Async bool

	// ErrorHandler is invoked when writing a change record fails. It makes the
	// handling of a failed audit write explicit and overridable. If nil, the
	// failure is logged via slog. It does not control whether the operation
	// fails — see FailOnError.
	ErrorHandler func(error)

	// FailOnError, in synchronous mode, makes a failed change-record write return
	// its error from the CRUD operation instead of only being reported. This
	// surfaces audit gaps to the caller.
	//
	// NOTE: history is recorded after the underlying mutation succeeds, so when
	// this fires the entity change has ALREADY been applied; the returned error
	// signals that the change could not be audited, not that the mutation was
	// rolled back. For atomic audit, run the operation inside a transaction.
	// Ignored in async mode. Default: false.
	FailOnError bool
}

Options configures the behavior of a CRUD decorator.

type ParentRef

type ParentRef struct {
	// ParentType is the RecordType of the parent entity (e.g. "campaigns").
	ParentType string

	// FKField is the Go struct field name on the child that holds the parent's ID
	// (e.g. "CampaignID"). The decorator reads this field via reflection to populate
	// ParentID on each change record.
	FKField string
}

ParentRef links a child entity to its parent for tree/nested history queries. When configured, every change record for this entity will include the parent's type and ID, enabling ForTree() queries.

type RetentionEnforcer

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

RetentionEnforcer runs cleanup of old change records according to configured policies.

Usage:

enforcer := history.NewRetentionEnforcer(historyStore, history.RetentionPolicy{
    MaxAge:      90 * 24 * time.Hour,  // delete records older than 90 days
    MaxVersions: 100,                   // keep at most 100 versions per entity
})

enforcer.Enforce(ctx, "orders")

func NewRetentionEnforcer

func NewRetentionEnforcer(store r3.CRUD[ChangeRecord, string], policy RetentionPolicy) *RetentionEnforcer

NewRetentionEnforcer creates a new enforcer with the given policy.

func (*RetentionEnforcer) Enforce

func (e *RetentionEnforcer) Enforce(ctx context.Context, recordType string) int64

Enforce runs a single retention pass for the given record type. Returns the number of records deleted.

func (*RetentionEnforcer) Start

func (e *RetentionEnforcer) Start(ctx context.Context, recordType string, interval time.Duration) func()

Start runs periodic retention enforcement in a background goroutine. Returns a stop function that cancels the background loop.

type RetentionPolicy

type RetentionPolicy struct {
	// MaxAge is the maximum age of change records. Records older than this
	// are deleted during enforcement. Zero means no age-based retention.
	MaxAge time.Duration

	// MaxVersions is the maximum number of versions to keep per entity.
	// When exceeded, the oldest versions are deleted first.
	// Zero means no version-based limit.
	MaxVersions int64
}

RetentionPolicy defines rules for automatic cleanup of old change records.

type Reverter

type Reverter[T any, ID comparable] struct {
	// contains filtered or unexported fields
}

Reverter provides revert/undo capabilities for a history-tracked entity.

It reconstructs historical states purely from field-level diffs and applies them via the inner CRUD's Update method. Every revert is itself recorded as a new change record (Action: "revert"), so the audit trail is always append-only and reverting a revert is possible.

State reconstruction strategy:

  • Start from version 1 (the create record, which contains every field as a FieldChange with OldValue=nil), then replay changes forward to the target version.
  • No snapshots are needed — this is purely diff-based.

func (*Reverter[T, ID]) Reconstruct

func (r *Reverter[T, ID]) Reconstruct(ctx context.Context, recordID string, version int64) (T, error)

Reconstruct rebuilds the entity state at a specific version by replaying all FieldChanges from version 1 (the create) through the target version.

This is purely diff-based — no snapshots are needed. Version 1 (create) always contains every field as a FieldChange (OldValue=nil, NewValue=<value>), which provides the baseline. Subsequent versions' changes are applied on top.

Strategy:

  1. Fetch all records from version 1 to the target version.
  2. Start with a zero-value T.
  3. Apply changes from each version in order (v1, v2, ..., vN).
  4. Return the reconstructed entity.

func (*Reverter[T, ID]) RevertLast

func (r *Reverter[T, ID]) RevertLast(ctx context.Context, id ID) (T, error)

RevertLast undoes the most recent change by reverting to the previous version. Equivalent to RevertTo(ctx, id, latestVersion-1).

Returns ErrNoHistory if the entity has no history, or an error if there's only one version (the initial create — nothing to revert to).

func (*Reverter[T, ID]) RevertTo

func (r *Reverter[T, ID]) RevertTo(ctx context.Context, id ID, version int64) (T, error)

RevertTo restores an entity to the state it was in at a specific version.

It reconstructs the state by replaying all FieldChanges from version 1 through the target version. A new change record with Action "revert" is always created.

Returns the restored entity and an error if reconstruction fails.

type Snapshot

type Snapshot struct {
	// ID is the unique identifier for this snapshot.
	ID string `json:"id" db:"id,pk" bson:"_id"`

	// RecordType is the type name of the entity (e.g. "benefit_sheets").
	RecordType string `json:"record_type" db:"record_type" bson:"record_type"`

	// RecordID is the primary key of the entity, stringified.
	RecordID string `json:"record_id" db:"record_id" bson:"record_id"`

	// Version is the change record version that triggered this snapshot.
	Version int64 `json:"version" db:"version" bson:"version"`

	// RuleName identifies which SnapshotRule triggered this snapshot.
	// Useful when multiple rules exist for the same entity type.
	RuleName string `json:"rule_name,omitempty" db:"rule_name" bson:"rule_name,omitempty"`

	// ActorID / ActorType identify who triggered the snapshot, mirrored from the
	// change record that produced it (resolved from r3.GetActor(ctx)).
	ActorID   string `json:"actor_id,omitempty"   db:"actor_id"   bson:"actor_id,omitempty"`
	ActorType string `json:"actor_type,omitempty" db:"actor_type" bson:"actor_type,omitempty"`

	// Data is the full serialized state of the entity at snapshot time.
	// Stored as JSON bytes for portability across storage backends.
	Data json.RawMessage `json:"data" db:"data" bson:"data"`

	// Metadata carries contextual information (same as on the change record).
	// Stored as a JSON blob in SQL databases via JSONColumn.
	Metadata r3.JSONColumn[Metadata] `json:"metadata" db:"metadata" bson:"metadata"`

	// CreatedAt is the timestamp when the snapshot was taken.
	CreatedAt time.Time `json:"created_at" db:"created_at" bson:"created_at"`
}

Snapshot represents a full point-in-time capture of an entity. Snapshots are completely separate from change records — they have their own storage (separate table/collection, e.g. "original_benefit_sheets") and are triggered by custom conditions (SnapshotRule).

Unlike change records (which are always created on every mutation), snapshots are opt-in and only taken when a SnapshotRule's condition is met.

Snapshot is a first-class r3 entity: it can be stored via any r3.CRUD[Snapshot, string] implementation.

type SnapshotConditionFunc

type SnapshotConditionFunc[T any] func(action Action, old, cur T) bool

SnapshotConditionFunc evaluates whether a snapshot should be taken after a mutation. It receives the action, the old state (zero value for create), and the new state (zero value for delete). Return true to trigger a snapshot.

Example: snapshot when status changes from "draft" to "published":

func(action Action, old, cur BenefitSheet) bool {
    return old.Status == "draft" && cur.Status == "published"
}

type SnapshotRule

type SnapshotRule[T any] struct {
	// Name identifies this rule (for logging and the RuleName field on Snapshot).
	Name string

	// Store is where snapshots triggered by this rule are persisted.
	// Only [r3.Commander] is required — snapshots are only written, never read back
	// through this interface. Any r3.CRUD also satisfies Commander.
	Store r3.Commander[Snapshot, string]

	// Condition determines whether a snapshot should be taken.
	// If nil, the rule never triggers (effectively disabled).
	Condition SnapshotConditionFunc[T]
}

SnapshotRule defines when and where to take a snapshot for an entity type. Multiple rules can exist per entity — each with its own condition and store.

The Store field is any r3.Commander for Snapshot — only write access is needed.

Example:

r3history.SnapshotRule[BenefitSheet]{
    Name:  "on_publish",
    Store: snapshotCRUD,  // any r3.Commander[Snapshot, string] (or r3.CRUD)
    Condition: func(action Action, old, cur BenefitSheet) bool {
        return old.Status == "draft" && cur.Status == "published"
    },
}

type TreeScope

type TreeScope struct {
	// RecordType is the entity type to match (e.g. "campaigns", "adsets").
	RecordType string

	// RecordID limits to a specific entity instance. Empty means all of this type.
	RecordID string

	// ParentType scopes to children of a specific parent type.
	// Empty means this is a root scope (match by RecordType/RecordID only).
	ParentType string

	// ParentID scopes to children of a specific parent instance.
	// Empty with a non-empty ParentType means all children of that parent type.
	ParentID string
}

TreeScope defines a scope for querying history across a parent-child hierarchy. Used with QueryForTree to retrieve changes for a record and all its descendants.

Example: query all changes for Campaign #5 and its adsets:

[]TreeScope{
    {RecordType: "campaigns", RecordID: "5"},
    {RecordType: "adsets", ParentType: "campaigns", ParentID: "5"},
}

Jump to

Keyboard shortcuts

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