release

XKV Now Streams Every Change as It Commits, With the Reason Attached

XKV 0.3.0 adds watch. A program asks the store for every new revision, and gets each one the moment its transaction commits: the key, the value, who wrote it, why, and the earlier changes that caused it. So the part of a system that reacts to a change also knows the reason for it.

That covers the jobs people usually bolt onto a config store with polling or triggers. Refresh a cache when a price changes. Tell a person when a limit drops, along with the risk score behind it. Write an audit line. Start a job when an AI agent changes a setting, with the tool call it gave as its reference.

A terminal running xkv watch on a store file: the chargeback, the risk score it raised and the credit limit that dropped arrive one after another, each with its writer, its reason and its cause.

Three Ways In

err := db.Watch(ctx, xkv.WatchOptions{Since: last, Prefix: "config/"}, func(r xkv.Revision) error {
	log.Printf("%s = %s by %s: %s", r.Key, r.Value, r.Actor, r.Reason)
	return nil
})

From Go it’s Watch. From a shell, xkv watch FILE prints changes as they land, with --prefix to narrow it and --json for scripts. Over HTTP, GET /v1/watch keeps the connection open and streams JSON lines, or Server-Sent Events for a browser.

A store has one writing process, but a watcher doesn’t have to be it. Another process can open the same file read-only and follow along, even when the writer compacts the file under it.

What It Promises

Revisions arrive in order, each once, and only after their whole transaction has committed, so a watcher never sees half of a change that touched three keys. Pass the last revision you handled as Since and a restarted watcher picks up exactly where it left off.

Compaction is where a stream like this usually lies. If revisions a watcher hadn’t reached yet were compacted away, Watch stops with ErrCompacted and says which ones were lost, instead of skipping them quietly. A watcher that doesn’t mind can pass SkipCompacted and carry on with what’s left.

Changes that arrive through an exchange import are revisions like any other, so they show up in the stream too, with the reason their first writer gave.

How It Was Tested

Four goroutines wrote 150 transactions each while a watcher took them in. All 1,201 revisions arrived once, in order, and no transaction arrived in pieces. A longer stress run added compaction in a loop, a watcher limited to one prefix, and a read-only handle following the file from outside, polling every millisecond. Each one got every revision it should, in order, through every compaction.

The harder case is a writer that dies. Twenty writer processes were killed with SIGKILL while another process watched the file. All 3,996 transactions they had acknowledged reached the watcher whole and in order, and it never delivered anything the file doesn’t hold.

An independent review went through the code before the release, in two rounds. It found that a browser reconnecting to the stream could miss or repeat changes, that a stopped watch could still deliver a few more revisions, and that a watcher could go quiet if someone recreated the file under it. All of that is fixed, and each finding has its own test.

In the Demo

The live demo has a new panel, “Changes as they happen”. It’s fed by the same Watch, compiled to WebAssembly, so every story you load and every change you write shows up there as it commits.

The reference lists the new call, command and endpoint. Binaries for Linux, macOS and Windows are on the download page.