This page grows one program. Steps 1–4 need nothing but Go; step 5 needs a MySQL or ClickHouse you can reach.
You never write a Change by hand here. You pass the document before and after
an edit, and the library works out the difference — that is the path most
applications want, and it is the shortest one.
1. Install
Three modules: the core, a backend, and the kit that diffs documents for you.
go mod init example.com/myapp
go get github.com/zdirnecamlcs96/chronicle/core@latest
go get github.com/zdirnecamlcs96/chronicle/adapters/memory@latest
go get github.com/zdirnecamlcs96/chronicle/kit@latest
One naming quirk, then never again: the import path ends in core, the package
is called changelog. Alias it and move on.
import changelog "github.com/zdirnecamlcs96/chronicle/core"
2. Record a change
Two lines of setup — pick a backend, wrap it in a kit — then record.
RecordUpdate diffs before → after and seals the difference as one commit.
Save as main.go and run it:
package main
import (
"context"
"fmt"
changelogmemory "github.com/zdirnecamlcs96/chronicle/adapters/memory"
chroniclekit "github.com/zdirnecamlcs96/chronicle/kit"
)
func main() {
ctx := context.Background()
log := changelogmemory.New() // the backend; step 5 swaps this line
k := chroniclekit.New(log)
invoice := map[string]any{"status": "draft", "total": 1200}
_, err := k.RecordUpdate(ctx, "invoice-42", nil, invoice,
chroniclekit.WithActor("alice"),
chroniclekit.WithMessage("create invoice"))
if err != nil {
panic(err)
}
sent := map[string]any{"status": "sent", "total": 1200}
c, err := k.RecordUpdate(ctx, "invoice-42", invoice, sent,
chroniclekit.WithActor("bob"),
chroniclekit.WithMessage("send invoice"))
if err != nil {
panic(err)
}
fmt.Printf("sealed %s\n", c.ID[:7])
}
Three things that are worth knowing now, and nothing else yet:
nilas the before state means “this document did not exist” — the create. Every later call passes the previous state.WithActoris who. Without it a commit seals with no authors, and “who changed it” is half the reason to keep a changelog.- Structs work as well as maps. Both sides are JSON-normalized first,
honouring
jsontags.
adapters/memory keeps everything in a map and loses it when the process
exits. It is for dev and tests, never production; step 5 fixes that.
3. Read it back
Two questions a changelog answers. Add to main:
state, _ := k.State(ctx, "invoice-42") // what is it now
fmt.Println("now:", state)
commits, _ := k.Service().Commits(ctx, "invoice-42", 0) // how did it get here
for _, c := range commits {
fmt.Printf("%s %-15s %v\n", c.ID[:7], c.Message, c.Authors)
for _, ch := range c.Changes {
fmt.Printf(" %s %s: %s -> %s\n", ch.Kind, ch.Path, ch.From, ch.To)
}
}
past, _ := k.StateAt(ctx, "invoice-42", commits[len(commits)-1].ID)
fmt.Println("at the root commit:", past)
now: map[status:sent total:1200]
f089cc5 send invoice [bob]
put status: "draft" -> "sent"
b9404a3 create invoice [alice]
create status: -> "draft"
create total: -> 1200
at the root commit: map[status:draft total:1200]
Current state is never stored — State replays the chain to derive it, and
StateAt replays it as far as a commit you name. Commits is newest-first,
like git log; 0 means all.
Your commit ids will differ from the ones above. Each change carries the timestamp it was staged at, and that timestamp is inside the hash.
4. Verify the chain
The reason commits are hashed is so you can detect that stored history was edited afterwards. One call, against the backend — this is the first step that needs the core import, so add it:
import changelog "github.com/zdirnecamlcs96/chronicle/core"
if err := changelog.Verify(ctx, log, "invoice-42"); err != nil {
panic(err) // history was tampered with, or the chain is not linear
}
fmt.Println("verify: ok")
Verify recomputes every commit’s id from its content and checks every parent
link. It reads the whole document each time; once you have a head you already
trust, VerifyAfter checks only what came after it. Both, and what each failure
means, are in reference.
5. Go durable
Everything above works unchanged against a real database. One line changes.
go get github.com/zdirnecamlcs96/chronicle/adapters/sql@latest
log, err := changelogsql.Open(ctx,
"user:pass@tcp(127.0.0.1:3306)/changelog?parseTime=true",
changelogsql.WithMigrate(true))
if err != nil {
panic(err)
}
defer log.Close()
k := chroniclekit.New(log) // identical to step 2
The DSN must carry parseTime=true, or DATETIME will not scan into
time.Time. adapters/clickhouse swaps in the same way with a
clickhouse://… DSN.
Use MySQL when two writers can touch one document at once and you need a single linear chain; use ClickHouse for cheap retention and analytical queries, where one producer serializes per document. Operations has the comparison and the tuning that goes with each.
That is the whole loop: record, read, verify, durable.
What you skipped
You did not need any of this, which is why it is down here.
| What it is | When you want it | |
|---|---|---|
Recorder |
Stage changes one at a time, seal when ready — git add then git commit |
You build changes yourself instead of diffing documents |
Service |
The facade under the kit: retries, cross-document reads, idempotency | You want commits across all documents, or exactly-once from a queue |
| Capabilities | Four optional interfaces backends may implement | Never, until you write a backend — all three shipped adapters implement all four |
Explain |
Replays history into labelled, human-readable rows | You are rendering history in a UI — the kit page |
The one thing genuinely worth deciding early: if your documents contain
arrays of objects, declare how their elements are identified before your first
write. Commits sealed without that are recorded positionally, and no later
declaration can re-key them. It is one option, and the kit
page covers it. Add WithStrictIdentity() alongside it and the
kit refuses such a write instead of recording it silently — cheap insurance
against the one mistake you cannot repair.
Before production
- Pin versions. v0, experimental; modules version independently
(
core/vX.Y.Z,adapters/sql/vX.Y.Z). - Not
adapters/memory. Dev and tests only. kit/httpapiships no auth, authorization, or rate limiting. If you mount it, that is yours to add.- Read operations for pool sizing, the ClickHouse
FINALcost, pruning idempotency keys, and backups.
Where to go next
| If you want to… | Read |
|---|---|
| render history for humans | the kit, end to end |
| know exactly what a call guarantees | reference |
| understand why it is shaped like git | the git model |
| run it for real | operations |
| know the guarantees and the patterns behind them | concepts |
| write your own backend | architecture |