changetracker

package module
v1.3.1 Latest Latest
Warning

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

Go to latest
Published: Feb 25, 2026 License: MIT Imports: 7 Imported by: 0

README

Change Tracker

A Go package for variable management with automatic change detection. Track values in nested data structures and detect changes with priority-based sorting.

No observer pattern, no event emitters, no interfaces to implement. Just point the tracker at your existing data structures and detect what changed.

Features

  • Variable Tracking - Track values in nested object hierarchies with parent-child relationships
  • Change Detection - Automatic detection via value comparison with DetectChanges()
  • Priority Sorting - Changes returned sorted by priority (high → medium → low)
  • Object Registry - Consistent identity for objects via weak references (Go 1.24+)
  • Path Navigation - Navigate nested structures: "Address.City", "items.0", "GetName()"
  • Pluggable Resolvers - Custom navigation strategies for complex domains
  • Wrappers - Provide alternative objects for child navigation via custom resolvers
  • Recomputation Timing - Per-variable ComputeTime and MaxComputeTime for profiling
  • Diagnostics - Per-variable diagnostic collection via Diag() for debugging resolvers
  • Structured Errors - Typed VariableError with categorized error types
  • Zero Coupling - Domain objects require no modification

Installation

go get github.com/zot/change-tracker

Requires Go 1.24+ (uses weak package).

Quick Start

tracker := changetracker.NewTracker()

// Create a root variable holding your data
data := &MyData{Name: "Alice", Count: 42}
root := tracker.CreateVariable(data, 0, "", nil)

// Create child variables to track nested values
name := tracker.CreateVariable(nil, root.ID, "Name", nil)
count := tracker.CreateVariable(nil, root.ID, "Count?priority=high", nil)

// Make changes to your data...
data.Name = "Bob"
data.Count = 100

// Detect what changed (sorted by priority: high → medium → low)
changes := tracker.DetectChanges()
for _, change := range changes {
    fmt.Printf("Variable %d: value=%v props=%v\n",
        change.VariableID, change.ValueChanged, change.PropertiesChanged)
}

Access Modes

Mode Read Write Change Detection Initial Value
rw (default) computed
r computed
w computed
action skipped

Set via path query: "field?access=r" or properties map.

The action mode is for variables that trigger side effects (like AddContact(_)) where computing the initial value would invoke the action prematurely.

Path Navigation

Paths navigate from a parent variable's value to a nested value:

// Struct fields
tracker.CreateVariable(nil, root.ID, "Address.City", nil)

// Slice indices
tracker.CreateVariable(nil, root.ID, "Items.0.Name", nil)

// Zero-arg method calls (getters)
tracker.CreateVariable(nil, root.ID, "GetName()", map[string]string{"access": "r"})

// One-arg method calls (setters)
tracker.CreateVariable(nil, root.ID, "SetName(_)", map[string]string{"access": "action"})

// URL-style query parameters set properties
tracker.CreateVariable(nil, root.ID, "Count?priority=high&label=counter", nil)

Priority Levels

Priority Value Use Case
High 1 Critical changes to process first
Medium 0 Default priority
Low -1 Background/deferred changes

Set via path properties: "field?priority=high"

Properties can also have independent priorities via suffix: v.SetProperty("label:high", "Important")

Active Flag

Variables can be deactivated to skip change detection for an entire subtree:

v.SetActive(false)  // v and all descendants skipped during DetectChanges
v.SetActive(true)   // re-enable

Wrappers

Custom resolvers can provide alternative objects for child navigation:

type myResolver struct { *changetracker.Tracker }

func (r *myResolver) CreateWrapper(v *changetracker.Variable) any {
    if p, ok := v.Value.(*Person); ok {
        return &PersonView{DisplayName: p.First + " " + p.Last}
    }
    return nil
}

tr := changetracker.NewTracker()
tr.Resolver = &myResolver{tr}
parent := tr.CreateVariable(person, 0, "?wrapper=true", nil)
// Children now navigate through PersonView, not Person
child := tr.CreateVariable(nil, parent.ID, "DisplayName", nil)

Wrappers support state preservation — returning the same pointer from CreateWrapper keeps the wrapper's internal state intact across value changes.

Recomputation Timing

Each variable tracks how long its own path navigation takes:

child := tracker.CreateVariable(nil, root.ID, "Address.City", nil)
val, _ := child.Get()

fmt.Println(child.ComputeTime)    // duration of most recent recompute
fmt.Println(child.MaxComputeTime) // peak duration across all recomputes

Timing measures only the variable's own path navigation, excluding parent value retrieval.

Diagnostics

