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 ¶
- Constants
- Variables
- func As[T any](p Payload) (T, bool)
- func DeleteBranch(ctx context.Context, s Store, name string) error
- func Head(ctx context.Context, s Store, branch string) (int64, error)
- func TypeName(v any) (string, error)
- type AnyModel
- type AnyProjection
- type AppendCondition
- type Batcher
- type Branch
- type Change
- type CommandData
- type Commit
- type Comparison
- type Divergence
- type EventData
- type ForkOptions
- type Model
- func (m *Model[S]) DecodeCommand(c CommandData) (any, error)
- func (m *Model[S]) DecodeEvent(e EventData) (any, error)
- func (m *Model[S]) Handle(ctx context.Context, s Store, id string, cmd any, opts ...Option) (Result[S], error)
- func (m *Model[S]) History(ctx context.Context, s Store, id string, opts ...Option) ([]Commit, error)
- func (m *Model[S]) Load(ctx context.Context, s Store, id string, opts ...Option) (State[S], error)
- func (m *Model[S]) LoadAny(ctx context.Context, s Store, id string, opts ...Option) (State[any], error)
- func (m *Model[S]) ModelName() string
- func (m *Model[S]) Record(ctx context.Context, s Store, id string, events []any, opts ...Option) (Result[S], error)
- type Named
- type Option
- type Payload
- type Projection
- type Query
- type Record
- type Rejection
- type ReplayOptions
- type Report
- type Result
- type Snapshot
- type State
- type Store
Examples ¶
Constants ¶
const ( KindFork = "fork" KindReplay = "replay" )
Branch kinds.
const ( Added = "added" Removed = "removed" Modified = "modified" )
Change kinds.
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.
const Any int64 = -1
Any disables the expected-version check in AppendCondition.
const DefaultSnapshotEvery = 100
DefaultSnapshotEvery is the snapshot interval used when Model.SnapshotEvery is 0.
const Main = "main"
Main is the name of the root branch. It always exists.
Variables ¶
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 ¶
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 ¶
DeleteBranch deletes a branch and everything written to it. Branches forked from it must be deleted first.
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 ¶
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 ¶
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
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.
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.
type Comparison ¶
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 ¶
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 ¶
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 ¶
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]) 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 CommandID ¶
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.
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 ¶
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]) 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 ¶
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 ¶
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 ¶
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 ¶
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.
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.
Source Files
¶
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
|


