Reference

Everything XKV offers, on one page: the Go package, the xkv command, the HTTP mode, and the file. The README is the full manual, and the Go documentation is on pkg.go.dev.

Revisions

Every write is a revision. A revision has:

Field Meaning
Rev store-wide revision number; only goes up
Tx transaction id; revisions written together share it
Key, Value the key and its new value
Deleted true when the revision deletes the key
Actor who made it
Reason why; required unless the store is relaxed
Ref an outside reference: a ticket, a request id, a tool call id
Causes up to 8 earlier revisions, as key and revision number
Time when it was written
Origin for an imported revision, where it was first written, as STOREID:REV
History set on a revision imported for the record only; it is never a key’s value

The Go Package

Call Does
Open(path, opts) opens or creates a store file; one writer per file
OpenMemory(opts) a store in memory, for tests and the browser
Put(ctx, key, value, opts...) writes one value
Delete(ctx, key, opts...) deletes a key; the delete is a revision too
Update(ctx, fn, opts...) writes several keys in one transaction
Get(ctx, key) the current revision
GetAt(ctx, key, rev) the key as the store stood at revision rev, or ErrCompacted if compaction removed that state
Revision(ctx, rev) one revision by number
History(ctx, key, limit) a key’s revisions, newest first
Why(ctx, key, depth) the tree of causes behind the current value
WhyRev(ctx, rev, depth) the same for one revision
Keys(ctx, prefix) keys that have a value
Compact(ctx, opts) drops old revisions, keeping every one a kept value points at. On Windows it fails with nothing changed while another process has the file open
Check(path) reads every record and cause of a file
Export(ctx, w, ExportOptions{Since}) writes the changes above a revision as an exchange file
Import(ctx, r, ImportOptions{Conflicts}) adds an exchange file’s changes, each with the reason it was written with
Upgrade(ctx) rewrites a 0.1 store as file format 2, keeping every revision
Watch(ctx, WatchOptions{Since, Prefix, Poll, SkipCompacted}, fn) calls fn with every revision above Since, then each new one as its transaction commits, in order, each once
Refresh() brings a read-only handle up to date with another process’s writes; Watch does it on its own
NewStoreID(ctx, keepThrough) gives a copied store its own id; revisions up to keepThrough stay shared with the original
StoreID() the store’s id

Write options: Reason(s), Actor(s), Ref(s), Cause(key, rev) and CauseLatest(key). WithActor(ctx, name) sets the actor for every write made with that context.

Open options: ReadOnly, Relaxed (accept writes without a reason, recorded as “unspecified”), NoSync (skip the fsync after each transaction; a power cut can then need a repair), Repair (trim a damaged file back to its last good transaction), DefaultActor.

Errors to check with errors.Is: ErrNotFound, ErrNoReason, ErrBadCause, ErrCompacted, ErrLocked, ErrReadOnly, ErrCorrupt, ErrFailed (a write or fsync failed; reopen the store), ErrTooLarge, ErrInvalid, ErrBadExchange (an exchange file that is cut, changed or invalid; nothing was written), ErrReplaced (a read-only handle found a different store at its path).

Watch

Watch hands a program every revision as it commits, with its reason and causes. Revisions arrive in revision order, each once, and only after their whole transaction committed, so a watcher never sees part of a change. Passing the last revision handled as Since resumes with nothing missed or repeated. A read-only handle follows writes another process makes to the same file, through compaction too. If compaction removed revisions a watcher hadn’t reached, Watch stops with ErrCompacted and names them; SkipCompacted carries on with what’s left. fn holds no lock, so it may read and write the store.

Exchange

Every store has an id, and every revision keeps the id of the store it was first written to and its number there, wherever it travels. An export carries the changes above a revision number, with their actors, reasons, refs, times and causes, plus the older revisions those causes reach, which arrive as history. Importing the same file twice changes nothing.

Each change carries what its writer had seen of the key, so an import can tell an update from a conflict without trusting clocks. When both stores changed a key without seeing each other’s change, the import keeps both: the conflict policy picks the value, newest (the default, the same choice on every store), ours or theirs, and the other change stays in the key’s history, where History marks it. If both made the same change, there’s nothing to settle. A write is never recorded as earlier than a change to its key the store already holds, so a slow clock can’t make newest undo a later edit. The whole file is checked before anything is written, and each of its transactions is imported as one. The exchange file is described line by line.

