epoch

package module
v2.0.0-beta.1 Latest Latest
Warning

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

Go to latest
Published: Oct 6, 2026 License: MIT Imports: 13 Imported by: 0

README

Epoch

Backtest your business rules against real history.

Epoch is a Go library that records every command your application receives, including the ones it rejects, alongside the events it produces. That record lets you:

  • Time travel. See any entity, or any report, exactly as it was at any moment.
  • Branch history. Fork the past into an isolated, copy-on-write branch and change it.
  • Replay with new rules. Re-run months of real commands through a changed pricing, credit, fraud or stock rule, and get a precise list of every decision that would have come out differently.
  • Compare. Diff any read model between what happened and what would have happened.
$ go run ./examples/shop

3. What if customers of 12+ months had had 15% off all year?
   Replayed 2,280 commands on branch "loyalty" in 13ms.
   1,477 orders turned out differently: 0 now accepted, 0 now rejected, 1,477 re-priced.

                                actual        loyalty     difference
   orders                        2,068          2,068              ·
   discounts given          $24,617.30     $68,033.70    +$43,416.40
   revenue                 $544,745.70    $501,329.30    -$43,416.40

Epoch Studio

The core library has zero dependencies and needs Go 1.22 or newer. Storage is pluggable: an in-memory store and a pure-Go SQLite store (no cgo) are included, and a conformance suite lets you add your own.

Why

When a team changes a business rule (a discount, a credit limit, a fraud threshold, a stock policy), the impact is usually estimated with a spreadsheet and a guess. The data needed to know is gone. Your database stores outcomes, and a rejected order leaves no trace at all.

Epoch keeps the inputs. Because each decision is a pure function of state and command, Epoch can re-make every past decision with the new rule, in order, with the state each decision would actually have seen. Accepting one extra order early in the month correctly changes the stock that every later order sees.

Install

go get github.com/HarshalPatel1972/epoch/v2
go get github.com/HarshalPatel1972/epoch/v2/sqlitestore   # optional: durable storage

A minimal example

A model is your business logic as two pure functions: Decide turns a command into events or rejects it, and Evolve folds events into state.

type Account struct{ Balance, Limit int64 }

type (
	Open     struct{ Limit int64 }   // commands: what you were asked to do
	Deposit  struct{ Amount int64 }
	Withdraw struct{ Amount int64 }

	Opened    struct{ Limit int64 }  // events: what happened
	Deposited struct{ Amount int64 }
	Withdrew  struct{ Amount int64 }
)

var Accounts = &epoch.Model[Account]{
	Name:     "account",
	Commands: []any{Open{}, Deposit{}, Withdraw{}},
	Events:   []any{Opened{}, Deposited{}, Withdrew{}},
	Evolve: func(a Account, e any) Account {
		switch e := e.(type) {
		case Opened:
			a.Limit = e.Limit
		case Deposited:
			a.Balance += e.Amount
		case Withdrew:
			a.Balance -= e.Amount
		}
		return a
	},
	Decide: func(a Account, c any, now time.Time) ([]any, error) {
		switch c := c.(type) {
		case Open:
			return []any{Opened(c)}, nil
		case Deposit:
			return []any{Deposited(c)}, nil
		case Withdraw:
			if a.Balance-c.Amount < -a.Limit {
				return nil, ErrInsufficientFunds
			}
			return []any{Withdrew(c)}, nil
		}
		return nil, fmt.Errorf("unknown command %T", c)
	},
}

Use it like any other domain service:

store, _ := sqlitestore.Open("bank.db")

res, err := Accounts.Handle(ctx, store, "alice", Withdraw{Amount: 500})
if errors.Is(err, epoch.ErrRejected) {
	// The rejection is recorded too. A replay can find out whether new rules
	// would have accepted it.
}

now, _ := Accounts.Load(ctx, store, "alice")
then, _ := Accounts.Load(ctx, store, "alice", epoch.AsOf(lastFriday))

Then ask what a different rule would have done:

stricter := *Accounts
stricter.Decide = decideWithLowerOverdraft

report, _ := epoch.Replay(ctx, store, epoch.ReplayOptions{
	Name:   "overdraft-20",
	From:   startOfQuarter,
	Models: []epoch.AnyModel{&stricter},
})
fmt.Println(report.NowRejected, "withdrawals would have been declined")
fmt.Println(report.NowAccepted, "withdrawals that were declined would have gone through")

cmp, _ := epoch.Compare(ctx, store, LedgerView, epoch.Main, "overdraft-20")
for _, c := range cmp.Changes {
	fmt.Println(c.Path, c.From, "→", c.To)
}

Each of these is a runnable example in the package documentation.

Try the demo

git clone https://github.com/HarshalPatel1972/epoch
cd epoch/v2
go run ./examples/shop              # the tour, in your terminal
go run ./examples/shop -serve :8080 # then explore it in Epoch Studio

The demo generates six months of history for a small shop, then answers three questions:

  1. What was in stock on 1 March?
  2. What if we had allowed backorders when stock ran out? (Fewer orders are recovered than you might expect, because backorders eat into later stock. Replay accounts for that.)
  3. What would a 15% loyalty discount have cost?

Concepts

Model One kind of entity: Decide (command → events or error) and Evolve (state + event → state). Must be deterministic.
Commit One entry in the log: the command, the events it produced or the rejection reason, a time, and a global sequence number.
Branch A line of history. main always exists; Fork creates a copy-on-write branch at any point in its parent's history. Branches nest.
Replay Forks a branch at a point in the past and re-decides every later command with the Models you pass. Other commits are copied as recorded. Returns a Report of every changed outcome.
Projection A read model folded from the whole log: a report, a dashboard, a balance sheet. It can be computed on any branch, at any time.
Fact Events that are not decisions, such as a payment confirmed by a provider. Record them with Model.Record; replays copy them unchanged.
Options

Reads and writes take options: epoch.On(branch), epoch.AsOf(t), epoch.AtSeq(n), epoch.WithTime(t) (backdating, for imports and tests) and epoch.CommandID(id) (idempotency: a repeated ID returns the original outcome instead of executing twice).

Epoch Studio

epochhttp serves a web UI and JSON API for exploring history: an activity chart with a time slider, the branch tree, the log, an entity inspector, branch comparison, and a replay runner. Every view is a shareable link.

