Documentation
¶
Overview ¶
Package changelog is a git-style, database-agnostic audit log. If you know git, you know the model:
- Change — one line of a commit's diff (the content of an edit).
- Recorder — the PORCELAIN, bound to ONE document. Append stages a Change (git add), Pending lists what's staged (git status), Commit seals the staged Changes into a hash-chained Commit (git commit).
- Commit — an immutable, content-addressed commit: ID = hash(parent, message, changes), chained to its parent. Deliberately UNLIKE git, the hash excludes author + time, so identical content converges to one ID (this powers Deduper).
- Log — the REPOSITORY, where commits live. Each document has its own chain, like a branch: Head is the tip, Commits is `git log`. Storage is pluggable; in-memory / SQL / ClickHouse backends live under adapters/. There is no "init" — a document's history begins at its first commit (parent == "").
In short: the Recorder writes (stage → seal), the Log stores. See core/MODEL.md for a side-by-side diagram with git.
The core is generic and standard-library only: feed it by calling Recorder.Append with any Change you construct (e.g. from CRUD handlers), or build a Service facade (NewService) and Seal from your own transport. The library ships no HTTP; exposing it over a wire is the consumer's job.
Index ¶
Constants ¶
This section is empty.
Variables ¶
var ErrEmptyChanges = errors.New("changelog: seal requires at least one change")
ErrEmptyChanges is returned by Service.Seal when no changes are supplied.
var ErrNothingToCommit = errors.New("changelog: nothing to commit")
ErrNothingToCommit is returned by Recorder.Commit when nothing is staged.
var ErrParentConflict = errors.New("changelog: commit parent does not match current head")
ErrParentConflict is returned by a Log's AppendCommit when the commit's Parent no longer matches the document's current Head — git's non-fast-forward: another commit landed between the Recorder reading Head and this append. The caller re-reads Head and re-seals (the Recorder re-chains and re-hashes), so the retry is correct. The memory adapter never returns it — it stores whatever parent it is given, so it can fork; durable backends that serialize concurrent same-document appends (e.g. adapters/sql) enforce it.
Functions ¶
This section is empty.
Types ¶
type Change ¶
type Change struct {
At time.Time `json:"at"`
Actor string `json:"actor"`
Path string `json:"path"`
Kind string `json:"kind"`
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
}
Change is one recorded edit — a single line in a Commit's diff. The model is deliberately git-like: a Recorder stages Changes and seals a batch of them into one hash-chained Commit.
- At when the edit happened; the Recorder stamps it from its own clock on Append, overwriting any value you set.
- Actor who made the edit (its author).
- Path what was edited — a dotted path into the document (e.g. "items.k1.qty").
- Kind a free-form operation label ("put", "delete", "create", …); the package fixes no vocabulary.
- From/To the before/after values — usually JSON-encoded, but any string the producer chooses. "" means "no value" (From on a create, To on a delete).
type Commit ¶
type Commit struct {
ID string `json:"id"`
Parent string `json:"parent"`
At time.Time `json:"at"`
Authors []string `json:"authors"`
Message string `json:"message,omitempty"`
Changes []Change `json:"changes"`
}
Commit is a git-style commit: an immutable, content-addressed bundle of Changes hash-chained to its parent.
Deliberately UNLIKE git, the hash (ID) covers only (Parent, Message, Changes) — NOT At or Authors. So the same logical change sealed by different producers, or at different times, yields the SAME ID. That content-addressing is what lets a retried delivery dedup to a single commit (see Deduper).
- ID the commit hash (see computeID).
- Parent the previous commit's ID; "" marks the root (first commit on a doc).
- At when it was sealed (NOT hashed).
- Authors the distinct Change actors, sorted (NOT hashed).
- Message an optional annotation from WithMessage; IS hashed, so editing it after the fact breaks the chain.
- Changes the edits this commit seals — its diff.
type CommitOption ¶
type CommitOption func(*commitOpts)
CommitOption configures one call to Recorder.Commit. Pass via Commit's variadic opts argument. Options apply in order.
func WithMessage ¶
func WithMessage(s string) CommitOption
WithMessage annotates the commit (`git commit -m`). The message is part of the commit hash, so editing it after the fact breaks the chain.
type Deduper ¶
type Deduper interface {
// Seen returns the commit (docID, key) previously sealed; ok is false if
// unseen. Keys are scoped per document: the same key on a different docID is a
// distinct delivery, so a replay never returns another document's commit.
Seen(ctx context.Context, docID, key string) (c Commit, ok bool, err error)
// MarkSeen records that key sealed commit c for document docID. First writer
// wins: it is a no-op if (docID, key) already exists.
MarkSeen(ctx context.Context, docID, key string, c Commit) error
}
Deduper is an optional capability that makes producer idempotency durable: a delivery retry carrying a previously seen key returns the original commit instead of sealing a duplicate, even across a restart.
type DocCommit ¶
DocCommit pairs a Commit with the document it belongs to, for cross-document query results. The per-document Log methods omit the docID because the caller already supplied it; cross-document results must self-identify.
type Indexer ¶
type Indexer interface {
// AllCommits returns commits across all documents, newest first
// (`git log --all`). limit <= 0 means all.
AllCommits(ctx context.Context, limit int) ([]DocCommit, error)
// FindByID returns the commit with the given id and its document, anywhere in
// the store; ok is false if no such commit exists.
FindByID(ctx context.Context, commitID string) (dc DocCommit, ok bool, err error)
}
Indexer is an optional capability for backends that answer cross-document queries natively — `git log --all` over every document, and looking a commit up by its hash regardless of which document it belongs to (`git show <id>`). Without it, a consumer must keep its own index of the documents it has seen.
type Log ¶
type Log interface {
// AppendCommit stores one commit for a document, extending its chain.
AppendCommit(ctx context.Context, docID string, c Commit) error
// Commits returns a document's commits, newest first (its `git log`).
// limit <= 0 means all.
Commits(ctx context.Context, docID string, limit int) ([]Commit, error)
// Head returns the document's tip commit ID, "" if it has no commits yet.
Head(ctx context.Context, docID string) (string, error)
}
Log is the repository — pluggable storage for the per-document commit history, the port every backend implements. Each document is its own git branch: AppendCommit extends the chain, Head is the branch tip, and Commits is `git log`. (The Recorder is the porcelain that produces those commits.)
The memory adapter satisfies the contract with no external dependencies (reference/test only); a durable backend is the same contract over real storage, shipped as a sibling adapter module with its own driver dependency (e.g. adapters/sql). Every implementation must pass the conformance suite.
type Recorder ¶
type Recorder struct {
// contains filtered or unexported fields
}
Recorder is the porcelain — the staging area plus `git commit`, bound to one document: Append stages Changes, Commit seals them into a hash-chained Commit. (The Log is the repository where the sealed Commits live.) A Recorder is safe for concurrent use.
func NewRecorder ¶
NewRecorder returns a Recorder that records the history of docID into log. The Recorder uses time.Now().UTC for change and commit timestamps; use WithClock to inject a deterministic clock for tests or replay.
func (*Recorder) Append ¶
Append stages c (like `git add`). The Recorder stamps c.At from its own clock, overwriting any value the caller provided, so it remains the single source of time.
func (*Recorder) Commit ¶
Commit seals every staged Change into a new Commit, hash-chained onto the document's current Head, and appends it to the Log — `git commit`. It returns ErrNothingToCommit when nothing is staged. On any error the staged Changes are restored, so nothing is lost. Options (e.g. WithMessage) are variadic and additive — existing callers `rec.Commit(ctx)` continue to work.
type SealOption ¶
type SealOption func(*sealConfig)
SealOption configures a single Seal call.
func WithIdempotencyKey ¶
func WithIdempotencyKey(key string) SealOption
WithIdempotencyKey makes Seal a dedup-safe replay: a later Seal carrying the same key returns the already-sealed commit instead of sealing a duplicate, so an at-least-once delivery retry lands exactly one commit.
type Service ¶
type Service interface {
// Seal stages changes for docID and commits them as a single Commit, retrying
// transparently on ErrParentConflict. WithIdempotencyKey makes a replay return
// the already-sealed commit. Returns ErrEmptyChanges if changes is empty.
Seal(ctx context.Context, docID string, changes []Change, message string, opts ...SealOption) (Commit, error)
// Commits returns a document's commits, newest first. limit <= 0 means all.
Commits(ctx context.Context, docID string, limit int) ([]Commit, error)
// AllCommits returns commits across all documents, newest first.
AllCommits(ctx context.Context, limit int) ([]DocCommit, error)
// Get returns the commit with the given id and its document; ok is false if
// no such commit exists.
Get(ctx context.Context, commitID string) (dc DocCommit, ok bool, err error)
}
Service is the in-process changelog facade — the operations a server needs, over any Log, with NO transport and no net/http dependency. Construct it once with NewService and call it directly in a Go server; an HTTP layer (an http.Handler you write) is one optional adapter on top, not a requirement.
It adds the two things the per-document Log port lacks: cross-document queries (AllCommits, Get) and producer idempotency (Seal + WithIdempotencyKey).
func NewService ¶
NewService wraps a Log with cross-document queries and producer idempotency, detecting the backend's optional Indexer/Deduper through any Unwrap() chain. The service keeps no fallback state: cross-document queries require an Indexer backend and idempotency requires a Deduper. adapters/memory and the durable adapters (e.g. adapters/sql) implement both.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package conformance is the conformance suite for changelog.Log implementations — the executable contract every storage backend must satisfy.
|
Package conformance is the conformance suite for changelog.Log implementations — the executable contract every storage backend must satisfy. |