The TypeScript client
npm install, one connection per session, a builder whose values never reach the text — and the same client in a browser, over a WebSocket.
npm install @tessaridb/client
Node.js 22 or newer, no dependencies, Apache-2.0. It runs on the runtimes
that implement node:net, which today are Node, Deno and Bun — and, over a
WebSocket, in a browser.
Not on npm yet. Until it is: clone the repository, run
npm install && npm run buildin it, thennpm install ../tessaridb-sdk-jsfrom your project.
22 and not 20, for one reason. A record identity is an i64, and
JSON.parse reads every number as a double — so an id past 2⁵³ comes back
changed, with nothing anywhere reporting it. The fix is the parser's source-text
access, which arrived in Node 22. On a runtime without it this client refuses to
read the body rather than reading it wrongly.
A first read
import { connect } from '@tessaridb/client';
const db = await connect({ host: '127.0.0.1', port: 9080 });
const reply = await db.execute(
'USE NAMESPACE prod; USE DATABASE app; SELECT * FROM person;',
);
One outcome per statement, in order. Credentials are optional: a store with no
users declared is open and runs anything, and the first
DEFINE USER closes it.
One connection is one session
USE NAMESPACE prod; is still in force on the next execute on that connection,
and two connections are two sessions. A pooled connection that reconnects has
forgotten its USE, and the read that follows goes to the wrong database with no
error — reading a namespace that exists is not a fault. Send the USE with the
work.
Over TLS
A node given a certificate speaks TLS 1.3 on both ports and nothing else, and a
node in a cluster serves clients in the clear only when its operator chose to
(node 0.21.0-beta and later; Encryption in transit).
import { readFileSync } from 'node:fs';
import { connect, HttpClient } from '@tessaridb/client';
const connection = await connect({
host: 'db.example',
port: 9080,
tls: { ca: readFileSync('ca.pem', 'utf8') }, // or tls: {} for the system's store
});
const http = new HttpClient({ host: 'db.example', port: 8000, secure: true });
The wire connection checks the node's certificate chain and that it names the
host, including each node a redirect sends it to; no option turns either check
off, and a failed handshake is a TlsError, which is not retried. The HTTP
client uses the runtime's fetch, which checks against the runtime's own store:
start Node with NODE_EXTRA_CA_CERTS=ca.pem to add a private authority to it.
From 0.8.0.
A refusal says what to do next
From 0.9.0 a refusal carries its class — RefusalError.refusalClass (and HttpError.refusalClass), typed as the RefusalClass string union. Branch on it rather than on the message, which is the store's own prose and changes between releases. It is undefined from a node before 0.30.0-beta, which sends the words alone, and a class this build does not know reads as unknown, which is not retried.
A redirect is followed
On a cluster, a request sent to a node that should not answer it comes back as a
redirect (where to go), and execute follows it before returning — at most three hops,
never to an older leadership than one already followed, the session's namespace
and database selected again on arrival, and only to the node the redirect named.
A settled redirect moves the connection there; a transient one answers there
and leaves the connection where it was. Each way following can stop is its own
error: RedirectLoopError, StaleRedirectError, WrongNodeError and NotFollowableError. Over a WebSocket a browser cannot dial another node, so the reply is the redirect itself, { kind: 'elsewhere' }, for the caller to act on. From 0.7.0.
Building a statement
import { connect, select, compare } from '@tessaridb/client';
const { script, parameters } = select('memories')
.field('body')
.where(compare('session', 'eq', { kind: 'string', value: 'abc' }))
.orderBy('created', 'desc')
.limit(50)
.render();
// SELECT body FROM memories WHERE session = $p0 ORDER BY created DESC LIMIT 50;
const reply = await db.execute(script, parameters);
A value you pass never reaches the statement text. A name is the other half: a table or a field is grammar, so a parameter cannot supply one and it is written into the text directly — which is safe only because each is checked first, against a production deliberately narrower than the node's own lexer.
select('memories; DROP COLLECTION memories; --');
// BuilderError: reason 'not-a-name', position 'a table'
Refused rather than quoted into acceptance: quoting would turn the mistake into a statement that runs and means something else.
On a cluster, .staleness('30s') and .answeredBy('LEADER') say which node
may answer rather than what the answer holds. They are separate controls: a
follower at zero lag is level, not authoritative.
A feed narrowed by a condition
A feed over one table can be narrowed by a condition — TessariQL without WHERE, its values bound
rather than written into it:
for await (const arrival of conn.changes({
table: 'orders',
condition: 'total > $least',
parameters: new Map([['least', { kind: 'integer', value: 100n }]]),
})) {
if (arrival.kind === 'progress') continue; // store arrival.sequence as a change's position
console.log(arrival.identity, arrival.fate);
}
A record that stops matching arrives as a removal, so a mirror applying the feed holds exactly the matching
records. A feed that skipped changes hands over a Progress — store it as you store a change's position, or the
resume point falls behind a pruned log. Only a node of minor 4 (0.33.0-beta and later) reads a condition, and an
older one would send every change, so the client refuses before anything is sent: NodeTooOldError. See
narrowing a feed. From 0.10.0.
Consuming a topic
A topic's consumer group hands each message to one member and forgets it only when it is acknowledged. Consumer reads under a group and calls your function once per message, in order. From 0.3.0, against an engine at 0.12.0-beta or later.
import { connect, Consumer } from '@tessaridb/client';
const connection = await connect({ host: '127.0.0.1', port: 9080 });
const consumer = new Consumer(connection, {
namespace: 'app',
database: 'main',
topic: 'jobs',
group: 'workers',
});
// Automatic: resolving acknowledges the message, throwing hands it back at once.
await consumer.runAuto(async (message) => {
console.log(message.position, message.deliveries, message.value);
});
// Manual: resolve to { kind: 'ack' }, { kind: 'nack', delayMs: 5000 } or
// { kind: 'leave' } for the group's deadline to hand it out again.
await consumer.runManual(async () => ({ kind: 'ack' }));
consumer.stop(); // from anywhere: the running handler finishes, then the loop ends
Both modes are at least once: make an effect outside the store idempotent,
keyed by the topic, the group and message.position. The group, not the
connection, holds the state, so a restarted process carries on where the group
stands, and the group is declared in the store rather than by the consumer. The
behaviour is the protocol repository's spec/consumer-v1.md, which every client
follows.
A space as a cache
Cache makes each cache use one call over a connection you hold — TCP, or a
WebSocket in a browser; a ttl is milliseconds (node 0.15.0-beta and later):
const cache = new Cache(connection, { namespace: 'app', database: 'main', space: 'cache' });
await cache.set('session:abc', { kind: 'string', value: 'ada' }, { ttlMs: 1_800_000 });
const lease = await cache.lock('nightly-report', 30_000);
if (lease) await lease.release();
The two rules are the page's own: a plain set clears an expiry, and a lock is a lease whose release is an expiring conditional write, never a delete. The full surface is A cache over HTTP and in the clients.
A vault, and its passphrase
A vault (DEFINE VAULT) keeps SECRET fields encrypted in every copy that is not a
running, unsealed node. The passphrase goes in a frame of its own, never in a
statement, and is in no error this package raises:
import { Vault, connect, unseal } from '@tessaridb/client';
const conn = await connect({ host: '127.0.0.1', port: 9080 });
await unseal(conn, storePassphrase); // the store's key, for ten minutes
const vault = new Vault(conn, 'app', 'main', 'team');
await vault.write(
'github',
new Map([['password', { kind: 'string', value: 'hunter2' }]]),
);
const page = await vault.list({ limit: 100 }); // ids only, never a value
const secret = await vault.reveal('github', ['password']);
A vault declared DEFINE VAULT team PASSPHRASE '…' opens with its own passphrase
instead, and the store's opens nothing in it: vault.status(), vault.unseal(…),
vault.seal() and vault.changePassphrase(…) act on that vault alone, and
status().custody says which kind a vault is. An unseal lasts the node's period and
then closes by itself; a refusal after a run of wrong passphrases means wait, and
is not retried here.
What unsealing opens, and what a vault with its own passphrase changes, is on
Vaults.
Objects, files and health
import { HttpClient } from '@tessaridb/client';
const node = new HttpClient({
host: '127.0.0.1',
port: 8000,
credentials: { user: 'app', password: process.env.TESSARIDB_PASSWORD },
});
await node.put('prod', 'app', 'uploads', 'reports/100% done.pdf', bytes);
const back = await node.get('prod', 'app', 'uploads', 'reports/100% done.pdf');
const listing = await node.list('prod', 'app', 'uploads');
The password is spent once. HTTP has no connection to hang a session on, so a node verifies Basic with Argon2id on every request that carries one. This client opens a session on its first authenticated call and presents the token after. Measured against a debug build over loopback, on a statement that does nothing: 31.5 ms per request with a password against 14.7 ms with a token.
A 404 is an answer. A file that is not there reads as undefined, and a
file that exists and is empty reads as zero bytes. A listing that comes back
undefined means the name is not a bucket; an empty array means the bucket is
there and holds nothing.
Appending events to a series
const landed = await node.append('prod', 'metrics', 'readings', [
{
kind: 'object',
fields: new Map([
['sensor', { kind: 'string', value: 's1' }],
['at', { kind: 'datetime', seconds: 1_790_676_000n, nanos: 0 }],
]),
},
]);
From 0.4.0, against an engine at 0.14.0-beta or later. The batch lands
whole or not at all, and the answer is how many landed. It is sent once: appending is not idempotent, so a transport failure after the
request left is yours to judge. A kind an event cannot carry — bytes, a range, a non-finite float — throws NotAnEventError before anything is sent. See
Time-series for
what a series does with the events.
In a browser
A browser cannot open a TCP socket, so the node also carries the same wire
protocol over a WebSocket on its HTTP port, at GET /wire (node 0.15.0-beta
and later). The same frames, the same seventeen types and the same session — not
the HTTP half and its JSON.
import { connect } from '@tessaridb/client/browser';
// The HTTP port, not the wire port.
const connection = await connect({ host: 'db.example', port: 443, secure: true });
await connection.execute('USE NAMESPACE app; USE DATABASE main;');
const reply = await connection.execute('SELECT * FROM users LIMIT 10;');
In Node.js the same transport is connect({ ..., transport: 'websocket' }); the
default stays TCP to the wire port. A bundler that honours the browser export
condition picks the browser entry from @tessaridb/client on its own, and that
entry imports no Node.js module by any path.
Three things to know first:
- Credentials go in
connect, never in a cookie. The node ignores a browser'sAuthorizationheader and its cookies on/wire, deliberately: a browser attaches both to a WebSocket opened by any page, so honouring them would let any page act as the user. A store with no users is open here, as it is on every other route — declare an owner first. secure: trueneeds a node serving TLS — one given a certificate (from node0.21.0-beta), or a proxy in front of it. A page loaded overhttps://cannot openws://at all.- A browser cannot stop reading. Over TCP a subscriber that stops consuming
fills its socket and the node drops it after 30 seconds; a browser's
WebSocketkeeps accepting, so changes nobody consumes pile up in the page's memory. Consume what you subscribe to.