mux.Handle("/epoch/", http.StripPrefix("/epoch", epochhttp.New(epochhttp.Config{
	Store:       store,
	Models:      []epoch.AnyModel{Accounts},
	Projections: []epoch.AnyProjection{LedgerView},
	Policies:    map[string]epoch.AnyModel{"overdraft 20": &stricter},
	AllowWrites: true, // allow forks and replays from the UI
})))

Studio has no authentication of its own and shows your full history. Mount it behind your existing auth or on an internal address. It is read-only unless AllowWrites is set, and its write endpoints only accept requests a browser cannot forge from another site.

Replay report Compare branches

Storage

Package Use it for
memstore Tests, examples, short-lived processes.
sqlitestore Single-node applications. Pure Go (modernc.org/sqlite), WAL mode, needs Go 1.25+. Separate module, so the core stays dependency-free.

A store implements a small interface: append with an optimistic version check, read a branch's commits, and save branches and snapshots. Branch inheritance is resolved by Epoch, so stores never deal with it. To write a store for Postgres, DynamoDB or anything else, run the conformance suite against it:

func TestConformance(t *testing.T) {
	epochtest.Run(t, func(t *testing.T) epoch.Store { return mystore.New(t) })
}

How Epoch compares

Epoch is not a database, and does not try to replace one. It overlaps with several tools:

Time travel Branches Re-run history with new logic
SQL temporal tables, XTDB, Datomic as-of ✓ – –
Dolt, Neon, PlanetScale branching ✓ ✓ – (branches hold data, not decisions)
Datomic d/with ✓ speculative –
EventStoreDB, Marten, Axon ✓ – rebuild projections, not decisions
Epoch ✓ ✓ ✓

Event-sourcing frameworks can rebuild a read model from stored events. Epoch also re-makes the decisions, because it stores the commands that caused them, including rejected ones.

Guarantees and limits

  • Determinism is your side of the contract. Replays are only meaningful if Decide depends on nothing but state, command and the now it is given. Put external data (prices, scores, exchange rates) in the command so it is recorded.
  • Replays re-run inputs, not behaviour. A replay tells you what your system would have done with the requests it actually received. It cannot tell you how customers would have reacted to different rules.
  • Concurrency. Writes to one entity use optimistic concurrency; Handle retries conflicts automatically. Different entities never block each other.
  • Time never goes backwards on a branch. A commit's time is the later of the requested time and the branch's previous commit, so AsOf reads are exact.
  • Snapshots keep loads fast: every 100 events by default (Model.SnapshotEvery). Without them, each command re-reads the entity's full history. Change Model.Schema when your state shape or Evolve changes, and old snapshots are ignored.
  • Projections fold the whole visible log on every Get. That is fast for tens of thousands of commits (see below). For larger histories, cache results or maintain your own read models.
  • Panics in Decide are returned as errors and never recorded as business outcomes. A replay that hits one stops and removes its partial branch.

Performance

Measured on a laptop (Intel Core i3-1220P, 16 GB, Windows 11, Go 1.26) with go test -bench . ./memstore ./sqlitestore:

Operation memstore sqlitestore
Handle a command (entity with a long history) 42 µs 0.46 ms
Handle across 1,000 entities 63 µs 0.34 ms
Load an entity with 10,000 events, snapshots on 2.4 µs 82 µs
Load an entity with 10,000 events, snapshots off 15 ms 98 ms
Replay 10,000 commands with new rules 35 ms (285k/s) 0.59 s (17k/s)
Projection.Get over 10,000 commits 21 ms 86 ms

SQLite writes use one serialised writer, which suits a single application node. Replays batch their writes into one transaction per 500 commits.

Project status

Epoch v2 is new. The API is stable in shape, but may change before v2.0.0 based on feedback. Pin a version, and please open an issue with anything that gets in your way.

Epoch v1, an earlier demo HTTP server, has been retired; its code is preserved at the v1-final tag.

Contributing

Bug reports, store implementations and examples are very welcome. See CONTRIBUTING.md.

License

MIT

Documentation

Overview

Package epoch records what your application was asked to do, not just what it did, so you can travel back to any moment, branch history, and re-run the past under new business rules.

Model

Application logic is written as a Model: a pure Decide function that turns a command into events (or rejects it), and a pure Evolve function that folds events into state. Every call to Model.Handle appends one Commit to the log. A commit stores the command, the resulting events, and, when Decide returned an error, the rejection reason. Recording rejected commands is what lets a replay discover work that new rules would have accepted.

Time travel

Reads take AsOf or AtSeq options. Model.Load rebuilds one entity; Projection.Get folds the whole log into a read model.

Branches

Fork creates a copy-on-write branch of the log at any point. Writes to a branch never touch its parent. Branches are stored, can be nested, and are read with the same API as the main timeline by passing On.

Replay

Replay forks a branch at a point in the past and re-executes every recorded command after it through the Models you pass, which typically carry new Decide rules. Commits from other models are copied as recorded. The Report lists every command whose outcome changed, and Compare diffs any projection between the original and the replayed branch.

Replay re-runs the inputs your system actually received. It cannot predict how people would have behaved differently under different rules, so it answers "what would our system have done", not "what would have happened".

Determinism

Replay is only meaningful if Decide is deterministic: it should depend on the current state, the command, and the decision time passed to it, and nothing else. Data fetched from elsewhere (prices, scores, exchange rates) belongs in the command so that it is recorded with it.

Example
package main

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/HarshalPatel1972/epoch/v2"
	"github.com/HarshalPatel1972/epoch/v2/memstore"
)

// A bank account with an overdraft limit.
type Account struct {
	Balance int64
	Limit   int64
}

// Commands are what the application is asked to do.
type (
	Open     struct{ Limit int64 }
	Deposit  struct{ Amount int64 }
	Withdraw struct{ Amount int64 }
)

// Events are what happened.
type (
	Opened    struct{ Limit int64 }
	Deposited struct{ Amount int64 }
	Withdrew  struct{ Amount int64 }
)

var ErrInsufficientFunds = errors.New("insufficient funds")

