TessariDBDocs
  • 0.34.2-betalatest
  • 0.34.1-beta
  • 0.34.0-beta
  • 0.33.4-beta
  • 0.33.3-beta
  • 0.33.2-beta
  • 0.33.1-beta
  • 0.33.0-beta
  • 0.32.0-beta

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 build in it, then npm install ../tessaridb-sdk-js from 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's Authorization header 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: true needs a node serving TLS — one given a certificate (from node 0.21.0-beta), or a proxy in front of it. A page loaded over https:// cannot open ws:// 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 WebSocket keeps accepting, so changes nobody consumes pile up in the page's memory. Consume what you subscribe to.
The TypeScript client — TessariDB