Custom resolvers can emit per-variable diagnostics during path navigation:

tracker.DiagLevel = 1  // enable level-1 diagnostics

// In your custom resolver's Get method:
func (r *myResolver) Get(obj any, pathElement any) (any, error) {
    r.Tracker.Diag(1, "resolving %v on %T", pathElement, obj)
    return r.Tracker.Get(obj, pathElement)
}

// After Get() or DetectChanges(), check variable.Diags
val, _ := child.Get()
for _, msg := range child.Diags {
    fmt.Println(msg)
}

Diagnostics are cleared at the start of each recompute. DiagLevel = 0 (default) disables collection.

Structured Errors

Operations return *VariableError with typed error categories:

_, err := variable.Get()
if ve, ok := variable.Error.(*changetracker.VariableError); ok {
    switch ve.ErrorType {
    case changetracker.PathError:   // path navigation failed
    case changetracker.NotFound:    // variable or parent not found
    case changetracker.BadAccess:   // access mode violation
    case changetracker.NilPath:     // nil value in path
    case changetracker.BadCall:     // method call failed
    }
}

Object Registry

The tracker maintains a weak map from Go objects (pointers and maps) to IDs, providing consistent identity without modifying domain types:

alice := &Person{Name: "Alice"}
root := tracker.CreateVariable(alice, 0, "", nil)

// Same object always serializes to the same reference
json := tracker.ToValueJSON(alice)  // {"obj": 1}

// Weak references — objects can be garbage collected normally
id, ok := tracker.LookupObject(alice)
obj := tracker.GetObject(id)

Concurrency

Change detection happens in a single thread. If your data structures are accessed from multiple goroutines, provide a custom Resolver implementation that implements safe access.

Documentation

License

MIT

Documentation

Overview

Package changetracker provides variable management with automatic change detection.

The package provides:

  • A change tracker that manages variables and detects changes
  • Variables that hold values and track parent-child relationships
  • Object registry with weak references for consistent object identity
  • Value JSON serialization with object references
  • Change detection via value comparison
  • Pluggable value resolution for navigating into objects

Basic Usage

Create a tracker and register variables:

tracker := changetracker.NewTracker()
data := &MyData{Count: 42}
root := tracker.CreateVariable(data, 0, "", nil)

// Create a child variable for a field
countVar := tracker.CreateVariable(nil, root.ID, "Count", nil)

// Modify value externally
data.Count = 100

// Detect changes
changes := tracker.DetectChanges()

Path Navigation

Variables use dot-separated paths to navigate into values:

// Navigate to nested fields
cityVar := tracker.CreateVariable(nil, root.ID, "Address.City", nil)

// Use method calls in paths (requires appropriate access mode)
nameVar := tracker.CreateVariable(nil, root.ID, "GetName()?access=r", nil)

// Use setter methods (requires access "w" or "action")
setterVar := tracker.CreateVariable(nil, root.ID, "SetValue(_)?access=w", nil)

Access Modes

Variables support four access modes that control read/write permissions:

| Mode   | Get | Set | Change Detection | Initial Value |
|--------|-----|-----|------------------|---------------|
| rw     | OK  | OK  | Yes              | Computed      |
| r      | OK  | Err | Yes              | Computed      |
| w      | Err | OK  | No               | Computed      |
| action | Err | OK  | No               | Skipped       |

Path restrictions apply based on access mode:

  • Paths ending in () require access "r" or "action"
  • Paths ending in (_) require access "w" or "action"

The "action" mode is designed for variables that trigger side effects, where computing the initial value would invoke the action prematurely.

Priority System

Values and properties can have priority levels (Low, Medium, High). Changes are returned sorted by priority (high first):

// Set priority via path query
v := tracker.CreateVariable(nil, root.ID, "Count?priority=high", nil)

// Set property with priority suffix
v.SetProperty("label:high", "Important")

Object Registry

The tracker maintains a weak map from Go objects (pointers/maps) to variable IDs. This enables consistent object identity in Value JSON serialization:

alice := &Person{Name: "Alice"}
tracker.CreateVariable(alice, 0, "", nil)  // ID 1

// Serialize to Value JSON - registered objects become {"obj": id}
json := tracker.ToValueJSON(alice)  // {"obj": 1}

Unregistered objects found during ToValueJSON serialization are auto-registered. This enables arrays of objects to be properly serialized as arrays of object references:

people := []*Person{{Name: "Alice"}, {Name: "Bob"}}
json := tracker.ToValueJSON(people)  // [{"obj": 2}, {"obj": 3}]

Per protocol spec: "Arrays contain only variable values (no nested objects, only references)"