var Accounts = &epoch.Model[Account]{
	Name:     "account",
	Commands: []any{Open{}, Deposit{}, Withdraw{}},
	Events:   []any{Opened{}, Deposited{}, Withdrew{}},
	Evolve: func(a Account, e any) Account {
		switch e := e.(type) {
		case Opened:
			a.Limit = e.Limit
		case Deposited:
			a.Balance += e.Amount
		case Withdrew:
			a.Balance -= e.Amount
		}
		return a
	},
	Decide: func(a Account, c any, _ time.Time) ([]any, error) {
		switch c := c.(type) {
		case Open:
			return []any{Opened(c)}, nil
		case Deposit:
			return []any{Deposited(c)}, nil
		case Withdraw:
			if a.Balance-c.Amount < -a.Limit {
				return nil, ErrInsufficientFunds
			}
			return []any{Withdrew(c)}, nil
		}
		return nil, fmt.Errorf("unknown command %T", c)
	},
}

func main() {
	ctx := context.Background()
	store := memstore.New()

	Accounts.Handle(ctx, store, "alice", Open{Limit: 100})
	Accounts.Handle(ctx, store, "alice", Deposit{Amount: 50})
	_, err := Accounts.Handle(ctx, store, "alice", Withdraw{Amount: 500})
	fmt.Println(err)
	fmt.Println(errors.Is(err, epoch.ErrRejected), errors.Is(err, ErrInsufficientFunds))

	st, _ := Accounts.Load(ctx, store, "alice")
	fmt.Println("balance:", st.Value.Balance)
}
Output:
epoch: command rejected: insufficient funds
true true
balance: 50

Index

Examples

Constants

View Source
const (
	KindFork   = "fork"
	KindReplay = "replay"
)

Branch kinds.

View Source
const (
	Added    = "added"
	Removed  = "removed"
	Modified = "modified"
)

Change kinds.

View Source
const (
	// NowAccepted: the original command was rejected, the replay accepted it.
	NowAccepted = "now_accepted"
	// NowRejected: the original command was accepted, the replay rejected it.
	NowRejected = "now_rejected"
	// EventsChanged: both accepted the command but produced different events.
	EventsChanged = "events_changed"
)

Divergence kinds.

View Source
const Any int64 = -1

Any disables the expected-version check in AppendCondition.

View Source
const DefaultSnapshotEvery = 100

DefaultSnapshotEvery is the snapshot interval used when Model.SnapshotEvery is 0.

View Source
const Main = "main"

Main is the name of the root branch. It always exists.

Variables

View Source
var (
	// ErrConflict is returned by Store.Append when the stream's version does
	// not match the expected version. Model.Handle retries it automatically.
	ErrConflict = errors.New("epoch: stream was modified concurrently")

	// ErrRejected wraps the error returned by a Model's Decide function.
	// errors.Is(err, ErrRejected) tells rejections from failures, and
	// errors.Is / errors.As on the same error reach the Decide error.
	ErrRejected = errors.New("epoch: command rejected")

	ErrBranchExists      = errors.New("epoch: branch already exists")
	ErrBranchNotFound    = errors.New("epoch: branch not found")
	ErrBranchHasChildren = errors.New("epoch: branch has child branches")
	ErrInvalidBranchName = errors.New("epoch: invalid branch name")

	// ErrUnknownType is returned when a command or event type is not
	// registered with the Model handling it.
	ErrUnknownType = errors.New("epoch: unknown type")
)

Functions

func As

func As[T any](p Payload) (T, bool)

As decodes p into T if p was recorded as type T. It reports false if the type names differ or the data does not decode into T.

if d, ok := epoch.As[Deposited](rec); ok { total += d.Amount }

func DeleteBranch

func DeleteBranch(ctx context.Context, s Store, name string) error

DeleteBranch deletes a branch and everything written to it. Branches forked from it must be deleted first.

func Head(ctx context.Context, s Store, branch string) (int64, error)

Head returns the Seq of the newest commit visible on a branch, or 0.

func TypeName

func TypeName(v any) (string, error)

TypeName returns the name v's type is recorded under.

Types

type AnyModel

type AnyModel interface {
	ModelName() string
	LoadAny(ctx context.Context, s Store, id string, opts ...Option) (State[any], error)
	History(ctx context.Context, s Store, id string, opts ...Option) ([]Commit, error)
	// contains filtered or unexported methods
}

AnyModel is implemented by every *Model[S]. It lets Replay and the HTTP API work with models of different state types.

type AnyProjection

type AnyProjection interface {
	ProjectionName() string
	GetAny(ctx context.Context, s Store, opts ...Option) (any, error)
}

AnyProjection is implemented by every *Projection[V].

type AppendCondition

type AppendCondition struct {
	// ExpectedVersion is the stream version the caller based its decision
	// on, or Any.
	ExpectedVersion int64

	// BaseVersion is the stream's version inherited from parent branches.
	// The store uses it as the current version while the branch itself has
	// no commits for the stream.
	BaseVersion int64

	// MinTime is the earliest time the commit may carry, normally the
	// branch's ForkTime.
	MinTime time.Time
}

AppendCondition guards Store.Append.

type Batcher

type Batcher interface {
	Batch(ctx context.Context, fn func(Store) error) error
}

Batcher is an optional Store extension for writing many commits in one transaction. Replay uses it when available.

Batch calls fn with a Store whose reads see the batch's own writes. If fn returns an error, nothing it wrote is kept.

type Branch

type Branch struct {
	Name        string    `json:"name"`
	Parent      string    `json:"parent,omitempty"`
	ForkSeq     int64     `json:"fork_seq"`
	ForkTime    time.Time `json:"fork_time"`
	Created     time.Time `json:"created"`
	Kind        string    `json:"kind,omitempty"`
	Description string    `json:"description,omitempty"`
}

Branch describes a line of history. Every branch except Main has a Parent and inherits the parent's commits up to and including ForkSeq.

func Fork

func Fork(ctx context.Context, s Store, name string, o ForkOptions) (Branch, error)

Fork creates a branch that shares the parent's history up to the fork point. Commits written to the branch are invisible to the parent.

Example
package main

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/HarshalPatel1972/epoch/v2"
	"github.com/HarshalPatel1972/epoch/v2/memstore"
)

// A bank account with an overdraft limit.
type Account struct {
	Balance int64
	Limit   int64
}

