@limidus/kdb-client
v0.9.2
Published
TypeScript client for KDB - wire codec, transports and the document/SQL/lease operation set.
Readme
@limidus/kdb-client
The KDB network driver for TypeScript. Implements Component 63
Phase 1: the wire codec, the transports, and the operation set that go/kdb/client exposes.
Zero runtime dependencies. The main entry point touches no Node built-ins, so it loads unchanged
in browsers, Bun, Deno and Workers-style runtimes; the TCP transport lives behind the
@limidus/kdb-client/tcp subpath and is reached lazily.
import { connect } from "@limidus/kdb-client";
const client = await connect("tcp://localhost:9090", { token: "alice:secret" });
const docId = "6f9619ff-8b86-d011-b42d-00cf4fc964ff";
await client.putJSON("app/users", docId, { name: "ada", score: 42 });
const { body, commit } = await client.get("app/users", docId);
// Read-modify-write without losing a concurrent update.
await client.compareAndSwap("app/users", docId, (current) => ({
...(current as Record<string, unknown>),
score: 43,
}));
const rows = await client.query("app/users", "SELECT id, name FROM users WHERE score > ?", [40]);
await client.close();Status
| | | |---|---| | Wire codec, transports, operations | Implemented, 81 tests passing | | Cross-language conformance | Proven against golden fixtures generated by the Go encoder | | Live interop vs. the Go server | Passing, over a real WebSocket | | Live interop vs. the JVM server | Not run — see below | | Published to npm | Per release, at the server's version (see below) |
What is and isn't verified
test/interop.test.ts runs this client against a real Go server over a real WebSocket:
handshake, document round trips (including non-ASCII), upsert, conditional writes, concurrent
compare-and-swap, multi-document commit, and SQL. It starts the server itself via
go/cmd/kdb-tsinterop and skips cleanly when the Go toolchain is absent.
The content-hash agreement is checked end to end there, which is worth calling out: that hash
never crosses the wire, so the only way to test it is to hand one back as an
EXPECT_CONTENT_HASH precondition and require the server to accept it — and then to show a
stale one is refused.
Still unverified: the JVM kdb-server. It implements only part of the message set — codes
0x14–0x1C (documentGet, upsert, and the four lock verbs) are Go-server-only. Against it,
get, upsert, appendEvent and the leases should raise KdbUnsupportedError while query,
exec, putJSON, commit and the conditional writes work; that split is implemented and
unit-tested, but has not been run against an actual JVM server.
Connecting
connect(addr, options) accepts ws://, wss://, tcp://, tcps://, or a bare host:port
(which means TCP, and is therefore Node-only). tcps:// and wss:// require options.tls and
refuse to fall back to plaintext.
const client = await connect("tcps://kdb.internal:9090", {
token: "alice:secret",
tls: { ca: await readFile("ca.pem", "utf8") },
requestTimeoutMs: 10_000,
});One Client is one connection is one KDB session — hold it for the process lifetime rather than
opening one per request. Concurrent calls on a single client are fine; requests are matched to
replies by correlation id.
Attachments
A document can carry named binary attachments. Against a server that agrees the attachments
wire feature (binary frames; not jsonOnly):
const { attachment } = await client.putAttachment(ns, docId, file.stream(), { name: "scan.pdf", mime: "application/pdf" });
const items = await client.listAttachments(ns, docId); // [{ id, name, hash, size, mime }]
for await (const page of await client.getAttachment(ns, docId, "scan.pdf")) { /* ... */ }
const { data } = await client.readAttachment(ns, docId, { id: attachment.id });
await client.renameAttachment(ns, docId, "scan.pdf", "contract.pdf");
await client.deleteAttachment(ns, docId, "contract.pdf");Sources may be a Uint8Array, Blob, ReadableStream or (async) iterable; uploads go in 1 MiB
chunks and a KdbUploadError carries what resumes one. Reads are checked against the
attachment's content hash as they stream (the last page is withheld until it verifies;
KdbIntegrityError otherwise). uploadAttachment stores bytes only; prepareAttachment returns
an operation for Transaction.attachments, so attachments can commit together with document
writes. In SQL, a Uint8Array argument binds as bytes, sqlAttachment(blob) binds an uploaded
blob (STREAM ?), and queryValues returns bytes cells (ATTACHMENT('name')) as Uint8Array.
Part of an attachment can be read verified, without the rest, from a server that also agrees
attachment-proofs: every 1 MiB chunk the range touches is checked against the content hash with
its Merkle inclusion proof before any of its bytes is handed out.
const reader = await client.openAttachmentRange(ns, docId, "video.mp4", { offset: 5_000_000, length: 65_536 });
for await (const part of reader) { /* ... */ }
const bytes = await client.readAttachmentAt(ns, docId, "video.mp4", 5_000_000, 65_536); // shorter at the endAn offset past the end throws KdbAttachmentRangeError. (ReadAttachmentOptions.offset is a
cheaper, unverified partial read.)
Error handling
Everything thrown extends KdbError, which carries the server's code and retryAfterMs when
it sent them.
import { KdbConflictError, KdbNotFoundError, KdbPreconditionError } from "@limidus/kdb-client";
try {
await client.replaceIf(ns, docId, body, expectedHash);
} catch (error) {
if (error instanceof KdbPreconditionError) {
// The document changed under us. Re-read and decide - or just use compareAndSwap.
} else if (error instanceof KdbConflictError) {
// Someone else committed against the same baseVersion first. Retryable.
} else if (error instanceof KdbNotFoundError) {
// ...
}
}KdbPreconditionError and KdbConflictError are deliberately distinct even though both arrive
on the same wire message: a failed assertion is generally not worth retrying, a lost race is.
Things worth knowing
Document ids are UUIDs. Anything else is rejected client-side with an argument error rather than becoming an opaque server failure.
SQL values come back as strings. sqlResult.rows is string[][] on the wire and this layer
does not guess at types — a driver that silently parsed "0123" as 123 would corrupt data.
Typed coercion arrives with @limidus/kdb-codegen (Phase 2), which derives types from the KDB schema.
Content hashes are computed locally. getWithHash and replaceIf compare a hash defined as
a pure function of (documentId, json); it never crosses the wire. That is why those methods,
and compareAndSwap, are async even when nothing is being sent.
Every request has a deadline (default 30s, overridable per call). This is a correctness backstop: some server paths answer an unrecognized message with nothing at all rather than an error frame, and a driver that can hang forever on a server bug is not shippable.
Reconnection is yours. Connection loss surfaces honestly as a rejected promise; automatic
reconnect with request replay is deferred, because replaying a txCommit whose reply was lost is
not obviously safe and the client cannot make that call for you.
Development
npm install
npm run typecheck
npm test
npm run buildRegenerate the golden fixtures after any change to the wire format on the Go side:
cd ../../go && go run ./cmd/kdb-tsfixturesThat command writes test/golden/*.json from the real Go encoder — the same code paths a live
server uses. It is what makes "wire-compatible" a checked claim rather than an asserted one, and
it is the first thing to re-run when a frame stops decoding.
Versioning and compatibility
@limidus/kdb-client is published once per KDB release with the server's version number (0.9.2 of
the package speaks to 0.7.x servers). The handshake enforces it: the server reports its release
in handshakeAck.serverVersion, and connect fails with a KdbProtocolError when that is not a
release this client was published for (same major; while the major is 0, same minor). A server
that reports no version (older than 0.7.0, or the JVM engine) is refused too, and a from-source
0.0.0-dev server is accepted. Pass skipVersionCheck: true to override deliberately.
Pin the dependency to the server release you deploy ("@limidus/kdb-client": "0.9.2"); the release
workflow refuses to publish a package whose version differs from the repo's VERSION, and
attaches the tarball to the GitHub release as well as publishing it to npm.