Change Detection

DetectChanges performs a depth-first traversal from root variables, comparing current values to cached Value JSON:

changes := tracker.DetectChanges()
for _, change := range changes {
    fmt.Printf("Variable %d changed: value=%v props=%v\n",
        change.VariableID, change.ValueChanged, change.PropertiesChanged)
}

Active/inactive variables control which subtrees participate in detection. Non-readable variables (access "w" or "action") are skipped.

Package changetracker provides variable management with automatic change detection. CRC: crc-Tracker.md, crc-Variable.md, crc-Resolver.md, crc-ObjectRef.md, crc-ObjectRegistry.md, crc-Change.md, crc-Priority.md Spec: main.md, api.md

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func GetObjectRefID

func GetObjectRefID(value any) (int64, bool)

GetObjectRefID extracts the ID from an ObjectRef. CRC: crc-ObjectRef.md

func IsObjectRef

func IsObjectRef(value any) bool

IsObjectRef checks if a value is an ObjectRef. CRC: crc-ObjectRef.md

func JsonEqual

func JsonEqual(a, b any) bool

JsonEqual compares two Value JSON values for equality.

Types

type Change

type Change struct {
	VariableID        int64
	Priority          Priority
	ValueChanged      bool
	PropertiesChanged []string
}

Change represents a change to a variable. CRC: crc-Change.md Spec: api.md

type ObjectRef

type ObjectRef struct {
	Obj int64 `json:"obj"`
}

ObjectRef represents an object reference in Value JSON form. CRC: crc-ObjectRef.md Spec: value-json.md

type Priority

type Priority int

Priority represents priority level for values and properties. CRC: crc-Priority.md Spec: api.md

const (
	PriorityLow    Priority = -1
	PriorityMedium Priority = 0 // default
	PriorityHigh   Priority = 1
)

func ParsePriority

func ParsePriority(s string) Priority

ParsePriority converts a string to a Priority.

type Resolver

type Resolver interface {
	// Get retrieves a value at the given path element within obj.
	// pathElement can be:
	//   - string: field name or map key
	//   - int: slice/array index (0-based)
	Get(obj any, pathElement any) (any, error)

	// Set assigns a value at the given path element within obj.
	Set(obj any, pathElement any, value any) error

	// Call invokes a zero-argument method and returns its result.
	// Used for getter-style methods in path navigation.
	Call(obj any, methodName string) (any, error)

	// CallWith invokes a one-argument method with the given value.
	// Return values are ignored.
	// Used for setter-style methods at path terminals and variadic methods with rw access.
	CallWith(obj any, methodName string, value any) error

	// CreateValue creates a value for the given variable.
	// This happens when CreateVariable has a "create" property
	CreateValue(variable *Variable, typ string, value any) any

	// CreateWrapper creates a wrapper object for the given variable.
	// The wrapper stands in for the variable's value when child variables navigate paths.
	// Returns nil if no wrapper is needed.
	CreateWrapper(variable *Variable) any

	// Get the type for a value
	GetType(variable *Variable, value any) string

	// ConvertToValueJSON converts a value to its JSON-serializable form.
	// Custom resolvers override this to handle domain-specific types (e.g., Lua tables).
	ConvertToValueJSON(tracker *Tracker, value any) any
}

Resolver is the interface for navigating into values. CRC: crc-Resolver.md Spec: resolver.md

type Tracker

type Tracker struct {
	Resolver    Resolver // defaults to the tracker itself
	DiagLevel   int      // diagnostic level (0 = disabled)
	ChangeCount int64    // incremented each time DetectChanges() finds changes

	PropertyChanges map[int64]*propertyChange // variables with property changes

	// Diagnostics
	ComputingVar *Variable // variable currently having its value computed
	// contains filtered or unexported fields
}

Tracker is the central change tracker. CRC: crc-Tracker.md Spec: main.md, api.md

func NewTracker

func NewTracker() *Tracker

NewTracker creates a new change tracker. Sequence: seq-create-variable.md

func (*Tracker) Call

func (t *Tracker) Call(obj any, methodName string) (any, error)

Call implements the Resolver interface for zero-arg method invocation. Sequence: seq-get-value.md

func (*Tracker) CallWith

func (t *Tracker) CallWith(obj any, methodName string, value any) error

CallWith implements the Resolver interface for one-arg void method invocation. Sequence: seq-set-value.md

func (*Tracker) ChangeAll

func (t *Tracker) ChangeAll(varID int64)

func (*Tracker) Children

func (t *Tracker) Children(parentID int64) []*Variable