// Commands are what the application is asked to do.
type (
	Open     struct{ Limit int64 }
	Deposit  struct{ Amount int64 }
	Withdraw struct{ Amount int64 }
)

// Events are what happened.
type (
	Opened    struct{ Limit int64 }
	Deposited struct{ Amount int64 }
	Withdrew  struct{ Amount int64 }
)

var ErrInsufficientFunds = errors.New("insufficient funds")

var Accounts = &epoch.Model[Account]{
	Name:     "account",
	Commands: []any{Open{}, Deposit{}, Withdraw{}},
	Events:   []any{Opened{}, Deposited{}, Withdrew{}},
	Evolve: func(a Account, e any) Account {
		switch e := e.(type) {
		case Opened:
			a.Limit = e.Limit
		case Deposited:
			a.Balance += e.Amount
		case Withdrew:
			a.Balance -= e.Amount
		}
		return a
	},
	Decide: func(a Account, c any, _ time.Time) ([]any, error) {
		switch c := c.(type) {
		case Open:
			return []any{Opened(c)}, nil
		case Deposit:
			return []any{Deposited(c)}, nil
		case Withdraw:
			if a.Balance-c.Amount < -a.Limit {
				return nil, ErrInsufficientFunds
			}
			return []any{Withdrew(c)}, nil
		}
		return nil, fmt.Errorf("unknown command %T", c)
	},
}

func day(n int) time.Time { return time.Date(2026, 1, n, 12, 0, 0, 0, time.UTC) }

// seed records a short history. The last withdrawal is rejected, and the
// rejection is recorded too.
func seed(ctx context.Context, store epoch.Store) {
	Accounts.Handle(ctx, store, "alice", Open{Limit: 100}, epoch.WithTime(day(1)))
	Accounts.Handle(ctx, store, "alice", Deposit{Amount: 50}, epoch.WithTime(day(2)))
	Accounts.Handle(ctx, store, "alice", Withdraw{Amount: 120}, epoch.WithTime(day(3)))
	Accounts.Handle(ctx, store, "alice", Withdraw{Amount: 40}, epoch.WithTime(day(4)))
}

func main() {
	ctx := context.Background()
	store := memstore.New()
	seed(ctx, store)

	// Branch off as things were on day 2 and try something different.
	epoch.Fork(ctx, store, "what-if", epoch.ForkOptions{At: day(2)})
	Accounts.Handle(ctx, store, "alice", Deposit{Amount: 1000}, epoch.On("what-if"))

	main, _ := Accounts.Load(ctx, store, "alice")
	fork, _ := Accounts.Load(ctx, store, "alice", epoch.On("what-if"))
	fmt.Println("main:", main.Value.Balance)
	fmt.Println("what-if:", fork.Value.Balance)
}
Output:
main: -70
what-if: 1050

func GetBranch

func GetBranch(ctx context.Context, s Store, name string) (Branch, error)

GetBranch returns the named branch. Main always exists.

func ListBranches

func ListBranches(ctx context.Context, s Store) ([]Branch, error)

ListBranches returns Main followed by every stored branch.

type Change

type Change struct {
	// Path locates the value, e.g. `revenue.total` or `items[2].qty`. Keys
	// that are not identifiers are quoted: `byCountry["en-GB"]`.
	Path string `json:"path"`
	Kind string `json:"kind"`
	From any    `json:"from,omitempty"`
	To   any    `json:"to,omitempty"`
}

A Change is one difference found by Diff.

func Diff

func Diff(a, b any) ([]Change, error)

Diff compares the JSON encodings of a and b and returns every difference, ordered by path. Object keys are compared by name and arrays by index. Numbers keep their exact JSON text, so From and To are json.Number values.

type CommandData

type CommandData struct {
	ID   string          `json:"id,omitempty"`
	Type string          `json:"type"`
	Data json.RawMessage `json:"data"`
}

CommandData is a recorded command.

func (CommandData) PayloadData

func (c CommandData) PayloadData() json.RawMessage

func (CommandData) PayloadType

func (c CommandData) PayloadType() string

type Commit

type Commit struct {
	// Seq is assigned by the store. It is unique across all branches and
	// increases with every append, so a branch's history sorts by it.
	Seq    int64     `json:"seq"`
	Branch string    `json:"branch"`
	Time   time.Time `json:"time"`

	// Model and Stream identify the entity: Model is the Model's Name, Stream
	// is the entity ID passed to Handle.
	Model  string `json:"model"`
	Stream string `json:"stream"`

	// Version is the stream's version after this commit: the number of events
	// in the stream's history, including history inherited by a branch.
	Version int64 `json:"version"`

	Command  *CommandData `json:"command,omitempty"`
	Events   []EventData  `json:"events,omitempty"`
	Rejected string       `json:"rejected,omitempty"`

	// Origin is the Seq of the commit this one was replayed or copied from,
	// or 0 for commits written directly.
	Origin int64 `json:"origin,omitempty"`
}

A Commit is one entry in the log: a command handled by a Model, the events it produced, or the reason it was rejected.

func Log

func Log(ctx context.Context, s Store, afterSeq int64, limit int, opts ...Option) ([]Commit, error)

Log returns commits visible on a branch in Seq order, starting after afterSeq. Use limit to page through long histories. AsOf and AtSeq bound the result; On selects the branch.

type Comparison

type Comparison[V any] struct {
	A       V        `json:"a"`
	B       V        `json:"b"`
	Changes []Change `json:"changes"`
}

A Comparison holds a read model computed on two branches and the differences between them.

func Compare

func Compare[V any](ctx context.Context, s Store, p *Projection[V], a, b string, opts ...Option) (Comparison[V], error)

Compare computes p on branches a and b and diffs the results. AsOf and AtSeq apply to both branches; On is ignored.

Example
package main

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/HarshalPatel1972/epoch/v2"
	"github.com/HarshalPatel1972/epoch/v2/memstore"
)

// A bank account with an overdraft limit.
type Account struct {
	Balance int64
	Limit   int64
}

// Commands are what the application is asked to do.
type (
	Open     struct{ Limit int64 }
	Deposit  struct{ Amount int64 }
	Withdraw struct{ Amount int64 }
)

// Events are what happened.
type (
	Opened    struct{ Limit int64 }
	Deposited struct{ Amount int64 }
	Withdrew  struct{ Amount int64 }
)

