skaidb — Node.js / TypeScript driver
Repository: github.com/porcupin26/skaidb-node — this page is the driver's README at
v1.4.1; the repository also holds its docs, examples and changelog. Install:
npm install @skaidb/client
The official skaidb driver for Node.js. The API is
modeled on node-postgres (pg): new Client(…),
await client.query(…), { rows, rowCount, fields }. Zero dependencies
(pure net, tls, crypto), one file, TypeScript declarations included.
- Node.js 18 or newer, CommonJS (
require) and ESM/TypeScript (import) both work. - Speaks skaidb's binary protocol directly: SCRAM-SHA-256 auth, server-side prepared statements with typed parameters, streaming result sets, one-round-trip batches, full type fidelity (bigint, Decimal, UUID, Bytes, Timestamp, Array, Document).
- Multi-seed failover, transparent reconnect, TLS, connection pooling balanced across the cluster's members (policies, health ejection, discovery).
Full documentation: this README, the docs/ folder
(getting started · API reference ·
types and TypeScript), runnable examples/, the
wire protocol specification and the
skaidb documentation.
Install
npm install @skaidb/client
The package is @skaidb/client on the npm registry and is required as
@skaidb/client. To install straight from GitHub instead, pin a tag with
npm install github:porcupin26/skaidb-node#v1.4.1 (#main follows the
development branch); the module name is the same whatever the install
source.
Quick start
const { Client } = require('@skaidb/client');
const client = new Client({
host: 'localhost', port: 7000,
user: 'skaidb', password: 'secret',
});
await client.connect();
await client.query('CREATE TABLE users (PRIMARY KEY (id))');
await client.query('INSERT INTO users (id, name) VALUES ($1, $2)', [1, 'Ada']);
const res = await client.query('SELECT id, name FROM users WHERE id = $1', [1]);
console.log(res.rows); // [ { id: 1, name: 'Ada' } ]
console.log(res.rowCount); // 1
await client.end();
TypeScript:
import { Client, QueryResult } from '@skaidb/client';
interface User { id: number; name: string }
const client = new Client({ seeds: ['db1:7000', 'db2:7000'], user: 'app', password: 'secret' });
await client.connect();
const res: QueryResult<User> = await client.query<User>('SELECT id, name FROM users');
Connecting
new Client({
// Where. Either host/port, or a seed list (which wins when both are given).
host: 'localhost', // default 'localhost'
port: 7000, // default 7000; also the port for seeds given without one
seeds: ['db1:7000', 'db2:7000', 'db3'],
// Who.
user: 'skaidb', // default 'anonymous'
password: 'secret', // default '' (anonymous)
database: 'app', // optional: `USE app` right after the handshake
// How.
consistency: 'QUORUM', // 'ONE' | 'QUORUM' | 'ALL' (or 0 | 1 | 2); default QUORUM
connectTimeout: 10000, // ms per seed, TCP + TLS handshake
// TLS (see below).
tls: false, tlsCa: undefined, tlsInsecure: false, tlsServerName: 'skaidb',
tlsClientCert: undefined, tlsClientKey: undefined, // PEM paths; imply tls
authMechanism: 'scram', // or 'certificate': log in with the client certificate
});
Every option is also accepted by Pool.
Seeds and failover
skaidb is leaderless: every node accepts reads and writes, so a seed list is
only "somewhere to land". connect() shuffles the seeds and tries them in
turn until one connects and authenticates (a node that accepts TCP while
unhealthy does not swallow the attempt). If none works it throws
no reachable endpoint in <list>: <last error>. Bare hosts take port.
The same walk runs again on reconnect (below), so a client survives the node it was talking to going away. A pooled connection re-dials in the pool's order instead: usable members first, the one it just lost last.
TLS modes
| Option | Meaning |
|---|---|
| (none) | Plaintext. A server with client_tls = required refuses this. |
tls: true |
TLS, server certificate verified against the system trust store. |
tlsCa: '/path/ca.pem' |
TLS, certificate verified against this PEM bundle. Implies tls. |
tlsInsecure: true |
TLS with no certificate verification: encrypts, authenticates nothing. Development only. Implies tls. |
tlsServerName |
SNI / hostname to verify (default skaidb). Must match a SAN on the server certificate, which is usually not the address you dialled — skaidb's own certificates carry DNS:skaidb. |
new Client({ seeds, user, password, tlsCa: '/etc/skaidb/skai-ca.crt' }); // recommended
Client certificates and certificate login
tlsClientCert / tlsClientKey (PEM file paths, given together) present a
client certificate in the TLS handshake, which a server that verifies
clients needs. Either implies tls. On its own the certificate only opens
the TLS session; the login is still the SCRAM user/password.
With authMechanism: 'certificate' the certificate is the login (wire
mechanism EXTERNAL): the server maps its Common Name to a role and no
password is sent. The server needs auth.x509_enabled, and its client CA
must have signed the certificate. Pass user only to assert the expected
identity: a certificate mapped to a different role fails the connect.
new Client({ seeds, tlsCa: '/etc/skaidb/ca.crt',
tlsClientCert: '/etc/app/app.crt', tlsClientKey: '/etc/app/app.key',
authMechanism: 'certificate', database: 'app' });
Consistency
consistency selects how many replicas must acknowledge a write or be
consulted for a read: ONE, QUORUM (the default) or ALL. Set it per client
or per statement:
const client = new Client({ ..., consistency: 'ONE' });
await client.query({ text: 'SELECT ...', consistency: 'ALL' });
for await (const row of client.stream('SELECT ...', { consistency: 'ONE' })) { ... }
batch(sql, rows, { consistency }) takes it per batch too. The CONSISTENCY export holds the
numeric constants ({ ONE: 0, QUORUM: 1, ALL: 2 }).
Queries
client.query(text, values?) / client.query({ text, values, consistency, rowMode })
Runs one statement and resolves to a result:
{
command: 'SELECT' | 'MUTATION' | 'DDL' | 'CALL',
rowCount: number | null, // rows returned, rows affected, or null for DDL
rows: [...], // objects keyed by column name (default) or arrays
fields: [{ name }], // column descriptors, pg-style
columns: ['id', 'name'], // column names, for row-producing statements
resultSets?: [...] // only for a CALL whose body EMITs; see below
}
rowMode: 'array' yields each row as a cell array in column order (like pg).
Statements on one client are serialized: one request/response is in
flight at a time and further calls queue. Use a Pool (or several clients)
for concurrency.
Parameters and prepared statements
Placeholders are $1, $2, … (pg style). A parameter may be referenced more
than once; $N inside a string literal is left alone.
await client.query('UPDATE users SET name = $2, seen = $3 WHERE id = $1', [1, 'Ada', new Date()]);
With parameters, the driver prepares the statement on the server (once per
distinct SQL text per connection, cached up to 240 entries) and sends the
values as typed binary — no string interpolation, no injection surface,
and JavaScript arrays and plain objects travel as skaidb Array and
Document values, which have no SQL literal form. The statement's parameter
count must match what you pass (missing ones throw before anything is sent).
There is no separate prepare()/execute() API: preparing is automatic.
Statements the server declines to prepare (DDL, USE and other session
statements) fall back to safe client-side quoting of the same $N
parameters, so every statement kind accepts parameters. Prepared ids are
scoped to a connection and the cache is dropped on reconnect.
client.batch(sql, rows, opts?) — many rows, one round-trip
const n = await client.batch('INSERT INTO t (id, tags) VALUES ($1, $2)',
[[1, ['a']], [2, []], [3, ['b', 'c']]]); // n === 3
Runs a prepared statement once per row in a single request and returns the
total affected count. Rows autocommit individually: on the first
failing row the server answers with an error naming the row index and how
many rows applied before it, and those earlier rows stay applied — so use
idempotent statements. Only preparable statements (SELECT/INSERT/
UPDATE/DELETE) can be batched; the whole request must fit one 64 MiB
frame. An empty rows resolves to 0 without a round-trip. opts is
{ consistency } (default: the client's).
Multiple result sets
A CALL of a procedure whose body runs EMIT <select> answers with every
emitted set plus the call's final result:
const r = await client.query('CALL report()');
r.resultSets // every set, in emission order, each { rows, fields, columns, rowCount }
r.rows // the LAST set (the call's own result)
Transactions
Every statement autocommits; the driver adds no transaction API of its own.
Whatever transaction statements your server version supports are plain SQL
sent through query() on one connection (statements on a client are
serialized, so they run in order). batch() rows autocommit one by one, as
described above.
Streaming large results — client.stream(sql, values?, opts?)
query() buffers the whole result; a large scan is also bounded by the
server's scan budgets. stream() asks the server to deliver the rows in
chunks and yields them one at a time, holding one chunk in memory. The
server executes a plain SELECT page by page, and no scan budget applies to
a streamed page (server ≥ 0.330; EXPLAIN says whether a statement pages,
and PROTOCOL.md §3.4
gives the rules):
for await (const row of client.stream('SELECT id, v FROM readings')) {
process(row);
}
console.log(client.stream.columns); // column names, set once the header arrives
values bind $1, $2, … exactly as query(text, values) binds them —
typed, arrays and objects included:
for await (const ev of client.stream(
'SELECT id, ts, body FROM events WHERE ts >= $1 AND ts < $2', [start, end])) {
save(ev);
}
They travel with the statement in one request, which needs a server ≥
0.330: an older one refuses it with server does not support parameterised
streaming (skaidb >= 0.330): …, and the connection stays usable. A stream
without values sends the original request and works against every server
with streaming. Values bind the statement kinds that can be prepared
(SELECT/INSERT/UPDATE/DELETE); unlike query(), a stream has no
client-side quoting fallback for the others.
Options: { consistency, rowMode, values } — values given there wins
over the argument, as config.values does for query(), and
stream(sql, opts) is the call without values. A non-row statement
streamed this way yields nothing; client.stream.command is then
'MUTATION' or 'DDL', client.stream.affected holds a mutation's
affected-row count and client.stream.columns is null (a row result sets
command to 'SELECT'). A CALL whose body EMITs result sets is answered
with all of them in one frame even when streamed: the stream yields the rows
query() would return (the last set), command is 'CALL', and
client.stream.resultSets holds every set, as query()'s resultSets does.
Like columns, these describe the stream started most recently. An error before any row is an ordinary statement error; an
error partway through (a node dying mid-scan, a scan budget tripping) is
thrown after the rows already yielded, which are valid.
The abandon rule
The connection is busy for the whole stream, and closing the iterator is
what frees it. break, return, a throw out of the loop body and an
explicit iterator.return() all close it. On an early close the driver reads
the rest of the result out (up to 8 MB) so the socket is left at a request
boundary; past that it drops the connection instead, and the next statement
transparently reconnects. Either way the next statement reads its own reply.
An iterator that is merely dropped half-read is closed by nobody — JavaScript has no finalizer that could do it — and that connection stays busy for good, with every queued statement behind it. Always iterate to the end or close it.
const it = client.stream('SELECT ...');
try {
const first = await it.next();
...
} finally {
await it.return(); // releases the connection
}
Other query()/batch() calls made while a stream is open queue behind
it rather than failing.
Following a stream — client.subscribe(name, { after, pollMs })
For tables with a CREATE STREAM, subscribe() yields the stream's events
as they arrive, forever:
for await (const ev of client.subscribe('big_orders', { after: lastSeenId, pollMs: 500 })) {
// ev = { id, op, k, ts, doc }
save(ev.id); // pass it back as `after` to resume exactly here
}
It polls the stream's log (_stream_<name>) with a keyset cursor, 500 events
per page; id is the position — an opaque string that sorts in log order
("00000001789857620741-0000000000-0902800000000000000100"), not a number, so
after takes a saved id string or null. For push delivery subscribe to
$stream/<db>/<name> with any MQTT client instead — the events are identical.
To stop, break out of the loop, call .return() on the iterator, or pass an
AbortSignal as signal and abort it; each ends the iteration within a tick
while it is idle between polls (an aborted signal ends it cleanly, without an
error):
const ac = new AbortController();
setTimeout(() => ac.abort(), 60_000);
for await (const ev of client.subscribe('big_orders', { signal: ac.signal })) {
handle(ev);
} // the loop exits when the signal fires
Pooling — new Pool(options)
const { Pool } = require('@skaidb/client');
const pool = new Pool({ seeds: ['h1:7000', 'h2:7000'], user, password, database: 'app', maxsize: 8 });
const res = await pool.withConnection((c) => c.query('SELECT 1'));
const conn = await pool.acquire();
try { await conn.query('...'); } finally { await pool.release(conn); }
await pool.end();
maxsize (default 10) bounds the connections kept idle, not the number
checked out: a burst opens extras and the surplus is closed on release.
acquire() validates an idle connection before handing it out and discards
one the server closed meanwhile; release() closes a broken connection
instead of pooling it. Every Client option passes through; seeds (or
host/port) name the starting members.
Balancing across members
By default (loadBalancing: 'random') each new connection dials the members
in random order and stays on the member it reached — fine for many
processes, but one process can end up with most of its connections on one
member. A policy spreads a single process's load:
const pool = new Pool({
seeds: ['db1:7000', 'db2:7000', 'db3:7000'], user, password, database: 'app',
loadBalancing: 'least_latency', // or 'round_robin'
discover: true, // learn the members from the cluster
readYourWrites: 2000, // ms; see below
idleOrder: 'fifo',
});
| Option | Default | What it does |
|---|---|---|
loadBalancing |
'random' |
'round_robin': checkouts rotate over the usable members in endpoint order. 'least_latency': of two members picked at random, the one with the lower round-trip average × (checked-out connections + 1), so near and idle members win; a member not measured in the last 2 s scores 0, so every member keeps being re-measured. |
idleOrder |
'lifo' |
'fifo' hands out the longest-idle connection first, so every idle connection keeps being used and a server-side idle timeout never finds one. |
discover |
false |
Reads the members with SHOW ENDPOINTS (server ≥ 0.327; no privilege needed) when a member is first reached and every refreshInterval (60000 ms) after — every 5 s while one is resyncing or unreachable. New members are added, members that left are dropped (seeds are kept, marked unlisted), and members reported resyncing or unreachable get no new connections until they are ready again, when they start with a fresh latency average. An older or standalone server leaves the seeds as they are and is not asked again. |
ejectErrors |
3 |
Consecutive transport failures (a failed dial, an I/O error mid-request, a capacity refusal) that eject a member. Statement errors and refused logins do not count. |
ejectLatencyFactor, ejectLatencyFloor |
3, 50 (ms) |
A member whose round-trip average is above factor × the other members' median and more than floor ms above it is ejected (after 10 samples on it and on another member). The fastest of a member's first three round trips seeds its average, so a statement that stalls in your own process — a CPU-bound start-up blocking the event loop — cannot get a healthy member ejected; the 'eject' event's reason names the average, the median and the slowest of the member's last 10 round trips. Factor 0 disables. |
ejectBackoff, ejectBackoffMax |
5000, 60000 (ms) |
How long an ejection lasts, doubling per repeat (back to the base after a healthy ejectBackoffMax). When it is up, a background probe dials the member and runs one statement; success re-admits it. The last usable member is never ejected. |
readYourWrites |
0 (off) |
Milliseconds after a write during which checkouts with the same affinity go to the member that took it. |
Read-your-writes. The member that coordinated a write reads it back at
any consistency, ONE included; another member may not have applied it yet.
With readYourWrites set, pass an affinity key — a user or session id —
when you check out:
await pool.withConnection({ affinity: userId }, (c) =>
c.query('INSERT INTO notes (id, owner, body) VALUES ($1, $2, $3)', [id, userId, body]));
// the next request for the same user, within the window, reaches the same member:
const note = await pool.withConnection({ affinity: userId }, (c) =>
c.query('SELECT body FROM notes WHERE id = $1', [id]));
const conn = await pool.acquire({ affinity: userId }); // acquire() takes it too
Checkouts without a key share one pool-wide key, so with constant writes
everything follows the latest writer — pass a key when you balance. A
statement is a write unless its first keyword is SELECT, SHOW,
EXPLAIN, DESCRIBE/DESC, VALUES, WITH or USE.
Derive the key without touching the pool: if it comes from something that itself queries the database through the same pool (a web framework's "current user" loader), a helper that checks out a connection and computes the key inside it re-enters itself. Read the raw id from the session or token instead.
Watching it. pool.members() returns one entry per member, in endpoint
order:
[{ endpoint: 'db1:7000', canonical: '10.0.0.1:7000', state: 'ready',
latencyMs: 0.8, inUse: 2, idle: 3, seed: true }, ...]
state is ready, ejected, resyncing, unreachable or unlisted. The
pool is an EventEmitter: 'eject' ({ endpoint, reason }) and
'readmit' ({ endpoint }) fire as members leave and rejoin the rotation.
The probe/discovery timer is unref()'d, so it never keeps the process
alive, and pool.end() stops it. The rules are the same in every skaidb
driver (PROTOCOL.md §6.1).
Reconnect and errors
Every error the driver raises is a SkaidbError (an Error subclass).
- Statement errors (
SELECT nope→no such column …) reject the call; the connection stays usable. - Transport errors reject the in-flight statement with
connection lost/connection closedand mark the client broken, not closed. The next statement re-dials (through the seed walk), re-authenticates, re-sendsUSE <database>and clears the prepared-statement cache. Because the failed statement may have executed, the driver never retries it for you: retry a write only if it is idempotent.isUsable()reports the state. - Protocol desync (an unexpected frame): the driver drops the connection
(
isUsable()→ false) rather than hand the next caller a misaligned reply, and the next statement reconnects. end()is terminal: a closed client never reconnects (connection is closed).
Common messages: connect failed: …, connect timeout, no reachable
endpoint in …, authentication denied: …, server signature mismatch
(mutual auth failed), cannot read tlsCa …, cannot read tlsClientCert …, server does not support
streaming: …, server does not support parameterised streaming (skaidb >=
0.330): …, statement expects N parameters, got M, no parameter for
$N, cannot bind value of type …, cannot bind NaN/Infinity, bigint …
does not fit a 64-bit integer, stream abandoned with too much data left to
drain.
Type mapping
| skaidb | JavaScript (results) | Bind (parameters) |
|---|---|---|
| Null | null |
null, undefined |
| Bool | boolean |
boolean |
| Int (i64) | number, or bigint when outside ±2^53 |
integer number, bigint |
| Float | number |
non-integer number, and -0 (NaN/Infinity refused) |
| Decimal | string (exact, e.g. '123.45') |
bind as string |
| String | string |
string |
| Bytes | Buffer |
Buffer |
| Uuid | string (canonical 8-4-4-4-12) |
bind as string |
| Timestamp | Date (millisecond precision) |
Date |
| Array | Array |
Array (nested) |
| Document | plain object, key order preserved | plain object |
An integer-valued number binds as Int and a fractional one as Float; pass a
bigint to force Int. In the client-side fallback (unpreparable statements)
a Date renders as its epoch milliseconds, a Buffer as a hex string, and
arrays/objects cannot be bound at all.
Compatibility
- Node.js ≥ 18. No build step, no native code.
- Server: any skaidb. Prepared statements need server ≥ 0.17.0 (older
servers get the client-side fallback automatically),
batch()≥ 0.87.0,stream()a server with the streaming opcode (an older one answersserver does not support streaming) and, with values, ≥ 0.330 (an older one answersserver does not support parameterised streaming), multiple result sets a server withEMIT. The Hello frame that fills the server'sdriverstable (client_namenodejs,client_version= this package's version) is ignored by older servers. - Wire protocol: https://skaidb.org/docs/PROTOCOL.html.
Conformance
test/conformance.test.js runs the shared skaidb wire-protocol conformance
suite, conformance/vectors.json (generated from the server's reference
encoders; contract in conformance/README.md), as
part of npm test:
- every value vector decodes to the documented JavaScript form (above) and, where JavaScript can bind that value, encodes to the reference bytes;
- the SCRAM vectors against the driver's own SCRAM code;
- a scripted fake server that verifies the client proof independently,
checks every request byte for byte and replays the reference responses,
for every case through the public API (
query,streamwith and without values, parameterizedquery,batch), plus the auth outcomes (ok, a bad server signature must fail, denied).
Skipped: no call method. Encoding is not checked for the values JavaScript
has no parameter form for: Decimal and Uuid (they surface as strings, which
bind as String) and integral Floats such as 0.0 (an integer-valued
number binds as Int). The vectors' ? placeholders are given in this
driver's $1, $2, … form, which it sends as ?. CI fails when the vendored
conformance/vectors.json differs from https://skaidb.org/conformance/vectors.json.
Development
npm test # node:test unit tests + conformance suite, no server needed (~3 s)
npm run typecheck # compiles test/types/consumer.ts against skaidb.d.ts
npm run pack:check # what a publish would ship
node examples/basic.js host 7000 user password # against a real node
CI runs the suite on Node 18, 20 and 22 and the TypeScript check on every
push and pull request. Tagging vX.Y.Z runs the publish workflow, which
publishes to npm when the NPM_TOKEN secret is present and otherwise exits
with a notice.