A ~4 MB static binary
One self-contained musl binary, about 4 MB with TLS and jemalloc built in, no JVM and no runtime dependencies. Copy it to any Linux host and run it.

A ~4 MB static binary
One self-contained musl binary, about 4 MB with TLS and jemalloc built in, no JVM and no runtime dependencies. Copy it to any Linux host and run it.
Group commit
One fsync per batch, so batch size is the throughput dial: batching lifts conditional appends by two orders of magnitude over committing one at a time.
Dynamic Consistency Boundary
One query reads exactly the events a decision depends on and guards the append that records it. No aggregate boundary to grow, no saga to compensate.
Everything is derived from the log
The log is the source of truth. The tag and type indexes are built from it and rebuildable, so there is no index write-ahead log, no compaction, and no page reuse.
Tephra is an append-only event store. Every event carries a type and a set of tags, a query reads exactly the events a decision depends on, and the same query guards the append that records the decision. A single writer assigns a dense, monotonic position to every event, so the log is one global order and everything else (the tag index, the type column) is derived from it and rebuildable by replaying it.
It is for teams applying the Dynamic Consistency Boundary pattern, where the consistency boundary is derived per decision from a query rather than baked into an aggregate. You run it as a server and talk to it with the client, one node, one logical writer per bounded context. If you need to shard writes horizontally, this is the wrong tool, and the constraints below say so before you adopt it, not after.
// Connect to a running Tephra server.let mut client = Client::connect(addr)?;
// Record an enrolment, but only if this course has no enrolment recorded yet.let event = Event::new( "StudentEnrolled", ["course:c1", "student:s1"], br#"{"course":"c1","student":"s1"}"#.to_vec(),)?;let guard = AppendCondition::new(Query::item(QueryItem::with_tags(Tags::new([Tag::new( "course:c1",)?])?)));
match client.append([event], Some(guard)) { Ok(result) => println!("recorded at {}", result.first), Err(err) => eprintln!("append rejected: {err:?}"),}
// Read every event tagged course:c1, ascending, from the start of the log.let query = Query::item(QueryItem::with_tags(Tags::new([Tag::new("course:c1")?])?));let (events, _watermark) = client.read_all(query, Position::ZERO, None)?;for seq in &events { println!("{} {}", seq.position(), seq.event().event_type());}Prefer to embed the engine in your own process instead of running the server? See Embedded.
The log is the source of truth. The indexes keep no write-ahead log and no crash-consistent update path, because a lost or corrupt index is rebuilt by replaying the log segment it derives from.
Data is written once. There is no update, no delete, no compaction, no free list, and no page reuse, so the machinery a B-tree or an LSM exists to run is simply not here.
Structures split on cardinality. High-cardinality tags get an inverted index: an FST term dictionary over tiered posting lists. Low-cardinality types get a dense two-byte column, indexed by position, that a type-only query scans directly.
Segments are position-disjoint. An after: p bound prunes whole segments by comparing a
header, with no probing, and merging two adjacent index segments is per-term concatenation
rather than a k-way merge, because their position ranges never overlap.
The planner changes the speed, never the answer. Indexed fetch and filtered scan return the identical positions, and a test runs every read under both to prove it, so a wrong cost estimate can only pick a slower correct path.
One logical writer per bounded context. You cannot shard your way out: the value of the Dynamic Consistency Boundary is queries that span entities, so partitioning by tag would break exactly the cross-entity conditions the store exists to check. You scale by running more bounded contexts, not by splitting one.
The ceiling is fsync, not ordering. Throughput is set by how many writes coalesce into one group-committed fsync: on the same disk and the same code, batching conditional appends lifts throughput by about two orders of magnitude over committing one at a time. Batch, and measure on the storage you will actually run on. The Operations page carries the measured figures with their full hardware conditions.
Single node. Replication is not built. See Status for what is deferred and what would need to be true before it starts.
# Run the server, whichever way suits you (Docker, Nix, Cargo, or a prebuilt binary):docker run -p 9000:9000 -v tephra-data:/data git.tqwewe.com/tephra/tephra:latestnix run git+https://git.tqwewe.com/tephra/tephracargo install tephra-servercargo binstall tephra-server # no-compile: grabs a prebuilt binary from the releases
# Then talk to it with a client. Go:go get github.com/tephradb/tephra-go# JavaScript:npm install @tephradb/client# Rust:cargo add tephra-client
# Or embed the engine directly in a Rust process:cargo add tephraOfficial clients for Go, JavaScript, and Rust all speak the same protocol; see Clients.
Next: read the Introduction for what a Dynamic Consistency Boundary is and the problem it removes, or jump to Getting started to run the server and append your first event.
Tephra is the ash and rock a volcano throws out in a single eruption. One fall settles as one layer across otherwise unrelated sediment records, and tephrochronology reads that layer as a shared time marker to line those records up. A globally ordered log is the same marker for events: one order every reader agrees on, laid down once.