Causes

A cause must exist, must belong to the key it names, and must be older than the write. A write with a bad cause is refused and writes nothing. CauseLatest(key) points at the key’s revision current when the write is made. Inside a transaction, a cause may point at a write made earlier in the same transaction.

Why follows causes 16 levels deep by default. A node whose causes the limit didn’t reach is marked Truncated; a revision reached twice is shown in full once and marked Repeat after that. Each node also carries Prev, the key’s revision just before it. If compaction removed that revision, Prev is empty and PrevCompacted is set.

The xkv Command

Command Does
xkv put FILE KEY VALUE --reason R writes a value; add --cause KEY[@REV], --actor, --ref, or --stdin for the value
xkv del FILE KEY --reason R deletes a key
xkv get FILE KEY [--at REV] prints the value
xkv log FILE KEY [--limit N] a key’s revisions, newest first
xkv why FILE KEY [--depth N] the chain of causes; --rev REV for one revision
xkv rev FILE REV one revision
xkv keys FILE [PREFIX] keys that have a value
xkv stats FILE counts
xkv check FILE checks every record and cause
xkv compact FILE [--keep N] [--keep-days D] compacts
xkv repair FILE trims a damaged file back to its last good transaction, keeping the cut bytes in a side file
xkv export FILE [--since REV] [--out PATH] the changes above a revision as an exchange file
xkv import FILE EXCHANGE adds an exchange file’s changes; --conflicts newest, ours or theirs; - reads standard input
xkv upgrade FILE rewrites a 0.1 store as file format 2
xkv new-id FILE --keep-through REV gives a copied store file its own id; run it on the copy before it exchanges anything. It can’t be undone
xkv watch FILE [--since REV | --all] [--prefix P] [--count N] prints revisions as they commit, new ones only by default; --json for one JSON line each
xkv serve FILE [--addr HOST:PORT] the HTTP mode

Most commands take --json. The actor is --actor, else $XKV_ACTOR, else your user name. Read commands open the file read-only, so they work while xkv serve holds it.

HTTP Mode

xkv serve FILE listens on 127.0.0.1:7070 and speaks JSON. It has no authentication, so keep it on localhost.

Method and path Does
GET /v1/keys?prefix=P keys that have a value
GET /v1/keys/KEY[?at=REV] a key’s revision
PUT /v1/keys/KEY writes; body {"value", "reason", "actor", "ref", "causes": [{"key", "rev"}]}
DELETE /v1/keys/KEY deletes; same body without value
GET /v1/history/KEY[?limit=N] revisions, newest first
GET /v1/why/KEY[?depth=N] the tree of causes
GET /v1/revs/REV and /v1/revs/REV/why one revision, and its tree
GET /v1/stats counts, the file format and the store id
GET /v1/export[?since=REV] an exchange file; the X-Xkv-Through header gives the revision to ask from next time
POST /v1/import[?conflicts=newest|ours|theirs] imports the exchange file in the body and answers with the report
GET /v1/watch[?since=REV][&prefix=P] a stream of revisions as they commit: JSON lines, or Server-Sent Events with Accept: text/event-stream, resuming from Last-Event-ID

A cause without rev points at the key’s current revision. Errors come back as {"error": "..."} with 400, 404, 410, 413 or 500. The HTTP reference has the details.

The File

A store is one append-only file: a 48-byte header holding the store id (32 bytes in files made by 0.1), then frames, each with a length and a CRC-32C checksum. A transaction is its revision records followed by a commit record, written with one write call and an fsync. On open, a torn end of file is trimmed back to the last complete transaction; damage in the middle is refused. An index snapshot beside the file, FILE.idx, makes large files open faster and is always checked against the file. The format is written up byte by byte.

Limits

Keys up to 4 KB, values up to 16 MB, reasons up to 4 KB, 8 causes per write, 64 MB per transaction. The index lives in memory at about 70 bytes per revision.