JavaScript client
@tephradb/client is the official JavaScript and TypeScript client. It is a single, concurrent-safe
Client that multiplexes requests over a control socket and a pool of bulk read sockets, built on
Node’s net and tls with no runtime dependencies.
npm install @tephradb/clientRequires Node.js 18 or newer and a Tephra server on 0.4 or above, which speaks the mandatory Hello
handshake this client opens with.
Quick start
Section titled “Quick start”Client.connect dials the control and bulk sockets and runs the handshake on each; close shuts
them all down.
import { Client, Event, Query, ZERO } from "@tephradb/client";
const client = await Client.connect("127.0.0.1:9000");try { const event = Event.create("CourseOpened", ["course:c1"], new TextEncoder().encode(`{"seats":30}`)); await client.append([event]);
const { events, watermark } = await client.readAll(Query.all(), ZERO); for (const seq of events) { console.log(`${seq.position} ${seq.event.type}`); }} finally { await client.close();}Event.create validates the type and tags exactly as the server does, and holds the payload as a
Uint8Array. A Position is a bigint; ZERO is the start cursor and MAX is the “from the tip”
cursor for a backward read. The event and query model is the same across every client; Core
concepts is the precise definition.
Append with a condition
Section titled “Append with a condition”An AppendCondition guards the append with two checks, OR’d. The boundary check is a dynamic
consistency boundary: it rejects the append if any event matching failIfEventsMatch already exists
after its after position. after defaults to ZERO, which considers the whole log, the
uniqueness-guard pattern.
import { AppendCondition, ErrorCode, Event, Query, QueryItem, ServerError } from "@tephradb/client";
const guard = AppendCondition.create(Query.items(QueryItem.withTags("email:a@example.com")));const event = Event.create("Registered", ["email:a@example.com"], new Uint8Array());
try { await client.append([event], guard);} catch (err) { if (err instanceof ServerError && err.code === ErrorCode.Conflict) { console.log("email already registered"); } else { throw err; }}See Handling conflicts for the retry contract on a same-batch versus a durable conflict.
Idempotent appends
Section titled “Idempotent appends”The optional existence check, failIfExists, rejects the append if any event matches its query
anywhere in the log, independent of the boundary after. It is the idempotency/dedupe guard: a
single after cannot be both a moving decision boundary and a whole-log uniqueness assertion, so
this is the second, separate check. Its conflict surfaces as ErrorCode.AlreadyExists, distinct
from a boundary ErrorCode.Conflict, so a retried command can be treated as “already applied” (a
no-op) rather than “rebuild and retry”. Use AppendCondition.existsOnly for the pure-dedupe case,
or pass a third argument to AppendCondition.create to assert a boundary and a dedupe key in one
append.
import { AppendCondition, ErrorCode, Event, Query, QueryItem, ServerError } from "@tephradb/client";
const dedupe = AppendCondition.existsOnly(Query.items(QueryItem.withTags("cmd:order-42")));
try { await client.append([Event.create("OrderPlaced", ["cmd:order-42"])], dedupe);} catch (err) { if (err instanceof ServerError && err.code === ErrorCode.AlreadyExists) { // The command was already applied; treat this retry as a no-op. } else { throw err; }}See Idempotency and deduplication for the full pattern.
Reads and pagination
Section titled “Reads and pagination”read returns a ReadStream, an async iterable; drive it with for await, then read its
watermark once it ends. readAll drains one into an array. readBack and readAllBack are the
newest-first duals.
after (exclusive) and limit compose into a stateless pagination cursor:
let cursor = ZERO;for (;;) { const page = await client.readAll(query, cursor, 100); if (page.events.length === 0) { break; } for (const seq of page.events) { handle(seq); } cursor = page.events[page.events.length - 1].position; // next page resumes here, no gap or duplicate}A streaming read consumes incrementally:
for await (const seq of client.read(Query.all(), ZERO)) { console.log(`${seq.position} ${seq.event.type}`);}Subscriptions
Section titled “Subscriptions”subscribe catches up on matching events, then tails new ones live, delivering a caught-up marker
each time it reaches the live edge.
import { isCaughtUp } from "@tephradb/client";
const subscription = client.subscribe(Query.all(), ZERO);for await (const item of subscription) { if (isCaughtUp(item)) { continue; } handle(item.event);}Cancel a stream by calling close, by passing an AbortSignal and aborting it, or by breaking out
of the for await loop. Each sends a best-effort cancel so the server stops producing frames. Every
operation also accepts an AbortSignal for cancellation and deadlines.
Server stats
Section titled “Server stats”stats returns a point-in-time snapshot with bigint counters: the event, segment, and
on-disk-byte counts, uptime, and the live connection and subscription counts.
const stats = await client.stats();console.log(`${stats.eventCount} events across ${stats.segmentCount} segments`);The server can serve TLS 1.3 (server-authenticated). Enable it with the tls option, which passes
through to Node’s tls.connect. servername defaults to the dial host, so a hostname certificate
needs no extra configuration.
import { readFileSync } from "node:fs";
// Verify against the system roots (a public CA):const client = await Client.connect("tephra.example.com:9000", { tls: true });
// Or trust a private CA, and optionally present a client certificate for mutual TLS:const client = await Client.connect("tephra.internal:9000", { tls: { ca: readFileSync("ca.pem"), cert: readFileSync("client.pem"), key: readFileSync("client-key.pem"), },});Authentication
Section titled “Authentication”When the server requires bearer tokens, pass one with authToken; it is carried in each socket’s
Hello. A missing or rejected token fails the connect with a ServerError whose code is
ErrorCode.Unauthenticated, rather than on the first request. Pair it with tls so the token is not
sent in the clear; a plaintext token is accepted only by a server explicitly configured to allow it.
const client = await Client.connect("tephra.example.com:9000", { tls: true, authToken: process.env.TEPHRA_TOKEN,});Options
Section titled “Options”Client.connect takes an options object; the defaults mirror the reference Rust client.
| Option | Default | Meaning |
|---|---|---|
bulkConnections |
4 | Dedicated bulk sockets for reads and subscriptions. 0 folds reads onto the control socket. |
maxInflightRequests |
1024 | Outstanding requests per socket before backpressure. |
requestQueueDepth |
256 | Outbound queue depth per socket. |
maxFrameLen |
16 MiB | Largest frame accepted or produced. |
connectTimeout |
none | Bounds the dial, in milliseconds. |
tls |
off | true for the system roots, or an object for a private CA, mutual TLS, or a custom minVersion. |
authToken |
none | Bearer token presented in each socket’s Hello. |
signal |
none | An AbortSignal that aborts the connect. |
Errors
Section titled “Errors”The client throws typed errors, all extending TephraError. It performs no automatic retries or
reconnection.
ServerError: the server returned an error.codeis anErrorCode(Conflictfor a boundary conflict,AlreadyExistsfor afailIfExistsduplicate),retryablemarks an advisory same-batch append conflict, andconflictPositionis set for a durable one.ProtocolError: the peer sent something outside the protocol.ConnError: the connection failed with requests in flight; every one is failed with it rather than left hanging. The cause is oncause.FrameTooLargeError: a frame exceeded the configured maximum (lengthandmaxreport the sizes).ValidationError: an event type or tag failed validation before it reached the wire.ClosedError: the client was closed.