var ErrInsufficientFunds = errors.New("insufficient funds")

var Accounts = &epoch.Model[Account]{
	Name:     "account",
	Commands: []any{Open{}, Deposit{}, Withdraw{}},
	Events:   []any{Opened{}, Deposited{}, Withdrew{}},
	Evolve: func(a Account, e any) Account {
		switch e := e.(type) {
		case Opened:
			a.Limit = e.Limit
		case Deposited:
			a.Balance += e.Amount
		case Withdrew:
			a.Balance -= e.Amount
		}
		return a
	},
	Decide: func(a Account, c any, _ time.Time) ([]any, error) {
		switch c := c.(type) {
		case Open:
			return []any{Opened(c)}, nil
		case Deposit:
			return []any{Deposited(c)}, nil
		case Withdraw:
			if a.Balance-c.Amount < -a.Limit {
				return nil, ErrInsufficientFunds
			}
			return []any{Withdrew(c)}, nil
		}
		return nil, fmt.Errorf("unknown command %T", c)
	},
}

func day(n int) time.Time { return time.Date(2026, 1, n, 12, 0, 0, 0, time.UTC) }

// seed records a short history. The last withdrawal is rejected, and the
// rejection is recorded too.
func seed(ctx context.Context, store epoch.Store) {
	Accounts.Handle(ctx, store, "alice", Open{Limit: 100}, epoch.WithTime(day(1)))
	Accounts.Handle(ctx, store, "alice", Deposit{Amount: 50}, epoch.WithTime(day(2)))
	Accounts.Handle(ctx, store, "alice", Withdraw{Amount: 120}, epoch.WithTime(day(3)))
	Accounts.Handle(ctx, store, "alice", Withdraw{Amount: 40}, epoch.WithTime(day(4)))
}

func main() {
	ctx := context.Background()
	store := memstore.New()
	seed(ctx, store)

	type Totals struct {
		Deposited, Withdrawn int64
		Declined             int
	}
	totals := &epoch.Projection[Totals]{
		Name: "totals",
		Event: func(t Totals, r epoch.Record) Totals {
			if e, ok := epoch.As[Deposited](r); ok {
				t.Deposited += e.Amount
			}
			if e, ok := epoch.As[Withdrew](r); ok {
				t.Withdrawn += e.Amount
			}
			return t
		},
		Rejected: func(t Totals, r epoch.Rejection) Totals { t.Declined++; return t },
	}

	generous := *Accounts
	generous.Decide = func(a Account, c any, now time.Time) ([]any, error) {
		if w, ok := c.(Withdraw); ok {
			return []any{Withdrew(w)}, nil // no limit at all
		}
		return Accounts.Decide(a, c, now)
	}
	epoch.Replay(ctx, store, epoch.ReplayOptions{Name: "no-limit", Models: []epoch.AnyModel{&generous}})

	cmp, _ := epoch.Compare(ctx, store, totals, epoch.Main, "no-limit")
	for _, c := range cmp.Changes {
		fmt.Printf("%s: %v -> %v\n", c.Path, c.From, c.To)
	}
}
Output:
Declined: 1 -> 0
Withdrawn: 120 -> 160

type Divergence

type Divergence struct {
	Kind     string `json:"kind"`
	Original Commit `json:"original"`
	Replayed Commit `json:"replayed"`
}

A Divergence is a command whose outcome changed in a replay.

type EventData

type EventData struct {
	Type string          `json:"type"`
	Data json.RawMessage `json:"data"`
}

EventData is a recorded event.

func (EventData) PayloadData

func (e EventData) PayloadData() json.RawMessage

func (EventData) PayloadType

func (e EventData) PayloadType() string

type ForkOptions

type ForkOptions struct {
	// From is the parent branch. The default is Main.
	From string
	// At forks the parent as it was at this time. Seq forks it right after
	// the commit with this Seq. If neither is set, the fork starts at the
	// parent's latest commit.
	At  time.Time
	Seq int64

	Description string
}

ForkOptions configures Fork.

type Model

type Model[S any] struct {
	// Name identifies the model in the log. Changing it orphans existing
	// history.
	Name string

	// Schema versions the state. Change it whenever S or Evolve changes in a
	// way that makes stored snapshots wrong; old snapshots are then ignored.
	Schema string

	// Commands and Events list one value of every command and event type the
	// model uses, e.g. Commands: []any{Deposit{}, Withdraw{}}.
	Commands []any
	Events   []any

	// Evolve applies one event to the state and returns the new state. It
	// must be pure and must not modify state in place (copy slices and maps).
	Evolve func(state S, event any) S

	// Decide turns a command into events, or returns an error to reject it.
	// It must be deterministic: depend only on state, command and now. The
	// returned events must be types listed in Events.
	Decide func(state S, command any, now time.Time) ([]any, error)

	// SnapshotEvery saves a snapshot every n events per stream. 0 uses
	// DefaultSnapshotEvery; a negative value disables snapshots.
	SnapshotEvery int
}

A Model is one kind of entity: an account, an order, a cart. S is its state.

Declare Models as package-level pointers and do not change their fields after first use. To try different rules in a replay, copy the Model and replace Decide:

strict := *Accounts
strict.Decide = decideStrict
epoch.Replay(ctx, store, epoch.ReplayOptions{Models: []epoch.AnyModel{&strict}, ...})

func (*Model[S]) DecodeCommand

func (m *Model[S]) DecodeCommand(c CommandData) (any, error)

DecodeCommand decodes a recorded command into its Go type.

func (*Model[S]) DecodeEvent

func (m *Model[S]) DecodeEvent(e EventData) (any, error)

DecodeEvent decodes a recorded event into its Go type.

func (*Model[S]) Handle

func (m *Model[S]) Handle(ctx context.Context, s Store, id string, cmd any, opts ...Option) (Result[S], error)

Handle decides cmd against the entity's current state and records the outcome. If Decide rejects the command, the rejection is recorded too and Handle returns an error wrapping both ErrRejected and Decide's error.

Handle retries automatically when another writer changed the entity between loading and appending.

func (*Model[S]) History

func (m *Model[S]) History(ctx context.Context, s Store, id string, opts ...Option) ([]Commit, error)