Children returns child variables of a given parent. CRC: crc-Tracker.md

func (*Tracker) ConvertToValueJSON

func (t *Tracker) ConvertToValueJSON(tracker *Tracker, value any) any

ConvertToValueJSON implements the Resolver interface. The default implementation returns the value unchanged.

func (*Tracker) CreateValue

func (t *Tracker) CreateValue(variable *Variable, typ string, value any) any

CreateWrapper implements the Resolver interface. The default implementation returns value (no creation).

func (*Tracker) CreateVariable

func (t *Tracker) CreateVariable(value any, parentID int64, path string, properties map[string]string) *Variable

CreateVariable creates a new variable in the tracker with an auto-assigned ID. Sequence: seq-create-variable.md

func (*Tracker) CreateVariableWithId

func (t *Tracker) CreateVariableWithId(id int64, value any, parentID int64, path string, properties map[string]string) *Variable

CreateVariableWithId creates a new variable in the tracker with a caller-specified ID. Returns nil if the ID is already in use. Sequence: seq-create-variable.md

func (*Tracker) CreateWrapper

func (t *Tracker) CreateWrapper(variable *Variable) any

CreateWrapper implements the Resolver interface. The default implementation returns nil (no wrapper).

func (*Tracker) DestroyVariable

func (t *Tracker) DestroyVariable(id int64)

DestroyVariable removes a variable from the tracker. CRC: crc-Tracker.md Sequence: seq-destroy-variable.md

func (*Tracker) DetectChanges

func (t *Tracker) DetectChanges() bool

DetectChanges collects active variables via tree traversal, then checks them in priority order (high → medium → low). CRC: crc-Tracker.md Sequence: seq-detect-changes.md

func (*Tracker) Diag

func (t *Tracker) Diag(level int, format string, args ...any)

Diag adds a diagnostic message to the currently-computing variable's Diags slice. CRC: crc-Tracker.md

func (*Tracker) FromValueJSONBytes

func (t *Tracker) FromValueJSONBytes(value []byte) (any, error)

func (*Tracker) Get

func (t *Tracker) Get(obj any, pathElement any) (any, error)

Get implements the Resolver interface using reflection. Sequence: seq-get-value.md

func (*Tracker) GetByIndex

func (t *Tracker) GetByIndex(rv reflect.Value, index int) (any, error)

func (*Tracker) GetByString

func (t *Tracker) GetByString(rv reflect.Value, name string) (any, error)

func (*Tracker) GetChanges

func (t *Tracker) GetChanges() []Change

func (*Tracker) GetObject

func (t *Tracker) GetObject(objID int64) any

GetObject retrieves an object by its object ID. CRC: crc-Tracker.md, crc-ObjectRegistry.md

func (*Tracker) GetType

func (t *Tracker) GetType(variable *Variable, value any) string

GetType implements the Resolver interface. The default implementation returns "" (no type).

func (*Tracker) GetVariable

func (t *Tracker) GetVariable(id int64) *Variable

GetVariable retrieves a variable by ID. CRC: crc-Tracker.md

func (*Tracker) LookupObject

func (t *Tracker) LookupObject(obj any) (int64, bool)

LookupObject finds the object ID for a registered object. CRC: crc-Tracker.md, crc-ObjectRegistry.md Sequence: seq-to-value-json.md

func (*Tracker) RecordPropertyChange

func (t *Tracker) RecordPropertyChange(varID int64, propName string)

RecordPropertyChange records that a property changed for a variable. CRC: crc-Tracker.md Sequence: seq-set-property.md

func (*Tracker) RegisterObject

func (t *Tracker) RegisterObject(obj any) (int64, bool)

RegisterObject registers an object and returns its ID. Returns (id, true) if registered or already registered, (0, false) if not registerable. CRC: crc-Tracker.md, crc-ObjectRegistry.md Sequence: seq-to-value-json.md

func (*Tracker) RootVariables

func (t *Tracker) RootVariables() []*Variable

RootVariables returns variables with no parent (parentID == 0). CRC: crc-Tracker.md

func (*Tracker) Set

func (t *Tracker) Set(obj any, pathElement any, value any) error

Set implements the Resolver interface using reflection. Sequence: seq-set-value.md

func (*Tracker) ToValueJSON

func (t *Tracker) ToValueJSON(value any) any

ToValueJSON serializes a value to Value JSON form. Sequence: seq-to-value-json.md Spec: protocol.md - "Arrays contain only variable values (no nested objects, only references)"

func (*Tracker) ToValueJSONBytes

func (t *Tracker) ToValueJSONBytes(value any) ([]byte, error)

