changelog

package module
v0.1.2 Latest Latest
Warning

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

Go to latest
Published: Jun 21, 2026 License: MIT Imports: 10 Imported by: 0

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

View Source
var ErrEmptyChanges = errors.New("changelog: seal requires at least one change")

ErrEmptyChanges is returned by Service.Seal when no changes are supplied.

View Source
var ErrNothingToCommit = errors.New("changelog: nothing to commit")

ErrNothingToCommit is returned by Recorder.Commit when nothing is staged.

View Source
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

type DocCommit struct {
	DocID  string
	Commit Commit
}

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

func NewRecorder(docID string, log Log) *Recorder

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

func (r *Recorder) Append(c Change)

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

func (r *Recorder) Commit(ctx context.Context, opts ...CommitOption) (Commit, error)

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.

func (*Recorder) Pending

func (r *Recorder) Pending() []Change

Pending returns a copy of the staged, not-yet-committed Changes (like `git status`).

func (*Recorder) WithClock

func (r *Recorder) WithClock(now func() time.Time) *Recorder

WithClock replaces the Recorder's clock and returns the Recorder so callers can chain it onto NewRecorder. A nil now resets to time.Now().UTC.

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

func NewService(log Log) Service

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.

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.

Jump to

Keyboard shortcuts

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