History returns every commit for an entity, including rejected commands, oldest first. AsOf and AtSeq bound it; On selects the branch.

func (*Model[S]) Load

func (m *Model[S]) Load(ctx context.Context, s Store, id string, opts ...Option) (State[S], error)

Load returns an entity's state. Use AsOf or AtSeq to read the past and On to read a branch.

Example (AsOf)
package main

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/HarshalPatel1972/epoch/v2"
	"github.com/HarshalPatel1972/epoch/v2/memstore"
)

// A bank account with an overdraft limit.
type Account struct {
	Balance int64
	Limit   int64
}

// Commands are what the application is asked to do.
type (
	Open     struct{ Limit int64 }
	Deposit  struct{ Amount int64 }
	Withdraw struct{ Amount int64 }
)

// Events are what happened.
type (
	Opened    struct{ Limit int64 }
	Deposited struct{ Amount int64 }
	Withdrew  struct{ Amount int64 }
)

var ErrInsufficientFunds = errors.New("insufficient funds")

var Accounts = &epoch.Model[Account]{
	Name:     "account",
	Commands: []any{Open{}, Deposit{}, Withdraw{}},
	Events:   []any{Opened{}, Deposited{}, Withdrew{}},
	Evolve: func(a Account, e any) Account {
		switch e := e.(type) {
		case Opened:
			a.Limit = e.Limit
		case Deposited:
			a.Balance += e.Amount
		case Withdrew:
			a.Balance -= e.Amount
		}
		return a
	},
	Decide: func(a Account, c any, _ time.Time) ([]any, error) {
		switch c := c.(type) {
		case Open:
			return []any{Opened(c)}, nil
		case Deposit:
			return []any{Deposited(c)}, nil
		case Withdraw:
			if a.Balance-c.Amount < -a.Limit {
				return nil, ErrInsufficientFunds
			}
			return []any{Withdrew(c)}, nil
		}
		return nil, fmt.Errorf("unknown command %T", c)
	},
}

func day(n int) time.Time { return time.Date(2026, 1, n, 12, 0, 0, 0, time.UTC) }

// seed records a short history. The last withdrawal is rejected, and the
// rejection is recorded too.
func seed(ctx context.Context, store epoch.Store) {
	Accounts.Handle(ctx, store, "alice", Open{Limit: 100}, epoch.WithTime(day(1)))
	Accounts.Handle(ctx, store, "alice", Deposit{Amount: 50}, epoch.WithTime(day(2)))
	Accounts.Handle(ctx, store, "alice", Withdraw{Amount: 120}, epoch.WithTime(day(3)))
	Accounts.Handle(ctx, store, "alice", Withdraw{Amount: 40}, epoch.WithTime(day(4)))
}

func main() {
	ctx := context.Background()
	store := memstore.New()
	seed(ctx, store)

	for _, d := range []int{1, 2, 3, 4} {
		st, _ := Accounts.Load(ctx, store, "alice", epoch.AsOf(day(d)))
		fmt.Printf("day %d: %d\n", d, st.Value.Balance)
	}
}
Output:
day 1: 0
day 2: 50
day 3: -70
day 4: -70

func (*Model[S]) LoadAny

func (m *Model[S]) LoadAny(ctx context.Context, s Store, id string, opts ...Option) (State[any], error)

LoadAny is Load for callers that do not know S, such as the HTTP API.

func (*Model[S]) ModelName

func (m *Model[S]) ModelName() string

ModelName returns m.Name.

func (*Model[S]) Record

func (m *Model[S]) Record(ctx context.Context, s Store, id string, events []any, opts ...Option) (Result[S], error)

Record appends events that are facts rather than decisions, such as a payment confirmed by an external provider. No command is recorded, so a replay copies recorded facts as they are instead of deciding them again.

type Named

type Named interface {
	EpochType() string
}

Named lets a command or event type choose the name it is recorded under. Without it, the Go type name is used (e.g. "Deposited"). Implement it when you rename a type but need old records to keep decoding, or when two packages use the same type name.

type Option

type Option func(*options)

Option configures reads and writes. Options that do not apply to a call are ignored.

func AsOf

func AsOf(t time.Time) Option

AsOf reads the state as it was at time t (inclusive).

func AtSeq

func AtSeq(seq int64) Option

AtSeq reads the state as it was right after the commit with sequence seq.

func CommandID

func CommandID(id string) Option

CommandID makes Model.Handle idempotent: if a commit with this command ID already exists on the branch (or the history it inherits), Handle returns that commit's outcome instead of executing the command again.

func On

func On(branch string) Option

On selects the branch to read from or write to. The default is Main.

func WithTime

func WithTime(t time.Time) Option

WithTime sets the decision time for Model.Handle instead of time.Now. It is meant for imports and tests. Time never goes backwards on a branch, so a time earlier than the branch's last commit is moved forward to it.

type Payload

type Payload interface {
	PayloadType() string
	PayloadData() json.RawMessage
}

A Payload is anything carrying a recorded type name and JSON data: EventData, CommandData, Record and Rejection.

type Projection

type Projection[V any] struct {
	Name string
	// Init returns a new, empty read model. If nil, the zero V is used, so set
	// Init when V contains maps that Event writes to.
	Init func() V
	// Event applies one event.
	Event func(v V, r Record) V
	// Rejected, if set, applies one rejected command. Use it to track demand
	// your rules turned away.
	Rejected func(v V, r Rejection) V
}

A Projection folds the whole log into a read model V: a report, a dashboard, a search index. Because it reads the log, it can be computed for any branch and any point in time.

Every Get starts from a fresh Init() and folds the log in order, so Event and Rejected may update v in place. They must not depend on anything but v and the record.

func (*Projection[V]) Get

func (p *Projection[V]) Get(ctx context.Context, s Store, opts ...Option) (V, error)

Get computes the read model. Use AsOf or AtSeq to compute it for the past and On to compute it for a branch.

Get reads every visible commit, so its cost grows with history. Cache the result if you call it often.

func (*Projection[V]) GetAny

func (p *Projection[V]) GetAny(ctx context.Context, s Store, opts ...Option) (any, error)

GetAny is Get for callers that do not know V, such as the HTTP API.

func (*Projection[V]) ProjectionName

func (p *Projection[V]) ProjectionName() string

