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.