CARLOS rastrillo docs

๐Ÿค– eventlog

amadan.net/rastrillo/rastrillo/eventlog

The mergeable store shape: a command never UPDATEs. It appends an immutable event to a resource's stream, and a pure Derive fold recomputes the read model.

Because the fold is pure and total over the history, replay works for free โ€” truncate and refold โ€” and multi-edge works for free: the visible state is the merge of many single-writer streams.

This is the app-side half of the platform's mergeable contract, and Manifests can generate a store on it with store = "mergeable".

The table

One table holds every stream. Each row is one immutable event, keyed (stream, writer, seq): writer is the appending instance's identity, seq its own dense counter. A writer never rewrites its history.

eventlog.Schema is the migration set.

Open, Append, Events

func Open(db *sql.DB, writer string) (*Log, error)
func (l *Log) Append(ctx context.Context, stream, kind, actor string, payload any) (Event, error)
func (l *Log) AppendChecked(ctx context.Context, stream, kind, actor string, payload any, check func(history []Event) error) (Event, error)
func (l *Log) AppendUnder(ctx context.Context, prefix, kind, actor string, payload any, name func(streams []string) (string, error)) (Event, error)
func (l *Log) Events(ctx context.Context, stream string) ([]Event, error)
func (l *Log) EventsByPrefix(ctx context.Context, prefix string) ([]Event, error)
func (l *Log) PayloadColumn() string

Open finds which payload column the table has: payload, or payload_v2 once MoneySchema has run. PayloadColumn says which. A table with both, or with neither, is refused, so apply Schema before you call Open: eventlog: the table has neither payload nor payload_v2; apply eventlog.Schema first.

Use AppendChecked when an event is only valid on top of the stream as it is. check gets the stream's events, and nothing is appended if it returns an error; no other write can land between the check and the append. Generated stores update and delete records this way.

Use AppendUnder when the stream's name depends on which streams exist. name gets every stream that starts with prefix and returns the one to append to; no other write can add a stream under prefix between the two. Generated stores create records this way.

EventsByPrefix escapes its LIKE pattern, so a stream name containing % or _ matches literally rather than as a wildcard.

The merge order

The merged read order is (lamport, ts, writer, seq): a total order, deterministic across edges by construction. Any two logs holding the same events in any ingest order read them back identically, which is what makes Derive converge.

Log.Order is the app-supplied comparator seam for a different merge rule. The lamport default is the framework's answer until a real one is confirmed against this shape.

Derive

func Derive[S any](events []Event, fold func(S, Event) S, zero S) S

A pure generic fold, and you should keep yours pure. The whole design rests on recomputing state from history at any time, and a fold that reads the clock or the database cannot be replayed.

ErrDiverged reports a fold that disagreed with a recorded result.

Ingest

func (l *Log) Ingest(ctx context.Context, events []Event) error

Idempotent, and the seam the platform's transport will call. Re-ingesting an event you already hold is a no-op instead of a duplicate, which is what lets a transport retry freely.

LocalWriter

func LocalWriter(db *sql.DB) (string, error)

This instance's writer identity, stable across restarts because it is persisted in its own table.

Money in events

If your events carry money, include MoneySchema after Schema:

var BootSchema = migrate.Merge(eventlog.Schema, eventlog.MoneySchema, genSchema, Schema)

It renames payload to payload_v2. An older version of your app reads and writes payload, so after the rename it fails instead of reading amounts without their currencies. That makes this a stop-start upgrade: take a backup, stop every running copy, then start the new version. To go back, restore the backup. The rename takes about half a second for 200,000 events.

Include it only when your events carry money. That is your decision, and nothing guesses it from the data. An app whose events carry no money keeps payload and upgrades with no stop-start.

A generated app does not list it. gen.Boot calls UpgradeForMoney, which applies the same change through your migration ledger if you have one, or in one transaction if your tables come from Options.Migrations. Pass it your writer, the one-connection pool.

The rename also records, for each StreamWriter (a stream and a writer), the last event written before it. UpgradeBoundary returns that record. A replay uses it to refuse an event with no version written after the upgrade, which only a version that knows nothing of currencies could have written.

var MoneySchema *migrate.Set
func UpgradeForMoney(ctx context.Context, db *sql.DB) error
func UpgradeBoundary(ctx context.Context, db *sql.DB) (map[StreamWriter]int64, error)
type StreamWriter struct{ Stream, Writer string }

What is deliberately not here

Read this page as markdown โ€” exact, unstyled, and cheap for an agent to fetch.