ProjectionName returns p.Name.

type Query

type Query struct {
	Branch string
	// Model and Stream filter by entity. Stream requires Model.
	Model  string
	Stream string
	// CommandID filters by Commit.Command.ID.
	CommandID string
	// AfterSeq and MaxSeq bound Seq: AfterSeq < Seq <= MaxSeq. A MaxSeq of 0
	// means no upper bound.
	AfterSeq int64
	MaxSeq   int64
	// Limit caps the number of commits returned. 0 means no limit.
	Limit int
	// Reverse returns the newest commits first.
	Reverse bool
}

Query selects commits on a single branch. Inherited history is resolved by the epoch package, never by stores.

type Record

type Record struct {
	Seq    int64     `json:"seq"`
	Time   time.Time `json:"time"`
	Branch string    `json:"branch"`
	Model  string    `json:"model"`
	Stream string    `json:"stream"`
	// Version is the stream's version right after this event.
	Version int64 `json:"version"`
	// Command is the type of the command that produced the event, or "" for
	// a recorded fact.
	Command string          `json:"command,omitempty"`
	Type    string          `json:"type"`
	Data    json.RawMessage `json:"data"`
}

A Record is one event as seen by a Projection.

func (Record) PayloadData

func (r Record) PayloadData() json.RawMessage

func (Record) PayloadType

func (r Record) PayloadType() string

type Rejection

type Rejection struct {
	Seq     int64       `json:"seq"`
	Time    time.Time   `json:"time"`
	Branch  string      `json:"branch"`
	Model   string      `json:"model"`
	Stream  string      `json:"stream"`
	Command CommandData `json:"command"`
	Reason  string      `json:"reason"`
}

A Rejection is a command that a Model's Decide rejected.

func (Rejection) PayloadData

func (r Rejection) PayloadData() json.RawMessage

func (Rejection) PayloadType

func (r Rejection) PayloadType() string

PayloadType and PayloadData expose the rejected command, so As works on a Rejection.

type ReplayOptions

type ReplayOptions struct {
	// Name of the branch to create. Required.
	Name string
	// Source is the branch whose history is replayed. The default is Main.
	Source string

	// From is the fork point: history up to and including it is kept as is,
	// everything after it is replayed. Set From (a time) or FromSeq (a commit
	// Seq). If neither is set, the whole history is replayed.
	From    time.Time
	FromSeq int64

	// To ends the replay at this time (inclusive) or ToSeq. If neither is set,
	// the replay runs to the source's latest commit.
	To    time.Time
	ToSeq int64

	// Models re-decide their recorded commands, normally with changed Decide
	// rules. Commits of other models are copied as recorded. A model's
	// Name selects which commits it handles.
	Models []AnyModel

	Description string

	// MaxDivergences caps Report.Divergences; the counters still count every
	// divergence. 0 means 1000; negative means no cap.
	MaxDivergences int
}

ReplayOptions configures Replay.

type Report

type Report struct {
	Branch  Branch `json:"branch"`
	FromSeq int64  `json:"from_seq"`
	ToSeq   int64  `json:"to_seq"`
	// Commits is the number of commits replayed or copied.
	Commits int `json:"commits"`
	// Replayed is the number of commands re-decided. Only Replay sets it;
	// ReplayReport cannot tell re-decided from copied commits.
	Replayed int `json:"replayed"`

	Diverged      int          `json:"diverged"`
	NowAccepted   int          `json:"now_accepted"`
	NowRejected   int          `json:"now_rejected"`
	EventsChanged int          `json:"events_changed"`
	Divergences   []Divergence `json:"divergences"`
	// Truncated is true when Divergences was capped by MaxDivergences.
	Truncated bool `json:"truncated,omitempty"`
	// contains filtered or unexported fields
}

Report summarises a replay.

func Replay

func Replay(ctx context.Context, s Store, o ReplayOptions) (*Report, error)

Replay creates a branch from Source at the From point and re-runs every commit after it, up to To, on that branch:

  • commands of the given Models are decided again by those Models, with the decision time set to the original commit's time;
  • everything else is copied as recorded.

Replayed commits keep their original times, so time-travel reads work on the branch exactly as on the source. If Replay fails, the partial branch is deleted.

Example
package main

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/HarshalPatel1972/epoch/v2"
	"github.com/HarshalPatel1972/epoch/v2/memstore"
)

// A bank account with an overdraft limit.
type Account struct {
	Balance int64
	Limit   int64
}

// Commands are what the application is asked to do.
type (
	Open     struct{ Limit int64 }
	Deposit  struct{ Amount int64 }
	Withdraw struct{ Amount int64 }
)

// Events are what happened.
type (
	Opened    struct{ Limit int64 }
	Deposited struct{ Amount int64 }
	Withdrew  struct{ Amount int64 }
)

var ErrInsufficientFunds = errors.New("insufficient funds")

var Accounts = &epoch.Model[Account]{
	Name:     "account",
	Commands: []any{Open{}, Deposit{}, Withdraw{}},
	Events:   []any{Opened{}, Deposited{}, Withdrew{}},
	Evolve: func(a Account, e any) Account {
		switch e := e.(type) {
		case Opened:
			a.Limit = e.Limit
		case Deposited:
			a.Balance += e.Amount
		case Withdrew:
			a.Balance -= e.Amount
		}
		return a
	},
	Decide: func(a Account, c any, _ time.Time) ([]any, error) {
		switch c := c.(type) {
		case Open:
			return []any{Opened(c)}, nil
		case Deposit:
			return []any{Deposited(c)}, nil
		case Withdraw:
			if a.Balance-c.Amount < -a.Limit {
				return nil, ErrInsufficientFunds
			}
			return []any{Withdrew(c)}, nil
		}
		return nil, fmt.Errorf("unknown command %T", c)
	},
}

func day(n int) time.Time { return time.Date(2026, 1, n, 12, 0, 0, 0, time.UTC) }

// seed records a short history. The last withdrawal is rejected, and the
// rejection is recorded too.
func seed(ctx context.Context, store epoch.Store) {
	Accounts.Handle(ctx, store, "alice", Open{Limit: 100}, epoch.WithTime(day(1)))
	Accounts.Handle(ctx, store, "alice", Deposit{Amount: 50}, epoch.WithTime(day(2)))
	Accounts.Handle(ctx, store, "alice", Withdraw{Amount: 120}, epoch.WithTime(day(3)))
	Accounts.Handle(ctx, store, "alice", Withdraw{Amount: 40}, epoch.WithTime(day(4)))
}