ToValueJSONBytes serializes a value to Value JSON as a byte slice. CRC: crc-Tracker.md

func (*Tracker) UnregisterObject

func (t *Tracker) UnregisterObject(obj any)

UnregisterObject removes an object from the registry. CRC: crc-Tracker.md, crc-ObjectRegistry.md

func (*Tracker) Variables

func (t *Tracker) Variables() []*Variable

Variables returns all variables in the tracker. CRC: crc-Tracker.md

type Variable

type Variable struct {
	ID                 int64
	ParentID           int64
	ChildIDs           []int64 // IDs of child variables (maintained automatically)
	Active             bool    // whether this variable and its children are checked for changes
	Access             string  // access mode: "r" (read-only), "w" (write-only), "rw" (read-write, default)
	Properties         map[string]string
	PropertyPriorities map[string]Priority
	Path               []any         // parsed path elements
	Value              any           // cached value for child navigation
	ValueJSON          any           // cached Value JSON for change detection
	ValuePriority      Priority      // priority of the value
	WrapperValue       any           // wrapper object for child navigation (optional)
	WrapperJSON        any           // serialized WrapperValue
	Error              error         // error from last get or nil if none
	ComputeTime        time.Duration // duration of the most recent value recomputation
	MaxComputeTime     time.Duration // maximum ComputeTime observed across all recomputations
	ChangeCount        int64         // number of times value changed during DetectChanges
	Diags              []string      // diagnostics from most recent value recomputation
	// contains filtered or unexported fields
}

Variable is a tracked variable. CRC: crc-Variable.md Spec: main.md, api.md, resolver.md

func (*Variable) Get

func (v *Variable) Get() (any, error)

Get gets the variable's value by navigating from the parent's value using the path. Sequence: seq-get-value.md

func (*Variable) GetAccess

func (v *Variable) GetAccess() string

GetAccess returns the access mode of the variable. CRC: crc-Variable.md

func (*Variable) GetId

func (v *Variable) GetId() int64

func (*Variable) GetProperty

func (v *Variable) GetProperty(name string) string

GetProperty returns a property value, or empty string if not set. CRC: crc-Variable.md

func (*Variable) GetPropertyPriority

func (v *Variable) GetPropertyPriority(name string) Priority

GetPropertyPriority returns the priority for a property. CRC: crc-Variable.md

func (*Variable) GetValue

func (v *Variable) GetValue() (any, error)

GetValue is the internal method that navigates to the value without access checks. Used for caching values during CreateVariable and DetectChanges.

func (*Variable) IsAction

func (v *Variable) IsAction() bool

IsAction returns true if the variable is an action trigger (access "action"). CRC: crc-Variable.md

func (*Variable) IsReadable

func (v *Variable) IsReadable() bool

IsReadable returns true if the variable allows reading (access "r" or "rw"). CRC: crc-Variable.md

func (*Variable) IsWritable

func (v *Variable) IsWritable() bool

IsWritable returns true if the variable allows writing (access "w", "rw", or "action"). CRC: crc-Variable.md

func (*Variable) JsonForUpdate added in v1.2.0

func (v *Variable) JsonForUpdate() any

func (*Variable) NavigationValue

func (v *Variable) NavigationValue() any

NavigationValue returns the value used for child path navigation. Returns WrapperValue if present, otherwise Value. CRC: crc-Variable.md

func (*Variable) Parent

func (v *Variable) Parent() *Variable

Parent returns the parent variable, or nil if this is a root variable. CRC: crc-Variable.md

func (*Variable) Set

func (v *Variable) Set(value any) error

Set sets the variable's value by navigating from the parent's value using the path. Sequence: seq-set-value.md

func (*Variable) SetActive

func (v *Variable) SetActive(active bool)

SetActive sets whether the variable and its children should be checked for changes. CRC: crc-Variable.md

func (*Variable) SetProperty

func (v *Variable) SetProperty(name, value string)

SetProperty sets a property. Empty value removes the property. Sequence: seq-set-property.md

func (*Variable) SetType

func (v *Variable) SetType()

type VariableError

type VariableError struct {
	ErrorType VariableErrorType
	Message   string
	Cause     error
}

func (*VariableError) Error

func (v *VariableError) Error() string

type VariableErrorType

type VariableErrorType int64
const (
	NoError VariableErrorType = iota
	PathError
	NotFound
	BadSetterCall
	BadAccess
	BadIndex
	BadReference
	BadParent
	BadCall
	NilPath
	DeferredCode
)

func (VariableErrorType) String

func (e VariableErrorType) String() string

Jump to

Keyboard shortcuts

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