func main() {
	ctx := context.Background()
	store := memstore.New()
	seed(ctx, store)

	// What if overdrafts had been capped at 20 instead of 100?
	stricter := *Accounts
	stricter.Decide = func(a Account, c any, now time.Time) ([]any, error) {
		if w, ok := c.(Withdraw); ok && a.Balance-w.Amount < -20 {
			return nil, ErrInsufficientFunds
		}
		return Accounts.Decide(a, c, now)
	}

	report, _ := epoch.Replay(ctx, store, epoch.ReplayOptions{
		Name:   "overdraft-20",
		Models: []epoch.AnyModel{&stricter},
	})
	fmt.Printf("replayed %d commands, %d outcomes changed\n", report.Commits, report.Diverged)
	for _, d := range report.Divergences {
		fmt.Printf("%s: %s %s\n", d.Original.Time.Format("Jan 2"), d.Original.Command.Type, d.Kind)
	}

	st, _ := Accounts.Load(ctx, store, "alice", epoch.On("overdraft-20"))
	fmt.Println("balance on the replay branch:", st.Value.Balance)
}
Output:
replayed 4 commands, 2 outcomes changed
Jan 3: Withdraw now_rejected
Jan 4: Withdraw now_accepted
balance on the replay branch: 10

func ReplayReport

func ReplayReport(ctx context.Context, s Store, branch string, maxDivergences int) (*Report, error)

ReplayReport rebuilds the Report of a replay branch from stored commits, by comparing every commit on the branch with the commit it was replayed from. Report.Replayed is always 0, since stored commits do not record whether a command was re-decided or copied.

type Result

type Result[S any] struct {
	// State is the entity's state after the command, or before it if the
	// command was rejected.
	State State[S]
	// Commit is the recorded commit.
	Commit Commit
	// Events are the events the command produced.
	Events []any
	// Duplicate is true if CommandID matched an earlier commit and the
	// command was not executed again.
	Duplicate bool
}

Result is the outcome of Model.Handle.

type Snapshot

type Snapshot struct {
	Branch  string          `json:"branch"`
	Model   string          `json:"model"`
	Stream  string          `json:"stream"`
	Key     string          `json:"key"` // Model.Name + "@" + Model.Schema
	Seq     int64           `json:"seq"`
	Version int64           `json:"version"`
	State   json.RawMessage `json:"state"`
}

A Snapshot caches a stream's state at a commit so that loads only replay the commits after it.

type State

type State[S any] struct {
	Value S `json:"value"`
	// Version is the number of events in the stream's history. 0 means the
	// entity does not exist yet.
	Version int64 `json:"version"`
	// Seq is the last commit that touched the stream, or 0.
	Seq int64 `json:"seq"`
}

State is an entity's state at a point in history.

func (State[S]) Exists

func (s State[S]) Exists() bool

Exists reports whether any event has been recorded for the entity.

type Store

type Store interface {
	// Append atomically writes c to c.Branch. It must:
	//   - fail with ErrConflict unless cond.ExpectedVersion is Any or equals
	//     the stream's current version: the Version of the branch's latest
	//     commit for (c.Model, c.Stream), or cond.BaseVersion if there is none;
	//   - set c.Version to the current version plus len(c.Events);
	//   - set c.Time to the latest of c.Time, cond.MinTime and the time of the
	//     branch's previous commit, so time never goes backwards on a branch;
	//   - set c.Seq greater than every Seq the store assigned before.
	Append(ctx context.Context, c *Commit, cond AppendCondition) error

	// Read returns the commits on q.Branch that match q, ordered by Seq.
	Read(ctx context.Context, q Query) ([]Commit, error)

	// SeqAt returns the Seq of the last commit on branch with Time <= t and
	// Seq <= maxSeq (0 means no bound), or 0 if there is none.
	SeqAt(ctx context.Context, branch string, t time.Time, maxSeq int64) (int64, error)

	// CreateBranch stores b, failing with ErrBranchExists if the name is taken.
	CreateBranch(ctx context.Context, b Branch) error
	// GetBranch fails with ErrBranchNotFound if there is no such branch.
	GetBranch(ctx context.Context, name string) (Branch, error)
	ListBranches(ctx context.Context) ([]Branch, error)
	// DeleteBranch removes the branch with its commits and snapshots. It fails
	// with ErrBranchNotFound if there is no such branch.
	DeleteBranch(ctx context.Context, name string) error

	SaveSnapshot(ctx context.Context, s Snapshot) error
	// LoadSnapshot returns the snapshot with the highest Seq <= maxSeq (0
	// means no bound) for the branch, model, stream and key, or nil.
	LoadSnapshot(ctx context.Context, branch, model, stream, key string, maxSeq int64) (*Snapshot, error)
}

Store persists commits, branches and snapshots. Implementations must be safe for concurrent use and should pass the conformance suite in package epochtest.

A Store only ever deals with one branch at a time; inheritance between branches is handled by the epoch package.

Directories

Path Synopsis
Package epochhttp serves Epoch Studio, a web UI for exploring history, branches and replays, and the JSON API behind it.
Package epochhttp serves Epoch Studio, a web UI for exploring history, branches and replays, and the JSON API behind it.
Package epochtest is a conformance suite for epoch.Store implementations.
Package epochtest is a conformance suite for epoch.Store implementations.
examples
shop command
Command shop is a tour of Epoch: it generates six months of history for a small online shop, then answers questions a normal database cannot.
Command shop is a tour of Epoch: it generates six months of history for a small online shop, then answers questions a normal database cannot.
shop/shop
Package shop is the domain of the Epoch example: a small online shop whose inventory model decides which orders it can fulfil and at what price.
Package shop is the domain of the Epoch example: a small online shop whose inventory model decides which orders it can fulfil and at what price.
Package memstore is an in-memory epoch.Store for tests, examples and short-lived processes.
Package memstore is an in-memory epoch.Store for tests, examples and short-lived processes.
sqlitestore module

Jump to

Keyboard shortcuts

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